mirror of
https://github.com/zerotier/coyote.git
synced 2024-11-24 12:18:02 +00:00
Initial commit
Signed-off-by: Erik Hollensbe <git@hollensbe.org>
This commit is contained in:
commit
efd5dd9902
33 changed files with 8826 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
coyote.db
|
||||
error.log
|
||||
ca.{pem,key}
|
||||
caddy
|
2107
Cargo.lock
generated
Normal file
2107
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
51
Cargo.toml
Normal file
51
Cargo.toml
Normal file
|
@ -0,0 +1,51 @@
|
|||
[package]
|
||||
name = "coyote"
|
||||
version = "0.1.0"
|
||||
authors = ["Erik Hollensbe <git@hollensbe.org>", "Adam Ierymenko <adam.ierymenko@zerotier.com>"]
|
||||
edition = "2021"
|
||||
license = "BSD-3-Clause"
|
||||
readme = "README.md"
|
||||
description = "Embeddable ACME server with programmable challenges and storage"
|
||||
repository = "https://github.com/zerotier/coyote"
|
||||
homepage = "https://github.com/zerotier/coyote"
|
||||
keywords = ["ACME", "letsencrypt", "zerotier"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
thiserror = "^1.0"
|
||||
rand = "^0.8"
|
||||
base64 = "^0.13"
|
||||
spectral = "^0.6"
|
||||
serde_json = "^1.0"
|
||||
serde = "^1.0"
|
||||
tokio = { version = "^1.16", features = ["full"] }
|
||||
ratpack = "^0.1" # { git = "https://github.com/zerotier/ratpack", branch = "main" }
|
||||
hyper = "^0.14"
|
||||
http = "^0.2"
|
||||
url = { version = "^2.2", features = [ "serde" ] }
|
||||
deadpool-postgres = { version = "^0.10", features = ["serde"] }
|
||||
log = "^0.4"
|
||||
env_logger = "^0.9"
|
||||
trust-dns-client = "^0.20"
|
||||
tempfile = "^3.3"
|
||||
openssl = "^0.10"
|
||||
lazy_static = "^1.4"
|
||||
refinery = { version = "^0.8", features = ["tokio-postgres"] }
|
||||
tokio-postgres = { version = "^0.7", features = ["with-serde_json-1", "with-chrono-0_4"] }
|
||||
async-trait = "^0.1"
|
||||
eggshell = "^0.1" # { path = "../eggshell" }
|
||||
bollard = "^0.11"
|
||||
futures = "^0.3"
|
||||
futures-core = "^0.3"
|
||||
chrono = { version = "^0.4", features = [ "serde" ] }
|
||||
x509-parser = { version = "^0.12", features = [ "ring", "verify", "validate" ] }
|
||||
rustls = "^0.20"
|
||||
rustls-pemfile = "^0.3"
|
||||
webpki-roots = "^0.22"
|
||||
|
||||
[lib]
|
||||
|
||||
[[bin]]
|
||||
name = "acmed"
|
||||
path = "src/acmed.rs"
|
26
LICENSE.txt
Normal file
26
LICENSE.txt
Normal file
|
@ -0,0 +1,26 @@
|
|||
Copyright 2022 ZeroTier, Inc.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
10
Makefile
Normal file
10
Makefile
Normal file
|
@ -0,0 +1,10 @@
|
|||
postgres: stop-postgres
|
||||
docker run -e POSTGRES_DB=coyote -e POSTGRES_HOST_AUTH_METHOD=trust -itd -p 127.0.0.1:5432:5432 --name acmed-postgres postgres:latest
|
||||
|
||||
stop-postgres:
|
||||
docker rm -f acmed-postgres || :
|
||||
|
||||
run-with-backtrace:
|
||||
RUST_BACKTRACE=1 rustup run nightly cargo run
|
||||
|
||||
.PHONY: postgres stop-postgres
|
113
README.md
Normal file
113
README.md
Normal file
|
@ -0,0 +1,113 @@
|
|||
## Coyote: an ACME toolkit
|
||||
|
||||
Coyote lets you make ACME servers, which are not guaranteed to not explode in
|
||||
your face. You have to code that out yourself.
|
||||
|
||||
coyote aims to solve a few problems (not all of these are solved yet; see "Task List" below):
|
||||
|
||||
- Provide an alternative to boulder and pebble
|
||||
- Provide ACME with backing storage you prefer to use, by way of Rust's traits for storage implementation.
|
||||
- Provide ACME in non-conforming scenarios (e.g., behind corporate firewalls)
|
||||
- Provide ACME services with hooks into the validation system, so you can implement validations however you feel like.
|
||||
- It's a library; make it as big or as small as you like. No need for multiple implementations.
|
||||
- A FOSS alternative to the letsencrypt canonical implementation that is _also_ tested against LE's test suite.
|
||||
|
||||
`acmed` comes with coyote; it is a complete example canonical implementation against PostgreSQL for backing storage and OpenSSL for crypto. It (deliberately) allows all challenges through and is not meant for production usage.
|
||||
|
||||
coyote is intended to let you build an `acmed` without using `acmed` itself, but that will not come until the project is complete. Until then it is a single implementation with enough traits / generic parameters to make alternatives possible later. For example, work to implement a redis-based nonce validation system would just be a trait implementation at the time of this writing, even though it is not available today.
|
||||
|
||||
## Running `acmed`
|
||||
|
||||
[acmed](src/acmed.rs) is a very small, example implementation of coyote, intended to demonstrate usage of it. It is not meant or designed to be used in a production environment. It does not perform challenges properly, allowing all of them that come in.
|
||||
|
||||
You'll need `docker` to launch the postgres instance. Provide `HOSTNAME` to set a host name for TLS service; otherwise `localhost` is assumed. A CA at `ca.pem` and `ca.key` will be generated at the directory you run the `cargo` commands from, which you will need to pass to clients to your certificates. Also, a TLS in-memory cert will be generated to serve the `acmed` instance.
|
||||
|
||||
To launch:
|
||||
|
||||
```
|
||||
$ make postgres
|
||||
$ HOSTNAME=tls-hostname.local cargo run --bin acmed
|
||||
```
|
||||
|
||||
It will start a service on `https://${HOSTNAME}:8000` which you can then pass as
|
||||
the `--server` flag to `certbot`, e.g.:
|
||||
|
||||
```
|
||||
certbot --server 'https://${HOSTNAME}:8000' certonly --standalone -d 'foo.com' -m 'erik+github@hollensbe.org' --agree-tos
|
||||
```
|
||||
|
||||
Otherwise, the use is the same.
|
||||
|
||||
To access the postgres instance that `acmed` is running against (provided by `make postgres`):
|
||||
|
||||
```
|
||||
psql -U postgres -h localhost coyote
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
`docker` is required to run the tests. The tests take around 70 seconds to run on a 5900X and use all 24 threads most of the test runtime. Be mindful of the time they take, especially when running them on a slower system.
|
||||
|
||||
```
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Task List
|
||||
|
||||
### JOSE/ACME Protocols:
|
||||
|
||||
- [x] JWS decoding; serde codec (handled in middleware)
|
||||
- [x] JWK conversion to openssl types; signing and validation
|
||||
- [x] Full validation and production of nonce
|
||||
- [x] Full validation of ACME protected header (in middleware)
|
||||
- [x] RFC7807 "problem details" HTTP error return values
|
||||
- [x] Various validating codecs for ACME structs
|
||||
- [ ] MAYBE: rate limiting (see 6.6 of RFC8555), but probably later
|
||||
- [ ] Integration of well-used third party ACME client in testing
|
||||
|
||||
### Handlers:
|
||||
|
||||
- [x] Nonce Handlers
|
||||
- [x] Nonce Middleware
|
||||
- [x] Accounts (RFC8555 7.3)
|
||||
- [x] Handlers:
|
||||
- [x] New Account
|
||||
- [x] Lookup Account
|
||||
- [x] De-registration
|
||||
- [x] Orders (RFC8555 7.4)
|
||||
- [x] Challenge Traits
|
||||
- [ ] HTTP basic impl: needed for certbot tests
|
||||
- [ ] MAYBE: DNS basic impl; see "Other concerns" below
|
||||
- [ ] Handlers:
|
||||
- [x] Authorization Request
|
||||
- [x] Fetch challenges
|
||||
- [x] Initiate Challenge
|
||||
- [x] Deactivate Challenge
|
||||
- [x] Challenge status
|
||||
- [x] Finalization
|
||||
- [x] Fetch Certificate
|
||||
- [ ] Revocation of Certificate
|
||||
- Other concerns:
|
||||
- [ ] Key Changes (`/key-change` endpoint, see RFC8555 7.3.5)
|
||||
|
||||
### Storage:
|
||||
|
||||
- DB Layer
|
||||
- [x] PostgreSQL implementation
|
||||
- [x] Nonce storage
|
||||
- [x] Account storage
|
||||
- [x] Order information / state machine storage
|
||||
- [x] Cert storage
|
||||
- [ ] Encrypted at rest
|
||||
|
||||
## Things coyote doesn't currently handle
|
||||
|
||||
These are things that are not covered by our initial goals, and we do not feel they are higher priority items. We will happily accept pull requests for this functionality.
|
||||
|
||||
- Accounts:
|
||||
- Terms of Service changes
|
||||
- External Account Bindings
|
||||
|
||||
### LICENSE
|
||||
|
||||
This software is covered by the BSD-3-Clause License. See [LICENSE.txt](LICENSE.txt) for more details.
|
12
dockerfiles/Dockerfile.zlint
Normal file
12
dockerfiles/Dockerfile.zlint
Normal file
|
@ -0,0 +1,12 @@
|
|||
# vim: ft=dockerfile
|
||||
|
||||
FROM alpine:latest as builder
|
||||
|
||||
ARG ZLINT
|
||||
|
||||
RUN apk add curl
|
||||
RUN curl -sSL "https://github.com/zmap/zlint/releases/download/v${ZLINT}/zlint_${ZLINT}_Linux_x86_64.tar.gz" | tar -vxz -C /tmp
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
COPY --from=builder /tmp/zlint*/zlint /usr/bin
|
6
dockerfiles/Makefile
Normal file
6
dockerfiles/Makefile
Normal file
|
@ -0,0 +1,6 @@
|
|||
all: zlint
|
||||
|
||||
ZLINT=3.3.0
|
||||
|
||||
zlint:
|
||||
docker build --build-arg ZLINT=${ZLINT} -t zerotier/zlint:latest -f Dockerfile.zlint .
|
3
hack/pg_hba.conf
Normal file
3
hack/pg_hba.conf
Normal file
|
@ -0,0 +1,3 @@
|
|||
host all all all trust
|
||||
local all all ident map=postgres
|
||||
local all all trust
|
0
migrations/.gitkeep
Normal file
0
migrations/.gitkeep
Normal file
86
migrations/V1__init.sql
Normal file
86
migrations/V1__init.sql
Normal file
|
@ -0,0 +1,86 @@
|
|||
create table nonces (
|
||||
nonce varchar primary key
|
||||
);
|
||||
--
|
||||
create table jwks (
|
||||
id serial primary key,
|
||||
nonce_key varchar not null unique,
|
||||
alg varchar not null,
|
||||
-- base64'd public key components
|
||||
n varchar,
|
||||
e varchar,
|
||||
x varchar,
|
||||
y varchar,
|
||||
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz,
|
||||
|
||||
CHECK((n is not null and e is not null) or (x is not null and y is not null))
|
||||
);
|
||||
--
|
||||
create table accounts (
|
||||
id serial primary key,
|
||||
jwk_id integer not null,
|
||||
orders_nonce varchar not null unique,
|
||||
-- TODO: external accounts
|
||||
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
--
|
||||
create table contacts (
|
||||
id serial primary key,
|
||||
account_id integer not null,
|
||||
contact varchar not null,
|
||||
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz,
|
||||
|
||||
UNIQUE (account_id, contact)
|
||||
);
|
||||
--
|
||||
create table orders_challenges (
|
||||
id serial primary key,
|
||||
order_id varchar not null,
|
||||
authorization_id varchar not null,
|
||||
challenge_type varchar not null,
|
||||
reference varchar not null unique,
|
||||
identifier varchar not null,
|
||||
token varchar not null,
|
||||
status varchar not null,
|
||||
validated timestamptz,
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
--
|
||||
create table orders_authorizations (
|
||||
id serial primary key,
|
||||
order_id varchar not null,
|
||||
identifier varchar not null,
|
||||
reference varchar not null unique,
|
||||
expires timestamptz not null,
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
--
|
||||
create table orders_certificate (
|
||||
id serial primary key,
|
||||
order_id varchar not null unique,
|
||||
reference varchar not null unique,
|
||||
certificate bytea not null,
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
--
|
||||
create table orders (
|
||||
id serial primary key,
|
||||
order_id varchar not null unique, -- this is the *public* order id. it is not the primary key used in the db.
|
||||
expires timestamptz,
|
||||
not_before timestamptz default CURRENT_TIMESTAMP,
|
||||
not_after timestamptz,
|
||||
error text,
|
||||
finalized bool not null,
|
||||
|
||||
created_at timestamptz default CURRENT_TIMESTAMP not null,
|
||||
deleted_at timestamptz
|
||||
);
|
333
src/acme/ca.rs
Normal file
333
src/acme/ca.rs
Normal file
|
@ -0,0 +1,333 @@
|
|||
use std::{
|
||||
convert::TryInto,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use log::warn;
|
||||
use openssl::{
|
||||
asn1::Asn1Time,
|
||||
bn::BigNum,
|
||||
error::ErrorStack,
|
||||
hash::MessageDigest,
|
||||
pkey::{PKey, Private},
|
||||
rsa::Rsa,
|
||||
x509::{X509Extension, X509Name, X509Req, X509},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub(crate) fn st_to_asn1(time: SystemTime) -> Result<Asn1Time, ErrorStack> {
|
||||
Asn1Time::from_unix(
|
||||
time.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.try_into()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CA {
|
||||
certificate: X509,
|
||||
private_key: PKey<Private>,
|
||||
}
|
||||
|
||||
impl CA {
|
||||
pub fn new(certificate: X509, private_key: PKey<Private>) -> Self {
|
||||
Self {
|
||||
certificate,
|
||||
private_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn certificate(self) -> X509 {
|
||||
self.certificate
|
||||
}
|
||||
|
||||
pub fn private_key(self) -> PKey<Private> {
|
||||
self.private_key
|
||||
}
|
||||
|
||||
pub fn generate_and_sign_cert(
|
||||
&self,
|
||||
req: X509Req,
|
||||
not_before: SystemTime,
|
||||
not_after: SystemTime,
|
||||
) -> Result<X509, ErrorStack> {
|
||||
let mut builder = X509::builder()?;
|
||||
builder.set_pubkey(req.public_key()?.as_ref())?;
|
||||
builder.set_issuer_name(self.certificate.issuer_name())?;
|
||||
builder.set_serial_number(
|
||||
BigNum::from_u32(rand::random::<u32>())?
|
||||
.as_ref()
|
||||
.to_asn1_integer()?
|
||||
.as_ref(),
|
||||
)?;
|
||||
|
||||
let exts = req.extensions();
|
||||
if let Ok(exts) = exts {
|
||||
for ext in exts {
|
||||
builder.append_extension(ext)?;
|
||||
}
|
||||
}
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"keyUsage",
|
||||
"critical,keyEncipherment,digitalSignature",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"extendedKeyUsage",
|
||||
"critical,serverAuth",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"authorityKeyIdentifier",
|
||||
"issuer",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"subjectKeyIdentifier",
|
||||
"hash",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"issuerAltName",
|
||||
"issuer:copy",
|
||||
)?)?;
|
||||
|
||||
builder.set_subject_name(req.subject_name())?;
|
||||
builder.set_version(2)?;
|
||||
builder.set_not_before(st_to_asn1(not_before)?.as_ref())?;
|
||||
builder.set_not_after(st_to_asn1(not_after)?.as_ref())?;
|
||||
|
||||
builder.sign(&self.private_key, MessageDigest::sha512())?;
|
||||
Ok(builder.build())
|
||||
}
|
||||
|
||||
pub fn new_test_ca() -> Result<Self, ErrorStack> {
|
||||
let mut builder = X509::builder()?;
|
||||
|
||||
let mut namebuilder = X509Name::builder()?;
|
||||
namebuilder.append_entry_by_text("C", "US")?;
|
||||
namebuilder.append_entry_by_text("O", "ZeroTier")?;
|
||||
namebuilder.append_entry_by_text("CN", "CA Signing Certificate")?;
|
||||
namebuilder.append_entry_by_text("ST", "California")?;
|
||||
namebuilder.append_entry_by_text("L", "Irvine")?;
|
||||
namebuilder.append_entry_by_text("OU", "A Test Suite")?;
|
||||
builder.set_subject_name(&namebuilder.build())?;
|
||||
|
||||
let mut namebuilder = X509Name::builder()?;
|
||||
namebuilder.append_entry_by_text("C", "US")?;
|
||||
namebuilder.append_entry_by_text("O", "ZeroTier")?;
|
||||
namebuilder.append_entry_by_text("CN", "CA Signing Certificate")?;
|
||||
namebuilder.append_entry_by_text("ST", "California")?;
|
||||
namebuilder.append_entry_by_text("L", "Irvine")?;
|
||||
namebuilder.append_entry_by_text("OU", "A Test Suite")?;
|
||||
builder.set_issuer_name(&namebuilder.build())?;
|
||||
|
||||
builder.set_serial_number(
|
||||
BigNum::from_u32(rand::random::<u32>())?
|
||||
.as_ref()
|
||||
.to_asn1_integer()?
|
||||
.as_ref(),
|
||||
)?;
|
||||
|
||||
let key = Rsa::generate(4096)?;
|
||||
// FIXME there has to be a much better way of doing this!
|
||||
let pubkey = PKey::public_key_from_pem(&key.public_key_to_pem().unwrap()).unwrap();
|
||||
|
||||
builder.set_pubkey(&pubkey)?;
|
||||
builder.set_version(2)?;
|
||||
builder.set_not_before(Asn1Time::days_from_now(0)?.as_ref())?;
|
||||
builder.set_not_after(Asn1Time::days_from_now(365)?.as_ref())?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"basicConstraints",
|
||||
"critical,CA:true,pathlen:0",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"keyUsage",
|
||||
"critical,keyCertSign",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"subjectKeyIdentifier",
|
||||
"hash",
|
||||
)?)?;
|
||||
|
||||
builder.append_extension(X509Extension::new(
|
||||
None,
|
||||
Some(&builder.x509v3_context(None, None)),
|
||||
"issuerAltName",
|
||||
"issuer:copy",
|
||||
)?)?;
|
||||
|
||||
let privkey = PKey::from_rsa(key)?;
|
||||
builder.sign(privkey.as_ref(), MessageDigest::sha512())?;
|
||||
Ok(Self::new(builder.build(), privkey))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CACollector {
|
||||
poll_interval: Duration,
|
||||
ca: SharedCA,
|
||||
}
|
||||
|
||||
type SharedCA = Arc<RwLock<Option<CA>>>;
|
||||
|
||||
impl CACollector {
|
||||
pub fn new(poll_interval: Duration) -> Self {
|
||||
Self {
|
||||
poll_interval,
|
||||
ca: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ca(self) -> SharedCA {
|
||||
self.ca.clone()
|
||||
}
|
||||
|
||||
pub async fn spawn_collector<F>(&mut self, f: F)
|
||||
where
|
||||
F: Fn() -> Result<CA, ErrorStack>,
|
||||
{
|
||||
loop {
|
||||
let res = f();
|
||||
|
||||
match res {
|
||||
Ok(ca) => { self.ca.write().await.replace(ca); },
|
||||
Err(e) => warn!("Failed to retrieve CA, signing will will continue to use the old CA, if any. Error: {}", e.to_string())
|
||||
}
|
||||
tokio::time::sleep(self.poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign(
|
||||
self,
|
||||
req: X509Req,
|
||||
not_before: SystemTime,
|
||||
not_after: SystemTime,
|
||||
) -> Result<X509, ErrorStack> {
|
||||
Ok(self
|
||||
.ca()
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.unwrap()
|
||||
.generate_and_sign_cert(req, not_before, not_after)?)
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
use openssl::{error::ErrorStack, x509::X509Req};
|
||||
|
||||
fn generate_csr() -> Result<X509Req, ErrorStack> {
|
||||
use openssl::{pkey::PKey, rsa::Rsa, x509::X509Name};
|
||||
|
||||
let mut namebuilder = X509Name::builder().unwrap();
|
||||
namebuilder
|
||||
.append_entry_by_text("CN", "example.org")
|
||||
.unwrap();
|
||||
let mut req = X509Req::builder().unwrap();
|
||||
req.set_subject_name(&namebuilder.build()).unwrap();
|
||||
|
||||
let key = Rsa::generate(4096).unwrap();
|
||||
// FIXME there has to be a much better way of doing this!
|
||||
let pubkey = PKey::public_key_from_pem(&key.public_key_to_pem().unwrap()).unwrap();
|
||||
|
||||
req.set_pubkey(&pubkey).unwrap();
|
||||
Ok(req.build())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_ca_sign() {
|
||||
use spectral::prelude::*;
|
||||
|
||||
use super::{st_to_asn1, CA};
|
||||
use openssl::{pkey::PKey, rsa::Rsa};
|
||||
use std::time::SystemTime;
|
||||
|
||||
let now = SystemTime::now();
|
||||
|
||||
let ca = CA::new_test_ca().unwrap();
|
||||
let signed = ca
|
||||
.generate_and_sign_cert(generate_csr().unwrap(), SystemTime::UNIX_EPOCH, now)
|
||||
.unwrap();
|
||||
|
||||
let result = signed.verify(&ca.private_key());
|
||||
assert_that!(result).is_ok();
|
||||
assert_that!(result.unwrap()).is_true();
|
||||
|
||||
let badkey = Rsa::generate(4096).unwrap();
|
||||
let result = signed.verify(PKey::from_rsa(badkey).unwrap().as_ref());
|
||||
assert_that!(result).is_ok();
|
||||
assert_that!(result.unwrap()).is_false();
|
||||
|
||||
assert_that!(signed.not_before())
|
||||
.is_equal_to(&*st_to_asn1(SystemTime::UNIX_EPOCH).unwrap());
|
||||
assert_that!(signed.not_after()).is_equal_to(&*st_to_asn1(now).unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_ca_collector() {
|
||||
use super::{st_to_asn1, CACollector, CA};
|
||||
use openssl::{pkey::PKey, rsa::Rsa};
|
||||
use spectral::prelude::*;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
|
||||
let collector = CACollector::new(Duration::new(0, 500));
|
||||
|
||||
let mut inner = collector.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
// we only want one of these, instead of polling for new ones, in this test.
|
||||
let ca = CA::new_test_ca().unwrap();
|
||||
inner
|
||||
.spawn_collector(|| -> Result<CA, ErrorStack> { Ok(ca.clone()) })
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::new(1, 0)).await;
|
||||
|
||||
let now = SystemTime::now();
|
||||
let signed = collector
|
||||
.clone()
|
||||
.sign(generate_csr().unwrap(), SystemTime::UNIX_EPOCH, now)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = signed.verify(&collector.ca().read().await.clone().unwrap().private_key());
|
||||
assert_that!(result).is_ok();
|
||||
assert_that!(result.unwrap()).is_true();
|
||||
|
||||
let badkey = Rsa::generate(4096).unwrap();
|
||||
let result = signed.verify(PKey::from_rsa(badkey).unwrap().as_ref());
|
||||
assert_that!(result).is_ok();
|
||||
assert_that!(result.unwrap()).is_false();
|
||||
|
||||
assert_that!(signed.not_before())
|
||||
.is_equal_to(&*st_to_asn1(SystemTime::UNIX_EPOCH).unwrap());
|
||||
assert_that!(signed.not_after()).is_equal_to(&*st_to_asn1(now).unwrap());
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
}
|
324
src/acme/challenge.rs
Normal file
324
src/acme/challenge.rs
Normal file
|
@ -0,0 +1,324 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, convert::TryFrom, ops::Add, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
errors::db::{LoadError, SaveError},
|
||||
models::{order::Challenge, Postgres},
|
||||
};
|
||||
|
||||
use super::handlers::order::OrderStatus;
|
||||
|
||||
// most of this is RFC8555 section 8
|
||||
// read RFC8555 7.1.6 on state transitions between different parts of the challenge
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(into = "String")]
|
||||
pub enum ChallengeType {
|
||||
DNS01, // dns-01 challenge type
|
||||
HTTP01, // http-01 challenge type
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ChallengeType {
|
||||
type Error = LoadError;
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
"dns-01" => Ok(ChallengeType::DNS01),
|
||||
"http-01" => Ok(ChallengeType::HTTP01),
|
||||
_ => Err(LoadError::InvalidEnum),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<String> for ChallengeType {
|
||||
fn into(self) -> String {
|
||||
match self {
|
||||
ChallengeType::DNS01 => "dns-01",
|
||||
ChallengeType::HTTP01 => "http-01",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChallengeType {
|
||||
pub(crate) fn to_string(self) -> String {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Challenger {
|
||||
list: Arc<Mutex<HashMap<String, Challenge>>>,
|
||||
expiration: Option<chrono::Duration>,
|
||||
}
|
||||
|
||||
impl Challenger {
|
||||
pub fn new(expiration: Option<chrono::Duration>) -> Self {
|
||||
Self {
|
||||
list: Arc::new(Mutex::new(HashMap::new())),
|
||||
expiration,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn schedule(&self, c: Challenge) {
|
||||
self.list.lock().await.insert(c.reference.clone(), c);
|
||||
}
|
||||
|
||||
pub async fn tick<T>(&self, ticker: T)
|
||||
where
|
||||
T: Fn(Challenge) -> Option<()>,
|
||||
{
|
||||
let mut lock = self.list.lock().await;
|
||||
let mut ch = HashMap::new();
|
||||
let mut sv = Vec::new();
|
||||
let mut iv = Vec::new();
|
||||
|
||||
for (s, c) in lock.iter_mut() {
|
||||
match c.status {
|
||||
OrderStatus::Processing => {
|
||||
ch.insert(s.clone(), c.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
drop(lock);
|
||||
|
||||
let expires = self.expiration.is_some();
|
||||
let now = chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now());
|
||||
|
||||
for (s, c) in ch {
|
||||
if expires && c.created_at.add(self.expiration.unwrap()) < now {
|
||||
iv.push(s.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
match ticker(c.clone()) {
|
||||
Some(_) => {
|
||||
sv.push(s.clone());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lock = self.list.lock().await;
|
||||
|
||||
for s in sv {
|
||||
match lock.get_mut(&s) {
|
||||
Some(i) => i.status = OrderStatus::Valid,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
for s in iv {
|
||||
match lock.get_mut(&s) {
|
||||
Some(i) => i.status = OrderStatus::Invalid,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reconcile(&self, db: Postgres) -> Result<(), SaveError> {
|
||||
let mut lock = self.list.lock().await;
|
||||
let mut db_lock = db.client().await?;
|
||||
let tx = db_lock.transaction().await?;
|
||||
let mut sv = Vec::new();
|
||||
|
||||
// FIXME needs to manage challenge statuses, or that needs to move up a level
|
||||
for (s, c) in lock.iter_mut() {
|
||||
match c.status {
|
||||
OrderStatus::Pending | OrderStatus::Processing => {}
|
||||
_ => {
|
||||
let mut c: crate::models::order::Challenge = c.clone().into();
|
||||
c.persist_status(&tx).await?;
|
||||
sv.push(s.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for s in sv {
|
||||
lock.remove(&s);
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_challenge_scheduler_basic_with_expiration() {
|
||||
use super::{ChallengeType, Challenger};
|
||||
use crate::acme::handlers::order::OrderStatus;
|
||||
use crate::models::order::{Authorization, Challenge, Order};
|
||||
use crate::models::Record;
|
||||
use crate::test::PGTest;
|
||||
use crate::util::make_nonce;
|
||||
use spectral::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
let pg = PGTest::new("test_challenge_scheduler_basic_with_expiration")
|
||||
.await
|
||||
.unwrap();
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(1)));
|
||||
|
||||
let mut order = Order::default();
|
||||
order.create(pg.db()).await.unwrap();
|
||||
|
||||
let mut authz = Authorization::default();
|
||||
authz.order_id = order.order_id.clone();
|
||||
authz.identifier = Some("example.com".to_string());
|
||||
authz.create(pg.db().clone()).await.unwrap();
|
||||
|
||||
// FIXME some of this shit needs to be in default()
|
||||
let mut challenge = Challenge {
|
||||
id: None,
|
||||
order_id: order.order_id.clone(),
|
||||
authorization_id: authz.reference.clone(),
|
||||
identifier: "example.com".to_string(),
|
||||
challenge_type: ChallengeType::DNS01,
|
||||
reference: make_nonce(None),
|
||||
token: make_nonce(None),
|
||||
status: OrderStatus::Processing,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now()),
|
||||
deleted_at: None,
|
||||
validated: None,
|
||||
};
|
||||
|
||||
challenge.create(pg.db()).await.unwrap();
|
||||
|
||||
c.schedule(challenge.clone()).await;
|
||||
c.tick(|_c| Some(())).await;
|
||||
c.reconcile(pg.db()).await.unwrap();
|
||||
|
||||
let challenges = order
|
||||
.challenges(&pg.db().client().await.unwrap().transaction().await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_that!(challenges.len()).is_equal_to(1);
|
||||
assert_that!(challenges[0].id).is_equal_to(challenge.id);
|
||||
assert_that!(challenges[0].status).is_equal_to(OrderStatus::Valid);
|
||||
assert_that!(challenges[0].validated).is_some();
|
||||
|
||||
let mut challenge = Challenge {
|
||||
id: None,
|
||||
order_id: order.order_id.clone(),
|
||||
authorization_id: authz.reference.clone(),
|
||||
identifier: "example.com".to_string(),
|
||||
challenge_type: ChallengeType::DNS01,
|
||||
reference: make_nonce(None),
|
||||
token: make_nonce(None),
|
||||
status: OrderStatus::Processing,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now()),
|
||||
deleted_at: None,
|
||||
validated: None,
|
||||
};
|
||||
|
||||
challenge.create(pg.db()).await.unwrap();
|
||||
|
||||
// wait for the challenge to expire
|
||||
tokio::time::sleep(Duration::new(2, 0)).await;
|
||||
|
||||
c.schedule(challenge.clone()).await;
|
||||
c.tick(|_c| None).await;
|
||||
c.reconcile(pg.db()).await.unwrap();
|
||||
|
||||
let challenges = order
|
||||
.challenges(&pg.db().client().await.unwrap().transaction().await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_that!(challenges.len()).is_equal_to(2);
|
||||
assert_that!(challenges[1].id).is_equal_to(challenge.id);
|
||||
assert_that!(challenges[1].status).is_equal_to(OrderStatus::Invalid);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_challenge_scheduler_async() {
|
||||
use super::{ChallengeType, Challenger};
|
||||
use crate::acme::handlers::order::OrderStatus;
|
||||
use crate::models::order::{Authorization, Challenge, Order};
|
||||
use crate::models::Record;
|
||||
use crate::test::PGTest;
|
||||
use crate::util::make_nonce;
|
||||
use spectral::prelude::*;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let pg = PGTest::new("test_challenge_scheduler_async").await.unwrap();
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(1)));
|
||||
let db = pg.db();
|
||||
|
||||
let (s, mut r) = mpsc::unbounded_channel();
|
||||
let mut handles = Vec::new();
|
||||
|
||||
let c2 = c.clone();
|
||||
let db2 = db.clone();
|
||||
let supervisor = tokio::spawn(async move {
|
||||
loop {
|
||||
c2.tick(|_c| Some(())).await;
|
||||
c2.reconcile(db2.clone()).await.unwrap();
|
||||
tokio::time::sleep(Duration::new(1, 0)).await;
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..10 {
|
||||
let c = c.clone();
|
||||
let mut order = Order::default();
|
||||
order.create(db.clone()).await.unwrap();
|
||||
let mut authz = Authorization::default();
|
||||
authz.identifier = Some("example.com".to_string());
|
||||
authz.order_id = order.order_id.clone();
|
||||
authz.create(db.clone()).await.unwrap();
|
||||
let s = s.clone();
|
||||
let db2 = db.clone();
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
for _ in 0..100 {
|
||||
let mut challenge = Challenge {
|
||||
id: None,
|
||||
order_id: order.order_id.clone(),
|
||||
authorization_id: authz.reference.clone(),
|
||||
identifier: "example.com".to_string(),
|
||||
token: make_nonce(None),
|
||||
reference: make_nonce(None),
|
||||
challenge_type: ChallengeType::DNS01,
|
||||
status: OrderStatus::Pending,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(
|
||||
std::time::SystemTime::now(),
|
||||
),
|
||||
deleted_at: None,
|
||||
validated: None,
|
||||
};
|
||||
|
||||
challenge.create(db2.clone()).await.unwrap();
|
||||
c.schedule(challenge.clone()).await;
|
||||
s.send((order.clone(), challenge.id.unwrap())).unwrap();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
drop(s);
|
||||
tokio::time::sleep(Duration::new(2, 0)).await; // give the supervisor an opp to wake up
|
||||
|
||||
loop {
|
||||
if let Some((order, challenge_id)) = r.recv().await {
|
||||
let mut lockeddb = db.clone().client().await.unwrap();
|
||||
let tx = lockeddb.transaction().await.unwrap();
|
||||
|
||||
let ch = order.challenges(&tx).await.unwrap();
|
||||
assert_that!(ch
|
||||
.iter()
|
||||
.find(|x| x.id.is_some() && x.id.unwrap() == challenge_id))
|
||||
.is_some();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
supervisor.abort();
|
||||
}
|
||||
}
|
73
src/acme/dns.rs
Normal file
73
src/acme/dns.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
|
||||
use std::str::FromStr;
|
||||
use trust_dns_client::rr::Name;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DNSName(pub(crate) Name);
|
||||
|
||||
impl DNSName {
|
||||
pub(crate) fn from_str(name: &str) -> Result<Self, trust_dns_client::error::ParseError> {
|
||||
Ok(Self(Name::from_str(&name)?))
|
||||
}
|
||||
|
||||
pub(crate) fn to_string(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DNSNameVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DNSNameVisitor {
|
||||
type Value = DNSName;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("A DNS Name")
|
||||
}
|
||||
|
||||
fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
match Self::Value::from_str(v) {
|
||||
Ok(name) => Ok(name),
|
||||
Err(e) => Err(serde::de::Error::custom(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DNSName {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.0.to_string().trim_end_matches("."))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DNSName {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(deserializer.deserialize_string(DNSNameVisitor)?)
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_dns_serde() {
|
||||
use super::DNSName;
|
||||
use crate::acme::ACMEIdentifier;
|
||||
use spectral::prelude::*;
|
||||
|
||||
let json =
|
||||
serde_json::to_string(&ACMEIdentifier::DNS(DNSName::from_str("foo.com").unwrap()));
|
||||
assert_that!(json).is_ok();
|
||||
let json = json.unwrap();
|
||||
|
||||
let id = serde_json::from_str::<ACMEIdentifier>(&json);
|
||||
assert_that!(id).is_ok();
|
||||
let id = id.unwrap();
|
||||
assert_that!(id.to_string()).is_equal_to("foo.com".to_string());
|
||||
}
|
||||
}
|
316
src/acme/handlers/account.rs
Normal file
316
src/acme/handlers/account.rs
Normal file
|
@ -0,0 +1,316 @@
|
|||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
use ratpack::prelude::*;
|
||||
|
||||
use super::{uri_to_url, HandlerState, ServiceState};
|
||||
use crate::{
|
||||
errors::{acme::JWSError, ACMEValidationError},
|
||||
models::{
|
||||
account::{new_accounts, JWK},
|
||||
Record,
|
||||
},
|
||||
};
|
||||
|
||||
/// RFC8555 7.1.2
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Account {
|
||||
status: AccountStatus,
|
||||
contact: Option<Vec<AccountUrl>>,
|
||||
terms_of_service_agreed: Option<bool>,
|
||||
external_account_binding: Option<ExternalBinding>,
|
||||
orders: Option<Url>,
|
||||
}
|
||||
|
||||
impl Default for Account {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: AccountStatus::Revoked,
|
||||
contact: None,
|
||||
terms_of_service_agreed: None,
|
||||
external_account_binding: None,
|
||||
orders: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AccountStatus {
|
||||
Valid,
|
||||
Deactivated,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AccountUrl(Url);
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum AccountUrlError {
|
||||
#[error("invalid url scheme for account")]
|
||||
InvalidScheme,
|
||||
#[error("unknown error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for AccountUrl {
|
||||
type Error = AccountUrlError;
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match Url::parse(s) {
|
||||
Ok(url) => url.try_into(),
|
||||
Err(e) => Err(AccountUrlError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Url> for AccountUrl {
|
||||
type Error = AccountUrlError;
|
||||
|
||||
fn try_from(url: Url) -> Result<Self, Self::Error> {
|
||||
// RFC8555 7.3
|
||||
if url.scheme() != "mailto" {
|
||||
return Err(AccountUrlError::InvalidScheme);
|
||||
}
|
||||
|
||||
Ok(Self(url))
|
||||
}
|
||||
}
|
||||
|
||||
impl Into<String> for AccountUrl {
|
||||
fn into(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalBinding {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NewAccount {
|
||||
pub contact: Option<Vec<AccountUrl>>,
|
||||
pub terms_of_service_agreed: Option<bool>,
|
||||
pub only_return_existing: Option<bool>,
|
||||
pub external_account_binding: Option<ExternalBinding>,
|
||||
}
|
||||
|
||||
impl NewAccount {
|
||||
pub fn contacts(&self) -> Option<Vec<AccountUrl>> {
|
||||
self.contact.clone()
|
||||
}
|
||||
|
||||
pub fn is_deleted(&self) -> bool {
|
||||
self.contact.is_none() || self.contact.as_ref().unwrap().is_empty()
|
||||
}
|
||||
|
||||
pub fn to_account(&self) -> Account {
|
||||
Account {
|
||||
status: AccountStatus::Valid,
|
||||
contact: self.contact.clone(),
|
||||
terms_of_service_agreed: self.terms_of_service_agreed,
|
||||
external_account_binding: None,
|
||||
orders: None, // FIXME needs to be populated with a slug for user orders
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NewAccount {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contact: None,
|
||||
terms_of_service_agreed: None,
|
||||
only_return_existing: None,
|
||||
external_account_binding: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn new_account(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(mut jws) => {
|
||||
let newacct = jws.clone().payload::<NewAccount>()?;
|
||||
let uri = req.uri().clone();
|
||||
let url = uri_to_url(appstate.clone().baseurl, uri).await?;
|
||||
|
||||
let protected = jws.protected()?;
|
||||
|
||||
if protected.kid().is_some() && newacct.only_return_existing.unwrap_or_default() {
|
||||
let rec =
|
||||
match JWK::find_by_kid(protected.kid().unwrap(), appstate.db.clone()).await {
|
||||
Ok(rec) => rec,
|
||||
Err(_) => return Err(ACMEValidationError::AccountDoesNotExist.to_status()),
|
||||
};
|
||||
|
||||
let resp = state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
"Location",
|
||||
url.clone()
|
||||
.join(&format!("./account/{}", &rec.clone().nonce_key()))?
|
||||
.to_string(),
|
||||
)
|
||||
.body(Body::from(serde_json::to_string(&rec)?))
|
||||
.unwrap();
|
||||
return Ok((req, Some(resp), state));
|
||||
} else {
|
||||
let mut jwk = jws.into_db_jwk()?;
|
||||
|
||||
jwk.create(appstate.db.clone()).await?;
|
||||
|
||||
let mut acct = new_accounts(newacct.clone(), jwk.clone(), appstate.db.clone())?;
|
||||
acct.create(appstate.db.clone()).await?;
|
||||
|
||||
let resp = state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::CREATED)
|
||||
.header(
|
||||
"Location",
|
||||
url.join(&format!("./account/{}", &jwk.nonce_key()))?
|
||||
.to_string(),
|
||||
)
|
||||
.body(Body::from(serde_json::to_string(&newacct.to_account())?))
|
||||
.unwrap();
|
||||
return Ok((req, Some(resp), state));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(ratpack::Error::StatusCode(
|
||||
StatusCode::NOT_FOUND,
|
||||
String::default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn post_account(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
// FIXME this still needs code to update contact lists; see 7.3.2.
|
||||
match state.clone().jws {
|
||||
Some(mut jws) => {
|
||||
let acct: Account = jws.payload()?;
|
||||
|
||||
match acct.status {
|
||||
AccountStatus::Deactivated => {
|
||||
let aph = jws.protected()?;
|
||||
let kid = aph.kid();
|
||||
|
||||
if kid.is_none() {
|
||||
return Err(JWSError::InvalidPublicKey.to_status());
|
||||
}
|
||||
|
||||
let kid = kid.unwrap();
|
||||
let target = JWK::find_by_kid(kid, appstate.db.clone()).await?;
|
||||
let target_jwk: crate::acme::jose::JWK = target.clone().try_into()?;
|
||||
|
||||
match target_jwk.try_into() {
|
||||
Ok(key) => match jws.verify(key) {
|
||||
Ok(b) => {
|
||||
if !b {
|
||||
return Err(ACMEValidationError::InvalidSignature.to_status());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
target.delete(appstate.db.clone()).await?;
|
||||
let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?;
|
||||
|
||||
return Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(serde_json::to_string(&target)?))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(ratpack::Error::StatusCode(
|
||||
StatusCode::NOT_FOUND,
|
||||
String::default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.to_status());
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn new_account_failures() {
|
||||
use crate::test::TestService;
|
||||
use http::StatusCode;
|
||||
use hyper::Body;
|
||||
use spectral::prelude::*;
|
||||
|
||||
let srv = TestService::new("new_account_failures").await;
|
||||
let res = srv.clone().app.get("/account").await;
|
||||
assert_that!(res.status()).is_equal_to(StatusCode::METHOD_NOT_ALLOWED);
|
||||
|
||||
let res = srv.clone().app.post("/account", Body::default()).await;
|
||||
assert_that!(res.status()).is_equal_to(StatusCode::FORBIDDEN);
|
||||
|
||||
let res = srv.clone().app.post("/account/herp", Body::default()).await;
|
||||
assert_that!(res.status()).is_equal_to(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn account_register_with_certbot() {
|
||||
use crate::test::TestService;
|
||||
use spectral::prelude::*;
|
||||
|
||||
let srv = TestService::new("account_register_with_certbot").await;
|
||||
|
||||
for _ in 0..10 {
|
||||
let res = srv
|
||||
.clone()
|
||||
.certbot(
|
||||
None,
|
||||
"register -m 'erik@hollensbe.org' --agree-tos".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let dir = res.unwrap();
|
||||
|
||||
let res = srv
|
||||
.clone()
|
||||
.certbot(
|
||||
Some(dir.clone()),
|
||||
"unregister -m 'erik@hollensbe.org'".to_string(),
|
||||
)
|
||||
.await;
|
||||
assert_that!(res).is_ok();
|
||||
}
|
||||
}
|
||||
}
|
146
src/acme/handlers/directory.rs
Normal file
146
src/acme/handlers/directory.rs
Normal file
|
@ -0,0 +1,146 @@
|
|||
use super::{uri_to_url, HandlerState, ServiceState};
|
||||
use ratpack::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// See 7.1.1 of RFC8555
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryMeta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
terms_of_service: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
website: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
caa_identities: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_account_required: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for DirectoryMeta {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
terms_of_service: None,
|
||||
website: None,
|
||||
caa_identities: None,
|
||||
external_account_required: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// See 7.1.1 of RFC8555
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Directory {
|
||||
new_nonce: url::Url,
|
||||
new_account: url::Url,
|
||||
new_order: url::Url,
|
||||
new_authz: url::Url,
|
||||
revoke_cert: url::Url,
|
||||
key_change: url::Url,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<DirectoryMeta>,
|
||||
}
|
||||
|
||||
pub(crate) async fn directory(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let uri = req.uri().clone();
|
||||
let url = uri_to_url(app.state().await.unwrap().lock().await.baseurl.clone(), uri).await?;
|
||||
|
||||
let dir = Directory {
|
||||
new_nonce: url.join("./nonce")?,
|
||||
new_account: url.join("./account")?,
|
||||
new_order: url.join("./order")?,
|
||||
new_authz: url.join("./authz")?,
|
||||
revoke_cert: url.join("./revoke")?,
|
||||
key_change: url.join("./key")?,
|
||||
meta: Some(DirectoryMeta::default()),
|
||||
};
|
||||
|
||||
Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(url, Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(serde_json::to_string(&dir)?))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_basic_directory() {
|
||||
use super::{super::*, Directory, DirectoryMeta};
|
||||
use crate::test::PGTest;
|
||||
use ratpack::app::TestApp;
|
||||
use spectral::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
let pg = PGTest::new("test_basic_directory").await.unwrap();
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(1)));
|
||||
let mut app = App::with_state(
|
||||
ServiceState::new(
|
||||
"http://example.com".to_string(),
|
||||
pg.db(),
|
||||
c.clone(),
|
||||
CACollector::new(Duration::MAX),
|
||||
PostgresNonceValidator::new(pg.db()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
configure_routes(&mut app, None);
|
||||
|
||||
let app = TestApp::new(app);
|
||||
|
||||
let mut res = app.get("/").await;
|
||||
|
||||
let res = hyper::body::to_bytes(res.body_mut()).await.unwrap();
|
||||
let res = serde_json::from_slice::<Directory>(&res).unwrap();
|
||||
|
||||
assert_that!(res).is_equal_to(Directory {
|
||||
new_nonce: "http://example.com/nonce".parse().unwrap(),
|
||||
new_account: "http://example.com/account".parse().unwrap(),
|
||||
new_order: "http://example.com/order".parse().unwrap(),
|
||||
new_authz: "http://example.com/authz".parse().unwrap(),
|
||||
revoke_cert: "http://example.com/revoke".parse().unwrap(),
|
||||
key_change: "http://example.com/key".parse().unwrap(),
|
||||
meta: Some(DirectoryMeta::default()),
|
||||
});
|
||||
|
||||
let mut app = App::with_state(
|
||||
ServiceState::new(
|
||||
"http://example.com/acme".to_string(),
|
||||
pg.db(),
|
||||
c,
|
||||
CACollector::new(Duration::MAX),
|
||||
PostgresNonceValidator::new(pg.db()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
configure_routes(&mut app, Some("/acme"));
|
||||
|
||||
let app = TestApp::new(app);
|
||||
let mut res = app.get("/acme/").await;
|
||||
|
||||
let res = hyper::body::to_bytes(res.body_mut()).await.unwrap();
|
||||
let res = serde_json::from_slice::<Directory>(&res).unwrap();
|
||||
|
||||
assert_that!(res).is_equal_to(Directory {
|
||||
new_nonce: "http://example.com/acme/nonce".parse().unwrap(),
|
||||
new_account: "http://example.com/acme/account".parse().unwrap(),
|
||||
new_order: "http://example.com/acme/order".parse().unwrap(),
|
||||
new_authz: "http://example.com/acme/authz".parse().unwrap(),
|
||||
revoke_cert: "http://example.com/acme/revoke".parse().unwrap(),
|
||||
key_change: "http://example.com/acme/key".parse().unwrap(),
|
||||
meta: Some(DirectoryMeta::default()),
|
||||
});
|
||||
}
|
||||
}
|
246
src/acme/handlers/mod.rs
Normal file
246
src/acme/handlers/mod.rs
Normal file
|
@ -0,0 +1,246 @@
|
|||
use std::convert::TryInto;
|
||||
|
||||
use crate::{
|
||||
acme::{
|
||||
ca::CACollector,
|
||||
challenge::Challenger,
|
||||
handlers::{
|
||||
account::{new_account, post_account},
|
||||
directory::directory,
|
||||
nonce::{new_nonce_get, new_nonce_head},
|
||||
order::{
|
||||
existing_order, finalize_order, get_certificate, new_order, post_authz,
|
||||
post_challenge,
|
||||
},
|
||||
},
|
||||
jose::{ACMEKey, JWK},
|
||||
NonceValidator, PostgresNonceValidator,
|
||||
},
|
||||
errors::{acme::JWSError, ACMEValidationError, Error, HandlerError},
|
||||
models::Postgres,
|
||||
};
|
||||
use http::response::Builder;
|
||||
use ratpack::prelude::*;
|
||||
|
||||
pub(crate) mod account;
|
||||
pub(crate) mod directory;
|
||||
pub(crate) mod nonce;
|
||||
pub(crate) mod order;
|
||||
|
||||
pub(crate) const REPLAY_NONCE_HEADER: &str = "Replay-Nonce";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceState {
|
||||
baseurl: url::Url,
|
||||
db: Postgres,
|
||||
c: Challenger,
|
||||
ca: CACollector,
|
||||
pnv: PostgresNonceValidator,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
pub fn new(
|
||||
baseurl: String,
|
||||
db: Postgres,
|
||||
c: Challenger,
|
||||
ca: CACollector,
|
||||
pnv: PostgresNonceValidator,
|
||||
) -> Result<Self, url::ParseError> {
|
||||
Ok(Self {
|
||||
baseurl: baseurl.parse()?,
|
||||
db,
|
||||
c,
|
||||
ca,
|
||||
pnv,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HandlerState {
|
||||
jws: Option<crate::acme::jose::JWS>,
|
||||
nonce: Option<String>,
|
||||
}
|
||||
|
||||
impl HandlerState {
|
||||
pub(crate) fn decorate_response(
|
||||
&self,
|
||||
url: url::Url,
|
||||
builder: Builder,
|
||||
) -> Result<Builder, HandlerError> {
|
||||
if self.nonce.is_none() {
|
||||
return Err(ACMEValidationError::NonceNotFound.into());
|
||||
}
|
||||
|
||||
Ok(builder
|
||||
.header("content-type", "application/json")
|
||||
.header(REPLAY_NONCE_HEADER, self.clone().nonce.unwrap())
|
||||
.header(
|
||||
"Link",
|
||||
format!(r#"<{}>;rel="index""#, url.join("/")?.to_string()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl TransientState for HandlerState {
|
||||
fn initial() -> Self {
|
||||
Self {
|
||||
jws: None,
|
||||
nonce: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn uri_to_url(
|
||||
baseurl: url::Url,
|
||||
uri: http::Uri,
|
||||
) -> Result<url::Url, url::ParseError> {
|
||||
baseurl.join(&uri.to_string())
|
||||
}
|
||||
|
||||
async fn handle_log_request(
|
||||
req: Request<Body>,
|
||||
resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
_app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
log::info!("{} request to {}", req.method(), req.uri().path());
|
||||
Ok((req, resp, state))
|
||||
}
|
||||
|
||||
async fn handle_nonce(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
mut state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
state.nonce = Some(app.state().await.unwrap().lock().await.pnv.make().await?);
|
||||
Ok((req, None, state))
|
||||
}
|
||||
|
||||
async fn handle_jws(
|
||||
mut req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
mut state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let jws: Result<crate::acme::jose::JWS, _> =
|
||||
serde_json::from_slice(&hyper::body::to_bytes(req.body_mut()).await?);
|
||||
|
||||
// what a mess.
|
||||
if let Ok(jws) = jws {
|
||||
let uri = req.uri().clone();
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match jws.clone().protected() {
|
||||
Ok(mut protected) => {
|
||||
if let Err(e) = protected
|
||||
.validate(
|
||||
uri_to_url(appstate.baseurl.clone(), uri).await?,
|
||||
appstate.pnv.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(e.to_status());
|
||||
} else {
|
||||
let key: Result<Option<ACMEKey>, Error> = if let Some(jwk) = protected.jwk() {
|
||||
Ok(Some(jwk.try_into()?))
|
||||
} else if let Some(kid) = protected.kid() {
|
||||
let jwk =
|
||||
crate::models::account::JWK::find_by_kid(kid, appstate.db.clone())
|
||||
.await?;
|
||||
|
||||
let localjwk: Result<JWK, JWSError> = jwk.try_into();
|
||||
match localjwk {
|
||||
Ok(mut localjwk) => match (&mut localjwk).try_into() {
|
||||
Ok(x) => Ok(Some(x)),
|
||||
Err(e) => Err(e.into()),
|
||||
},
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
|
||||
match key {
|
||||
Err(e) => return Err(e.to_status()),
|
||||
Ok(Some(key)) => match jws.verify(key) {
|
||||
Ok(result) => {
|
||||
if result {
|
||||
state.jws = Some(jws);
|
||||
return Ok((req, None, state));
|
||||
}
|
||||
|
||||
return Err(JWSError::ValidationFailed.to_status());
|
||||
}
|
||||
Err(e) => return Err(e.to_status()),
|
||||
},
|
||||
Ok(None) => return Err(JWSError::Missing.to_status()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e.to_status()),
|
||||
}
|
||||
}
|
||||
|
||||
Err(ratpack::Error::StatusCode(
|
||||
StatusCode::FORBIDDEN,
|
||||
String::default(),
|
||||
))
|
||||
}
|
||||
|
||||
macro_rules! jws_handler {
|
||||
($($x:path)*) => {
|
||||
compose_handler!(handle_log_request, handle_nonce, handle_jws, $($x)*)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn configure_routes(app: &mut App<ServiceState, HandlerState>, rootpath: Option<&str>) {
|
||||
let rootpath = rootpath.unwrap_or("/").to_string();
|
||||
|
||||
app.get(
|
||||
&(rootpath.clone()),
|
||||
compose_handler!(handle_log_request, handle_nonce, directory),
|
||||
);
|
||||
|
||||
app.head(
|
||||
&(rootpath.clone() + "nonce"),
|
||||
compose_handler!(handle_log_request, handle_nonce, new_nonce_head),
|
||||
);
|
||||
app.get(
|
||||
&(rootpath.clone() + "nonce"),
|
||||
compose_handler!(handle_log_request, handle_nonce, new_nonce_get),
|
||||
);
|
||||
|
||||
app.post(&(rootpath.clone() + "account"), jws_handler!(new_account));
|
||||
app.post(
|
||||
&(rootpath.clone() + "account/:key_id"),
|
||||
jws_handler!(post_account),
|
||||
);
|
||||
|
||||
app.post(&(rootpath.clone() + "order"), jws_handler!(new_order));
|
||||
app.post(
|
||||
&(rootpath.clone() + "order/:order_id"),
|
||||
jws_handler!(existing_order),
|
||||
);
|
||||
app.post(
|
||||
&(rootpath.clone() + "order/:order_id/finalize"),
|
||||
jws_handler!(finalize_order),
|
||||
);
|
||||
app.post(
|
||||
&(rootpath.clone() + "order/:order_id/certificate"),
|
||||
jws_handler!(get_certificate),
|
||||
);
|
||||
app.post(
|
||||
&(rootpath.clone() + "authz/:auth_id"),
|
||||
jws_handler!(post_authz),
|
||||
);
|
||||
app.post(
|
||||
&(rootpath.clone() + "chall/:challenge_id"),
|
||||
jws_handler!(post_challenge),
|
||||
);
|
||||
}
|
173
src/acme/handlers/nonce.rs
Normal file
173
src/acme/handlers/nonce.rs
Normal file
|
@ -0,0 +1,173 @@
|
|||
// nonces are covered in RFC8555 section 7.2 mostly. They're also a critical part of the JOSE usage
|
||||
// in this library.
|
||||
|
||||
use super::{uri_to_url, HandlerState, ServiceState};
|
||||
use ratpack::prelude::*;
|
||||
|
||||
pub(crate) async fn new_nonce_head(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let uri = req.uri().clone();
|
||||
|
||||
Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(
|
||||
uri_to_url(app.state().await.unwrap().lock().await.baseurl.clone(), uri)
|
||||
.await?,
|
||||
Response::builder(),
|
||||
)?
|
||||
.status(StatusCode::OK)
|
||||
.header("Cache-Control", "no-store") // last para of 7.2
|
||||
.body(Body::default())
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn new_nonce_get(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let uri = req.uri().clone();
|
||||
|
||||
Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(
|
||||
uri_to_url(app.state().await.unwrap().lock().await.baseurl.clone(), uri)
|
||||
.await?,
|
||||
Response::builder(),
|
||||
)?
|
||||
.status(StatusCode::CREATED)
|
||||
.header("Cache-Control", "no-store") // last para of 7.2
|
||||
.body(Body::default())
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
))
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_basic_head() {
|
||||
use super::super::*;
|
||||
use crate::test::PGTest;
|
||||
use ratpack::app::TestApp;
|
||||
use spectral::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
let pg = PGTest::new("test_basic_head").await.unwrap();
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(1)));
|
||||
let mut app = App::with_state(
|
||||
ServiceState::new(
|
||||
"http://127.0.0.1:8000".to_string(),
|
||||
pg.db(),
|
||||
c,
|
||||
CACollector::new(Duration::MAX),
|
||||
PostgresNonceValidator::new(pg.db()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
configure_routes(&mut app, None);
|
||||
|
||||
let app: TestApp<ServiceState, HandlerState> = TestApp::new(app);
|
||||
|
||||
let res = app.head("/nonce").await;
|
||||
let headers = res.headers();
|
||||
let nonce = headers.get(REPLAY_NONCE_HEADER).unwrap().clone();
|
||||
assert_that!(nonce.is_empty()).is_false();
|
||||
drop(headers);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..10000 {
|
||||
let nonce = nonce.clone();
|
||||
let app = app.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let res = app.head("/nonce").await;
|
||||
assert_that!(res.status()).is_equal_to(StatusCode::OK);
|
||||
|
||||
let nonce2 = res
|
||||
.headers()
|
||||
.get(REPLAY_NONCE_HEADER)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(!nonce2.is_empty());
|
||||
|
||||
assert_ne!(nonce, nonce2);
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
for handle in handles {
|
||||
handle.await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_basic_get() {
|
||||
use super::super::*;
|
||||
use crate::test::PGTest;
|
||||
use ratpack::app::TestApp;
|
||||
use spectral::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
let pg = PGTest::new("test_basic_get").await.unwrap();
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(1)));
|
||||
let mut app = App::with_state(
|
||||
ServiceState::new(
|
||||
"http://127.0.0.1:8000".to_string(),
|
||||
pg.db(),
|
||||
c,
|
||||
CACollector::new(Duration::MAX),
|
||||
PostgresNonceValidator::new(pg.db()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
configure_routes(&mut app, None);
|
||||
|
||||
let app: TestApp<ServiceState, HandlerState> = TestApp::new(app);
|
||||
|
||||
let res = app.get("/nonce").await;
|
||||
let headers = res.headers();
|
||||
let nonce = headers.get(REPLAY_NONCE_HEADER).unwrap().clone();
|
||||
assert_that!(nonce.is_empty()).is_false();
|
||||
drop(headers);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..10000 {
|
||||
let nonce = nonce.clone();
|
||||
let app = app.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let res = app.get("/nonce").await;
|
||||
let headers = res.headers();
|
||||
let nonce2 = headers.get(REPLAY_NONCE_HEADER).unwrap().clone();
|
||||
assert_that!(nonce.is_empty()).is_false();
|
||||
drop(headers);
|
||||
|
||||
assert!(!nonce2.is_empty());
|
||||
|
||||
assert_ne!(nonce, nonce2);
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
for handle in handles {
|
||||
handle.await.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
708
src/acme/handlers/order.rs
Normal file
708
src/acme/handlers/order.rs
Normal file
|
@ -0,0 +1,708 @@
|
|||
use http::HeaderValue;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
convert::{TryFrom, TryInto},
|
||||
};
|
||||
use tokio_postgres::Transaction;
|
||||
use url::Url;
|
||||
use x509_parser::prelude::*;
|
||||
|
||||
use ratpack::prelude::*;
|
||||
|
||||
use crate::{
|
||||
acme::{challenge::ChallengeType, ACMEIdentifier},
|
||||
errors::{db::LoadError, ACMEValidationError},
|
||||
models::{order::Challenge, Record},
|
||||
};
|
||||
|
||||
use super::{uri_to_url, HandlerState, ServiceState};
|
||||
|
||||
pub struct OrdersList {
|
||||
orders: Vec<Url>,
|
||||
}
|
||||
|
||||
/// RFC8555 7.1.3. Detailed read.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Order {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<OrderStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires: Option<chrono::DateTime<chrono::Local>>, // required for pending and valid states
|
||||
pub identifiers: Vec<crate::acme::ACMEIdentifier>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub not_before: Option<chrono::DateTime<chrono::Local>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub not_after: Option<chrono::DateTime<chrono::Local>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<crate::errors::Error>,
|
||||
// read 7.1.3's missive on this + section 7.5
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub authorizations: Option<Vec<Url>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finalize: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<Url>,
|
||||
}
|
||||
|
||||
/// RFC8555 7.1.3 & 7.1.6
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum OrderStatus {
|
||||
Pending,
|
||||
Ready,
|
||||
Processing,
|
||||
Valid,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
impl ToString for OrderStatus {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Ready => "ready",
|
||||
Self::Processing => "processing",
|
||||
Self::Valid => "valid",
|
||||
Self::Invalid => "invalid",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for OrderStatus {
|
||||
type Error = crate::errors::db::LoadError;
|
||||
|
||||
fn try_from(s: String) -> Result<Self, Self::Error> {
|
||||
Self::try_from(s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for OrderStatus {
|
||||
type Error = crate::errors::db::LoadError;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
Ok(match s {
|
||||
"pending" => Self::Pending,
|
||||
"ready" => Self::Ready,
|
||||
"processing" => Self::Processing,
|
||||
"valid" => Self::Valid,
|
||||
"invalid" => Self::Invalid,
|
||||
_ => return Err(LoadError::InvalidEnum),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn new_order(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
_params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(jws) => {
|
||||
let order: Order = jws.payload()?;
|
||||
|
||||
let mut o = crate::models::order::Order::new(
|
||||
order.not_before.map_or(None, |f| Some(f.into())),
|
||||
order.not_after.map_or(None, |f| Some(f.into())),
|
||||
);
|
||||
o.create(appstate.db.clone()).await?;
|
||||
|
||||
for id in order.identifiers {
|
||||
let mut authz = crate::models::order::Authorization::default();
|
||||
authz.identifier = Some(id.clone().to_string());
|
||||
authz.order_id = o.order_id.clone();
|
||||
authz.create(appstate.db.clone()).await?;
|
||||
|
||||
// for now at least, schedule one http-01 and dns-01 per name
|
||||
|
||||
for chall in vec![ChallengeType::DNS01, ChallengeType::HTTP01] {
|
||||
let mut c = Challenge::new(
|
||||
o.order_id.clone(),
|
||||
authz.reference.clone(),
|
||||
chall,
|
||||
id.clone().to_string(),
|
||||
OrderStatus::Pending,
|
||||
);
|
||||
|
||||
c.create(appstate.db.clone()).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?;
|
||||
|
||||
let order: Order =
|
||||
crate::models::order::Order::find(o.id()?.unwrap(), appstate.db.clone())
|
||||
.await?
|
||||
.into_handler_order(url.clone())?;
|
||||
|
||||
return Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::CREATED)
|
||||
.header(
|
||||
"Location",
|
||||
url.join(&format!("./order/{}", o.order_id))?.to_string(),
|
||||
)
|
||||
.body(Body::from(serde_json::to_string(&order)?))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
pub(crate) async fn existing_order(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(_jws) => {
|
||||
let order_id = params.get("order_id").unwrap();
|
||||
|
||||
let o = crate::models::order::Order::find_by_reference(
|
||||
order_id.to_string(),
|
||||
appstate.db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?;
|
||||
let h_order = serde_json::to_string(&o.clone().into_handler_order(url.clone())?)?;
|
||||
|
||||
return Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
"Location",
|
||||
url.join(&format!("./order/{}", o.order_id))?.to_string(),
|
||||
)
|
||||
.body(Body::from(h_order))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
/// RFC8555 7.4.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FinalizeOrderRequest {
|
||||
csr: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn finalize_order(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(jws) => {
|
||||
let finalize_order: FinalizeOrderRequest = jws.payload()?;
|
||||
|
||||
let order_id = params.get("order_id").unwrap();
|
||||
|
||||
let order = crate::models::order::Order::find_by_reference(
|
||||
order_id.to_string(),
|
||||
appstate.db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if order.authorizations.is_none() {
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
// this code yields to the x509-parser crate to reap and check the subjectAltName
|
||||
// extensions. This is necessary because rust-openssl does not support this
|
||||
// functionality.
|
||||
//
|
||||
// relevant topic on github:
|
||||
// https://github.com/sfackler/rust-openssl/pull/1095#issuecomment-636279332
|
||||
//
|
||||
// commonName is not checked as it should not be validated in a typical situation
|
||||
// involving TLS and subjectAltName components, but this may be something to revisit
|
||||
// later.
|
||||
//
|
||||
// Later, the csr is handed back to rust-openssl to complete the CA signing process.
|
||||
|
||||
let decoded =
|
||||
&base64::decode_config(finalize_order.csr.clone(), base64::URL_SAFE_NO_PAD)?;
|
||||
|
||||
let (_, csr) = X509CertificationRequest::from_der(decoded)?;
|
||||
csr.verify_signature()?;
|
||||
|
||||
let mut mapping = HashSet::new();
|
||||
|
||||
for id in order.authorizations.clone().unwrap() {
|
||||
for id in id.identifier {
|
||||
mapping.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extensions) = csr.requested_extensions() {
|
||||
for extension in extensions {
|
||||
match extension {
|
||||
ParsedExtension::SubjectAlternativeName(name) => {
|
||||
for val in name.general_names.iter() {
|
||||
match val {
|
||||
GeneralName::DNSName(val) => {
|
||||
if mapping.contains(&val.to_string()) {
|
||||
mapping.remove(&val.to_string());
|
||||
} else {
|
||||
return Err(ACMEValidationError::Other(
|
||||
"CSR contains invalid names".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !mapping.is_empty() {
|
||||
return Err(
|
||||
ACMEValidationError::Other("CSR contains invalid names".to_string()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
if order.not_before.is_none() {
|
||||
return Err(ACMEValidationError::Other(
|
||||
"not_before missing in order cadence".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if order.not_after.is_none() {
|
||||
return Err(ACMEValidationError::Other(
|
||||
"not_after missing in order cadence".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let csr = openssl::x509::X509Req::from_der(decoded)?;
|
||||
|
||||
let res = appstate
|
||||
.ca
|
||||
.clone()
|
||||
.sign(
|
||||
csr,
|
||||
order.clone().not_before.unwrap().into(),
|
||||
order.clone().not_after.unwrap().into(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(cert) => order.record_certificate(cert, appstate.db.clone()).await?,
|
||||
Err(e) => return Err(ACMEValidationError::Other(e.to_string()).into()),
|
||||
};
|
||||
|
||||
let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?;
|
||||
let h_order = serde_json::to_string(&order.clone().into_handler_order(url.clone())?)?;
|
||||
|
||||
return Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
"Location",
|
||||
url.join(&format!("./order/{}", order.order_id))?
|
||||
.to_string(),
|
||||
)
|
||||
.body(Body::from(h_order))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
pub(crate) async fn get_certificate(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(_jws) => {
|
||||
let order_id = params.get("order_id").unwrap();
|
||||
|
||||
let order = crate::models::order::Order::find_by_reference(
|
||||
order_id.to_string(),
|
||||
appstate.db.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cert = order.certificate(appstate.db.clone()).await?;
|
||||
let mut cacert = appstate
|
||||
.ca
|
||||
.clone()
|
||||
.ca()
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.unwrap()
|
||||
.certificate()
|
||||
.to_pem()?;
|
||||
|
||||
let mut chain = cert.certificate;
|
||||
chain.append(&mut cacert);
|
||||
|
||||
let url = uri_to_url(appstate.baseurl.clone(), req.uri().clone()).await?;
|
||||
|
||||
return Ok((
|
||||
req,
|
||||
Some(
|
||||
state
|
||||
.decorate_response(url, Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.body(Body::from(chain))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Authorization {
|
||||
identifier: ACMEIdentifier,
|
||||
status: AuthStatus,
|
||||
expires: chrono::DateTime<chrono::Local>,
|
||||
challenges: Vec<ChallengeAuthorization>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
wildcard: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AuthStatus {
|
||||
Pending,
|
||||
Valid,
|
||||
Deactivated,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChallengeAuthorization {
|
||||
#[serde(rename = "type")]
|
||||
typ: ChallengeType,
|
||||
url: Url,
|
||||
token: String,
|
||||
status: OrderStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
validated: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
impl ChallengeAuthorization {
|
||||
fn from_challenge(c: &Challenge, url: Url) -> Result<Self, LoadError> {
|
||||
Ok(Self {
|
||||
typ: c.challenge_type.clone(),
|
||||
url,
|
||||
token: c.token.clone(),
|
||||
status: c.status.clone(),
|
||||
validated: c.validated.map(|t| t.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Authorization {
|
||||
async fn from_authorization_id(
|
||||
auth_id: &str,
|
||||
url: Url,
|
||||
tx: &Transaction<'_>,
|
||||
) -> Result<Self, LoadError> {
|
||||
let auth = crate::models::order::Authorization::find_by_reference(auth_id, &tx).await?;
|
||||
let challenges = auth.challenges(&tx).await?;
|
||||
|
||||
let chs = challenges
|
||||
.iter()
|
||||
.map(|c| ChallengeAuthorization::from_challenge(c, c.into_url(url.clone())))
|
||||
.collect::<Vec<Result<ChallengeAuthorization, LoadError>>>();
|
||||
|
||||
if let Some(Err(error)) = chs.iter().find(|p| p.is_err()) {
|
||||
return Err(LoadError::Generic(error.to_string()));
|
||||
}
|
||||
|
||||
let chs = chs
|
||||
.iter()
|
||||
.map(|c| c.as_ref().unwrap().clone())
|
||||
.collect::<Vec<ChallengeAuthorization>>();
|
||||
|
||||
Ok(Self {
|
||||
expires: auth.expires.into(),
|
||||
status: if auth.deleted_at.is_some() {
|
||||
AuthStatus::Deactivated
|
||||
} else {
|
||||
if chs.iter().any(|ca| ca.status == OrderStatus::Valid) {
|
||||
AuthStatus::Valid
|
||||
} else if chs
|
||||
.iter()
|
||||
.all(|ca| ca.status != OrderStatus::Valid && ca.status != OrderStatus::Invalid)
|
||||
{
|
||||
AuthStatus::Pending
|
||||
} else {
|
||||
AuthStatus::Revoked
|
||||
}
|
||||
},
|
||||
identifier: auth.identifier.unwrap().try_into()?,
|
||||
challenges: chs,
|
||||
wildcard: None, // FIXME wtf? re-check spec
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn post_authz(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(_jws) => {
|
||||
let auth_id = params.get("auth_id").unwrap();
|
||||
|
||||
let db = appstate.db.clone();
|
||||
let mut lockeddb = db.client().await?;
|
||||
let tx = lockeddb.transaction().await?;
|
||||
|
||||
let mut statuscode = StatusCode::CREATED;
|
||||
|
||||
let authz = Authorization::from_authorization_id(
|
||||
auth_id,
|
||||
uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?,
|
||||
&tx,
|
||||
)
|
||||
.await?;
|
||||
for chall in authz.clone().challenges {
|
||||
if chall.status == OrderStatus::Valid {
|
||||
statuscode = StatusCode::OK;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?;
|
||||
let builder = state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.header(
|
||||
"Link",
|
||||
HeaderValue::from_str(&format!(r#"<{}>;rel="up""#, url.clone()))?,
|
||||
);
|
||||
|
||||
let out = serde_json::to_string(&authz)?;
|
||||
return Ok((
|
||||
req,
|
||||
Some(builder.status(statuscode).body(Body::from(out)).unwrap()),
|
||||
state,
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
pub(crate) async fn post_challenge(
|
||||
req: Request<Body>,
|
||||
_resp: Option<Response<Body>>,
|
||||
params: Params,
|
||||
app: App<ServiceState, HandlerState>,
|
||||
state: HandlerState,
|
||||
) -> HTTPResult<HandlerState> {
|
||||
let appstate_opt = app.state().await.clone().unwrap();
|
||||
let appstate = appstate_opt.lock().await;
|
||||
|
||||
match state.clone().jws {
|
||||
Some(_jws) => {
|
||||
let challenge_id = params.get("challenge_id").unwrap();
|
||||
|
||||
let db = appstate.db.clone();
|
||||
let mut lockeddb = db.client().await?;
|
||||
let tx = lockeddb.transaction().await?;
|
||||
|
||||
let mut ch = Challenge::find_by_reference(challenge_id.to_string(), &tx).await?;
|
||||
if ch.status == OrderStatus::Pending {
|
||||
ch.status = OrderStatus::Processing;
|
||||
ch.persist_status(&tx).await?;
|
||||
appstate.c.schedule(ch.clone()).await;
|
||||
}
|
||||
|
||||
let authz = ch.authorization(&tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?;
|
||||
|
||||
// FIXME 7.5.1 indicates a Retry-After header can be sent to feed the client hints on how
|
||||
// often to retry here... we can use the polling value fed to the challenger for this
|
||||
// value.
|
||||
let builder = state
|
||||
.decorate_response(url.clone(), Response::builder())?
|
||||
.status(StatusCode::OK)
|
||||
.header(
|
||||
"Link",
|
||||
HeaderValue::from_str(&format!(
|
||||
r#"<{}>;rel="up""#,
|
||||
authz.into_url(url.clone())
|
||||
))?,
|
||||
);
|
||||
|
||||
return Ok((
|
||||
req,
|
||||
Some(
|
||||
builder
|
||||
.body(Body::from(serde_json::to_string(
|
||||
&ChallengeAuthorization::from_challenge(&ch, ch.into_url(url))?,
|
||||
)?))
|
||||
.unwrap(),
|
||||
),
|
||||
state,
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
return Err(ACMEValidationError::InvalidRequest.into());
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_order_flow_single_domain() {
|
||||
use crate::test::TestService;
|
||||
use spectral::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let srv = TestService::new("test_order_flow_single_domain").await;
|
||||
|
||||
let dir = Arc::new(TempDir::new().unwrap());
|
||||
|
||||
for _ in 0..10 {
|
||||
let res = srv.clone().certbot(
|
||||
Some(dir.clone()),
|
||||
format!("certonly --http-01-port {} --standalone -d 'foo.com' -m 'erik@hollensbe.org' --agree-tos",
|
||||
rand::random::<u16>() % 10000 + 1024)
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let res = srv
|
||||
.clone()
|
||||
.certbot(Some(dir.clone()), "update_symlinks".to_string())
|
||||
.await;
|
||||
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let mut root = dir.path().to_path_buf();
|
||||
root.push("live/foo.com");
|
||||
|
||||
for filename in vec!["fullchain", "cert", "chain", "privkey"] {
|
||||
let mut path = root.clone();
|
||||
path.push(filename.to_string() + ".pem");
|
||||
let res = path.metadata();
|
||||
assert_that!(res).is_ok();
|
||||
}
|
||||
|
||||
assert_that!(srv.zlint(dir.clone()).await).is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_order_flow_multi_domain() {
|
||||
use crate::test::TestService;
|
||||
use spectral::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let srv = TestService::new("test_order_flow_multi_domain").await;
|
||||
|
||||
let dir = Arc::new(TempDir::new().unwrap());
|
||||
|
||||
for domain in vec!["foo.com", "bar.com", "example.org", "example.com"] {
|
||||
let res = srv.clone().certbot(
|
||||
Some(dir.clone()),
|
||||
format!(
|
||||
"certonly --http-01-port {} --standalone -d '{}' -m 'erik@hollensbe.org' --agree-tos",
|
||||
rand::random::<u16>() % 10000 + 1024,
|
||||
domain
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let res = srv
|
||||
.clone()
|
||||
.certbot(Some(dir.clone()), "update_symlinks".to_string())
|
||||
.await;
|
||||
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let mut root = dir.path().to_path_buf();
|
||||
root.push(format!("live/{}", domain));
|
||||
|
||||
for filename in vec!["fullchain", "cert", "chain", "privkey"] {
|
||||
let mut path = root.clone();
|
||||
path.push(filename.to_string() + ".pem");
|
||||
let res = path.metadata();
|
||||
assert_that!(res).is_ok();
|
||||
}
|
||||
|
||||
assert_that!(srv.zlint(dir.clone()).await).is_ok();
|
||||
}
|
||||
}
|
||||
}
|
8
src/acme/jose/crypto/mod.rs
Normal file
8
src/acme/jose/crypto/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
use lazy_static::lazy_static;
|
||||
use openssl::{ec::EcGroup, nid::Nid};
|
||||
|
||||
const NID_ES256: Nid = Nid::X9_62_PRIME256V1;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref EC_GROUP: EcGroup = EcGroup::from_curve_name(NID_ES256).unwrap();
|
||||
}
|
602
src/acme/jose/mod.rs
Normal file
602
src/acme/jose/mod.rs
Normal file
|
@ -0,0 +1,602 @@
|
|||
pub mod crypto;
|
||||
|
||||
use std::{
|
||||
convert::{TryFrom, TryInto},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use openssl::{
|
||||
bn::BigNum,
|
||||
ec::{EcKey, EcPointRef},
|
||||
ecdsa::EcdsaSig,
|
||||
hash::MessageDigest,
|
||||
pkey::{PKey, Private, Public},
|
||||
rsa::Rsa,
|
||||
sha::sha256,
|
||||
sign::{Signer, Verifier},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
acme::{NonceValidator, ACME_EXPECTED_ALGS},
|
||||
errors::{acme::*, ACMEValidationError},
|
||||
util::{make_nonce, to_base64},
|
||||
};
|
||||
|
||||
use super::PostgresNonceValidator;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ACMEProtectedHeader {
|
||||
alg: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
jwk: Option<JWK>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kid: Option<Url>,
|
||||
nonce: String,
|
||||
url: Url,
|
||||
}
|
||||
|
||||
impl ACMEProtectedHeader {
|
||||
pub fn new_jwk(jwk: JWK, url: Url, nonce: String) -> Self {
|
||||
Self {
|
||||
url,
|
||||
alg: String::from(ACME_EXPECTED_ALGS[0].clone()),
|
||||
jwk: Some(jwk),
|
||||
kid: None,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_kid(kid: Url, url: Url, nonce: String) -> Self {
|
||||
Self {
|
||||
url,
|
||||
alg: String::from(ACME_EXPECTED_ALGS[0].clone()),
|
||||
jwk: None,
|
||||
kid: Some(kid),
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nonce(&self) -> String {
|
||||
self.nonce.clone()
|
||||
}
|
||||
|
||||
pub fn kid(&self) -> Option<Url> {
|
||||
self.kid.clone()
|
||||
}
|
||||
|
||||
pub fn jwk(&mut self) -> Option<&mut JWK> {
|
||||
self.jwk.as_mut()
|
||||
}
|
||||
|
||||
pub async fn validate(
|
||||
&self,
|
||||
request_url: Url,
|
||||
validator: PostgresNonceValidator,
|
||||
) -> Result<(), ACMEValidationError> {
|
||||
if self.nonce.is_empty() {
|
||||
return Err(ACMEValidationError::NonceNotFound);
|
||||
}
|
||||
|
||||
if base64::decode_config(self.nonce.clone(), base64::URL_SAFE_NO_PAD).is_err() {
|
||||
return Err(ACMEValidationError::NonceDecodeError);
|
||||
}
|
||||
|
||||
if self.jwk.is_none() && self.kid.is_none() {
|
||||
return Err(ACMEValidationError::NoKeyProvided);
|
||||
}
|
||||
|
||||
if !self.url.eq(&request_url) {
|
||||
return Err(ACMEValidationError::URLNotEqual(
|
||||
request_url.to_string(),
|
||||
self.url.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !ACME_EXPECTED_ALGS.contains(&self.alg) {
|
||||
return Err(ACMEValidationError::AlgNotEqual(
|
||||
ACME_EXPECTED_ALGS.join(", "),
|
||||
self.alg.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(validator.validate(&self.nonce).await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ACMEKey {
|
||||
ECDSA(EcKey<Public>),
|
||||
RSA(Rsa<Public>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ACMEPrivateKey {
|
||||
ECDSA(EcKey<Private>),
|
||||
RSA(Rsa<Private>),
|
||||
}
|
||||
|
||||
impl TryFrom<&EcPointRef> for ACMEKey {
|
||||
type Error = JWSError;
|
||||
|
||||
fn try_from(ec: &EcPointRef) -> Result<Self, Self::Error> {
|
||||
let mut ctx = openssl::bn::BigNumContext::new()?;
|
||||
let mut x = openssl::bn::BigNum::new()?;
|
||||
let mut y = openssl::bn::BigNum::new()?;
|
||||
ec.affine_coordinates_gfp(&crypto::EC_GROUP, &mut x, &mut y, &mut ctx)?;
|
||||
Ok((&mut JWK {
|
||||
x: Some(base64::encode_config(&x.to_vec(), base64::URL_SAFE_NO_PAD)),
|
||||
y: Some(base64::encode_config(&y.to_vec(), base64::URL_SAFE_NO_PAD)),
|
||||
alg: Some("ES256".into()),
|
||||
crv: Some("P-256".into()),
|
||||
_use: Some("sig".into()),
|
||||
kty: "EC".into(),
|
||||
n: None,
|
||||
e: None,
|
||||
})
|
||||
.try_into()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Rsa<Public>> for ACMEKey {
|
||||
type Error = JWSError;
|
||||
|
||||
fn try_from(value: Rsa<Public>) -> Result<Self, Self::Error> {
|
||||
Ok((&mut JWK {
|
||||
e: Some(base64::encode_config(
|
||||
&value.e().to_vec(),
|
||||
base64::URL_SAFE_NO_PAD,
|
||||
)),
|
||||
n: Some(base64::encode_config(
|
||||
&value.n().to_vec(),
|
||||
base64::URL_SAFE_NO_PAD,
|
||||
)),
|
||||
alg: Some("RS256".into()),
|
||||
kty: "RSA".into(),
|
||||
_use: Some("sig".into()),
|
||||
x: None,
|
||||
y: None,
|
||||
crv: None,
|
||||
})
|
||||
.try_into()?)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&mut JWK> for ACMEKey {
|
||||
type Error = JWSError;
|
||||
|
||||
fn try_from(jwk: &mut JWK) -> Result<Self, Self::Error> {
|
||||
match jwk.kty.as_str() {
|
||||
"RSA" => Ok(ACMEKey::RSA(jwk.into_rsa()?)),
|
||||
"EC" => Ok(ACMEKey::ECDSA(jwk.into_ec()?)),
|
||||
_ => Err(JWSError::InvalidPublicKey),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<ACMEKey> for JWK {
|
||||
type Error = JWSError;
|
||||
fn try_into(self) -> Result<ACMEKey, Self::Error> {
|
||||
match self.kty.as_str() {
|
||||
"RSA" => Ok(ACMEKey::RSA(self.into_rsa()?)),
|
||||
"EC" => Ok(ACMEKey::ECDSA(self.into_ec()?)),
|
||||
_ => Err(JWSError::InvalidPublicKey),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<JWS> for ACMEKey {
|
||||
type Error = JWSError;
|
||||
|
||||
fn try_from(mut jws: JWS) -> Result<Self, Self::Error> {
|
||||
if let Some(jwk) = jws.protected()?.jwk() {
|
||||
return jwk.try_into();
|
||||
}
|
||||
|
||||
return Err(JWSError::InvalidPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct JWK {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub alg: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub crv: Option<String>,
|
||||
pub kty: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "use")]
|
||||
pub _use: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub x: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub y: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub e: Option<String>,
|
||||
}
|
||||
|
||||
impl JWK {
|
||||
fn into_rsa(&self) -> Result<Rsa<Public>, JWSError> {
|
||||
if self.n.is_none() || self.e.is_none() {
|
||||
return Err(JWSError::Encode(
|
||||
"e/n parameters missing in RSA JWK translation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let n = base64::decode_config(self.n.clone().unwrap(), base64::URL_SAFE_NO_PAD)?;
|
||||
let e = base64::decode_config(self.e.clone().unwrap(), base64::URL_SAFE_NO_PAD)?;
|
||||
|
||||
Ok(Rsa::from_public_components(
|
||||
BigNum::from_slice(&n)?,
|
||||
BigNum::from_slice(&e)?,
|
||||
)?)
|
||||
}
|
||||
|
||||
fn into_ec(&self) -> Result<EcKey<Public>, JWSError> {
|
||||
if self.x.is_none() || self.y.is_none() {
|
||||
return Err(JWSError::Encode(
|
||||
"x/y parameters missing in EC JWK translation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let x = base64::decode_config(self.x.clone().unwrap(), base64::URL_SAFE_NO_PAD)?;
|
||||
let y = base64::decode_config(self.y.clone().unwrap(), base64::URL_SAFE_NO_PAD)?;
|
||||
|
||||
let key = EcKey::from_public_key_affine_coordinates(
|
||||
&crypto::EC_GROUP,
|
||||
BigNum::from_slice(&x)?.as_ref(),
|
||||
BigNum::from_slice(&y)?.as_ref(),
|
||||
)?;
|
||||
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn from_jws(jws: &mut JWS) -> Result<Self, JWSError> {
|
||||
let mut aph = jws.protected()?;
|
||||
let alg = aph.alg.clone();
|
||||
|
||||
if aph.jwk().is_some() {
|
||||
let mut jwk = aph.jwk.unwrap();
|
||||
jwk.alg = Some(alg);
|
||||
return Ok(jwk);
|
||||
}
|
||||
|
||||
return Err(JWSError::InvalidPublicKey);
|
||||
}
|
||||
|
||||
// just a simple way to unroll private params without making them easy to dink with
|
||||
pub(crate) fn params(
|
||||
&self,
|
||||
) -> (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
(
|
||||
self.alg.clone(),
|
||||
self.n.clone(),
|
||||
self.e.clone(),
|
||||
self.x.clone(),
|
||||
self.y.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JWS {
|
||||
protected: String,
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
impl JWS {
|
||||
pub fn new<T>(protected: &ACMEProtectedHeader, payload: &T) -> Self
|
||||
where
|
||||
T: serde::Serialize + ?Sized,
|
||||
{
|
||||
JWS {
|
||||
protected: to_base64(protected).expect("could not encode protected header"),
|
||||
payload: to_base64(payload).expect("could not encode payload"),
|
||||
signature: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protected(&mut self) -> Result<ACMEProtectedHeader, JWSError> {
|
||||
let res = serde_json::from_slice::<ACMEProtectedHeader>(&base64::decode_config(
|
||||
self.protected.clone(),
|
||||
base64::URL_SAFE_NO_PAD,
|
||||
)?)?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn payload<T>(&self) -> Result<T, JWSError>
|
||||
where
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
{
|
||||
Ok(serde_json::from_slice(&base64::decode_config(
|
||||
self.payload.clone(),
|
||||
base64::URL_SAFE_NO_PAD,
|
||||
)?)?)
|
||||
}
|
||||
|
||||
pub fn verify(&self, key: ACMEKey) -> Result<bool, JWSValidationError> {
|
||||
let to_verify = format!("{}.{}", self.protected, self.payload);
|
||||
let digest = sha256(to_verify.as_bytes());
|
||||
|
||||
let decoded = base64::decode_config(self.signature.clone(), base64::URL_SAFE_NO_PAD)?;
|
||||
|
||||
match key {
|
||||
ACMEKey::ECDSA(key) => {
|
||||
if decoded.len() != 64 {
|
||||
return Err(JWSValidationError::SignatureDecode);
|
||||
}
|
||||
|
||||
let r = BigNum::from_slice(&decoded[0..32])?;
|
||||
let s = BigNum::from_slice(&decoded[32..64])?;
|
||||
|
||||
let signature =
|
||||
EcdsaSig::from_private_components(r, s).expect("could not program components");
|
||||
|
||||
Ok(signature.verify(&digest, &key)?)
|
||||
}
|
||||
ACMEKey::RSA(key) => {
|
||||
let pkey = PKey::from_rsa(key)?;
|
||||
let mut verifier = Verifier::new(MessageDigest::sha256(), pkey.as_ref())?;
|
||||
verifier.update(to_verify.as_bytes())?;
|
||||
Ok(verifier.verify(&decoded)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_with_signature(
|
||||
&mut self,
|
||||
key: ACMEKey,
|
||||
signature: String,
|
||||
) -> Result<bool, JWSValidationError> {
|
||||
self.signature = signature;
|
||||
self.verify(key)
|
||||
}
|
||||
|
||||
pub fn sign(&mut self, key: ACMEPrivateKey) -> Result<Self, JWSError> {
|
||||
let to_sign = format!("{}.{}", self.protected, self.payload);
|
||||
|
||||
match key {
|
||||
ACMEPrivateKey::ECDSA(key) => {
|
||||
let digest = sha256(to_sign.as_bytes());
|
||||
let signature = EcdsaSig::sign(&digest, &key)?;
|
||||
|
||||
let r = signature.r().to_vec();
|
||||
let s = signature.s().to_vec();
|
||||
|
||||
let mut v = Vec::with_capacity(r.len() + s.len());
|
||||
let pad = &[0; 32];
|
||||
v.extend_from_slice(
|
||||
&pad.iter()
|
||||
.take(32 - r.len())
|
||||
.map(|c| *c)
|
||||
.collect::<Vec<u8>>(),
|
||||
);
|
||||
v.extend_from_slice(&r);
|
||||
v.extend_from_slice(
|
||||
&pad.iter()
|
||||
.take(32 - s.len())
|
||||
.map(|c| *c)
|
||||
.collect::<Vec<u8>>(),
|
||||
);
|
||||
v.extend_from_slice(&s);
|
||||
|
||||
self.signature = base64::encode_config(v, base64::URL_SAFE_NO_PAD);
|
||||
|
||||
Ok(self.clone())
|
||||
}
|
||||
ACMEPrivateKey::RSA(key) => {
|
||||
let pkey = PKey::from_rsa(key)?;
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), pkey.as_ref())?;
|
||||
signer.update(to_sign.as_bytes())?;
|
||||
|
||||
self.signature =
|
||||
base64::encode_config(signer.sign_to_vec().unwrap(), base64::URL_SAFE_NO_PAD);
|
||||
|
||||
Ok(self.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_db_jwk(&self) -> Result<crate::models::account::JWK, JWSError> {
|
||||
let aph = self.clone().protected()?;
|
||||
if aph.jwk.is_none() {
|
||||
return Err(JWSError::InvalidPublicKey);
|
||||
}
|
||||
|
||||
let jwk = aph.jwk.unwrap();
|
||||
|
||||
Ok(crate::models::account::JWK {
|
||||
nonce_key: make_nonce(crate::models::NONCE_KEY_SIZE),
|
||||
n: jwk.n.clone(),
|
||||
e: jwk.e.clone(),
|
||||
x: jwk.x.clone(),
|
||||
y: jwk.y.clone(),
|
||||
alg: aph.alg.clone(),
|
||||
id: None,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(SystemTime::now()),
|
||||
deleted_at: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
extern crate spectral;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn aph_test_validate() {
|
||||
use super::*;
|
||||
use crate::test::TestService;
|
||||
use spectral::prelude::*;
|
||||
|
||||
let svc = TestService::new("aph_test_validate").await;
|
||||
|
||||
let good_url = Url::parse("https://one/two").unwrap();
|
||||
let bad_url = Url::parse("https://not/one/two").unwrap();
|
||||
|
||||
let validator = crate::acme::PostgresNonceValidator::new(svc.pg.db());
|
||||
|
||||
let kid = Url::parse("http://127.0.0.1:8000/accounts/this_is_a_kid").unwrap();
|
||||
|
||||
let aph = ACMEProtectedHeader::new_kid(kid.clone(), good_url.clone(), "😀".to_string());
|
||||
assert_that!(aph.validate(good_url.clone(), validator.clone()).await)
|
||||
.is_err_containing(ACMEValidationError::NonceDecodeError);
|
||||
|
||||
let aph = ACMEProtectedHeader::new_kid(kid.clone(), good_url.clone(), "".to_string());
|
||||
|
||||
assert_that!(aph.validate(good_url.clone(), validator.clone()).await)
|
||||
.is_err_containing(ACMEValidationError::NonceNotFound);
|
||||
|
||||
let aph = ACMEProtectedHeader::new_kid(
|
||||
kid.clone(),
|
||||
good_url.clone(),
|
||||
validator.make().await.expect("could not insert a nonce"),
|
||||
);
|
||||
assert_that!(aph.validate(good_url.clone(), validator.clone()).await).is_ok();
|
||||
|
||||
assert_that!(
|
||||
ACMEProtectedHeader::new_kid(
|
||||
kid.clone(),
|
||||
good_url.clone(),
|
||||
validator.make().await.expect("could not insert a nonce")
|
||||
)
|
||||
.validate(bad_url.clone(), validator.clone())
|
||||
.await
|
||||
)
|
||||
.is_err_containing(ACMEValidationError::URLNotEqual(
|
||||
bad_url.to_string(),
|
||||
good_url.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jws_validate() {
|
||||
use super::{ACMEProtectedHeader, JWS};
|
||||
use openssl::{ec::EcKey, rsa::Rsa};
|
||||
use serde::Serialize;
|
||||
use spectral::prelude::*;
|
||||
use std::convert::TryInto;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct JWSTest {
|
||||
artist: String,
|
||||
song: String,
|
||||
}
|
||||
|
||||
let payload = JWSTest {
|
||||
artist: "Tone Loc".to_string(),
|
||||
song: "Funky Cold Medina".to_string(),
|
||||
};
|
||||
|
||||
let kid = Url::parse("http://127.0.0.1:8000/accounts/this_is_a_kid").unwrap();
|
||||
|
||||
// we won't be validating the protected headers in this pass so garbage for the nonce is OK
|
||||
let protected = ACMEProtectedHeader::new_kid(
|
||||
kid,
|
||||
Url::parse("http://good.url").unwrap(),
|
||||
"1234".to_string(),
|
||||
);
|
||||
|
||||
// 1000 iterations of this crap because we were seeing some stuff
|
||||
// in parallel becausa I hate waiting
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for _ in 0..10 {
|
||||
let payload = payload.clone();
|
||||
let protected = protected.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
for _ in 0..100 {
|
||||
let mut jws = JWS::new(&protected, &payload);
|
||||
let eckey = EcKey::generate(super::crypto::EC_GROUP.as_ref()).unwrap();
|
||||
|
||||
let signed = jws.sign(super::ACMEPrivateKey::ECDSA(eckey.clone()));
|
||||
assert_that!(signed).is_ok();
|
||||
|
||||
let mut jws = signed.unwrap();
|
||||
let acmekey: &super::ACMEKey = &eckey.public_key().try_into().unwrap();
|
||||
let res = jws.verify(acmekey.clone());
|
||||
assert_that!(res).is_ok();
|
||||
assert_that!(res.unwrap()).is_true();
|
||||
|
||||
let rsa = Rsa::generate(4096).unwrap();
|
||||
let pubkey = Rsa::from_public_components(
|
||||
rsa.n().to_owned().unwrap(),
|
||||
rsa.e().to_owned().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let signed = jws.sign(super::ACMEPrivateKey::RSA(rsa.clone()));
|
||||
assert_that!(signed).is_ok();
|
||||
|
||||
let jws = signed.unwrap();
|
||||
let res = jws.verify(pubkey.try_into().unwrap());
|
||||
assert_that!(res).is_ok();
|
||||
assert_that!(res.unwrap()).is_true();
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwk_into() {
|
||||
use openssl::ec::EcKey;
|
||||
use openssl::rsa::Rsa;
|
||||
use spectral::prelude::*;
|
||||
use std::convert::TryInto;
|
||||
|
||||
let eckey = EcKey::generate(&super::crypto::EC_GROUP).unwrap();
|
||||
let jwk: Result<super::ACMEKey, super::JWSError> = eckey.public_key().try_into();
|
||||
assert_that!(jwk).is_ok();
|
||||
|
||||
let eckey2 = match jwk.unwrap() {
|
||||
super::ACMEKey::ECDSA(key) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
assert_that!(eckey2).is_some();
|
||||
let mut ctx = openssl::bn::BigNumContext::new().unwrap();
|
||||
|
||||
let res = eckey.public_key().eq(
|
||||
&super::crypto::EC_GROUP,
|
||||
eckey2.unwrap().public_key(),
|
||||
&mut ctx,
|
||||
);
|
||||
assert_that!(res).is_ok();
|
||||
assert_that!(res.unwrap()).is_true();
|
||||
|
||||
let rsa = Rsa::generate(4096).unwrap();
|
||||
let pubkey =
|
||||
Rsa::from_public_components(rsa.n().to_owned().unwrap(), rsa.e().to_owned().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let jwk: Result<super::ACMEKey, super::JWSError> = pubkey.clone().try_into();
|
||||
|
||||
assert_that!(jwk).is_ok();
|
||||
|
||||
let rsa2 = match jwk.unwrap() {
|
||||
super::ACMEKey::RSA(key) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
assert_that!(rsa2).is_some();
|
||||
|
||||
assert_that!(pubkey.public_key_to_der().unwrap())
|
||||
.is_equal_to(rsa2.unwrap().public_key_to_der().unwrap());
|
||||
}
|
||||
}
|
165
src/acme/mod.rs
Normal file
165
src/acme/mod.rs
Normal file
|
@ -0,0 +1,165 @@
|
|||
pub mod ca;
|
||||
pub mod challenge;
|
||||
pub mod dns;
|
||||
pub mod handlers;
|
||||
pub mod jose;
|
||||
|
||||
use std::{collections::HashSet, convert::TryFrom, sync::Arc};
|
||||
|
||||
use hyper::Body;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
errors::{
|
||||
db::{LoadError, SaveError},
|
||||
ACMEValidationError,
|
||||
},
|
||||
models::{nonce::Nonce, Postgres, Record},
|
||||
util::make_nonce,
|
||||
};
|
||||
|
||||
use self::dns::DNSName;
|
||||
|
||||
const DEFAULT_NONCE_SIZE: usize = 16;
|
||||
const ACME_CONTENT_TYPE: &str = "application/jose+json";
|
||||
|
||||
lazy_static! {
|
||||
/// List of supported algorithms, with the ACME preferred one first. This is relied on in this
|
||||
/// code.
|
||||
pub static ref ACME_EXPECTED_ALGS: [String; 2] = ["ES256".to_string(), "RS256".to_string()];
|
||||
}
|
||||
|
||||
/// A Result<> that calls can return to trampoline through ratpack handlers swiftly by triggering HTTP
|
||||
/// "problem documents" (RFC7807) to be returned immediately from ratpack's routing framework.
|
||||
#[must_use]
|
||||
pub enum ACMEResult {
|
||||
Ok(hyper::Response<Body>),
|
||||
Err(crate::errors::Error),
|
||||
}
|
||||
|
||||
impl Into<Result<hyper::Response<Body>, serde_json::Error>> for ACMEResult {
|
||||
fn into(self) -> Result<hyper::Response<Body>, serde_json::Error> {
|
||||
match self {
|
||||
ACMEResult::Ok(res) => Ok(res),
|
||||
ACMEResult::Err(e) => {
|
||||
return Ok(hyper::Response::builder()
|
||||
.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&e)?))
|
||||
.unwrap())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::errors::Error> for ACMEResult {
|
||||
fn from(e: crate::errors::Error) -> Self {
|
||||
return ACMEResult::Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the notion of an "identifier" from the ACME specification.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")] // NOTE: other identifier types as they are added may break this
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum ACMEIdentifier {
|
||||
DNS(dns::DNSName), // NOTE: DNS names cannot be wildcards.
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ACMEIdentifier {
|
||||
type Error = LoadError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match DNSName::from_str(&value) {
|
||||
Ok(x) => Ok(ACMEIdentifier::DNS(x)),
|
||||
Err(e) => Err(LoadError::Generic(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ACMEIdentifier {
|
||||
pub fn to_string(self) -> String {
|
||||
match self {
|
||||
ACMEIdentifier::DNS(name) => name.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait NonceValidator {
|
||||
/// This function must mutate the underlying storage to prune the nonce it's validating after a
|
||||
/// successful fetch. One may use ACMEValidationError::NonceFetchError to specify errors with
|
||||
/// fetching the Nonce. Likewise, ACMEValidationError::NonceNotFound is expected to be returned
|
||||
/// when the nonce cannot be located (validation error).
|
||||
async fn validate(&self, nonce: &str) -> Result<(), ACMEValidationError>;
|
||||
|
||||
/// This function is expected to always make & store a new nonce; if it fails to add because it already
|
||||
/// exists, it should return error.
|
||||
async fn make(&self) -> Result<String, SaveError>;
|
||||
}
|
||||
|
||||
/// Defines a basic (very basic) Nonce validation system
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SetValidator(Arc<Mutex<HashSet<String>>>);
|
||||
|
||||
impl Default for SetValidator {
|
||||
fn default() -> Self {
|
||||
SetValidator(Arc::new(Mutex::new(HashSet::new())))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NonceValidator for SetValidator {
|
||||
async fn validate(&self, nonce: &str) -> Result<(), ACMEValidationError> {
|
||||
if self.0.lock().await.remove(nonce) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ACMEValidationError::NonceNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
async fn make(&self) -> Result<String, SaveError> {
|
||||
let nonce = make_nonce(None);
|
||||
|
||||
if !self.0.lock().await.insert(nonce.clone()) {
|
||||
return Err(SaveError::Generic("could not persist nonce".to_string()));
|
||||
}
|
||||
|
||||
Ok(nonce)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PostgresNonceValidator(crate::models::Postgres);
|
||||
|
||||
impl PostgresNonceValidator {
|
||||
pub fn new(pg: Postgres) -> Self {
|
||||
Self(pg)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NonceValidator for PostgresNonceValidator {
|
||||
async fn validate(&self, nonce: &str) -> Result<(), ACMEValidationError> {
|
||||
let nonce = match Nonce::find(nonce.to_string(), self.0.clone()).await {
|
||||
Ok(nonce) => nonce,
|
||||
Err(_) => return Err(ACMEValidationError::NonceNotFound),
|
||||
};
|
||||
|
||||
if let Err(_) = nonce.delete(self.0.clone()).await {
|
||||
return Err(ACMEValidationError::NonceNotFound);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn make(&self) -> Result<String, SaveError> {
|
||||
let mut nonce = Nonce::new();
|
||||
nonce.create(self.0.clone()).await?;
|
||||
Ok(nonce.id().unwrap().unwrap())
|
||||
}
|
||||
}
|
146
src/acmed.rs
Normal file
146
src/acmed.rs
Normal file
|
@ -0,0 +1,146 @@
|
|||
use std::{
|
||||
io::Write,
|
||||
ops::Add,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use openssl::{
|
||||
error::ErrorStack,
|
||||
pkey::{PKey, Private},
|
||||
rsa::Rsa,
|
||||
x509::{X509Extension, X509Name, X509Req},
|
||||
};
|
||||
|
||||
use coyote::{
|
||||
acme::{
|
||||
ca::{CACollector, CA},
|
||||
challenge::Challenger,
|
||||
handlers::{configure_routes, ServiceState},
|
||||
PostgresNonceValidator,
|
||||
},
|
||||
models::Postgres,
|
||||
};
|
||||
|
||||
use ratpack::prelude::*;
|
||||
|
||||
const CHALLENGE_EXPIRATION: i64 = 600;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), ServerError> {
|
||||
// set HOSTNAME in your environment to something your webserver or certbot can hit; otherwise
|
||||
// it will be 'localhost'. a cert will be generated with this name to serve the service with.
|
||||
// This is really important.
|
||||
let dnsname = &std::env::var("HOSTNAME").unwrap_or("localhost".to_string());
|
||||
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.init();
|
||||
//
|
||||
// to start a database to work with me:
|
||||
//
|
||||
// make postgres
|
||||
//
|
||||
let pg = Postgres::new("host=localhost dbname=coyote user=postgres", 10)
|
||||
.await
|
||||
.unwrap();
|
||||
pg.migrate().await.unwrap();
|
||||
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(CHALLENGE_EXPIRATION)));
|
||||
let ca = CACollector::new(Duration::MAX);
|
||||
|
||||
let pg2 = pg.clone();
|
||||
let c2 = c.clone();
|
||||
|
||||
// FIXME probably need something magical with signals here to manage shutdown that I don't want to think about yet
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
// FIXME whitelist all challenge requests. This is not how ACME is supposed to work. You have to write this.
|
||||
c2.tick(|_c| Some(())).await;
|
||||
// NOTE this will explode violently if it unwraps to error, e.g. if the db goes down.
|
||||
c2.reconcile(pg2.clone()).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::new(1, 0)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut ca2 = ca.clone();
|
||||
let (csr, key) = generate_csr(dnsname)?;
|
||||
|
||||
let test_ca = CA::new_test_ca().unwrap();
|
||||
let cert = test_ca.generate_and_sign_cert(
|
||||
csr,
|
||||
SystemTime::now(),
|
||||
SystemTime::now().add(Duration::from_secs(365 * 24 * 60 * 60)),
|
||||
)?;
|
||||
|
||||
let test_ca2 = test_ca.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// after CA generation, write out the key and certificate
|
||||
let mut buf = std::fs::File::create("ca.key").unwrap();
|
||||
let private = test_ca
|
||||
.clone()
|
||||
.private_key()
|
||||
.private_key_to_pem_pkcs8()
|
||||
.unwrap();
|
||||
buf.write(&private).unwrap();
|
||||
|
||||
let mut buf = std::fs::File::create("ca.pem").unwrap();
|
||||
let cert = test_ca.clone().certificate().to_pem().unwrap();
|
||||
buf.write(&cert).unwrap();
|
||||
|
||||
ca2.spawn_collector(|| -> Result<CA, ErrorStack> { Ok(test_ca.clone()) })
|
||||
.await
|
||||
});
|
||||
|
||||
let validator = PostgresNonceValidator::new(pg.clone());
|
||||
let ss = ServiceState::new(
|
||||
format!("https://{}:8000", dnsname),
|
||||
pg.clone(),
|
||||
c,
|
||||
ca,
|
||||
validator,
|
||||
)?;
|
||||
let mut app = App::with_state(ss);
|
||||
|
||||
configure_routes(&mut app, None);
|
||||
|
||||
let key = key.private_key_to_der()?;
|
||||
|
||||
let config = rustls::ServerConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![
|
||||
rustls::Certificate(cert.to_der()?),
|
||||
rustls::Certificate(test_ca2.certificate().to_der()?),
|
||||
],
|
||||
rustls::PrivateKey(key),
|
||||
)?;
|
||||
|
||||
Ok(app.serve_tls("0.0.0.0:8000", config).await?)
|
||||
}
|
||||
|
||||
fn generate_csr(dnsname: &str) -> Result<(X509Req, Rsa<Private>), ErrorStack> {
|
||||
log::info!("hostname: {}", dnsname);
|
||||
let mut namebuilder = X509Name::builder().unwrap();
|
||||
namebuilder.append_entry_by_text("CN", dnsname).unwrap();
|
||||
let mut req = X509Req::builder().unwrap();
|
||||
req.set_subject_name(&namebuilder.build()).unwrap();
|
||||
let mut extensions = openssl::stack::Stack::new()?;
|
||||
extensions.push(X509Extension::new(
|
||||
None,
|
||||
Some(&req.x509v3_context(None)),
|
||||
"subjectAltName",
|
||||
&format!("DNS:{}", dnsname),
|
||||
)?)?;
|
||||
req.add_extensions(&extensions)?;
|
||||
req.set_version(2)?;
|
||||
|
||||
let key = Rsa::generate(4096).unwrap();
|
||||
// FIXME there has to be a much better way of doing this!
|
||||
let pubkey = PKey::public_key_from_pem(&key.public_key_to_pem().unwrap()).unwrap();
|
||||
|
||||
req.set_pubkey(&pubkey).unwrap();
|
||||
Ok((req.build(), key))
|
||||
}
|
80
src/errors/acme.rs
Normal file
80
src/errors/acme.rs
Normal file
|
@ -0,0 +1,80 @@
|
|||
use super::ACMEValidationError;
|
||||
use openssl::error::ErrorStack;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Error, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JWSValidationError {
|
||||
#[error("general JWS handshake error: {0:?}")]
|
||||
General(JWSError),
|
||||
#[error("base64 error decoding signature")]
|
||||
SignatureDecode,
|
||||
#[error("openssl internal error managing signature: {0}")]
|
||||
OpenSSL(String),
|
||||
#[error("error validating ACME payload: {0}")]
|
||||
ACMEValidationError(ACMEValidationError),
|
||||
}
|
||||
|
||||
impl From<JWSError> for JWSValidationError {
|
||||
fn from(e: JWSError) -> Self {
|
||||
Self::General(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for JWSValidationError {
|
||||
fn from(_: base64::DecodeError) -> Self {
|
||||
Self::SignatureDecode
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ErrorStack> for JWSValidationError {
|
||||
fn from(es: ErrorStack) -> Self {
|
||||
let errors = es
|
||||
.errors()
|
||||
.into_iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
Self::OpenSSL(errors.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Error, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JWSError {
|
||||
#[error("openssl error: {0}")]
|
||||
OpenSSL(String),
|
||||
#[error("error encoding JWS component: {0}")]
|
||||
Encode(String),
|
||||
#[error("serde error decoding JSON: {0}")]
|
||||
JSONDecode(String),
|
||||
#[error("base64 error decoding payload")]
|
||||
PayloadDecode,
|
||||
#[error("invalid public key")]
|
||||
InvalidPublicKey,
|
||||
#[error("missing JWS")]
|
||||
Missing,
|
||||
#[error("validation failed")]
|
||||
ValidationFailed,
|
||||
}
|
||||
|
||||
impl From<ErrorStack> for JWSError {
|
||||
fn from(es: ErrorStack) -> Self {
|
||||
let errors = es
|
||||
.errors()
|
||||
.into_iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
Self::OpenSSL(errors.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for JWSError {
|
||||
fn from(_: base64::DecodeError) -> Self {
|
||||
Self::PayloadDecode
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for JWSError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::JSONDecode(e.to_string())
|
||||
}
|
||||
}
|
132
src/errors/db.rs
Normal file
132
src/errors/db.rs
Normal file
|
@ -0,0 +1,132 @@
|
|||
use deadpool_postgres::PoolError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConnectionError {
|
||||
#[error("Unspecified connection error: {0}")]
|
||||
Generic(String),
|
||||
#[error("Database error: {0}")]
|
||||
DB(tokio_postgres::Error),
|
||||
#[error("Connection pool error: {0}")]
|
||||
Pool(PoolError),
|
||||
#[error("Migration run error: {0}")]
|
||||
Migrations(MigrationError),
|
||||
}
|
||||
|
||||
impl From<tokio_postgres::Error> for ConnectionError {
|
||||
fn from(tp: tokio_postgres::Error) -> Self {
|
||||
Self::DB(tp)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MigrationError> for ConnectionError {
|
||||
fn from(me: MigrationError) -> Self {
|
||||
Self::Migrations(me)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PoolError> for ConnectionError {
|
||||
fn from(pe: PoolError) -> Self {
|
||||
Self::Pool(pe)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SaveError {
|
||||
#[error("error while saving: {0}")]
|
||||
Generic(String),
|
||||
#[error("database error while saving: {0}")]
|
||||
DBError(tokio_postgres::Error),
|
||||
#[error("error while encoding json: {0}")]
|
||||
JSONCodecError(String),
|
||||
#[error("error while refreshing results after write: {0}")]
|
||||
ReloadError(LoadError),
|
||||
#[error("db connection error: {0}")]
|
||||
ConnectionError(ConnectionError),
|
||||
}
|
||||
|
||||
impl From<ConnectionError> for SaveError {
|
||||
fn from(e: ConnectionError) -> Self {
|
||||
Self::ConnectionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoadError> for SaveError {
|
||||
fn from(e: LoadError) -> Self {
|
||||
return Self::JSONCodecError(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for SaveError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
return Self::JSONCodecError(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio_postgres::Error> for SaveError {
|
||||
fn from(tp: tokio_postgres::Error) -> Self {
|
||||
Self::DBError(tp)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoadError {
|
||||
#[error("error while loading: {0}")]
|
||||
Generic(String),
|
||||
#[error("database error while loading: {0}")]
|
||||
DBError(tokio_postgres::Error),
|
||||
#[error("error while decoding json: {0}")]
|
||||
JSONCodecError(String),
|
||||
#[error("error while connecting to database: {0}")]
|
||||
ConnectionError(ConnectionError),
|
||||
#[error("invalid token in enum translation")]
|
||||
InvalidEnum,
|
||||
#[error("key not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl From<ConnectionError> for LoadError {
|
||||
fn from(ce: ConnectionError) -> Self {
|
||||
Self::ConnectionError(ce)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for LoadError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
return Self::JSONCodecError(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio_postgres::Error> for LoadError {
|
||||
fn from(e: tokio_postgres::Error) -> Self {
|
||||
Self::DBError(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MigrationError {
|
||||
#[error("Unspecified migration error: {0}")]
|
||||
Generic(String),
|
||||
#[error("Database error: {0}")]
|
||||
DBError(tokio_postgres::Error),
|
||||
#[error("migration error: {0}")]
|
||||
Error(refinery::Error),
|
||||
}
|
||||
|
||||
impl From<tokio_postgres::Error> for MigrationError {
|
||||
fn from(e: tokio_postgres::Error) -> Self {
|
||||
Self::DBError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<refinery::Error> for MigrationError {
|
||||
fn from(e: refinery::Error) -> Self {
|
||||
Self::Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConnectionError> for MigrationError {
|
||||
fn from(e: ConnectionError) -> Self {
|
||||
Self::Generic(e.to_string())
|
||||
}
|
||||
}
|
495
src/errors/mod.rs
Normal file
495
src/errors/mod.rs
Normal file
|
@ -0,0 +1,495 @@
|
|||
use crate::acme::ACMEIdentifier;
|
||||
use http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use self::db::LoadError;
|
||||
|
||||
pub mod acme;
|
||||
pub mod db;
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum HandlerError {
|
||||
#[error("generic handler error: {0}")]
|
||||
Generic(String),
|
||||
#[error("ACME validation error: {0}")]
|
||||
ACMEValidationError(ACMEValidationError),
|
||||
}
|
||||
|
||||
impl From<ACMEValidationError> for HandlerError {
|
||||
fn from(ave: ACMEValidationError) -> Self {
|
||||
HandlerError::ACMEValidationError(ave)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for HandlerError {
|
||||
fn from(upe: url::ParseError) -> Self {
|
||||
HandlerError::Generic(upe.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HandlerError> for Error {
|
||||
fn from(he: HandlerError) -> Self {
|
||||
match he {
|
||||
HandlerError::Generic(he) => Error::new(RFCError::Malformed, &he),
|
||||
HandlerError::ACMEValidationError(ave) => Error::from(ave),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ACMEValidationError {
|
||||
#[error("No key provided")]
|
||||
NoKeyProvided,
|
||||
|
||||
#[error("url {0} not equal to protected header value: {1}")]
|
||||
URLNotEqual(String, String),
|
||||
|
||||
#[error("alg must be {0}, not {1}")]
|
||||
AlgNotEqual(String, String),
|
||||
|
||||
#[error("nonce decode error")]
|
||||
NonceDecodeError,
|
||||
|
||||
#[error("could not validate nonce")]
|
||||
NonceNotFound,
|
||||
|
||||
#[error("Nonce fetching error: {0}")]
|
||||
NonceFetchError(String),
|
||||
|
||||
#[error("Other error: {0}")]
|
||||
Other(String),
|
||||
|
||||
#[error("invalid signature")]
|
||||
InvalidSignature,
|
||||
|
||||
#[error("invalid request")]
|
||||
InvalidRequest,
|
||||
|
||||
#[error("account does not exist")]
|
||||
AccountDoesNotExist,
|
||||
}
|
||||
|
||||
impl ratpack::ToStatus for Error {
|
||||
fn to_status(&self) -> ratpack::Error {
|
||||
match self.error_type {
|
||||
RFCError::BadNonce | RFCError::BadPublicKey | RFCError::BadSignatureAlgorithm => {
|
||||
ratpack::Error::StatusCode(StatusCode::BAD_REQUEST, self.detail.clone())
|
||||
}
|
||||
_ => ratpack::Error::StatusCode(StatusCode::FORBIDDEN, self.detail.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ratpack::ToStatus for ACMEValidationError {
|
||||
fn to_status(&self) -> ratpack::Error {
|
||||
let e: Error = self.clone().into();
|
||||
e.to_status()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ACMEValidationError> for Error {
|
||||
fn from(ave: ACMEValidationError) -> Self {
|
||||
match ave.clone() {
|
||||
ACMEValidationError::NoKeyProvided
|
||||
| ACMEValidationError::NonceDecodeError
|
||||
| ACMEValidationError::InvalidRequest => {
|
||||
Self::new(RFCError::Malformed, &ave.to_string())
|
||||
}
|
||||
ACMEValidationError::Other(_)
|
||||
| ACMEValidationError::NonceNotFound
|
||||
| ACMEValidationError::NonceFetchError(_)
|
||||
| ACMEValidationError::URLNotEqual(_, _)
|
||||
| ACMEValidationError::InvalidSignature => {
|
||||
Self::new(RFCError::Unauthorized, &ave.to_string())
|
||||
}
|
||||
ACMEValidationError::AlgNotEqual(_, _) => {
|
||||
Self::new(RFCError::BadSignatureAlgorithm, &ave.to_string())
|
||||
}
|
||||
ACMEValidationError::AccountDoesNotExist => {
|
||||
Self::new(RFCError::AccountDoesNotExist, &ave.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for LoadError {
|
||||
fn from(u: url::ParseError) -> Self {
|
||||
LoadError::Generic(u.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl ratpack::ToStatus for acme::JWSError {
|
||||
fn to_status(&self) -> ratpack::Error {
|
||||
let e: Error = self.clone().into();
|
||||
e.to_status()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<acme::JWSError> for Error {
|
||||
fn from(jws: acme::JWSError) -> Self {
|
||||
match jws {
|
||||
acme::JWSError::InvalidPublicKey => Self::new(RFCError::BadPublicKey, &jws.to_string()),
|
||||
acme::JWSError::Missing => Self::new(RFCError::Malformed, &jws.to_string()),
|
||||
_ => Self::new(
|
||||
RFCError::Malformed,
|
||||
"malformed content during generation or parsing of JWS envelope",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ratpack::ToStatus for acme::JWSValidationError {
|
||||
fn to_status(&self) -> ratpack::Error {
|
||||
let e: Error = self.clone().into();
|
||||
e.to_status()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<acme::JWSValidationError> for Error {
|
||||
fn from(jve: acme::JWSValidationError) -> Self {
|
||||
match jve {
|
||||
acme::JWSValidationError::ACMEValidationError(e) => e.into(),
|
||||
acme::JWSValidationError::General(e) => e.into(),
|
||||
_ => Self::new(
|
||||
RFCError::Malformed,
|
||||
"malformed content during generation or parsing of JWS envelope",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All error return values inherit from the URN below.
|
||||
const ACME_URN_NAMESPACE: &str = "urn:ietf:params:acme:error:";
|
||||
|
||||
/// RFCError is for reporting errors conformant to the ACME RFC.
|
||||
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
|
||||
pub enum RFCError {
|
||||
AccountDoesNotExist,
|
||||
AlreadyRevoked,
|
||||
BadCSR,
|
||||
BadNonce,
|
||||
BadPublicKey,
|
||||
BadRevocationReason,
|
||||
BadSignatureAlgorithm,
|
||||
CAA,
|
||||
Compound,
|
||||
Connection,
|
||||
DNS,
|
||||
ExternalAccountRequired,
|
||||
IncorrectResponse,
|
||||
InvalidContact,
|
||||
Malformed,
|
||||
OrderNotReady,
|
||||
RateLimited,
|
||||
RejectedIdentifier,
|
||||
ServerInterval,
|
||||
TLS,
|
||||
Unauthorized,
|
||||
UnsupportedContact,
|
||||
UnsupportedIdentifier,
|
||||
UserActionRequired,
|
||||
}
|
||||
|
||||
impl RFCError {
|
||||
/// to_string converts an enum error into the string you should return to the client.
|
||||
fn to_string(self) -> String {
|
||||
ACME_URN_NAMESPACE.to_string()
|
||||
+ match self {
|
||||
RFCError::AccountDoesNotExist => "accountDoesNotExist",
|
||||
RFCError::AlreadyRevoked => "alreadyRevoked",
|
||||
RFCError::BadCSR => "badCSR",
|
||||
RFCError::BadNonce => "badNonce",
|
||||
RFCError::BadPublicKey => "badPublicKey",
|
||||
RFCError::BadRevocationReason => "badRevocationReason",
|
||||
RFCError::BadSignatureAlgorithm => "badSignatureAlgorithm",
|
||||
RFCError::CAA => "caa",
|
||||
RFCError::Compound => "compound",
|
||||
RFCError::Connection => "connection",
|
||||
RFCError::DNS => "dns",
|
||||
RFCError::ExternalAccountRequired => "externalAccountRequired",
|
||||
RFCError::IncorrectResponse => "incorrectResponse",
|
||||
RFCError::InvalidContact => "invalidContact",
|
||||
RFCError::Malformed => "malformed",
|
||||
RFCError::OrderNotReady => "orderNotReady",
|
||||
RFCError::RateLimited => "rateLimited",
|
||||
RFCError::RejectedIdentifier => "rejectedIdentifier",
|
||||
RFCError::ServerInterval => "serverInterval",
|
||||
RFCError::TLS => "tls",
|
||||
RFCError::Unauthorized => "unauthorized",
|
||||
RFCError::UnsupportedContact => "unsupportedContact",
|
||||
RFCError::UnsupportedIdentifier => "unsupportedIdentifier",
|
||||
RFCError::UserActionRequired => "userActionRequired",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ValidationError are for errors in validation of the .. error. See Error::validate for more
|
||||
/// information.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ValidationError {
|
||||
/// MissingContext is returned when details about a specific error are required, and missing
|
||||
MissingContext(&'static str),
|
||||
/// This error is returned when critical missing detail about the request's subject is missing
|
||||
MissingTarget,
|
||||
/// This error is returned when the identifier's subject is parsed invalid.
|
||||
InvalidIdentifier,
|
||||
}
|
||||
|
||||
/// Error is the error returned to the client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Error {
|
||||
#[serde(rename = "type")]
|
||||
error_type: RFCError,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
subproblems: Option<Vec<Error>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
identifier: Option<ACMEIdentifier>,
|
||||
detail: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
external_account_binding: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
user_action_instance: Option<String>,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// new constructs a new error struct. Use methods like subproblems() and identifier() to build
|
||||
/// a fully validate()able struct from parts.
|
||||
pub fn new(error_type: RFCError, detail: &str) -> Self {
|
||||
Self {
|
||||
error_type,
|
||||
detail: detail.to_string(),
|
||||
subproblems: None,
|
||||
identifier: None,
|
||||
user_action_instance: None,
|
||||
external_account_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// validate the error. For an error to be valid it must:
|
||||
/// - have an identifier, or subproblems, and all its subproblems must validate.
|
||||
/// - For RFCError::ExternalAccountRequired, external_account_binding() must be called to set data.
|
||||
/// - For RFCError::UserActionRequired, user_action_instance() must be called to set data.
|
||||
/// - For RFCError::Compound, subproblems must exist.
|
||||
pub fn validate(self) -> Result<(), ValidationError> {
|
||||
match self.error_type {
|
||||
RFCError::ExternalAccountRequired => {
|
||||
if self.external_account_binding.is_none() {
|
||||
return Err(ValidationError::MissingContext(
|
||||
"missing external_account_binding",
|
||||
));
|
||||
}
|
||||
}
|
||||
RFCError::UserActionRequired => {
|
||||
if self.user_action_instance.is_none() {
|
||||
return Err(ValidationError::MissingContext(
|
||||
"missing user_action_instance",
|
||||
));
|
||||
}
|
||||
}
|
||||
RFCError::Compound => {
|
||||
if self.subproblems.is_none() {
|
||||
return Err(ValidationError::MissingContext(
|
||||
"missing subproblems in compound error",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let sp = self.subproblems;
|
||||
|
||||
if self.identifier.is_none() && sp.is_none()
|
||||
|| (sp.is_some() && sp.clone().unwrap().is_empty())
|
||||
{
|
||||
return Err(ValidationError::MissingTarget);
|
||||
}
|
||||
|
||||
if sp.is_some() {
|
||||
for prob in sp.unwrap() {
|
||||
if let Err(r) = prob.validate() {
|
||||
return Err(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.identifier.is_some() {
|
||||
match self.identifier.unwrap() {
|
||||
ACMEIdentifier::DNS(name) => {
|
||||
if name.0.is_empty()
|
||||
|| name.0.is_root()
|
||||
|| name.0.is_localhost()
|
||||
|| name.0.is_wildcard()
|
||||
// no TLDs
|
||||
|| name.0.num_labels() < 2
|
||||
{
|
||||
return Err(ValidationError::InvalidIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn subproblems(mut self, problems: Vec<Error>) -> Self {
|
||||
self.subproblems = Some(problems);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn identifier(mut self, identifier: ACMEIdentifier) -> Self {
|
||||
self.identifier = Some(identifier);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn user_action_instance(mut self, user_action_instance: String) -> Self {
|
||||
self.user_action_instance = Some(user_action_instance);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn external_account_binding(mut self, external_account_binding: String) -> Self {
|
||||
self.external_account_binding = Some(external_account_binding);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_validate() {
|
||||
use super::{Error, RFCError, ValidationError};
|
||||
use crate::acme::{dns::DNSName, ACMEIdentifier};
|
||||
use spectral::prelude::*;
|
||||
|
||||
assert_that!(Error::new(RFCError::AccountDoesNotExist, "these are the details").validate())
|
||||
.is_err_containing(ValidationError::MissingTarget);
|
||||
|
||||
assert_that!(
|
||||
Error::new(RFCError::AccountDoesNotExist, "these are the details")
|
||||
.identifier(ACMEIdentifier::DNS(DNSName::from_str("foo.com").unwrap()))
|
||||
.validate()
|
||||
)
|
||||
.is_ok();
|
||||
|
||||
assert_that!(
|
||||
Error::new(RFCError::AccountDoesNotExist, "these are the details")
|
||||
.subproblems(vec![])
|
||||
.validate()
|
||||
)
|
||||
.is_err_containing(ValidationError::MissingTarget);
|
||||
|
||||
assert_that!(
|
||||
Error::new(RFCError::AccountDoesNotExist, "these are the details")
|
||||
.subproblems(vec![Error::new(
|
||||
RFCError::AccountDoesNotExist,
|
||||
"these are the details"
|
||||
)
|
||||
.identifier(ACMEIdentifier::DNS(DNSName::from_str("foo.com").unwrap()))])
|
||||
.validate()
|
||||
)
|
||||
.is_ok();
|
||||
|
||||
let bad_names = vec!["*.foo.com", ".", "yodawg", "localhost"];
|
||||
|
||||
for name in bad_names {
|
||||
assert_that!(
|
||||
Error::new(RFCError::AccountDoesNotExist, "these are the details")
|
||||
.subproblems(vec![Error::new(
|
||||
RFCError::AccountDoesNotExist,
|
||||
"these are the details"
|
||||
)
|
||||
.identifier(ACMEIdentifier::DNS(DNSName::from_str(name).unwrap()))])
|
||||
.validate()
|
||||
)
|
||||
.named(name)
|
||||
.is_err_containing(ValidationError::InvalidIdentifier);
|
||||
}
|
||||
|
||||
assert_that!(
|
||||
Error::new(RFCError::AccountDoesNotExist, "these are the details")
|
||||
.subproblems(vec![Error::new(
|
||||
RFCError::AccountDoesNotExist,
|
||||
"these are the details"
|
||||
)])
|
||||
.validate()
|
||||
)
|
||||
.is_err_containing(ValidationError::MissingTarget);
|
||||
|
||||
assert_that!(
|
||||
Error::new(RFCError::ExternalAccountRequired, "these are the details").validate()
|
||||
)
|
||||
.is_err_containing(ValidationError::MissingContext(
|
||||
"missing external_account_binding",
|
||||
));
|
||||
|
||||
assert_that!(Error::new(RFCError::UserActionRequired, "these are the details").validate())
|
||||
.is_err_containing(ValidationError::MissingContext(
|
||||
"missing user_action_instance",
|
||||
));
|
||||
|
||||
assert_that!(Error::new(RFCError::Compound, "these are the details").validate())
|
||||
.is_err_containing(ValidationError::MissingContext(
|
||||
"missing subproblems in compound error",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_string() {
|
||||
use super::RFCError;
|
||||
use spectral::prelude::*;
|
||||
|
||||
assert_that!(RFCError::AccountDoesNotExist.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:accountDoesNotExist".to_string());
|
||||
assert_that!(RFCError::AlreadyRevoked.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:alreadyRevoked".to_string());
|
||||
assert_that!(RFCError::BadCSR.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:badCSR".to_string());
|
||||
assert_that!(RFCError::BadNonce.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:badNonce".to_string());
|
||||
assert_that!(RFCError::BadPublicKey.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:badPublicKey".to_string());
|
||||
assert_that!(RFCError::BadRevocationReason.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:badRevocationReason".to_string());
|
||||
assert_that!(RFCError::BadSignatureAlgorithm.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:badSignatureAlgorithm".to_string());
|
||||
assert_that!(RFCError::CAA.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:caa".to_string());
|
||||
assert_that!(RFCError::Compound.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:compound".to_string());
|
||||
assert_that!(RFCError::Connection.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:connection".to_string());
|
||||
assert_that!(RFCError::DNS.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:dns".to_string());
|
||||
assert_that!(RFCError::ExternalAccountRequired.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:externalAccountRequired".to_string());
|
||||
assert_that!(RFCError::IncorrectResponse.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:incorrectResponse".to_string());
|
||||
assert_that!(RFCError::InvalidContact.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:invalidContact".to_string());
|
||||
assert_that!(RFCError::Malformed.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:malformed".to_string());
|
||||
assert_that!(RFCError::OrderNotReady.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:orderNotReady".to_string());
|
||||
assert_that!(RFCError::RateLimited.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:rateLimited".to_string());
|
||||
assert_that!(RFCError::RejectedIdentifier.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:rejectedIdentifier".to_string());
|
||||
assert_that!(RFCError::ServerInterval.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:serverInterval".to_string());
|
||||
assert_that!(RFCError::TLS.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:tls".to_string());
|
||||
assert_that!(RFCError::Unauthorized.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:unauthorized".to_string());
|
||||
assert_that!(RFCError::UnsupportedContact.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:unsupportedContact".to_string());
|
||||
assert_that!(RFCError::UnsupportedIdentifier.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:unsupportedIdentifier".to_string());
|
||||
assert_that!(RFCError::UserActionRequired.to_string())
|
||||
.is_equal_to("urn:ietf:params:acme:error:userActionRequired".to_string());
|
||||
}
|
||||
}
|
6
src/lib.rs
Normal file
6
src/lib.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
#![allow(dead_code)]
|
||||
pub mod acme;
|
||||
pub mod errors;
|
||||
pub mod models;
|
||||
pub mod test;
|
||||
pub(crate) mod util;
|
635
src/models/account.rs
Normal file
635
src/models/account.rs
Normal file
|
@ -0,0 +1,635 @@
|
|||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_postgres::{Row, Transaction};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
acme::{handlers::account::NewAccount, jose},
|
||||
errors::acme::JWSError,
|
||||
util::make_nonce,
|
||||
};
|
||||
|
||||
use super::{LoadError, Postgres, Record, SaveError};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Account {
|
||||
pub id: Option<i32>,
|
||||
jwk_id: i32,
|
||||
orders_nonce: String,
|
||||
contacts: Vec<String>,
|
||||
created_at: chrono::DateTime<chrono::Local>,
|
||||
deleted_at: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
pub(crate) fn new_accounts(
|
||||
account: NewAccount,
|
||||
jwk: JWK,
|
||||
_db: Postgres,
|
||||
) -> Result<Account, LoadError> {
|
||||
let jwk_id = jwk.id()?;
|
||||
|
||||
if jwk_id.is_none() {
|
||||
return Err(LoadError::Generic(
|
||||
"missing JWK in account save".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let contacts = account.contacts();
|
||||
let contacts = contacts.unwrap();
|
||||
let jwk_id = jwk_id.unwrap();
|
||||
|
||||
Ok(Account::new(
|
||||
jwk_id,
|
||||
contacts
|
||||
.iter()
|
||||
.map(|c| c.to_owned().into())
|
||||
.collect::<Vec<String>>(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_contacts_for_account(
|
||||
id: i32,
|
||||
tx: &Transaction<'_>,
|
||||
) -> Result<Vec<String>, LoadError> {
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
let rows = tx
|
||||
.query("select contact from contacts where account_id=$1", &[&id])
|
||||
.await?;
|
||||
for row in rows {
|
||||
contacts.push(row.get("contact"));
|
||||
}
|
||||
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn new(jwk_id: i32, contacts: Vec<String>) -> Self {
|
||||
Self {
|
||||
jwk_id,
|
||||
contacts,
|
||||
orders_nonce: make_nonce(super::NONCE_KEY_SIZE),
|
||||
id: None,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now()),
|
||||
deleted_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_kid(jwk_id: i32, db: Postgres) -> Result<Self, LoadError> {
|
||||
let mut lockeddb = db.client().await?;
|
||||
let tx = lockeddb.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one("select * from accounts where jwk_id=$1", &[&jwk_id])
|
||||
.await?;
|
||||
|
||||
Self::new_from_row(&res, &tx).await
|
||||
}
|
||||
|
||||
pub async fn find_deleted(id: i32, db: Postgres) -> Result<Self, LoadError> {
|
||||
let mut lockeddb = db.client().await?;
|
||||
let tx = lockeddb.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one("select * from accounts where id=$1", &[&id])
|
||||
.await?;
|
||||
|
||||
Self::new_from_row(&res, &tx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Record<i32> for Account {
|
||||
async fn new_from_row(row: &Row, tx: &Transaction<'_>) -> Result<Self, LoadError> {
|
||||
Ok(Self {
|
||||
id: Some(row.get("id")),
|
||||
jwk_id: row.get("jwk_id"),
|
||||
orders_nonce: row.get("orders_nonce"),
|
||||
contacts: get_contacts_for_account(row.get("id"), tx).await?,
|
||||
created_at: row.get("created_at"),
|
||||
deleted_at: row.get("deleted_at"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn find(id: i32, db: Postgres) -> Result<Self, LoadError> {
|
||||
let mut lockeddb = db.client().await?;
|
||||
let tx = lockeddb.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one(
|
||||
"select * from accounts where id=$1 and deleted_at is null",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::new_from_row(&res, &tx).await
|
||||
}
|
||||
|
||||
fn id(&self) -> Result<Option<i32>, LoadError> {
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
async fn create(&mut self, db: Postgres) -> Result<i32, SaveError> {
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one(
|
||||
"
|
||||
insert into accounts (jwk_id, orders_nonce) values ($1, $2)
|
||||
returning id, created_at
|
||||
",
|
||||
&[&self.jwk_id, &self.orders_nonce],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id = res.get("id");
|
||||
let created_at = res.get("created_at");
|
||||
|
||||
self.id = Some(id);
|
||||
self.created_at = created_at;
|
||||
|
||||
for contact in &self.contacts {
|
||||
tx.query_one(
|
||||
"
|
||||
insert into contacts (account_id, contact) values ($1, $2)
|
||||
returning id, created_at
|
||||
",
|
||||
&[&id, &contact],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
async fn delete(&self, db: Postgres) -> Result<(), SaveError> {
|
||||
if self.id.is_none() {
|
||||
return Err(SaveError::Generic(
|
||||
"this JWK record was never saved".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
let res = tx
|
||||
.execute(
|
||||
"update accounts set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null",
|
||||
&[&self.id.unwrap()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if res == 0 {
|
||||
// FIXME this should probably be a log warning later
|
||||
return Err(SaveError::Generic(
|
||||
"db did not delete primary key; was already removed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(tx.commit().await?)
|
||||
}
|
||||
|
||||
async fn update(&self, _db: Postgres) -> Result<(), SaveError> {
|
||||
Err(SaveError::Generic(
|
||||
"accounts may not be updated".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JWK {
|
||||
pub id: Option<i32>,
|
||||
pub nonce_key: String,
|
||||
pub alg: String,
|
||||
pub n: Option<String>,
|
||||
pub e: Option<String>,
|
||||
pub x: Option<String>,
|
||||
pub y: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Local>,
|
||||
pub deleted_at: Option<chrono::DateTime<chrono::Local>>,
|
||||
}
|
||||
|
||||
impl JWK {
|
||||
pub fn new_rs256(n: String, e: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
nonce_key: make_nonce(super::NONCE_KEY_SIZE),
|
||||
n: Some(n),
|
||||
e: Some(e),
|
||||
alg: "RS256".into(),
|
||||
x: None,
|
||||
y: None,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now()),
|
||||
deleted_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_es256(x: String, y: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
nonce_key: make_nonce(super::NONCE_KEY_SIZE),
|
||||
x: Some(x),
|
||||
y: Some(y),
|
||||
alg: "ES256".into(),
|
||||
e: None,
|
||||
n: None,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now()),
|
||||
deleted_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_deleted(id: i32, db: Postgres) -> Result<Self, LoadError> {
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one("select * from jwks where id=$1", &[&id])
|
||||
.await?;
|
||||
|
||||
Self::new_from_row(&res, &tx).await
|
||||
}
|
||||
|
||||
pub async fn find_by_nonce(nonce_key: String, db: Postgres) -> Result<Self, LoadError> {
|
||||
let res = db
|
||||
.clone()
|
||||
.client()
|
||||
.await?
|
||||
.query_one(
|
||||
"select id from jwks where nonce_key=$1 and deleted_at is null",
|
||||
&[&nonce_key],
|
||||
)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => {
|
||||
let id: i32 = row.get(0);
|
||||
Self::find(id, db).await
|
||||
}
|
||||
|
||||
Err(_) => Err(LoadError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_by_kid(url: Url, db: Postgres) -> Result<Self, LoadError> {
|
||||
if let None = url.path_segments() {
|
||||
return Err(LoadError::NotFound);
|
||||
}
|
||||
|
||||
if let None = url.path_segments().unwrap().last() {
|
||||
return Err(LoadError::NotFound);
|
||||
}
|
||||
|
||||
Self::find_by_nonce(url.path_segments().unwrap().last().unwrap().to_string(), db).await
|
||||
}
|
||||
|
||||
pub fn nonce_key(&self) -> String {
|
||||
self.nonce_key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&mut jose::JWK> for JWK {
|
||||
type Error = JWSError;
|
||||
|
||||
fn try_from(jwk: &mut jose::JWK) -> Result<Self, Self::Error> {
|
||||
let (alg, n, e, x, y) = jwk.params();
|
||||
|
||||
let alg = match alg {
|
||||
Some(alg) => alg,
|
||||
None => return Err(JWSError::InvalidPublicKey),
|
||||
};
|
||||
|
||||
Ok(JWK {
|
||||
nonce_key: make_nonce(super::NONCE_KEY_SIZE),
|
||||
n,
|
||||
e,
|
||||
x,
|
||||
y,
|
||||
alg,
|
||||
id: None,
|
||||
created_at: chrono::DateTime::<chrono::Local>::from(std::time::SystemTime::now()),
|
||||
deleted_at: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<jose::JWK> for JWK {
|
||||
type Error = JWSError;
|
||||
|
||||
fn try_into(self) -> Result<jose::JWK, Self::Error> {
|
||||
let mut crv = None;
|
||||
|
||||
if self.x.is_some() && self.y.is_some() {
|
||||
// NOTE once more algos are supported this will need to change
|
||||
crv = Some("P-256".to_string())
|
||||
}
|
||||
|
||||
Ok(jose::JWK {
|
||||
_use: None,
|
||||
kty: match self.alg.as_str() {
|
||||
"ES256" => "ECDSA",
|
||||
"RS256" => "RSA",
|
||||
_ => "you should really be validating this field",
|
||||
}
|
||||
.to_string(),
|
||||
crv,
|
||||
n: self.n,
|
||||
e: self.e,
|
||||
x: self.x,
|
||||
y: self.y,
|
||||
alg: Some(self.alg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Record<i32> for JWK {
|
||||
async fn new_from_row(row: &Row, _tx: &Transaction<'_>) -> Result<Self, LoadError> {
|
||||
Ok(Self {
|
||||
id: Some(row.get("id")),
|
||||
nonce_key: row.get("nonce_key"),
|
||||
n: row.get("n"),
|
||||
e: row.get("e"),
|
||||
alg: row.get("alg"),
|
||||
x: row.get("x"),
|
||||
y: row.get("y"),
|
||||
created_at: row.get("created_at"),
|
||||
deleted_at: row.get("deleted_at"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn find(id: i32, db: Postgres) -> Result<Self, LoadError> {
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one(
|
||||
"select * from jwks where id=$1 and deleted_at is null",
|
||||
&[&id],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::new_from_row(&res, &tx).await
|
||||
}
|
||||
|
||||
fn id(&self) -> Result<Option<i32>, LoadError> {
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
async fn create(&mut self, db: Postgres) -> Result<i32, SaveError> {
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
|
||||
let res = tx
|
||||
.query_one(
|
||||
"
|
||||
insert into jwks (nonce_key, n, e, alg, x, y) values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at
|
||||
",
|
||||
&[
|
||||
&self.nonce_key,
|
||||
&self.n,
|
||||
&self.e,
|
||||
&self.alg,
|
||||
&self.x,
|
||||
&self.y,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let id = res.get("id");
|
||||
let created_at = res.get("created_at");
|
||||
self.id = Some(id);
|
||||
self.created_at = created_at;
|
||||
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
async fn delete(&self, db: Postgres) -> Result<(), SaveError> {
|
||||
if self.id.is_none() {
|
||||
return Err(SaveError::Generic(
|
||||
"this JWK record was never saved".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
let res = tx
|
||||
.execute(
|
||||
"update jwks set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null",
|
||||
&[&self.id.unwrap()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if res == 0 {
|
||||
// FIXME this should probably be a log warning later
|
||||
return Err(SaveError::Generic(
|
||||
"db did not delete primary key; was already removed".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(tx.commit().await?)
|
||||
}
|
||||
|
||||
async fn update(&self, _db: Postgres) -> Result<(), SaveError> {
|
||||
Err(SaveError::Generic("JWKs may not be updated".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn account_crud_single_contact() {
|
||||
use spectral::prelude::*;
|
||||
|
||||
use super::{Account, JWK};
|
||||
use crate::acme::handlers::account::NewAccount;
|
||||
use crate::models::Record;
|
||||
use crate::test::PGTest;
|
||||
use std::convert::TryInto;
|
||||
|
||||
let pg = PGTest::new("account_crud_single_contact").await.unwrap();
|
||||
|
||||
let mut acct = NewAccount::default();
|
||||
acct.contact = Some(vec!["mailto:erik@hollensbe.org".try_into().unwrap()]);
|
||||
|
||||
let mut jwk = JWK::new_es256("x".to_string(), "y".to_string());
|
||||
jwk.create(pg.db()).await.unwrap();
|
||||
let acct = super::new_accounts(acct, jwk, pg.db());
|
||||
assert_that!(acct).is_ok();
|
||||
let mut acct = acct.unwrap();
|
||||
assert_that!(acct.create(pg.db()).await).is_ok();
|
||||
let id = acct.id();
|
||||
assert_that!(id).is_ok();
|
||||
let id = id.unwrap();
|
||||
assert_that!(id).is_some();
|
||||
assert_that!(id.unwrap()).is_not_equal_to(0);
|
||||
let id = id.unwrap();
|
||||
|
||||
let newacct = Account::find(id, pg.db()).await.unwrap();
|
||||
assert_that!(acct).is_equal_to(newacct);
|
||||
|
||||
assert_that!(acct.delete(pg.db()).await).is_ok();
|
||||
assert_that!(acct.delete(pg.db()).await).is_err();
|
||||
|
||||
let oldacct = Account::find_deleted(id, pg.db()).await.unwrap();
|
||||
|
||||
acct.deleted_at = oldacct.deleted_at;
|
||||
|
||||
assert_that!(acct).is_equal_to(oldacct);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jwk_check_constraint() {
|
||||
use spectral::prelude::*;
|
||||
|
||||
// this test ensures that n & e and x & y are sticky to each other. other validation is performed outside
|
||||
// the db, but this is good for keeping the db hygenic.
|
||||
use crate::test::PGTest;
|
||||
use tokio_postgres::types::ToSql;
|
||||
|
||||
let pg = PGTest::new("jwk_check_constraint").await.unwrap();
|
||||
|
||||
let bad: &[(&str, &[&(dyn ToSql + Sync)])] = &[
|
||||
(
|
||||
"insert into jwks (nonce_key, n, x, alg) values ($1, $2, $3, $4)",
|
||||
&[
|
||||
&"firstbad".to_string(),
|
||||
&"aaaa".to_string(),
|
||||
&"bbbb".to_string(),
|
||||
&"alg".to_string(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"insert into jwks (nonce_key, e, y, alg) values ($1, $2, $3, $4)",
|
||||
&[
|
||||
&"secondbad".to_string(),
|
||||
&"aaaa".to_string(),
|
||||
&"bbbb".to_string(),
|
||||
&"alg".to_string(),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
for args in bad.iter() {
|
||||
let res = pg
|
||||
.db()
|
||||
.client()
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(args.0, args.1)
|
||||
.await;
|
||||
assert_that!(res).is_err();
|
||||
}
|
||||
|
||||
let good: &[(&str, &[&(dyn ToSql + Sync)])] = &[
|
||||
(
|
||||
"insert into jwks (nonce_key, n, e, alg) values ($1, $2, $3, $4)",
|
||||
&[
|
||||
&"firstgood".to_string(),
|
||||
&"aaaa".to_string(),
|
||||
&"bbbb".to_string(),
|
||||
&"alg".to_string(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"insert into jwks (nonce_key, x, y, alg) values ($1, $2, $3, $4)",
|
||||
&[
|
||||
&"secondgood".to_string(),
|
||||
&"aaaa".to_string(),
|
||||
&"bbbb".to_string(),
|
||||
&"alg".to_string(),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
for args in good.iter() {
|
||||
let res = pg
|
||||
.db()
|
||||
.client()
|
||||
.await
|
||||
.unwrap()
|
||||
.execute(args.0, args.1)
|
||||
.await;
|
||||
assert_that!(res).is_ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jwk_find_nonexistent_nonce() {
|
||||
use spectral::prelude::*;
|
||||
|
||||
use super::JWK;
|
||||
use crate::errors::db::LoadError;
|
||||
use crate::test::PGTest;
|
||||
|
||||
let pg = PGTest::new("jwk_find_nonexistent_nonce").await.unwrap();
|
||||
|
||||
let res = JWK::find_by_nonce("abcdef".to_string(), pg.db()).await;
|
||||
assert_that!(res).is_err();
|
||||
assert_that!(res.unwrap_err()).matches(|e| match e {
|
||||
// ugh!
|
||||
&LoadError::NotFound => true,
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn jwk_create_delete_find() {
|
||||
use spectral::prelude::*;
|
||||
|
||||
use super::JWK;
|
||||
use crate::models::Record;
|
||||
use crate::test::PGTest;
|
||||
|
||||
let pg = PGTest::new("jwk_create_delete_find").await.unwrap();
|
||||
|
||||
let jwks = &mut [
|
||||
JWK::new_rs256("aaaaaa".to_string(), "bbbb".to_string()),
|
||||
JWK::new_es256("aaaaaa".to_string(), "bbbb".to_string()),
|
||||
];
|
||||
|
||||
for origjwk in jwks.iter_mut() {
|
||||
let res = origjwk.create(pg.db()).await;
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let res = JWK::find(origjwk.id().unwrap().unwrap(), pg.db()).await;
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let jwk = res.unwrap();
|
||||
|
||||
assert_that!(jwk).is_equal_to(origjwk.clone());
|
||||
|
||||
let res = JWK::find_by_nonce(origjwk.nonce_key(), pg.db()).await;
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let jwk = res.unwrap();
|
||||
|
||||
assert_that!(jwk).is_equal_to(origjwk.clone());
|
||||
|
||||
let res = origjwk.delete(pg.db()).await;
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let res = origjwk.delete(pg.db()).await;
|
||||
assert_that!(res).is_err();
|
||||
|
||||
let res = JWK::find(origjwk.id().unwrap().unwrap(), pg.db()).await;
|
||||
assert_that!(res).is_err();
|
||||
|
||||
let res = JWK::find_by_nonce(origjwk.nonce_key(), pg.db()).await;
|
||||
assert_that!(res).is_err();
|
||||
|
||||
let res = JWK::find_deleted(origjwk.id().unwrap().unwrap(), pg.db()).await;
|
||||
assert_that!(res).is_ok();
|
||||
|
||||
let jwk = res.unwrap();
|
||||
origjwk.deleted_at = jwk.deleted_at;
|
||||
|
||||
assert_that!(jwk).is_equal_to(origjwk.clone());
|
||||
}
|
||||
}
|
||||
}
|
135
src/models/mod.rs
Normal file
135
src/models/mod.rs
Normal file
|
@ -0,0 +1,135 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use crate::errors::db::*;
|
||||
use async_trait::async_trait;
|
||||
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool};
|
||||
use refinery::Report;
|
||||
use tokio_postgres::{Config, NoTls, Row, Transaction};
|
||||
|
||||
pub mod migrations {
|
||||
use refinery::embed_migrations;
|
||||
embed_migrations!("migrations");
|
||||
}
|
||||
|
||||
pub mod account;
|
||||
pub mod nonce;
|
||||
pub mod order;
|
||||
|
||||
pub(crate) const NONCE_KEY_SIZE: Option<usize> = Some(32);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Postgres {
|
||||
pool: Pool,
|
||||
config: String,
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
pub async fn connect_one(config: &str) -> Result<tokio_postgres::Client, ConnectionError> {
|
||||
let (client, conn) = tokio_postgres::connect(config, NoTls).await?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
log::error!("postgresql connection error: {}", e)
|
||||
}
|
||||
});
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub async fn new(config: &str, pool_size: usize) -> Result<Self, ConnectionError> {
|
||||
let pg_config = Config::from_str(config)?;
|
||||
let mgr_config = ManagerConfig::default();
|
||||
let mgr = Manager::from_config(pg_config, NoTls, mgr_config);
|
||||
// FIXME deadpool's error here is in a private package, so we can't apply Try
|
||||
// operations
|
||||
let pool = Pool::builder(mgr).max_size(pool_size).build().unwrap();
|
||||
|
||||
Ok(Self {
|
||||
pool,
|
||||
config: config.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn client(self) -> Result<Object, ConnectionError> {
|
||||
Ok(self.pool.get().await?)
|
||||
}
|
||||
|
||||
/// migrate the database. The migration implementation is refinery and the migrations live in
|
||||
/// `migrations/` off the root of the repository, but are otherwise compiled into the library.
|
||||
pub async fn migrate(&self) -> Result<Report, MigrationError> {
|
||||
let mut c = Self::connect_one(&self.config).await?;
|
||||
let report = migrations::migrations::runner().run_async(&mut c).await?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// resets the database, destroying all data in the public schema.
|
||||
/// useful for tests.
|
||||
pub(crate) async fn reset(&self) -> Result<(), SaveError> {
|
||||
let c = Self::connect_one(&self.config).await?;
|
||||
c.execute("drop schema public cascade", &[]).await?;
|
||||
c.execute("create schema public", &[]).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// This trait encapsulates a record with a typed primary key (PK). Each record is capable of a
|
||||
/// number of operations on itself provided by the trait members, but a the database handle must be
|
||||
/// passed, and it needs to be kept under lock inside many of the functions.
|
||||
#[async_trait]
|
||||
pub trait Record<PK>
|
||||
where
|
||||
Self: Sized + Sync + Clone + Send + 'static,
|
||||
{
|
||||
/// new_from_row converts a row in the database to the appropriate struct. This method is async
|
||||
/// so it can make other database calls, etc.
|
||||
async fn new_from_row(row: &Row, db: &Transaction<'_>) -> Result<Self, LoadError>;
|
||||
|
||||
/// find a record by the PK, requires a database handle.
|
||||
async fn find(id: PK, db: Postgres) -> Result<Self, LoadError>;
|
||||
|
||||
/// Get the ID (if available) of the primary key.
|
||||
fn id(&self) -> Result<Option<PK>, LoadError>;
|
||||
|
||||
/// Create the record; mutates the record, returns the PK.
|
||||
async fn create(&mut self, db: Postgres) -> Result<PK, SaveError>;
|
||||
/// Update the record.
|
||||
async fn update(&self, db: Postgres) -> Result<(), SaveError>;
|
||||
/// Delete the record.
|
||||
async fn delete(&self, db: Postgres) -> Result<(), SaveError>;
|
||||
}
|
||||
|
||||
/// This trait encapuslates a list of Records. Some set operations are supplied that work on a
|
||||
/// transaction handle. FK is a typed foreign key against the join table.
|
||||
#[async_trait]
|
||||
pub trait RecordList<FK>
|
||||
where
|
||||
Self: Sized + Sync + Clone + Send + 'static,
|
||||
{
|
||||
/// Fetch the collection by its related foreign key
|
||||
async fn collect(id: FK, tx: &Transaction<'_>) -> Result<Vec<Self>, LoadError>;
|
||||
/// Get the latest record for a given collection
|
||||
async fn latest(id: FK, tx: &Transaction<'_>) -> Result<Self, LoadError>;
|
||||
/// append a record to this collection. Returns the new collection.
|
||||
async fn append(&self, id: FK, tx: &Transaction<'_>) -> Result<Vec<Self>, SaveError>;
|
||||
/// remove a record from this collection. You must re-call collect to get an updated list.
|
||||
async fn remove(&self, id: FK, tx: &Transaction<'_>) -> Result<(), SaveError>;
|
||||
/// Determine if a record exists in this collection.
|
||||
async fn exists(&self, id: FK, tx: &Transaction<'_>) -> Result<bool, LoadError>;
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn test_migrate() {
|
||||
use crate::test::PGTest;
|
||||
use spectral::prelude::*;
|
||||
|
||||
let pg = PGTest::new("test_migrate").await.unwrap();
|
||||
let db = pg.db();
|
||||
db.reset().await.unwrap();
|
||||
let report = db.migrate().await.unwrap();
|
||||
assert_that!(report.applied_migrations().len()).is_greater_than(0);
|
||||
|
||||
let report = db.migrate().await.unwrap();
|
||||
assert_that!(report.applied_migrations().len()).is_equal_to(0);
|
||||
}
|
||||
}
|
117
src/models/nonce.rs
Normal file
117
src/models/nonce.rs
Normal file
|
@ -0,0 +1,117 @@
|
|||
use super::{LoadError, Record, Postgres, SaveError};
|
||||
use crate::util::make_nonce;
|
||||
use async_trait::async_trait;
|
||||
use tokio_postgres::{Row, Transaction};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Nonce {
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Nonce {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.nonce)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Nonce {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.nonce.eq(&other.nonce)
|
||||
}
|
||||
}
|
||||
|
||||
impl Nonce {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nonce: make_nonce(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Record<String> for Nonce {
|
||||
async fn new_from_row(row: &Row, _tx: &Transaction<'_>) -> Result<Self, LoadError> {
|
||||
if row.len() > 0 {
|
||||
Ok(Self {
|
||||
nonce: row.get("nonce"),
|
||||
})
|
||||
} else {
|
||||
Err(LoadError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
fn id(&self) -> Result<Option<String>, LoadError> {
|
||||
return Ok(Some(self.nonce.clone()));
|
||||
}
|
||||
|
||||
async fn find(id: String, db: Postgres) -> Result<Self, LoadError> {
|
||||
let mut db = db.client().await?;
|
||||
let row = db
|
||||
.query_one("select nonce from nonces where nonce = $1", &[&id])
|
||||
.await?;
|
||||
|
||||
let tx = db.transaction().await?;
|
||||
|
||||
Self::new_from_row(&row, &tx).await
|
||||
}
|
||||
|
||||
async fn create(&mut self, db: Postgres) -> Result<String, SaveError> {
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
tx.execute("insert into nonces (nonce) values ($1)", &[&self.nonce])
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(self.nonce.clone())
|
||||
}
|
||||
|
||||
async fn delete(&self, db: Postgres) -> Result<(), SaveError> {
|
||||
let mut db = db.client().await?;
|
||||
let tx = db.transaction().await?;
|
||||
let res = tx
|
||||
.execute("delete from nonces where nonce = $1", &[&self.nonce])
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
if res == 0 {
|
||||
return Err(SaveError::Generic("nonce was already removed".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self, _: Postgres) -> Result<(), SaveError> {
|
||||
Err(SaveError::Generic("cannot update a nonce".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn nonce_crud_test() {
|
||||
use spectral::prelude::*;
|
||||
|
||||
use super::Nonce;
|
||||
use crate::models::Record;
|
||||
use crate::test::PGTest;
|
||||
|
||||
let pg = PGTest::new("nonce_crud_test").await.unwrap();
|
||||
let db = pg.db();
|
||||
|
||||
let mut nonce = Nonce::new();
|
||||
nonce.create(db.clone()).await.unwrap();
|
||||
|
||||
let found = Nonce::find(nonce.id().unwrap().unwrap(), db.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_that!(nonce).is_equal_to(found.clone());
|
||||
|
||||
let res = found.delete(db.clone()).await;
|
||||
assert_that!(res).is_ok();
|
||||
let res = found.delete(db.clone()).await;
|
||||
assert_that!(res).is_err();
|
||||
|
||||
let res = Nonce::find(found.id().unwrap().unwrap(), db.clone()).await;
|
||||
assert_that!(res).is_err();
|
||||
}
|
||||
}
|
1070
src/models/order.rs
Normal file
1070
src/models/order.rs
Normal file
File diff suppressed because it is too large
Load diff
473
src/test/mod.rs
Normal file
473
src/test/mod.rs
Normal file
|
@ -0,0 +1,473 @@
|
|||
#![cfg(test)]
|
||||
|
||||
use std::process::Stdio;
|
||||
use std::sync::Once;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::acme::ca::{CACollector, CA};
|
||||
use crate::acme::challenge::Challenger;
|
||||
use crate::acme::handlers::{configure_routes, HandlerState, ServiceState};
|
||||
use crate::acme::PostgresNonceValidator;
|
||||
use crate::errors::db::MigrationError;
|
||||
use crate::models::Postgres;
|
||||
use crate::util::make_nonce;
|
||||
|
||||
use bollard::container::{LogsOptions, StartContainerOptions};
|
||||
use openssl::error::ErrorStack;
|
||||
use ratpack::app::TestApp;
|
||||
use ratpack::prelude::*;
|
||||
|
||||
use bollard::{
|
||||
container::{Config, WaitContainerOptions},
|
||||
models::HostConfig,
|
||||
Docker,
|
||||
};
|
||||
use eggshell::EggShell;
|
||||
use futures::TryStreamExt;
|
||||
use lazy_static::lazy_static;
|
||||
use openssl::sha::sha256;
|
||||
use tempfile::{tempdir, TempDir};
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use url::Url;
|
||||
|
||||
const DEBUG_VAR: &str = "DEBUG";
|
||||
|
||||
fn is_debug() -> bool {
|
||||
!std::env::var(DEBUG_VAR).unwrap_or_default().is_empty()
|
||||
}
|
||||
|
||||
pub(crate) const DEFAULT_CONTACT: &str = "erik@hollensbe.org";
|
||||
|
||||
impl From<MigrationError> for eggshell::Error {
|
||||
fn from(me: MigrationError) -> Self {
|
||||
Self::Generic(me.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PGTest {
|
||||
gs: Arc<Mutex<EggShell>>,
|
||||
postgres: Postgres,
|
||||
docker: Arc<Mutex<Docker>>,
|
||||
// NOTE: the only reason we keep this is to ensure it lives the same lifetime as the PGTest
|
||||
// struct; otherwise the temporary directory is removed prematurely.
|
||||
temp: Arc<Mutex<TempDir>>,
|
||||
}
|
||||
|
||||
fn pull_images(images: Vec<&str>) -> () {
|
||||
// bollard doesn't let you pull images. sadly, this is what I came up with until I can patch
|
||||
// it.
|
||||
|
||||
for image in images {
|
||||
let mut cmd = &mut std::process::Command::new("docker");
|
||||
if !is_debug() {
|
||||
cmd = cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
}
|
||||
|
||||
let stat = cmd.args(vec!["pull", image]).status().unwrap();
|
||||
if !stat.success() {
|
||||
panic!("could not pull images");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_images(images: Vec<&str>) -> () {
|
||||
let docker = Docker::connect_with_local_defaults().unwrap();
|
||||
|
||||
for image in images {
|
||||
loop {
|
||||
match docker.inspect_image(image).await {
|
||||
Ok(_) => break,
|
||||
Err(_) => {
|
||||
tokio::time::sleep(Duration::new(0, 200)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
lazy_static! {
|
||||
static ref IMAGES: Vec<&'static str> = vec![
|
||||
"certbot/certbot:latest",
|
||||
"postgres:latest",
|
||||
"zerotier/zlint:latest",
|
||||
];
|
||||
}
|
||||
|
||||
const HBA_CONFIG_PATH: &str = "hack/pg_hba.conf";
|
||||
|
||||
impl PGTest {
|
||||
pub async fn new(name: &str) -> Result<Self, eggshell::Error> {
|
||||
INIT.call_once(|| {
|
||||
let mut builder = &mut env_logger::builder();
|
||||
if is_debug() {
|
||||
builder = builder.filter_level(log::LevelFilter::Info)
|
||||
}
|
||||
builder.init();
|
||||
pull_images(IMAGES.to_vec());
|
||||
});
|
||||
|
||||
wait_for_images(IMAGES.to_vec()).await;
|
||||
|
||||
let pwd = std::env::current_dir().unwrap();
|
||||
let hbapath = pwd.join(HBA_CONFIG_PATH);
|
||||
|
||||
let temp = tempdir().unwrap();
|
||||
|
||||
let docker = Arc::new(Mutex::new(Docker::connect_with_local_defaults().unwrap()));
|
||||
let mut gs = EggShell::new(docker.clone()).await?;
|
||||
|
||||
if is_debug() {
|
||||
gs.set_debug(true)
|
||||
}
|
||||
|
||||
log::info!("launching postgres instance: {}", name);
|
||||
|
||||
gs.launch(
|
||||
name,
|
||||
bollard::container::Config {
|
||||
image: Some("postgres:latest".to_string()),
|
||||
env: Some(
|
||||
vec!["POSTGRES_PASSWORD=dummy", "POSTGRES_DB=coyote"]
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
),
|
||||
host_config: Some(HostConfig {
|
||||
binds: Some(vec![
|
||||
format!(
|
||||
"{}:{}",
|
||||
hbapath.to_string_lossy().to_string(),
|
||||
"/etc/postgresql/pg_hba.conf"
|
||||
),
|
||||
format!("{}:{}", temp.path().display(), "/var/run/postgresql"),
|
||||
]),
|
||||
..Default::default()
|
||||
}),
|
||||
cmd: Some(
|
||||
vec![
|
||||
"-c",
|
||||
"shared_buffers=512MB",
|
||||
"-c",
|
||||
"max_connections=200",
|
||||
"-c",
|
||||
"unix_socket_permissions=0777",
|
||||
]
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
log::info!("waiting for postgres instance: {}", name);
|
||||
|
||||
let mut postgres: Option<Postgres> = None;
|
||||
let config = format!("host={} dbname=coyote user=postgres", temp.path().display());
|
||||
|
||||
while postgres.is_none() {
|
||||
let pg = Postgres::connect_one(&config).await;
|
||||
|
||||
match pg {
|
||||
Ok(_) => postgres = Some(Postgres::new(&config, 200).await.unwrap()),
|
||||
Err(_) => tokio::time::sleep(Duration::new(1, 0)).await,
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("connected to postgres instance: {}", name);
|
||||
|
||||
let postgres = postgres.unwrap();
|
||||
postgres.migrate().await?;
|
||||
|
||||
Ok(Self {
|
||||
docker,
|
||||
gs: Arc::new(Mutex::new(gs)),
|
||||
postgres,
|
||||
temp: Arc::new(Mutex::new(temp)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn db(&self) -> Postgres {
|
||||
self.postgres.clone()
|
||||
}
|
||||
|
||||
pub fn eggshell(self) -> Arc<Mutex<EggShell>> {
|
||||
self.gs
|
||||
}
|
||||
|
||||
pub fn docker(&self) -> Arc<Mutex<Docker>> {
|
||||
self.docker.clone()
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub(crate) enum ContainerError {
|
||||
#[error("Unknown error encountered: {0}")]
|
||||
Generic(String),
|
||||
|
||||
#[error("container failed with exit status: {0}: {1}")]
|
||||
Failed(i64, String),
|
||||
}
|
||||
|
||||
fn short_hash(s: String) -> String {
|
||||
String::from(
|
||||
&sha256(s.as_bytes())
|
||||
.iter()
|
||||
.map(|c| format!("{:x}", c))
|
||||
.take(10)
|
||||
.collect::<Vec<String>>()
|
||||
.join("")[0..10],
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TestService {
|
||||
pub pg: Box<PGTest>,
|
||||
pub nonce: PostgresNonceValidator,
|
||||
pub app: ratpack::app::TestApp<ServiceState, HandlerState>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
impl TestService {
|
||||
pub(crate) async fn new(name: &str) -> Self {
|
||||
let pg = PGTest::new(name).await.unwrap();
|
||||
let c = Challenger::new(Some(chrono::Duration::seconds(60)));
|
||||
let validator = PostgresNonceValidator::new(pg.db().clone());
|
||||
|
||||
let c2 = c.clone();
|
||||
let pg2 = pg.db().clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
c2.tick(|_c| Some(())).await;
|
||||
c2.reconcile(pg2.clone()).await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::new(0, 250)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let ca = CACollector::new(Duration::new(0, 250));
|
||||
let mut ca2 = ca.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let ca = CA::new_test_ca().unwrap();
|
||||
ca2.spawn_collector(|| -> Result<CA, ErrorStack> { Ok(ca.clone()) })
|
||||
.await
|
||||
});
|
||||
|
||||
let lis = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = lis.local_addr().unwrap();
|
||||
let url = format!("http://{}", addr);
|
||||
drop(lis);
|
||||
|
||||
let mut app = App::with_state(
|
||||
ServiceState::new(url.clone(), pg.db(), c, ca, validator.clone()).unwrap(),
|
||||
);
|
||||
|
||||
configure_routes(&mut app, None);
|
||||
|
||||
let a = app.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
a.serve(&addr.clone().to_string()).await.unwrap();
|
||||
});
|
||||
|
||||
Self {
|
||||
pg: Box::new(pg),
|
||||
nonce: validator,
|
||||
app: TestApp::new(app),
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn zlint(&self, certs: Arc<TempDir>) -> Result<(), ContainerError> {
|
||||
log::info!("letsencrypt dir: {}", certs.path().display());
|
||||
let name = &format!("zlint-{}", short_hash(make_nonce(None)));
|
||||
|
||||
let res = self
|
||||
.launch(
|
||||
name,
|
||||
Config {
|
||||
attach_stdout: Some(is_debug()),
|
||||
attach_stderr: Some(is_debug()),
|
||||
image: Some("zerotier/zlint:latest".to_string()),
|
||||
entrypoint: Some(
|
||||
vec!["/bin/sh", "-c"]
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
),
|
||||
cmd: Some(vec![
|
||||
"set -e; for file in /etc/letsencrypt/live/*/fullchain.pem; do zlint $file; done"
|
||||
.to_string(),
|
||||
]),
|
||||
host_config: Some(HostConfig {
|
||||
binds: Some(vec![format!(
|
||||
"{}:{}",
|
||||
certs.path().to_string_lossy(),
|
||||
"/etc/letsencrypt"
|
||||
)]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(ContainerError::Generic(e.to_string()));
|
||||
}
|
||||
|
||||
self.wait(name).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
pub(crate) async fn certbot(
|
||||
&self,
|
||||
certs: Option<Arc<TempDir>>,
|
||||
command: String,
|
||||
) -> Result<Arc<TempDir>, ContainerError> {
|
||||
let server_url = Url::parse(&self.url).unwrap();
|
||||
let server_url_hash = short_hash(server_url.to_string());
|
||||
let certs: Arc<tempfile::TempDir> = match certs {
|
||||
Some(certs) => certs,
|
||||
None => Arc::new(tempdir().unwrap()),
|
||||
};
|
||||
|
||||
log::info!("letsencrypt dir: {}", certs.path().display());
|
||||
|
||||
let name = &format!(
|
||||
"certbot-{}-{}",
|
||||
server_url_hash,
|
||||
short_hash(make_nonce(None))
|
||||
);
|
||||
|
||||
let res = self
|
||||
.launch(
|
||||
name,
|
||||
Config {
|
||||
image: Some("certbot/certbot:latest".to_string()),
|
||||
entrypoint: Some(
|
||||
vec!["/bin/sh", "-c"]
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
),
|
||||
cmd: Some(vec![format!(
|
||||
// this 755 set is a hack around containers running as root and the
|
||||
// test launching them running as a user.
|
||||
"certbot --non-interactive --logs-dir '/etc/letsencrypt/logs' --server '{}' {} && chmod -R 755 /etc/letsencrypt",
|
||||
server_url, command
|
||||
)]),
|
||||
host_config: Some(HostConfig {
|
||||
network_mode: Some("host".to_string()),
|
||||
binds: Some(vec![format!(
|
||||
"{}:{}",
|
||||
certs.path().to_string_lossy(),
|
||||
"/etc/letsencrypt"
|
||||
)]),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(ContainerError::Generic(e.to_string()));
|
||||
}
|
||||
|
||||
self.wait(name).await?;
|
||||
return Ok(certs);
|
||||
}
|
||||
|
||||
async fn launch(
|
||||
&self,
|
||||
name: &str,
|
||||
config: Config<String>,
|
||||
start_opts: Option<StartContainerOptions<String>>,
|
||||
) -> Result<(), eggshell::Error> {
|
||||
self.pg
|
||||
.clone()
|
||||
.eggshell()
|
||||
.lock()
|
||||
.await
|
||||
.set_debug(is_debug());
|
||||
|
||||
self.pg
|
||||
.clone()
|
||||
.eggshell()
|
||||
.lock()
|
||||
.await
|
||||
.launch(name, config, start_opts)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait(&self, name: &str) -> Result<(), ContainerError> {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::new(1, 0)).await;
|
||||
|
||||
let locked = self.pg.docker.lock().await;
|
||||
let waitres = locked
|
||||
.wait_container::<String>(
|
||||
name,
|
||||
Some(WaitContainerOptions {
|
||||
condition: "not-running".to_string(),
|
||||
}),
|
||||
)
|
||||
.try_next()
|
||||
.await;
|
||||
|
||||
if let Ok(Some(res)) = waitres {
|
||||
if res.status_code != 0 || res.error.is_some() {
|
||||
let mut error = res.error.unwrap_or_default().message;
|
||||
|
||||
let logs = locked
|
||||
.logs::<String>(
|
||||
name,
|
||||
Some(LogsOptions::<String> {
|
||||
stderr: is_debug(),
|
||||
stdout: is_debug(),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.try_next()
|
||||
.await;
|
||||
if let Ok(Some(logs)) = logs {
|
||||
error = Some(format!("{}", logs));
|
||||
let logs = logs.into_bytes();
|
||||
if logs.len() > 50 && is_debug() {
|
||||
std::fs::write("error.log", logs).unwrap();
|
||||
error = Some("error too long: error written to error.log".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
return Err(ContainerError::Failed(
|
||||
res.status_code,
|
||||
error.unwrap_or_default(),
|
||||
));
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod tests {
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn pgtest_basic() {
|
||||
use super::PGTest;
|
||||
use spectral::prelude::*;
|
||||
|
||||
let res = PGTest::new("pgtest_basic").await;
|
||||
assert_that!(res.is_ok()).is_true();
|
||||
}
|
||||
}
|
24
src/util/mod.rs
Normal file
24
src/util/mod.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use rand::Fill;
|
||||
|
||||
const DEFAULT_NONCE_SIZE: usize = 64;
|
||||
|
||||
// generate some random bytes
|
||||
pub(crate) fn make_nonce(len: Option<usize>) -> String {
|
||||
let mut r = Vec::new();
|
||||
r.resize(len.unwrap_or(DEFAULT_NONCE_SIZE), 0);
|
||||
|
||||
r.try_fill(&mut rand::thread_rng())
|
||||
.expect("Couldn't do a random");
|
||||
|
||||
base64::encode_config(r, base64::URL_SAFE_NO_PAD)
|
||||
}
|
||||
|
||||
pub(crate) fn to_base64<T>(payload: &T) -> Result<String, serde_json::Error>
|
||||
where
|
||||
T: serde::Serialize + ?Sized,
|
||||
{
|
||||
Ok(base64::encode_config(
|
||||
serde_json::to_string(payload)?,
|
||||
base64::URL_SAFE_NO_PAD,
|
||||
))
|
||||
}
|
Loading…
Reference in a new issue