Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Akāmu is a self-hosted certificate authority that speaks the ACME protocol defined in RFC 8555. It is written in Rust and is designed to be operated inside a private network or behind a reverse proxy, issuing X.509 certificates to ACME clients such as certbot, acme.sh, or any RFC 8555-compliant library. The project is organized as a Cargo workspace. In addition to the server binary, it ships standalone client libraries — see Client Libraries.

For a detailed breakdown of RFC and draft coverage — including which sections are implemented, which are intentionally omitted, and post-quantum support — see the RFC Support Reference.

What it does

  • Implements the full RFC 8555 ACME server protocol: directory, nonces, accounts, orders, authorizations, challenges, certificate issuance, and revocation.
  • Validates domain ownership using http-01, dns-01, tls-alpn-01, and dns-persist-01 challenge types (RFC 8555 §8, RFC 8737, and the Let’s Encrypt dns-persist-01 specification).
  • Issues end-entity certificates signed by a built-in Certificate Authority whose key and self-signed root are generated automatically on first run, or loaded from existing PEM files.
  • Maintains a SQLite database for all ACME objects (accounts, orders, authorizations, challenges, certificates, nonces).
  • Generates and serves CRLs (Certificate Revocation Lists).
  • Exposes OCSP responder URLs in issued certificates when configured.
  • Implements the ACME Renewal Information extension (RFC 9773) so ACME clients know when to renew.
  • Optionally appends issued certificates to a Merkle Tree Certificate transparency log using the synta-mtc library.
  • When external_account_required = true, performs full HMAC verification of the externalAccountBinding JWS (HS256/HS384/HS512), confirms the payload is the account key, and atomically consumes the EAB key on account creation. Keys are provisioned in the TOML config under [server.eab_keys].
  • Optionally terminates TLS directly using rustls, with an auto-generated certificate on first run. Supports mutual TLS (mTLS) client certificate authentication with configurable CA trust anchors, chain depth, RSA modulus enforcement, and post-quantum client certificate acceptance.

What it does not do

  • It does not serve the CRL or OCSP responses over HTTP itself; those endpoints must be provided separately if you enable crl_url or ocsp_url.
  • It does not support wildcard certificates via http-01 or tls-alpn-01 (only dns-01 and dns-persist-01 can authorize wildcard identifiers per RFC 8555 §7.1.3).

Technology stack

ComponentLibrary
Async runtimetokio
HTTP frameworkaxum 0.8
Databaserusqlite (system SQLite) + tokio-rusqlite
Schema migrationsrusqlite_migration
X.509 / PKCS#10 / CRLsynta-certificate
MTC transparency logsynta-mtc
DNS resolutionhickory-resolver
TLS serveraxum-server + rustls
TLS clientrustls + tokio-rustls
HTTP clienthyper 1
ConfigurationTOML
JWK/JWS primitivesakamu-jose (workspace crate)
ACME client libraryakamu-client (workspace crate)
CLIakamu-cli (workspace crate)

Standards implemented

Quick navigation

New to Akāmu? Start with the Quick Start guide. If you want to understand every configuration key, see the Configuration Reference. Developers should read the Architecture chapter first — it includes a full system architecture diagram covering all subsystems and their interactions.

Installation

Prerequisites

  • Rust toolchain 1.75 or later (install via rustup)
  • OpenSSL development headers (required by synta-certificate’s cryptography backend)
  • The synta family of crates, which are path dependencies and must be checked out alongside this repository

Fedora / RHEL

sudo dnf install openssl-devel

Debian / Ubuntu

sudo apt install libssl-dev

Checking out the source

Akāmu depends on three path-based crates from the synta workspace. Both repositories must be present on the local filesystem:

git clone <akamu-repo> akamu
git clone <synta-repo> synta

The Cargo.toml in Akāmu contains:

synta            = { path = "/home/abokovoy/src/upstream/synta" }
synta-certificate = { path = "/home/abokovoy/src/upstream/synta/synta-certificate" }
synta-x509-verification = { path = "/home/abokovoy/src/upstream/synta/synta-x509-verification" }
synta-mtc        = { path = "/home/abokovoy/src/upstream/synta/synta-mtc" }

Adjust the paths to match where you cloned synta before building.

Building from source

The repository is a Cargo workspace with four members: the akamu server binary, akamu-jose, akamu-client, and akamu-cli.

cd akamu
cargo build --release

This compiles all four workspace members. The binaries are placed at:

  • target/release/akamu — the ACME server
  • target/release/akamu-cli — the command-line client

To build only the server:

cargo build --bin akamu --release

To build only the CLI:

cargo build --bin akamu-cli --release

Note: The first build downloads and compiles all dependencies including bundled SQLite and the OpenSSL fork used by synta-certificate. It can take several minutes on a first run.

Verifying the build

./target/release/akamu --help

The binary accepts a single optional argument: the path to the configuration file (defaults to config.toml in the current directory).

Installing the binary

Copy the binary to a location in $PATH:

sudo install -m 0755 target/release/akamu /usr/local/bin/akamu

systemd service (optional)

Create /etc/systemd/system/akamu.service:

[Unit]
Description=ACME Certificate Server
After=network.target

[Service]
Type=simple
User=akamu
Group=akamu
ExecStart=/usr/local/bin/akamu /etc/akamu/config.toml
Restart=on-failure
RestartSec=5s

# Logging
StandardOutput=journal
StandardError=journal

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/akamu /etc/akamu

[Install]
WantedBy=multi-user.target

Then enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now akamu

Running tests

cargo test

cargo test runs tests across all workspace members: the server, akamu-jose, and akamu-client. To limit the run to a specific crate:

cargo test -p akamu          # server tests only
cargo test -p akamu-jose     # JWK/JWS primitive tests
cargo test -p akamu-client   # ACME client library tests

All tests are self-contained and do not require external services. Some integration tests start local HTTP or TLS servers on ephemeral ports.

First Run

This chapter walks through the minimal steps to get Akāmu running and reachable by ACME clients.

1. Create directories

sudo mkdir -p /etc/akamu /var/lib/akamu
sudo chown akamu:akamu /etc/akamu /var/lib/akamu
sudo chmod 0750 /etc/akamu /var/lib/akamu

2. Write a minimal configuration file

Copy the example configuration and adjust it:

sudo cp config.toml.example /etc/akamu/config.toml
sudo chmod 0640 /etc/akamu/config.toml

At a minimum you need:

listen_addr = "0.0.0.0:8080"
base_url    = "https://acme.example.com"

[database]
path = "/var/lib/akamu/akamu.db"

[ca]
key_file  = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"

