Initial commit

Signed-off-by: Erik Hollensbe <git@hollensbe.org>
This commit is contained in:
Erik Hollensbe 2022-02-16 17:02:02 -08:00
commit efd5dd9902
No known key found for this signature in database
GPG key ID: 4BB0E241A863B389
33 changed files with 8826 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target
coyote.db
error.log
ca.{pem,key}
caddy

2107
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

51
Cargo.toml Normal file
View 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
View 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
View 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
View 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.

View 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
View 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
View 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
View file

86
migrations/V1__init.sql Normal file
View 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
View 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
View 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
View 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());
}
}

View 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();
}
}
}

View 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
View 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
View 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
View 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();
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

473
src/test/mod.rs Normal file
View 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
View 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,
))
}