Introduction
Akāmu is a self-hosted certificate authority that speaks the ACME protocol defined in RFC 8555. It is written in Rust and is designed to be operated inside a private network or behind a reverse proxy, issuing X.509 certificates to ACME clients such as certbot, acme.sh, or any RFC 8555-compliant library. The project is organized as a Cargo workspace. In addition to the server binary, it ships standalone client libraries — see Client Libraries.
For a detailed breakdown of RFC and draft coverage — including which sections are implemented, which are intentionally omitted, and post-quantum support — see the RFC Support Reference.
What it does
- Implements the full RFC 8555 ACME server protocol: directory, nonces, accounts, orders, authorizations, challenges, certificate issuance, and revocation.
- Validates domain ownership using http-01, dns-01, tls-alpn-01, and dns-persist-01 challenge types (RFC 8555 §8, RFC 8737, and the Let’s Encrypt dns-persist-01 specification).
- Issues end-entity certificates signed by a built-in Certificate Authority whose key and self-signed root are generated automatically on first run, or loaded from existing PEM files.
- Maintains a SQLite database for all ACME objects (accounts, orders, authorizations, challenges, certificates, nonces).
- Generates and serves CRLs (Certificate Revocation Lists).
- Exposes OCSP responder URLs in issued certificates when configured.
- Implements the ACME Renewal Information extension (RFC 9773) so ACME clients know when to renew.
- Optionally appends issued certificates to a Merkle Tree Certificate transparency log using the
synta-mtclibrary. - When
external_account_required = true, performs full HMAC verification of theexternalAccountBindingJWS (HS256/HS384/HS512), confirms the payload is the account key, and atomically consumes the EAB key on account creation. Keys are provisioned in the TOML config under[server.eab_keys]. - Optionally terminates TLS directly using rustls, with an auto-generated certificate on first run. Supports mutual TLS (mTLS) client certificate authentication with configurable CA trust anchors, chain depth, RSA modulus enforcement, and post-quantum client certificate acceptance.
What it does not do
- It does not serve the CRL or OCSP responses over HTTP itself; those endpoints must be provided separately if you enable
crl_urlorocsp_url. - It does not support wildcard certificates via http-01 or tls-alpn-01 (only dns-01 and dns-persist-01 can authorize wildcard identifiers per RFC 8555 §7.1.3).
Technology stack
| Component | Library |
|---|---|
| Async runtime | tokio |
| HTTP framework | axum 0.8 |
| Database | rusqlite (system SQLite) + tokio-rusqlite |
| Schema migrations | rusqlite_migration |
| X.509 / PKCS#10 / CRL | synta-certificate |
| MTC transparency log | synta-mtc |
| DNS resolution | hickory-resolver |
| TLS server | axum-server + rustls |
| TLS client | rustls + tokio-rustls |
| HTTP client | hyper 1 |
| Configuration | TOML |
| JWK/JWS primitives | akamu-jose (workspace crate) |
| ACME client library | akamu-client (workspace crate) |
| CLI | akamu-cli (workspace crate) |
Standards implemented
- RFC 8555 — Automatic Certificate Management Environment (ACME)
- RFC 8659 — DNS CAA Resource Record
- RFC 8657 — CAA Extensions: accounturi and validationmethods
- RFC 8737 — ACME TLS-ALPN-01 Challenge Type
- RFC 8738 — ACME IP Identifier Validation
- RFC 8739 — ACME Short-Term, Automatically Renewed (STAR) Certificates
- RFC 9444 — ACME for Subdomains
- RFC 9773 — ACME Renewal Information (ARI)
- RFC 9799 — ACME Extensions for .onion Special-Use Domain Names
- RFC 7807 — Problem Details for HTTP APIs (error responses)
- RFC 5280 — X.509 Certificate and CRL profile
- Let’s Encrypt dns-persist-01 — Persistent DNS challenge type
- draft-aaron-acme-profiles-01 — ACME certificate profiles
- draft-ietf-lamps-pq-composite-sigs — ML-DSA composite TLS signature schemes (provisional code points)
Quick navigation
New to Akāmu? Start with the Quick Start guide. If you want to understand every configuration key, see the Configuration Reference. Developers should read the Architecture chapter first — it includes a full system architecture diagram covering all subsystems and their interactions.
Installation
Prerequisites
- Rust toolchain 1.75 or later (install via rustup)
- OpenSSL development headers (required by
synta-certificate’s cryptography backend) - The
syntafamily of crates, which are path dependencies and must be checked out alongside this repository
Fedora / RHEL
sudo dnf install openssl-devel
Debian / Ubuntu
sudo apt install libssl-dev
Checking out the source
Akāmu depends on three path-based crates from the synta workspace. Both repositories must be present on the local filesystem:
git clone <akamu-repo> akamu
git clone <synta-repo> synta
The Cargo.toml in Akāmu contains:
synta = { path = "/home/abokovoy/src/upstream/synta" }
synta-certificate = { path = "/home/abokovoy/src/upstream/synta/synta-certificate" }
synta-x509-verification = { path = "/home/abokovoy/src/upstream/synta/synta-x509-verification" }
synta-mtc = { path = "/home/abokovoy/src/upstream/synta/synta-mtc" }
Adjust the paths to match where you cloned synta before building.
Building from source
The repository is a Cargo workspace with four members: the akamu server binary, akamu-jose, akamu-client, and akamu-cli.
cd akamu
cargo build --release
This compiles all four workspace members. The binaries are placed at:
target/release/akamu— the ACME servertarget/release/akamu-cli— the command-line client
To build only the server:
cargo build --bin akamu --release
To build only the CLI:
cargo build --bin akamu-cli --release
Note: The first build downloads and compiles all dependencies including bundled SQLite and the OpenSSL fork used by
synta-certificate. It can take several minutes on a first run.
Verifying the build
./target/release/akamu --help
The binary accepts a single optional argument: the path to the configuration file (defaults to config.toml in the current directory).
Installing the binary
Copy the binary to a location in $PATH:
sudo install -m 0755 target/release/akamu /usr/local/bin/akamu
systemd service (optional)
Create /etc/systemd/system/akamu.service:
[Unit]
Description=ACME Certificate Server
After=network.target
[Service]
Type=simple
User=akamu
Group=akamu
ExecStart=/usr/local/bin/akamu /etc/akamu/config.toml
Restart=on-failure
RestartSec=5s
# Logging
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/akamu /etc/akamu
[Install]
WantedBy=multi-user.target
Then enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now akamu
Running tests
cargo test
cargo test runs tests across all workspace members: the server, akamu-jose, and akamu-client. To limit the run to a specific crate:
cargo test -p akamu # server tests only
cargo test -p akamu-jose # JWK/JWS primitive tests
cargo test -p akamu-client # ACME client library tests
All tests are self-contained and do not require external services. Some integration tests start local HTTP or TLS servers on ephemeral ports.
First Run
This chapter walks through the minimal steps to get Akāmu running and reachable by ACME clients.
1. Create directories
sudo mkdir -p /etc/akamu /var/lib/akamu
sudo chown akamu:akamu /etc/akamu /var/lib/akamu
sudo chmod 0750 /etc/akamu /var/lib/akamu
2. Write a minimal configuration file
Copy the example configuration and adjust it:
sudo cp config.toml.example /etc/akamu/config.toml
sudo chmod 0640 /etc/akamu/config.toml
At a minimum you need:
listen_addr = "0.0.0.0:8080"
base_url = "https://acme.example.com"
[database]
path = "/var/lib/akamu/akamu.db"
[ca]
key_file = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = false
Replace acme.example.com with the actual hostname your ACME clients will use.
Note:
listen_addris the address the server binds to.base_urlis the public-facing URL that appears in all ACME responses, including the directory and certificate URLs. They do not have to match — for example you might bind on0.0.0.0:8080and have a reverse proxy forward HTTPS traffic from port 443 to that address.
3. Start the server
akamu /etc/akamu/config.toml
On first run, because neither ca.key.pem nor ca.cert.pem exist, the server generates a new EC P-256 CA key and a 10-year self-signed CA certificate and writes them to the configured paths. You will see log output like:
INFO akamu: loading config from '/etc/akamu/config.toml'
INFO akamu: opening database '/var/lib/akamu/akamu.db'
INFO akamu: loading CA from '/etc/akamu/ca.key.pem'
INFO akamu::ca::init: Generating new CA key (ec:P-256) — writing to /etc/akamu/ca.key.pem and /etc/akamu/ca.cert.pem
INFO akamu::ca::init: Generated CA certificate (ec:P-256, 10 years)
INFO akamu: ACME server listening on 0.0.0.0:8080 (base_url=https://acme.example.com)
4. Verify the server is running
curl -s https://acme.example.com/acme/directory | python3 -m json.tool
Expected output:
{
"keyChange": "https://acme.example.com/acme/key-change",
"meta": {},
"newAccount": "https://acme.example.com/acme/new-account",
"newNonce": "https://acme.example.com/acme/new-nonce",
"newOrder": "https://acme.example.com/acme/new-order",
"revokeCert": "https://acme.example.com/acme/revoke-cert"
}
5. Install the CA certificate in your trust store (optional)
ACME clients will reject certificates issued by an unknown CA unless you install the CA certificate in your system’s trust store or tell the client to trust it explicitly.
Copy the CA certificate:
sudo cp /etc/akamu/ca.cert.pem /usr/local/share/ca-certificates/akamu-ca.crt
sudo update-ca-certificates # Debian/Ubuntu
# or
sudo update-ca-trust # Fedora/RHEL
Logging
The server uses the tracing crate. Control log verbosity with the RUST_LOG environment variable:
RUST_LOG=debug akamu /etc/akamu/config.toml
Useful levels: error, warn, info (default), debug, trace.
Reverse proxy configuration (nginx)
The server speaks plain HTTP internally. An nginx TLS termination configuration:
server {
listen 443 ssl;
server_name acme.example.com;
ssl_certificate /etc/nginx/ssl/acme.example.com.crt;
ssl_certificate_key /etc/nginx/ssl/acme.example.com.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Your First Certificate
This chapter shows how to obtain a certificate from your freshly running Akāmu using two common ACME clients: certbot and acme.sh.
Both examples use the http-01 challenge, which is the simplest to set up: the ACME server makes an HTTP request to port 80 of the domain being validated.
Before you begin
Make sure:
Akāmuis running and reachable athttps://acme.example.com/acme/directory.- The CA certificate (
/etc/akamu/ca.cert.pem) has been added to the system trust store, or you plan to pass it explicitly to the ACME client. - Port 80 is reachable on the domain you want to certify so the http-01 challenge can succeed.
Using certbot
Install certbot
sudo apt install certbot # Debian/Ubuntu
sudo dnf install certbot # Fedora/RHEL
Obtain a certificate
sudo certbot certonly \
--standalone \
--server https://acme.example.com/acme/directory \
--email admin@yourdomain.com \
--agree-tos \
-d yourdomain.com
If the CA certificate is not yet trusted system-wide, pass it via --no-verify-ssl (insecure, for testing only) or add it to certbot’s CA bundle.
What happens
- certbot fetches the directory at
https://acme.example.com/acme/directory. - It creates an ACME account with the provided email address.
- It places a new order for
yourdomain.com. Akāmucreates an authorization and a set of challenge objects for the domain.- certbot writes the key authorization file to
/.well-known/acme-challenge/<token>on port 80. - certbot signals readiness by POSTing to the challenge URL.
Akāmufetcheshttp://yourdomain.com/.well-known/acme-challenge/<token>and verifies the response matches the expected key authorization.- Once the challenge is valid, certbot POSTs a CSR to the finalize URL.
Akāmuvalidates the CSR, issues the certificate, and returns the certificate URL.- certbot downloads the certificate from
https://acme.example.com/acme/cert/<id>.
The certificate and private key are written to /etc/letsencrypt/live/yourdomain.com/.
Using acme.sh
Install acme.sh
curl https://get.acme.sh | sh -s email=admin@yourdomain.com
Register an account and obtain a certificate
~/.acme.sh/acme.sh \
--issue \
--standalone \
--server https://acme.example.com/acme/directory \
-d yourdomain.com \
--ca-bundle /etc/akamu/ca.cert.pem
The --ca-bundle flag tells acme.sh to trust your private CA when connecting to the ACME server. Remove it if the CA is already in the system trust store.
Install the certificate to a target location
~/.acme.sh/acme.sh \
--install-cert \
-d yourdomain.com \
--cert-file /etc/ssl/yourdomain.com/cert.pem \
--key-file /etc/ssl/yourdomain.com/key.pem \
--fullchain-file /etc/ssl/yourdomain.com/fullchain.pem \
--reloadcmd "systemctl reload nginx"
Checking the issued certificate
After a successful run, inspect the certificate:
openssl x509 -in /etc/letsencrypt/live/yourdomain.com/cert.pem -text -noout
Key fields to verify:
- Issuer: matches the
common_nameandorganizationfrom your[ca]configuration. - Subject Alternative Name: contains
DNS:yourdomain.com. - Extended Key Usage:
TLS Web Server Authentication. - Validity: 90 days from issuance (default; adjustable via
validity_days).
Renewal
Both clients support automatic renewal. They check the certificate expiry and renew when less than 30 days remain.
For certbot:
sudo systemctl enable --now certbot.timer
For acme.sh, renewal is installed automatically as a cron job during --install-cert.
The ACME Renewal Information endpoint (/acme/renewal-info/<cert_id>) is available for clients that support RFC 9773. It suggests a renewal window in the last third of the certificate’s validity period by default.
Troubleshooting
Challenge validation fails with a connection error
Ensure port 80 is open on the domain being validated. For the http-01 challenge the server makes a plain HTTP request to port 80; no TLS is involved.
Certificate is not trusted
The certificate is issued by your private CA. Install the CA certificate (/etc/akamu/ca.cert.pem) in the trust store of every system that needs to trust the issued certificates.
“account does not exist” error on renewal
If you re-created the database, existing accounts were lost. Re-register with certbot register or re-run acme.sh --register-account.
Using akamu-client (Rust)
If you are writing a Rust application and want to obtain a certificate programmatically, you can use the akamu-client library directly. For command-line usage without writing Rust code, see akamu-cli.
use akamu_client::{
AccountKey, AccountOptions, AcmeClient,
Http01Solver, Identifier, build_csr,
};
use std::{fs, time::Duration};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Generate or load an account key
let key = if std::path::Path::new("account.pem").exists() {
let pem = fs::read("account.pem")?;
AccountKey::from_pem(&pem)?
} else {
let k = AccountKey::generate("ec:P-256")?;
fs::write("account.pem", k.to_pem()?)?;
k
};
// 2. Connect to the ACME directory
let client = AcmeClient::new("https://acme.example.com/acme/directory").await?;
// 3. Register an account (agree to Terms of Service)
let opts = AccountOptions {
contacts: vec!["mailto:admin@example.com".to_string()],
agree_tos: true,
eab: None,
};
let account = client.new_account(&key, opts).await?;
// 4. Place an order for the desired identifiers
let ids = vec![Identifier::Dns("example.com".to_string())];
let order = client.new_order(&account, ids).await?;
// 5. Start the built-in http-01 solver on port 80
let solver = Http01Solver::new(80);
solver.start().await?;
// 6. Solve each authorization
for authz_url in &order.authorizations {
let authz = client.get_authorization(&account, authz_url).await?;
let challenge = authz
.challenges
.into_iter()
.find(|c| c.challenge_type == "http-01")
.ok_or("http-01 challenge not available")?;
// Present the key authorization at the well-known URL
let key_auth = account.key_authorization(&challenge.token);
solver.present(&challenge.token, &key_auth).await?;
// Signal readiness to the ACME server
client.trigger_challenge(&account, &challenge).await?;
// Poll until the authorization is validated
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
let updated = client.get_authorization(&account, authz_url).await?;
match updated.status.as_str() {
"valid" => break,
"invalid" => return Err("authorization failed".into()),
_ => continue,
}
}
// Remove the challenge token from the solver
solver.cleanup(&challenge.token).await?;
}
// 7. Generate a certificate key, build a CSR, and finalize the order
let cert_key = AccountKey::generate("ec:P-256")?;
fs::write("cert.key.pem", cert_key.to_pem()?)?;
let csr_der = build_csr(&["example.com"], &cert_key)?;
let finalized = client.finalize(&account, &order, &csr_der).await?;
let cert_url = finalized.certificate.ok_or("no certificate URL")?;
// 8. Download the certificate bundle and write to disk
let pem = client.download_certificate(&account, &cert_url).await?;
fs::write("cert.pem", &pem)?;
println!("Certificate written to cert.pem");
println!("Private key written to cert.key.pem");
Ok(())
}
Add the following to your Cargo.toml:
[dependencies]
akamu-client = { path = "/path/to/akamu/crates/akamu-client" }
tokio = { version = "1", features = ["full"] }
[patch.crates-io]
openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
openssl = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
The [patch.crates-io] block is required because akamu-client depends on the PQC OpenSSL fork. See Client Libraries Overview for details.
Configuration Reference
Akāmu reads a single TOML configuration file whose path is passed as the first command-line argument:
akamu /etc/akamu/config.toml
If no argument is given, the server looks for config.toml in the current working directory.
The file is parsed once at startup. Changes require a restart. Unknown keys produce a parse error on startup (serde’s strict TOML parser).
Complete example
listen_addr = "0.0.0.0:8080"
base_url = "https://acme.example.com"
[database]
path = "/var/lib/akamu/akamu.db"
[ca]
key_file = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"
key_type = "ec:P-256"
hash_alg = "sha256"
validity_days = 90
ca_validity_years = 10
common_name = "Example ACME CA"
organization = "Example Org"
crl_url = "http://acme.example.com/crl/ca.crl"
ocsp_url = "http://ocsp.example.com"
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = false
[server]
terms_of_service_url = "https://acme.example.com/tos.html"
website_url = "https://acme.example.com"
caa_identities = ["acme.example.com"]
external_account_required = false
order_expiry_secs = 86400
authz_expiry_secs = 86400
max_body_bytes = 65536
ari_retry_after_secs = 21600
allow_subdomain_auth = false
star_min_lifetime_secs = 86400
star_max_duration_secs = 31536000
star_allow_certificate_get = true
tor_connectivity_enabled = false
[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
"client-auth" = "https://acme.example.com/docs/profiles/client-auth"
Top-level keys
listen_addr
Required. The TCP address and port the server binds to.
listen_addr = "0.0.0.0:8080"
Use 127.0.0.1:8080 if you only want to accept connections from a local reverse proxy. The server does not support TLS on this socket; TLS termination must be handled upstream.
base_url
Required. The public HTTPS base URL of the ACME server. This value is embedded in every URL the server returns to clients — directory endpoint URLs, account URLs, order URLs, certificate download URLs, etc.
base_url = "https://acme.example.com"
It must match the URL that ACME clients use to reach the directory. It must not end with a slash.
[database]
path
Required. Path to the SQLite database file. The file and its WAL journal are created automatically if they do not exist.
[database]
path = "/var/lib/akamu/akamu.db"
Use :memory: for an ephemeral in-memory database (useful for testing; all data is lost when the process exits).
[ca]
key_file
Required. Path to the CA private key PEM file.
- If both
key_fileandcert_fileare absent on disk, a new key is generated and written to this path on first run. - If both files are present, they are loaded without modification.
- If exactly one file is present, the server refuses to start.
key_file = "/etc/akamu/ca.key.pem"
cert_file
Required. Path to the CA certificate PEM file. Same presence rules as key_file.
cert_file = "/etc/akamu/ca.cert.pem"
key_type
Optional. Default: "ec:P-256".
Algorithm used when auto-generating a new CA key. Ignored when loading an existing key from key_file.
| Value | Algorithm |
|---|---|
"ec:P-256" | ECDSA with NIST P-256 curve |
"ec:P-384" | ECDSA with NIST P-384 curve |
"ec:P-521" | ECDSA with NIST P-521 curve |
"rsa:2048" | RSA 2048-bit, exponent 65537 |
"rsa:3072" | RSA 3072-bit, exponent 65537 |
"rsa:4096" | RSA 4096-bit, exponent 65537 |
"ed25519" | Ed25519 |
key_type = "ec:P-256"
hash_alg
Optional. Default: "sha256".
Hash algorithm used for signing certificates and CRLs.
| Value | Algorithm |
|---|---|
"sha256" | SHA-256 |
"sha384" | SHA-384 |
"sha512" | SHA-512 |
hash_alg = "sha256"
validity_days
Optional. Default: 90.
Default validity period in days for issued end-entity certificates. The validity window starts at the moment the certificate is signed.
validity_days = 90
ca_validity_years
Optional. Default: 10.
Validity period in years for the auto-generated CA certificate. Ignored when loading an existing certificate.
ca_validity_years = 10
common_name
Optional. Default: "Akāmu CA".
Common Name (CN) used in the Subject and Issuer fields of the auto-generated CA certificate.
common_name = "Example ACME CA"
organization
Optional. Default: "Akāmu".
Organization (O) used in the Subject and Issuer fields of the auto-generated CA certificate.
organization = "Example Org"
crl_url
Optional. Default: absent (no CDP extension).
If set, this URL is included as a CRL Distribution Point (CDP) URI in the CRLDistributionPoints extension of every issued end-entity certificate.
crl_url = "http://acme.example.com/crl/ca.crl"
The server does not serve the CRL itself at this URL. You must arrange for the CRL file to be available at this location separately (for example, by generating it with a custom script or tool that uses the CA key).
ocsp_url
Optional. Default: absent (no AIA OCSP extension).
If set, this URL is included in the AuthorityInfoAccess (AIA) extension as an OCSP responder URI in every issued end-entity certificate.
ocsp_url = "http://ocsp.example.com"
The server does not implement an OCSP responder. You must run a separate OCSP responder at this URL.
[mtc]
log_path
Required. Path to the disk-backed Merkle Tree Certificate transparency log file.
[mtc]
log_path = "/var/lib/akamu/mtc.log"
The file is created automatically on first run when enabled = true. It is never written when enabled = false, but the path must still be specified.
enabled
Optional. Default: false.
When true, each issued certificate is appended as a leaf to the MTC transparency log. The leaf index is stored in the certificates database table (mtc_log_index column).
enabled = false
[server]
The [server] section is optional. When omitted entirely, all fields take their default values.
terms_of_service_url
Optional. Default: absent.
URL of the Terms of Service document. When set, it appears in the meta.termsOfService field of the ACME directory response.
terms_of_service_url = "https://acme.example.com/tos.html"
website_url
Optional. Default: absent.
URL of the operator’s website. When set, it appears in the meta.website field of the directory response.
website_url = "https://acme.example.com"
caa_identities
Optional. Default: empty list.
List of CA domain names for CAA record verification (RFC 8659). When set, Akāmu queries CAA DNS records before issuing each certificate and verifies that at least one issue (or issuewild for wildcard) record authorises one of these CA domain names. The values also appear in meta.caaIdentities of the directory response.
When the list is empty (the default), CAA checking is skipped entirely — including RFC 8657 accounturi enforcement, because accounturi is evaluated as part of the CAA record check.
caa_identities = ["acme.example.com"]
external_account_required
Optional. Default: false.
When true, new-account requests must include an externalAccountBinding field (RFC 8555 §7.3.4). Requests without it are rejected with urn:ietf:params:acme:error:externalAccountRequired (HTTP 403). The directory response also includes meta.externalAccountRequired: true.
When enabled, the server performs full HMAC verification: it resolves the kid in [server.eab_keys], verifies the HS256/HS384/HS512 MAC, confirms the payload is the account key, and atomically consumes the key at account creation so each EAB key can only be used once.
external_account_required = true
eab_keys
Optional. Default: {}.
Pre-shared External Account Binding keys, expressed as a TOML table under [server.eab_keys]. Each entry maps a key identifier (kid) to its base64url-encoded raw HMAC key bytes. The key material must be at least 16 bytes; 32 bytes (256 bits) is recommended for HS256.
Keys are seeded into the eab_keys database table at startup using INSERT OR IGNORE. A key that has been consumed (used to create an account) or modified by a future admin API call is never overwritten by a restart.
[server.eab_keys]
"kid-1" = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg" # base64url, no padding
"kid-2" = "YW5vdGhlci1rZXktaGVyZQ"
To generate a key:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
order_expiry_secs
Optional. Default: 86400 (24 hours).
Number of seconds after creation before an order expires. Expired orders cannot be finalized.
order_expiry_secs = 86400
authz_expiry_secs
Optional. Default: 86400 (24 hours).
Number of seconds after creation before an authorization expires. Expired authorizations must be re-created via a new order.
authz_expiry_secs = 86400
max_body_bytes
Optional. Default: 65536 (64 KiB).
Maximum size in bytes of JOSE+JSON request bodies. Requests larger than this limit are rejected with HTTP 413. This applies to all POST endpoints that carry ACME payloads.
max_body_bytes = 65536
http_validation_port
Optional. Default: 80.
TCP port used when the server fetches http-01 challenge responses. RFC 8555 §8.3 requires port 80 in production deployments. Override this to a high port for local testing or non-standard network environments.
http_validation_port = 80
dns_persist_issuer_domain
Optional. Default: absent (dns-persist-01 disabled).
The issuer domain placed in the issuer-domain-names field of dns-persist-01 challenge objects and matched against the first token of TXT records during validation. When this field is set, the server offers dns-persist-01 as an additional challenge type for all dns identifiers. When absent, dns-persist-01 is not offered and existing clients are unaffected.
See dns-persist-01 Challenge for the full description of the challenge type and TXT record format.
dns_persist_issuer_domain = "acme.example.com"
dns_resolver_addr
Optional. Default: absent (system resolver).
Override the DNS resolver used for dns-01 and dns-persist-01 challenge validation. Format: "<ip>:<port>". When absent, the system default resolver is used. Useful for split-horizon DNS deployments where the ACME server cannot reach the public resolver, and for integration testing against a local stub server.
dns_resolver_addr = "127.0.0.1:5353"
ari_retry_after_secs
Optional. Default: 21600 (6 hours).
The value of the Retry-After header returned on GET /acme/renewal-info/{cert-id} responses (RFC 9773 §4.3). Controls how frequently ACME clients poll for renewal information.
ari_retry_after_secs = 21600
allow_subdomain_auth
Optional. Default: false.
When true, the directory meta includes "subdomainAuthAllowed": true, advertising that the server supports RFC 9444 subdomain authorization. Clients may then:
- Include
"subdomainAuthAllowed": trueinPOST /acme/new-authzrequests. - Reference an ancestor domain in
newOrdervia theancestorDomainidentifier field.
allow_subdomain_auth = true
star_min_lifetime_secs
Optional. Default: absent (STAR not advertised).
Minimum certificate lifetime in seconds for ACME STAR orders (RFC 8739). When set, the directory meta includes an auto-renewal object advertising STAR capability. Clients that place STAR orders must request a lifetime value greater than or equal to this minimum.
Setting this field enables the STAR background reissuance task.
star_min_lifetime_secs = 86400 # 1 day minimum
star_max_duration_secs
Optional. Default: absent.
Maximum total renewal duration in seconds for ACME STAR orders. When set, it is included in the directory meta.auto-renewal object as max-duration. Clients must supply an end-date that does not exceed this value beyond the order creation time.
star_max_duration_secs = 31536000 # 1 year maximum
star_allow_certificate_get
Optional. Default: true.
Controls whether the rolling STAR certificate URL (/acme/cert/star/<order-id>) can be fetched with an unauthenticated GET request. When true, the directory meta.auto-renewal object includes "allow-certificate-get": true and clients may request this capability per order by including "allow-certificate-get": true in the auto-renewal object of newOrder. When false, the capability is not advertised and unauthenticated GET requests are rejected.
star_allow_certificate_get = true
tor_connectivity_enabled
Optional. Default: false.
Controls whether the server offers http-01 and tls-alpn-01 challenge types for .onion identifiers. RFC 9799 §4 prohibits offering those challenge types unless the CA can actually reach the Tor network. When false (the default), only onion-csr-01 is offered for .onion identifiers. Set to true only when the Akāmu server process can make outbound Tor connections to hidden services (for example, via torsocks or a SOCKS5 proxy configured at the OS level).
tor_connectivity_enabled = true
profiles
Optional. Default: empty (profiles not advertised).
A table mapping certificate profile identifiers to human-readable descriptions or documentation URLs, per draft-aaron-acme-profiles-01. When non-empty, the directory meta includes a "profiles" object with these entries, and clients may select a profile by name in newOrder.
Requests for a profile name not present in this table are rejected with urn:ietf:params:acme:error:invalidProfile (HTTP 400).
[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
"client-auth" = "https://acme.example.com/docs/profiles/client-auth"
When profiles is empty (the default), the profile field in newOrder is accepted but ignored — the server issues under its default policy.
Account Management
ACME accounts are persistent identities that tie a public key to one or more email addresses. Every order, authorization, and certificate is associated with an account.
Account lifecycle
stateDiagram-v2
direction LR
[*] --> valid : create account\nPOST /acme/new-account
valid --> deactivated : POST status=deactivated
deactivated --> [*]
Accounts start in valid status. A valid account can:
- Create new orders.
- Manage existing orders and authorizations.
- Download certificates.
- Revoke certificates it owns.
- Update its contact list.
- Rotate its key.
- Deactivate itself.
A deactivated account is permanently disabled. All subsequent requests using a deactivated account’s key are rejected.
Creating an account
Send a POST request to /acme/new-account with a JWS signed by the account’s public key in jwk form. The payload must contain:
| Field | Type | Required | Description |
|---|---|---|---|
contact | array of strings | No | mailto: URIs for contact addresses |
onlyReturnExisting | boolean | No | If true, return the existing account or error |
Only mailto: URIs are accepted as contact values. Any other scheme causes a unsupportedContact error.
Example payload:
{
"contact": ["mailto:admin@example.com"],
"onlyReturnExisting": false
}
Response on creation (201 Created):
{
"status": "valid",
"contact": ["mailto:admin@example.com"],
"orders": "https://acme.example.com/acme/orders/<account-id>"
}
The Location header contains the account URL: https://acme.example.com/acme/account/<account-id>.
If an account already exists for the submitted key and onlyReturnExisting is false, the server returns the existing account with HTTP 200 rather than creating a duplicate.
Reading account details
POST to the account URL (/acme/account/<id>) with an empty payload (POST-as-GET). The kid header must reference the account being queried, and it must match the account ID in the URL.
Updating contact information
POST to /acme/account/<id> with a payload containing the new contact array:
{
"contact": ["mailto:new-admin@example.com"]
}
Contact addresses are replaced entirely; partial updates are not supported.
Deactivating an account
POST to /acme/account/<id> with:
{
"status": "deactivated"
}
The account is immediately marked deactivated. This action is irreversible.
Key rollover
To replace an account’s signing key without losing the account:
- Construct an outer JWS signed with the old key addressed to
/acme/key-change. - Embed an inner JWS signed with the new key as the payload. The inner JWS payload must contain
{ "account": "<account-url>" }.
POST /acme/key-change
Content-Type: application/jose+json
{
"protected": "<outer-header-signed-with-old-key>",
"payload": "<inner-jws-signed-with-new-key>",
"signature": "<outer-signature>"
}
sequenceDiagram
participant C as ACME Client
participant S as Akāmu Server
Note over C: Currently holds old private key
C->>C: Generate new key pair
C->>C: Build inner JWS signed by NEW key
Note right of C: payload = {"account": "https://…/acme/account/ID"}
C->>C: Wrap in outer JWS signed by OLD key
Note right of C: url = /acme/key-change, kid = account URL
C->>S: POST /acme/key-change
S->>S: Verify outer JWS signature (old key from DB)
S->>S: Verify inner JWS signature (new key from inner jwk)
S->>S: Check inner payload account == outer kid account URL
S->>S: Check new key not registered to another account
S->>S: Replace stored public_key + jwk_thumbprint
S-->>C: 200 OK (account object)
Note over C: All future requests must be signed with the new key
The server verifies:
- The outer JWS is signed by the current account key.
- The inner JWS is signed by the new key (
jwkmust be present in the inner header). - The inner payload’s
accountfield matches the account URL derived from the outerkid. - The new key is not already registered to another account.
On success, the account’s stored key and thumbprint are replaced with the new key. Subsequent requests must be signed with the new key.
Security considerations
- Each account is identified by the SHA-256 thumbprint of its JWK public key. The thumbprint is stored in the database to enable fast lookup without storing or parsing the full public key on every request.
- Key rollover is the only mechanism to change the signing key. There is no password or other credential; possession of the private key is the sole proof of identity.
- Contact email addresses are stored as plain text JSON in the database and are not validated against any mail server.
Orders
An ACME order is the top-level object that represents a request to obtain a certificate. It contains:
- The list of identifiers (domain names or IP addresses) to be included in the certificate.
- A set of authorizations — one per identifier — that must be completed before the certificate can be issued.
- A finalize URL where the ACME client submits a CSR once all authorizations are valid.
- An expires timestamp after which the order can no longer be finalized.
Order lifecycle
stateDiagram-v2
direction LR
[*] --> pending : new-order
pending --> ready : all authorizations valid
pending --> invalid : any authorization fails
ready --> processing : finalize (submit CSR)
processing --> valid : certificate issued
processing --> invalid : CSR validation failed
valid --> [*]
invalid --> [*]
| Status | Meaning |
|---|---|
pending | One or more authorizations are not yet valid. |
ready | All authorizations are valid. The client may now submit a CSR. |
processing | CSR submitted; certificate being issued. |
valid | Certificate has been issued. |
invalid | A challenge or CSR validation failed. |
Creating an order
POST to /acme/new-order with a JWS signed by the account key. The payload:
{
"identifiers": [
{ "type": "dns", "value": "example.com" },
{ "type": "dns", "value": "www.example.com" }
]
}
Supported identifier types:
| Type | Description | Supported challenges |
|---|---|---|
dns | DNS domain name | http-01, dns-01, tls-alpn-01 |
ip | IP address literal | http-01, tls-alpn-01 |
Wildcard identifiers (*.example.com) are accepted as dns type identifiers. Only the dns-01 challenge can authorize wildcard identifiers. Attempting to validate a wildcard with http-01 or tls-alpn-01 will fail.
Response (201 Created):
{
"status": "pending",
"expires": "2026-04-11T12:00:00Z",
"identifiers": [
{ "type": "dns", "value": "example.com" },
{ "type": "dns", "value": "www.example.com" }
],
"authorizations": [
"https://acme.example.com/acme/authz/<authz-id-1>",
"https://acme.example.com/acme/authz/<authz-id-2>"
],
"finalize": "https://acme.example.com/acme/order/<order-id>/finalize"
}
The Location header contains the order URL.
Reading an order
POST to /acme/order/<id> with an empty payload (POST-as-GET). The response contains the current state of the order, including the certificate URL once the order is in valid status.
Completing authorizations
For each authorization URL in the order’s authorizations array, the client must complete one challenge. See the Challenges chapter for details.
Finalizing an order
Once all authorizations are in valid status (order status becomes ready), POST a CSR to the finalize URL:
{
"csr": "<base64url-encoded-DER-CSR>"
}
The CSR must:
- Be a valid PKCS#10 DER structure.
- Have a valid self-signature.
- Contain a SubjectAlternativeName extension listing exactly the identifiers from the order (no more, no fewer).
- Not assert
cA=TRUEin BasicConstraints.
The server validates the CSR and immediately issues the certificate. On success, the order status changes to valid and the response includes the certificate field:
{
"status": "valid",
"certificate": "https://acme.example.com/acme/cert/<cert-id>",
...
}
Order expiry
Orders expire after order_expiry_secs seconds (default 86400, one day). An expired order cannot be finalized. A new order must be created.
Error handling
If a challenge fails, the corresponding authorization transitions to invalid. The server also marks the order invalid and records the error on the order object:
{
"status": "invalid",
"error": {
"type": "urn:ietf:params:acme:error:connection",
"detail": "connection error during challenge: TCP connect to example.com:80: ..."
}
}
Create a new order and begin the authorization process again.
Challenges
A challenge is the mechanism by which Akāmu verifies that an ACME client controls the identifier (domain name or IP address) in an authorization. The server supports four challenge types: http-01, dns-01, tls-alpn-01, and dns-persist-01.
For each identifier in an order, the server creates one challenge of each supported type. The client chooses which challenge type to complete.
dns-persist-01 is an opt-in type that requires explicit server configuration. When it is not configured, clients see the standard three types and are not affected. See dns-persist-01 below for the full description.
Key authorization
Before responding to any challenge, compute the key authorization string:
key_authorization = token + "." + base64url(SHA-256(JWK-thumbprint-of-account-key))
Where:
tokenis the challenge token provided by the server in the authorization object.- The SHA-256 is computed over the JWK thumbprint string (itself a base64url-encoded SHA-256 of the canonical JSON of the account public key).
base64urluses the URL-safe alphabet without padding characters.
In practice, ACME client libraries compute this for you.
dns-persist-01 does not use this formula. Instead of a token, the client receives an issuer-domain-names array, and the server matches the account URI directly against the TXT record. See dns-persist-01 below for details.
Responding to a challenge
To signal that the client has provisioned the challenge response, POST to the challenge URL with an empty JSON object payload:
{}
The server immediately marks the challenge as processing and spawns a background task to validate it. The response returns the current challenge status:
{
"type": "http-01",
"url": "https://acme.example.com/acme/chall/<authz-id>/http-01",
"status": "processing",
"token": "<token>"
}
Poll the authorization URL (POST-as-GET) to check when validation completes.
Challenge status transitions
stateDiagram-v2
direction LR
[*] --> pending : authorization created
pending --> processing : client POSTs {} to challenge URL
processing --> valid : server probe succeeds
processing --> invalid : server probe fails
valid --> [*]
invalid --> [*]
A challenge that is already processing or valid is returned as-is if the client POSTs to it again.
Challenge types at a glance
flowchart TD
A([Authorization created]) --> B{"Choose one<br/>challenge type"}
B -->|http-01| C["Serve key auth at<br/>/.well-known/acme-challenge/TOKEN<br/>on port 80"]
B -->|dns-01| D["Add DNS TXT record<br/>_acme-challenge.DOMAIN<br/>= base64url(SHA-256(key_auth))"]
B -->|tls-alpn-01| E["Configure TLS on port 443<br/>ALPN: acme-tls/1<br/>Cert with id-pe-acmeIdentifier ext<br/>= SHA-256(key_auth)"]
B -->|dns-persist-01| F["Persistent DNS TXT record<br/>_validation-persist.DOMAIN<br/>with issuer + accounturi + policy fields"]
C & D & E & F --> G["POST {} to challenge URL"]
G --> H["Server validates in background<br/>tokio::spawn task"]
H -->|probe succeeds| I(["Authorization → valid<br/>Order → ready when all valid"])
H -->|probe fails| J(["Authorization → invalid<br/>Order → invalid<br/>Create new order to retry"])
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class I ok
class J fail
http-01
The server makes an HTTP/1.1 GET request to:
http://<domain>/.well-known/acme-challenge/<token>
on port 80. The response body (trimmed of whitespace) must equal the key authorization string.
Provisioning
Create a file at the path /.well-known/acme-challenge/<token> on the web server for the domain being validated. The file content must be exactly the key authorization string.
Example:
If the token is abc123 and the key authorization is abc123.XYZ...:
File path: /.well-known/acme-challenge/abc123
File content: abc123.XYZ...
For Apache or nginx, ensure that requests to /.well-known/acme-challenge/ are served from the document root without authentication and without redirects.
Constraints
- Port 80 must be reachable from the ACME server.
- The response body must be less than 8 KiB.
- Redirects are not followed; the initial response must be 200 OK.
- IPv6 addresses are supported as
iptype identifiers; the URL literal uses bracket notation (e.g.,http://[2001:db8::1]/.well-known/acme-challenge/<token>). - Wildcard identifiers (
*.example.com) cannot be validated with http-01.
dns-01
The server queries the DNS TXT record at:
_acme-challenge.<domain>
At least one TXT record value must equal:
base64url(SHA-256(key_authorization))
For example, if SHA-256(key_authorization) produces the bytes \xde\xad..., the expected TXT value is the base64url encoding of those 32 bytes.
Provisioning
Add a DNS TXT record:
Name: _acme-challenge.example.com
Type: TXT
TTL: 60
Content: <base64url-SHA256-of-key-authorization>
Concrete example:
- Token:
mytoken - JWK thumbprint:
mythumbprint - Key authorization:
mytoken.mythumbprint - SHA-256 of key auth (hex):
e3b0c4...(varies; compute for your actual values) - base64url of SHA-256:
47DEQp...(varies) - TXT record value:
47DEQp...
Use openssl dgst -sha256 -binary <<< "mytoken.mythumbprint" | base64 -w0 | tr '+/' '-_' | tr -d '=' to compute it manually.
Wildcard domains
For a wildcard identifier *.example.com, the leading *. is stripped before constructing the DNS query. The TXT record must be placed at _acme-challenge.example.com, not _acme-challenge.*.example.com.
DNS propagation
The server uses the system default DNS resolver (or the address configured via server.dns_resolver_addr). If the TXT record has not propagated by the time the server queries, validation will fail. Use a short TTL (60 seconds or less) to speed up propagation.
tls-alpn-01
The server opens a TLS connection to port 443 of the domain being validated, advertising the ALPN protocol acme-tls/1. The applicant’s TLS server must respond with a certificate that:
- Contains the domain as a dNSName in the SubjectAlternativeName extension.
- Contains the
id-pe-acmeIdentifierextension (OID1.3.6.1.5.5.7.1.31) marked critical, with a value ofOCTET STRING { SHA-256(key_authorization) }.
Provisioning
Set up a TLS virtual host that listens on port 443, responds only to ALPN negotiation for acme-tls/1, and presents a specially crafted certificate. Most ACME clients handle this automatically.
The certificate must:
- Be self-signed (the server does not verify the trust chain for this challenge type; it only checks the extensions).
- Have
id-pe-acmeIdentifieras a critical extension. - Have the SHA-256 hash of the key authorization (32 raw bytes) wrapped in an
OCTET STRINGas the extension value.
TLS version support
The validator accepts both TLS 1.2 and TLS 1.3 connections.
Constraints
- Port 443 must be reachable from the ACME server.
- Wildcard identifiers are not supported by tls-alpn-01.
- IP address identifiers (
iptype) are supported; the server connects to the IP address directly. - RFC 8737 §3 requires exactly one SAN entry in the validation certificate. Certificates that contain more than one SAN are rejected.
dns-persist-01
dns-persist-01 is a DNS-based challenge type that differs from dns-01 in one important way: the TXT record is persistent. It does not change from one certificate renewal to the next. Once an operator provisions the record, it remains valid for every subsequent renewal by the same ACME account — no per-renewal DNS changes are required.
The challenge type is defined by the Let’s Encrypt specification at https://letsencrypt.org/2026/02/18/dns-persist-01. It is available only for dns type identifiers; IP address identifiers are not supported.
This type is opt-in: it is offered to clients only when server.dns_persist_issuer_domain is set in config.toml. When that field is absent, only the standard three types are advertised and existing clients are unaffected.
How it works
When dns-persist-01 is offered, the client does not receive a token field. Instead it receives an issuer-domain-names array:
{
"type": "dns-persist-01",
"url": "https://acme.example.com/acme/chall/<authz-id>/dns-persist-01",
"status": "pending",
"issuer-domain-names": ["acme.example.com"]
}
The ACME client must ensure that a TXT record exists at _validation-persist.<domain> before signalling the challenge. The server queries that name and evaluates each TXT record value against a set of rules.
TXT record format
_validation-persist.<domain>. IN TXT "<issuer-domain>; accounturi=<account-uri>[; policy=wildcard][; persistUntil=<ISO8601Z>]"
Fields are separated by semicolons. Field order is not significant except that the issuer domain must appear first.
| Field | Required | Description |
|---|---|---|
<issuer-domain> | Yes | First token (before the first ;). Must match the CA’s configured issuer domain, case-insensitively, trailing dot stripped. |
accounturi=<uri> | Yes | Full ACME account URI, e.g. https://acme.example.com/acme/account/42. Must match the requesting account exactly. |
policy=wildcard | Only for wildcard orders | Must be present when the identifier starts with *.. |
persistUntil=<timestamp> | No | UTC timestamp in YYYY-MM-DDTHH:MM:SSZ format. The server rejects records whose timestamp is in the past. |
Concrete example for account https://acme.example.com/acme/account/42 validating example.com through a CA with issuer domain acme.example.com:
_validation-persist.example.com. 300 IN TXT "acme.example.com; accounturi=https://acme.example.com/acme/account/42"
For a wildcard certificate (*.example.com), add policy=wildcard:
_validation-persist.example.com. 300 IN TXT "acme.example.com; accounturi=https://acme.example.com/acme/account/42; policy=wildcard"
A single TXT record containing policy=wildcard also satisfies non-wildcard orders for the same domain.
What the server checks
The server performs a TXT lookup at _validation-persist.<base-domain> (the *. prefix is stripped for wildcard orders). It accepts the challenge as soon as one record satisfies all of:
- The first
;-delimited token equals the CA’s issuer domain (case-insensitive, trailing dot stripped). accounturi=<uri>matches the requesting account’s full URI.- For wildcard orders,
policy=wildcardis present. - If
persistUntil=<timestamp>is present, the timestamp is at or after the current time.
Unknown key-value tokens are silently ignored, allowing forward-compatible extensions.
Key authorization
Unlike the other challenge types, dns-persist-01 does not use a token · thumbprint key authorization. The server stores the account URI in the key_auth database column instead, and matches it directly against the accounturi= field in the TXT record.
Wildcard certificates
To validate *.example.com, the TXT record must include policy=wildcard. The query name is always _validation-persist.example.com — the *. prefix is stripped before the DNS lookup.
persistUntil expiry
The optional persistUntil field lets an operator set an explicit expiry on the authorization grant, independently of the DNS TTL. Format: YYYY-MM-DDTHH:MM:SSZ (literal Z; lowercase z also accepted).
- Timestamp at or after current time → field is valid; evaluation continues.
- Timestamp before current time → record rejected, even if all other fields match.
- Timestamp unparseable → record rejected.
Records without persistUntil never expire through this mechanism; their lifetime is determined by DNS TTL and operator action.
Configuration
Both fields belong to the [server] section of config.toml.
dns_persist_issuer_domain
Optional. Default: absent (dns-persist-01 disabled).
The issuer domain placed in issuer-domain-names challenge objects and matched against the first token of TXT records. When set, dns-persist-01 is offered alongside the standard types for all dns identifiers.
[server]
dns_persist_issuer_domain = "acme.example.com"
dns_resolver_addr
Optional. Default: absent (system resolver).
DNS resolver override for dns-01 and dns-persist-01 validation. Format: "<ip>:<port>". The http-01 and tls-alpn-01 validators are not affected.
[server]
dns_resolver_addr = "127.0.0.1:5353"
Useful for split-horizon DNS (where the ACME server cannot reach the public resolver) and for integration tests against a local stub server.
Limitations
- No ACME client library support yet. As of instant-acme 0.4.3,
dns-persist-01is not implemented. A custom client, a patched library, or direct ACME HTTP calls are required until upstream support arrives. - No revocation check during TXT lookup. If an account is deactivated in the Akāmu database, the TXT record is not invalidated automatically; operators must remove it manually.
- One issuer domain per server instance.
dns_persist_issuer_domainaccepts a single string. Deployments with multiple issuer identities should usedns-01instead. - Resolver override is global.
dns_resolver_addrapplies to all DNS-based challenge validation; per-type resolver selection is not supported.
Challenge failure
If validation fails, the challenge transitions to invalid and an error is recorded:
{
"type": "http-01",
"status": "invalid",
"token": "<token>",
"error": {
"type": "urn:ietf:params:acme:error:connection",
"detail": "connection error during challenge: TCP connect to example.com:80: connection refused"
}
}
Common error types:
| Error type | Meaning |
|---|---|
connection | Could not connect to the applicant server |
dns | DNS TXT lookup failed or name not found |
incorrectResponse | Server responded but the content did not match |
tls | TLS handshake failed or extension verification failed |
A failed authorization invalidates the parent order. Create a new order to try again.
Certificates
This chapter covers certificate issuance, retrieval, revocation, and the ACME Renewal Information (ARI) extension.
Issuance
Certificates are issued when an order is finalized. The client submits a PKCS#10 CSR (DER-encoded, base64url) to the finalize endpoint.
flowchart TD
A(["POST /acme/order/ID/finalize<br/>csr = base64url DER"]) --> B[Decode + parse CSR]
B --> C{"Self-signature<br/>valid?"}
C -->|No| FAIL([400 badCSR])
C -->|Yes| D{"BasicConstraints<br/>cA=FALSE?"}
D -->|cA=TRUE found| FAIL
D -->|OK| E{"SAN set equals<br/>order identifiers?"}
E -->|Mismatch| FAIL
E -->|Match| F["Generate random 16-byte serial<br/>clear high bit for positive integer"]
F --> G["Build X.509 v3 end-entity cert<br/>KeyUsage=digitalSignature<br/>EKU=serverAuth"]
G --> H[Sign with CA private key]
H --> I["Store DER + PEM bundle<br/>in certificates table"]
I --> J["Update order: status=valid<br/>certificate_id set"]
J --> K([Return order with certificate URL])
K --> L(["Client: GET /acme/cert/ID<br/>Download PEM bundle"])
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class K,L ok
class FAIL fail
The server:
- Decodes and parses the CSR.
- Verifies the CSR’s self-signature.
- Checks that the CSR does not request CA authority (
cA=TRUEin BasicConstraints is rejected). - Verifies that the CSR’s SubjectAlternativeName extension contains exactly the identifiers from the order — no more, no fewer.
- Generates a random 16-byte serial number (positive two’s complement, high bit cleared).
- Issues an X.509 v3 certificate with the following profile:
| Extension | Value |
|---|---|
| BasicConstraints | Not critical; cA=FALSE |
| KeyUsage | Critical; digitalSignature |
| ExtendedKeyUsage | Not critical; serverAuth |
| SubjectKeyIdentifier | RFC 5280 SHA-1 method |
| AuthorityKeyIdentifier | RFC 5280 SHA-1 method, from CA key |
| SubjectAlternativeName | Rebuilt from validated CSR SANs |
| AuthorityInfoAccess (OCSP) | Present if ocsp_url is configured |
| CRLDistributionPoints | Present if crl_url is configured |
The Subject Name from the CSR is copied verbatim into the issued certificate.
The validity period runs from the moment of issuance for validity_days days (default 90).
Downloading a certificate
Send a GET request to the certificate URL provided in the order’s certificate field:
GET /acme/cert/<cert-id>
No authentication is required. The response is a PEM bundle with Content-Type: application/pem-certificate-chain:
-----BEGIN CERTIFICATE-----
<base64-encoded end-entity certificate>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<base64-encoded CA certificate>
-----END CERTIFICATE-----
The bundle always contains the end-entity certificate followed by the CA certificate. No intermediate certificates are included (there are none in this single-tier CA architecture).
Certificate storage
The server stores both the DER and PEM representations of the issued certificate in the certificates database table, along with:
- The UUID used as the certificate ID in the download URL.
- The hex-encoded serial number.
- Validity window timestamps (Unix epoch).
- The MTC log leaf index (if MTC logging is enabled).
- The suggested renewal window (ARI; computed on first query if not set).
Revocation
To revoke a certificate, POST to /acme/revoke-cert with a JWS signed by either:
- The account key of the account that owns the certificate, or
- The private key that corresponds to the certificate’s subject public key.
The payload:
{
"certificate": "<base64url-encoded-DER-certificate>",
"reason": 1
}
The certificate field contains the DER-encoded end-entity certificate (not the PEM bundle).
The reason field is optional. When present, it must be a CRL reason code:
| Code | Meaning |
|---|---|
| 0 | Unspecified |
| 1 | Key compromise |
| 2 | CA compromise |
| 3 | Affiliation changed |
| 4 | Superseded |
| 5 | Cessation of operation |
| 6 | Certificate hold |
| 8 | Remove from CRL |
| 9 | Privilege withdrawn |
| 10 | AA compromise |
Codes 7 and values above 10 are invalid and rejected with badRevocationReason.
On success, the server returns HTTP 200 with no body. The certificate’s status is set to revoked in the database with the revocation timestamp and reason code.
Revoking an already-revoked certificate returns alreadyRevoked.
ACME Renewal Information (ARI)
Akāmu implements RFC 9773, which allows ACME clients to ask the server when to renew a certificate.
Endpoint
GET /acme/renewal-info/<cert-id>
No authentication required.
Response
{
"suggestedWindow": {
"start": "2026-04-01T00:00:00Z",
"end": "2026-04-09T00:00:00Z"
},
"explanationURL": null
}
Window computation
If the server has not explicitly set a renewal window for the certificate, it computes one as follows:
- Start: two-thirds of the way through the certificate’s validity period.
- End: 24 hours before the certificate expires.
For a 90-day certificate issued on January 1, 2026:
- Validity period: 90 days = 7,776,000 seconds
- Start: January 1 + 60 days = March 2, 2026
- End: April 1, 2026 (one day before April 2 expiry)
flowchart LR
A(["Jan 1<br/>Issued"]) -->|"60 days"| B(["Mar 2<br/>Window opens"])
B -->|"29 days"| C(["Apr 1<br/>Window closes"])
C -->|"24 h"| D(["Apr 2<br/>Expires"])
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class B,C ok
class D fail
Note: The
explanationURLfield is alwaysnullin the current implementation. RFC 9773 allows an explanation URL to be provided when the server has specific reasons for the suggested window.
Using ARI with certbot
Certbot 2.7 and later support ARI. It fetches the renewal window when deciding whether to renew:
certbot renew --server https://acme.example.com/acme/directory
If the current time falls within the suggestedWindow, certbot proceeds with renewal even if the certificate has more than 30 days remaining.
CRL and OCSP
Akāmu supports both Certificate Revocation List (CRL) and Online Certificate Status Protocol (OCSP) as optional mechanisms to communicate revocation status. Neither is served directly by the server; instead, the server embeds URLs in issued certificates that point to external services.
CRL Distribution Points
When crl_url is set in [ca], every issued end-entity certificate contains a CRLDistributionPoints extension pointing to that URL:
[ca]
crl_url = "http://acme.example.com/crl/ca.crl"
Clients that check CRL status will fetch the file at this URL and verify the serial number of the certificate against the revocation list.
Generating a CRL
The CRL generation capability is implemented in src/ca/revoke.rs via the build_crl function. The server does not automatically generate or publish CRL files; you must write tooling around the CA key and the revoked certificate records in the database to produce and serve the CRL.
A v2 CRL is generated using:
- The CA’s private key for signing.
- The CA’s subject name as the issuer.
- The current timestamp as
thisUpdate. - A configurable
nextUpdateoffset. - A CRL Number extension containing the current Unix timestamp as a monotonically increasing integer.
- One revoked certificate entry per revoked certificate, each with the serial number, revocation time, and optional reason code.
CRL reason codes
The same reason codes used for revocation (see Certificates) are recorded in the CRL:
| Code | CRL reason string |
|---|---|
| 0 | Unspecified |
| 1 | Key Compromise |
| 2 | CA Compromise |
| 3 | Affiliation Changed |
| 4 | Superseded |
| 5 | Cessation of Operation |
| 6 | Certificate Hold |
| 8 | Remove From CRL |
| 9 | Privilege Withdrawn |
| 10 | AA Compromise |
OCSP
When ocsp_url is set in [ca], every issued end-entity certificate contains an AuthorityInfoAccess extension with an OCSP responder URI:
[ca]
ocsp_url = "http://ocsp.example.com"
OCSP clients query this URL to determine the status of a specific certificate. The server does not implement an OCSP responder; you must operate one separately.
Checking revocation status
You can check whether a certificate stored in the database is revoked by querying the certificates table directly:
SELECT id, serial_number, status, revoked_at, revocation_reason
FROM certificates
WHERE serial_number = '<hex-serial>';
A status of 'revoked' indicates the certificate has been revoked, along with the Unix timestamp of revocation and the reason code.
Practical deployment
For a minimal private CA deployment that only needs revocation status for internal clients:
- Set
crl_urlto a URL you control, for examplehttp://acme.internal/crl/ca.crl. - Periodically query the database for revoked certificates and build a CRL with the
build_crlfunction. - Publish the CRL at the URL.
For larger deployments requiring OCSP:
- Set
ocsp_urlto an OCSP responder you operate (e.g., OpenSSL’socspcommand or a dedicated OCSP responder service). - The OCSP responder must have access to the CA’s private key (or a delegated OCSP signing key) and the list of revoked certificates from the database.
Merkle Tree Certificate Log
Akāmu integrates with a Merkle Tree Certificate (MTC) transparency log using the synta-mtc library. When enabled, each issued end-entity certificate is appended as a leaf to a disk-backed append-only log.
What is an MTC log?
A Merkle Tree Certificate log is a tamper-evident, append-only data structure. Each leaf is the SHA-256 hash (with Merkle domain separation) of the DER-encoded TBSCertificateLogEntry derived from the issued certificate’s TBSCertificate. The log supports efficient proofs of inclusion and consistency that third parties can verify.
This is analogous in concept to Certificate Transparency (CT) logs (RFC 6962) but uses a different data structure and encoding based on the synta-mtc specification.
Configuration
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = true
When enabled = true:
- On startup, the server opens the existing log file at
log_path, or creates a new one if the file does not exist. - After each successful certificate issuance, the certificate is appended to the log asynchronously in a background task.
- The resulting leaf index is stored in the
certificatesdatabase table (mtc_log_indexcolumn).
When enabled = false (the default):
- The log file is never written.
- The
log_pathmust still be specified but is not used.
Log format
The log file is a binary file managed by synta_mtc::storage::DiskBackedLog. Entries are written as fixed-size SHA-256 hashes (32 bytes each) in leaf-order. The hash function includes Merkle tree domain separation to prevent second-preimage attacks.
The file is created by DiskBackedLog::create and opened by DiskBackedLog::open. The server uses a “try create first, fall back to open” strategy to eliminate time-of-check-to-time-of-use races.
Appending certificates
Appending a certificate involves:
- Parsing the DER-encoded certificate to extract the
TBSCertificate. - Converting the
TBSCertificateto aTBSCertificateLogEntryusingsynta_mtc::integration::tbs_certificate_to_log_entry. - DER-encoding the log entry.
- Computing
hash_leaf(SHA-256, entry_der)— the Merkle leaf hash with domain separation prefix\x00. - Appending the 32-byte hash to the log file under a
tokio::sync::Mutexguard.
Steps 1–4 run in a tokio::task::spawn_blocking thread to avoid blocking the async executor with CPU-bound encoding work. Step 5 takes the mutex and writes.
If the append fails, a warning is logged but the certificate issuance response is not affected. The mtc_log_index column remains NULL in the database for that certificate.
Checking the log index
Query the database to find the MTC log index for a certificate:
SELECT id, serial_number, mtc_log_index
FROM certificates
WHERE mtc_log_index IS NOT NULL
ORDER BY mtc_log_index;
A NULL index means the certificate was either issued before MTC logging was enabled, or the log append failed.
Concurrency
The DiskBackedLog is not thread-safe internally. The server wraps it in a tokio::sync::Mutex<DiskBackedLog> (the SharedLog type alias in src/mtc/log.rs). All leaf appends and reads acquire this mutex, serializing concurrent operations at the async level.
Multiple processes accessing the same log file concurrently are not supported. A single Akāmu process is the exclusive writer.
Log integrity
The log is append-only by design. Once a leaf is appended it cannot be removed or modified without corrupting the file. The Merkle root can be computed from the log at any time using compute_root:
- For a log with zero leaves the root is undefined.
- For a log with one or more leaves the root is the SHA-256 Merkle root of all leaf hashes.
Note: The current implementation does not expose the log root, tree size, or inclusion proofs over HTTP. These would be additional endpoints if the MTC log were to be made externally verifiable. For now the log functions as an internal audit trail.
TLS Configuration
By default Akāmu listens on a plain TCP socket and relies on an upstream reverse proxy
(nginx, Caddy, HAProxy, …) for TLS termination. If you want a fully self-contained
deployment without a proxy, set [tls] enabled = true and Akāmu will accept HTTPS
connections directly.
Backward compatibility is strict: deployments without a [tls] section in
config.toml see zero behavior change.
When to use native TLS vs. a reverse proxy
| Scenario | Recommendation |
|---|---|
| Single-host lab / development | Native TLS — fewer moving parts |
| High-traffic or load-balanced production | Reverse proxy — better performance, centralized cert management |
| Mutual-TLS client authentication | Native TLS — the proxy would need to forward raw TLS which most do not |
| Post-quantum hybrid mTLS | Native TLS — composite ML-DSA schemes require direct OpenSSL integration |
Deployment modes overview
flowchart TD
A([Akāmu startup]) --> B{"tls section<br/>in config.toml?"}
B -->|No| C["Mode 1 — Plain HTTP<br/>Bind plain TCP socket<br/>Upstream proxy handles TLS"]
B -->|Yes, enabled=true| D{"cert_file AND<br/>key_file both exist?"}
D -->|Neither exists| E["Mode 2 — Auto-generated TLS<br/>Generate server key + cert<br/>signed by Akāmu CA<br/>Write files for next start"]
D -->|One missing| ERR["Error: both or neither required<br/>startup aborted"]
D -->|Both exist| F{"tls.client_auth<br/>section present?"}
F -->|No| G["Mode 3 — Native TLS<br/>Load externally-issued cert + key<br/>Serve HTTPS directly"]
F -->|Yes| H["Mode 4 — Mutual TLS<br/>Require client certificate<br/>signed by configured CA<br/>Handshake fails for unknown clients"]
C & E & G & H --> READY([Server ready])
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class READY ok
class ERR fail
Deployment walkthroughs
The sections below walk through each of the four supported operating modes in order of increasing complexity. Pick the one that matches your environment.
Mode 1 — Plain HTTP behind a reverse proxy
This is the default and requires no [tls] section at all. Akāmu binds to a
plain TCP socket — typically on a loopback or private address — and an upstream
reverse proxy handles HTTPS termination and forwards requests over HTTP.
config.toml (no [tls] section)
listen_addr = "127.0.0.1:8080"
base_url = "https://acme.example.com"
[database]
path = "/var/lib/akamu/akamu.db"
[ca]
key_file = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = false
base_url must be the external HTTPS URL that ACME clients use to reach the
directory — not the internal loopback address. Akāmu embeds this value in every
URL it returns to clients (account URLs, order URLs, certificate download URLs,
etc.). If base_url points at 127.0.0.1 clients will receive unusable URLs.
nginx example
server {
listen 443 ssl;
server_name acme.example.com;
ssl_certificate /etc/nginx/tls/acme.example.com.crt;
ssl_certificate_key /etc/nginx/tls/acme.example.com.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Caddy example
acme.example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy automatically obtains and renews its own TLS certificate for the public hostname; no extra TLS configuration is needed on the Akāmu side.
Mode 2 — Native TLS with auto-generated certificate (development / lab)
Use this when you want Akāmu to speak HTTPS directly without a proxy but you do not yet have an externally-issued certificate. Akāmu will generate a server certificate signed by its own CA on first start.
Step 1. Add a [tls] section with enabled = true and supply paths for
cert_file and key_file. The files do not need to exist yet.
listen_addr = "0.0.0.0:8443"
base_url = "https://akamu.internal:8443"
[tls]
enabled = true
cert_file = "/etc/akamu/server.crt"
key_file = "/etc/akamu/server.key"
server_name = "akamu.internal"
listen_addr and base_url must agree on the port (8443 here). base_url
is the URL clients will use; listen_addr is what the OS socket binds to.
0.0.0.0 binds on all interfaces; restrict to a specific IP if needed.
Step 2. Start Akāmu. Because cert_file and key_file are both absent,
the bootstrap logic in tls::init::load_or_generate runs automatically:
- A fresh server key is generated using
bootstrap_key_type(defaultec:P-256). - A server certificate for
server_nameis signed by the Akāmu CA. - The PEM chain (
leaf + CA) is written tocert_fileand the private key tokey_file.
On every subsequent start the existing files are loaded without regeneration.
Step 3. Trust the Akāmu CA on the client. The auto-generated server
certificate chains to the Akāmu CA — the same CA that signs ACME-issued
certificates. Its PEM is at the path configured in [ca] cert_file. Pass it
to curl with --cacert:
curl --cacert /etc/akamu/ca.cert.pem \
https://akamu.internal:8443/acme/directory
ACME client software (certbot, acme.sh, …) typically has an equivalent option for specifying a custom CA bundle.
Mode 3 — Native TLS with an externally-issued certificate (production, no proxy)
Use this when ACME clients cannot be pre-configured to trust the Akāmu CA and a publicly-trusted TLS certificate is required for the ACME endpoint itself.
The Akāmu CA (which signs certificates issued to your ACME clients) is entirely separate from the TLS server certificate. Obtaining a public TLS cert for the ACME server hostname does not affect the CA or the certificates it issues.
Step 1. Obtain a certificate for the Akāmu server hostname from a public CA (Let’s Encrypt, ZeroSSL, your organisation’s PKI, etc.). You can use any ACME client pointed at a public CA — Akāmu itself does not need to be running yet for this step.
Step 2. Place the PEM certificate chain in cert_file (leaf first, then
any intermediates) and the unencrypted private key in key_file.
Step 3. Configure Akāmu:
listen_addr = "0.0.0.0:8443"
base_url = "https://acme.example.com:8443"
[tls]
enabled = true
cert_file = "/etc/akamu/server.crt" # publicly-trusted chain, leaf first
key_file = "/etc/akamu/server.key" # unencrypted PKCS#8 or EC key
server_name = "acme.example.com" # used only if cert/key are absent (bootstrap)
Because cert_file and key_file already exist when Akāmu starts, the
bootstrap step is skipped entirely. server_name has no effect at runtime but
is kept as documentation of the hostname and makes the config self-describing.
Step 4. When the public certificate is renewed, replace cert_file and
key_file on disk and restart Akāmu (or send SIGHUP if live reload is
supported by your deployment).
Mode 4 — Mutual TLS (mTLS)
Mutual TLS requires every connecting client to present a certificate signed by a trusted CA. This is useful for restricting access to the ACME server to known ACME agents or administrative tooling.
The TLS handshake enforces client authentication before any HTTP request is processed. A client that presents no certificate or an untrusted certificate receives a TLS handshake error — not an ACME-level error response.
Step 1. Decide on a client CA. This can be the Akāmu CA itself (so that only clients holding Akāmu-issued certificates may connect) or a separate private CA dedicated to client authentication. The CA certificate(s) must be available as PEM files on the Akāmu host.
Step 2. Add a [tls.client_auth] section. For a private PKI use
profile = "rfc5280" to avoid CAB Forum restrictions that do not apply to
internally-issued certificates.
listen_addr = "0.0.0.0:8443"
base_url = "https://akamu.internal:8443"
[tls]
enabled = true
cert_file = "/etc/akamu/server.crt"
key_file = "/etc/akamu/server.key"
[tls.client_auth]
required = true
ca_files = ["/etc/akamu/client-ca.crt"]
profile = "rfc5280"
Step 3. Test with curl, supplying both a CA bundle for server certificate verification and a client certificate with its key:
curl --cacert /etc/akamu/ca.cert.pem \
--cert client.crt \
--key client.key \
https://akamu.internal:8443/acme/directory
Step 4. Understand the failure modes. When a client connects without a
certificate or with a certificate that does not chain to the configured
ca_files, the TLS handshake is aborted. The client sees a TLS alert (for
example handshake failure or certificate required), not an HTTP 4xx
response. Check the Akāmu log for client cert verification failed: … to
diagnose chain or profile issues.
Minimal configuration
[tls]
enabled = true
cert_file = "/etc/akamu/server.crt" # PEM chain: leaf cert first, then intermediates/CA
key_file = "/etc/akamu/server.key" # PEM private key (PKCS#8 or SEC1, unencrypted)
If both cert_file and key_file are absent when the server starts, Akāmu
auto-generates a server certificate signed by the Akāmu CA and writes both files.
This makes the first-run experience zero-configuration: any client that already
trusts the Akāmu CA will also trust the TLS channel.
If only one of the two files exists, startup fails with an explicit error.
Certificate and key format
-
cert_file: PEM file with one or more
-----BEGIN CERTIFICATE-----blocks. The leaf certificate must come first; intermediate and root certificates follow in order. When Akāmu generates the file it writes<leaf>\n<CA>automatically. -
key_file: PEM file with a single private key —
-----BEGIN PRIVATE KEY-----(PKCS#8) or-----BEGIN EC PRIVATE KEY-----(SEC1). The file must be unencrypted (no passphrase). Akāmu never reads an encrypted key file.
Full [tls] field reference
[tls]
# Whether to enable native TLS. Default: false.
enabled = true
# PEM file with the server certificate chain (leaf first).
cert_file = "/etc/akamu/server.crt"
# PEM file with the server private key (unencrypted PKCS#8 or SEC1).
key_file = "/etc/akamu/server.key"
# TLS protocol versions to accept. Default: ["TLSv1.2", "TLSv1.3"].
protocols = ["TLSv1.3"]
# Hostname placed in CN and SAN of the auto-generated server certificate.
# Only used when cert_file/key_file are both absent. Default: "localhost".
server_name = "akamu.internal"
# Key algorithm for the auto-generated server certificate.
# Accepted values: "ec:P-256", "ec:P-384", "ec:P-521",
# "rsa:2048", "rsa:3072", "rsa:4096", "ed25519".
# Default: "ec:P-256".
bootstrap_key_type = "ec:P-256"
Mutual TLS client certificate authentication
[tls.client_auth] enables mTLS: Akāmu requests a client certificate and validates
the chain against a configurable set of trusted CAs.
[tls.client_auth]
# Reject connections that present no client certificate. Default: false.
required = true
# PEM files containing trusted root CA certificates.
# Each file may contain multiple PEM blocks.
ca_files = [
"/etc/akamu/client-ca.crt",
]
# Validation profile: "webpki" (CAB Forum) or "rfc5280". Default: "webpki".
profile = "webpki"
# Allow ML-DSA / composite post-quantum algorithms in client cert chains.
# Default: false.
allow_post_quantum = false
# Maximum certificate chain depth (leaf not counted). Default: 8.
max_chain_depth = 8
# Minimum RSA modulus size in bits. Default: 2048.
minimum_rsa_modulus = 2048
profile — CAB Forum vs RFC 5280
| Setting | Behaviour |
|---|---|
"webpki" | CAB Forum / Web PKI profile enforced by synta-x509-verification. Rejects certificates that violate Baseline Requirements (e.g. missing SAN, weak key). Suitable for publicly-trusted client CAs. |
"rfc5280" | Strict RFC 5280 profile. More permissive than WebPKI on some extensions; suitable for enterprise or private PKI that does not follow CAB Forum rules. |
Post-quantum support (allow_post_quantum = true)
When enabled, Akāmu accepts:
- Pure ML-DSA certificate chains: verified by
synta-x509-verificationusing the OpenSSL backend (pqc-prs fork). - Composite ML-DSA+classical TLS 1.3
CertificateVerifysignatures: provisional code points from draft-ietf-lamps-pq-composite-sigs are advertised and verified via the OpenSSL EVP interface.
Classical verification is always performed via the ring crypto provider.
TLS 1.2 CertificateVerify always uses classical ring verification — composite
schemes are TLS 1.3 only and never appear in a TLS 1.2 handshake.
Full annotated example with mTLS
[server]
listen_addr = "0.0.0.0:8443"
base_url = "https://akamu.internal:8443"
[tls]
enabled = true
cert_file = "/etc/akamu/server.crt"
key_file = "/etc/akamu/server.key"
protocols = ["TLSv1.3"]
server_name = "akamu.internal"
bootstrap_key_type = "ec:P-384"
[tls.client_auth]
required = true
ca_files = ["/etc/akamu/client-ca.crt", "/etc/akamu/sub-ca.crt"]
profile = "rfc5280"
allow_post_quantum = true
max_chain_depth = 5
minimum_rsa_modulus = 3072
Known limitations
-
Composite scheme code points (
src/tls/schemes.rs) are taken from the provisional IANA allocations in draft-ietf-lamps-pq-composite-sigs. They must be verified against the current draft version before deploying to production; if the draft advances and code points change, only that file needs updating. -
Composite OpenSSL binding: composite ML-DSA+classical
CertificateVerifyverification relies on the pqc-prs OpenSSL fork exposing composite NIDs viaPKey::public_key_from_der. If those NIDs are not yet in the Rust binding layer, the function will return an OpenSSL error at runtime. The fix is to add composite NID support to the Rust openssl bindings in the pqc-prs fork, not to Akāmu itself. -
Pure ML-DSA TLS
SignatureSchemecode points: no IANA code points exist yet for standalone ML-DSA (non-composite) TLS schemes. Even withallow_post_quantum = true, only composite schemes are advertised. -
Client remote address: when native TLS is active, handlers can extract the client’s remote address via
axum::extract::ConnectInfo<SocketAddr>— this is available because the server is started withinto_make_service_with_connect_info::<SocketAddr>().
Troubleshooting
| Error | Likely cause |
|---|---|
TLS cert file 'X' contains no PEM blocks | Wrong file path, or file is DER-encoded (convert to PEM first) |
TLS cert and key must both be present or both absent | One file exists but the other does not; either supply both or remove both |
build client-auth trust store: … | A CA PEM file is malformed or contains non-certificate data |
client cert verification failed: … | Client presented a cert that does not chain to the configured CA, has expired, or violates the chosen profile |
composite signature verification failed: … | pqc-prs OpenSSL fork does not expose the composite NID for the scheme used; see Known Limitations |
TLS versions: … | protocols list contains an unsupported value; use "TLSv1.2" and/or "TLSv1.3" |
Performance
This chapter covers issuance throughput and latency characteristics of Akāmu under load, with guidance on key type selection and capacity planning.
All numbers were collected on a single host using the acme-bench tool shipped
in the repository:
cargo bench --bench acme_bench -- [OPTIONS]
The benchmark runs full ACME flows (account → new-order → challenge validate →
finalize → certificate download) against a real in-process server over the
loopback interface using an in-memory SQLite database. Reported latency is
end-to-end wall time from the start of new-order through certificate download;
account creation is excluded because it is amortised across all orders from a
given client.
Note — database layer (sqlx). Akāmu uses sqlx 0.8 for SQLite access. Both in-memory (
:memory:) and file-backed databases use a single-connection pool (max_connections = 1). In-memory databases require this because every SQLite in-memory connection opens its own private, empty database. File-backed databases use it to avoidSQLITE_BUSY_SNAPSHOT(error 517), a WAL-mode contention error that bypasses the busy handler and cannot be retried — sqlx attempts to reuse connection read-snapshots across pool round-trips, and when a concurrent writer commits between those round-trips the stale snapshot triggers the error. Approximately 24 SQL round-trips are needed per issuance (reduced from ~55 by moving anti-replay nonces to an in-memory store and using JOIN queries to collapse read pairs into single round-trips); throughput plateaus at ≈ 1350–1500 iss/s at 25 concurrent clients, determined by how fast the single sqlx connection can process queries rather than by crypto, network, or storage speed. See the Database scalability section for guidance on exceeding this ceiling.
Concurrency scaling
With EC P-256 certificates and an EC P-256 CA, throughput scales up to ~10 concurrent clients and then plateaus as the single-connection pool becomes the bottleneck:
| Concurrent clients | Throughput (iss/s) | Mean latency (ms) | p95 (ms) |
|---|---|---|---|
| 1 | 231 | 4.3 | 5.3 |
| 5 | 1086 | 4.6 | 5.6 |
| 10 | 1445 | 6.8 | 7.7 |
| 25 | 1501 | 15.2 | 17.7 |
| 50 | 1356 | 30.8 | 34.7 |
Throughput peaks around 10–25 concurrent clients at ~1500 iss/s and remains stable at 25–50 clients (≈ 1350–1500 iss/s); latency grows roughly linearly with client count, consistent with a single serialised resource. The practical bottleneck is the in-memory SQLite single connection; crypto and network are not limiting factors at these rates.
Key type comparison
The table below compares issuance performance for different CSR key types at 25 concurrent clients with an EC P-256 CA.
| CSR key type | Throughput (iss/s) | Mean latency (ms) | p95 (ms) | Finalize phase (ms) |
|---|---|---|---|---|
| ec:P-256 | 1419 | 15.0 | 17.0 | 3.4 |
| ed25519 | 1265 | 15.9 | 17.2 | 3.7 |
| ec:P-384 | 1311 | 16.3 | 19.4 | 5.2 |
| ml-dsa-44 | 1332 | 16.2 | 18.8 | 4.7 |
| ml-dsa-65 | 1149 | 18.2 | 21.2 | 5.7 |
| ml-dsa-87 | 1216 | 17.7 | 20.9 | 5.8 |
| rsa:2048 | 142 | 137.6 | 246.3 | 104.0 |
| rsa:4096 | 14 | 993 | — | 915 |
All classical and post-quantum key types cluster around 1150–1420 iss/s because throughput is bounded by the single-connection database pool, not by crypto. Finalize-phase latency (CSR verification + certificate issuance) still reflects relative signing cost: EC and Ed25519 are fastest, ML-DSA adds ~1–2 ms, and RSA adds tens to hundreds of milliseconds.
RSA is the outlier: RSA 2048 adds ~100 ms to finalize, and RSA 4096 adds ~900 ms.
RSA 4096 saturation
| Clients | Throughput (iss/s) | Finalize mean (ms) | p99 (ms) |
|---|---|---|---|
| 1 | 3 | 353 | 974 |
| 10 | 12 | 484 | 1188 |
| 25 | 14 | 915 | 2021 |
| 50 | 9 | 1118 | 3271 |
Throughput is limited by RSA 4096 key generation time. At 50 clients the additional queuing raises both finalize latency and overall contention, reducing aggregate throughput below the 25-client figure. Avoid RSA 4096 in any configuration where more than a handful of concurrent ACME clients are expected.
Post-quantum cryptography
Akāmu supports ML-DSA (FIPS 204 / RFC 9881) for both CA keys and certificate
keys. Three security levels are available. The table uses a full post-quantum
chain (ML-DSA CA + ML-DSA leaf, with --verify-cert) at 25 concurrent clients:
| Parameter set | NIST category | Throughput (iss/s) | Alloc pressure (MiB/iss) |
|---|---|---|---|
| ML-DSA-44 | 2 | 1366 | 0.54 |
| ML-DSA-65 | 3 | 1093 | 0.64 |
| ML-DSA-87 | 5 | 1067 | 0.77 |
| EC P-256 | — | 1365 | 0.33 |
ML-DSA allocation pressure is 60–130% higher than EC P-256 per issuance, reflecting the larger key and signature structures. Throughput difference between ML-DSA and EC P-256 varies by parameter set: ML-DSA-44 matches EC P-256 closely (1366 vs 1365 iss/s) because the database single-connection bottleneck dominates over crypto cost at 25 clients. ML-DSA-65 and ML-DSA-87 trail by ~20–25% due to their larger certificate structures consuming more of the single connection’s capacity during signing and serialisation.
ML-DSA requires OpenSSL 3.5 or later. Akāmu will report a startup error if the requested key type is unavailable on the installed OpenSSL version.
CA key type impact
| CA key | Throughput (iss/s) | Mean latency (ms) | Finalize (ms) |
|---|---|---|---|
| ec:P-256 | 1255 | 16.3 | 3.7 |
| ec:P-384 | 1333 | 14.7 | 3.3 |
| rsa:2048 | 1293 | 15.5 | 4.0 |
| rsa:4096 | 832 | 23.8 | 11.4 |
EC and RSA 2048 CA keys deliver equivalent throughput in the optimised server (all are database-bottlenecked at 25 clients). RSA 4096 as the CA key reduces throughput by ~35% vs EC P-256 due to slower signing raising finalize latency above the per-query DB round-trip time; avoid it for performance-sensitive deployments.
Challenge type comparison
| Challenge type | Throughput (iss/s) | Challenge phase (ms) | Alloc pressure (MiB/iss) |
|---|---|---|---|
| http-01 | 1456 | 5.5 | 0.33 |
| dns-persist-01 | 1252 | 6.8 | 0.37 |
http-01 delivers ~15% higher throughput than dns-persist-01 on loopback.
Both challenge phases reflect the adaptive poll backoff (starts at 1 ms, caps at
--poll-ms) rather than network latency; the 5–7 ms figure is dominated by
polling overhead and background validation round-trips.
Key type recommendations
| Scenario | Recommended key type |
|---|---|
| General purpose, broad client compatibility | ec:P-256 |
| Smallest footprint, fastest validation | ed25519 |
| Higher security margin, still classical | ec:P-384 |
| Post-quantum resistant, FIPS 204 category 2 | ml-dsa-44 |
| Post-quantum resistant, FIPS 204 category 3 | ml-dsa-65 |
| Post-quantum resistant, FIPS 204 category 5 | ml-dsa-87 |
| Interoperability with RSA-only clients | rsa:2048 (avoid RSA 4096 under load) |
Database scalability
Both in-memory (:memory:) and file-backed databases use a single-connection
pool, so the throughput ceiling of ≈ 1350–1500 iss/s applies to both. The ceiling
is set by how fast the sqlx SQLite worker thread can process one query at a time —
each query requires a channel round-trip to the background thread, and ~24 such
round-trips are needed per issuance (reduced from ~55 by moving anti-replay nonces
to an in-memory store and using JOIN queries to collapse read pairs).
Backend comparison (tmpfs vs in-memory)
The table below shows that file-backed SQLite on a RAM-backed filesystem (tmpfs
/ /dev/shm) produces equivalent throughput to an in-memory database. WAL
journal mode adds a small amount of write bookkeeping overhead; the difference
is within run-to-run noise.
| Concurrent clients | In-memory (iss/s) | tmpfs WAL (iss/s) |
|---|---|---|
| 1 | 231 | 220 |
| 5 | 1086 | 1108 |
| 10 | 1445 | 1380 |
| 25 | 1501 | 1321 |
| 50 | 1356 | 1250 |
Both backends plateau around 1350–1500 iss/s at 10–25 concurrent clients. The bottleneck is the sqlx connection round-trip per query, not storage speed; switching from in-memory to a tmpfs-backed file provides durability without a throughput penalty.
For sustained high-throughput targets consider:
- In-memory database for lab, CI, or ephemeral CA use cases. Fastest startup; data is lost on restart.
- File-backed WAL database on a fast SSD or RAM-backed filesystem. Throughput matches in-memory while providing crash durability.
- Sharding — multiple Akāmu instances behind a load balancer, each with its own database — for production-scale deployments requiring higher aggregate issuance rates above the ≈ 1500 iss/s per-instance ceiling.
Connection pool size and BEGIN IMMEDIATE
SQLITE_BUSY_SNAPSHOT (error 517) occurs in WAL mode when a deferred
transaction (BEGIN) captures a read snapshot that becomes stale after another
connection commits — even when the two transactions write to completely different
rows. Unlike SQLITE_BUSY (error 5), error 517 bypasses the busy handler
entirely, so busy_timeout has no effect on it.
Akāmu resolves this by using BEGIN IMMEDIATE for every write transaction
(db::begin_write). BEGIN IMMEDIATE acquires the write lock at transaction
start, so the snapshot is always current. Any resulting SQLITE_BUSY
contention is handled transparently by the busy_timeout = 5 s already
configured on the pool.
The table below shows that after this fix, pool > 1 produces zero errors
at every concurrency level. Write throughput is unchanged because BEGIN IMMEDIATE still serialises writers — only one connection can hold the write
lock at a time — but errors are eliminated.
Throughput (iss/s) and error count (out of 200 requests) on tmpfs WAL with BEGIN IMMEDIATE:
| Concurrent clients | Pool = 1 | Pool = 2 | Pool = 4 | Pool = 8 |
|---|---|---|---|---|
| 1 | 208 / 0 err | 206 / 0 err | 202 / 0 err | 220 / 0 err |
| 5 | 1092 / 0 err | 959 / 0 err | 707 / 0 err | 617 / 0 err |
| 10 | 1389 / 0 err | 1372 / 0 err | 1247 / 0 err | 821 / 0 err |
| 25 | 1307 / 0 err | 1164 / 0 err | 1096 / 0 err | 998 / 0 err |
| 50 | 1197 / 0 err | 1119 / 0 err | 1041 / 0 err | 996 / 0 err |
All pool sizes produce zero errors — BEGIN IMMEDIATE eliminates
SQLITE_BUSY_SNAPSHOT regardless of how many connections are in the pool.
Pool = 1 consistently delivers the highest throughput because all requests
share a single serialised connection channel with no lock-acquisition contention.
Pool = 2 and above pay increasingly for BEGIN IMMEDIATE wait time as multiple
connections compete for the WAL write lock; the gap widens at medium concurrency
(5–10 clients) where lock contention is highest relative to available parallelism.
For the single-connection production default (open) this has no observable
effect: with one connection there is never a concurrent writer, so BEGIN IMMEDIATE and BEGIN DEFERRED behave identically.
The --pool-connections benchmark option can be used to measure pool behaviour:
# Pool comparison on tmpfs with BEGIN IMMEDIATE (zero errors expected)
for p in 1 2 4 8; do
DB=$(mktemp /dev/shm/bench_pool_XXXXXX.db)
cargo bench --bench acme_bench -- \
--db "$DB" --pool-connections "$p" \
--clients 25 --requests 200 --warmup 20 --poll-ms 5
rm -f "$DB" "${DB}-wal" "${DB}-shm"
done
Running the benchmark
The acme-bench binary is built as a Cargo bench target:
cargo bench --bench acme_bench -- --help
Common invocations:
# Baseline: 25 concurrent clients, 200 issuances, EC P-256, 5 ms poll cap
cargo bench --bench acme_bench -- --clients 25 --requests 200 --warmup 20 --poll-ms 5
# Compare RSA 2048 vs EC P-256
cargo bench --bench acme_bench -- --key-type rsa:2048 --clients 25 --requests 100
cargo bench --bench acme_bench -- --key-type ec:P-256 --clients 25 --requests 100
# Full post-quantum chain (ML-DSA-65 CA + ML-DSA-65 leaf)
cargo bench --bench acme_bench -- \
--ca-key-type ml-dsa-65 --key-type ml-dsa-65 \
--clients 25 --requests 100 --verify-cert
# Scalability sweep
for n in 1 5 10 25 50; do
cargo bench --bench acme_bench -- --clients $n --requests 300 --warmup 20 --poll-ms 5
done
# dns-persist-01 challenge type
cargo bench --bench acme_bench -- --challenge dns-persist-01 --clients 25 --requests 200
# JSON output for scripting
cargo bench --bench acme_bench -- --output json --clients 25 --requests 200 --poll-ms 5 | jq .summary
Available options
| Option | Default | Description |
|---|---|---|
--clients N | 10 | Concurrent worker tasks |
--requests N | 100 | Issuances to measure (warmup not counted) |
--warmup N | 10 | Warmup issuances discarded before measurement |
--poll-ms N | 50 | Poll interval cap in milliseconds; adaptive backoff starts at 1 ms |
--challenge TYPE | http-01 | http-01 or dns-persist-01 |
--key-type TYPE | ec:P-256 | CSR key type (see table above) |
--ca-key-type TYPE | ec:P-256 | CA key type (same syntax) |
--db PATH | :memory: | SQLite path — :memory: or a file path |
--pool-connections N | 1 | SQLite pool size; ignored (clamped to 1) when --db :memory:; see Connection pool size |
--wildcard | off | Issue *.bench-N.acme-bench.test (dns-persist-01 only) |
--output FORMAT | text | text or json |
--verify-cert | off | Parse and verify the SAN of every issued certificate |
The poll loop uses adaptive exponential backoff: it starts at 1 ms, doubles each
miss, and caps at --poll-ms. This mirrors how production ACME clients behave
and reveals the true validation latency without a fixed artificial floor.
Memory consumption
The benchmark instruments heap allocation using a custom
GlobalAlloc
wrapper that records four AtomicU64 counters. This reports in-process heap
usage without any external tooling or /proc parsing.
Three snapshots are taken:
| Milestone | When |
|---|---|
| process start | Before the server is initialised |
| server ready | After the server has bound its port and is accepting connections |
| after bench | After all issuances (warmup + measured) have completed |
The peak counter is reset at server ready so the high-water mark reflects
only the issuance window, not server startup allocations.
Text output
Heap (allocator counters):
process start: 0.1 MiB live
server ready: 0.2 MiB live (server overhead: +0.5 MiB)
after 220 iss.: 0.6 MiB live (issuance growth: +0.4 MiB, 1.9 KiB/iss.)
peak live: 1.5 MiB (high-water mark during issuances)
alloc pressure: 83.5 MiB total (0.379 MiB/iss. requested, incl. freed)
live — bytes currently held on the heap (footprint).
alloc pressure — cumulative bytes requested from the system allocator since
server ready, including memory that was allocated and subsequently freed. A
high pressure-to-footprint ratio indicates short-lived allocations (normal for
per-request work like signature buffers and JSON serialisation).
JSON output
The "memory" key is present in JSON output when --output json is used:
{
"memory": {
"start_live_bytes": 102400,
"server_ready_live_bytes": 204800,
"after_bench_live_bytes": 614400,
"peak_live_bytes": 1572864,
"server_overhead_bytes": 512000,
"issuance_growth_bytes": 409600,
"per_issuance_growth_bytes": 1900,
"issuance_alloc_bytes": 87523328,
"per_issuance_alloc_bytes": 397833,
"total_alloc_count": 700000
}
}
| Field | Meaning |
|---|---|
*_live_bytes | Heap footprint at each milestone |
peak_live_bytes | Highest live bytes seen during the issuance window |
server_overhead_bytes | Live growth from start to server-ready |
issuance_growth_bytes | Live growth from server-ready to end of bench |
per_issuance_growth_bytes | Per-issuance share of issuance growth |
issuance_alloc_bytes | Total bytes requested during the issuance window |
per_issuance_alloc_bytes | Per-issuance allocation pressure |
total_alloc_count | Total number of alloc calls in the whole process |
Typical figures
At 25 concurrent clients with 200 measured issuances (EC P-256, :memory: DB,
5 ms poll cap):
- Server overhead: ~0.3 MiB live (router tables, DB connection pool, CA state, HTTP client)
- Per-issuance heap growth: ~1 KiB (request-scoped state retained by tokio workers)
- Peak during issuances: ~2.7 MiB (25 in-flight requests simultaneously)
- Allocation pressure: ~335 KiB per issuance (JWS buffers, JSON serialisation, cert DER/PEM)
For ML-DSA key types allocation pressure rises to ~550–790 KiB per issuance due to larger key and certificate structures (lower end with EC P-256 CA, higher end with a matching ML-DSA CA).
These figures confirm that Akāmu has a stable heap footprint at steady state. Per-issuance live growth is small and bounded by the number of concurrent workers, not the total number of issuances.
RFC Support Reference
This page documents every RFC that is relevant to Akāmu, explaining what each one specifies, which parts are implemented, and — for RFCs that are intentionally not implemented — why.
Summary
| Specification | Title | Status |
|---|---|---|
| dns-persist-01 | Let’s Encrypt Persistent DNS Challenge | Full |
| draft-aaron-acme-profiles-01 | ACME Certificate Profiles | Full |
| draft-ietf-cose-dilithium-11 | ML-DSA (Dilithium) for JOSE (JWK + JWS) | Full |
| draft-ietf-lamps-pq-composite-sigs | ML-DSA Composite TLS Signature Schemes | Partial (provisional code points) |
| RFC 8555 | Automatic Certificate Management Environment (ACME) | Full |
| RFC 8659 | DNS Certification Authority Authorization (CAA) | Full |
| RFC 8657 | CAA Extensions: accounturi and validationmethods | Full |
| RFC 8737 | ACME TLS-ALPN-01 Challenge Extension | Full |
| RFC 8738 | ACME IP Identifier Validation | Full |
| RFC 8739 | ACME Short-Term, Automatically Renewed (STAR) Certificates | Full |
| RFC 9444 | ACME for Subdomains | Full |
| RFC 9773 | ACME Renewal Information (ARI) | Full |
| RFC 9799 | ACME Extensions for .onion Special-Use Domain Names | Full |
| RFC 5280 | X.509 Certificate and CRL Profile | Full |
| RFC 8823 | ACME Extensions for S/MIME Certificates | Not implemented |
| RFC 9115 | ACME Profile for Delegated Certificates | Not implemented |
| RFC 9345 | Delegated Credentials for TLS | Not implemented |
| RFC 9447 | ACME Challenges Using an Authority Token | Not implemented |
| RFC 9448 | ACME TNAuthList Authority Token | Not implemented |
| RFC 9538 | ACME Delegation Metadata for CDNI | Not implemented |
| RFC 9891 | ACME DTN Node ID Validation (Experimental) | Not implemented |
RFC 8555 — Core ACME
RFC 8555 is the foundation. It defines the full ACME protocol: the HTTP API, the JSON object model, the JWS (JSON Web Signature) authentication scheme, and the challenge validation framework.
What it covers
| Section | Feature | Status |
|---|---|---|
| §7.1 | Directory (GET /acme/directory) | Yes |
| §7.2 | Nonces (HEAD /acme/new-nonce, GET /acme/new-nonce) | Yes |
| §7.3 | Account creation and management (/acme/new-account, /acme/account/{id}) | Yes |
| §7.3.4 | externalAccountRequired enforcement | Yes |
| §7.4 | Order management (/acme/new-order, /acme/order/{id}) | Yes |
| §7.4.1 | Pre-authorization (POST /acme/new-authz) | Yes |
| §7.1.3 | Honour order notBefore / notAfter in issued certificates | Yes |
| §7.5 | Authorizations (/acme/authz/{id}) | Yes |
| §7.5.1 | Challenge response (/acme/chall/{authz}/{type}) | Yes |
| §7.4 finalize | Certificate issuance (/acme/order/{id}/finalize) | Yes |
| §7.4.2 | Certificate download (/acme/cert/{id}) | Yes |
| §7.6 | Certificate revocation (/acme/revoke-cert) | Yes |
| §7.3.5 | Account key rollover (/acme/key-change) | Yes |
| §8.3 | http-01 challenge validation | Yes |
| §8.4 | dns-01 challenge validation | Yes |
Pre-authorization (newAuthz)
Pre-authorization lets a client prove domain control ahead of any specific order. Once pre-authorized, the client can request multiple certificates for that domain (or its subdomains, if subdomainAuthAllowed is set) without repeating the challenge for each order.
POST /acme/new-authz
Content-Type: application/jose+json
payload: {
"identifier": { "type": "dns", "value": "example.com" }
}
The response is identical to a reactive authorization created by newOrder.
External Account Binding
When server.external_account_required = true, every newAccount request must include an externalAccountBinding field. Requests without it are rejected with urn:ietf:params:acme:error:externalAccountRequired (HTTP 403).
EAB keys are provisioned in the TOML configuration under [server]:
[server]
external_account_required = true
[server.eab_keys]
"kid-1" = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg" # base64url-encoded raw key bytes
"kid-2" = "YW5vdGhlci1rZXktaGVyZQ"
Keys are seeded into the eab_keys database table at startup using INSERT OR IGNORE, so a key consumed or modified at runtime is never overwritten by a server restart. The server performs full HMAC verification per RFC 8555 §7.3.4:
- Extracts the
kidfrom the EAB protected header. - Looks up the key in the database; rejects unknown or already-consumed kids.
- Validates the algorithm (
HS256,HS384, orHS512), theurl(must match thenew-accountendpoint), and the payload (must be the account public key). - Verifies the HMAC signature using OpenSSL constant-time comparison.
- Inserts the new account and marks the EAB key as consumed in a single SQLite transaction.
The design is forward-compatible with an admin API endpoint: insert, delete, and get_by_kid are already implemented in the database layer.
Certificate validity window
If the newOrder request includes notBefore and/or notAfter fields, the issued certificate’s validity period will honour them, subject to the CA’s configured validity_days limit and a 5-minute clock-skew grace on notBefore.
RFC 8659 — CAA DNS Resource Record
RFC 8659 requires a CA to look up DNS Certification Authority Authorization (CAA) records before issuing a certificate. A domain owner can publish CAA records to restrict which CAs are allowed to issue certificates for that domain.
How Akāmu implements it
Before issuing any certificate, Akāmu queries CAA records for each DNS identifier in the order:
- It starts at the requested domain (e.g.,
sub.example.com) and walks up the DNS tree (example.com,com) until it finds a CAA record set or exhausts the tree. - If no CAA records are found anywhere, issuance proceeds (unconstrained domain).
- If a CAA record set is found, Akāmu checks whether any
issuerecord (orissuewildrecord for wildcard certs) contains one of the CA’s configured domain names (server.caa_identities). - If none match, issuance is denied with
urn:ietf:params:acme:error:caa(HTTP 403).
Configuration
[server]
caa_identities = ["acme.example.com"]
When caa_identities is empty (the default), CAA checking is disabled entirely.
Example CAA record
A domain owner who trusts only this Akāmu instance would publish:
example.com. IN CAA 0 issue "acme.example.com"
To also allow wildcard certificates:
example.com. IN CAA 0 issuewild "acme.example.com"
IP identifiers are not subject to CAA checking (CAA is a DNS mechanism).
RFC 8657 — CAA accounturi and validationmethods
RFC 8657 extends CAA with two optional parameters that give domain owners finer-grained control:
accounturi— Restricts issuance to a specific ACME account URI.validationmethods— Restricts issuance to specific challenge types (e.g., onlydns-01).
validationmethods
When Akāmu finds a matching issue or issuewild CAA record that contains a validationmethods parameter, it checks whether the challenge type used to validate the order appears in the list. If not, issuance is denied.
Example:
; Only allow dns-01 for this CA
example.com. IN CAA 0 issue "acme.example.com; validationmethods=dns-01"
With this record, an http-01-validated order for example.com would be denied at finalization time.
accounturi
When a matching issue or issuewild CAA record contains an accounturi parameter, Akāmu enforces it: the full ACME account URL of the requesting client (e.g. https://acme.example.com/acme/account/42) must match the parameter value exactly. If it does not match, the record is treated as non-authorizing and issuance is denied unless another record in the set authorizes it without an accounturi constraint.
Example:
; Only the named account may obtain a certificate from this CA
example.com. IN CAA 0 issue "acme.example.com; accounturi=https://acme.example.com/acme/account/42"
RFC 8737 — TLS-ALPN-01 Challenge
RFC 8737 defines the tls-alpn-01 challenge, which proves domain control by serving a specially crafted TLS certificate on port 443 using the ALPN protocol identifier acme-tls/1.
How it works
- Akāmu computes the SHA-256 of the key authorization.
- It opens a TLS connection to port 443 of the domain, advertising
acme-tls/1as the ALPN protocol. - It verifies that the server presents a certificate with:
- The domain as a
dNSNameSAN (exactly one SAN entry). - A critical
id-pe-acmeIdentifierextension (OID1.3.6.1.5.5.7.1.31) containing the SHA-256 hash of the key authorization as a DEROCTET STRING.
- The domain as a
- For IP identifiers, the server connects directly to the IP address; the reverse-DNS name is used as the TLS SNI value.
Constraints
- Port 443 must be reachable from the Akāmu server.
- Wildcard identifiers cannot be validated with
tls-alpn-01. - Both TLS 1.2 and TLS 1.3 are accepted.
- RFC 8737 §3 requires exactly one SAN entry in the validation certificate. Certificates with multiple SANs are rejected.
RFC 8738 — IP Identifier Validation
RFC 8738 extends ACME to issue certificates for IP addresses (IPv4 and IPv6), not just domain names.
Supported identifier type
{ "type": "ip", "value": "192.0.2.1" }
{ "type": "ip", "value": "2001:db8::1" }
IPv4 values use dotted-decimal notation. IPv6 values use the compressed text representation defined in RFC 5952.
Supported challenge types for IP identifiers
| Challenge | Supported |
|---|---|
| http-01 | Yes — connects directly to the IP; Host header is the IP address literal |
| tls-alpn-01 | Yes — connects to the IP; SNI uses the reverse-DNS name (e.g., 1.2.0.192.in-addr.arpa) |
| dns-01 | No — MUST NOT be used for IP identifiers per RFC 8738 §7 |
| dns-persist-01 | No — DNS-based, not applicable to IP identifiers |
RFC 8739 — ACME STAR
RFC 8739 (Short-Term, Automatically Renewed) allows a client to place a single order and receive a continuous stream of short-lived certificates without repeating domain validation. The CA automatically reissues each certificate before the previous one expires.
Use case
STAR is designed for scenarios where certificate revocation is unreliable. Instead of revoking a compromised certificate, the operator simply cancels the STAR order; the attacker’s window is limited to the remaining validity of the current short-lived certificate.
Another key use case is CDN delegation (see RFC 9115): the domain owner holds the STAR order and can revoke the CDN’s access at any time by canceling it.
Creating a STAR order
Include an auto-renewal object in the newOrder payload:
{
"identifiers": [{ "type": "dns", "value": "example.com" }],
"auto-renewal": {
"start-date": "2025-01-01T00:00:00Z",
"end-date": "2025-12-31T00:00:00Z",
"lifetime": 86400
}
}
| Field | Required | Description |
|---|---|---|
end-date | Yes | The latest date of validity of the last certificate issued (RFC 3339). |
lifetime | Yes | Validity period of each certificate, in seconds. |
start-date | No | The earliest notBefore of the first certificate. Defaults to when the order becomes ready. |
lifetime-adjust | No | Pre-dates each certificate’s notBefore by this many seconds (for clock-skew tolerance). Default: 0. |
allow-certificate-get | No | If true, the rolling certificate URL can be fetched with an unauthenticated GET. |
notBeforeandnotAftermust NOT be present in a STAR order.
Rolling certificate URL
After finalization, the order response includes a star-certificate URL instead of certificate:
{
"status": "valid",
"star-certificate": "https://acme.example.com/acme/cert/star/<order-id>"
}
GET /acme/cert/star/<order-id> always returns the currently active PEM certificate, along with Cert-Not-Before and Cert-Not-After HTTP headers matching the certificate’s validity window.
Canceling a STAR order
POST to the order URL with {"status": "canceled"} to stop automatic renewal:
POST /acme/order/<id>
{ "status": "canceled" }
Once canceled, the star-certificate endpoint returns HTTP 403 (autoRenewalCanceled). The currently active short-lived certificate continues to be usable until it expires naturally.
Server configuration
Advertise STAR capability in the directory by configuring minimum lifetime and maximum duration:
[server]
star_min_lifetime_secs = 86400 # 1 day minimum cert lifetime
star_max_duration_secs = 31536000 # 1 year maximum renewal period
When either field is set, the directory meta object includes the auto-renewal advertisement.
RFC 9444 — ACME for Subdomains
RFC 9444 allows a client to prove control of an ancestor domain (e.g., example.com) and then obtain certificates for any subdomain (e.g., api.example.com, www.example.com) without repeating the challenge for each one.
ancestorDomain in new orders
When placing an order for a subdomain, the client can declare which ancestor domain it controls:
{
"identifiers": [
{
"type": "dns",
"value": "api.example.com",
"ancestorDomain": "example.com"
}
]
}
Akāmu validates that ancestorDomain is a genuine ancestor (label-aligned DNS suffix) of the requested identifier. If accepted, the authorization challenge is issued against example.com rather than api.example.com.
subdomainAuthAllowed in pre-authorization
When pre-authorizing an ancestor domain, include subdomainAuthAllowed: true:
POST /acme/new-authz
{
"identifier": { "type": "dns", "value": "example.com" },
"subdomainAuthAllowed": true
}
The returned authorization object includes the same flag:
{
"identifier": { "type": "dns", "value": "example.com" },
"status": "valid",
"subdomainAuthAllowed": true,
...
}
A client can reuse this authorization for any subsequent order that specifies ancestorDomain: "example.com".
Server advertisement
To advertise subdomain authorization support in the directory:
[server]
allow_subdomain_auth = true
This adds "subdomainAuthAllowed": true to the directory meta object.
RFC 9773 — ACME Renewal Information (ARI)
RFC 9773 defines the Renewal Information extension, which lets the server tell ACME clients when to renew their certificates — even before the certificate expires. This is useful when a CA needs to revoke and reissue certificates en masse (e.g., due to a key compromise or mis-issuance event).
Endpoints
GET /acme/renewal-info/<cert-id>
<cert-id> is the RFC 9773 certificate identifier: base64url(AKI keyIdentifier) "." base64url(DER-encoded serial number bytes).
The response includes a suggested renewal window:
{
"suggestedWindow": {
"start": "2025-03-15T00:00:00Z",
"end": "2025-03-20T00:00:00Z"
}
}
The server includes a Retry-After header indicating how often to poll.
Renewal replacement
When placing a renewal order for a certificate that is being replaced, include the predecessor’s cert-id in the order:
{
"identifiers": [...],
"replaces": "<cert-id-of-predecessor>"
}
Akāmu validates that the predecessor cert belongs to the same account, marks it as replaced in the database at finalization, and returns an HTTP 409 (alreadyReplaced) if a replacement order has already been finalized.
Configuration
[server]
ari_retry_after_secs = 21600 # 6 hours between renewal-info polls (default)
RFC 9799 — ACME for .onion Domains
RFC 9799 defines how ACME can issue certificates for Tor Hidden Services (.onion Special-Use Domain Names). These are not DNS names — the second-level label encodes the hidden service’s Ed25519 public key.
Supported challenges for .onion identifiers
| Challenge | Supported | Notes |
|---|---|---|
onion-csr-01 | Yes | Key validation via CSR; no Tor network access needed server-side |
http-01 | Conditional | Only offered when server.tor_connectivity_enabled = true |
tls-alpn-01 | Conditional | Only offered when server.tor_connectivity_enabled = true |
dns-01 | No | MUST NOT be used for .onion identifiers |
Tor connectivity configuration
RFC 9799 §4 prohibits offering http-01 or tls-alpn-01 for .onion identifiers unless the CA can actually reach the Tor network. By default, Akāmu offers only onion-csr-01. To enable the additional challenge types, set:
[server]
tor_connectivity_enabled = true
Only set this when the Akāmu server process can make outbound Tor connections to hidden services (e.g. via torsocks or a SOCKS5 proxy configured at the OS level).
onion-csr-01 challenge
onion-csr-01 is the recommended challenge type for .onion domains because it does not require the ACME server to connect to the Tor network. Proof of control comes from a cryptographic signature by the hidden service’s private key (the same key embedded in the .onion address).
Protocol:
- Akāmu returns a challenge object with
type: "onion-csr-01", atoken, and anauthKey(the JWK thumbprint of the ACME account key). - The client builds a CSR that:
- Contains the
.onionSAN. - Includes a
cabf-onion-csr-nonceextension (OID2.23.140.41) containing the key authorization (token.thumbprint). - Is signed with both the CSR subject key and the hidden service’s Ed25519 private key.
- Contains the
- The client POSTs
{"csr": "<base64url-CSR-DER>"}to the challenge URL. - Akāmu:
- Extracts the 32-byte Ed25519 public key from the
.onionaddress. - Verifies the
cabf-onion-csr-nonceextension contains the correct key authorization. - Verifies the CSR signature using the extracted hidden-service public key.
- If all checks pass, marks the authorization as valid.
- Extracts the 32-byte Ed25519 public key from the
Identifier format
Only v3 (Ed25519) .onion addresses are accepted. A v3 address has a 56-character base32 second-level label:
bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion
Version 2 addresses (16-character label) are rejected per RFC 9799 §2.
RFC 5280 — X.509 Certificate Profile
RFC 5280 defines the structure of X.509 v3 certificates and Certificate Revocation Lists (CRLs). Akāmu issues certificates that conform to the RFC 5280 PKIX profile via the synta-certificate library.
Conformance includes:
- Correct
BasicConstraints(CA: false on end-entity certs). SubjectKeyIdentifierandAuthorityKeyIdentifierextensions.KeyUsageandExtendedKeyUsageextensions.SubjectAlternativeNameextensions carrying dNSName (including.oniondomains) or iPAddress.- CRL Distribution Points and OCSP Access Information when
crl_url/ocsp_urlare configured.
RFC 7807 — Problem Details for HTTP APIs
RFC 7807 defines a JSON format for HTTP error responses. All Akāmu error responses use this format with Content-Type: application/problem+json:
{
"type": "urn:ietf:params:acme:error:malformed",
"detail": "JWS url mismatch: got '...', expected '...'",
"status": 400
}
All ACME-specific error URNs are defined in RFC 8555 §6.7 and its extensions.
Let’s Encrypt dns-persist-01
The dns-persist-01 specification is a non-standard ACME challenge type published by Let’s Encrypt. Unlike the standard dns-01 challenge, which requires a fresh DNS TXT record for every renewal, dns-persist-01 uses a single long-lived TXT record that remains in place across renewals. This eliminates the need to modify DNS on every certificate renewal cycle.
How it differs from dns-01
| Property | dns-01 | dns-persist-01 |
|---|---|---|
| TXT record name | _acme-challenge.<domain> | _validation-persist.<domain> |
| Record changes per renewal | Required | Not required |
| Token in record | Yes (changes each time) | No |
| Record format | <key-auth> | "<issuer-domain>; accounturi=<uri>[; policy=wildcard][; persistUntil=<ISO8601Z>]" |
| Wildcard support | Requires explicit policy=wildcard parameter |
Configuration
[server]
dns_persist_issuer_domain = "acme.example.com"
When dns_persist_issuer_domain is set, the server offers dns-persist-01 as an additional challenge type alongside http-01, dns-01, and tls-alpn-01. Without it, the challenge type is not advertised.
TXT record format
The domain owner publishes (and keeps permanently):
_validation-persist.example.com. IN TXT "acme.example.com; accounturi=https://acme.example.com/acme/account/abc123"
Optional extensions:
policy=wildcard— authorizes wildcard certificate issuance.persistUntil=2026-12-31T00:00:00Z— caps the record’s validity. After this date, the record must be renewed.
Validation
Akāmu queries the _validation-persist.<domain> TXT record, verifies the issuer domain matches dns_persist_issuer_domain, and checks that the accounturi matches the requesting ACME account URL. If both match, the authorization is marked valid.
draft-aaron-acme-profiles-01
draft-aaron-acme-profiles-01 defines a mechanism for an ACME server to advertise named certificate profiles and for clients to request a specific profile when placing an order. This moves policy selection from CSR extensions and post-issuance inspection into the order object itself, making the server’s issuance policy explicit and machine-readable.
What it adds
| Feature | Location | Status |
|---|---|---|
meta.profiles in directory | GET /acme/directory | Yes |
profile field in newOrder payload | POST /acme/new-order | Yes |
profile field in order response | GET/POST /acme/order/{id} | Yes |
invalidProfile error type | All order and finalize endpoints | Yes |
| Finalize-time profile re-validation | POST /acme/order/{id}/finalize | Yes |
Directory advertisement
When server.profiles is configured, the directory meta includes a profiles object:
"meta": {
"profiles": {
"tls-server-auth": "https://acme.example.com/docs/profiles/tls-server-auth",
"client-auth": "https://acme.example.com/docs/profiles/client-auth"
}
}
Requesting a profile in newOrder
Clients include the profile field in the newOrder payload:
{
"identifiers": [{ "type": "dns", "value": "example.com" }],
"profile": "tls-server-auth"
}
The server validates that the requested profile is in the configured map. If not, it returns:
{
"type": "urn:ietf:params:acme:error:invalidProfile",
"status": 400,
"detail": "profile 'unknown-profile' is not advertised by this server"
}
The profile field is echoed back in every subsequent order response so that clients can confirm which profile applies.
Finalize-time re-validation
If a profile is removed from the server’s configuration after an order has been placed but before it is finalized, the finalize endpoint rejects the request with invalidProfile. This prevents silent issuance under a policy the server no longer supports.
Configuration
[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
"client-auth" = "https://acme.example.com/docs/profiles/client-auth"
When profiles is empty (the default), profile selection is not advertised. A profile field in newOrder is accepted but ignored — the server issues under its default policy.
draft-ietf-cose-dilithium-11
draft-ietf-cose-dilithium-11 defines how ML-DSA (Module-Lattice-Based Digital Signature Algorithm, formerly CRYSTALS-Dilithium, standardized in FIPS 204) keys and signatures are represented in JOSE (JSON Object Signing and Encryption). This draft has been submitted for RFC publication and its wire format is frozen. Akāmu implements it for ACME account key authentication, meaning ACME clients can register an ML-DSA key pair and sign every subsequent ACME request with it.
JWK key type: AKP
ML-DSA keys use the key type "AKP" (Algorithm Key Pair). Unlike classical key types, the
algorithm is encoded inside the JWK itself (not only in the JWS protected header), so the
alg field is required in the JWK:
{
"kty": "AKP",
"alg": "ML-DSA-65",
"pub": "<base64url-encoded raw public key bytes>"
}
| JWK field | Required | Description |
|---|---|---|
kty | Yes | Always "AKP" for ML-DSA keys |
alg | Yes | "ML-DSA-44", "ML-DSA-65", or "ML-DSA-87" |
pub | Yes | Base64url-encoded raw public key bytes (no padding) |
priv | No | 32-byte seed (private key); never sent to the server and ignored if present |
Supported variants
| Algorithm | FIPS 204 parameter set | Public key size | Signature size | OID (SPKI) |
|---|---|---|---|---|
| ML-DSA-44 | Parameter set 2 (k=4, l=4) | 1312 bytes | 2420 bytes | 2.16.840.1.101.3.4.3.17 |
| ML-DSA-65 | Parameter set 3 (k=6, l=5) | 1952 bytes | 3309 bytes | 2.16.840.1.101.3.4.3.18 |
| ML-DSA-87 | Parameter set 5 (k=8, l=7) | 2592 bytes | 4627 bytes | 2.16.840.1.101.3.4.3.19 |
JWK thumbprint
Per draft-ietf-cose-dilithium-11 §6, the JWK thumbprint for an AKP key is the
SHA-256 hash of the following canonical JSON object with members in lexicographic order:
{"alg":"ML-DSA-65","kty":"AKP","pub":"<base64url-key>"}
This is the same SHA-256 / base64url procedure as RFC 7638, applied to the three required
members alg, kty, and pub (in that order).
Signature format
ML-DSA signatures in JOSE are raw bytes as defined by FIPS 204 §7.2. They are
not DER-encoded. The server validates the signature length before attempting
verification and returns HTTP 400 if the length does not match the declared algorithm.
The signing context MUST be an empty byte string (b"") per draft-ietf-cose-dilithium-11 §4.
Verification uses BackendPublicKey::verify_ml_dsa_with_context(signing_input, signature, b"").
Signature failures return HTTP 401 Unauthorized.
SPKI DER construction
When an AKP JWK arrives in a new-account request (or any request using jwk instead
of kid), Akāmu converts it to a SubjectPublicKeyInfo (SPKI) DER structure for storage and
subsequent signature verification. The SPKI follows the X.509 / PKIX encoding for ML-DSA
keys:
SEQUENCE { -- outer SEQUENCE
SEQUENCE { -- AlgorithmIdentifier
OID <variant OID> -- no parameters (absent, not NULL)
}
BIT STRING {
0x00 -- unused-bits octet
<raw public key bytes> -- from the "pub" JWK field
}
}
The alg field in the JWK determines which OID is embedded. The length octets in the DER
use the minimum-length encoding; for ML-DSA key sizes, the outer SEQUENCE and BIT STRING
both use the two-byte (0x82) length form.
ACME client integration notes
An ACME client registering with an ML-DSA key must:
- Generate an ML-DSA key pair (any of the three variants).
- Construct the
AKPJWK from the raw public key bytes (base64url-encode them intopub). - Include the JWK in the
new-accountprotected header (thejwkfield). - Sign all ACME requests with the ML-DSA private key using an empty context string.
- Set
algin the JWS protected header to match the JWK’salgfield.
Existing ACME clients designed for classical algorithms require ML-DSA support in their underlying JOSE library. There is no server-side configuration to enable or disable ML-DSA; the feature is always available.
draft-ietf-lamps-pq-composite-sigs
draft-ietf-lamps-pq-composite-sigs is an active IETF draft that defines hybrid post-quantum signature algorithms combining a classical algorithm (ECDSA or RSA) with a post-quantum algorithm (ML-DSA, formerly CRYSTALS-Dilithium). Akāmu implements the TLS 1.3 signature scheme code points from the provisional IANA allocations in this draft.
What this affects
These code points are used only for mutual TLS client authentication — they appear in the TLS CertificateVerify message when a client presents a certificate signed with a composite ML-DSA scheme. Server-side certificate issuance (for ACME clients) is not affected.
The 12 composite scheme code points implemented are:
| Code point | Scheme |
|---|---|
| 0x0901 | id-MLDSA44-RSA2048-PSS-SHA256 |
| 0x0902 | id-MLDSA44-RSA2048-PKCS15-SHA256 |
| 0x0903 | id-MLDSA44-Ed25519-SHA512 |
| 0x0904 | id-MLDSA44-ECDSA-P256-SHA256 |
| 0x0905 | id-MLDSA65-RSA3072-PSS-SHA512 |
| 0x0906 | id-MLDSA65-RSA3072-PKCS15-SHA512 |
| 0x0907 | id-MLDSA65-ECDSA-P384-SHA512 |
| 0x0908 | id-MLDSA65-ECDSA-brainpoolP256r1-SHA512 |
| 0x0909 | id-MLDSA87-ECDSA-P384-SHA512 |
| 0x090A | id-MLDSA87-ECDSA-brainpoolP384r1-SHA512 |
| 0x090B | id-MLDSA87-Ed448-SHA512 |
| 0x090C | id-MLDSA65-ECDSA-P256-SHA512 |
Stability warning
These code points come from the provisional IANA registry for an in-progress draft. They may change as the draft advances toward RFC publication. Before deploying to production, verify the current draft version against the code points in src/tls/schemes.rs. If the code points change, only that file needs to be updated.
Not implemented
RFC 8823 — S/MIME Certificates (Informational)
Defines an email identifier type and email-reply-00 challenge, where proof of control is a DKIM-signed reply email.
Not implemented. Issuing S/MIME certificates requires an SMTP/IMAP email delivery stack that is outside the scope of an embedded CA.
RFC 9115 — ACME Profile for Delegated Certificates
Enables a three-party delegation model: a domain owner (IdO) authorizes a third party (e.g., a CDN) to obtain certificates for the IdO’s domain, where the certificate’s public key belongs to the third party rather than the domain owner. The CA acts as a proxy between the two parties and enforces a JSON CSR template that restricts what the delegate may request.
Not implemented. This requires a dedicated API surface for IdOs to manage delegation policies, plus proxy routing between the NDC and IdO accounts. It is primarily useful for large-scale CDN deployments.
RFC 9345 — Delegated Credentials for TLS
Defines a TLS certificate extension (delegated_credential) that allows a TLS server to present short-lived sub-credentials derived from an issued certificate, without requiring a new CA-signed certificate for each sub-credential. The ACME interaction is limited to requesting certs with this extension set.
Not implemented. Requires X.509 extension support in synta-certificate for the Delegated Credentials extension (OID TBD / draft status at time of implementation).
RFC 9447 — ACME Challenges Using an Authority Token
Defines a generic tkauth-01 challenge type where proof of control comes from a JWT issued by an external authority rather than from DNS or HTTP. Designed for identifier types that cannot be validated by the classic ACME challenges (e.g., telephone numbers in STIR/SHAKEN).
Not implemented. Requires integration with an external token authority, which is deployment-specific.
RFC 9448 — ACME TNAuthList Authority Token
Extends RFC 9447 for telephone number (STIR/SHAKEN) use cases, where the authority token contains a TNAuthList claim.
Not implemented. Telecom-specific; requires connectivity to a Secure Telephone Identity (STI) Policy Administrator.
RFC 9538 — ACME Delegation Metadata for CDNI
Extends RFC 9115 for CDN Interconnection (CDNI) scenarios where multiple CDN tiers chain certificate delegation.
Not implemented. Layered on top of RFC 9115 and equally CDN-infrastructure-specific.
RFC 9891 — ACME DTN Node ID Validation (Experimental)
An experimental RFC that defines a bundleEID identifier type and a Bundle Protocol (BP) challenge for validating Delay-Tolerant Networking node identities.
Not implemented. Experimental status; targets space/satellite networks using the Bundle Protocol (RFC 9171).
Client Libraries Overview
The Akāmu repository ships three standalone crates in addition to the server binary. They were extracted from the server so that external Rust applications can speak ACME without pulling in the full server stack.
| Crate | What it provides |
|---|---|
akamu-jose | RFC 7517/7515 JWK/JWS primitives, key thumbprints, ML-DSA signatures |
akamu-client | Full RFC 8555 ACME client lifecycle (async, tokio + hyper) |
akamu-cli | End-user CLI wrapping akamu-client |
Crate dependency graph
graph LR
CLI["akamu-cli"]
CLIENT["akamu-client"]
JOSE["akamu-jose"]
SYNTA["synta-certificate"]
CLI --> CLIENT
CLIENT --> JOSE
JOSE --> SYNTA
The server binary (akamu) also depends on akamu-jose directly; its src/jose/ module is a thin re-export layer.
When to use which crate
Use akamu-jose when you need only cryptographic primitives: JWK parsing, JWS signing/verification, thumbprint computation, or algorithm support. It has no HTTP or database dependencies and compiles quickly.
Use akamu-client when you want to drive the full ACME protocol from Rust code — account registration, ordering, challenge solving, finalization, and certificate download. It brings in tokio and hyper but nothing database-related.
Use akamu-cli when you want a command-line tool and do not want to write Rust. It wraps akamu-client and exposes register, issue, and deregister subcommands.
The PQC OpenSSL patch requirement
Important:
akamu-jose(and therefore all crates that depend on it) require a patched OpenSSL fork that adds post-quantum ML-DSA support. Any workspace that uses these crates as dependencies must add the following[patch.crates-io]block to its rootCargo.toml. The patch cannot live in a sub-crate’s manifest.
[patch.crates-io]
openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
openssl = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
Without this block, cargo build will either fail to resolve openssl-sys or will resolve the upstream crates.io version, which lacks ML-DSA support.
Getting started
Add crates to your Cargo.toml
[dependencies]
# ACME client + JWK/JWS:
akamu-client = { path = "/path/to/akamu/crates/akamu-client" }
# Or just the crypto primitives:
akamu-jose = { path = "/path/to/akamu/crates/akamu-jose" }
Add the patch block
In your workspace root Cargo.toml (not in a member manifest):
[patch.crates-io]
openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
openssl = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
Verify the build
cargo build -p akamu-jose
cargo build -p akamu-client
Both crates should compile without errors. The first build downloads and compiles the patched OpenSSL fork, which can take a few minutes.
Further reading
akamu-jose — JWK/JWS Primitives
akamu-jose is a standalone Rust crate that provides RFC 7517 JWK key handling and RFC 7515 JWS signing and verification. It supports both classical and post-quantum algorithms and has no HTTP, database, or server dependencies.
What it provides
- Parsing RFC 7517 public keys from JSON (
JwkPublic) - Computing RFC 7638 JWK thumbprints
- Converting public keys to SPKI DER for use with
synta-certificate - Signing and verifying RFC 7515 JWS flattened serialization (
JwsFlattened) - Decoding JWS protected headers (
JwsProtectedHeader) - All classical algorithms: ES256/ES384/ES512, PS256/PS384/PS512, EdDSA
- Post-quantum algorithms: ML-DSA-44, ML-DSA-65, ML-DSA-87 (draft-ietf-cose-dilithium-11)
What it does NOT provide
- HTTP requests of any kind
- ACME protocol logic
- Database access
- Certificate issuance or CSR handling
For those, use akamu-client.
JwkPublic
JwkPublic represents an RFC 7517 public key. It can be constructed from a JSON JWK, from a synta-certificate BackendPublicKey, or converted to SPKI DER.
Parsing a JWK
#![allow(unused)]
fn main() {
use akamu_jose::JwkPublic;
let json = r#"{"kty":"EC","crv":"P-256","x":"...","y":"..."}"#;
let jwk: JwkPublic = serde_json::from_str(json)?;
}
Computing a thumbprint
The thumbprint is a SHA-256 digest of the canonical JWK representation (RFC 7638). It is used in ACME key authorizations.
#![allow(unused)]
fn main() {
let thumb = jwk.thumbprint()?; // returns String (base64url, no padding)
}
Converting to SPKI DER
to_spki_der is needed when you want to pass the public key to synta-certificate for signature verification or certificate issuance.
#![allow(unused)]
fn main() {
let spki_der: Vec<u8> = jwk.to_spki_der()?;
}
Constructing from a public key
If you have a BackendPublicKey from synta-certificate, convert it to a JwkPublic:
#![allow(unused)]
fn main() {
let jwk = JwkPublic::from_public_key(&backend_public_key)?;
}
JwsFlattened
JwsFlattened is the RFC 7515 flattened JSON serialization. It is the format used by all ACME POST requests.
Signing
JwsFlattened::sign takes a protected header (already base64url-encoded JSON), a payload (already base64url-encoded), and a BackendPrivateKey.
#![allow(unused)]
fn main() {
use akamu_jose::{JwsFlattened, JwsProtectedHeader, JwsKeyRef};
// Sign with ES256 (P-256 key)
let protected_b64 = base64url_encode(&serde_json::to_vec(&header)?);
let payload_b64 = base64url_encode(payload_json.as_bytes());
let jws = JwsFlattened::sign(&protected_b64, &payload_b64, &private_key)?;
}
For ML-DSA-87, the call is identical — the algorithm is determined by the key type, not by a separate parameter:
#![allow(unused)]
fn main() {
// private_key is a BackendPrivateKey for an ML-DSA-87 key
let jws = JwsFlattened::sign(&protected_b64, &payload_b64, &private_key)?;
}
ML-DSA signatures are produced over the raw signing input using an empty context string as required by draft-ietf-cose-dilithium-11 §4.
Verifying
verify checks the signature against a provided SPKI DER:
#![allow(unused)]
fn main() {
let spki_der = jwk.to_spki_der()?;
jws.verify(&spki_der)?; // returns Ok(()) or Err(JoseError)
}
Decoding header and payload
#![allow(unused)]
fn main() {
let header: JwsProtectedHeader = jws.decode_header()?;
let payload_bytes: Vec<u8> = jws.decode_payload()?;
}
JwsProtectedHeader
The decoded ACME JWS protected header. Fields:
| Field | Type | Meaning |
|---|---|---|
alg | String | Algorithm string (e.g. "ES256", "ML-DSA-87") |
nonce | String | ACME anti-replay nonce |
url | String | Target URL (must match the request URL) |
key_ref | JwsKeyRef | Key identification: embedded JWK or account kid |
JwsKeyRef
JwsKeyRef indicates how the signing key is identified in the JWS header.
#![allow(unused)]
fn main() {
pub enum JwsKeyRef {
Jwk { jwk: JwkPublic }, // embedded public key (first request; new-account, key-change)
Kid { kid: String }, // account URL (all subsequent requests)
}
}
Use Jwk when the request is made before an account exists (new-account) or for key-change operations where the new key must be embedded. Use Kid for all other ACME requests once an account URL is known.
Algorithm support
| Family | alg string | JWK kty / crv | Notes |
|---|---|---|---|
| ECDSA | ES256 | EC / P-256 | SHA-256 |
| ECDSA | ES384 | EC / P-384 | SHA-384 |
| ECDSA | ES512 | EC / P-521 | SHA-512 |
| RSASSA-PSS | PS256 | RSA | SHA-256 / MGF1-SHA-256 |
| RSASSA-PSS | PS384 | RSA | SHA-384 / MGF1-SHA-384 |
| RSASSA-PSS | PS512 | RSA | SHA-512 / MGF1-SHA-512 |
| EdDSA | EdDSA | OKP / Ed25519 or Ed448 | RFC 8037 |
| ML-DSA | ML-DSA-44 | LWE (draft) | draft-ietf-cose-dilithium-11 |
| ML-DSA | ML-DSA-65 | LWE (draft) | draft-ietf-cose-dilithium-11 |
| ML-DSA | ML-DSA-87 | LWE (draft) | draft-ietf-cose-dilithium-11 |
JoseError
#![allow(unused)]
fn main() {
pub enum JoseError {
BadRequest(String), // malformed input (missing field, wrong format)
Crypto(String), // signature failure or key operation error
UnsupportedAlgorithm(String), // alg string not recognized
Base64(String), // base64url decode failure
Json(String), // JSON parse failure
}
}
When akamu-jose is used inside the server, From<JoseError> for AcmeError converts these automatically:
BadRequest→AcmeError::BadRequestCrypto→AcmeError::CryptoUnsupportedAlgorithm→AcmeError::BadSignatureAlgorithm
Full example: generate P-256 key, compute thumbprint, sign a JWS
#![allow(unused)]
fn main() {
use akamu_jose::{JwkPublic, JwsFlattened};
use synta_certificate::BackendPrivateKey;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
fn base64url(data: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(data)
}
// 1. Generate a P-256 private key via synta-certificate
let private_key = BackendPrivateKey::generate("ec:P-256")?;
let public_key = private_key.to_public_key()?;
// 2. Wrap as JwkPublic and compute the thumbprint
let jwk = JwkPublic::from_public_key(&public_key)?;
let thumb = jwk.thumbprint()?;
println!("Thumbprint: {thumb}");
// 3. Build an ACME-style protected header (illustrative; real code uses serde)
let header_json = serde_json::json!({
"alg": "ES256",
"nonce": "some-nonce",
"url": "https://acme.example.com/acme/new-account",
"jwk": serde_json::to_value(&jwk)?,
});
let protected_b64 = base64url(&serde_json::to_vec(&header_json)?);
// 4. Encode the payload
let payload_json = serde_json::json!({"termsOfServiceAgreed": true});
let payload_b64 = base64url(&serde_json::to_vec(&payload_json)?);
// 5. Sign
let jws = JwsFlattened::sign(&protected_b64, &payload_b64, &private_key)?;
// 6. Verify round-trip
let spki_der = jwk.to_spki_der()?;
jws.verify(&spki_der)?;
println!("Signature verified");
}
akamu-client — ACME Client Library
akamu-client is an async Rust library that implements the full RFC 8555 ACME client lifecycle. It targets applications that need to obtain and renew certificates programmatically without shelling out to certbot or acme.sh.
Overview
The library covers:
- ACME directory discovery and nonce management (automatic, with nonce recycling)
- Account registration with optional External Account Binding (EAB)
- Account lookup (
find_account), state retrieval (get_account), contact updates (update_account) - Account key rollover (
key_change, RFC 8555 §7.3.5) - Account deactivation
- Order creation, authorization retrieval, challenge triggering, and status polling
- CSR construction (
build_csr) - Order finalization and certificate download
- Certificate revocation via account key or certificate’s own key (RFC 8555 §7.6)
- ARI renewal window query (
get_renewal_info, RFC 9773) - Built-in http-01 challenge solver (
Http01Solver) - Built-in tls-alpn-01 challenge solver (
TlsAlpn01Solver, RFC 8737) - DNS challenge helpers (
Dns01Helper,DnsPersist01Helper) - onion-csr-01 CSR builder (
build_onion_csr, RFC 9799) - A
ChallengeSolvertrait for custom solvers
Dependencies: tokio, hyper-rustls (TLS enabled by default), akamu-jose. No database or server dependencies.
End-to-end example: P-256 key, http-01 challenge
use akamu_client::{
AccountKey, AccountOptions, AcmeClient,
Http01Solver, Identifier, build_csr, ChallengeSolver as _,
};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Account key
let key = Arc::new(AccountKey::generate("ec:P-256")?);
// 2. Connect to the ACME server (HTTPS by default)
let client = AcmeClient::new("https://acme.example.com/acme/directory").await?;
// 3. Register an account
let opts = AccountOptions {
contacts: &["mailto:ops@example.com"],
agree_tos: true,
eab: None,
};
let account = client.new_account(Arc::clone(&key), &opts).await?;
// 4. Place an order
let ids = vec![Identifier::dns("example.com")];
let order = client.new_order(&account, &ids).await?;
// 5. Solve each authorization
let solver = Http01Solver::new(80);
solver.start().await?;
for authz_url in &order.authorizations {
let authz = client.get_authorization(&account, authz_url).await?;
if authz.status == "valid" { continue; }
let challenge = authz.find_challenge("http-01")
.expect("http-01 challenge not offered");
let token = challenge.token.as_deref().expect("challenge has no token");
let key_auth = account.key_authorization(token);
solver.present(token, &key_auth).await?;
client.trigger_challenge(&account, challenge).await?;
let _order = client.poll_order(&account, &order.url).await?;
solver.cleanup(token).await?;
}
// 6. Finalize
let cert_key = AccountKey::generate("ec:P-256")?;
let csr_der = build_csr(&["example.com"], cert_key.private_key())?;
let finalized = client.finalize(&account, &order, &csr_der).await?;
// 7. Download
let cert_url = finalized.certificate.expect("order has no certificate URL");
let pem = client.download_certificate(&account, &cert_url).await?;
std::fs::write("cert.pem", &pem)?;
println!("Certificate written to cert.pem");
Ok(())
}
AccountKey
AccountKey holds the ACME account private key. It wraps a BackendPrivateKey from synta-certificate.
Generating a key
#![allow(unused)]
fn main() {
let key = AccountKey::generate("ec:P-256")?; // or "rsa:2048", "ed25519", "ml-dsa-65", ...
}
Supported key types: ec:P-256, ec:P-384, ec:P-521, rsa:2048, rsa:3072, rsa:4096, ed25519, ed448, ml-dsa-44, ml-dsa-65, ml-dsa-87.
Saving and loading
#![allow(unused)]
fn main() {
let pem = key.to_pem()?;
std::fs::write("account.key", &pem)?;
let loaded = AccountKey::from_pem(&pem)?;
}
Thumbprint and key authorization
#![allow(unused)]
fn main() {
let thumb = key.thumbprint(); // base64url SHA-256 of JWK (no fallible call needed)
let key_auth = key.key_authorization("some-token"); // "<token>.<thumb>"
}
JWS algorithm
#![allow(unused)]
fn main() {
let alg = key.alg(); // "ES256", "EdDSA", "ML-DSA-65", etc.
}
AcmeClient
Directory discovery
AcmeClient::new fetches the ACME directory over HTTPS and caches the endpoint URLs. Nonces are recycled from Replay-Nonce response headers; HEAD /new-nonce is only called on a cache miss.
#![allow(unused)]
fn main() {
let client = AcmeClient::new("https://acme.example.com/acme/directory").await?;
}
The client also transparently retries any request that receives a badNonce error exactly once.
Account registration
#![allow(unused)]
fn main() {
use std::sync::Arc;
let key = Arc::new(AccountKey::generate("ec:P-256")?);
let opts = AccountOptions {
contacts: &["mailto:admin@example.com"],
agree_tos: true,
eab: None,
};
let account = client.new_account(Arc::clone(&key), &opts).await?;
}
The returned Account contains:
account.url— the account URL (a.k.a. kid), used in subsequent requestsaccount.status—"valid","deactivated", or"revoked"account.contacts— the contact URIs registered
Finding an existing account
POST to /new-account with onlyReturnExisting: true (RFC 8555 §7.3.1). Fails with accountDoesNotExist if no account is registered for the key.
#![allow(unused)]
fn main() {
let account = client.find_account(Arc::clone(&key)).await?;
}
Fetching current account state
POST-as-GET to the account URL (RFC 8555 §7.3.2). Returns a fresh Account reflecting the server’s current view.
#![allow(unused)]
fn main() {
let account = client.get_account(&account).await?;
println!("{} {:?}", account.status, account.contacts);
}
Updating account contacts
#![allow(unused)]
fn main() {
let updated = client.update_account(
&account,
&["mailto:new@example.com", "mailto:ops@example.com"],
).await?;
}
Pass an empty slice to clear all contacts.
Account key rollover (RFC 8555 §7.3.5)
Replaces the account key on the server with a new key. After this call the old account is no longer valid; use the returned Account, which holds the new key.
#![allow(unused)]
fn main() {
let new_key = Arc::new(AccountKey::generate("ec:P-384")?);
let updated = client.key_change(&account, Arc::clone(&new_key)).await?;
// updated.key is now new_key; old account key is rejected by the server
}
Account deactivation
#![allow(unused)]
fn main() {
client.deactivate_account(&account).await?;
}
After deactivation the account status becomes "deactivated". The server will reject all future requests signed with that account key.
External Account Binding (EAB)
Some ACME servers require EAB before accepting new accounts. EAB proves that the account request is authorized by an out-of-band credential.
Pass an EabOptions inside AccountOptions:
#![allow(unused)]
fn main() {
use akamu_client::{AccountOptions, EabOptions};
let eab_key_bytes: Vec<u8> = /* base64url-decode your HMAC key here */ vec![];
let opts = AccountOptions {
contacts: &["mailto:admin@example.com"],
agree_tos: true,
eab: Some(EabOptions {
kid: "eab-key-id-from-your-ca",
hmac_key: &eab_key_bytes, // raw bytes, NOT base64
alg: "HS256", // "HS256", "HS384", or "HS512"
}),
};
let account = client.new_account(Arc::clone(&key), &opts).await?;
}
The library builds the EAB JWS internally, signs it with the HMAC key, and embeds it in the new-account request as externalAccountBinding.
Order lifecycle
sequenceDiagram
participant App as Your App
participant Lib as AcmeClient
participant Srv as ACME Server
App->>Lib: new_order(account, ids)
Lib->>Srv: POST /acme/new-order
Srv-->>Lib: 201 Order {authorizations, finalize}
Lib-->>App: Order
loop For each authorization URL
App->>Lib: get_authorization(account, url)
Lib->>Srv: POST /acme/authz/{id}
Srv-->>Lib: 200 Authorization {challenges}
Lib-->>App: Authorization
App->>App: Present challenge (http-01 / dns-01 / ...)
App->>Lib: trigger_challenge(account, challenge)
Lib->>Srv: POST /acme/chall/{authz_id}/{type}
Srv-->>Lib: 200 Challenge {status: processing}
loop Poll until valid
Lib->>Srv: POST-as-GET /acme/order/{id}
Srv-->>Lib: 200 Order {status}
end
end
App->>Lib: finalize(account, order, csr_der)
Lib->>Srv: POST /acme/order/{id}/finalize
Srv-->>Lib: 200 Order {certificate URL}
Lib-->>App: Order (finalized)
App->>Lib: download_certificate(account, cert_url)
Lib->>Srv: POST /acme/cert/{id}
Srv-->>Lib: 200 PEM bundle
Lib-->>App: PEM bytes
new_order
#![allow(unused)]
fn main() {
let ids = vec![
Identifier::dns("example.com"),
Identifier::dns("www.example.com"),
Identifier::ip("192.0.2.1"), // RFC 8555 ip-type identifier
Identifier::onion("foo.onion"), // RFC 9799 onion identifier
];
let order = client.new_order(&account, &ids).await?;
// order.authorizations — Vec<String> of authz URLs
// order.finalize — finalize URL
// order.status — "pending"
}
get_authorization
#![allow(unused)]
fn main() {
let authz = client.get_authorization(&account, &authz_url).await?;
// authz.identifier — Identifier { type, value }
// authz.status — "pending", "valid", "invalid", ...
// authz.challenges — Vec<Challenge>
// Convenience method to find a challenge by type:
let chall = authz.find_challenge("http-01").expect("no http-01 challenge offered");
}
trigger_challenge
#![allow(unused)]
fn main() {
client.trigger_challenge(&account, &challenge).await?;
}
Sends an empty-body POST ({}) to the challenge URL, signaling that the client is ready. The server begins validation asynchronously.
trigger_challenge_onion (RFC 9799)
For onion-csr-01 challenges, use this instead of trigger_challenge. It posts a {"csr": "<base64url>"} payload:
#![allow(unused)]
fn main() {
let csr_der = akamu_client::build_onion_csr(&domain, &key_auth, &hs_key_pem)?;
client.trigger_challenge_onion(&account, &challenge.url, &csr_der).await?;
}
poll_order
#![allow(unused)]
fn main() {
let order = client.poll_order(&account, &order.url).await?;
}
Polls with exponential backoff until order.status is "ready" or "valid". Respects the Retry-After header from the server. The internal deadline is 30 seconds; wrap with tokio::time::timeout for longer limits.
finalize
#![allow(unused)]
fn main() {
let csr_der = build_csr(&["example.com", "www.example.com"], cert_key.private_key())?;
let finalized = client.finalize(&account, &order, &csr_der).await?;
}
Submits the CSR. Returns the updated order which, when the server is done, contains a certificate URL.
download_certificate
#![allow(unused)]
fn main() {
let pem = client.download_certificate(&account, &cert_url).await?;
// pem is a Vec<u8> containing a PEM bundle (leaf + intermediates)
}
Certificate revocation (RFC 8555 §7.6)
Via account key
#![allow(unused)]
fn main() {
use akamu_client::pem_to_der;
let cert_pem = std::fs::read("cert.pem")?;
let cert_der = pem_to_der(&cert_pem).into_iter().next()
.expect("no certificate in PEM");
// reason: None = unspecified; Some(0..=10, not 7) = CRL reason code
client.revoke_certificate(&account, &cert_der, None).await?;
}
Via certificate’s own private key (self-revocation)
Use this when the account key is unavailable but the certificate’s private key is known.
#![allow(unused)]
fn main() {
let cert_key = Arc::new(AccountKey::from_pem(&std::fs::read("cert.key.pem")?)?);
client.revoke_certificate_with_cert_key(&cert_key, &cert_der, Some(1)).await?;
}
ARI renewal information (RFC 9773)
#![allow(unused)]
fn main() {
let cert_pem = std::fs::read("cert.pem")?;
let info = client.get_renewal_info(&cert_pem).await?;
// info.window_start — RFC 3339 string (start of suggested renewal window)
// info.window_end — RFC 3339 string
// info.retry_after_secs — Option<u64> from Retry-After header
println!("Renew between {} and {}", info.window_start, info.window_end);
}
Returns Err if the server does not advertise a renewalInfo endpoint.
STAR order API (RFC 8739)
ACME STAR (Short-Term, Automatically Renewed) orders let a client place a single order and receive a continuous stream of short-lived certificates without repeating domain validation. Use StarOrderParams to describe the order and AcmeClient::new_star_order() to place it.
Placing a STAR order
#![allow(unused)]
fn main() {
use akamu_client::{StarOrderParams, Identifier};
let params = StarOrderParams {
identifiers: &[Identifier::dns("example.com")],
end_date: "2026-12-31T00:00:00Z", // RFC 3339
lifetime_secs: 86400, // each cert is valid for 1 day
start_date: None, // start when order becomes ready
lifetime_adjust_secs: 0, // no clock-skew pre-dating
allow_certificate_get: true, // allow unauthenticated rolling GET
};
let star_order = client.new_star_order(&account, ¶ms).await?;
// star_order.status — "pending"
// star_order.authorizations — authz URLs to solve, same as a regular order
// star_order.finalize — finalize URL
}
After placing the order, solve the authorizations and finalize using the standard get_authorization, trigger_challenge, and finalize calls. The server then automatically reissues a new certificate before each one expires.
StarOrderParams fields
| Field | Required | Description |
|---|---|---|
identifiers | Yes | Identifiers to certify. |
end_date | Yes | RFC 3339 timestamp; the last certificate’s notBefore must not exceed this. |
lifetime_secs | Yes | Validity period of each automatically issued certificate, in seconds. |
start_date | No | RFC 3339 timestamp for the earliest notBefore of the first certificate. Defaults to when the order becomes ready. |
lifetime_adjust_secs | No | Pre-dates each certificate’s notBefore by this many seconds to create an overlap window (RFC 8739 §3.1.1). Default: 0. |
allow_certificate_get | No | When true, the rolling certificate URL can be fetched without authentication. |
Downloading the rolling certificate
After finalization star_order.star_certificate contains the rolling URL. Download it either with an authenticated POST-as-GET or (when allow_certificate_get was requested) an unauthenticated GET:
#![allow(unused)]
fn main() {
// Authenticated download (always works):
let pem = client.download_star_certificate(&account, &star_cert_url).await?;
// Unauthenticated GET (only when allow_certificate_get was true):
let pem = client.get_star_certificate(&star_cert_url).await?;
}
Both methods return the current PEM certificate chain.
Canceling a STAR order
#![allow(unused)]
fn main() {
client.cancel_star_order(&account, &star_order.url).await?;
}
After cancellation the rolling certificate URL returns HTTP 403 (autoRenewalCanceled). The currently active short-lived certificate remains usable until it expires.
Identifier constructors
#![allow(unused)]
fn main() {
Identifier::dns("example.com") // {"type":"dns","value":"example.com"}
Identifier::dns("*.example.com") // wildcard (dns-01 only)
Identifier::ip("192.0.2.1") // RFC 8555 §7.1.4 ip-type
Identifier::ip("2001:db8::1") // IPv6
Identifier::onion("foo.onion") // RFC 9799 Tor hidden service
}
ChallengeSolver trait
Implement this trait to provide a custom challenge solver (for example, a DNS-01 solver that calls your registrar’s API):
#![allow(unused)]
fn main() {
pub trait ChallengeSolver: Send + Sync {
fn present(
&self,
token: &str,
key_auth: &str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ClientError>> + Send + '_>>;
fn cleanup(
&self,
token: &str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), ClientError>> + Send + '_>>;
}
}
present is called before trigger_challenge. cleanup is called after the authorization reaches a terminal state.
Http01Solver
The built-in http-01 solver starts a small HTTP server that serves key authorization values at /.well-known/acme-challenge/<token>.
#![allow(unused)]
fn main() {
let solver = Http01Solver::new(80); // listen port
solver.start().await?; // spawns a background task
}
Port 80 requires elevated privileges on most Linux systems. Either run as root, use CAP_NET_BIND_SERVICE, or configure an iptables redirect from port 80 to a high port.
Http01Solver implements ChallengeSolver. Call present before triggering and cleanup after validation completes.
TlsAlpn01Solver (RFC 8737)
Serves ephemeral ACME challenge certificates over TLS for tls-alpn-01. The solver binds port 443 (or another port), responds to SNI lookups from the ACME server, and completes the TLS handshake with ALPN acme-tls/1.
#![allow(unused)]
fn main() {
use akamu_client::TlsAlpn01Solver;
let mut solver = TlsAlpn01Solver::new(443);
solver.start().await?;
// For each authorization:
let key_auth = account.key_authorization(token);
solver.present(
&authz.identifier.value, // domain or IP string
&authz.identifier.r#type, // "dns" or "ip"
&key_auth,
).await?;
client.trigger_challenge(&account, &challenge).await?;
client.poll_order(&account, &order.url).await?;
// When done with all authorizations:
solver.cleanup(); // note: not async, takes &mut self
}
new(port)— creates the solver; does not bind yet.start()— binds the port and spawns the TLS accept loop.present(domain, id_type, key_auth)— generates an ephemeral P-256 certificate with theid-pe-acmeIdentifierextension and registers it for SNI lookup.cleanup()— aborts the background listener.
tls-alpn-01 cannot validate wildcard identifiers (RFC 8737 §3).
DNS helpers
Dns01Helper
Computes the TXT record value for dns-01. Does not modify DNS — you must add and remove the record yourself.
#![allow(unused)]
fn main() {
use akamu_client::Dns01Helper;
let txt = Dns01Helper::txt_value(&key_auth)?;
// Add TXT record: _acme-challenge.example.com TXT <txt>
// After validation completes, remove the record.
}
DnsPersist01Helper
Same computation for the dns-persist-01 challenge variant. The record name is _validation-persist.<domain> and it is long-lived (set once per account key):
#![allow(unused)]
fn main() {
use akamu_client::DnsPersist01Helper;
let txt = DnsPersist01Helper::txt_value(&key_auth)?;
// Add TXT record: _validation-persist.example.com TXT <txt>
}
build_onion_csr (RFC 9799)
Builds a DER-encoded CSR for an onion-csr-01 challenge. The CSR carries the cabf-onion-csr-nonce extension (OID 2.23.140.41) containing the key authorization as a DER UTF8String, and is signed by the hidden-service Ed25519 private key.
#![allow(unused)]
fn main() {
use akamu_client::build_onion_csr;
let hs_key_pem = std::fs::read("hs_ed25519_secret_key.pem")?;
let csr_der = build_onion_csr(
"example.onion", // .onion domain
&key_auth, // token.thumbprint
&hs_key_pem, // Ed25519 hidden-service private key (PEM)
)?;
client.trigger_challenge_onion(&account, &challenge.url, &csr_der).await?;
}
build_csr
Generates a PKCS#10 CSR in DER format. The first element of the domains slice becomes the CN; all elements become Subject Alternative Names.
#![allow(unused)]
fn main() {
let cert_key = AccountKey::generate("ec:P-256")?;
let csr_der = build_csr(&["example.com", "www.example.com"], cert_key.private_key())?;
}
Wildcard domains are supported: pass "*.example.com". The CSR key type is independent of the account key type.
pem_to_der
Re-exported from synta_certificate for convenience. Decodes all PEM blocks in a byte slice to DER:
#![allow(unused)]
fn main() {
use akamu_client::pem_to_der;
let cert_pem = std::fs::read("cert.pem")?;
let ders: Vec<Vec<u8>> = pem_to_der(&cert_pem);
let cert_der = ders.into_iter().next().expect("no certificate");
}
ClientError
#![allow(unused)]
fn main() {
pub enum ClientError {
Jose(JoseError), // JWK/JWS error from akamu-jose
Http(String), // HTTP transport error (hyper)
Acme { acme_type: String, detail: String }, // server returned problem+json
Crypto(String), // key generation or CSR error
Io(String), // I/O error
}
}
Handle Acme errors by inspecting acme_type:
#![allow(unused)]
fn main() {
match err {
ClientError::Acme { acme_type, detail } => {
eprintln!("ACME error {acme_type}: {detail}");
// acme_type examples:
// "urn:ietf:params:acme:error:badNonce"
// "urn:ietf:params:acme:error:unauthorized"
// "urn:ietf:params:acme:error:incorrectResponse"
// "urn:ietf:params:acme:error:accountDoesNotExist"
}
_ => eprintln!("Other error: {err}"),
}
}
badNonce errors are retried automatically once by the library; if they appear in user code it means the retry also failed.
akamu-cli — Command Reference
akamu-cli is a command-line ACME client that wraps akamu-client. It covers the full ACME lifecycle: account management, certificate issuance with multiple challenge types, ARI-aware renewal, and revocation.
Installation
Build from source
cargo build -p akamu-cli --release
The binary is placed at target/release/akamu-cli.
sudo install -m 0755 target/release/akamu-cli /usr/local/bin/akamu-cli
cargo install (from a local checkout)
cargo install --path crates/akamu-cli
This installs the binary to ~/.cargo/bin/akamu-cli.
Command tree
akamu-cli
account
register Register a new ACME account
deregister Deactivate an existing account (RFC 8555 §7.3.7)
show Print current account URL, status, and contacts
update Update account contact list
key-change Roll the account key to a new key (RFC 8555 §7.3.5)
issue Obtain a certificate (http-01 / dns-01 / dns-persist-01 /
tls-alpn-01 / onion-csr-01)
renew ARI-aware renewal (RFC 9773); skips issuance if outside window
revoke Revoke a certificate via account key or certificate's own key
Subcommands
account register
Register a new ACME account and save the account URL to a sidecar file.
akamu-cli account register --server URL --account-key FILE [OPTIONS]
| Flag | Required | Description |
|---|---|---|
--server URL | yes | ACME directory URL. Default: Let’s Encrypt production. |
--account-key FILE | yes | Path to the account key PEM file. Generated and saved if the file does not exist. |
--key-type TYPE | no | Key type to generate when the key file does not exist. Default: ec:P-256. |
--contact URI | no | Contact URI (e.g. mailto:admin@example.com). Repeatable. |
--agree-tos | no | Agree to the server’s Terms of Service. |
--eab-kid KID | no | External Account Binding key ID. |
--eab-key KEY_B64U | no | EAB HMAC key encoded as base64url (no padding). |
--eab-alg ALG | no | EAB HMAC algorithm: HS256, HS384, or HS512. Default: HS256. |
After registration the account URL is written to <account-key>.account-url (see Sidecar file).
account deregister
Deactivate an existing account (RFC 8555 §7.3.7).
akamu-cli account deregister --server URL --account-key FILE
The account URL is read from <account-key>.account-url. After deactivation the sidecar file is removed.
account show
Fetch the current account state from the server and print it.
akamu-cli account show --server URL --account-key FILE
Output example:
URL: https://acme.example.com/acme/account/1234
Status: valid
Contact: mailto:admin@example.com
account update
Update the contact list for an existing account.
akamu-cli account update --server URL --account-key FILE [--contact URI ...]
| Flag | Required | Description |
|---|---|---|
--server URL | yes | ACME directory URL |
--account-key FILE | yes | Account key PEM file |
--contact URI | no (repeatable) | New contact URI. Omit entirely to clear all contacts. |
account key-change
Roll the account key to a new key (RFC 8555 §7.3.5). The server atomically replaces the account key. The new key is written back to --account-key, overwriting the old one. The account URL sidecar file is unchanged.
akamu-cli account key-change --server URL --account-key FILE --new-key FILE [OPTIONS]
| Flag | Required | Description |
|---|---|---|
--server URL | yes | ACME directory URL |
--account-key FILE | yes | Current account key PEM file |
--new-key FILE | yes | New key PEM file. Generated if the file does not exist. |
--new-key-type TYPE | no | Key type for generation when --new-key file is absent. Default: ec:P-256. |
After success, --account-key contains the new key.
issue
Obtain a certificate for one or more domains.
akamu-cli issue --server URL --account-key FILE -d DOMAIN --out FILE [OPTIONS]
| Flag | Required | Description |
|---|---|---|
--server URL | yes | ACME directory URL |
--account-key FILE | yes | Account key PEM file |
-d DOMAIN | yes (repeatable) | Domain to include. First value becomes the CN. |
--out FILE | yes | Output path for the PEM bundle (leaf + chain). |
--key-type TYPE | no | Account key type when generating a new key. Default: ec:P-256. |
--cert-key-type TYPE | no | Certificate key type. Default: ec:P-256. |
--cert-key FILE | no | Reuse an existing certificate private key PEM file. Generated and saved alongside --out if absent. |
--challenge TYPE | no | Challenge type: http-01, dns-01, dns-persist-01, tls-alpn-01, onion-csr-01. Default: http-01. |
--http-port PORT | no | Port for the built-in http-01 solver. Default: 80. |
--tls-port PORT | no | Port for the built-in tls-alpn-01 solver. Default: 443. |
--onion-key FILE | required for onion-csr-01 | Ed25519 hidden-service private key PEM file. |
--poll-timeout SECS | no | Maximum seconds to wait for order/challenge validation. Default: 120. |
--eab-kid KID | no | EAB key ID (used if no account exists yet). |
--eab-key KEY_B64U | no | EAB HMAC key (base64url). |
If the account URL sidecar file does not exist, issue registers a new account first.
The http-01 and tls-alpn-01 challenge types cannot validate wildcard identifiers (RFC 8555 §8.3, RFC 8737 §3). Use dns-01 or dns-persist-01 for *.example.com.
The certificate private key is generated and saved to <out>.key.pem unless --cert-key is provided.
renew
Check the ARI renewal window (RFC 9773) for an existing certificate, then issue a replacement if renewal is suggested or --force is given.
akamu-cli renew --server URL --account-key FILE -d DOMAIN --out FILE [OPTIONS]
| Flag | Required | Description |
|---|---|---|
--server URL | yes | ACME directory URL |
--account-key FILE | yes | Account key PEM file |
-d DOMAIN | yes (repeatable) | Domains for the new certificate |
--out FILE | yes | Output path for the new PEM bundle |
--cert FILE | no | Existing certificate PEM to check against ARI. Without this flag, no ARI check is performed. |
--force | no | Renew unconditionally, skipping the ARI window check. |
--challenge TYPE | no | Same values as issue. Default: http-01. |
--http-port PORT | no | Default: 80. |
--tls-port PORT | no | Default: 443. |
--onion-key FILE | no | Required for onion-csr-01. |
--poll-timeout SECS | no | Default: 120. |
--key-type TYPE | no | Account key type. Default: ec:P-256. |
--cert-key-type TYPE | no | Certificate key type. Default: ec:P-256. |
--eab-kid KID | no | EAB key ID. |
--eab-key KEY_B64U | no | EAB HMAC key (base64url). |
Behavior when --cert is given and --force is not:
- If the current time is before the ARI window start, the command exits without issuing and prints a message.
- If the current time is within (or past) the window, issuance proceeds.
- If the server does not support ARI or returns an error, issuance proceeds with a warning.
revoke
Revoke an issued certificate (RFC 8555 §7.6).
akamu-cli revoke --server URL --account-key FILE --cert FILE [OPTIONS]
| Flag | Required | Description |
|---|---|---|
--server URL | yes | ACME directory URL |
--account-key FILE | yes | Account key PEM file (used unless --cert-key is given) |
--cert FILE | yes | PEM file containing the certificate to revoke |
--reason N | no | CRL reason code: 0–6 or 8–10 (7 is not valid). Omit for unspecified. |
--cert-key FILE | no | Certificate’s own private key PEM file. When provided, revocation is signed with the certificate key instead of the account key (self-revocation). |
Self-revocation (via --cert-key) is useful when the ACME account key is unavailable.
Sidecar file
When you register an account, akamu-cli writes the account URL to a file named <account-key>.account-url in the same directory as the account key. For example, if your account key is ~/.akamu/account.pem, the sidecar is ~/.akamu/account.pem.account-url.
The issue, renew, deregister, account show, account update, and account key-change subcommands read this file to find the account URL without re-registering.
If the sidecar is missing and you run issue, the CLI registers a new account first. If the sidecar is missing and you run deregister, the command fails with an error.
Keep the key file and sidecar file together and back them up. If you lose the account key, you cannot deactivate or otherwise manage the account.
Example sessions
Register, issue (http-01), and deregister
# 1. Register an account
akamu-cli account register \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--key-type ec:P-256 \
--contact mailto:admin@example.com \
--agree-tos
# 2. Issue a certificate
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d example.com \
-d www.example.com \
--out /etc/ssl/example.com/fullchain.pem
# 3. Deregister the account
akamu-cli account deregister \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem
Port 80 must be reachable from the ACME server for http-01 validation. The CLI starts a temporary HTTP server during challenge validation and shuts it down afterwards.
Issue with tls-alpn-01
Port 443 must be reachable from the ACME server. Wildcard domains are not supported.
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d example.com \
--challenge tls-alpn-01 \
--tls-port 443 \
--out /etc/ssl/example.com/fullchain.pem
Issue with dns-01 (wildcard)
dns-01 is interactive: the CLI prints the TXT record to add and waits for you to press Enter.
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d "*.example.com" \
--challenge dns-01 \
--out /etc/ssl/example.com/wildcard.pem
Output:
DNS-01 challenge for *.example.com:
Name: _acme-challenge.example.com.
Type: TXT
Value: <base64url>
Press Enter after the TXT record has propagated (Ctrl-C to abort)...
Issue with onion-csr-01 (RFC 9799)
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d myservice.onion \
--challenge onion-csr-01 \
--onion-key ~/.tor/hs_ed25519_secret_key.pem \
--out /etc/ssl/myservice/fullchain.pem
ARI-aware renewal
# Check ARI window; issue only if inside it
akamu-cli renew \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d example.com \
--cert /etc/ssl/example.com/fullchain.pem \
--out /etc/ssl/example.com/fullchain.pem
# Force renewal regardless of ARI window
akamu-cli renew \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d example.com \
--cert /etc/ssl/example.com/fullchain.pem \
--out /etc/ssl/example.com/fullchain.pem \
--force
Revoke a certificate
# Via account key
akamu-cli revoke \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--cert /etc/ssl/example.com/fullchain.pem \
--reason 1
# Self-revocation (account key unavailable)
akamu-cli revoke \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--cert /etc/ssl/example.com/fullchain.pem \
--cert-key /etc/ssl/example.com/fullchain.pem.key.pem
Roll the account key
akamu-cli account key-change \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--new-key ~/.akamu/account-new.pem \
--new-key-type ec:P-384
After success, ~/.akamu/account.pem contains the new key and the old key is no longer accepted by the server.
Show and update account contacts
akamu-cli account show \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem
akamu-cli account update \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--contact mailto:new@example.com \
--contact mailto:backup@example.com
External Account Binding
Some CAs require EAB credentials before accepting a new account. Obtain a KID and HMAC key from your CA’s operator, then pass them to account register or issue:
akamu-cli account register \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--agree-tos \
--eab-kid my-eab-key-id \
--eab-key dGhpcyBpcyBhIHRlc3Qga2V5 \
--eab-alg HS256
The --eab-key value must be the raw HMAC key encoded as base64url without padding. Do not wrap it in additional base64 encoding.
If the server has external_account_required = true and you omit the EAB flags, registration fails with urn:ietf:params:acme:error:externalAccountRequired.
Key type selection
| Use case | Recommended type | Notes |
|---|---|---|
| Default / broadest compatibility | ec:P-256 | Widely supported, fast, small signatures |
| Stronger classical security | ec:P-384 or rsa:3072 | Use when policy requires |
| Post-quantum account key | ml-dsa-65 | Larger signatures; check server support |
| Post-quantum certificate key | ml-dsa-44 | Smallest PQ key; suitable for most cases |
| Legacy RSA-only environments | rsa:2048 or rsa:4096 | Avoid unless forced by policy |
ML-DSA keys require that the Akāmu server is built with the PQC OpenSSL fork. Vanilla Let’s Encrypt does not support ML-DSA.
Logging
Set the RUST_LOG environment variable to control log output:
RUST_LOG=info akamu-cli issue ... # normal progress messages
RUST_LOG=debug akamu-cli issue ... # HTTP request/response details
RUST_LOG=trace akamu-cli issue ... # full JWS content and all internal steps
The default level is warn, which prints only errors and warnings.
Error messages and troubleshooting
ACME error urn:ietf:params:acme:error:badNonce
The server rejected the nonce. The library retries automatically once. If this appears in CLI output, both attempts failed. Retry the command.
ACME error urn:ietf:params:acme:error:incorrectResponse
The server could not validate the challenge. For http-01, verify that port 80 is reachable and that no firewall or reverse proxy is blocking .well-known/acme-challenge/. For tls-alpn-01, verify port 443 is reachable with no TLS terminator in front.
ACME error urn:ietf:params:acme:error:externalAccountRequired
The server requires EAB credentials. Pass --eab-kid and --eab-key.
ACME error urn:ietf:params:acme:error:accountDoesNotExist
No account is registered for the given key. Run account register first.
invalid reason code 7; valid values: 0–6, 8–10
Reason code 7 (removeFromCRL) is not valid for revocation requests. Choose a different code or omit --reason.
Failed to bind port 80: Permission denied
The http-01 solver needs to listen on port 80. Either run with sudo, grant CAP_NET_BIND_SERVICE to the binary, or use an iptables redirect from port 80 to a high port and pass --http-port <high-port>.
Error: No such file: account.pem.account-url
The sidecar file is missing. Run account register first, or restore the sidecar from a backup.
Unsupported algorithm: ML-DSA-65
The server does not support the requested key type. Use a classical key type such as ec:P-256, or connect to an Akāmu server built with the PQC OpenSSL fork.
Architecture
This chapter describes the overall structure of Akāmu, the key modules, and the full request lifecycle from a TCP connection to an HTTP response.
System architecture
graph TB
subgraph clients["ACME Clients"]
certbot[certbot]
acmesh[acme.sh]
akamucli[akamu-cli]
custom[RFC 8555 library]
end
subgraph akamu["Akāmu Server"]
direction TB
tls["TLS layer<br/>rustls + axum-server<br/>(optional — Mode 2/3/4)"]
acme["ACME endpoints<br/>new-account · new-order · finalize<br/>revoke · ARI · key-change"]
jose["akamu-jose<br/>JWK / JWS verification<br/>EAB HMAC check"]
ca["CA module<br/>CSR validation<br/>certificate issuance<br/>CRL generation"]
db[("SQLite<br/>accounts · orders · authzs<br/>challenges · certs · nonces")]
val["Validators<br/>http-01 · dns-01<br/>tls-alpn-01 · dns-persist-01"]
mtc["MTC log<br/>synta-mtc<br/>(optional)"]
end
subgraph external["External — Applicant Infrastructure"]
httpserver["HTTP server<br/>port 80"]
dns["DNS server<br/>TXT records"]
tlsserver["TLS server<br/>port 443, ALPN acme-tls/1"]
end
subgraph artifacts["Issued Artifacts"]
certchain["X.509 certificate chain<br/>PEM bundle"]
crlsvc["CRL / OCSP service<br/>(external, referenced by URL)"]
end
clients -->|"HTTPS ACME requests (JWS-signed)"| tls
tls --> acme
acme --> jose
acme --> ca
acme --> db
acme -->|"spawns tokio task"| val
val -->|"GET /.well-known/…"| httpserver
val -->|"TXT _acme-challenge.…"| dns
val -->|"TLS ALPN connect"| tlsserver
ca -->|"leaf + CA bundle"| certchain
ca -->|"revoked serial list"| crlsvc
ca --> mtc
Crate layout
The repository is organized as a Cargo workspace with four members:
Cargo.toml <- workspace root (members: ., crates/*)
src/ <- akamu server binary
crates/
akamu-jose/ <- JWK/JWS primitives (no HTTP/DB deps)
akamu-client/ <- async ACME client library (tokio, hyper)
akamu-cli/ <- CLI binary wrapping akamu-client
Crate dependencies
graph LR
SERVER["akamu (server)"]
CLIENT["akamu-client"]
CLI["akamu-cli"]
JOSE["akamu-jose"]
SYNTA["synta-certificate"]
SERVER --> JOSE
SERVER --> SYNTA
CLIENT --> JOSE
CLIENT --> SYNTA
CLI --> CLIENT
JOSE --> SYNTA
The server and akamu-client both depend directly on akamu-jose and synta-certificate. akamu-cli depends only on akamu-client.
See Client Libraries for the standalone client API.
The server’s jose/ module
src/jose/jwk.rs and src/jose/jws.rs are thin re-exports:
#![allow(unused)]
fn main() {
// src/jose/jwk.rs
pub use akamu_jose::JwkPublic;
// src/jose/jws.rs
pub use akamu_jose::{JwsFlattened, JwsKeyRef, JwsProtectedHeader};
}
All JWK/JWS logic lives in crates/akamu-jose. The src/jose/ shim exists so the rest of the server can use short import paths without knowing about the crate boundary.
Server source layout
The src/ directory is organized as follows:
src/
main.rs Entry point; parses config, initializes subsystems, starts axum
lib.rs Re-exports public modules for integration tests
config.rs TOML configuration structs (Config, CaConfig, MtcConfig, ServerConfig)
state.rs Shared application state (AppState, CaState, MtcState)
error.rs AcmeError enum with HTTP mapping and problem+json serialization
db/
mod.rs Database initialization (open, migrations, WAL mode)
schema.rs Row types mirroring SQLite columns
accounts.rs CRUD for accounts table
authz.rs CRUD for authorizations table
certs.rs CRUD for certificates table
challenges.rs CRUD for challenges table
nonces.rs Anti-replay nonce management
orders.rs CRUD for orders table
routes/
mod.rs Router assembly, shared helpers (parse_jws, acme_headers, json_response)
directory.rs GET /acme/directory
nonce.rs HEAD/GET /acme/new-nonce
account.rs POST /acme/new-account, POST /acme/account/{id}
order.rs POST /acme/new-order, POST /acme/order/{id}
authz.rs POST /acme/authz/{id}
challenge.rs POST /acme/chall/{authz_id}/{type}
finalize.rs POST /acme/order/{id}/finalize
certificate.rs GET /acme/cert/{id}
revoke.rs POST /acme/revoke-cert
key_change.rs POST /acme/key-change
renewal_info.rs GET /acme/renewal-info/{cert_id}
ca/
mod.rs Re-exports ca submodules
init.rs CA key and certificate load-or-generate
csr.rs PKCS#10 CSR parsing and validation
issue.rs End-entity certificate issuance
revoke.rs CRL generation
validation/
mod.rs Challenge dispatch and DB state transitions (validate_challenge)
http01.rs http-01 validation (hyper HTTP client)
dns01.rs dns-01 validation (hickory-resolver)
tls_alpn01.rs tls-alpn-01 validation (rustls TLS client)
mtc/
mod.rs Re-exports mtc submodules
log.rs Disk-backed Merkle Tree Certificate log integration
jose/ Thin re-exports from crates/akamu-jose
(JwkPublic, JwsFlattened, JwsKeyRef, JwsProtectedHeader)
Key types
AppState
Defined in src/state.rs. Every axum handler receives an Arc<AppState> via axum’s State extractor. It contains:
config: Arc<Config>— immutable configuration parsed at startup.db: Arc<Connection>— shared tokio-rusqlite connection. All database access goes through this.ca: Arc<CaState>— CA private key, certificate, and signing policy.mtc: Arc<MtcState>— MTC log handle and algorithm (orNoneif disabled).
AppState is Clone because Arc<T> is Clone. Cloning is cheap (reference count bump). All mutable state (the database and MTC log) is protected at a lower level by tokio-rusqlite’s internal background thread and a tokio::sync::Mutex<DiskBackedLog>, respectively.
CaState
Holds the CA private key (BackendPrivateKey from synta-certificate) and the DER-encoded CA certificate. The key is used for both certificate signing and CRL signing. CaState is shared across all concurrent handler tasks via Arc<CaState>. The underlying BackendPrivateKey delegates to the OpenSSL backend, which serializes concurrent signing operations internally.
AcmeError
Defined in src/error.rs. Implements IntoResponse so it can be returned directly from axum handlers. Maps each variant to:
- An ACME problem type string (
urn:ietf:params:acme:error:*). - An HTTP status code.
- A human-readable
detailstring.
The response body is application/problem+json (RFC 7807).
Request lifecycle
1. TCP accept
The tokio runtime accepts a TCP connection on the configured listen_addr. axum’s serve function passes it to the hyper HTTP/1.1 or HTTP/2 codec.
2. HTTP parsing
hyper parses the HTTP request (method, URL, headers, body). Tower middleware is applied in order; currently only TraceLayer is configured, which emits a tracing span for each request.
3. Route dispatch
axum matches the request method and path against the router built in routes::build_router. Each route maps to a handler function in the corresponding routes/ module. The handler receives the following extractors:
State(state): State<Arc<AppState>>— shared application state.Path(...)— URL path parameters (e.g., order ID, authz ID).body: Bytes— raw request body for JWS verification.
4. JWS verification (POST endpoints)
Almost every POST endpoint calls routes::parse_jws before processing the payload:
- Parse: deserialize the
Bytesbody as a JWS flattened JSON serialization. - Decode header: base64url-decode the
protectedheader and parse the JSON. - URL check: compare
header.urlwith the expected full URL for this endpoint. A mismatch returnsunauthorized. - Nonce check: look up
header.noncein thenoncesdatabase table and mark it consumed. A missing or already-used nonce returnsbadNonce. Anti-replay protection is thus database-backed, surviving server restarts. - Key resolution: if the header uses
jwk, extract the SPKI DER from the JWK directly. If it useskid, look up the account in the database and fetch its stored SPKI DER. - Signature verification: verify the JWS signature over
protected || "." || payloadusing the resolved public key viasynta-certificate. Classical algorithms (RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA) useverify_signature. ML-DSA algorithms (ML-DSA-44,ML-DSA-65,ML-DSA-87) are dispatched first — their raw-byte signatures (not DER) are verified withverify_ml_dsa_with_contextusing an empty context string, as required by draft-ietf-cose-dilithium-11 §4. - Payload decode: base64url-decode the
payloadfield.
The result is a JwsContext struct containing the decoded header, payload bytes, SPKI DER, and optional account ID.
5. Business logic
Each handler implements the ACME protocol semantics for its endpoint: reading from and writing to the database, dispatching validation, invoking the CA, etc.
For write operations that span multiple tables (e.g., creating an order with its authorizations and challenges), the handler uses a single SQLite transaction to ensure atomicity.
6. Response construction
Handlers return Result<Response, AcmeError>. On success, they call routes::json_response, which:
- Generates a new anti-replay nonce.
- Inserts it into the
noncestable. - Adds the
Replay-NonceandLink: <directory>; rel="index"headers. - Serializes the JSON body and sets
Content-Type: application/json.
On error, AcmeError::into_response builds a application/problem+json body.
7. Background tasks
Challenge validation does not block the HTTP response. When a challenge is triggered, the handler:
- Marks the challenge
processingin the database. - Spawns a
tokio::spawntask runningvalidation::validate_challenge. - Spawns a second observer task watching for panics via
JoinHandle::await. - Returns immediately with the
processingstatus.
Similarly, MTC log appends are spawned as background tasks after certificate issuance.
Database access model
All database access goes through tokio_rusqlite::Connection, which runs rusqlite calls on a dedicated background OS thread. Calls cross the thread boundary via a channel. This means:
db.call(|conn| { ... })is the only way to issue queries.- The closure runs synchronously on the background thread and must not call async functions.
- For multi-statement atomicity, start a SQLite transaction inside the closure.
Foreign key enforcement is enabled at database open time (PRAGMA foreign_keys=ON). WAL journal mode is also enabled after migrations (PRAGMA journal_mode=WAL).
Async design
The server is fully async on the tokio runtime. All I/O — TCP, HTTP, DNS, TLS — is async. CPU-bound work (DER encoding for MTC) is offloaded to tokio::task::spawn_blocking.
The only shared mutable state in the async domain is the Mutex<DiskBackedLog> for the MTC log. All other state is either immutable after startup (AppState, Config, CaState) or encapsulated in the database background thread.
Database
Akāmu uses SQLite as its persistence layer, accessed via rusqlite (with bundled SQLite) and tokio-rusqlite for async access. Schema migrations are managed by rusqlite_migration.
Connection model
The server maintains a single tokio_rusqlite::Connection shared across all handler tasks via Arc<Connection>. tokio_rusqlite runs rusqlite operations on a dedicated background OS thread, communicating via an internal channel. This avoids blocking the tokio thread pool with synchronous I/O while keeping the SQLite API simple.
All queries use the pattern:
#![allow(unused)]
fn main() {
db.call(|conn| {
// synchronous rusqlite operations here
conn.execute(...)?;
Ok(result)
}).await?
}
Initialization
db::open(path) in src/db/mod.rs performs the following in order:
- Opens or creates the SQLite database file (or opens an in-memory database for
:memory:). - Enables foreign key enforcement:
PRAGMA foreign_keys=ON. - Runs all pending migrations via
rusqlite_migration. - Enables WAL mode:
PRAGMA journal_mode=WAL.
WAL (Write-Ahead Logging) mode is enabled after migrations rather than before, because changing the journal mode during a migration can cause transaction issues.
At server startup, nonces older than 24 hours are swept: db::nonces::sweep_expired(&db, 86400).
Schema
The database has six tables, applied through three migration files.
Migration 001 — Initial schema
nonces — Anti-replay nonces consumed on first use.
CREATE TABLE nonces (
nonce TEXT PRIMARY KEY,
created INTEGER NOT NULL -- Unix epoch seconds
);
accounts — ACME accounts.
CREATE TABLE accounts (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'valid',
contact TEXT, -- JSON array of mailto: URIs
public_key BLOB NOT NULL, -- DER-encoded SubjectPublicKeyInfo
jwk_thumbprint TEXT NOT NULL UNIQUE, -- base64url SHA-256 JWK thumbprint
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
jwk_thumbprint has a unique constraint so the database enforces that no two accounts share a key.
orders — ACME orders.
CREATE TABLE orders (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
status TEXT NOT NULL DEFAULT 'pending',
expires INTEGER,
identifiers TEXT NOT NULL, -- JSON [{type,value}]
not_before INTEGER,
not_after INTEGER,
error TEXT, -- problem+json string if invalid
certificate_id TEXT,
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
identifiers is stored as a JSON string, e.g. [{"type":"dns","value":"example.com"}].
authorizations — One per identifier per order.
CREATE TABLE authorizations (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
account_id TEXT NOT NULL REFERENCES accounts(id),
status TEXT NOT NULL DEFAULT 'pending',
identifier TEXT NOT NULL, -- JSON {"type":..,"value":..}
expires INTEGER,
wildcard INTEGER NOT NULL DEFAULT 0, -- 0=false, 1=true
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
account_id is denormalized from the parent order to allow efficient per-account queries without joins.
challenges — One or more per authorization.
CREATE TABLE challenges (
id TEXT PRIMARY KEY,
authz_id TEXT NOT NULL REFERENCES authorizations(id),
type TEXT NOT NULL, -- http-01|dns-01|tls-alpn-01
status TEXT NOT NULL DEFAULT 'pending',
token TEXT NOT NULL,
validated INTEGER,
error TEXT,
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
All challenges for a given authorization share the same token (generated once per authorization at order creation).
certificates — Issued X.509 certificates.
CREATE TABLE certificates (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
account_id TEXT NOT NULL REFERENCES accounts(id),
serial_number TEXT NOT NULL UNIQUE, -- hex-encoded
status TEXT NOT NULL DEFAULT 'valid',
der BLOB NOT NULL,
pem TEXT NOT NULL,
not_before INTEGER NOT NULL,
not_after INTEGER NOT NULL,
revoked_at INTEGER,
revocation_reason INTEGER,
mtc_log_index INTEGER,
created INTEGER NOT NULL
);
der stores only the leaf certificate DER. pem stores the full PEM chain (leaf + CA). Both der and pem are stored because some operations (CRL generation, MTC logging) need the DER, while the download endpoint serves the PEM.
Migration 002 — Renewal info
Adds ARI (ACME Renewal Information) columns to certificates:
ALTER TABLE certificates ADD COLUMN suggested_window_start INTEGER;
ALTER TABLE certificates ADD COLUMN suggested_window_end INTEGER;
These are NULL by default. The ARI endpoint computes a default window if they are not set.
Migration 003 — Performance indexes
CREATE INDEX IF NOT EXISTS idx_certs_status
ON certificates(status);
CREATE INDEX IF NOT EXISTS idx_certs_account_status_not_after
ON certificates(account_id, status, not_after);
CREATE INDEX IF NOT EXISTS idx_nonces_created
ON nonces(created);
idx_certs_status speeds up CRL generation (which selects all revoked certificates). idx_certs_account_status_not_after speeds up per-account certificate listing. idx_nonces_created speeds up the expiry sweep.
Row types
src/db/schema.rs defines Rust structs mirroring each table row. These are plain data structs used to move data between the database layer and the application logic:
AccountRow— mirrorsaccounts.OrderRow— mirrorsorders.AuthorizationRow— mirrorsauthorizations.ChallengeRow— mirrorschallenges.CertificateRow— mirrorscertificates.
Database module structure
Each table has its own submodule in src/db/:
| Module | Exposed functions |
|---|---|
db::accounts | insert, get_by_id, get_by_thumbprint, update_contact, update_status, update_key |
db::orders | insert, get_by_id, update_status, list_authz_ids |
db::authz | insert, get_by_id, update_status |
db::challenges | insert, get_by_id, list_by_authz, set_processing, set_invalid |
db::certs | get_by_id, get_by_serial, revoke, set_mtc_log_index |
db::nonces | insert, consume, sweep_expired |
Transactions
Multi-table writes use explicit SQLite transactions to ensure atomicity:
- Order creation: the order row, all authorization rows, and all challenge rows are inserted in a single transaction.
- Challenge validation success: the challenge, authorization, and (if all authorizations are now valid) the order are updated in a single transaction.
- Certificate issuance: the certificate row is inserted and the order is updated to
validin a single transaction.
This prevents the database from being left in an inconsistent state if the process crashes between writes.
Schema diagram
The entity-relationship diagram below shows all six tables and their foreign-key
relationships. The account_id column on authorizations is denormalized from the
parent order; both FKs exist in the database.
erDiagram
accounts {
TEXT id PK
TEXT status
TEXT contact
BLOB public_key
TEXT jwk_thumbprint UK
INTEGER created
INTEGER updated
}
orders {
TEXT id PK
TEXT account_id FK
TEXT status
INTEGER expires
TEXT identifiers
TEXT error
TEXT certificate_id
INTEGER created
INTEGER updated
}
authorizations {
TEXT id PK
TEXT order_id FK
TEXT account_id FK
TEXT status
TEXT identifier
INTEGER expires
INTEGER wildcard
INTEGER created
INTEGER updated
}
challenges {
TEXT id PK
TEXT authz_id FK
TEXT type
TEXT status
TEXT token
INTEGER validated
TEXT error
INTEGER created
INTEGER updated
}
certificates {
TEXT id PK
TEXT order_id FK
TEXT account_id FK
TEXT serial_number UK
TEXT status
BLOB der
TEXT pem
INTEGER not_before
INTEGER not_after
INTEGER revoked_at
INTEGER revocation_reason
INTEGER mtc_log_index
INTEGER created
}
nonces {
TEXT nonce PK
INTEGER created
}
accounts ||--o{ orders : "account_id"
accounts ||--o{ authorizations : "account_id (denormalized)"
accounts ||--o{ certificates : "account_id"
orders ||--o{ authorizations : "order_id"
authorizations ||--o{ challenges : "authz_id"
orders ||--o{ certificates : "order_id"
Foreign key enforcement
Foreign key constraints are enabled at database open time. The constraint graph is:
orders.account_id→accounts.idauthorizations.order_id→orders.idauthorizations.account_id→accounts.idchallenges.authz_id→authorizations.idcertificates.order_id→orders.idcertificates.account_id→accounts.id
Enabling foreign keys is done before running migrations so that any migration that would violate a constraint fails immediately rather than silently inserting orphaned rows.
Certificate Authority
The CA module (src/ca/) handles key generation, certificate issuance, CSR validation, and CRL generation. All CA operations are synchronous (no async); they are called from async handlers but do not perform I/O themselves (except during initialization).
Initialization (src/ca/init.rs)
ca::init::load_or_generate(config: &CaConfig) is called once at startup. It follows this logic:
key_file exists | cert_file exists | Action |
|---|---|---|
| No | No | Generate a new CA key and self-signed certificate; write both to disk. |
| Yes | Yes | Load both PEM files from disk. |
| Yes | No (or No/Yes) | Return an error — partial state is rejected. |
flowchart TD
A([ca::init::load_or_generate]) --> B{"key_file<br/>exists?"}
B -->|No| C{"cert_file<br/>exists?"}
C -->|No| D["Generate CA private key<br/>generate_backend_key"]
D --> E["Build self-signed CA certificate<br/>BasicConstraints cA=TRUE<br/>KeyUsage keyCertSign+cRLSign"]
E --> F[Write key_file + cert_file to disk]
F --> G([CA ready])
C -->|Yes| ERR["Error: partial state rejected<br/>startup aborted"]
B -->|Yes| H{"cert_file<br/>exists?"}
H -->|Yes| I[Load both PEM files]
I --> G
H -->|No| ERR
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class G ok
class ERR fail
Key generation
generate_backend_key(key_type: &str) dispatches to synta_certificate::BackendPrivateKey:
key_type string | Algorithm |
|---|---|
"ec:P-256" or "P-256" | ECDSA P-256 |
"ec:P-384" or "P-384" | ECDSA P-384 |
"ec:P-521" or "P-521" | ECDSA P-521 |
"rsa:2048" or "rsa2048" | RSA 2048 (e=65537) |
"rsa:3072" or "rsa3072" | RSA 3072 (e=65537) |
"rsa:4096" or "rsa4096" | RSA 4096 (e=65537) |
"ed25519" | Ed25519 |
"ed448" | Ed448 |
Any other string returns AcmeError::Internal.
CA certificate profile
The auto-generated CA certificate contains:
| Field | Value |
|---|---|
| Serial | INTEGER { 1 } |
| Subject | CN=<common_name>, O=<organization> |
| Issuer | Same as subject (self-signed) |
| NotBefore | Current time |
| NotAfter | ca_validity_years * 365.25 * 86400 seconds in the future |
| BasicConstraints | Critical; cA=TRUE |
| KeyUsage | Critical; keyCertSign + cRLSign |
| SubjectKeyIdentifier | SHA-1 of the public key (RFC 5280 method 1) |
| AuthorityKeyIdentifier | Same key ID (self-signed) |
Time encoding
Internal timestamps are represented as Unix epoch seconds. The helper function unix_to_generalized_time(secs: i64) -> String converts them to the YYYYMMDDHHmmssZ format used by ASN.1 GeneralizedTime, using synta::GeneralizedTime::from_unix for the Gregorian calendar decomposition (Howard Hinnant’s algorithm, no external dependencies).
CSR validation (src/ca/csr.rs)
ca::csr::validate_csr(csr_der: &[u8], allowed_identifiers: &[(&str, &str)]) -> Result<ValidatedCsr, AcmeError> performs the following checks in order:
flowchart TD
A([CSR DER bytes]) --> B["1. Parse PKCS#10<br/>CertificationRequest via synta"]
B --> C["2. Re-encode CRI to DER<br/>exact bytes that were signed"]
C --> D[3. Re-encode AlgorithmIdentifier]
D --> E[4. Re-encode SubjectPublicKeyInfo]
E --> F{"5. Verify CSR<br/>self-signature"}
F -->|invalid| FAIL([Return BadCsr])
F -->|valid| G["6. Walk CSR attributes<br/>for extensionRequest OID"]
G --> H{"7. BasicConstraints<br/>cA=TRUE?"}
H -->|yes| FAIL
H -->|no / absent| I["8. Parse SANs<br/>dNSName + iPAddress entries"]
I --> J{"9. Bidirectional set equality<br/>CSR SANs == allowed identifiers"}
J -->|mismatch| FAIL
J -->|match| K[10. Re-encode Subject DER]
K --> L(["Return ValidatedCsr<br/>SPKI + Subject + SANs"])
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class L ok
class FAIL fail
- Parse: decode the
csr_deras a DER PKCS#10CertificationRequestusingsynta. - Re-encode CRI: encode the
CertificationRequestInfoback to DER to obtain the exact bytes that were signed. - Re-encode AlgorithmIdentifier: same for the signature algorithm.
- Re-encode SPKI: extract and re-encode the SubjectPublicKeyInfo.
- Verify signature:
BackendPublicKey::verify_signature(cri_der, alg_der, signature). ReturnsBadCsrif invalid. - Extract extensions: walk the CSR attributes for the
extensionRequestattribute (OID1.2.840.113549.1.9.14) and decode the extension list. - Check BasicConstraints: if present, reject
cA=TRUE. - Parse SANs: walk the SubjectAlternativeName extension for
dNSName(tag[2]) andiPAddress(tag[7]) entries. Other SAN types are silently ignored. - Bidirectional set equality: every CSR SAN must appear in
allowed_identifiers, and every entry inallowed_identifiersmust appear in the CSR SANs. A mismatch returnsBadCsr. - Re-encode Subject: extract the subject Name DER.
The returned ValidatedCsr contains the SPKI DER, subject DER, and parsed SAN list. These are passed directly to issue_certificate.
The DER walking in step 6 is done manually (not using synta’s high-level decoder) because the extension attribute is nested inside a SET OF ANY, which requires raw byte manipulation to extract.
Certificate issuance (src/ca/issue.rs)
ca::issue::issue_certificate(ca_key, ca_cert_der, hash_alg, validity_days, crl_url, ocsp_url, csr) builds and signs an X.509 v3 end-entity certificate.
Serial number
A random 16-byte serial is generated by getrandom. The high bit is cleared to ensure the value is non-negative in two’s complement (required by RFC 5280 §4.1.2.2).
Extensions added
| Extension | Critical | Notes |
|---|---|---|
| BasicConstraints | No | cA=FALSE |
| KeyUsage | Yes | digitalSignature only |
| ExtendedKeyUsage | No | serverAuth |
| SubjectKeyIdentifier | No | SHA-1 of SPKI from CSR |
| AuthorityKeyIdentifier | No | SHA-1 of CA’s SPKI |
| SubjectAlternativeName | No | Rebuilt from validated SANs |
| AuthorityInfoAccess | No | Present only if ocsp_url is set |
| CRLDistributionPoints | No | Present only if crl_url is set |
PEM bundle
The returned IssuedCert contains:
cert_der— the leaf certificate DER (stored incertificates.der).cert_pem— a PEM bundle with the leaf certificate followed by the CA certificate (stored incertificates.pemand served by the download endpoint).
CRL generation (src/ca/revoke.rs)
ca::revoke::build_crl(ca_key, ca_cert_der, hash_alg, revoked_entries, next_update_secs) generates a v2 CRL.
The CRL:
- Uses the CA’s subject Name as the issuer.
- Contains one entry per
RevokedEntrywith serial bytes, revocation time, and optional reason code. - Includes a
CRLNumberextension (required for v2 by RFC 5280 §5.2.3) with the current Unix timestamp as a monotonically increasing integer. - Is returned as both DER and PEM.
The CRL Number is encoded as a positive DER INTEGER. encode_integer_der handles the two’s complement padding (adding a 0x00 prefix when the high bit of the first content byte is set).
The server does not call build_crl automatically. It must be called by external tooling or a future CRL-serving endpoint.
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>,
challenge_id: &str,
authz_id: &str,
chall_type: &str,
id_type: &str,
id_value: &str,
key_auth: &str,
token: &str,
)
}
validate_challenge calls dispatch(chall_type, ...), which routes to one of four validators:
chall_type | Module | Function |
|---|---|---|
"http-01" | validation::http01 | validate(domain, token, key_auth) |
"dns-01" | validation::dns01 | validate(domain, key_auth) |
"tls-alpn-01" | validation::tls_alpn01 | validate(domain, key_auth) |
"dns-persist-01" | validation::dns_persist_01 | validate(domain, account_uri, issuer_domain, resolver_addr) |
| Any other | — | Returns AcmeError::IncorrectResponse |
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 (spawned)
participant D as dispatch()
participant Ext as Applicant Server / DNS
participant DB as SQLite
Client->>H: POST to challenge URL
H->>DB: challenge → processing
H->>V: tokio::spawn(validate_challenge)
H-->>Client: 200 processing (immediate return)
V->>D: dispatch(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 :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 (issuer;accounturi;policy;persistUntil)
end
alt probe succeeded
V->>DB: BEGIN TRANSACTION
V->>DB: challenge → valid (+ validated timestamp)
V->>DB: authorization → valid
V->>DB: count non-valid authzs for order
alt all authorizations now valid
V->>DB: order → ready
end
V->>DB: COMMIT
else probe failed
V->>DB: challenge → invalid (+ error JSON)
V->>DB: authorization → invalid
V->>DB: order → invalid
end
Note over Client: Client polls authorization URL (POST-as-GET)
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,
&challenge_id,
&authz_id_clone,
&chall_type_clone,
&id_type,
&id_value,
&key_auth,
&token,
)
.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 (on_valid)\nindependent steps (on_invalid)
State transitions on success (on_valid)
All three updates run inside a single SQLite transaction:
UPDATE challenges SET status = 'valid', validated = <now> WHERE id = <challenge_id>UPDATE authorizations SET status = 'valid' WHERE id = <authz_id>SELECT order_id FROM authorizations WHERE id = <authz_id>SELECT COUNT(*) FROM authorizations WHERE order_id = <order_id> AND status != 'valid'- If count is zero:
UPDATE orders SET status = 'ready' WHERE id = <order_id>
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 until the next validation attempt or a timeout.
State transitions on failure (on_invalid)
db::challenges::set_invalid(challenge_id, error_json, now)— marks the challengeinvalidand stores the error JSON.db::authz::update_status(authz_id, "invalid", now)— marks the authorizationinvalid.db::authz::get_by_id(authz_id)— finds the parent order ID.db::orders::update_status(order_id, "invalid", None, now)— marks the orderinvalid.
Each step is independent; a failure in one step logs a warning but does not prevent the others from running. This is intentional: if the database is in a degraded state, we still attempt to record as much of the failure as possible.
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>. - Send a GET request via
hyper_util::client::legacy::Client. - Check that the response status is 2xx.
- Read up to 8192 bytes 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 too large →
AcmeError::IncorrectResponse - Key auth mismatch →
AcmeError::IncorrectResponse
dns-01 validator (src/validation/dns01.rs)
Uses hickory_resolver::TokioAsyncResolver with the system default resolver configuration.
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.
- 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) accepts a custom resolver 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.
Validation steps:
- Compute
expected_hash = SHA-256(key_auth). - 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
<domain>:443. - Perform the TLS handshake.
- Extract the end-entity certificate from the peer certificate chain.
- Call
verify_acme_cert(domain, 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.
- Checks it contains
domainas adNSName.
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 →
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 - Domain not 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 hickory_resolver::TokioAsyncResolver. The resolver is either the system default or the address configured via server.dns_resolver_addr.
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.
- For each TXT record value, call
matches_record(value, issuer_domain, 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.
It splits the raw TXT value on ; and applies the following checks in order:
- The first token (trimmed, trailing dot stripped, lowercased) equals
expected_issuer(same normalization applied). - 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. 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:
- DNS lookup failure →
AcmeError::Dns - No matching TXT record →
AcmeError::IncorrectResponse
Error Handling
All fallible operations in Akāmu return Result<T, AcmeError>. The AcmeError type is defined in src/error.rs and implements both std::error::Error (via thiserror) and axum’s IntoResponse.
AcmeError taxonomy
ACME-specific errors
These map to ACME problem type URNs (urn:ietf:params:acme:error:*):
| Variant | ACME type | HTTP status |
|---|---|---|
BadNonce | badNonce | 400 |
BadSignatureAlgorithm(String) | badSignatureAlgorithm | 400 |
Unauthorized(String) | unauthorized | 401 |
AccountDoesNotExist | accountDoesNotExist | 400 |
AccountAlreadyExists | accountAlreadyExists | 409 |
InvalidContact(String) | invalidContact | 400 |
UnsupportedContact | unsupportedContact | 400 |
UserActionRequired(String) | userActionRequired | 403 |
RejectedIdentifier(String) | rejectedIdentifier | 400 |
UnsupportedIdentifier(String) | unsupportedIdentifier | 400 |
OrderNotReady | orderNotReady | 403 |
BadCsr(String) | badCSR | 400 |
BadRevocationReason | badRevocationReason | 400 |
AlreadyRevoked | alreadyRevoked | 400 |
Caa(String) | caa | 403 |
Connection(String) | connection | 400 |
Dns(String) | dns | 400 |
IncorrectResponse(String) | incorrectResponse | 400 |
Tls(String) | tls | 400 |
Generic HTTP-mapped errors
These do not have dedicated ACME error types; they fall through to serverInternal in the ACME type, but carry the appropriate HTTP status:
| Variant | HTTP status |
|---|---|
NotFound | 404 |
MethodNotAllowed | 405 |
Conflict(String) | 409 |
UnsupportedMediaType | 415 |
PayloadTooLarge | 413 |
BadRequest(String) | 400 |
Internal errors
These indicate server-side failures. They map to serverInternal in the ACME error type and HTTP 500:
| Variant | Meaning |
|---|---|
Database(String) | SQLite error |
Crypto(String) | Cryptographic operation failure |
Builder(String) | Certificate or CRL builder error |
Mtc(String) | MTC log operation failure |
Internal(String) | General internal error |
Response format
AcmeError::into_response builds a response with:
- HTTP status from
http_status(). Content-Type: application/problem+json(RFC 7807).- JSON body:
{
"type": "urn:ietf:params:acme:error:badNonce",
"status": 400,
"detail": "bad nonce"
}
The detail field is the Display string of the variant, which for parameterized variants includes the inner string.
From implementations
Two From implementations allow the ? operator to be used with database errors:
From<tokio_rusqlite::Error> for AcmeError→ wraps inAcmeError::Database.From<rusqlite::Error> for AcmeError→ wraps inAcmeError::Database.
This means any db.call(|conn| { ... }).await? will automatically convert database errors to AcmeError::Database(msg).
Error propagation in handlers
Handlers return Result<Response, AcmeError>. axum automatically calls IntoResponse::into_response on the error variant when building the HTTP response.
Example handler error propagation chain:
db::accounts::get_by_id(&db, &id).await?
↓ rusqlite::Error or tokio_rusqlite::Error
↓ From<...> for AcmeError
↓ AcmeError::Database("...")
↓ returned from handler as Err(AcmeError::Database(...))
↓ axum calls AcmeError::into_response()
↓ HTTP 500 with application/problem+json body
Error handling in background tasks
Background validation tasks (tokio::spawn) must not panic and must not propagate errors. validation::validate_challenge is declared infallible: it calls on_valid or on_invalid internally and logs any database errors via tracing::warn!. Panics inside validation tasks are caught by the observer task pattern described in the Validation chapter.
Design principles
- No
unwrap()in production paths. All fallible operations use?or explicit error handling. - Internal errors do not leak details to clients.
AcmeError::InternalandAcmeError::Databaseboth map toserverInternaland HTTP 500. Thedetailfield is included in the response because ACME clients need some indication of what went wrong, but sensitive internal state is not exposed. - Challenge errors are ACME errors. The validation layer converts
hyper,hickory-resolver, andrustlserrors into specificAcmeErrorvariants (Connection,Dns,Tls,IncorrectResponse) so the client receives a meaningful ACME error type.
Testing
Akāmu uses Rust’s built-in test framework (cargo test). Tests are organized at three levels:
- Unit tests inside each source file (
#[cfg(test)] mod tests). - Integration tests in
tests/acme_flow.rs, which test the full HTTP stack.
Running tests
Run all tests:
cargo test
Run a specific test by name:
cargo test validate_csr
Run all tests in a specific module:
cargo test ca::csr::tests
Run with output visible (useful for debugging):
cargo test -- --nocapture
Library crate tests
Each library crate ships its own unit tests:
cargo test -p akamu-jose # 66 tests: JWK parsing, JWS sign/verify, ML-DSA
cargo test -p akamu-client # 16 tests: AccountKey, EAB, CSR, challenge helpers
akamu-jose tests cover every key type including ML-DSA-44/65/87 round-trip sign/verify. akamu-client tests use real OpenSSL key generation (no mocking). See crates/akamu-jose/src/ and crates/akamu-client/src/ for test modules.
Test dependencies
dev-dependencies in Cargo.toml:
| Crate | Purpose |
|---|---|
tokio (with test-util) | #[tokio::test] macro for async tests |
tempfile | Temporary files and directories for CA key tests and database tests |
tower | ServiceExt::oneshot for integration test HTTP requests |
Unit test coverage
src/config.rs
Tests verify:
- Minimal TOML parses correctly with all required fields present.
- Default values are applied when optional fields are omitted.
- All optional fields parse correctly when present.
Config::from_filereturns a descriptive error for missing files.Config::from_filereturns a descriptive error for invalid TOML.
src/error.rs
Tests verify:
- Every
AcmeErrorvariant maps to the correct ACME type string. - Every variant maps to the correct HTTP status code.
Displaystrings are correct.From<rusqlite::Error>andFrom<tokio_rusqlite::Error>convert correctly.into_responseproducesContent-Type: application/problem+jsonand the correct status.
src/routes/mod.rs
Tests verify:
fmt_time(0)returns"1970-01-01T00:00:00Z".fmt_time(1704067200)returns"2024-01-01T00:00:00Z".unix_now()returns a positive integer.require_payloadreturnsBadRequestfor an empty payload.require_payloadreturnsBadRequestfor invalid JSON.require_payloadsucceeds for valid JSON.
src/db/mod.rs
Tests verify:
db::open(":memory:")succeeds and accepts queries.db::open(path)creates the file and applies migrations (accounts table exists).
src/db/accounts.rs, orders.rs, authz.rs, challenges.rs, certs.rs, nonces.rs
Each module has tests for:
- Happy-path insert and retrieval.
- Missing-row returns
None. - Update functions returning
falsefor non-existent rows. - Error propagation paths (using a raw connection with no schema).
src/ca/init.rs
Tests verify:
- Each key type generates a non-empty SPKI DER.
"bogus:key-type"returnsAcmeError::Internal.unix_to_generalized_time(0)returns"19700101000000Z".load_or_generatecreates both files when neither exists.load_or_generateloads successfully when both files exist.load_or_generatereturns an error when exactly one file exists.
src/ca/csr.rs
Tests verify:
- A valid CSR parses correctly and SANs are extracted.
- A tampered signature is rejected with
BadCsr. cA=TRUEin BasicConstraints is rejected.- SANs not in the allowed set are rejected.
- Required identifiers missing from CSR SANs are rejected.
- IPv4 and IPv6 SAN parsing.
- Edge cases: no SAN extension, email SANs (ignored), non-extensionRequest attributes.
- DER helper functions:
strip_sequence,tlv_header,bytes_to_ip_string.
src/ca/issue.rs
Tests verify:
- End-to-end certificate issuance produces a parseable DER certificate.
- Serial number in issued cert matches
serial_hex. - PEM bundle contains exactly two certificates (leaf + CA).
- Chain verification passes using
synta-x509-verification. - CRL URL and OCSP URL extensions are included when configured.
- IP SAN issuance works.
- Invalid IP SAN string returns
AcmeError::Builder. - Unknown SAN type is silently skipped.
src/ca/revoke.rs
Tests verify:
- An empty CRL is generated with correct PEM headers.
- CRL with revoked entries is generated.
encode_integer_derproduces correct DER for edge cases (0, 127, 128, 255, 256).- Invalid CA cert DER returns an error.
src/validation/mod.rs
Tests verify:
unix_now()returns a positive integer.err_typemaps eachAcmeErrorvariant correctly.dispatchreturnsIncorrectResponsefor unsupported challenge types.on_validandon_invaliddo not panic when called with non-existent IDs.on_validwith real DB rows updates challenge, authz, and order to valid/ready.on_invalidwith real DB rows marks everything invalid.validate_challengefor http-01 with a live local server marks the challenge valid.- Database error paths in
on_validandon_invalidare covered with partial-schema databases.
src/validation/http01.rs
Tests use a local axum HTTP server on an ephemeral port:
- Correct key auth returns
Ok. - Unreachable domain returns
AcmeError::Connection. - HTTP 404 response returns
AcmeError::IncorrectResponse. - Response body over 8 KiB returns
AcmeError::IncorrectResponse. - Key auth mismatch returns
AcmeError::IncorrectResponse.
src/validation/dns01.rs
Tests use a hand-crafted UDP DNS stub server:
- Correct TXT value returns
Ok. - Wrong TXT value returns
AcmeError::IncorrectResponse. - Non-existent domain returns an error.
- Wildcard prefix is stripped before querying.
src/validation/tls_alpn01.rs
Tests include both unit tests for the DER walker and integration tests using local TLS servers:
decode_length,read_tlv,strip_sequence,strip_octet_string,skip_tlv— edge cases.find_extension_valuewith correct, missing, and wrong-OID extensions.verify_acme_certwith hand-crafted DER certificates.- Local TLS 1.3 and TLS 1.2 servers for
validate_innercoverage. AcceptAnyCertverifier always returnsOk.
src/mtc/log.rs
Tests verify:
open_or_createcreates a new log file.- Appending leaves increments the tree size.
- Re-opening an existing log file restores the leaf count.
compute_rootreturns a 32-byte value for a non-empty log.
src/routes/account.rs
Tests verify contact validation and account_json structure.
src/routes/order.rs
Tests verify order_json for pending, valid, invalid, and expired orders.
Integration tests (tests/acme_flow.rs)
The integration tests build a full AppState with an in-memory database, a generated CA, and a real axum router. They use tower::ServiceExt::oneshot to send HTTP requests directly to the router without binding a TCP port.
The test suite covers multi-step ACME flows including account creation, order creation, challenge signaling, and verification that status transitions occur correctly.
Adding new tests
Place unit tests in a #[cfg(test)] mod tests { ... } block at the bottom of the source file being tested. Use tokio::test for async tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn my_async_test() {
// ...
}
#[test]
fn my_sync_test() {
// ...
}
}
}
For tests that need a database, call crate::db::open(":memory:").await.unwrap() to get a fresh in-memory database with the full schema applied.
For tests that need a CA, call crate::ca::init::load_or_generate(&config).unwrap() with a CaConfig pointing to a tempfile::TempDir.
Coverage
Coverage measurement is supported via the measure_coverage.sh script in the repository root. It uses cargo llvm-cov or a similar tool. See that script for the exact invocation.
Local CI
contrib/ci/local-ci.sh runs the same checks that a GitHub Actions workflow
would run, in the same order, without needing a CI account or a push. Use it
to catch failures before committing or to reproduce a CI failure locally.
Quick start
# Run the full pipeline
./contrib/ci/local-ci.sh all
# Run only formatting and lint checks
./contrib/ci/local-ci.sh fmt clippy
# List available jobs
./contrib/ci/local-ci.sh --list
Requirements
| Tool | Install |
|---|---|
cargo | https://rustup.rs/ |
rustfmt | rustup component add rustfmt |
clippy | rustup component add clippy |
mdbook | cargo install mdbook (optional, for the doc job) |
actionlint | https://github.com/rhysd/actionlint (optional, for lint-workflows) |
yamllint | pip install yamllint (optional, fallback for lint-workflows) |
The script detects which optional tools are present at startup and skips or degrades gracefully for any that are missing.
Jobs
| Job | Command | Depends on |
|---|---|---|
build | cargo build --workspace + cargo build --benches | — |
fmt | cargo fmt --all -- --check | — |
clippy | cargo clippy -- -D warnings | build |
doc | cargo doc --no-deps + mdbook build docs/ | build |
test | cargo test | build |
bench | cargo build --benches (compile-only) | build |
lint-workflows | actionlint or yamllint on .github/workflows/*.yml | — |
The bench job only compiles the benchmark binary; it does not run any
issuance measurements. Use cargo bench --bench acme_bench directly when you
want timing numbers (see Performance).
Dependency graph
When you request a job whose prerequisite has not run yet, the script runs the prerequisite automatically. If the prerequisite fails, the dependent job is skipped rather than attempted:
build ──┬── clippy
├── doc
├── test
└── bench
fmt and lint-workflows have no prerequisites and can run independently.
Options
| Option | Effect |
|---|---|
--no-color | Disable ANSI colour output (also honoured via NO_COLOR=1) |
--no-deps | Skip prerequisite auto-dispatch — intended for use inside an actual CI system where needs: already serialises the jobs |
--list | Print available job names and exit |
--help / -h | Show usage and exit |
Environment variables
| Variable | Effect |
|---|---|
CARGO_TARGET_DIR | Redirect Cargo build artefacts to an isolated directory |
NO_COLOR | Set to 1 to disable ANSI colour output |
Summary output
After all requested jobs finish the script prints a table:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CI Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
build PASS (22s)
fmt PASS (1s)
clippy PASS (0s)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total time: 23s
All jobs passed.
The script exits with status 0 when all executed jobs pass and 1 if any job
fails. Skipped jobs (due to a failed dependency) are shown as SKIP and do
not affect the exit status.
Use inside GitHub Actions
When running a single job from within a workflow step, pass --no-deps so the
script does not re-run prerequisites that the workflow needs: graph has
already enforced:
- name: Clippy
run: ./contrib/ci/local-ci.sh --no-deps clippy
Without --no-deps, the script would automatically invoke build before
clippy, duplicating work that another job already performed.
Example invocations
# Full pipeline
./contrib/ci/local-ci.sh all
# Only the fast checks (no compilation required)
./contrib/ci/local-ci.sh fmt lint-workflows
# Isolated build directory (keeps the main target/ clean)
CARGO_TARGET_DIR=/tmp/akamu-ci ./contrib/ci/local-ci.sh all
# CI-mode: run one job without triggering its prerequisites
./contrib/ci/local-ci.sh --no-deps test
# No colour output (e.g. when piping to a log file)
./contrib/ci/local-ci.sh --no-color all
Contributing
This chapter describes the conventions for code style, commit messages, and the pull request process for Akāmu.
Running CI locally
Before pushing, verify the pipeline locally with contrib/ci/local-ci.sh:
./contrib/ci/local-ci.sh all
This runs the same jobs the CI system runs: build, fmt, clippy, doc, test, bench (compile-only), and workflow linting. See Local CI for the full reference.
Code style
Formatting
All Rust code is formatted with rustfmt using the default configuration:
cargo fmt
Run this before committing, or let the fmt job catch it:
./contrib/ci/local-ci.sh fmt
Lints
Clippy is the linter. Address all warnings before submitting:
cargo clippy -- -D warnings
Or via the CI script:
./contrib/ci/local-ci.sh clippy
Documentation comments
Public types, functions, and modules should have doc comments (/// for items, //! for module-level). The standard format is:
#![allow(unused)]
fn main() {
/// Brief one-line summary.
///
/// Longer explanation if needed. Include:
/// - what the function does
/// - what each parameter means
/// - what the return value represents
/// - any notable edge cases or panics
pub fn my_function(arg: &str) -> Result<(), AcmeError> {
// ...
}
}
No unwrap() in production code
Use ? or explicit error handling. Exceptions:
- Test code may use
unwrap()freely. - Truly infallible operations (e.g.,
serde_json::to_stringon a value that is always serializable) may useunwrap()with a comment explaining why.
Error variants
When adding a new operation that can fail, prefer returning a specific existing AcmeError variant over adding a new one. If a new variant is genuinely needed, add it to the AcmeError enum in src/error.rs and update:
AcmeError::acme_type()— return the ACME error URN string.AcmeError::http_status()— return the appropriate HTTP status code.- The test in
error.rsthat verifies all variants map correctly.
Database access
All database access must go through the db:: submodule functions, not raw SQL in route handlers. New database operations belong in the appropriate src/db/*.rs file, not in routes/ or ca/ modules.
Transactions must be used when writing to multiple tables atomically. Do not rely on SQLite’s autocommit for multi-table writes.
Async hygiene
- Do not call blocking I/O or CPU-intensive code directly from async tasks. Use
tokio::task::spawn_blockingfor synchronous blocking work. - Do not hold mutex guards across
.awaitpoints. SQLite access viadb.call()does not hold any async mutex; it is safe. - Background tasks spawned with
tokio::spawnmust be panic-safe. Use the observer task pattern to log panics.
Commit conventions
Commits follow the format:
<type>: <short summary (imperative, lowercase, no period)>
[optional body]
Types used in this repository:
| Type | When to use |
|---|---|
feat | A new feature visible to users or operators |
fix | A bug fix |
doc | Documentation changes only |
test | Adding or modifying tests without changing production code |
refactor | Code change that neither fixes a bug nor adds a feature |
perf | Performance improvement |
chore | Build system, dependency updates, CI |
Keep the summary line under 72 characters. Use the body to explain why the change was made, not what was changed (the diff explains what).
Examples from the repository:
doc: document thread-safety assumption for CaState key field
fix: eliminate TOCTOU race in MTC log open_or_create
fix: log panics in background validation task
fix: use compile-time-safe HeaderValue in error response
fix: use saturating_sub in nonce sweep to avoid debug-mode panic
Pull request process
- Fork the repository and create a topic branch from
main. - Make your changes with appropriate tests.
- Ensure
./contrib/ci/local-ci.sh allpasses (covers fmt, clippy, doc, test, and bench compilation). - Write a clear PR description explaining what problem the change solves and how it was tested.
- Keep PRs focused on a single concern. Unrelated changes should be separate PRs.
- Address review feedback by adding new commits; do not force-push to squash during review (it makes the diff hard to follow). Squashing happens at merge time if the reviewer requests it.
Adding a new endpoint
When adding a new ACME or HTTP endpoint:
- Create a handler function in a new or existing file under
src/routes/. - Register the route in
routes::build_routerinsrc/routes/mod.rs. - Add the endpoint URL to the directory response in
routes::directory::get_directoryif it should be advertised. - Add any new database operations to the appropriate
src/db/*.rsmodule. - Add unit tests for the handler logic and any new database functions.
- Update the user-facing documentation for the affected feature.
Adding a new challenge type
- Create a new module in
src/validation/, e.g.,src/validation/mytype01.rs. - Export an async
validate(domain, key_auth)function. - Add a new arm to
dispatchinsrc/validation/mod.rs. - Add the new challenge type to the list of challenges created per identifier in
routes::order::new_order. - Add tests, including both unit tests and, if possible, an integration test using a local stub server.
- Document the new challenge type in
docs/src/user/challenges.md.
Workspace development
The repository is a Cargo workspace. When adding code to crates/akamu-jose or crates/akamu-client, run that crate’s tests in isolation first:
cargo test -p akamu-jose
cargo test -p akamu-client
Then verify the full workspace:
cargo test
cargo clippy --workspace
cargo fmt -- --check
Do not add axum, rusqlite, or server-specific dependencies to akamu-jose or akamu-client — they must remain usable without a running server.