[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled  = false

Replace acme.example.com with the actual hostname your ACME clients will use.

Note: listen_addr is the address the server binds to. base_url is 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 on 0.0.0.0:8080 and have a reverse proxy forward HTTPS traffic from port 443 to that address.

3. Start the server

akamu /etc/akamu/config.toml

On first run, because neither ca.key.pem nor ca.cert.pem exist, the server generates a new EC P-256 CA key and a 10-year self-signed CA certificate and writes them to the configured paths. You will see log output like:

INFO akamu: loading config from '/etc/akamu/config.toml'
INFO akamu: opening database '/var/lib/akamu/akamu.db'
INFO akamu: loading CA from '/etc/akamu/ca.key.pem'
INFO akamu::ca::init: Generating new CA key (ec:P-256) — writing to /etc/akamu/ca.key.pem and /etc/akamu/ca.cert.pem
INFO akamu::ca::init: Generated CA certificate (ec:P-256, 10 years)
INFO akamu: ACME server listening on 0.0.0.0:8080 (base_url=https://acme.example.com)

4. Verify the server is running

curl -s https://acme.example.com/acme/directory | python3 -m json.tool

Expected output:

{
    "keyChange": "https://acme.example.com/acme/key-change",
    "meta": {},
    "newAccount": "https://acme.example.com/acme/new-account",
    "newNonce": "https://acme.example.com/acme/new-nonce",
    "newOrder": "https://acme.example.com/acme/new-order",
    "revokeCert": "https://acme.example.com/acme/revoke-cert"
}

5. Install the CA certificate in your trust store (optional)

ACME clients will reject certificates issued by an unknown CA unless you install the CA certificate in your system’s trust store or tell the client to trust it explicitly.

Copy the CA certificate:

sudo cp /etc/akamu/ca.cert.pem /usr/local/share/ca-certificates/akamu-ca.crt
sudo update-ca-certificates          # Debian/Ubuntu
# or
sudo update-ca-trust                 # Fedora/RHEL

Logging

The server uses the tracing crate. Control log verbosity with the RUST_LOG environment variable:

RUST_LOG=debug akamu /etc/akamu/config.toml

Useful levels: error, warn, info (default), debug, trace.

Reverse proxy configuration (nginx)

The server speaks plain HTTP internally. An nginx TLS termination configuration:

server {
    listen 443 ssl;
    server_name acme.example.com;

    ssl_certificate     /etc/nginx/ssl/acme.example.com.crt;
    ssl_certificate_key /etc/nginx/ssl/acme.example.com.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Your First Certificate

This chapter shows how to obtain a certificate from your freshly running Akāmu using two common ACME clients: certbot and acme.sh.

Both examples use the http-01 challenge, which is the simplest to set up: the ACME server makes an HTTP request to port 80 of the domain being validated.

Before you begin

Make sure:

  1. Akāmu is running and reachable at https://acme.example.com/acme/directory.
  2. 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.
  3. 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

  1. certbot fetches the directory at https://acme.example.com/acme/directory.
  2. It creates an ACME account with the provided email address.
  3. It places a new order for yourdomain.com.
  4. Akāmu creates an authorization and a set of challenge objects for the domain.
  5. certbot writes the key authorization file to /.well-known/acme-challenge/<token> on port 80.
  6. certbot signals readiness by POSTing to the challenge URL.
  7. Akāmu fetches http://yourdomain.com/.well-known/acme-challenge/<token> and verifies the response matches the expected key authorization.
  8. Once the challenge is valid, certbot POSTs a CSR to the finalize URL.
  9. Akāmu validates the CSR, issues the certificate, and returns the certificate URL.
  10. 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_name and organization from your [ca] configuration.
  • Subject Alternative Name: contains DNS:yourdomain.com.
  • Extended Key Usage: TLS Web Server Authentication.
  • Validity: 90 days from issuance (default; adjustable via validity_days).

Renewal

Both clients support automatic renewal. They check the certificate expiry and renew when less than 30 days remain.

For certbot:

sudo systemctl enable --now certbot.timer

For acme.sh, renewal is installed automatically as a cron job during --install-cert.

The ACME Renewal Information endpoint (/acme/renewal-info/<cert_id>) is available for clients that support RFC 9773. It suggests a renewal window in the last third of the certificate’s validity period by default.

Troubleshooting

Challenge validation fails with a connection error

Ensure port 80 is open on the domain being validated. For the http-01 challenge the server makes a plain HTTP request to port 80; no TLS is involved.

Certificate is not trusted

The certificate is issued by your private CA. Install the CA certificate (/etc/akamu/ca.cert.pem) in the trust store of every system that needs to trust the issued certificates.

“account does not exist” error on renewal

If you re-created the database, existing accounts were lost. Re-register with certbot register or re-run acme.sh --register-account.

Using akamu-client (Rust)

If you are writing a Rust application and want to obtain a certificate programmatically, you can use the akamu-client library directly. For command-line usage without writing Rust code, see akamu-cli.

use akamu_client::{
    AccountKey, AccountOptions, AcmeClient,
    Http01Solver, Identifier, build_csr,
};
use std::{fs, time::Duration};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Generate or load an account key
    let key = if std::path::Path::new("account.pem").exists() {
        let pem = fs::read("account.pem")?;
        AccountKey::from_pem(&pem)?
    } else {
        let k = AccountKey::generate("ec:P-256")?;
        fs::write("account.pem", k.to_pem()?)?;
        k
    };

    // 2. Connect to the ACME directory
    let client = AcmeClient::new("https://acme.example.com/acme/directory").await?;

    // 3. Register an account (agree to Terms of Service)
    let opts = AccountOptions {
        contacts: vec!["mailto:admin@example.com".to_string()],
        agree_tos: true,
        eab: None,
    };
    let account = client.new_account(&key, opts).await?;

    // 4. Place an order for the desired identifiers
    let ids = vec![Identifier::Dns("example.com".to_string())];
    let order = client.new_order(&account, ids).await?;

    // 5. Start the built-in http-01 solver on port 80
    let solver = Http01Solver::new(80);
    solver.start().await?;

    // 6. Solve each authorization
    for authz_url in &order.authorizations {
        let authz = client.get_authorization(&account, authz_url).await?;
        let challenge = authz
            .challenges
            .into_iter()
            .find(|c| c.challenge_type == "http-01")
            .ok_or("http-01 challenge not available")?;

        // Present the key authorization at the well-known URL
        let key_auth = account.key_authorization(&challenge.token);
        solver.present(&challenge.token, &key_auth).await?;

        // Signal readiness to the ACME server
        client.trigger_challenge(&account, &challenge).await?;

        // Poll until the authorization is validated
        loop {
            tokio::time::sleep(Duration::from_secs(2)).await;
            let updated = client.get_authorization(&account, authz_url).await?;
            match updated.status.as_str() {
                "valid"   => break,
                "invalid" => return Err("authorization failed".into()),
                _         => continue,
            }
        }

        // Remove the challenge token from the solver
        solver.cleanup(&challenge.token).await?;
    }

    // 7. Generate a certificate key, build a CSR, and finalize the order
    let cert_key = AccountKey::generate("ec:P-256")?;
    fs::write("cert.key.pem", cert_key.to_pem()?)?;

    let csr_der  = build_csr(&["example.com"], &cert_key)?;
    let finalized = client.finalize(&account, &order, &csr_der).await?;

    let cert_url = finalized.certificate.ok_or("no certificate URL")?;

    // 8. Download the certificate bundle and write to disk
    let pem = client.download_certificate(&account, &cert_url).await?;
    fs::write("cert.pem", &pem)?;

    println!("Certificate written to cert.pem");
    println!("Private key written to cert.key.pem");
    Ok(())
}

Add the following to your Cargo.toml:

[dependencies]
akamu-client = { path = "/path/to/akamu/crates/akamu-client" }
tokio = { version = "1", features = ["full"] }

[patch.crates-io]
openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
openssl     = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }

The [patch.crates-io] block is required because akamu-client depends on the PQC OpenSSL fork. See Client Libraries Overview for details.

Configuration Reference

Akāmu reads a single TOML configuration file whose path is passed as the first command-line argument:

akamu /etc/akamu/config.toml

If no argument is given, the server looks for config.toml in the current working directory.

The file is parsed once at startup. Changes require a restart. Unknown keys produce a parse error on startup (serde’s strict TOML parser).

Complete example

listen_addr = "0.0.0.0:8080"
base_url    = "https://acme.example.com"

[database]
path = "/var/lib/akamu/akamu.db"

[ca]
key_file         = "/etc/akamu/ca.key.pem"
cert_file        = "/etc/akamu/ca.cert.pem"
key_type         = "ec:P-256"
hash_alg         = "sha256"
validity_days    = 90
ca_validity_years = 10
common_name      = "Example ACME CA"
organization     = "Example Org"
crl_url          = "http://acme.example.com/crl/ca.crl"
ocsp_url         = "http://ocsp.example.com"

[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled  = false

[server]
terms_of_service_url   = "https://acme.example.com/tos.html"
website_url            = "https://acme.example.com"
caa_identities         = ["acme.example.com"]
external_account_required = false
order_expiry_secs      = 86400
authz_expiry_secs      = 86400
max_body_bytes         = 65536
ari_retry_after_secs   = 21600
allow_subdomain_auth   = false
star_min_lifetime_secs = 86400
star_max_duration_secs = 31536000
star_allow_certificate_get = true
tor_connectivity_enabled  = false

[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
"client-auth"     = "https://acme.example.com/docs/profiles/client-auth"

Top-level keys

listen_addr

Required. The TCP address and port the server binds to.

listen_addr = "0.0.0.0:8080"

Use 127.0.0.1:8080 if you only want to accept connections from a local reverse proxy. The server does not support TLS on this socket; TLS termination must be handled upstream.

base_url

Required. The public HTTPS base URL of the ACME server. This value is embedded in every URL the server returns to clients — directory endpoint URLs, account URLs, order URLs, certificate download URLs, etc.

base_url = "https://acme.example.com"

It must match the URL that ACME clients use to reach the directory. It must not end with a slash.


[database]

path

Required. Path to the SQLite database file. The file and its WAL journal are created automatically if they do not exist.

[database]
path = "/var/lib/akamu/akamu.db"

Use :memory: for an ephemeral in-memory database (useful for testing; all data is lost when the process exits).


[ca]

key_file

Required. Path to the CA private key PEM file.

  • If both key_file and cert_file are 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.

ValueAlgorithm
"ec:P-256"ECDSA with NIST P-256 curve
"ec:P-384"ECDSA with NIST P-384 curve
"ec:P-521"ECDSA with NIST P-521 curve
"rsa:2048"RSA 2048-bit, exponent 65537
"rsa:3072"RSA 3072-bit, exponent 65537
"rsa:4096"RSA 4096-bit, exponent 65537
"ed25519"Ed25519
key_type = "ec:P-256"

hash_alg

Optional. Default: "sha256".

Hash algorithm used for signing certificates and CRLs.

ValueAlgorithm
"sha256"SHA-256
"sha384"SHA-384
"sha512"SHA-512
hash_alg = "sha256"

validity_days

Optional. Default: 90.

Default validity period in days for issued end-entity certificates. The validity window starts at the moment the certificate is signed.

validity_days = 90

ca_validity_years

Optional. Default: 10.

Validity period in years for the auto-generated CA certificate. Ignored when loading an existing certificate.

ca_validity_years = 10

common_name

Optional. Default: "Akāmu CA".

Common Name (CN) used in the Subject and Issuer fields of the auto-generated CA certificate.

common_name = "Example ACME CA"

organization

Optional. Default: "Akāmu".

Organization (O) used in the Subject and Issuer fields of the auto-generated CA certificate.

organization = "Example Org"

crl_url

Optional. Default: absent (no CDP extension).

If set, this URL is included as a CRL Distribution Point (CDP) URI in the CRLDistributionPoints extension of every issued end-entity certificate.

crl_url = "http://acme.example.com/crl/ca.crl"

The server does not serve the CRL itself at this URL. You must arrange for the CRL file to be available at this location separately (for example, by generating it with a custom script or tool that uses the CA key).

ocsp_url

Optional. Default: absent (no AIA OCSP extension).

If set, this URL is included in the AuthorityInfoAccess (AIA) extension as an OCSP responder URI in every issued end-entity certificate.

ocsp_url = "http://ocsp.example.com"

The server does not implement an OCSP responder. You must run a separate OCSP responder at this URL.


[mtc]

log_path

Required. Path to the disk-backed Merkle Tree Certificate transparency log file.

[mtc]
log_path = "/var/lib/akamu/mtc.log"

The file is created automatically on first run when enabled = true. It is never written when enabled = false, but the path must still be specified.

enabled

Optional. Default: false.

When true, each issued certificate is appended as a leaf to the MTC transparency log. The leaf index is stored in the certificates database table (mtc_log_index column).

enabled = false

[server]

The [server] section is optional. When omitted entirely, all fields take their default values.

terms_of_service_url

Optional. Default: absent.

URL of the Terms of Service document. When set, it appears in the meta.termsOfService field of the ACME directory response.

terms_of_service_url = "https://acme.example.com/tos.html"

website_url

Optional. Default: absent.

URL of the operator’s website. When set, it appears in the meta.website field of the directory response.

website_url = "https://acme.example.com"

caa_identities

Optional. Default: empty list.

List of CA domain names for CAA record verification (RFC 8659). When set, Akāmu queries CAA DNS records before issuing each certificate and verifies that at least one issue (or issuewild for wildcard) record authorises one of these CA domain names. The values also appear in meta.caaIdentities of the directory response.

When the list is empty (the default), CAA checking is skipped entirely — including RFC 8657 accounturi enforcement, because accounturi is evaluated as part of the CAA record check.

caa_identities = ["acme.example.com"]

external_account_required

Optional. Default: false.

When true, new-account requests must include an externalAccountBinding field (RFC 8555 §7.3.4). Requests without it are rejected with urn:ietf:params:acme:error:externalAccountRequired (HTTP 403). The directory response also includes meta.externalAccountRequired: true.

When enabled, the server performs full HMAC verification: it resolves the kid in [server.eab_keys], verifies the HS256/HS384/HS512 MAC, confirms the payload is the account key, and atomically consumes the key at account creation so each EAB key can only be used once.

external_account_required = true

eab_keys

Optional. Default: {}.

Pre-shared External Account Binding keys, expressed as a TOML table under [server.eab_keys]. Each entry maps a key identifier (kid) to its base64url-encoded raw HMAC key bytes. The key material must be at least 16 bytes; 32 bytes (256 bits) is recommended for HS256.

Keys are seeded into the eab_keys database table at startup using INSERT OR IGNORE. A key that has been consumed (used to create an account) or modified by a future admin API call is never overwritten by a restart.

[server.eab_keys]
"kid-1" = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg"   # base64url, no padding
"kid-2" = "YW5vdGhlci1rZXktaGVyZQ"

To generate a key:

openssl rand -base64 32 | tr '+/' '-_' | tr -d '='

order_expiry_secs

Optional. Default: 86400 (24 hours).

Number of seconds after creation before an order expires. Expired orders cannot be finalized.

order_expiry_secs = 86400

authz_expiry_secs

Optional. Default: 86400 (24 hours).

Number of seconds after creation before an authorization expires. Expired authorizations must be re-created via a new order.

authz_expiry_secs = 86400

max_body_bytes

Optional. Default: 65536 (64 KiB).

Maximum size in bytes of JOSE+JSON request bodies. Requests larger than this limit are rejected with HTTP 413. This applies to all POST endpoints that carry ACME payloads.

max_body_bytes = 65536

http_validation_port

Optional. Default: 80.

TCP port used when the server fetches http-01 challenge responses. RFC 8555 §8.3 requires port 80 in production deployments. Override this to a high port for local testing or non-standard network environments.

http_validation_port = 80

dns_persist_issuer_domain

Optional. Default: absent (dns-persist-01 disabled).

The issuer domain placed in the issuer-domain-names field of dns-persist-01 challenge objects and matched against the first token of TXT records during validation. When this field is set, the server offers dns-persist-01 as an additional challenge type for all dns identifiers. When absent, dns-persist-01 is not offered and existing clients are unaffected.

See dns-persist-01 Challenge for the full description of the challenge type and TXT record format.

dns_persist_issuer_domain = "acme.example.com"

dns_resolver_addr

Optional. Default: absent (system resolver).

Override the DNS resolver used for dns-01 and dns-persist-01 challenge validation. Format: "<ip>:<port>". When absent, the system default resolver is used. Useful for split-horizon DNS deployments where the ACME server cannot reach the public resolver, and for integration testing against a local stub server.

dns_resolver_addr = "127.0.0.1:5353"

ari_retry_after_secs

Optional. Default: 21600 (6 hours).

The value of the Retry-After header returned on GET /acme/renewal-info/{cert-id} responses (RFC 9773 §4.3). Controls how frequently ACME clients poll for renewal information.

ari_retry_after_secs = 21600

allow_subdomain_auth

Optional. Default: false.

When true, the directory meta includes "subdomainAuthAllowed": true, advertising that the server supports RFC 9444 subdomain authorization. Clients may then:

  • Include "subdomainAuthAllowed": true in POST /acme/new-authz requests.
  • Reference an ancestor domain in newOrder via the ancestorDomain identifier field.
allow_subdomain_auth = true

star_min_lifetime_secs

Optional. Default: absent (STAR not advertised).

Minimum certificate lifetime in seconds for ACME STAR orders (RFC 8739). When set, the directory meta includes an auto-renewal object advertising STAR capability. Clients that place STAR orders must request a lifetime value greater than or equal to this minimum.

Setting this field enables the STAR background reissuance task.

star_min_lifetime_secs = 86400   # 1 day minimum

star_max_duration_secs

Optional. Default: absent.

Maximum total renewal duration in seconds for ACME STAR orders. When set, it is included in the directory meta.auto-renewal object as max-duration. Clients must supply an end-date that does not exceed this value beyond the order creation time.

star_max_duration_secs = 31536000   # 1 year maximum

star_allow_certificate_get

Optional. Default: true.

Controls whether the rolling STAR certificate URL (/acme/cert/star/<order-id>) can be fetched with an unauthenticated GET request. When true, the directory meta.auto-renewal object includes "allow-certificate-get": true and clients may request this capability per order by including "allow-certificate-get": true in the auto-renewal object of newOrder. When false, the capability is not advertised and unauthenticated GET requests are rejected.

star_allow_certificate_get = true

tor_connectivity_enabled

Optional. Default: false.

Controls whether the server offers http-01 and tls-alpn-01 challenge types for .onion identifiers. RFC 9799 §4 prohibits offering those challenge types unless the CA can actually reach the Tor network. When false (the default), only onion-csr-01 is offered for .onion identifiers. Set to true only when the Akāmu server process can make outbound Tor connections to hidden services (for example, via torsocks or a SOCKS5 proxy configured at the OS level).

tor_connectivity_enabled = true

profiles

Optional. Default: empty (profiles not advertised).

A table mapping certificate profile identifiers to human-readable descriptions or documentation URLs, per draft-aaron-acme-profiles-01. When non-empty, the directory meta includes a "profiles" object with these entries, and clients may select a profile by name in newOrder.

Requests for a profile name not present in this table are rejected with urn:ietf:params:acme:error:invalidProfile (HTTP 400).

[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
"client-auth"     = "https://acme.example.com/docs/profiles/client-auth"

When profiles is empty (the default), the profile field in newOrder is accepted but ignored — the server issues under its default policy.

Account Management

ACME accounts are persistent identities that tie a public key to one or more email addresses. Every order, authorization, and certificate is associated with an account.

Account lifecycle

stateDiagram-v2
    direction LR
    [*] --> valid : create account\nPOST /acme/new-account
    valid --> deactivated : POST status=deactivated
    deactivated --> [*]

Accounts start in valid status. A valid account can:

  • Create new orders.
  • Manage existing orders and authorizations.
  • Download certificates.
  • Revoke certificates it owns.
  • Update its contact list.
  • Rotate its key.
  • Deactivate itself.

A deactivated account is permanently disabled. All subsequent requests using a deactivated account’s key are rejected.

Creating an account

Send a POST request to /acme/new-account with a JWS signed by the account’s public key in jwk form. The payload must contain:

FieldTypeRequiredDescription
contactarray of stringsNomailto: URIs for contact addresses
onlyReturnExistingbooleanNoIf true, return the existing account or error

Only mailto: URIs are accepted as contact values. Any other scheme causes a unsupportedContact error.

Example payload:

{
  "contact": ["mailto:admin@example.com"],
  "onlyReturnExisting": false
}

Response on creation (201 Created):

{
  "status": "valid",
  "contact": ["mailto:admin@example.com"],
  "orders": "https://acme.example.com/acme/orders/<account-id>"
}

The Location header contains the account URL: https://acme.example.com/acme/account/<account-id>.

If an account already exists for the submitted key and onlyReturnExisting is false, the server returns the existing account with HTTP 200 rather than creating a duplicate.

Reading account details

POST to the account URL (/acme/account/<id>) with an empty payload (POST-as-GET). The kid header must reference the account being queried, and it must match the account ID in the URL.

Updating contact information

POST to /acme/account/<id> with a payload containing the new contact array:

{
  "contact": ["mailto:new-admin@example.com"]
}

Contact addresses are replaced entirely; partial updates are not supported.

Deactivating an account

POST to /acme/account/<id> with:

{
  "status": "deactivated"
}

The account is immediately marked deactivated. This action is irreversible.

Key rollover

To replace an account’s signing key without losing the account:

  1. Construct an outer JWS signed with the old key addressed to /acme/key-change.
  2. Embed an inner JWS signed with the new key as the payload. The inner JWS payload must contain { "account": "<account-url>" }.
POST /acme/key-change
Content-Type: application/jose+json

{
  "protected": "<outer-header-signed-with-old-key>",
  "payload": "<inner-jws-signed-with-new-key>",
  "signature": "<outer-signature>"
}
sequenceDiagram
    participant C as ACME Client
    participant S as Akāmu Server

    Note over C: Currently holds old private key
    C->>C: Generate new key pair
    C->>C: Build inner JWS signed by NEW key
    Note right of C: payload = {"account": "https://…/acme/account/ID"}
    C->>C: Wrap in outer JWS signed by OLD key
    Note right of C: url = /acme/key-change, kid = account URL

    C->>S: POST /acme/key-change
    S->>S: Verify outer JWS signature (old key from DB)
    S->>S: Verify inner JWS signature (new key from inner jwk)
    S->>S: Check inner payload account == outer kid account URL
    S->>S: Check new key not registered to another account
    S->>S: Replace stored public_key + jwk_thumbprint
    S-->>C: 200 OK (account object)

    Note over C: All future requests must be signed with the new key

The server verifies:

  • The outer JWS is signed by the current account key.
  • The inner JWS is signed by the new key (jwk must be present in the inner header).
  • The inner payload’s account field matches the account URL derived from the outer kid.
  • The new key is not already registered to another account.

On success, the account’s stored key and thumbprint are replaced with the new key. Subsequent requests must be signed with the new key.

Security considerations

  • Each account is identified by the SHA-256 thumbprint of its JWK public key. The thumbprint is stored in the database to enable fast lookup without storing or parsing the full public key on every request.
  • Key rollover is the only mechanism to change the signing key. There is no password or other credential; possession of the private key is the sole proof of identity.
  • Contact email addresses are stored as plain text JSON in the database and are not validated against any mail server.

Orders

An ACME order is the top-level object that represents a request to obtain a certificate. It contains:

  • The list of identifiers (domain names or IP addresses) to be included in the certificate.
  • A set of authorizations — one per identifier — that must be completed before the certificate can be issued.
  • A finalize URL where the ACME client submits a CSR once all authorizations are valid.
  • An expires timestamp after which the order can no longer be finalized.

Order lifecycle

stateDiagram-v2
    direction LR
    [*] --> pending : new-order
    pending --> ready : all authorizations valid
    pending --> invalid : any authorization fails
    ready --> processing : finalize (submit CSR)
    processing --> valid : certificate issued
    processing --> invalid : CSR validation failed
    valid --> [*]
    invalid --> [*]
StatusMeaning
pendingOne or more authorizations are not yet valid.
readyAll authorizations are valid. The client may now submit a CSR.
processingCSR submitted; certificate being issued.
validCertificate has been issued.
invalidA 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:

TypeDescriptionSupported challenges
dnsDNS domain namehttp-01, dns-01, tls-alpn-01
ipIP address literalhttp-01, tls-alpn-01

Wildcard identifiers (*.example.com) are accepted as dns type identifiers. Only the dns-01 challenge can authorize wildcard identifiers. Attempting to validate a wildcard with http-01 or tls-alpn-01 will fail.

Response (201 Created):

{
  "status": "pending",
  "expires": "2026-04-11T12:00:00Z",
  "identifiers": [
    { "type": "dns", "value": "example.com" },
    { "type": "dns", "value": "www.example.com" }
  ],
  "authorizations": [
    "https://acme.example.com/acme/authz/<authz-id-1>",
    "https://acme.example.com/acme/authz/<authz-id-2>"
  ],
  "finalize": "https://acme.example.com/acme/order/<order-id>/finalize"
}

The Location header contains the order URL.

Reading an order

POST to /acme/order/<id> with an empty payload (POST-as-GET). The response contains the current state of the order, including the certificate URL once the order is in valid status.

Completing authorizations

For each authorization URL in the order’s authorizations array, the client must complete one challenge. See the Challenges chapter for details.

Finalizing an order

Once all authorizations are in valid status (order status becomes ready), POST a CSR to the finalize URL:

{
  "csr": "<base64url-encoded-DER-CSR>"
}

The CSR must:

  • Be a valid PKCS#10 DER structure.
  • Have a valid self-signature.
  • Contain a SubjectAlternativeName extension listing exactly the identifiers from the order (no more, no fewer).
  • Not assert cA=TRUE in BasicConstraints.

The server validates the CSR and immediately issues the certificate. On success, the order status changes to valid and the response includes the certificate field:

{
  "status": "valid",
  "certificate": "https://acme.example.com/acme/cert/<cert-id>",
  ...
}

Order expiry

Orders expire after order_expiry_secs seconds (default 86400, one day). An expired order cannot be finalized. A new order must be created.

Error handling

If a challenge fails, the corresponding authorization transitions to invalid. The server also marks the order invalid and records the error on the order object:

{
  "status": "invalid",
  "error": {
    "type": "urn:ietf:params:acme:error:connection",
    "detail": "connection error during challenge: TCP connect to example.com:80: ..."
  }
}

Create a new order and begin the authorization process again.

Challenges

A challenge is the mechanism by which Akāmu verifies that an ACME client controls the identifier (domain name or IP address) in an authorization. The server supports four challenge types: http-01, dns-01, tls-alpn-01, and dns-persist-01.

For each identifier in an order, the server creates one challenge of each supported type. The client chooses which challenge type to complete.

dns-persist-01 is an opt-in type that requires explicit server configuration. When it is not configured, clients see the standard three types and are not affected. See dns-persist-01 below for the full description.

Key authorization

Before responding to any challenge, compute the key authorization string:

key_authorization = token + "." + base64url(SHA-256(JWK-thumbprint-of-account-key))

Where:

  • token is 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).
  • base64url uses the URL-safe alphabet without padding characters.

In practice, ACME client libraries compute this for you.

dns-persist-01 does not use this formula. Instead of a token, the client receives an issuer-domain-names array, and the server matches the account URI directly against the TXT record. See dns-persist-01 below for details.

Responding to a challenge

To signal that the client has provisioned the challenge response, POST to the challenge URL with an empty JSON object payload:

{}

The server immediately marks the challenge as processing and spawns a background task to validate it. The response returns the current challenge status:

{
  "type": "http-01",
  "url": "https://acme.example.com/acme/chall/<authz-id>/http-01",
  "status": "processing",
  "token": "<token>"
}

Poll the authorization URL (POST-as-GET) to check when validation completes.

Challenge status transitions

stateDiagram-v2
    direction LR
    [*] --> pending : authorization created
    pending --> processing : client POSTs {} to challenge URL
    processing --> valid : server probe succeeds
    processing --> invalid : server probe fails
    valid --> [*]
    invalid --> [*]

A challenge that is already processing or valid is returned as-is if the client POSTs to it again.

Challenge types at a glance

flowchart TD
    A([Authorization created]) --> B{"Choose one<br/>challenge type"}

    B -->|http-01| C["Serve key auth at<br/>/.well-known/acme-challenge/TOKEN<br/>on port 80"]
    B -->|dns-01| D["Add DNS TXT record<br/>_acme-challenge.DOMAIN<br/>= base64url(SHA-256(key_auth))"]
    B -->|tls-alpn-01| E["Configure TLS on port 443<br/>ALPN: acme-tls/1<br/>Cert with id-pe-acmeIdentifier ext<br/>= SHA-256(key_auth)"]
    B -->|dns-persist-01| F["Persistent DNS TXT record<br/>_validation-persist.DOMAIN<br/>with issuer + accounturi + policy fields"]

    C & D & E & F --> G["POST {} to challenge URL"]
    G --> H["Server validates in background<br/>tokio::spawn task"]

    H -->|probe succeeds| I(["Authorization → valid<br/>Order → ready when all valid"])
    H -->|probe fails| J(["Authorization → invalid<br/>Order → invalid<br/>Create new order to retry"])

    classDef ok   fill:#f0fdf4,stroke:#16a34a,color:#0f172a
    classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
    class I ok
    class J fail

http-01

The server makes an HTTP/1.1 GET request to:

http://<domain>/.well-known/acme-challenge/<token>

on port 80. The response body (trimmed of whitespace) must equal the key authorization string.

Provisioning

Create a file at the path /.well-known/acme-challenge/<token> on the web server for the domain being validated. The file content must be exactly the key authorization string.

Example:

If the token is abc123 and the key authorization is abc123.XYZ...:

File path:    /.well-known/acme-challenge/abc123
File content: abc123.XYZ...

For Apache or nginx, ensure that requests to /.well-known/acme-challenge/ are served from the document root without authentication and without redirects.

Constraints

  • Port 80 must be reachable from the ACME server.
  • The response body must be less than 8 KiB.
  • Redirects are not followed; the initial response must be 200 OK.
  • IPv6 addresses are supported as ip type identifiers; the URL literal uses bracket notation (e.g., http://[2001:db8::1]/.well-known/acme-challenge/<token>).
  • Wildcard identifiers (*.example.com) cannot be validated with http-01.

dns-01

The server queries the DNS TXT record at:

_acme-challenge.<domain>

At least one TXT record value must equal:

base64url(SHA-256(key_authorization))

For example, if SHA-256(key_authorization) produces the bytes \xde\xad..., the expected TXT value is the base64url encoding of those 32 bytes.

Provisioning

Add a DNS TXT record:

Name:    _acme-challenge.example.com
Type:    TXT
TTL:     60
Content: <base64url-SHA256-of-key-authorization>

Concrete example:

  1. Token: mytoken
  2. JWK thumbprint: mythumbprint
  3. Key authorization: mytoken.mythumbprint
  4. SHA-256 of key auth (hex): e3b0c4... (varies; compute for your actual values)
  5. base64url of SHA-256: 47DEQp... (varies)
  6. 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:

  1. Contains the domain as a dNSName in the SubjectAlternativeName extension.
  2. Contains the id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31) marked critical, with a value of OCTET 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-acmeIdentifier as a critical extension.
  • Have the SHA-256 hash of the key authorization (32 raw bytes) wrapped in an OCTET STRING as the extension value.

TLS version support

The validator accepts both TLS 1.2 and TLS 1.3 connections.

Constraints

  • Port 443 must be reachable from the ACME server.
  • Wildcard identifiers are not supported by tls-alpn-01.
  • IP address identifiers (ip type) are supported; the server connects to the IP address directly.
  • RFC 8737 §3 requires exactly one SAN entry in the validation certificate. Certificates that contain more than one SAN are rejected.

dns-persist-01

dns-persist-01 is a DNS-based challenge type that differs from dns-01 in one important way: the TXT record is persistent. It does not change from one certificate renewal to the next. Once an operator provisions the record, it remains valid for every subsequent renewal by the same ACME account — no per-renewal DNS changes are required.

The challenge type is defined by the Let’s Encrypt specification at https://letsencrypt.org/2026/02/18/dns-persist-01. It is available only for dns type identifiers; IP address identifiers are not supported.

This type is opt-in: it is offered to clients only when server.dns_persist_issuer_domain is set in config.toml. When that field is absent, only the standard three types are advertised and existing clients are unaffected.

How it works

When dns-persist-01 is offered, the client does not receive a token field. Instead it receives an issuer-domain-names array:

{
  "type": "dns-persist-01",
  "url": "https://acme.example.com/acme/chall/<authz-id>/dns-persist-01",
  "status": "pending",
  "issuer-domain-names": ["acme.example.com"]
}

The ACME client must ensure that a TXT record exists at _validation-persist.<domain> before signalling the challenge. The server queries that name and evaluates each TXT record value against a set of rules.

TXT record format

_validation-persist.<domain>. IN TXT "<issuer-domain>; accounturi=<account-uri>[; policy=wildcard][; persistUntil=<ISO8601Z>]"

Fields are separated by semicolons. Field order is not significant except that the issuer domain must appear first.

FieldRequiredDescription
<issuer-domain>YesFirst token (before the first ;). Must match the CA’s configured issuer domain, case-insensitively, trailing dot stripped.
accounturi=<uri>YesFull ACME account URI, e.g. https://acme.example.com/acme/account/42. Must match the requesting account exactly.
policy=wildcardOnly for wildcard ordersMust be present when the identifier starts with *..
persistUntil=<timestamp>NoUTC 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:

  1. The first ;-delimited token equals the CA’s issuer domain (case-insensitive, trailing dot stripped).
  2. accounturi=<uri> matches the requesting account’s full URI.
  3. For wildcard orders, policy=wildcard is present.
  4. If persistUntil=<timestamp> is present, the timestamp is at or after the current time.

Unknown key-value tokens are silently ignored, allowing forward-compatible extensions.

Key authorization

Unlike the other challenge types, dns-persist-01 does not use a token · thumbprint key authorization. The server stores the account URI in the key_auth database column instead, and matches it directly against the accounturi= field in the TXT record.

Wildcard certificates

To validate *.example.com, the TXT record must include policy=wildcard. The query name is always _validation-persist.example.com — the *. prefix is stripped before the DNS lookup.

persistUntil expiry

The optional persistUntil field lets an operator set an explicit expiry on the authorization grant, independently of the DNS TTL. Format: YYYY-MM-DDTHH:MM:SSZ (literal Z; lowercase z also accepted).

  • Timestamp at or after current time → field is valid; evaluation continues.
  • Timestamp before current time → record rejected, even if all other fields match.
  • Timestamp unparseable → record rejected.

Records without persistUntil never expire through this mechanism; their lifetime is determined by DNS TTL and operator action.

Configuration

Both fields belong to the [server] section of config.toml.

dns_persist_issuer_domain

Optional. Default: absent (dns-persist-01 disabled).

The issuer domain placed in issuer-domain-names challenge objects and matched against the first token of TXT records. When set, dns-persist-01 is offered alongside the standard types for all dns identifiers.

[server]
dns_persist_issuer_domain = "acme.example.com"

dns_resolver_addr

Optional. Default: absent (system resolver).

DNS resolver override for dns-01 and dns-persist-01 validation. Format: "<ip>:<port>". The http-01 and tls-alpn-01 validators are not affected.

[server]
dns_resolver_addr = "127.0.0.1:5353"

Useful for split-horizon DNS (where the ACME server cannot reach the public resolver) and for integration tests against a local stub server.

Limitations

  • No ACME client library support yet. As of instant-acme 0.4.3, dns-persist-01 is not implemented. A custom client, a patched library, or direct ACME HTTP calls are required until upstream support arrives.
  • No revocation check during TXT lookup. If an account is deactivated in the Akāmu database, the TXT record is not invalidated automatically; operators must remove it manually.
  • One issuer domain per server instance. dns_persist_issuer_domain accepts a single string. Deployments with multiple issuer identities should use dns-01 instead.
  • Resolver override is global. dns_resolver_addr applies to all DNS-based challenge validation; per-type resolver selection is not supported.

Challenge failure

If validation fails, the challenge transitions to invalid and an error is recorded:

{
  "type": "http-01",
  "status": "invalid",
  "token": "<token>",
  "error": {
    "type": "urn:ietf:params:acme:error:connection",
    "detail": "connection error during challenge: TCP connect to example.com:80: connection refused"
  }
}

Common error types:

Error typeMeaning
connectionCould not connect to the applicant server
dnsDNS TXT lookup failed or name not found
incorrectResponseServer responded but the content did not match
tlsTLS handshake failed or extension verification failed

A failed authorization invalidates the parent order. Create a new order to try again.

Certificates

This chapter covers certificate issuance, retrieval, revocation, and the ACME Renewal Information (ARI) extension.

Issuance

Certificates are issued when an order is finalized. The client submits a PKCS#10 CSR (DER-encoded, base64url) to the finalize endpoint.

flowchart TD
    A(["POST /acme/order/ID/finalize<br/>csr = base64url DER"]) --> B[Decode + parse CSR]
    B --> C{"Self-signature<br/>valid?"}
    C -->|No| FAIL([400 badCSR])
    C -->|Yes| D{"BasicConstraints<br/>cA=FALSE?"}
    D -->|cA=TRUE found| FAIL
    D -->|OK| E{"SAN set equals<br/>order identifiers?"}
    E -->|Mismatch| FAIL
    E -->|Match| F["Generate random 16-byte serial<br/>clear high bit for positive integer"]
    F --> G["Build X.509 v3 end-entity cert<br/>KeyUsage=digitalSignature<br/>EKU=serverAuth"]
    G --> H[Sign with CA private key]
    H --> I["Store DER + PEM bundle<br/>in certificates table"]
    I --> J["Update order: status=valid<br/>certificate_id set"]
    J --> K([Return order with certificate URL])
    K --> L(["Client: GET /acme/cert/ID<br/>Download PEM bundle"])

    classDef ok   fill:#f0fdf4,stroke:#16a34a,color:#0f172a
    classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
    class K,L ok
    class FAIL fail

The server:

  1. Decodes and parses the CSR.
  2. Verifies the CSR’s self-signature.
  3. Checks that the CSR does not request CA authority (cA=TRUE in BasicConstraints is rejected).
  4. Verifies that the CSR’s SubjectAlternativeName extension contains exactly the identifiers from the order — no more, no fewer.
  5. Generates a random 16-byte serial number (positive two’s complement, high bit cleared).
  6. Issues an X.509 v3 certificate with the following profile:
ExtensionValue
BasicConstraintsNot critical; cA=FALSE
KeyUsageCritical; digitalSignature
ExtendedKeyUsageNot critical; serverAuth
SubjectKeyIdentifierRFC 5280 SHA-1 method
AuthorityKeyIdentifierRFC 5280 SHA-1 method, from CA key
SubjectAlternativeNameRebuilt from validated CSR SANs
AuthorityInfoAccess (OCSP)Present if ocsp_url is configured
CRLDistributionPointsPresent if crl_url is configured

The Subject Name from the CSR is copied verbatim into the issued certificate.

The validity period runs from the moment of issuance for validity_days days (default 90).

Downloading a certificate

Send a GET request to the certificate URL provided in the order’s certificate field:

GET /acme/cert/<cert-id>

No authentication is required. The response is a PEM bundle with Content-Type: application/pem-certificate-chain:

-----BEGIN CERTIFICATE-----
<base64-encoded end-entity certificate>
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
<base64-encoded CA certificate>
-----END CERTIFICATE-----

The bundle always contains the end-entity certificate followed by the CA certificate. No intermediate certificates are included (there are none in this single-tier CA architecture).

Certificate storage

The server stores both the DER and PEM representations of the issued certificate in the certificates database table, along with:

  • The UUID used as the certificate ID in the download URL.
  • The hex-encoded serial number.
  • Validity window timestamps (Unix epoch).
  • The MTC log leaf index (if MTC logging is enabled).
  • The suggested renewal window (ARI; computed on first query if not set).

Revocation

To revoke a certificate, POST to /acme/revoke-cert with a JWS signed by either:

  • The account key of the account that owns the certificate, or
  • The private key that corresponds to the certificate’s subject public key.

The payload:

{
  "certificate": "<base64url-encoded-DER-certificate>",
  "reason": 1
}

The certificate field contains the DER-encoded end-entity certificate (not the PEM bundle).

The reason field is optional. When present, it must be a CRL reason code:

CodeMeaning
0Unspecified
1Key compromise
2CA compromise
3Affiliation changed
4Superseded
5Cessation of operation
6Certificate hold
8Remove from CRL
9Privilege withdrawn
10AA compromise

Codes 7 and values above 10 are invalid and rejected with badRevocationReason.

On success, the server returns HTTP 200 with no body. The certificate’s status is set to revoked in the database with the revocation timestamp and reason code.

Revoking an already-revoked certificate returns alreadyRevoked.

ACME Renewal Information (ARI)

Akāmu implements RFC 9773, which allows ACME clients to ask the server when to renew a certificate.

Endpoint

GET /acme/renewal-info/<cert-id>

No authentication required.

Response

{
  "suggestedWindow": {
    "start": "2026-04-01T00:00:00Z",
    "end":   "2026-04-09T00:00:00Z"
  },
  "explanationURL": null
}

Window computation

If the server has not explicitly set a renewal window for the certificate, it computes one as follows:

  • Start: two-thirds of the way through the certificate’s validity period.
  • End: 24 hours before the certificate expires.

For a 90-day certificate issued on January 1, 2026:

  • Validity period: 90 days = 7,776,000 seconds
  • Start: January 1 + 60 days = March 2, 2026
  • End: April 1, 2026 (one day before April 2 expiry)
flowchart LR
    A(["Jan 1<br/>Issued"]) -->|"60 days"| B(["Mar 2<br/>Window opens"])
    B -->|"29 days"| C(["Apr 1<br/>Window closes"])
    C -->|"24 h"| D(["Apr 2<br/>Expires"])

    classDef ok   fill:#f0fdf4,stroke:#16a34a,color:#0f172a
    classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
    class B,C ok
    class D fail

Note: The explanationURL field is always null in the current implementation. RFC 9773 allows an explanation URL to be provided when the server has specific reasons for the suggested window.

Using ARI with certbot

Certbot 2.7 and later support ARI. It fetches the renewal window when deciding whether to renew:

certbot renew --server https://acme.example.com/acme/directory

If the current time falls within the suggestedWindow, certbot proceeds with renewal even if the certificate has more than 30 days remaining.

CRL and OCSP

Akāmu supports both Certificate Revocation List (CRL) and Online Certificate Status Protocol (OCSP) as optional mechanisms to communicate revocation status. Neither is served directly by the server; instead, the server embeds URLs in issued certificates that point to external services.

CRL Distribution Points

When crl_url is set in [ca], every issued end-entity certificate contains a CRLDistributionPoints extension pointing to that URL:

[ca]
crl_url = "http://acme.example.com/crl/ca.crl"

Clients that check CRL status will fetch the file at this URL and verify the serial number of the certificate against the revocation list.

Generating a CRL

The CRL generation capability is implemented in src/ca/revoke.rs via the build_crl function. The server does not automatically generate or publish CRL files; you must write tooling around the CA key and the revoked certificate records in the database to produce and serve the CRL.

A v2 CRL is generated using:

  • The CA’s private key for signing.
  • The CA’s subject name as the issuer.
  • The current timestamp as thisUpdate.
  • A configurable nextUpdate offset.
  • A CRL Number extension containing the current Unix timestamp as a monotonically increasing integer.
  • One revoked certificate entry per revoked certificate, each with the serial number, revocation time, and optional reason code.

CRL reason codes

The same reason codes used for revocation (see Certificates) are recorded in the CRL:

CodeCRL reason string
0Unspecified
1Key Compromise
2CA Compromise
3Affiliation Changed
4Superseded
5Cessation of Operation
6Certificate Hold
8Remove From CRL
9Privilege Withdrawn
10AA Compromise

OCSP

When ocsp_url is set in [ca], every issued end-entity certificate contains an AuthorityInfoAccess extension with an OCSP responder URI:

[ca]
ocsp_url = "http://ocsp.example.com"

OCSP clients query this URL to determine the status of a specific certificate. The server does not implement an OCSP responder; you must operate one separately.

Checking revocation status

You can check whether a certificate stored in the database is revoked by querying the certificates table directly:

SELECT id, serial_number, status, revoked_at, revocation_reason
FROM certificates
WHERE serial_number = '<hex-serial>';

A status of 'revoked' indicates the certificate has been revoked, along with the Unix timestamp of revocation and the reason code.

Practical deployment

For a minimal private CA deployment that only needs revocation status for internal clients:

  1. Set crl_url to a URL you control, for example http://acme.internal/crl/ca.crl.
  2. Periodically query the database for revoked certificates and build a CRL with the build_crl function.
  3. Publish the CRL at the URL.

For larger deployments requiring OCSP:

  1. Set ocsp_url to an OCSP responder you operate (e.g., OpenSSL’s ocsp command or a dedicated OCSP responder service).
  2. The OCSP responder must have access to the CA’s private key (or a delegated OCSP signing key) and the list of revoked certificates from the database.

Merkle Tree Certificate Log

Akāmu integrates with a Merkle Tree Certificate (MTC) transparency log using the synta-mtc library. When enabled, each issued end-entity certificate is appended as a leaf to a disk-backed append-only log.

What is an MTC log?

A Merkle Tree Certificate log is a tamper-evident, append-only data structure. Each leaf is the SHA-256 hash (with Merkle domain separation) of the DER-encoded TBSCertificateLogEntry derived from the issued certificate’s TBSCertificate. The log supports efficient proofs of inclusion and consistency that third parties can verify.

This is analogous in concept to Certificate Transparency (CT) logs (RFC 6962) but uses a different data structure and encoding based on the synta-mtc specification.

Configuration

[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled  = true

When enabled = true:

  • On startup, the server opens the existing log file at log_path, or creates a new one if the file does not exist.
  • After each successful certificate issuance, the certificate is appended to the log asynchronously in a background task.
  • The resulting leaf index is stored in the certificates database table (mtc_log_index column).

When enabled = false (the default):

  • The log file is never written.
  • The log_path must still be specified but is not used.

Log format

The log file is a binary file managed by synta_mtc::storage::DiskBackedLog. Entries are written as fixed-size SHA-256 hashes (32 bytes each) in leaf-order. The hash function includes Merkle tree domain separation to prevent second-preimage attacks.

The file is created by DiskBackedLog::create and opened by DiskBackedLog::open. The server uses a “try create first, fall back to open” strategy to eliminate time-of-check-to-time-of-use races.

Appending certificates

Appending a certificate involves:

  1. Parsing the DER-encoded certificate to extract the TBSCertificate.
  2. Converting the TBSCertificate to a TBSCertificateLogEntry using synta_mtc::integration::tbs_certificate_to_log_entry.
  3. DER-encoding the log entry.
  4. Computing hash_leaf(SHA-256, entry_der) — the Merkle leaf hash with domain separation prefix \x00.
  5. Appending the 32-byte hash to the log file under a tokio::sync::Mutex guard.

Steps 1–4 run in a tokio::task::spawn_blocking thread to avoid blocking the async executor with CPU-bound encoding work. Step 5 takes the mutex and writes.

If the append fails, a warning is logged but the certificate issuance response is not affected. The mtc_log_index column remains NULL in the database for that certificate.

Checking the log index

Query the database to find the MTC log index for a certificate:

SELECT id, serial_number, mtc_log_index
FROM certificates
WHERE mtc_log_index IS NOT NULL
ORDER BY mtc_log_index;

A NULL index means the certificate was either issued before MTC logging was enabled, or the log append failed.

Concurrency

The DiskBackedLog is not thread-safe internally. The server wraps it in a tokio::sync::Mutex<DiskBackedLog> (the SharedLog type alias in src/mtc/log.rs). All leaf appends and reads acquire this mutex, serializing concurrent operations at the async level.

Multiple processes accessing the same log file concurrently are not supported. A single Akāmu process is the exclusive writer.

Log integrity

The log is append-only by design. Once a leaf is appended it cannot be removed or modified without corrupting the file. The Merkle root can be computed from the log at any time using compute_root:

  • For a log with zero leaves the root is undefined.
  • For a log with one or more leaves the root is the SHA-256 Merkle root of all leaf hashes.

Note: The current implementation does not expose the log root, tree size, or inclusion proofs over HTTP. These would be additional endpoints if the MTC log were to be made externally verifiable. For now the log functions as an internal audit trail.

TLS Configuration

By default Akāmu listens on a plain TCP socket and relies on an upstream reverse proxy (nginx, Caddy, HAProxy, …) for TLS termination. If you want a fully self-contained deployment without a proxy, set [tls] enabled = true and Akāmu will accept HTTPS connections directly.

Backward compatibility is strict: deployments without a [tls] section in config.toml see zero behavior change.


When to use native TLS vs. a reverse proxy

ScenarioRecommendation
Single-host lab / developmentNative TLS — fewer moving parts
High-traffic or load-balanced productionReverse proxy — better performance, centralized cert management
Mutual-TLS client authenticationNative TLS — the proxy would need to forward raw TLS which most do not
Post-quantum hybrid mTLSNative TLS — composite ML-DSA schemes require direct OpenSSL integration

Deployment modes overview

flowchart TD
    A([Akāmu startup]) --> B{"tls section<br/>in config.toml?"}
    B -->|No| C["Mode 1 — Plain HTTP<br/>Bind plain TCP socket<br/>Upstream proxy handles TLS"]
    B -->|Yes, enabled=true| D{"cert_file AND<br/>key_file both exist?"}
    D -->|Neither exists| E["Mode 2 — Auto-generated TLS<br/>Generate server key + cert<br/>signed by Akāmu CA<br/>Write files for next start"]
    D -->|One missing| ERR["Error: both or neither required<br/>startup aborted"]
    D -->|Both exist| F{"tls.client_auth<br/>section present?"}
    F -->|No| G["Mode 3 — Native TLS<br/>Load externally-issued cert + key<br/>Serve HTTPS directly"]
    F -->|Yes| H["Mode 4 — Mutual TLS<br/>Require client certificate<br/>signed by configured CA<br/>Handshake fails for unknown clients"]
    C & E & G & H --> READY([Server ready])

    classDef ok   fill:#f0fdf4,stroke:#16a34a,color:#0f172a
    classDef fail fill:#fef2f2,stroke:#dc2626,color:#0f172a
    class READY ok
    class ERR fail

Deployment walkthroughs

The sections below walk through each of the four supported operating modes in order of increasing complexity. Pick the one that matches your environment.


Mode 1 — Plain HTTP behind a reverse proxy

This is the default and requires no [tls] section at all. Akāmu binds to a plain TCP socket — typically on a loopback or private address — and an upstream reverse proxy handles HTTPS termination and forwards requests over HTTP.

config.toml (no [tls] section)

listen_addr = "127.0.0.1:8080"
base_url    = "https://acme.example.com"

[database]
path = "/var/lib/akamu/akamu.db"

[ca]
key_file  = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"

[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled  = false

base_url must be the external HTTPS URL that ACME clients use to reach the directory — not the internal loopback address. Akāmu embeds this value in every URL it returns to clients (account URLs, order URLs, certificate download URLs, etc.). If base_url points at 127.0.0.1 clients will receive unusable URLs.

nginx example

server {
    listen 443 ssl;
    server_name acme.example.com;

    ssl_certificate     /etc/nginx/tls/acme.example.com.crt;
    ssl_certificate_key /etc/nginx/tls/acme.example.com.key;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }
}

Caddy example

acme.example.com {
    reverse_proxy 127.0.0.1:8080
}

Caddy automatically obtains and renews its own TLS certificate for the public hostname; no extra TLS configuration is needed on the Akāmu side.


Mode 2 — Native TLS with auto-generated certificate (development / lab)

Use this when you want Akāmu to speak HTTPS directly without a proxy but you do not yet have an externally-issued certificate. Akāmu will generate a server certificate signed by its own CA on first start.

Step 1. Add a [tls] section with enabled = true and supply paths for cert_file and key_file. The files do not need to exist yet.

listen_addr = "0.0.0.0:8443"
base_url    = "https://akamu.internal:8443"

[tls]
enabled     = true
cert_file   = "/etc/akamu/server.crt"
key_file    = "/etc/akamu/server.key"
server_name = "akamu.internal"

listen_addr and base_url must agree on the port (8443 here). base_url is the URL clients will use; listen_addr is what the OS socket binds to. 0.0.0.0 binds on all interfaces; restrict to a specific IP if needed.

Step 2. Start Akāmu. Because cert_file and key_file are both absent, the bootstrap logic in tls::init::load_or_generate runs automatically:

  1. A fresh server key is generated using bootstrap_key_type (default ec:P-256).
  2. A server certificate for server_name is signed by the Akāmu CA.
  3. The PEM chain (leaf + CA) is written to cert_file and the private key to key_file.

On every subsequent start the existing files are loaded without regeneration.

Step 3. Trust the Akāmu CA on the client. The auto-generated server certificate chains to the Akāmu CA — the same CA that signs ACME-issued certificates. Its PEM is at the path configured in [ca] cert_file. Pass it to curl with --cacert:

curl --cacert /etc/akamu/ca.cert.pem \
     https://akamu.internal:8443/acme/directory

ACME client software (certbot, acme.sh, …) typically has an equivalent option for specifying a custom CA bundle.


Mode 3 — Native TLS with an externally-issued certificate (production, no proxy)

Use this when ACME clients cannot be pre-configured to trust the Akāmu CA and a publicly-trusted TLS certificate is required for the ACME endpoint itself.

The Akāmu CA (which signs certificates issued to your ACME clients) is entirely separate from the TLS server certificate. Obtaining a public TLS cert for the ACME server hostname does not affect the CA or the certificates it issues.

Step 1. Obtain a certificate for the Akāmu server hostname from a public CA (Let’s Encrypt, ZeroSSL, your organisation’s PKI, etc.). You can use any ACME client pointed at a public CA — Akāmu itself does not need to be running yet for this step.

Step 2. Place the PEM certificate chain in cert_file (leaf first, then any intermediates) and the unencrypted private key in key_file.

Step 3. Configure Akāmu:

listen_addr = "0.0.0.0:8443"
base_url    = "https://acme.example.com:8443"

[tls]
enabled     = true
cert_file   = "/etc/akamu/server.crt"   # publicly-trusted chain, leaf first
key_file    = "/etc/akamu/server.key"   # unencrypted PKCS#8 or EC key
server_name = "acme.example.com"        # used only if cert/key are absent (bootstrap)

Because cert_file and key_file already exist when Akāmu starts, the bootstrap step is skipped entirely. server_name has no effect at runtime but is kept as documentation of the hostname and makes the config self-describing.

Step 4. When the public certificate is renewed, replace cert_file and key_file on disk and restart Akāmu (or send SIGHUP if live reload is supported by your deployment).


Mode 4 — Mutual TLS (mTLS)

Mutual TLS requires every connecting client to present a certificate signed by a trusted CA. This is useful for restricting access to the ACME server to known ACME agents or administrative tooling.

The TLS handshake enforces client authentication before any HTTP request is processed. A client that presents no certificate or an untrusted certificate receives a TLS handshake error — not an ACME-level error response.

Step 1. Decide on a client CA. This can be the Akāmu CA itself (so that only clients holding Akāmu-issued certificates may connect) or a separate private CA dedicated to client authentication. The CA certificate(s) must be available as PEM files on the Akāmu host.

Step 2. Add a [tls.client_auth] section. For a private PKI use profile = "rfc5280" to avoid CAB Forum restrictions that do not apply to internally-issued certificates.

listen_addr = "0.0.0.0:8443"
base_url    = "https://akamu.internal:8443"

[tls]
enabled   = true
cert_file = "/etc/akamu/server.crt"
key_file  = "/etc/akamu/server.key"

[tls.client_auth]
required = true
ca_files = ["/etc/akamu/client-ca.crt"]
profile  = "rfc5280"

Step 3. Test with curl, supplying both a CA bundle for server certificate verification and a client certificate with its key:

curl --cacert /etc/akamu/ca.cert.pem \
     --cert   client.crt \
     --key    client.key \
     https://akamu.internal:8443/acme/directory

Step 4. Understand the failure modes. When a client connects without a certificate or with a certificate that does not chain to the configured ca_files, the TLS handshake is aborted. The client sees a TLS alert (for example handshake failure or certificate required), not an HTTP 4xx response. Check the Akāmu log for client cert verification failed: … to diagnose chain or profile issues.


Minimal configuration

[tls]
enabled   = true
cert_file = "/etc/akamu/server.crt"   # PEM chain: leaf cert first, then intermediates/CA
key_file  = "/etc/akamu/server.key"   # PEM private key (PKCS#8 or SEC1, unencrypted)

If both cert_file and key_file are absent when the server starts, Akāmu auto-generates a server certificate signed by the Akāmu CA and writes both files. This makes the first-run experience zero-configuration: any client that already trusts the Akāmu CA will also trust the TLS channel.

If only one of the two files exists, startup fails with an explicit error.


Certificate and key format

  • cert_file: PEM file with one or more -----BEGIN CERTIFICATE----- blocks. The leaf certificate must come first; intermediate and root certificates follow in order. When Akāmu generates the file it writes <leaf>\n<CA> automatically.

  • key_file: PEM file with a single private key — -----BEGIN PRIVATE KEY----- (PKCS#8) or -----BEGIN EC PRIVATE KEY----- (SEC1). The file must be unencrypted (no passphrase). Akāmu never reads an encrypted key file.


Full [tls] field reference

[tls]
# Whether to enable native TLS.  Default: false.
enabled = true

# PEM file with the server certificate chain (leaf first).
cert_file = "/etc/akamu/server.crt"

# PEM file with the server private key (unencrypted PKCS#8 or SEC1).
key_file = "/etc/akamu/server.key"

# TLS protocol versions to accept.  Default: ["TLSv1.2", "TLSv1.3"].
protocols = ["TLSv1.3"]

# Hostname placed in CN and SAN of the auto-generated server certificate.
# Only used when cert_file/key_file are both absent.  Default: "localhost".
server_name = "akamu.internal"

# Key algorithm for the auto-generated server certificate.
# Accepted values: "ec:P-256", "ec:P-384", "ec:P-521",
#                  "rsa:2048", "rsa:3072", "rsa:4096", "ed25519".
# Default: "ec:P-256".
bootstrap_key_type = "ec:P-256"

Mutual TLS client certificate authentication

[tls.client_auth] enables mTLS: Akāmu requests a client certificate and validates the chain against a configurable set of trusted CAs.

[tls.client_auth]
# Reject connections that present no client certificate.  Default: false.
required = true

# PEM files containing trusted root CA certificates.
# Each file may contain multiple PEM blocks.
ca_files = [
    "/etc/akamu/client-ca.crt",
]

# Validation profile: "webpki" (CAB Forum) or "rfc5280".  Default: "webpki".
profile = "webpki"

# Allow ML-DSA / composite post-quantum algorithms in client cert chains.
# Default: false.
allow_post_quantum = false

# Maximum certificate chain depth (leaf not counted).  Default: 8.
max_chain_depth = 8

# Minimum RSA modulus size in bits.  Default: 2048.
minimum_rsa_modulus = 2048

profile — CAB Forum vs RFC 5280

SettingBehaviour
"webpki"CAB Forum / Web PKI profile enforced by synta-x509-verification. Rejects certificates that violate Baseline Requirements (e.g. missing SAN, weak key). Suitable for publicly-trusted client CAs.
"rfc5280"Strict RFC 5280 profile. More permissive than WebPKI on some extensions; suitable for enterprise or private PKI that does not follow CAB Forum rules.

Post-quantum support (allow_post_quantum = true)

When enabled, Akāmu accepts:

  • Pure ML-DSA certificate chains: verified by synta-x509-verification using the OpenSSL backend (pqc-prs fork).
  • Composite ML-DSA+classical TLS 1.3 CertificateVerify signatures: provisional code points from draft-ietf-lamps-pq-composite-sigs are advertised and verified via the OpenSSL EVP interface.

Classical verification is always performed via the ring crypto provider. TLS 1.2 CertificateVerify always uses classical ring verification — composite schemes are TLS 1.3 only and never appear in a TLS 1.2 handshake.


Full annotated example with mTLS

[server]
listen_addr = "0.0.0.0:8443"
base_url    = "https://akamu.internal:8443"

[tls]
enabled             = true
cert_file           = "/etc/akamu/server.crt"
key_file            = "/etc/akamu/server.key"
protocols           = ["TLSv1.3"]
server_name         = "akamu.internal"
bootstrap_key_type  = "ec:P-384"

[tls.client_auth]
required           = true
ca_files           = ["/etc/akamu/client-ca.crt", "/etc/akamu/sub-ca.crt"]
profile            = "rfc5280"
allow_post_quantum = true
max_chain_depth    = 5
minimum_rsa_modulus = 3072

Known limitations

  • Composite scheme code points (src/tls/schemes.rs) are taken from the provisional IANA allocations in draft-ietf-lamps-pq-composite-sigs. They must be verified against the current draft version before deploying to production; if the draft advances and code points change, only that file needs updating.

  • Composite OpenSSL binding: composite ML-DSA+classical CertificateVerify verification relies on the pqc-prs OpenSSL fork exposing composite NIDs via PKey::public_key_from_der. If those NIDs are not yet in the Rust binding layer, the function will return an OpenSSL error at runtime. The fix is to add composite NID support to the Rust openssl bindings in the pqc-prs fork, not to Akāmu itself.

  • Pure ML-DSA TLS SignatureScheme code points: 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, handlers can extract the client’s remote address via axum::extract::ConnectInfo<SocketAddr> — this is available because the server is started with into_make_service_with_connect_info::<SocketAddr>().


Troubleshooting

ErrorLikely cause
TLS cert file 'X' contains no PEM blocksWrong file path, or file is DER-encoded (convert to PEM first)
TLS cert and key must both be present or both absentOne file exists but the other does not; either supply both or remove both
build client-auth trust store: …A CA PEM file is malformed or contains non-certificate data
client cert verification failed: …Client presented a cert that does not chain to the configured CA, has expired, or violates the chosen profile
composite signature verification failed: …pqc-prs OpenSSL fork does not expose the composite NID for the scheme used; see Known Limitations
TLS versions: …protocols list contains an unsupported value; use "TLSv1.2" and/or "TLSv1.3"

Performance

This chapter covers issuance throughput and latency characteristics of Akāmu under load, with guidance on key type selection and capacity planning.

All numbers were collected on a single host using the acme-bench tool shipped in the repository:

cargo bench --bench acme_bench -- [OPTIONS]

The benchmark runs full ACME flows (account → new-order → challenge validate → finalize → certificate download) against a real in-process server over the loopback interface using an in-memory SQLite database. Reported latency is end-to-end wall time from the start of new-order through certificate download; account creation is excluded because it is amortised across all orders from a given client.

Note — database layer (sqlx). Akāmu uses sqlx 0.8 for SQLite access. Both in-memory (:memory:) and file-backed databases use a single-connection pool (max_connections = 1). In-memory databases require this because every SQLite in-memory connection opens its own private, empty database. File-backed databases use it to avoid SQLITE_BUSY_SNAPSHOT (error 517), a WAL-mode contention error that bypasses the busy handler and cannot be retried — sqlx attempts to reuse connection read-snapshots across pool round-trips, and when a concurrent writer commits between those round-trips the stale snapshot triggers the error. Approximately 24 SQL round-trips are needed per issuance (reduced from ~55 by moving anti-replay nonces to an in-memory store and using JOIN queries to collapse read pairs into single round-trips); throughput plateaus at ≈ 1350–1500 iss/s at 25 concurrent clients, determined by how fast the single sqlx connection can process queries rather than by crypto, network, or storage speed. See the Database scalability section for guidance on exceeding this ceiling.


Concurrency scaling

With EC P-256 certificates and an EC P-256 CA, throughput scales up to ~10 concurrent clients and then plateaus as the single-connection pool becomes the bottleneck:

Concurrent clientsThroughput (iss/s)Mean latency (ms)p95 (ms)
12314.35.3
510864.65.6
1014456.87.7
25150115.217.7
50135630.834.7

Throughput peaks around 10–25 concurrent clients at ~1500 iss/s and remains stable at 25–50 clients (≈ 1350–1500 iss/s); latency grows roughly linearly with client count, consistent with a single serialised resource. The practical bottleneck is the in-memory SQLite single connection; crypto and network are not limiting factors at these rates.


Key type comparison

The table below compares issuance performance for different CSR key types at 25 concurrent clients with an EC P-256 CA.

CSR key typeThroughput (iss/s)Mean latency (ms)p95 (ms)Finalize phase (ms)
ec:P-256141915.017.03.4
ed25519126515.917.23.7
ec:P-384131116.319.45.2
ml-dsa-44133216.218.84.7
ml-dsa-65114918.221.25.7
ml-dsa-87121617.720.95.8
rsa:2048142137.6246.3104.0
rsa:409614993915

All classical and post-quantum key types cluster around 1150–1420 iss/s because throughput is bounded by the single-connection database pool, not by crypto. Finalize-phase latency (CSR verification + certificate issuance) still reflects relative signing cost: EC and Ed25519 are fastest, ML-DSA adds ~1–2 ms, and RSA adds tens to hundreds of milliseconds.

RSA is the outlier: RSA 2048 adds ~100 ms to finalize, and RSA 4096 adds ~900 ms.

RSA 4096 saturation

ClientsThroughput (iss/s)Finalize mean (ms)p99 (ms)
13353974
10124841188
25149152021
50911183271

Throughput is limited by RSA 4096 key generation time. At 50 clients the additional queuing raises both finalize latency and overall contention, reducing aggregate throughput below the 25-client figure. Avoid RSA 4096 in any configuration where more than a handful of concurrent ACME clients are expected.


Post-quantum cryptography

Akāmu supports ML-DSA (FIPS 204 / RFC 9881) for both CA keys and certificate keys. Three security levels are available. The table uses a full post-quantum chain (ML-DSA CA + ML-DSA leaf, with --verify-cert) at 25 concurrent clients:

Parameter setNIST categoryThroughput (iss/s)Alloc pressure (MiB/iss)
ML-DSA-44213660.54
ML-DSA-65310930.64
ML-DSA-87510670.77
EC P-25613650.33

ML-DSA allocation pressure is 60–130% higher than EC P-256 per issuance, reflecting the larger key and signature structures. Throughput difference between ML-DSA and EC P-256 varies by parameter set: ML-DSA-44 matches EC P-256 closely (1366 vs 1365 iss/s) because the database single-connection bottleneck dominates over crypto cost at 25 clients. ML-DSA-65 and ML-DSA-87 trail by ~20–25% due to their larger certificate structures consuming more of the single connection’s capacity during signing and serialisation.

ML-DSA requires OpenSSL 3.5 or later. Akāmu will report a startup error if the requested key type is unavailable on the installed OpenSSL version.

CA key type impact

CA keyThroughput (iss/s)Mean latency (ms)Finalize (ms)
ec:P-256125516.33.7
ec:P-384133314.73.3
rsa:2048129315.54.0
rsa:409683223.811.4

EC and RSA 2048 CA keys deliver equivalent throughput in the optimised server (all are database-bottlenecked at 25 clients). RSA 4096 as the CA key reduces throughput by ~35% vs EC P-256 due to slower signing raising finalize latency above the per-query DB round-trip time; avoid it for performance-sensitive deployments.


Challenge type comparison

Challenge typeThroughput (iss/s)Challenge phase (ms)Alloc pressure (MiB/iss)
http-0114565.50.33
dns-persist-0112526.80.37

http-01 delivers ~15% higher throughput than dns-persist-01 on loopback. Both challenge phases reflect the adaptive poll backoff (starts at 1 ms, caps at --poll-ms) rather than network latency; the 5–7 ms figure is dominated by polling overhead and background validation round-trips.


Key type recommendations

ScenarioRecommended key type
General purpose, broad client compatibilityec:P-256
Smallest footprint, fastest validationed25519
Higher security margin, still classicalec:P-384
Post-quantum resistant, FIPS 204 category 2ml-dsa-44
Post-quantum resistant, FIPS 204 category 3ml-dsa-65
Post-quantum resistant, FIPS 204 category 5ml-dsa-87
Interoperability with RSA-only clientsrsa:2048 (avoid RSA 4096 under load)

Database scalability

Both in-memory (:memory:) and file-backed databases use a single-connection pool, so the throughput ceiling of ≈ 1350–1500 iss/s applies to both. The ceiling is set by how fast the sqlx SQLite worker thread can process one query at a time — each query requires a channel round-trip to the background thread, and ~24 such round-trips are needed per issuance (reduced from ~55 by moving anti-replay nonces to an in-memory store and using JOIN queries to collapse read pairs).

Backend comparison (tmpfs vs in-memory)

The table below shows that file-backed SQLite on a RAM-backed filesystem (tmpfs / /dev/shm) produces equivalent throughput to an in-memory database. WAL journal mode adds a small amount of write bookkeeping overhead; the difference is within run-to-run noise.

Concurrent clientsIn-memory (iss/s)tmpfs WAL (iss/s)
1231220
510861108
1014451380
2515011321
5013561250

Both backends plateau around 1350–1500 iss/s at 10–25 concurrent clients. The bottleneck is the sqlx connection round-trip per query, not storage speed; switching from in-memory to a tmpfs-backed file provides durability without a throughput penalty.

For sustained high-throughput targets consider:

  • In-memory database for lab, CI, or ephemeral CA use cases. Fastest startup; data is lost on restart.
  • File-backed WAL database on a fast SSD or RAM-backed filesystem. Throughput matches in-memory while providing crash durability.
  • Sharding — multiple Akāmu instances behind a load balancer, each with its own database — for production-scale deployments requiring higher aggregate issuance rates above the ≈ 1500 iss/s per-instance ceiling.

Connection pool size and BEGIN IMMEDIATE

SQLITE_BUSY_SNAPSHOT (error 517) occurs in WAL mode when a deferred transaction (BEGIN) captures a read snapshot that becomes stale after another connection commits — even when the two transactions write to completely different rows. Unlike SQLITE_BUSY (error 5), error 517 bypasses the busy handler entirely, so busy_timeout has no effect on it.

Akāmu resolves this by using BEGIN IMMEDIATE for every write transaction (db::begin_write). BEGIN IMMEDIATE acquires the write lock at transaction start, so the snapshot is always current. Any resulting SQLITE_BUSY contention is handled transparently by the busy_timeout = 5 s already configured on the pool.

The table below shows that after this fix, pool > 1 produces zero errors at every concurrency level. Write throughput is unchanged because BEGIN IMMEDIATE still serialises writers — only one connection can hold the write lock at a time — but errors are eliminated.

Throughput (iss/s) and error count (out of 200 requests) on tmpfs WAL with BEGIN IMMEDIATE:

Concurrent clientsPool = 1Pool = 2Pool = 4Pool = 8
1208 / 0 err206 / 0 err202 / 0 err220 / 0 err
51092 / 0 err959 / 0 err707 / 0 err617 / 0 err
101389 / 0 err1372 / 0 err1247 / 0 err821 / 0 err
251307 / 0 err1164 / 0 err1096 / 0 err998 / 0 err
501197 / 0 err1119 / 0 err1041 / 0 err996 / 0 err

All pool sizes produce zero errorsBEGIN IMMEDIATE eliminates SQLITE_BUSY_SNAPSHOT regardless of how many connections are in the pool. Pool = 1 consistently delivers the highest throughput because all requests share a single serialised connection channel with no lock-acquisition contention. Pool = 2 and above pay increasingly for BEGIN IMMEDIATE wait time as multiple connections compete for the WAL write lock; the gap widens at medium concurrency (5–10 clients) where lock contention is highest relative to available parallelism.

For the single-connection production default (open) this has no observable effect: with one connection there is never a concurrent writer, so BEGIN IMMEDIATE and BEGIN DEFERRED behave identically.

The --pool-connections benchmark option can be used to measure pool behaviour:

# Pool comparison on tmpfs with BEGIN IMMEDIATE (zero errors expected)
for p in 1 2 4 8; do
  DB=$(mktemp /dev/shm/bench_pool_XXXXXX.db)
  cargo bench --bench acme_bench -- \
    --db "$DB" --pool-connections "$p" \
    --clients 25 --requests 200 --warmup 20 --poll-ms 5
  rm -f "$DB" "${DB}-wal" "${DB}-shm"
done

Running the benchmark

The acme-bench binary is built as a Cargo bench target:

cargo bench --bench acme_bench -- --help

Common invocations:

# Baseline: 25 concurrent clients, 200 issuances, EC P-256, 5 ms poll cap
cargo bench --bench acme_bench -- --clients 25 --requests 200 --warmup 20 --poll-ms 5

# Compare RSA 2048 vs EC P-256
cargo bench --bench acme_bench -- --key-type rsa:2048 --clients 25 --requests 100
cargo bench --bench acme_bench -- --key-type ec:P-256  --clients 25 --requests 100

# Full post-quantum chain (ML-DSA-65 CA + ML-DSA-65 leaf)
cargo bench --bench acme_bench -- \
  --ca-key-type ml-dsa-65 --key-type ml-dsa-65 \
  --clients 25 --requests 100 --verify-cert

# Scalability sweep
for n in 1 5 10 25 50; do
  cargo bench --bench acme_bench -- --clients $n --requests 300 --warmup 20 --poll-ms 5
done

# dns-persist-01 challenge type
cargo bench --bench acme_bench -- --challenge dns-persist-01 --clients 25 --requests 200

# JSON output for scripting
cargo bench --bench acme_bench -- --output json --clients 25 --requests 200 --poll-ms 5 | jq .summary

Available options

OptionDefaultDescription
--clients N10Concurrent worker tasks
--requests N100Issuances to measure (warmup not counted)
--warmup N10Warmup issuances discarded before measurement
--poll-ms N50Poll interval cap in milliseconds; adaptive backoff starts at 1 ms
--challenge TYPEhttp-01http-01 or dns-persist-01
--key-type TYPEec:P-256CSR key type (see table above)
--ca-key-type TYPEec:P-256CA key type (same syntax)
--db PATH:memory:SQLite path — :memory: or a file path
--pool-connections N1SQLite pool size; ignored (clamped to 1) when --db :memory:; see Connection pool size
--wildcardoffIssue *.bench-N.acme-bench.test (dns-persist-01 only)
--output FORMATtexttext or json
--verify-certoffParse and verify the SAN of every issued certificate

The poll loop uses adaptive exponential backoff: it starts at 1 ms, doubles each miss, and caps at --poll-ms. This mirrors how production ACME clients behave and reveals the true validation latency without a fixed artificial floor.


Memory consumption

The benchmark instruments heap allocation using a custom GlobalAlloc wrapper that records four AtomicU64 counters. This reports in-process heap usage without any external tooling or /proc parsing.

Three snapshots are taken:

MilestoneWhen
process startBefore the server is initialised
server readyAfter the server has bound its port and is accepting connections
after benchAfter all issuances (warmup + measured) have completed

The peak counter is reset at server ready so the high-water mark reflects only the issuance window, not server startup allocations.

Text output

  Heap (allocator counters):
    process start:        0.1 MiB  live
    server ready:         0.2 MiB  live   (server overhead: +0.5 MiB)
    after  220 iss.:      0.6 MiB  live   (issuance growth: +0.4 MiB, 1.9 KiB/iss.)
    peak live:            1.5 MiB         (high-water mark during issuances)
    alloc pressure:      83.5 MiB  total  (0.379 MiB/iss. requested, incl. freed)

live — bytes currently held on the heap (footprint). alloc pressure — cumulative bytes requested from the system allocator since server ready, including memory that was allocated and subsequently freed. A high pressure-to-footprint ratio indicates short-lived allocations (normal for per-request work like signature buffers and JSON serialisation).

JSON output

The "memory" key is present in JSON output when --output json is used:

{
  "memory": {
    "start_live_bytes":           102400,
    "server_ready_live_bytes":    204800,
    "after_bench_live_bytes":     614400,
    "peak_live_bytes":           1572864,
    "server_overhead_bytes":      512000,
    "issuance_growth_bytes":      409600,
    "per_issuance_growth_bytes":    1900,
    "issuance_alloc_bytes":     87523328,
    "per_issuance_alloc_bytes":   397833,
    "total_alloc_count":         700000
  }
}
FieldMeaning
*_live_bytesHeap footprint at each milestone
peak_live_bytesHighest live bytes seen during the issuance window
server_overhead_bytesLive growth from start to server-ready
issuance_growth_bytesLive growth from server-ready to end of bench
per_issuance_growth_bytesPer-issuance share of issuance growth
issuance_alloc_bytesTotal bytes requested during the issuance window
per_issuance_alloc_bytesPer-issuance allocation pressure
total_alloc_countTotal number of alloc calls in the whole process

Typical figures

At 25 concurrent clients with 200 measured issuances (EC P-256, :memory: DB, 5 ms poll cap):

  • Server overhead: ~0.3 MiB live (router tables, DB connection pool, CA state, HTTP client)
  • Per-issuance heap growth: ~1 KiB (request-scoped state retained by tokio workers)
  • Peak during issuances: ~2.7 MiB (25 in-flight requests simultaneously)
  • Allocation pressure: ~335 KiB per issuance (JWS buffers, JSON serialisation, cert DER/PEM)

For ML-DSA key types allocation pressure rises to ~550–790 KiB per issuance due to larger key and certificate structures (lower end with EC P-256 CA, higher end with a matching ML-DSA CA).

These figures confirm that Akāmu has a stable heap footprint at steady state. Per-issuance live growth is small and bounded by the number of concurrent workers, not the total number of issuances.

RFC Support Reference

This page documents every RFC that is relevant to Akāmu, explaining what each one specifies, which parts are implemented, and — for RFCs that are intentionally not implemented — why.

Summary

SpecificationTitleStatus
dns-persist-01Let’s Encrypt Persistent DNS ChallengeFull
draft-aaron-acme-profiles-01ACME Certificate ProfilesFull
draft-ietf-cose-dilithium-11ML-DSA (Dilithium) for JOSE (JWK + JWS)Full
draft-ietf-lamps-pq-composite-sigsML-DSA Composite TLS Signature SchemesPartial (provisional code points)
RFC 8555Automatic Certificate Management Environment (ACME)Full
RFC 8659DNS Certification Authority Authorization (CAA)Full
RFC 8657CAA Extensions: accounturi and validationmethodsFull
RFC 8737ACME TLS-ALPN-01 Challenge ExtensionFull
RFC 8738ACME IP Identifier ValidationFull
RFC 8739ACME Short-Term, Automatically Renewed (STAR) CertificatesFull
RFC 9444ACME for SubdomainsFull
RFC 9773ACME Renewal Information (ARI)Full
RFC 9799ACME Extensions for .onion Special-Use Domain NamesFull
RFC 5280X.509 Certificate and CRL ProfileFull
RFC 8823ACME Extensions for S/MIME CertificatesNot implemented
RFC 9115ACME Profile for Delegated CertificatesNot implemented
RFC 9345Delegated Credentials for TLSNot implemented
RFC 9447ACME Challenges Using an Authority TokenNot implemented
RFC 9448ACME TNAuthList Authority TokenNot implemented
RFC 9538ACME Delegation Metadata for CDNINot implemented
RFC 9891ACME DTN Node ID Validation (Experimental)Not implemented

RFC 8555 — Core ACME

RFC 8555 is the foundation. It defines the full ACME protocol: the HTTP API, the JSON object model, the JWS (JSON Web Signature) authentication scheme, and the challenge validation framework.

What it covers

SectionFeatureStatus
§7.1Directory (GET /acme/directory)Yes
§7.2Nonces (HEAD /acme/new-nonce, GET /acme/new-nonce)Yes
§7.3Account creation and management (/acme/new-account, /acme/account/{id})Yes
§7.3.4externalAccountRequired enforcementYes
§7.4Order management (/acme/new-order, /acme/order/{id})Yes
§7.4.1Pre-authorization (POST /acme/new-authz)Yes
§7.1.3Honour order notBefore / notAfter in issued certificatesYes
§7.5Authorizations (/acme/authz/{id})Yes
§7.5.1Challenge response (/acme/chall/{authz}/{type})Yes
§7.4 finalizeCertificate issuance (/acme/order/{id}/finalize)Yes
§7.4.2Certificate download (/acme/cert/{id})Yes
§7.6Certificate revocation (/acme/revoke-cert)Yes
§7.3.5Account key rollover (/acme/key-change)Yes
§8.3http-01 challenge validationYes
§8.4dns-01 challenge validationYes

Pre-authorization (newAuthz)

Pre-authorization lets a client prove domain control ahead of any specific order. Once pre-authorized, the client can request multiple certificates for that domain (or its subdomains, if subdomainAuthAllowed is set) without repeating the challenge for each order.

POST /acme/new-authz
Content-Type: application/jose+json

payload: {
  "identifier": { "type": "dns", "value": "example.com" }
}

The response is identical to a reactive authorization created by newOrder.

External Account Binding

When server.external_account_required = true, every newAccount request must include an externalAccountBinding field. Requests without it are rejected with urn:ietf:params:acme:error:externalAccountRequired (HTTP 403).

EAB keys are provisioned in the TOML configuration under [server]:

[server]
external_account_required = true

[server.eab_keys]
"kid-1" = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg"   # base64url-encoded raw key bytes
"kid-2" = "YW5vdGhlci1rZXktaGVyZQ"

Keys are seeded into the eab_keys database table at startup using INSERT OR IGNORE, so a key consumed or modified at runtime is never overwritten by a server restart. The server performs full HMAC verification per RFC 8555 §7.3.4:

  1. Extracts the kid from the EAB protected header.
  2. Looks up the key in the database; rejects unknown or already-consumed kids.
  3. Validates the algorithm (HS256, HS384, or HS512), the url (must match the new-account endpoint), and the payload (must be the account public key).
  4. Verifies the HMAC signature using OpenSSL constant-time comparison.
  5. Inserts the new account and marks the EAB key as consumed in a single SQLite transaction.

The design is forward-compatible with an admin API endpoint: insert, delete, and get_by_kid are already implemented in the database layer.

Certificate validity window

If the newOrder request includes notBefore and/or notAfter fields, the issued certificate’s validity period will honour them, subject to the CA’s configured validity_days limit and a 5-minute clock-skew grace on notBefore.


RFC 8659 — CAA DNS Resource Record

RFC 8659 requires a CA to look up DNS Certification Authority Authorization (CAA) records before issuing a certificate. A domain owner can publish CAA records to restrict which CAs are allowed to issue certificates for that domain.

How Akāmu implements it

Before issuing any certificate, Akāmu queries CAA records for each DNS identifier in the order:

  1. 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.
  2. If no CAA records are found anywhere, issuance proceeds (unconstrained domain).
  3. If a CAA record set is found, Akāmu checks whether any issue record (or issuewild record for wildcard certs) contains one of the CA’s configured domain names (server.caa_identities).
  4. 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., only dns-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

  1. Akāmu computes the SHA-256 of the key authorization.
  2. It opens a TLS connection to port 443 of the domain, advertising acme-tls/1 as the ALPN protocol.
  3. It verifies that the server presents a certificate with:
    • The domain as a dNSName SAN (exactly one SAN entry).
    • A critical id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31) containing the SHA-256 hash of the key authorization as a DER OCTET STRING.
  4. 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

ChallengeSupported
http-01Yes — connects directly to the IP; Host header is the IP address literal
tls-alpn-01Yes — connects to the IP; SNI uses the reverse-DNS name (e.g., 1.2.0.192.in-addr.arpa)
dns-01No — MUST NOT be used for IP identifiers per RFC 8738 §7
dns-persist-01No — 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
  }
}
FieldRequiredDescription
end-dateYesThe latest date of validity of the last certificate issued (RFC 3339).
lifetimeYesValidity period of each certificate, in seconds.
start-dateNoThe earliest notBefore of the first certificate. Defaults to when the order becomes ready.
lifetime-adjustNoPre-dates each certificate’s notBefore by this many seconds (for clock-skew tolerance). Default: 0.
allow-certificate-getNoIf true, the rolling certificate URL can be fetched with an unauthenticated GET.

notBefore and notAfter must NOT be present in a STAR order.

Rolling certificate URL

After finalization, the order response includes a star-certificate URL instead of certificate:

{
  "status": "valid",
  "star-certificate": "https://acme.example.com/acme/cert/star/<order-id>"
}

GET /acme/cert/star/<order-id> always returns the currently active PEM certificate, along with Cert-Not-Before and Cert-Not-After HTTP headers matching the certificate’s validity window.

Canceling a STAR order

POST to the order URL with {"status": "canceled"} to stop automatic renewal:

POST /acme/order/<id>
{ "status": "canceled" }

Once canceled, the star-certificate endpoint returns HTTP 403 (autoRenewalCanceled). The currently active short-lived certificate continues to be usable until it expires naturally.

Server configuration

Advertise STAR capability in the directory by configuring minimum lifetime and maximum duration:

[server]
star_min_lifetime_secs = 86400     # 1 day minimum cert lifetime
star_max_duration_secs = 31536000  # 1 year maximum renewal period

When either field is set, the directory meta object includes the auto-renewal advertisement.


RFC 9444 — ACME for Subdomains

RFC 9444 allows a client to prove control of an ancestor domain (e.g., example.com) and then obtain certificates for any subdomain (e.g., api.example.com, www.example.com) without repeating the challenge for each one.

ancestorDomain in new orders

When placing an order for a subdomain, the client can declare which ancestor domain it controls:

{
  "identifiers": [
    {
      "type": "dns",
      "value": "api.example.com",
      "ancestorDomain": "example.com"
    }
  ]
}

Akāmu validates that ancestorDomain is a genuine ancestor (label-aligned DNS suffix) of the requested identifier. If accepted, the authorization challenge is issued against example.com rather than api.example.com.

subdomainAuthAllowed in pre-authorization

When pre-authorizing an ancestor domain, include subdomainAuthAllowed: true:

POST /acme/new-authz
{
  "identifier": { "type": "dns", "value": "example.com" },
  "subdomainAuthAllowed": true
}

The returned authorization object includes the same flag:

{
  "identifier": { "type": "dns", "value": "example.com" },
  "status": "valid",
  "subdomainAuthAllowed": true,
  ...
}

A client can reuse this authorization for any subsequent order that specifies ancestorDomain: "example.com".

Server advertisement

To advertise subdomain authorization support in the directory:

[server]
allow_subdomain_auth = true

This adds "subdomainAuthAllowed": true to the directory meta object.


RFC 9773 — ACME Renewal Information (ARI)

RFC 9773 defines the Renewal Information extension, which lets the server tell ACME clients when to renew their certificates — even before the certificate expires. This is useful when a CA needs to revoke and reissue certificates en masse (e.g., due to a key compromise or mis-issuance event).

Endpoints

GET /acme/renewal-info/<cert-id>

<cert-id> is the RFC 9773 certificate identifier: base64url(AKI keyIdentifier) "." base64url(DER-encoded serial number bytes).

The response includes a suggested renewal window:

{
  "suggestedWindow": {
    "start": "2025-03-15T00:00:00Z",
    "end":   "2025-03-20T00:00:00Z"
  }
}

The server includes a Retry-After header indicating how often to poll.

Renewal replacement

When placing a renewal order for a certificate that is being replaced, include the predecessor’s cert-id in the order:

{
  "identifiers": [...],
  "replaces": "<cert-id-of-predecessor>"
}

Akāmu validates that the predecessor cert belongs to the same account, marks it as replaced in the database at finalization, and returns an HTTP 409 (alreadyReplaced) if a replacement order has already been finalized.

Configuration

[server]
ari_retry_after_secs = 21600  # 6 hours between renewal-info polls (default)

RFC 9799 — ACME for .onion Domains

RFC 9799 defines how ACME can issue certificates for Tor Hidden Services (.onion Special-Use Domain Names). These are not DNS names — the second-level label encodes the hidden service’s Ed25519 public key.

Supported challenges for .onion identifiers

ChallengeSupportedNotes
onion-csr-01YesKey validation via CSR; no Tor network access needed server-side
http-01ConditionalOnly offered when server.tor_connectivity_enabled = true
tls-alpn-01ConditionalOnly offered when server.tor_connectivity_enabled = true
dns-01NoMUST 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:

  1. Akāmu returns a challenge object with type: "onion-csr-01", a token, and an authKey (the JWK thumbprint of the ACME account key).
  2. The client builds a CSR that:
    • Contains the .onion SAN.
    • Includes a cabf-onion-csr-nonce extension (OID 2.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.
  3. The client POSTs {"csr": "<base64url-CSR-DER>"} to the challenge URL.
  4. Akāmu:
    • Extracts the 32-byte Ed25519 public key from the .onion address.
    • Verifies the cabf-onion-csr-nonce extension 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.

Identifier format

Only v3 (Ed25519) .onion addresses are accepted. A v3 address has a 56-character base32 second-level label:

bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion

Version 2 addresses (16-character label) are rejected per RFC 9799 §2.


RFC 5280 — X.509 Certificate Profile

RFC 5280 defines the structure of X.509 v3 certificates and Certificate Revocation Lists (CRLs). Akāmu issues certificates that conform to the RFC 5280 PKIX profile via the synta-certificate library.

Conformance includes:

  • Correct BasicConstraints (CA: false on end-entity certs).
  • SubjectKeyIdentifier and AuthorityKeyIdentifier extensions.
  • KeyUsage and ExtendedKeyUsage extensions.
  • SubjectAlternativeName extensions carrying dNSName (including .onion domains) or iPAddress.
  • CRL Distribution Points and OCSP Access Information when crl_url / ocsp_url are configured.

RFC 7807 — Problem Details for HTTP APIs

RFC 7807 defines a JSON format for HTTP error responses. All Akāmu error responses use this format with Content-Type: application/problem+json:

{
  "type":   "urn:ietf:params:acme:error:malformed",
  "detail": "JWS url mismatch: got '...', expected '...'",
  "status": 400
}

All ACME-specific error URNs are defined in RFC 8555 §6.7 and its extensions.


Let’s Encrypt dns-persist-01

The dns-persist-01 specification is a non-standard ACME challenge type published by Let’s Encrypt. Unlike the standard dns-01 challenge, which requires a fresh DNS TXT record for every renewal, dns-persist-01 uses a single long-lived TXT record that remains in place across renewals. This eliminates the need to modify DNS on every certificate renewal cycle.

How it differs from dns-01

Propertydns-01dns-persist-01
TXT record name_acme-challenge.<domain>_validation-persist.<domain>
Record changes per renewalRequiredNot required
Token in recordYes (changes each time)No
Record format<key-auth>"<issuer-domain>; accounturi=<uri>[; policy=wildcard][; persistUntil=<ISO8601Z>]"
Wildcard supportRequires explicit policy=wildcard parameter

Configuration

[server]
dns_persist_issuer_domain = "acme.example.com"

When dns_persist_issuer_domain is set, the server offers dns-persist-01 as an additional challenge type alongside http-01, dns-01, and tls-alpn-01. Without it, the challenge type is not advertised.

TXT record format

The domain owner publishes (and keeps permanently):

_validation-persist.example.com. IN TXT "acme.example.com; accounturi=https://acme.example.com/acme/account/abc123"

Optional extensions:

  • policy=wildcard — authorizes wildcard certificate issuance.
  • persistUntil=2026-12-31T00:00:00Z — caps the record’s validity. After this date, the record must be renewed.

Validation

Akāmu queries the _validation-persist.<domain> TXT record, verifies the issuer domain matches dns_persist_issuer_domain, and checks that the accounturi matches the requesting ACME account URL. If both match, the authorization is marked valid.


draft-aaron-acme-profiles-01

draft-aaron-acme-profiles-01 defines a mechanism for an ACME server to advertise named certificate profiles and for clients to request a specific profile when placing an order. This moves policy selection from CSR extensions and post-issuance inspection into the order object itself, making the server’s issuance policy explicit and machine-readable.

What it adds

FeatureLocationStatus
meta.profiles in directoryGET /acme/directoryYes
profile field in newOrder payloadPOST /acme/new-orderYes
profile field in order responseGET/POST /acme/order/{id}Yes
invalidProfile error typeAll order and finalize endpointsYes
Finalize-time profile re-validationPOST /acme/order/{id}/finalizeYes

Directory advertisement

When server.profiles is configured, the directory meta includes a profiles object:

"meta": {
  "profiles": {
    "tls-server-auth": "https://acme.example.com/docs/profiles/tls-server-auth",
    "client-auth":     "https://acme.example.com/docs/profiles/client-auth"
  }
}

Requesting a profile in newOrder

Clients include the profile field in the newOrder payload:

{
  "identifiers": [{ "type": "dns", "value": "example.com" }],
  "profile": "tls-server-auth"
}

The server validates that the requested profile is in the configured map. If not, it returns:

{
  "type": "urn:ietf:params:acme:error:invalidProfile",
  "status": 400,
  "detail": "profile 'unknown-profile' is not advertised by this server"
}

The profile field is echoed back in every subsequent order response so that clients can confirm which profile applies.

Finalize-time re-validation

If a profile is removed from the server’s configuration after an order has been placed but before it is finalized, the finalize endpoint rejects the request with invalidProfile. This prevents silent issuance under a policy the server no longer supports.

Configuration

[server.profiles]
"tls-server-auth" = "https://acme.example.com/docs/profiles/tls-server-auth"
"client-auth"     = "https://acme.example.com/docs/profiles/client-auth"

When profiles is empty (the default), profile selection is not advertised. A profile field in newOrder is accepted but ignored — the server issues under its default policy.


draft-ietf-cose-dilithium-11

draft-ietf-cose-dilithium-11 defines how ML-DSA (Module-Lattice-Based Digital Signature Algorithm, formerly CRYSTALS-Dilithium, standardized in FIPS 204) keys and signatures are represented in JOSE (JSON Object Signing and Encryption). This draft has been submitted for RFC publication and its wire format is frozen. Akāmu implements it for ACME account key authentication, meaning ACME clients can register an ML-DSA key pair and sign every subsequent ACME request with it.

JWK key type: AKP

ML-DSA keys use the key type "AKP" (Algorithm Key Pair). Unlike classical key types, the algorithm is encoded inside the JWK itself (not only in the JWS protected header), so the alg field is required in the JWK:

{
  "kty": "AKP",
  "alg": "ML-DSA-65",
  "pub": "<base64url-encoded raw public key bytes>"
}
JWK fieldRequiredDescription
ktyYesAlways "AKP" for ML-DSA keys
algYes"ML-DSA-44", "ML-DSA-65", or "ML-DSA-87"
pubYesBase64url-encoded raw public key bytes (no padding)
privNo32-byte seed (private key); never sent to the server and ignored if present

Supported variants

AlgorithmFIPS 204 parameter setPublic key sizeSignature sizeOID (SPKI)
ML-DSA-44Parameter set 2 (k=4, l=4)1312 bytes2420 bytes2.16.840.1.101.3.4.3.17
ML-DSA-65Parameter set 3 (k=6, l=5)1952 bytes3309 bytes2.16.840.1.101.3.4.3.18
ML-DSA-87Parameter set 5 (k=8, l=7)2592 bytes4627 bytes2.16.840.1.101.3.4.3.19

JWK thumbprint

Per draft-ietf-cose-dilithium-11 §6, the JWK thumbprint for an AKP key is the SHA-256 hash of the following canonical JSON object with members in lexicographic order:

{"alg":"ML-DSA-65","kty":"AKP","pub":"<base64url-key>"}

This is the same SHA-256 / base64url procedure as RFC 7638, applied to the three required members alg, kty, and pub (in that order).

Signature format

ML-DSA signatures in JOSE are raw bytes as defined by FIPS 204 §7.2. They are not DER-encoded. The server validates the signature length before attempting verification and returns HTTP 400 if the length does not match the declared algorithm.

The signing context MUST be an empty byte string (b"") per draft-ietf-cose-dilithium-11 §4. Verification uses BackendPublicKey::verify_ml_dsa_with_context(signing_input, signature, b""). Signature failures return HTTP 401 Unauthorized.

SPKI DER construction

When an AKP JWK arrives in a new-account request (or any request using jwk instead of kid), Akāmu converts it to a SubjectPublicKeyInfo (SPKI) DER structure for storage and subsequent signature verification. The SPKI follows the X.509 / PKIX encoding for ML-DSA keys:

SEQUENCE {                          -- outer SEQUENCE
  SEQUENCE {                        -- AlgorithmIdentifier
    OID <variant OID>               -- no parameters (absent, not NULL)
  }
  BIT STRING {
    0x00                            -- unused-bits octet
    <raw public key bytes>          -- from the "pub" JWK field
  }
}

The alg field in the JWK determines which OID is embedded. The length octets in the DER use the minimum-length encoding; for ML-DSA key sizes, the outer SEQUENCE and BIT STRING both use the two-byte (0x82) length form.

ACME client integration notes

An ACME client registering with an ML-DSA key must:

  1. Generate an ML-DSA key pair (any of the three variants).
  2. Construct the AKP JWK from the raw public key bytes (base64url-encode them into pub).
  3. Include the JWK in the new-account protected header (the jwk field).
  4. Sign all ACME requests with the ML-DSA private key using an empty context string.
  5. Set alg in the JWS protected header to match the JWK’s alg field.

Existing ACME clients designed for classical algorithms require ML-DSA support in their underlying JOSE library. There is no server-side configuration to enable or disable ML-DSA; the feature is always available.


draft-ietf-lamps-pq-composite-sigs

draft-ietf-lamps-pq-composite-sigs is an active IETF draft that defines hybrid post-quantum signature algorithms combining a classical algorithm (ECDSA or RSA) with a post-quantum algorithm (ML-DSA, formerly CRYSTALS-Dilithium). Akāmu implements the TLS 1.3 signature scheme code points from the provisional IANA allocations in this draft.

What this affects

These code points are used only for mutual TLS client authentication — they appear in the TLS CertificateVerify message when a client presents a certificate signed with a composite ML-DSA scheme. Server-side certificate issuance (for ACME clients) is not affected.

The 12 composite scheme code points implemented are:

Code pointScheme
0x0901id-MLDSA44-RSA2048-PSS-SHA256
0x0902id-MLDSA44-RSA2048-PKCS15-SHA256
0x0903id-MLDSA44-Ed25519-SHA512
0x0904id-MLDSA44-ECDSA-P256-SHA256
0x0905id-MLDSA65-RSA3072-PSS-SHA512
0x0906id-MLDSA65-RSA3072-PKCS15-SHA512
0x0907id-MLDSA65-ECDSA-P384-SHA512
0x0908id-MLDSA65-ECDSA-brainpoolP256r1-SHA512
0x0909id-MLDSA87-ECDSA-P384-SHA512
0x090Aid-MLDSA87-ECDSA-brainpoolP384r1-SHA512
0x090Bid-MLDSA87-Ed448-SHA512
0x090Cid-MLDSA65-ECDSA-P256-SHA512

Stability warning

These code points come from the provisional IANA registry for an in-progress draft. They may change as the draft advances toward RFC publication. Before deploying to production, verify the current draft version against the code points in src/tls/schemes.rs. If the code points change, only that file needs to be updated.


Not implemented

RFC 8823 — S/MIME Certificates (Informational)

Defines an email identifier type and email-reply-00 challenge, where proof of control is a DKIM-signed reply email.

Not implemented. Issuing S/MIME certificates requires an SMTP/IMAP email delivery stack that is outside the scope of an embedded CA.

RFC 9115 — ACME Profile for Delegated Certificates

Enables a three-party delegation model: a domain owner (IdO) authorizes a third party (e.g., a CDN) to obtain certificates for the IdO’s domain, where the certificate’s public key belongs to the third party rather than the domain owner. The CA acts as a proxy between the two parties and enforces a JSON CSR template that restricts what the delegate may request.

Not implemented. This requires a dedicated API surface for IdOs to manage delegation policies, plus proxy routing between the NDC and IdO accounts. It is primarily useful for large-scale CDN deployments.

RFC 9345 — Delegated Credentials for TLS

Defines a TLS certificate extension (delegated_credential) that allows a TLS server to present short-lived sub-credentials derived from an issued certificate, without requiring a new CA-signed certificate for each sub-credential. The ACME interaction is limited to requesting certs with this extension set.

Not implemented. Requires X.509 extension support in synta-certificate for the Delegated Credentials extension (OID TBD / draft status at time of implementation).

RFC 9447 — ACME Challenges Using an Authority Token

Defines a generic tkauth-01 challenge type where proof of control comes from a JWT issued by an external authority rather than from DNS or HTTP. Designed for identifier types that cannot be validated by the classic ACME challenges (e.g., telephone numbers in STIR/SHAKEN).

Not implemented. Requires integration with an external token authority, which is deployment-specific.

RFC 9448 — ACME TNAuthList Authority Token

Extends RFC 9447 for telephone number (STIR/SHAKEN) use cases, where the authority token contains a TNAuthList claim.

Not implemented. Telecom-specific; requires connectivity to a Secure Telephone Identity (STI) Policy Administrator.

RFC 9538 — ACME Delegation Metadata for CDNI

Extends RFC 9115 for CDN Interconnection (CDNI) scenarios where multiple CDN tiers chain certificate delegation.

Not implemented. Layered on top of RFC 9115 and equally CDN-infrastructure-specific.

RFC 9891 — ACME DTN Node ID Validation (Experimental)

An experimental RFC that defines a bundleEID identifier type and a Bundle Protocol (BP) challenge for validating Delay-Tolerant Networking node identities.

Not implemented. Experimental status; targets space/satellite networks using the Bundle Protocol (RFC 9171).

Client Libraries Overview

The Akāmu repository ships three standalone crates in addition to the server binary. They were extracted from the server so that external Rust applications can speak ACME without pulling in the full server stack.

CrateWhat it provides
akamu-joseRFC 7517/7515 JWK/JWS primitives, key thumbprints, ML-DSA signatures
akamu-clientFull RFC 8555 ACME client lifecycle (async, tokio + hyper)
akamu-cliEnd-user CLI wrapping akamu-client

Crate dependency graph

graph LR
    CLI["akamu-cli"]
    CLIENT["akamu-client"]
    JOSE["akamu-jose"]
    SYNTA["synta-certificate"]

    CLI --> CLIENT
    CLIENT --> JOSE
    JOSE --> SYNTA

The server binary (akamu) also depends on akamu-jose directly; its src/jose/ module is a thin re-export layer.

When to use which crate

Use akamu-jose when you need only cryptographic primitives: JWK parsing, JWS signing/verification, thumbprint computation, or algorithm support. It has no HTTP or database dependencies and compiles quickly.

Use akamu-client when you want to drive the full ACME protocol from Rust code — account registration, ordering, challenge solving, finalization, and certificate download. It brings in tokio and hyper but nothing database-related.

Use akamu-cli when you want a command-line tool and do not want to write Rust. It wraps akamu-client and exposes register, issue, and deregister subcommands.

The PQC OpenSSL patch requirement

Important: akamu-jose (and therefore all crates that depend on it) require a patched OpenSSL fork that adds post-quantum ML-DSA support. Any workspace that uses these crates as dependencies must add the following [patch.crates-io] block to its root Cargo.toml. The patch cannot live in a sub-crate’s manifest.

[patch.crates-io]
openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
openssl     = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }

Without this block, cargo build will either fail to resolve openssl-sys or will resolve the upstream crates.io version, which lacks ML-DSA support.

Getting started

Add crates to your Cargo.toml

[dependencies]
# ACME client + JWK/JWS:
akamu-client = { path = "/path/to/akamu/crates/akamu-client" }

# Or just the crypto primitives:
akamu-jose = { path = "/path/to/akamu/crates/akamu-jose" }

Add the patch block

In your workspace root Cargo.toml (not in a member manifest):

[patch.crates-io]
openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }
openssl     = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" }

Verify the build

cargo build -p akamu-jose
cargo build -p akamu-client

Both crates should compile without errors. The first build downloads and compiles the patched OpenSSL fork, which can take a few minutes.

Further reading

akamu-jose — JWK/JWS Primitives

akamu-jose is a standalone Rust crate that provides RFC 7517 JWK key handling and RFC 7515 JWS signing and verification. It supports both classical and post-quantum algorithms and has no HTTP, database, or server dependencies.

What it provides

  • Parsing RFC 7517 public keys from JSON (JwkPublic)
  • Computing RFC 7638 JWK thumbprints
  • Converting public keys to SPKI DER for use with synta-certificate
  • Signing and verifying RFC 7515 JWS flattened serialization (JwsFlattened)
  • Decoding JWS protected headers (JwsProtectedHeader)
  • All classical algorithms: ES256/ES384/ES512, PS256/PS384/PS512, EdDSA
  • Post-quantum algorithms: ML-DSA-44, ML-DSA-65, ML-DSA-87 (draft-ietf-cose-dilithium-11)

What it does NOT provide

  • HTTP requests of any kind
  • ACME protocol logic
  • Database access
  • Certificate issuance or CSR handling

For those, use akamu-client.

JwkPublic

JwkPublic represents an RFC 7517 public key. It can be constructed from a JSON JWK, from a synta-certificate BackendPublicKey, or converted to SPKI DER.

Parsing a JWK

#![allow(unused)]
fn main() {
use akamu_jose::JwkPublic;

let json = r#"{"kty":"EC","crv":"P-256","x":"...","y":"..."}"#;
let jwk: JwkPublic = serde_json::from_str(json)?;
}

Computing a thumbprint

The thumbprint is a SHA-256 digest of the canonical JWK representation (RFC 7638). It is used in ACME key authorizations.

#![allow(unused)]
fn main() {
let thumb = jwk.thumbprint()?;  // returns String (base64url, no padding)
}

Converting to SPKI DER

to_spki_der is needed when you want to pass the public key to synta-certificate for signature verification or certificate issuance.

#![allow(unused)]
fn main() {
let spki_der: Vec<u8> = jwk.to_spki_der()?;
}

Constructing from a public key

If you have a BackendPublicKey from synta-certificate, convert it to a JwkPublic:

#![allow(unused)]
fn main() {
let jwk = JwkPublic::from_public_key(&backend_public_key)?;
}

JwsFlattened

JwsFlattened is the RFC 7515 flattened JSON serialization. It is the format used by all ACME POST requests.

Signing

JwsFlattened::sign takes a protected header (already base64url-encoded JSON), a payload (already base64url-encoded), and a BackendPrivateKey.

#![allow(unused)]
fn main() {
use akamu_jose::{JwsFlattened, JwsProtectedHeader, JwsKeyRef};

// Sign with ES256 (P-256 key)
let protected_b64 = base64url_encode(&serde_json::to_vec(&header)?);
let payload_b64   = base64url_encode(payload_json.as_bytes());

let jws = JwsFlattened::sign(&protected_b64, &payload_b64, &private_key)?;
}

For ML-DSA-87, the call is identical — the algorithm is determined by the key type, not by a separate parameter:

#![allow(unused)]
fn main() {
// private_key is a BackendPrivateKey for an ML-DSA-87 key
let jws = JwsFlattened::sign(&protected_b64, &payload_b64, &private_key)?;
}

ML-DSA signatures are produced over the raw signing input using an empty context string as required by draft-ietf-cose-dilithium-11 §4.

Verifying

verify checks the signature against a provided SPKI DER:

#![allow(unused)]
fn main() {
let spki_der = jwk.to_spki_der()?;
jws.verify(&spki_der)?;   // returns Ok(()) or Err(JoseError)
}

Decoding header and payload

#![allow(unused)]
fn main() {
let header: JwsProtectedHeader = jws.decode_header()?;
let payload_bytes: Vec<u8> = jws.decode_payload()?;
}

JwsProtectedHeader

The decoded ACME JWS protected header. Fields:

FieldTypeMeaning
algStringAlgorithm string (e.g. "ES256", "ML-DSA-87")
nonceStringACME anti-replay nonce
urlStringTarget URL (must match the request URL)
key_refJwsKeyRefKey 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

Familyalg stringJWK kty / crvNotes
ECDSAES256EC / P-256SHA-256
ECDSAES384EC / P-384SHA-384
ECDSAES512EC / P-521SHA-512
RSASSA-PSSPS256RSASHA-256 / MGF1-SHA-256
RSASSA-PSSPS384RSASHA-384 / MGF1-SHA-384
RSASSA-PSSPS512RSASHA-512 / MGF1-SHA-512
EdDSAEdDSAOKP / Ed25519 or Ed448RFC 8037
ML-DSAML-DSA-44LWE (draft)draft-ietf-cose-dilithium-11
ML-DSAML-DSA-65LWE (draft)draft-ietf-cose-dilithium-11
ML-DSAML-DSA-87LWE (draft)draft-ietf-cose-dilithium-11

JoseError

#![allow(unused)]
fn main() {
pub enum JoseError {
    BadRequest(String),           // malformed input (missing field, wrong format)
    Crypto(String),               // signature failure or key operation error
    UnsupportedAlgorithm(String), // alg string not recognized
    Base64(String),               // base64url decode failure
    Json(String),                 // JSON parse failure
}
}

When akamu-jose is used inside the server, From<JoseError> for AcmeError converts these automatically:

  • BadRequestAcmeError::BadRequest
  • CryptoAcmeError::Crypto
  • UnsupportedAlgorithmAcmeError::BadSignatureAlgorithm

Full example: generate P-256 key, compute thumbprint, sign a JWS

#![allow(unused)]
fn main() {
use akamu_jose::{JwkPublic, JwsFlattened};
use synta_certificate::BackendPrivateKey;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};

fn base64url(data: &[u8]) -> String {
    URL_SAFE_NO_PAD.encode(data)
}

// 1. Generate a P-256 private key via synta-certificate
let private_key = BackendPrivateKey::generate("ec:P-256")?;
let public_key  = private_key.to_public_key()?;

// 2. Wrap as JwkPublic and compute the thumbprint
let jwk   = JwkPublic::from_public_key(&public_key)?;
let thumb = jwk.thumbprint()?;
println!("Thumbprint: {thumb}");

// 3. Build an ACME-style protected header (illustrative; real code uses serde)
let header_json = serde_json::json!({
    "alg":   "ES256",
    "nonce": "some-nonce",
    "url":   "https://acme.example.com/acme/new-account",
    "jwk":   serde_json::to_value(&jwk)?,
});
let protected_b64 = base64url(&serde_json::to_vec(&header_json)?);

// 4. Encode the payload
let payload_json  = serde_json::json!({"termsOfServiceAgreed": true});
let payload_b64   = base64url(&serde_json::to_vec(&payload_json)?);

// 5. Sign
let jws = JwsFlattened::sign(&protected_b64, &payload_b64, &private_key)?;

// 6. Verify round-trip
let spki_der = jwk.to_spki_der()?;
jws.verify(&spki_der)?;
println!("Signature verified");
}

