Challenge Validation
Challenge validation is the process by which Akāmu probes applicant servers to verify domain ownership. All validation is asynchronous and intentionally infallible — errors are recorded in the database rather than propagated.
Dispatch
The entry point is validation::validate_challenge in src/validation/mod.rs. It is called from routes::challenge::respond_challenge after marking the challenge as processing.
#![allow(unused)]
fn main() {
pub async fn validate_challenge(
state: &Arc<AppState>,
params: ChallengeParams<'_>,
) -> &'static str
}
ChallengeParams carries all per-challenge inputs as named fields:
#![allow(unused)]
fn main() {
pub struct ChallengeParams<'a> {
pub challenge_id: &'a str,
pub authz_id: &'a str,
pub order_id: &'a str,
pub chall_type: &'a str,
pub id_type: &'a str,
pub id_value: &'a str,
pub key_auth: &'a str,
pub token: &'a str,
pub onion_csr_der: Option<&'a [u8]>,
pub account_id: &'a str,
}
}
validate_challenge calls dispatch(...), which routes to one of five validators. email-reply-00 is handled separately — see email-reply-00 two-phase model below.
chall_type | Module | Function |
|---|---|---|
"http-01" | validation::http01 | validate(domain, token, key_auth, port, allow_private_ips, client) |
"dns-01" | validation::dns01 | validate(domain, key_auth, validate_dnssec, dot_server_name) |
"tls-alpn-01" | validation::tls_alpn01 | validate(id_type, domain, key_auth) |
"dns-persist-01" | validation::dns_persist_01 | validate(domain, account_uri, issuer_domains, resolver_addr, validate_dnssec, dot_server_name) |
"onion-csr-01" | validation::onion_csr_01 | validate(domain, csr_der, key_auth) |
| Any other | — | Returns AcmeError::IncorrectResponse |
Note:
email-reply-00does NOT appear in the dispatch table above. Client POST to the challenge URL triggerssend_challenge_email(Phase 1), not a network probe. Validation completes later via the webhook endpoint. See the section below for the full model.
email-reply-00 two-phase model
email-reply-00 (src/validation/email_reply_00.rs) works differently from all other
challenge types. Instead of a network probe, it uses a two-channel token delivered by
email, with completion driven by an inbound webhook rather than by validate_challenge.
Phase 1 — client POST triggers send_challenge_email
When the ACME client POSTs to the challenge URL, the route handler calls
email_reply_00::send_challenge_email(state, challenge_id, email_addr, token_part2_b64)
instead of spawning a validate_challenge task. The function:
- Generates
token-part1: 20 random bytes encoded as base64url (≥128 bits of entropy). - Generates a unique
Message-IDof the form<uuid@from-domain>. - Writes both values to
challenges.email_token_part1andchallenges.email_message_idin the database before invoking the send script, so a script failure leaves the token record in a recoverable state. - Invokes the configured
send_scriptwithenv_clear(), passingACME_TO,ACME_FROM,ACME_SUBJECT,ACME_MESSAGE_ID,ACME_AUTO_SUBMITTED, andACME_TOKEN_PART2as environment variables. The script must exit 0 on success. - If the script exits non-zero or times out, returns an error and the route handler
marks the challenge
"invalid". Otherwise the challenge stays"processing"and the client polls until Phase 2 completes it.
Phase 2 — webhook receives email reply via verify_response
The MTA that receives the applicant’s reply POSTs the parsed reply to
POST /acme/email-webhook. This endpoint does not use ACME JWS authentication;
instead it verifies the X-Akamu-Signature: sha256=<hex> header against the raw
request body using HMAC-SHA256 with email_challenge.webhook_hmac_secret.
After the HMAC check passes, email_reply_00::verify_response(state, payload) is called.
The payload is a WebhookPayload struct with fields from, in_reply_to, dkim_domain,
dkim_status, and body. The function:
- Looks up the challenge via
challenges.email_message_id = payload.in_reply_tousing the write pool (state.db) to avoid stale WAL reads that could miss Phase 1 writes. - Checks that the challenge is in
"processing"state. - Checks that the authorization has not expired.
- Verifies that
payload.frommatches the identifier’s email address (local-part case-sensitive, domain case-insensitive per RFC 5321 §2.4). - Verifies that
payload.dkim_domainmatches the domain part ofpayload.from(case-insensitive), enforcing RFC 8823 §3.2. - Verifies that
payload.dkim_statusis"pass"(case-insensitive to accommodate MTAs that report"Pass"). - Extracts the base64url payload between
-----BEGIN ACME RESPONSE-----/-----END ACME RESPONSE-----delimiters; rejects if absent, whitespace-only, non-ASCII, or longer than 512 bytes. - Computes the expected digest:
SHA-256(token-part1 || token-part2 || "." || thumbprint)where both token parts are the stored base64url strings andthumbprintis the account’s JWK thumbprint (fromaccounts.jwk_thumbprint). - Compares the decoded response bytes with the digest using
constant_time_eq. - On match, calls
on_valid; on any mismatch or error, callson_invalid.
The webhook handler always returns HTTP 200 regardless of outcome (to prevent oracle attacks on the HMAC or challenge state).
sequenceDiagram
participant Client as ACME Client
participant H as Route Handler
participant E as send_challenge_email
participant Script as send_script
participant Inbox as Applicant Inbox
participant MTA as Inbound MTA
participant W as Webhook Handler
participant V as verify_response
participant DB as Database
Client->>H: POST /acme/.../chall/... (email-reply-00)
H->>E: send_challenge_email(challenge_id, email, token_part2)
E->>DB: INSERT email_token_part1, email_message_id
E->>Script: exec send_script (ACME_TO, ACME_FROM, ACME_SUBJECT, ...)
Script->>Inbox: delivers challenge email
E-->>H: Ok(())
H->>DB: challenge status = processing
H-->>Client: 200 processing
Note over Client: Client polls authorization URL
Inbox->>MTA: applicant replies to challenge email
MTA->>W: POST /acme/email-webhook (X-Akamu-Signature: sha256=...)
W->>W: verify HMAC-SHA256
W->>V: verify_response(payload)
V->>DB: lookup challenge by email_message_id
V->>V: verify From, DKIM domain, DKIM status, response digest
alt digest matches
V->>DB: challenge/authz/order = valid
else mismatch or error
V->>DB: challenge/authz/order = invalid
end
W-->>MTA: 200 OK
dns-persist-01 account pre-check
For dns-persist-01 only, validate_challenge performs an account status check before calling dispatch. Because the TXT record is long-lived and may have been provisioned weeks before the order is placed, the account could have been deactivated or revoked in the intervening time. The pre-check queries the database:
SELECT status FROM accounts WHERE id = ?
- If the account status is
"valid", dispatch proceeds normally. - If the status is anything else (e.g.,
"deactivated","revoked"),on_invalidis called immediately withAcmeError::Unauthorizedand the challenge is markedinvalidwithout any DNS query. - If the database query itself fails,
on_invalidis called withAcmeError::Internal.
After dispatch returns, validate_challenge calls either on_valid or on_invalid to update the database.
Validation flow
sequenceDiagram
participant Client as ACME Client
participant H as Route Handler
participant V as validate_challenge
participant D as dispatch
participant Ext as Applicant Server
participant DB as SQLite
Client->>H: POST /acme/.../chall/...
H->>DB: challenge status = processing
H->>V: tokio::spawn
H-->>Client: 200 processing
V->>D: chall_type, domain, key_auth
alt http-01
D->>Ext: GET /.well-known/acme-challenge/TOKEN
Ext-->>D: 200 key_authorization body
else dns-01
D->>Ext: TXT _acme-challenge.DOMAIN
Ext-->>D: TXT record value
else tls-alpn-01
D->>Ext: TLS connect port 443, ALPN acme-tls/1
Ext-->>D: Certificate with id-pe-acmeIdentifier
else dns-persist-01
D->>Ext: TXT _validation-persist.DOMAIN
Ext-->>D: TXT record value
end
alt probe succeeded
V->>DB: BEGIN TRANSACTION
V->>DB: challenge status = valid
V->>DB: authorization status = valid
V->>DB: order status = ready
V->>DB: COMMIT
else probe failed
V->>DB: BEGIN TRANSACTION
V->>DB: challenge status = invalid
V->>DB: authorization status = invalid
V->>DB: order status = invalid
V->>DB: COMMIT
end
Note over Client: Client polls authorization URL
Background execution
Validation runs inside a tokio::spawn task, not in the request handler’s async context:
#![allow(unused)]
fn main() {
let handle = tokio::spawn(async move {
validation::validate_challenge(
&state_clone,
ChallengeParams {
challenge_id: &challenge_id,
authz_id: &authz_id,
order_id: &order_id,
chall_type: &chall_type,
id_type: &id_type,
id_value: &id_value,
key_auth: &key_auth,
token: &token,
onion_csr_der: onion_csr_der.as_deref(), // Some(der) for onion-csr-01, None otherwise
account_id: &account_id,
},
)
.await;
});
// Observer task: log panics without letting them go silent.
tokio::spawn(async move {
if let Err(e) = handle.await {
tracing::error!(
"challenge {challenge_id_for_log}: validation task panicked: {e:?}"
);
}
});
}
The challenge handler returns the processing status immediately. The client must poll the authorization URL to detect completion.
The observer task pattern ensures that panics inside the validation task are logged via tracing::error! rather than silently discarded (which would happen if the JoinHandle were simply dropped).
State cascade diagram
stateDiagram-v2
direction TB
state "Challenge" as chall {
[*] --> ch_pending
ch_pending --> ch_processing : client responds
ch_processing --> ch_valid : probe OK
ch_processing --> ch_invalid : probe failed
}
state "Authorization" as authz {
[*] --> az_pending
az_pending --> az_valid : challenge valid
az_pending --> az_invalid : challenge invalid
}
state "Order" as ord {
[*] --> or_pending
or_pending --> or_ready : all authzs valid
or_pending --> or_invalid : any authz invalid
}
chall --> authz : atomic DB transaction
authz --> ord : atomic DB transaction
State transitions on success (on_valid)
All three updates run inside a single database transaction:
UPDATE challenges SET status = 'valid', validated = <now> WHERE id = <challenge_id>UPDATE authorizations SET status = 'valid' WHERE id = <authz_id>- Conditionally advance the order to
readyusing a singleUPDATE orders SET status = 'ready' WHERE id = <order_id> AND NOT EXISTS (SELECT 1 FROM authorizations WHERE order_id = <order_id> AND status != 'valid'). This replaces the previous SELECT COUNT(*) + conditional UPDATE pattern; it saves a round-trip on the common single-identifier path.
If any step fails (e.g., a database error), a warning is logged and the transaction is rolled back. The challenge remains in processing status.
State transitions on failure (on_invalid)
All three updates run inside a single database transaction:
UPDATE challenges SET status = 'invalid', error = <json> WHERE id = <challenge_id>UPDATE authorizations SET status = 'invalid' WHERE id = <authz_id>- Look up the parent order ID and mark the order
invalid.
If the transaction fails (e.g., a database error), a warning is logged.
http-01 validator (src/validation/http01.rs)
Uses hyper (already a transitive dependency via axum) as the HTTP client.
Validation steps:
- Construct the URL
http://<domain>/.well-known/acme-challenge/<token>. - SSRF guard — initial target: before making any connection, resolve the target host and reject it if any returned address is in a private, loopback, link-local, or otherwise non-globally-routable range (RFC 1918,
169.254.0.0/16,::1,fe80::/10,fc00::/7, etc.). This guard applies to both IP literals and hostnames. Bypassed only whenhttp_validation_allow_private_ips = true. - Send a GET request via
hyper_util::client::legacy::Client. - Check the response status. 3xx redirects are followed (up to 10 hops, including redirects to HTTPS targets).
- SSRF guard — redirect targets: each redirect target is also subjected to the same IP check before following it.
- Check that the final response status is 2xx.
- Read up to 1 MiB of the response body.
- Decode as UTF-8 and trim whitespace.
- Compare with
key_auth. Any mismatch returnsAcmeError::IncorrectResponse.
Error mapping:
- Connection or parse failure →
AcmeError::Connection - Non-2xx status →
AcmeError::IncorrectResponse - Body exceeds 1 MiB →
AcmeError::IncorrectResponse - Key auth mismatch →
AcmeError::IncorrectResponse - Initial or redirect target resolves to blocked IP →
AcmeError::IncorrectResponse
dns-01 validator (src/validation/dns01.rs)
Uses crate::dns::dns_query backed by hickory_resolver. DNS-over-TLS (DoT) is supported when server.dns_dot_server_name is set.
Validation steps:
- Strip any leading
*.prefix from the domain (RFC 8555 §8.4 requires this for wildcard orders). - Construct the query name
_acme-challenge.<base_domain>. - Compute
expected = base64url(SHA-256(key_auth)). - Perform a TXT record lookup via
crate::dns::dns_query(UDP, DNSSEC-aware, or DoT depending on config). - For each TXT record, join all character-strings (TXT records may be split) and compare the trimmed result with
expected. - If at least one record matches, return
Ok(()).
Error mapping:
- DNS lookup failure →
AcmeError::Dns - No matching TXT record →
AcmeError::IncorrectResponse
The inner function validate_with_resolver(domain, key_auth, resolver_addr, validate_dnssec, dot_server_name) accepts explicit resolver settings for testability. Unit tests provide a local UDP stub DNS server.
tls-alpn-01 validator (src/validation/tls_alpn01.rs)
Uses rustls and tokio-rustls. Supports both DNS identifiers (RFC 8737) and IP identifiers (RFC 8738).
Validation steps:
- Compute
expected_hash = SHA-256(key_auth). - For IP identifiers (RFC 8738 §4): convert the IP address to its reverse-DNS form for SNI (
1.2.3.4→4.3.2.1.in-addr.arpa; IPv6 uses the nibble-expanded.ip6.arpaform). For DNS identifiers, the SNI is the identifier value directly. - Build a
rustls::ClientConfigthat:- Accepts any server certificate without chain validation (
AcceptAnyCertcustom verifier). - Advertises only the ALPN protocol
"acme-tls/1". - Supports both TLS 1.2 and TLS 1.3.
- Accepts any server certificate without chain validation (
- TCP-connect to
<id_value>:443(the raw IP or DNS name, not the reverse-DNS SNI). - Perform the TLS handshake with the SNI from step 2.
- Extract the end-entity certificate from the peer certificate chain.
- Call
verify_acme_cert(id_type, id_value, cert_der, &expected_hash).
verify_acme_cert walks the certificate DER manually:
- Finds the
id-pe-acmeIdentifierextension (OID1.3.6.1.5.5.7.1.31). - Checks it is marked critical.
- Checks its value is
OCTET STRING(32 bytes)equal toexpected_hash. - Finds the SubjectAlternativeName extension.
- For DNS identifiers: checks it contains
id_valueas adNSName(tag0x82). - For IP identifiers: checks it contains
id_valueas aniPAddress(tag0x87) encoded as 4 (IPv4) or 16 (IPv6) raw bytes.
The DER walker (find_extension_value) navigates the Certificate → TBSCertificate → Extensions structure using hand-written TLV parsing helpers (read_tlv, decode_length, strip_sequence, etc.). This approach avoids requiring the full synta decoder for a security-critical path.
Error mapping:
- Invalid server name or IP-to-reverse-DNS conversion failure →
AcmeError::Tls - TCP connect failure →
AcmeError::Connection - TLS handshake failure →
AcmeError::Tls - Missing or non-critical
id-pe-acmeIdentifier→AcmeError::IncorrectResponse - Hash mismatch →
AcmeError::IncorrectResponse - Missing SAN →
AcmeError::IncorrectResponse - Identifier not found in SAN →
AcmeError::IncorrectResponse
AcceptAnyCert
The AcceptAnyCert struct implements rustls::client::danger::ServerCertVerifier. It returns Ok(ServerCertVerified::assertion()) unconditionally for every certificate. Chain validation is intentionally bypassed because the tls-alpn-01 certificate is self-signed and issued by the ACME client for validation purposes only. All semantic checks are performed by verify_acme_cert instead.
dns-persist-01 validator (src/validation/dns_persist_01.rs)
Uses crate::dns::dns_query backed by hickory_resolver. The resolver address comes from server.dns_persist01_resolver_addr when set; if absent it falls back to server.dns_resolver_addr, and if that is also absent it uses the system default. DNS-over-TLS is supported when server.dns_dot_server_name is set.
Unlike the other challenge types, dns-persist-01 does not use a token · thumbprint key authorization. The key_auth value passed to the validator is the requesting account’s full URI (constructed as <base_url>/acme/account/<account_id> in routes::challenge). This URI is matched directly against the accounturi= field in the TXT record.
Validation steps:
- Strip any leading
*.prefix from the domain; record whether the order is a wildcard. - Construct the query name
_validation-persist.<base_domain>. - Perform a TXT record lookup via
crate::dns::dns_query. - For each TXT record value, call
matches_record(value, issuer_domains, account_uri, is_wildcard, now). - If at least one record matches, return
Ok(()).
matches_record
matches_record is pub(crate) and is unit-tested independently of the DNS stack.
#![allow(unused)]
fn main() {
pub(crate) fn matches_record(
raw: &str,
expected_issuers: &[&str],
expected_account_uri: &str,
require_wildcard_policy: bool,
now: i64,
) -> bool
}
It splits the raw TXT value on ; and applies the following checks in order:
- The first token (trimmed, trailing dot stripped, lowercased) equals any entry in
expected_issuers(same normalization applied). Multiple issuer domains are supported. - Among the remaining tokens,
accounturi=<uri>is present and the URI matchesexpected_account_uriexactly (case-sensitive). - If
require_wildcard_policyis true,policy=wildcardis present among the tokens. - If a
persistUntil=<ts>token is present,parse_persist_until(ts)returns a Unix timestamp that is greater than or equal tonow.
Unknown tokens are silently ignored. The function returns false as soon as any required condition is not met.
parse_persist_until
A pure-Rust, zero-dependency parser for the YYYY-MM-DDTHH:MM:SSZ timestamp format (lowercase z is also accepted). It performs the proleptic Gregorian day count from the Unix epoch without using any external date/time crate. Returns None for malformed input (wrong separators, out-of-range fields, missing Z suffix).
Error mapping for dns-persist-01:
- DNS lookup failure →
AcmeError::Dns - No matching TXT record →
AcmeError::IncorrectResponse
onion-csr-01 validator (src/validation/onion_csr_01.rs)
Implements server-side validation for hidden-service domain ownership per RFC 9799 §3.2. The client submits a DER-encoded CSR in the challenge response payload ({"csr": "<base64url>"}); the handler decodes it and passes it to this validator via onion_csr_der.
Validation steps:
- Decode hidden-service public key: extract the 32-byte Ed25519 public key from the v3
.onionaddress label. The label is 56 base32 characters encoding 35 bytes:[pubkey(32)] || [checksum(2)] || [version=0x03(1)]. Version byte must be0x03; v2 addresses (16-character label) are rejected. - Parse CSR: decode
csr_deras a DER PKCS#10CertificationRequestusing synta. - Re-encode CRI: re-encode the
CertificationRequestInfoto obtain the exact bytes that were signed. - Verify CSR self-signature: call
BackendPublicKey::verify_signatureon the CRI bytes using the CSR’s own public key. ReturnsIncorrectResponseif invalid. - Verify
cabf-onion-csr-nonceextension (OID2.23.140.41): the extension value must be a DERUTF8String(orIA5String/ raw bytes) whose decoded string equalskey_auth(token.thumbprint). ReturnsIncorrectResponseif absent or mismatched. - Verify hidden-service Ed25519 signature over the CRI bytes using the public key from step 1. Two cases are accepted:
- If the outer CSR signature (the
BIT STRINGat the top level) verifies with the hidden-service Ed25519 key, the challenge passes. - Otherwise, if the CSR’s own public key matches the hidden-service key, the self-signature from step 4 already proves key control and no separate HS signature is required.
If neither condition holds,
IncorrectResponseis returned.
- If the outer CSR signature (the
- Verify SAN: check that the
.oniondomain appears as adNSNamein the CSR’sSubjectAlternativeNameextension.
The validate_onion_v3(domain) helper (exported as pub) can be called by the order/authorization handler to reject non-v3 .onion domains before creating a challenge.
Error mapping:
- Cannot decode
.onionpublic key →AcmeError::IncorrectResponse - CSR parse failure →
AcmeError::IncorrectResponse - CSR self-signature invalid →
AcmeError::IncorrectResponse - Missing or mismatched
cabf-onion-csr-nonceextension →AcmeError::IncorrectResponse - Hidden-service signature verification failed →
AcmeError::IncorrectResponse - Domain not found in CSR SAN →
AcmeError::IncorrectResponse