#![allow(dead_code, unused_imports)]
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use acme_proxy_jobs::auditor::Auditor;
pub use acme_proxy_core::audit::ClientContext;
use acme_proxy_protocol::profile::Profile;
use acme_proxy_protocol::profile::ProfileParts;
use acme_proxy_protocol::router::build_app;
use acme_proxy_admin::admin::password::PasswordContext;
use acme_proxy_core::client::ProxyPolicy;
use acme_proxy_core::config::Config;
use acme_proxy_core::config::JobsConfig;
use acme_proxy_core::identifier::Identifier;
use acme_proxy_jobs::jobs::JobQueue;
use acme_proxy_jobs::jobs::JobRegistry;
pub use acme_proxy_jobs::metrics::Metrics;
use acme_proxy_jobs::notify::BackendSlot;
use acme_proxy_jobs::notify::NotifyBackend;
use acme_proxy_jobs::notify::NotifyDispatcher;
use acme_proxy_jobs::notify::NotifyError;
use acme_proxy_jobs::notify::NotifyEvent;
use acme_proxy_jobs::notify::NotifyJob;
use acme_proxy_net::challenge::ChallengeError;
use acme_proxy_net::challenge::ChallengeRegistry;
use acme_proxy_net::challenge::ChallengeValidator;
use acme_proxy_net::challenge::ValidationContext;
use acme_proxy_policy::filter::ConnectionContext;
use acme_proxy_policy::filter::FilterPolicy;
use acme_proxy_policy::filter::IdentifierContext;
use acme_proxy_policy::filter::Stage;
use acme_proxy_policy::filter::expr::Condition;
use acme_proxy_policy::filter::policy::Check;
use acme_proxy_policy::filter::policy::Effect;
use acme_proxy_policy::filter::policy::Mode;
use acme_proxy_policy::filter::policy::Rule;
use acme_proxy_policy::filter::policy::StageSet;
use acme_proxy_policy::filter::policy::Verdict;
use acme_proxy_protocol::acme::issue::SignerIssueJob;
use acme_proxy_protocol::acme::revoke::SignerRevokeJob;
use acme_proxy_protocol::acme::validate::ChallengeValidateJob;
use acme_proxy_signer::CrlRefresher;
use acme_proxy_signer::Http01TokenStore;
use acme_proxy_signer::IssueOutcome;
use acme_proxy_signer::RenewalWindow;
use acme_proxy_signer::RequestedValidity;
use acme_proxy_signer::RevocationRoute;
use acme_proxy_signer::SignerBackend;
use acme_proxy_signer::SignerError;
use acme_proxy_signer::SignerInfo;
use acme_proxy_signer::local_ca::LocalCa;
use acme_proxy_signer::local_ca::sweep::CrlRegenerateJob;
use acme_proxy_signer::relay::http01::DbTokenStore;
use acme_proxy_store::db::Database;
use async_trait::async_trait;
use axum::Router;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request, StatusCode, header};
use axum::response::Response;
use base64::prelude::*;
use http_body_util::BodyExt;
use ring::hmac;
use ring::rand::SystemRandom;
use ring::signature::{self, EcdsaKeyPair, KeyPair, RsaKeyPair};
use serde_json::{Value, json};
use simple_asn1::ASN1Block;
use tower::ServiceExt;
fn init_tracing() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let _ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(std::io::sink)
.try_init();
});
}
pub fn make_csr(dns: &str) -> String {
make_csr_for(&[dns])
}
pub fn make_csr_for(names: &[&str]) -> String {
let key_pair = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(
names
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>(),
)
.unwrap();
let csr = params.serialize_request(&key_pair).unwrap();
BASE64_URL_SAFE_NO_PAD.encode(csr.der())
}
pub fn make_csr_with_sans(dns: &str, extra: Vec<rcgen::SanType>) -> String {
let key_pair = rcgen::KeyPair::generate().unwrap();
let mut params = rcgen::CertificateParams::new(vec![dns.to_string()]).unwrap();
params.subject_alt_names.extend(extra);
let csr = params.serialize_request(&key_pair).unwrap();
BASE64_URL_SAFE_NO_PAD.encode(csr.der())
}
pub fn make_csr_and_keypair(dns: &str) -> (String, EcSigner) {
let rng = SystemRandom::new();
let pkcs8 =
EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
let signer = EcSigner::from_pkcs8(pkcs8.as_ref());
let rcgen_key = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(
&rustls_pki_types::PrivatePkcs8KeyDer::from(pkcs8.as_ref().to_vec()),
&rcgen::PKCS_ECDSA_P256_SHA256,
)
.unwrap();
let params = rcgen::CertificateParams::new(vec![dns.to_string()]).unwrap();
let csr = params.serialize_request(&rcgen_key).unwrap();
(BASE64_URL_SAFE_NO_PAD.encode(csr.der()), signer)
}
pub fn first_certificate(chain: &str) -> Vec<u8> {
acme_proxy_core::cert::leaf_der_from_chain(chain).unwrap()
}
pub const HOST: &str = "http://localhost:3000";
pub const PROFILE: &str = "default";
pub const PREFIX: &str = "/profile/default";
pub const BASE: &str = "http://localhost:3000/profile/default";
#[must_use]
pub fn p(path: &str) -> String {
format!("{PREFIX}{path}")
}
pub async fn test_app() -> Router {
test_app_with_db().await.0
}
pub async fn test_app_with_db() -> (Router, Arc<Database>) {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let signer = Arc::new(
LocalCa::generate_in_memory("ecdsa-p256", 90, database.clone())
.expect("an in-memory CA is always available"),
);
(test_app_over(database.clone(), signer).await, database)
}
pub async fn memory_ca() -> LocalCa {
LocalCa::generate_in_memory(
"ecdsa-p256",
90,
Arc::new(Database::connect_in_memory().await.unwrap()),
)
.expect("an in-memory CA is always available")
}
pub async fn test_app_with_signer(signer: Arc<dyn SignerBackend>) -> (Router, Arc<Database>) {
test_app_full(
Config::default(),
signer,
Arc::new(FilterPolicy::default()),
default_challenges(),
no_notifications().await,
)
.await
}
pub async fn test_app_with_filter(filter: Arc<FilterPolicy>) -> (Router, Arc<Database>) {
let signer = Arc::new(memory_ca().await);
test_app_full(
Config::default(),
signer,
filter,
default_challenges(),
no_notifications().await,
)
.await
}
pub async fn test_app_with_challenges(
config: Config,
challenges: Arc<ChallengeRegistry>,
) -> (Router, Arc<Database>) {
let signer = Arc::new(memory_ca().await);
test_app_full(
config,
signer,
Arc::new(FilterPolicy::default()),
challenges,
no_notifications().await,
)
.await
}
pub async fn test_app_with_notify(notify: Arc<NotifyDispatcher>) -> (Router, Arc<Database>) {
let signer = Arc::new(memory_ca().await);
test_app_full(
Config::default(),
signer,
Arc::new(FilterPolicy::default()),
default_challenges(),
notify,
)
.await
}
pub async fn no_notifications() -> Arc<NotifyDispatcher> {
Arc::new(NotifyDispatcher::disabled(test_job_queue().await))
}
pub async fn test_job_queue() -> JobQueue {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
JobQueue::new(database, &JobsConfig::default())
}
pub struct NotifyHarness {
pub dispatcher: Arc<NotifyDispatcher>,
pub recorder: Arc<RecordingNotifyBackend>,
shutdown: tokio::sync::watch::Sender<bool>,
}
impl NotifyHarness {
pub async fn new() -> Self {
Self::over(Arc::new(RecordingNotifyBackend::default())).await
}
pub async fn failing() -> Self {
Self::over(Arc::new(RecordingNotifyBackend::failing())).await
}
pub async fn admin() -> (Self, acme_proxy_jobs::notify::Notifiers) {
let harness = Self::over_key(
acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
Arc::new(RecordingNotifyBackend::default()),
)
.await;
let mut map = std::collections::HashMap::new();
map.insert(
acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
harness.dispatcher.clone(),
);
let notifiers: acme_proxy_jobs::notify::Notifiers = Arc::new(map).into();
(harness, notifiers)
}
async fn over(recorder: Arc<RecordingNotifyBackend>) -> Self {
Self::over_key(PROFILE, recorder).await
}
async fn over_key(key: &str, recorder: Arc<RecordingNotifyBackend>) -> Self {
let every: Vec<String> = acme_proxy_core::config::ALL_NOTIFY_EVENTS
.iter()
.map(|kind| (*kind).to_string())
.collect();
let config = JobsConfig {
poll_interval_ms: 5,
max_attempts: 1,
retry_base_seconds: 0,
..JobsConfig::default()
};
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let queue = JobQueue::new(database, &config);
let dispatcher = Arc::new(NotifyDispatcher::new(
key,
vec![BackendSlot::new("recording", recorder.clone(), &every)],
queue.clone(),
));
let mut registry = JobRegistry::new();
let mut dispatchers = std::collections::HashMap::new();
dispatchers.insert(key.to_string(), dispatcher.clone());
registry
.register(Arc::new(NotifyJob::new(Arc::new(dispatchers))))
.unwrap();
let (shutdown, receiver) = tokio::sync::watch::channel(false);
acme_proxy_jobs::jobs::spawn_runner(queue, Arc::new(registry), &config, receiver);
Self {
dispatcher,
recorder,
shutdown,
}
}
pub async fn recorded(&self, count: usize) -> Vec<NotifyEvent> {
for _ in 0..400 {
let events = self.recorder.events();
if events.len() >= count {
return events;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!(
"expected at least {count} notification(s), saw {:?}",
self.recorder.events()
);
}
pub async fn settled(&self) -> Vec<NotifyEvent> {
tokio::time::sleep(Duration::from_millis(150)).await;
self.recorder.events()
}
}
impl Drop for NotifyHarness {
fn drop(&mut self) {
let _ = self.shutdown.send(true);
}
}
pub fn default_challenges() -> Arc<ChallengeRegistry> {
Arc::new(ChallengeRegistry::default())
}
pub fn challenges_with(
types: &[&str],
validators: Vec<Arc<dyn ChallengeValidator>>,
) -> Arc<ChallengeRegistry> {
Arc::new(ChallengeRegistry::new(
validators,
types.iter().map(std::string::ToString::to_string).collect(),
false,
Duration::from_secs(5),
))
}
pub fn bypassing_challenges(types: &[&str]) -> Arc<ChallengeRegistry> {
Arc::new(ChallengeRegistry::new(
Vec::new(),
types.iter().map(std::string::ToString::to_string).collect(),
true,
Duration::from_secs(5),
))
}
pub struct TestProfile {
pub name: &'static str,
pub signer: Arc<dyn SignerBackend>,
pub filter: Arc<FilterPolicy>,
pub challenges: Arc<ChallengeRegistry>,
pub eab: acme_proxy_core::config::EabConfig,
pub meta: acme_proxy_core::config::MetaConfig,
pub notify: Option<Arc<NotifyDispatcher>>,
}
impl TestProfile {
pub async fn new(name: &'static str) -> Self {
Self {
name,
signer: Arc::new(memory_ca().await),
filter: Arc::new(FilterPolicy::default()),
challenges: default_challenges(),
eab: acme_proxy_core::config::EabConfig::default(),
meta: acme_proxy_core::config::MetaConfig::default(),
notify: None,
}
}
#[must_use]
pub fn requiring_eab(mut self) -> Self {
self.eab.enabled = true;
self
}
#[must_use]
pub fn with_filter(mut self, filter: Arc<FilterPolicy>) -> Self {
self.filter = filter;
self
}
#[must_use]
pub fn with_notify(mut self, notify: Arc<NotifyDispatcher>) -> Self {
self.notify = Some(notify);
self
}
#[must_use]
pub fn base(name: &str) -> String {
format!("{HOST}/profile/{name}")
}
#[must_use]
pub fn path(name: &str, path: &str) -> String {
format!("/profile/{name}{path}")
}
}
pub async fn test_app_with_profiles(profiles: Vec<TestProfile>) -> (Router, Arc<Database>) {
init_tracing();
let config = Config::default();
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let mut built = Vec::with_capacity(profiles.len());
let mut backends = Vec::with_capacity(profiles.len());
for profile in profiles {
let notify = match profile.notify {
Some(notify) => notify,
None => no_notifications().await,
};
met_by_a_worker(profile.signer.as_ref()).await;
backends.push((profile.name.to_string(), profile.signer.clone()));
built.push(Arc::new(Profile::new(
profile.name,
&config.server.base_url,
ProfileParts {
signer_info: read_side(profile.signer.as_ref(), &database).await,
filter: profile.filter,
challenges: profile.challenges,
order: config.order.clone(),
eab: profile.eab,
meta: profile.meta,
notify,
},
)));
}
let jobs = spawn_worker_runner(&database, &built, &backends, test_auditor(database.clone()));
let router = build_app(
database.clone(),
Arc::new(config),
built,
test_auditor(database.clone()),
Arc::new(Metrics::new(database.clone())),
jobs,
);
(router, database)
}
fn spawn_worker_runner(
database: &Arc<Database>,
profiles: &[Arc<Profile>],
backends: &[(String, Arc<dyn SignerBackend>)],
audit: Arc<Auditor>,
) -> JobQueue {
let config = worker_jobs_config();
let queue = JobQueue::new(database.clone(), &config);
let mut registry = JobRegistry::new();
registry
.register(Arc::new(ChallengeValidateJob::new(
database.clone(),
audit.clone(),
profiles
.iter()
.map(|profile| (profile.name.clone(), profile.clone()))
.collect(),
)))
.expect("the validation handler registers");
let (notifiers_tx, notifiers) = acme_proxy_jobs::notify::notifiers_channel(
profiles
.iter()
.map(|profile| (profile.name.clone(), profile.notify.clone()))
.collect(),
);
let revoke_notifiers = notifiers.clone();
std::mem::forget(notifiers_tx);
let audit_for_revocations = audit.clone();
registry
.register(Arc::new(SignerIssueJob::new(
database.clone(),
audit,
backends.to_vec(),
notifiers,
)))
.expect("the issuance handler registers");
registry
.register(Arc::new(SignerRevokeJob::new(
database.clone(),
audit_for_revocations,
backends.to_vec(),
revoke_notifiers,
)))
.expect("the revocation handler registers");
let mut refreshers: Vec<Arc<dyn CrlRefresher>> = Vec::new();
for (_, backend) in backends {
if let Some(refresher) = backend.crl_refresher()
&& !refreshers
.iter()
.any(|known| known.issuer() == refresher.issuer())
{
refreshers.push(refresher);
}
}
if !refreshers.is_empty() {
registry
.register(Arc::new(CrlRegenerateJob::new(refreshers)))
.expect("the CRL handler registers");
}
let (shutdown, rx) = tokio::sync::watch::channel(false);
std::mem::forget(shutdown);
acme_proxy_jobs::jobs::spawn_runner(queue.clone(), Arc::new(registry), &config, rx);
queue
}
fn worker_jobs_config() -> JobsConfig {
JobsConfig {
poll_interval_ms: 10,
retry_base_seconds: 0,
retry_max_seconds: 0,
..JobsConfig::default()
}
}
pub async fn test_app_full(
config: Config,
signer: Arc<dyn SignerBackend>,
filter: Arc<FilterPolicy>,
challenges: Arc<ChallengeRegistry>,
notify: Arc<NotifyDispatcher>,
) -> (Router, Arc<Database>) {
init_tracing();
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let (profile, backends) =
one_profile(&database, &config, signer, filter, challenges, notify).await;
let jobs = spawn_worker_runner(
&database,
std::slice::from_ref(&profile),
&backends,
test_auditor(database.clone()),
);
let router = build_app(
database.clone(),
Arc::new(config),
vec![profile],
test_auditor(database.clone()),
Arc::new(Metrics::new(database.clone())),
jobs,
);
(router, database)
}
pub async fn test_app_over(database: Arc<Database>, signer: Arc<dyn SignerBackend>) -> Router {
init_tracing();
let config = Config::default();
let (profile, backends) = one_profile(
&database,
&config,
signer,
Arc::new(FilterPolicy::default()),
default_challenges(),
no_notifications().await,
)
.await;
let jobs = spawn_worker_runner(
&database,
std::slice::from_ref(&profile),
&backends,
test_auditor(database.clone()),
);
build_app(
database.clone(),
Arc::new(config),
vec![profile],
test_auditor(database.clone()),
Arc::new(Metrics::new(database)),
jobs,
)
}
pub struct DiskDb(std::path::PathBuf);
impl Drop for DiskDb {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", self.0.display()));
}
}
}
pub async fn test_app_on_disk() -> (Router, Arc<Database>, DiskDb) {
init_tracing();
let path = std::env::temp_dir().join(format!("acme-proxy-test-{}.db", uuid::Uuid::now_v7()));
let database = Arc::new(
Database::connect_and_migrate(&format!("sqlite://{}", path.display()))
.await
.unwrap(),
);
let config = Config::default();
let (profile, backends) = one_profile(
&database,
&config,
Arc::new(memory_ca().await),
Arc::new(FilterPolicy::default()),
default_challenges(),
no_notifications().await,
)
.await;
let jobs = spawn_worker_runner(
&database,
std::slice::from_ref(&profile),
&backends,
test_auditor(database.clone()),
);
let router = build_app(
database.clone(),
Arc::new(config),
vec![profile],
test_auditor(database.clone()),
Arc::new(Metrics::new(database.clone())),
jobs,
);
(router, database, DiskDb(path))
}
pub async fn test_app_with_metrics(
mut config: Config,
signer: Arc<dyn SignerBackend>,
) -> (Router, Arc<Database>, Arc<Metrics>) {
init_tracing();
config.metrics.enabled = true;
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let metrics = Arc::new(Metrics::new(database.clone()));
let (profile, backends) = one_profile(
&database,
&config,
signer,
Arc::new(FilterPolicy::default()),
Arc::new(ChallengeRegistry::default()),
no_notifications().await,
)
.await;
let auditor = Arc::new(
Auditor::with_resolver(
database.clone(),
None,
std::time::Duration::from_millis(100),
)
.with_metrics(metrics.clone()),
);
let jobs = spawn_worker_runner(
&database,
std::slice::from_ref(&profile),
&backends,
auditor.clone(),
);
let router = build_app(
database.clone(),
Arc::new(config),
vec![profile],
auditor,
metrics.clone(),
jobs,
);
(router, database, metrics)
}
async fn one_profile(
database: &Database,
config: &Config,
signer: Arc<dyn SignerBackend>,
filter: Arc<FilterPolicy>,
challenges: Arc<ChallengeRegistry>,
notify: Arc<NotifyDispatcher>,
) -> (Arc<Profile>, Vec<(String, Arc<dyn SignerBackend>)>) {
met_by_a_worker(signer.as_ref()).await;
let profile = Arc::new(Profile::new(
PROFILE,
&config.server.base_url,
ProfileParts {
signer_info: read_side(signer.as_ref(), database).await,
filter,
challenges,
order: config.order.clone(),
eab: config.eab.clone(),
meta: config.meta.clone(),
notify,
},
));
(profile, vec![(PROFILE.to_string(), signer)])
}
pub async fn met_by_a_worker(signer: &dyn SignerBackend) {
if let Some(refresher) = signer.crl_refresher() {
refresher
.refresh()
.await
.expect("an in-memory CA stores its first CRL");
}
}
pub async fn read_side(signer: &dyn SignerBackend, database: &Database) -> Arc<dyn SignerInfo> {
let info = signer.info();
if let RevocationRoute::Ledger { issuer } = info.revocation_route()
&& acme_proxy_store::crl::StoredCrl::find_current(&issuer, database)
.await
.unwrap()
.is_none()
{
return Arc::new(OwnDatabase(info));
}
info
}
struct OwnDatabase(Arc<dyn SignerInfo>);
#[async_trait]
impl SignerInfo for OwnDatabase {
async fn crl_der(&self) -> Result<Option<Vec<u8>>, SignerError> {
self.0.crl_der().await
}
async fn ca_chain_pem(&self) -> Option<String> {
self.0.ca_chain_pem().await
}
async fn renewal_info(&self, cert_der: &[u8]) -> Result<Option<RenewalWindow>, SignerError> {
self.0.renewal_info(cert_der).await
}
fn http01_tokens(&self) -> Option<Arc<dyn Http01TokenStore>> {
self.0.http01_tokens()
}
fn revocation_route(&self) -> RevocationRoute {
RevocationRoute::Delegated
}
}
pub fn test_auditor(database: Arc<Database>) -> Arc<Auditor> {
Arc::new(Auditor::with_resolver(
database,
None,
std::time::Duration::from_millis(100),
))
}
pub async fn await_crl_listing(app: &Router, crl_path: &str, serial_hex: &str) -> Vec<String> {
use x509_parser::prelude::FromDer;
let mut serials = Vec::new();
for _ in 0..600 {
let response = app
.clone()
.oneshot(Request::get(crl_path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "GET {crl_path}");
let der = response.into_body().collect().await.unwrap().to_bytes();
let (_, crl) =
x509_parser::revocation_list::CertificateRevocationList::from_der(&der).unwrap();
serials = crl
.iter_revoked_certificates()
.map(|entry| entry.raw_serial_as_string().replace(':', ""))
.collect();
if serials.iter().any(|s| s.eq_ignore_ascii_case(serial_hex)) {
return serials;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("expected {serial_hex} in the CRL at {crl_path}, which lists {serials:?}");
}
pub fn admin_config() -> Config {
let mut config = Config::default();
config.admin.enabled = true;
config
}
pub async fn test_admin_app(config: Config) -> (Router, Arc<Database>) {
let (router, database, _signer) = test_admin_app_with_signer(config).await;
(router, database)
}
pub async fn test_admin_app_with_signer(
config: Config,
) -> (Router, Arc<Database>, Arc<dyn SignerBackend>) {
admin_app_with(config, Arc::new(FilterPolicy::default())).await
}
async fn admin_app_with(
config: Config,
filter: Arc<FilterPolicy>,
) -> (Router, Arc<Database>, Arc<dyn SignerBackend>) {
admin_app_with_notifiers(
config,
filter,
acme_proxy_jobs::notify::DispatcherMap::new().into(),
)
.await
}
async fn admin_app_with_notifiers(
config: Config,
filter: Arc<FilterPolicy>,
notifiers: acme_proxy_jobs::notify::Notifiers,
) -> (Router, Arc<Database>, Arc<dyn SignerBackend>) {
init_tracing();
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let signer: Arc<dyn SignerBackend> = Arc::new(
LocalCa::generate_in_memory("ecdsa-p256", 90, database.clone())
.expect("an in-memory CA is always available"),
);
let (profile, backends) = one_profile(
&database,
&config,
signer.clone(),
filter,
default_challenges(),
no_notifications().await,
)
.await;
let jobs = spawn_worker_runner(
&database,
std::slice::from_ref(&profile),
&backends,
test_auditor(database.clone()),
);
let router = acme_proxy_admin::webadmin::build_admin_app(
database.clone(),
Arc::new(config),
&[profile],
test_auditor(database.clone()),
notifiers,
jobs,
);
(router, database, signer)
}
pub async fn test_admin_app_logged_in_with_security_notify(
config: Config,
) -> (Router, Arc<Database>, AdminSessionHandle, NotifyHarness) {
let (harness, notifiers) = NotifyHarness::admin().await;
let (app, database, _signer) =
admin_app_with_notifiers(config, Arc::new(FilterPolicy::default()), notifiers).await;
acme_proxy_admin::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.expect("the bootstrap operator must be creatable");
acme_proxy_admin::admin::users::set_contact_email(
"alice",
Some("alice@example.com"),
database.clone(),
)
.await
.expect("a contact address must be settable");
let handle = admin_login(&app, "alice", ADMIN_PASSWORD).await;
(app, database, handle, harness)
}
pub fn test_filter_policy() -> Arc<FilterPolicy> {
use acme_proxy_core::config::CheckConfig;
use acme_proxy_core::config::FilterConfig;
use acme_proxy_core::config::RuleConfig;
let filter = FilterConfig {
rules: vec!["mgmt-bypass".to_string(), "corp".to_string()],
default: "deny".to_string(),
rule: [
(
"mgmt-bypass".to_string(),
RuleConfig {
when: "mgmt-net".to_string(),
then: "allow".to_string(),
..RuleConfig::default()
},
),
(
"corp".to_string(),
RuleConfig {
when: "names or mgmt-net and names".to_string(),
then: "deny".to_string(),
mode: "warn".to_string(),
..RuleConfig::default()
},
),
]
.into_iter()
.collect(),
check: [
(
"mgmt-net".to_string(),
CheckConfig {
r#type: "allowed_ip".to_string(),
allow: vec!["10.0.0.0/8".to_string()],
..CheckConfig::default()
},
),
(
"names".to_string(),
CheckConfig {
r#type: "identifiers".to_string(),
allow: vec!["*.corp.example.com".to_string()],
..CheckConfig::default()
},
),
]
.into_iter()
.collect(),
..FilterConfig::default()
};
acme_proxy_policy::filter::from_config(
&filter,
&acme_proxy_core::config::DnsConfig::default(),
None,
false,
)
.expect("the test policy must build")
}
pub struct AdminSessionHandle {
pub cookie: String,
pub csrf: String,
}
pub const ADMIN_PASSWORD: &str = "a-long-enough-password";
pub async fn test_admin_app_logged_in(
config: Config,
) -> (Router, Arc<Database>, AdminSessionHandle) {
test_admin_app_logged_in_with_filter(config, Arc::new(FilterPolicy::default())).await
}
pub async fn test_admin_app_logged_in_with_filter(
config: Config,
filter: Arc<FilterPolicy>,
) -> (Router, Arc<Database>, AdminSessionHandle) {
let (app, database, _signer) = admin_app_with(config, filter).await;
acme_proxy_admin::admin::users::create_user(
"alice",
ADMIN_PASSWORD,
&PasswordContext::empty(),
None,
database.clone(),
)
.await
.expect("the bootstrap operator must be creatable");
let handle = admin_login(&app, "alice", ADMIN_PASSWORD).await;
(app, database, handle)
}
pub async fn admin_login(app: &Router, username: &str, password: &str) -> AdminSessionHandle {
let response = admin_request(
app,
Method::POST,
"/api/session",
None,
Some(serde_json::json!({ "username": username, "password": password })),
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"login must succeed to build a session handle"
);
let cookie = session_cookie_token(&response).expect("login must set the session cookie");
let body = json_body(response).await;
let csrf = body["csrfToken"]
.as_str()
.expect("login must return a csrfToken")
.to_string();
AdminSessionHandle { cookie, csrf }
}
pub async fn admin_login_pending(app: &Router, username: &str, password: &str) -> (String, String) {
let response = admin_request(
app,
Method::POST,
"/api/session",
None,
Some(json!({ "username": username, "password": password })),
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"a correct password answers 200 even when a factor is outstanding"
);
let cookie = session_cookie_token(&response).expect("a pending login must set the cookie");
let body = json_body(response).await;
assert_eq!(
body["mfaRequired"], true,
"this helper is for an operator who owes a second factor"
);
let csrf = body["csrfToken"]
.as_str()
.expect("a pending login must return a csrfToken")
.to_string();
(cookie, csrf)
}
pub async fn enrol_totp(database: Arc<Database>, username: &str) -> Vec<u8> {
let mut user = acme_proxy_store::admin_user::AdminUser::find_by_username(username, &database)
.await
.unwrap()
.expect("the operator must exist before enrolling them");
let enrolment = acme_proxy_admin::admin::mfa::begin_totp_enrolment(
&mut user,
"http://localhost:3001",
database.clone(),
)
.await
.unwrap();
let code = totp_code(&enrolment.secret, 0);
acme_proxy_admin::admin::mfa::confirm_totp_enrolment(&mut user, &code, None, database)
.await
.unwrap()
.expect("a freshly generated code must confirm its own enrolment");
enrolment.secret
}
pub fn between(haystack: &str, start: &str, end: &str) -> String {
let rest = haystack
.split_once(start)
.unwrap_or_else(|| panic!("`{start}` not found in the rendered page"))
.1;
rest.split_once(end)
.unwrap_or_else(|| panic!("`{end}` not found after `{start}`"))
.0
.trim()
.to_string()
}
pub fn base32_decode(encoded: &str) -> Vec<u8> {
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let mut bits = 0u32;
let mut buffer = 0u64;
let mut out = Vec::new();
for character in encoded.bytes() {
let value = ALPHABET
.iter()
.position(|symbol| *symbol == character)
.unwrap_or_else(|| panic!("`{}` is not base32", character as char));
buffer = (buffer << 5) | value as u64;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push(((buffer >> bits) & 0xff) as u8);
}
}
out
}
pub fn totp_code(secret: &[u8], steps: i64) -> String {
use acme_proxy_admin::admin::totp;
totp::totp_at(secret, totp::step_at(now_unix()) + steps, totp::DIGITS)
}
pub fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
pub async fn admin_login_mfa(
app: &Router,
database: Arc<Database>,
username: &str,
password: &str,
secret: &[u8],
) -> AdminSessionHandle {
sqlx::query("UPDATE admin_users SET totp_last_step = NULL WHERE username = ?;")
.bind(username)
.execute(database.raw_pool())
.await
.unwrap();
let (cookie, csrf) = admin_login_pending(app, username, password).await;
let pending = AdminSessionHandle { cookie, csrf };
let response = admin_request(
app,
Method::POST,
"/api/session/mfa",
Some(&pending),
Some(json!({ "code": totp_code(secret, 0) })),
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"the second step must complete the login"
);
let cookie = session_cookie_token(&response).expect("promotion must rotate the cookie");
let body = json_body(response).await;
AdminSessionHandle {
cookie,
csrf: body["csrfToken"]
.as_str()
.expect("a promoted session must return a csrfToken")
.to_string(),
}
}
pub fn session_cookie_token(response: &Response) -> Option<String> {
let raw = response
.headers()
.get(header::SET_COOKIE)?
.to_str()
.ok()?
.to_string();
let value = raw
.split(';')
.next()?
.strip_prefix("__Host-acme_admin_session=")?
.to_string();
(!value.is_empty()).then_some(value)
}
pub async fn admin_request(
app: &Router,
method: Method,
path: &str,
session: Option<&AdminSessionHandle>,
body: Option<serde_json::Value>,
) -> Response {
admin_request_from(app, method, path, session, body, "127.0.0.1:40000").await
}
pub async fn admin_request_from(
app: &Router,
method: Method,
path: &str,
session: Option<&AdminSessionHandle>,
body: Option<serde_json::Value>,
peer: &str,
) -> Response {
let mut builder = Request::builder().method(method).uri(path);
if let Some(session) = session {
builder = builder
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", &session.csrf);
}
let request = match body {
Some(value) => builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(value.to_string()))
.unwrap(),
None => builder.body(Body::empty()).unwrap(),
};
send_from(app, request, peer).await
}
pub async fn admin_page(
app: &Router,
path: &str,
session: Option<&AdminSessionHandle>,
hx: bool,
) -> Response {
let mut builder = Request::builder().method(Method::GET).uri(path);
if let Some(session) = session {
builder = builder.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
);
}
if hx {
builder = builder.header("hx-request", "true");
}
send_from(app, builder.body(Body::empty()).unwrap(), "127.0.0.1:40000").await
}
pub async fn admin_form_request(
app: &Router,
method: Method,
path: &str,
session: Option<&AdminSessionHandle>,
form: Option<&[(&str, &str)]>,
) -> Response {
let mut builder = Request::builder().method(method).uri(path);
if let Some(session) = session {
builder = builder
.header(
header::COOKIE,
format!("__Host-acme_admin_session={}", session.cookie),
)
.header("x-csrf-token", &session.csrf);
}
let request = match form {
Some(pairs) => builder
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(encode_form(pairs)))
.unwrap(),
None => builder.body(Body::empty()).unwrap(),
};
send_from(app, request, "127.0.0.1:40000").await
}
fn encode_form(pairs: &[(&str, &str)]) -> String {
fn encode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for byte in raw.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
b' ' => out.push('+'),
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
pairs
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(value)))
.collect::<Vec<_>>()
.join("&")
}
pub async fn html_body(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).expect("an admin page must be UTF-8")
}
pub async fn json_body(response: Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap_or_else(|error| {
panic!(
"expected a JSON body, got `{}`: {error}",
String::from_utf8_lossy(&bytes)
)
})
}
pub struct BlockingValidator {
typ: &'static str,
calls: Arc<AtomicUsize>,
gate: Arc<tokio::sync::Semaphore>,
entered: Arc<tokio::sync::Semaphore>,
first_only: bool,
}
impl BlockingValidator {
pub fn new(typ: &'static str) -> Self {
Self {
typ,
calls: Arc::new(AtomicUsize::new(0)),
gate: Arc::new(tokio::sync::Semaphore::new(0)),
entered: Arc::new(tokio::sync::Semaphore::new(0)),
first_only: false,
}
}
pub fn gating_the_first(typ: &'static str) -> Self {
Self {
first_only: true,
..Self::new(typ)
}
}
#[must_use]
pub fn handles(
&self,
) -> (
Arc<AtomicUsize>,
Arc<tokio::sync::Semaphore>,
Arc<tokio::sync::Semaphore>,
) {
(
Arc::clone(&self.calls),
Arc::clone(&self.gate),
Arc::clone(&self.entered),
)
}
}
#[async_trait]
impl ChallengeValidator for BlockingValidator {
fn typ(&self) -> &'static str {
self.typ
}
async fn validate(&self, _context: &ValidationContext<'_>) -> Result<(), ChallengeError> {
let first = self.calls.fetch_add(1, Ordering::SeqCst) == 0;
self.entered.add_permits(1);
if first || !self.first_only {
self.gate
.acquire()
.await
.expect("the gate is never closed while a test holds it")
.forget();
}
Ok(())
}
}
pub struct StubValidator {
typ: &'static str,
outcome: Option<String>,
calls: Arc<AtomicUsize>,
}
impl StubValidator {
pub fn passing(typ: &'static str) -> Self {
Self {
typ,
outcome: None,
calls: Arc::new(AtomicUsize::new(0)),
}
}
pub fn failing(typ: &'static str, detail: &str) -> Self {
Self {
typ,
outcome: Some(detail.to_string()),
calls: Arc::new(AtomicUsize::new(0)),
}
}
pub fn counter(&self) -> Arc<AtomicUsize> {
self.calls.clone()
}
}
#[async_trait]
impl ChallengeValidator for StubValidator {
fn typ(&self) -> &'static str {
self.typ
}
async fn validate(&self, _ctx: &ValidationContext<'_>) -> Result<(), ChallengeError> {
self.calls.fetch_add(1, Ordering::SeqCst);
match &self.outcome {
Some(detail) => Err(ChallengeError::IncorrectResponse(detail.clone())),
None => Ok(()),
}
}
}
pub struct RecordingValidator {
typ: &'static str,
seen: Arc<Mutex<Vec<String>>>,
}
impl RecordingValidator {
pub fn new(typ: &'static str) -> Self {
Self {
typ,
seen: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn seen(&self) -> Arc<Mutex<Vec<String>>> {
self.seen.clone()
}
}
#[async_trait]
impl ChallengeValidator for RecordingValidator {
fn typ(&self) -> &'static str {
self.typ
}
async fn validate(&self, ctx: &ValidationContext<'_>) -> Result<(), ChallengeError> {
self.seen.lock().unwrap().push(format!(
"{}|{}|{}|{}",
ctx.identifier, ctx.wildcard, ctx.token, ctx.key_authorization
));
Ok(())
}
}
pub struct FailingSigner;
#[async_trait]
impl SignerBackend for FailingSigner {
async fn issue(
&self,
_order_id: &str,
_csr_der: &[u8],
_identifiers: &[Identifier],
_validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
Err(SignerError::Internal("boom".to_string()))
}
async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
Err(SignerError::Internal("boom".to_string()))
}
}
pub struct GatedSigner {
ca: Arc<LocalCa>,
pub calls: Arc<AtomicUsize>,
gate: Arc<tokio::sync::Semaphore>,
entered: Arc<tokio::sync::Semaphore>,
}
impl GatedSigner {
pub async fn new() -> Self {
Self {
ca: Arc::new(memory_ca().await),
calls: Arc::new(AtomicUsize::new(0)),
gate: Arc::new(tokio::sync::Semaphore::new(0)),
entered: Arc::new(tokio::sync::Semaphore::new(0)),
}
}
#[must_use]
pub fn handles(
&self,
) -> (
Arc<AtomicUsize>,
Arc<tokio::sync::Semaphore>,
Arc<tokio::sync::Semaphore>,
) {
(
Arc::clone(&self.calls),
Arc::clone(&self.gate),
Arc::clone(&self.entered),
)
}
}
#[async_trait]
impl SignerBackend for GatedSigner {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
let first = self.calls.fetch_add(1, Ordering::SeqCst) == 0;
self.entered.add_permits(1);
if first {
let _permit = self
.gate
.acquire()
.await
.expect("the gate is never closed while a test holds it");
}
self.ca
.issue(order_id, csr_der, identifiers, validity)
.await
}
async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
self.ca.revoke(cert_der, reason).await
}
fn info(&self) -> Arc<dyn SignerInfo> {
self.ca.info()
}
fn crl_refresher(&self) -> Option<Arc<dyn CrlRefresher>> {
self.ca.crl_refresher()
}
}
pub struct GatedCrlSigner {
ca: Arc<LocalCa>,
gate: Arc<tokio::sync::Semaphore>,
entered: Arc<tokio::sync::Semaphore>,
}
impl GatedCrlSigner {
pub async fn new() -> Self {
Self {
ca: Arc::new(memory_ca().await),
gate: Arc::new(tokio::sync::Semaphore::new(0)),
entered: Arc::new(tokio::sync::Semaphore::new(0)),
}
}
#[must_use]
pub fn handles(&self) -> (Arc<tokio::sync::Semaphore>, Arc<tokio::sync::Semaphore>) {
(Arc::clone(&self.gate), Arc::clone(&self.entered))
}
}
#[async_trait]
impl SignerBackend for GatedCrlSigner {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
self.ca
.issue(order_id, csr_der, identifiers, validity)
.await
}
async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
self.ca.revoke(cert_der, reason).await
}
fn crl_refresher(&self) -> Option<Arc<dyn CrlRefresher>> {
self.ca.crl_refresher()
}
fn info(&self) -> Arc<dyn SignerInfo> {
Arc::new(GatedCrlInfo {
ca: self.ca.info(),
gate: self.gate.clone(),
entered: self.entered.clone(),
})
}
}
struct GatedCrlInfo {
ca: Arc<dyn SignerInfo>,
gate: Arc<tokio::sync::Semaphore>,
entered: Arc<tokio::sync::Semaphore>,
}
#[async_trait]
impl SignerInfo for GatedCrlInfo {
async fn crl_der(&self) -> Result<Option<Vec<u8>>, SignerError> {
self.entered.add_permits(1);
let _permit = self
.gate
.acquire()
.await
.expect("the gate is never closed while a test holds it");
self.ca.crl_der().await
}
fn revocation_route(&self) -> RevocationRoute {
self.ca.revocation_route()
}
}
pub struct RevokeFailingSigner(pub Arc<LocalCa>);
impl RevokeFailingSigner {
pub async fn new() -> Self {
Self(Arc::new(memory_ca().await))
}
}
#[async_trait]
impl SignerBackend for RevokeFailingSigner {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
self.0.issue(order_id, csr_der, identifiers, validity).await
}
async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
Err(SignerError::Internal("the CA refused".to_string()))
}
fn info(&self) -> Arc<dyn SignerInfo> {
self.0.info()
}
fn crl_refresher(&self) -> Option<Arc<dyn CrlRefresher>> {
self.0.crl_refresher()
}
}
pub struct RevokePersistFailingSigner {
ca: Arc<LocalCa>,
database: std::sync::OnceLock<Arc<Database>>,
pub revocations: Arc<std::sync::atomic::AtomicUsize>,
}
impl RevokePersistFailingSigner {
pub async fn new() -> Self {
Self {
ca: Arc::new(memory_ca().await),
database: std::sync::OnceLock::new(),
revocations: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
}
}
pub fn arm(&self, database: Arc<Database>) {
let _ = self.database.set(database);
}
}
#[async_trait]
impl SignerBackend for RevokePersistFailingSigner {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
self.ca
.issue(order_id, csr_der, identifiers, validity)
.await
}
async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
self.revocations
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.ca.revoke(cert_der, reason).await?;
if let Some(database) = self.database.get() {
database.close().await;
}
Ok(())
}
fn info(&self) -> Arc<dyn SignerInfo> {
self.ca.info()
}
fn crl_refresher(&self) -> Option<Arc<dyn CrlRefresher>> {
self.ca.crl_refresher()
}
}
pub struct TokenStoreSigner(pub Arc<DbTokenStore>);
impl TokenStoreSigner {
pub async fn new() -> Self {
Self::over(Arc::new(Database::connect_in_memory().await.unwrap()))
}
#[must_use]
pub fn over(database: Arc<Database>) -> Self {
Self(Arc::new(DbTokenStore::new(
database,
std::time::Duration::from_secs(3600),
)))
}
}
#[async_trait]
impl SignerBackend for TokenStoreSigner {
async fn issue(
&self,
_order_id: &str,
_csr_der: &[u8],
_identifiers: &[Identifier],
_validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
Err(SignerError::Internal("not a signing backend".to_string()))
}
async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
Ok(())
}
fn info(&self) -> Arc<dyn SignerInfo> {
Arc::new(TokenStoreInfo(self.0.clone()))
}
}
struct TokenStoreInfo(Arc<DbTokenStore>);
impl SignerInfo for TokenStoreInfo {
fn http01_tokens(&self) -> Option<Arc<dyn Http01TokenStore>> {
Some(self.0.clone())
}
fn revocation_route(&self) -> RevocationRoute {
RevocationRoute::Delegated
}
}
#[derive(Default)]
pub struct RecordingSigner {
issued: std::sync::atomic::AtomicBool,
}
impl RecordingSigner {
pub fn was_called(&self) -> bool {
self.issued.load(std::sync::atomic::Ordering::SeqCst)
}
}
#[async_trait]
impl SignerBackend for RecordingSigner {
async fn issue(
&self,
_order_id: &str,
_csr_der: &[u8],
_identifiers: &[Identifier],
_validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
self.issued.store(true, std::sync::atomic::Ordering::SeqCst);
Err(SignerError::Internal("recorded".to_string()))
}
async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
Ok(())
}
}
pub struct DelegatingSigner;
#[async_trait]
impl SignerBackend for DelegatingSigner {
async fn issue(
&self,
_order_id: &str,
_csr_der: &[u8],
_identifiers: &[Identifier],
_validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
Ok(IssueOutcome::Processing)
}
async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
Ok(())
}
}
#[derive(Clone, Copy)]
pub enum AriAnswer {
Window(i64, i64),
WindowWithExplanation(i64, i64, &'static str),
Unreachable,
}
pub struct ScriptedAriSigner {
inner: LocalCa,
answer: AriAnswer,
}
impl ScriptedAriSigner {
pub async fn new(answer: AriAnswer) -> Self {
Self {
inner: memory_ca().await,
answer,
}
}
}
#[async_trait]
impl SignerBackend for ScriptedAriSigner {
async fn issue(
&self,
order_id: &str,
csr_der: &[u8],
identifiers: &[Identifier],
validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
self.inner
.issue(order_id, csr_der, identifiers, validity)
.await
}
async fn revoke(&self, cert_der: &[u8], reason: Option<u32>) -> Result<(), SignerError> {
self.inner.revoke(cert_der, reason).await
}
fn crl_refresher(&self) -> Option<Arc<dyn CrlRefresher>> {
self.inner.crl_refresher()
}
fn info(&self) -> Arc<dyn SignerInfo> {
Arc::new(ScriptedAriInfo {
ca: self.inner.info(),
answer: self.answer,
})
}
}
struct ScriptedAriInfo {
ca: Arc<dyn SignerInfo>,
answer: AriAnswer,
}
#[async_trait]
impl SignerInfo for ScriptedAriInfo {
async fn crl_der(&self) -> Result<Option<Vec<u8>>, SignerError> {
self.ca.crl_der().await
}
async fn ca_chain_pem(&self) -> Option<String> {
self.ca.ca_chain_pem().await
}
async fn renewal_info(&self, _cert_der: &[u8]) -> Result<Option<RenewalWindow>, SignerError> {
match self.answer {
AriAnswer::Window(start, end) => Ok(Some(RenewalWindow::new(start, end))),
AriAnswer::WindowWithExplanation(start, end, url) => Ok(Some(RenewalWindow {
start,
end,
explanation_url: Some(url.to_string()),
})),
AriAnswer::Unreachable => {
Err(SignerError::Internal("upstream unreachable".to_string()))
}
}
}
fn revocation_route(&self) -> RevocationRoute {
self.ca.revocation_route()
}
}
pub struct GarbageChainSigner;
#[async_trait]
impl SignerBackend for GarbageChainSigner {
async fn issue(
&self,
_order_id: &str,
_csr_der: &[u8],
_identifiers: &[Identifier],
_validity: RequestedValidity,
) -> Result<IssueOutcome, SignerError> {
Ok(IssueOutcome::Issued("not a certificate at all".to_string()))
}
async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
Ok(())
}
}
#[derive(Default)]
pub struct RecordingNotifyBackend {
events: Mutex<Vec<NotifyEvent>>,
fail: bool,
}
impl RecordingNotifyBackend {
pub fn failing() -> Self {
Self {
events: Mutex::new(Vec::new()),
fail: true,
}
}
pub fn events(&self) -> Vec<NotifyEvent> {
self.events.lock().unwrap().clone()
}
}
#[async_trait]
impl NotifyBackend for RecordingNotifyBackend {
fn name(&self) -> &'static str {
"recording"
}
async fn send(&self, event: &NotifyEvent) -> Result<(), NotifyError> {
self.events.lock().unwrap().push(event.clone());
if self.fail {
Err(NotifyError::new("recording backend configured to fail"))
} else {
Ok(())
}
}
}
pub struct RejectingCheck {
pub connections: bool,
pub identifiers: bool,
pub internal: bool,
}
impl RejectingCheck {
pub fn connections() -> Self {
Self {
connections: true,
identifiers: false,
internal: false,
}
}
pub fn identifiers() -> Self {
Self {
connections: false,
identifiers: true,
internal: false,
}
}
pub fn failing() -> Self {
Self {
connections: true,
identifiers: false,
internal: true,
}
}
pub fn failing_identifiers() -> Self {
Self {
connections: false,
identifiers: true,
internal: true,
}
}
fn refusal(&self) -> Verdict {
if self.internal {
Verdict::Undecided("resolver exploded".to_string())
} else {
Verdict::Fail("refused by test filter".to_string())
}
}
}
#[async_trait]
impl Check for RejectingCheck {
fn kind(&self) -> &'static str {
"rejecting"
}
fn stages(&self) -> StageSet {
StageSet::both()
}
async fn check_connection(&self, _context: &ConnectionContext<'_>) -> Verdict {
if self.connections {
return self.refusal();
}
Verdict::Pass
}
async fn check_identifiers(&self, _context: &IdentifierContext<'_>) -> Verdict {
if self.identifiers {
return self.refusal();
}
Verdict::Pass
}
}
pub fn policy_of(checks: Vec<(String, Arc<dyn Check>)>) -> Arc<FilterPolicy> {
let mut rules = Vec::new();
for (stage, label) in [
(Stage::Connection, "connection"),
(Stage::Identifiers, "identifiers"),
] {
let names: Vec<&str> = checks
.iter()
.filter(|(_, check)| check.stages().contains(stage))
.map(|(name, _)| name.as_str())
.collect();
if names.is_empty() {
continue;
}
rules.push(Rule {
name: format!("all-{label}"),
when: Condition::parse(&names.join(" and ")).expect("test condition should parse"),
then: Effect::Allow,
message: None,
mode: Mode::Enforce,
});
}
Arc::new(FilterPolicy::new(
checks,
rules,
Effect::Deny,
ProxyPolicy::default(),
))
}
pub fn policy_with(check: Arc<dyn Check>) -> Arc<FilterPolicy> {
policy_of(vec![("only".to_string(), check)])
}
pub async fn send_from(app: &Router, request: Request<Body>, peer: &str) -> Response {
let addr: SocketAddr = peer.parse().expect("peer must be an ip:port");
let mut request = request;
request.extensions_mut().insert(ConnectInfo(addr));
app.clone().oneshot(request).await.unwrap()
}
pub async fn post_from(app: &Router, path: &str, body: String, peer: &str) -> Response {
send_from(
app,
Request::post(path)
.header("content-type", "application/jose+json")
.body(Body::from(body))
.unwrap(),
peer,
)
.await
}
pub async fn fetch_nonce_from(app: &Router, peer: &str) -> String {
let res = send_from(
app,
Request::get(p("/newNonce")).body(Body::empty()).unwrap(),
peer,
)
.await;
res.headers()
.get("replay-nonce")
.expect("newNonce response must carry a Replay-Nonce header")
.to_str()
.unwrap()
.to_string()
}
pub async fn body_json(response: Response) -> Value {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
pub async fn fetch_nonce(app: &Router) -> String {
let res = app
.clone()
.oneshot(Request::get(p("/newNonce")).body(Body::empty()).unwrap())
.await
.unwrap();
res.headers()
.get("replay-nonce")
.expect("newNonce response must carry a Replay-Nonce header")
.to_str()
.unwrap()
.to_string()
}
fn build_jws_parts(
protected: Value,
payload_b64: &str,
sign: impl FnOnce(&[u8]) -> Vec<u8>,
) -> String {
let protected_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&protected).unwrap());
let signing_input = format!("{protected_b64}.{payload_b64}");
let signature = sign(signing_input.as_bytes());
flattened_jws(&protected_b64, payload_b64, &signature)
}
fn encode_payload(payload: &Value) -> String {
BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(payload).unwrap())
}
pub fn build_jws(
alg: &str,
jwk: Value,
url: &str,
nonce: &str,
payload: &Value,
sign: impl FnOnce(&[u8]) -> Vec<u8>,
) -> String {
let protected = json!({ "alg": alg, "jwk": jwk, "nonce": nonce, "url": url });
build_jws_parts(protected, &encode_payload(payload), sign)
}
pub fn build_jws_kid(
alg: &str,
kid: &str,
url: &str,
nonce: &str,
payload: &Value,
sign: impl FnOnce(&[u8]) -> Vec<u8>,
) -> String {
let protected = json!({ "alg": alg, "kid": kid, "nonce": nonce, "url": url });
build_jws_parts(protected, &encode_payload(payload), sign)
}
pub fn build_jws_kid_empty(
alg: &str,
kid: &str,
url: &str,
nonce: &str,
sign: impl FnOnce(&[u8]) -> Vec<u8>,
) -> String {
let protected = json!({ "alg": alg, "kid": kid, "nonce": nonce, "url": url });
build_jws_parts(protected, "", sign)
}
pub fn build_jws_no_nonce(
alg: &str,
jwk: Value,
url: &str,
payload: &Value,
sign: impl FnOnce(&[u8]) -> Vec<u8>,
) -> String {
let protected = json!({ "alg": alg, "jwk": jwk, "url": url });
build_jws_parts(protected, &encode_payload(payload), sign)
}
pub fn flattened_jws(protected_b64: &str, payload_b64: &str, signature: &[u8]) -> String {
json!({
"protected": protected_b64,
"payload": payload_b64,
"signature": BASE64_URL_SAFE_NO_PAD.encode(signature),
})
.to_string()
}
pub fn build_eab(kid: &str, hmac_secret: &[u8], url: &str, account_jwk: &Value) -> Value {
let protected = json!({ "alg": "HS256", "kid": kid, "url": url });
let protected_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(&protected).unwrap());
let payload_b64 = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(account_jwk).unwrap());
let signing_input = format!("{protected_b64}.{payload_b64}");
let key = hmac::Key::new(hmac::HMAC_SHA256, hmac_secret);
let signature = hmac::sign(&key, signing_input.as_bytes());
json!({
"protected": protected_b64,
"payload": payload_b64,
"signature": BASE64_URL_SAFE_NO_PAD.encode(signature.as_ref()),
})
}
pub trait TestSigner {
fn alg(&self) -> &'static str;
fn jwk(&self) -> Value;
fn sign_input(&self, input: &[u8]) -> Vec<u8>;
fn sign(&self, url: &str, nonce: &str, payload: &Value) -> String {
build_jws(self.alg(), self.jwk(), url, nonce, payload, |input| {
self.sign_input(input)
})
}
fn sign_kid(&self, kid: &str, url: &str, nonce: &str, payload: &Value) -> String {
build_jws_kid(self.alg(), kid, url, nonce, payload, |input| {
self.sign_input(input)
})
}
fn sign_kid_empty(&self, kid: &str, url: &str, nonce: &str) -> String {
build_jws_kid_empty(self.alg(), kid, url, nonce, |input| self.sign_input(input))
}
fn sign_inner(&self, url: &str, payload: &Value) -> String {
build_jws_no_nonce(self.alg(), self.jwk(), url, payload, |input| {
self.sign_input(input)
})
}
}
pub struct EcSigner {
key_pair: EcdsaKeyPair,
rng: SystemRandom,
}
impl EcSigner {
pub fn new() -> Self {
let rng = SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
Self::from_pkcs8(pkcs8.as_ref())
}
pub fn from_pkcs8(pkcs8_der: &[u8]) -> Self {
let rng = SystemRandom::new();
let key_pair =
EcdsaKeyPair::from_pkcs8(&signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8_der, &rng)
.unwrap();
Self { key_pair, rng }
}
}
impl TestSigner for EcSigner {
fn alg(&self) -> &'static str {
"ES256"
}
fn jwk(&self) -> Value {
let sec1 = self.key_pair.public_key().as_ref();
json!({
"kty": "EC",
"crv": "P-256",
"x": BASE64_URL_SAFE_NO_PAD.encode(&sec1[1..33]),
"y": BASE64_URL_SAFE_NO_PAD.encode(&sec1[33..65]),
})
}
fn sign_input(&self, input: &[u8]) -> Vec<u8> {
self.key_pair
.sign(&self.rng, input)
.unwrap()
.as_ref()
.to_vec()
}
}
pub struct RsaSigner {
key_pair: RsaKeyPair,
rng: SystemRandom,
}
impl RsaSigner {
pub fn new() -> Self {
let pkcs8 = include_bytes!("../fixtures/rsa_test_key.pk8");
let key_pair = RsaKeyPair::from_pkcs8(pkcs8).unwrap();
Self {
key_pair,
rng: SystemRandom::new(),
}
}
}
impl TestSigner for RsaSigner {
fn alg(&self) -> &'static str {
"RS256"
}
fn jwk(&self) -> Value {
let blocks = simple_asn1::from_der(self.key_pair.public_key().as_ref()).unwrap();
let (n, e) = match &blocks[0] {
ASN1Block::Sequence(_, items) => {
let int = |block: &ASN1Block| match block {
ASN1Block::Integer(_, v) => v.to_bytes_be().1,
_ => panic!("expected INTEGER in RSA public key"),
};
(int(&items[0]), int(&items[1]))
}
_ => panic!("unexpected RSA public key DER structure"),
};
json!({
"kty": "RSA",
"n": BASE64_URL_SAFE_NO_PAD.encode(&n),
"e": BASE64_URL_SAFE_NO_PAD.encode(&e),
})
}
fn sign_input(&self, input: &[u8]) -> Vec<u8> {
let mut sig = vec![0u8; 256];
self.key_pair
.sign(&signature::RSA_PKCS1_SHA256, &self.rng, input, &mut sig)
.unwrap();
sig
}
}
pub struct TempDir(std::path::PathBuf);
impl TempDir {
pub fn new(label: &str) -> Self {
let path =
std::env::temp_dir().join(format!("acme-proxy-{label}-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
pub fn path(&self) -> &std::path::Path {
&self.0
}
pub fn join(&self, name: &str) -> std::path::PathBuf {
self.0.join(name)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(unix)]
pub fn write_script(dir: &TempDir, name: &str, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
pub mod acme {
use super::{BASE, HOST, TestSigner, body_json, fetch_nonce, p};
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tower::ServiceExt;
#[must_use]
pub fn url(path: &str) -> String {
format!("{BASE}{path}")
}
pub async fn post(app: &Router, path: &str, body: String) -> Response {
app.clone()
.oneshot(
Request::post(path)
.header("content-type", "application/jose+json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
pub async fn body_text(response: Response) -> String {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
pub async fn assert_problem(response: Response, status: StatusCode, typ: &str) {
assert_eq!(response.status(), status);
let problem = body_json(response).await;
assert_eq!(problem["type"], typ, "problem type");
}
pub async fn assert_problem_detail(
response: Response,
status: StatusCode,
typ: &str,
detail: &str,
) {
assert_eq!(response.status(), status);
let problem = body_json(response).await;
assert_eq!(problem["type"], typ, "problem type");
let got = problem["detail"].as_str().unwrap_or_default();
assert!(
got.contains(detail),
"detail {got:?} does not contain {detail:?}"
);
}
pub async fn register(app: &Router, signer: &impl TestSigner) -> String {
let nonce = fetch_nonce(app).await;
let payload = json!({ "termsOfServiceAgreed": true });
let response = post(
app,
&p("/newAccount"),
signer.sign(&url("/newAccount"), &nonce, &payload),
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
location(&response)
}
pub fn location(response: &Response) -> String {
response
.headers()
.get("location")
.and_then(|value| value.to_str().ok())
.expect("this response must set a Location header")
.to_string()
}
#[must_use]
pub fn path_of(absolute: &str) -> String {
absolute
.strip_prefix(HOST)
.unwrap_or_else(|| panic!("{absolute} is not under {HOST}"))
.to_string()
}
pub async fn new_order(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
names: &[&str],
) -> (String, Value) {
let identifiers: Vec<Value> = names
.iter()
.map(|name| json!({ "type": "dns", "value": name }))
.collect();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid(
account_url,
&url("/newOrder"),
&nonce,
&json!({ "identifiers": identifiers }),
);
let response = post(app, &p("/newOrder"), body).await;
assert_eq!(response.status(), StatusCode::CREATED);
let order_url = location(&response);
(order_url, body_json(response).await)
}
pub async fn post_as_get(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
absolute_url: &str,
) -> Value {
let response = post_as_get_raw(app, signer, account_url, absolute_url).await;
assert_eq!(response.status(), StatusCode::OK);
body_json(response).await
}
pub async fn post_as_get_raw(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
absolute_url: &str,
) -> Response {
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid_empty(account_url, absolute_url, &nonce);
post(app, &path_of(absolute_url), body).await
}
pub async fn trigger(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
challenge_url: &str,
) -> Response {
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid(account_url, challenge_url, &nonce, &json!({}));
post(app, &path_of(challenge_url), body).await
}
pub async fn await_challenge(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
challenge_url: &str,
) -> Value {
for _ in 0..600 {
let response = trigger(app, signer, account_url, challenge_url).await;
let challenge = crate::common::body_json(response).await;
if challenge["status"] != "processing" {
return challenge;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("`{challenge_url}` never left `processing`");
}
pub async fn trigger_and_settle(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
challenge_url: &str,
) -> Value {
let response = trigger(app, signer, account_url, challenge_url).await;
assert_eq!(response.status(), StatusCode::OK);
await_challenge(app, signer, account_url, challenge_url).await
}
pub async fn drive_to_ready(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order: &Value,
) {
let authorizations: Vec<String> = order["authorizations"]
.as_array()
.expect("an order carries its authorizations")
.iter()
.map(|value| value.as_str().unwrap().to_string())
.collect();
for authz_url in &authorizations {
let authz = post_as_get(app, signer, account_url, authz_url).await;
let challenge_url = authz["challenges"][0]["url"].as_str().unwrap().to_string();
trigger_and_settle(app, signer, account_url, &challenge_url).await;
}
}
pub async fn ready_order(
app: &Router,
signer: &impl TestSigner,
names: &[&str],
) -> (String, String, Value) {
let account_url = register(app, signer).await;
let (order_url, order) = new_order(app, signer, &account_url, names).await;
drive_to_ready(app, signer, &account_url, &order).await;
let order = post_as_get(app, signer, &account_url, &order_url).await;
(account_url, order_url, order)
}
pub async fn await_order(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
) -> Value {
for _ in 0..600 {
let order = post_as_get(app, signer, account_url, order_url).await;
if order["status"] != "processing" {
return order;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
panic!("`{order_url}` never left `processing`");
}
pub async fn finalize_and_settle(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
order_url: &str,
names: &[&str],
) -> Value {
let response = finalize(
app,
signer,
account_url,
&format!("{order_url}/finalize"),
names,
)
.await;
assert_eq!(response.status(), StatusCode::OK, "finalize");
await_order(app, signer, account_url, order_url).await
}
pub async fn finalize(
app: &Router,
signer: &impl TestSigner,
account_url: &str,
finalize_url: &str,
names: &[&str],
) -> Response {
let csr = super::make_csr_for(names);
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid(account_url, finalize_url, &nonce, &json!({ "csr": csr }));
post(app, &path_of(finalize_url), body).await
}
pub async fn issue_certificate(
app: &Router,
signer: &impl TestSigner,
names: &[&str],
) -> (String, String, String) {
let (account_url, order_url, order) = ready_order(app, signer, names).await;
let finalize_url = order["finalize"].as_str().unwrap().to_string();
let response = finalize(app, signer, &account_url, &finalize_url, names).await;
assert_eq!(response.status(), StatusCode::OK, "finalize");
let order = await_order(app, signer, &account_url, &order_url).await;
let certificate_url = order["certificate"]
.as_str()
.expect("a valid order carries a certificate URL")
.to_string();
let nonce = fetch_nonce(app).await;
let body = signer.sign_kid_empty(&account_url, &certificate_url, &nonce);
let response = post(app, &path_of(&certificate_url), body).await;
assert_eq!(response.status(), StatusCode::OK, "certificate");
let pem = body_text(response).await;
(account_url, order_url, pem)
}
}
pub async fn certified_order(
database: &acme_proxy_store::db::Database,
account: uuid::Uuid,
not_after: Option<i64>,
) -> acme_proxy_store::order::Order {
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::order::Order;
let mut order = Order::create(
"default",
account,
vec![Identifier::dns("live.example.com")],
2_000_000_000,
None,
None,
database,
)
.await
.unwrap();
order
.finalize(
"-----BEGIN CERTIFICATE-----\n...".to_string(),
order.id.simple().to_string(),
vec![1],
not_after,
database,
)
.await
.unwrap();
order
}
pub async fn bound_eab(
database: &acme_proxy_store::db::Database,
count: u8,
not_after: Option<i64>,
) -> (String, Vec<uuid::Uuid>) {
use acme_proxy_store::account::Account;
use acme_proxy_store::eab::Eab;
let eab = Eab::create(Some("tenant".to_string()), None, database)
.await
.unwrap();
let mut accounts = Vec::new();
for index in 0..count {
let (mut account, _) = Account::find_or_create(
"default",
&[eab.kid.as_bytes()[15], index, 99],
vec![],
&acme_proxy_core::audit::ClientContext::default(),
database,
)
.await
.unwrap();
account.set_eab_kid(eab.kid, database).await.unwrap();
certified_order(database, account.id, not_after).await;
accounts.push(account.id);
}
(eab.kid.to_string(), accounts)
}