akamu-client — ACME Client Library

akamu-client is an async Rust library that implements the full RFC 8555 ACME client lifecycle. It targets applications that need to obtain and renew certificates programmatically without shelling out to certbot or acme.sh.

Overview

The library covers:

  • ACME directory discovery and nonce management (automatic, with nonce recycling)
  • Account registration with optional External Account Binding (EAB)
  • Account lookup (find_account), state retrieval (get_account), contact updates (update_account)
  • Account key rollover (key_change, RFC 8555 §7.3.5)
  • Account deactivation
  • Order creation, authorization retrieval, challenge triggering, and status polling
  • CSR construction (build_csr)
  • Order finalization and certificate download
  • Certificate revocation via account key or certificate’s own key (RFC 8555 §7.6)
  • ARI renewal window query (get_renewal_info, RFC 9773)
  • Built-in http-01 challenge solver (Http01Solver)
  • Built-in tls-alpn-01 challenge solver (TlsAlpn01Solver, RFC 8737)
  • DNS challenge helpers (Dns01Helper, DnsPersist01Helper)
  • onion-csr-01 CSR builder (build_onion_csr, RFC 9799)
  • A ChallengeSolver trait for custom solvers

Dependencies: tokio, hyper-rustls (TLS enabled by default), akamu-jose. No database or server dependencies.

