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).
- Validates
TNAuthListandJWTClaimConstraintsidentifiers using the tkauth-01 challenge type (RFC 9447 / RFC 9448), verifying signed authority tokens issued by an external Token Authority. - 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.
- Persists all ACME objects (accounts, orders, authorizations, challenges, certificates, nonces) in a SQL database. The supported backends are SQLite (default; single-file, no external service required), PostgreSQL, and MariaDB/MySQL, selected by the
database.urlconfiguration key. - Generates and serves CRLs (Certificate Revocation Lists) at
GET /ca/crl. - Serves OCSP responses at
GET /ca/ocsp/{request}andPOST /ca/ocsp(RFC 6960). - 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. EAB keys can be provisioned in two ways: statically in the TOML config under[server.eab_keys], or derived on demand via HKDF-SHA-256 (RFC 5869) when[server].eab_master_secretis set and the client authenticates via GSSAPI or a trusted proxy (GET /acme/eab). - 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.
- Supports multi-node clustering through a built-in CRDT + gossip replication layer. All domain state (accounts, orders, authorizations, challenges, certificates, EAB keys, operators, delegations, MTC) is replicated to every cluster member via signed gossip envelopes. Nodes are registered with each other via the
POST /admin/gossip/registeradmin endpoint. When the[gossip]section is absent the node runs in single-node mode with no replication overhead.
What it does not do
- 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 | sqlx 0.8 (SQLite / PostgreSQL / MariaDB via Any backend) |
| Schema migrations | sqlx built-in migrate |
| 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) |
| CRDT replication | akamu-crdt (workspace crate) — LWW-register, OR-map, LWW-map, GrowSet primitives |
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 9447 — ACME Challenges Using an Authority Token (tkauth-01)
- RFC 9448 — ACME TNAuthList Authority Token
- 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
- RFC 6960 — Online Certificate Status Protocol (OCSP)
- Let’s Encrypt dns-persist-01 — Persistent DNS challenge type
- draft-ietf-acme-profiles-01 — ACME certificate profiles
- draft-ietf-lamps-pq-composite-sigs — ML-DSA composite TLS signature schemes (provisional code points)
- draft-ietf-plants-merkle-tree-certs-04 — Merkle Tree Certificates (MTC), transparency-log-backed certificate format (experimental OIDs, pre-IANA)
Reading guide
The documentation is split into three sections targeting distinct audiences:
| Section | Who it is for | What it covers |
|---|---|---|
| Operator Guide | System administrators deploying and running Akāmu | Installation, configuration, account policies, certificate issuance, revocation, TLS, backup |
| API Reference | Developers consuming Akāmu’s HTTP APIs or using the Rust client libraries | Admin REST API, ACME protocol details (algorithms, challenge types, error codes, wire formats), akamu-jose / akamu-client / akamu-cli |
| Implementation Guide | Contributors working on the Akāmu source code | Architecture, database schema, CA internals, challenge validation, EAB and account internals, testing |
Quick navigation
New to Akāmu? Start with the Quick Start guide.
Deploying or configuring the server? See the Configuration Reference for every configuration key, or Operator Roles for RBAC setup.
Building an ACME client or integrating via the API? Start with ACME Protocol Reference for the wire-level details, Admin API for the management REST API, or akamu-client for the Rust library.
Contributing to Akāmu? Read the Architecture chapter first — it includes a full system 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 and byrustls-native-ossl, the TLS crypto provider)
Fedora / RHEL
sudo dnf install openssl-devel
Debian / Ubuntu
sudo apt install libssl-dev
Checking out the source
git clone <akamu-repo> akamu
All synta dependencies are fetched automatically from crates.io — no manual checkout required.
Building from source
The repository is a Cargo workspace with seven members: the akamu server binary, akamu-jose, akamu-client, akamu-cli, akamuctl, akamu-cosigner, and akamu-ldap (the OpenLDAP C-binding library, used by the server when reading profiles from LDAP).
cd akamu
cargo build --release
This compiles all seven workspace members. The binaries are placed at:
target/release/akamu— the ACME servertarget/release/akamu-cli— the command-line clienttarget/release/akamuctl— the admin CLItarget/release/akamu-cosigner— the MTC cosigner daemon
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. 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]
url = "sqlite:///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;
}
}
Running behind a reverse proxy (Unix socket)
Instead of binding a TCP port, Akāmu can listen on a Unix domain socket. This avoids exposing a TCP port and simplifies firewall rules when the reverse proxy runs on the same host.
Set listen_addr to a unix: path (TLS must be absent or disabled — the proxy terminates TLS):
listen_addr = "unix:/run/akamu/akamu.sock"
base_url = "https://acme.example.com"
# No [tls] section — TLS is terminated at the proxy
The AKAMU_LISTEN environment variable overrides listen_addr without touching the config file:
AKAMU_LISTEN=unix:/run/akamu/akamu.sock akamu /etc/akamu/config.toml
nginx
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://unix:/run/akamu/akamu.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Apache
ProxyPass / unix:/run/akamu/akamu.sock|http://localhost/
ProxyPassReverse / unix:/run/akamu/akamu.sock|http://localhost/
Trusted proxies and X-Remote-User
If you use server.trusted_proxies for admin auth via X-Remote-User, all connections arriving over a Unix socket are treated as locally trusted (no CIDR check). The header is still required — any connection without it receives a 401.
systemd socket activation
The provided unit files support socket activation, which lets systemd pre-bind the socket before the service starts:
systemctl enable --now akamu.socket akamu.service
The socket is created at /run/akamu/akamu.sock (mode 0660). The reverse proxy process must be in group akamu to connect. When using socket activation, listen_addr in the config file is ignored — the pre-bound socket is passed via LISTEN_FDS.
Stale socket files are removed automatically on config-based startup. Under socket activation, systemd owns the socket file and no cleanup is needed.
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"] }
Operator Roles
Operator Guide — This section is for administrators who deploy and run Akāmu. It covers configuration, account management, certificate issuance policies, and operational concerns. If you are building an ACME client or integrating with the API, see the API Reference. If you are contributing to Akāmu itself, see the Implementation Guide.
Akamu’s admin API uses role-based access control to enforce least privilege and
separation of duties. Every admin request is authenticated, and every operator
has exactly one role. The role determines which endpoints the operator may call.
Roles are assigned at operator creation time and can be changed later by an
administrator.
Understanding the roles before provisioning operators is important: a mis-scoped operator can either have too much access (a security risk) or too little (causing operational failures).
Role hierarchy
The four roles form a strict access hierarchy. Higher roles have a superset of the read permissions of lower roles, but lower roles do NOT inherit write permissions from higher roles.
administrator
|-- ca_operations
| |-- ca_ra (read only within own CA scope)
| |-- auditor (read only, no CA data)
More precisely:
administratorcan call every endpoint.ca_operationscan call everythingadministratorcan except operator management, server config reads, and profile write operations.ca_rais a scoped RA role. It can do a subset ofca_operationsreads but only within its assigned CA, and can revoke and issue EAB keys. It cannot see other CAs or perform any CA infrastructure operations.auditoris read-only and cannot perform any write operations.
Per-role reference
administrator
The administrator role is for PKI administrators who need full control over
the server. Assign it to the smallest possible number of humans and avoid using
it for automated service accounts.
Key capabilities:
- All endpoints without restriction.
- Create, update, activate, deactivate, and unlock operators.
- Read and write certificate profiles.
- Read server configuration.
- Manage EAB keys (create and delete).
- Revoke certificates from any CA.
- Force CRL regeneration for any CA or all CAs.
- Cross-sign CAs.
- Manage RFC 9115 delegation objects.
- Deactivate ACME accounts.
- Query the audit log.
Key restrictions:
- None. This role has full access.
Typical use case: A human PKI administrator who needs to bootstrap the system, manage other operators, update certificate profiles, or respond to a security incident that requires account deactivation or forced revocation.
Example:
akamuctl operator add \
--name alice \
--role administrator \
--cert-file /etc/akamu/alice-client.pem
ca_operations
The ca_operations role is for automated CA infrastructure processes and
operations staff who manage the certificate lifecycle but do not need to manage
operators or read the server configuration.
Key capabilities:
- List and view CAs and their certificates.
- Download CA certificates.
- Force CRL regeneration (global and per-CA).
- Cross-sign CAs.
- List, view, and download issued certificates.
- Create and delete EAB keys.
- Revoke certificates from any CA.
- Manage RFC 9115 delegation objects (create, update, delete).
- Manage ACME account profile grants (set but not clear).
- View accounts, orders, profiles, EAB keys, stats.
- Query the audit log.
Key restrictions:
- Cannot manage operators (no access to
/admin/operatorsendpoints). - Cannot read server configuration (
GET /admin/config). - Cannot write certificate profiles (no
POST,PUT, orDELETEon profiles). - Cannot deactivate ACME accounts.
- Cannot clear account profile grant lists.
CA scope (optional): A ca_operations operator can be assigned a ca_id
scope. When scoped, the operator’s visibility is limited to the assigned CA:
GET /admin/cas returns only that CA, CRL/cross-sign operations are restricted
to the scoped CA, and certificate and order queries are automatically filtered.
Unlike ca_ra, a ca_id scope is optional for ca_operations; an
unscoped ca_operations operator has server-wide access.
EAB keys and CA scope: Even a scoped ca_operations operator may create
EAB keys. EAB keys are not bound to any CA — they are used for ACME account
registration which is server-global — so this is intentional. Only an
administrator may set for_operator_id when creating an EAB key via
POST /admin/eab to assign it to a different operator for web UI login.
Typical use case: A CI/CD pipeline that issues EAB keys for new devices, or an operations team member who manages revocation and CRL generation without having the ability to create new operators.
Example:
akamuctl operator add \
--name ci-pipeline \
--role ca_operations \
--cert-file /etc/akamu/ci-client.pem
ca_ra
The ca_ra role is for front-line registration authority (RA) staff or
automated RA services that operate on behalf of a single specific CA. This role
is intentionally narrow: it cannot see data from other CAs and cannot perform
any CA infrastructure operations.
Key capabilities:
- View EAB keys.
- List and view certificates issued by the scoped CA only.
- Download certificates issued by the scoped CA only.
- Revoke certificates issued by the scoped CA only.
- View accounts and orders (filtered to the scoped CA automatically).
- View profiles, delegations, stats.
Key restrictions:
- Cannot create or delete EAB keys (
POST /admin/eab,DELETE /admin/eab/{kid}). - Cannot list or view CAs (
GET /admin/cas,GET /admin/cas/{id}). - Cannot see cross-certificates.
- Cannot force CRL regeneration.
- Cannot cross-sign CAs.
- Cannot manage operators.
- Cannot read server configuration.
- Cannot write certificate profiles or delegation objects.
- Cannot manage ACME accounts (deactivate, set/clear profile grants).
- Cannot query the audit log.
- Cannot view or act on certificates from a CA other than its assigned
ca_id. - Requires a
ca_idscope to be assigned. An unscopedca_raoperator is rejected at every restricted endpoint with403 Forbidden.
Special requirement — ca_id: See CA scope for ca_ra
below.
Typical use case: A registration authority system at a branch office that
accepts certificate requests for a specific CA (for example, rsa), issues EAB
keys for new ACME clients, and revokes certificates when devices are
decommissioned, without having any visibility into the rest of the PKI.
Example:
akamuctl operator add \
--name branch-ra \
--role ca_ra \
--ca-id rsa \
--cert-file /etc/akamu/branch-ra.pem
auditor
The auditor role is for security auditors, compliance officers, and monitoring
systems that need read access to the server’s operational data but must never be
able to make changes.
Key capabilities:
- Query the audit event log (
GET /admin/audit). - List and view issued certificates (across all CAs).
- List and view EAB keys.
- List and view accounts, orders, and profiles.
- View delegations.
- View cross-certificates.
- View server statistics.
Key restrictions:
- No write operations at all.
- Cannot view CA details or CA certificates.
- Cannot download certificate content (PEM/DER).
- Cannot view server configuration.
- Cannot manage operators.
Typical use case: A security operations center tool that polls the audit log for anomalies, or a compliance dashboard that tracks certificate issuance counts and expiry windows.
Example:
akamuctl operator add \
--name soc-monitor \
--role auditor \
--gssapi-principal soc-svc@EXAMPLE.COM
Permission matrix
The table below is the authoritative route-by-role matrix derived from the
server source code. Y means the role is permitted. Where ca_ra is permitted
on a cert or account endpoint, the server automatically enforces the CA scope
filter — the operator only sees data belonging to its assigned CA.
| Method | Path | administrator | ca_operations | ca_ra | auditor |
|---|---|---|---|---|---|
POST | /admin/session | Y | Y | Y | Y |
DELETE | /admin/session | Y | Y | Y | Y |
GET | /admin/operators | Y | |||
POST | /admin/operators | Y | |||
GET | /admin/operators/{id} | Y | |||
PUT | /admin/operators/{id} | Y | |||
PATCH | /admin/operators/{id} | Y | |||
POST | /admin/operators/{id}/unlock | Y | |||
GET | /admin/audit | Y | Y | ||
GET | /admin/profiles | Y | Y | Y | Y |
GET | /admin/profiles/{id} | Y | Y | Y | Y |
POST | /admin/profiles | Y | |||
PUT | /admin/profiles/{id} | Y | |||
DELETE | /admin/profiles/{id} | Y | |||
GET | /admin/accounts | Y | Y | Y | Y |
GET | /admin/account/{id} | Y | Y | Y | Y |
POST | /admin/account/{id}/deactivate | Y | |||
GET | /admin/account/{id}/profile-grants | Y | Y | Y | Y |
PUT | /admin/account/{id}/profile-grants | Y | Y | ||
DELETE | /admin/account/{id}/profile-grants | Y | |||
GET | /admin/certs | Y | Y | Y (scoped) | Y |
GET | /admin/certs/{id} | Y | Y | Y (scoped) | Y |
GET | /admin/certs/{id}/download | Y | Y | Y (scoped) | |
POST | /admin/eab | Y | Y | ||
GET | /admin/eab/{kid} | Y | Y | Y | Y |
DELETE | /admin/eab/{kid} | Y | Y | ||
GET | /admin/eab | Y | Y | Y | Y |
GET | /admin/orders | Y | Y | Y (scoped) | Y |
GET | /admin/orders/{id} | Y | Y | Y | Y |
GET | /admin/config | Y | |||
POST | /admin/crl/force | Y | Y | ||
POST | /admin/revoke | Y | Y | Y (scoped) | |
GET | /admin/stats | Y | Y | Y | Y |
GET | /admin/cas | Y | Y | ||
GET | /admin/cas/{id} | Y | Y | ||
GET | /admin/cas/{id}/cert | Y | Y | ||
POST | /admin/ca/{id}/crl/force | Y | Y | ||
POST | /admin/ca/{id}/cross-sign | Y | Y | ||
GET | /admin/cross-certs | Y | Y | Y | |
GET | /admin/cross-certs/{id} | Y | Y | Y | |
GET | /admin/delegations | Y | Y | Y | Y |
POST | /admin/delegations | Y | Y | ||
GET | /admin/delegations/{id} | Y | Y | Y | Y |
PUT | /admin/delegations/{id} | Y | Y | ||
DELETE | /admin/delegations/{id} | Y | Y |
Note on ca_ra scoping: When ca_ra is listed as permitted on a cert,
account, or order endpoint, the server silently overrides any ca_id query
parameter the operator supplies and substitutes the operator’s assigned ca_id.
An unscoped ca_ra (one with an empty ca_id) is rejected at all such
endpoints with 403 Forbidden.
Creating operators
All operator creation requires the administrator role.
Create an mTLS operator
# administrator — full access human admin
akamuctl operator add \
--name alice \
--role administrator \
--cert-file /etc/akamu/alice-client.pem
# ca_operations — automated CA pipeline
akamuctl operator add \
--name ca-pipeline \
--role ca_operations \
--cert-file /etc/akamu/pipeline-client.pem
# ca_ra — scoped RA for the 'rsa' CA
akamuctl operator add \
--name branch-ra \
--role ca_ra \
--ca-id rsa \
--cert-file /etc/akamu/branch-ra.pem
# auditor — read-only monitoring
akamuctl operator add \
--name soc-monitor \
--role auditor \
--cert-file /etc/akamu/soc-monitor.pem
The --cert-file flag accepts a PEM file. akamuctl computes the SHA-256
fingerprint of the DER-encoded leaf certificate locally and sends only the
fingerprint to the server. The private key never leaves the operator’s machine.
Create a GSSAPI/Kerberos operator
akamuctl operator add \
--name bob \
--role auditor \
--gssapi-principal bob@EXAMPLE.COM
Dual-credential operator
An operator can authenticate by either mTLS or GSSAPI by supplying both credentials at creation time:
akamuctl operator add \
--name carol \
--role ca_operations \
--cert-file /etc/akamu/carol-client.pem \
--gssapi-principal carol@EXAMPLE.COM
Change an operator’s role
# Promote an auditor to ca_operations
akamuctl operator update 4 --role ca_operations
# Assign a ca_ra operator to a different CA
akamuctl operator update 5 --role ca_ra --ca-id ec
When a role or CA scope changes, all active sessions for that operator are invalidated immediately.
Choosing a role
Use this guide to decide which role to assign:
| Scenario | Role |
|---|---|
| Full PKI administrator who bootstraps the system and manages operators | administrator |
| CI/CD pipeline that provisions EAB keys for new devices | ca_operations |
| Automated system that revokes certificates across all CAs | ca_operations |
| Branch RA service that views EAB keys and revokes certs for one CA | ca_ra |
| External ACME client (NDC) that registers via a specific CA | ca_ra |
| Security operations center monitoring tool | auditor |
| Compliance dashboard that tracks issuance counts | auditor |
| Human auditor reviewing the audit log for a compliance audit | auditor |
When in doubt, start with auditor and escalate only when a required operation
fails. The server returns 403 Forbidden with a clear error message when a role
is insufficient for a requested endpoint.
CA scope for ca_ra
The ca_id field on a ca_ra operator is a mandatory scope that restricts the
operator to data belonging to a single CA. It must be the ID string of a CA
configured in the server’s [ca.*] sections (for example, "rsa" or "ec").
Why it is required: Without a CA scope, a ca_ra operator would have
server-wide revocation and EAB-issuance authority, which defeats the purpose of
the role. The server enforces this: any ca_ra operator that reaches a
restricted endpoint with an empty ca_id receives 403 Forbidden.
What it controls:
GET /admin/certs: theca_idquery parameter is ignored; only certs from the scoped CA are returned.GET /admin/certs/{id}/download: the server returns404 Not Foundif the certificate belongs to a different CA.POST /admin/revoke:403 Forbiddenif the target certificate belongs to a different CA.GET /admin/accountsandGET /admin/orders: automatically filtered to the scoped CA.
Assigning a CA scope at creation:
akamuctl operator add \
--name branch-ra \
--role ca_ra \
--ca-id rsa \
--cert-file /etc/akamu/branch-ra.pem
Changing the CA scope after creation:
# Move the operator to a different CA
akamuctl operator update 5 --role ca_ra --ca-id ec
The --ca-id flag is only accepted when --role ca_ra is also provided (or
the operator already has the ca_ra role). Supplying --ca-id for any other
role is rejected by the server.
Revoking without a scope: If you need to create a ca_ra operator without
a CA scope initially and assign the scope in a second step, use operator add
followed by operator update:
# Step 1: create with scope (required at creation if role is ca_ra)
akamuctl operator add --name branch-ra --role ca_ra --ca-id rsa \
--cert-file /etc/akamu/branch-ra.pem
# Later: reassign to a different CA
akamuctl operator update 7 --role ca_ra --ca-id ec
There is no way to store a ca_ra operator with an empty ca_id; the server
rejects such a request at POST /admin/operators with 400 Bad Request.
Authentication methods
Operators authenticate to the admin API using one or more of three mechanisms:
-
mTLS client certificate — the operator presents a TLS client certificate during the handshake. The server looks up the SHA-256 fingerprint of the DER-encoded leaf in the
operatorstable. This is the recommended mechanism for automated service accounts. -
GSSAPI/Kerberos — the operator sends an
Authorization: Negotiateheader with a SPNEGO token. The server validates the token and looks up the extracted principal in theoperatorstable. This is the recommended mechanism for human operators in organizations with Kerberos infrastructure. -
Bearer session token — after a successful mTLS or GSSAPI login, the returned token is used for subsequent requests. Session tokens idle-expire after
session_ttl_secs(default one hour).
An operator record can hold both a cert_fingerprint and a gssapi_principal,
allowing the same logical operator to authenticate by either method.
For configuration details and security notes on GSSAPI, see EAB and Kerberos Authentication. For the full authentication protocol including session token expiry and the bounded session store, see Admin API and Operator Management.
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"
crdt_db_url = "sqlite:///var/lib/akamu/crdt.db"
[database]
url = "sqlite:///var/lib/akamu/akamu.db"
[[ca]]
id = "default"
is_default = true
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/ca/crl"
crl_next_update_secs = 86400
ocsp_url = "http://acme.example.com/ca/ocsp"
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = false
# log_number = 1
# tree_minimum_index = 0
# trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"
[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
ari_explanation_url = "https://acme.example.com/docs/renewal-policy"
allow_subdomain_auth = false
account_scope = "server"
star_min_lifetime_secs = 86400
star_max_duration_secs = 31536000
star_allow_certificate_get = true
tor_connectivity_enabled = false
dns_resolver_addr = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"
dns_persist01_resolver_addr = "127.0.0.1:5354"
validate_dnssec = true
trusted_proxies = ["127.0.0.1/32"]
eab_master_secret = "Zm9vYmFyYmF6cXV4cXV1eGZvb2JhcmJhenF1eHF1dXg"
delegation_enabled = false
allow_certificate_get = false
[server.gssapi]
keytab_file = "/etc/akamu/http.keytab" # omit and set gssproxy = true to use gssproxy instead
service_name = "HTTP"
# [server.webui]
# static_dir = "/usr/share/akamu/webui"
[tls.client_auth]
ca_certs = ["/etc/akamu/operator-ca.pem"]
required = false
[admin]
session_ttl_secs = 3600
[email_challenge]
enabled = true
from_address = "acme-validation@example.com"
send_script = "/etc/akamu/send-email.sh"
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"
# [delegation_upstream]
# directory_url = "https://upstream-ca.example.com/acme/directory"
# account_key_file = "/etc/akamu/upstream-acme.key.pem"
# contacts = ["mailto:admin@example.com"]
# challenge_solver = "dns-01"
# challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"
# challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"
# poll_interval_secs = 10
[tkauth]
enabled = false
# trusted_ta_ca_files = ["/etc/akamu/ta-root.pem"]
# token_authority_url = "https://ta.example.com"
# max_validity_secs = 3600
# jti_prune_interval_secs = 3600
# [[tkauth.claim_encoders]]
# claim = "sub"
# encoder = "krb5-kpn"
[gossip]
peers = ["https://node2.example.com", "https://node3.example.com"]
interval_secs = 15
tombstone_ttl_secs = 604800
ownership_ttl_secs = 150
gossip_envelope_max_age_secs = 300
clock_skew_tolerance_secs = 30
fan_out = 3
[profiles]
refresh_interval_secs = 3600
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.tlsserver]
description = "Standard TLS server certificate"
validity_days = 90
key_usage = ["digital_signature", "key_encipherment"]
eku = ["server_auth"]
Top-level keys
listen_addr
Required. The address the server binds to. Two forms are accepted:
| Form | Example | Description |
|---|---|---|
"host:port" | "0.0.0.0:8080" | TCP socket — bind on all interfaces, port 8080 |
"unix:/path" or "/path" | "unix:/run/akamu/akamu.sock" | Unix domain socket at the given filesystem path |
# TCP (bind on all interfaces)
listen_addr = "0.0.0.0:8080"
# TCP (localhost only — behind a reverse proxy on the same host)
listen_addr = "127.0.0.1:8080"
# Unix domain socket (reverse proxy on the same host)
listen_addr = "unix:/run/akamu/akamu.sock"
The AKAMU_LISTEN environment variable overrides this field without touching the config file — useful for socket-activated deployments or overriding the bind address at start time:
AKAMU_LISTEN=unix:/run/akamu/akamu.sock akamu /etc/akamu/config.toml
Constraint: Unix domain sockets and [tls] are mutually exclusive. When listen_addr is a Unix path and [tls].enabled = true, the server exits at startup with an error. TLS termination must be handled by the reverse proxy in front of the socket.
See Running behind a reverse proxy (Unix socket) in the quickstart for nginx, Apache, and systemd socket-activation examples.
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.
crdt_db_url
Optional. Default: absent (uses [database].url).
SQLite connection URL for the separate CRDT database used when multi-node clustering is enabled (see [gossip]). When absent, CRDT tables are created in the main [database] database. A separate database is recommended in production clustering deployments to isolate CRDT write traffic from ACME traffic.
Only SQLite is supported for the CRDT database (sqlite://… URL format). The file and its WAL journal are created automatically if they do not exist.
crdt_db_url = "sqlite:///var/lib/akamu/crdt.db"
[database]
url
Required. Database connection URL. The format depends on the compiled backend:
| Backend | URL format |
|---|---|
| SQLite | sqlite:///absolute/path/to/akamu.db or sqlite::memory: |
| PostgreSQL | postgres://user:pass@host/dbname |
| MariaDB/MySQL | mariadb://user:pass@host/dbname or mysql://user:pass@host/dbname |
For SQLite, the database file and its WAL journal are created automatically if they do not exist.
[database]
url = "sqlite:///var/lib/akamu/akamu.db"
Use sqlite::memory: for an ephemeral in-memory database (useful for testing; all data is lost when the process exits).
max_connections
Optional. Default: 1 for SQLite, 10 for PostgreSQL/MariaDB.
Maximum number of pooled database connections. For SQLite, this must remain 1 to avoid SQLITE_BUSY_SNAPSHOT errors under concurrent writes; the default is correct for production SQLite deployments.
[database]
url = "postgres://akamu:secret@localhost/akamu"
max_connections = 20
require_tls
Optional. Default: false.
When true, the server refuses to start unless the database URL explicitly
enables SSL/TLS encryption:
| Backend | Required URL parameter |
|---|---|
| PostgreSQL | sslmode=require, sslmode=verify-ca, or sslmode=verify-full |
| MariaDB/MySQL | ssl-mode=REQUIRED, ssl-mode=VERIFY_CA, or ssl-mode=VERIFY_IDENTITY |
| SQLite | Ignored (local file, no network transport) |
Set true in production deployments where the database is not co-located
with the server process (FPT_ITT.1).
[database]
url = "postgres://akamu:secret@db.example.com/akamu?sslmode=verify-full"
require_tls = true
[[ca]] / [ca]
Akāmu supports one or more CA instances. Use the TOML array-of-tables
syntax [[ca]] to configure multiple CAs; the legacy single [ca] table
continues to work and is treated as a single CA with id = "default" and
is_default = true.
# Single CA (backward-compatible)
[ca]
key_file = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"
# Multiple CAs
[[ca]]
id = "rsa"
is_default = true
key_file = "/etc/akamu/rsa-ca.key.pem"
cert_file = "/etc/akamu/rsa-ca.cert.pem"
[[ca]]
id = "ec"
key_file = "/etc/akamu/ec-ca.key.pem"
cert_file = "/etc/akamu/ec-ca.cert.pem"
id
Required when multiple [[ca]] entries are present. Optional for a
single [ca] table (defaults to "default").
Unique identifier for this CA. Must be lowercase alphanumeric with _
or - allowed, at most 64 characters. Reserved ACME path segments
(directory, new-nonce, new-account, new-order, new-authz,
revoke-cert, key-change) are rejected at startup.
[[ca]]
id = "rsa"
is_default
Required (as true) for exactly one CA when multiple [[ca]] entries
are configured. Implicit true for the legacy single [ca] table.
The default CA is also served at the backward-compatible /acme/directory
alias (without a CA ID prefix).
[[ca]]
id = "rsa"
is_default = true
caa_identities (per-CA)
Optional. Default: [] (inherit from [server].caa_identities).
Per-CA list of CA domain names for CAA record verification (RFC 8659).
When non-empty, this list overrides [server].caa_identities for orders
processed through this CA’s ACME endpoint. When empty (the default), the
server-level list applies.
[[ca]]
id = "rsa"
caa_identities = ["rsa.acme.example.com", "acme.example.com"]
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 |
"ed448" | Ed448 |
"ml-dsa-44" | Pure ML-DSA-44 (FIPS 204 parameter set 2) — requires OpenSSL 3.5+ |
"ml-dsa-65" | Pure ML-DSA-65 (FIPS 204 parameter set 3) — requires OpenSSL 3.5+ |
"ml-dsa-87" | Pure ML-DSA-87 (FIPS 204 parameter set 5) — requires OpenSSL 3.5+ |
Composite ML-DSA variants (draft-ietf-lamps-pq-composite-sigs-19, sub-arcs 37–54 — requires OpenSSL 3.5+):
| Value | Algorithm | OID sub-arc |
|---|---|---|
"composite-mldsa44-rsa2048-pss-sha256" | ML-DSA-44 + RSA-2048-PSS-SHA-256 | 37 |
"composite-mldsa44-rsa2048-pkcs15-sha256" | ML-DSA-44 + RSA-2048-PKCS#1v1.5-SHA-256 | 38 |
"composite-mldsa44-ed25519-sha512" | ML-DSA-44 + Ed25519-SHA-512 | 39 |
"composite-mldsa44-ecdsa-p256-sha256" | ML-DSA-44 + ECDSA-P256-SHA-256 | 40 |
"composite-mldsa65-rsa3072-pss-sha512" | ML-DSA-65 + RSA-3072-PSS-SHA-512 | 41 |
"composite-mldsa65-rsa3072-pkcs15-sha512" | ML-DSA-65 + RSA-3072-PKCS#1v1.5-SHA-512 | 42 |
"composite-mldsa65-rsa4096-pss-sha512" | ML-DSA-65 + RSA-4096-PSS-SHA-512 | 43 |
"composite-mldsa65-rsa4096-pkcs15-sha512" | ML-DSA-65 + RSA-4096-PKCS#1v1.5-SHA-512 | 44 |
"composite-mldsa65-ecdsa-p256-sha512" | ML-DSA-65 + ECDSA-P256-SHA-512 | 45 |
"composite-mldsa65-ecdsa-p384-sha512" | ML-DSA-65 + ECDSA-P384-SHA-512 | 46 |
"composite-mldsa65-ecdsa-brainpoolp256r1-sha512" | ML-DSA-65 + ECDSA-brainpoolP256r1-SHA-512 | 47 |
"composite-mldsa65-ed25519-sha512" | ML-DSA-65 + Ed25519-SHA-512 | 48 |
"composite-mldsa87-ecdsa-p384-sha512" | ML-DSA-87 + ECDSA-P384-SHA-512 | 49 |
"composite-mldsa87-ecdsa-brainpoolp384r1-sha512" | ML-DSA-87 + ECDSA-brainpoolP384r1-SHA-512 | 50 |
"composite-mldsa87-ed448-shake256" | ML-DSA-87 + Ed448-SHAKE-256 | 51 |
"composite-mldsa87-rsa3072-pss-sha512" | ML-DSA-87 + RSA-3072-PSS-SHA-512 | 52 |
"composite-mldsa87-rsa4096-pss-sha512" | ML-DSA-87 + RSA-4096-PSS-SHA-512 | 53 |
"composite-mldsa87-ecdsa-p521-sha512" | ML-DSA-87 + ECDSA-P521-SHA-512 | 54 |
Each composite variant also accepts the canonical COMPSIG-* label (case-insensitive, with or without the COMPSIG- prefix). For example, sub-arc 40 accepts all three forms: "composite-mldsa44-ecdsa-p256-sha256", "COMPSIG-MLDSA44-ECDSA-P256-SHA256", and "mldsa44-ecdsa-p256-sha256".
hash_algis ignored for composite CA keys. The hash algorithm is fixed by the composite algorithm specification (e.g. SHA-256 for sub-arc 40, SHA-512 for sub-arc 46). Sethash_algto a valid value for any non-composite CA that shares the same config stanza; Akāmu silently ignores it for composite keys. For pure ML-DSA keys,hash_algis also ignored — ML-DSA has no separate hash parameter.
OID stability note: The composite OIDs (sub-arcs 37–54) are defined by draft-ietf-lamps-pq-composite-sigs-19 and are provisional pending IANA allocation. They may change as the draft advances toward RFC publication.
key_type = "ec:P-256"
Example: composite CA key with ML-DSA-65 + ECDSA-P384:
[[ca]]
id = "composite-pq"
key_file = "/etc/akamu/composite-ca.key.pem"
cert_file = "/etc/akamu/composite-ca.cert.pem"
key_type = "composite-mldsa65-ecdsa-p384-sha512"
hash_alg = "sha512" # ignored for composite keys; set for documentation clarity
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: "ACME Server 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: "ACME Server".
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/ca/crl"
Set this to the URL of the built-in /ca/crl endpoint (i.e. {base_url}/ca/crl) to use the server’s built-in CRL endpoint. The endpoint is served by Akāmu and requires no external CRL generation.
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://acme.example.com/ca/ocsp"
Set this to the URL of the built-in /ca/ocsp endpoint (i.e. {base_url}/ca/ocsp) to use the server’s built-in OCSP responder. Both GET and POST OCSP requests are handled at this base URL.
crl_next_update_secs
Optional. Default: 86400 (1 day).
Controls the nextUpdate field in the CRL served at /ca/crl. The nextUpdate is set to the current time plus this many seconds. Adjust to match how frequently clients are expected to re-fetch the CRL.
crl_next_update_secs = 86400 # one day (default)
enforce_validity_cap
Optional. Default: false.
When true, certificate issuance is rejected at the time of signing if the computed validity period exceeds 200 days (the current CA/B Forum BR §6.3.2 hard limit since 2026-03-15). When false (the default), the server only emits a startup warning for validity periods exceeding the limit, allowing private or enterprise PKI deployments to use longer validity periods without chaining to a public root.
Public WebPKI CAs should set this to true to enforce the limit at issuance time rather than relying solely on the startup warning.
[ca]
enforce_validity_cap = true
require_encrypted_key
Optional. Default: false.
When true, the server refuses to load a plaintext (unencrypted) PEM private key
from a file (FCS_STG_EXT.1). Only PKCS#8 encrypted PEM (ENCRYPTED PRIVATE KEY)
or PKCS#11 URIs are accepted. Use key_password_file to supply the decryption
passphrase when using an encrypted PEM file.
[ca]
require_encrypted_key = true
key_password_file = "/etc/akamu/ca-key-passphrase"
key_password_file
Optional. Default: absent.
Path to a file containing the passphrase used to decrypt an encrypted PEM CA
private key. The file is read once at startup; trailing newlines are stripped.
Required when require_encrypted_key = true and key_file points to a
filesystem path (not a PKCS#11 URI).
[ca]
key_password_file = "/etc/akamu/ca-key-passphrase"
[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
MTC standalone certificate format
When issue_as = "mtc" is set in a profile, the server builds a standalone certificate for each issued certificate. The standalone certificate is a standard X.509 v3 Certificate where:
signatureAlgorithmisid-alg-mtcProof(experimental OID1.3.6.1.4.1.44363.47.0from the Cloudflare PEN arc)signatureValuecarries a TLS-encodedMTCProof(inclusion proof + cosignature records); per draft-04 §4.3,MTCProofhas a leadingextensionsfield (uint16 length-prefixed; empty =\x00\x00),start/endas uint48 (6-byte big-endian), and a uint8-prefixedcosigner_idin eachMtcSignatureserialNumberencodes the log entry index per draft §6.1
The GET /acme/mtc/cert/{cert_id}/standalone and GET /acme/mtc/landmarks/{seq}/cert endpoints return the DER-encoded certificate with Content-Type: application/pkix-cert and the X-MTC-Version: draft-04 response header.
OID stability note: The OIDs are experimental and pre-IANA. They will change when draft-ietf-plants-merkle-tree-certs is published as an RFC, requiring a coordinated update of the
synta-mtclibrary and relying implementations. When[mtc]is enabled and[mtc.signing_key]is configured, the auto-generated CA certificate includes theid-pe-mtcCertificationAuthorityextension (experimental OID1.3.6.1.4.1.44363.47.2), identifying the CA as an MTC-capable issuer.
checkpoint_interval_secs
Optional. Default: 3600 (1 hour).
How often the checkpoint background task fires, in seconds. A checkpoint is produced only when the log has grown since the last one; if the tree size has not changed the task is a no-op. Requires [mtc.signing_key] to be configured.
checkpoint_interval_secs = 3600
checkpoint_retention_count
Optional. Default: 1000.
Maximum number of checkpoint rows to retain in the mtc_checkpoints database table. After each new checkpoint is produced, rows beyond this limit are pruned (oldest first). Their associated cosignature rows in mtc_cosignatures are also deleted via the foreign-key ON DELETE CASCADE constraint.
checkpoint_retention_count = 1000
landmark_interval_secs
Optional. Default: 86400 (1 day).
How often the landmark background task fires, in seconds. A new landmark is allocated only when the tree has grown since the last landmark; otherwise the task is a no-op. Requires [mtc.signing_key] to be configured.
landmark_interval_secs = 86400
max_active_landmarks
Optional. Default: 100.
Maximum number of landmark rows to retain in the mtc_landmarks table. After each new landmark is built, rows beyond this limit are pruned (oldest first by sequence number).
max_active_landmarks = 100
hash_alg
Optional. Default: "sha256".
Hash algorithm used for Merkle tree leaf hashing. Valid values:
| Value | Algorithm |
|---|---|
"sha256" | SHA-256 (32-byte leaf hashes) |
"sha384" | SHA-384 (48-byte leaf hashes) |
"sha512" | SHA-512 (64-byte leaf hashes) |
"sha3-256" | SHA3-256 (32-byte leaf hashes) |
"sha3-384" | SHA3-384 (48-byte leaf hashes) |
"sha3-512" | SHA3-512 (64-byte leaf hashes) |
The algorithm is stored in the log file’s header at creation time and cannot be changed for an existing log. If you change hash_alg after the log file already exists, Akāmu will refuse to start with an error identifying the mismatch. To switch algorithms you must delete the log file (and its lock file, if any) and let the server recreate it from scratch.
[mtc]
hash_alg = "sha256"
log_number
Optional. Default: 1.
Log number for serialNumber encoding per draft-04 §6.1. The serial number of each standalone certificate is computed as (log_number << 48) | entry_index. Each CA log should receive a unique, consecutive-from-1 log number.
[mtc]
log_number = 1
tree_minimum_index
Optional. Default: absent.
Minimum valid entry index (§5.2.3 log pruning). When set, the value is included in the Checkpoint.treeMinimumIndex field, indicating that entries below this index may have been pruned from the log. Relying parties should not attempt to verify inclusion proofs for entries below this index.
[mtc]
tree_minimum_index = 100
trust_anchor_id
Optional. Default: absent.
The CA’s own TrustAnchorID OID in dotted-decimal notation. Per draft-04 §5.4, each CA MUST operate a CA cosigner whose cosigner ID is the same as its CA ID. When set, a self-cosignature is produced alongside any external cosignatures during checkpoint production. The signing key configured in [mtc.signing_key] is used for the self-cosignature.
When absent, no self-cosignature is produced.
[mtc]
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"
[mtc.signing_key]
Optional subsection. When present, enables checkpoint production and standalone/landmark certificate construction. The signing key must be distinct from the X.509 CA key (§5.5 of draft-ietf-plants-merkle-tree-certs).
key_file
Required within [mtc.signing_key]. Path to the MTC signing key PEM file. If absent on disk, a new key of key_type is generated and written here on startup.
key_type
Optional. Default: "ec:P-256".
Key algorithm for auto-generation. Accepts the same values as [ca].key_type. Per §5.4.2 of the draft, only ECDSA P-256/P-384, Ed25519, and ML-DSA are valid MTC signing algorithms; prefer EC or EdDSA.
hash_alg
Optional. Default: "sha256".
Hash algorithm used for ECDSA/RSA signing of MTC checkpoints and cosignatures: "sha256", "sha384", "sha512". Ignored for EdDSA and ML-DSA signing key types.
This field controls the signing hash only and is unrelated to the Merkle tree leaf-hash algorithm, which is configured separately via [mtc].hash_alg.
[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"
key_type = "ec:P-256"
hash_alg = "sha256"
[[mtc.cosigners]]
Optional array of external cosigner entries. After each checkpoint is produced, Akāmu POSTs the DER-encoded Checkpoint to each cosigner URL and stores the returned SubtreeSignature. Multiple entries are supported; all cosigners are contacted in parallel.
Each entry has the following fields:
url
Required. URL to POST the DER-encoded Checkpoint to (e.g. https://cosigner.example.com/sign).
cosigner_id_cert_pem
Optional. Path to the cosigner’s X.509 identity certificate PEM file. When set, the file is loaded at startup and added to the TLS trust store for that cosigner’s HTTPS connection, in addition to the system root CAs. This allows cosigners whose TLS certificate chains to an operator-provisioned CA to be used without installing that CA system-wide. The certificate is also used for cryptographic verification of received SubtreeSignature values.
trust_anchor_id
Optional. The expected TrustAnchorID OID of the cosigner in dotted-decimal notation (e.g. "1.3.6.1.4.1.44363.47.10.1"). Per draft-ietf-plants-merkle-tree-certs-04 §4.1, CosignerID is now an OBJECT IDENTIFIER (TrustAnchorID ::= OBJECT IDENTIFIER) rather than a SEQUENCE of hash algorithm and public key. When set, Akāmu verifies that the SubtreeSignature.cosigner OID in each response matches this value. When absent, the OID identity check is skipped; cryptographic verification via cosigner_id_cert_pem still applies when that field is set. Operators must agree on the OID value with their cosigner operator.
Security constraint: Setting
trust_anchor_idwithout also settingcosigner_id_cert_pemis a hard startup error. OID-only verification provides no cryptographic assurance — anyone who knows the OID could forge a cosignature. Both fields must be set together to enable verified cosignature acceptance.
[[mtc.cosigners]]
url = "https://cosigner1.example.com/sign"
cosigner_id_cert_pem = "/etc/akamu/cosigner1-id.pem"
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1" # optional; TrustAnchorID OID
[[mtc.cosigners]]
url = "https://cosigner2.example.com/sign"
[server]
The [server] section is optional. When omitted entirely, all fields take their default values.
audit_log_file
Optional. Default: absent (use systemd journal namespace).
Path to a JSONL (JSON Lines) audit log file. When set, audit events are written as append-only JSON Lines to this file instead of the systemd journal namespace socket. Each line is a JSON object with occurred_at, AKAMU_EVENT_TYPE, AKAMU_OUTCOME, and optional AKAMU_SUBJECT, AKAMU_PRINCIPAL, AKAMU_DETAIL fields.
When absent (the default), the server writes to the akamu journal namespace (see contrib/systemd/journald@akamu.conf). Use this option on systems without systemd or when journal namespace sockets are not available.
External logrotate(8) with copytruncate is expected for log rotation. Each audit query scans at most 500,000 lines to prevent unbounded reads on unrotated files.
[server]
audit_log_file = "/var/log/akamu/audit.jsonl"
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"]
account_scope
Optional. Default: "server".
Controls whether ACME accounts are shared across all CAs or isolated per CA.
| Value | Behaviour |
|---|---|
"server" | One account works with all CAs. This is the default and matches the behavior of single-CA deployments. |
"ca" | Accounts are isolated per CA. An account registered via one CA’s new-account endpoint cannot create orders via a different CA. |
[server]
account_scope = "server"
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 the eab_keys database table (populated either from [server.eab_keys] at startup or by HKDF derivation via GET /acme/eab), 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 loaded at startup and persisted in the database. A key that has been consumed (used to create an account) is never overwritten on a subsequent restart, so spent keys remain invalidated across restarts.
[server.eab_keys]
"kid-1" = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg" # base64url, no padding
"kid-2" = "YW5vdGhlci1rZXktaGVyZQ"
To generate a key:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
eab_master_secret
Optional. Default: absent.
Base64url-encoded master secret (must decode to at least 32 bytes) used to derive deterministic EAB credentials via HKDF-SHA-256 (RFC 5869). When set, the GET /acme/eab endpoint derives a unique (kid, hmac_key) pair for each authenticated principal using the following construction:
kid = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-kid:<principal>", L=16) )
hmac_key = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-key:<principal>", L=32) )
The same (master_secret, principal) pair always produces the same (kid, hmac_key). Credentials are stored in the eab_keys table on first request and returned on subsequent requests. Once the kid has been consumed by an account registration, re-fetching GET /acme/eab for that principal returns HTTP 409 Conflict.
When eab_master_secret is absent, GET /acme/eab returns only {"principal":"…"} (backward-compatible stub behaviour, no EAB credentials).
Authentication for GET /acme/eab requires either [server.gssapi] (standalone GSSAPI/SPNEGO) or trusted_proxies (reverse-proxy mode supplying X-Remote-User).
Generate a suitable secret:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
[server]
external_account_required = true
eab_master_secret = "Zm9vYmFyYmF6cXV4cXV1eGZvb2JhcmJhenF1eHF1dXg"
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, and also to the admin API listener.
max_body_bytes = 65536 # 64 KiB
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
http_validation_allow_private_ips
Optional. Default: false.
When false (the default), the initial connection target and any redirect targets that resolve to private, link-local, or loopback IP addresses (RFC 1918, 169.254.0.0/16, 127.0.0.0/8, fc00::/7, fe80::/10, etc.) are blocked to prevent SSRF attacks against cloud metadata endpoints such as 169.254.169.254. Both IP literals and hostnames are checked: for hostnames, every resolved address must be globally routable.
Set to true only in isolated test environments where the http-01 challenge responder intentionally runs on a private address.
http_validation_allow_private_ips = false # default — recommended for all production deployments
dns_persist_issuer_domains
Optional. Default: absent (dns-persist-01 disabled).
The issuer domain(s) 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.
Accepts either a single string or an array of strings. Multi-tenant or multi-identity deployments can list all accepted issuer domains; validation succeeds when the TXT record’s issuer domain matches any of the configured values.
See dns-persist-01 Challenge for the full description of the challenge type and TXT record format.
# Single domain
dns_persist_issuer_domains = "acme.example.com"
# Multiple domains (multi-tenant or multi-identity deployments)
dns_persist_issuer_domains = ["acme.example.com", "acme.example.org"]
dns_resolver_addr
Optional. Default: absent (system resolver).
Override the DNS resolver used for dns-01, dns-persist-01, and CAA record lookups. 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"
dns_persist01_resolver_addr
Optional. Default: absent (falls back to dns_resolver_addr).
Resolver override used exclusively for dns-persist-01 TXT lookups at _validation-persist.*. When set, this address is used instead of dns_resolver_addr for dns-persist-01 validation only. Useful when persistent TXT records are served by a different DNS infrastructure than the one used for dns-01 and CAA lookups.
dns_resolver_addr = "127.0.0.1:5353" # used for dns-01 and CAA
dns_persist01_resolver_addr = "127.0.0.1:5354" # used only for dns-persist-01
dns_dot_server_name
Optional. Default: absent (plain UDP).
TLS server name (SNI hostname) for DNS-over-TLS (DoT, RFC 7858). When set, all DNS challenge validation queries — dns-01, dns-persist-01, and CAA record lookups — are sent over TLS to the resolver specified by dns_resolver_addr instead of plain UDP.
Use DoT when the network path between the Akāmu server and its resolver is untrusted: for example, when the resolver is a public DNS provider reached over the Internet, when the operator wants to prevent on-path DNS hijacking by an ISP, or to satisfy privacy requirements that prohibit cleartext DNS queries.
dns_resolver_addr must be set to the DoT server’s IP address and port 853 when this field is present. The TLS certificate presented by the resolver is verified against the system root CA store (system OpenSSL). LDAP SRV lookups for certificate profile providers are unaffected — they always use plain UDP.
DoT and DNSSEC validation (validate_dnssec = true) are independent and can be enabled at the same time. DoT protects the query transport channel; DNSSEC authenticates the DNS response data itself.
# DoT only — encrypt queries to Cloudflare's resolver
dns_resolver_addr = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"
# DoT + DNSSEC — encrypted transport and cryptographic response validation
dns_resolver_addr = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"
validate_dnssec = true
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
ari_explanation_url
Optional. Default: absent.
URL included in GET /acme/renewal-info/{cert-id} responses as the explanationURL field (RFC 9773 §4.1). When set, it points clients to a human-readable page explaining why early renewal is being suggested (for example, an incident notice or CA policy update). When absent, the field is omitted from the response entirely.
ari_explanation_url = "https://acme.example.com/docs/renewal-policy"
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
delegation_enabled
Optional. Default: false.
When true, Akāmu activates the RFC 9115 delegation API surface:
- The directory
metaobject includes"delegation-enabled": true. - Every account response includes a
"delegations"URL. - The delegation listing and fetch endpoints become active:
POST /acme/delegations/{account_id}— list delegations for an account (POST-as-GET).POST /acme/delegation/{id}— fetch a single delegation object (POST-as-GET).
POST /acme/new-orderaccepts the"delegation"field to link an order to a delegation.- Delegation orders start in
readystatus and return"authorizations": [].
Delegations themselves are managed via the Admin API (/admin/delegations). The [admin] section must be configured before delegations can be created.
[server]
delegation_enabled = true
allow_certificate_get
Optional. Default: false.
When true, the directory meta object includes "allow-certificate-get": true for RFC 9115 delegation orders (distinct from the star_allow_certificate_get flag, which covers RFC 8739 STAR certificates). When an order is placed with "allow-certificate-get": true in the new-order payload, the certificate endpoint for that order can be fetched with an unauthenticated GET rather than a POST-as-GET. The capability is advertised in the directory only when this flag is true.
[server]
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
validate_dnssec
Optional. Default: true.
Controls whether DNSSEC validation is enforced during DNS-based challenge verification (dns-01, dns-persist-01) and CAA record lookups. CA/B Forum BR §3.2.2.4 and §3.2.2.8.1 require DNSSEC validation for publicly trusted CAs as of 2026-03-15. Set to false only for testing environments or deployments where the DNS infrastructure is not yet DNSSEC-signed; doing so makes the CA non-compliant.
validate_dnssec = true
trusted_proxies
Optional. Default: empty (proxy header mode disabled).
List of CIDR blocks (IPv4 or IPv6) whose connecting IP address is trusted to
supply an X-Remote-User header. When a request arrives from one of these
addresses, akamu reads the header value as the already-authenticated principal
name — the reverse proxy is expected to have completed SPNEGO or another
authentication step before forwarding the request.
Requests from addresses not in this list never have X-Remote-User honoured,
regardless of what the header contains.
Mutually exclusive with [server.gssapi]. Setting both trusted_proxies
and [server.gssapi] at the same time is a configuration error; the server
exits at startup with an error message.
[server]
trusted_proxies = ["127.0.0.1/32", "::1/128", "10.0.0.0/8"]
Security note: keep this list tightly scoped to the IP addresses of your
reverse proxy or load balancer. Adding broad ranges (e.g. 0.0.0.0/0) allows
any client to impersonate any principal.
[server.gssapi]
Optional. When absent, standalone GSSAPI mode is disabled.
Mutually exclusive with trusted_proxies. Setting both at the same time is
a configuration error; the server exits at startup with an error message.
Configures akamu to accept Authorization: Negotiate tokens directly, without
a reverse proxy. At startup the server acquires a combined initiator+acceptor
credential (GSS_C_BOTH) using gss_acquire_cred_from and then uses
gss_accept_sec_context to validate each SPNEGO token. The GSS_C_BOTH usage
flag is required for S4U2Self constrained delegation (used internally for LDAP
profile lookups). Credentials are acquired either directly from keytab_file
or, when gssproxy = true, via the gssproxy
daemon (which intercepts the underlying GSSAPI call and supplies credentials
from its own keytab configuration).
Use this mode when you want akamu to handle Kerberos authentication itself rather than delegating to a front-end proxy such as Apache or Nginx.
Security behaviors in standalone GSSAPI mode:
- Token size limit.
Authorization: Negotiatetokens larger than 128 KiB are rejected with400 Bad Request. Legitimate Kerberos tickets are always smaller than this limit. - Case-insensitive scheme matching. The
"Negotiate "prefix is matched case-insensitively per RFC 7235 §2.1. - TLS channel bindings (RFC 5929). When akamu terminates TLS itself, the
tls-server-end-pointbinding is computed from the leaf certificate and passed togss_accept_sec_context, binding the Kerberos exchange to the TLS channel. Channel bindings are disabled automatically when the server certificate uses ML-DSA (pure or composite) or Ed448, because RFC 5929 defines no canonical hash for those algorithms. - Replay detection. After a successful context acceptance, akamu checks
whether
GSS_C_REPLAY_FLAGis set. When the flag is absent (common when clients connect over TLS, because TLS already provides replay protection), adebug-level log entry is emitted and the authentication proceeds normally. This behaviour is intentional: browsers and TLS-first clients typically do not negotiate Kerberos-level replay protection. - GSSAPI without TLS. Running standalone GSSAPI without TLS is permitted
but emits a
warn-level log at startup: SPNEGO tokens are vulnerable to interception and relay attacks without TLS. - No mechanism configured. When neither
trusted_proxiesnor[server.gssapi]is set, authenticated endpoints return404 Not Found.
keytab_file
Required when gssproxy = false (the default). Must be absent when gssproxy = true.
Path to the HTTP service keytab file. The akamu process must be able to read
this file; no other user should have read access to it. The path is logged at
debug level only. Setting both keytab_file and gssproxy = true is a
configuration error; the server exits at startup.
keytab_file = "/etc/akamu/http.keytab"
Generate the keytab for an IPA-managed host:
ipa-getkeytab -s ipa.example.com -p HTTP/akamu.example.com@EXAMPLE.COM \
-k /etc/akamu/http.keytab
chmod 600 /etc/akamu/http.keytab
chown akamu: /etc/akamu/http.keytab
gssproxy
Optional. Default: false.
When true, GSSAPI credential acquisition is delegated to the
gssproxy daemon instead of reading a
keytab file directly. The akamu process must have a matching entry in
/etc/gssproxy/conf.d/ (typically matched by UID). The server sets
GSS_USE_PROXY=yes in its environment before the first GSSAPI call so that the
GSSAPI library routes the credential request through gssproxy. No direct access
to a keytab file on disk is needed. keytab_file must be absent when this is
true.
# gssproxy mode — no keytab access required for the akamu process
[server.gssapi]
gssproxy = true
service_name = "HTTP"
service_name
Optional. Default: "HTTP".
Host-based service name to acquire credentials for. MIT Kerberos appends
@<local-hostname> when no realm is specified, so "HTTP" is correct for a
single-homed host. Use "HTTP@akamu.example.com" to be explicit.
service_name = "HTTP"
Proxy mode example
[server]
trusted_proxies = ["192.168.1.10/32"]
In this configuration, only connections from 192.168.1.10 (the reverse proxy)
are allowed to supply X-Remote-User. Requests from any other source that reach
an authenticated endpoint return 404 Not Found (no authentication mechanism
is configured for those connections).
Standalone GSSAPI examples
Keytab mode — akamu reads the keytab file directly:
[server.gssapi]
keytab_file = "/etc/akamu/http.keytab"
service_name = "HTTP"
gssproxy mode — akamu delegates credential acquisition to gssproxy (no direct keytab access required for the akamu process):
[server.gssapi]
gssproxy = true
service_name = "HTTP"
In both configurations, akamu handles Authorization: Negotiate directly.
Clients must obtain a Kerberos service ticket for HTTP/<hostname> before
calling authenticated endpoints.
[server.webui]
Optional. When absent, the /ui/ routes are not registered and return 404.
When present, Akāmu serves the built PatternFly management web UI from a
directory of static files. The UI is mounted at /ui/* on the same listener
as the ACME and admin APIs; no separate process or proxy is required.
When [server.webui] is absent (the default), no /ui/* routes are
registered at all. Requests to /ui/ and GET / receive 404 responses as
if the routes did not exist.
When [server.webui] is present:
GET /ui/*serves static files fromstatic_dir.- Directory requests fall back to
index.htmlinside that directory (SPA routing support). GET /permanently redirects to/ui/.- Security headers (
Content-Security-Policy,X-Frame-Options, etc.) are added to every/ui/*response.
The admin API (/admin/*) is served on the same listener and is called
directly by the browser — no additional proxy is needed.
static_dir
Optional. Default: absent (web UI disabled).
Absolute path to the directory containing the built web UI files. The
directory must contain at minimum an index.html file.
| Deployment | Typical path |
|---|---|
| Fedora / RHEL package | /usr/share/akamu/webui |
| Source build | webui/dist/ (relative to the repository root, absolute path required) |
When static_dir is absent but [server.webui] is present, the section is
accepted without error and no routes are registered — the behavior is identical
to omitting [server.webui] entirely. Set static_dir to actually serve the UI.
Config validation rejects a relative path with a startup error.
[server.webui]
static_dir = "/usr/share/akamu/webui"
Source build example (absolute path required):
[server.webui]
static_dir = "/home/user/akamu/webui/dist"
[tls]
The [tls] section enables Akāmu to terminate TLS directly on the main listen_addr socket, without a reverse proxy. When this section is absent or enabled = false, the server operates over plain HTTP.
[tls]
enabled = true
cert_file = "/etc/akamu/server.pem"
key_file = "/etc/akamu/server-key.pem"
protocols = ["TLSv1.2", "TLSv1.3"]
[tls.client_auth]
required = false
ca_files = ["/etc/akamu/client-ca.pem"]
profile = "webpki"
allow_post_quantum = false
max_chain_depth = 8
minimum_rsa_modulus = 2048
enabled
Optional. Default: false.
When true, the server listens with TLS on listen_addr. When false, the socket accepts plain HTTP connections.
[tls]
enabled = true
cert_file
Optional. Path to the PEM file containing the server TLS certificate chain (leaf certificate first). When this file is absent on disk and key_file is also absent, Akāmu generates a self-signed certificate on first run using bootstrap_key_type and server_name.
cert_file = "/etc/akamu/server.pem"
key_file
Optional. Path to the PEM file containing the server TLS private key (PKCS#8 or SEC1, unencrypted). Same auto-generation rules as cert_file.
key_file = "/etc/akamu/server-key.pem"
protocols
Optional. Default: ["TLSv1.2", "TLSv1.3"].
List of TLS protocol versions the server accepts. Both TLS 1.2 and TLS 1.3 are enabled by default.
protocols = ["TLSv1.2", "TLSv1.3"]
server_name
Optional. Default: "localhost".
Hostname placed in the CN and SAN of the auto-generated server certificate. Only used when cert_file and key_file are absent on disk.
server_name = "acme.example.com"
bootstrap_key_type
Optional. Default: "ec:P-256".
Key algorithm for the auto-generated server certificate. Only used when cert_file and key_file are absent on disk. Accepts the same values as [ca].key_type.
bootstrap_key_type = "ec:P-256"
[tls.client_auth]
Optional. When absent, client certificate authentication is disabled.
Configures mutual TLS (mTLS) client certificate authentication. When present, the server requests a client certificate during the TLS handshake.
required
Optional. Default: false.
When true, connections that present no client certificate are rejected. When false, client certificates are optional — presented certificates are still verified if provided.
required = true
ca_files
Required within [tls.client_auth]. List of PEM files containing the trusted CA certificates used to verify client certificates.
ca_files = ["/etc/akamu/client-ca.pem"]
profile
Optional. Default: "webpki".
Certificate validation profile. Accepted values:
| Value | Behaviour |
|---|---|
"webpki" | CA/Browser Forum profile — enforces WebPKI policy rules (default). |
"rfc5280" | RFC 5280 profile — less restrictive, accepts private or enterprise PKI chains. |
profile = "webpki"
allow_post_quantum
Optional. Default: false.
When true, ML-DSA and hybrid composite post-quantum signature algorithms (draft-ietf-lamps-pq-composite-sigs) are accepted in client certificates and CertificateVerify messages. When false, only classical algorithms are accepted.
allow_post_quantum = false
max_chain_depth
Optional. Default: 8.
Maximum certificate chain depth accepted for client certificates. Chains longer than this value are rejected.
max_chain_depth = 8
minimum_rsa_modulus
Optional. Default: 2048.
Minimum RSA modulus size in bits for RSA client certificates. Connections presenting an RSA certificate with a smaller key are rejected.
minimum_rsa_modulus = 2048
[email_challenge]
The [email_challenge] section enables the RFC 8823 email-reply-00 challenge type for S/MIME certificate issuance. When this section is absent or enabled = false, the server does not offer email-reply-00 challenges and rejects orders with email identifier types.
See email-reply-00 in the Challenges reference for the full protocol description, CSR requirements, and webhook payload format.
[email_challenge]
enabled = true
from_address = "acme-validation@example.com"
send_script = "/etc/akamu/send-email.sh"
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"
enabled
Optional. Default: false.
Set to true to activate the email-reply-00 challenge type. When false, any POST to /acme/email-webhook returns 503 Service Unavailable.
from_address
Required when enabled = true. The email address the server sends challenge emails from. This value is returned to clients in the from field of the challenge object and passed to the send script as $ACME_FROM.
from_address = "acme-validation@example.com"
The domain portion of this address is used to construct the Message-ID header (<uuid@from-domain>).
send_script
Required when enabled = true. Absolute path to an executable that sends the challenge email. The server invokes it with no arguments; all parameters are passed as environment variables:
| Variable | Value |
|---|---|
ACME_TO | Recipient email address (the identifier value) |
ACME_FROM | Sender address (equals from_address) |
ACME_SUBJECT | ACME: <base64url(token-part1)> |
ACME_MESSAGE_ID | Server-generated Message-ID — the script must use this exactly in the outbound Message-ID header |
ACME_AUTO_SUBMITTED | auto-generated; type=acme |
ACME_TOKEN_PART2 | token-part2 (base64url); the ACME challenge token field value, exposed for logging or advanced script use |
Exit code 0 = success. Any non-zero exit code marks the challenge invalid and the client may retry.
The script is responsible for DKIM signing of the outbound email. Akāmu does not perform SMTP or DKIM internally.
send_script = "/etc/akamu/send-email.sh"
send_script_timeout_secs
Optional. Default: 30.
Maximum time in seconds the server waits for send_script to exit. If the script does not exit within this limit, the server kills it and marks the challenge invalid. Must be at least 1. Increase this if your mail transfer agent has a slow startup or must authenticate to a relay.
send_script_timeout_secs = 30
webhook_hmac_secret
Required when enabled = true. A shared secret used to authenticate POST /acme/email-webhook requests. The caller must include the header:
X-Akamu-Signature: sha256=<lowercase-hex(HMAC-SHA256(raw-body, webhook_hmac_secret))>
Choose a long random value (≥256 bits recommended). Requests with a missing, malformed, or incorrect signature are rejected with 403 Forbidden.
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"
Keep this value secret. Anyone who knows it can submit webhook payloads and influence challenge outcomes.
[delegation_upstream]
The [delegation_upstream] section configures Akāmu to act as an ACME client toward an upstream CA when processing RFC 9115 delegation orders. When this section is present, a background task polls delegation orders in processing status and drives them through the full ACME flow on the upstream CA: account registration (if needed), order creation, dns-01 challenge deployment, finalization, and certificate retrieval.
When this section is absent, Akāmu operates only as an IdO ACME server — it issues delegation orders but does not drive an upstream CA leg. The background task is not started.
[delegation_upstream]
directory_url = "https://upstream-ca.example.com/acme/directory"
account_key_file = "/etc/akamu/upstream-acme.key.pem"
contacts = ["mailto:admin@example.com"]
challenge_solver = "dns-01"
challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"
# challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"
# poll_interval_secs = 10
directory_url
Required within [delegation_upstream]. ACME directory URL of the upstream CA. Akāmu fetches the directory at startup to discover the upstream CA’s endpoint URLs.
directory_url = "https://upstream-ca.example.com/acme/directory"
account_key_file
Required within [delegation_upstream]. Path to a PEM file containing the ACME account key used when registering with the upstream CA. The file is loaded at startup. If the key file is absent on disk, a new EC P-256 key is generated and written to this path on first run.
account_key_file = "/etc/akamu/upstream-acme.key.pem"
contacts
Optional. Default: [].
List of contact URIs (e.g. mailto: addresses) submitted to the upstream CA when registering the ACME account. Omit if the upstream CA does not require contacts.
contacts = ["mailto:admin@example.com"]
challenge_solver
Required within [delegation_upstream]. Challenge type used to satisfy the upstream CA’s authorizations. Only "dns-01" is currently supported.
challenge_solver = "dns-01"
challenge_deploy_script
Required within [delegation_upstream]. Absolute path to an executable that deploys the dns-01 TXT record at the upstream CA’s direction. The script is invoked with env_clear(); only the following environment variables are set:
| Variable | Value |
|---|---|
CERTBOT_DOMAIN | The domain name being validated (e.g. _acme-challenge.example.com) |
CERTBOT_VALIDATION | The TXT record value to publish |
Exit code 0 = record deployed successfully. Any non-zero exit code marks the challenge attempt as failed.
challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"
The cleanup script is called only after the authorization has transitioned to valid at the upstream CA — not immediately after the deploy script exits. This ensures the TXT record remains queryable for the full upstream validation window.
challenge_cleanup_script
Optional. Default: absent (no cleanup).
Absolute path to an optional cleanup executable invoked after the upstream authorization has become valid. Receives the same CERTBOT_DOMAIN and CERTBOT_VALIDATION variables as the deploy script, plus CERTBOT_AUTH_OUTPUT="". Use it to remove the TXT record from DNS.
challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"
poll_interval_secs
Optional. Default: 10.
How often the background task polls the upstream CA for order and authorization status, in seconds.
poll_interval_secs = 10
[tkauth]
The [tkauth] section enables the RFC 9447 tkauth-01 challenge type for TNAuthList (RFC 9448) and JWTClaimConstraints (draft-ietf-acme-authority-token-jwtclaimcon) identifier types. When this section is absent or enabled = false, orders containing these identifier types are rejected.
See tkauth-01 in the Challenges reference for the full protocol description including the authority token format, validation steps, and claim encoder configuration.
[tkauth]
enabled = true
trusted_ta_ca_files = ["/etc/akamu/ta-root.pem"]
token_authority_url = "https://ta.example.com" # optional hint
max_validity_secs = 3600
jti_prune_interval_secs = 3600
[[tkauth.claim_encoders]]
claim = "sub"
encoder = "krb5-kpn"
enabled
Optional. Default: false.
Set to true to activate the tkauth-01 challenge type. When false, any order with TNAuthList or JWTClaimConstraints identifiers is rejected with unsupportedIdentifier.
trusted_ta_ca_files
Required when enabled = true. List of absolute paths to PEM files containing trusted CA certificates for Token Authority signing certificate validation. The signing certificate presented in the authority token (via x5u or x5c) must chain to one of these CA roots. Not used for kid-signed tokens (which rely on trust_jwks_urls in per-profile configuration instead).
trusted_ta_ca_files = ["/etc/akamu/ta-root.pem"]
token_authority_url
Optional. Default: absent.
URL hint included in tkauth-01 challenge responses as the token-authority field. When set, ACME clients that read this field can use it to discover where to obtain an authority token. This is informational only; the server does not contact this URL itself.
token_authority_url = "https://ta.example.com"
max_validity_secs
Optional. Default: 3600 (1 hour).
Maximum accepted lifetime of an authority token: tokens with exp − now > max_validity_secs are rejected. This caps how long into the future a Token Authority may pre-issue tokens, limiting the window in which a stolen token could be replayed.
max_validity_secs = 3600
jti_prune_interval_secs
Optional. Default: 3600 (1 hour).
How often the background task purges expired JTI (JWT ID) entries from the replay-prevention cache in the database. Lower values reduce database growth at the cost of more frequent pruning queries. The background task only runs when [tkauth] is enabled.
jti_prune_interval_secs = 3600
Operators can also trigger manual pruning via the admin API or akamuctl:
POST /admin/tkauth/prune-jti
POST /admin/tkauth/prune-jti?dry_run=true
akamuctl tkauth prune-jti
akamuctl tkauth prune-jti --dry-run # count without deleting
[[tkauth.claim_encoders]]
Optional array of claim-to-extension encoder entries. Each entry maps a JWT claim name in permittedValues of the validated JWTClaimConstraints token to a built-in certificate extension encoder. The encoder runs at finalize time and injects the claim value as a Subject Alternative Name in the issued certificate.
[[tkauth.claim_encoders]]
claim = "sub"
encoder = "krb5-kpn"
# default_realm = "EXAMPLE.COM" # appended when claim value has no '@'
[[tkauth.claim_encoders]]
claim = "upn"
encoder = "ms-upn"
[[tkauth.claim_encoders]]
claim = "dns"
encoder = "dns-san"
Built-in encoder names:
| Encoder | SAN type | Notes |
|---|---|---|
krb5-kpn | OtherName (id-pkinit-san, OID 1.3.6.1.5.2.2) | principal@REALM; if @ is absent, default_realm is appended |
ms-upn | OtherName (OID 1.3.6.1.4.1.311.20.2.3) | user@domain |
dns-san | dNSName | Plain hostname; wildcards rejected; lowercased before injection |
Each encoder entry has these fields:
| Field | Required | Description |
|---|---|---|
claim | yes | JWT claim name in the authority token’s permittedValues |
encoder | yes | Encoder name: "krb5-kpn", "ms-upn", or "dns-san" |
default_realm | no | Kerberos realm appended when the claim value contains no @. Only meaningful for "krb5-kpn". |
A permittedValues entry with exactly one value is injected as a SAN using the matching encoder. Entries with multiple permitted values are skipped (the server cannot determine which specific value to attest).
[gossip]
The [gossip] section enables multi-node clustering via CRDT replication. When present, Akāmu gossips CRDT deltas to the listed peer nodes over HTTP (POST /gossip/sync). All domain state — accounts, orders, authorizations, challenges, certificates, EAB keys, operators, delegations, and MTC data — is replicated to every cluster member. When absent, the node operates in single-node mode with no replication.
Gossip envelopes are signed with ML-KEM-768 + ECDSA-P256 to authenticate the source. Before gossip can proceed between two nodes, each node’s keys must be registered on the other node via POST /admin/gossip/register.
[gossip]
peers = ["https://node2.example.com", "https://node3.example.com"]
interval_secs = 15
tombstone_ttl_secs = 604800
ownership_ttl_secs = 150
gossip_envelope_max_age_secs = 300
clock_skew_tolerance_secs = 30
fan_out = 3
peers
Optional. Default: [].
List of peer gossip URLs to push CRDT state to. Each entry must be the HTTPS base URL of a peer Akāmu node (scheme, host, and optional port; no trailing path). Peers are contacted each gossip round; the fan_out setting limits how many are contacted per round.
peers = ["https://node2.acme.internal:8443", "https://node3.acme.internal:8443"]
interval_secs
Optional. Default: 15.
How often (in seconds) the background gossip loop fires and pushes CRDT deltas to peers. Lower values reduce replication lag at the cost of more network traffic.
interval_secs = 15
tombstone_ttl_secs
Optional. Default: 604800 (7 days).
How long tombstone records are retained in the CRDT before they are garbage-collected. Tombstones must be retained long enough to ensure every peer in the cluster has received the deletion before the record is purged.
tombstone_ttl_secs = 604800
ownership_ttl_secs
Optional. Default: 150.
Lease duration in seconds for write-ownership of orders and MTC entries. Each node refreshes its ownership lease every gossip round. When a lease expires (the owning node has been silent for ownership_ttl_secs), another node may take over.
ownership_ttl_secs = 150
gossip_envelope_max_age_secs
Optional. Default: 300 (5 minutes).
Maximum age in seconds of a gossip envelope. Envelopes timestamped more than this many seconds in the past are rejected as potential replays.
gossip_envelope_max_age_secs = 300
clock_skew_tolerance_secs
Optional. Default: 30.
Maximum acceptable clock difference between cluster nodes in seconds. Gossip envelopes timestamped more than this many seconds in the future are rejected. Ensure NTP synchronisation across all cluster members keeps skew well below this threshold.
clock_skew_tolerance_secs = 30
fan_out
Optional. Default: 0 (contact all peers).
Maximum number of peers contacted per gossip round. When set to a positive integer, only that many peers are selected at random each round; this reduces O(N²) gossip overhead in clusters larger than roughly five nodes while convergence still occurs transitively in O(log_k(N)) rounds. Set to 0 to contact all configured peers every round.
fan_out = 3 # recommended for clusters of 5+ nodes
[profiles]
The [profiles] section configures the certificate profile subsystem. Profiles are loaded from one or more providers at startup, cached in memory, and refreshed periodically by a background task. Akāmu’s own CA always signs; profiles only control which extensions are included and with what values. When no providers are configured, every order falls back to CA defaults (digitalSignature KeyUsage, serverAuth EKU, and the [ca] validity/URL settings).
When at least one provider is configured and a newOrder request omits the profile field, the server checks whether a profile named "default" exists in the registry. If it does, "default" is applied automatically and echoed back in the order response. If no "default" profile exists, the order falls back to the CA’s built-in defaults.
See Certificate Profiles for the complete reference including all provider types, key usage names, EKU OIDs, and three-state URL semantics.
refresh_interval_secs
Optional. Default: 3600 (1 hour).
How often the background task re-reads profiles from all providers. Set to 0 to disable automatic refresh (profiles are loaded once at startup and never refreshed).
[profiles]
refresh_interval_secs = 1800 # refresh every 30 minutes
[profiles.providers.<name>]
Each key under [profiles.providers] names a provider. The required type field selects the backend:
type | Source |
|---|---|
"builtin" | Inline TOML profile declarations in config.toml |
"dogtag" | Dogtag PKI .cfg files — filesystem or LDAP (simple bind or GSSAPI/Kerberos) |
"ipa" | FreeIPA/IPAThinCA — filesystem or LDAP (simple bind or GSSAPI/Kerberos) |
# Builtin provider: inline declarations
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.tlsserver]
description = "Standard TLS server certificate"
validity_days = 90
key_usage = ["digital_signature", "key_encipherment"]
eku = ["server_auth"]
# Dogtag provider: load .cfg files from a directory
[profiles.providers.dogtag_prod]
type = "dogtag"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles = ["caServerCert"] # empty = all .cfg files
# Dogtag provider: load profiles from LDAP (simple bind, single server)
# Setting tls_ca_cert_file triggers STARTTLS automatically on ldap:// URIs.
[profiles.providers.dogtag_ldap]
type = "dogtag"
profiles = ["caServerCert"]
[profiles.providers.dogtag_ldap.ldap]
uri = "ldap://dogtag.example.com:389"
base_dn = "dc=example,dc=com"
bind_dn = "uid=admin,ou=people,dc=example,dc=com"
bind_password_file = "/etc/akamu/ldap-password"
tls_ca_cert_file = "/etc/ssl/certs/dogtag-ldap-ca.pem" # triggers STARTTLS
# Dogtag provider: multiple servers for failover (GSSAPI)
[profiles.providers.dogtag_ha]
type = "dogtag"
profiles = ["caServerCert"]
[profiles.providers.dogtag_ha.ldap]
uris = ["ldap://dogtag1.example.com:389", "ldap://dogtag2.example.com:389"]
base_dn = "dc=example,dc=com"
gssapi = true
# IPA provider: filesystem fallback
[profiles.providers.ipa_prod]
type = "ipa"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles = ["caIPAserviceCert"]
# IPA provider: SRV-based discovery with GSSAPI
[profiles.providers.ipa_ldap]
type = "ipa"
profiles = ["caIPAserviceCert"]
[profiles.providers.ipa_ldap.ldap]
srv_domain = "example.com" # resolves _ldap._tcp.example.com SRV records
base_dn = "o=ipaca"
gssapi = true
[ldap] sub-table fields (applies to both dogtag and ipa providers)
Server selection — at least one of the following is required
| Key | Type | Default | Description |
|---|---|---|---|
uri | string | absent | Single LDAP URI (ldap://host:port or ldaps://host:636). Kept for backward compatibility; use uris when listing multiple servers explicitly. |
uris | array of strings | [] | Ordered list of LDAP URIs tried in turn for failover. All URIs are passed to ldap_initialize as a space-separated string. |
srv_domain | string | absent | DNS domain for SRV discovery. Resolves _ldap._tcp.{srv_domain} SRV records; discovered servers are sorted by RFC 2782 priority/weight and appended after any explicit uris. |
Explicit servers (uri / uris) are always tried before SRV-discovered servers. An error is returned at startup if none of the three keys is set.
Search parameters — required
| Key | Type | Description |
|---|---|---|
base_dn | string | Base DN for the profile search. Dogtag: directory root suffix (e.g. dc=example,dc=com). IPA: o=ipaca. |
Authentication — choose one method
| Key | Type | Default | Description |
|---|---|---|---|
bind_dn | string | absent | Bind DN for LDAP simple bind. Required when using simple authentication. |
bind_password_file | string | absent | Path to a file containing the simple bind password (trailing newline is stripped). Required when bind_dn is set. |
gssapi | boolean | false | Use SASL GSSAPI (Kerberos) authentication. Pre-condition: the process must hold a valid Kerberos TGT in its credential cache. Mutually exclusive with bind_dn / bind_password_file. |
TLS
| Key | Type | Default | Description |
|---|---|---|---|
tls_ca_cert_file | string | absent | Path to a PEM CA certificate used to verify the LDAP server’s TLS certificate. When this is set on an ldap:// URI, STARTTLS is negotiated automatically before any credentials are sent. When set on an ldaps:// URI, the CA is used for the immediate TLS handshake. When absent, the system trust store is used. |
Additional builtin profile fields
Beyond the core extension fields, each builtin profile supports four groups of optional settings:
Multi-CA restriction
| Key | Default | Description |
|---|---|---|
ca_ids | [] | List of CA IDs for which this profile is available. When non-empty, the profile is only accessible via the named CAs’ ACME endpoints; requests via other CAs receive invalidProfile. Empty = available via all CAs. Config validation rejects entries not matching a configured CA id. |
Certificate format
| Key | Default | Description |
|---|---|---|
issue_as | absent / "x509" | Set to "mtc" to issue a Merkle Tree Certificate standalone certificate instead of a PEM chain. Requires [mtc] to be enabled. The standalone certificate is a standard X.509 v3 Certificate where signatureAlgorithm is id-alg-mtcProof (OID 1.3.6.1.4.1.44363.47.0, experimental pre-IANA) and signatureValue carries a TLS-encoded MTCProof. Per draft-04 §4.3, MTCProof contains a leading extensions field (uint16 length-prefixed; empty = \x00\x00), start/end as uint48 (6-byte big-endian), and a uint8-prefixed cosigner_id in each MtcSignature. The OID will change when the draft is published as an RFC. |
Per-profile authorization
| Key | Default | Description |
|---|---|---|
allowed_identifiers | [] | List of regex patterns. Each order identifier is matched as "type:value" (e.g. "dns:example.com"). Empty = no restriction. |
identifier_match | "all" | "all": every identifier must match a pattern. "any": at least one identifier must match. Ignored when allowed_identifiers is empty. |
auth_hook | absent | Path to an external executable. Receives JSON on stdin; exit 0 = permit, non-zero = deny. |
auth_hook_timeout_secs | 30 | Seconds to wait for the hook before denying. |
require_account_grant | false | When true, the account must have this profile’s name in its profile_grants attribute (set via the Admin API or inherited from its EAB key). |
tkauth-01 JWKS trust
| Key | Default | Description |
|---|---|---|
trust_jwks_urls | [] | List of HTTPS or http+unix:// URLs of JWKS endpoints trusted for kid-signed authority tokens (RFC 9447 tkauth-01). Only meaningful when [tkauth] is enabled. When empty, kid-signed tokens are rejected for this profile. |
The http+unix:// form allows co-located Token Authorities (for example, an Ekishib IdP on the same host) to be reached without a network port. Encode the socket path with / as %2F:
[profiles.providers.local.profiles.kerberos-svc]
description = "Kerberos service certificate"
ca_ids = ["kerberos-ca"]
trust_jwks_urls = [
"https://idp.example.com/jwks",
"http+unix://%2Frun%2Fekishib%2Fekishib.sock/jwks",
]
JWKS responses are cached in memory for 5 minutes and refreshed independently per URL.
See Certificate Profiles for detailed descriptions with examples.
[admin]
The [admin] section enables the server-side Admin API. Admin endpoints (/admin/*) are served on the same listener as the main ACME API — there is no separate admin listener. When this section is absent, all admin endpoints return 404. This is the default; no admin access is possible without explicit configuration.
Operator authentication uses one or both of:
- mTLS client certificates — configure
[tls.client_auth]withrequired = falseand the operator CA(s); the connecting client presents a certificate signed by one of those CAs. - GSSAPI/Kerberos — configure
[admin.gssapi]; clients authenticate via a Kerberos service ticket without requiring a client certificate.
At least one of [tls.client_auth] or [admin.gssapi] must be configured; the server exits at startup if neither is set.
# mTLS client authentication — operator CA(s) accepted for /admin/* requests.
[tls.client_auth]
ca_certs = ["/etc/akamu/operator-ca.pem"]
required = false # allow GSSAPI-only clients that carry no cert
[admin]
session_ttl_secs = 3600
# Bootstrap operator (mTLS) — generated and registered on first run when operators table is empty.
# bootstrap_operator_cert_file = "/etc/akamu/admin-bootstrap.pem"
# bootstrap_operator_key_file = "/etc/akamu/admin-bootstrap-key.pem"
# Bootstrap operator (GSSAPI) — mutually exclusive with cert bootstrap above.
# When operators table is empty at startup, registers this principal as Administrator.
# bootstrap_operator_gssapi_principal = "admin@EXAMPLE.COM"
# Optional: also accept GSSAPI-authenticated operators
[admin.gssapi]
keytab_file = "/etc/akamu/http.keytab" # omit and set gssproxy = true to use gssproxy instead
service_name = "HTTP"
bootstrap_key_type
Optional. Default: "ec:P-256".
Key algorithm used when auto-generating the bootstrap operator certificate. Same syntax as ca.key_type.
bootstrap_key_type = "ec:P-256"
bootstrap_operator_cert_file
Optional.
Path where the bootstrap Administrator operator’s client certificate will be written on first run. When this file (and bootstrap_operator_key_file) are absent and the operators table is empty, Akāmu generates a client certificate signed by the Akāmu CA and registers the operator automatically. Both fields must be set together.
bootstrap_operator_cert_file = "/etc/akamu/admin-bootstrap.pem"
bootstrap_operator_key_file
Optional.
Path where the bootstrap Administrator operator’s client private key will be written on first run. Must be set alongside bootstrap_operator_cert_file.
bootstrap_operator_key_file = "/etc/akamu/admin-bootstrap-key.pem"
bootstrap_operator_name
Optional. Default: "admin".
Name recorded in the operators table for the auto-provisioned bootstrap administrator.
bootstrap_operator_name = "admin"
bootstrap_operator_gssapi_principal
Optional. Default: unset.
Kerberos principal for the GSSAPI bootstrap Administrator operator (e.g. "admin@REALM"). When set and the operators table is empty at startup, Akāmu inserts an Administrator row with this principal so that the first akamuctl login --gssapi succeeds without a prior akamuctl operator add. Mutually exclusive with bootstrap_operator_cert_file / bootstrap_operator_key_file.
bootstrap_operator_gssapi_principal = "admin@EXAMPLE.COM"
auth_rate_limit
Optional. Default: 20.
Maximum credential presentations (Bearer session token, mTLS client certificate, or GSSAPI token) accepted from a single source IP in a rolling 5-minute window before that source receives 429 Too Many Requests. This limits audit-event floods that could otherwise trigger the audit_alarm_action or, when audit_overflow = "halt", refuse all new requests.
auth_rate_limit = 20
session_ttl_secs
Optional. Default: 3600 (1 hour).
Inactive session expiry in seconds. Operator sessions that have had no activity for this duration are invalidated and require re-authentication.
session_ttl_secs = 3600
session_lock_secs
Optional. Default: 900 (15 minutes).
Inactivity threshold before a session enters locked state (FTA_SSL_EXT.1). After
this many idle seconds, requests that present the session token receive
423 Locked instead of 401 Unauthorized. The session is not destroyed; the
operator must re-authenticate to obtain a fresh token. This value must be less
than session_ttl_secs.
session_lock_secs = 900
max_failed_auth
Optional. Default: 5.
Maximum number of failed authentication attempts allowed for an operator before
the account is locked (FIA_AFL.1). Once the threshold is exceeded, further
authentication attempts return 423 Locked until an administrator calls
POST /admin/operators/{id}/unlock (or uses akamuctl operator unlock).
max_failed_auth = 5
lockout_duration_secs
Optional. Default: 1800 (30 minutes).
How long in seconds the operator account remains locked after exceeding
max_failed_auth (FIA_AFL.1). After this duration the lock is automatically
cleared; an administrator may also clear it early with operator unlock.
lockout_duration_secs = 1800
audit_max_events
Optional. Default: absent (unlimited). Backward-compatible alias: audit_max_rows.
Maximum number of audit events since startup before the audit_overflow policy triggers. Audit events are written to the configured audit backend (systemd journal namespace, JSONL file, or in-process store), not to the database. The counter is in-memory and resets on restart. The audit backend manages its own disk retention independently (see contrib/systemd/journald@akamu.conf for journald, or external logrotate(8) for the file backend).
Negative values are treated as unlimited (with a startup warning). Zero is also treated as unlimited.
audit_max_events = 500000
audit_overflow
Optional. Default: "drop_oldest".
Policy applied when audit_max_events is reached. Accepted values:
| Value | Behaviour |
|---|---|
"drop_oldest" | Continue recording events; journald manages its own retention (default). This is effectively a no-op. |
"halt" | Refuse new requests until the server is restarted. |
audit_overflow = "drop_oldest"
audit_alarm_threshold
Optional. Default: 10.
Number of SecurityViolation audit events in a rolling 5-minute window that triggers the alarm response configured by audit_alarm_action.
audit_alarm_threshold = 10
audit_alarm_action
Optional. Default: "syslog".
Action taken when the audit_alarm_threshold is exceeded. Accepted values:
| Value | Behaviour |
|---|---|
"syslog" | Log a CRIT-level message to syslog (default). |
"halt" | Halt the server process immediately. |
audit_alarm_action = "syslog"
[admin.gssapi]
Optional. When absent, GSSAPI authentication for the admin interface is disabled.
Configures GSSAPI/Kerberos authentication for operators accessing the admin API. When set, operators can authenticate by presenting a Kerberos service ticket without requiring a client certificate.
keytab_file
Required when gssproxy = false (the default). Must be absent when gssproxy = true.
Path to the Kerberos keytab file for the admin service principal. The akamu
process must be able to read this file; no other user should have read access
to it. Setting both keytab_file and gssproxy = true is a configuration
error; the server exits at startup.
keytab_file = "/etc/akamu/http.keytab"
gssproxy
Optional. Default: false.
When true, GSSAPI credential acquisition for the admin service principal is
delegated to the gssproxy daemon. The
akamu process must have a matching entry in /etc/gssproxy/conf.d/. The server
sets GSS_USE_PROXY=yes in its environment before the first GSSAPI call. No
direct access to a keytab file on disk is needed. keytab_file must be absent
when this is true.
# gssproxy mode for the admin interface
[admin.gssapi]
gssproxy = true
service_name = "HTTP"
service_name
Optional. Default: "HTTP".
Host-based service name. MIT Kerberos appends @<local-hostname> when no realm is specified.
service_name = "HTTP"
Admin endpoints and RBAC roles:
| Method | Path | administrator | ca_operations | ca_ra | auditor |
|---|---|---|---|---|---|
POST | /admin/session | Y | Y | Y | Y |
DELETE | /admin/session | Y | Y | Y | Y |
GET | /admin/operators | Y | |||
POST | /admin/operators | Y | |||
GET | /admin/operators/{id} | Y | |||
PUT | /admin/operators/{id} | Y | |||
PATCH | /admin/operators/{id} | Y | |||
POST | /admin/operators/{id}/unlock | Y | |||
GET | /admin/audit | Y | Y | ||
GET | /admin/profiles | Y | Y | Y | Y |
POST | /admin/profiles | Y | |||
PUT | /admin/profiles/{id} | Y | |||
DELETE | /admin/profiles/{id} | Y | |||
GET | /admin/accounts | Y | Y | Y | Y |
GET | /admin/account/{id} | Y | Y | Y | Y |
POST | /admin/account/{id}/deactivate | Y | |||
GET | /admin/account/{id}/profile-grants | Y | Y | Y | Y |
PUT | /admin/account/{id}/profile-grants | Y | Y | ||
DELETE | /admin/account/{id}/profile-grants | Y | |||
GET | /admin/certs | Y | Y | Y | |
GET | /admin/certs/{id} | Y | Y | Y | |
GET | /admin/certs/{id}/download | Y | Y | ||
POST | /admin/eab | Y | Y | Y | |
GET | /admin/eab/{kid} | Y | Y | Y | Y |
DELETE | /admin/eab/{kid} | Y | Y | ||
GET | /admin/eab | Y | Y | Y | Y |
GET | /admin/orders | Y | Y | Y | Y |
GET | /admin/orders/{id} | Y | Y | Y | Y |
GET | /admin/config | Y | |||
POST | /admin/crl/force | Y | Y | ||
POST | /admin/revoke | Y | Y | Y | |
GET | /admin/stats | Y | Y | Y | Y |
GET | /admin/cas | Y | Y | ||
GET | /admin/cas/{id} | Y | Y | ||
GET | /admin/cas/{id}/cert | Y | Y | ||
POST | /admin/ca/{id}/crl/force | Y | Y | ||
POST | /admin/ca/{id}/cross-sign | Y | Y | ||
GET | /admin/cross-certs | Y | Y | Y | |
GET | /admin/cross-certs/{id} | Y | Y | Y | |
GET | /admin/delegations | Y | Y | Y | Y |
POST | /admin/delegations | Y | Y | ||
GET | /admin/delegations/{id} | Y | Y | Y | Y |
PUT | /admin/delegations/{id} | Y | Y | ||
DELETE | /admin/delegations/{id} | Y | Y | ||
GET | /admin/gossip/status | Y | Y | Y | Y |
POST | /admin/gossip/register | Y | |||
POST | /admin/tkauth/prune-jti | Y | Y |
See Admin API and Operator Management for the full request/response format of each endpoint.
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<br/>POST /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 |
Contact values must be URIs (containing :). The server does not restrict schemes — mailto:, tel:, and other URI schemes are accepted. Reachability is not verified.
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 account key
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.
Profile grants
Accounts may have a profile_grants attribute that restricts which certificate profiles they are allowed to request. When a profile is configured with require_account_grant = true, the account’s profile_grants must include that profile’s name or the finalization request is denied.
An account with no grants (the default) can only request profiles that do not require a grant.
Viewing and modifying grants
Grants are managed through the Admin API (requires [admin] to be configured in config.toml):
GET /admin/account/{id}/profile-grants → {"profile_grants": ["p1"]}
PUT /admin/account/{id}/profile-grants ← {"profile_grants": ["p1", "p2"]}
DELETE /admin/account/{id}/profile-grants
All admin endpoints require Authorization: Bearer <token>.
EAB grant inheritance
When an EAB key is provisioned with profile_grants via POST /admin/eab, any account created using that EAB key automatically inherits those grants at account creation time. The transfer is atomic — the same database transaction that inserts the new account and marks the EAB key as used also sets the profile_grants on the account row.
POST /admin/eab
{"kid":"key-1","hmac_key_b64u":"<base64url>","profile_grants":["internal"]}
After an account is created using key-1, it will have profile_grants = ["internal"] without any additional admin action.
Delegation URL
When server.delegation_enabled = true, the account object includes an additional "delegations" URL:
{
"status": "valid",
"contact": ["mailto:admin@example.com"],
"orders": "https://acme.example.com/acme/orders/<account-id>",
"delegations": "https://acme.example.com/acme/delegations/<account-id>"
}
POST-as-GET to the delegations URL returns the list of delegation objects available for that account. NDC clients use this URL to discover which CSR templates they are authorized to use. See Orders — Delegation orders and the RFC 9115 configuration reference for the full workflow.
Security considerations
- Each account is identified by the SHA-256 thumbprint of its JWK public key. The server uses this thumbprint to look up accounts without needing to parse or compare full public key material 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 URIs are not validated for reachability. The server accepts any URI containing
:(e.g.mailto:,tel:); it does not restrict the scheme or verify that the address is reachable.
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 | Value | Supported challenges |
|---|---|---|
dns | Regular domain name | http-01, dns-01, tls-alpn-01, dns-persist-01 (when configured) |
dns | Wildcard (*.example.com) | dns-01, dns-persist-01 (when configured) |
dns | .onion v3 hidden service | onion-csr-01; additionally http-01 and tls-alpn-01 when tor_connectivity_enabled = true |
ip | IP address literal | http-01, tls-alpn-01 |
Wildcard identifiers (*.example.com) are accepted as dns type identifiers. Only dns-01 (and dns-persist-01, when configured) can authorize wildcard identifiers. Attempting to validate a wildcard with http-01 or tls-alpn-01 will fail.
dns-persist-01 (draft-ietf-acme-dns-persist) is offered alongside the standard DNS challenges when the server operator has configured at least one entry in server.dns_persist_issuer_domains. If that list is empty the challenge type is not offered.
.onion identifiers (RFC 9799) are submitted as type: "dns" with a v3 .onion value (a 56-character base32 label followed by .onion, e.g. bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion). Only v3 addresses are accepted; v2 (16-character label) addresses are rejected. The server always offers onion-csr-01 for these identifiers. When server.tor_connectivity_enabled = true, it also offers http-01 and tls-alpn-01 (for CAs with Tor network connectivity). dns-01 and dns-persist-01 are never offered for .onion identifiers.
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.
For onion-csr-01 (RFC 9799), the challenge response payload must carry the validation CSR rather than being empty. The client POSTs:
{ "csr": "<base64url-encoded DER PKCS#10 CSR>" }
The CSR must contain:
- The
.oniondomain in a SubjectAlternativeNamedNSNameentry. - The
cabf-onion-csr-nonceextension (OID 2.23.140.41) whose value is the key authorization string (token.thumbprint). - A self-signature by the CSR key.
- An Ed25519 signature by the hidden-service key whose public key is encoded in the
.onionaddress (the outer CSR signature, or the CSR key itself when it is the hidden-service key).
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.
Delegation orders (RFC 9115)
When the server has delegation_enabled = true, ACME clients can place a delegation order by including a "delegation" field in the new-order payload. The value is the URL of a delegation object previously created by the IdO via the Admin API.
{
"identifiers": [
{ "type": "dns", "value": "cdn.example.com" }
],
"delegation": "https://acme.example.com/acme/delegation/b1c2d3e4-…",
"allow-certificate-get": true
}
Delegation order behaviour differences from regular orders:
| Property | Regular order | Delegation order |
|---|---|---|
| Initial status | pending | ready |
authorizations array | One URL per identifier | Always empty ([]) |
| Challenge/authz flow | Required | Skipped entirely |
| CSR validation at finalize | SAN match check | SAN match + CSR template check |
allow-certificate-get | Not applicable | Supported (unauthenticated cert GET) |
| Upstream CA leg | None | Driven automatically by [delegation_upstream] |
The NDC (delegate client) discovers the delegation URL by fetching the IdO’s account object and following the "delegations" URL, then POST-as-GETting POST /acme/delegations/{account_id} to list available delegation objects.
Response on creation (201 Created) for a delegation order:
{
"status": "ready",
"identifiers": [
{ "type": "dns", "value": "cdn.example.com" }
],
"authorizations": [],
"finalize": "https://acme.example.com/acme/order/<order-id>/finalize"
}
The NDC may immediately proceed to finalize the order with a CSR. The CSR is validated against the delegation’s stored CSR template before issuance proceeds.
Challenges
A challenge is the mechanism by which Akāmu verifies that an ACME client controls the identifier (domain name, IP address, email address, or telephone-number authority) in an authorization. The server supports seven challenge types: http-01, dns-01, tls-alpn-01, dns-persist-01, onion-csr-01, email-reply-00, and tkauth-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.
onion-csr-01 is offered exclusively for .onion identifiers (Tor hidden services) and uses a CSR-based proof-of-control mechanism rather than a network probe. See onion-csr-01 below for the full description.
email-reply-00 is offered exclusively for email identifiers (RFC 8823 S/MIME) and uses a two-channel token delivered by email. It requires explicit server configuration. See email-reply-00 below for the full description.
tkauth-01 is offered exclusively for TNAuthList and JWTClaimConstraints identifiers (RFC 9447/9448). The client obtains a signed JWT from an external Token Authority and submits it in the challenge response. It requires explicit server configuration. See tkauth-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.
onion-csr-01 uses the standard token.thumbprint key authorization formula. The server also includes an authKey field in the challenge object containing the JWK thumbprint so the client can construct the key authorization without a separate lookup.
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:
{}
onion-csr-01 is an exception: the POST body must include the DER-encoded CSR (see onion-csr-01 for the payload format).
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"]
B -->|onion-csr-01| G2["Build DER CSR with<br/>cabf-onion-csr-nonce ext (OID 2.23.140.41)<br/>= key_auth (UTF8String)<br/>Sign with hidden-service Ed25519 key"]
C & D & E & F --> G["POST {} to challenge URL"]
G2 --> G2P["POST {csr: base64url-DER} to challenge URL"]
G & G2P --> H["Server validates in background"]
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 1 MiB.
- HTTP 3xx redirects are followed (up to 10 hops), including redirects to HTTPS targets.
- 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 (RFC 8555 §7.1.3).
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 (RFC 8737 §3).
- 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 IETF draft draft-ietf-acme-dns-persist. 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_domains 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
All fields belong to the [server] section of config.toml.
dns_persist_issuer_domains
Optional. Default: absent (dns-persist-01 disabled).
The issuer domain(s) 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.
Accepts either a single string or an array of strings. Multi-tenant or multi-identity deployments can list all accepted issuer domains; validation succeeds when any configured domain matches.
[server]
# Single domain
dns_persist_issuer_domains = "acme.example.com"
# Multiple domains
dns_persist_issuer_domains = ["acme.example.com", "acme.example.org"]
dns_resolver_addr
Optional. Default: absent (system resolver).
DNS resolver override for dns-01, dns-persist-01, and CAA 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.
dns_persist01_resolver_addr
Optional. Default: absent (falls back to dns_resolver_addr).
Resolver override used exclusively for dns-persist-01 validation. When set, this address is used instead of dns_resolver_addr for TXT lookups at _validation-persist.*. Useful when persistent TXT records are served by a different DNS infrastructure than the one used for dns-01 and CAA lookups.
[server]
dns_resolver_addr = "127.0.0.1:5353" # used for dns-01 and CAA
dns_persist01_resolver_addr = "127.0.0.1:5354" # used only for dns-persist-01
dns_dot_server_name
Optional. Default: absent (plain UDP).
TLS server name (SNI hostname) for DNS-over-TLS (DoT, RFC 7858). When set, all DNS challenge validation queries — dns-01, dns-persist-01, and CAA record lookups — are sent over TLS instead of plain UDP. dns_resolver_addr must be set to the DoT server’s IP address and port 853.
Use DoT when the path between the Akāmu server and its resolver is untrusted (e.g. a public resolver reached over the open Internet, an ISP that intercepts cleartext DNS queries, or a network with strict privacy requirements). The TLS certificate presented by the resolver is verified against the system root CA store. LDAP SRV lookups for certificate profile providers are unaffected.
DoT and DNSSEC validation (validate_dnssec = true) are independent and can be used together: DoT protects the transport channel while DNSSEC authenticates the response data.
[server]
# DoT only
dns_resolver_addr = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"
# DoT + DNSSEC
dns_resolver_addr = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"
validate_dnssec = true
Limitations
- No IP address identifier support.
dns-persist-01is defined only for DNS name identifiers; IP address identifiers are not supported (no TXT record format is specified indraft-ietf-acme-dns-persistfor IP identifiers).
onion-csr-01 (RFC 9799)
onion-csr-01 validates control of a Tor v3 hidden service (.onion) identifier. Instead of making a network probe, the server verifies a PKCS #10 CSR that the client constructs and submits in the challenge response body. The challenge type is defined by RFC 9799.
This challenge is offered only for .onion DNS identifiers. It cannot be used for regular DNS names or IP address identifiers. The identifier in the ACME order must use "type": "dns" and the value must be a valid v3 .onion address (56-character base32 label + .onion). v2 .onion addresses (16-character label) are rejected.
Challenge object
When the authorization is created for a .onion identifier, the server includes an onion-csr-01 challenge object. Unlike other challenge types, it also carries an authKey field containing the account’s JWK thumbprint:
{
"type": "onion-csr-01",
"url": "https://acme.example.com/acme/chall/<authz-id>/onion-csr-01",
"status": "pending",
"token": "<token>",
"authKey": "<jwk-thumbprint>"
}
The authKey field is provided as a convenience: the client needs the JWK thumbprint to compute the key authorization, and authKey exposes it directly so the client does not need to derive it from the account key a second time.
Key authorization
Compute the key authorization using the standard formula:
key_authorization = token + "." + authKey
Where authKey is the authKey field from the challenge object (equal to base64url(SHA-256(JWK-thumbprint-of-account-key))).
Client provisioning
The client must build a DER-encoded PKCS #10 CSR that satisfies all of the following:
- Subject Alternative Name: contains the
.oniondomain as adNSName. cabf-onion-csr-nonceextension (OID2.23.140.41): the extension value is a DERUTF8String(tag0x0C) containing the key authorization string (token.thumbprint). This extension does not need to be marked critical.- Signature: the CSR must be signed by the hidden-service Ed25519 private key — the key whose public key is encoded in the v3
.onionaddress. The most common approach is to use the hidden-service key directly as the CSR key, producing a single self-signature that also proves hidden-service key control.
Example OID DER encoding for 2.23.140.41: 06 04 67 81 0C 29
Challenge response
POST to the challenge URL with the base64url-encoded DER CSR as the payload (this is the only challenge type where the POST body is not an empty {}):
{
"csr": "<base64url-encoded DER CSR>"
}
The server decodes the csr field from base64url immediately and returns 400 Bad Request if the field is missing or the encoding is invalid.
Server validation
After the challenge is flipped to processing, the server performs these checks synchronously (no network probe is made):
- Decode the 32-byte Ed25519 public key from the v3
.onionaddress label (56-character lowercase base32, version byte0x03). - Parse the DER CSR structure.
- Verify the CSR self-signature.
- Locate the
cabf-onion-csr-nonceextension (OID2.23.140.41) and verify that its value decodes to the expected key authorization string. - Verify that the Ed25519 signature over the
CertificationRequestInfoDER is valid under the hidden-service public key. This succeeds if the CSR signing key is the hidden-service key (self-signed CSR), or if the outer CSR signature verifies directly with the hidden-service key. - Verify that the CSR’s SAN extension contains the
.oniondomain as adNSName.
Challenge types offered for .onion identifiers
RFC 9799 §4 requires that onion-csr-01 is always offered for .onion identifiers and that dns-01 is never offered. Whether http-01 and tls-alpn-01 are also offered depends on the server.tor_connectivity_enabled configuration:
tor_connectivity_enabled = false(default): onlyonion-csr-01is offered. The server cannot reach.onionaddresses, so HTTP and TLS probes would always fail.tor_connectivity_enabled = true:onion-csr-01,http-01, andtls-alpn-01are all offered, giving the client a choice.
dns-persist-01 is never offered for .onion identifiers.
Constraints
- Only valid for
dnstype identifiers whose value ends in.onion. - Only v3
.onionaddresses (56-character base32 label) are accepted; v2 addresses are rejected at order/pre-authorization creation time. dns-01anddns-persist-01are never offered for.onionidentifiers.- Wildcard
.onionidentifiers (*.xxx...xxx.onion) are not supported.
Configuration
No additional server configuration is required to enable onion-csr-01; it is always offered when an order or pre-authorization contains a .onion identifier. To additionally offer http-01 and tls-alpn-01 for .onion identifiers (requires Tor network access from the server), set:
[server]
tor_connectivity_enabled = true
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 (http-01, tls-alpn-01) |
dns | DNS TXT lookup failed or name not found (dns-01, dns-persist-01) |
incorrectResponse | Server responded but the content did not match; or, for onion-csr-01, the CSR failed validation (missing nonce extension, nonce mismatch, invalid Ed25519 signature, missing SAN, or malformed .onion address) |
tls | TLS handshake failed or extension verification failed (tls-alpn-01) |
For onion-csr-01, the server also returns an immediate 400 Bad Request (before the background validation task starts) if the POST body is not valid JSON with a csr field, or if the csr value is not valid base64url.
A failed authorization invalidates the parent order. Create a new order to try again.
email-reply-00 (RFC 8823)
email-reply-00 is defined by RFC 8823. It is the only challenge type offered for email identifier orders and proves email address control via a DKIM-authenticated reply email. Issuing S/MIME certificates using this challenge requires the [email_challenge] configuration section — see email_challenge configuration.
Protocol
-
The client creates an order with
{"type": "email", "value": "user@example.com"}. -
The server returns an authorization with an
email-reply-00challenge object:{ "type": "email-reply-00", "url": "https://acme.example.com/acme/chall/<id>", "status": "pending", "token": "<base64url(token-part2)>", "from": "acme-validation@example.com" }fromis the address the server will send the challenge email from.tokenis token-part2 (server-generated, ≥128 bits of random data). -
The client POSTs
{}to the challenge URL to trigger the challenge. The server:- Generates token-part1 (≥128 bits of random data) and a
Message-ID. - Stores both in the database.
- Invokes the configured
send_scriptto send an email to the identifier address with subjectACME: <base64url(token-part1)>and the generatedMessage-ID.
- Generates token-part1 (≥128 bits of random data) and a
-
The client reads the email, extracts
token-part1from theSubjectheader, then computes:full_token = base64url(token-part1) || base64url(token-part2) key_auth = full_token || "." || base64url(SHA-256(canonical-JWK)) response = base64url(SHA-256(key_auth)) -
The client sends a reply email (preserving
In-Reply-Toand DKIM signing) with body:-----BEGIN ACME RESPONSE----- <response> -----END ACME RESPONSE----- -
Mail routing infrastructure (a filter script, procmail rule, or email service webhook) POSTs the reply to
POST /acme/email-webhook. See Webhook endpoint below. -
The server verifies the DKIM domain, extracts and verifies the response digest, and marks the challenge and authorization valid.
-
The client finalizes with a CSR containing an
rfc822NameSAN matching the email address and theemailProtectionEKU.
Key authorization formula
email-reply-00 uses a modified key authorization:
full_token = base64url(token-part1) || base64url(token-part2)
key_auth = full_token || "." || base64url(SHA-256(canonical-JWK))
response = base64url(SHA-256(key_auth as UTF-8 bytes))
Where base64url(SHA-256(canonical-JWK)) is the JWK thumbprint of the account key per RFC 7638 — the same value used as thumbprint in the standard key authorization formula. Do not hash the thumbprint string itself; hash the canonical JWK JSON.
This is different from the standard token.thumbprint formula used by http-01/dns-01/tls-alpn-01. The response value (not key_auth) is what the client sends in the reply body.
CSR requirements
The CSR submitted at finalize time must:
- Contain an
rfc822NameSubject Alternative Name matching the email identifier value (case-insensitive). - Use the
emailProtectionExtended Key Usage (OID 1.3.6.1.5.5.7.3.4). - Not contain any DNS/IP SANs that were not authorized by a separate authorization.
The certificate profile should be configured to enforce email_protection EKU. See the S/MIME profile example in the profiles documentation.
Webhook endpoint
POST /acme/email-webhook receives the client’s reply from any mail routing tool that can POST JSON:
{
"from": "user@example.com",
"in_reply_to": "<uuid@acme-server.example.com>",
"dkim_domain": "example.com",
"dkim_status": "pass",
"body": "-----BEGIN ACME RESPONSE-----\nABC123==\n-----END ACME RESPONSE-----"
}
| Field | Description |
|---|---|
from | Envelope/header From address of the reply email |
in_reply_to | In-Reply-To header of the reply email (must match the server’s Message-ID) |
dkim_domain | DKIM d= tag from a valid DKIM signature on the reply |
dkim_status | "pass" if DKIM verification succeeded; any other value fails the challenge |
body | Full text body of the reply email |
DKIM trust model: The server does not perform DKIM verification itself. DKIM verification is the responsibility of the webhook caller (the mail routing script or MTA filter). The server enforces two properties on every request:
dkim_domainmust match the domain part offrom(case-insensitively). This prevents a malicious script from claiming DKIM pass for a different domain.dkim_statusmust equal"pass"(case-insensitively; some MTAs report"Pass"or"PASS").
If your MTA or mail service provides DKIM results in a format other than "pass", normalize it in your routing script before POSTing to the webhook.
HMAC authentication: Every POST must include the header:
X-Akamu-Signature: sha256=<lowercase-hex(HMAC-SHA256(raw-body, webhook_hmac_secret))>
The webhook_hmac_secret is configured in [email_challenge]. Requests with a missing, malformed, or incorrect signature are rejected with 403 Forbidden. All other responses are 200 OK regardless of the challenge outcome (to prevent webhook callers from retrying indefinitely on validation failures).
Example send script
The server invokes the configured send_script with these environment variables:
| Variable | Value |
|---|---|
ACME_TO | Recipient email address (the identifier) |
ACME_FROM | Sender address (from_address in [email_challenge]) |
ACME_SUBJECT | ACME: <base64url(token-part1)> |
ACME_MESSAGE_ID | Server-generated Message-ID (script must preserve this exactly) |
ACME_AUTO_SUBMITTED | auto-generated; type=acme |
ACME_TOKEN_PART2 | token-part2 (base64url, from the challenge JSON token field); exposed so advanced scripts can pre-compute or log the expected response |
Exit code 0 = success. Non-zero = the challenge is marked invalid and the client must retry.
A minimal script using sendmail:
#!/bin/bash
set -euo pipefail
sendmail -f "$ACME_FROM" "$ACME_TO" <<EOF
From: $ACME_FROM
To: $ACME_TO
Subject: $ACME_SUBJECT
Message-ID: $ACME_MESSAGE_ID
Auto-Submitted: $ACME_AUTO_SUBMITTED
MIME-Version: 1.0
Content-Type: text/plain
This email was sent automatically as part of an ACME S/MIME certificate
issuance request. If you did not request a certificate, ignore this email.
EOF
The script is responsible for DKIM signing. The server does not sign outbound email.
tkauth-01 (RFC 9447 / RFC 9448)
tkauth-01 validates control of a TNAuthList or JWTClaimConstraints identifier by verifying a signed JWT authority token issued by an external Token Authority (TA). This challenge type is defined by RFC 9447; the TNAuthList profile is defined by RFC 9448.
Unlike network-based challenges, tkauth-01 relies on the TA asserting the client’s authority over the identifier out-of-band. The server only verifies the cryptographic integrity of the token and the atc claim binding.
Challenge object
When an order contains a TNAuthList or JWTClaimConstraints identifier, the authorization contains a tkauth-01 challenge object:
{
"type": "tkauth-01",
"url": "https://acme.example.com/acme/chall/<authz-id>/tkauth-01",
"status": "pending",
"tkauth-type": "atc",
"token-authority": "https://ta.example.com"
}
| Field | Description |
|---|---|
tkauth-type | Always "atc" — the authority token type profile |
token-authority | Optional URL hint for the Token Authority, from tkauth.token_authority_url |
Key authorization
tkauth-01 uses the standard key authorization formula: token.thumbprint where thumbprint is the base64url-encoded SHA-256 JWK thumbprint of the account key. The token field is not present in the challenge object; the thumbprint is used directly as the fingerprint check in the authority token’s atc claim.
The fingerprint field in the atc claim must be formatted as:
SHA256 XX:XX:XX:...
where XX:XX:XX:... is the colon-separated uppercase hex encoding of the raw SHA-256 JWK thumbprint bytes.
Identifier value format (JWTClaimConstraints)
For JWTClaimConstraints identifiers, the tkvalue in the atc claim and the ACME order identifier value must both be the base64url encoding of a DER-encoded JWTClaimConstraints structure as defined in RFC 8226 §3:
JWTClaimConstraints ::= SEQUENCE {
mustInclude [0] JWTClaimNames OPTIONAL,
permittedValues [1] JWTClaimValuesList OPTIONAL
}
The server validates both mustInclude (claim names that must be present in the JWT) and permittedValues (allowed values for specific claims). At least one of the two MUST be present.
Example: to require a specific Kerberos principal in the sub claim, encode:
JWTClaimConstraints {
permittedValues [1]: [("sub", ["user@REALM"])]
}
The resulting DER bytes, base64url-encoded, become the identifier value.
Obtaining an authority token
The client contacts the Token Authority (identified by token-authority or by deployment convention) and requests an authority token for its identifier. The TA issues a compact JWT that must contain:
-
Header:
algand one of:x5c: inline certificate chain (array of base64-encoded DER certs); the leaf signs the JWTx5u: URL from which the server fetches the signing certificate chainkid: key identifier used to locate the signing public key in a JWKS endpoint
-
Claims:
atc: an object withtktype(identifier type),tkvalue(identifier value, DER-encoded forJWTClaimConstraints),fingerprint(account key thumbprint inSHA256 XX:XX:...format), and optionallyca: falsejti: a unique token identifier (REQUIRED; enforces one-time use)exp: expiry timestamp (REQUIRED)
Challenge response
POST to the challenge URL with the JWT in the tkauth field:
{
"tkauth": "<compact-JWT>"
}
Example (header shown decoded):
{
"alg": "ES256",
"x5u": "https://ta.example.com/cert"
}
Validation steps
- Decode the JWT header to determine the key resolution path:
x5c: parse the inline certificate chain directlyx5u: fetch the certificate chain from the URL (HTTPS only; SSRF guard applies)kid: look up the signing public key from a JWKS endpoint (see JWKS trust below)
- For
x5c/x5u: validate the certificate chain against the configuredtrusted_ta_ca_files. Forkid: the trust is established by the per-profiletrust_jwks_urlslist. - Verify the JWT signature using the resolved public key. The public key algorithm must match
alg; supported algorithms include ES256/ES384/ES512 (ECDSA), RS256/RS384/RS512/PS256/PS384/PS512 (RSA), EdDSA (Ed25519), and ML-DSA-44/ML-DSA-65/ML-DSA-87. - Confirm
exphas not elapsed andexp - nowdoes not exceedmax_validity_secs. - Check the
atcclaim:tktypematches the identifier type,tkvaluematches the identifier value,fingerprintmatches the account key thumbprint. - For
JWTClaimConstraints: parse the DER-encodedtkvalueand verify that the JWT’s own claims satisfy bothmustInclude(all named claims are present) andpermittedValues(each constrained claim’s value is in the allowed list). - Record the
jtiin the replay-prevention store; duplicate JTIs are rejected.
JWKS trust (kid-based tokens)
When the JWT header contains kid instead of x5c/x5u, the server looks up the signing public key from a JWKS endpoint. Trust is configured per profile:
[profiles.providers.local.profiles.my-profile]
description = "Example profile"
ca_ids = ["my-ca"]
trust_jwks_urls = [
"https://idp.example.com/jwks",
"http+unix://%2Frun%2Fekishib%2Fekishib.sock/jwks",
]
The trust_jwks_urls list is searched in order. The first JWKS that contains a key with a matching kid is used. If no profile has trust_jwks_urls set, kid-signed tokens are rejected.
JWKS responses are cached in memory for 5 minutes. Each URL is refreshed independently when the cache entry expires.
URL schemes:
| Scheme | Usage |
|---|---|
https://... | Standard HTTPS fetch; SSRF guard applies (no RFC-1918 targets without explicit configuration) |
http+unix://ENCODED_PATH/request-path | HTTP over a Unix domain socket; ENCODED_PATH is the socket file path with / encoded as %2F (e.g. %2Frun%2Fekishib%2Fekishib.sock) |
The http+unix:// scheme is intended for co-located Token Authorities (e.g. Ekishib IdP running on the same host) where TLS is unnecessary.
Note: ML-DSA-44/ML-DSA-65/ML-DSA-87 keys are supported in JWKS entries (
kty: "AKP"). This allows post-quantum Token Authorities to sign authority tokens without an X.509 certificate chain.
Certificate SAN injection (claim_encoders)
When the server issues a certificate for a JWTClaimConstraints identifier, it can inject Subject Alternative Names derived from the permittedValues of the validated token. This is controlled by the [[tkauth.claim_encoders]] configuration.
Each entry maps a JWT claim name to an encoder that knows how to produce an OtherName SAN:
[[tkauth.claim_encoders]]
claim = "sub"
encoder = "krb5-kpn" # Kerberos principal (id-pkinit-san OtherName, OID 1.3.6.1.5.2.2)
[[tkauth.claim_encoders]]
claim = "upn"
encoder = "ms-upn" # Microsoft UPN OtherName (OID 1.3.6.1.4.1.311.20.2.3)
[[tkauth.claim_encoders]]
claim = "dns"
encoder = "dns-san" # plain dNSName SAN; wildcards are rejected
Built-in encoders:
| Encoder name | SAN type | Details |
|---|---|---|
krb5-kpn | OtherName (id-pkinit-san, OID 1.3.6.1.5.2.2) | principal@REALM; if @ is absent, default_realm is appended |
ms-upn | OtherName (OID 1.3.6.1.4.1.311.20.2.3) | user@domain |
dns-san | dNSName | Plain hostname; wildcards rejected; lowercased before injection |
A permittedValues entry with exactly one value is injected as a SAN using the matching encoder. Entries with multiple permitted values are skipped (the server cannot determine which specific value the TA attested).
For dns-san, the DNS name from the token constraint is added alongside any DNS SANs already in the CSR. The ACME client must still include the name in its CSR — the encoder only grants permission to inject it from the token, it does not bypass the CSR.
Configuration
[tkauth]
enabled = true
trusted_ta_ca_files = ["/etc/akamu/ta-root.pem"]
token_authority_url = "https://ta.example.com" # optional hint surfaced in challenge object
max_validity_secs = 3600 # reject tokens with exp - now > this value
jti_prune_interval_secs = 3600 # how often to purge expired JTI records from the database
# SAN injection: map JWT claim names to OtherName encoders
[[tkauth.claim_encoders]]
claim = "sub"
encoder = "krb5-kpn"
Per-profile JWKS trust (set in each profile that should accept kid-signed tokens):
[profiles.providers.local.profiles.kerberos-svc]
description = "Kerberos service certificate"
ca_ids = ["kerberos-ca"]
trust_jwks_urls = ["https://idp.example.com/jwks"]
[profiles.providers.local.profiles.ipa-ldap]
description = "IPA LDAP server certificate"
ca_ids = ["ipa-ca"]
# Co-located Ekishib IdP via Unix socket
trust_jwks_urls = ["http+unix://%2Frun%2Fekishib%2Fekishib.sock/jwks"]
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["Resolve profile parameters<br/>(from requested profile, or server defaults)"]
F --> AUTH{"Per-profile auth checks<br/>(patterns, hook, grants)"}
AUTH -->|Denied| UNAUTH([401/403 unauthorized])
AUTH -->|Permitted| G["Build end-entity certificate<br/>extensions from resolved profile"]
G --> H[Sign with CA private key]
H --> MTC{"issue_as = mtc?"}
MTC -->|Yes| MTCB["Build MTC StandaloneCertificate<br/>DER; Content-Type: application/pkix-cert"]
MTC -->|No| I["Store DER + PEM bundle<br/>in certificates table"]
MTCB --> I
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 certificate"])
classDef ok fill:#f0fdf4,stroke:#16a34a,color:#0f172a
classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
class K,L ok
class FAIL,UNAUTH 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).
- Applies certificate parameters from the requested profile, or from the server’s default policy if no profile was specified.
- Issues the certificate. When the resolved profile has
issue_as = "mtc", a Merkle Tree CertificateStandaloneCertificateis built instead of a PEM chain; otherwise, a standard X.509 v3 certificate is issued. The extensions depend on the active profile:
| Extension | Critical | Default (no profile) | With profile |
|---|---|---|---|
| BasicConstraints | No | cA=FALSE | cA=FALSE |
| KeyUsage | Yes | digitalSignature | As configured in profile |
| ExtendedKeyUsage | No | serverAuth | As configured in profile |
| SubjectKeyIdentifier | No | RFC 7093 §2 Method 1 (SHA-256) | RFC 7093 §2 Method 1 (SHA-256) |
| AuthorityKeyIdentifier | No | RFC 7093 §2 Method 1 (SHA-256) | RFC 7093 §2 Method 1 (SHA-256) |
| SubjectAlternativeName | No | Rebuilt from validated CSR SANs | Rebuilt from validated CSR SANs |
| AuthorityInfoAccess (OCSP) | No | If ocsp_url configured | If profile or ocsp_url set |
| CRLDistributionPoints | No | If crl_url configured | If profile or crl_url set |
| CertificatePolicies | No | Absent | If profile includes policies |
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), or the profile-specific validity if a profile is active. See Certificate Profiles for how to configure per-profile extension content, MTC issuance, and per-profile authorization.
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 format depends on how the certificate was issued:
Standard X.509 certificate (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).
MTC StandaloneCertificate (Content-Type: application/pkix-cert):
When the order was finalized with a profile that sets issue_as = "mtc", the endpoint returns the raw DER-encoded StandaloneCertificate (§6.1 of draft-ietf-plants-merkle-tree-certs). The server detects MTC certificates automatically and sets the appropriate Content-Type. See Certificate Profiles — MTC certificate issuance for how to configure an MTC-issuing profile.
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"
}
}
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
RFC 9773 allows an optional explanationURL field pointing to a human-readable page explaining the renewal recommendation. Set ari_explanation_url in [server] to include it:
[server]
ari_explanation_url = "https://acme.example.com/docs/renewal-policy"
When absent, the field is omitted from the response.
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.
Certificate Profiles
Certificate profiles let Akāmu issue certificates with different extension sets, validity periods, and key usage policies depending on the use case. Without profiles every order gets the same default profile: digitalSignature KeyUsage, serverAuth EKU, and the validity and URL settings from [ca]. With profiles configured, clients can request a named policy at order time and the server enforces it at issuance.
Profiles implement draft-ietf-acme-profiles-01.
How it works
- At startup
Akāmuloads profile definitions from one or more providers (see below) and caches them in memory. - The directory endpoint advertises the available profiles in
meta.profiles. - A client includes
"profile": "<name>"in itsnewOrderrequest. - At finalize time the server resolves the profile’s
CertificateParametersand issues the certificate with those extension values;Akāmu’s own CA always signs. - A background task refreshes the cache every
refresh_interval_secsseconds (default: 3600).
If no profile is requested, or no providers are configured, the server falls back to CA defaults unchanged.
Configuration overview
[profiles]
refresh_interval_secs = 3600 # how often to reload from providers (default)
# ── Provider 1: inline TOML definitions ─────────────────────────────────────
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.tlsserver]
description = "Standard TLS server certificate"
validity_days = 90
key_usage = ["digital_signature", "key_encipherment"]
eku = ["server_auth"]
[profiles.providers.local.profiles.clientauth]
description = "Client authentication certificate"
validity_days = 365
key_usage = ["digital_signature"]
eku = ["client_auth"]
# ── Provider 2: Dogtag PKI profile files ────────────────────────────────────
[profiles.providers.dogtag_prod]
type = "dogtag"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles = ["caServerCert", "caIPAserviceCert"] # empty = all
# ── Provider 3: FreeIPA/IPAThinCA via GSSAPI LDAP ───────────────────────────
[profiles.providers.ipa_prod]
type = "ipa"
profiles = ["caIPAserviceCert", "IECUserRoles"]
[profiles.providers.ipa_prod.ldap]
uri = "ldap://ipa.example.com:389"
base_dn = "o=ipaca"
gssapi = true
Provider types
builtin — inline TOML
Define profiles directly in config.toml. No external system required.
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.<profile-id>]
description = "Human-readable description shown in meta.profiles"
validity_days = 90 # optional; inherits from [ca].validity_days
hash_alg = "sha256" # optional; inherits from [ca].hash_alg
key_usage = ["digital_signature"] # see table below
eku = ["server_auth"] # see table below
crl_url = "http://crl.example.com/ca.crl" # optional
ocsp_url = "http://ocsp.example.com" # optional
allowed_key_types = ["ec:P-256", "rsa:2048"] # optional; empty = any
issue_as = "mtc" # optional; "mtc" or absent/"x509" for standard X.509
# Multi-CA restriction (optional; empty = available via all CAs)
ca_ids = ["rsa", "ec"] # restrict to specific CA IDs
# Per-profile authorization (all three checks are AND-combined)
allowed_identifiers = ['^dns:.*\.example\.com$'] # optional; empty = no restriction
identifier_match = "all" # "all" (default) or "any"
auth_hook = "/etc/akamu/hooks/auth.sh" # optional; path to executable
auth_hook_timeout_secs = 30 # optional; default 30
require_account_grant = false # optional; default false
[[profiles.providers.local.profiles.<profile-id>.certificate_policies]]
oid = "2.23.140.1.2.1" # DV certificate
cps_uri = "https://example.com/cps" # optional
key_usage names
| Name | KeyUsage bit |
|---|---|
digital_signature | digitalSignature (bit 0) |
non_repudiation / content_commitment | nonRepudiation (bit 1) |
key_encipherment | keyEncipherment (bit 2) |
data_encipherment | dataEncipherment (bit 3) |
key_agreement | keyAgreement (bit 4) |
key_cert_sign | keyCertSign (bit 5) |
crl_sign | cRLSign (bit 6) |
encipher_only | encipherOnly (bit 7) |
decipher_only | decipherOnly (bit 8) |
eku names and dotted-decimal OIDs
| Name | OID |
|---|---|
server_auth | 1.3.6.1.5.5.7.3.1 |
client_auth | 1.3.6.1.5.5.7.3.2 |
code_signing | 1.3.6.1.5.5.7.3.3 |
email_protection | 1.3.6.1.5.5.7.3.4 |
time_stamping | 1.3.6.1.5.5.7.3.8 |
ocsp_signing | 1.3.6.1.5.5.7.3.9 |
1.2.3.4.5.6 | raw dotted-decimal OID string |
crl_url / ocsp_url three-state semantics
| Value | Effect |
|---|---|
| Absent (key not set) | Inherit from [ca].crl_url / [ca].ocsp_url |
"" (empty string) | Suppress the extension — no CDP / AIA in the certificate |
"https://…" | Override with the given URL |
dogtag — Dogtag PKI profile files
Load profiles from a Dogtag PKI .cfg file directory. Each file is named <profile-id>.cfg and uses the Dogtag Java-properties format.
[profiles.providers.dogtag_prod]
type = "dogtag"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles = ["caServerCert", "caIPAserviceCert"]
# profiles = [] # empty = load all .cfg files in the directory
| Key | Required | Description |
|---|---|---|
profile_dir | Conditional | Path to directory of .cfg files |
ldap | Conditional | LDAP connection sub-table; see LDAP options under ipa below |
profiles | No | Allowlist of profile IDs; empty = all |
At least one of profile_dir or ldap must be set. When both are configured, ldap takes priority.
# LDAP source — simple bind, TLS via STARTTLS
[profiles.providers.dogtag_ldap]
type = "dogtag"
profiles = ["caServerCert"]
[profiles.providers.dogtag_ldap.ldap]
uri = "ldap://dogtag.example.com:389"
base_dn = "dc=example,dc=com"
bind_dn = "uid=admin,ou=people,dc=example,dc=com"
bind_password_file = "/etc/akamu/ldap-password"
tls_ca_cert_file = "/etc/ssl/certs/ldap-ca.pem" # triggers STARTTLS on ldap:// URIs
# LDAP source — multiple servers, GSSAPI
[profiles.providers.dogtag_ha]
type = "dogtag"
profiles = ["caServerCert"]
[profiles.providers.dogtag_ha.ldap]
uris = ["ldap://dogtag1.example.com:389", "ldap://dogtag2.example.com:389"]
base_dn = "dc=example,dc=com"
gssapi = true
Supported Dogtag policy classes
| Class | Fields extracted |
|---|---|
validityDefaultImpl | params.range + params.rangeUnit → validity days |
keyUsageExtDefaultImpl | 9 params.keyUsage* booleans → KeyUsage bitmask |
extendedKeyUsageExtDefaultImpl | params.exKeyUsageOIDs comma-separated OIDs → EKU list |
authInfoAccessExtDefaultImpl | OCSP URL via method 1.3.6.1.5.5.7.48.1 → ocsp_url |
crlDistributionPointsExtDefaultImpl | params.crlDistPointsPointName_0 → crl_url |
Unrecognised policy class IDs are silently skipped.
ipa — FreeIPA / IPAThinCA
Load profiles from a FreeIPA or IPAThinCA instance. Profile .cfg files use the same Dogtag format. The standard location for IPA-embedded Dogtag is /etc/pki/pki-tomcat/ca/profiles/ca on the IPA server, and LDAP profiles are stored at ou=certificateProfiles,ou=ca,o=ipaca — accessible on the standard LDAP ports (389 for plain/STARTTLS, 636 for LDAPS).
# Filesystem source
[profiles.providers.ipa_prod]
type = "ipa"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles = ["caIPAserviceCert"]
# LDAP source — single server, simple bind
[profiles.providers.ipa_ldap]
type = "ipa"
profiles = ["caIPAserviceCert"]
[profiles.providers.ipa_ldap.ldap]
uri = "ldap://ipa.example.com:389"
base_dn = "o=ipaca"
bind_dn = "uid=admin,cn=users,cn=accounts,dc=example,dc=com"
bind_password_file = "/etc/akamu/ipa-ldap-password"
# LDAP source — multiple servers (failover list), GSSAPI
[profiles.providers.ipa_ha]
type = "ipa"
profiles = ["caIPAserviceCert"]
[profiles.providers.ipa_ha.ldap]
uris = ["ldap://ipa1.example.com:389", "ldap://ipa2.example.com:389"]
base_dn = "o=ipaca"
gssapi = true
# LDAP source — SRV-based discovery, GSSAPI
[profiles.providers.ipa_gssapi]
type = "ipa"
profiles = ["caIPAserviceCert"]
[profiles.providers.ipa_gssapi.ldap]
srv_domain = "example.com" # resolves _ldap._tcp.example.com SRV records
base_dn = "o=ipaca"
gssapi = true
LDAP server selection
| Key | Description |
|---|---|
uri | Single LDAP URI: ldap://host:port or ldaps://host:port. |
uris | List of LDAP URIs tried in order for failover. |
srv_domain | Discover servers via DNS SRV (_ldap._tcp.{srv_domain}), sorted by RFC 2782 priority/weight. Appended after any explicit uris. |
At least one of uri, uris, or srv_domain must be set.
LDAP authentication options
| Key | Description |
|---|---|
bind_dn | Bind DN for simple authentication. Required for simple bind. |
bind_password_file | Path to a file containing the simple bind password. Required when bind_dn is set. |
gssapi = true | Use SASL GSSAPI (Kerberos). Pre-condition: the process must have a valid Kerberos TGT in its credential cache (e.g. obtained via kinit or a system keytab). No explicit credentials are passed to the server. |
tls_ca_cert_file | Path to a PEM CA certificate for verifying the LDAP server’s TLS certificate. When set on an ldap:// URI, STARTTLS is negotiated automatically before any credentials are sent. |
Attribute name lowercasing: the
akamu-ldaplibrary normalises all LDAP attribute names to lower case in the returned entries. Profile lookup keys such ascnandcertProfileConfigare matched in lower case internally; this is transparent to the operator.
Refresh behaviour
Akāmu loads all providers once at startup and caches the results. A background tokio task wakes every refresh_interval_secs seconds and re-loads all providers, atomically replacing the cache. Certificates being issued concurrently always see a consistent snapshot.
If a refresh fails (e.g., a .cfg file is temporarily unreadable), the previous cache is kept and a warning is logged. The server never stops serving because of a failed refresh.
The refresh task exits automatically when the server shuts down (it holds a weak reference to the registry).
[profiles]
refresh_interval_secs = 1800 # refresh every 30 minutes instead of 1 hour
Precedence when multiple providers list the same profile
If two providers both export a profile with the same ID, the first provider listed in config.toml wins. The second is silently ignored. This is determined by HashMap iteration order over [profiles.providers], which is non-deterministic in TOML. To avoid ambiguity, give each profile a unique ID across providers, or use a single canonical provider.
Requesting a profile from an ACME client
Include "profile" in the newOrder payload:
{
"identifiers": [{ "type": "dns", "value": "example.com" }],
"profile": "tlsserver"
}
The server:
- Records the profile name on the order.
- Validates the profile name at finalize time (rejects with
invalidProfileif no longer loaded). - Runs per-profile authorization checks (see below).
- Issues the certificate using the profile’s
CertificateParameters.
The profile name is echoed back in every order response:
{
"status": "valid",
"profile": "tlsserver",
"certificate": "https://acme.example.com/acme/cert/…"
}
CA restriction (ca_ids)
When ca_ids is set on a builtin profile, the profile is only offered via
the ACME endpoints of the listed CAs. Requests for this profile via any other
CA’s endpoint receive urn:ietf:params:acme:error:invalidProfile at finalize
time.
[profiles.providers.local.profiles.rsa-only]
description = "Certificate restricted to the RSA CA"
ca_ids = ["rsa"]
Config validation rejects ca_ids entries that do not match any configured
CA id. When ca_ids is empty (the default), the profile is available via
all configured CAs.
Per-profile authorization
Three independent checks are applied at finalize time. All configured checks must pass (AND logic) for issuance to proceed. Checks that are not configured are skipped.
1. Identifier patterns
allowed_identifiers is a list of regular expressions. Each order identifier is formatted as "type:value" (e.g. "dns:example.com", "dns:*.example.com") before being tested against the patterns.
[profiles.providers.local.profiles.internal]
description = "Internal services only"
allowed_identifiers = ['^dns:.*\.internal\.example\.com$', '^dns:internal\.example\.com$']
identifier_match = "all" # "all" (default) or "any"
identifier_match controls how the patterns are applied:
| Value | Behaviour |
|---|---|
"all" (default) | Every identifier in the order must match at least one pattern. |
"any" | At least one identifier must match at least one pattern; the others are unrestricted. |
When allowed_identifiers is empty (the default), no identifier restriction is applied.
An invalid regular expression in allowed_identifiers causes the finalize request to fail with invalidProfile.
2. External authorization hook
auth_hook is a path to an executable. The server spawns it at finalize time and sends a JSON object on stdin:
{
"account_id": "abc123",
"profile": "internal",
"identifiers": [
{ "type": "dns", "value": "svc.internal.example.com" }
]
}
- Exit code 0: issuance proceeds.
- Non-zero exit code: issuance is denied. The hook’s standard output (trimmed) is forwarded to the ACME client as the denial reason.
- Standard error is discarded.
auth_hook_timeout_secs (default: 30) sets the maximum time the server waits for the hook to exit. If the hook times out, issuance is denied.
[profiles.providers.local.profiles.internal]
description = "Internal services"
auth_hook = "/etc/akamu/hooks/check-service.sh"
auth_hook_timeout_secs = 10
3. Account grants
require_account_grant = true means the account requesting the order must have this profile’s name in its profile_grants attribute.
[profiles.providers.local.profiles.privileged]
description = "Privileged cert profile"
require_account_grant = true
Grants are managed two ways:
- Admin API:
PUT /admin/account/{id}/profile-grantswith body{"profile_grants":["privileged"]}. See Admin API below. - EAB key inheritance: when an EAB key is provisioned with
profile_grants, those grants are automatically copied to any account created using that key.
An account whose profile_grants is NULL (the default) is considered to have no grants. When require_account_grant is true, such an account is denied.
MTC certificate issuance (issue_as = "mtc")
A builtin profile can issue a Merkle Tree Certificate (MTC) StandaloneCertificate instead of a standard X.509 PEM chain by setting issue_as = "mtc":
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = true
[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.mtc-tls]
description = "MTC TLS certificate"
validity_days = 90
key_usage = ["digital_signature"]
eku = ["server_auth"]
issue_as = "mtc"
When issue_as = "mtc":
- The finalize handler issues the certificate as usual (X.509
TBSCertificate). - The certificate is appended to the MTC log synchronously during finalization; the resulting leaf index is stored in the database.
- A
StandaloneCertificate(per §6.1 of draft-ietf-plants-merkle-tree-certs) is built from theTBSCertificate, a Merkle inclusion proof, and a signature from the MTC signing key. - The raw DER-encoded
StandaloneCertificateis stored in the database and served at the certificate download URL withContent-Type: application/pkix-cert.
Requirements:
[mtc]must be configured andenabled = true.[mtc.signing_key]must be configured (the standalone certificate requires a signature).- If either condition is not met, finalization returns
invalidProfile.
The download endpoint auto-detects MTC certificates by their PEM marker and switches the response Content-Type accordingly:
| Certificate type | Content-Type |
|---|---|
| Standard X.509 chain | application/pem-certificate-chain |
| MTC StandaloneCertificate | application/pkix-cert |
Admin API
The admin API is enabled by adding an [admin] section to config.toml with a listen_addr and at least one of ca_certs (for mTLS client certificates) or [admin.gssapi] (for Kerberos). See Configuration Reference — [admin] for all configuration keys.
Operators authenticate via mTLS client certificate or GSSAPI/Kerberos session token. A successful login returns a session_token that is passed as Authorization: Bearer <token> on subsequent requests. Each endpoint enforces a role-based access policy; see Admin API — Endpoint reference for the full role matrix.
When [admin] is absent from the configuration, the admin listener is not started and all admin endpoints are unreachable.
Account profile grants
GET /admin/account/{id}/profile-grants
Returns the current grants for the account:
{ "profile_grants": ["p1", "p2"] }
Returns {"profile_grants": null} when the account has no grants. Returns 404 when the account is not found.
PUT /admin/account/{id}/profile-grants
Replace the account’s grants entirely. Body:
{ "profile_grants": ["p1", "p2"] }
Send {"profile_grants": null} or {"profile_grants": []} to clear all grants (equivalent to NULL — account may use any profile). Returns 204 on success, 404 when not found.
DELETE /admin/account/{id}/profile-grants
Clear all grants for the account (set to NULL). Returns 204 on success, 404 when not found.
EAB key provisioning
POST /admin/eab
Provision a new EAB key with optional profile grants:
{
"kid": "key-id-1",
"hmac_key_b64u": "<base64url-encoded-HMAC-key>",
"profile_grants": ["p1", "p2"]
}
profile_grants is optional; omit it or pass null for no restriction. When present, any account created with this EAB key will automatically inherit these grants at account creation time.
Returns 201 with {"kid": "key-id-1", "created": <unix-epoch>} on success. Returns 409 when the kid already exists.
To generate a suitable HMAC key:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
S/MIME profile example
To issue S/MIME end-user certificates via the RFC 8823 email-reply-00 challenge, configure a profile with email_protection EKU and restrict it to email identifiers. Combine with [email_challenge] in the server configuration.
[email_challenge]
enabled = true
from_address = "acme-validation@example.com"
send_script = "/etc/akamu/send-email.sh"
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.smime]
description = "S/MIME end-user certificate (RFC 8823)"
key_usage = ["digital_signature", "non_repudiation", "key_encipherment"]
eku = ["email_protection"]
allowed_identifiers = ['^email:.*$']
validity_days = 365
The allowed_identifiers pattern '^email:.*$' restricts this profile to email identifier orders; it is rejected for DNS/IP orders. The non_repudiation bit is optional but commonly included for S/MIME signing certificates per CA/Browser Forum S/MIME Baseline Requirements.
The CSR submitted at finalize time must include an rfc822Name SAN and the emailProtection EKU. The server validates both before issuing. See email-reply-00 in the Challenges reference for the complete protocol.
Legacy [server.profiles]
Prior to the [profiles] subsystem, profile names were declared as a flat string map under [server]:
[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
This still works for advertising profile names in the directory (the meta.profiles field). However, the map is a pure label registry — no actual certificate parameters are loaded from it, and any profile name is accepted at order time (no enforcement of key usage or EKU). Use the new [profiles] section for real per-profile issuance policy.
When [profiles] providers are configured, meta.profiles is populated from the registry; [server.profiles] is ignored.
CRL and OCSP
Akāmu supports both Certificate Revocation List (CRL) and Online Certificate Status Protocol (OCSP) to communicate revocation status to relying parties. Both protocols are served directly by Akāmu at built-in endpoints.
CRL — GET /ca/{ca_id}/crl and GET /ca/crl
Akāmu generates and serves a signed v2 CRL (RFC 5280) for each configured CA. In multi-CA deployments each CA has its own CRL endpoint:
GET /ca/{ca_id}/crl # per-CA CRL
GET /ca/crl # backward-compatible alias → default CA
The CRL is built on each request from the current revocation database. No caching or pre-generation is required for typical issuance volumes. The response uses Content-Type: application/pkix-crl.
Configuring the CRL URL
Set crl_url in each [[ca]] (or [ca]) entry to the public URL of that CA’s
CRL endpoint. This URL is embedded in every issued end-entity certificate in the
CRLDistributionPoints extension.
For a single-CA deployment the URL is typically the /ca/crl alias:
[ca]
crl_url = "http://acme.example.com/ca/crl"
For multi-CA deployments use the per-CA path so each CA’s certificates point to the correct revocation list:
[[ca]]
id = "rsa"
crl_url = "http://acme.example.com/ca/rsa/crl"
[[ca]]
id = "ec"
crl_url = "http://acme.example.com/ca/ec/crl"
Clients that check CRL status fetch this URL and verify the certificate’s serial number against the revocation list.
CRL validity window
The nextUpdate field in the CRL is set to the current time plus crl_next_update_secs (default: 86400 seconds, i.e. one day):
[ca]
crl_next_update_secs = 86400 # one day (default)
Adjust this value to match how frequently clients are expected to re-fetch the CRL.
What a CRL contains
Each CRL entry carries the certificate’s serial number, the revocation timestamp, and the reason code (if one was provided at revocation time). The CRL also includes a cRLNumber extension (RFC 5280 §5.2.3) derived from the current Unix timestamp.
CRL reason codes
| 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 |
Verifying the CRL manually
curl http://acme.example.com/ca/crl | openssl crl -inform DER -text -noout
OCSP — GET /ca/{ca_id}/ocsp/{request} and POST /ca/{ca_id}/ocsp
Akāmu includes a built-in OCSP responder (RFC 6960) for each configured CA. In multi-CA deployments each CA has its own OCSP endpoint:
POST /ca/{ca_id}/ocsp # per-CA OCSP (POST)
GET /ca/{ca_id}/ocsp/{request} # per-CA OCSP (GET)
POST /ca/ocsp # backward-compatible alias → default CA
GET /ca/ocsp/{request} # backward-compatible alias → default CA
The {request} path segment is the base64url-encoded DER OCSPRequest
(RFC 6960 §A.1). Both endpoints return a signed OCSPResponse with
Content-Type: application/ocsp-response. No authentication is required.
Both endpoints return a signed OCSPResponse with Content-Type: application/ocsp-response. No authentication is required — OCSP is a public protocol.
Configuring the OCSP URL
Set ocsp_url in each [[ca]] (or [ca]) entry to the public base URL of
that CA’s OCSP endpoint. This URL is embedded in every issued end-entity
certificate in the AuthorityInfoAccess extension.
For a single-CA deployment:
[ca]
ocsp_url = "http://acme.example.com/ca/ocsp"
For multi-CA deployments use the per-CA path:
[[ca]]
id = "rsa"
ocsp_url = "http://acme.example.com/ca/rsa/ocsp"
[[ca]]
id = "ec"
ocsp_url = "http://acme.example.com/ca/ec/ocsp"
Clients sending GET requests append the base64url-encoded request to this URL. Clients sending POST requests target the URL directly.
OCSP response behaviour
For each serial number in an OCSPRequest:
| DB state | OCSP CertStatus |
|---|---|
| Certificate not found | unknown (2) |
Certificate found, status = "revoked" | revoked (1) |
| Certificate found, any other status | good (0) |
The response is signed with the CA key. The responder identity is set to byName using the CA’s subject DER.
The nextUpdate field in each SingleResponse is fixed at 24 hours after the response is produced.
Verifying OCSP manually
openssl ocsp -issuer ca.pem -cert issued.pem -url http://acme.example.com/ca/ocsp -text
Cross-certificates — GET /ca/{ca_id}/cross-certs
When cross-signing is used, the resulting cross-certificates are available at a public (unauthenticated) endpoint:
GET /ca/{ca_id}/cross-certs
This returns a JSON list of cross-certificates where {ca_id} is the
subject (the CA whose public key was cross-signed). Relying parties and
ACME clients can use this endpoint to discover cross-certificates for path
building.
{
"cross_certs": [
{
"id": "a1b2c3d4-…",
"issuer_ca_id": "rsa",
"not_before": "2026-05-06T12:00:00Z",
"not_after": "2031-05-06T12:00:00Z"
}
]
}
To download the cross-certificate PEM, use the admin API:
GET /admin/cross-certs/{id} (see Admin API — CA management endpoints)
or akamuctl cross-cert download <id>.
Checking revocation status from the database
To verify whether a specific certificate is currently marked as revoked in Akāmu’s database, query the certificates table by serial number. The serial number is printed in hex by most certificate inspection tools (for example, openssl x509 -serial -noout -in cert.pem):
SELECT serial_number, status, revoked_at, revocation_reason
FROM certificates
WHERE serial_number = '<hex-serial>';
A status value of revoked indicates the certificate has been revoked. revoked_at is a Unix timestamp of when revocation occurred, and revocation_reason is the numeric CRL reason code (or NULL if no reason was specified).
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 encodes an issued certificate in a way that allows efficient proofs of inclusion and consistency that third parties can verify independently.
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. The append happens in a background task so it does not delay the issuance response.
- The resulting leaf index is stored in the
certificatesdatabase table. If the append fails, a warning is logged but the certificate issuance response is not affected; the log index will be NULL for that certificate.
When enabled = false (the default):
- The log file is never written.
- The
log_pathmust still be specified but is not used.
Issuing MTC certificates directly from a profile
When [mtc] is enabled and [mtc.signing_key] is configured, a builtin certificate profile can be set to issue a Merkle Tree Certificate StandaloneCertificate instead of a standard X.509 PEM chain. Set issue_as = "mtc" on the profile:
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = true
[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"
key_type = "ec:P-256"
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.mtc-tls]
description = "MTC TLS certificate"
validity_days = 90
key_usage = ["digital_signature"]
eku = ["server_auth"]
issue_as = "mtc"
When a client finalizes an order with this profile, the finalize handler:
- Issues the X.509
TBSCertificateas usual. - Appends the certificate to the MTC log synchronously (not in a background task, because the leaf index is needed immediately for the standalone certificate).
- Builds a
StandaloneCertificateembedding theTBSCertificate, a Merkle inclusion proof, and a signature from the MTC signing key. - Stores the DER-encoded
StandaloneCertificateand returns the certificate URL to the client.
The certificate download endpoint (GET /acme/cert/{id}) detects MTC certificates and serves them as raw DER with Content-Type: application/pkix-cert.
If [mtc] is not enabled or [mtc.signing_key] is absent when a profile with issue_as = "mtc" is finalized, the server returns invalidProfile.
See Certificate Profiles — MTC certificate issuance for the full configuration reference.
Checkpoint signing
To enable periodic checkpoint production, add a [mtc.signing_key] section. The signing key must be distinct from the X.509 CA key (§5.5 of the MTC draft).
[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled = true
checkpoint_interval_secs = 3600 # default: 3600 (1 hour)
landmark_interval_secs = 86400 # default: 86400 (1 day)
max_active_landmarks = 100 # default: 100
hash_alg = "sha256" # leaf hash algorithm: sha256 | sha384 | sha512 | sha3-256 | sha3-384 | sha3-512
log_number = 1 # serial encoding: (log_number << 48) | entry_index
# tree_minimum_index = 0 # §5.2.3 log pruning; absent = no pruning
# trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1" # CA self-cosigner OID (§5.4)
[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key" # auto-generated if absent
key_type = "ec:P-256" # same values as [ca].key_type
hash_alg = "sha256" # sha256 | sha384 | sha512
Supported key_type values are the same set accepted for the CA key: ec:P-256, ec:P-384, ec:P-521, rsa:2048–rsa:4096, ed25519, ed448, ml-dsa-44, ml-dsa-65, ml-dsa-87. Per §5.4.2 of the draft, only ECDSA P-256/P-384, Ed25519, and ML-DSA are listed as valid cosigner signature algorithms; prefer EC or EdDSA for the MTC signing key.
When [mtc.signing_key] is present:
- At startup the server reads the PEM file at
key_file, or auto-generates a new key ofkey_typeand writes it there. - A background task fires every
checkpoint_interval_secsseconds. If the log has grown since the last checkpoint, it computes the Merkle root, constructs a signed checkpoint, and stores it in the database. - Checkpoints are idempotent: if the tree size has not grown the task is a no-op.
When [mtc.signing_key] is absent, checkpoint production is disabled.
External cosigners
After each checkpoint, Akāmu can POST the checkpoint to external cosigner servers and embed their signatures in each StandaloneCertificate.
[[mtc.cosigners]]
url = "https://cosigner.example.com/sign"
cosigner_id_cert_pem = "/etc/akamu/cosigner1.pem" # optional; path to cosigner X.509 cert PEM
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1" # optional; expected TrustAnchorID OID
Multiple [[mtc.cosigners]] entries are supported. For each entry:
- Akāmu POSTs the DER-encoded checkpoint with
Content-Type: application/octet-stream. - The cosigner returns a DER-encoded signature with HTTP 200.
- Each request has a 30-second per-cosigner timeout.
- Failures are logged and skipped — partial success is acceptable; the standalone certificate is still built with whatever signatures arrive.
When cosigner_id_cert_pem is set, the PEM file is loaded at startup and added to the TLS trust store for that cosigner’s HTTPS connection, in addition to the system root CAs. The certificate’s public key is also used for cryptographic verification of received SubtreeSignature values. This allows cosigners whose TLS certificate chains to an operator-provisioned CA to be used without installing that CA system-wide.
When trust_anchor_id is set, the SubtreeSignature.cosigner OID in each response is compared against this value. Per draft-ietf-plants-merkle-tree-certs-04 §4.1, CosignerID is TrustAnchorID ::= OBJECT IDENTIFIER; a mismatch causes the signature to be rejected.
Security constraint: Setting
trust_anchor_idwithout also settingcosigner_id_cert_pemis a hard startup error. OID-only verification provides no cryptographic assurance — anyone who knows the OID could forge a cosignature. Both fields must be set together to enable verified cosignature acceptance. When neither field is set, cosignatures are accepted without any verification and a warning is logged at startup.
Querying the log index
To find which MTC log slot a certificate occupies, query the database:
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 at issuance time.
HTTP API
The following read-only endpoints expose the log state. All return 404 when MTC is disabled (enabled = false).
GET /acme/mtc/tree-size
Returns the current number of leaves in the log.
{ "treeSize": 42 }
GET /acme/mtc/root
Returns the current tree size and the Merkle root hash as a lowercase hex string.
{ "treeSize": 42, "rootHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }
GET /acme/mtc/inclusion-proof/{cert_id}
Returns a Merkle inclusion proof for the certificate identified by cert_id (the internal UUID stored in the certificates table). Returns 404 if the certificate does not exist or has no log index.
{
"leafIndex": 7,
"treeSize": 42,
"proof": [
{ "hash": "a1b2c3..." },
{ "hash": "d4e5f6..." }
]
}
Each element of proof is an object with a single "hash" field containing the sibling hash as a lowercase hex string. The proof is ordered from the leaf up to the root. The sibling position (left or right) is determined algorithmically from the leaf index and tree size, following the standard RFC 6962 Merkle audit proof construction; it is not encoded in the response.
GET /acme/mtc/cert/{cert_id}/standalone
Returns the DER-encoded standalone certificate (§6.1 of the MTC draft) for the given certificate, with Content-Type: application/octet-stream.
The standalone certificate embeds the certificate’s TBS data, a Merkle inclusion proof, and a signature from the MTC signing key. Relying parties can verify the certificate’s presence in the log without querying the CA.
Returns 404 when:
- MTC is disabled
- The certificate does not exist
- The certificate has no MTC log index (the log append failed at issuance)
- A checkpoint covering the certificate has not yet been produced (the standalone certificate is built during the next checkpoint cycle)
GET /acme/mtc/landmarks
Returns a JSON array of all allocated landmarks, ordered by sequence number ascending.
[
{ "sequenceNo": 0, "treeSize": 100, "createdAt": 1700000000 },
{ "sequenceNo": 1, "treeSize": 250, "createdAt": 1700086400 }
]
Returns 404 when MTC is disabled.
GET /acme/mtc/landmarks/{seq}/cert
Returns the DER-encoded landmark certificate (§6.3.1 of the MTC draft) for the landmark with sequence number seq, with Content-Type: application/octet-stream.
Returns 404 when:
- MTC is disabled
- No landmark with that sequence number exists
- The landmark certificate has not yet been built
GET /acme/mtc/consistency-proof?from={old_size}&to={new_size}
Returns the Merkle roots at two tree sizes so a monitor can verify that the tree at to extends the tree at from.
{
"fromSize": 10,
"toSize": 42,
"fromRoot": "a1b2c3...",
"toRoot": "d4e5f6..."
}
Both from and to must be positive integers with from < to and to <= current tree size. Returns 400 for invalid parameters.
GET /acme/mtc/subtree-root?start={start}&end={end}
Returns the Merkle root hash for the subtree [start, end). The subtree must satisfy the alignment constraint from draft-04 §4.3.1 (start is a multiple of BIT_CEIL(end - start)).
{
"start": 0,
"end": 256,
"rootHash": "e3b0c442..."
}
GET /acme/mtc/revoked-ranges
Returns a JSON array of [start, end] pairs representing revoked log entry index ranges (draft-04 §5.6). Relying parties use these to reject standalone certificates whose serial number falls within a revoked range.
[[10, 15], [100, 120]]
Returns 404 when MTC is disabled.
C2SP tlog-tiles API
When [mtc.signing_key] is configured, three additional endpoints implement the C2SP tlog-tiles and C2SP signed-note specifications, enabling compatibility with transparency-log clients that speak the tlog-tiles protocol.
All three endpoints return 404 when MTC is disabled. GET /acme/mtc/tlog/checkpoint and GET /acme/mtc/tlog/cosignature additionally require a signing key to be configured; without one they return 503.
GET /acme/mtc/tlog/checkpoint
Returns the current tree as a C2SP signed-note checkpoint signed by the MTC signing key acting as the primary log operator.
- Ed25519 key: signature type 0x01.
- ECDSA key: signature type 0x02.
Response Content-Type is text/plain; charset=utf-8. The note body format is:
<log origin>
<tree_size>
<base64(root_hash)>
— <key_name> <base64(key_id || signature)>
GET /acme/mtc/tlog/tile/{*path}
Serves a C2SP hash tile. The path component encodes {level}/{tile_index_path}[.p/{width}]:
levelis 0 for leaf-hash tiles, or L > 0 for Merkle subtree roots (covering 256^L leaves each).tile_index_pathis the C2SP multi-level decimal encoding (e.g.000,x001/234).- The optional
.p/{width}suffix requests a partial tile with fewer than 256 entries.
Response Content-Type is application/octet-stream; each hash entry is 32, 48, or 64 bytes depending on the [mtc].hash_alg configured for the log (SHA-256/SHA3-256, SHA-384/SHA3-384, or SHA-512/SHA3-512 respectively).
Returns 404 when the tile is entirely beyond the current log size. Returns 501 for tile/entries/... paths because Akāmu stores only leaf hashes, not raw entry data.
GET /acme/mtc/tlog/cosignature
Returns a C2SP cosignature note for the current checkpoint, produced by the MTC signing key acting as a cosigner. The current POSIX timestamp is embedded in the signature blob.
- Ed25519 key: cosignature type 0x04 (
cosignature/v1signed-note format). - ML-DSA-44 key: cosignature type 0x06 (
subtree/v1binary cosigned message). - ECDSA key: uses the operator format (type 0x02) because no dedicated ECDSA cosignature type is defined by C2SP.
Response Content-Type is text/plain; charset=utf-8.
Landmark management
A landmark is a frozen snapshot of the tree size at a point in time. Relying parties use landmarks to anchor inclusion proofs across the log’s lifetime without tracking every checkpoint.
When [mtc.signing_key] is configured, a background task fires every landmark_interval_secs seconds (default: 86400 = 1 day). If the tree has grown since the last landmark, a new landmark is built and stored in the database. Rows beyond max_active_landmarks (default: 100) are pruned automatically, removing the oldest landmarks by sequence number.
Log integrity
The log is append-only by design. Once a leaf is appended it cannot be removed or modified without corrupting the file. A single Akāmu process is the exclusive writer. At startup, Akāmu acquires an exclusive advisory lock on <log_path>.lock; if another process already holds the lock the server exits immediately with a clear error rather than proceeding to corrupt the log.
For details on the internal log format, appending algorithm, checkpoint production, and concurrency model, see MTC Implementation in the Developer Guide.
MTC Cosigner Daemon
akamu-cosigner is a standalone binary that acts as an external MTC (Merkle Tree Certificate) cosigner. When the main akamu server produces a checkpoint, it POSTs the DER-encoded Checkpoint to each configured cosigner URL. akamu-cosigner signs the checkpoint with its own key and returns a DER-encoded SubtreeSignature. The signature is then embedded in every StandaloneCertificate produced from that checkpoint.
Operators who run an independent MTC log can expose akamu-cosigner as a public cosigning service. Operators who want additional signatures on their own log can run one or more cosigner instances as part of their infrastructure.
Binary
Build from source:
cargo build -p akamu-cosigner --release
The binary is placed at target/release/akamu-cosigner.
Run:
akamu-cosigner /etc/akamu/cosigner.toml
If no argument is given, the daemon looks for cosigner.toml in the current working directory.
Configuration file
akamu-cosigner reads a single TOML configuration file. All sections are described below.
Complete example
[server]
listen_addr = "0.0.0.0:8080"
base_url = "https://cosigner.example.com"
[tls]
cert_file = "/etc/akamu/cosigner-tls.crt"
key_file = "/etc/akamu/cosigner-tls.key"
[signing_key]
key_file = "/var/lib/akamu/cosigner-signing.key"
key_type = "ec:P-256"
hash_alg = "sha256"
[cosigner_id]
cert_file = "/var/lib/akamu/cosigner-id.crt"
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"
[acme_bootstrap]
server_url = "https://acme.example.com/acme/directory"
account_email = "ops@example.com"
eab_kid = "my-eab-key-id"
eab_hmac = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg"
domain = "cosigner.example.com"
challenge_type = "http-01"
cert_file = "/var/lib/akamu/cosigner-tls.crt"
key_file = "/var/lib/akamu/cosigner-tls.key"
[server]
listen_addr
Optional. Default: "0.0.0.0:8080".
Address the daemon binds to. Two forms are accepted:
| Form | Example | Description |
|---|---|---|
"host:port" | "0.0.0.0:8080" | TCP socket |
"unix:/path" or "/path" | "unix:/run/akamu/akamu-cosigner.sock" | Unix domain socket |
[server]
listen_addr = "0.0.0.0:8443"
# Or, for a Unix domain socket behind a reverse proxy:
# listen_addr = "unix:/run/akamu/akamu-cosigner.sock"
The AKAMU_COSIGNER_LISTEN environment variable overrides this field:
AKAMU_COSIGNER_LISTEN=unix:/run/akamu/akamu-cosigner.sock akamu-cosigner /etc/akamu/cosigner.toml
Constraint: Unix domain sockets and [tls] are mutually exclusive. When listen_addr is a Unix path and a [tls] section is present (or [acme_bootstrap] is configured, which implies TLS), the daemon exits at startup with an error.
Systemd socket activation: The provided akamu-cosigner.socket unit pre-binds /run/akamu/akamu-cosigner.sock (mode 0660, user/group akamu-cosigner). Enable with systemctl enable --now akamu-cosigner.socket akamu-cosigner.service. When socket activation is active, listen_addr in the config file is ignored — the pre-bound socket is passed via LISTEN_FDS.
base_url
Required.
Public HTTPS base URL of the cosigner. Used as the dNSName SAN when auto-generating the self-signed cosigner-id certificate.
[server]
base_url = "https://cosigner.example.com"
[tls]
Optional. When present, the daemon serves HTTPS using the given certificate and key. When absent, the daemon listens on plain HTTP — only suitable behind a TLS-terminating reverse proxy.
When [acme_bootstrap] is configured and the [tls] section is absent, the daemon derives TLS configuration from the ACME-issued certificate and key paths specified in [acme_bootstrap].
cert_file
Required within [tls]. Path to the TLS server certificate PEM file (leaf + chain).
key_file
Required within [tls]. Path to the TLS server private key PEM file.
[tls]
cert_file = "/etc/akamu/cosigner-tls.crt"
key_file = "/etc/akamu/cosigner-tls.key"
[signing_key]
The MTC signing key. This key must be distinct from any TLS certificate key. Its public half is embedded in the cosigner-id certificate so that relying parties can verify the SubtreeSignature.
key_file
Required. Path to the signing key PEM file. If the file does not exist, a new key of key_type is generated and written here on startup.
key_type
Optional. Default: "ec:P-256".
Key algorithm for auto-generation. Accepts the same values as [[ca]].key_type in akamu: "ec:P-256", "ec:P-384", "ec:P-521", "rsa:2048", "rsa:3072", "rsa:4096", "ed25519", "ed448".
hash_alg
Optional. Default: "sha256".
Hash algorithm for ECDSA/RSA signing: "sha256", "sha384", "sha512". Ignored for EdDSA keys.
[signing_key]
key_file = "/var/lib/akamu/cosigner-signing.key"
key_type = "ec:P-256"
hash_alg = "sha256"
[cosigner_id]
The cosigner identity configuration. Per draft-ietf-plants-merkle-tree-certs-04 §4.1, CosignerID is now TrustAnchorID ::= OBJECT IDENTIFIER. The trust_anchor_id OID is embedded in every SubtreeSignature.cosigner field so that akamu servers and relying parties can identify which cosigner produced the signature. The certificate file is retained for cryptographic verification by relying parties.
cert_file
Required. Path to the cosigner-id PEM certificate file.
- If the file exists, it is loaded as the identity certificate.
- If the file is absent and
[acme_bootstrap]is configured and the bootstrap certificate exists, that certificate is used as the cosigner-id. - Otherwise, a self-signed certificate is auto-generated from the
[signing_key]and written to this path. The self-signed cert usesbase_url’s hostname as its dNSName SAN and has 10-year validity.
trust_anchor_id
Required. The OID (dotted-decimal) that identifies this cosigner as a TrustAnchorID. This value is embedded in every SubtreeSignature.cosigner field. Operators must agree on this OID with log operators before deploying. Example: "1.3.6.1.4.1.44363.47.10.1".
[cosigner_id]
cert_file = "/var/lib/akamu/cosigner-id.crt"
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"
[acme_bootstrap]
Optional. When present, akamu-cosigner uses akamu-client to obtain a certificate from the configured ACME server at startup (if the certificate is absent or expiring within 30 days). The issued certificate is used as both the TLS server certificate and the source of the cosigner-id.
server_url
Required. ACME server directory URL, e.g. "https://acme.example.com/acme/directory".
account_email
Optional. Contact e-mail for the ACME account. The mailto: prefix is added automatically.
eab_kid
Required. External Account Binding key identifier provisioned by the CA.
eab_hmac
Required. EAB HMAC key, base64url-encoded without padding.
domain
Required. DNS name to certify. Must be publicly resolvable for the chosen challenge type.
challenge_type
Optional. Default: "http-01".
ACME challenge type to use: "http-01", "dns-01", "dns-persist-01", or "tls-alpn-01".
dns_hook
Optional. Shell command invoked for dns-01 DNS provisioning. Called with ACME_DOMAIN and ACME_TXT_VALUE environment variables set. An exit code of 0 indicates the record was provisioned. When absent and challenge_type = "dns-01", the daemon logs the required TXT record and exits with an error — the operator must set the record manually and restart.
dns_persist_hook
Optional. Shell command invoked for dns-persist-01 DNS provisioning. Called with the following environment variables:
| Variable | Value |
|---|---|
ACME_DOMAIN | The domain being certified |
ACME_TXT_NAME | Full record name, e.g. _validation-persist.example.com |
ACME_TXT_VALUE | Full record value, e.g. "acme.example.com; accounturi=https://…" |
ACME_ACCOUNT_URI | The ACME account URI |
ACME_ISSUER_DOMAIN | The issuer domain from the challenge |
An exit code of 0 indicates the record was provisioned.
cert_file
Required. Where to write the issued certificate PEM chain.
key_file
Required. Where to write the private key PEM for the issued certificate.
csr_key_type
Optional. Default: "ec:P-256".
Key type for the ACME CSR key. Accepts the same values as [signing_key].key_type.
[acme_bootstrap]
server_url = "https://acme.example.com/acme/directory"
account_email = "ops@example.com"
eab_kid = "my-eab-key-id"
eab_hmac = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg"
domain = "cosigner.example.com"
challenge_type = "http-01"
cert_file = "/var/lib/akamu/cosigner-tls.crt"
key_file = "/var/lib/akamu/cosigner-tls.key"
csr_key_type = "ec:P-256"
HTTP endpoints
akamu-cosigner exposes the following endpoints:
POST /sign
Accepts a DER-encoded Checkpoint (Content-Type: application/octet-stream). Returns a DER-encoded SubtreeSignature with HTTP 200.
The SubtreeSignature covers the full checkpoint range [0, tree_size) and is signed with the [signing_key]. The cosigner field in the response contains the TrustAnchorID OID configured in [cosigner_id].trust_anchor_id.
GET /.well-known/acme-challenge/:token
Serves http-01 challenge tokens during the ACME bootstrap phase. Only active while the bootstrap flow is running; this endpoint returns 404 at all other times.
Integrating with akamu
Add a [[mtc.cosigners]] entry in the akamu server configuration for each akamu-cosigner instance:
[[mtc.cosigners]]
url = "https://cosigner.example.com/sign"
cosigner_id_cert_pem = "/etc/akamu/cosigner-id.crt"
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1" # optional; must match cosigner's [cosigner_id].trust_anchor_id
See Configuration Reference — [[mtc.cosigners]] for the full field reference.
Startup sequence
On each startup, akamu-cosigner:
- Loads and validates the configuration file.
- Loads or generates the
[signing_key]. - Runs the ACME bootstrap if
[acme_bootstrap]is configured and the certificate is absent or expiring within 30 days. - Loads or generates the cosigner-id certificate at
[cosigner_id].cert_file. - Starts the HTTP (or HTTPS) server.
The daemon does not persist any state other than the key and certificate files on disk.
Logging
akamu-cosigner uses the tracing crate. Control log verbosity with the RUST_LOG environment variable:
RUST_LOG=info akamu-cosigner /etc/akamu/cosigner.toml
RUST_LOG=debug akamu-cosigner /etc/akamu/cosigner.toml
Admin interface
akamu-cosigner includes its own admin HTTP listener. When the [admin]
section is present in the configuration file, the daemon starts a second
HTTPS server for operator access. The admin listener is independent of the
signing endpoint and supports the same mTLS and session-token authentication
model as the main akamu server.
When the [admin] section is absent, all admin endpoints return 401 Unauthorized and a warning is logged at startup.
Configuration
Add an [admin] section and one or more [[admin.operators]] entries to
cosigner.toml:
[admin]
listen_addr = "127.0.0.1:9444"
cert_file = "/etc/akamu/cosigner-admin-tls.pem"
key_file = "/etc/akamu/cosigner-admin-tls.key"
ca_certs = ["/etc/akamu/operator-ca.pem"]
session_ttl_secs = 3600
[[admin.operators]]
name = "alice"
role = "administrator"
cert_fingerprint = "a3b4c5…" # SHA-256 hex of the DER leaf cert
[[admin.operators]]
name = "bob"
role = "auditor"
cert_fingerprint = "b2c3d4…"
Operators are defined statically in the config file rather than in a database. Changes to the operator list require a daemon restart.
[admin] fields
| Field | Required | Default | Description |
|---|---|---|---|
listen_addr | Yes | — | TCP address and port for the admin listener. |
cert_file | Yes | — | PEM file for the admin listener’s TLS server certificate. |
key_file | Yes | — | PEM file for the admin listener’s TLS private key. |
ca_certs | No | [] | PEM CA files whose issued client certificates are accepted as operator credentials. |
session_ttl_secs | No | 3600 | Idle session expiry in seconds. |
[[admin.operators]] fields
| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable operator name (shown in logs). |
role | Yes | "administrator" or "auditor". |
cert_fingerprint | At least one | Lowercase hex SHA-256 of the DER leaf certificate. |
gssapi_principal | At least one | Kerberos principal (reserved for future use). |
Admin endpoints
| Method | Path | administrator | auditor |
|---|---|---|---|
POST | /admin/session | Y | Y |
DELETE | /admin/session | Y | Y |
GET | /admin/status | Y | Y |
GET | /admin/stats | Y | Y |
GET | /admin/config | Y |
POST /admin/session
Authenticate with a client certificate and receive a session token.
Response 200 OK:
{
"session_token": "a4f1…64-hex-chars…",
"role": "administrator",
"expires_at": "2026-05-02T14:00:00Z"
}
DELETE /admin/session
Invalidate the current session token.
Response: 204 No Content.
GET /admin/status
Liveness check. All authenticated operators may call this endpoint.
Response 200 OK:
{ "status": "ok", "uptime_secs": 3600 }
GET /admin/stats
Return signing statistics.
Response 200 OK:
{
"uptime_secs": 3600,
"checkpoints_signed": 42,
"last_checkpoint_at": "2026-05-02T13:45:00Z"
}
GET /admin/config
Return a redacted view of the running configuration: operator names, roles, and
session TTL. Private key material and CA certificate paths are not included.
Requires the administrator role.
Response 200 OK:
{
"operators": [
{ "name": "alice", "role": "administrator" },
{ "name": "bob", "role": "auditor" }
],
"session_ttl_secs": 3600
}
Querying via akamuctl
The akamuctl cosigner subcommands target the cosigner’s admin listener.
Configure the connection in the [cosigner] section of
~/.config/akamu/akamuctl.toml:
[cosigner]
url = "https://cosigner.example.com:9444"
ca_cert = "/etc/akamu/cosigner-admin-ca.pem"
cert_file = "/etc/akamu/operator.pem"
key_file = "/etc/akamu/operator.key"
akamuctl cosigner status
akamuctl cosigner stats
See akamuctl — Cosigner administration for the full command reference.
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 server generates a TLS certificate automatically:
- A fresh server key is generated using the
bootstrap_key_typealgorithm (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", "ed448".
# 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 via the OpenSSL backend.
- Composite ML-DSA+classical TLS 1.3
CertificateVerifysignatures: provisional code points from draft-reddy-tls-composite-mldsa are advertised and verified.
Classical algorithms are always verified using a standard cryptographic backend. 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: the TLS
SignatureSchemecode points for composite ML-DSA+classical schemes are provisional values from draft-reddy-tls-composite-mldsa (all TBD pending IANA allocation). The corresponding X.509 OIDs are defined in draft-ietf-lamps-pq-composite-sigs. If assigned code points differ from the provisional values used here, a code update will be required before deploying to production. -
Composite scheme support depends on the OpenSSL version: composite ML-DSA+classical
CertificateVerifyverification requires OpenSSL 3.5 or later with composite NID support. If the installed OpenSSL version does not support the required NIDs, verification will return an OpenSSL error at runtime. -
Pure ML-DSA TLS signature schemes: no IANA code points exist yet for standalone ML-DSA (non-composite) TLS schemes. Even with
allow_post_quantum = true, only composite schemes are advertised. -
Client remote address: when native TLS is active, the client’s remote IP address is available to handlers through the standard axum connection-info mechanism.
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: … | OpenSSL does not support 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, connection pool tuning, and capacity planning.
All numbers were collected on a single host — Intel Core i7-12800H (14 cores /
20 threads, 63 GB RAM, Fedora Linux 6.15, OpenSSL 3.5.6) — using the
acme-bench tool in two modes:
- Process mode (
--spawn process): the server runs as a separate OS process with its own Tokio runtime, memory allocator, and SQLite:memory:database. This matches how a real deployment behaves. Heap allocation numbers reflect the client side only. - Inprocess mode (default): server and clients share a single process. This mode enables SQLite backend, connection pool, and read-only pool split benchmarks that require shared-process access to the database layer. Heap allocation numbers include both client and server.
Audit events are written to a JSONL file (/tmp/akamu-bench-audit.jsonl) in
both modes.
The benchmark runs full ACME workflows (new-order → authz → challenge validate
→ finalize → certificate download). Latency is end-to-end wall time from
new-order through certificate download; account creation is amortised and
excluded. Default configuration uses ec:P-256 client keys, ec:P-256 CA key,
and http-01 challenge.
The full benchmark suite can be run with
contrib/performance/run_benchmarks.sh, which writes newline-delimited JSON
results to a file for post-processing. Set SPAWN_MODE="--spawn process" to
run the suite in process mode.
Concurrency
With ec:P-256 certificates, http-01 validation, and SQLite :memory:,
throughput peaks at 5–10 concurrent clients in both modes and degrades
under higher concurrency as queue depth grows.
Process mode
| Clients | Throughput (iss/s) | Mean (ms) | p99 (ms) | new_order | authz | challenge | finalize | download |
|---|---|---|---|---|---|---|---|---|
| 1 | 100 | 10.2 | 13.7 | 1.3 | 1.0 | 4.3 | 3.4 | 0.4 |
| 5 | 975 | 5.0 | 6.3 | 0.6 | 0.4 | 2.6 | 1.2 | 0.2 |
| 10 | 1,098 | 8.7 | 14.7 | 1.6 | 0.6 | 4.0 | 2.2 | 0.3 |
| 25 | 1,208 | 19.0 | 24.3 | 4.6 | 0.5 | 8.2 | 5.4 | 0.3 |
| 50 | 1,015 | 34.5 | 73.7 | 9.2 | 0.5 | 17.7 | 6.9 | 0.2 |
Inprocess mode
| Clients | Throughput (iss/s) | Mean (ms) | p99 (ms) | new_order | authz | challenge | finalize | download |
|---|---|---|---|---|---|---|---|---|
| 1 | 120 | 8.3 | 13.1 | 1.1 | 0.8 | 3.7 | 2.4 | 0.3 |
| 5 | 818 | 6.1 | 8.0 | 0.6 | 0.5 | 2.8 | 1.8 | 0.3 |
| 10 | 854 | 11.5 | 13.9 | 1.2 | 1.1 | 4.3 | 4.1 | 0.8 |
| 25 | 889 | 27.5 | 36.3 | 3.2 | 3.2 | 8.1 | 11.1 | 1.9 |
| 50 | 681 | 67.0 | 80.5 | 7.5 | 7.0 | 20.1 | 26.6 | 5.7 |
Phase columns show mean milliseconds per ACME step.
Process mode peaks at c=5–10 (975–1,098 iss/s) with sub-9 ms mean
latency, driven by read-only pool separation, crypto caching, and
spawn_blocking for certificate signing. Inprocess mode peaks at c=5–25
(818–889 iss/s). Process mode shows lower download times (0.2 ms vs
1–6 ms) because certificate delivery bypasses the shared-process HTTP stack.
Inprocess mode shows higher authz and download overhead at high concurrency
due to Tokio task contention within the single runtime.
Client key type
The client key type is the largest single determinant of per-issuance latency. All runs use ec:P-256 CA; process mode uses 25 concurrent clients, inprocess mode uses 50.
Process mode
| CSR key type | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) | Alloc/iss |
|---|---|---|---|---|---|
| ed25519 | 561 | 33.0 | 69.1 | 14.2 | 166 KB |
| ec:P-256 | 556 | 33.6 | 57.6 | 15.5 | 164 KB |
| ML-DSA-44 | 523 | 37.9 | 66.8 | 17.2 | 243 KB |
| ML-DSA-65 | 511 | 41.1 | 52.2 | 22.4 | 269 KB |
| ML-DSA-87 | 418 | 45.5 | 88.5 | 23.0 | 313 KB |
| ec:P-384 | 377 | 53.2 | 71.7 | 28.2 | 175 KB |
| rsa:2048 | 153 | 124.0 | 266.8 | 88.9 | 166 KB |
| rsa:4096 | 13 | 1156.6 | 2345.5 | 779.8 | 223 KB |
Inprocess mode
| CSR key type | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) | Alloc/iss |
|---|---|---|---|---|---|
| ec:P-256 | 770 | 54.6 | 70.0 | 20.7 | 434 KB |
| ed25519 | 742 | 54.1 | 72.8 | 20.2 | 432 KB |
| ML-DSA-44 | 685 | 60.4 | 75.0 | 24.0 | 575 KB |
| ML-DSA-65 | 589 | 69.9 | 98.8 | 30.0 | 624 KB |
| ML-DSA-87 | 540 | 77.3 | 99.9 | 34.7 | 696 KB |
| ec:P-384 | 487 | 85.7 | 97.3 | 46.3 | 438 KB |
| rsa:2048 | 157 | 279.7 | 554.2 | 165.1 | 454 KB |
| rsa:4096 | 15 | 2506.9 | 4099.1 | 1496.2 | 531 KB |
In process mode ed25519 and ec:P-256 are effectively tied (~33 ms, 556–561 iss/s). ML-DSA variants perform well: ML-DSA-44 at 523 iss/s is only 6% slower than ec:P-256. EC P-384 is consistently slower than ML-DSA-87 in both modes due to its heavier finalize cost.
RSA 2048 is 3.6–4.7× slower than ec:P-256; RSA 4096 at ~1,160 ms mean is dominated entirely by key generation.
RSA 4096 is strongly discouraged for ACME clients in multi-client deployments.
RSA 4096 saturation
RSA 4096 key generation is CPU-wall-limited. Adding concurrency barely improves throughput while latency grows linearly.
Process mode
| Clients | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) |
|---|---|---|---|---|
| 1 | 3 | 375 | 1,215 | 370 |
| 10 | 13 | 691 | 2,292 | 666 |
| 25 | 15 | 1,334 | 3,463 | 1,068 |
| 50 | 15 | 2,417 | 4,831 | 1,283 |
Inprocess mode
| Clients | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) |
|---|---|---|---|---|
| 1 | 3 | 357 | 922 | 353 |
| 10 | 14 | 651 | 2,817 | 647 |
| 25 | 14 | 1,340 | 4,501 | 1,155 |
| 50 | 13 | 2,804 | 4,441 | 1,749 |
Throughput saturates at ~13–15 iss/s regardless of concurrency or mode. At c=50, p99 reaches 4.4–4.8 seconds. This is entirely client-side key generation; the server is idle waiting for CSRs.
CA key type
CA signing is server-side. The CA key type directly affects the finalize phase; other phases are unaffected. All runs use ec:P-256 client keys; process mode uses 25 concurrent clients, inprocess mode uses 50.
Process mode
| CA key | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) |
|---|---|---|---|---|
| ec:P-256 | 624 | 31.0 | 48.5 | 14.9 |
| rsa:2048 | 466 | 42.7 | 61.4 | 25.5 |
| ec:P-384 | 307 | 66.3 | 95.9 | 38.5 |
| rsa:3072 | 266 | 76.0 | 94.1 | 49.2 |
| rsa:4096 | 183 | 116.5 | 165.8 | 89.9 |
Inprocess mode
| CA key | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) |
|---|---|---|---|---|
| ec:P-256 | 729 | 58.3 | 68.4 | 22.8 |
| ec:P-384 | 663 | 63.1 | 75.0 | 25.7 |
| rsa:2048 | 643 | 64.5 | 82.7 | 26.9 |
| rsa:3072 | 598 | 69.3 | 81.5 | 31.7 |
| rsa:4096 | 518 | 80.4 | 99.6 | 38.5 |
EC P-256 is the fastest CA key type and the recommended default. In process mode, RSA 2048 CA (466 iss/s) outperforms EC P-384 CA (307 iss/s) because OpenSSL’s RSA 2048 signing is faster than ECDSA P-384; in inprocess mode RSA 2048 and EC P-384 are close (643 vs 663 iss/s). RSA 4096 as CA reduces throughput to 183–518 iss/s.
Post-quantum chain
Akāmu supports ML-DSA (FIPS 204 / RFC 9881) CA keys at three NIST security
levels. The table measures a full post-quantum chain (matching ML-DSA CA +
ML-DSA client keys, with --verify-cert) and compares to an ec:P-256
baseline. Process mode uses 25 concurrent clients, inprocess mode uses 50.
Process mode
| CA + client | NIST cat. | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) | vs P-256 | Alloc/iss |
|---|---|---|---|---|---|---|---|
| ec:P-256 | — | 526 | 36.7 | 70.0 | 16.0 | — | 170 KB |
| ML-DSA-44 | 2 | 362 | 55.7 | 75.1 | 34.6 | +52% | 257 KB |
| ML-DSA-65 | 3 | 298 | 68.6 | 86.0 | 46.0 | +87% | 312 KB |
| ML-DSA-87 | 5 | 250 | 80.5 | 105.8 | 56.6 | +119% | 385 KB |
Inprocess mode
| CA + client | NIST cat. | Throughput (iss/s) | Mean (ms) | p99 (ms) | Finalize (ms) | vs P-256 | Alloc/iss |
|---|---|---|---|---|---|---|---|
| ec:P-256 | — | 730 | 56.0 | 68.2 | 21.9 | — | 438 KB |
| ML-DSA-44 | 2 | 637 | 64.1 | 84.3 | 28.4 | +14% | 671 KB |
| ML-DSA-65 | 3 | 551 | 75.6 | 89.5 | 32.3 | +35% | 770 KB |
| ML-DSA-87 | 5 | 508 | 83.8 | 96.9 | 40.3 | +50% | 914 KB |
ML-DSA-44 shows a smaller overhead in process mode (+52% vs +14% inprocess) because the server’s larger ML-DSA signature is generated out-of-process without competing for the client’s Tokio runtime. Allocation pressure in inprocess mode (671–914 KB) reflects both client and server heap usage; process mode (257–385 KB) reflects client-side only.
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.
Challenge type
All runs use ec:P-256 keys and SQLite :memory:; process mode uses 25
concurrent clients, inprocess mode uses 50.
Process mode
| Challenge | Throughput (iss/s) | Mean (ms) | p99 (ms) | Challenge phase (ms) |
|---|---|---|---|---|
| http-01 | 615 | 31.0 | 71.1 | 9.6 |
| dns-persist-01 | 544 | 37.7 | 62.9 | 14.7 |
Inprocess mode
| Challenge | Throughput (iss/s) | Mean (ms) | p99 (ms) | Challenge phase (ms) |
|---|---|---|---|---|
| http-01 | 746 | 57.3 | 65.3 | 17.7 |
| dns-persist-01 | 623 | 67.8 | 83.7 | 26.7 |
dns-persist-01 adds 5–9 ms to the challenge phase, reducing throughput by
12–16% in both modes. Both challenge types deliver zero errors across all
runs.
Backend comparison
SQLite :memory: versus a tmpfs-backed WAL file (/dev/shm), sweeping
concurrency with ec:P-256 keys and http-01. Inprocess mode only — process
mode always uses :memory:. The tmpfs backend uses a write coalescer that
batches concurrent writes through a single connection, eliminating BEGIN IMMEDIATE contention.
| Clients | :memory: (iss/s) | :memory: mean (ms) | tmpfs (iss/s) | tmpfs mean (ms) | Delta |
|---|---|---|---|---|---|
| 1 | 114 | 8.7 | 110 | 9.1 | −4% |
| 5 | 746 | 6.7 | 845 | 5.9 | +13% |
| 10 | 836 | 11.8 | 1,112 | 8.9 | +33% |
| 25 | 872 | 28.1 | 1,030 | 23.6 | +18% |
| 50 | 747 | 60.6 | 910 | 48.7 | +22% |
| 75 | 681 | 96.8 | 932 | 68.6 | +37% |
With the write coalescer, tmpfs WAL outperforms :memory: at c≥5: the
coalescer serialises writes on a dedicated connection, avoiding contention
that :memory: still experiences through the pool. Peak tmpfs throughput is
1,112 iss/s at c=10 versus 872 iss/s for :memory: at c=25. Tmpfs WAL is
the recommended backend for deployments that need crash-recoverable state
without the complexity of PostgreSQL.
Connection pool
Connection pool sizing affects throughput when multiple concurrent clients contend for database reads. The write coalescer handles all writes through a dedicated connection, so the pool primarily serves read operations. Inprocess mode with tmpfs WAL backend — process mode ignores pool settings.
| Pool | c=1 (iss/s) | c=5 (iss/s) | c=10 (iss/s) | c=25 (iss/s) | c=50 (iss/s) |
|---|---|---|---|---|---|
| 1 | 129 | 917 | 1,074 | 1,168 | 934 |
| 2 | 134 | 954 | 1,407 | 1,175 | 1,141 |
| 4 | 117 | 970 | 1,075 | 1,588 | 1,295 |
| 8 | 114 | 942 | 1,106 | 1,531 | 1,127 |
At c=1 pool size is irrelevant. At c=25, pool=4 delivers the best throughput (1,588 iss/s) — a 36% improvement over pool=1 (1,168 iss/s). Pool=4 is the recommended choice: it delivers the highest peak throughput while maintaining reasonable p99 latency (22.8 ms at c=25).
Pool sizes above 4 show diminishing returns; pool=8 at c=25 reaches 1,531 iss/s (−4% vs pool=4) with slightly higher p99 variance.
Read-only pool split
Splitting read-only handlers (get_order, get_authz, download_cert, star_cert,
renewal_info, ocsp) onto a separate ?mode=ro connection pool frees the
write pool for write-path handlers. Inprocess mode with tmpfs WAL — process
mode ignores pool settings.
| Clients | No split (iss/s) | Split ro=4 (iss/s) | Improvement |
|---|---|---|---|
| 1 | 113 | 110 | −3% |
| 5 | 894 | 990 | +11% |
| 10 | 1,253 | 1,154 | −8% |
| 25 | 1,197 | 1,548 | +29% |
| 50 | 1,009 | 1,430 | +42% |
The split delivers significant gains at c≥25 where read contention competes with the write coalescer. Peak improvement is +42% at c=50 (1,430 vs 1,009 iss/s). At lower concurrency the overhead of managing a separate pool can slightly reduce throughput.
RO connection sweep at c=10
| ro-connections | Throughput (iss/s) | Mean (ms) | p99 (ms) |
|---|---|---|---|
| 1 | 1,201 | 8.2 | 12.8 |
| 2 | 1,207 | 8.1 | 13.2 |
| 4 | 1,149 | 8.6 | 14.8 |
| 8 | 1,121 | 8.8 | 15.6 |
| 16 | 1,223 | 8.1 | 14.3 |
At c=10, all RO connection counts perform similarly (1,121–1,223 iss/s). ro=1 or ro=2 is the recommended setting for typical deployments; higher counts add connection overhead without meaningful throughput gain.
Key type recommendations
| Scenario | Recommended 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) |
Capacity planning
Single-node throughput for ec:P-256 keys, http-01:
| Target throughput | Configuration | Expected mean latency | Notes |
|---|---|---|---|
| ≤100 iss/s | 1 client, pool=1 | ~9 ms | Minimal deployment |
| ≤1,000 iss/s | 5–10 clients | 6–12 ms | Sweet spot: low latency, high throughput |
| ≤1,200 iss/s | 25 clients | ~20 ms | Near :memory: ceiling |
| ≤1,600 iss/s | 25 clients, pool=4, tmpfs WAL | ~14 ms | Coalescer + pool tuning |
Figures assume ec:P-256 keys and http-01 challenge. RSA or ML-DSA keys lower throughput proportionally.
For the database backend: SQLite :memory: suits nodes with no persistent
state requirement (accounts, orders, and certificates are lost on restart).
Tmpfs WAL (/dev/shm) with the write coalescer outperforms :memory: under
concurrency (up to 1,588 iss/s vs ~889 iss/s) and provides crash-recoverable
state. For persistent deployments, PostgreSQL is recommended; use a
connection pool of 20–25 ([database] pool_connections = 25).
Memory
The benchmark instruments heap allocation using a custom GlobalAlloc wrapper.
Per-issuance allocation pressure — bytes requested from the system allocator
per certificate, including memory subsequently freed — varies by configuration
and mode.
In process mode, allocation reflects the client side only (server runs in a separate process); in inprocess mode it includes both client and server.
Process mode (client-side allocation)
| Configuration | Per-issuance alloc |
|---|---|
| ec:P-256 CA + ec:P-256 client, c=1 | 134 KB |
| ec:P-256 CA + ec:P-256 client, c=5 | 134 KB |
| ec:P-256 CA + ec:P-256 client, c=10 | 137 KB |
| ec:P-256 CA + ec:P-256 client, c=50 | 190 KB |
| ec:P-256 CA + rsa:4096 client, c=25 | 223 KB |
| ML-DSA-44 CA + ML-DSA-44 client, c=25 | 257 KB |
| ML-DSA-65 CA + ML-DSA-65 client, c=25 | 312 KB |
| ML-DSA-87 CA + ML-DSA-87 client, c=25 | 385 KB |
Inprocess mode (client + server allocation)
| Configuration | Per-issuance alloc |
|---|---|
| ec:P-256 CA + ec:P-256 client, c=1 | 416 KB |
| ec:P-256 CA + ec:P-256 client, c=5 | 416 KB |
| ec:P-256 CA + ec:P-256 client, c=10 | 417 KB |
| ec:P-256 CA + ec:P-256 client, c=50 | 426 KB |
| ec:P-256 CA + rsa:4096 client, c=50 | 531 KB |
| ML-DSA-44 CA + ML-DSA-44 client, c=50 | 671 KB |
| ML-DSA-65 CA + ML-DSA-65 client, c=50 | 770 KB |
| ML-DSA-87 CA + ML-DSA-87 client, c=50 | 914 KB |
The difference between modes (e.g. 416 KB − 134 KB = 282 KB for ec:P-256) represents the server-side allocation per issuance: certificate construction, DER encoding, audit logging, and database writes.
JSON output
The "memory" key is present 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": 150120,
"total_alloc_count": 319099
}
}
| 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 |
Running the benchmark
Full suite
The benchmark suite script runs all configurations and writes newline-delimited JSON results:
cargo build --release
# Inprocess mode (default)
contrib/performance/run_benchmarks.sh [OUTPUT_FILE]
# Process mode
SPAWN_MODE="--spawn process" contrib/performance/run_benchmarks.sh [OUTPUT_FILE]
Post-processing examples:
# Print throughput for all runs
jq -r '.label + ": " + (.summary.throughput_per_sec|round|tostring) + " iss/s"' results.ndjson
# Extract concurrency scaling table
jq 'select(.label | startswith("concurrency_"))
| [.label, .summary.throughput_per_sec,
.summary.total_latency_ms.mean, .summary.total_latency_ms.p95]' results.ndjson
Individual runs
cargo build --release
# Concurrency sweep (process mode)
for c in 1 5 10 25 50; do
cargo bench --bench acme_bench -- --spawn process --clients $c --requests 300 --warmup 20
done
# Key type comparison at c=25
for kt in ec:P-256 ec:P-384 ed25519 rsa:2048 ml-dsa-44; do
cargo bench --bench acme_bench -- --spawn process --clients 25 --key-type $kt --requests 100
done
# CA key type comparison
for cakt in ec:P-256 ec:P-384 rsa:2048 rsa:4096; do
cargo bench --bench acme_bench -- --spawn process --clients 25 --ca-key-type $cakt --requests 100
done
# Post-quantum full chain with verification
cargo bench --bench acme_bench -- \
--spawn process --clients 25 --ca-key-type ml-dsa-44 --key-type ml-dsa-44 --verify-cert
# Challenge type comparison
cargo bench --bench acme_bench -- --spawn process --clients 25 --challenge dns-persist-01
# Backend comparison (inprocess mode, tmpfs WAL)
cargo bench --bench acme_bench -- --clients 10 --db "sqlite:///dev/shm/bench.db" --requests 300
# RO pool split (inprocess mode)
cargo bench --bench acme_bench -- \
--clients 10 --db "sqlite:///dev/shm/bench.db" --ro-connections 4 --requests 300
# JSON output for scripting
cargo bench --bench acme_bench -- --spawn process --clients 25 --requests 100 --output json | jq .summary
Available options
| Option | Default | Description |
|---|---|---|
--spawn MODE | inprocess | inprocess or process; process starts separate OS processes |
--nodes N | 1 | Number of akamu nodes in the cluster |
--clients N | 10 | Concurrent worker tasks |
--requests N | 100 | Issuances to measure (warmup not counted) |
--warmup N | 10 | Warmup issuances discarded before measurement |
--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) |
--topology MODE | direct | direct (round-robin) or proxy (single-node proxy) |
--no-gossip | off | Disable gossip in multi-node runs |
--db PATH | :memory: | SQLite URL or PostgreSQL connection string |
--pool-connections N | 1 | Write connection pool size |
--ro-connections N | 0 | Read-only connection pool size (0 = no split) |
--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 |
--poll-ms N | 100 | Challenge poll interval in milliseconds |
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 |
|---|---|---|
| CA/B Forum BR | CA/Browser Forum Baseline Requirements v2.x | Partial |
| dns-persist-01 | Let’s Encrypt Persistent DNS Challenge | Full |
| draft-ietf-acme-profiles-01 | ACME Certificate Profiles | Full |
| RFC 9964 | ML-DSA for JSON Object Signing and Encryption (JOSE) and CBOR Object Signing and Encryption (COSE) | Full |
| draft-ietf-lamps-pq-composite-sigs / draft-reddy-tls-composite-mldsa | ML-DSA Composite TLS Signature Schemes | Partial (provisional code points) |
| RFC 7807 | Problem Details for HTTP APIs | Full |
| 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 8823 | ACME Extensions for S/MIME 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 6960 | Online Certificate Status Protocol (OCSP) | Full |
| RFC 9115 | ACME Profile for Delegated Certificates | Full |
| RFC 9447 | ACME Challenges Using an Authority Token | Full |
| RFC 9448 | ACME TNAuthList Authority Token | Full |
| draft-ietf-acme-authority-token-jwtclaimcon | ACME Authority Token: JWTClaimConstraints | Full |
| RFC 9538 | ACME Delegation Metadata for CDNI | Not implemented |
| RFC 9891 | ACME DTN Node ID Validation (Experimental) | Not considered |
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 can be provisioned in two ways:
Static provisioning — keys are declared in the TOML configuration under [server.eab_keys] and loaded into the database at startup:
[server]
external_account_required = true
[server.eab_keys]
"kid-1" = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg" # base64url-encoded raw key bytes
"kid-2" = "YW5vdGhlci1rZXktaGVyZQ"
GSSAPI self-service derivation — when [server].eab_master_secret is set, authenticated clients call GET /acme/eab (authenticating via Authorization: Negotiate or via a trusted reverse proxy supplying X-Remote-User). The server derives deterministic (kid, hmac_key) pairs using HKDF-SHA-256 (RFC 5869) keyed by (master_secret, principal), stores them in the eab_keys table on first request, and returns them to the client:
[server]
external_account_required = true
eab_master_secret = "<base64url-encoded 32-byte secret>" # see configuration reference
The response JSON is {"principal":"…","kid":"…","hmac_key":"…","alg":"HS256"}. The client uses the returned kid and hmac_key to construct the externalAccountBinding JWS for newAccount. Once a kid has been consumed by an account registration, re-fetching GET /acme/eab for the same principal returns HTTP 409 Conflict.
Regardless of the provisioning method, the server performs full HMAC verification per RFC 8555 §7.3.4: it checks the kid, validates the algorithm and URL, verifies the HMAC signature, and confirms the EAB payload contains the account public key. Account creation and EAB key consumption happen atomically so that a key can never be used more than once even under concurrent requests.
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 8823 — S/MIME Certificates
RFC 8823 defines the email identifier type and the email-reply-00 challenge for issuing S/MIME end-user certificates. Proof of email address control is established via a DKIM-authenticated reply to a challenge email.
Identifier type
Orders may include {"type": "email", "value": "user@example.com"} identifiers. The server validates the format (non-empty local-part, non-empty domain, exactly one @, no wildcard prefix) and returns 400 unsupportedIdentifier for malformed addresses.
Challenge type
email-reply-00 is the only challenge offered for email identifiers. The challenge object includes a mandatory from field (the server’s validation address) in addition to the standard token and url fields:
{
"type": "email-reply-00",
"url": "https://acme.example.com/acme/chall/<id>",
"status": "pending",
"token": "<base64url(token-part2)>",
"from": "acme-validation@example.com"
}
Two-channel token
The token is split across two channels per RFC 8823 §3:
- token-part2 (≥128 bits): returned in the challenge JSON. The client stores it.
- token-part1 (≥128 bits): sent by the server in the challenge email
Subject: ACME: <base64url(token-part1)>. The client reads it from the email.
The client concatenates them: full_token = base64url(token-part1) || base64url(token-part2), then computes the key authorization and digest.
DKIM enforcement
RFC 8823 §3.2 requires that the DKIM d= tag on the reply email matches the domain of the From address. Akāmu enforces this via the webhook payload: dkim_domain must equal the domain portion of from, and dkim_status must be "pass".
DKIM verification itself is performed by the mail routing infrastructure (the webhook caller), not by Akāmu. The server trusts the dkim_domain and dkim_status fields in the webhook payload — secure HMAC authentication of the webhook endpoint is therefore essential.
Certificate requirements
Issued S/MIME certificates contain:
- An
rfc822NameSubject Alternative Name matching the validated email address. - The
emailProtectionExtended Key Usage (OID 1.3.6.1.5.5.7.3.4).
These are enforced at CSR validation time (the server rejects CSRs where the rfc822Name SANs do not match the authorized email identifiers).
Configuration
Requires [email_challenge] in the server configuration with enabled = true. See the email_challenge configuration reference and the challenges documentation for the full webhook payload format and send script interface.
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)
# ari_explanation_url = "https://acme.example.com/docs/renewal-policy" # optional
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 6960 — OCSP Responder
RFC 6960 defines the Online Certificate Status Protocol (OCSP), which allows relying parties to query a CA for the real-time revocation status of a specific certificate.
Endpoints
POST /ca/ocsp # body: DER OCSPRequest, Content-Type: application/ocsp-request
GET /ca/ocsp/{request} # {request}: base64url-encoded DER OCSPRequest (RFC 6960 §A.1)
Both endpoints return a signed OCSPResponse with Content-Type: application/ocsp-response. No authentication is required.
Status mapping
For each serial number in the OCSPRequest, the server looks up the certificate in the database:
| DB state | CertStatus |
|---|---|
| Certificate not found | unknown |
status = "revoked" | revoked |
| Any other status | good |
The response is signed with the CA private key. The responder identity is byName using the CA’s subject Name DER.
Configuration
Set ocsp_url in [ca] to the public URL of the OCSP endpoint so the URL is embedded in issued certificates:
[ca]
ocsp_url = "http://acme.example.com/ca/ocsp"
See CRL and OCSP for the complete deployment guide.
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.
CA/B Forum Baseline Requirements
The CA/Browser Forum Baseline Requirements for TLS Server Certificates (BR) is not an RFC but a policy document maintained by the CA/Browser Forum and enforced by browser trust-store membership. Any CA intending to issue publicly-trusted TLS certificates must comply with it. Akāmu enforces several BR requirements at startup and at certificate-issuance time.
Compliance status
| Requirement | Section | Deadline | Status | Implementation |
|---|---|---|---|---|
| Maximum validity 200 days | §6.3.2 | 2026-03-15 | Enforced (warning) | Startup warning when ca.validity_days > 200 |
| Maximum validity 100 days | §6.3.2 | 2027-03-15 | Enforced (warning) | Startup warning when ca.validity_days > 100 |
| SHA-1 prohibited in signatures | §7.1.3.2.1 | 2026-09-15 | Enforced (hard error) | Startup hard error when ca.hash_alg is sha1 or sha-1 |
| DNSSEC validation for DNS challenges | §3.2.2.4, §3.2.2.8.1 | 2026-03-15 | Enforced by default | server.validate_dnssec (default true) |
| Pre-issuance linting | §4.3.1.2 | 2025-03-15 | Enforced | Every issued certificate is verified via synta-x509-verification before delivery |
| Multi-perspective validation | §3.2.2.9 | 2025-03-15 | To do, not a priority | Requires validation from multiple network vantage points |
§6.3.2 — Certificate Validity Period
The CA/B Forum has progressively shortened the maximum certificate validity period:
- 200 days — hard limit since 2026-03-15
- 100 days — hard limit from 2027-03-15
Akāmu enforces these limits as startup warnings rather than hard errors, because the restriction applies only to publicly-trusted WebPKI certificates. Private or enterprise deployments may legitimately use longer validity periods when not chaining to a public root. The warning makes the misconfiguration visible without breaking private-CA use cases.
Configure ca.validity_days in your config.toml:
[ca]
validity_days = 90 # ≤ 100 is fully compliant through 2027-03-15
§7.1.3.2.1 — SHA-1 Sunset
SHA-1 signatures in certificates and CRLs are prohibited from 2026-09-15. Akāmu enforces this as a startup hard error: if ca.hash_alg is set to sha1 or sha-1, the server refuses to start with an explicit error message citing the BR section.
Compliant hash algorithms: sha256, sha384, sha512.
§3.2.2.4 / §3.2.2.8.1 — DNSSEC Validation
DNS-based challenge validation (dns-01, dns-persist-01) and CAA record checking must use DNSSEC-validated answers as of 2026-03-15.
Akāmu enables DNSSEC validation by default. The behaviour is controlled by server.validate_dnssec:
[server]
validate_dnssec = true # default — required for BR compliance
Set validate_dnssec = false only for testing environments or private deployments where the DNS infrastructure is not DNSSEC-signed. Disabling DNSSEC makes the server non-compliant with CA/B Forum BR and ineligible for public WebPKI inclusion.
§4.3.1.2 — Pre-Issuance Linting
CAs must programmatically verify every certificate before signing and delivering it, using a linting tool that checks structural and policy conformance. Akāmu satisfies this requirement by running the synta-x509-verification policy engine against every issued certificate immediately after signing and before delivering it to the client.
The linter checks:
- X.509 version = v3
- Serial number: ≤ 20 octets, positive integer
- Validity window present and well-formed
- SPKI algorithm on the WebPKI allowlist (no SHA-1, no weak RSA)
- RSA keys: minimum 2048 bits; EC keys: named curves only
- Signature algorithm on the WebPKI allowlist (includes ML-DSA / composite post-quantum)
AuthorityKeyIdentifierextension presentBasicConstraints: cA=FALSEon end-entity certificates- CA signature is cryptographically valid over the certificate body
If linting fails, the certificate is not delivered and the order moves to the invalid state with an internal error. The malformed certificate is never exposed to the client.
§3.2.2.9 — Multi-Perspective Issuance Corroboration (MPIC)
As of 2025-03-15, CAs are required to validate domain control from multiple network vantage points — at minimum two remote perspectives in addition to the primary validation — to mitigate BGP hijacking attacks against ACME challenge responses.
To do, not a priority. Satisfying this requirement demands either integration with a set of geographically distributed MPIC agents or reliance on an external MPIC service. Akāmu is intended for private and enterprise deployments where the network topology is controlled; public CAs using Akāmu as a backend must implement MPIC at the infrastructure layer until this is supported natively.
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_domains = "acme.example.com"
When dns_persist_issuer_domains 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 one of the configured dns_persist_issuer_domains, and checks that the accounturi matches the requesting ACME account URL. If both match, the authorization is marked valid.
draft-ietf-acme-profiles-01
draft-ietf-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 [profiles] providers are configured, the directory meta includes a profiles object mapping each profile name to its description:
"meta": {
"profiles": {
"tlsserver": "Standard TLS server certificate",
"clientauth": "Client authentication certificate"
}
}
Requesting a profile in newOrder
Clients include the profile field in the newOrder payload:
{
"identifiers": [{ "type": "dns", "value": "example.com" }],
"profile": "tlsserver"
}
The server validates that the requested profile is loaded in the registry. If not, it returns:
{
"type": "urn:ietf:params:acme:error:invalidProfile",
"status": 400,
"detail": "profile 'unknown-profile' is not served by any configured provider"
}
The profile field is echoed back in every subsequent order response so that clients can confirm which profile applies.
Default profile auto-selection
When a newOrder request omits the profile field and a profile named "default" exists in the registry, the server automatically applies "default" and echoes it in the order response. This means clients that do not specify a profile will receive "profile": "default" in the order JSON rather than an absent field, giving operators a clean way to enforce a baseline policy without requiring client-side changes.
If no "default" profile is configured and the client omits profile, the order is issued under the CA’s built-in defaults (no profile applied).
Finalize-time enforcement
At finalize time the server reads the profile registry once and uses the result for both authorization and certificate parameter construction. Per-profile authorization checks (allowed_identifiers, auth_hook, require_account_grant) run before CSR validation so that authorization failures are reported before the server expends effort parsing and validating the CSR.
The server resolves the profile’s CertificateParameters (key usage bits, EKU OIDs, validity, CRL/OCSP URLs, certificate policies) and issues the certificate with those exact extension values. If the profile is no longer loaded (e.g. removed since the order was placed), the request is rejected with invalidProfile.
Configuration
[profiles.providers.local]
type = "builtin"
[profiles.providers.local.profiles.tlsserver]
description = "Standard TLS server certificate"
validity_days = 90
key_usage = ["digital_signature", "key_encipherment"]
eku = ["server_auth"]
See Certificate Profiles for the full configuration reference including Dogtag and IPA providers. When no providers are configured, the profile field in newOrder is accepted but ignored — the server issues under its default policy.
RFC 9964 — ML-DSA for JOSE and COSE
RFC 9964 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) and COSE (CBOR Object Signing and Encryption). 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 RFC 9964 §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 per RFC 9964 §4.
Signature failures return HTTP 401 Unauthorized.
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-reddy-tls-composite-mldsa
draft-ietf-lamps-pq-composite-sigs defines the X.509/PKIX OIDs for hybrid ML-DSA+classical composite signature algorithms (sub-arcs 37–54 under the id-CompositeSig arc). The TLS 1.3 SignatureScheme code points for use in CertificateVerify are defined in the companion draft draft-reddy-tls-composite-mldsa.
CA signing keys (draft-ietf-lamps-pq-composite-sigs-19)
All 18 composite ML-DSA variants defined in sub-arcs 37–54 are supported as CA signing keys. When ca.key_type is set to a composite variant (e.g. "composite-mldsa65-ecdsa-p384-sha512"), Akāmu generates a composite CA key and issues all end-entity certificates with that composite signature. Issued certificates pass pre-issuance lint via synta-x509-verification.
Requires OpenSSL 3.5 or later (same requirement as pure ML-DSA keys). The full list of 18 supported variants and their OID sub-arcs is documented in the ca.key_type configuration reference.
Mutual TLS client authentication (draft-reddy-tls-composite-mldsa)
Composite ML-DSA schemes also appear in the TLS CertificateVerify message when a client presents a certificate signed with a composite ML-DSA scheme. The 11 composite scheme code points implemented for mTLS are:
| Code point | Scheme |
|---|---|
| 0x0901 | MLDSA44-ECDSA-P256-SHA256 |
| 0x0902 | MLDSA44-RSA2048-PKCS15-SHA256 |
| 0x0903 | MLDSA44-RSA2048-PSS-SHA256 |
| 0x0904 | MLDSA44-Ed25519-SHA512 |
| 0x0905 | MLDSA65-ECDSA-P256-SHA512 |
| 0x0906 | MLDSA65-ECDSA-P384-SHA512 |
| 0x0907 | MLDSA65-RSA3072-PKCS15-SHA512 |
| 0x0908 | MLDSA65-RSA3072-PSS-SHA512 |
| 0x0909 | MLDSA65-Ed25519-SHA512 |
| 0x090A | MLDSA87-ECDSA-P384-SHA512 |
| 0x090C | MLDSA87-Ed448-SHAKE256 |
Stability warning
All OID sub-arcs (37–54) and all SignatureScheme code points (0x090x) are provisional pending IANA allocation. They may change as the drafts advance toward RFC publication. Before deploying to production, verify the current draft version against the values listed above.
RFC 9115 — ACME Profile for Delegated Certificates
RFC 9115 defines a three-party ACME delegation model in which an Identifier Owner (IdO) pre-authorizes a Name Delegation Consumer (NDC) to obtain certificates for the IdO’s domain names. The CA enforces a JSON CSR template that constrains what the NDC may request. Akāmu implements both roles: it acts as the IdO-facing ACME CA (serving NDC clients) and as an IdO ACME client that drives the upstream CA leg automatically.
Roles
| Role | Description |
|---|---|
| IdO | The domain owner. Creates delegation objects (CSR templates + CNAME maps) and holds the STAR or regular order on Akāmu. |
| NDC | The delegate (e.g., a CDN PoP). Discovers the delegation URL via the IdO’s account, submits a new-order referencing it, and finalizes with a CSR that satisfies the template. |
| Upstream CA | An external ACME CA that issues to the IdO. Akāmu drives this leg automatically using [delegation_upstream]. |
What it adds to the ACME API
| Feature | RFC 9115 section | Status |
|---|---|---|
delegation-enabled in directory meta | §2.3.1 | Yes — when server.delegation_enabled = true |
allow-certificate-get in directory meta | §2.3.5 | Yes — when server.allow_certificate_get = true |
delegations URL in account object | §2.3.2 | Yes — appears when delegation_enabled = true |
POST /acme/delegations/{account_id} — list delegations | §2.3.2 | Yes |
POST /acme/delegation/{id} — fetch one delegation | §2.3.3 | Yes |
"delegation" field in new-order payload | §2.3.4 | Yes |
"allow-certificate-get" field in new-order payload | §2.3.5 | Yes |
Delegation orders start in ready status (no challenge/authz flow) | §2.3.4 | Yes |
"authorizations": [] on delegation orders | §2.3.4 | Yes |
CSR template validation at finalize | §4 | Yes |
Unauthenticated GET /acme/cert/{id} when allow_cert_get = 1 | §2.3.5 | Yes |
CSR template format (RFC 9115 §4)
The delegation object’s csr_template field is a JSON object that constrains what an NDC may put in its CSR:
{
"keyTypes": [{"type": "EC", "curve": "P-256"}],
"subject": {
"commonName": {},
"organization": "ExampleCorp"
},
"extensions": {
"subjectAltName": {},
"keyUsage": ["digitalSignature"],
"extendedKeyUsage": ["1.3.6.1.5.5.7.3.1"]
}
}
Field value semantics:
| Value | Meaning |
|---|---|
{} | MandatoryWildcard — the field MUST be present in the CSR |
null | OptionalWildcard — the field MAY be present in the CSR |
"ExampleCorp" | Literal — the field must equal this exact value |
| absent | The field is forbidden in the CSR |
Akāmu validates the CSR against the stored template at finalize time. CSRs that violate the template are rejected with urn:ietf:params:acme:error:badCSR.
Server configuration (IdO-server role)
[server]
# Enable the delegation API surface and advertise it in the directory.
delegation_enabled = true
# Advertise and allow unauthenticated GET of delegation order certificates.
allow_certificate_get = true
When delegation_enabled = true, the directory meta object includes "delegation-enabled": true and every account response includes a "delegations" URL. The delegation endpoints become active:
POST /acme/delegations/{account_id} — list delegations (POST-as-GET)
POST /acme/delegation/{id} — fetch one delegation object (POST-as-GET)
When allow_certificate_get = true, the directory meta also includes "allow-certificate-get": true, and orders placed with "allow-certificate-get": true in their payload allow the NDC (or any bearer) to fetch the certificate with an unauthenticated GET.
Upstream CA configuration (IdO-client role)
The [delegation_upstream] section configures Akāmu to act as an ACME client toward an upstream CA. A background task polls orders whose status = 'processing' and a non-null delegation_id, drives the upstream ACME flow (account registration, order creation, dns-01 challenge, finalize), and stores the resulting certificate URL back on the order.
[delegation_upstream]
# ACME directory URL of the upstream CA.
directory_url = "https://upstream-ca.example.com/acme/directory"
# PEM file containing the ACME account key for the upstream CA.
account_key_file = "/etc/akamu/upstream-acme.key.pem"
# Contact email(s) used when registering the upstream account.
contacts = ["mailto:admin@example.com"]
# Challenge type for the upstream authz flow. Only "dns-01" is supported.
challenge_solver = "dns-01"
# Executable that deploys the dns-01 TXT record.
# Called with env_clear(); receives CERTBOT_DOMAIN and CERTBOT_VALIDATION.
challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"
# Optional cleanup script called after the authz transitions to valid.
# Receives CERTBOT_DOMAIN, CERTBOT_VALIDATION, and CERTBOT_AUTH_OUTPUT="".
# challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"
# Polling interval for the upstream order status (seconds). Default: 10.
# poll_interval_secs = 10
The deploy script is invoked after Akāmu has triggered the challenge at the upstream CA. The cleanup script is called once the authorization has transitioned to valid — not immediately after the deploy script, which allows the TXT record to remain in place long enough for the upstream CA’s validators to query it.
Admin API — delegation CRUD
Delegations are managed through the Admin API. The delegation_enabled config flag must be set; the [admin] section must be configured with at least one operator.
| Method | Path | Role required |
|---|---|---|
GET | /admin/delegations | any authenticated role |
GET | /admin/delegations?account_id={id} | any authenticated role |
POST | /admin/delegations | ca_operations, administrator |
GET | /admin/delegations/{id} | any authenticated role |
PUT | /admin/delegations/{id} | ca_operations, administrator |
DELETE | /admin/delegations/{id} | ca_operations, administrator |
DELETE returns 409 Conflict when one or more orders still reference the delegation.
The CSR template syntax is validated at write time (POST and PUT). A malformed template is rejected with 400 Bad Request before it reaches the database.
Every write operation emits a structured audit event: delegation.create (POST), delegation.update (PUT), or delegation.delete (DELETE). These events are queryable via GET /admin/audit and the akamuctl audit --type delegation.* filter.
Delegation management is also available through akamuctl delegation — see akamuctl — Admin CLI for the full command reference.
Delegation order lifecycle
stateDiagram-v2
direction LR
[*] --> ready : new-order (with delegation URL)
ready --> processing : finalize (NDC submits CSR)
processing --> valid : upstream CA issues cert
processing --> invalid : CSR template mismatch or upstream failure
valid --> [*]
invalid --> [*]
Delegation orders skip the pending state and the challenge/authorization flow entirely. The authorizations array in the order response is always empty. The order transitions from ready to processing when the NDC calls finalize, and from processing to valid when the background upstream task has retrieved the certificate from the upstream CA.
RFC 9447 — ACME Challenges Using an Authority Token
RFC 9447 defines the tkauth-01 ACME challenge type. Instead of a network probe, the client proves control of the identifier by presenting a signed JWT (an authority token) issued by an external Token Authority (TA). This enables ACME automation for identifier types — such as telephone numbers — that cannot be validated by http-01 or dns-01.
The authority token is a compact JWT carrying an atc claim that binds:
tktype— the identifier type (e.g.,"TNAuthList")tkvalue— the identifier value (base64url-encoded DER)fingerprint— the ACME account’s JWK thumbprintca— must be absent orfalse(CA-cert issuance not supported)
Akāmu validates the TA’s signing certificate chain against a locally-configured set of trust anchors, verifies the JWT signature and expiry, and enforces one-time use via a JTI replay-prevention cache.
What it adds to the ACME API
| Feature | Status |
|---|---|
tkauth-01 challenge type | Yes |
tkauth-type field in challenge object | Yes — always "atc" |
token-authority hint in challenge object | Yes — optional, from tkauth.token_authority_url |
| x5u cert fetch for TA signing cert | Yes |
| x5c inline cert for TA signing cert | Yes |
| JTI replay prevention | Yes — database-backed tkauth_jti_cache table |
| Automatic JTI cache pruning | Yes — background task, interval from tkauth.jti_prune_interval_secs |
Configuration
[tkauth]
enabled = true
trusted_ta_ca_files = ["/etc/akamu/ta-root.pem"]
token_authority_url = "https://ta.example.com" # optional hint
max_validity_secs = 3600
jti_prune_interval_secs = 3600
trusted_ta_ca_files must list one or more PEM files containing the CA certificates that sign Token Authority certificates. The signing cert presented in the authority token (via x5u or x5c) must chain to one of these anchors.
JTI cache management
Expired JTI entries accumulate over time. The background task prunes them automatically. Operators can also trigger manual pruning via:
akamuctl tkauth prune-jti
akamuctl tkauth prune-jti --dry-run # count without deleting
Or via the Admin API:
POST /admin/tkauth/prune-jti
POST /admin/tkauth/prune-jti?dry_run=true
RFC 9448 — ACME TNAuthList Authority Token
RFC 9448 defines the TNAuthList ACME identifier type and its use with the RFC 9447 tkauth-01 challenge for STIR/SHAKEN telephone number automation. The identifier value is a base64url-encoded DER-encoded TNAuthorizationList structure as defined in RFC 8226.
When a new-order request contains a TNAuthList identifier, Akāmu creates a tkauth-01 challenge. The client obtains a signed authority token from the Token Authority — attesting that the account holds the telephone number authority — and submits it in the challenge response.
draft-ietf-acme-authority-token-jwtclaimcon
draft-ietf-acme-authority-token-jwtclaimcon defines a second RFC 9447 profile for the JWTClaimConstraints identifier type. The identifier value is a base64url-encoded DER-encoded JWTClaimConstraints ASN.1 structure (from RFC 8226), constraining which PASSporT claims may appear on issued certificates.
The tkauth-01 validation is identical to RFC 9448 — the only differences are the identifier type string ("JWTClaimConstraints") and the corresponding atc.tktype value in the authority token. Akāmu validates these generically; no separate configuration is required beyond enabling [tkauth].
An order may contain both TNAuthList and JWTClaimConstraints identifiers simultaneously. Each gets its own authorization and tkauth-01 challenge; all authorizations must be valid before the order may be finalized.
Not implemented
RFC 9538 — ACME Delegation Metadata for CDNI
Extends RFC 9115 for CDN Interconnection (CDNI) scenarios where multiple CDN tiers chain certificate delegation.
Not implemented. RFC 9115 single-tier delegation is fully supported (see above). Chained multi-tier delegation across CDN interconnects as defined in RFC 9538 is not yet implemented.
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 considered. Experimental status; targets space/satellite networks using the Bundle Protocol (RFC 9171), outside the scope of Akāmu’s target deployments.
EAB and Kerberos Authentication
akamu can require callers to prove their Kerberos identity before issuing External Account Binding (EAB) credentials. Two authentication modes are supported: a reverse proxy that sets a header after completing SPNEGO, and standalone GSSAPI where akamu validates Negotiate tokens directly.
Authentication modes
Proxy header mode
In this mode a trusted reverse proxy (Apache, Nginx, HAProxy, etc.) terminates
the SPNEGO / Kerberos exchange and sets an X-Remote-User header on every
forwarded request. akamu accepts this header as the authenticated principal only
when the request arrives from an IP address listed in trusted_proxies.
Requests from any other IP — including unauthenticated clients — never have the header honoured.
Standalone GSSAPI mode
In this mode akamu handles Authorization: Negotiate tokens directly using MIT
Kerberos. At startup the server reads a keytab file and acquires an acceptor
credential for the configured HTTP service principal. Each incoming token is
validated with gss_accept_sec_context.
When the token is absent, akamu returns 401 Unauthorized with a
WWW-Authenticate: Negotiate challenge. When the token is invalid or expired,
akamu returns 403 Forbidden.
Additional behaviors of this mode:
- Token size limit. Negotiate tokens larger than 128 KiB are rejected with
400 Bad Request. Legitimate Kerberos service tickets are always smaller than this limit. - Case-insensitive scheme matching. The
"Negotiate "prefix in theAuthorizationheader is matched case-insensitively per RFC 7235 §2.1. - TLS channel bindings. When akamu terminates TLS itself, the
tls-server-end-pointchannel binding (RFC 5929 §4) is computed from the server certificate and passed togss_accept_sec_context. This binds the Kerberos exchange to the TLS channel, preventing token relay attacks. When the server certificate uses ML-DSA (pure or composite) or Ed448 — algorithms for which RFC 5929 defines no canonical hash — channel bindings are disabled automatically. - Replay detection. After a successful
gss_accept_sec_contextcall, akamu checks whetherGSS_C_REPLAY_FLAGis set. When the flag is absent (common when clients connect over TLS, because TLS already provides replay protection), adebug-level log entry is emitted and the authentication proceeds normally. This behaviour is intentional: browsers and TLS-first clients typically do not negotiate Kerberos-level replay protection. - No authentication mechanism configured. When neither
trusted_proxiesnor[server.gssapi]is set, requests to authenticated endpoints return404 Not Foundrather than403 Forbidden. - GSSAPI without TLS. Running standalone GSSAPI without TLS is permitted
but emits a
warn-level log at startup, because SPNEGO tokens are not protected against interception or relay attacks without TLS.
Only one mode may be active at a time. Enabling trusted_proxies and
[server.gssapi] simultaneously is a configuration error: the server exits at
startup with an error message if both are set.
Deployment prerequisites
Both modes require a working Kerberos environment:
- A Kerberos realm (for example, managed by FreeIPA or Active Directory).
- A service principal of the form
HTTP/<hostname>@REALMregistered in the KDC. - For standalone GSSAPI: either a keytab file readable only by the akamu process, or a gssproxy daemon entry that supplies the credential (no direct keytab access needed — see FreeIPA deployment).
- For proxy mode: a reverse proxy configured to perform SPNEGO and set
X-Remote-User.
Configuration
Proxy mode
[server]
trusted_proxies = ["192.168.1.10/32"]
trusted_proxieslists the IP addresses (CIDR notation) of your reverse proxy.- Keep this list as narrow as possible. Any host in the list can claim any
principal name by forging the
X-Remote-Userheader. - IPv4-mapped IPv6 addresses (
::ffff:a.b.c.d) are automatically normalised to plain IPv4 for matching purposes.
No additional configuration is needed on the akamu side. The reverse proxy must
be configured separately to perform Kerberos/SPNEGO authentication and forward
the authenticated username in X-Remote-User.
Example Apache configuration (mod_auth_gssapi):
<Location /acme/eab>
AuthType GSSAPI
AuthName "Kerberos"
GssapiCredStore keytab:/etc/httpd/http.keytab
Require valid-user
RequestHeader set X-Remote-User %{REMOTE_USER}e
</Location>
Standalone GSSAPI mode
Two credential sources are supported: a keytab file read directly by akamu, or the gssproxy daemon (no direct file access needed).
Keytab mode — akamu reads the keytab at startup:
[server.gssapi]
keytab_file = "/etc/akamu/http.keytab"
Generate and install the keytab for an IPA-managed host:
ipa-getkeytab -s ipa.example.com \
-p HTTP/akamu.example.com@EXAMPLE.COM \
-k /etc/akamu/http.keytab
chmod 600 /etc/akamu/http.keytab
chown akamu: /etc/akamu/http.keytab
gssproxy mode — gssproxy supplies the credential; no keytab path is needed:
[server.gssapi]
gssproxy = true
Set GSS_USE_PROXY=yes is handled automatically; akamu sets it before the
first GSSAPI call when gssproxy = true. Install the gssproxy service entry
first — see FreeIPA deployment for a complete example.
Common option — service_name selects the Kerberos service component.
MIT Kerberos appends @<local-hostname> automatically when no realm is given.
The default is "HTTP"; use "HTTP@akamu.example.com" to be explicit:
[server.gssapi]
keytab_file = "/etc/akamu/http.keytab"
service_name = "HTTP@akamu.example.com" # explicit hostname
keytab_file and gssproxy are mutually exclusive — the server exits at
startup if both are set.
The GET /acme/eab endpoint
The GET /acme/eab endpoint is the entry point for EAB credential issuance.
It requires a valid authenticated identity through one of the two modes above.
Behaviour with eab_master_secret configured (full mode)
When [server].eab_master_secret is set, the endpoint derives a deterministic
EAB key identifier and HMAC secret from the master secret and the authenticated
principal using HKDF-SHA-256 (RFC 5869):
kid = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-kid:<principal>", L=16) )
hmac_key = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-key:<principal>", L=32) )
Request:
GET /acme/eab
Authorization: Negotiate <base64-token>
Response (200 OK):
{
"principal": "host/client.example.com@EXAMPLE.COM",
"kid": "…22-char base64url…",
"hmac_key": "…43-char base64url…",
"alg": "HS256"
}
The same (master_secret, principal) pair always produces the same kid and
hmac_key. Credentials are stored in the eab_keys table on first request and
returned unchanged on subsequent requests by the same principal.
Once the kid has been consumed by an account registration (newAccount with a
valid externalAccountBinding), re-fetching returns 409 Conflict. Contact
your CA administrator to reset the credential if you need to re-register.
Behaviour without eab_master_secret (stub / backward-compatible mode)
When eab_master_secret is absent, the endpoint confirms authentication
succeeded but returns only the principal name:
{ "principal": "host/client.example.com@EXAMPLE.COM" }
This mode is useful for testing authentication configuration before enabling EAB enforcement.
Configuring eab_master_secret
Generate a random 32-byte secret and encode it as base64url:
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
Add the result to your configuration:
[server]
external_account_required = true
eab_master_secret = "<base64url output from above>"
The decoded secret must be at least 32 bytes; the server exits at startup if it is shorter. Treat the master secret with the same care as a private key — anyone who holds it can derive valid EAB credentials for any principal.
Client-side usage with akamu-cli
The akamu-cli and akamu-client library support calling this endpoint using a
Kerberos keytab. The CLI --gssapi-keytab flag (shared by account register
and issue) authenticates to the endpoint, logs the returned principal, and
automatically uses the returned kid and hmac_key to construct the
externalAccountBinding field in newAccount (RFC 8555 §7.3.4). No manual
copy-paste of EAB credentials is required.
The library exposes fetch_eab_via_gssapi(eab_url, keytab_file), which derives
the target service name HTTP@<hostname> from the URL automatically and returns
a GssapiEabResult containing principal, kid, hmac_key, and alg.
Using EAB credentials with other ACME clients
Any standard ACME client that supports External Account Binding can use
credentials from GET /acme/eab. The pattern is to fetch the credentials in a
pre-registration script and then pass them to the ACME client’s EAB flags.
Step 1 — fetch credentials with curl and a Kerberos ticket
# Obtain a Kerberos ticket first (if not already cached)
kinit host/client.example.com@EXAMPLE.COM -k -t /etc/client.keytab
# curl handles SPNEGO automatically with --negotiate
RESPONSE=$(curl -s --negotiate -u : \
https://akamu.example.com/acme/eab)
KID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['kid'])")
HMAC_KEY=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['hmac_key'])")
Step 2 — pass the credentials to your ACME client
Certbot:
certbot register \
--server https://akamu.example.com/acme/directory \
--eab-kid "$KID" \
--eab-hmac-key "$HMAC_KEY"
acme.sh:
export EAB_KID="$KID"
export EAB_HMAC_KEY="$HMAC_KEY"
acme.sh --register-account \
--server https://akamu.example.com/acme/directory \
--eab
Lego:
lego --server https://akamu.example.com/acme/directory \
--eab \
--kid "$KID" \
--hmac "$HMAC_KEY" \
--email "ops@example.com" \
run --domains client.example.com ...
The kid and hmac_key values are valid until the first successful
newAccount call that consumes them. After registration succeeds the account
key is the ongoing credential; the EAB pair is not needed again. If registration
fails before newAccount completes, re-running the script returns the same
kid and hmac_key (derivation is deterministic), so it is safe to retry.
If GET /acme/eab returns 409 Conflict, the credentials have already been
consumed by a prior registration. Contact your CA administrator to reset them.
Security notes
- In keytab mode the keytab grants the ability to accept Kerberos service
tickets for the HTTP principal. Treat it with the same care as a private key:
permissions
600, owned by the akamu service account, never shared with other services. - In gssproxy mode the keytab is held by the gssproxy daemon; verify that the
gssproxy service entry restricts access by
euid = akamuso that no other process on the host can obtain the HTTP service credential. - The
trusted_proxieslist must be kept tightly scoped to the actual IP addresses of your reverse proxy. A broadly scoped list (for example,0.0.0.0/0) allows any network client to assert any principal name. - Kerberos tickets have a finite lifetime (typically 10 hours). Clients must
obtain fresh tickets before they expire; akamu returns
403for expired tokens.
FreeIPA Co-deployment
This guide covers running akamu on the same host as a FreeIPA server, proxied
behind IPA’s Apache httpd at the /acme path prefix. IPA manages TLS
termination and DNS; akamu focuses on ACME certificate issuance and operator
authentication via Kerberos.
The complete configuration files and Ansible automation described here live in
contrib/demo/ipa/.
Topology
Client (certbot / acme.sh)
│ HTTPS /acme/acme/directory
▼
IPA Apache httpd (TLS termination)
│ mod_proxy /acme/ → unix socket
▼
akamu (Unix domain socket /run/akamu/akamu.sock)
│ GSS_USE_PROXY=yes
▼
gssproxy daemon (reads /var/lib/ipa/gssproxy/http.keytab)
│ S4U2Self / S4U2Proxy
▼
IPA KDC / IPA LDAP (optional profile fetch)
akamu never touches the HTTP keytab directly. gssproxy mediates all Kerberos
operations and enforces that only the akamu OS user can obtain the HTTP
service credential.
Prerequisites
-
FreeIPA server installed and running on the target host.
-
The
HTTP/<fqdn>@REALMservice principal registered in IPA (created automatically when IPA is installed on the host). -
gssproxyinstalled (part of the standard IPA server package set on Fedora/RHEL). -
akamuinstalled from the abbra/synta COPR:dnf copr enable abbra/synta dnf install akamu
Step 1 — gssproxy service entry
Create /etc/gssproxy/20-akamu.conf (from
contrib/demo/ipa/akamu-gssproxy.conf):
[service/akamu]
mechs = krb5
cred_store = keytab:/var/lib/ipa/gssproxy/http.keytab
cred_store = client_keytab:/var/lib/ipa/gssproxy/http.keytab
allow_protocol_transition = true
allow_constrained_delegation = true
cred_usage = both
euid = akamu
Then restart gssproxy:
systemctl restart gssproxy
cred_usage = both is required: akamu acts as both an acceptor (validating
incoming SPNEGO tokens from ACME clients and admin operators) and an initiator
(obtaining service tickets for LDAP profile fetches when
[profiles.providers.ipa] is enabled).
Step 2 — akamu configuration
Create /etc/akamu/config.toml (from
contrib/demo/ipa/akamu.toml,
substituting your FQDN and realm):
listen_addr = "unix:/run/akamu/akamu.sock"
base_url = "https://ipa.example.com/acme"
[database]
url = "sqlite:///var/lib/akamu/akamu.db"
[ca]
key_file = "/etc/akamu/certs/ca.key.pem"
cert_file = "/etc/akamu/certs/ca.cert.pem"
key_type = "ec:P-256"
hash_alg = "sha256"
[server]
validate_dnssec = true
[server.gssapi]
gssproxy = true
[admin]
bootstrap_operator_gssapi_principal = "admin@EXAMPLE.COM"
[admin.gssapi]
gssproxy = true
gssproxy = true tells akamu to set GSS_USE_PROXY=yes at startup and acquire
its acceptor credential via gssproxy rather than reading the keytab directly.
service_name defaults to "HTTP" and does not need to be set explicitly.
bootstrap_operator_gssapi_principal seeds the first akamu Administrator from
the IPA admin principal on first run (when the operators table is empty).
Remove this line after the initial operator account has been created.
Step 3 — Apache proxy
Create /etc/httpd/conf.d/ipa-acme-proxy.conf (from
contrib/demo/ipa/ipa-acme-proxy.conf):
ProxyPass /acme/ unix:/run/akamu/akamu.sock|http://localhost/ nocanon
ProxyPassReverse /acme/ http://localhost/
ProxyPassReverseCookiePath / /acme/
ProxyPreserveHost On
RedirectMatch permanent ^/acme$ /acme/
<Location "/acme/">
AuthType None
Require all granted
SSLOptions +StdEnvVars +ExportCertData +StrictRequire
SSLVerifyClient none
RequestHeader set X-Forwarded-Proto "https"
Header always set X-Content-Type-Options "nosniff"
Header always append X-Frame-Options "DENY"
Header always append Content-Security-Policy "frame-ancestors 'none'"
</Location>
RewriteCond %{SERVER_PORT} !^443$
RewriteRule ^/acme/(.*) https://%{HTTP_HOST}/acme/$1 [L,R=307,NC]
Step 4 — socket permissions
The akamu Unix socket lives in /run/akamu/ (mode 0710, owned by
akamu:akamu). Apache must be in the akamu group to connect:
usermod -aG akamu apache
systemctl reload httpd
Step 5 — start akamu
systemctl enable --now akamu
Verify startup in the journal:
journalctl -u akamu -n 50
Expected lines on a healthy start:
INFO gssproxy mode enabled: GSS_USE_PROXY=yes
INFO initializing GSSAPI credential for service 'HTTP'
INFO acquiring GSSAPI credential via gssproxy
INFO akamu listening on unix:/run/akamu/akamu.sock
The ACME directory is now available at:
https://ipa.example.com/acme/acme/directory
Bootstrapping the first admin
On first run, akamu registers admin@EXAMPLE.COM (or whichever principal you
set in bootstrap_operator_gssapi_principal) as an Administrator. Confirm the
account was created:
kinit admin
akamuctl --url https://ipa.example.com/acme --gssapi operators list
After the initial operator is confirmed, remove bootstrap_operator_gssapi_principal
from config.toml and restart akamu so the boot-strap path is no longer active.
Optional — IPAThinCA certificate profiles
To expose FreeIPA CA certificate profiles via ACME, add a
[profiles.providers.ipa] section. The HTTP service principal needs read access
to o=ipaca; on Fedora/RHEL IPA this subtree is readable by any IPA-enrolled
principal. See the commented block in contrib/demo/ipa/akamu.toml for the
full LDAP connection configuration.
The ipa_permissions.yml Ansible playbook in contrib/demo/ipa/ansible/
creates an explicit Akamu IPA CA Read IPA privilege and assigns it to the
HTTP service principal for auditability.
Automated deployment with Ansible
The contrib/demo/ipa/ansible/ directory contains a complete Ansible setup
that installs FreeIPA, deploys akamu on every node, and grants IPA privileges
in one command:
cd contrib/demo/ipa/ansible
cp inventory.ini.example inventory.ini
# Edit inventory.ini: set hostnames, ipa_domain, ipa_realm, passwords
ansible-playbook -i inventory.ini site.yml
See contrib/demo/ipa/ansible/README.md
for the full reference, including standalone (non-replica) node setup with
S4U2Proxy constrained delegation.
Troubleshooting
| Symptom | Check |
|---|---|
| akamu not starting | journalctl -u akamu — often a config parse error or missing DB directory (/var/lib/akamu/) |
502 Bad Gateway from Apache | ls -la /run/akamu/ — the socket must be group-accessible; run usermod -aG akamu apache && systemctl reload httpd |
GSS_USE_PROXY not appearing in logs | Confirm gssproxy = true is set in [server.gssapi] or [admin.gssapi] |
| gssproxy errors | journalctl -u gssproxy — verify /etc/gssproxy/20-akamu.conf has euid = akamu and the keytab path is correct |
401 Unauthorized on ACME requests | The client’s Kerberos ticket may have expired; run kinit and retry |
| Admin operator login fails | Confirm akamuctl --url ... --gssapi is used and the operator is in the akamu database: akamuctl operators list |
| Certificate issuance fails with profile errors | Enable [profiles.providers.ipa] and run ipa_permissions.yml to grant CA LDAP read access |
Backup and Restore
This runbook describes how to back up an akamu deployment and restore it to a known-good state after hardware failure, corruption, or disaster recovery.
What to back up
| Asset | Location | Notes |
|---|---|---|
| Database | database.url in config | SQLite file or PostgreSQL/MariaDB dump |
| CA private key(s) | [[ca]] key_file | PEM or PKCS#11 — see below; one entry per configured CA |
| CA certificate(s) | [[ca]] cert_file | PEM; one entry per configured CA |
| Admin TLS key/cert | admin.key_file, admin.cert_file | If not auto-generated |
| Configuration file | akamu.toml | Server configuration |
| MTC log directory | mtc.log_path | Only if MTC is enabled |
| Cosigner config & key | akamu-cosigner.toml + key file | If cosigner is deployed |
Database backup
SQLite
SQLite in WAL mode supports online backup. Use the .backup command
while the server is running:
sqlite3 /var/lib/akamu/db.sqlite3 ".backup /var/backups/akamu/db-$(date +%F).sqlite3"
Alternatively, stop the server and copy the database file and its WAL/SHM companions:
systemctl stop akamu
cp /var/lib/akamu/db.sqlite3{,-wal,-shm} /var/backups/akamu/
systemctl start akamu
PostgreSQL
Use pg_dump for logical backups:
pg_dump -Fc akamu > /var/backups/akamu/db-$(date +%F).pgdump
For point-in-time recovery, configure WAL archiving and base backups per the PostgreSQL documentation.
MariaDB
Use mariadb-dump (or mysqldump):
mariadb-dump --single-transaction akamu > /var/backups/akamu/db-$(date +%F).sql
Key material backup
File-based keys
Copy the PEM files to secure offline storage:
cp /etc/akamu/ca.key.pem /var/backups/akamu/
cp /etc/akamu/ca.cert.pem /var/backups/akamu/
chmod 600 /var/backups/akamu/ca.key.pem
When ca.require_encrypted_key is enabled, also back up the
password file referenced by ca.key_password_file.
PKCS#11 / HSM keys
HSM key backup procedures are device-specific. Consult your HSM vendor’s documentation for key export or key wrapping operations. Ensure the wrapped key material is stored separately from the HSM.
Restore procedure
-
Stop the server:
systemctl stop akamu -
Restore the database:
- SQLite: copy the backup file to the configured path.
- PostgreSQL:
pg_restore -d akamu /var/backups/akamu/db-YYYY-MM-DD.pgdump - MariaDB:
mariadb akamu < /var/backups/akamu/db-YYYY-MM-DD.sql
-
Restore key material and configuration:
cp /var/backups/akamu/ca.key.pem /etc/akamu/ cp /var/backups/akamu/ca.cert.pem /etc/akamu/ cp /var/backups/akamu/akamu.toml /etc/akamu/ chmod 600 /etc/akamu/ca.key.pem -
Verify schema version:
The server applies pending migrations automatically on startup. No manual migration step is required.
-
Start the server:
systemctl start akamu -
Regenerate the CRL:
After restoring from backup, the CRL may be stale. Force regeneration:
akamuctl crl-force -
Verify the server is healthy:
akamuctl statsConfirm certificate and account counts match expectations.
Disaster recovery testing
Periodically verify that backups can be restored to a staging environment:
- Provision a clean staging host.
- Restore the database and key material from the latest backup.
- Start the server with the production configuration (adjusted for the staging listener address).
- Run
akamuctl statsand compare counts against production. - Issue a test certificate using
akamu-clito confirm end-to-end functionality.
Schedule this verification at least quarterly, or after any change to the backup pipeline.
Rotation and retention
- Keep at least 7 daily and 4 weekly database backups.
- Rotate backups using your preferred tool (
logrotate, cron cleanup script, or cloud object lifecycle rules). - Store backups on a different host or storage system from the production server.
- Encrypt backup archives at rest using
gpgor your storage provider’s server-side encryption.
akamuctl — Admin CLI
akamuctl is the command-line tool for administering a running akamu server or
cosigner daemon. It talks to the admin REST API over HTTPS with mTLS or session
token authentication and prints results as a human-readable table or as JSON.
Installation
Build from source alongside the rest of the workspace:
cargo build -p akamuctl --release
The binary is placed at target/release/akamuctl.
Quick start
# Log in via mTLS client certificate (caches a session token)
akamuctl --server-url https://admin.example.com:9443 \
--ca-cert /etc/akamu/certs/ca.cert.pem \
--cert /etc/akamu/certs/operator.cert.pem \
--key /etc/akamu/certs/operator.key.pem \
login
# Log in via Kerberos/GSSAPI (uses the ambient ccache from 'kinit')
akamuctl --server-url https://admin.example.com:9443 login --gssapi
# List operators
akamuctl operator list
# Add an EAB key
akamuctl eab add --kid acmeclient-001 \
--hmac-key c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg
# Query audit log for the last 20 failed events
akamuctl audit --outcome failure --limit 20
After login succeeds, the session token is written to
~/.config/akamu/session.json and reused automatically for subsequent
commands until it expires (default 1 hour).
Configuration file
akamuctl reads ~/.config/akamu/akamuctl.toml if it exists.
Command-line flags take precedence over the config file.
Use akamuctl config generate to print an annotated template you can save as a
starting point:
akamuctl config generate > ~/.config/akamu/akamuctl.toml
Full example
[server]
url = "https://admin.example.com:9443"
ca_cert = "/etc/akamu/certs/ca.cert.pem"
cert_file = "/etc/akamu/certs/operator.cert.pem"
key_file = "/etc/akamu/certs/operator.key.pem"
# Optional: override the GSSAPI SPN used by 'akamuctl login --gssapi'.
# When absent the SPN is derived automatically as HTTP@<hostname>.
# gssapi_service = "HTTP@admin.example.com"
[cosigner]
url = "https://cosigner.example.com:9444"
ca_cert = "/etc/akamu/certs/ca.cert.pem"
cert_file = "/etc/akamu/certs/operator.cert.pem"
key_file = "/etc/akamu/certs/operator.key.pem"
# gssapi_service = "HTTP@cosigner.example.com"
[server]
| Key | Description |
|---|---|
url | Admin listener URL (e.g. https://127.0.0.1:9443). |
ca_cert | PEM CA certificate used to verify the server’s TLS certificate. When absent, the system trust store is used. |
cert_file | PEM client certificate presented for mTLS authentication. |
key_file | PEM private key matching cert_file. |
gssapi_service | GSSAPI service principal name used by akamuctl login --gssapi. Overrides the automatic HTTP@<hostname> derivation from url. |
[cosigner]
Same fields as [server], applied when running cosigner subcommands.
Falls back to [server] values for any field that is absent.
Global flags
| Flag | Short | Description |
|---|---|---|
--config FILE | -c | Path to the akamuctl.toml config file. |
--server-url URL | Admin listener URL (overrides config). | |
--ca-cert FILE | CA certificate for server TLS verification. | |
--cert FILE | mTLS client certificate. | |
--key FILE | mTLS client private key. | |
--output FORMAT | -o | Output format: table (default) or json. |
Session management
login
Authenticate with the server and save a session token:
# mTLS — uses cert_file / key_file from config or --cert / --key flags
akamuctl login
# GSSAPI/Kerberos — uses the ambient Kerberos credential cache
kinit alice@EXAMPLE.COM # obtain a TGT if not already present
akamuctl login --gssapi
Both forms POST to /admin/session. On success the returned token is saved to
~/.config/akamu/session.json with mode 0600 (user-readable only).
Subsequent commands reuse this token without re-authenticating. A 30-second
expiry margin triggers automatic re-authentication before the server would
reject the token.
--gssapi flag
Sends an Authorization: Negotiate header built from the ambient Kerberos
ccache instead of presenting an mTLS client certificate. No keytab is
required — only a valid TGT (run kinit first).
The GSSAPI service principal name (SPN) is resolved as follows:
- If
gssapi_serviceis set in[server]config, it is used as-is. - Otherwise the SPN is derived from the server URL as
HTTP@<hostname>, where<hostname>is determined by:- Loopback names (
localhost,localhost.localdomain,ip6-localhost,ip6-loopback) and loopback IP addresses (127.x.x.x,::1) → replaced with the machine’s own FQDN viagethostname(2)and forward/reverse DNS. - Non-loopback IP addresses → resolved to a hostname via a DNS PTR lookup using hickory-resolver; a warning is printed and the bare IP is used as a fallback if reverse DNS fails.
- DNS hostnames → used directly.
- Loopback names (
logout
Invalidate the cached session token:
akamuctl logout
Calls DELETE /admin/session on the server and clears the local cache.
stats
Print live server counters:
akamuctl stats
Returns server version, uptime, and totals for accounts, certificates, EAB keys, and audit events. All authenticated roles may call this command.
whoami
Show the locally cached session identity without contacting the server:
akamuctl whoami
Displays the server URL and token expiry time for both the main server and cosigner sessions (if cached).
Operator management
Operator management requires the administrator role.
operator list
List all operators (active and inactive):
akamuctl operator list
operator show
Show the details of a single operator:
akamuctl operator show 3
Returns all fields including certificate fingerprint, GSSAPI principal, creation time, last-seen timestamp, and active status.
operator add
Register a new operator. At least one of --cert-file or --gssapi-principal
must be provided.
# mTLS operator: extract fingerprint from the certificate file
akamuctl operator add \
--name alice \
--role administrator \
--cert-file /etc/akamu/alice-client.pem
# GSSAPI/Kerberos operator
akamuctl operator add \
--name bob \
--role auditor \
--gssapi-principal bob@EXAMPLE.COM
Accepted roles: administrator, ca_operations, ca_ra, auditor.
When --cert-file is given, akamuctl computes the SHA-256 fingerprint of the
DER-encoded certificate leaf locally and sends only the fingerprint to the server.
The private key never leaves the operator’s machine.
operator update
Update fields of an existing operator. Only provided flags are changed; omitted fields remain unchanged.
# Change role
akamuctl operator update 3 --role ca_operations
# Replace client certificate
akamuctl operator update 3 --cert-file /etc/akamu/alice-new.pem
# Assign a CA scope to a ca_ra operator
akamuctl operator update 5 --role ca_ra --ca-id rsa
# Update multiple fields at once
akamuctl operator update 3 --name "Alice Smith" --role administrator \
--gssapi-principal alice@NEWREALM.COM
When the new role is ca_ra, --ca-id must also be provided. Setting
--role ca_ra without a CA scope is rejected by the server.
operator remove
Deactivate an operator (the operator record is retained for audit purposes):
akamuctl operator remove 3
The numeric argument is the operator id shown by operator list. Deactivating
an operator immediately invalidates any active sessions for that operator.
operator activate
Re-enable a previously deactivated operator:
akamuctl operator activate 3
operator unlock
Reset the failed-authentication counter for a locked-out operator:
akamuctl operator unlock 3
Use this when an operator has been locked out due to exceeding
max_failed_auth in the server configuration. Calls
POST /admin/operators/{id}/unlock (FIA_AFL.1).
Requires the administrator role.
EAB key management
eab list
List all EAB keys:
akamuctl eab list # all keys
akamuctl eab list --used # only consumed keys
akamuctl eab list --unused # only unconsumed keys
All authenticated roles may list EAB keys.
eab show
Show details of a single EAB key:
akamuctl eab show my-device-001
Returns the key identifier, creation time, usage status, and profile grants.
eab add
Provision a new EAB key, optionally restricting it to specific certificate profiles:
# Auto-generate kid and HMAC key on the server
akamuctl eab add
# Provide explicit values
akamuctl eab add --kid my-device-001 \
--hmac-key c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg
# Restrict to named profiles
akamuctl eab add --kid my-device-001 \
--profile tlsserver \
--profile codesigning
Requires the administrator, ca_operations, or ca_ra role.
eab remove
Deactivate an EAB key before it has been used:
akamuctl eab remove my-device-001
Requires the administrator or ca_operations role.
Certificate operations
cert list
Search issued certificates:
akamuctl cert list
akamuctl cert list --serial 0a1b2c3d --limit 5
akamuctl cert list --subject "CN=device.example.com" \
--status active --limit 50
akamuctl cert list --after 2026-01-01T00:00:00Z \
--before 2026-06-01T00:00:00Z
| Flag | Description |
|---|---|
--ca CA_ID | Filter by CA ID. Only certificates issued by the named CA are returned. |
--serial HEX | Filter by hex serial number. |
--subject TEXT | Filter by subject distinguished name substring. |
--after RFC3339 | Issued at or after this timestamp. |
--before RFC3339 | Issued at or before this timestamp. |
--status VALUE | active or revoked. |
--limit N | Maximum results (default 20). |
--offset N | Pagination offset (default 0). |
Requires the administrator, ca_operations, or auditor role.
cert show
Show a certificate’s metadata (no PEM/DER content):
akamuctl cert show <cert-uuid>
Returns order ID, account ID, serial number, validity window (not_before,
not_after), revocation status, MTC log index, and ARI renewal window
(suggested_window_start, suggested_window_end).
Requires the administrator, ca_operations, or auditor role.
cert download
Download a certificate’s content as PEM or DER:
# Print PEM to stdout (default)
akamuctl cert download <cert-uuid>
# Save DER to a file
akamuctl cert download <cert-uuid> --format der -o cert.der
# Save PEM to a file
akamuctl cert download <cert-uuid> --format pem -o cert.pem
Requires the administrator or ca_operations role.
revoke
Revoke a certificate by its internal ID:
akamuctl revoke <cert-id>
akamuctl revoke <cert-id> --reason 1 # keyCompromise
The revocation reason code follows RFC 5280 §5.3.1 (0 = unspecified, 1 = keyCompromise, 3 = affiliationChanged, 4 = superseded, etc.). Revoking immediately invalidates the CRL cache so the next CRL request reflects the change.
Requires the administrator, ca_operations, or ca_ra role.
crl-force
Force immediate CRL regeneration for the default CA without waiting for the next scheduled update:
akamuctl crl-force
To force regeneration for a specific CA use the ca crl-force subcommand
(see CA management below).
Requires the administrator or ca_operations role.
Account management
account list
List ACME accounts with optional status filtering:
akamuctl account list
akamuctl account list --status valid --limit 50
akamuctl account list --status deactivated
| Flag | Description |
|---|---|
--ca CA_ID | Filter by CA ID. Only accounts registered via the named CA’s new-account endpoint are returned. |
--status VALUE | valid or deactivated. |
--limit N | Maximum results (default 100). |
--offset N | Pagination offset (default 0). |
All authenticated roles may list accounts.
account show
Show details of a single ACME account:
akamuctl account show <account-uuid>
Returns status, contact information, JWK thumbprint, creation and update times, and profile grants.
account deactivate
Admin-initiated account deactivation:
akamuctl account deactivate <account-uuid>
The account is set to deactivated status and can no longer create orders
or issue certificates.
Requires the administrator role.
account grants get
Show current profile grants for an account:
akamuctl account grants get <account-uuid>
account grants set
Replace all profile grants for an account:
akamuctl account grants set <account-uuid> \
--profile tlsserver \
--profile codesigning
Requires the administrator or ca_operations role.
account grants clear
Remove all profile restrictions (restore unrestricted access):
akamuctl account grants clear <account-uuid>
Requires the administrator role.
CA management
In multi-CA deployments, these subcommands let you inspect the configured CAs, issue cross-certificates, and force per-CA CRL regeneration.
ca list
List all configured CAs:
akamuctl ca list
Returns each CA’s ID, default flag, key type, hash algorithm, CRL URL, and OCSP URL. All authenticated roles may call this command.
ca show
Show details of a single CA:
akamuctl ca show rsa
ca cert
Print the CA certificate PEM for the specified CA:
akamuctl ca cert rsa
akamuctl ca cert rsa -o /etc/akamu/rsa-ca.cert.pem
ca crl-force
Force immediate CRL regeneration for a specific CA:
akamuctl ca crl-force rsa
akamuctl ca crl-force ec
Requires the administrator or ca_operations role.
ca cross-sign
Issue a cross-certificate from one CA to another:
# Cross-sign another configured CA by its ID
akamuctl ca cross-sign rsa --subject-ca ec --validity-years 5
# Cross-sign an externally provided CA certificate
akamuctl ca cross-sign rsa --subject-cert /path/to/partner-ca.pem \
--validity-years 3
The issuer CA (rsa in the examples above) signs the subject’s public key.
The resulting cross-certificate has pathLenConstraint = 0.
Requires the administrator or ca_operations role.
Cross-certificate management
cross-cert list
List stored cross-certificates:
akamuctl cross-cert list
akamuctl cross-cert list --issuer-ca rsa
akamuctl cross-cert list --subject-ca ec
| Flag | Description |
|---|---|
--issuer-ca CA_ID | Filter by issuing CA ID. |
--subject-ca CA_ID | Filter by subject CA ID. |
--limit N | Maximum results (default 100). |
--offset N | Pagination offset (default 0). |
cross-cert show
Show the metadata for a single cross-certificate by UUID:
akamuctl cross-cert show <cross-cert-uuid>
Returns the cross-certificate UUID, issuer CA ID, subject CA ID (or null for
an external subject), creation timestamp, and the full PEM-encoded certificate.
Requires any authenticated role.
cross-cert download
Download a cross-certificate by UUID:
akamuctl cross-cert download <cross-cert-uuid>
akamuctl cross-cert download <cross-cert-uuid> --format pem -o cross.pem
Requires any authenticated role.
Delegation management
RFC 9115 delegation objects associate a CSR template (and optional CNAME map) with an ACME
account, allowing NDC (Name Delegation Consumer) clients to obtain certificates without
running a challenge responder. These commands require delegation_enabled = true in the
server configuration.
Read operations (delegation list, delegation show) are available to all authenticated
roles. Write operations (delegation add, delegation update, delegation remove)
require the administrator or ca_operations role.
delegation list
List all delegation objects, optionally filtered to a single account:
akamuctl delegation list
akamuctl delegation list --account-id <account-uuid>
Returns each delegation’s UUID, account ID, CSR template, CNAME map, creation timestamp, and last-updated timestamp.
delegation show
Show a single delegation object by its UUID:
akamuctl delegation show <delegation-uuid>
delegation add
Create a delegation for an account. The CSR template is a JSON file following RFC 9115 §4. The CNAME map is an optional JSON object mapping source FQDNs to target FQDNs.
akamuctl delegation add \
--account-id <account-uuid> \
--csr-template /path/to/template.json
akamuctl delegation add \
--account-id <account-uuid> \
--csr-template /path/to/template.json \
--cname-map /path/to/cname-map.json
Minimal CSR template example (template.json):
{
"keyTypes": [{"type": "EC", "curve": "P-256"}],
"subject": {
"commonName": {},
"organization": "ExampleCorp"
},
"extensions": {
"subjectAltName": {},
"keyUsage": ["digitalSignature"],
"extendedKeyUsage": ["1.3.6.1.5.5.7.3.1"]
}
}
CNAME map example (cname-map.json):
{
"cdn.example.com": "cdn.provider.example"
}
The CSR template is validated against the RFC 9115 §4 schema at write time. A malformed template is rejected before it is stored.
delegation update
Replace the CSR template and optionally the CNAME map for an existing delegation. The
account_id is immutable and cannot be changed.
# Replace template, keep existing CNAME map unchanged
akamuctl delegation update <delegation-uuid> \
--csr-template /path/to/new-template.json
# Replace template and CNAME map
akamuctl delegation update <delegation-uuid> \
--csr-template /path/to/new-template.json \
--cname-map /path/to/new-cname-map.json
# Replace template and remove the CNAME map
akamuctl delegation update <delegation-uuid> \
--csr-template /path/to/new-template.json \
--clear-cname-map
--cname-map and --clear-cname-map are mutually exclusive.
delegation remove
Delete a delegation object. Returns an error if one or more active orders still reference this delegation (the orders must be finalized or expired first).
akamuctl delegation remove <delegation-uuid>
Returns a 409 Conflict error when active orders reference the delegation.
Profile management
profile list
List all loaded certificate profiles with their parameters:
akamuctl profile list
Returns the profile ID, description, validity period (days), hash algorithm, extended key usages, and whether MTC issuance is enabled.
All authenticated roles may list profiles.
profile show
Show the full parameters for a single certificate profile by ID:
akamuctl profile show codesigning
Returns all profile fields: ID, description, validity period, hash algorithm,
key usage bits, extended key usages, CRL and OCSP URLs, allowed key types,
certificate policies, MTC issuance flag, allowed identifier patterns,
identifier_match_all, auth hook configuration, require_account_grant, and
the list of CA IDs the profile is restricted to (if any).
Returns 404 Not Found when no profile with the given ID is loaded.
All authenticated roles may show a profile.
profile add
Add a new certificate profile to the runtime cache:
akamuctl profile add codesigning --params-file /tmp/codesigning.json
The JSON file must contain the profile parameters accepted by
POST /admin/profiles. At minimum it should include description;
all other fields have defaults. Example file:
{
"description": "Code signing certificate",
"validity_days": 365,
"extended_key_usages": ["code_signing"],
"require_account_grant": true
}
Requires the administrator role.
profile update
Replace an existing certificate profile in the runtime cache:
akamuctl profile update codesigning --params-file /tmp/codesigning-v2.json
The JSON file uses the same schema as profile add (without the id field).
Requires the administrator role.
profile remove
Remove a certificate profile from the runtime cache:
akamuctl profile remove codesigning
Requires the administrator role.
Order management
order list
List certificate orders with optional filters:
akamuctl order list
akamuctl order list --account-id <account-uuid>
akamuctl order list --status pending --limit 50
| Flag | Description |
|---|---|
--ca CA_ID | Filter by CA ID. Only orders placed against the named CA are returned. |
--account-id UUID | Filter by account UUID. |
--status VALUE | Filter by order status (pending, ready, processing, valid, invalid). |
--limit N | Maximum results (default 100). |
--offset N | Pagination offset (default 0). |
All authenticated roles may list orders.
order show
Show a single order’s details:
akamuctl order show <order-uuid>
Returns identifiers, associated authorization IDs, certificate ID (if issued),
profile, and timing fields (created, updated, expires, not_before,
not_after).
All authenticated roles may view order details.
Server configuration
server-config
Show the server’s redacted runtime configuration:
akamuctl server-config
Returns the base URL, whether MTC is enabled, CAA identities, DNSSEC validation setting, and a masked database URL.
Requires the administrator role.
Audit log
audit
Query the structured audit event log:
akamuctl audit # most recent 100 events
akamuctl audit --type cert.issue # certificate issuance events
akamuctl audit --outcome failure --limit 50 # failed operations
akamuctl audit --subject <account-uuid> # events for a specific account
akamuctl audit --from 2026-05-01T00:00:00Z \
--until 2026-05-02T00:00:00Z # time range
| Flag | Description |
|---|---|
--type TYPE | Filter by event type string (see event types). |
--subject ID | Filter by subject (JWK thumbprint, account UUID, certificate serial, etc.). |
--from RFC3339 | Events at or after this timestamp. |
--until RFC3339 | Events at or before this timestamp. |
--outcome VALUE | success or failure. |
--limit N | Maximum results (default 100). |
--offset N | Pagination offset (default 0). |
Results are returned newest-first.
Requires the administrator or auditor role.
Audit event types
| Event type | Description |
|---|---|
ca.start | Server startup. |
ca.stop | Server shutdown. |
account.create | New ACME account registered. |
account.deactivate | Account deactivated. |
order.create | New certificate order created. |
order.finalize | Order finalization attempted. |
cert.issue | Certificate issued. |
cert.revoke | Certificate revoked. |
crl.generate | CRL generated. |
key.generate | Signing or CA key generated. |
key.load | Signing or CA key loaded from disk. |
auth.jws.ok | ACME JWS request authentication succeeded. |
auth.jws.fail | ACME JWS request authentication failed. |
auth.challenge.ok | ACME challenge validation succeeded. |
auth.challenge.fail | ACME challenge validation failed. |
eab.use | EAB key consumed by account registration. |
eab.reject | EAB key rejected (unknown, used, or MAC mismatch). |
admin.login | Operator authenticated to the admin API. |
admin.logout | Operator session invalidated. |
admin.action | Administrative action performed (operator CRUD, EAB management, etc.). |
delegation.create | RFC 9115 delegation object created. |
delegation.update | RFC 9115 delegation object updated. |
delegation.delete | RFC 9115 delegation object deleted. |
security.violation | Security anomaly detected. |
Cosigner administration
akamuctl can also administer the akamu-cosigner daemon.
Configure the cosigner connection in [cosigner] in the config file, or pass
--server-url and TLS flags directly.
cosigner login
Authenticate with the cosigner admin API and cache the session token:
akamuctl cosigner login
Uses the [cosigner] TLS settings from the config file (or falls back to
[server] settings if [cosigner] is not configured).
cosigner logout
Invalidate the cosigner session token:
akamuctl cosigner logout
cosigner status
Check whether the cosigner is running:
akamuctl cosigner status
Returns {"status":"ok","uptime_secs":…}.
cosigner stats
Show cosigner signing statistics:
akamuctl cosigner stats
Returns uptime, total checkpoints signed, and the timestamp of the most recent signing operation.
cosigner config
Show the cosigner’s redacted runtime configuration:
akamuctl cosigner config
Requires the administrator role on the cosigner.
tkauth JTI cache management
These commands require administrator or ca_operations role and are only available when [tkauth] is enabled in the server configuration.
tkauth prune-jti
Delete expired entries from the JTI replay-prevention cache:
akamuctl tkauth prune-jti
Use --dry-run to see the count of expired entries without deleting them:
akamuctl tkauth prune-jti --dry-run
The background task prunes the cache automatically at the configured interval. This command provides on-demand pruning or lets operators inspect cache size before a scheduled maintenance window.
Configuration utilities
config generate
Print an annotated example akamuctl.toml to stdout:
akamuctl config generate > ~/.config/akamu/akamuctl.toml
config validate
Validate the current configuration file:
akamuctl config validate
Checks that the server URL is well-formed and that all referenced files (CA certificate, client certificate, private key) exist on disk. Reports warnings and errors for each issue found.
Shell completions
completions
Generate shell completion scripts:
# Bash
akamuctl completions bash > /etc/bash_completion.d/akamuctl
# Zsh
akamuctl completions zsh > ~/.zfunc/_akamuctl
# Fish
akamuctl completions fish > ~/.config/fish/completions/akamuctl.fish
Supported shells: bash, zsh, fish, elvish, powershell.
Output formats
By default, akamuctl prints results as aligned tables. Use --output json
(or -o json) to get pretty-printed JSON suitable for scripting:
akamuctl -o json operator list | jq '.operators[] | select(.role == "auditor")'
Table output
When the server response is a JSON object with a single array-valued field
(the common case for paginated endpoints such as cert list and audit),
the array is rendered as an aligned table and any remaining scalar fields
(e.g. total, offset, limit) are printed as a key-value footer
below the table. When the response is a plain JSON array the table is
printed directly. Scalar responses are printed as a single line.
When a paginated query returns no rows, (no results) is printed instead
of an empty table.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | General error (HTTP, network, JSON parse failure). |
| 2 | Authentication error (session expired, certificate rejected). |
| 3 | Configuration error (missing or invalid config file or flag). |
Cluster Setup and Gossip Replication
Akamu supports multi-node deployments through CRDT-based gossip replication. Each node maintains its own local SQLite (or PostgreSQL/MariaDB) database and replicates state to peers over an authenticated, encrypted gossip channel.
Prerequisites
- Separate database per node. Each node holds its own database; there is no shared database in a cluster. Provision one SQLite/PostgreSQL/MariaDB instance per node.
- CA private keys on every node. CA keys are never replicated. Copy the CA PEM files to every node before starting it.
- Network reachability. Each node must be able to reach every peer’s gossip URL (typically the admin socket or a dedicated internal port).
- Firewall rules. Gossip traffic goes to the admin interface. Keep it off the public ACME listener.
Configuration
Add a [gossip] section to each node’s akamu.toml:
[gossip]
# URLs of all other cluster nodes (admin base URL, not the ACME URL).
peers = [
"http://node2.acme.internal:8081",
"http://node3.acme.internal:8081",
]
# How often to run a gossip round (seconds). Default: 15.
interval_secs = 15
# How long to keep tombstoned entries before GC (seconds). Default: 604800 = 7 days.
tombstone_ttl_secs = 604800
# How long a node may claim exclusive ownership of an order/MTC write slot
# before another node may take over. Default: 150 seconds.
ownership_ttl_secs = 150
Omitting the [gossip] section entirely puts the node in single-node mode: no replication,
no gossip background task.
Startup Sequence
On first start a new node:
- Generates an ML-KEM-768 key pair and an ECDSA P-256 gossip signing key pair.
- Stores both key pairs in the local database (
node_keystable). - Registers itself in the in-memory CRDT cluster node map.
- Starts the gossip background loop.
On the first successful gossip round with each peer the node logs:
INFO gossip: first-contact merge complete peer="http://node2.acme.internal:8081"
accounts=142 orders=891 certificates=734 authorizations=1023 cluster_nodes=2
After this log line the node has full knowledge of all existing ACME state and is ready to serve requests.
Adding a Node to a Running Cluster
- Provision the new node’s database and CA key files.
- Add the new node’s gossip URL to every existing node’s
peerslist and reload their configuration (SIGHUP or restart). - Start the new node with a
[gossip]section listing at least one existing peer. - Wait for the “first-contact merge complete” log line. The new node is now in sync.
Troubleshooting
gossip: no KEM key for peer, skipping
The peer is not yet in the cluster node map. This is normal for 1–2 rounds after a new node starts. If it persists after 3 rounds, check that:
- The peer node started successfully and its gossip loop is running.
- The peer’s gossip URL is reachable from this node.
- Both nodes list each other in their
peersconfiguration.
gossip: verify_and_open response failed
The response could not be authenticated. Possible causes:
- Clock skew between nodes (default tolerance is
tombstone_ttl_secs; a node whose clock is wildly ahead will produce envelopes that look stale to peers). - A misconfigured or corrupted
node_keystable.
X-Akamu-Node-Id header rejected by peer
The responding peer’s handler rejected the sender’s node ID because the sender is not yet
in the peer’s cluster_nodes CRDT. This resolves automatically after the first successful
full-state exchange. If it does not resolve, verify that gossip traffic is not blocked by a
firewall between the nodes.
Gossip stalls entirely
Check that the [gossip] section is present in akamu.toml and that peers is non-empty.
A node with no configured peers (or no [gossip] section) logs
gossip: no peers configured — loop disabled and exits the gossip loop immediately.
Admin API and Operator Management
API Reference — This page documents the admin REST API for operators and automation tools. For the akamuctl CLI that wraps this API, see akamuctl — Admin CLI in the Operator Guide.
The akamu admin API is a separate HTTPS listener that exposes management
endpoints for operators. It is completely independent of the main ACME
listener: it binds to a different address, uses its own TLS certificate, and
requires operator authentication on every request. When the [admin] section
is absent from the configuration file, all admin endpoints return 404 Not Found
and are unreachable.
See akamuctl for the command-line tool that wraps this API.
See Configuration Reference — [admin] for all
configuration keys.
Authentication
Every request to the admin API must be authenticated. Three mechanisms are supported.
mTLS client certificate
The client presents a certificate during the TLS handshake. The server computes
the SHA-256 fingerprint of the DER-encoded leaf certificate and looks it up in
the operators table. On success, the server issues a session token and returns
it in the response body under session_token and in the X-Session-Token
response header.
GSSAPI/Kerberos
The client sends an Authorization: Negotiate <base64-SPNEGO-token> header.
The server validates the token against the keytab configured in
[admin.gssapi], extracts the Kerberos principal, and looks it up in the
operators table. On success the server issues a session token (same as the
mTLS path) and may include a GSSAPI continuation token in a
WWW-Authenticate: Negotiate <token> response header.
Bearer session token
After a successful mTLS or GSSAPI login, the client passes the returned token
as Authorization: Bearer <token> on subsequent requests. The server looks up
the token in its in-memory session store and refreshes the idle timer. Tokens
that have been idle for longer than session_ttl_secs (default 1 hour) are
expired and the client receives 401 Unauthorized.
The session store is bounded at 1 000 active sessions. When the cap is reached, the least-recently-active session is evicted.
Token comparisons use constant-time equality to prevent timing side-channels.
Roles
Each operator has exactly one role that determines which admin endpoints they may call. For a full description of each role, its capabilities, restrictions, and the complete route-by-role permission matrix, see Operator Roles.
Endpoint reference
All paths are relative to the admin listener base URL. The [admin].listen_addr
field controls the address; the default in the configuration example is
https://127.0.0.1:9443.
POST /admin/session
Authenticate and obtain a session token. The request must carry one of the three credential types described above.
Response 200 OK:
{
"session_token": "a4f1…64-hex-chars…",
"role": "auditor",
"expires_at": "2026-05-02T14:00:00Z"
}
The token is also returned in the X-Session-Token response header.
DELETE /admin/session
Invalidate the current session token. The server removes the token from its
in-memory store, records an admin.logout audit event, and returns a
Set-Cookie: session=; Max-Age=0 header that instructs the browser to
immediately expire the session cookie set at login.
Response: 204 No Content.
GET /admin/operators
List all registered operators, including deactivated ones.
Query parameters: limit (1–1000, default 1000), offset (default 0).
Response 200 OK:
{
"operators": [
{
"id": 1,
"name": "alice",
"role": "administrator",
"cert_fingerprint": "a3b4c5…",
"gssapi_principal": null,
"created_at": "2026-05-01T09:00:00Z",
"last_seen_at": "2026-05-02T08:30:00Z",
"active": true,
"failed_attempts": 0,
"locked_until": null
}
]
}
POST /admin/operators
Register a new operator. At least one of cert_fingerprint or
gssapi_principal must be provided.
Request body:
{
"name": "bob",
"role": "auditor",
"cert_fingerprint": "b2c3d4…",
"gssapi_principal": null
}
cert_fingerprint is the lowercase hex SHA-256 digest of the DER-encoded
client certificate leaf. The akamuctl operator add --cert-file command
computes this automatically.
Response 201 Created:
{ "name": "bob", "created_at": "2026-05-02T10:00:00Z" }
Returns 409 Conflict when an operator with the same fingerprint or principal
already exists.
GET /admin/operators/{id}
Show a single operator’s details.
Response 200 OK:
{
"id": 3,
"name": "alice",
"role": "administrator",
"cert_fingerprint": "a3b4c5…",
"gssapi_principal": null,
"created_at": "2026-05-01T09:00:00Z",
"last_seen_at": "2026-05-02T08:30:00Z",
"active": true,
"failed_attempts": 0,
"locked_until": null
}
Returns 404 Not Found when the ID does not exist.
PUT /admin/operators/{id}
Update operator fields. Only provided fields are changed; omitted fields remain unchanged.
Request body:
{
"name": "Alice Smith",
"role": "ca_operations",
"cert_fingerprint": "d4e5f6…",
"gssapi_principal": "alice@NEWREALM.COM"
}
All fields are optional. role must be one of administrator, ca_operations,
ca_ra, or auditor when provided.
Response: 204 No Content on success, 404 Not Found when the ID does not
exist.
PATCH /admin/operators/{id}
Update the active status or ca_id scope of an operator.
Request body:
{ "active": false }
or, to assign a CA scope to a ca_ra operator:
{ "ca_id": "rsa" }
Set active to false to deactivate, true to reactivate. Deactivating an
operator immediately invalidates all of that operator’s active session tokens.
When ca_id is provided, the operator’s CA scope is updated. Setting
role = "ca_ra" without also providing a non-empty ca_id (either in this
request or already stored) is rejected with 422 Unprocessable Entity.
Response: 204 No Content on success, 404 Not Found when the ID does not
exist.
POST /admin/operators/{id}/unlock
Reset the operator’s failed-authentication counter and clear the lockout
timestamp (FIA_AFL.1). Use this when an operator has been locked out due to
exceeding max_failed_auth.
Response: 204 No Content on success, 404 Not Found when the ID does not
exist.
GET /admin/audit
Query the structured audit event log. See Audit Trail for details on the event taxonomy.
Query parameters:
| Parameter | Description |
|---|---|
type | Filter by event type string (e.g. cert.issue). |
subject | Filter by subject (account UUID, certificate serial, JWK thumbprint, etc.). |
from | RFC 3339 lower bound for occurred_at. |
until | RFC 3339 upper bound for occurred_at. |
outcome | success or failure. |
limit | 1–1000, default 100. |
offset | Default 0. |
Results are ordered newest-first.
Response 200 OK:
{
"events": [
{
"occurred_at": "2026-05-02T08:30:00Z",
"event_type": "cert.issue",
"subject": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"principal": "acme:xZ9gF…",
"outcome": "success",
"detail": "{\"profile\":\"tlsserver\"}"
}
],
"total_since_startup": 5000,
"limit": 100,
"offset": 0
}
Error responses:
| Status | Condition |
|---|---|
400 Bad Request | The from or until parameter is not a valid RFC 3339 timestamp. The response body includes a detail field describing the error. |
500 Internal Server Error | The audit backend (journal namespace socket, JSONL file, or journalctl subprocess) is inaccessible. The response body includes {"status": 500, "detail": "journal query error"}. |
GET /admin/profiles
List all loaded certificate profiles with their parameters.
Response 200 OK:
{
"profiles": [
{
"id": "tlsserver",
"description": "TLS server certificate",
"validity_days": 90,
"hash_alg": "SHA256",
"extended_key_usages": ["serverAuth"],
"issue_as_mtc": false
}
]
}
GET /admin/profiles/{id}
Return a single certificate profile by ID.
Response 200 OK:
{
"id": "codesigning",
"description": "Code signing certificate",
"validity_days": 365,
"hash_alg": "SHA256",
"key_usage_bits": null,
"extended_key_usages": ["code_signing"],
"crl_url": null,
"ocsp_url": null,
"allowed_key_types": null,
"certificate_policies": null,
"issue_as_mtc": false,
"allowed_identifier_patterns": null,
"identifier_match_all": false,
"auth_hook": null,
"auth_hook_timeout_secs": null,
"require_account_grant": true,
"ca_ids": null
}
Returns 404 Not Found when no profile with the given ID is loaded.
Requires any authenticated role.
POST /admin/profiles
Add a new certificate profile to the runtime cache (FPT_NPE_EXT.1).
Requires the administrator role.
Request body:
{
"id": "codesigning",
"description": "Code signing certificate",
"validity_days": 365,
"hash_alg": "sha256",
"extended_key_usages": ["code_signing"],
"require_account_grant": true
}
All fields except id are optional and have defaults (90 days validity, sha256
hash, no extended key usage restriction). Returns 409 Conflict when a profile
with the same id already exists.
Response 201 Created:
{ "id": "codesigning", "description": "Code signing certificate" }
PUT /admin/profiles/{id}
Replace an existing certificate profile in the runtime cache (FPT_NPE_EXT.1).
The profile is identified by {id} in the URL path; the request body uses the
same schema as POST /admin/profiles but without the id field.
Requires the administrator role.
Response: 204 No Content on success, 404 Not Found when the profile does
not exist.
DELETE /admin/profiles/{id}
Remove a certificate profile from the runtime cache (FPT_NPE_EXT.1).
Requires the administrator role.
Response: 204 No Content on success, 404 Not Found when the profile does
not exist.
GET /admin/accounts
List ACME accounts with optional filtering and pagination.
Query parameters:
| Parameter | Description |
|---|---|
ca_id | Filter by CA ID. Only accounts registered via the named CA’s new-account endpoint are returned. |
status | Filter by account status (valid or deactivated). |
limit | 1–1000, default 100. |
offset | Default 0. |
Response 200 OK:
{
"accounts": [
{
"id": "d290f1ee-…",
"status": "valid",
"contact": "[\"mailto:admin@example.com\"]",
"jwk_thumbprint": "xZ9gF…",
"created": 1746154800,
"updated": 1746241200,
"profile_grants": "[\"tlsserver\"]"
}
],
"limit": 100,
"offset": 0
}
GET /admin/account/{id}
Show a single account’s details.
Response 200 OK:
{
"id": "d290f1ee-…",
"status": "valid",
"contact": "[\"mailto:admin@example.com\"]",
"jwk_thumbprint": "xZ9gF…",
"created": 1746154800,
"updated": 1746241200,
"profile_grants": "[\"tlsserver\"]"
}
Returns 404 Not Found when the account does not exist.
POST /admin/account/{id}/deactivate
Admin-initiated account deactivation. Sets the account status to deactivated.
The account can no longer create orders or issue certificates.
Response: 204 No Content on success, 404 Not Found when the account
does not exist.
GET /admin/account/{id}/profile-grants
Return the profile grant list for account {id}. null means the account
has no restrictions and may request any profile.
Response 200 OK:
{ "profile_grants": ["tlsserver", "codesigning"] }
or
{ "profile_grants": null }
PUT /admin/account/{id}/profile-grants
Replace the account’s profile grant list.
Request body:
{ "profile_grants": ["tlsserver"] }
Response: 204 No Content.
DELETE /admin/account/{id}/profile-grants
Clear all profile restrictions. Sets profile_grants to null
(unrestricted).
Response: 204 No Content.
GET /admin/certs
Search the certificate table.
Query parameters: ca_id (filter by CA ID), serial, subject (subject DN substring match), account_id,
after (RFC 3339), before (RFC 3339), status (active or revoked),
limit (1–1000, default 100), offset.
Response 200 OK:
{
"certs": [
{
"id": "3fa85f64-…",
"account_id": "d290f1ee-…",
"serial_number": "0a1b2c3d",
"status": "active",
"not_before": "2026-05-01T00:00:00Z",
"not_after": "2026-07-30T00:00:00Z",
"revoked_at": null,
"revocation_reason": null
}
],
"limit": 100,
"offset": 0
}
GET /admin/certs/{id}
Show a single certificate’s metadata. Does not return the PEM or DER content (use the download endpoint for that).
Response 200 OK:
{
"id": "3fa85f64-…",
"order_id": "7b2e1a3f-…",
"account_id": "d290f1ee-…",
"serial_number": "0a1b2c3d",
"status": "active",
"not_before": "2026-05-01T00:00:00Z",
"not_after": "2026-07-30T00:00:00Z",
"revoked_at": null,
"revocation_reason": null,
"mtc_log_index": null,
"created": 1746154800,
"suggested_window_start": 1750000000,
"suggested_window_end": 1751000000,
"replaced_by": null
}
Returns 404 Not Found when the certificate does not exist.
GET /admin/certs/{id}/download
Download a certificate’s content as PEM or DER.
Query parameters:
| Parameter | Description |
|---|---|
format | pem (default) or der. |
Response 200 OK:
- PEM format:
Content-Type: application/pem-certificate-chain - DER format:
Content-Type: application/pkix-cert
Returns 404 Not Found when the certificate does not exist.
POST /admin/eab
Provision a new External Account Binding key.
Requires the administrator or ca_operations role.
Request body:
{
"kid": "my-device-001",
"hmac_key_b64u": "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg",
"profile_grants": ["tlsserver"],
"alg": "sha256",
"for_operator_id": 3
}
| Field | Required | Description |
|---|---|---|
kid | Yes | Unique key identifier string. |
hmac_key_b64u | Yes | Base64url-encoded raw HMAC key bytes (no padding). |
profile_grants | No | Array of profile names the EAB key pre-authorizes. Omit or set to null for an unrestricted key. |
alg | No | HMAC algorithm: "sha256" (default), "sha384", or "sha512". |
for_operator_id | No | Administrator only. When set, created_by_operator_id on the new key is set to this operator ID instead of the calling operator. This controls which operator the key is associated with for POST /admin/session/eab web UI login. |
EAB keys are server-global and are not bound to any CA, even when created by a
scoped ca_operations operator. Only administrator may set for_operator_id;
a ca_operations caller that includes it receives 403 Forbidden.
Returns 409 Conflict when the kid already exists.
Response 201 Created:
{ "kid": "my-device-001", "created": 1746154800, "alg": "sha256" }
GET /admin/eab/{kid}
Show a single EAB key’s details.
Response 200 OK:
{
"kid": "my-device-001",
"created": 1746154800,
"used_at": null,
"profile_grants": "[\"tlsserver\"]"
}
Returns 404 Not Found when the key does not exist.
DELETE /admin/eab/{kid}
Deactivate an EAB key. The key is removed from the table; any previously
issued HMAC credentials for this kid are permanently invalidated.
Response: 204 No Content, 404 Not Found when the key does not exist.
GET /admin/eab
List EAB keys.
Query parameters: used (true/false to filter by usage status), limit
(1–1000, default 200), offset.
Response 200 OK:
{
"eab_keys": [
{
"kid": "my-device-001",
"created": 1746154800,
"used_at": null,
"profile_grants": "[\"tlsserver\"]"
}
]
}
GET /admin/orders
List certificate orders with optional filtering and pagination.
Query parameters:
| Parameter | Description |
|---|---|
ca_id | Filter by CA ID. |
account_id | Filter by account UUID. |
status | Filter by order status (pending, ready, processing, valid, invalid). |
limit | 1–1000, default 100. |
offset | Default 0. |
Response 200 OK:
{
"orders": [
{
"id": "7b2e1a3f-…",
"account_id": "d290f1ee-…",
"status": "valid",
"identifiers": "[{\"type\":\"dns\",\"value\":\"example.com\"}]",
"certificate_id": "3fa85f64-…",
"profile": "tlsserver",
"created": 1746154800,
"updated": 1746241200,
"expires": 1746760800
}
],
"limit": 100,
"offset": 0
}
GET /admin/orders/{id}
Show a single order’s details, including authorization IDs.
Response 200 OK:
{
"id": "7b2e1a3f-…",
"account_id": "d290f1ee-…",
"status": "valid",
"identifiers": "[{\"type\":\"dns\",\"value\":\"example.com\"}]",
"certificate_id": "3fa85f64-…",
"profile": "tlsserver",
"created": 1746154800,
"updated": 1746241200,
"expires": 1746760800,
"not_before": null,
"not_after": null,
"replaces": null,
"authorization_ids": ["a1b2c3d4-…", "e5f6a7b8-…"]
}
Returns 404 Not Found when the order does not exist.
GET /admin/config
Show the server’s redacted runtime configuration. Sensitive values such as the database URL are masked.
Response 200 OK:
{
"base_url": "https://acme.example.com",
"db_url": "***",
"mtc_enabled": false,
"caa_identities": ["example.com"],
"validate_dnssec": true
}
POST /admin/crl/force
Force immediate CRL regeneration. The cached CRL is invalidated so the next
GET /ca/crl request produces a fresh CRL reflecting all current revocations.
Response: 204 No Content.
POST /admin/revoke
Revoke a certificate by its internal ID.
Request body:
{ "cert_id": "3fa85f64-…", "reason": 1 }
reason is an RFC 5280 reason code (0 = unspecified, 1 = keyCompromise,
3 = affiliationChanged, 4 = superseded, 5 = cessationOfOperation, etc.).
Revocation immediately invalidates the CRL cache.
Response: 204 No Content, 404 Not Found when the certificate is not
found or is already revoked.
GET /admin/stats
Return live server statistics. All authenticated roles may call this endpoint.
Response 200 OK:
{
"server_version": "0.1.0",
"uptime_secs": 3600,
"accounts": { "total": 42, "active": 40 },
"certs": { "total": 200, "active": 180, "revoked": 20 },
"eab_keys": { "total": 10, "used": 8, "unused": 2 },
"audit_events": { "since_startup": 5000 }
}
CA management endpoints
GET /admin/cas
List all configured CAs.
Response 200 OK:
{
"cas": [
{
"id": "rsa",
"is_default": true,
"key_type": "rsa:4096",
"hash_alg": "sha256",
"crl_url": "http://acme.example.com/ca/rsa/crl",
"ocsp_url": "http://acme.example.com/ca/rsa/ocsp"
}
]
}
GET /admin/cas/{id}
Show details of a single CA including the CA certificate PEM.
Response 200 OK:
{
"id": "rsa",
"is_default": true,
"key_type": "rsa:4096",
"hash_alg": "sha256",
"crl_url": "http://acme.example.com/ca/rsa/crl",
"ocsp_url": "http://acme.example.com/ca/rsa/ocsp",
"cert_pem": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n"
}
Returns 404 Not Found when the CA ID does not exist.
POST /admin/ca/{id}/crl/force
Force immediate CRL regeneration for the specified CA. The cached CRL is
invalidated so the next GET /ca/{id}/crl request produces a fresh CRL
reflecting all current revocations for that CA.
Requires the ca_operations or administrator role.
Response: 204 No Content. Returns 404 Not Found when the CA ID does
not exist.
POST /admin/ca/{id}/cross-sign
Issue a cross-certificate: CA {id} (the issuer) signs the public key of
another CA or an externally supplied certificate. The resulting cross-cert is
stored in the database and retrievable via GET /admin/cross-certs/{id} and
the public GET /ca/{subject_id}/cross-certs endpoint.
The issued cross-certificate always has pathLenConstraint = 0 — the subject
CA cannot use it to issue further subordinate CAs.
Request body (exactly one of subject_ca_id or subject_cert_pem must
be provided):
{ "subject_ca_id": "ec", "validity_years": 5 }
or
{ "subject_cert_pem": "-----BEGIN CERTIFICATE-----\n…", "validity_years": 5 }
Requires the administrator or ca_operations role.
Response 201 Created:
{ "id": "a1b2c3d4-…", "created_at": "2026-05-06T12:00:00Z" }
Returns 404 Not Found when the issuer CA ID or subject_ca_id does not exist.
GET /admin/cross-certs
List stored cross-certificates.
Query parameters:
| Parameter | Description |
|---|---|
issuer_ca_id | Filter by issuing CA ID. |
subject_ca_id | Filter by subject CA ID. |
limit | 1–1000, default 100. |
offset | Default 0. |
Response 200 OK:
{
"cross_certs": [
{
"id": "a1b2c3d4-…",
"issuer_ca_id": "rsa",
"subject_ca_id": "ec",
"not_before": "2026-05-06T12:00:00Z",
"not_after": "2031-05-06T12:00:00Z",
"created_at": "2026-05-06T12:00:00Z"
}
],
"limit": 100,
"offset": 0
}
GET /admin/cross-certs/{id}
Show a single cross-certificate by UUID, including its PEM.
Response 200 OK:
{
"id": "a1b2c3d4-…",
"issuer_ca_id": "rsa",
"subject_ca_id": "ec",
"not_before": "2026-05-06T12:00:00Z",
"not_after": "2031-05-06T12:00:00Z",
"created_at": "2026-05-06T12:00:00Z",
"cert_pem": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n"
}
Returns 404 Not Found when the cross-cert ID does not exist.
Delegation management endpoints
These endpoints are active only when server.delegation_enabled = true is set in the configuration. Delegations represent pre-configured RFC 9115 IdO-to-NDC delegation policies: a CSR template and an optional CNAME map. Read operations (GET) are available to all authenticated roles; write operations (POST, PUT, DELETE) require the administrator or ca_operations role.
GET /admin/delegations
List all delegation objects. Optionally filter by account.
Query parameters:
| Parameter | Description |
|---|---|
account_id | Filter by ACME account UUID. |
limit | 1–1000, default 100. |
offset | Default 0. |
Response 200 OK:
{
"delegations": [
{
"id": "b1c2d3e4-…",
"account_id": "d290f1ee-…",
"csr_template": "{…}",
"cname_map": null,
"created": 1746154800,
"updated": 1746154800
}
],
"limit": 100,
"offset": 0
}
POST /admin/delegations
Create a new delegation object. The csr_template field is validated against the RFC 9115 §4 schema at write time; a malformed template is rejected with 400 Bad Request.
Request body:
{
"account_id": "d290f1ee-…",
"csr_template": {
"keyTypes": [{"type": "EC", "curve": "P-256"}],
"subject": {"commonName": {}, "organization": "ExampleCorp"},
"extensions": {
"subjectAltName": {},
"keyUsage": ["digitalSignature"],
"extendedKeyUsage": ["1.3.6.1.5.5.7.3.1"]
}
},
"cname_map": null
}
cname_map is optional. When present it is a JSON object mapping source FQDNs to target FQDNs (e.g. {"cdn.example.com": "cdn.provider.example"}).
Response 201 Created:
{ "id": "b1c2d3e4-…", "created": 1746154800 }
GET /admin/delegations/{id}
Fetch a single delegation object by UUID.
Response 200 OK:
{
"id": "b1c2d3e4-…",
"account_id": "d290f1ee-…",
"csr_template": "{…}",
"cname_map": null,
"created": 1746154800,
"updated": 1746154800
}
Returns 404 Not Found when the ID does not exist.
PUT /admin/delegations/{id}
Replace the csr_template and/or cname_map of an existing delegation. The csr_template is re-validated at write time. Only csr_template and cname_map may be updated; account_id is immutable.
Request body:
{
"csr_template": {…},
"cname_map": {"cdn.example.com": "cdn.provider.example"}
}
Response: 204 No Content on success, 404 Not Found when the ID does not exist.
DELETE /admin/delegations/{id}
Delete a delegation object. Returns 409 Conflict when one or more orders still reference this delegation (the orders must be finalized or deleted first).
Response: 204 No Content, 404 Not Found when the ID does not exist, 409 Conflict when orders reference it.
Audit events
Every write operation emits a structured audit event to the configured audit backend (systemd journal namespace, JSONL file, or in-process store):
| Operation | Event type |
|---|---|
POST /admin/delegations | delegation.create |
PUT /admin/delegations/{id} | delegation.update |
DELETE /admin/delegations/{id} | delegation.delete |
Query these events with GET /admin/audit?type=delegation.create or akamuctl audit --type delegation.create.
CLI
All delegation management endpoints are wrapped by akamuctl delegation. See
akamuctl — Admin CLI for the full command
reference including flags and examples.
Audit trail
Every admin operation is written to a structured audit backend. Three backends are available:
- Systemd journal namespace (default) — when running under systemd with
LogNamespace=akamu(seecontrib/systemd/akamu.service), events are stored in/var/log/journal/<machine-id>.akamu/. - JSONL file — when
[server].audit_log_fileis set, events are written as append-only JSON Lines to the specified file. Externallogrotate(8)withcopytruncateis expected for rotation. Each query scans at most 500,000 lines to prevent unbounded reads on unrotated files. - In-process store — in tests or development without systemd and without a configured file, an in-memory store is used automatically.
Each journal entry carries structured fields:
| Journal field | Content |
|---|---|
AKAMU_EVENT_TYPE | Event type string (e.g. cert.issue, admin.login) |
AKAMU_SUBJECT | Resource identifier (account UUID, certificate serial, etc.) |
AKAMU_PRINCIPAL | Authenticated operator name or acme:<jwk_thumbprint> |
AKAMU_OUTCOME | success or failure |
AKAMU_DETAIL | JSON object with operation-specific fields |
Query examples:
journalctl --namespace=akamu # all audit events
journalctl --namespace=akamu AKAMU_EVENT_TYPE=cert.issue # by type
journalctl --namespace=akamu AKAMU_OUTCOME=failure # failures only
Retention is managed by journald itself (see contrib/systemd/journald@akamu.conf
for default settings: 500 MB disk, 1 year max age).
Overflow policy (FAU_STG.4)
When audit_max_events is set (backward-compatible alias: audit_max_rows),
the server tracks an in-memory event count since startup. The
audit_overflow policy determines what happens when the count reaches the
limit. The default is "drop_oldest", which is effectively a no-op
(journald or the file backend manages its own retention). The alternative
"halt" refuses all new requests until the server is restarted.
Alarm response (FAU_ARP.1)
The server maintains an in-memory rolling 5-minute count of
security.violation audit events. When the count reaches
audit_alarm_threshold (default 10), the audit_alarm_action fires:
"syslog"(default) — aCRIT-level message is emitted viatracing, which is forwarded to the system log by the process manager."halt"— the server stops accepting new requests until restarted.
The halt flag is also set when the "halt" overflow policy is triggered.
Operator management workflow
Initial setup
Akāmu auto-provisions the first administrator on first run. Add two keys to
[admin] that point to where the bootstrap certificate and key should live:
[admin]
listen_addr = "127.0.0.1:9443"
cert_file = "/etc/akamu/admin-tls.pem"
key_file = "/etc/akamu/admin-tls-key.pem"
ca_certs = ["/etc/akamu/ca.pem"]
# Bootstrap operator — generated automatically on first run.
bootstrap_operator_cert_file = "/etc/akamu/admin-bootstrap.pem"
bootstrap_operator_key_file = "/etc/akamu/admin-bootstrap-key.pem"
# bootstrap_operator_name = "admin" # default
# bootstrap_key_type = "ec:P-256" # default
On the first startup, if both files are absent and the operators table is empty, Akāmu:
- Generates a fresh private key (using
bootstrap_key_type). - Issues a client certificate signed by the Akāmu CA with
CN=<bootstrap_operator_name>. - Writes the key and certificate PEM files to the configured paths.
- Registers the certificate’s SHA-256 fingerprint in the operators table with
the
administratorrole.
Both the admin listener TLS certificate (cert_file/key_file) and the
bootstrap operator cert are auto-generated if absent; the admin listener cert
uses server_name (default "localhost") as the CN/SAN.
After first boot, use the bootstrap cert to authenticate and provision real operator accounts:
# Add a permanent operator with their own client cert.
akamuctl --cert /etc/akamu/admin-bootstrap.pem \
--key /etc/akamu/admin-bootstrap-key.pem \
operator add --name alice --role administrator \
--cert-file /etc/akamu/alice-client.pem
# Deactivate the bootstrap operator once a permanent one is in place.
akamuctl --cert /etc/akamu/admin-bootstrap.pem \
--key /etc/akamu/admin-bootstrap-key.pem \
operator remove 1
Note: If the bootstrap cert/key files are absent but the operators table already contains rows (e.g. after a mistaken file deletion), Akāmu refuses to start with an error rather than silently creating a duplicate administrator. Restore the files from backup, or remove
bootstrap_operator_cert_fileandbootstrap_operator_key_filefrom the config and manage operators entirely throughakamuctl.
Revoking access
Deactivate an operator with akamuctl operator remove <id> or
PATCH /admin/operators/{id} with {"active":false}. The record is
preserved for audit trail continuity. The operator’s active sessions are
invalidated immediately and they cannot authenticate again until reactivated.
ACME Protocol Reference
This page is the protocol-level API reference for clients interacting with Akāmu: which JWS algorithms the server accepts, which challenge types are offered and for which identifier types, the wire format for EAB credentials, and the endpoint contract for ARI and ACME STAR.
For implementation notes — how the server verifies these on the wire, DER encoding helpers, and pre-issuance linting — see RFC Compliance Internals.
JWS algorithm support (RFC 8555 §6.2)
All ACME POST requests must be signed with JWS flattened JSON serialization (RFC 7515 §7.2.6). The server accepts the following alg values in the JWS protected header:
alg | Key type | Curve / variant |
|---|---|---|
RS256 | RSA | SHA-256 |
RS384 | RSA | SHA-384 |
RS512 | RSA | SHA-512 |
PS256 | RSA-PSS | SHA-256 |
PS384 | RSA-PSS | SHA-384 |
PS512 | RSA-PSS | SHA-512 |
ES256 | EC | P-256 |
ES384 | EC | P-384 |
ES512 | EC | P-521 |
EdDSA | OKP | Ed25519 or Ed448 |
ML-DSA-44 | AKP | FIPS 204 ML-DSA-44 |
ML-DSA-65 | AKP | FIPS 204 ML-DSA-65 |
ML-DSA-87 | AKP | FIPS 204 ML-DSA-87 |
Any other alg value returns badSignatureAlgorithm (HTTP 400). ECDSA signatures use IEEE P1363 encoding (raw r||s).
ML-DSA signature wire format (RFC 9964)
ML-DSA signatures in JOSE are raw bytes per FIPS 204 §7.2 — not DER-wrapped. The server checks the signature length before verification:
| Algorithm | Expected signature length |
|---|---|
ML-DSA-44 | 2420 bytes |
ML-DSA-65 | 3309 bytes |
ML-DSA-87 | 4627 bytes |
A length mismatch causes an immediate badSignatureAlgorithm error. The signing context must be an empty byte string per RFC 9964 §4.
JWK thumbprint for AKP keys (ML-DSA)
Per RFC 9964 §6, the canonical JSON for computing the RFC 7638 thumbprint of an ML-DSA public key is:
{"alg":"ML-DSA-65","kty":"AKP","pub":"<base64url-public-key>"}
Members in lexicographic order: alg, kty, pub. The pub field contains the raw public key bytes (no DER wrapping).
Supported challenge types
The server offers the following challenge types per identifier type:
| Challenge type | Identifier types | Specification |
|---|---|---|
http-01 | dns, ip | RFC 8555 §8.3 |
dns-01 | dns | RFC 8555 §8.4 |
tls-alpn-01 | dns, ip | RFC 8737 / RFC 8738 §4 |
dns-persist-01 | dns | draft-ietf-acme-dns-persist |
onion-csr-01 | dns (.onion only) | RFC 9799 §3.2 |
dns-persist-01 is only offered when the server is configured with at least one dns_persist_issuer_domains entry.
onion-csr-01 is offered exclusively for .onion (Tor v3 hidden service) identifiers. The server rejects v2 .onion addresses.
IP identifiers (RFC 8738)
"type": "ip" identifiers in new-order requests are accepted per RFC 8738. Two challenge types are offered for IP identifiers:
http-01— standard HTTP challenge connecting to the IP address directly.tls-alpn-01— per RFC 8738 §4, the TLS SNI is the reverse-DNS form of the IP (arpa.suffix), and theacmeIdentifierextension carries aniPAddressGeneralName rather thandNSName.
dns-01 is not offered for IP identifiers.
EAB JWS wire format (RFC 8555 §7.3.4)
When External Account Binding is required, the externalAccountBinding field of the newAccount payload must be a JWS Flattened JSON Serialization:
{
"protected": "<base64url(JSON protected header)>",
"payload": "<base64url(JSON public JWK of the account key)>",
"signature": "<base64url(HMAC over 'protected.payload')>"
}
The protected header must contain:
{ "alg": "HS256", "kid": "<eab-key-id>", "url": "<new-account endpoint URL>" }
The signing input is the ASCII concatenation "{protected}.{payload}". The payload must be the canonical JSON representation of the account’s public JWK. The server verifies the payload JWK thumbprint matches the outer account key.
EAB algorithm support
EAB alg | HMAC hash function |
|---|---|
HS256 | SHA-256 |
HS384 | SHA-384 |
HS512 | SHA-512 |
Any other alg value returns badRequest (HTTP 400).
Renewal Information / ARI (RFC 9773)
Endpoint: GET /acme/renewal-info/{cert_id}
Per-CA variant: GET /acme/{ca_id}/renewal-info/{cert_id}
The cert_id path parameter is base64url(AKI) "." base64url(serial) per RFC 9773 §4.1. The server returns 404 if the AKI does not match this CA’s key identifier.
The response is plain JSON (not wrapped in a JWS envelope) with content type application/json:
{
"suggestedWindow": {
"start": "<RFC 3339 timestamp>",
"end": "<RFC 3339 timestamp>"
},
"explanationURL": "<url>" // present only when configured
}
The default window starts at two-thirds of the certificate lifetime and ends one day before expiry. Operators can override this per-certificate via the admin API. The Retry-After response header is set per RFC 9773 §4.3.
ACME STAR — short-term auto-renewal (RFC 8739)
STAR certificates are issued and renewed automatically by the server. The auto-renewal object in the new-order payload accepts:
| Field | Meaning |
|---|---|
start-date | ISO 8601 date when auto-renewal begins |
end-date | ISO 8601 date when auto-renewal stops |
lifetime | Per-certificate validity duration (seconds) |
lifetime-adjust | Optional clock-skew window (seconds) |
allow-certificate-get | true to allow unauthenticated certificate retrieval |
The current STAR certificate is available at:
GET /acme/cert/star/{order_id}
Unauthenticated access is gated on both the allow-certificate-get order field and the server-level star_allow_certificate_get config flag. The response includes Cert-Not-Before and Cert-Not-After headers (RFC 8739 §3.3).
To cancel a STAR order, POST {"status":"canceled"} to POST /acme/order/{id}. Subsequent certificate requests return autoRenewalCanceled.
Client Libraries Overview
API Reference — This section is for developers consuming Akāmu’s HTTP APIs or using the Rust client libraries. It covers the Admin REST API, ACME protocol wire formats, and the
akamu-jose/akamu-client/akamu-cliSDK. If you are deploying or operating the server, see the Operator Guide. If you are contributing to Akāmu itself, see the Implementation Guide.
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.
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" }
Verify the build
cargo build -p akamu-jose
cargo build -p akamu-client
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 (RFC 9964)
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 RFC 9964 §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 | RFC 9964 |
| ML-DSA | ML-DSA-65 | LWE | RFC 9964 |
| ML-DSA | ML-DSA-87 | LWE | RFC 9964 |
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) - DNS hook solver (
DnsHookSolver) — delegates TXT record management to an external script - onion-csr-01 CSR builder (
build_onion_csr, RFC 9799) - A
ChallengeSolvertrait for custom solvers AccountKey::from_jwk_private— load an account key from a certbot-style private JWKRenewalConfig— serialisable struct for persisting renewal parameters alongside a certificate
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)?;
}
Loading from a certbot JWK
AccountKey::from_jwk_private parses a private JWK JSON string (the format used by certbot’s private_key.json) and returns an AccountKey. Supported key types: EC P-256, P-384, P-521, and RSA.
#![allow(unused)]
fn main() {
use akamu_client::AccountKey;
let jwk_json = std::fs::read_to_string("/etc/letsencrypt/accounts/.../private_key.json")?;
let key = AccountKey::from_jwk_private(&jwk_json)?;
}
This is the same conversion performed internally by akamu-cli import certbot.
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.
GSSAPI-authenticated EAB fetch
When the ACME server uses Kerberos to authenticate EAB requests, use
fetch_eab_via_gssapi to retrieve the authenticated principal identity from the
server’s GET /acme/eab endpoint before registering:
#![allow(unused)]
fn main() {
use akamu_client::{fetch_eab_via_gssapi, GssapiEabResult};
let result: GssapiEabResult = fetch_eab_via_gssapi(
"https://acme.example.com/acme/eab",
"/etc/akamu/client.keytab",
).await?;
println!("Authenticated as: {}", result.principal);
}
The function:
- Loads an initiator credential from
keytab_fileviaakamu-gssapi. - Derives the target service name as
HTTP@<hostname>from the URL. - Calls
gss_init_sec_context(viatokio::task::spawn_blocking) to produce a Kerberos token. - Sends
GET <eab_url>withAuthorization: Negotiate <base64-token>. - Parses
{"principal": "..."}from the response body.
The returned GssapiEabResult currently contains only the principal field.
Full EAB key derivation is not yet implemented server-side; the endpoint echoes
the authenticated principal name as confirmation that Kerberos authentication
succeeded.
Both fetch_eab_via_gssapi and GssapiEabResult are re-exported from the crate
root.
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>
}
DnsHookSolver
DnsHookSolver implements ChallengeSolver by delegating TXT record management to an external script. It is designed for automated pipelines where a registrar API or DDNS tool handles DNS updates.
The hook is invoked as:
<hook_script> add
<hook_script> remove
All values are passed through environment variables only (never as command-line arguments):
| Variable | Value |
|---|---|
AKAMU_DOMAIN | DNS name being validated (wildcard prefix stripped) |
AKAMU_TOKEN | ACME challenge token |
AKAMU_TXT | TXT record value: base64url(SHA-256(key_authorization)) |
AKAMU_KEY_AUTH | Full key authorization string (<token>.<jwk_thumbprint>) |
Exit code 0 is success. Non-zero causes a ClientError that includes captured stderr.
#![allow(unused)]
fn main() {
use akamu_client::DnsHookSolver;
let solver = DnsHookSolver::new("/etc/akamu/hooks/dns-update.sh".to_string());
// Before triggering the ACME challenge:
solver.deploy(&domain, token, &key_auth).await?;
client.trigger_challenge(&account, &challenge).await?;
client.poll_order(&account, &order.url).await?;
// After the challenge completes (success or failure):
solver.clean(&domain, token, &key_auth).await?;
}
For dns-persist-01, call only deploy; do not call clean on success because the TXT record is long-lived.
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");
}
RenewalConfig
RenewalConfig is a serialisable/deserialisable struct that captures every parameter needed to repeat a certificate issuance. It is used by akamu-cli issue (written to <out>.renewal.toml) and by akamu-cli import certbot (generated from certbot’s renewal config).
#![allow(unused)]
fn main() {
use akamu_client::{RenewalConfig, Identifier};
use std::path::PathBuf;
let config = RenewalConfig {
server: "https://acme.example.com/acme/directory".into(),
domains: vec![Identifier::dns("example.com")],
account_key: PathBuf::from("/etc/akamu/account.pem"),
account_key_type: "ec:P-256".into(),
cert_path: PathBuf::from("/etc/ssl/example.com/fullchain.pem"),
cert_key_path: PathBuf::from("/etc/ssl/example.com/fullchain.pem.key.pem"),
cert_key_type: "ec:P-256".into(),
challenge_type: "dns-01".into(),
http_port: 80,
tls_port: 443,
onion_key: None,
poll_timeout: 120,
contacts: vec!["mailto:admin@example.com".into()],
eab_kid: None,
eab_key: None,
eab_alg: "HS256".into(),
gssapi_keytab: None,
dns_hook: Some("/etc/akamu/hooks/dns-update.sh".into()),
};
let toml = toml::to_string_pretty(&config)?;
std::fs::write("fullchain.pem.renewal.toml", &toml)?;
// Round-trip:
let loaded: RenewalConfig = toml::from_str(&toml)?;
}
Fields with defaults (account_key_type, cert_key_type, challenge_type, http_port, tls_port, poll_timeout, eab_alg) are optional in TOML files; missing fields are filled with sensible defaults on deserialisation. The gssapi_keytab field is also optional (defaults to absent when not present in the file, via #[serde(default)]).
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
Gssapi(String), // GSSAPI / Kerberos error from akamu-gssapi
}
}
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, revocation, and migration from other ACME clients.
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 [-v] [-vv]
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 / tkauth-01)
renew ARI-aware renewal (RFC 9773); skips issuance if outside window
revoke Revoke a certificate via account key or certificate's own key
import
certbot Import accounts and certificates from a certbot installation
Global flags
| Flag | Description |
|---|---|
-v | Enable debug logging for akamu_client. When --server-ca is in use (i.e., issue or renew), also logs TLS certificate details (subject, issuer, validity, signature algorithm) for the ACME server and any CA certificates loaded via --server-ca. |
-vv | All of -v, plus enables debug logging for hyper_util and rustls (HTTP internals and TLS handshake details). |
These flags are global and may be placed immediately after akamu-cli, before the subcommand name. The RUST_LOG environment variable still works for fine-grained control (see Logging).
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. Mutually exclusive with --gssapi-keytab. |
--eab-key KEY_B64U | no | EAB HMAC key encoded as base64url (no padding). Mutually exclusive with --gssapi-keytab. |
--eab-alg ALG | no | EAB HMAC algorithm: HS256, HS384, or HS512. Default: HS256. |
--gssapi-keytab PATH | no | Path to a Kerberos keytab file. When set, the CLI performs a GSSAPI-authenticated GET /acme/eab request and logs the authenticated principal. Mutually exclusive with --eab-kid / --eab-key. |
After registration the account URL is written to <account-key>.account-url (see Sidecar files).
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, tkauth-01. Default: http-01. |
--dns-hook CMD | no | Hook script for dns-01 / dns-persist-01 automation. See DNS hook interface. |
--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. |
--tkauth-url URL | required for tkauth-01 | Token Authority URL. Accepts https://, and http+unix://ENCODED_PATH/path for a local Token Authority reachable via a Unix domain socket. |
--tkauth-keytab FILE | required for tkauth-01 | Path to a Kerberos keytab file used for SPNEGO authentication to the Token Authority. |
--jwtcc B64URL | no | Base64url-encoded DER JWTClaimConstraints blob. When provided with --challenge tkauth-01, the order uses a JWTClaimConstraints identifier instead of dns; --domain is not required. |
--poll-timeout SECS | no | Maximum seconds to wait for order/challenge validation. Default: 120. |
--profile NAME | no | Certificate profile name (draft-aaron-acme-profiles-01). Sent as "profile" in the newOrder payload. The server may echo a different name (e.g. "default") if auto-selection applies; the echoed value is stored in the renewal sidecar. |
--server-ca FILE | no | PEM file containing an extra CA certificate to trust for the ACME server’s TLS connection. Use when the server uses a private CA not in the system trust store. |
--eab-kid KID | no | EAB key ID (used if no account exists yet). Mutually exclusive with --gssapi-keytab. |
--eab-key KEY_B64U | no | EAB HMAC key (base64url). Mutually exclusive with --gssapi-keytab. |
--gssapi-keytab PATH | no | Path to a Kerberos keytab file. When set and no account URL sidecar exists, the CLI performs a GSSAPI-authenticated GET /acme/eab before registering. Mutually exclusive with --eab-kid / --eab-key. |
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.
After every successful issuance, a renewal configuration sidecar is written to <out>.renewal.toml.
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 (unless --renewal-config is used) | ACME directory URL |
--account-key FILE | yes (unless --renewal-config is used) | Account key PEM file |
-d DOMAIN | yes (unless --renewal-config is used) | Domains for the new certificate |
--out FILE | yes (unless --renewal-config is used) | Output path for the new PEM bundle |
--renewal-config FILE | no | Load all settings from a .renewal.toml file. Explicit CLI flags override individual fields from the file. |
--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. |
--dns-hook CMD | no | Hook script for dns-01 / dns-persist-01 automation. See DNS hook interface. |
--http-port PORT | no | Default: 80. |
--tls-port PORT | no | Default: 443. |
--onion-key FILE | no | Required for onion-csr-01. |
--tkauth-url URL | no (required for tkauth-01) | Token Authority URL for tkauth-01 challenges. Loaded from the renewal sidecar when --renewal-config is used. |
--tkauth-keytab FILE | no (required for tkauth-01) | Kerberos keytab for SPNEGO authentication to the Token Authority. |
--jwtcc B64URL | no | Base64url-encoded JWTClaimConstraints blob for tkauth-01 orders. |
--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. |
--cert-key FILE | no | Reuse an existing certificate private key PEM file instead of generating a new one. Useful for HPKP / TLSA key pinning scenarios where the public key must remain stable across renewals. |
--profile NAME | no | Certificate profile name (draft-aaron-acme-profiles-01). Stored in the renewal sidecar so subsequent renew calls request the same profile. |
--server-ca FILE | no | PEM file containing an extra CA certificate to trust for the ACME server’s TLS connection. Not stored in the renewal sidecar; must be re-supplied on each invocation when needed. |
--eab-kid KID | no | EAB key ID. Mutually exclusive with --gssapi-keytab. |
--eab-key KEY_B64U | no | EAB HMAC key (base64url). Mutually exclusive with --gssapi-keytab. |
--gssapi-keytab PATH | no | Path to a Kerberos keytab file for GSSAPI-authenticated EAB. Mutually exclusive with --eab-kid / --eab-key. |
Behavior when --cert is given (or when --renewal-config is used and the cert file from the config exists) 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.
When --renewal-config FILE is provided, all settings are loaded from the TOML file (written by issue or import certbot). This makes cron-based renewal straightforward:
akamu-cli renew --renewal-config /etc/ssl/example.com/fullchain.pem.renewal.toml
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.
import certbot
Import ACME account keys and certificate renewal configurations from an existing certbot installation.
akamu-cli import certbot [OPTIONS]
| Flag | Required | Description |
|---|---|---|
--certbot-dir DIR | no | Certbot configuration directory. Default: /etc/letsencrypt. |
--account-key FILE | yes (unless --list) | Output path for the imported account key PEM. Must not already exist. |
--server URL | no | Import only the account registered with this CA URL. Required when multiple accounts are found. |
-d DOMAIN | no (repeatable) | Limit certificate import to these domains. Default: all discovered domains. |
--cert-dir DIR | yes (when importing certificates) | Directory to write imported certificate chains and keys. Created if absent. |
--dns-challenge TYPE | no | Challenge type to use for DNS-based certbot configs: dns-01 or dns-persist-01. Default: dns-01. |
--dns-hook CMD | no | Hook script to embed in generated renewal configs for DNS TXT record management. |
--dry-run | no | Show what would be done without writing any files. |
--list | no | List all discoverable accounts and certificate renewal configurations, then exit. |
What the importer does
-
Walks
<certbot-dir>/accounts/<ca-hostname>/<account-id>/to discover accounts. Each account directory must containprivate_key.json(the account key in JWK format) andregr.json(the registration response with account URL and contacts). -
Converts the certbot JWK private key to a PEM file written to
--account-key. Writes the account URL to<account-key>.account-urlas an account URL sidecar. -
Walks
<certbot-dir>/renewal/*.confto discover certificate configurations. For each matching domain, copieslive/<domain>/fullchain.pemandlive/<domain>/privkey.peminto--cert-dirwith mode0o600, then writes a.renewal.tomlsidecar (see Renewal configuration sidecar). If either the certificate chain or the private key file cannot be read (for example, due to a permissions issue), the renewal sidecar is not written for that domain and a warning is printed. -
Prints the
akamu-cli renew --renewal-configcommand for each imported certificate.
Challenge type mapping
Certbot’s authenticator field is mapped to an akamu challenge type as follows:
| Certbot authenticator | akamu challenge type |
|---|---|
standalone, webroot, nginx, apache | http-01 |
manual with preferred_challenges = dns | value of --dns-challenge |
manual (no DNS preference) | http-01 |
Any dns-* plugin (dns-cloudflare, dns-route53, etc.) | value of --dns-challenge |
tls-sni-01 | tls-alpn-01 (with a deprecation warning) |
When a DNS-based challenge type is selected but no --dns-hook was supplied, the importer emits a warning for each affected domain (unless --dry-run is set) reminding you to add a hook script before the next renewal.
Certificate key type detection
The importer reads the actual key PEM file from live/<domain>/privkey.pem to determine the key type (ec:P-256, rsa:2048, etc.) stored in the renewal sidecar. This ensures the sidecar accurately reflects the key material rather than relying on filename heuristics.
Wildcard domain handling
Certbot stores wildcard certificates under live/_wildcard.<domain>/. The importer decodes this convention automatically: _wildcard.example.com becomes *.example.com in the generated renewal configuration file.
Prerequisites
- The importer reads certbot’s
live/andaccounts/directories. On most systems these are owned by root with mode 0700. Run the importer as root or with sufficient privilege to read them. - The
--account-keyoutput path must not already exist. - All output files (account key, certificate chain, certificate key, renewal sidecar) are written with mode
0o600.
Example
# List what would be imported
akamu-cli import certbot --list
# Dry run (shows actions without writing files)
sudo akamu-cli import certbot \
--account-key /etc/akamu/acct.pem \
--cert-dir /etc/akamu/certs \
--dry-run
# Full import from Let's Encrypt production, with a DNS hook for future renewals
sudo akamu-cli import certbot \
--account-key /etc/akamu/acct.pem \
--server https://acme-v02.api.letsencrypt.org/directory \
--cert-dir /etc/akamu/certs \
--dns-challenge dns-01 \
--dns-hook /etc/akamu/hooks/dns-update.sh
# Import only specific domains
sudo akamu-cli import certbot \
--account-key /etc/akamu/acct.pem \
--cert-dir /etc/akamu/certs \
-d example.com \
-d "*.example.com"
After import, renew each certificate with:
akamu-cli renew --renewal-config /etc/akamu/certs/example.com.pem.renewal.toml
File permissions
All output files written by akamu-cli — account keys, certificate private keys, account URL sidecars, and renewal configuration sidecars — are created with mode 0o600 (owner read/write only). When overwriting a pre-existing file, the mode is explicitly re-applied so that stale group or world permissions left by a previous tool are corrected.
Sidecar files
Account URL sidecar
When you register an account (or when issue or import certbot creates one), 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 its sidecar together and back them up. If you lose the account key, you cannot deactivate or otherwise manage the account.
Renewal configuration sidecar
After every successful issue (and after import certbot), akamu-cli writes a TOML file named <out>.renewal.toml alongside the certificate chain. This file captures every parameter needed to repeat the issuance and is consumed by renew --renewal-config.
File format
server = "https://acme.example.com/acme/directory"
account_key = "/etc/akamu/account.pem"
account_key_type = "ec:P-256"
cert_path = "/etc/ssl/example.com/fullchain.pem"
cert_key_path = "/etc/ssl/example.com/fullchain.pem.key.pem"
cert_key_type = "ec:P-256"
challenge_type = "dns-01"
http_port = 80
tls_port = 443
poll_timeout = 120
eab_alg = "HS256"
[[domains]]
type = "dns"
value = "example.com"
[[domains]]
type = "dns"
value = "*.example.com"
# Optional fields (omit when not applicable):
onion_key = "/path/to/hs_key.pem"
contacts = ["mailto:admin@example.com"]
eab_kid = "kid-from-ca"
# eab_key is intentionally omitted: the HMAC key is never written to this file.
gssapi_keytab = "/etc/akamu/http.keytab"
dns_hook = "/etc/akamu/hooks/dns-update.sh"
profile = "tlsserver"
tkauth_url = "https://ta.example.com"
tkauth_keytab = "/etc/akamu/ta-client.keytab"
# jwtcc is stored when --jwtcc was used at issuance time
Fields
| Field | Default | Description |
|---|---|---|
server | — | ACME directory URL |
domains | — | Array of {type, value} identifier objects |
account_key | — | Path to the account private key PEM |
account_key_type | "ec:P-256" | Key type string for the account key |
cert_path | — | Output path for the certificate chain PEM |
cert_key_path | — | Output path for the certificate private key PEM |
cert_key_type | "ec:P-256" | Key type string for the certificate key |
challenge_type | "http-01" | ACME challenge type |
http_port | 80 | Port for the http-01 solver |
tls_port | 443 | Port for the tls-alpn-01 solver |
onion_key | — | Path to the Ed25519 onion service key (onion-csr-01 only) |
tkauth_url | — | Token Authority URL (tkauth-01 only). Saved from --tkauth-url at issuance time. |
tkauth_keytab | — | Path to the Kerberos keytab for the Token Authority (tkauth-01 only). |
jwtcc | — | Base64url-encoded JWTClaimConstraints blob (tkauth-01 + JWTClaimConstraints orders only). |
poll_timeout | 120 | Seconds to wait for challenge validation |
contacts | [] | Contact URIs registered with the account |
eab_kid | — | EAB key ID |
eab_alg | "HS256" | EAB HMAC algorithm |
gssapi_keytab | — | Path to a Kerberos keytab for GSSAPI-authenticated EAB (mutually exclusive with eab_kid) |
dns_hook | — | Hook script path for DNS TXT record management |
profile | — | Certificate profile name (draft-aaron-acme-profiles-01). When set, passed as "profile" in the newOrder payload on renewal. Populated from the server’s echoed value after issuance. |
The
eab_keyHMAC secret is never written to the renewal sidecar. Supply it again via--eab-keyor--gssapi-keytabif EAB is required for the renewal account.
Fields that have defaults are optional in the TOML file. Existing configs with fewer fields remain forward-compatible as new optional fields are added.
DNS hook interface
When --dns-hook CMD is passed with a DNS-based challenge type (dns-01 or dns-persist-01), akamu-cli delegates TXT record management to an external script instead of waiting for manual input.
The hook script is invoked as:
<CMD> add
<CMD> remove
All values are passed through environment variables only (never as command-line arguments, which would be visible in /proc/<pid>/cmdline):
| Variable | Value |
|---|---|
AKAMU_DOMAIN | DNS name being validated (base domain, wildcard prefix stripped) |
AKAMU_TOKEN | ACME challenge token |
AKAMU_TXT | TXT record value: base64url(SHA-256(key_authorization)) |
AKAMU_KEY_AUTH | Full key authorization string (<token>.<account-key-thumbprint>) |
An exit code of 0 means success. Any non-zero exit code is treated as a failure; stderr from the script is captured and included in the error message.
For dns-01, the hook is called with add before challenge validation and with remove after the challenge completes (whether it succeeds or fails). For dns-persist-01, the hook is only called with add; on success the TXT record is left in place (it is a long-lived record tied to the account key, not the token).
Minimal hook example
#!/usr/bin/env bash
# /etc/akamu/hooks/dns-nsupdate.sh
set -euo pipefail
ZONE="example.com."
RECORD="_acme-challenge.${AKAMU_DOMAIN}."
case "$1" in
add)
nsupdate -k /etc/akamu/ddns.key <<EOF
server ns1.example.com
zone ${ZONE}
update add ${RECORD} 60 TXT "${AKAMU_TXT}"
send
EOF
;;
remove)
nsupdate -k /etc/akamu/ddns.key <<EOF
server ns1.example.com
zone ${ZONE}
update delete ${RECORD} TXT
send
EOF
;;
esac
Make the hook executable:
chmod 0755 /etc/akamu/hooks/dns-nsupdate.sh
Then use it with issue or renew:
akamu-cli issue \
--account-key ~/.akamu/account.pem \
-d "*.example.com" \
--challenge dns-01 \
--dns-hook /etc/akamu/hooks/dns-nsupdate.sh \
--out /etc/ssl/example.com/wildcard.pem
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, manual)
Without --dns-hook, 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 dns-01 (wildcard, automated hook)
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
-d "*.example.com" \
--challenge dns-01 \
--dns-hook /etc/akamu/hooks/dns-nsupdate.sh \
--out /etc/ssl/example.com/wildcard.pem
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
Issue with tkauth-01 (RFC 9447 — authority token)
tkauth-01 is used for TNAuthList and JWTClaimConstraints identifier types. Use --jwtcc to supply a base64url-encoded JWTClaimConstraints blob when ordering a certificate constrained by JWT claims rather than DNS names.
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--challenge tkauth-01 \
--tkauth-url https://ta.example.com \
--tkauth-keytab /etc/akamu/ta-client.keytab \
--jwtcc "AAABBBCCC..." \
--out /etc/ssl/service/fullchain.pem
The CLI:
- Computes the RFC 9447
fingerprint(SHA256 XX:XX:...) from the account key’s JWK thumbprint. - POSTs
{"atc": {"tktype":"EnhancedJWTClaimConstraints","tkvalue":"","fingerprint":"SHA256 ...","ca":false}}to the Token Authority with SPNEGO/Negotiate authentication using--tkauth-keytab. - Extracts the
tkvaluefrom the returned JWT. - For each authorization, fetches a per-identifier authority token from the Token Authority and submits it to the challenge URL as
{"tkauth": "<jwt>"}.
ARI-aware renewal (manual flags)
# 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
ARI-aware renewal from a renewal config
After issue has written a .renewal.toml sidecar, renewals require only one flag:
akamu-cli renew \
--renewal-config /etc/ssl/example.com/fullchain.pem.renewal.toml
The command checks ARI using the certificate path stored in the config, and re-issues only if the renewal window is open (or if --force is added). A new .renewal.toml is written after each successful renewal.
This form is suitable for use from cron or a systemd timer:
# /etc/cron.d/akamu-renew
0 3 * * * root akamu-cli renew --renewal-config /etc/ssl/example.com/fullchain.pem.renewal.toml
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
Migrate from certbot
# 1. Preview
akamu-cli import certbot --list
# 2. Import (run as root to read certbot's live/ and accounts/ directories)
sudo akamu-cli import certbot \
--server https://acme-v02.api.letsencrypt.org/directory \
--account-key /etc/akamu/acct.pem \
--cert-dir /etc/akamu/certs \
--dns-hook /etc/akamu/hooks/dns-update.sh
# 3. Renew each certificate with its generated config
akamu-cli renew --renewal-config /etc/akamu/certs/example.com.pem.renewal.toml
External Account Binding
Some CAs require EAB credentials before accepting a new account. Two methods are available: manual key/ID or GSSAPI (Kerberos) authentication.
Manual EAB (kid + HMAC key)
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.
GSSAPI-authenticated EAB
When the CA uses Kerberos to authenticate EAB requests (via /acme/eab), supply a keytab instead:
akamu-cli account register \
--server https://acme.example.com/acme/directory \
--account-key ~/.akamu/account.pem \
--agree-tos \
--gssapi-keytab /etc/akamu/client.keytab
The CLI derives the target service name HTTP@<hostname> from the server URL and calls GET /acme/eab with an Authorization: Negotiate token. The authenticated Kerberos principal is logged. --gssapi-keytab and --eab-kid / --eab-key are mutually exclusive.
If the server has external_account_required = true and you omit all 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 an Akāmu server linked against OpenSSL 3.5 or later, which provides native ML-DSA support via the standard EVP interface. Vanilla Let’s Encrypt does not support ML-DSA.
Unix domain socket URLs
Both --server and --tkauth-url accept http+unix:// URLs in addition to https://. This allows connecting to a local Akāmu server or Token Authority through a Unix domain socket without opening a network port.
URL format:
http+unix://SOCKET_PATH_ENCODED/REQUEST_PATH
SOCKET_PATH_ENCODED is the socket file path with each / percent-encoded as %2F:
http+unix://%2Frun%2Fakamu%2Fakamu.sock/acme/default/directory
Examples:
# ACME server via Unix socket
akamu-cli issue \
--server "http+unix://%2Frun%2Fakamu%2Fakamu.sock/acme/default" \
--account-key ~/.akamu/account.pem \
-d example.com \
--out /etc/ssl/example.com/fullchain.pem
# Token Authority via Unix socket (tkauth-01)
akamu-cli issue \
--server https://acme.example.com/acme/directory \
--challenge tkauth-01 \
--tkauth-url "http+unix://%2Frun%2Fekishib%2Fekishib.sock" \
--tkauth-keytab /etc/akamu/ta-client.keytab \
--jwtcc "AAAA..." \
--out /etc/ssl/service/fullchain.pem
When the Token Authority is reached via a Unix socket URL, the GSSAPI service principal is derived as HTTP@<local-hostname> (using gethostname(2)).
Logging
Use the -v / -vv global flags for convenient verbosity control:
akamu-cli -v issue ... # akamu_client=debug (TLS cert details, request flow)
akamu-cli -vv issue ... # additionally enables hyper_util=debug and rustls=debug
The RUST_LOG environment variable provides fine-grained control and overrides the -v flags:
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 (no -v flag and no RUST_LOG) 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 linked against OpenSSL 3.5 or later (which provides native ML-DSA support).
dns hook '...' add exited with status 1: ...
The DNS hook script returned a non-zero exit code. Check the hook’s stderr output (included in the error message) and verify that the script has the correct permissions and that the DNS update API is reachable.
no certbot accounts found; check --certbot-dir and --server
The importer found no accounts under <certbot-dir>/accounts/. Verify the --certbot-dir path and that the directory is readable. If multiple accounts are present but no --server flag was given, run with --list first to identify the account URL, then pass --server.
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 |
CertAlreadyReplaced | alreadyReplaced | 409 |
BadCsr(String) | badCSR | 400 |
BadRevocationReason | badRevocationReason | 400 |
AlreadyRevoked | alreadyRevoked | 400 |
Caa(String) | caa | 403 |
ExternalAccountRequired | externalAccountRequired | 403 |
Connection(String) | connection | 400 |
Dns(String) | dns | 400 |
IncorrectResponse(String) | incorrectResponse | 400 |
Tls(String) | tls | 400 |
AutoRenewalCanceled | autoRenewalCanceled | 403 |
AutoRenewalCancellationInvalid | autoRenewalCancellationInvalid | 400 |
AutoRenewalRevocationNotSupported | autoRenewalRevocationNotSupported | 403 |
InvalidProfile(String) | invalidProfile | 400 |
Generic HTTP-mapped errors
These do not have dedicated ACME error types and carry the appropriate HTTP status. NotFound maps to the malformed ACME type; all others fall through to serverInternal.
| Variant | ACME type | HTTP status |
|---|---|---|
NotFound | malformed | 404 |
MethodNotAllowed | serverInternal | 405 |
Conflict(String) | serverInternal | 409 |
UnsupportedMediaType | serverInternal | 415 |
PayloadTooLarge | serverInternal | 413 |
BadRequest(String) | serverInternal | 400 |
ServiceUnavailable(String) | serverInternal | 503 |
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 |
Config(String) | Configuration or startup error |
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"
}
For responses with an HTTP 4xx status, the detail field is the Display string of the variant, which for parameterised variants includes the inner string.
For responses with an HTTP 5xx status (server errors), the detail field is always the fixed string "internal server error", regardless of the underlying cause. The actual error is logged server-side at ERROR level but is never included in the response body. This applies to ServiceUnavailable (503) as well as the 500-class internal errors.
From implementations
Two From implementations allow the ? operator to be used with library errors:
From<sqlx::Error> for AcmeError→ wraps inAcmeError::Database.From<akamu_jose::JoseError> for AcmeError→ maps toAcmeError::BadRequest,AcmeError::Crypto, orAcmeError::BadSignatureAlgorithmdepending on the JOSE error kind.
This means any sqlx::query!(...).fetch_one(&db).await? will automatically convert database errors to AcmeError::Database(msg), and any JOSE verification failure will convert to the appropriate ACME error without an explicit match.
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?
↓ sqlx::Error
↓ From<sqlx::Error> for AcmeError
↓ AcmeError::Database("...")
↓ returned from handler as Err(AcmeError::Database(...))
↓ axum calls AcmeError::into_response()
↓ HTTP 500 with application/problem+json body
↓ detail field = "internal server error" (not the database message)
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::Internal,AcmeError::Database,AcmeError::Crypto,AcmeError::Builder,AcmeError::Mtc,AcmeError::Config, andAcmeError::ServiceUnavailableall produce 5xx responses whosedetailfield is the fixed string"internal server error". The actual error message is written to the server log only. - 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.
Architecture
Implementation Guide — This section is for contributors working on the Akāmu source code. It covers architecture, internal module design, database schema, CA internals, and testing. If you are deploying or operating the server, see the Operator Guide. If you are building an ACME client or integrating with the API, see the API Reference.
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 + tokio-rustls<br/>(optional)"]
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 · certificate issuance · CRL generation"]
db[("SQL database<br/>· accounts · orders · authzs · challenges · certs · nonces")]
val["Validators<br/>· http-01 · dns-01 · tls-alpn-01 · dns-persist-01 · onion-csr-01"]
mtc["MTC log<br/>synta-mtc<br/>(optional)"]
end
subgraph external["Applicant Infrastructure"]
httpserver["HTTP server<br/>· port 80"]
dns["DNS server<br/>· TXT records"]
tlsserver["TLS server<br/>· port 443<br/>· 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
certbot -->|"HTTPS ACME requests (JWS-signed)"| tls
acmesh --> tls
akamucli --> tls
custom --> tls
tls --> acme
acme --> jose
acme --> ca
acme --> db
acme -->|"spawns tokio task"| val
acme --> mtc
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
Crate layout
The repository is organized as a Cargo workspace with eight 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
akamu-cosigner/ <- standalone MTC cosigner daemon
akamu-ldap/ <- safe Rust wrapper around the OpenLDAP C library (libldap);
provides LdapConnection (sync) and AsyncLdapConnection
(tokio::task::spawn_blocking) with simple-bind and
SASL GSSAPI/Kerberos authentication
akamu-gssapi/ <- safe Rust GSSAPI/SPNEGO bindings (FFI to libgssapi_krb5);
provides GssServerCred and GssClientCred for keytab- and
ccache-based credential acquisition, plus accept_token /
init_token convenience wrappers; MIT Kerberos thread-safety
guarantees allow an Arc<GssServerCred> to be shared across
all handler threads without a mutex
akamuctl/ <- server administration CLI binary; talks to /admin/* endpoints
over mTLS or GSSAPI/Kerberos (ccache-based) authentication
Crate dependencies
graph LR
SERVER["akamu (server)"]
CLIENT["akamu-client"]
CLI["akamu-cli"]
COSIGNER["akamu-cosigner"]
JOSE["akamu-jose"]
SYNTA["synta-certificate"]
SYNTABASE["synta"]
SYNTAVERIF["synta-x509-verification"]
LDAP["akamu-ldap"]
GSSAPI["akamu-gssapi"]
AKAMUCTL["akamuctl"]
NATIVEOSSL["native-ossl"]
HYPERRUSTLS["hyper-rustls"]
SERVER --> JOSE
SERVER --> SYNTA
SERVER --> SYNTABASE
SERVER --> SYNTAVERIF
SERVER --> LDAP
SERVER --> GSSAPI
SERVER --> NATIVEOSSL
SERVER --> HYPERRUSTLS
CLIENT --> JOSE
CLIENT --> SYNTA
CLIENT --> GSSAPI
CLIENT --> HYPERRUSTLS
CLI --> CLIENT
COSIGNER --> CLIENT
COSIGNER --> SERVER
COSIGNER --> NATIVEOSSL
COSIGNER --> HYPERRUSTLS
JOSE --> SYNTA
SYNTA --> SYNTABASE
AKAMUCTL --> GSSAPI
AKAMUCTL --> SYNTA
AKAMUCTL --> NATIVEOSSL
The server and akamu-client both depend directly on akamu-jose and synta-certificate. The server additionally depends on:
akamu-ldapfor reading Dogtag and IPA certificate profiles from LDAP.akamu-gssapifor standalone SPNEGO authentication (gss_cred,admin_gss_credinAppState).native-ossldirectly for digest operations (SHA-256 fingerprinting, channel-binding hashes), HKDF key derivation (eab_derivation.rs), and asymmetric-key signature verification in the mTLS verifier.rustls-native-ossl(a thin rustls crypto-provider shim overnative-ossl) supplies the OpenSSL backend to rustls.synta(the base ASN.1 codec crate) forDecoder/Encoderand theIntegertype used in certificate serial number and OID handling;synta-certificateis built on top of it.synta-x509-verificationfor X.509 certification-path validation (trust anchor resolution, name constraints, key usage checking) used in mTLS client-certificate verification and OCSP response validation.hyper-rustlsas the HTTPS connector for outbound MTC cosigner requests; paired withrustls-native-certsto load the OS root CA store for verifying cosigner TLS certificates.
akamu-client uses akamu-gssapi for GSSAPI-authenticated ACME requests and hyper-rustls for all outbound HTTPS. akamu-cli depends only on akamu-client. akamu-cosigner depends on both akamu-client (for ACME EAB bootstrap) and akamu itself (to reuse TLS loader helpers and key generation utilities), and uses native-ossl directly for crypto operations. akamuctl is a standalone admin CLI that depends on akamu-gssapi for ccache-based Kerberos login to the /admin/* endpoints, on synta-certificate for certificate handling, and on native-ossl directly; it does not depend on akamu-client or akamu-jose.
Key external dependencies
The following external crates are direct server dependencies whose role is not otherwise obvious from the module descriptions above.
| Crate | Purpose |
|---|---|
uuid | Generates UUIDv4 identifiers for every ACME resource (orders, authorizations, challenges, certificates, nonces). |
getrandom | Cryptographically random byte source for anti-replay nonce generation (NonceBucket). |
subtle | Constant-time byte comparison (subtle::ConstantTimeEq) used in admin session-token lookup to prevent timing-based token recovery. |
zeroize | Zeroizing<T> wrapper that zeroes memory on drop; applied to the EAB master secret in AppState to satisfy FDP_RIP.1 (residual information protection). |
ipnet | CIDR range type (IpNet) used to match incoming request source addresses against the [server].trusted_proxies allow-list. |
regex | Compiled regular expressions for profile identifier authorization (auth.rs check_profile_auth — identifier pattern matching against the allowed_identifiers field). |
libc | flock(2) call that takes an exclusive advisory lock on the MTC log file, preventing two server processes from writing the same log concurrently. |
rustls-native-certs | Loads the operating-system root certificate store into rustls for verifying cosigner HTTPS connections. |
indexmap | IndexMap preserves config-file insertion order for the multi-CA registry (AppState::cas), ensuring deterministic directory listing and default-CA fallback. |
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,
ProfilesConfig, ProviderConfig, BuiltinProviderConfig, AdminConfig, …)
state.rs Shared application state (AppState, CaState, MtcState, NonceBucket,
CrlCache, TlsState, CachedAccount, OperatorRole, AdminSession, …)
error.rs AcmeError enum with HTTP mapping and problem+json serialization
audit.rs Structured audit trail (FAU family): AuditEvent, AuditState
(VecDeque-backed violation window), AuditPolicy, record_or_log,
track_record_result, overflow handling (FAU_STG.4)
dns.rs Thin DNS query helper (hickory-resolver; optional DNS-over-TLS)
eab_derivation.rs HKDF-SHA-256 (RFC 5869) credential derivation for /acme/eab
extract.rs Axum extractors: RemoteUser (proxy header or standalone GSSAPI)
star.rs RFC 8739 ACME STAR background reissuance task
delegation_upstream.rs RFC 9115 upstream CA polling task (see delegation/ module)
util.rs Shared utilities: unix_now, unix_to_rfc3339, certificate helpers
admin/
mod.rs Re-exports admin submodules
auth.rs Operator authentication (mTLS cert or GSSAPI/Kerberos session token),
OperatorContext extractor, session management (POST/DELETE /admin/session)
init.rs Admin operator bootstrap — seeds the first operator from config on startup
db/
mod.rs Database initialization (open, migrations, WAL mode)
schema.rs Row types mirroring database columns
accounts.rs CRUD for accounts table
(audit.rs removed — audit events are now written to the systemd journal namespace via src/journal.rs)
authz.rs CRUD for authorizations table
certs.rs CRUD for certificates table (includes mtc_standalone_der column)
challenges.rs CRUD for challenges table
checkpoints.rs CRUD for mtc_checkpoints table (upsert, get_latest, prune_oldest)
cosignatures.rs CRUD for mtc_cosignatures table
cross_certs.rs CRUD for cross_certs table (insert, get_by_id, list by issuer/subject CA)
eab.rs CRUD for eab_keys table
landmarks.rs CRUD for mtc_landmarks table (insert, get_by_seq, list, prune_oldest)
nonces.rs Anti-replay nonce management
operators.rs CRUD for operators table (insert, get_by_fingerprint/principal, update,
failed-attempt tracking and account locking — FIA_AFL.1)
orders.rs CRUD for orders table
delegations.rs CRUD for delegations table (insert, get_by_id, update, delete, list, list_by_account)
stats.rs Aggregate statistics queries for GET /admin/stats
routes/
mod.rs Router assembly (build_router, build_admin_router), CaId extractor,
shared helpers (parse_jws, acme_headers, json_response, acme_prefix)
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/new-authz, 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}, POST /acme/cert/{id}
(auto-detects MTC vs X.509 by PEM marker)
star_cert.rs GET/POST /acme/cert/star/{order_id} (RFC 8739 §3.3 STAR rolling cert URL)
revoke.rs POST /acme/revoke-cert
key_change.rs POST /acme/key-change
renewal_info.rs GET /acme/renewal-info/{cert_id}
mtc.rs GET /acme/mtc/tree-size, /root, /inclusion-proof/{id},
/cert/{id}/standalone, /landmarks, /landmarks/{seq}/cert;
C2SP tlog-tiles: /acme/mtc/tlog/checkpoint, /tlog/cosignature,
/tlog/tile/{*path}
crl.rs GET /ca/crl, GET /ca/{ca_id}/crl — serve DER-encoded CRLs (cached);
GET /ca/cross-certs, GET /ca/{ca_id}/cross-certs — PEM cross-cert bundles
ocsp.rs POST /ca/ocsp, GET /ca/ocsp/{request},
POST /ca/{ca_id}/ocsp, GET /ca/{ca_id}/ocsp/{request} (RFC 6960)
eab_identity.rs GET /acme/eab — derive and return EAB credentials for
the authenticated principal (proxy or standalone GSSAPI)
delegation.rs POST /acme/delegations/{account_id} (list; POST-as-GET),
POST /acme/delegation/{id} (fetch one; POST-as-GET) —
RFC 9115 NDC-facing delegation endpoints; active when
server.delegation_enabled = true
admin.rs All /admin/* endpoints served on the dedicated admin listener;
POST/DELETE /admin/session (auth);
GET/POST /admin/operators, GET/PUT/PATCH /admin/operators/{id},
POST /admin/operators/{id}/unlock;
GET/POST/PUT/DELETE /admin/account/{id}/profile-grants,
POST /admin/account/{id}/deactivate;
GET /admin/accounts, GET /admin/account/{id};
POST/GET/DELETE /admin/eab, GET/DELETE /admin/eab/{kid};
GET /admin/audit;
GET /admin/certs, GET /admin/certs/{id},
GET /admin/certs/{id}/download;
GET/POST /admin/profiles, PUT/DELETE /admin/profiles/{id};
GET /admin/orders, GET /admin/orders/{id};
GET /admin/config;
POST /admin/crl/force, POST /admin/revoke;
GET /admin/stats;
GET /admin/cas, GET /admin/cas/{id}, GET /admin/cas/{id}/cert;
POST /admin/ca/{id}/crl/force, POST /admin/ca/{id}/cross-sign;
GET /admin/cross-certs, GET /admin/cross-certs/{id};
GET/POST /admin/delegations, GET/PUT/DELETE /admin/delegations/{id}
(role-based access control; admin listener not started when [admin] absent)
ca/
mod.rs Re-exports ca submodules
init.rs CA key and certificate load-or-generate
key_loader.rs CaKeyLoader — routes key loading to PEM file or PKCS#11 token URI
csr.rs PKCS#10 CSR parsing and validation
issue.rs End-entity and CA certificate issuance (issue_certificate,
issue_with_params, issue_ca_cert, check_is_ca_cert)
revoke.rs CRL generation
profiles/
mod.rs ProfileRegistry — in-memory cache, background refresh, resolve()
builtin.rs Builtin provider: inline TOML profile declarations;
handles issue_as, allowed_identifiers, auth_hook, require_account_grant
auth.rs check_profile_auth — identifier pattern, external hook, account grant checks
cfg.rs Dogtag Java-properties .cfg parser and translator
dogtag.rs Dogtag PKI provider (filesystem or LDAP — simple bind and GSSAPI/Kerberos)
ipa.rs FreeIPA/IPAThinCA provider (filesystem or LDAP — simple bind and GSSAPI/Kerberos)
ldap_resolve.rs resolve_ldap_uris() — merges uri/uris/srv_domain into a space-separated
URI string for ldap_initialize; SRV records sorted per RFC 2782
ldap_session.rs Shared LDAP connect→bind→search→parse helper used by dogtag and ipa providers
tls/
mod.rs Re-exports tls submodules; module-level doc for standalone TLS support
channel_binding.rs TLS channel binding helpers (tls-unique / tls-exporter)
init.rs load_or_generate — loads or auto-generates the server TLS certificate
loader.rs Async TLS config reloader (hot-reload of cert/key without restart)
schemes.rs Custom rustls SignatureScheme negotiation for hybrid post-quantum keys
verifier.rs SyntaClientCertVerifier — mTLS client certificate verification
(CAB Forum or RFC 5280 profile; hybrid ML-DSA+classical chains)
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)
dns_persist_01.rs dns-persist-01 validation (hickory-resolver; persistent TXT records)
onion_csr_01.rs onion-csr-01 validation (CSR-based; .onion identifiers)
caa.rs CAA record validation (RFC 8659 + RFC 8657)
mtc/
mod.rs Re-exports mtc submodules
log.rs SharedLog type alias; tree_size, proof_and_tree_size async wrappers
checkpoint.rs Checkpoint production background task; signs and stores Merkle roots
cosign.rs CosignerClient; parallel cosignature gathering from external cosigners
landmark.rs Landmark background task; allocates and builds LandmarkCertificate DERs
standalone.rs StandaloneCertificate construction after each checkpoint
tlog.rs C2SP tlog-tiles and signed-note support: checkpoint format, key-ID
computation, cosignature production, hash tile computation for all
C2SP signature types (Ed25519, ECDSA, ML-DSA-44 cosignatures)
delegation/
mod.rs Re-exports delegation submodules
upstream.rs DelegationUpstreamTask — background tokio task that polls
processing delegation orders and drives the upstream ACME
flow (account registration, new-order, dns-01 deploy/poll,
finalize, cert retrieval) using [delegation_upstream] config;
not started when [delegation_upstream] is absent
jose/ Thin re-exports and helpers for crates/akamu-jose
mod.rs Module re-exports
jwk.rs Re-exports JwkPublic from akamu-jose
jws.rs Re-exports JwsFlattened, JwsKeyRef, JwsProtectedHeader from akamu-jose
eab.rs EAB (External Account Binding) HMAC verification helpers
kid.rs account_id_from_kid — extracts and validates the account ID from a JWS kid URL
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: crate::db::Db— write connection pool. All write transactions and most read queries use this pool.db_ro: crate::db::Db— read-only connection pool for SQLite file-backed databases (opened with?mode=roURI parameter). Pure-read handlers (get_order,get_authz,download_cert) route through this pool so WAL concurrent reads do not contend on the write lock. For:memory:databases and non-SQLite backends this is a clone ofdb.db_kind: DbKind— discriminant indicating the underlying database backend (SQLite, Postgres, MariaDB); used by a small number of queries that need backend-specific SQL.cas: Arc<IndexMap<String, Arc<CaState>>>— all configured CAs keyed by their ID, in config-file insertion order. Replaces the old singlecafield.default_ca_id: Arc<String>— the CA ID that serves the backward-compatible/acme/directoryand/ca/crlroutes. Set to the entry withis_default = truein[[ca]]config.crl_caches: Arc<HashMap<String, CrlCache>>— per-CA CRL cache keyed by CA ID. Each entry isNoneuntil the first CRL request for that CA. Replaces the old singlecrl_cachefield.link_headers: Arc<HashMap<String, Arc<HeaderValue>>>— per-CA precomputedLink: …; rel="index"header values keyed by CA ID.acme_headers(state, ca_id, nonce)looks up the header for the request’s CA, falling back to the default CA’s header. Replaces the old singlelink_headerfield.mtc: Arc<MtcState>— MTC log handle, signing key, and pre-built cosigner HTTPS clients.profiles: Arc<ProfileRegistry>— in-memory certificate profile cache; empty when no providers are configured, in which case every order falls back to CA defaults.tls: Option<Arc<TlsState>>— present when[tls]is enabled and client auth is configured; holds the client-auth config for introspection by handlers.nonces: Arc<NonceBucket>— in-memory anti-replay nonce store.spki_cache: Arc<RwLock<HashMap<String, CachedAccount>>>— per-account SPKI/thumbprint cache to avoid a DB round-trip per authenticated request after the first.validation_client: ValidationClient— shared hyper HTTP client for http-01 challenge validation; connection-pooled so TCP connections are reused across validations.gss_cred: Option<Arc<GssServerCred>>— server-side GSSAPI credential for standalone SPNEGO authentication.Nonewhen[server.gssapi]is absent.admin_gss_cred: Option<Arc<GssServerCred>>— admin-specific GSSAPI credential from[admin.gssapi]; takes precedence overgss_credfor admin SPNEGO.Nonewhen[admin.gssapi]is absent.eab_master_secret: Option<Arc<Zeroizing<Vec<u8>>>>— decoded master secret for HKDF-based EAB key derivation.Nonewhen[server].eab_master_secretis absent.journal: Arc<JournalWriter>— audit event writer. Connects to the systemd journal namespace socket, writes to a JSONL file ([server].audit_log_file), or uses an in-process store (development/CI). Always present.audit: Arc<AuditState>— shared in-memory audit state (overflow flag, FAU_ARP.1 alarm counter,VecDeque-backed violation timestamp window). Always present.audit_policy: Arc<AuditPolicy>— audit policy extracted from[admin]at startup.admin_sessions: Option<Arc<tokio::sync::Mutex<HashMap<String, AdminSession>>>>— opaque session token store for admin operator sessions.Nonewhen[admin]is absent.admin_auth_limiter: Option<AdminAuthLimiter>— per-source-IP credential-attempt timestamps for admin auth rate-limiting.Nonewhen[admin]is absent.startup_time: Instant— time the server process started; used for uptime reporting inGET /admin/stats.
AppState is Clone because Arc<T> is Clone and sqlx::AnyPool is Clone. Cloning is cheap (reference count bump). All mutable state (the database and MTC log) is protected at a lower level by sqlx’s internal pool management and a tokio::sync::Mutex<DiskBackedLog>, respectively.
CaState
Holds the key material and issuance policy for a single CA. Key fields:
id: String— the CA’s unique identifier, matchingCaConfig.idfrom the config file.key_type: String— the key algorithm string (e.g."ec:P-256","rsa:2048").key: BackendPrivateKey— CA private key used for certificate and CRL signing.cert_der: Vec<u8>— DER-encoded CA certificate.hash_alg: String— hash algorithm string for certificate and CRL signatures (e.g."sha256").validity_days: u32— default validity period for issued end-entity certificates.crl_url: Option<String>— optional CRL distribution point URL embedded in issued certificates.ocsp_url: Option<String>— optional OCSP responder URL embedded in issued certificates.aki_bytes: Vec<u8>— RFC 7093 §2 Method 1 key identifier bytes (leftmost 20 bytes of the SHA-256 of the CA public key BIT STRING). Used to validate the AKI component of ARI cert-ids (RFC 9773 §4.1).enforce_validity_cap: bool— whentrue,issue_with_paramsrejects issuance when the computed validity exceeds 200 days (CA/B Forum BR §6.3.2).crl_next_update_secs: u64— validity window for signed CRLs (determines cache TTL).caa_identities: Vec<String>— CAA domain identities specific to this CA; falls back to[server].caa_identitieswhen empty.
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.
Two helpers on AppState provide access to CA instances:
AppState::get_ca(id: &str) -> Option<&Arc<CaState>>— look up a CA by ID; returnsNonefor unknown IDs.AppState::default_ca() -> &Arc<CaState>— return the default CA (the one designated byis_default = true). Panics only if the server was constructed incorrectly.
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. Two route sets are registered:
- Legacy routes (
/acme/directory,/acme/new-account, etc.) — served by the same handlers, usingdefault_ca_idas the implicit CA context. - Per-CA routes (
/acme/{ca_id}/directory,/acme/{ca_id}/new-account, etc.) — the same handlers, but the{ca_id}path parameter selects the CA. Axum resolves static path segments before dynamic ones, so the legacy/acme/directoryroute is always matched before/acme/{ca_id}/directory; config validation ensures no CA ID collides with reserved ACME path segments ("directory","new-nonce", etc.).
The CaId extractor (src/routes/mod.rs) resolves the active CA for each request. When the {ca_id} path parameter is present it validates the value against state.cas and returns 404 Not Found for unknown IDs. On legacy routes without {ca_id}, it returns the default_ca_id. Every handler that is CA-aware accepts a CaId argument.
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.CaId(ca_id): CaId— resolved CA identifier for this request.Path(...)— URL path parameters (e.g., order ID, authz ID).body: Bytes— raw request body for JWS verification.
The account_scope setting in [server] affects account creation and JWS validation. When set to "server" (the default), one account registration is valid for all CAs. When set to "ca", accounts are isolated per CA and each CA directory advertises its own /acme/{ca_id}/new-account endpoint; JWS kid validation additionally checks that the account’s ca_id matches the request’s CA.
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 the in-memoryNonceBucketand atomically replace it with a fresh nonce. A missing or already-used nonce returnsbadNonce. The nonce store is in-memory (aMutex<HashMap>insideAppState::nonces); nonces issued before a server restart are silently dropped, and clients detect the resultingbadNonceand retry per RFC 8555 §6.5. - 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 RFC 9964 §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 database transaction to ensure atomicity. On PostgreSQL, state-transition transactions (new-order, new-authz, challenge) additionally issue SET LOCAL synchronous_commit = off via db::pg_local_async_commit to eliminate per-commit WAL flush overhead; the certificate issuance transaction retains full durability.
6. Response construction
Handlers return Result<Response, AcmeError>. On success, they call routes::json_response, which:
- Generates a new anti-replay nonce.
- Stores it in the in-memory
NonceBucket. - Adds the
Replay-NonceandLink: <directory>; rel="index"headers, selecting the correctLinkvalue fromstate.link_headersfor the active CA. - 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.
A third background task (ProfileRegistry::spawn_refresh_task) wakes every refresh_interval_secs seconds, re-loads all configured profile providers, and atomically replaces the in-memory ProfileCache under a write lock. It holds a weak Arc reference to the registry so that dropping the server’s strong reference causes the task to exit cleanly on shutdown.
A fourth background task (DelegationUpstreamTask) is started at server startup when [delegation_upstream] is configured. It wakes every poll_interval_secs seconds (default 10), queries the database for delegation orders in processing status, and drives each one through the upstream ACME flow: it contacts the upstream CA’s directory, registers an account (if not already registered), submits a new-order, triggers the dns-01 challenge by invoking challenge_deploy_script, polls until the upstream authorization is valid, calls challenge_cleanup_script (if configured), finalizes the order, and stores the resulting certificate URL back into the order row. On success the order transitions to valid; on any unrecoverable upstream failure it transitions to invalid.
Database access model
All database access goes through sqlx::AnyPool. Queries are async and run directly on the tokio runtime. This means:
sqlx::query!(...)/sqlx::query_as!(...)are the primary way to issue queries.- For multi-statement atomicity, acquire a transaction with
pool.begin().await?and commit withtx.commit().await?.
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 sqlx 0.8 as its database layer, with a runtime-dispatch AnyPool that supports SQLite, PostgreSQL, and MariaDB. The active backend is selected by a compile-time feature flag (backend-sqlite, backend-postgres, or backend-mariadb). Schema migrations are managed by sqlx’s built-in migrate! macro.
Connection model
The server holds two sqlx::AnyPool instances stored in AppState:
db(write pool) — all migrations run here; all write transactions use it; most read queries also use it.db_ro(read-only pool) — opened with the?mode=roSQLite URI parameter. Pure-read handlers (get_order,get_authz,download_cert) route through this pool so concurrent reads do not contend on the WAL write lock. For:memory:databases and non-SQLite backendsdb_rois a clone ofdb.
db::open_ro(url, max_connections) in src/db/mod.rs opens the read-only pool. It returns None for :memory: URLs (each connection sees an empty schema) and for non-SQLite URLs.
sqlx manages each pool’s connection count internally; callers pass &pool to query helpers or &mut *tx inside transactions.
All queries use the sqlx QueryBuilder or typed-query pattern:
#![allow(unused)]
fn main() {
sqlx::query_as!(Row, "SELECT … FROM …", param)
.fetch_one(&db_ro)
.await?
}
Initialization
db::open(url, max_connections, require_tls) in src/db/mod.rs performs the following in order:
- Registers all compiled-in sqlx drivers via
sqlx::any::install_default_drivers(). - Optionally validates the URL for SSL/TLS parameters when
require_tlsistrue(FPT_ITT.1). - Opens the pool (creates the SQLite file if needed via the
?mode=rwcURI parameter; for:memory:a fresh in-memory database is used). - Enables WAL mode and performance pragmas for SQLite:
PRAGMA journal_mode=WAL,PRAGMA synchronous=NORMAL,PRAGMA foreign_keys=ON,PRAGMA mmap_size=134217728,PRAGMA cache_size=-65536. - Runs all pending migrations via the compiled-in
sqlx::migrate!macro, selecting the backend-specific migration directory (migrations/sqlite/,migrations/postgres/, ormigrations/mariadb/).
At server startup, nonces older than 24 hours are swept from the in-memory NonceBucket.
Migration numbering
Each database backend has its own migration directory (migrations/sqlite/, migrations/postgres/, migrations/mariadb/). Two backend-specific migrations affect the numbering:
- SQLite 0006 (
0006_mtc_log_index.sql) — a WAL-mode index-tuning step that does not apply to PostgreSQL or MariaDB. All SQLite migrations from 0007 onward are therefore one higher than the corresponding PostgreSQL/MariaDB number. - PostgreSQL 0015 (
0015_hot_indexes.sql) — two partial/compound indexes onauthorizationsthat are specific to PostgreSQL concurrency characteristics. This migration has no SQLite or MariaDB counterpart.
The remainder of this document uses SQLite numbers as the canonical reference and notes the PostgreSQL/MariaDB equivalent where the numbers differ.
Schema
All seven core tables are created in a single initial migration (0001_initial.sql). Later migrations add columns and additional tables.
Migration 0001 — Initial schema
nonces — Anti-replay nonces. The in-memory NonceBucket is the hot path; this table exists for startup cleanup of nonces written by a previous process version.
CREATE TABLE nonces (
nonce TEXT PRIMARY KEY,
created INTEGER NOT NULL -- Unix epoch seconds
);
CREATE INDEX idx_nonces_created ON nonces(created);
accounts — ACME accounts.
CREATE TABLE accounts (
id TEXT PRIMARY KEY, -- UUID
status TEXT NOT NULL DEFAULT 'valid', -- valid|deactivated|revoked
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, including STAR (RFC 8739) auto-renewal fields.
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, -- FK to certificates.id when valid
replaces TEXT, -- RFC 9773 ARI: cert_id of predecessor
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
-- RFC 8739 STAR auto-renewal
star_start_date INTEGER,
star_end_date INTEGER,
star_lifetime_secs INTEGER,
star_lifetime_adjust_secs INTEGER NOT NULL DEFAULT 0,
star_allow_cert_get INTEGER NOT NULL DEFAULT 0,
star_canceled_at INTEGER,
star_csr_der BLOB, -- stored CSR DER for reissuance
-- draft-ietf-acme-profiles-01
profile TEXT
);
CREATE INDEX idx_orders_account ON orders(account_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_replaces ON orders(replaces) WHERE replaces IS NOT NULL;
CREATE INDEX idx_orders_star ON orders(star_end_date) WHERE star_end_date IS NOT NULL;
authorizations — One per identifier per order. account_id is denormalized from the parent order to allow efficient per-account queries without joins. subdomain_auth_allowed records whether RFC 9444 subdomain authorization was granted.
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":"dns","value":"example.com"}
expires INTEGER,
wildcard INTEGER NOT NULL DEFAULT 0,
subdomain_auth_allowed INTEGER NOT NULL DEFAULT 0, -- RFC 9444
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
CREATE INDEX idx_authz_order ON authorizations(order_id);
CREATE INDEX idx_authz_account ON authorizations(account_id);
challenges — One or more per authorization. All challenges for a given authorization share the same token.
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
);
CREATE INDEX idx_chall_authz ON challenges(authz_id);
certificates — Issued X.509 certificates. der stores only the leaf DER; pem stores the full chain (leaf + CA). Both are stored because CRL generation and MTC logging need DER while the download endpoint serves PEM.
CREATE TABLE certificates (
id TEXT PRIMARY KEY, -- UUID used in the cert URL path
order_id TEXT NOT NULL REFERENCES orders(id),
account_id TEXT NOT NULL REFERENCES accounts(id),
serial_number TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'valid', -- valid|revoked
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,
suggested_window_start INTEGER, -- RFC 9773 ARI renewal window
suggested_window_end INTEGER,
replaced_by TEXT -- RFC 9773: order_id that superseded this cert
);
CREATE INDEX idx_certs_account ON certificates(account_id);
CREATE INDEX idx_certs_serial ON certificates(serial_number);
CREATE INDEX idx_certs_order ON certificates(order_id);
CREATE INDEX idx_certs_status ON certificates(status);
CREATE INDEX idx_certs_account_status_not_after ON certificates(account_id, status, not_after);
CREATE INDEX idx_certs_replaced_by ON certificates(replaced_by)
WHERE replaced_by IS NOT NULL;
eab_keys — External Account Binding (RFC 8555 §7.3.4) pre-provisioned HMAC keys.
CREATE TABLE eab_keys (
kid TEXT PRIMARY KEY,
hmac_key_b64u TEXT NOT NULL,
created INTEGER NOT NULL,
used_at INTEGER,
profile_grants TEXT -- JSON array of profile IDs; NULL = unrestricted
);
(profile_grants is added inline in the initial migration. The old 0007_profile_grants migration added it as ALTER TABLE in the original schema; it is now baked into the 0001 baseline for new installations.)
Migration 0002 — MTC checkpoints
Adds the Merkle Tree Certificate issuance-log checkpoint table:
CREATE TABLE mtc_checkpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tree_size INTEGER NOT NULL UNIQUE,
root_hex TEXT NOT NULL,
signature BLOB NOT NULL,
created INTEGER NOT NULL
);
Migration 0003 — MTC standalone DER
ALTER TABLE certificates ADD COLUMN mtc_standalone_der BLOB;
Stores the standalone (non-chained) DER encoding of an MTC-logged certificate, used when serving MTC certificate downloads.
Migration 0004 — MTC cosignatures
CREATE TABLE mtc_cosignatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
checkpoint_id INTEGER NOT NULL REFERENCES mtc_checkpoints(id) ON DELETE CASCADE,
cosigner_url TEXT NOT NULL,
signature_der BLOB NOT NULL,
created INTEGER NOT NULL,
UNIQUE(checkpoint_id, cosigner_url)
);
CREATE INDEX idx_mtc_cosignatures_checkpoint ON mtc_cosignatures(checkpoint_id);
Migration 0005 — MTC landmarks
CREATE TABLE mtc_landmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sequence_no INTEGER NOT NULL UNIQUE,
tree_size INTEGER NOT NULL UNIQUE,
cert_der BLOB, -- DER-encoded LandmarkCertificate; NULL until built
created INTEGER NOT NULL
);
Migration 0006 — MTC log index (SQLite only)
CREATE INDEX idx_certs_mtc_log_index
ON certificates(mtc_log_index)
WHERE mtc_log_index IS NOT NULL;
This index is specific to SQLite WAL mode and has no equivalent in the PostgreSQL/MariaDB migrations. All subsequent SQLite migration numbers are therefore one higher than their PostgreSQL/MariaDB counterparts.
Migration 0007 — Profile grants (PostgreSQL/MariaDB: 0006)
ALTER TABLE accounts ADD COLUMN profile_grants TEXT;
ALTER TABLE eab_keys ADD COLUMN profile_grants TEXT;
profile_grants is a JSON array of profile IDs (e.g. '["tls-server","mtc-tls"]'). NULL means no restriction. When an EAB key has grants set, they are copied to the account at registration time.
Migration 0008 — Audit events (PostgreSQL/MariaDB: 0007) — DROPPED
This migration originally created the audit_events database table for the PP CA v2.1 FAU structured audit trail. The table has been dropped by migration 0031 (SQLite) / 0032 (MariaDB) / 0033 (PostgreSQL). Audit events are now written to a dedicated systemd journal namespace (journalctl --namespace=akamu) via src/journal.rs. See contrib/systemd/journald@akamu.conf for retention settings.
Migration 0009 — Operators (PostgreSQL/MariaDB: 0008)
PP CA v2.1 FMT role-based access control. Operators authenticate via mTLS client certificate, Kerberos/GSSAPI, or both:
CREATE TABLE operators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
role TEXT NOT NULL
CHECK(role IN ('administrator','ca_operations','ca_ra','auditor')),
cert_fingerprint TEXT UNIQUE, -- SHA-256 hex of DER leaf cert
gssapi_principal TEXT UNIQUE, -- Kerberos principal e.g. alice@REALM
created_at TEXT NOT NULL, -- RFC 3339
last_seen_at TEXT, -- RFC 3339
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
CHECK(cert_fingerprint IS NOT NULL OR gssapi_principal IS NOT NULL)
);
Migration 0010 — Certificate subject DN (PostgreSQL/MariaDB: 0009)
Adds a searchable subject DN column for FAU_SCR_EXT.1 audit queries:
ALTER TABLE certificates ADD COLUMN subject_dn TEXT;
CREATE INDEX idx_certs_subject_dn ON certificates(subject_dn);
Migration 0011 — Operator lockout (PostgreSQL/MariaDB: 0010)
FIA_AFL.1 per-operator authentication lockout after repeated failures:
ALTER TABLE operators ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE operators ADD COLUMN locked_until TEXT;
Migration 0012 — Multi-CA support (PostgreSQL/MariaDB: 0011)
Adds ca_id to accounts, orders, and certificates. Sentinel conventions:
accounts.ca_id = ''— server-wide account scope; the account may use any CA. The empty string is not a valid CA ID (config validator requires^[a-z0-9]).orders.ca_id = 'default'— backfills pre-migration rows to the canonical single-CA name.certificates.ca_id = 'default'— same.
ALTER TABLE accounts ADD COLUMN ca_id TEXT NOT NULL DEFAULT '';
ALTER TABLE orders ADD COLUMN ca_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE certificates ADD COLUMN ca_id TEXT NOT NULL DEFAULT 'default';
CREATE INDEX idx_accounts_ca_id ON accounts(ca_id);
CREATE INDEX idx_orders_ca_id ON orders(ca_id);
CREATE INDEX idx_certs_ca_id ON certificates(ca_id);
CREATE INDEX idx_certs_ca_id_revoked ON certificates(ca_id) WHERE status = 'revoked';
Migration 0013 — Cross-certificates (PostgreSQL/MariaDB: 0012)
Stores CA certificates issued by one akāmu CA for another CA’s public key. Rows are insert-only.
CREATE TABLE cross_certs (
id TEXT PRIMARY KEY, -- UUID
issuer_ca_id TEXT NOT NULL,
subject_ca_id TEXT, -- akāmu CA ID if same-server; NULL if external
subject_dn TEXT NOT NULL, -- RFC 4514 subject DN
subject_spki BLOB NOT NULL, -- DER SubjectPublicKeyInfo of subject CA key
cross_cert_der BLOB NOT NULL,
cross_cert_pem TEXT NOT NULL,
not_before INTEGER NOT NULL,
not_after INTEGER NOT NULL,
serial_number TEXT NOT NULL,
created INTEGER NOT NULL,
UNIQUE (issuer_ca_id, serial_number)
);
CREATE INDEX idx_cross_certs_issuer ON cross_certs(issuer_ca_id);
CREATE INDEX idx_cross_certs_subject ON cross_certs(subject_ca_id)
WHERE subject_ca_id IS NOT NULL;
Migration 0014 — Authorization CA scope (PostgreSQL/MariaDB: 0013)
Records which CA owns each authorization, enabling per-CA namespace isolation. Pre-migration rows are backfilled from the parent order’s ca_id:
ALTER TABLE authorizations ADD COLUMN ca_id TEXT NOT NULL DEFAULT 'default';
UPDATE authorizations
SET ca_id = COALESCE((SELECT ca_id FROM orders WHERE id = authorizations.order_id), 'default')
WHERE order_id IS NOT NULL AND order_id != '';
CREATE INDEX idx_orders_ca_account ON orders(ca_id, account_id);
CREATE INDEX idx_authzs_ca_id ON authorizations(ca_id);
Migration 0015 — Operator CA scope (PostgreSQL/MariaDB: 0014)
Scopes ca_ra operators to a single CA. Empty string = server-wide (the operator can act on any CA):
ALTER TABLE operators ADD COLUMN ca_id TEXT NOT NULL DEFAULT '';
Migration 0015 (PostgreSQL only) — Hot-path indexes
Two partial and compound indexes on authorizations that speed up the hot paths hit during every successful challenge validation. This migration has no SQLite or MariaDB equivalent because both databases perform adequately without it at typical concurrency levels; SQLite uses a single write connection that serialises concurrent writers, and MariaDB’s query planner handles these patterns differently.
-- Speeds up the NOT EXISTS subquery in on_valid: filters to non-valid rows only.
CREATE INDEX IF NOT EXISTS idx_authz_order_nonvalid
ON authorizations(order_id)
WHERE status != 'valid';
-- Speeds up find_valid_by_account_and_identifier: covers both filter columns.
CREATE INDEX IF NOT EXISTS idx_authz_acct_ident
ON authorizations(account_id, identifier);
SQLite migration numbers remain one higher than the PostgreSQL/MariaDB equivalents from migration 0007 onward (due to the SQLite-only MTC log index at SQLite 0006). The PostgreSQL migration directory now contains 17 migrations (0001–0016, plus the PostgreSQL-only hot-indexes file); the SQLite directory contains 17 migrations (0001–0017). The SQLite offset means its 0017 corresponds to the RFC 9115 delegation changes, which is PostgreSQL/MariaDB 0016.
Migration 0016 — Email challenge state (SQLite) / Migration 0015 (PostgreSQL/MariaDB)
Adds two columns to the challenges table to support the two-channel token required by the RFC 8823 email-reply-00 challenge:
ALTER TABLE challenges ADD COLUMN email_token_part1 TEXT;
ALTER TABLE challenges ADD COLUMN email_message_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_chall_email_message_id
ON challenges(email_message_id)
WHERE email_message_id IS NOT NULL;
email_token_part1 holds the server-generated first half of the RFC 8823 two-part token, delivered to the applicant in the challenge email subject. email_message_id is the Message-ID of the outbound challenge email, used to correlate the inbound webhook reply.
Migration 0017 — RFC 9115 delegation (SQLite) / Migration 0016 (PostgreSQL/MariaDB)
Adds the delegations table and four new columns to orders to support RFC 9115 ACME delegated certificates:
CREATE TABLE IF NOT EXISTS delegations (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
csr_template TEXT NOT NULL, -- JSON per RFC 9115 §4
cname_map TEXT, -- JSON {fqdn: fqdn} or NULL
created INTEGER NOT NULL,
updated INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_delegations_account ON delegations(account_id);
ALTER TABLE orders ADD COLUMN delegation_id TEXT REFERENCES delegations(id);
ALTER TABLE orders ADD COLUMN allow_cert_get INTEGER NOT NULL DEFAULT 0;
ALTER TABLE orders ADD COLUMN upstream_order_url TEXT;
ALTER TABLE orders ADD COLUMN upstream_cert_url TEXT;
CREATE INDEX IF NOT EXISTS idx_orders_delegation ON orders(delegation_id)
WHERE delegation_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_orders_delegation_status
ON orders(delegation_id, status)
WHERE delegation_id IS NOT NULL AND status = 'processing';
delegation_id is a nullable FK to delegations(id). Orders with a non-null delegation_id skip the authorization flow and start in ready status. allow_cert_get mirrors the "allow-certificate-get" field from the new-order payload — when set to 1, the certificate endpoint for that order accepts unauthenticated GET. upstream_order_url and upstream_cert_url are set by the background delegation task as it progresses through the upstream ACME flow.
The PostgreSQL version uses ALTER TABLE ... ADD COLUMN IF NOT EXISTS and CREATE INDEX CONCURRENTLY to allow the migration to run without an exclusive table lock.
Row types
src/db/schema.rs defines Rust structs mirroring each table row:
AccountRow— mirrorsaccounts. Includesca_id: String(empty = server-wide; non-empty only whenserver.account_scope = "ca"),profile_grants: Option<String>.OrderRow— mirrorsorders. Includesca_id: String(defaults to"default"for pre-migration rows),profile: Option<String>, allstar_*fields, and the RFC 9115 delegation fields:delegation_id: Option<String>,allow_cert_get: bool,upstream_order_url: Option<String>,upstream_cert_url: Option<String>.DelegationRow— mirrorsdelegations. Fields:id: String,account_id: String,csr_template: String(JSON),cname_map: Option<String>(JSON),created: i64,updated: i64.AuthorizationRow— mirrorsauthorizations. Includesca_id: Stringandsubdomain_auth_allowed: bool.ChallengeRow— mirrorschallenges.CertificateRow— mirrorscertificates. Includesca_id: String,subject_dn: Option<String>,suggested_window_start/end: Option<i64>,replaced_by: Option<String>.CrossCertRow— mirrorscross_certs.subject_ca_id: Option<String>isNonewhen the subject is an external CA.OperatorRow— mirrorsoperators. Includesca_id: String(CA scope forca_raoperators; empty = server-wide),failed_attempts: i64,locked_until: Option<String>.
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, set_profile_grants, get_profile_grants, list |
db::orders | insert, get_by_id, update_status, list_authz_ids, list_pending_delegation_orders |
db::delegations | insert, get_by_id, update, delete, list, list_by_account |
db::authz | insert, get_by_id, update_status |
db::challenges | insert, get_by_id, list_by_authz, set_processing, set_invalid |
db::certs | insert, get_by_id, get_by_serial, get_by_cert_id, mark_replaced, revoke, set_mtc_log_index, set_renewal_window, list_revoked, list_valid_for_account, get_latest_for_order, search |
db::cross_certs | insert, get_by_id, list |
db::eab | insert, get_by_kid, mark_used, list, delete |
db::nonces | insert, consume, sweep_expired |
db::operators | insert, insert_if_absent, is_empty, get_by_id, get_by_fingerprint, get_by_principal, list, update, set_active, update_last_seen, increment_failed, reset_failed, unlock, is_locked |
db::audit | Removed — audit events are now written to the configured audit backend (systemd journal namespace, JSONL file, or in-process store) via src/journal.rs and src/audit.rs |
CertSearchParams
db::certs::search accepts a CertSearchParams<'_> struct to satisfy clippy::too_many_arguments. All filter fields are optional; only Some values are emitted as WHERE clauses via QueryBuilder:
#![allow(unused)]
fn main() {
pub struct CertSearchParams<'a> {
pub serial: Option<&'a str>,
pub account_id: Option<&'a str>,
pub status: Option<&'a str>,
pub subject_dn: Option<&'a str>, // LIKE-escaped substring match
pub ca_id: Option<&'a str>,
pub limit: i64,
pub offset: i64,
}
}
The subject_dn filter uses LIKE with ! as the escape character; % and _ in the input are automatically escaped to prevent injection.
OperatorUpdateParams
db::operators::update accepts an OperatorUpdateParams<'_> struct. Only Some fields are included in the generated UPDATE statement:
#![allow(unused)]
fn main() {
pub struct OperatorUpdateParams<'a> {
pub name: Option<&'a str>,
pub role: Option<&'a str>,
pub cert_fingerprint: Option<&'a str>,
pub gssapi_principal: Option<&'a str>,
pub ca_id: Option<&'a str>, // Some("") clears CA scope; None leaves it unchanged
}
}
update is called by the PUT /admin/operators/{id} handler, which evicts any active session for that operator from AppState::admin_sessions on every successful update. This ensures that role and CA-scope changes take effect immediately rather than at the next session expiry.
Query helpers
src/db/mod.rs exports several helpers that make raw sqlx queries portable across backends.
pg_sql / query / query_as
PostgreSQL uses $N positional placeholders while SQLite and MariaDB use ?. sqlx’s
AnyPool does not automatically rewrite ? for PostgreSQL because ? is also the
JSONB existence operator there. The helpers below handle the rewrite transparently:
| Helper | Usage |
|---|---|
pg_sql(sql) | Rewrites ? → $1, $2, … for PostgreSQL; returns the string unchanged for all other backends. The rewritten string is cached by static pointer identity, so each unique SQL literal is rewritten at most once. |
query(sql) | Calls pg_sql, then sqlx::query. Use everywhere a raw ?-parameterised query string is needed. |
query_as::<O>(sql) | Calls pg_sql, then sqlx::query_as. Use for typed row mapping. |
DynQueryBuilder
For dynamically constructed queries (variable number of WHERE clauses, multi-row
VALUES inserts), DynQueryBuilder emits $N for PostgreSQL and ? for all other
backends, and tracks the bind count internally:
#![allow(unused)]
fn main() {
let mut q = DynQueryBuilder::new("SELECT id FROM certificates WHERE 1=1");
if let Some(serial) = params.serial {
q.push(" AND serial_number = ").push_bind(serial);
}
let rows = q.fetch_all(&db).await?;
}
pg_local_async_commit
#![allow(unused)]
fn main() {
pub(crate) async fn pg_local_async_commit(
tx: &mut sqlx::Transaction<'_, sqlx::Any>,
kind: DbKind,
) -> Result<(), sqlx::Error>
}
Issues SET LOCAL synchronous_commit = off inside the current PostgreSQL transaction,
eliminating the per-commit WAL flush (~1–4 ms on SSD) for writes on state-transition
paths that are eventually consistent by ACME protocol design.
Called at the start of the following write transactions:
new-order— inserts the order, authorization, and challenge rows.new-authz— inserts a standalone authorization row.- Challenge processing — updates challenge, authorization, and order status on
on_validandon_invalid.
The certificate issuance transaction (finalize) does not call this function; cert
rows require full durability guarantees.
No-op on SQLite and MariaDB.
Transactions
Multi-table writes use explicit transactions to ensure atomicity:
- Order creation: the order row, all authorization rows, and all challenge rows are inserted in a single transaction. For PostgreSQL,
pg_local_async_commitis called at transaction start to defer WAL flush. - Challenge validation success: the challenge, authorization, and (if all authorizations are now valid) the order are updated in a single transaction. For PostgreSQL,
pg_local_async_commitis called at transaction start. - Certificate issuance: the certificate row is inserted and the order is updated to
validin a single transaction. STAR re-issuance also stores the new CSR DER in the same transaction. Full WAL durability is retained for this transaction on all backends.
Schema diagram
The entity-relationship diagram below shows the ACME core tables and their foreign-key relationships. MTC tables (mtc_checkpoints, mtc_cosignatures, mtc_landmarks) and the standalone nonces table are omitted for readability. The audit_events table has been dropped; audit events are now written to the configured audit backend (systemd journal namespace, JSONL file, or in-process store).
erDiagram
accounts {
TEXT id PK
TEXT status
TEXT contact
BLOB public_key
TEXT jwk_thumbprint UK
TEXT profile_grants
TEXT ca_id
INTEGER created
INTEGER updated
}
orders {
TEXT id PK
TEXT account_id FK
TEXT ca_id
TEXT status
INTEGER expires
TEXT identifiers
TEXT replaces
TEXT error
TEXT certificate_id
TEXT profile
INTEGER star_end_date
INTEGER star_lifetime_secs
TEXT delegation_id FK
INTEGER allow_cert_get
TEXT upstream_order_url
TEXT upstream_cert_url
INTEGER created
INTEGER updated
}
authorizations {
TEXT id PK
TEXT order_id FK
TEXT account_id FK
TEXT ca_id
TEXT status
TEXT identifier
INTEGER expires
INTEGER wildcard
INTEGER subdomain_auth_allowed
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 ca_id
TEXT serial_number UK
TEXT status
BLOB der
TEXT pem
TEXT subject_dn
INTEGER not_before
INTEGER not_after
INTEGER revoked_at
INTEGER revocation_reason
INTEGER mtc_log_index
INTEGER suggested_window_start
INTEGER suggested_window_end
TEXT replaced_by
INTEGER created
}
eab_keys {
TEXT kid PK
TEXT hmac_key_b64u
TEXT profile_grants
INTEGER created
INTEGER used_at
}
operators {
INTEGER id PK
TEXT name UK
TEXT role
TEXT cert_fingerprint UK
TEXT gssapi_principal UK
TEXT ca_id
INTEGER active
INTEGER failed_attempts
TEXT locked_until
TEXT created_at
TEXT last_seen_at
}
cross_certs {
TEXT id PK
TEXT issuer_ca_id
TEXT subject_ca_id
TEXT subject_dn
BLOB subject_spki
BLOB cross_cert_der
TEXT cross_cert_pem
TEXT serial_number
INTEGER not_before
INTEGER not_after
INTEGER created
}
delegations {
TEXT id PK
TEXT account_id FK
TEXT csr_template
TEXT cname_map
INTEGER created
INTEGER updated
}
accounts ||--o{ orders : "account_id"
accounts ||--o{ authorizations : "account_id (denormalized)"
accounts ||--o{ certificates : "account_id"
accounts ||--o{ delegations : "account_id"
orders ||--o{ authorizations : "order_id"
authorizations ||--o{ challenges : "authz_id"
orders ||--o{ certificates : "order_id"
delegations ||--o{ orders : "delegation_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.idmtc_cosignatures.checkpoint_id→mtc_checkpoints.id(withON DELETE CASCADE)delegations.account_id→accounts.idorders.delegation_id→delegations.id(nullable)
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.
Gossip Replication Protocol
Akamu replicates all ACME and cluster state across nodes using a CRDT-based push-pull gossip protocol. Each node maintains its own authoritative in-memory CRDT; the local database is a persistence cache for crash recovery only. Replication is eventually consistent: a write on one node appears on all peers within one or two gossip intervals.
Architecture Overview
flowchart TD
subgraph Node A
A_DB[(DB write)]
A_Hook[crdt_hooks]
A_CRDT[AkaCrdt\nin memory]
A_Notify[write_notify]
A_Loop[gossip_loop]
end
subgraph Node B
B_Handler[POST /admin/gossip/sync]
B_CRDT[AkaCrdt\nin memory]
B_DB2[(DB persist\nevery 30 s)]
end
A_DB --> A_Hook --> A_CRDT
A_Hook --> A_Notify --> A_Loop
A_Loop -->|sign_and_seal\nCBOR+zstd+CMS| B_Handler
B_Handler --> B_CRDT
B_CRDT --> B_DB2
B_Handler -->|delta response\nsign_and_seal| A_Loop
A_Loop --> A_CRDT
CRDT Data Model
Top-Level: AkaCrdt
AkaCrdt (crates/akamu-crdt/src/crdt.rs) is the single replicated data structure
holding all cluster state. It composes two CRDT primitive types:
| Field | Type | Semantic |
|---|---|---|
cluster_nodes | OrMap<String, AkaNodeEntry> | Node registry (gossip keys, URLs) |
accounts | OrMap<String, AccountEntry> | ACME accounts |
orders | OrMap<String, OrderEntry> | ACME orders |
authorizations | OrMap<String, AuthzEntry> | ACME authorizations |
challenges | LwwMap<String, ChallengeEntry> | Challenge state |
certificates | OrMap<String, CertEntry> | Issued certificates |
eab_keys | LwwMap<String, EabKeyEntry> | EAB metadata (HMAC secret excluded) |
operators | OrMap<String, OperatorEntry> | Admin operator accounts |
delegations | OrMap<String, DelegationEntry> | STAR delegation records |
mtc_checkpoints | LwwMap<u64, MtcCheckpointEntry> | MTC checkpoint metadata |
mtc_cosignatures | LwwMap<(String,String), MtcCosigEntry> | External cosigner sigs |
order_owners | LwwMap<String, OrderOwner> | Processing-node ownership claims |
mtc_writer | LwwRegister<MtcWriter> | Single elected MTC log writer |
What is NOT replicated: nonces (single-node anti-replay), CA private keys,
admin sessions, and EAB HMAC key bytes (the hmac_key_b64u field has #[serde(skip)]
and is verified by a unit test to never appear in gossip CBOR).
OrMap<K, V>: Observed-Remove Map
or_map.rs — used for entities that can be soft-deleted (accounts, orders, etc.).
Each entry stores (value, added_at: i64, tombstone: bool, tombstone_at: Option<i64>).
Merge rules:
- Tombstone always wins over live. A delete from any node propagates unconditionally.
- Live vs live: higher
added_atwins (Last-Write-Wins by wall-clock timestamp). - Tombstone before insert:
remove()inserts a tombstone even when the key is absent, so an insert arriving later via a different gossip path cannot resurrect the entry.
LwwMap<K, V> + LwwRegister<T>: Last-Write-Wins
lww_map.rs / lww_register.rs — used for mutable state updated by a single authority
(challenge status, EAB usage, ownership claims).
LwwRegister stores (value: Option<T>, timestamp: i64, node_id: String).
Merge rule: higher timestamp wins; on equal timestamps the lexicographically greater
node_id wins. A None value with timestamp > 0 is a deletion tombstone.
Generation Counter
generation.rs exports a process-wide AtomicU64 CRDT_GENERATION.
next_gen() is called on every in-memory CRDT write (upsert, remove, set, merge
of a new entry). The resulting local_gen is stored per-entry but never serialised to
CBOR — it resets to 0 on reload. The counter is used exclusively for delta computation.
delta_since(gen) → sparse AkaCrdt with only entries where local_gen > gen
delta_range(since, until) → entries where local_gen ∈ (since, until]
After DB load, AkaCrdt::max_local_gen() seeds CRDT_GENERATION so deltas computed
after startup do not collide with pre-existing generations.
Write Path
After each successful DB write, crdt_hooks.rs maintains the in-memory CRDT:
DB write succeeds
→ on_account_upsert(state, p) [example]
if gossip_enabled(state):
crdt.write().await.accounts.upsert(id, entry, updated)
state.write_notify.notify_one()
The guard gossip_enabled() is a single config check (state.config.gossip.is_some()).
In single-node deployments the hooks are no-ops; no CRDT overhead is incurred.
Gossip Loop
src/gossip/gossip_loop.rs — spawned once from main, runs forever.
Wake Triggers
The loop wakes on whichever fires first:
- Periodic timer (
interval_secs, default 15 s) - Write notification —
state.write_notify.notified()fires after any CRDT hook write
The write-notify path has two rate controls:
| Control | Value | Purpose |
|---|---|---|
| Slide window | 20 ms | Extend wait on each additional notification within cap |
| Hard cap | 150 ms | Maximum debounce; coalesces ~8 writes per ACME issuance |
| Min interval | 500 ms | Floor between write-notify rounds (~2 Hz max) |
Startup jitter: Each node derives a deterministic jitter from its node_id
(hash(node_id) % min_interval_ms) and delays the first round by that amount. Without
this, nodes started simultaneously create a gossip storm where all N nodes gossip
simultaneously to each peer, causing N-1 merge write-lock requests to queue on every
receiver.
Four-Phase Round
Phase A — Build envelopes (one CRDT read lock for all peers)
For each peer in gossip_peers:
1. Look up peer in cluster_nodes by URL; extract KEM + signing keys
2. If peer not in cluster_nodes or keys absent → skip (warn first 3 rounds)
3. Decide full vs delta:
peer_last_gen absent → clone full CRDT
peer_last_gen present → crdt.delta_since(peer_last_gen)
4. CBOR-encode CRDT bytes
5. Generate 16-byte random nonce
6. Build GossipEnvelope{crdt, issued_at, is_delta, my_gen, request_delta_since, nonce}
7. CBOR-encode envelope
CRDT read lock released
Phase B — Sign envelopes (no lock)
For each prepared peer:
sign_and_seal(envelope_bytes, peer_kem_key, own_signing_priv, own_signing_cert)
Phase C — Parallel HTTP round-trips
Spawn one JoinHandle per peer:
POST {peer_url}/gossip/sync
Headers: Content-Type: application/pkcs7-mime
X-Akamu-Node-Id: {own_node_id}
Body: signed CMS blob
Phase D — Validate + batch-merge (one write lock for all peers)
Pass 1 (no lock):
For each peer response:
verify_and_open(response, own_kem_priv, peer_signing_pub)
decode GossipEnvelope
validate issued_at: reject if > now+clock_skew or < now-max_age
decode inner AkaCrdt
Pass 2 (single write lock):
Pre-merge all valid peer CRDTs into scratch accumulator (no lock)
Acquire write lock once
Merge accumulator into live CRDT
Release write lock
Pass 3 (no lock):
Update peer_last_gen → post_merge_gen
Update peer_response_gen → peer's reported my_gen
Log first-contact entry counts if is_first_contact
Batch-merge (N peers → 1 write lock acquisition) keeps the lock-hold duration proportional to one merge operation rather than N. The pre-merge accumulator is built lock-free.
Delta vs Full-State
| Condition | Payload |
|---|---|
First contact (no peer_last_gen) | Full AkaCrdt clone |
| Subsequent rounds | delta_since(peer_last_gen) — sparse CRDT |
| Receiver response, delta requested | delta_range(request_delta_since, pre_merge_gen) |
| Receiver response, no delta requested | Full CRDT |
The sender’s my_gen at send time is echoed back by the receiver as the basis for
future request_delta_since. This means the requester asks for only entries the peer
received between the last exchange and the current one — not the full state history.
Fan-Out Limiting
fan_out = 0 (default): all peers contacted every round.
fan_out = K > 0: a rotating window of K peers is selected per round, indexed by
current_gen % N. Every peer is reached within ⌈N/K⌉ rounds. This bounds the
simultaneous inbound gossip handler count on receiving nodes to K × (number of sending
nodes) per round.
Wire Protocol
GossipEnvelope
src/gossip/envelope.rs — CBOR-serialised using compact field names.
| Field | CBOR key | Type | Description |
|---|---|---|---|
crdt | p | bytes | CBOR-encoded AkaCrdt (full or delta) |
issued_at | t | i64 | Unix timestamp (seconds); anti-replay anchor |
is_delta | d | bool | True when crdt is a sparse delta |
my_gen | g | u64 | Sender’s CRDT_GENERATION at send time |
request_delta_since | r | u64? | Ask receiver to respond with delta since this gen |
nonce | n | bytes | 16 random bytes; replay deduplication |
Cryptographic Layer
src/gossip/crypto.rs — CMS SignedData(EnvelopedData).
Send — sign_and_seal(plaintext, recipients, signing_priv, signing_cert):
plaintext
→ zstd compress (level 3)
→ AES-256-GCM encrypt with fresh 32-byte CEK + 12-byte nonce
→ for each recipient:
ML-KEM-768 encapsulate(peer_kem_pub)
→ shared_secret, kemct
HKDF-SHA-256(key=shared_secret, salt=kemct, info="akamu-cms-kek") → 32-byte KEK
RFC 3394 AES-256 Key Wrap(KEK, CEK) → 40-byte encrypted CEK
KEMRecipientInfo{kem=ML-KEM-768, kemct, kdf=HKDF-SHA-256, encrypted_key}
→ EnvelopedData DER
→ ECDSA P-256 sign(enveloped_der) → SignedData DER
Receive — verify_and_open(signed_der, kem_priv, expected_sender_spki):
SignedData DER
→ extract embedded signer certificate
→ assert signer_cert.public_key == expected_sender_spki (pinned — no TOFU)
→ ECDSA verify SignedData signature
→ extract EnvelopedData
→ iterate KEMRecipientInfo entries:
ML-KEM-768 decapsulate(kem_priv, kemct) → shared_secret
HKDF-SHA-256(shared_secret, kemct, "akamu-cms-kek") → KEK
AES-256 Key Unwrap(KEK, encrypted_cek) → CEK
AES-256-GCM decrypt(CEK, nonce, ciphertext) → compressed_plaintext
→ zstd decompress (64 MiB limit)
No Trust-On-First-Use. Both the sender’s ECDSA signing key and the receiver’s
ML-KEM-768 public key must be pre-pinned via POST /admin/gossip/register before any
gossip exchange. A node without pre-pinned keys will log a warning and skip the peer.
The KEM public key for the response is captured before the inbound CRDT merge. This
prevents a compromised peer from redirecting the encrypted response by injecting a
modified cluster_nodes entry in its CRDT payload.
Receiver Handler
src/gossip/handlers.rs — POST /admin/gossip/sync.
Processing order:
- Header validation —
X-Akamu-Node-Idheader: required, ≤ 64 bytes. - Pre-merge key lookup — sender’s signing pub + KEM pub read from CRDT under read lock. Returns 401 if either key is absent.
- Verify and decrypt —
verify_and_open()with the pre-fetched signing key. - CBOR decode —
GossipEnvelope::decode(). - Timestamp validation:
- Reject if
issued_at > now + clock_skew_tolerance_secs(default 30 s) - Reject if
issued_at < now - gossip_envelope_max_age_secs(default 300 s)
- Reject if
- Nonce deduplication — 16–32 byte nonce checked against
gossip_nonce_cache(HashMap<Vec<u8>, i64>). Lazy eviction; max 10 000 entries; returns 429 when full. Old peers omitting the nonce field bypass dedup. - Decode CRDT from envelope.
- Merge under write lock.
CRDT_GENERATIONcaptured inside the lock to avoid racing with concurrent hook writes. - Build response:
- If
request_delta_sincepresent:delta_range(request_delta_since, pre_merge_gen) - Otherwise: full CRDT
- Encode as
GossipEnvelopewith ownmy_gen= post-merge gen sign_and_seal()response for the sender’s KEM public key
- If
- Return 200 with
Content-Type: application/pkcs7-mimebody.
DB persist is intentionally absent from this hot path. The CRDT is the source of
truth; the DB is written on a 30-second timer (persist_crdt_cluster + persist_crdt_acme)
on a dedicated pool (crdt_db) to avoid contending with ACME writes.
Node Identity and Key Bootstrap
Each node’s identity is derived from its gossip signing key using RFC 7093 §2 Method 1:
node_id = base64url_nopad(SHA-256(BIT_STRING_value_of_signing_pub_key)[0..20])
(compute_aki_from_spki in src/ca/init.rs implements the derivation.)
On first startup, src/ca/init.rs generates:
- ML-KEM-768 key pair (PKCS8 DER + SPKI DER stored in
node_keystable) - ECDSA P-256 gossip signing key pair + self-signed certificate
AppState loads both key pairs into:
node_kem_priv— PKCS8 DER, used by the receiver to decapsulate inbound messagesnode_gossip_signing_priv— PEM, used by the sender to sign outbound messagesnode_gossip_signing_cert— DER, embedded in outboundSignedData
Enrollment: Before two nodes can exchange gossip, each must pre-pin the other’s keys
via POST /admin/gossip/register (requires administrator role):
{
"node_id": "…",
"gossip_url": "https://peer.acme.internal:8443",
"kem_public_key_b64u": "<SPKI DER, base64url>",
"gossip_signing_pub_key_b64u": "<SPKI DER, base64url>",
"gossip_signing_cert_b64u": "<X.509 DER, base64url>"
}
This call upserts a AkaNodeEntry into crdt.cluster_nodes and immediately persists it
to crdt_db. The entry then replicates to all other peers in the next gossip round.
Peer Discovery
The gossip loop builds its peer list each round by unioning two sources:
config.gossip.peers— statically configured URLs fromakamu.toml- Live values from
crdt.cluster_nodes— dynamically discovered peers enrolled viagossip/registerand propagated through the cluster
Duplicate URLs are eliminated with a HashSet. Stale per-peer state maps
(peer_last_gen, peer_response_gen) are pruned when a peer is no longer in the union.
Distributed Coordination
Two CRDT-based consensus mechanisms are built on top of gossip:
Order Processing Ownership
When a node finalises an ACME order it calls:
#![allow(unused)]
fn main() {
crdt.claim_order(order_id, node_id, now, ownership_ttl_secs)
}
This writes to order_owners: LwwMap<String, OrderOwner> and gossips within the
debounce window. Another node calling claim_order for the same order will fail
(return false) unless the incumbent’s claim has expired (claimed_at + ttl < now).
The TTL (default 150 s) is intentionally longer than a typical HTTP timeout so that a crashing node’s slot naturally expires and another node can take over, rather than requiring explicit release.
MTC Log Writer Election
Only one node should produce MTC checkpoints per CA. The election uses:
#![allow(unused)]
fn main() {
crdt.claim_mtc_writer(node_id, now, ownership_ttl_secs)
}
backed by mtc_writer: LwwRegister<MtcWriter>. The node that writes the highest
timestamp wins; ties break by lexicographic node_id. A live incumbent blocks
challengers until its claim lapses.
Both mechanisms rely on gossip propagating the claim before the TTL expires. With a 15-second gossip interval and a 150-second TTL, a claim survives at least nine missed rounds before another node can preempt.
Persistence and Recovery
| What | Where | When |
|---|---|---|
| ACME state (accounts, orders, …) | db pool | Periodic, every 30 s |
| Cluster state (nodes, ownership) | crdt_db pool | On gossip/register; periodic 30 s |
CRDT_GENERATION | Seeded from AkaCrdt::max_local_gen() at startup | DB load |
max_local_gen() walks all CRDT sub-collections and returns the highest stored
local_gen. Because local_gen is not serialised in CBOR (and therefore not in the DB
schema for merged entries), entries received via gossip and persisted to DB will have
local_gen = 0 after reload. This is safe: the startup seed ensures the post-load
CRDT_GENERATION is ≥ the highest write seen locally, so deltas computed after startup
will include any entries written before the restart.
Tombstone GC runs hourly in-place (not via snapshot) under a write lock, purging
tombstones older than tombstone_ttl_secs (default 7 days). The in-place approach
avoids a data-loss window that would exist if entries written between the snapshot read
and the purge application were lost in a crash.
Concurrency Invariants
| Resource | Lock | Usage |
|---|---|---|
AppState::crdt | tokio::sync::RwLock | Read: many concurrent ACME handlers; Write: hooks + gossip merge |
AppState::gossip_nonce_cache | std::sync::Mutex | Short critical section; one lock per inbound gossip request |
CRDT_GENERATION | AtomicU64 (AcqRel) | Bumped on every CRDT write; read after merge under Acquire ordering |
The gossip loop captures CRDT_GENERATION with Ordering::Acquire before building
envelopes and after each merge. Acquire ordering ensures all preceding writes (from
hooks) are visible before the generation value is read.
The receiver handler captures pre_merge_gen inside the write lock, not before
acquiring it, to prevent a race where a concurrent hook write increments the generation
between the read and the lock acquisition. This ensures delta_range(request_delta_since, pre_merge_gen) in the response does not include entries written after the merge.
Configuration Reference
[gossip]
# Peer admin base URLs (not ACME listener URLs).
peers = ["https://node2.acme.internal:8081"]
# Gossip interval (seconds). Default: 15.
interval_secs = 15
# Tombstone retention before GC (seconds). Default: 604800 = 7 days.
tombstone_ttl_secs = 604800
# Order/MTC-writer ownership TTL (seconds). Default: 150.
ownership_ttl_secs = 150
# Maximum age of an accepted gossip envelope (seconds). Default: 300.
gossip_envelope_max_age_secs = 300
# Clock skew tolerance (seconds). Default: 30.
clock_skew_tolerance_secs = 30
# Peers contacted per round. 0 = all (default). Use in large clusters.
fan_out = 0
See Cluster Setup and Gossip for deployment procedures.
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 | Leftmost 20 bytes of SHA-256 hash of the public key bit string (RFC 7093 §2 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)
There are two issuance entry points:
| Function | Used when |
|---|---|
issue_certificate(…) | Internal default path (tests, legacy callers) |
issue_with_params(ca, csr, params, not_before, not_after) | All order finalizations — receives CertificateParameters resolved from the profile registry |
Both return an IssuedCert with the same structure. issue_certificate is kept for backward compatibility with existing unit tests.
issue_with_params
ca::issue::issue_with_params(ca, csr, params, not_before_override, not_after_override) builds and signs an X.509 v3 end-entity certificate using the resolved CertificateParameters. It is the primary issuance path called by routes::finalize.
CertificateParameters is resolved at finalize time from ProfileRegistry::resolve(profile_name) when a profile is requested, or from CertificateParameters::from_ca(ca) when no profile is given. The latter reproduces the pre-profile default: digitalSignature KeyUsage, serverAuth EKU, and the [ca] validity/URL settings.
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 | Condition |
|---|---|---|
| BasicConstraints | No | Always; cA=FALSE |
| KeyUsage | Yes | Only when key_usage_bits ≠ 0 |
| ExtendedKeyUsage | No | Only when extended_key_usages is non-empty |
| SubjectKeyIdentifier | No | Always; RFC 7093 §2 Method 1 (SHA-256) of SPKI from CSR |
| AuthorityKeyIdentifier | No | Always; RFC 7093 §2 Method 1 (SHA-256) of CA’s SPKI |
| SubjectAlternativeName | No | Always; rebuilt from validated SANs |
| AuthorityInfoAccess (OCSP) | No | Only when ocsp_url is Some(_) |
| CRLDistributionPoints | No | Only when crl_url is Some(_) |
| CertificatePolicies | No | Only when certificate_policies is non-empty |
The default CertificateParameters::from_ca sets key_usage_bits = digitalSignature and extended_key_usages = ["server_auth"], so the KeyUsage and EKU extensions are always present in the default case.
Validity clamping
If not_before_override or not_after_override is given (from the newOrder payload), the validity window is set accordingly. The notBefore is granted a 5-minute clock-skew grace (it may be up to 5 minutes in the past). The total window cannot exceed params.validity_days from the moment of signing.
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).
build_crl is called by the CRL handler in src/routes/crl.rs, which serves both GET /ca/crl (legacy, defaults to the default CA) and GET /ca/{ca_id}/crl (per-CA). The handler uses a per-CA in-memory cache (AppState::crl_caches) keyed by CA ID:
- Fast path: if the cached DER is still within its TTL, it is returned immediately without a DB query or signing operation.
- Slow path: if the cache is empty or expired,
db::certs::list_revoked(ca_id)fetches all revoked certificates for that CA (bounded toMAX_CRL_ENTRIES = 500,000),build_crlsigns a fresh CRL, and the result is stored in the cache with a TTL ofcrl_next_update_secs / 2(minimum 30 seconds).
The POST /admin/ca/{id}/crl/force endpoint invalidates the per-CA cache so operators can force an immediate rebuild after a revocation.
Multi-CA support
When more than one [[ca]] entry is present in config.toml, main.rs runs the ca::init::load_or_generate loop for each entry and builds an IndexMap<String, Arc<CaState>> stored in AppState::cas.
CaState structure
Each CaState instance carries all information needed to issue and revoke certificates for one CA. The fields added for multi-CA deployments are:
| Field | Type | Description |
|---|---|---|
id | String | Unique CA identifier; matches CaConfig.id and appears as the {ca_id} URL segment. |
key_type | String | Key algorithm string from config (e.g. "ec:P-256", "rsa:2048"). Stored for logging and API responses. |
crl_next_update_secs | u64 | Validity window for signed CRLs in seconds; determines the CRL cache TTL. |
caa_identities | Vec<String> | CAA domain identities specific to this CA. When empty, falls back to [server].caa_identities. |
All other CaState fields (key, cert_der, hash_alg, validity_days, crl_url, ocsp_url, aki_bytes, enforce_validity_cap) exist in single-CA deployments as well.
CA lookup helpers
Two methods on AppState are the canonical way to obtain a CaState reference inside handlers:
#![allow(unused)]
fn main() {
// Look up a CA by its string ID; returns None for unknown IDs.
let ca: Option<&Arc<CaState>> = state.get_ca("rsa");
// Return the default CA (the one with is_default = true in config).
// Panics only if the server was constructed incorrectly.
let ca: &Arc<CaState> = state.default_ca();
}
The CaId extractor in src/routes/mod.rs resolves the per-request CA ID from the {ca_id} URL path parameter or falls back to state.default_ca_id. Handlers pass this string to state.get_ca(ca_id) when they need the full CaState.
Per-CA initialization loop
main.rs builds the multi-CA state with a loop:
#![allow(unused)]
fn main() {
for ca_cfg in &config.cas {
let (key, cert_der) = ca::init::load_or_generate(ca_cfg)?;
let ca_state = Arc::new(CaState {
id: ca_cfg.id.clone(),
key_type: ca_cfg.key_type.clone(),
// … other fields …
});
crl_caches_map.insert(ca_cfg.id.clone(), Default::default());
link_headers_map.insert(ca_cfg.id.clone(), build_link_header(&config, &ca_cfg.id));
cas_map.insert(ca_cfg.id.clone(), ca_state);
}
let default_ca_id = config.default_ca().id.clone();
}
Config::default_ca() returns the CaConfig entry with is_default = true. Config::validate() enforces that exactly one entry is default, all IDs are unique and lowercase, and no ID matches a reserved ACME path segment.
Profile filtering per CA
ProfileRegistry::profiles_for_ca(ca_id) returns only the profiles whose ca_ids list is empty (unrestricted) or explicitly contains ca_id. ProfileRegistry::resolve_for_ca(name, ca_id) applies the same filter to a single profile lookup. Handlers call these instead of the unfiltered all_profiles() / resolve() methods when operating on a specific CA.
Cross-signing
The admin API allows an operator to issue a cross-certificate: a CA certificate signed by one Akāmu CA for the public key of another CA (same-server or external). Cross-certificates are stored in the cross_certs table and served at /ca/{ca_id}/cross-certs.
Request body (CrossSignSubject)
POST /admin/ca/{id}/cross-sign accepts a JSON body with two variants (mutually exclusive; serde uses untagged dispatch):
// Variant 1 — same-server CA:
{ "subject_ca_id": "rsa", "validity_years": 5 }
// Variant 2 — external CA supplied as PEM:
{ "subject_cert_pem": "-----BEGIN CERTIFICATE-----\n…", "validity_years": 5 }
validity_years defaults to 5 when omitted. The {id} path parameter identifies the issuing CA; the subject_ca_id or subject_cert_pem identifies the subject whose public key is signed.
issue_ca_cert
#![allow(unused)]
fn main() {
pub fn issue_ca_cert(
issuer_ca: &CaState,
subject_cert_der: &[u8],
validity_years: u32,
) -> Result<IssuedCaCert, AcmeError>
}
Issues a CA certificate signed by issuer_ca for the public key extracted from subject_cert_der. The issued certificate carries:
| Extension | Value |
|---|---|
| BasicConstraints | Critical; cA=TRUE, pathLen=0 |
| KeyUsage | Critical; keyCertSign + cRLSign |
| SubjectKeyIdentifier | RFC 7093 §2 Method 1 hash of the subject CA’s SPKI |
| AuthorityKeyIdentifier | RFC 7093 §2 Method 1 hash of the issuer CA’s SPKI |
pathLen=0 limits the cross-certificate to a one-hop chain: the subject CA may sign end-entity certificates but may not sign further intermediate CAs. This is the narrowest cA=TRUE constraint that still allows the subject CA to fulfil its role while preventing the creation of unlimited additional CA layers beneath it.
Validity is computed as validity_years Julian years (365.25 days each) from now, with no 5-minute backdate clamp applied (cross-certificate issuance is operator-initiated, not time-sensitive).
The return value is an IssuedCaCert struct containing cert_der, cert_pem, the hex serial_number, Unix timestamps not_before / not_after, the subject_spki_der, and a subject_dn RFC 4514 string.
check_is_ca_cert
#![allow(unused)]
fn main() {
pub(crate) fn check_is_ca_cert(cert_der: &[u8], now: i64) -> Result<(), AcmeError>
}
Validates that a DER certificate has BasicConstraints cA=TRUE before it is accepted as a cross-signing subject. Uses ValidationProfile::Rfc5280 with ee_extension_policy = new_default_webpki_ca() so that CABF WebPKI end-entity restrictions are bypassed and cA=TRUE is required. The certificate is used as its own trust anchor (self-signed root CA scenario). Returns AcmeError::BadRequest when the check fails.
The admin cross-cert endpoint (POST /admin/ca/{id}/cross-sign) calls check_is_ca_cert before calling issue_ca_cert to reject requests that supply an end-entity certificate as the subject.
Challenge Validation
Challenge validation is the process by which Akāmu probes applicant servers to verify domain ownership. All validation is asynchronous and intentionally infallible — errors are recorded in the database rather than propagated.
Dispatch
The entry point is validation::validate_challenge in src/validation/mod.rs. It is called from routes::challenge::respond_challenge after marking the challenge as processing.
#![allow(unused)]
fn main() {
pub async fn validate_challenge(
state: &Arc<AppState>,
params: ChallengeParams<'_>,
) -> &'static str
}
ChallengeParams carries all per-challenge inputs as named fields:
#![allow(unused)]
fn main() {
pub struct ChallengeParams<'a> {
pub challenge_id: &'a str,
pub authz_id: &'a str,
pub order_id: &'a str,
pub chall_type: &'a str,
pub id_type: &'a str,
pub id_value: &'a str,
pub key_auth: &'a str,
pub token: &'a str,
pub onion_csr_der: Option<&'a [u8]>,
pub account_id: &'a str,
}
}
validate_challenge calls dispatch(...), which routes to one of five validators. email-reply-00 is handled separately — see email-reply-00 two-phase model below.
chall_type | Module | Function |
|---|---|---|
"http-01" | validation::http01 | validate(domain, token, key_auth, port, allow_private_ips, client) |
"dns-01" | validation::dns01 | validate(domain, key_auth, validate_dnssec, dot_server_name) |
"tls-alpn-01" | validation::tls_alpn01 | validate(id_type, domain, key_auth) |
"dns-persist-01" | validation::dns_persist_01 | validate(domain, account_uri, issuer_domains, resolver_addr, validate_dnssec, dot_server_name) |
"onion-csr-01" | validation::onion_csr_01 | validate(domain, csr_der, key_auth) |
| Any other | — | Returns AcmeError::IncorrectResponse |
Note:
email-reply-00does NOT appear in the dispatch table above. Client POST to the challenge URL triggerssend_challenge_email(Phase 1), not a network probe. Validation completes later via the webhook endpoint. See the section below for the full model.
email-reply-00 two-phase model
email-reply-00 (src/validation/email_reply_00.rs) works differently from all other
challenge types. Instead of a network probe, it uses a two-channel token delivered by
email, with completion driven by an inbound webhook rather than by validate_challenge.
Phase 1 — client POST triggers send_challenge_email
When the ACME client POSTs to the challenge URL, the route handler calls
email_reply_00::send_challenge_email(state, challenge_id, email_addr, token_part2_b64)
instead of spawning a validate_challenge task. The function:
- Generates
token-part1: 20 random bytes encoded as base64url (≥128 bits of entropy). - Generates a unique
Message-IDof the form<uuid@from-domain>. - Writes both values to
challenges.email_token_part1andchallenges.email_message_idin the database before invoking the send script, so a script failure leaves the token record in a recoverable state. - Invokes the configured
send_scriptwithenv_clear(), passingACME_TO,ACME_FROM,ACME_SUBJECT,ACME_MESSAGE_ID,ACME_AUTO_SUBMITTED, andACME_TOKEN_PART2as environment variables. The script must exit 0 on success. - If the script exits non-zero or times out, returns an error and the route handler
marks the challenge
"invalid". Otherwise the challenge stays"processing"and the client polls until Phase 2 completes it.
Phase 2 — webhook receives email reply via verify_response
The MTA that receives the applicant’s reply POSTs the parsed reply to
POST /acme/email-webhook. This endpoint does not use ACME JWS authentication;
instead it verifies the X-Akamu-Signature: sha256=<hex> header against the raw
request body using HMAC-SHA256 with email_challenge.webhook_hmac_secret.
After the HMAC check passes, email_reply_00::verify_response(state, payload) is called.
The payload is a WebhookPayload struct with fields from, in_reply_to, dkim_domain,
dkim_status, and body. The function:
- Looks up the challenge via
challenges.email_message_id = payload.in_reply_tousing the write pool (state.db) to avoid stale WAL reads that could miss Phase 1 writes. - Checks that the challenge is in
"processing"state. - Checks that the authorization has not expired.
- Verifies that
payload.frommatches the identifier’s email address (local-part case-sensitive, domain case-insensitive per RFC 5321 §2.4). - Verifies that
payload.dkim_domainmatches the domain part ofpayload.from(case-insensitive), enforcing RFC 8823 §3.2. - Verifies that
payload.dkim_statusis"pass"(case-insensitive to accommodate MTAs that report"Pass"). - Extracts the base64url payload between
-----BEGIN ACME RESPONSE-----/-----END ACME RESPONSE-----delimiters; rejects if absent, whitespace-only, non-ASCII, or longer than 512 bytes. - Computes the expected digest:
SHA-256(token-part1 || token-part2 || "." || thumbprint)where both token parts are the stored base64url strings andthumbprintis the account’s JWK thumbprint (fromaccounts.jwk_thumbprint). - Compares the decoded response bytes with the digest using
constant_time_eq. - On match, calls
on_valid; on any mismatch or error, callson_invalid.
The webhook handler always returns HTTP 200 regardless of outcome (to prevent oracle attacks on the HMAC or challenge state).
sequenceDiagram
participant Client as ACME Client
participant H as Route Handler
participant E as send_challenge_email
participant Script as send_script
participant Inbox as Applicant Inbox
participant MTA as Inbound MTA
participant W as Webhook Handler
participant V as verify_response
participant DB as Database
Client->>H: POST /acme/.../chall/... (email-reply-00)
H->>E: send_challenge_email(challenge_id, email, token_part2)
E->>DB: INSERT email_token_part1, email_message_id
E->>Script: exec send_script (ACME_TO, ACME_FROM, ACME_SUBJECT, ...)
Script->>Inbox: delivers challenge email
E-->>H: Ok(())
H->>DB: challenge status = processing
H-->>Client: 200 processing
Note over Client: Client polls authorization URL
Inbox->>MTA: applicant replies to challenge email
MTA->>W: POST /acme/email-webhook (X-Akamu-Signature: sha256=...)
W->>W: verify HMAC-SHA256
W->>V: verify_response(payload)
V->>DB: lookup challenge by email_message_id
V->>V: verify From, DKIM domain, DKIM status, response digest
alt digest matches
V->>DB: challenge/authz/order = valid
else mismatch or error
V->>DB: challenge/authz/order = invalid
end
W-->>MTA: 200 OK
dns-persist-01 account pre-check
For dns-persist-01 only, validate_challenge performs an account status check before calling dispatch. Because the TXT record is long-lived and may have been provisioned weeks before the order is placed, the account could have been deactivated or revoked in the intervening time. The pre-check queries the database:
SELECT status FROM accounts WHERE id = ?
- If the account status is
"valid", dispatch proceeds normally. - If the status is anything else (e.g.,
"deactivated","revoked"),on_invalidis called immediately withAcmeError::Unauthorizedand the challenge is markedinvalidwithout any DNS query. - If the database query itself fails,
on_invalidis called withAcmeError::Internal.
After dispatch returns, validate_challenge calls either on_valid or on_invalid to update the database.
Validation flow
sequenceDiagram
participant Client as ACME Client
participant H as Route Handler
participant V as validate_challenge
participant D as dispatch
participant Ext as Applicant Server
participant DB as SQLite
Client->>H: POST /acme/.../chall/...
H->>DB: challenge status = processing
H->>V: tokio::spawn
H-->>Client: 200 processing
V->>D: chall_type, domain, key_auth
alt http-01
D->>Ext: GET /.well-known/acme-challenge/TOKEN
Ext-->>D: 200 key_authorization body
else dns-01
D->>Ext: TXT _acme-challenge.DOMAIN
Ext-->>D: TXT record value
else tls-alpn-01
D->>Ext: TLS connect port 443, ALPN acme-tls/1
Ext-->>D: Certificate with id-pe-acmeIdentifier
else dns-persist-01
D->>Ext: TXT _validation-persist.DOMAIN
Ext-->>D: TXT record value
end
alt probe succeeded
V->>DB: BEGIN TRANSACTION
V->>DB: challenge status = valid
V->>DB: authorization status = valid
V->>DB: order status = ready
V->>DB: COMMIT
else probe failed
V->>DB: BEGIN TRANSACTION
V->>DB: challenge status = invalid
V->>DB: authorization status = invalid
V->>DB: order status = invalid
V->>DB: COMMIT
end
Note over Client: Client polls authorization URL
Background execution
Validation runs inside a tokio::spawn task, not in the request handler’s async context:
#![allow(unused)]
fn main() {
let handle = tokio::spawn(async move {
validation::validate_challenge(
&state_clone,
ChallengeParams {
challenge_id: &challenge_id,
authz_id: &authz_id,
order_id: &order_id,
chall_type: &chall_type,
id_type: &id_type,
id_value: &id_value,
key_auth: &key_auth,
token: &token,
onion_csr_der: onion_csr_der.as_deref(), // Some(der) for onion-csr-01, None otherwise
account_id: &account_id,
},
)
.await;
});
// Observer task: log panics without letting them go silent.
tokio::spawn(async move {
if let Err(e) = handle.await {
tracing::error!(
"challenge {challenge_id_for_log}: validation task panicked: {e:?}"
);
}
});
}
The challenge handler returns the processing status immediately. The client must poll the authorization URL to detect completion.
The observer task pattern ensures that panics inside the validation task are logged via tracing::error! rather than silently discarded (which would happen if the JoinHandle were simply dropped).
State cascade diagram
stateDiagram-v2
direction TB
state "Challenge" as chall {
[*] --> ch_pending
ch_pending --> ch_processing : client responds
ch_processing --> ch_valid : probe OK
ch_processing --> ch_invalid : probe failed
}
state "Authorization" as authz {
[*] --> az_pending
az_pending --> az_valid : challenge valid
az_pending --> az_invalid : challenge invalid
}
state "Order" as ord {
[*] --> or_pending
or_pending --> or_ready : all authzs valid
or_pending --> or_invalid : any authz invalid
}
chall --> authz : atomic DB transaction
authz --> ord : atomic DB transaction
State transitions on success (on_valid)
All three updates run inside a single database transaction:
UPDATE challenges SET status = 'valid', validated = <now> WHERE id = <challenge_id>UPDATE authorizations SET status = 'valid' WHERE id = <authz_id>- Conditionally advance the order to
readyusing a singleUPDATE orders SET status = 'ready' WHERE id = <order_id> AND NOT EXISTS (SELECT 1 FROM authorizations WHERE order_id = <order_id> AND status != 'valid'). This replaces the previous SELECT COUNT(*) + conditional UPDATE pattern; it saves a round-trip on the common single-identifier path.
If any step fails (e.g., a database error), a warning is logged and the transaction is rolled back. The challenge remains in processing status.
State transitions on failure (on_invalid)
All three updates run inside a single database transaction:
UPDATE challenges SET status = 'invalid', error = <json> WHERE id = <challenge_id>UPDATE authorizations SET status = 'invalid' WHERE id = <authz_id>- Look up the parent order ID and mark the order
invalid.
If the transaction fails (e.g., a database error), a warning is logged.
http-01 validator (src/validation/http01.rs)
Uses hyper (already a transitive dependency via axum) as the HTTP client.
Validation steps:
- Construct the URL
http://<domain>/.well-known/acme-challenge/<token>. - SSRF guard — initial target: before making any connection, resolve the target host and reject it if any returned address is in a private, loopback, link-local, or otherwise non-globally-routable range (RFC 1918,
169.254.0.0/16,::1,fe80::/10,fc00::/7, etc.). This guard applies to both IP literals and hostnames. Bypassed only whenhttp_validation_allow_private_ips = true. - Send a GET request via
hyper_util::client::legacy::Client. - Check the response status. 3xx redirects are followed (up to 10 hops, including redirects to HTTPS targets).
- SSRF guard — redirect targets: each redirect target is also subjected to the same IP check before following it.
- Check that the final response status is 2xx.
- Read up to 1 MiB of the response body.
- Decode as UTF-8 and trim whitespace.
- Compare with
key_auth. Any mismatch returnsAcmeError::IncorrectResponse.
Error mapping:
- Connection or parse failure →
AcmeError::Connection - Non-2xx status →
AcmeError::IncorrectResponse - Body exceeds 1 MiB →
AcmeError::IncorrectResponse - Key auth mismatch →
AcmeError::IncorrectResponse - Initial or redirect target resolves to blocked IP →
AcmeError::IncorrectResponse
dns-01 validator (src/validation/dns01.rs)
Uses crate::dns::dns_query backed by hickory_resolver. DNS-over-TLS (DoT) is supported when server.dns_dot_server_name is set.
Validation steps:
- Strip any leading
*.prefix from the domain (RFC 8555 §8.4 requires this for wildcard orders). - Construct the query name
_acme-challenge.<base_domain>. - Compute
expected = base64url(SHA-256(key_auth)). - Perform a TXT record lookup via
crate::dns::dns_query(UDP, DNSSEC-aware, or DoT depending on config). - For each TXT record, join all character-strings (TXT records may be split) and compare the trimmed result with
expected. - If at least one record matches, return
Ok(()).
Error mapping:
- DNS lookup failure →
AcmeError::Dns - No matching TXT record →
AcmeError::IncorrectResponse
The inner function validate_with_resolver(domain, key_auth, resolver_addr, validate_dnssec, dot_server_name) accepts explicit resolver settings for testability. Unit tests provide a local UDP stub DNS server.
tls-alpn-01 validator (src/validation/tls_alpn01.rs)
Uses rustls and tokio-rustls. Supports both DNS identifiers (RFC 8737) and IP identifiers (RFC 8738).
Validation steps:
- Compute
expected_hash = SHA-256(key_auth). - For IP identifiers (RFC 8738 §4): convert the IP address to its reverse-DNS form for SNI (
1.2.3.4→4.3.2.1.in-addr.arpa; IPv6 uses the nibble-expanded.ip6.arpaform). For DNS identifiers, the SNI is the identifier value directly. - Build a
rustls::ClientConfigthat:- Accepts any server certificate without chain validation (
AcceptAnyCertcustom verifier). - Advertises only the ALPN protocol
"acme-tls/1". - Supports both TLS 1.2 and TLS 1.3.
- Accepts any server certificate without chain validation (
- TCP-connect to
<id_value>:443(the raw IP or DNS name, not the reverse-DNS SNI). - Perform the TLS handshake with the SNI from step 2.
- Extract the end-entity certificate from the peer certificate chain.
- Call
verify_acme_cert(id_type, id_value, cert_der, &expected_hash).
verify_acme_cert walks the certificate DER manually:
- Finds the
id-pe-acmeIdentifierextension (OID1.3.6.1.5.5.7.1.31). - Checks it is marked critical.
- Checks its value is
OCTET STRING(32 bytes)equal toexpected_hash. - Finds the SubjectAlternativeName extension.
- For DNS identifiers: checks it contains
id_valueas adNSName(tag0x82). - For IP identifiers: checks it contains
id_valueas aniPAddress(tag0x87) encoded as 4 (IPv4) or 16 (IPv6) raw bytes.
The DER walker (find_extension_value) navigates the Certificate → TBSCertificate → Extensions structure using hand-written TLV parsing helpers (read_tlv, decode_length, strip_sequence, etc.). This approach avoids requiring the full synta decoder for a security-critical path.
Error mapping:
- Invalid server name or IP-to-reverse-DNS conversion failure →
AcmeError::Tls - TCP connect failure →
AcmeError::Connection - TLS handshake failure →
AcmeError::Tls - Missing or non-critical
id-pe-acmeIdentifier→AcmeError::IncorrectResponse - Hash mismatch →
AcmeError::IncorrectResponse - Missing SAN →
AcmeError::IncorrectResponse - Identifier not found in SAN →
AcmeError::IncorrectResponse
AcceptAnyCert
The AcceptAnyCert struct implements rustls::client::danger::ServerCertVerifier. It returns Ok(ServerCertVerified::assertion()) unconditionally for every certificate. Chain validation is intentionally bypassed because the tls-alpn-01 certificate is self-signed and issued by the ACME client for validation purposes only. All semantic checks are performed by verify_acme_cert instead.
dns-persist-01 validator (src/validation/dns_persist_01.rs)
Uses crate::dns::dns_query backed by hickory_resolver. The resolver address comes from server.dns_persist01_resolver_addr when set; if absent it falls back to server.dns_resolver_addr, and if that is also absent it uses the system default. DNS-over-TLS is supported when server.dns_dot_server_name is set.
Unlike the other challenge types, dns-persist-01 does not use a token · thumbprint key authorization. The key_auth value passed to the validator is the requesting account’s full URI (constructed as <base_url>/acme/account/<account_id> in routes::challenge). This URI is matched directly against the accounturi= field in the TXT record.
Validation steps:
- Strip any leading
*.prefix from the domain; record whether the order is a wildcard. - Construct the query name
_validation-persist.<base_domain>. - Perform a TXT record lookup via
crate::dns::dns_query. - For each TXT record value, call
matches_record(value, issuer_domains, account_uri, is_wildcard, now). - If at least one record matches, return
Ok(()).
matches_record
matches_record is pub(crate) and is unit-tested independently of the DNS stack.
#![allow(unused)]
fn main() {
pub(crate) fn matches_record(
raw: &str,
expected_issuers: &[&str],
expected_account_uri: &str,
require_wildcard_policy: bool,
now: i64,
) -> bool
}
It splits the raw TXT value on ; and applies the following checks in order:
- The first token (trimmed, trailing dot stripped, lowercased) equals any entry in
expected_issuers(same normalization applied). Multiple issuer domains are supported. - Among the remaining tokens,
accounturi=<uri>is present and the URI matchesexpected_account_uriexactly (case-sensitive). - If
require_wildcard_policyis true,policy=wildcardis present among the tokens. - If a
persistUntil=<ts>token is present,parse_persist_until(ts)returns a Unix timestamp that is greater than or equal tonow.
Unknown tokens are silently ignored. The function returns false as soon as any required condition is not met.
parse_persist_until
A pure-Rust, zero-dependency parser for the YYYY-MM-DDTHH:MM:SSZ timestamp format (lowercase z is also accepted). It performs the proleptic Gregorian day count from the Unix epoch without using any external date/time crate. Returns None for malformed input (wrong separators, out-of-range fields, missing Z suffix).
Error mapping for dns-persist-01:
- DNS lookup failure →
AcmeError::Dns - No matching TXT record →
AcmeError::IncorrectResponse
onion-csr-01 validator (src/validation/onion_csr_01.rs)
Implements server-side validation for hidden-service domain ownership per RFC 9799 §3.2. The client submits a DER-encoded CSR in the challenge response payload ({"csr": "<base64url>"}); the handler decodes it and passes it to this validator via onion_csr_der.
Validation steps:
- Decode hidden-service public key: extract the 32-byte Ed25519 public key from the v3
.onionaddress label. The label is 56 base32 characters encoding 35 bytes:[pubkey(32)] || [checksum(2)] || [version=0x03(1)]. Version byte must be0x03; v2 addresses (16-character label) are rejected. - Parse CSR: decode
csr_deras a DER PKCS#10CertificationRequestusing synta. - Re-encode CRI: re-encode the
CertificationRequestInfoto obtain the exact bytes that were signed. - Verify CSR self-signature: call
BackendPublicKey::verify_signatureon the CRI bytes using the CSR’s own public key. ReturnsIncorrectResponseif invalid. - Verify
cabf-onion-csr-nonceextension (OID2.23.140.41): the extension value must be a DERUTF8String(orIA5String/ raw bytes) whose decoded string equalskey_auth(token.thumbprint). ReturnsIncorrectResponseif absent or mismatched. - Verify hidden-service Ed25519 signature over the CRI bytes using the public key from step 1. Two cases are accepted:
- If the outer CSR signature (the
BIT STRINGat the top level) verifies with the hidden-service Ed25519 key, the challenge passes. - Otherwise, if the CSR’s own public key matches the hidden-service key, the self-signature from step 4 already proves key control and no separate HS signature is required.
If neither condition holds,
IncorrectResponseis returned.
- If the outer CSR signature (the
- Verify SAN: check that the
.oniondomain appears as adNSNamein the CSR’sSubjectAlternativeNameextension.
The validate_onion_v3(domain) helper (exported as pub) can be called by the order/authorization handler to reject non-v3 .onion domains before creating a challenge.
Error mapping:
- Cannot decode
.onionpublic key →AcmeError::IncorrectResponse - CSR parse failure →
AcmeError::IncorrectResponse - CSR self-signature invalid →
AcmeError::IncorrectResponse - Missing or mismatched
cabf-onion-csr-nonceextension →AcmeError::IncorrectResponse - Hidden-service signature verification failed →
AcmeError::IncorrectResponse - Domain not found in CSR SAN →
AcmeError::IncorrectResponse
MTC Implementation
This chapter describes the internal design of the Merkle Tree Certificate (MTC) log integration: how certificates are appended, how checkpoints are produced, and the concurrency model.
Log storage
The log file is a binary file managed by synta_mtc::storage::DiskBackedLog. Entries are written as fixed-size leaf hashes in leaf-order; the hash size (32, 48, or 64 bytes) is determined by [mtc].hash_alg and is stored in the log file’s header at creation time. The hash function includes Merkle tree domain separation (a \x00 prefix byte) 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 at startup (src/mtc/log.rs::open_or_create).
A brand-new log is immediately seeded with a null_entry at index 0 (required by §5.3 of the MTC draft so that no real certificate ever receives log index 0 as its serial number).
Root-hash cache
DiskBackedLog is wrapped in a CachedLog struct (src/mtc/log.rs) which adds an in-memory (tree_size, root_hash) cache. Because compute_root is an O(N) disk read, the cache avoids repeated traversals when the tree has not grown since the last checkpoint or HTTP read. Cache coherence rules:
- Warmed by
compute_root()andtree_size_and_root(). - Invalidated by
append_leaf()(any write to the log).
Appending a certificate
Appending a certificate leaf involves:
- Parsing the DER-encoded certificate to extract the
TBSCertificate. - Building a
TBSCertificateLogEntrymanually from the parsed TBS fields, substituting the LogID issuer DN for the original CA issuer DN. The LogID issuer DN is pre-computed at startup bybuild_logid_issuer_dn_der(insrc/mtc/standalone.rs) and passed toappend_cert_to_logas thelogid_issuer_dn_derparameter. This substitution ensures the Merkle leaf hash matches what a verifier computes from the standalone certificate’s TBS (which has the LogID as its issuer, not the CA DN). - Wrapping the entry as a
MerkleTreeCertEntry::TbsCertEntryand computing the Merkle leaf hash viahash_log_entry(algorithm, &entry). This function TLS wire-encodes the entry (per spec §4.2) and then hashes it with the\x00domain separation prefix. - Appending the fixed-size leaf hash (32, 48, or 64 bytes depending on
[mtc].hash_alg) to the log file under atokio::sync::Mutexguard.
Steps 1–3 run in a tokio::task::spawn_blocking thread to avoid blocking the async executor with CPU-bound encoding work. Step 4 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.
Concurrency model
DiskBackedLog is not thread-safe internally. The server wraps it in a CachedLog struct, which is then placed behind a tokio::sync::Mutex (the SharedLog type alias in src/mtc/log.rs is Arc<Mutex<CachedLog>>). 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. The server enforces single-process exclusive access via an advisory flock(LOCK_EX|LOCK_NB) on a sidecar lock file at {log_path}.lock (src/mtc/log.rs::acquire_log_lock). The lock file handle is stored in MtcState::_log_lock for the lifetime of the process; the kernel releases the lock automatically on exit or drop. A second process attempting to open the same log will receive an immediate error rather than blocking.
Checkpoint production
The checkpoint background task (src/mtc/checkpoint.rs) fires every checkpoint_interval_secs seconds. If the log has grown since the last checkpoint, produce_checkpoint runs the following phases:
Phase 1 (blocking thread):
- Acquires the
SharedLogmutex viablocking_lock()and reads the current tree size and computes the Merkle root viacompute_root(which also warms the root cache). - Generates Merkle inclusion proofs for all certificates that are newly covered by the checkpoint.
- Builds and DER-encodes a
Checkpointstructure (per §6.2 of the MTC draft). - Signs the
CheckpointDER with the MTC signing key.
Async phase:
- Inserts a row into the
mtc_checkpointsdatabase table. - Contacts all configured external cosigners in parallel to gather
SubtreeSignatureresponses.
Phase 2 (blocking thread):
- Builds
StandaloneCertificateDER blobs for each newly covered certificate (with cosignatures embedded) and persists them to thecertificates.mtc_standalone_derdatabase column.
Checkpoints are idempotent: if the tree size has not grown the task is a no-op.
After each new checkpoint is stored, rows beyond the checkpoint_retention_count limit are pruned from mtc_checkpoints. Associated cosignature rows in mtc_cosignatures are deleted via the ON DELETE CASCADE foreign-key constraint.
Cosignature gathering
After each checkpoint is produced, src/mtc/cosign.rs contacts all configured external cosigners in parallel. For each cosigner:
- An HTTPS POST is made with
Content-Type: application/octet-streamcarrying the DER-encodedCheckpoint. - The cosigner is expected to return a DER-encoded
SubtreeSignaturewith HTTP 200. - Each request uses a 30-second timeout.
- Failures are logged and skipped; partial success is acceptable.
The CosignerClient struct (one per [[mtc.cosigners]] entry) is built once at server startup. This surfaces misconfigured cosigners at startup rather than silently at checkpoint time, and preserves the HTTP connection pool across checkpoint intervals.
When cosigner_id_cert_pem is set for a cosigner, an AkamuCosignerVerifier is built at startup and stored inside the CosignerClient. At checkpoint time, the received SubtreeSignature is verified before being stored:
- OID identity check: When
trust_anchor_idis also configured, theSubtreeSignature.cosignerfield (aTrustAnchorID ::= OBJECT IDENTIFIERper draft-04 §4.1) is compared against the expected OID. A mismatch causes the signature to be rejected. - Cryptographic check: The public key is extracted from the
cosigner_id_cert_pemPEM and used for signature verification. Verification usessynta_mtc::cosignature::validate_cosignature_quorum_with_crypto, which builds the TLS-framedCosignedMessage(per §5.4.1 of the MTC draft) internally from the checkpoint and signature fields, then delegates the actual signature check toOpensslSignatureVerifier.
Setting trust_anchor_id without cosigner_id_cert_pem is a hard startup error: OID-only verification provides no cryptographic assurance. When neither field is set, cosignatures are accepted without verification and a warning is logged.
Each SubtreeSignature is stored in the mtc_cosignatures table, keyed by checkpoint sequence number and cosigner URL.
Standalone certificate construction
There are two code paths that produce a StandaloneCertificate:
Checkpoint-driven (background): After cosignatures are gathered, produce_checkpoint in src/mtc/checkpoint.rs builds a StandaloneCertificate (§6.1) for every certificate covered by the new checkpoint that does not already have one. The DER is stored in certificates.mtc_standalone_der. This is the path for ordinary X.509 certificates issued with [mtc] enabled — logging is asynchronous and the standalone certificate is built during the next checkpoint cycle.
Profile-driven (synchronous): When a builtin profile has issue_as = "mtc", the finalize handler (src/routes/finalize.rs) builds the StandaloneCertificate synchronously during the request itself, before the database transaction:
- The X.509
TBSCertificateis issued as normal. - The certificate is appended to the MTC log (synchronously, not via a background task) to obtain the leaf index immediately.
crate::mtc::standalone::build_standalone_derconstructs theStandaloneCertificateDER.- The DER is stored in the
certificates.mtc_standalone_dercolumn;certificates.pemstores a PEM-armored wrapper with theSTANDALONE MTC CERTIFICATEmarker so the download handler can detect the format. certificates.mtc_log_indexis set to the leaf index (notNULL), so the regular checkpoint-driven path skips this certificate.
The download handler (src/routes/certificate.rs::cert_pem_response) detects MTC certificates by the PEM marker prefix and returns the raw DER with Content-Type: application/pkix-cert instead of the PEM bundle.
In both paths the standalone certificate embeds:
- The
TBSCertificatefrom the issued certificate. - A Merkle inclusion proof (computed from the leaf hashes under the
SharedLogmutex). - A signature from the MTC signing key.
- Any gathered
SubtreeSignatureentries from external cosigners (empty slice for profile-driven issuance, which does not wait for cosigners).
Landmark construction
The landmark background task (src/mtc/landmark.rs) fires every landmark_interval_secs seconds. If the tree has grown since the last landmark:
- A new row is inserted into the
mtc_landmarkstable with the current tree size and a monotonically increasingsequence_no. - A representative certificate (any leaf with
mtc_log_index < tree_size) is selected. - All leaf hashes up to
tree_sizeare read from the log under the mutex. - A
LandmarkCertificateis built usingLandmarkCertificateBuilder: it embeds the representativeTBSCertificate, the leaf’s log index, all leaf hashes (for internal inclusion proof generation), theLandmarkID(log identity + frozen tree size), and a signature from the MTC signing key. - The DER-encoded certificate is stored in the
cert_dercolumn of the landmark row.
After each new landmark is built, rows beyond max_active_landmarks are pruned by sequence number.
Root computation
The Merkle root is computed from all leaf hashes using the RFC 6962 / synta-mtc binary tree algorithm:
- For a log with zero leaves the root is undefined.
- For a log with one or more leaves the root is the Merkle root of all leaf hashes, computed using the configured
[mtc].hash_algalgorithm.
The computation is performed under the SharedLog mutex and is exposed to handlers by src/mtc/log.rs::proof_and_tree_size, tree_size_and_root, and tree_size. The tree_size_and_root function reads both values under the same lock guard so that treeSize and rootHash in HTTP responses are always consistent; it also leverages the CachedLog root cache to avoid repeated O(N) traversals.
HTTP endpoints
The following read-only endpoints are served under /acme/mtc/ and return 404 when MTC is disabled:
| Endpoint | Handler |
|---|---|
GET /acme/mtc/tree-size | mtc::get_tree_size |
GET /acme/mtc/root | mtc::get_root |
GET /acme/mtc/inclusion-proof/{cert_id} | mtc::get_inclusion_proof |
GET /acme/mtc/cert/{cert_id}/standalone | mtc::get_standalone |
GET /acme/mtc/landmarks | mtc::get_landmarks |
GET /acme/mtc/landmarks/{seq}/cert | mtc::get_landmark_cert |
GET /acme/mtc/tlog/checkpoint | mtc::get_tlog_checkpoint |
GET /acme/mtc/tlog/tile/{*path} | mtc::get_tlog_tile |
GET /acme/mtc/tlog/cosignature | mtc::get_tlog_cosignature |
GET /acme/mtc/consistency-proof | mtc::get_consistency_proof |
GET /acme/mtc/subtree-root | mtc::get_subtree_root |
GET /acme/mtc/revoked-ranges | mtc::get_revoked_ranges |
C2SP tlog-tiles module (src/mtc/tlog.rs)
src/mtc/tlog.rs implements the C2SP tlog-tiles, signed-note, and tlog-cosignature specifications on top of the existing DiskBackedLog storage.
Signed-note key IDs
Key IDs are 4-byte prefixes derived from SHA-256 of a type-specific input:
| Key type | Role | C2SP type byte | Key ID formula |
|---|---|---|---|
| Ed25519 | Log operator | 0x01 | SHA-256(name | LF | 0x01 | 32-byte pubkey)[:4] |
| ECDSA | Log operator or cosigner | 0x02 | SHA-256(SPKI_DER)[:4] |
| Ed25519 | Cosigner | 0x04 | SHA-256(name | LF | 0x04 | 32-byte pubkey)[:4] |
| (RFC 6962 CT) | CT log | 0x05 | per c2sp.org/static-ct-api — not produced by Akāmu |
| ML-DSA-44 | Cosigner | 0x06 | SHA-256(name | LF | 0x06 | 1312-byte pubkey)[:4] |
ML-DSA-44 as a primary log operator key and Ed448/RSA keys are rejected — they have no assigned C2SP signed-note type byte.
Hash tiles
Level-0 tiles are leaf hashes read directly from the DiskBackedLog via the read_hash_range wrapper in src/mtc/log.rs. Level-L tiles are computed by applying MTH (RFC 9162 §2) recursively over 256 level-(L-1) entries. Partial tiles (.p/{width} suffix in the URL) return fewer than 256 entries when the log ends mid-tile.
HTTP route wiring
The three tlog-tiles endpoints are registered in src/routes/mod.rs and dispatched to handlers in src/routes/mtc.rs:
| Endpoint | Handler |
|---|---|
GET /acme/mtc/tlog/checkpoint | mtc::get_tlog_checkpoint |
GET /acme/mtc/tlog/tile/{*path} | mtc::get_tlog_tile |
GET /acme/mtc/tlog/cosignature | mtc::get_tlog_cosignature |
The log origin string used in checkpoint notes is {base_url}/acme/mtc/tlog.
RFC Compliance Internals
This chapter documents how specific RFC requirements are implemented in code. For the protocol-facing view — which algorithms are accepted, which challenge types are offered, error codes, and wire formats — see ACME Protocol Reference.
Topics covered here: JWS algorithm dispatch and ML-DSA verification internals, challenge validation code paths, DER encoding helpers, EAB constant-time HMAC verification, pre-issuance linting, ACME STAR and ARI source layout, and RFC 9115 CSR template validation.
JWS algorithm dispatch
All ACME POST requests are verified through the JWS path in crates/akamu-jose/src/jws.rs. The accepted alg values are listed in ACME Protocol Reference § JWS algorithm support. Any other alg returns JoseError::UnsupportedAlgorithm.
ECDSA signatures arrive as IEEE P1363 encoding (raw r||s) on the wire; the server converts them to DER before passing to the OpenSSL backend.
The JWK thumbprint computation (RFC 7638) supports key types RSA, EC, OKP, and AKP (ML-DSA). The canonical JSON fields and their order per key type are implemented in crates/akamu-jose/src/jwk.rs.
ML-DSA JWS verification internals (RFC 9964)
After detecting an ML-DSA-* algorithm in the protected header alg field, the server checks the raw signature length (see protocol reference for the byte counts). A length mismatch causes an immediate badSignatureAlgorithm error without attempting the verify call — this prevents malformed input from reaching the OpenSSL backend.
Per RFC 9964 §4, the signing context must be an empty byte string. The server calls:
#![allow(unused)]
fn main() {
BackendPublicKey::verify_ml_dsa_with_context(
message_bytes,
signature_bytes,
&[], // empty context
)
}
This is dispatched from crates/akamu-jose/src/jws.rs after the algorithm is detected.
Challenge validation code paths
The src/validation/mod.rs dispatch table routes each challenge type to its validator. The supported types and identifier constraints are listed in ACME Protocol Reference § Supported challenge types.
Any unrecognised challenge type returns AcmeError::IncorrectResponse("unsupported challenge type: …").
dns-persist-01 safety check
Beyond the standard TXT record content check, the server performs an extra safety step at validation time: it queries the account status from the database and rejects with unauthorized if the account is not in the valid state. This prevents a deactivated or revoked account from continuing to use a stale persistent TXT record that was provisioned before deactivation.
The separate per-challenge DNS resolver (dns_persist01_resolver_addr) is configured independently of the general resolver.
onion-csr-01 validation steps (RFC 9799)
src/validation/onion_csr_01.rs performs the following checks on the client-submitted CSR:
- Decodes the 32-byte Ed25519 public key from the
.onionlabel (base32, 56 chars, version byte0x03). - Parses the DER CSR and verifies its self-signature.
- Extracts the
cabf-onion-csr-nonceextension (OID2.23.140.41) and compares its value to the key authorization. - Verifies the hidden-service Ed25519 signature over the
CertificationRequestInfoDER. - Confirms the CSR SAN contains the
.oniondomain.
RFC 9799 §2 prohibits v2 .onion addresses (16-character label); this is enforced in both the new-order and pre-authorization paths.
tls-alpn-01 SNI encoding for IP identifiers (RFC 8738 §4)
For IP identifier challenges, the TLS SNI is the reverse-DNS form of the IP address (arpa. suffix), and the acmeIdentifier extension carries an iPAddress GeneralName rather than a dNSName. The SAN type switch is performed in src/validation/tls_alpn_01.rs.
ACME STAR implementation (RFC 8739)
The STAR protocol spans several source files:
src/routes/order.rs: accepts theauto-renewalobject in the new-order payload (§3.1.1), storesstart-date,end-date,lifetime,lifetime-adjust, andallow-certificate-geton the order row.src/routes/finalize.rs: issues the first STAR certificate.src/star.rs: background reissuance task that issues renewals automatically untilend-dateis reached or the order is canceled.src/routes/star_cert.rs: serves the most recent certificate; includesCert-Not-BeforeandCert-Not-Afterheaders per RFC 8739 §3.3.
The server-level star_allow_certificate_get config flag gates unauthenticated certificate retrieval globally (RFC 8739 §3.1.3).
For the endpoint URLs and request/response shape, see ACME Protocol Reference § ACME STAR.
Renewal Info / ARI implementation (RFC 9773)
The endpoint is implemented in src/routes/renewal_info.rs. The handler:
- Validates the AKI component against the CA’s key identifier; returns 404 on mismatch.
- Looks up the certificate by
cert_idin the database. - Computes the
suggestedWindow: if explicit window fields are set in the database (operator override), uses them; otherwise defaults to start at two-thirds of the certificate lifetime, end one day before expiry. - Includes
explanationURLifari_explanation_urlis configured. - Sets
Retry-Aftertoari_retry_after_secs(RFC 9773 §4.3).
For the cert_id format and response shape, see ACME Protocol Reference § Renewal Information / ARI.
DER structures
Serial number encoding
Leaf certificate serials are 16 random bytes from getrandom. The high bit of the first byte is cleared (bitwise AND with 0x7f) to ensure the value is a non-negative DER INTEGER per RFC 5280 §4.1.2.2.
In src/ca/revoke.rs, encode_integer_der(n: u64) handles DER INTEGER encoding for the CRL Number extension. It:
- Converts the
u64to 8 big-endian bytes. - Strips leading zero bytes (keeping at least one).
- Prepends
0x00if the high bit of the first remaining byte is set (two’s complement positive padding). - Prepends the DER INTEGER tag
0x02and the length byte.
n=127 → 02 01 7f
n=128 → 02 02 00 80 (zero-pad because high bit set)
n=256 → 02 02 01 00
CSR extensions: manual DER walking
The extensionRequest attribute (OID 1.2.840.113549.1.9.14) inside a PKCS#10 CSR is nested in a SET OF ANY, which synta’s high-level decoder does not unwrap automatically. src/ca/csr.rs walks the attribute bytes manually using read_tlv, decode_length, and strip_sequence helpers to locate and extract the extension list. This is deliberate: the alternative of using a fully-general ASN.1 parser for this path would add complexity with no benefit.
EAB implementation internals
See EAB Internals for the database schema, insert_if_absent, and the two-step verification pipeline (parse_eab_kid + verify_eab_jws). For the EAB JWS wire format and algorithm table, see ACME Protocol Reference § EAB JWS wire format.
EAB HMAC verification: constant-time comparison
default_hmac_provider().hmac_verify(hash_alg, hmac_key, message, signature) uses OpenSSL’s HMAC_CTX and a constant-time byte comparison. The OpenSSL backend returns false rather than an early exit if the MAC does not match, preventing timing side-channels.
EAB error mapping
| Condition | Error variant | ACME type | HTTP |
|---|---|---|---|
| EAB required but absent | AcmeError::ExternalAccountRequired | externalAccountRequired | 403 |
Unknown kid, used kid, MAC fail | AcmeError::Unauthorized(msg) | unauthorized | 401 |
Unsupported EAB alg | AcmeError::BadRequest(msg) | (maps to serverInternal) | 400 |
Pre-issuance linting
After signing each certificate, ca::issue::issue_with_params runs synta_x509_verification policy checks before returning the IssuedCert:
- The DER-encoded certificate is decoded again by
synta::Decoder. - A
PolicyDefinitionis constructed for end-entity certificate validation. - The CA’s public key is used as the trust anchor for the signature check.
verify(leaf, &[], &policy, RevocationChecks::default())is called.
If linting fails, AcmeError::Builder is returned and the certificate is not stored or delivered to the client. This satisfies CA/B Forum BR §4.3.1.2 (pre-issuance linting).
The checks include:
- X.509 version = v3 (tag
A2 03 02 01 02). - Serial number: ≤ 20 octets, positive (high bit not set without
0x00prefix). BasicConstraints: cA=FALSEon the end-entity certificate.AuthorityKeyIdentifierextension present.- SPKI algorithm on the WebPKI allowlist.
- RSA modulus ≥ 2048 bits; EC key on a named curve.
- CA signature cryptographically valid over the certificate body.
RFC 9115 — CSR template validation
The CSR template validation in src/routes/finalize.rs enforces the RFC 9115 §4 constraints on delegation-order CSRs. When an order has a non-null delegation_id, finalize loads the delegation’s csr_template from the database and passes it to validate_csr_against_template.
Template semantics
Each field in the CSR template carries a JSON value whose type determines the constraint:
| JSON value type | Constraint |
|---|---|
{} (empty object) | MandatoryWildcard — the field MUST appear in the CSR |
null | OptionalWildcard — the field MAY appear; its content is not checked |
"<literal>" (string) | Literal — the field MUST appear with this exact value |
| absent | Forbidden — the field MUST NOT appear in the CSR |
Validated fields
| Template field | CSR check |
|---|---|
keyTypes | At least one entry in the array must match the CSR’s SPKI algorithm and curve |
subject.commonName | MandatoryWildcard ({}) → must be present; Literal → must equal the string |
subject.organization | Same semantics as commonName |
extensions.subjectAltName | MandatoryWildcard → must be present; the SAN values themselves are constrained by the order identifiers (existing RFC 8555 check), not the template |
extensions.keyUsage | Array of allowed key usage bit names; the CSR’s requested KeyUsage must be a subset |
extensions.extendedKeyUsage | Array of allowed EKU OIDs; the CSR’s requested EKU must be a subset |
A CSR that violates any constraint is rejected with AcmeError::BadCSR → HTTP 400 urn:ietf:params:acme:error:badCSR.
Template validation at Admin API write time
POST /admin/delegations and PUT /admin/delegations/{id} both parse the csr_template JSON against the schema and reject malformed templates before they reach the database. This keeps the finalize-time validation path clean — by the time a CSR is checked against a template, the template is guaranteed to be structurally valid.
AcmeError type strings
Every ACME-level error maps to a URN in the urn:ietf:params:acme:error: namespace. The mapping is defined in src/error.rs and is tested exhaustively — see Error Reference for the full table and HTTP status mapping.
Account Management Internals
This chapter describes the internal implementation of ACME account creation, key rollover, and the SPKI cache.
Database representation
Accounts are stored in the accounts table (defined in src/db/schema.rs and migration 0001_initial, extended in migrations 0007_profile_grants and 0012_multi_ca):
CREATE TABLE accounts (
id TEXT PRIMARY KEY, -- UUID v4
status TEXT NOT NULL DEFAULT 'valid',
contact TEXT, -- JSON array e.g. ["mailto:a@b.com"]
public_key BLOB NOT NULL, -- DER-encoded SubjectPublicKeyInfo
jwk_thumbprint TEXT NOT NULL UNIQUE, -- base64url SHA-256 JWK thumbprint (RFC 7638)
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
profile_grants TEXT, -- NULL = unrestricted; JSON array of profile IDs
ca_id TEXT NOT NULL DEFAULT '' -- empty = server-wide scope (any CA)
);
Two fields carry cryptographic identity:
public_key— the raw DER-encodedSubjectPublicKeyInfoextracted from the outer JWSjwkat account creation time. Stored once; used to verify subsequent signed requests via a cache-aside pattern.jwk_thumbprint— the RFC 7638 SHA-256 thumbprint of the public JWK, base64url-encoded. Carries aUNIQUEconstraint so the database enforces that no two accounts share the same key. This is the lookup key for “does an account already exist for this key?” checks atnew-accounttime.
profile_grants stores a JSON array of profile ID strings (e.g. '["tls-server","mtc-tls"]'), or NULL when no restriction is in force. The NULL state is distinct from an empty array: NULL means “no restriction” while an empty array would grant access to no profiles. When a profile has require_account_grant = true, the finalize handler checks this column via db::accounts::get_profile_grants.
ca_id records the CA scope of the account. An empty string ('') means the account is server-wide and may place orders against any CA. A non-empty value restricts the account to a specific CA; this is set when the server is configured with server.account_scope = "ca". The column was added in migration 0012_multi_ca with DEFAULT '' so all pre-migration accounts are treated as server-wide.
Account creation flow (src/routes/account.rs)
routes::account::new_account handles POST /acme/new-account:
parse_jwsverifies the outer JWS and extracts theJwsKeyRef::Jwk { jwk }.jwk.thumbprint()computes the RFC 7638 thumbprint.db::accounts::get_by_thumbprint(&state.db, &thumbprint)checks for an existing account. If found, returns HTTP 200 with the existing account (idempotent creation).contactsare validated — any URI scheme is accepted (any string containing:); bare strings without a scheme separator are rejected per RFC 8555 §7.1.2. Note: this step happens before EAB validation in the code.- If
external_account_requiredis set, the EAB JWS is validated (see EAB Internals). - A new UUID account ID is generated.
- Account insertion and EAB key consumption happen atomically in a single
db::begin_writetransaction:
#![allow(unused)]
fn main() {
let mut tx = db::begin_write(&state.db, state.db_kind).await?;
db::accounts::insert(&mut *tx, AccountRow { … }).await?;
if let Some((eab_kid, _)) = verified_eab {
db::eab::mark_used(&mut *tx, &eab_kid, now).await?;
}
tx.commit().await.map_err(AcmeError::from)?;
}
SPKI cache (AppState::spki_cache)
Every authenticated POST endpoint (other than new-account) must look up the account’s public key to verify the JWS signature. Fetching public_key from the database on every request would add a read round-trip to every ACME operation.
AppState.spki_cache is an Arc<RwLock<HashMap<String, CachedAccount>>> that caches account key material keyed by account ID. CachedAccount is defined in src/state.rs and holds three fields:
#![allow(unused)]
fn main() {
pub struct CachedAccount {
pub spki_der: Vec<u8>, // DER-encoded SubjectPublicKeyInfo
pub jwk_thumbprint: String, // RFC 7638 JWK thumbprint (base64url SHA-256)
pub status: String, // "valid", "deactivated", or "revoked"
}
}
After the first authenticated request for an account, the full CachedAccount is stored here. Subsequent requests hit the in-memory cache instead of the database — and routes that need the JWK thumbprint (e.g., key-change audit events) avoid a second get_by_id call. The cache also enables fast status checks: if cached_account.status != "valid", the request is rejected immediately without a DB round-trip.
Cache eviction occurs in two places and uses a poison-guard pattern to avoid panicking on a poisoned RwLock:
-
Deactivation (
update_account):#![allow(unused)] fn main() { match state.spki_cache.write() { Ok(mut cache) => { cache.remove(&id); } Err(e) => { e.into_inner().remove(&id); } } }This removes the entry immediately after marking the account
deactivated, so subsequent requests with the deactivated account’s key are rejected at the database layer (wherestatus='valid'is required for updates) rather than served from a stale cache entry. -
Key rollover (
key_change): the same poison-guard removal is applied afterdb::accounts::update_keysucceeds, so the next request with the new key re-loads theCachedAccountfrom the database rather than finding the old SPKI bytes.
The cache is not bounded in size because the number of accounts is expected to be small relative to available memory. A future improvement could add LRU eviction.
Key rollover flow (src/routes/key_change.rs)
routes::key_change::key_change handles POST /acme/key-change per RFC 8555 §7.3.5. The outer JWS is signed with the old key (resolved via kid); the payload is itself an inner JWS signed with the new key. The steps are:
- Verify the outer JWS with
parse_jws— uses the old key from the SPKI cache or database. - Parse the payload as a
JwsFlattenedinner JWS. - Extract the new
JwkPublicfrom the inner JWS header (JwsKeyRef::Jwk). - Convert the new JWK to SPKI DER:
new_jwk.to_spki_der(). - Compute the new thumbprint:
new_jwk.thumbprint(). - Verify the inner JWS signature over the new SPKI DER.
- Decode the inner payload:
{ "account": "<account_url>", "oldKey": <old_jwk> }. - Check
inner_payload.account == expected_account_url. - Convert
inner_payload.old_keyto SPKI DER and compare withctx.spki_der(the outer JWS’s key). This is the RFC-mandated proof that the requester controls the old key. - Check that the new thumbprint is not already in use by another account:
db::accounts::get_by_thumbprint(&state.db, &new_thumbprint). - Call
db::accounts::update_key(&state.db, &account_id, new_spki, new_thumbprint, now). - Evict the old entry from the cache using the poison-guard pattern (see the SPKI cache section above).
- Emit an
AccountKeyChange("account.key-change") audit event with:subject: the account ID.principal:"acme:<old_thumbprint>"(the thumbprint of the key that was replaced).detail:"new_key=<new_thumbprint>"(the thumbprint of the replacement key).
db::accounts::update_key updates both public_key (the DER BLOB) and jwk_thumbprint (the unique TEXT) atomically in a single SQL UPDATE:
UPDATE accounts SET public_key = ?, jwk_thumbprint = ?, updated = ?
WHERE id = ? AND status = 'valid'
The AND status = 'valid' guard ensures that a deactivated account’s key cannot be rotated.
Database module (src/db/accounts.rs)
The account DB module exposes:
| Function | SQL |
|---|---|
insert(executor, row) | INSERT INTO accounts … |
get_by_id(executor, id) | SELECT … FROM accounts WHERE id = ? |
get_by_thumbprint(executor, thumbprint) | SELECT … FROM accounts WHERE jwk_thumbprint = ? |
update_contact(executor, id, contact, now) | UPDATE accounts SET contact = ? … WHERE id = ? AND status = 'valid' |
update_status(executor, id, status, now) | UPDATE accounts SET status = ? … |
update_key(executor, id, public_key, jwk_thumbprint, now) | UPDATE accounts SET public_key = ?, jwk_thumbprint = ? … WHERE id = ? AND status = 'valid' |
set_profile_grants(executor, id, grants, now) | UPDATE accounts SET profile_grants = ? … WHERE id = ? AND status = 'valid' |
get_profile_grants(executor, id) | SELECT profile_grants FROM accounts WHERE id = ? |
list(executor, status, ca_id, limit, offset) | SELECT … FROM accounts WHERE 1=1 [AND status = ?] [AND (ca_id = ? OR id IN (SELECT …))] ORDER BY created DESC LIMIT ? OFFSET ? |
get_profile_grants returns a nested Option:
Ok(None)— account not found.Ok(Some(None))— account exists,profile_grants IS NULL(no restriction).Ok(Some(Some(json)))— account exists and has a JSON grant array.
All functions accept impl sqlx::Executor<'_, Database = sqlx::Any>, which allows them to be called with either a pool reference (&Db) or a mutable transaction reference (&mut *tx). This is the standard sqlx pattern for composing queries into transactions without changing the function signatures.
Profile authorization (src/profiles/auth.rs)
At finalize time, after the profile parameters are resolved from ProfileRegistry::resolve, the finalize handler calls crate::profiles::auth::check_profile_auth when the order carries a profile name. The function applies three checks in sequence; the first failure short-circuits with an AcmeError::Unauthorized or AcmeError::InvalidProfile:
#![allow(unused)]
fn main() {
pub async fn check_profile_auth(
db: &db::Db,
account_id: &str,
profile_name: &str,
params: &CertificateParameters,
identifiers: &[(&str, &str)],
) -> Result<(), AcmeError>
}
Check 1 — Identifier patterns (check_identifier_patterns):
If params.allowed_identifier_patterns is non-empty, each identifier is formatted as "type:value" and matched against the compiled regex list. params.identifier_match_all controls whether every identifier must match (true) or just one (false). An invalid regex returns AcmeError::InvalidProfile.
Check 2 — External hook (check_auth_hook):
If params.auth_hook is Some(path), the handler spawns the executable at path, writes the following JSON to its stdin, and waits for it to exit within params.auth_hook_timeout_secs seconds (default: 30):
{
"account_id": "<uuid>",
"profile": "<name>",
"identifiers": [{"type": "dns", "value": "example.com"}]
}
Exit 0 = permit. Non-zero = deny; the hook’s trimmed stdout is used as the denial detail. A timeout returns AcmeError::Unauthorized. Spawn failures return AcmeError::Internal.
Check 3 — Account grant (check_account_grant):
If params.require_account_grant is true, db::accounts::get_profile_grants is called. The three-way return value maps as follows:
get_profile_grants return | Authorization result |
|---|---|
Ok(None) | Account not found → Unauthorized |
Ok(Some(None)) | Account exists, profile_grants IS NULL → Unauthorized |
Ok(Some(Some(json))) | JSON parsed as Vec<String>; Unauthorized unless profile_name is in the list |
EAB Internals
This chapter describes the internal implementation of External Account Binding (RFC 8555 §7.3.4): the database schema, the startup seeding pattern, the verification pipeline, and the atomic key-consumption transaction.
eab_keys table schema
EAB keys are stored in the eab_keys table. The table was created in migration 0001_initial and the profile_grants column was added by migration 0007_profile_grants:
-- 0001_initial.sql
CREATE TABLE eab_keys (
kid TEXT PRIMARY KEY,
hmac_key_b64u TEXT NOT NULL, -- base64url-encoded raw HMAC key bytes
created INTEGER NOT NULL, -- Unix epoch seconds
used_at INTEGER -- NULL = unused; non-NULL = consumed timestamp
);
-- 0007_profile_grants.sql
ALTER TABLE eab_keys ADD COLUMN profile_grants TEXT;
-- NULL = no restriction; JSON array of profile IDs
The current effective schema after all migrations is therefore:
CREATE TABLE eab_keys (
kid TEXT PRIMARY KEY,
hmac_key_b64u TEXT NOT NULL,
created INTEGER NOT NULL,
used_at INTEGER,
profile_grants TEXT
);
hmac_key_b64u stores the raw HMAC key in base64url encoding (no padding). The server base64url-decodes this before HMAC verification. A NULL used_at means the key is available for use; a non-NULL value means it has been consumed by an account-creation request and may not be reused.
profile_grants stores a JSON array of profile ID strings (e.g. '["tls-server","mtc-tls"]'), or NULL when no restriction applies. When an account is created with this EAB key, the profile_grants value is copied atomically to the new account’s profile_grants column.
Startup seeding: insert_if_absent
EAB keys configured in [server.eab_keys] are seeded into the database on every server start via db::eab::insert_if_absent:
#![allow(unused)]
fn main() {
pub async fn insert_if_absent(
executor: impl sqlx::Executor<'_, Database = sqlx::Any>,
kid: &str,
hmac_key_b64u: &str,
now: i64,
) -> Result<(), AcmeError>
}
The underlying SQL uses a portable WHERE NOT EXISTS subquery (not INSERT OR IGNORE, which is SQLite-specific) to avoid overwriting keys that were already modified or consumed at runtime:
INSERT INTO eab_keys (kid, hmac_key_b64u, created)
SELECT ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM eab_keys WHERE kid = ?)
This means:
- A config-file key that does not exist in the DB is inserted.
- A config-file key that exists in the DB (whether unconsumed, consumed, or modified by the admin API) is silently skipped.
- A restart never revives a consumed key.
The seeding loop in src/main.rs logs a tracing::warn! if insert_if_absent fails (e.g., due to a DB error), but does not abort startup.
EAB verification pipeline (src/jose/eab.rs)
The server’s EAB logic is split into two functions that are called in sequence from routes::account::new_account.
Step 1: parse_eab_kid
#![allow(unused)]
fn main() {
pub fn parse_eab_kid(eab: &serde_json::Value) -> Result<String, AcmeError>
}
Decodes and parses only the protected header of the EAB JWS to extract the kid. This is a partial parse that deliberately skips HMAC verification, so that the kid can be used for a database lookup before the full verification step.
The function:
- Deserializes the
protectedfield as a base64url string. - Decodes the base64url bytes and parses as JSON.
- Returns the
kidstring from the parsed header.
Step 2: verify_eab_jws
#![allow(unused)]
fn main() {
pub fn verify_eab_jws(
eab: &serde_json::Value,
expected_url: &str,
expected_kid: &str,
account_thumbprint: &str,
hmac_key: &[u8],
) -> Result<(), AcmeError>
}
Performs full EAB verification per RFC 8555 §7.3.4:
- Decodes and parses the full EAB JWS (
protected,payload,signature). - Parses the protected header and extracts
alg,kid, andurl. - Maps
algto a hash name:"HS256"→"sha256","HS384"→"sha384","HS512"→"sha512". Any other value returnsAcmeError::BadRequest. - Checks
header.kid == expected_kid. A mismatch returnsAcmeError::Unauthorized. - Checks
header.url == expected_url(the new-account endpoint URL). A mismatch returnsAcmeError::Unauthorized. - Decodes the
payloadfrom base64url, parses it as aJwkPublic, and computes its RFC 7638 thumbprint. The thumbprint must matchaccount_thumbprint(the thumbprint of the outer JWS’s account key). This check ensures the EAB payload contains the actual account public key. - Computes the signing input as
"{protected}.{payload}"(ASCII bytes). - Calls
default_hmac_provider().hmac_verify(hash_alg, hmac_key, signing_input, &raw_sig). The OpenSSL backend performs a constant-time HMAC comparison.
Handler integration (src/routes/account.rs)
The calling code in new_account:
#![allow(unused)]
fn main() {
let kid = crate::jose::eab::parse_eab_kid(eab_val)?;
let key_row = db::eab::get_by_kid(&state.db, &kid)
.await?
.ok_or_else(|| AcmeError::Unauthorized(format!("EAB: unknown kid '{kid}'")))?;
if key_row.used_at.is_some() {
return Err(AcmeError::Unauthorized(format!("EAB: kid '{kid}' has already been used")));
}
let hmac_key = URL_SAFE_NO_PAD
.decode(&key_row.hmac_key_b64u)
.map_err(|e| AcmeError::BadRequest(format!("EAB: invalid HMAC key encoding: {e}")))?;
if let Err(e) = crate::jose::eab::verify_eab_jws(eab_val, &url, &kid, &thumbprint, &hmac_key) {
// On HMAC verification failure, two audit events are emitted:
// EabReject ("eab.reject", failure) — records the rejected kid.
// SecurityViolation ("security.violation", failure) — feeds the FAU_ARP.1 alarm counter.
state.record_audit(AuditEvent::failure(AuditEventType::EabReject).with_subject(&kid)).await;
state.record_audit(
AuditEvent::failure(AuditEventType::SecurityViolation)
.with_subject(&kid)
.with_detail("EAB HMAC verification failed"),
).await;
return Err(e);
}
}
On successful HMAC verification an EabUse ("eab.use", success) audit event is emitted for the kid.
After verification, verified_eab is Some((kid, profile_grants)) and the account insert, EAB mark, and profile grant transfer are committed atomically:
#![allow(unused)]
fn main() {
// Profile grants inherited from the EAB key (None when no EAB was used).
let eab_profile_grants = verified_eab.as_ref().and_then(|(_, g)| g.clone());
let mut tx = db::begin_write(&state.db, state.db_kind).await?;
db::accounts::insert(&mut *tx, AccountRow {
profile_grants: eab_profile_grants, // inherited from EAB key
…
}).await?;
if let Some((eab_kid, _)) = verified_eab {
db::eab::mark_used(&mut *tx, &eab_kid, now).await?;
}
tx.commit().await.map_err(AcmeError::from)?;
}
The atomicity guarantee: the account row insertion, EAB key consumption, and profile grant transfer all happen in a single transaction. Either all three succeed together, or none of them do. A concurrent second request using the same kid will find used_at IS NOT NULL after the first transaction commits and will be rejected with Unauthorized.
When the EAB key’s profile_grants is NULL, the new account’s profile_grants is also NULL (no restriction). When it contains a JSON array, the array is stored verbatim on the account row and immediately governs profile authorization for that account.
db::eab module
| Function | Description |
|---|---|
insert_if_absent(executor, kid, hmac_key_b64u, now) | Seed from config; silent no-op if kid already exists |
insert(executor, kid, hmac_key_b64u, now) | Unconditional insert without grants; returns Conflict if kid exists |
insert_with_grants(executor, kid, hmac_key_b64u, profile_grants, now) | Unconditional insert with optional grants (used by the Admin API); returns Conflict if kid exists |
get_by_kid(executor, kid) | Fetch EabKeyRow; returns None for unknown kid |
list(db, used_filter, limit, offset) | List keys with optional used-status filter (Some(true) = used only, Some(false) = unused only, None = all) and pagination |
mark_used(executor, kid, now) | Set used_at; intended to be called within a write transaction. Returns Conflict when rows_affected == 0, meaning the key was already consumed by a concurrent request between the outer get_by_kid check and the transaction commit (TOCTOU guard). |
delete(executor, kid) | Remove the key entirely; returns Result<u64, AcmeError> where the u64 is the number of rows deleted (0 = not found) |
EabKeyRow mirrors the table columns:
#![allow(unused)]
fn main() {
pub struct EabKeyRow {
pub kid: String,
pub hmac_key_b64u: String,
pub created: i64,
pub used_at: Option<i64>, // None = unused
pub profile_grants: Option<String>, // None = no restriction; Some = JSON array
}
}
Admin API internals (src/routes/admin.rs)
The Admin API routes are served on a dedicated admin listener built by routes::build_admin_router. Each handler enforces role-based access using the require_role! macro, which delegates to the OperatorContext extractor. The OperatorContext verifies the operator’s session token and looks up their role in the operators table.
When the [admin] section is absent from the configuration, the admin router is not started and all admin endpoints are unreachable.
Role enforcement is applied per endpoint. A request from a role that is not authorised for that endpoint receives 403 Forbidden. The full role matrix is documented in Admin API and Operator Management.
Account profile grants endpoints
GET /admin/account/{id}/profile-grants calls db::accounts::get_profile_grants and returns:
{ "profile_grants": ["p1", "p2"] }
or {"profile_grants": null} for a NULL column. Returns 404 when the account ID is not found.
PUT /admin/account/{id}/profile-grants deserialises the body as {"profile_grants": <array or null>} and calls db::accounts::set_profile_grants. An empty JSON array and null both map to NULL in the database (the grants_to_json helper returns None for both). Returns 204 on success; 404 when the account is not found or is deactivated.
DELETE /admin/account/{id}/profile-grants calls set_profile_grants with grants = None, setting the column to NULL. Returns 204 on success; 404 when the account is not found or is deactivated.
EAB key provisioning endpoint
POST /admin/eab deserialises the body as:
{ "kid": "...", "hmac_key_b64u": "...", "profile_grants": ["p1"] }
profile_grants is optional (absent or null = no restriction). The handler calls db::eab::insert_with_grants, which inserts the key row with the profile_grants column set accordingly. Returns 201 with {"kid": "...", "created": <unix-epoch>}; returns 409 when the kid already exists (detected by a UNIQUE constraint violation). Requires administrator or ca_operations role; ca_ra is intentionally excluded because EAB keys are server-global (not CA-scoped).
Keys provisioned via this endpoint behave identically to keys seeded from [server.eab_keys] during EAB verification. The only difference is that config-file keys have profile_grants = NULL always (they are seeded via insert_if_absent, which does not write the profile_grants column), while admin-provisioned keys may carry grants.
EAB key listing endpoint
GET /admin/eab lists EAB keys. Query parameters:
used=true|false— filter by used status (omit for all keys)limit=N— maximum rows returned (default 200, max 1000)offset=N— skip first N rows (default 0)
Returns {"eab_keys": [...]} where each element contains kid, created, used_at, and profile_grants. Calls db::eab::list. Requires any role.
EAB key detail endpoint
GET /admin/eab/{kid} returns a single key’s details:
{ "kid": "...", "created": <unix-epoch>, "used_at": <unix-epoch or null>, "profile_grants": <array or null> }
Returns 404 when the kid is not found. Calls db::eab::get_by_kid. Requires any role.
EAB key deletion endpoint
DELETE /admin/eab/{kid} removes the key row entirely. Returns 204 on success; 404 when the kid is not found. Calls db::eab::delete. Requires administrator or ca_operations role.
HKDF-SHA-256 EAB credential derivation (src/eab_derivation.rs)
When [server].eab_master_secret is configured, EAB credentials for Kerberos-authenticated clients are derived deterministically rather than stored in advance. The derivation is implemented in src/eab_derivation.rs and called from the GET /acme/eab handler in src/routes/eab_identity.rs.
Public function
#![allow(unused)]
fn main() {
pub fn derive_eab_credentials(
master_secret: &[u8],
principal: &str,
) -> Result<(String, String), AcmeError>
}
Returns (kid_b64u, hmac_key_b64u) — both values are base64url-encoded (no padding).
Derivation scheme
Two independent HKDF-SHA-256 (RFC 5869) invocations are performed, one for the kid and one for the HMAC key:
kid = base64url( HKDF-Extract-Expand(IKM=master_secret, salt=<none>, info="akamu-eab-v1-kid:" + principal, L=16) )
hmac_key = base64url( HKDF-Extract-Expand(IKM=master_secret, salt=<none>, info="akamu-eab-v1-key:" + principal, L=32) )
No explicit salt is supplied to HKDF-Extract; RFC 5869 §2.2 specifies that an absent salt is treated as a string of zeroes of length HashLen (32 bytes for SHA-256). This is acceptable because master_secret is already high-entropy input key material (at least 32 random bytes) and does not require additional salt-based extraction to achieve uniform randomness.
The info field domain-separates the two outputs, ensuring that the kid and HMAC key bytes are independent even though they share the same IKM and salt.
Output sizes
| Output | Raw bytes | Base64url chars |
|---|---|---|
kid | 16 | 22 (no padding) |
hmac_key | 32 | 43 (no padding) |
Determinism guarantee
The same (master_secret, principal) pair always yields identical kid and hmac_key values. The GET /acme/eab handler inserts the derived key into eab_keys on the first call (using insert_if_absent) and returns the same values on subsequent calls. This lets clients retry a failed registration without administrator intervention.
Keying material lifetime
Once the kid has been consumed by a successful newAccount request, the eab_keys.used_at column is set and further calls to GET /acme/eab by the same principal return 409 Conflict. The administrator must delete the consumed key row (via the Admin API or akamuctl eab remove) to allow the principal to re-register.
TLS Layer
This chapter documents the internal implementation of Akāmu’s native TLS server, covering the crypto backend selection, certificate loading, composite ML-DSA scheme wiring, and the connection acceptance loop.
Module layout
src/tls/
mod.rs TLS module re-exports; build_rustls_server_config and
build_admin_rustls_server_config entry points; leaf_cert_der helper
init.rs tls::init::load_or_generate — certificate bootstrap
loader.rs PEM loading helpers (pem_to_der, BackendPrivateKey::from_pem)
schemes.rs Composite ML-DSA+classical code points (COMPOSITE_SCHEMES)
verifier.rs SyntaClientCertVerifier — rustls ClientCertVerifier impl
channel_binding.rs RFC 5929 tls-server-end-point channel binding computation
TLS is optional. When config.tls.enabled is false, the server uses a plain axum::serve call and the entire src/tls/ subsystem is never entered.
Crypto provider: rustls-native-ossl
The rustls ServerConfig is constructed with the rustls-native-ossl default provider, which delegates all cryptographic operations to the system OpenSSL library:
#![allow(unused)]
fn main() {
let provider = Arc::new(rustls_native_ossl::default_provider());
let builder = rustls::ServerConfig::builder_with_provider(provider)
.with_protocol_versions(&versions)?;
}
rustls-native-ossl handles all classical TLS signature schemes (ECDSA, RSA-PSS, RSA-PKCS1, EdDSA) for both server certificate verification and client certificate CertificateVerify in TLS 1.2.
Composite ML-DSA+classical CertificateVerify messages in TLS 1.3 are routed through the same native-ossl OpenSSL backend via a dedicated dispatch path (see Composite scheme verification below).
tls::init::load_or_generate (src/tls/init.rs)
Called once at startup when config.tls.enabled is true. It mirrors the logic of ca::init::load_or_generate:
cert_file exists | key_file exists | Action |
|---|---|---|
| No | No | Generate server key + CA-signed cert; write both files |
| Yes | Yes | Return immediately — caller has supplied its own cert |
| Yes | No (or No/Yes) | Return Err — partial state rejected |
When generating:
ca::init::generate_backend_key(&tls.bootstrap_key_type)generates a fresh server key.ca::issue::sign_server_cert(&tls.server_name, &server_key, ca)produces a CA-signed certificate DER.server_key.to_pem(None)serialises the private key PEM; written tokey_filefirst viacrate::util::write_key_file.synta_certificate::der_to_pem("CERTIFICATE", &cert_der)converts the certificate to PEM.- The PEM chain written to
cert_fileisleaf cert + CA cert(PEM-concatenated) so TLS clients see a complete chain without needing the CA cert separately.
The function signature is:
#![allow(unused)]
fn main() {
pub fn load_or_generate(tls: &TlsConfig, ca: &CaState) -> Result<(), String>
}
PEM loading (src/tls/loader.rs)
All PEM-to-DER conversion uses synta_certificate::pem_to_der — the same helper used throughout the server and CA subsystems. This avoids a second PEM parser dependency.
load_server_cert_chain
#![allow(unused)]
fn main() {
pub fn load_server_cert_chain(path: &str) -> Result<Vec<CertificateDer<'static>>, String>
}
Reads the file, calls pem_to_der, and maps each DER blob to rustls::pki_types::CertificateDer. Returns an error if the file contains no PEM blocks.
load_server_private_key
#![allow(unused)]
fn main() {
pub fn load_server_private_key(path: &str) -> Result<PrivateKeyDer<'static>, String>
}
Reads the PEM file and calls BackendPrivateKey::from_pem(&pem, None) to parse it — the same synta_certificate primitive used to load the CA key. The resulting BackendPrivateKey is then serialised to PKCS#8 DER via .to_der() and wrapped in rustls::pki_types::PrivateKeyDer::Pkcs8. This accepts both unencrypted PKCS#8 (-----BEGIN PRIVATE KEY-----) and SEC1 EC keys (-----BEGIN EC PRIVATE KEY-----).
load_ca_certs
#![allow(unused)]
fn main() {
pub fn load_ca_certs(ca_files: &[String]) -> Result<Vec<Vec<u8>>, String>
}
Iterates the configured CA PEM files, calls pem_to_der for each, and returns a flat Vec of DER blobs for the SyntaClientCertVerifier trust store.
SyntaClientCertVerifier (src/tls/verifier.rs)
Implements rustls::server::danger::ClientCertVerifier using synta-x509-verification for chain validation. Trust anchors are parsed once at startup via OwnedStore::try_new and reused across all connections with no DER re-parsing per handshake.
Construction
#![allow(unused)]
fn main() {
let verifier = SyntaClientCertVerifier::new(&ca_ders, client_auth_config)?;
}
OwnedStore::try_new parses each CA DER blob into an owned in-process trust store. The DN hints (root_hint_subjects) are also pre-computed once by parsing the subject Name from each CA DER using synta::Decoder.
verify_client_cert
On each TLS handshake, rustls calls this method. It:
- Clones the DER bytes out of the short-lived
CertificateDerborrows into ownedVec<u8>allocations. - Parses the leaf and each intermediate via
synta::Decoder::decode::<Certificate>(). - Builds a
PolicyDefinitionviaPolicyDefinition::new_client(OpensslSignatureVerifier, validation_time), then applies the configured profile, depth, minimum RSA modulus, and algorithm sets. - Calls
self.owned_store.verify(&leaf_vc, &inter_vcs, &policy, RevocationChecks::default())— no re-parsing of trust anchors.
Algorithm sets are chosen based on allow_post_quantum:
allow_post_quantum | SPKI algorithms | Signature algorithms |
|---|---|---|
false | WEBPKI_PERMITTED_SPKI_ALGORITHMS | WEBPKI_PERMITTED_SIGNATURE_ALGORITHMS |
true | WEBPKI_PERMITTED_SPKI_ALGORITHMS_WITH_PQ | WEBPKI_PERMITTED_SIGNATURE_ALGORITHMS_WITH_PQ |
verify_tls12_signature
All TLS 1.2 CertificateVerify schemes delegate to the rustls-native-ossl provider via
the provider field cached at construction time — no new default_provider() call per
handshake:
#![allow(unused)]
fn main() {
rustls::crypto::verify_tls12_signature(
message, cert, dss,
&self.provider.signature_verification_algorithms,
)
}
Composite ML-DSA schemes are TLS 1.3 only and never appear here.
verify_tls13_signature
TLS 1.3 CertificateVerify dispatch:
#![allow(unused)]
fn main() {
if crate::tls::schemes::is_composite(dss.scheme) {
verify_composite_tls13_signature(message, cert, dss)
} else {
rustls::crypto::verify_tls13_signature(
message, cert, dss,
&self.provider.signature_verification_algorithms,
)
}
}
Classical schemes go to rustls-native-ossl; composite ML-DSA schemes go to the
native-ossl EVP path. The provider is stored as Arc<rustls::crypto::CryptoProvider>
in the verifier struct (built once at SyntaClientCertVerifier::new), so a single
rustls_native_ossl::default_provider() call is shared across every connection.
Composite scheme code points (src/tls/schemes.rs)
#![allow(unused)]
fn main() {
pub const MLDSA44_ECDSA_P256_SHA256: u16 = 0x0901;
pub const MLDSA44_RSA2048_PKCS15_SHA256: u16 = 0x0902;
// … 11 entries total
pub const MLDSA87_ED448_SHAKE256: u16 = 0x090C;
}
These are provisional code points from draft-reddy-tls-composite-mldsa (all TBD pending IANA
allocation). The X.509 OIDs for the same algorithm combinations are defined in the companion
draft-ietf-lamps-pq-composite-sigs. They are advertised as SignatureScheme::Unknown(code)
values because rustls does not have built-in named variants for these provisional code points.
COMPOSITE_SCHEMES is a &[SignatureScheme] slice of all 11 entries, returned by supported_verify_schemes when allow_post_quantum = true.
is_composite(scheme: SignatureScheme) -> bool checks whether a scheme’s code point is in COMPOSITE_SCHEMES:
#![allow(unused)]
fn main() {
pub fn is_composite(scheme: SignatureScheme) -> bool {
if let SignatureScheme::Unknown(code) = scheme {
COMPOSITE_SCHEMES.contains(&SignatureScheme::Unknown(code))
} else {
false
}
}
}
Composite scheme verification (native-ossl)
When is_composite returns true, verification is routed to:
#![allow(unused)]
fn main() {
fn verify_composite_tls13_signature(
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError>
}
This function:
- Extracts the composite SubjectPublicKeyInfo DER from the raw certificate bytes using
synta_certificate::cert_byte_rangesto get the exact SPKI TLV byte range — avoiding a full certificate re-parse. - Calls
verify_composite_via_openssl(dss.scheme, message, spki_der, dss.signature()).
verify_composite_via_openssl uses native-ossl:
#![allow(unused)]
fn main() {
use native_ossl::pkey::{Pkey, Public, SignInit, Verifier};
let pkey = Pkey::<Public>::from_der(spki_der)?;
let digest = composite_digest(scheme)?;
let mut verifier = Verifier::new(&pkey, &SignInit { digest: Some(&digest), params: None })?;
verifier.update(message)?;
verifier.verify(sig_bytes)?
}
Pkey::<Public>::from_der loads the composite SubjectPublicKeyInfo DER via OpenSSL’s d2i_PUBKEY, which understands both the classical and ML-DSA components of the composite key. Verifier::verify dispatches to the OpenSSL provider, which applies “and” semantics — both the classical and ML-DSA components must verify.
composite_digest maps each code point to the correct hash algorithm name for native_ossl::digest::DigestAlg::fetch:
| Code point | Constant | Hash |
|---|---|---|
0x0901 | MLDSA44_ECDSA_P256_SHA256 | SHA2-256 |
0x0902 | MLDSA44_RSA2048_PKCS15_SHA256 | SHA2-256 |
0x0903 | MLDSA44_RSA2048_PSS_SHA256 | SHA2-256 |
0x0904 | MLDSA44_ED25519_SHA512 | SHA2-512 |
0x0905 | MLDSA65_ECDSA_P256_SHA512 | SHA2-512 |
0x0906 | MLDSA65_ECDSA_P384_SHA512 | SHA2-512 |
0x0907 | MLDSA65_RSA3072_PKCS15_SHA512 | SHA2-512 |
0x0908 | MLDSA65_RSA3072_PSS_SHA512 | SHA2-512 |
0x0909 | MLDSA65_ED25519_SHA512 | SHA2-512 |
0x090A | MLDSA87_ECDSA_P384_SHA512 | SHA2-512 |
0x090C | MLDSA87_ED448_SHAKE256 | SHAKE256 |
Channel binding (src/tls/channel_binding.rs)
Implements RFC 5929 §4 tls-server-end-point channel binding, used by the GSSAPI
authentication layer to bind Kerberos tokens to the TLS session.
TlsServerEndpointBinding
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct TlsServerEndpointBinding(pub Vec<u8>);
}
A typed request extension injected per-connection. Contains the raw binding bytes
(the hash of the leaf certificate DER per RFC 5929 §4). Absent when the server
certificate uses an algorithm with no defined hash (ML-DSA pure or composite, Ed448,
or any unrecognised algorithm) — in those cases the field is not inserted and the
GSSAPI layer passes None channel bindings.
tls_server_endpoint_binding
#![allow(unused)]
fn main() {
pub fn tls_server_endpoint_binding(cert_der: &[u8]) -> Option<Vec<u8>>
}
Parses the leaf certificate DER with synta::Decoder, extracts the signature
algorithm OID, and selects the appropriate hash:
| Signature algorithm | Hash used |
|---|---|
| ecdsa-with-SHA256 / sha256WithRSAEncryption | SHA-256 |
| md5WithRSAEncryption / sha1WithRSAEncryption | SHA-256 (RFC 5929 §4 override) |
| id-RSASSA-PSS with SHA-1 or SHA-256 params | SHA-256 (SHA-1 overridden) |
| id-RSASSA-PSS with SHA-384 params | SHA-384 |
| id-RSASSA-PSS with SHA-512 params | SHA-512 |
| ecdsa-with-SHA384 / sha384WithRSAEncryption | SHA-384 |
| ecdsa-with-SHA512 / sha512WithRSAEncryption / id-Ed25519 | SHA-512 |
| ML-DSA pure (FIPS 204), Composite ML-DSA, id-Ed448 | None — no canonical hash |
Returns None for unsupported algorithms; the caller logs an informational message
and disables GSSAPI channel bindings for that server certificate.
TLS connection acceptance loop (src/main.rs)
When config.tls.enabled is true, the server does not use axum::serve. Instead it runs a manual accept loop:
#![allow(unused)]
fn main() {
let mut server_cfg = akamu::tls::build_rustls_server_config(&config.tls)?;
server_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg));
// Pre-compute RFC 5929 tls-server-end-point channel binding once at startup.
let tls_channel_binding: Option<Arc<Vec<u8>>> = { ... };
loop {
tokio::select! {
_ = &mut shutdown => { break; }
result = listener.accept() => {
let (stream, peer_addr) = result?;
let acceptor = acceptor.clone();
let router = router.clone();
let tls_channel_binding = tls_channel_binding.clone();
tokio::spawn(async move {
let tls = match acceptor.accept(stream).await {
Ok(s) => s,
Err(e) => { tracing::warn!("TLS handshake failed: {e}"); return; }
};
let io = hyper_util::rt::TokioIo::new(tls);
let svc = hyper::service::service_fn(move |mut req| {
// Inject peer address so axum::extract::ConnectInfo works.
req.extensions_mut().insert(axum::extract::ConnectInfo(peer_addr));
// Inject pre-computed channel binding if available.
if let Some(ref b) = tls_channel_binding {
req.extensions_mut().insert(TlsServerEndpointBinding(b.as_ref().clone()));
}
...
});
hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
.serve_connection(io, svc)
.await
});
}
}
}
}
Each accepted TCP connection is handed to tokio_rustls::TlsAcceptor::accept, which completes the TLS handshake (including client certificate verification if client_auth is configured). TLS handshake failures log a warning via tracing::warn! and the task returns without serving any HTTP.
For the plain HTTP path, axum::serve(listener, router.into_make_service_with_connect_info::<SocketAddr>()).await is used without modification.
ALPN protocols ["h2", "http/1.1"] are negotiated; hyper’s auto::Builder handles both HTTP/1.1 and HTTP/2.
ConnectInfo is available in the TLS path. The accept loop explicitly inserts axum::extract::ConnectInfo(peer_addr) into each request’s extensions before routing, so handlers can use axum’s ConnectInfo<SocketAddr> extractor normally regardless of whether TLS is enabled.
Channel binding injection. The tls-server-end-point binding bytes (see Channel binding above) are pre-computed once at startup from the leaf certificate DER and stored as Option<Arc<Vec<u8>>>. Each spawned connection task clones the Arc and injects a TlsServerEndpointBinding extension into the request so GSSAPI handlers can access it without re-reading the certificate.
build_rustls_server_config (src/tls/mod.rs)
The central assembly function for the ACME listener:
#![allow(unused)]
fn main() {
pub fn build_rustls_server_config(
tls: &crate::config::TlsConfig,
) -> Result<rustls::ServerConfig, String>
}
- Calls
loader::load_server_cert_chainandloader::load_server_private_key. - Builds the provider:
Arc::new(rustls_native_ossl::default_provider()). - Filters
tls.protocolsto&rustls::version::TLS12and/or&rustls::version::TLS13. ReturnsErrif the resulting list is empty. - If
tls.client_authis present: buildsSyntaClientCertVerifierand calls.with_client_cert_verifier(verifier). - If absent: calls
.with_no_client_auth(). - Calls
.with_single_cert(certs, key)to install the server certificate and key.
build_admin_rustls_server_config (src/tls/mod.rs)
A parallel function for the dedicated admin listener:
#![allow(unused)]
fn main() {
pub fn build_admin_rustls_server_config(
admin: &crate::config::AdminConfig,
) -> Result<rustls::ServerConfig, String>
}
Differences from build_rustls_server_config:
- Always enables both TLS 1.2 and TLS 1.3 (not configurable via
protocols). - Client auth is optional: if
admin.ca_certsis empty,with_no_client_auth()is used; otherwise aSyntaClientCertVerifieris built withrequired = falseso the same listener serves both mTLS (cert path) and GSSAPI (no cert presented) connections. - Uses a fixed
ClientAuthConfigwithprofile = "rfc5280",max_chain_depth = 5,minimum_rsa_modulus = 2048, andallow_post_quantum = false. - Admin ALPN is
["http/1.1"]only (set by the caller after this function returns).
leaf_cert_der (src/tls/mod.rs)
#![allow(unused)]
fn main() {
pub fn leaf_cert_der(tls: &crate::config::TlsConfig) -> Result<Vec<u8>, String>
}
Returns the DER bytes of the first (leaf) certificate in the configured cert_file.
Used at startup to pre-compute the tls-server-end-point channel binding without
keeping a parsed certificate in memory.
Client Library Internals
This page documents the internal design of akamu-client and the akamu-cli
command-line tool: the DnsHookSolver, the RenewalConfig type contract,
AccountKey::from_jwk_private, and the certbot migration internals.
For the user-facing command reference, see akamu-cli — Command Reference.
Source layout
| File | Contents |
|---|---|
crates/akamu-client/src/account.rs | AccountKey, Account, key generation, alg_for_key, jwk_private_to_backend_key |
crates/akamu-client/src/challenge.rs | ChallengeSolver trait, Http01Solver, TlsAlpn01Solver, Dns01Helper, DnsPersist01Helper, DnsHookSolver |
crates/akamu-client/src/client.rs | AcmeClient — directory-aware async HTTP client with nonce management |
crates/akamu-client/src/csr.rs | build_csr — DER-encoded CSR construction |
crates/akamu-client/src/eab.rs | create_eab_jws — client-side EAB JWS construction (RFC 8555 §7.3.4) |
crates/akamu-client/src/error.rs | ClientError — unified error type |
crates/akamu-client/src/gssapi_eab.rs | fetch_eab_via_gssapi, GssapiEabResult — GSSAPI-authenticated EAB credential fetch |
crates/akamu-client/src/onion.rs | build_onion_csr — DER-encoded CSR for onion-csr-01 challenges (RFC 9799) |
crates/akamu-client/src/types.rs | Identifier, Order, Authorization, Challenge, RenewalConfig, AccountOptions, EabOptions, StarOrderParams, StarOrder, RenewalInfo |
crates/akamu-cli/src/import/certbot.rs | discover_accounts, discover_renewals, jwk_to_account_key, map_challenge_type, build_renewal_config, live_cert_paths |
AccountKey
AccountKey is the central type that owns an account’s private key and its
pre-computed derivative values. The struct holds four fields:
#![allow(unused)]
fn main() {
pub struct AccountKey {
priv_key: BackendPrivateKey, // synta-certificate private key handle
pub_jwk: JwkPublic, // pre-computed public JWK (from akamu-jose)
thumbprint: String, // pre-computed RFC 7638 thumbprint (base64url)
alg: &'static str, // JWS alg string ("ES256", "EdDSA", "ML-DSA-65", …)
}
}
All four values are computed once inside AccountKey::from_backend_key and
then stored. All subsequent callers (sign requests, key-authorization
computation, EAB payload) read from cache without re-deriving.
Key generation
AccountKey::generate(key_type) dispatches to generate_backend_key, which
calls the corresponding BackendPrivateKey::generate_* function:
key_type string | Function called |
|---|---|
"ec:P-256" (or "P-256") | BackendPrivateKey::generate_ec("P-256") |
"ec:P-384" (or "P-384") | BackendPrivateKey::generate_ec("P-384") |
"ec:P-521" (or "P-521") | BackendPrivateKey::generate_ec("P-521") |
"rsa:2048" (or "rsa2048") | BackendPrivateKey::generate_rsa(2048, 65537) |
"rsa:3072" (or "rsa3072") | BackendPrivateKey::generate_rsa(3072, 65537) |
"rsa:4096" (or "rsa4096") | BackendPrivateKey::generate_rsa(4096, 65537) |
"ed25519" | BackendPrivateKey::generate_ed25519() |
"ed448" | BackendPrivateKey::generate_ed448() |
"ml-dsa-44" (or "ML-DSA-44") | BackendPrivateKey::generate_ml_dsa("ML-DSA-44") |
"ml-dsa-65" (or "ML-DSA-65") | BackendPrivateKey::generate_ml_dsa("ML-DSA-65") |
"ml-dsa-87" (or "ML-DSA-87") | BackendPrivateKey::generate_ml_dsa("ML-DSA-87") |
Any other string returns ClientError::Crypto.
alg_for_key — JWS algorithm detection
alg_for_key derives the JWS alg string from the key material. It queries
the public key’s key_type() string and branches:
"ec"— callspub_key.ec_curve_name()to get the curve name and maps"P-256"/"prime256v1"→"ES256","P-384"/"secp384r1"→"ES384","P-521"/"secp521r1"→"ES512"."rsa"— always returns"PS256"(RSA-PSS with SHA-256)."ed25519"or"ed448"— returns"EdDSA".- Anything else — inspects the raw SPKI DER to detect ML-DSA.
ML-DSA SPKI OID detection. The FIPS 204 OIDs share a common prefix of eight bytes at offset 8 of the DER-encoded SPKI:
60 86 48 01 65 03 04 03
The byte at offset 16 distinguishes the parameter set:
| Byte at offset 16 | Algorithm |
|---|---|
0x11 | ML-DSA-44 |
0x12 | ML-DSA-65 |
0x13 | ML-DSA-87 |
Any other value returns ClientError::Crypto.
AccountKey::from_jwk_private
Builds an AccountKey from a raw private JWK JSON string — the format used
by certbot’s accounts/…/private_key.json. Supports EC (P-256, P-384,
P-521) and RSA. The entry point is:
#![allow(unused)]
fn main() {
pub fn from_jwk_private(json: &str) -> Result<Self, ClientError>
}
The implementation calls jwk_private_to_backend_key(json), which:
- Parses
jsonasserde_json::Value. - Reads
kty(case-insensitive, uppercased for matching). - EC path (
"EC"):- Reads
crv→ maps"P-256"/"P-384"/"P-521"to the curve string. - Decodes
d,x,yfrom base64url-no-padding. - Calls
BackendPrivateKey::from_ec_private_scalar(&d, &x, &y, curve).
- Reads
- RSA path (
"RSA"):- Decodes all eight CRT components from base64url-no-padding:
n,e,d,p,q,dp,dq,qi. - Constructs
synta_certificate::RsaPrivateComponents { n, e, d, p, q, dp, dq, qi }. - Calls
BackendPrivateKey::from_rsa_private_components(&components).
- Decodes all eight CRT components from base64url-no-padding:
- Any other
ktyreturnsErr("unsupported JWK kty: …").
After constructing the BackendPrivateKey, from_backend_key runs the same
path as generate: derives the public JWK, computes the thumbprint, and
determines the JWS alg string.
ChallengeSolver trait
#![allow(unused)]
fn main() {
pub trait ChallengeSolver: Send + Sync {
fn present(
&self,
token: &str,
key_auth: &str,
) -> Pin<Box<dyn Future<Output = Result<(), ClientError>> + Send + '_>>;
fn cleanup(
&self,
token: &str,
) -> Pin<Box<dyn Future<Output = Result<(), ClientError>> + Send + '_>>;
}
}
present is called before the ACME client triggers the challenge.
cleanup is called after the challenge completes (success or failure).
Both methods return boxed futures to allow dyn dispatch across the async
boundary without requiring async_trait.
Http01Solver
Http01Solver binds a minimal HTTP/1.1 server and serves
/.well-known/acme-challenge/<token> responses.
Internal design:
- Token storage is
Arc<RwLock<HashMap<String, String>>>(token → key_auth). start()binds0.0.0.0:<port>withTcpListener, then spawns a background accept loop withtokio::spawn. Each accepted connection gets its owntokio::spawnrunninghyper::server::conn::http1::Builder.present(token, key_auth)writes to theRwLockunder a write guard.cleanup(token)removes the entry under a write guard.handle_challengestrips the/.well-known/acme-challenge/prefix and returns the stored value with HTTP 200, or HTTP 404 for any other path.- All responses use
http_body_util::Full<Bytes>.
TlsAlpn01Solver
TlsAlpn01Solver serves ephemeral ACME challenge certificates for
tls-alpn-01 (RFC 8737).
Internal design:
- Certificate storage is
Arc<RwLock<HashMap<String, Arc<rustls::sign::CertifiedKey>>>>(domain → certified key). start()creates arustls::ServerConfigusing therustls-native-osslprovider, setsalpn_protocols = vec![b"acme-tls/1"], and spawns atokio_rustls::TlsAcceptoraccept loop. TheJoinHandleis stored inself.handle.present(domain, id_type, key_auth)performs all certificate construction:- Computes
SHA-256(key_auth)to get a 32-byte hash. - Encodes the
id-pe-acmeIdentifierextension as anOCTET STRINGcontaining the hash, then wraps it insynta_certificate::acme_types::Authorizationto produce the DER extension value. - Generates an ephemeral EC P-256 key with
BackendPrivateKey::generate_ec. - Builds a self-signed certificate using
synta_certificate::CertificateBuilderwith a 7-day validity window, the domain as CN, and theid-pe-acmeIdentifierextension marked critical. Forid_type == "ip"the SAN isiPAddress; otherwisedNSName. - Loads the key into rustls via
rustls_native_ossl::default_provider().key_provider.load_private_key. - Inserts the
rustls::sign::CertifiedKeyinto the SNI store under the domain name.
- Computes
SniResolverimplementsrustls::server::ResolvesServerCertby looking upclient_hello.server_name()in the store.cleanup()aborts the backgroundJoinHandle.
Dns01Helper and DnsPersist01Helper
Dns01Helper exposes one static method:
#![allow(unused)]
fn main() {
pub fn txt_value(key_auth: &str) -> Result<String, ClientError>
}
This returns base64url(SHA-256(key_auth)). The computation is done by
dns_txt_value in account.rs, which calls
synta_certificate::default_data_hasher().hash_data("sha256", key_auth.as_bytes()) and base64url-encodes the result.
DnsPersist01Helper is a different design — it does not hash the key
authorization. Instead, it builds the structured TXT record content
specified by draft-ietf-acme-dns-persist. It exposes two static methods:
#![allow(unused)]
fn main() {
// Non-wildcard: placed at _validation-persist.<domain>
pub fn txt_record(issuer_domain: &str, account_url: &str) -> String
// Returns: "<issuer_domain>; accounturi=<account_url>"
// Wildcard / subdomain coverage:
pub fn txt_record_wildcard(issuer_domain: &str, account_url: &str) -> String
// Returns: "<issuer_domain>; accounturi=<account_url>; policy=wildcard"
}
issuer_domain is taken from the issuer-domain-names array in the
server’s challenge object. account_url is the ACME account URL returned
at registration time.
Unlike dns-01, the dns-persist-01 record is long-lived; it is provisioned once and left in place — there is no cleanup call.
DnsHookSolver
DnsHookSolver implements DNS-01 and dns-persist-01 TXT record management
by delegating to an external hook script. The hook is never used for
http-01 or tls-alpn-01.
Struct:
#![allow(unused)]
fn main() {
pub struct DnsHookSolver {
hook: String, // path or shell command
}
}
Hook invocation — run_hook:
#![allow(unused)]
fn main() {
let output = tokio::process::Command::new(&self.hook)
.arg(operation) // "add" or "remove"
.env("AKAMU_DOMAIN", domain)
.env("AKAMU_TOKEN", token)
.env("AKAMU_TXT", &txt) // base64url(SHA-256(key_auth))
.env("AKAMU_KEY_AUTH", key_auth)
.output()
.await?;
}
Values are passed exclusively via environment variables, not command-line
arguments, to avoid leaking secrets through /proc/<pid>/cmdline.
Environment variables:
| Variable | Value |
|---|---|
AKAMU_DOMAIN | DNS name being validated |
AKAMU_TOKEN | ACME challenge token |
AKAMU_TXT | base64url(SHA-256(key_authorization)) |
AKAMU_KEY_AUTH | Full key authorization string ({token}.{thumbprint}) |
Exit code semantics: exit code 0 is success. Any non-zero exit code
produces ClientError::Crypto with the captured stderr included in the
message.
Public API:
deploy(domain, token, key_auth)— callsrun_hook("add", …)for dns-01.clean(domain, token, key_auth)— callsrun_hook("remove", …)for dns-01.deploy_persist(domain, txt_record)— for dns-persist-01: invokes the hook withaddand passes onlyAKAMU_DOMAINandAKAMU_TXT(the full structured record content built byDnsPersist01Helper). There is no correspondingcleanfor dns-persist-01 because the record is long-lived.
Environment variables for dns-persist-01 (deploy_persist):
| Variable | Value |
|---|---|
AKAMU_DOMAIN | DNS name being validated |
AKAMU_TXT | Full TXT record content ("issuer; accounturi=…[; policy=wildcard]") |
DnsHookSolver does not implement the ChallengeSolver trait directly
because the trait’s present/cleanup signatures do not carry the domain
name. Callers use deploy, clean, and deploy_persist explicitly.
fetch_eab_via_gssapi (gssapi_eab.rs)
fetch_eab_via_gssapi performs a one-shot authenticated GET to the server’s
/acme/eab endpoint using a Kerberos keytab and returns the EAB credentials
ready for use in a newAccount request.
Public types
#![allow(unused)]
fn main() {
pub struct GssapiEabResult {
pub principal: String, // e.g. "host/client.example.com@REALM"
pub kid: Option<String>, // present when eab_master_secret is configured
pub hmac_key: Option<String>, // base64url-encoded HMAC key
pub alg: Option<String>, // e.g. "HS256"
}
pub async fn fetch_eab_via_gssapi(
eab_url: &str,
keytab_file: &str,
) -> Result<GssapiEabResult, ClientError>
}
Internal steps
- Calls
GssClientCred::from_keytab(keytab_file)(fromakamu-gssapi) to load the initiator credential from the keytab. - Calls
derive_service_name(eab_url)to compute the target SPN asHTTP@<hostname>by stripping the URL scheme, port, and path. - Calls
akamu_gssapi::init_token(&cred, &target, None)insidetokio::task::spawn_blockingto avoid blocking the async executor on thegss_init_sec_contextFFI call. - Base64-encodes the resulting token and sends a single GET request with
Authorization: Negotiate <base64-token>. - Parses the JSON response into
GssapiEabResult.
Note: this function performs a single GSSAPI step (no multi-round-trip
loop). Kerberos AP-REQ exchanges are typically single-round-trip, so one
step is sufficient. Multi-round-trip SPNEGO is handled by
AdminClient::session_token in akamuctl for the admin API path.
derive_service_name
A private helper that strips the URL scheme (https:// or http://), path,
and port from eab_url to extract the bare hostname, then returns
HTTP@<hostname>. Returns ClientError::Http when no non-empty host
component can be extracted.
RenewalConfig
RenewalConfig is a Serialize + Deserialize struct that captures every
parameter needed to repeat a certificate issuance without user interaction.
Fields and serde defaults:
| Field | Type | Serde default | Notes |
|---|---|---|---|
server | String | (required) | ACME directory URL (base URL or full per-CA directory URL) |
ca | Option<String> | None | CA identifier for akamu multi-CA servers; derives directory URL as {server}/acme/{ca}/directory; ignored when server already ends in /directory; omitted from TOML when absent |
domains | Vec<Identifier> | (required) | Identifiers to certify |
account_key | PathBuf | (required) | Path to account private key PEM |
account_key_type | String | "ec:P-256" | Key type string for account key |
cert_path | PathBuf | (required) | Output path for certificate chain |
cert_key_path | PathBuf | (required) | Output path for certificate private key |
cert_key_type | String | "ec:P-256" | Key type string for certificate key |
challenge_type | String | "http-01" | Challenge type |
http_port | u16 | 80 | Port for http-01 challenge server |
tls_port | u16 | 443 | Port for tls-alpn-01 challenge server |
onion_key | Option<PathBuf> | None | Onion service private key path |
poll_timeout | u64 | 120 | Validation poll timeout in seconds |
contacts | Vec<String> | [] | Contact URIs for account registration |
eab_kid | Option<String> | None | EAB key identifier |
eab_key | Option<String> | None | EAB HMAC key (base64url) |
eab_alg | String | "HS256" | EAB HMAC algorithm |
gssapi_keytab | Option<PathBuf> | None | Path to a Kerberos keytab for GSSAPI-authenticated EAB fetch; mutually exclusive with eab_kid/eab_key |
dns_hook | Option<String> | None | DNS hook script path |
Fields with serde defaults use #[serde(default = "defaults::…")] pointing
to private free functions in the defaults module. Fields without a default
must be present in the TOML file.
TOML sidecar convention: akamu-cli issue writes the renewal config to
<cert-path>.renewal.toml (e.g. if cert_path is
/etc/akamu/certs/example.com.pem, the sidecar is
/etc/akamu/certs/example.com.pem.renewal.toml). akamu-cli renew --renewal-config reads this file.
TOML round-trip: all field types must survive a toml::to_string_pretty
→ toml::from_str round-trip. Identifier serializes as an inline table
{ type = "dns", value = "example.com" }.
Certbot import internals
crates/akamu-cli/src/import/certbot.rs implements the akamu-cli import certbot subcommand.
Account directory structure
Certbot stores accounts under:
<certbot-dir>/accounts/<ca-hostname>/<account-id>/
private_key.json # raw private JWK
regr.json # registration response (contains uri and body.contact)
meta.json # metadata (contains creation_dt)
discover_accounts walks accounts/ two levels deep. For each account
directory it:
- Reads
private_key.jsonas a raw JWK JSON string. - Calls
parse_regr_jsonto extracturi(the account URL) andbody.contact(the contact list) fromregr.json. - Calls
parse_meta_jsonto extractcreation_dtfrommeta.json. - Returns a
CertbotAccountstruct.
Any directory missing private_key.json is silently skipped.
Renewal file structure
Certbot writes one .conf file per certificate to <certbot-dir>/renewal/.
The file name stem is the primary domain name (with wildcard encoding; see
below).
discover_renewals reads all *.conf files in the renewal/ directory.
Each file is parsed by parse_ini_flat, which reads all key = value lines
from both the flat top section and the [renewalparams] section (section
headers and blank lines are skipped; # comments are stripped). The
relevant keys are:
| Key | Meaning |
|---|---|
server | ACME directory URL (default: Let’s Encrypt v2) |
authenticator | Challenge authenticator string |
preferred_challenges | Optional override for manual authenticator |
Challenge-type mapping
map_challenge_type(authenticator, preferred_challenges, dns_challenge)
maps certbot’s authenticator string to an akamu challenge type:
certbot authenticator | preferred_challenges | akamu challenge type | Warning? |
|---|---|---|---|
standalone | any | http-01 | No |
webroot | any | http-01 | No |
nginx | any | http-01 | No |
apache | any | http-01 | No |
manual | contains "dns" | value of --dns-challenge arg | Yes — manual DNS |
manual | anything else | http-01 | No |
tls-sni-01 | any | tls-alpn-01 | Yes — deprecated |
dns-* (any prefix) | any | value of --dns-challenge arg | Yes — hook needed |
| anything else | any | http-01 | No |
The --dns-challenge CLI argument controls whether DNS challenges map to
"dns-01" (default) or "dns-persist-01". The canonical_dns_challenge
helper performs this mapping.
Wildcard encoding convention
Certbot stores wildcard certificates under directory names that replace the
leading *. with _wildcard.:
| Domain | Certbot directory name |
|---|---|
*.example.com | _wildcard.example.com |
example.com | example.com |
build_renewal_config decodes this by checking whether renewal.domain
starts with "_wildcard." and, if so, substituting "*." at the start to
reconstruct the original domain name.
live_cert_paths encodes the inverse: given a domain starting with "*.",
it substitutes "_wildcard." to locate the certbot live/ subdirectory.
build_renewal_config
Takes a CertbotRenewal plus caller-supplied paths and options and constructs
a RenewalConfig. It does not attempt to detect the certificate key type
from the existing certbot certificate; it always writes "ec:P-256" for both
account_key_type and cert_key_type. The caller must update these if the
imported account uses a different key type.
The function returns (RenewalConfig, Option<&'static str>) where the second
element is a human-readable warning when the mapping is ambiguous (deprecated
authenticator, manual DNS required, etc.).
jwk_to_account_key
A thin wrapper around AccountKey::from_jwk_private. Used by the import
subcommand after discover_accounts has read the raw JWK JSON string, to
verify that the key can be loaded before writing it to disk.
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/, 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 # 25 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<sqlx::Error>converts 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.
not_before_overrideis honoured; values more than 5 minutes in the past are clamped.not_after_overrideis honoured; invalid values (not afternot_before) fall back tonot_before + validity_days * 86400.issue_with_paramsrejects issuance whenenforce_validity_cap=trueandvalidity_days > 200; allows exactly 200 days.
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 1 MiB returns
AcmeError::IncorrectResponse. - Key auth mismatch returns
AcmeError::IncorrectResponse. - Direct connection to a private IP (e.g.
192.168.1.1) withallow_private_ips = falsereturnsAcmeError::IncorrectResponse(SSRF guard on initial target). - Redirect to a private/link-local IP (e.g.
169.254.169.254) returnsAcmeError::IncorrectResponse(SSRF guard on redirect target). - Redirect followed successfully when
allow_private_ips = true(test-only override). - More than 10 redirects 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/dns_persist_01.rs
Tests cover both the parse_persist_until timestamp parser and the matches_record record-matching logic:
parse_persist_untilaccepts epoch, known timestamps, and leap-year dates; rejects bad separators, missingZ, and out-of-range fields.matches_recordverifies issuer match (case-insensitive, trailing-dot stripped),accounturimatch and mismatch, wildcardpolicy=denyhandling,persist-untilexpiry, unknown key-value tokens, and multi-issuer lists.- Async integration tests using a UDP DNS stub server: matching record returns
Ok, wrong issuer returns error, wildcard domain strips*.prefix, wildcard requirespolicy=deny, non-existent domain returns a DNS error.
src/validation/onion_csr_01.rs
Tests cover v3 onion address validation and CSR cryptographic binding:
validate_onion_v3accepts a valid 56-char base32 label and rejects v2 (16-char), too-short, wrong-chars, and non-.onion addresses.base32_decode_no_padhandles valid input, invalid chars, and non-zero trailing bits.decode_onion_pubkeyrejects wrong version bytes; decodes a synthetic v3 address correctly.ed25519_spki_derproduces the correct 44-byte DER structure.decode_utf8string_or_rawhandles DER UTF8String tags and falls back to raw UTF-8.- Full CSR validation: Ed25519 CSR key matches the onion address public key; missing nonce extension fails; wrong nonce value fails; wrong SAN fails.
src/validation/caa.rs
Tests cover the build_name_walk helper and check_caa async lookups using a UDP DNS stub server:
build_name_walkproduces the correct ordered list of names to query (single subdomain, deep subdomain, trailing-dot stripped, single-label returns empty).check_caa: emptyca_identitiesis a no-op; no CAA records returnsOk; matching issuer returnsOk; non-matching issuer returns error; wildcard falls back fromissuewildtoissue;validationmethodstag filtering;accounturimatching and mismatch; case-insensitive CA identity comparison.
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
All integration test files live under tests/. Each builds 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, except where a live TCP port is required by the protocol (tls-alpn-01 validation, cosigner HTTP server).
| File | What it covers |
|---|---|
tests/acme_flow.rs | Core ACME lifecycle: account creation, order creation, challenge signaling, status transitions, and certificate download |
tests/admin_auth.rs | Admin authentication paths: Bearer token, mTLS client-certificate, and expired-token rejection; operator deactivation purges live sessions; audit event end-to-end (write via JournalWriter, query via GET /admin/audit) |
tests/admin_rbac.rs | Table-driven RBAC: for every (route, method) pair and each of the four operator roles, verifies allowed roles are not 403 and disallowed roles get exactly 403 |
tests/ari_flow.rs | ACME Renewal Information (RFC 9773) query and renewal window logic |
tests/dns_persist_flow.rs | Full dns-persist-01 challenge flow against a local DNS stub server |
tests/mtc_cosigner_flow.rs | End-to-end ACME issuance followed by MTC checkpoint production, cosignature gathering from an inline cosigner HTTP server, and StandaloneCertificate verification |
tests/multi_ca.rs | Multi-CA routing: per-CA directory and CRL endpoints, legacy path falls through to default CA, unknown CA ID returns 404, CRL isolation across CAs, order CA isolation |
tests/tls_server.rs | Helper module providing a local TLS server for tls-alpn-01 integration tests |
tests/mtc_playground_compat.rs | Wire-compatibility tests for the C2SP tlog-tiles and signed-note implementation (RFC 9162 Merkle hashing, tile path encoding, checkpoint/cosignature note format, live HTTP endpoint smoke tests); optional DigiCert playground integration gated behind MTC_PLAYGROUND_DIR env var and --ignored |
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.
For tests that need a full AppState with multi-CA support, build cas as an IndexMap and populate crl_caches and link_headers as HashMaps keyed by CA ID. See the “Building a test AppState” section below for the canonical pattern.
Building a test AppState
Integration tests that exercise ACME handlers need a full AppState. The multi-CA refactor changed several fields; the canonical test-setup pattern is:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use indexmap::IndexMap;
// 1. Open an in-memory database with the full schema.
let db = crate::db::open(":memory:").await.unwrap();
// 2. Generate a test CA (or load from tempfile).
let ca_cfg = CaConfig { id: "default".to_string(), key_type: "ec:P-256".to_string(), .. };
let (key, cert_der) = crate::ca::init::load_or_generate(&ca_cfg).unwrap();
let ca_state = Arc::new(CaState {
id: ca_cfg.id.clone(),
key_type: ca_cfg.key_type.clone(),
key,
cert_der,
hash_alg: "sha256".into(),
validity_days: 90,
crl_url: None,
ocsp_url: None,
aki_bytes: vec![],
enforce_validity_cap: false,
crl_next_update_secs: 86400,
caa_identities: vec![],
});
// 3. Build the IndexMap of CAs.
let mut cas_map = IndexMap::new();
cas_map.insert(ca_cfg.id.clone(), ca_state.clone());
let cas = Arc::new(cas_map);
let default_ca_id = Arc::new(ca_cfg.id.clone());
// 4. Build per-CA CRL cache and Link headers.
let mut crl_caches_map: HashMap<String, crate::state::CrlCache> = HashMap::new();
crl_caches_map.insert(ca_cfg.id.clone(), Default::default());
let crl_caches = Arc::new(crl_caches_map);
let mut link_headers_map = HashMap::new();
let link_value = axum::http::HeaderValue::from_static(
"<https://acme.test/acme/directory>;rel=\"index\""
);
link_headers_map.insert(ca_cfg.id.clone(), Arc::new(link_value));
let link_headers = Arc::new(link_headers_map);
// 5. Build the outbound HTTPS client for challenge validation.
let validation_client = {
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.expect("native roots")
.https_or_http()
.enable_http1()
.build();
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(https)
};
// 6. Assemble AppState.
let state = Arc::new(AppState {
config: Arc::new(config),
db,
db_kind: crate::db::DbKind::Sqlite,
cas,
default_ca_id,
mtc: Arc::new(MtcState {
log: None,
algorithm: synta_mtc::crypto::HashAlgorithm::Sha256,
signing_key: None,
signing_hash_alg: "sha256".into(),
cosigner_clients: vec![],
_log_lock: None,
}),
profiles: crate::profiles::ProfileRegistry::empty(&ca_state),
tls: None,
spki_cache: Arc::new(RwLock::new(HashMap::new())),
nonces: Arc::new(NonceBucket::new()),
link_headers,
validation_client,
crl_caches,
gss_cred: None,
admin_gss_cred: None,
eab_master_secret: None,
audit: Arc::new(crate::audit::AuditState::new()),
audit_policy: Arc::new(crate::audit::AuditPolicy::default()),
admin_sessions: None,
admin_auth_limiter: None,
startup_time: std::time::Instant::now(),
});
}
Key points:
CaStaterequires all fields:id,key_type,key,cert_der,hash_alg,validity_days,crl_url,ocsp_url,aki_bytes,enforce_validity_cap,crl_next_update_secs, andcaa_identities. None have defaults.AppState::casis anArc<IndexMap<String, Arc<CaState>>>, not a singleArc<CaState>. All lookups go throughstate.get_ca(id)orstate.default_ca().AppState::db_kindmust be set; useDbKind::Sqlitefor in-memory test databases.AppState::profilesholds the certificate profile registry; useProfileRegistry::empty(&ca)to get a no-op registry that falls back to CA defaults for all issuance.AppState::crl_cachesis anArc<HashMap<String, CrlCache>>. Each entry must be keyed by the same CA ID as the correspondingcasentry;Default::default()yieldsArc::new(Mutex::new(None)).AppState::link_headersis anArc<HashMap<String, Arc<HeaderValue>>>. Theacme_headershelper falls back to the default CA’s header when a per-CA header is missing, but tests should always populate the map for every registered CA ID to avoid log noise.AppState::noncesholds the in-memory anti-replay nonce store; construct withArc::new(NonceBucket::new()).AppState::spki_cacheholds account key material; initialise with an emptyRwLock-protectedHashMap.AppState::validation_clientis required even for tests that do not perform outbound challenge validation; build a standard HTTPS client as shown above.AppState::auditandAppState::audit_policyare always required; useAuditState::new()andAuditPolicy::default()respectively.- Optional fields (
tls,gss_cred,admin_gss_cred,eab_master_secret,admin_sessions,admin_auth_limiter) should be set toNoneunless the test exercises those features. - Tests that only need a single CA can keep using a single entry in the
IndexMap; there is no requirement to configure multiple CAs in tests.
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.
Test Data Generation (akamu-seedgen)
akamu-seedgen is a standalone binary that populates a SQLite database with
realistic PKI test data. It runs an in-process Akāmu server, drives the full
ACME protocol to issue real certificates, then applies direct database
mutations to produce the complete range of PKI lifecycle states — revoked,
expired, near-expiry, STAR, delegation, ARI replacement chains — that would
accumulate naturally over months in a production deployment.
The output is a SQLite database and a ready-to-run akamu.toml config that
can be dropped into a dev or test Akāmu instance with no further setup.
Quick start
# Build the tool
cargo build -p akamu-seedgen
# Run with built-in defaults (~200 certs, 2 CAs, < 30 s)
cargo run -p akamu-seedgen -- --output /tmp/mytest.sqlite3
# Run with a specific scale spec
cargo run -p akamu-seedgen -- \
--spec contrib/seedgen/small.toml \
--output /tmp/small.sqlite3
# Launch a dev akamu + webui dev server against the result
./contrib/seedgen/dev.sh /tmp/small
Browse to http://localhost:9000/ui/ once the dev server starts.
The run prints EAB credentials for the seeded administrator at the end:
Web UI login (EAB tab at /ui/login):
Key ID (kid): seedgen-admin
HMAC key (base64url): <key>
Paste those two values into the EAB tab on the login page to authenticate.
Command-line reference
akamu-seedgen [OPTIONS]
Options:
-s, --spec <FILE> Population spec (TOML). Omit to use built-in defaults.
-o, --output <FILE> Output SQLite file [default: test-data.sqlite3]
--seed <N> Override the RNG seed from the spec
-v, --verbose Print per-cert issuance progress
--output-format text|json [default: text]
-h, --help
The output file and an artifacts directory (named after the output file with
its extension stripped) are both produced. For --output foo.sqlite3 the
layout is:
foo/ ← artifacts directory
akamu.toml ← ready-to-run Akāmu config
ca-<id>/
ca.key ← CA private key (mode 0640)
ca.crt ← CA certificate (mode 0644)
foo.sqlite3 ← database
Spec file format
A spec file is a TOML document describing the desired population. All sections are optional; omitting a section uses built-in defaults.
[global]
[global]
seed = 42 # RNG seed — same seed + same spec = identical output
output = "test-data.sqlite3" # default output path (overridden by --output)
seed controls the CSPRNG used for key generation, domain names, and
revocation reason selection. The same seed with the same spec always produces
the same database.
[[ca]]
At least one CA is required. Exactly one must have is_default = true.
[[ca]]
id = "ec-p256" # URL prefix: /acme/ec-p256/directory
is_default = true
key_type = "ec:P-256" # ec:P-{256,384,521}, rsa:{2048,3072,4096},
# ed25519, ed448, ml-dsa-{44,65,87}
hash_alg = "sha256" # sha256 | sha384 | sha512
validity_days = 90 # default end-entity cert validity
common_name = "EC P-256 Test CA"
organization = "Akamu Test PKI"
ca_validity_years = 10
[[cross_sign]]
Each entry makes the issuer CA sign the subject CA’s public key, producing a cross-certificate stored in the database.
[[cross_sign]]
issuer = "ec-p256"
subject = "rsa-2048"
validity_years = 5
Both issuer and subject must reference [[ca]] IDs defined in the same
spec. Self-sign (issuer == subject) is rejected.
[[profile]]
Profiles are added to the in-process server and emitted in the generated
akamu.toml so they are available when the instance is restarted.
[[profile]]
id = "tls-server"
description = "Standard TLS server certificate"
eku = ["server_auth"]
key_usage = ["digital_signature"]
validity_days = 90
allowed_key_types = [] # empty = any key type accepted
ca_ids = [] # empty = all CAs
allowed_key_types restricts the leaf certificate key algorithm. This is
independent of the CA key type: an rsa:2048 CA can issue a certificate for
an ec:P-256 subscriber key.
[[scenario]]
A scenario drives one batch of ACME accounts and certificates under a single CA + profile combination.
[[scenario]]
name = "ec-tls"
ca_id = "ec-p256"
profile_id = "tls-server"
num_accounts = 25
[scenario.certs]
valid = 300 # left in status=valid
revoked = 75 # revoked; reasons spread across RFC 5280 codes 0,1,3,4,5
expired = 60 # backdated 1–2 years into the past
near_expiry = 25 # not_after within 3–30 days from now
ari_chains = 8 # replacement chains; each chain = 3 certs (A→B→C)
star_active = 8 # STAR orders, active
star_canceled = 4 # STAR orders, canceled
delegation = 2 # processing-state delegation orders (no cert issued)
pending_orders = 3 # stale pending orders, never finalized
invalid_orders = 3 # orders set to status=invalid
[scenario.certs.key_types]
# Relative weights for leaf certificate key selection.
"ec:P-256" = 5
"ec:P-384" = 2
"rsa:2048" = 2
"ed25519" = 1
[scenario.accounts]
deactivated = 2 # this many accounts are deactivated after cert issuance
All cert counts are independent; they do not need to sum to any particular total. Each count causes that many issuance flows or DB mutations.
Pre-built specs
Ready-made specs for six scale points live in contrib/seedgen/:
| Spec | CAs | ~Certs | ~Accounts | Est. runtime |
|---|---|---|---|---|
tiny.toml | 1 (ec:P-256) | 100 | 10 | < 30 s |
small.toml | 2 (ec:P-256, rsa:2048) | 1 000 | 50 | 2–5 min |
small-pqc.toml | 2 (ec:P-256, ml-dsa-44) | 1 000 | 50 | 2–5 min |
medium.toml | 4 (ec:P-256/384, rsa:2048/4096) | 10 000 | 100 | 10–20 min |
medium-pqc.toml | 4 (ec:P-256, rsa:2048, ml-dsa-44/65) | 10 000 | 100 | 10–20 min |
large.toml | 8 (all classical) | 25 000 | 1 000 | 45–90 min |
xlarge.toml | 16 (classical + PQC + hash variants) | 50 000 | 10 000 | 3–6 h |
xxlarge.toml | 32 (all xlarge + long/short-lived variants) | 50 000 | 10 000 | 6–12 h |
The *-pqc specs pair classical and post-quantum CAs with cross-signs in both
directions, exercising hybrid trust chain building in the web UI.
Output layout
After a successful run the artifacts directory is structured so that akamu serve can be started directly from it:
<stem>/
akamu.toml ready-to-run config (HTTP, port 8080)
ca-<id>/
ca.key PEM private key, mode 0640
ca.crt PEM certificate
akamu.log written by dev.sh (not by seedgen itself)
<stem>.sqlite3 database file
akamu.toml references the database with an absolute path so the server can
be started from any working directory. CA key and certificate paths are also
absolute.
The generated config also includes:
[admin]
[server]
http_validation_allow_private_ips = true
http_validation_port = 5002
The empty [admin] section enables the admin session store with all-default
settings; without it the EAB web UI login cannot create sessions. The
[server] settings allow new ACME orders to be issued against the seeded
instance without additional config edits.
Dev workflow
contrib/seedgen/dev.sh starts both Akāmu and the Vite dev server in a
single command:
./contrib/seedgen/dev.sh <artifacts-dir>
or equivalently from webui/:
npm run dev:seed -- <artifacts-dir>
The script:
- Validates that
<artifacts-dir>/akamu.tomlexists. - Locates the
akamubinary (target/debug/akamu,target/release/akamu, or builds it if absent; override with$AKAMU_BIN). - Parses the
listen_addrport fromakamu.toml. - Starts
akamu akamu.tomlfrom inside the artifacts directory so the relative database path resolves correctly. - Polls
GET /acme/directoryuntil Akāmu accepts connections (timeout: 30 s; exits early with the log if the process crashes). - Sets
AKAMU_SERVER_URL=http://localhost:<port>and starts the Vite dev server athttp://localhost:9000/ui/. - On Ctrl-C or Vite exit, terminates the Akāmu process.
Akāmu stdout/stderr is written to <artifacts-dir>/akamu.log.
Environment variables:
| Variable | Default | Purpose |
|---|---|---|
AKAMU_BIN | auto-detected | Path to the akamu binary |
AKAMU_LOG | warn | RUST_LOG filter for the Akāmu process |
VITE_PORT | 9000 | Port for the Vite dev server |
The Vite proxy in vite.config.ts forwards /admin and /acme paths to
AKAMU_SERVER_URL, so the browser sees a single origin with no CORS issues.
Admin credentials
Every run inserts one administrator operator (seedgen-admin) and one linked
EAB key into the database, then prints them at the end of the summary:
Web UI login (EAB tab at /ui/login):
Key ID (kid): seedgen-admin
HMAC key (base64url): <43-char base64url string>
The HMAC key is derived from the seeded RNG, so the same seed value in the
spec always produces the same key. Paste kid and the HMAC key directly into
the EAB tab on the login page; the UI computes the HMAC-SHA256 signature
client-side.
The operator has role administrator and full access to every admin API
endpoint. It is intended for local development only — never import a seeded
database into a production instance.
Internal architecture
akamu-seedgen is a workspace crate at crates/akamu-seedgen/. Its modules
map directly to implementation steps:
| Module | Responsibility |
|---|---|
spec.rs | Deserialises and validates the TOML spec |
server.rs | Starts the in-process Akāmu server; persists CA key/cert files |
challenge.rs | HTTP-01 challenge responder (axum on port 0, RwLock<HashMap>) |
acme.rs | Thin wrappers over akamu-client: register account, issue cert |
names.rs | Seeded deterministic fake domain/org/contact names (ChaCha8Rng) |
setup.rs | Registers profiles, issues cross-certs, creates the seeded admin operator + EAB key |
scenarios.rs | Per-scenario issuance loop; produces Vec<(IssuedCert, TargetState)> |
postprocess.rs | Direct sqlx mutations for non-ACME states; WAL checkpoint |
config_writer.rs | Renders and writes akamu.toml |
summary.rs | Tallies counts; prints text or JSON summary |
main.rs | Clap CLI; orchestrates all modules |
Reproducibility
The ChaCha8Rng is seeded from global.seed at startup and threaded through
names.rs, scenarios.rs, and setup.rs. All random choices (domain names,
key type selection, revocation reasons, admin HMAC key) consume from this
single RNG in deterministic order, so the same seed with the same spec always
produces the same database and the same admin credentials.
The output database is opened directly as a file-backed SQLite pool at startup.
All writes go straight to <output>.sqlite3; no in-memory copy is made.
postprocess::run() finishes with PRAGMA wal_checkpoint(TRUNCATE) to merge
any pending WAL frames into the main file before the process exits.
In-process server lifecycle
server::start() mirrors the pattern in benches/acme_bench.rs:
- Build one
CaConfigper spec[[ca]]entry, pointing key/cert files to<artifacts-dir>/ca-<id>/. - Call
ca::init::load_or_generate()for each CA — generates key + self-signed cert on first run, loads existing files on subsequent runs. - Assemble
AppStatewith all CAs, the output SQLite pool, and MTC disabled. - Bind a random TCP port; start
axum::servein a background task. - Signal readiness via
tokio::sync::oneshotbefore entering the accept loop.
Post-processing
After all ACME issuance completes, postprocess::run() applies state mutations
directly to the database:
| State | Mechanism |
|---|---|
revoked | db::certs::revoke() — sets status, revoked_at, revocation_reason |
expired | UPDATE certificates SET not_before, not_after to 1–2 years ago |
near_expiry | UPDATE certificates SET not_after to 3–30 days from now |
ARI chain | db::certs::mark_replaced() + UPDATE orders SET replaces |
invalid_orders | UPDATE orders SET status='invalid', expires to the past |
Finally, PRAGMA wal_checkpoint(TRUNCATE) is run so the database file is
self-contained when the process exits.
Extending the tool
To add a new target state (e.g. on_hold revocation):
- Add the variant to
TargetStateinscenarios.rs. - Assign the new state in
scenarios::run_scenario()for the appropriate cert count. - Add a matching arm in
postprocess::run()that performs the database mutation. - Add a counter to
PostprocessStatsand include it insummary::Summary.
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 -- --check | — |
clippy | cargo clippy -- -D warnings | build |
doc | cargo doc --no-deps + mdbook build docs/ | build |
test | cargo test --features test-utils | 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 CI workflows
The project is hosted on Codeberg and uses Forgejo Actions
(.github/workflows/ci.yml). Each workflow job runs on a self-hosted
vdali runner inside the quay.io/hummingbird/rust:latest-builder
container image. Required system packages (OpenSSL, SQLite, libldap,
Kerberos, clang, etc.) are installed via dnf at the start of each job.
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 conventional commit format with a mandatory scope:
<type>(<scope>): <short summary (imperative, lowercase, no period)>
[optional body explaining why the change was made]
Signed-off-by: Your Name <email@example.com>
The scope identifies the subsystem or area affected (e.g. multi-ca,
admin, routes, db, tls, developer, docs, cli). A
Signed-off-by trailer is required on every commit (use git commit -s
or git commit --signoff).
Types used in this repository:
| Type | When to use |
|---|---|
feat | A new feature visible to users or operators |
fix | A bug fix |
docs | 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 |
style | Formatting-only changes (e.g. cargo fmt application) |
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:
docs(developer): update TLS chapter to match current implementation
fix(multi-ca): enforce ca_ra revocation scope, set pathLen=0 on cross-certs
feat(multi-ca): core multi-CA infrastructure, authz isolation, and ca_ra scoping
fix(security): prevent LIKE injection, STAR race, and header parse panics
test(admin): add admin auth and RBAC integration tests
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, sqlx, or server-specific dependencies to akamu-jose or akamu-client — they must remain usable without a running server.