End-to-end example: P-256 key, http-01 challenge

use akamu_client::{
    AccountKey, AccountOptions, AcmeClient,
    Http01Solver, Identifier, build_csr, ChallengeSolver as _,
};
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Account key
    let key = Arc::new(AccountKey::generate("ec:P-256")?);

    // 2. Connect to the ACME server (HTTPS by default)
    let client = AcmeClient::new("https://acme.example.com/acme/directory").await?;

    // 3. Register an account
    let opts = AccountOptions {
        contacts: &["mailto:ops@example.com"],
        agree_tos: true,
        eab: None,
    };
    let account = client.new_account(Arc::clone(&key), &opts).await?;

    // 4. Place an order
    let ids = vec![Identifier::dns("example.com")];
    let order = client.new_order(&account, &ids).await?;

    // 5. Solve each authorization
    let solver = Http01Solver::new(80);
    solver.start().await?;

    for authz_url in &order.authorizations {
        let authz = client.get_authorization(&account, authz_url).await?;
        if authz.status == "valid" { continue; }

        let challenge = authz.find_challenge("http-01")
            .expect("http-01 challenge not offered");

        let token = challenge.token.as_deref().expect("challenge has no token");
        let key_auth = account.key_authorization(token);
        solver.present(token, &key_auth).await?;

        client.trigger_challenge(&account, challenge).await?;
        let _order = client.poll_order(&account, &order.url).await?;

        solver.cleanup(token).await?;
    }

    // 6. Finalize
    let cert_key = AccountKey::generate("ec:P-256")?;
    let csr_der = build_csr(&["example.com"], cert_key.private_key())?;
    let finalized = client.finalize(&account, &order, &csr_der).await?;

    // 7. Download
    let cert_url = finalized.certificate.expect("order has no certificate URL");
    let pem = client.download_certificate(&account, &cert_url).await?;
    std::fs::write("cert.pem", &pem)?;
    println!("Certificate written to cert.pem");
    Ok(())
}

AccountKey

AccountKey holds the ACME account private key. It wraps a BackendPrivateKey from synta-certificate.

Generating a key

#![allow(unused)]
fn main() {
let key = AccountKey::generate("ec:P-256")?;   // or "rsa:2048", "ed25519", "ml-dsa-65", ...
}

Supported key types: ec:P-256, ec:P-384, ec:P-521, rsa:2048, rsa:3072, rsa:4096, ed25519, ed448, ml-dsa-44, ml-dsa-65, ml-dsa-87.

Saving and loading

#![allow(unused)]
fn main() {
let pem = key.to_pem()?;
std::fs::write("account.key", &pem)?;

let loaded = AccountKey::from_pem(&pem)?;
}

Thumbprint and key authorization

#![allow(unused)]
fn main() {
let thumb = key.thumbprint();                        // base64url SHA-256 of JWK (no fallible call needed)
let key_auth = key.key_authorization("some-token");  // "<token>.<thumb>"
}

JWS algorithm

#![allow(unused)]
fn main() {
let alg = key.alg();   // "ES256", "EdDSA", "ML-DSA-65", etc.
}

AcmeClient

Directory discovery

AcmeClient::new fetches the ACME directory over HTTPS and caches the endpoint URLs. Nonces are recycled from Replay-Nonce response headers; HEAD /new-nonce is only called on a cache miss.

#![allow(unused)]
fn main() {
let client = AcmeClient::new("https://acme.example.com/acme/directory").await?;
}

The client also transparently retries any request that receives a badNonce error exactly once.

Account registration

#![allow(unused)]
fn main() {
use std::sync::Arc;

let key = Arc::new(AccountKey::generate("ec:P-256")?);
let opts = AccountOptions {
    contacts: &["mailto:admin@example.com"],
    agree_tos: true,
    eab: None,
};
let account = client.new_account(Arc::clone(&key), &opts).await?;
}

The returned Account contains:

  • account.url — the account URL (a.k.a. kid), used in subsequent requests
  • account.status"valid", "deactivated", or "revoked"
  • account.contacts — the contact URIs registered

Finding an existing account

POST to /new-account with onlyReturnExisting: true (RFC 8555 §7.3.1). Fails with accountDoesNotExist if no account is registered for the key.

#![allow(unused)]
fn main() {
let account = client.find_account(Arc::clone(&key)).await?;
}

Fetching current account state

POST-as-GET to the account URL (RFC 8555 §7.3.2). Returns a fresh Account reflecting the server’s current view.

#![allow(unused)]
fn main() {
let account = client.get_account(&account).await?;
println!("{} {:?}", account.status, account.contacts);
}

Updating account contacts

#![allow(unused)]
fn main() {
let updated = client.update_account(
    &account,
    &["mailto:new@example.com", "mailto:ops@example.com"],
).await?;
}

Pass an empty slice to clear all contacts.

Account key rollover (RFC 8555 §7.3.5)

Replaces the account key on the server with a new key. After this call the old account is no longer valid; use the returned Account, which holds the new key.

#![allow(unused)]
fn main() {
let new_key = Arc::new(AccountKey::generate("ec:P-384")?);
let updated = client.key_change(&account, Arc::clone(&new_key)).await?;
// updated.key is now new_key; old account key is rejected by the server
}

Account deactivation

#![allow(unused)]
fn main() {
client.deactivate_account(&account).await?;
}

After deactivation the account status becomes "deactivated". The server will reject all future requests signed with that account key.

External Account Binding (EAB)

Some ACME servers require EAB before accepting new accounts. EAB proves that the account request is authorized by an out-of-band credential.

Pass an EabOptions inside AccountOptions:

#![allow(unused)]
fn main() {
use akamu_client::{AccountOptions, EabOptions};

let eab_key_bytes: Vec<u8> = /* base64url-decode your HMAC key here */ vec![];

let opts = AccountOptions {
    contacts: &["mailto:admin@example.com"],
    agree_tos: true,
    eab: Some(EabOptions {
        kid: "eab-key-id-from-your-ca",
        hmac_key: &eab_key_bytes,  // raw bytes, NOT base64
        alg: "HS256",              // "HS256", "HS384", or "HS512"
    }),
};
let account = client.new_account(Arc::clone(&key), &opts).await?;
}

The library builds the EAB JWS internally, signs it with the HMAC key, and embeds it in the new-account request as externalAccountBinding.

Order lifecycle

sequenceDiagram
    participant App as Your App
    participant Lib as AcmeClient
    participant Srv as ACME Server

    App->>Lib: new_order(account, ids)
    Lib->>Srv: POST /acme/new-order
    Srv-->>Lib: 201 Order {authorizations, finalize}
    Lib-->>App: Order

    loop For each authorization URL
        App->>Lib: get_authorization(account, url)
        Lib->>Srv: POST /acme/authz/{id}
        Srv-->>Lib: 200 Authorization {challenges}
        Lib-->>App: Authorization

        App->>App: Present challenge (http-01 / dns-01 / ...)

        App->>Lib: trigger_challenge(account, challenge)
        Lib->>Srv: POST /acme/chall/{authz_id}/{type}
        Srv-->>Lib: 200 Challenge {status: processing}

        loop Poll until valid
            Lib->>Srv: POST-as-GET /acme/order/{id}
            Srv-->>Lib: 200 Order {status}
        end
    end

    App->>Lib: finalize(account, order, csr_der)
    Lib->>Srv: POST /acme/order/{id}/finalize
    Srv-->>Lib: 200 Order {certificate URL}
    Lib-->>App: Order (finalized)

    App->>Lib: download_certificate(account, cert_url)
    Lib->>Srv: POST /acme/cert/{id}
    Srv-->>Lib: 200 PEM bundle
    Lib-->>App: PEM bytes

new_order

#![allow(unused)]
fn main() {
let ids = vec![
    Identifier::dns("example.com"),
    Identifier::dns("www.example.com"),
    Identifier::ip("192.0.2.1"),          // RFC 8555 ip-type identifier
    Identifier::onion("foo.onion"),        // RFC 9799 onion identifier
];
let order = client.new_order(&account, &ids).await?;
// order.authorizations — Vec<String> of authz URLs
// order.finalize       — finalize URL
// order.status         — "pending"
}

get_authorization

#![allow(unused)]
fn main() {
let authz = client.get_authorization(&account, &authz_url).await?;
// authz.identifier   — Identifier { type, value }
// authz.status       — "pending", "valid", "invalid", ...
// authz.challenges   — Vec<Challenge>

// Convenience method to find a challenge by type:
let chall = authz.find_challenge("http-01").expect("no http-01 challenge offered");
}

trigger_challenge

#![allow(unused)]
fn main() {
client.trigger_challenge(&account, &challenge).await?;
}

Sends an empty-body POST ({}) to the challenge URL, signaling that the client is ready. The server begins validation asynchronously.

trigger_challenge_onion (RFC 9799)

For onion-csr-01 challenges, use this instead of trigger_challenge. It posts a {"csr": "<base64url>"} payload:

#![allow(unused)]
fn main() {
let csr_der = akamu_client::build_onion_csr(&domain, &key_auth, &hs_key_pem)?;
client.trigger_challenge_onion(&account, &challenge.url, &csr_der).await?;
}

poll_order

#![allow(unused)]
fn main() {
let order = client.poll_order(&account, &order.url).await?;
}

Polls with exponential backoff until order.status is "ready" or "valid". Respects the Retry-After header from the server. The internal deadline is 30 seconds; wrap with tokio::time::timeout for longer limits.

finalize

#![allow(unused)]
fn main() {
let csr_der = build_csr(&["example.com", "www.example.com"], cert_key.private_key())?;
let finalized = client.finalize(&account, &order, &csr_der).await?;
}

Submits the CSR. Returns the updated order which, when the server is done, contains a certificate URL.

download_certificate

#![allow(unused)]
fn main() {
let pem = client.download_certificate(&account, &cert_url).await?;
// pem is a Vec<u8> containing a PEM bundle (leaf + intermediates)
}

Certificate revocation (RFC 8555 §7.6)

Via account key

#![allow(unused)]
fn main() {
use akamu_client::pem_to_der;

let cert_pem = std::fs::read("cert.pem")?;
let cert_der = pem_to_der(&cert_pem).into_iter().next()
    .expect("no certificate in PEM");

// reason: None = unspecified; Some(0..=10, not 7) = CRL reason code
client.revoke_certificate(&account, &cert_der, None).await?;
}

Via certificate’s own private key (self-revocation)

Use this when the account key is unavailable but the certificate’s private key is known.

#![allow(unused)]
fn main() {
let cert_key = Arc::new(AccountKey::from_pem(&std::fs::read("cert.key.pem")?)?);
client.revoke_certificate_with_cert_key(&cert_key, &cert_der, Some(1)).await?;
}

ARI renewal information (RFC 9773)

#![allow(unused)]
fn main() {
let cert_pem = std::fs::read("cert.pem")?;
let info = client.get_renewal_info(&cert_pem).await?;
// info.window_start  — RFC 3339 string (start of suggested renewal window)
// info.window_end    — RFC 3339 string
// info.retry_after_secs — Option<u64> from Retry-After header
println!("Renew between {} and {}", info.window_start, info.window_end);
}

Returns Err if the server does not advertise a renewalInfo endpoint.

STAR order API (RFC 8739)

ACME STAR (Short-Term, Automatically Renewed) orders let a client place a single order and receive a continuous stream of short-lived certificates without repeating domain validation. Use StarOrderParams to describe the order and AcmeClient::new_star_order() to place it.

Placing a STAR order

#![allow(unused)]
fn main() {
use akamu_client::{StarOrderParams, Identifier};

let params = StarOrderParams {
    identifiers: &[Identifier::dns("example.com")],
    end_date: "2026-12-31T00:00:00Z",   // RFC 3339
    lifetime_secs: 86400,               // each cert is valid for 1 day
    start_date: None,                   // start when order becomes ready
    lifetime_adjust_secs: 0,            // no clock-skew pre-dating
    allow_certificate_get: true,        // allow unauthenticated rolling GET
};

let star_order = client.new_star_order(&account, &params).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

FieldRequiredDescription
identifiersYesIdentifiers to certify.
end_dateYesRFC 3339 timestamp; the last certificate’s notBefore must not exceed this.
lifetime_secsYesValidity period of each automatically issued certificate, in seconds.
start_dateNoRFC 3339 timestamp for the earliest notBefore of the first certificate. Defaults to when the order becomes ready.
lifetime_adjust_secsNoPre-dates each certificate’s notBefore by this many seconds to create an overlap window (RFC 8739 §3.1.1). Default: 0.
allow_certificate_getNoWhen 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 the id-pe-acmeIdentifier extension and registers it for SNI lookup.
  • cleanup() — aborts the background listener.

tls-alpn-01 cannot validate wildcard identifiers (RFC 8737 §3).

DNS helpers

Dns01Helper

Computes the TXT record value for dns-01. Does not modify DNS — you must add and remove the record yourself.

#![allow(unused)]
fn main() {
use akamu_client::Dns01Helper;

let txt = Dns01Helper::txt_value(&key_auth)?;
// Add TXT record: _acme-challenge.example.com  TXT  <txt>
// After validation completes, remove the record.
}

DnsPersist01Helper

Same computation for the dns-persist-01 challenge variant. The record name is _validation-persist.<domain> and it is long-lived (set once per account key):

#![allow(unused)]
fn main() {
use akamu_client::DnsPersist01Helper;

let txt = DnsPersist01Helper::txt_value(&key_auth)?;
// Add TXT record: _validation-persist.example.com  TXT  <txt>
}

build_onion_csr (RFC 9799)

Builds a DER-encoded CSR for an onion-csr-01 challenge. The CSR carries the cabf-onion-csr-nonce extension (OID 2.23.140.41) containing the key authorization as a DER UTF8String, and is signed by the hidden-service Ed25519 private key.

#![allow(unused)]
fn main() {
use akamu_client::build_onion_csr;

let hs_key_pem = std::fs::read("hs_ed25519_secret_key.pem")?;
let csr_der = build_onion_csr(
    "example.onion",   // .onion domain
    &key_auth,         // token.thumbprint
    &hs_key_pem,       // Ed25519 hidden-service private key (PEM)
)?;
client.trigger_challenge_onion(&account, &challenge.url, &csr_der).await?;
}

build_csr

Generates a PKCS#10 CSR in DER format. The first element of the domains slice becomes the CN; all elements become Subject Alternative Names.

#![allow(unused)]
fn main() {
let cert_key = AccountKey::generate("ec:P-256")?;
let csr_der = build_csr(&["example.com", "www.example.com"], cert_key.private_key())?;
}

Wildcard domains are supported: pass "*.example.com". The CSR key type is independent of the account key type.

pem_to_der

Re-exported from synta_certificate for convenience. Decodes all PEM blocks in a byte slice to DER:

#![allow(unused)]
fn main() {
use akamu_client::pem_to_der;

let cert_pem = std::fs::read("cert.pem")?;
let ders: Vec<Vec<u8>> = pem_to_der(&cert_pem);
let cert_der = ders.into_iter().next().expect("no certificate");
}

ClientError

#![allow(unused)]
fn main() {
pub enum ClientError {
    Jose(JoseError),          // JWK/JWS error from akamu-jose
    Http(String),             // HTTP transport error (hyper)
    Acme { acme_type: String, detail: String },  // server returned problem+json
    Crypto(String),           // key generation or CSR error
    Io(String),               // I/O error
}
}

Handle Acme errors by inspecting acme_type:

#![allow(unused)]
fn main() {
match err {
    ClientError::Acme { acme_type, detail } => {
        eprintln!("ACME error {acme_type}: {detail}");
        // acme_type examples:
        //   "urn:ietf:params:acme:error:badNonce"
        //   "urn:ietf:params:acme:error:unauthorized"
        //   "urn:ietf:params:acme:error:incorrectResponse"
        //   "urn:ietf:params:acme:error:accountDoesNotExist"
    }
    _ => eprintln!("Other error: {err}"),
}
}

badNonce errors are retried automatically once by the library; if they appear in user code it means the retry also failed.

akamu-cli — Command Reference

akamu-cli is a command-line ACME client that wraps akamu-client. It covers the full ACME lifecycle: account management, certificate issuance with multiple challenge types, ARI-aware renewal, and revocation.

Installation

Build from source

cargo build -p akamu-cli --release

The binary is placed at target/release/akamu-cli.

sudo install -m 0755 target/release/akamu-cli /usr/local/bin/akamu-cli

cargo install (from a local checkout)

cargo install --path crates/akamu-cli

This installs the binary to ~/.cargo/bin/akamu-cli.

Command tree

akamu-cli
  account
    register       Register a new ACME account
    deregister     Deactivate an existing account (RFC 8555 §7.3.7)
    show           Print current account URL, status, and contacts
    update         Update account contact list
    key-change     Roll the account key to a new key (RFC 8555 §7.3.5)
  issue            Obtain a certificate (http-01 / dns-01 / dns-persist-01 /
                   tls-alpn-01 / onion-csr-01)
  renew            ARI-aware renewal (RFC 9773); skips issuance if outside window
  revoke           Revoke a certificate via account key or certificate's own key

Subcommands

account register

Register a new ACME account and save the account URL to a sidecar file.

akamu-cli account register --server URL --account-key FILE [OPTIONS]
FlagRequiredDescription
--server URLyesACME directory URL. Default: Let’s Encrypt production.
--account-key FILEyesPath to the account key PEM file. Generated and saved if the file does not exist.
--key-type TYPEnoKey type to generate when the key file does not exist. Default: ec:P-256.
--contact URInoContact URI (e.g. mailto:admin@example.com). Repeatable.
--agree-tosnoAgree to the server’s Terms of Service.
--eab-kid KIDnoExternal Account Binding key ID.
--eab-key KEY_B64UnoEAB HMAC key encoded as base64url (no padding).
--eab-alg ALGnoEAB HMAC algorithm: HS256, HS384, or HS512. Default: HS256.

After registration the account URL is written to <account-key>.account-url (see Sidecar file).

account deregister

Deactivate an existing account (RFC 8555 §7.3.7).

akamu-cli account deregister --server URL --account-key FILE

The account URL is read from <account-key>.account-url. After deactivation the sidecar file is removed.

account show

Fetch the current account state from the server and print it.

akamu-cli account show --server URL --account-key FILE

Output example:

URL:     https://acme.example.com/acme/account/1234
Status:  valid
Contact: mailto:admin@example.com

account update

Update the contact list for an existing account.

akamu-cli account update --server URL --account-key FILE [--contact URI ...]
FlagRequiredDescription
--server URLyesACME directory URL
--account-key FILEyesAccount key PEM file
--contact URIno (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]
FlagRequiredDescription
--server URLyesACME directory URL
--account-key FILEyesCurrent account key PEM file
--new-key FILEyesNew key PEM file. Generated if the file does not exist.
--new-key-type TYPEnoKey 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]
FlagRequiredDescription
--server URLyesACME directory URL
--account-key FILEyesAccount key PEM file
-d DOMAINyes (repeatable)Domain to include. First value becomes the CN.
--out FILEyesOutput path for the PEM bundle (leaf + chain).
--key-type TYPEnoAccount key type when generating a new key. Default: ec:P-256.
--cert-key-type TYPEnoCertificate key type. Default: ec:P-256.
--cert-key FILEnoReuse an existing certificate private key PEM file. Generated and saved alongside --out if absent.
--challenge TYPEnoChallenge type: http-01, dns-01, dns-persist-01, tls-alpn-01, onion-csr-01. Default: http-01.
--http-port PORTnoPort for the built-in http-01 solver. Default: 80.
--tls-port PORTnoPort for the built-in tls-alpn-01 solver. Default: 443.
--onion-key FILErequired for onion-csr-01Ed25519 hidden-service private key PEM file.
--poll-timeout SECSnoMaximum seconds to wait for order/challenge validation. Default: 120.
--eab-kid KIDnoEAB key ID (used if no account exists yet).
--eab-key KEY_B64UnoEAB HMAC key (base64url).

If the account URL sidecar file does not exist, issue registers a new account first.

The http-01 and tls-alpn-01 challenge types cannot validate wildcard identifiers (RFC 8555 §8.3, RFC 8737 §3). Use dns-01 or dns-persist-01 for *.example.com.

The certificate private key is generated and saved to <out>.key.pem unless --cert-key is provided.

renew

Check the ARI renewal window (RFC 9773) for an existing certificate, then issue a replacement if renewal is suggested or --force is given.

akamu-cli renew --server URL --account-key FILE -d DOMAIN --out FILE [OPTIONS]
FlagRequiredDescription
--server URLyesACME directory URL
--account-key FILEyesAccount key PEM file
-d DOMAINyes (repeatable)Domains for the new certificate
--out FILEyesOutput path for the new PEM bundle
--cert FILEnoExisting certificate PEM to check against ARI. Without this flag, no ARI check is performed.
--forcenoRenew unconditionally, skipping the ARI window check.
--challenge TYPEnoSame values as issue. Default: http-01.
--http-port PORTnoDefault: 80.
--tls-port PORTnoDefault: 443.
--onion-key FILEnoRequired for onion-csr-01.
--poll-timeout SECSnoDefault: 120.
--key-type TYPEnoAccount key type. Default: ec:P-256.
--cert-key-type TYPEnoCertificate key type. Default: ec:P-256.
--eab-kid KIDnoEAB key ID.
--eab-key KEY_B64UnoEAB HMAC key (base64url).

Behavior when --cert is given and --force is not:

  • If the current time is before the ARI window start, the command exits without issuing and prints a message.
  • If the current time is within (or past) the window, issuance proceeds.
  • If the server does not support ARI or returns an error, issuance proceeds with a warning.

revoke

Revoke an issued certificate (RFC 8555 §7.6).

akamu-cli revoke --server URL --account-key FILE --cert FILE [OPTIONS]
FlagRequiredDescription
--server URLyesACME directory URL
--account-key FILEyesAccount key PEM file (used unless --cert-key is given)
--cert FILEyesPEM file containing the certificate to revoke
--reason NnoCRL reason code: 0–6 or 8–10 (7 is not valid). Omit for unspecified.
--cert-key FILEnoCertificate’s own private key PEM file. When provided, revocation is signed with the certificate key instead of the account key (self-revocation).

Self-revocation (via --cert-key) is useful when the ACME account key is unavailable.

Sidecar file

When you register an account, akamu-cli writes the account URL to a file named <account-key>.account-url in the same directory as the account key. For example, if your account key is ~/.akamu/account.pem, the sidecar is ~/.akamu/account.pem.account-url.

The issue, renew, deregister, account show, account update, and account key-change subcommands read this file to find the account URL without re-registering.

If the sidecar is missing and you run issue, the CLI registers a new account first. If the sidecar is missing and you run deregister, the command fails with an error.

Keep the key file and sidecar file together and back them up. If you lose the account key, you cannot deactivate or otherwise manage the account.

Example sessions

Register, issue (http-01), and deregister

# 1. Register an account
akamu-cli account register \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --key-type ec:P-256 \
  --contact mailto:admin@example.com \
  --agree-tos

# 2. Issue a certificate
akamu-cli issue \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  -d example.com \
  -d www.example.com \
  --out /etc/ssl/example.com/fullchain.pem

# 3. Deregister the account
akamu-cli account deregister \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem

Port 80 must be reachable from the ACME server for http-01 validation. The CLI starts a temporary HTTP server during challenge validation and shuts it down afterwards.

Issue with tls-alpn-01

Port 443 must be reachable from the ACME server. Wildcard domains are not supported.

akamu-cli issue \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  -d example.com \
  --challenge tls-alpn-01 \
  --tls-port 443 \
  --out /etc/ssl/example.com/fullchain.pem

Issue with dns-01 (wildcard)

dns-01 is interactive: the CLI prints the TXT record to add and waits for you to press Enter.

akamu-cli issue \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  -d "*.example.com" \
  --challenge dns-01 \
  --out /etc/ssl/example.com/wildcard.pem

Output:

DNS-01 challenge for *.example.com:
  Name:  _acme-challenge.example.com.
  Type:  TXT
  Value: <base64url>

Press Enter after the TXT record has propagated (Ctrl-C to abort)...

Issue with onion-csr-01 (RFC 9799)

akamu-cli issue \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  -d myservice.onion \
  --challenge onion-csr-01 \
  --onion-key ~/.tor/hs_ed25519_secret_key.pem \
  --out /etc/ssl/myservice/fullchain.pem

ARI-aware renewal

# Check ARI window; issue only if inside it
akamu-cli renew \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  -d example.com \
  --cert /etc/ssl/example.com/fullchain.pem \
  --out /etc/ssl/example.com/fullchain.pem

# Force renewal regardless of ARI window
akamu-cli renew \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  -d example.com \
  --cert /etc/ssl/example.com/fullchain.pem \
  --out /etc/ssl/example.com/fullchain.pem \
  --force

Revoke a certificate

# Via account key
akamu-cli revoke \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --cert /etc/ssl/example.com/fullchain.pem \
  --reason 1

# Self-revocation (account key unavailable)
akamu-cli revoke \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --cert /etc/ssl/example.com/fullchain.pem \
  --cert-key /etc/ssl/example.com/fullchain.pem.key.pem

Roll the account key

akamu-cli account key-change \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --new-key ~/.akamu/account-new.pem \
  --new-key-type ec:P-384

After success, ~/.akamu/account.pem contains the new key and the old key is no longer accepted by the server.

Show and update account contacts

akamu-cli account show \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem

akamu-cli account update \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --contact mailto:new@example.com \
  --contact mailto:backup@example.com

External Account Binding

Some CAs require EAB credentials before accepting a new account. Obtain a KID and HMAC key from your CA’s operator, then pass them to account register or issue:

akamu-cli account register \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --agree-tos \
  --eab-kid my-eab-key-id \
  --eab-key dGhpcyBpcyBhIHRlc3Qga2V5 \
  --eab-alg HS256

The --eab-key value must be the raw HMAC key encoded as base64url without padding. Do not wrap it in additional base64 encoding.

If the server has external_account_required = true and you omit the EAB flags, registration fails with urn:ietf:params:acme:error:externalAccountRequired.

Key type selection

Use caseRecommended typeNotes
Default / broadest compatibilityec:P-256Widely supported, fast, small signatures
Stronger classical securityec:P-384 or rsa:3072Use when policy requires
Post-quantum account keyml-dsa-65Larger signatures; check server support
Post-quantum certificate keyml-dsa-44Smallest PQ key; suitable for most cases
Legacy RSA-only environmentsrsa:2048 or rsa:4096Avoid unless forced by policy

ML-DSA keys require that the Akāmu server is built with the PQC OpenSSL fork. Vanilla Let’s Encrypt does not support ML-DSA.

Logging

Set the RUST_LOG environment variable to control log output:

RUST_LOG=info   akamu-cli issue ...    # normal progress messages
RUST_LOG=debug  akamu-cli issue ...    # HTTP request/response details
RUST_LOG=trace  akamu-cli issue ...    # full JWS content and all internal steps

The default level is warn, which prints only errors and warnings.

Error messages and troubleshooting

ACME error urn:ietf:params:acme:error:badNonce

The server rejected the nonce. The library retries automatically once. If this appears in CLI output, both attempts failed. Retry the command.

ACME error urn:ietf:params:acme:error:incorrectResponse

The server could not validate the challenge. For http-01, verify that port 80 is reachable and that no firewall or reverse proxy is blocking .well-known/acme-challenge/. For tls-alpn-01, verify port 443 is reachable with no TLS terminator in front.

ACME error urn:ietf:params:acme:error:externalAccountRequired

The server requires EAB credentials. Pass --eab-kid and --eab-key.

ACME error urn:ietf:params:acme:error:accountDoesNotExist

No account is registered for the given key. Run account register first.

invalid reason code 7; valid values: 0–6, 8–10

Reason code 7 (removeFromCRL) is not valid for revocation requests. Choose a different code or omit --reason.

Failed to bind port 80: Permission denied

The http-01 solver needs to listen on port 80. Either run with sudo, grant CAP_NET_BIND_SERVICE to the binary, or use an iptables redirect from port 80 to a high port and pass --http-port <high-port>.

Error: No such file: account.pem.account-url

The sidecar file is missing. Run account register first, or restore the sidecar from a backup.

Unsupported algorithm: ML-DSA-65

The server does not support the requested key type. Use a classical key type such as ec:P-256, or connect to an Akāmu server built with the PQC OpenSSL fork.

Architecture

This chapter describes the overall structure of Akāmu, the key modules, and the full request lifecycle from a TCP connection to an HTTP response.

System architecture

graph TB
    subgraph clients["ACME Clients"]
        certbot[certbot]
        acmesh[acme.sh]
        akamucli[akamu-cli]
        custom[RFC 8555 library]
    end

    subgraph akamu["Akāmu Server"]
        direction TB
        tls["TLS layer<br/>rustls + axum-server<br/>(optional — Mode 2/3/4)"]
        acme["ACME endpoints<br/>new-account · new-order · finalize<br/>revoke · ARI · key-change"]
        jose["akamu-jose<br/>JWK / JWS verification<br/>EAB HMAC check"]
        ca["CA module<br/>CSR validation<br/>certificate issuance<br/>CRL generation"]
        db[("SQLite<br/>accounts · orders · authzs<br/>challenges · certs · nonces")]
        val["Validators<br/>http-01 · dns-01<br/>tls-alpn-01 · dns-persist-01"]
        mtc["MTC log<br/>synta-mtc<br/>(optional)"]
    end

    subgraph external["External — Applicant Infrastructure"]
        httpserver["HTTP server<br/>port 80"]
        dns["DNS server<br/>TXT records"]
        tlsserver["TLS server<br/>port 443, ALPN acme-tls/1"]
    end

    subgraph artifacts["Issued Artifacts"]
        certchain["X.509 certificate chain<br/>PEM bundle"]
        crlsvc["CRL / OCSP service<br/>(external, referenced by URL)"]
    end

    clients -->|"HTTPS ACME requests (JWS-signed)"| tls
    tls --> acme
    acme --> jose
    acme --> ca
    acme --> db
    acme -->|"spawns tokio task"| val

    val -->|"GET /.well-known/…"| httpserver
    val -->|"TXT _acme-challenge.…"| dns
    val -->|"TLS ALPN connect"| tlsserver

    ca -->|"leaf + CA bundle"| certchain
    ca -->|"revoked serial list"| crlsvc
    ca --> mtc

Crate layout

The repository is organized as a Cargo workspace with four members:

Cargo.toml          <- workspace root (members: ., crates/*)
src/                <- akamu server binary
crates/
  akamu-jose/       <- JWK/JWS primitives (no HTTP/DB deps)
  akamu-client/     <- async ACME client library (tokio, hyper)
  akamu-cli/        <- CLI binary wrapping akamu-client

Crate dependencies

graph LR
    SERVER["akamu (server)"]
    CLIENT["akamu-client"]
    CLI["akamu-cli"]
    JOSE["akamu-jose"]
    SYNTA["synta-certificate"]

    SERVER --> JOSE
    SERVER --> SYNTA
    CLIENT --> JOSE
    CLIENT --> SYNTA
    CLI --> CLIENT
    JOSE --> SYNTA

The server and akamu-client both depend directly on akamu-jose and synta-certificate. akamu-cli depends only on akamu-client.

See Client Libraries for the standalone client API.

The server’s jose/ module

src/jose/jwk.rs and src/jose/jws.rs are thin re-exports:

#![allow(unused)]
fn main() {
// src/jose/jwk.rs
pub use akamu_jose::JwkPublic;

// src/jose/jws.rs
pub use akamu_jose::{JwsFlattened, JwsKeyRef, JwsProtectedHeader};
}

All JWK/JWS logic lives in crates/akamu-jose. The src/jose/ shim exists so the rest of the server can use short import paths without knowing about the crate boundary.

Server source layout

The src/ directory is organized as follows:

src/
  main.rs          Entry point; parses config, initializes subsystems, starts axum
  lib.rs           Re-exports public modules for integration tests
  config.rs        TOML configuration structs (Config, CaConfig, MtcConfig, ServerConfig)
  state.rs         Shared application state (AppState, CaState, MtcState)
  error.rs         AcmeError enum with HTTP mapping and problem+json serialization

  db/
    mod.rs         Database initialization (open, migrations, WAL mode)
    schema.rs      Row types mirroring SQLite columns
    accounts.rs    CRUD for accounts table
    authz.rs       CRUD for authorizations table
    certs.rs       CRUD for certificates table
    challenges.rs  CRUD for challenges table
    nonces.rs      Anti-replay nonce management
    orders.rs      CRUD for orders table

  routes/
    mod.rs         Router assembly, shared helpers (parse_jws, acme_headers, json_response)
    directory.rs   GET /acme/directory
    nonce.rs       HEAD/GET /acme/new-nonce
    account.rs     POST /acme/new-account, POST /acme/account/{id}
    order.rs       POST /acme/new-order, POST /acme/order/{id}
    authz.rs       POST /acme/authz/{id}
    challenge.rs   POST /acme/chall/{authz_id}/{type}
    finalize.rs    POST /acme/order/{id}/finalize
    certificate.rs GET /acme/cert/{id}
    revoke.rs      POST /acme/revoke-cert
    key_change.rs  POST /acme/key-change
    renewal_info.rs GET /acme/renewal-info/{cert_id}

  ca/
    mod.rs         Re-exports ca submodules
    init.rs        CA key and certificate load-or-generate
    csr.rs         PKCS#10 CSR parsing and validation
    issue.rs       End-entity certificate issuance
    revoke.rs      CRL generation

  validation/
    mod.rs         Challenge dispatch and DB state transitions (validate_challenge)
    http01.rs      http-01 validation (hyper HTTP client)
    dns01.rs       dns-01 validation (hickory-resolver)
    tls_alpn01.rs  tls-alpn-01 validation (rustls TLS client)

  mtc/
    mod.rs         Re-exports mtc submodules
    log.rs         Disk-backed Merkle Tree Certificate log integration

  jose/            Thin re-exports from crates/akamu-jose
                   (JwkPublic, JwsFlattened, JwsKeyRef, JwsProtectedHeader)

Key types

AppState

Defined in src/state.rs. Every axum handler receives an Arc<AppState> via axum’s State extractor. It contains:

  • config: Arc<Config> — immutable configuration parsed at startup.
  • db: Arc<Connection> — shared tokio-rusqlite connection. All database access goes through this.
  • ca: Arc<CaState> — CA private key, certificate, and signing policy.
  • mtc: Arc<MtcState> — MTC log handle and algorithm (or None if disabled).

AppState is Clone because Arc<T> is Clone. Cloning is cheap (reference count bump). All mutable state (the database and MTC log) is protected at a lower level by tokio-rusqlite’s internal background thread and a tokio::sync::Mutex<DiskBackedLog>, respectively.

CaState

Holds the CA private key (BackendPrivateKey from synta-certificate) and the DER-encoded CA certificate. The key is used for both certificate signing and CRL signing. CaState is shared across all concurrent handler tasks via Arc<CaState>. The underlying BackendPrivateKey delegates to the OpenSSL backend, which serializes concurrent signing operations internally.

AcmeError

Defined in src/error.rs. Implements IntoResponse so it can be returned directly from axum handlers. Maps each variant to:

  • An ACME problem type string (urn:ietf:params:acme:error:*).
  • An HTTP status code.
  • A human-readable detail string.

The response body is application/problem+json (RFC 7807).

Request lifecycle

1. TCP accept

The tokio runtime accepts a TCP connection on the configured listen_addr. axum’s serve function passes it to the hyper HTTP/1.1 or HTTP/2 codec.

2. HTTP parsing

hyper parses the HTTP request (method, URL, headers, body). Tower middleware is applied in order; currently only TraceLayer is configured, which emits a tracing span for each request.

3. Route dispatch

axum matches the request method and path against the router built in routes::build_router. Each route maps to a handler function in the corresponding routes/ module. The handler receives the following extractors:

  • State(state): State<Arc<AppState>> — shared application state.
  • Path(...) — URL path parameters (e.g., order ID, authz ID).
  • body: Bytes — raw request body for JWS verification.

4. JWS verification (POST endpoints)

Almost every POST endpoint calls routes::parse_jws before processing the payload:

  1. Parse: deserialize the Bytes body as a JWS flattened JSON serialization.
  2. Decode header: base64url-decode the protected header and parse the JSON.
  3. URL check: compare header.url with the expected full URL for this endpoint. A mismatch returns unauthorized.
  4. Nonce check: look up header.nonce in the nonces database table and mark it consumed. A missing or already-used nonce returns badNonce. Anti-replay protection is thus database-backed, surviving server restarts.
  5. Key resolution: if the header uses jwk, extract the SPKI DER from the JWK directly. If it uses kid, look up the account in the database and fetch its stored SPKI DER.
  6. Signature verification: verify the JWS signature over protected || "." || payload using the resolved public key via synta-certificate. Classical algorithms (RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA) use verify_signature. ML-DSA algorithms (ML-DSA-44, ML-DSA-65, ML-DSA-87) are dispatched first — their raw-byte signatures (not DER) are verified with verify_ml_dsa_with_context using an empty context string, as required by draft-ietf-cose-dilithium-11 §4.
  7. Payload decode: base64url-decode the payload field.

The result is a JwsContext struct containing the decoded header, payload bytes, SPKI DER, and optional account ID.

5. Business logic

Each handler implements the ACME protocol semantics for its endpoint: reading from and writing to the database, dispatching validation, invoking the CA, etc.

For write operations that span multiple tables (e.g., creating an order with its authorizations and challenges), the handler uses a single SQLite transaction to ensure atomicity.

6. Response construction

Handlers return Result<Response, AcmeError>. On success, they call routes::json_response, which:

  1. Generates a new anti-replay nonce.
  2. Inserts it into the nonces table.
  3. Adds the Replay-Nonce and Link: <directory>; rel="index" headers.
  4. 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:

  1. Marks the challenge processing in the database.
  2. Spawns a tokio::spawn task running validation::validate_challenge.
  3. Spawns a second observer task watching for panics via JoinHandle::await.
  4. Returns immediately with the processing status.

Similarly, MTC log appends are spawned as background tasks after certificate issuance.

Database access model

All database access goes through tokio_rusqlite::Connection, which runs rusqlite calls on a dedicated background OS thread. Calls cross the thread boundary via a channel. This means:

  • db.call(|conn| { ... }) is the only way to issue queries.
  • The closure runs synchronously on the background thread and must not call async functions.
  • For multi-statement atomicity, start a SQLite transaction inside the closure.

Foreign key enforcement is enabled at database open time (PRAGMA foreign_keys=ON). WAL journal mode is also enabled after migrations (PRAGMA journal_mode=WAL).

Async design

The server is fully async on the tokio runtime. All I/O — TCP, HTTP, DNS, TLS — is async. CPU-bound work (DER encoding for MTC) is offloaded to tokio::task::spawn_blocking.

The only shared mutable state in the async domain is the Mutex<DiskBackedLog> for the MTC log. All other state is either immutable after startup (AppState, Config, CaState) or encapsulated in the database background thread.

Database

Akāmu uses SQLite as its persistence layer, accessed via rusqlite (with bundled SQLite) and tokio-rusqlite for async access. Schema migrations are managed by rusqlite_migration.

Connection model

The server maintains a single tokio_rusqlite::Connection shared across all handler tasks via Arc<Connection>. tokio_rusqlite runs rusqlite operations on a dedicated background OS thread, communicating via an internal channel. This avoids blocking the tokio thread pool with synchronous I/O while keeping the SQLite API simple.

All queries use the pattern:

#![allow(unused)]
fn main() {
db.call(|conn| {
    // synchronous rusqlite operations here
    conn.execute(...)?;
    Ok(result)
}).await?
}

Initialization

db::open(path) in src/db/mod.rs performs the following in order:

  1. Opens or creates the SQLite database file (or opens an in-memory database for :memory:).
  2. Enables foreign key enforcement: PRAGMA foreign_keys=ON.
  3. Runs all pending migrations via rusqlite_migration.
  4. Enables WAL mode: PRAGMA journal_mode=WAL.

WAL (Write-Ahead Logging) mode is enabled after migrations rather than before, because changing the journal mode during a migration can cause transaction issues.

At server startup, nonces older than 24 hours are swept: db::nonces::sweep_expired(&db, 86400).

Schema

The database has six tables, applied through three migration files.

Migration 001 — Initial schema

nonces — Anti-replay nonces consumed on first use.

CREATE TABLE nonces (
    nonce   TEXT    PRIMARY KEY,
    created INTEGER NOT NULL  -- Unix epoch seconds
);

accounts — ACME accounts.

CREATE TABLE accounts (
    id             TEXT    PRIMARY KEY,
    status         TEXT    NOT NULL DEFAULT 'valid',
    contact        TEXT,                        -- JSON array of mailto: URIs
    public_key     BLOB    NOT NULL,            -- DER-encoded SubjectPublicKeyInfo
    jwk_thumbprint TEXT    NOT NULL UNIQUE,     -- base64url SHA-256 JWK thumbprint
    created        INTEGER NOT NULL,
    updated        INTEGER NOT NULL
);

jwk_thumbprint has a unique constraint so the database enforces that no two accounts share a key.

orders — ACME orders.

CREATE TABLE orders (
    id             TEXT    PRIMARY KEY,
    account_id     TEXT    NOT NULL REFERENCES accounts(id),
    status         TEXT    NOT NULL DEFAULT 'pending',
    expires        INTEGER,
    identifiers    TEXT    NOT NULL,            -- JSON [{type,value}]
    not_before     INTEGER,
    not_after      INTEGER,
    error          TEXT,                        -- problem+json string if invalid
    certificate_id TEXT,
    created        INTEGER NOT NULL,
    updated        INTEGER NOT NULL
);

identifiers is stored as a JSON string, e.g. [{"type":"dns","value":"example.com"}].

authorizations — One per identifier per order.

CREATE TABLE authorizations (
    id         TEXT    PRIMARY KEY,
    order_id   TEXT    NOT NULL REFERENCES orders(id),
    account_id TEXT    NOT NULL REFERENCES accounts(id),
    status     TEXT    NOT NULL DEFAULT 'pending',
    identifier TEXT    NOT NULL,                -- JSON {"type":..,"value":..}
    expires    INTEGER,
    wildcard   INTEGER NOT NULL DEFAULT 0,      -- 0=false, 1=true
    created    INTEGER NOT NULL,
    updated    INTEGER NOT NULL
);

account_id is denormalized from the parent order to allow efficient per-account queries without joins.

challenges — One or more per authorization.

CREATE TABLE challenges (
    id        TEXT    PRIMARY KEY,
    authz_id  TEXT    NOT NULL REFERENCES authorizations(id),
    type      TEXT    NOT NULL,                -- http-01|dns-01|tls-alpn-01
    status    TEXT    NOT NULL DEFAULT 'pending',
    token     TEXT    NOT NULL,
    validated INTEGER,
    error     TEXT,
    created   INTEGER NOT NULL,
    updated   INTEGER NOT NULL
);

All challenges for a given authorization share the same token (generated once per authorization at order creation).

certificates — Issued X.509 certificates.

CREATE TABLE certificates (
    id                TEXT    PRIMARY KEY,
    order_id          TEXT    NOT NULL REFERENCES orders(id),
    account_id        TEXT    NOT NULL REFERENCES accounts(id),
    serial_number     TEXT    NOT NULL UNIQUE,  -- hex-encoded
    status            TEXT    NOT NULL DEFAULT 'valid',
    der               BLOB    NOT NULL,
    pem               TEXT    NOT NULL,
    not_before        INTEGER NOT NULL,
    not_after         INTEGER NOT NULL,
    revoked_at        INTEGER,
    revocation_reason INTEGER,
    mtc_log_index     INTEGER,
    created           INTEGER NOT NULL
);

der stores only the leaf certificate DER. pem stores the full PEM chain (leaf + CA). Both der and pem are stored because some operations (CRL generation, MTC logging) need the DER, while the download endpoint serves the PEM.

Migration 002 — Renewal info

Adds ARI (ACME Renewal Information) columns to certificates:

ALTER TABLE certificates ADD COLUMN suggested_window_start INTEGER;
ALTER TABLE certificates ADD COLUMN suggested_window_end   INTEGER;

These are NULL by default. The ARI endpoint computes a default window if they are not set.

Migration 003 — Performance indexes

CREATE INDEX IF NOT EXISTS idx_certs_status
    ON certificates(status);

CREATE INDEX IF NOT EXISTS idx_certs_account_status_not_after
    ON certificates(account_id, status, not_after);

CREATE INDEX IF NOT EXISTS idx_nonces_created
    ON nonces(created);

idx_certs_status speeds up CRL generation (which selects all revoked certificates). idx_certs_account_status_not_after speeds up per-account certificate listing. idx_nonces_created speeds up the expiry sweep.

Row types

src/db/schema.rs defines Rust structs mirroring each table row. These are plain data structs used to move data between the database layer and the application logic:

  • AccountRow — mirrors accounts.
  • OrderRow — mirrors orders.
  • AuthorizationRow — mirrors authorizations.
  • ChallengeRow — mirrors challenges.
  • CertificateRow — mirrors certificates.

Database module structure

Each table has its own submodule in src/db/:

ModuleExposed functions
db::accountsinsert, get_by_id, get_by_thumbprint, update_contact, update_status, update_key
db::ordersinsert, get_by_id, update_status, list_authz_ids
db::authzinsert, get_by_id, update_status
db::challengesinsert, get_by_id, list_by_authz, set_processing, set_invalid
db::certsget_by_id, get_by_serial, revoke, set_mtc_log_index
db::noncesinsert, consume, sweep_expired

Transactions

Multi-table writes use explicit SQLite transactions to ensure atomicity:

  • Order creation: the order row, all authorization rows, and all challenge rows are inserted in a single transaction.
  • Challenge validation success: the challenge, authorization, and (if all authorizations are now valid) the order are updated in a single transaction.
  • Certificate issuance: the certificate row is inserted and the order is updated to valid in a single transaction.

This prevents the database from being left in an inconsistent state if the process crashes between writes.

Schema diagram

The entity-relationship diagram below shows all six tables and their foreign-key relationships. The account_id column on authorizations is denormalized from the parent order; both FKs exist in the database.

erDiagram
    accounts {
        TEXT id PK
        TEXT status
        TEXT contact
        BLOB public_key
        TEXT jwk_thumbprint UK
        INTEGER created
        INTEGER updated
    }
    orders {
        TEXT id PK
        TEXT account_id FK
        TEXT status
        INTEGER expires
        TEXT identifiers
        TEXT error
        TEXT certificate_id
        INTEGER created
        INTEGER updated
    }
    authorizations {
        TEXT id PK
        TEXT order_id FK
        TEXT account_id FK
        TEXT status
        TEXT identifier
        INTEGER expires
        INTEGER wildcard
        INTEGER created
        INTEGER updated
    }
    challenges {
        TEXT id PK
        TEXT authz_id FK
        TEXT type
        TEXT status
        TEXT token
        INTEGER validated
        TEXT error
        INTEGER created
        INTEGER updated
    }
    certificates {
        TEXT id PK
        TEXT order_id FK
        TEXT account_id FK
        TEXT serial_number UK
        TEXT status
        BLOB der
        TEXT pem
        INTEGER not_before
        INTEGER not_after
        INTEGER revoked_at
        INTEGER revocation_reason
        INTEGER mtc_log_index
        INTEGER created
    }
    nonces {
        TEXT nonce PK
        INTEGER created
    }

    accounts ||--o{ orders : "account_id"
    accounts ||--o{ authorizations : "account_id (denormalized)"
    accounts ||--o{ certificates : "account_id"
    orders ||--o{ authorizations : "order_id"
    authorizations ||--o{ challenges : "authz_id"
    orders ||--o{ certificates : "order_id"

Foreign key enforcement

Foreign key constraints are enabled at database open time. The constraint graph is:

  • orders.account_idaccounts.id
  • authorizations.order_idorders.id
  • authorizations.account_idaccounts.id
  • challenges.authz_idauthorizations.id
  • certificates.order_idorders.id
  • certificates.account_idaccounts.id

Enabling foreign keys is done before running migrations so that any migration that would violate a constraint fails immediately rather than silently inserting orphaned rows.

Certificate Authority

The CA module (src/ca/) handles key generation, certificate issuance, CSR validation, and CRL generation. All CA operations are synchronous (no async); they are called from async handlers but do not perform I/O themselves (except during initialization).

Initialization (src/ca/init.rs)

ca::init::load_or_generate(config: &CaConfig) is called once at startup. It follows this logic:

key_file existscert_file existsAction
NoNoGenerate a new CA key and self-signed certificate; write both to disk.
YesYesLoad both PEM files from disk.
YesNo (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 stringAlgorithm
"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:

FieldValue
SerialINTEGER { 1 }
SubjectCN=<common_name>, O=<organization>
IssuerSame as subject (self-signed)
NotBeforeCurrent time
NotAfterca_validity_years * 365.25 * 86400 seconds in the future
BasicConstraintsCritical; cA=TRUE
KeyUsageCritical; keyCertSign + cRLSign
SubjectKeyIdentifierSHA-1 of the public key (RFC 5280 method 1)
AuthorityKeyIdentifierSame 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
  1. Parse: decode the csr_der as a DER PKCS#10 CertificationRequest using synta.
  2. Re-encode CRI: encode the CertificationRequestInfo back to DER to obtain the exact bytes that were signed.
  3. Re-encode AlgorithmIdentifier: same for the signature algorithm.
  4. Re-encode SPKI: extract and re-encode the SubjectPublicKeyInfo.
  5. Verify signature: BackendPublicKey::verify_signature(cri_der, alg_der, signature). Returns BadCsr if invalid.
  6. Extract extensions: walk the CSR attributes for the extensionRequest attribute (OID 1.2.840.113549.1.9.14) and decode the extension list.
  7. Check BasicConstraints: if present, reject cA=TRUE.
  8. Parse SANs: walk the SubjectAlternativeName extension for dNSName (tag [2]) and iPAddress (tag [7]) entries. Other SAN types are silently ignored.
  9. Bidirectional set equality: every CSR SAN must appear in allowed_identifiers, and every entry in allowed_identifiers must appear in the CSR SANs. A mismatch returns BadCsr.
  10. Re-encode Subject: extract the subject Name DER.

The returned ValidatedCsr contains the SPKI DER, subject DER, and parsed SAN list. These are passed directly to issue_certificate.

The DER walking in step 6 is done manually (not using synta’s high-level decoder) because the extension attribute is nested inside a SET OF ANY, which requires raw byte manipulation to extract.

Certificate issuance (src/ca/issue.rs)

ca::issue::issue_certificate(ca_key, ca_cert_der, hash_alg, validity_days, crl_url, ocsp_url, csr) builds and signs an X.509 v3 end-entity certificate.

Serial number

A random 16-byte serial is generated by getrandom. The high bit is cleared to ensure the value is non-negative in two’s complement (required by RFC 5280 §4.1.2.2).

Extensions added

ExtensionCriticalNotes
BasicConstraintsNocA=FALSE
KeyUsageYesdigitalSignature only
ExtendedKeyUsageNoserverAuth
SubjectKeyIdentifierNoSHA-1 of SPKI from CSR
AuthorityKeyIdentifierNoSHA-1 of CA’s SPKI
SubjectAlternativeNameNoRebuilt from validated SANs
AuthorityInfoAccessNoPresent only if ocsp_url is set
CRLDistributionPointsNoPresent only if crl_url is set

PEM bundle

The returned IssuedCert contains:

  • cert_der — the leaf certificate DER (stored in certificates.der).
  • cert_pem — a PEM bundle with the leaf certificate followed by the CA certificate (stored in certificates.pem and 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 RevokedEntry with serial bytes, revocation time, and optional reason code.
  • Includes a CRLNumber extension (required for v2 by RFC 5280 §5.2.3) with the current Unix timestamp as a monotonically increasing integer.
  • Is returned as both DER and PEM.

The CRL Number is encoded as a positive DER INTEGER. encode_integer_der handles the two’s complement padding (adding a 0x00 prefix when the high bit of the first content byte is set).

The server does not call build_crl automatically. It must be called by external tooling or a future CRL-serving endpoint.

Challenge Validation

Challenge validation is the process by which Akāmu probes applicant servers to verify domain ownership. All validation is asynchronous and intentionally infallible — errors are recorded in the database rather than propagated.

Dispatch

The entry point is validation::validate_challenge in src/validation/mod.rs. It is called from routes::challenge::respond_challenge after marking the challenge as processing.

#![allow(unused)]
fn main() {
pub async fn validate_challenge(
    state: &Arc<AppState>,
    challenge_id: &str,
    authz_id: &str,
    chall_type: &str,
    id_type: &str,
    id_value: &str,
    key_auth: &str,
    token: &str,
)
}

validate_challenge calls dispatch(chall_type, ...), which routes to one of four validators:

chall_typeModuleFunction
"http-01"validation::http01validate(domain, token, key_auth)
"dns-01"validation::dns01validate(domain, key_auth)
"tls-alpn-01"validation::tls_alpn01validate(domain, key_auth)
"dns-persist-01"validation::dns_persist_01validate(domain, account_uri, issuer_domain, resolver_addr)
Any otherReturns AcmeError::IncorrectResponse

After dispatch returns, validate_challenge calls either on_valid or on_invalid to update the database.

Validation flow

sequenceDiagram
    participant Client as ACME Client
    participant H as Route Handler
    participant V as validate_challenge (spawned)
    participant D as dispatch()
    participant Ext as Applicant Server / DNS
    participant DB as SQLite

    Client->>H: POST to challenge URL
    H->>DB: challenge → processing
    H->>V: tokio::spawn(validate_challenge)
    H-->>Client: 200 processing (immediate return)

    V->>D: dispatch(chall_type, domain, key_auth, …)

    alt http-01
        D->>Ext: GET /.well-known/acme-challenge/TOKEN
        Ext-->>D: 200 key_authorization body
    else dns-01
        D->>Ext: TXT _acme-challenge.DOMAIN
        Ext-->>D: TXT record value
    else tls-alpn-01
        D->>Ext: TLS connect :443, ALPN acme-tls/1
        Ext-->>D: Certificate with id-pe-acmeIdentifier
    else dns-persist-01
        D->>Ext: TXT _validation-persist.DOMAIN
        Ext-->>D: TXT record value (issuer;accounturi;policy;persistUntil)
    end

    alt probe succeeded
        V->>DB: BEGIN TRANSACTION
        V->>DB: challenge → valid (+ validated timestamp)
        V->>DB: authorization → valid
        V->>DB: count non-valid authzs for order
        alt all authorizations now valid
            V->>DB: order → ready
        end
        V->>DB: COMMIT
    else probe failed
        V->>DB: challenge → invalid (+ error JSON)
        V->>DB: authorization → invalid
        V->>DB: order → invalid
    end

    Note over Client: Client polls authorization URL (POST-as-GET)

Background execution

Validation runs inside a tokio::spawn task, not in the request handler’s async context:

#![allow(unused)]
fn main() {
let handle = tokio::spawn(async move {
    validation::validate_challenge(
        &state_clone,
        &challenge_id,
        &authz_id_clone,
        &chall_type_clone,
        &id_type,
        &id_value,
        &key_auth,
        &token,
    )
    .await;
});

// Observer task: log panics without letting them go silent.
tokio::spawn(async move {
    if let Err(e) = handle.await {
        tracing::error!(
            "challenge {challenge_id_for_log}: validation task panicked: {e:?}"
        );
    }
});
}

The challenge handler returns the processing status immediately. The client must poll the authorization URL to detect completion.

The observer task pattern ensures that panics inside the validation task are logged via tracing::error! rather than silently discarded (which would happen if the JoinHandle were simply dropped).

State cascade diagram

stateDiagram-v2
    direction TB

    state "Challenge" as chall {
        [*] --> ch_pending
        ch_pending --> ch_processing : client responds
        ch_processing --> ch_valid : probe OK
        ch_processing --> ch_invalid : probe failed
    }

    state "Authorization" as authz {
        [*] --> az_pending
        az_pending --> az_valid : challenge valid
        az_pending --> az_invalid : challenge invalid
    }

    state "Order" as ord {
        [*] --> or_pending
        or_pending --> or_ready : all authzs valid
        or_pending --> or_invalid : any authz invalid
    }

    chall --> authz : atomic DB transaction
    authz --> ord : atomic DB transaction (on_valid)\nindependent steps (on_invalid)

State transitions on success (on_valid)

All three updates run inside a single SQLite transaction:

  1. UPDATE challenges SET status = 'valid', validated = <now> WHERE id = <challenge_id>
  2. UPDATE authorizations SET status = 'valid' WHERE id = <authz_id>
  3. SELECT order_id FROM authorizations WHERE id = <authz_id>
  4. SELECT COUNT(*) FROM authorizations WHERE order_id = <order_id> AND status != 'valid'
  5. If count is zero: UPDATE orders SET status = 'ready' WHERE id = <order_id>

If any step fails (e.g., a database error), a warning is logged and the transaction is rolled back. The challenge remains in processing status until the next validation attempt or a timeout.

State transitions on failure (on_invalid)

  1. db::challenges::set_invalid(challenge_id, error_json, now) — marks the challenge invalid and stores the error JSON.
  2. db::authz::update_status(authz_id, "invalid", now) — marks the authorization invalid.
  3. db::authz::get_by_id(authz_id) — finds the parent order ID.
  4. db::orders::update_status(order_id, "invalid", None, now) — marks the order invalid.

Each step is independent; a failure in one step logs a warning but does not prevent the others from running. This is intentional: if the database is in a degraded state, we still attempt to record as much of the failure as possible.

http-01 validator (src/validation/http01.rs)

Uses hyper (already a transitive dependency via axum) as the HTTP client.

Validation steps:

  1. Construct the URL http://<domain>/.well-known/acme-challenge/<token>.
  2. Send a GET request via hyper_util::client::legacy::Client.
  3. Check that the response status is 2xx.
  4. Read up to 8192 bytes of the response body.
  5. Decode as UTF-8 and trim whitespace.
  6. Compare with key_auth. Any mismatch returns AcmeError::IncorrectResponse.

Error mapping:

  • Connection or parse failure → AcmeError::Connection
  • Non-2xx status → AcmeError::IncorrectResponse
  • Body too large → AcmeError::IncorrectResponse
  • Key auth mismatch → AcmeError::IncorrectResponse

dns-01 validator (src/validation/dns01.rs)

Uses hickory_resolver::TokioAsyncResolver with the system default resolver configuration.

Validation steps:

  1. Strip any leading *. prefix from the domain (RFC 8555 §8.4 requires this for wildcard orders).
  2. Construct the query name _acme-challenge.<base_domain>.
  3. Compute expected = base64url(SHA-256(key_auth)).
  4. Perform a TXT record lookup.
  5. For each TXT record, join all character-strings (TXT records may be split) and compare the trimmed result with expected.
  6. If at least one record matches, return Ok(()).

Error mapping:

  • DNS lookup failure → AcmeError::Dns
  • No matching TXT record → AcmeError::IncorrectResponse

The inner function validate_with_resolver(domain, key_auth, resolver) accepts a custom resolver for testability. Unit tests provide a local UDP stub DNS server.

tls-alpn-01 validator (src/validation/tls_alpn01.rs)

Uses rustls and tokio-rustls.

Validation steps:

  1. Compute expected_hash = SHA-256(key_auth).
  2. Build a rustls::ClientConfig that:
    • Accepts any server certificate without chain validation (AcceptAnyCert custom verifier).
    • Advertises only the ALPN protocol "acme-tls/1".
    • Supports both TLS 1.2 and TLS 1.3.
  3. TCP-connect to <domain>:443.
  4. Perform the TLS handshake.
  5. Extract the end-entity certificate from the peer certificate chain.
  6. Call verify_acme_cert(domain, cert_der, &expected_hash).

verify_acme_cert walks the certificate DER manually:

  • Finds the id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31).
  • Checks it is marked critical.
  • Checks its value is OCTET STRING(32 bytes) equal to expected_hash.
  • Finds the SubjectAlternativeName extension.
  • Checks it contains domain as a dNSName.

The DER walker (find_extension_value) navigates the Certificate → TBSCertificate → Extensions structure using hand-written TLV parsing helpers (read_tlv, decode_length, strip_sequence, etc.). This approach avoids requiring the full synta decoder for a security-critical path.

Error mapping:

  • Invalid server name → AcmeError::Tls
  • TCP connect failure → AcmeError::Connection
  • TLS handshake failure → AcmeError::Tls
  • Missing or non-critical id-pe-acmeIdentifierAcmeError::IncorrectResponse
  • Hash mismatch → AcmeError::IncorrectResponse
  • Missing SAN → AcmeError::IncorrectResponse
  • Domain not in SAN → AcmeError::IncorrectResponse

AcceptAnyCert

The AcceptAnyCert struct implements rustls::client::danger::ServerCertVerifier. It returns Ok(ServerCertVerified::assertion()) unconditionally for every certificate. Chain validation is intentionally bypassed because the tls-alpn-01 certificate is self-signed and issued by the ACME client for validation purposes only. All semantic checks are performed by verify_acme_cert instead.

dns-persist-01 validator (src/validation/dns_persist_01.rs)

Uses hickory_resolver::TokioAsyncResolver. The resolver is either the system default or the address configured via server.dns_resolver_addr.

Unlike the other challenge types, dns-persist-01 does not use a token · thumbprint key authorization. The key_auth value passed to the validator is the requesting account’s full URI (constructed as <base_url>/acme/account/<account_id> in routes::challenge). This URI is matched directly against the accounturi= field in the TXT record.

Validation steps:

  1. Strip any leading *. prefix from the domain; record whether the order is a wildcard.
  2. Construct the query name _validation-persist.<base_domain>.
  3. Perform a TXT record lookup.
  4. For each TXT record value, call matches_record(value, issuer_domain, account_uri, is_wildcard, now).
  5. If at least one record matches, return Ok(()).

matches_record

matches_record is pub(crate) and is unit-tested independently of the DNS stack.

It splits the raw TXT value on ; and applies the following checks in order:

  1. The first token (trimmed, trailing dot stripped, lowercased) equals expected_issuer (same normalization applied).
  2. Among the remaining tokens, accounturi=<uri> is present and the URI matches expected_account_uri exactly (case-sensitive).
  3. If require_wildcard_policy is true, policy=wildcard is present among the tokens.
  4. If a persistUntil=<ts> token is present, parse_persist_until(ts) returns a Unix timestamp that is greater than or equal to now.

Unknown tokens are silently ignored. The function returns false as soon as any required condition is not met.

parse_persist_until

A pure-Rust, zero-dependency parser for the YYYY-MM-DDTHH:MM:SSZ timestamp format. It performs the proleptic Gregorian day count from the Unix epoch without using any external date/time crate. Returns None for malformed input (wrong separators, out-of-range fields, missing Z suffix).

Error mapping:

  • DNS lookup failure → AcmeError::Dns
  • No matching TXT record → AcmeError::IncorrectResponse

Error Handling

All fallible operations in Akāmu return Result<T, AcmeError>. The AcmeError type is defined in src/error.rs and implements both std::error::Error (via thiserror) and axum’s IntoResponse.

AcmeError taxonomy

ACME-specific errors

These map to ACME problem type URNs (urn:ietf:params:acme:error:*):

VariantACME typeHTTP status
BadNoncebadNonce400
BadSignatureAlgorithm(String)badSignatureAlgorithm400
Unauthorized(String)unauthorized401
AccountDoesNotExistaccountDoesNotExist400
AccountAlreadyExistsaccountAlreadyExists409
InvalidContact(String)invalidContact400
UnsupportedContactunsupportedContact400
UserActionRequired(String)userActionRequired403
RejectedIdentifier(String)rejectedIdentifier400
UnsupportedIdentifier(String)unsupportedIdentifier400
OrderNotReadyorderNotReady403
BadCsr(String)badCSR400
BadRevocationReasonbadRevocationReason400
AlreadyRevokedalreadyRevoked400
Caa(String)caa403
Connection(String)connection400
Dns(String)dns400
IncorrectResponse(String)incorrectResponse400
Tls(String)tls400

Generic HTTP-mapped errors

These do not have dedicated ACME error types; they fall through to serverInternal in the ACME type, but carry the appropriate HTTP status:

VariantHTTP status
NotFound404
MethodNotAllowed405
Conflict(String)409
UnsupportedMediaType415
PayloadTooLarge413
BadRequest(String)400

Internal errors

These indicate server-side failures. They map to serverInternal in the ACME error type and HTTP 500:

VariantMeaning
Database(String)SQLite error
Crypto(String)Cryptographic operation failure
Builder(String)Certificate or CRL builder error
Mtc(String)MTC log operation failure
Internal(String)General internal error

Response format

AcmeError::into_response builds a response with:

  • HTTP status from http_status().
  • Content-Type: application/problem+json (RFC 7807).
  • JSON body:
{
  "type": "urn:ietf:params:acme:error:badNonce",
  "status": 400,
  "detail": "bad nonce"
}

The detail field is the Display string of the variant, which for parameterized variants includes the inner string.

From implementations

Two From implementations allow the ? operator to be used with database errors:

  • From<tokio_rusqlite::Error> for AcmeError → wraps in AcmeError::Database.
  • From<rusqlite::Error> for AcmeError → wraps in AcmeError::Database.

This means any db.call(|conn| { ... }).await? will automatically convert database errors to AcmeError::Database(msg).

Error propagation in handlers

Handlers return Result<Response, AcmeError>. axum automatically calls IntoResponse::into_response on the error variant when building the HTTP response.

Example handler error propagation chain:

db::accounts::get_by_id(&db, &id).await?
  ↓ rusqlite::Error or tokio_rusqlite::Error
  ↓ From<...> for AcmeError
  ↓ AcmeError::Database("...")
  ↓ returned from handler as Err(AcmeError::Database(...))
  ↓ axum calls AcmeError::into_response()
  ↓ HTTP 500 with application/problem+json body

Error handling in background tasks

Background validation tasks (tokio::spawn) must not panic and must not propagate errors. validation::validate_challenge is declared infallible: it calls on_valid or on_invalid internally and logs any database errors via tracing::warn!. Panics inside validation tasks are caught by the observer task pattern described in the Validation chapter.

Design principles

  • No unwrap() in production paths. All fallible operations use ? or explicit error handling.
  • Internal errors do not leak details to clients. AcmeError::Internal and AcmeError::Database both map to serverInternal and HTTP 500. The detail field is included in the response because ACME clients need some indication of what went wrong, but sensitive internal state is not exposed.
  • Challenge errors are ACME errors. The validation layer converts hyper, hickory-resolver, and rustls errors into specific AcmeError variants (Connection, Dns, Tls, IncorrectResponse) so the client receives a meaningful ACME error type.

Testing

Akāmu uses Rust’s built-in test framework (cargo test). Tests are organized at three levels:

  • Unit tests inside each source file (#[cfg(test)] mod tests).
  • Integration tests in tests/acme_flow.rs, which test the full HTTP stack.

Running tests

Run all tests:

cargo test

Run a specific test by name:

cargo test validate_csr

Run all tests in a specific module:

cargo test ca::csr::tests

Run with output visible (useful for debugging):

cargo test -- --nocapture

Library crate tests

Each library crate ships its own unit tests:

cargo test -p akamu-jose    # 66 tests: JWK parsing, JWS sign/verify, ML-DSA
cargo test -p akamu-client  # 16 tests: AccountKey, EAB, CSR, challenge helpers

akamu-jose tests cover every key type including ML-DSA-44/65/87 round-trip sign/verify. akamu-client tests use real OpenSSL key generation (no mocking). See crates/akamu-jose/src/ and crates/akamu-client/src/ for test modules.

Test dependencies

dev-dependencies in Cargo.toml:

CratePurpose
tokio (with test-util)#[tokio::test] macro for async tests
tempfileTemporary files and directories for CA key tests and database tests
towerServiceExt::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_file returns a descriptive error for missing files.
  • Config::from_file returns a descriptive error for invalid TOML.

src/error.rs

Tests verify:

  • Every AcmeError variant maps to the correct ACME type string.
  • Every variant maps to the correct HTTP status code.
  • Display strings are correct.
  • From<rusqlite::Error> and From<tokio_rusqlite::Error> convert correctly.
  • into_response produces Content-Type: application/problem+json and 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_payload returns BadRequest for an empty payload.
  • require_payload returns BadRequest for invalid JSON.
  • require_payload succeeds 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 false for 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" returns AcmeError::Internal.
  • unix_to_generalized_time(0) returns "19700101000000Z".
  • load_or_generate creates both files when neither exists.
  • load_or_generate loads successfully when both files exist.
  • load_or_generate returns 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=TRUE in BasicConstraints is rejected.
  • SANs not in the allowed set are rejected.
  • Required identifiers missing from CSR SANs are rejected.
  • IPv4 and IPv6 SAN parsing.
  • Edge cases: no SAN extension, email SANs (ignored), non-extensionRequest attributes.
  • DER helper functions: strip_sequence, tlv_header, bytes_to_ip_string.

src/ca/issue.rs

Tests verify:

  • End-to-end certificate issuance produces a parseable DER certificate.
  • Serial number in issued cert matches serial_hex.
  • PEM bundle contains exactly two certificates (leaf + CA).
  • Chain verification passes using synta-x509-verification.
  • CRL URL and OCSP URL extensions are included when configured.
  • IP SAN issuance works.
  • Invalid IP SAN string returns AcmeError::Builder.
  • Unknown SAN type is silently skipped.

src/ca/revoke.rs

Tests verify:

  • An empty CRL is generated with correct PEM headers.
  • CRL with revoked entries is generated.
  • encode_integer_der produces 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_type maps each AcmeError variant correctly.
  • dispatch returns IncorrectResponse for unsupported challenge types.
  • on_valid and on_invalid do not panic when called with non-existent IDs.
  • on_valid with real DB rows updates challenge, authz, and order to valid/ready.
  • on_invalid with real DB rows marks everything invalid.
  • validate_challenge for http-01 with a live local server marks the challenge valid.
  • Database error paths in on_valid and on_invalid are covered with partial-schema databases.

src/validation/http01.rs

Tests use a local axum HTTP server on an ephemeral port:

  • Correct key auth returns Ok.
  • Unreachable domain returns AcmeError::Connection.
  • HTTP 404 response returns AcmeError::IncorrectResponse.
  • Response body over 8 KiB returns AcmeError::IncorrectResponse.
  • Key auth mismatch returns AcmeError::IncorrectResponse.

src/validation/dns01.rs

Tests use a hand-crafted UDP DNS stub server:

  • Correct TXT value returns Ok.
  • Wrong TXT value returns AcmeError::IncorrectResponse.
  • Non-existent domain returns an error.
  • Wildcard prefix is stripped before querying.

src/validation/tls_alpn01.rs

Tests include both unit tests for the DER walker and integration tests using local TLS servers:

  • decode_length, read_tlv, strip_sequence, strip_octet_string, skip_tlv — edge cases.
  • find_extension_value with correct, missing, and wrong-OID extensions.
  • verify_acme_cert with hand-crafted DER certificates.
  • Local TLS 1.3 and TLS 1.2 servers for validate_inner coverage.
  • AcceptAnyCert verifier always returns Ok.

src/mtc/log.rs

Tests verify:

  • open_or_create creates a new log file.
  • Appending leaves increments the tree size.
  • Re-opening an existing log file restores the leaf count.
  • compute_root returns a 32-byte value for a non-empty log.

src/routes/account.rs

Tests verify contact validation and account_json structure.

src/routes/order.rs

Tests verify order_json for pending, valid, invalid, and expired orders.

Integration tests (tests/acme_flow.rs)

The integration tests build a full AppState with an in-memory database, a generated CA, and a real axum router. They use tower::ServiceExt::oneshot to send HTTP requests directly to the router without binding a TCP port.

The test suite covers multi-step ACME flows including account creation, order creation, challenge signaling, and verification that status transitions occur correctly.

Adding new tests

Place unit tests in a #[cfg(test)] mod tests { ... } block at the bottom of the source file being tested. Use tokio::test for async tests:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn my_async_test() {
        // ...
    }

    #[test]
    fn my_sync_test() {
        // ...
    }
}
}

For tests that need a database, call crate::db::open(":memory:").await.unwrap() to get a fresh in-memory database with the full schema applied.

For tests that need a CA, call crate::ca::init::load_or_generate(&config).unwrap() with a CaConfig pointing to a tempfile::TempDir.

Coverage

Coverage measurement is supported via the measure_coverage.sh script in the repository root. It uses cargo llvm-cov or a similar tool. See that script for the exact invocation.

Local CI

contrib/ci/local-ci.sh runs the same checks that a GitHub Actions workflow would run, in the same order, without needing a CI account or a push. Use it to catch failures before committing or to reproduce a CI failure locally.

Quick start

# Run the full pipeline
./contrib/ci/local-ci.sh all

# Run only formatting and lint checks
./contrib/ci/local-ci.sh fmt clippy

# List available jobs
./contrib/ci/local-ci.sh --list

Requirements

ToolInstall
cargohttps://rustup.rs/
rustfmtrustup component add rustfmt
clippyrustup component add clippy
mdbookcargo install mdbook (optional, for the doc job)
actionlinthttps://github.com/rhysd/actionlint (optional, for lint-workflows)
yamllintpip 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

JobCommandDepends on
buildcargo build --workspace + cargo build --benches
fmtcargo fmt --all -- --check
clippycargo clippy -- -D warningsbuild
doccargo doc --no-deps + mdbook build docs/build
testcargo testbuild
benchcargo build --benches (compile-only)build
lint-workflowsactionlint 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

OptionEffect
--no-colorDisable ANSI colour output (also honoured via NO_COLOR=1)
--no-depsSkip prerequisite auto-dispatch — intended for use inside an actual CI system where needs: already serialises the jobs
--listPrint available job names and exit
--help / -hShow usage and exit

Environment variables

VariableEffect
CARGO_TARGET_DIRRedirect Cargo build artefacts to an isolated directory
NO_COLORSet to 1 to disable ANSI colour output

Summary output

After all requested jobs finish the script prints a table:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CI Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  build                PASS  (22s)
  fmt                  PASS  (1s)
  clippy               PASS  (0s)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total time: 23s
All jobs passed.

The script exits with status 0 when all executed jobs pass and 1 if any job fails. Skipped jobs (due to a failed dependency) are shown as SKIP and do not affect the exit status.

Use inside GitHub Actions

When running a single job from within a workflow step, pass --no-deps so the script does not re-run prerequisites that the workflow needs: graph has already enforced:

- name: Clippy
  run: ./contrib/ci/local-ci.sh --no-deps clippy

Without --no-deps, the script would automatically invoke build before clippy, duplicating work that another job already performed.

Example invocations

# Full pipeline
./contrib/ci/local-ci.sh all

# Only the fast checks (no compilation required)
./contrib/ci/local-ci.sh fmt lint-workflows

# Isolated build directory (keeps the main target/ clean)
CARGO_TARGET_DIR=/tmp/akamu-ci ./contrib/ci/local-ci.sh all

# CI-mode: run one job without triggering its prerequisites
./contrib/ci/local-ci.sh --no-deps test

# No colour output (e.g. when piping to a log file)
./contrib/ci/local-ci.sh --no-color all

Contributing

This chapter describes the conventions for code style, commit messages, and the pull request process for Akāmu.

Running CI locally

Before pushing, verify the pipeline locally with contrib/ci/local-ci.sh:

./contrib/ci/local-ci.sh all

This runs the same jobs the CI system runs: build, fmt, clippy, doc, test, bench (compile-only), and workflow linting. See Local CI for the full reference.

Code style

Formatting

All Rust code is formatted with rustfmt using the default configuration:

cargo fmt

Run this before committing, or let the fmt job catch it:

./contrib/ci/local-ci.sh fmt

Lints

Clippy is the linter. Address all warnings before submitting:

cargo clippy -- -D warnings

Or via the CI script:

./contrib/ci/local-ci.sh clippy

Documentation comments

Public types, functions, and modules should have doc comments (/// for items, //! for module-level). The standard format is:

#![allow(unused)]
fn main() {
/// Brief one-line summary.
///
/// Longer explanation if needed. Include:
/// - what the function does
/// - what each parameter means
/// - what the return value represents
/// - any notable edge cases or panics
pub fn my_function(arg: &str) -> Result<(), AcmeError> {
    // ...
}
}

No unwrap() in production code

Use ? or explicit error handling. Exceptions:

  • Test code may use unwrap() freely.
  • Truly infallible operations (e.g., serde_json::to_string on a value that is always serializable) may use unwrap() 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.rs that 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_blocking for synchronous blocking work.
  • Do not hold mutex guards across .await points. SQLite access via db.call() does not hold any async mutex; it is safe.
  • Background tasks spawned with tokio::spawn must be panic-safe. Use the observer task pattern to log panics.

Commit conventions

Commits follow the format:

<type>: <short summary (imperative, lowercase, no period)>

[optional body]

Types used in this repository:

TypeWhen to use
featA new feature visible to users or operators
fixA bug fix
docDocumentation changes only
testAdding or modifying tests without changing production code
refactorCode change that neither fixes a bug nor adds a feature
perfPerformance improvement
choreBuild system, dependency updates, CI

Keep the summary line under 72 characters. Use the body to explain why the change was made, not what was changed (the diff explains what).

Examples from the repository:

doc: document thread-safety assumption for CaState key field
fix: eliminate TOCTOU race in MTC log open_or_create
fix: log panics in background validation task
fix: use compile-time-safe HeaderValue in error response
fix: use saturating_sub in nonce sweep to avoid debug-mode panic

Pull request process

  1. Fork the repository and create a topic branch from main.
  2. Make your changes with appropriate tests.
  3. Ensure ./contrib/ci/local-ci.sh all passes (covers fmt, clippy, doc, test, and bench compilation).
  4. Write a clear PR description explaining what problem the change solves and how it was tested.
  5. Keep PRs focused on a single concern. Unrelated changes should be separate PRs.
  6. 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:

  1. Create a handler function in a new or existing file under src/routes/.
  2. Register the route in routes::build_router in src/routes/mod.rs.
  3. Add the endpoint URL to the directory response in routes::directory::get_directory if it should be advertised.
  4. Add any new database operations to the appropriate src/db/*.rs module.
  5. Add unit tests for the handler logic and any new database functions.
  6. Update the user-facing documentation for the affected feature.

Adding a new challenge type

  1. Create a new module in src/validation/, e.g., src/validation/mytype01.rs.
  2. Export an async validate(domain, key_auth) function.
  3. Add a new arm to dispatch in src/validation/mod.rs.
  4. Add the new challenge type to the list of challenges created per identifier in routes::order::new_order.
  5. Add tests, including both unit tests and, if possible, an integration test using a local stub server.
  6. Document the new challenge type in docs/src/user/challenges.md.

Workspace development

The repository is a Cargo workspace. When adding code to crates/akamu-jose or crates/akamu-client, run that crate’s tests in isolation first:

cargo test -p akamu-jose
cargo test -p akamu-client

Then verify the full workspace:

cargo test
cargo clippy --workspace
cargo fmt -- --check

Do not add axum, rusqlite, or server-specific dependencies to akamu-jose or akamu-client — they must remain usable without a running server.