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).
  • Validates TNAuthList and JWTClaimConstraints identifiers using the tkauth-01 challenge type (RFC 9447 / RFC 9448), verifying signed authority tokens issued by an external Token Authority.
  • Issues end-entity certificates signed by a built-in Certificate Authority whose key and self-signed root are generated automatically on first run, or loaded from existing PEM files.
  • Persists all ACME objects (accounts, orders, authorizations, challenges, certificates, nonces) in a SQL database. The supported backends are SQLite (default; single-file, no external service required), PostgreSQL, and MariaDB/MySQL, selected by the database.url configuration key.
  • Generates and serves CRLs (Certificate Revocation Lists) at GET /ca/crl.
  • Serves OCSP responses at GET /ca/ocsp/{request} and POST /ca/ocsp (RFC 6960).
  • Implements the ACME Renewal Information extension (RFC 9773) so ACME clients know when to renew.
  • Optionally appends issued certificates to a Merkle Tree Certificate transparency log using the synta-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. EAB keys can be provisioned in two ways: statically in the TOML config under [server.eab_keys], or derived on demand via HKDF-SHA-256 (RFC 5869) when [server].eab_master_secret is set and the client authenticates via GSSAPI or a trusted proxy (GET /acme/eab).
  • Optionally terminates TLS directly using rustls, with an auto-generated certificate on first run. Supports mutual TLS (mTLS) client certificate authentication with configurable CA trust anchors, chain depth, RSA modulus enforcement, and post-quantum client certificate acceptance.
  • Supports multi-node clustering through a built-in CRDT + gossip replication layer. All domain state (accounts, orders, authorizations, challenges, certificates, EAB keys, operators, delegations, MTC) is replicated to every cluster member via signed gossip envelopes. Nodes are registered with each other via the POST /admin/gossip/register admin endpoint. When the [gossip] section is absent the node runs in single-node mode with no replication overhead.

What it does not do

  • It does not support wildcard certificates via http-01 or tls-alpn-01 (only dns-01 and dns-persist-01 can authorize wildcard identifiers per RFC 8555 §7.1.3).

Technology stack

ComponentLibrary
Async runtimetokio
HTTP frameworkaxum 0.8
Databasesqlx 0.8 (SQLite / PostgreSQL / MariaDB via Any backend)
Schema migrationssqlx built-in migrate
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)
CRDT replicationakamu-crdt (workspace crate) — LWW-register, OR-map, LWW-map, GrowSet primitives

Standards implemented

Reading guide

The documentation is split into three sections targeting distinct audiences:

SectionWho it is forWhat it covers
Operator GuideSystem administrators deploying and running AkāmuInstallation, configuration, account policies, certificate issuance, revocation, TLS, backup
API ReferenceDevelopers consuming Akāmu’s HTTP APIs or using the Rust client librariesAdmin REST API, ACME protocol details (algorithms, challenge types, error codes, wire formats), akamu-jose / akamu-client / akamu-cli
Implementation GuideContributors working on the Akāmu source codeArchitecture, database schema, CA internals, challenge validation, EAB and account internals, testing

Quick navigation

New to Akāmu? Start with the Quick Start guide.

Deploying or configuring the server? See the Configuration Reference for every configuration key, or Operator Roles for RBAC setup.

Building an ACME client or integrating via the API? Start with ACME Protocol Reference for the wire-level details, Admin API for the management REST API, or akamu-client for the Rust library.

Contributing to Akāmu? Read the Architecture chapter first — it includes a full system diagram covering all subsystems and their interactions.

Installation

Prerequisites

  • Rust toolchain 1.75 or later (install via rustup)
  • OpenSSL development headers (required by synta-certificate’s cryptography backend and by rustls-native-ossl, the TLS crypto provider)

Fedora / RHEL

sudo dnf install openssl-devel

Debian / Ubuntu

sudo apt install libssl-dev

Checking out the source

git clone <akamu-repo> akamu

All synta dependencies are fetched automatically from crates.io — no manual checkout required.

Building from source

The repository is a Cargo workspace with seven members: the akamu server binary, akamu-jose, akamu-client, akamu-cli, akamuctl, akamu-cosigner, and akamu-ldap (the OpenLDAP C-binding library, used by the server when reading profiles from LDAP).

cd akamu
cargo build --release

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

  • target/release/akamu — the ACME server
  • target/release/akamu-cli — the command-line client
  • target/release/akamuctl — the admin CLI
  • target/release/akamu-cosigner — the MTC cosigner daemon

To build only the server:

cargo build --bin akamu --release

To build only the CLI:

cargo build --bin akamu-cli --release

Note: The first build downloads and compiles all dependencies including bundled SQLite. It can take several minutes on a first run.

Verifying the build

./target/release/akamu --help

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

Installing the binary

Copy the binary to a location in $PATH:

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

systemd service (optional)

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

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

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

# Logging
StandardOutput=journal
StandardError=journal

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

[Install]
WantedBy=multi-user.target

Then enable and start:

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

Running tests

cargo test

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

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

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

First Run

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

1. Create directories

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

2. Write a minimal configuration file

Copy the example configuration and adjust it:

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

At a minimum you need:

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

[database]
url = "sqlite:///var/lib/akamu/akamu.db"

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

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

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

Note: listen_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;
    }
}

Running behind a reverse proxy (Unix socket)

Instead of binding a TCP port, Akāmu can listen on a Unix domain socket. This avoids exposing a TCP port and simplifies firewall rules when the reverse proxy runs on the same host.

Set listen_addr to a unix: path (TLS must be absent or disabled — the proxy terminates TLS):

listen_addr = "unix:/run/akamu/akamu.sock"
base_url    = "https://acme.example.com"
# No [tls] section — TLS is terminated at the proxy

The AKAMU_LISTEN environment variable overrides listen_addr without touching the config file:

AKAMU_LISTEN=unix:/run/akamu/akamu.sock akamu /etc/akamu/config.toml

nginx

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

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

    location / {
        proxy_pass http://unix:/run/akamu/akamu.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Apache

ProxyPass        / unix:/run/akamu/akamu.sock|http://localhost/
ProxyPassReverse / unix:/run/akamu/akamu.sock|http://localhost/

Trusted proxies and X-Remote-User

If you use server.trusted_proxies for admin auth via X-Remote-User, all connections arriving over a Unix socket are treated as locally trusted (no CIDR check). The header is still required — any connection without it receives a 401.

systemd socket activation

The provided unit files support socket activation, which lets systemd pre-bind the socket before the service starts:

systemctl enable --now akamu.socket akamu.service

The socket is created at /run/akamu/akamu.sock (mode 0660). The reverse proxy process must be in group akamu to connect. When using socket activation, listen_addr in the config file is ignored — the pre-bound socket is passed via LISTEN_FDS.

Stale socket files are removed automatically on config-based startup. Under socket activation, systemd owns the socket file and no cleanup is needed.

Your First Certificate

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

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

Before you begin

Make sure:

  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"] }

Operator Roles

Operator Guide — This section is for administrators who deploy and run Akāmu. It covers configuration, account management, certificate issuance policies, and operational concerns. If you are building an ACME client or integrating with the API, see the API Reference. If you are contributing to Akāmu itself, see the Implementation Guide.

Akamu’s admin API uses role-based access control to enforce least privilege and separation of duties. Every admin request is authenticated, and every operator has exactly one role. The role determines which endpoints the operator may call. Roles are assigned at operator creation time and can be changed later by an administrator.

Understanding the roles before provisioning operators is important: a mis-scoped operator can either have too much access (a security risk) or too little (causing operational failures).

Role hierarchy

The four roles form a strict access hierarchy. Higher roles have a superset of the read permissions of lower roles, but lower roles do NOT inherit write permissions from higher roles.

administrator
    |-- ca_operations
    |       |-- ca_ra (read only within own CA scope)
    |               |-- auditor (read only, no CA data)

More precisely:

  • administrator can call every endpoint.
  • ca_operations can call everything administrator can except operator management, server config reads, and profile write operations.
  • ca_ra is a scoped RA role. It can do a subset of ca_operations reads but only within its assigned CA, and can revoke and issue EAB keys. It cannot see other CAs or perform any CA infrastructure operations.
  • auditor is read-only and cannot perform any write operations.

Per-role reference

administrator

The administrator role is for PKI administrators who need full control over the server. Assign it to the smallest possible number of humans and avoid using it for automated service accounts.

Key capabilities:

  • All endpoints without restriction.
  • Create, update, activate, deactivate, and unlock operators.
  • Read and write certificate profiles.
  • Read server configuration.
  • Manage EAB keys (create and delete).
  • Revoke certificates from any CA.
  • Force CRL regeneration for any CA or all CAs.
  • Cross-sign CAs.
  • Manage RFC 9115 delegation objects.
  • Deactivate ACME accounts.
  • Query the audit log.

Key restrictions:

  • None. This role has full access.

Typical use case: A human PKI administrator who needs to bootstrap the system, manage other operators, update certificate profiles, or respond to a security incident that requires account deactivation or forced revocation.

Example:

akamuctl operator add \
    --name alice \
    --role administrator \
    --cert-file /etc/akamu/alice-client.pem

ca_operations

The ca_operations role is for automated CA infrastructure processes and operations staff who manage the certificate lifecycle but do not need to manage operators or read the server configuration.

Key capabilities:

  • List and view CAs and their certificates.
  • Download CA certificates.
  • Force CRL regeneration (global and per-CA).
  • Cross-sign CAs.
  • List, view, and download issued certificates.
  • Create and delete EAB keys.
  • Revoke certificates from any CA.
  • Manage RFC 9115 delegation objects (create, update, delete).
  • Manage ACME account profile grants (set but not clear).
  • View accounts, orders, profiles, EAB keys, stats.
  • Query the audit log.

Key restrictions:

  • Cannot manage operators (no access to /admin/operators endpoints).
  • Cannot read server configuration (GET /admin/config).
  • Cannot write certificate profiles (no POST, PUT, or DELETE on profiles).
  • Cannot deactivate ACME accounts.
  • Cannot clear account profile grant lists.

CA scope (optional): A ca_operations operator can be assigned a ca_id scope. When scoped, the operator’s visibility is limited to the assigned CA: GET /admin/cas returns only that CA, CRL/cross-sign operations are restricted to the scoped CA, and certificate and order queries are automatically filtered. Unlike ca_ra, a ca_id scope is optional for ca_operations; an unscoped ca_operations operator has server-wide access.

EAB keys and CA scope: Even a scoped ca_operations operator may create EAB keys. EAB keys are not bound to any CA — they are used for ACME account registration which is server-global — so this is intentional. Only an administrator may set for_operator_id when creating an EAB key via POST /admin/eab to assign it to a different operator for web UI login.

Typical use case: A CI/CD pipeline that issues EAB keys for new devices, or an operations team member who manages revocation and CRL generation without having the ability to create new operators.

Example:

akamuctl operator add \
    --name ci-pipeline \
    --role ca_operations \
    --cert-file /etc/akamu/ci-client.pem

ca_ra

The ca_ra role is for front-line registration authority (RA) staff or automated RA services that operate on behalf of a single specific CA. This role is intentionally narrow: it cannot see data from other CAs and cannot perform any CA infrastructure operations.

Key capabilities:

  • View EAB keys.
  • List and view certificates issued by the scoped CA only.
  • Download certificates issued by the scoped CA only.
  • Revoke certificates issued by the scoped CA only.
  • View accounts and orders (filtered to the scoped CA automatically).
  • View profiles, delegations, stats.

Key restrictions:

  • Cannot create or delete EAB keys (POST /admin/eab, DELETE /admin/eab/{kid}).
  • Cannot list or view CAs (GET /admin/cas, GET /admin/cas/{id}).
  • Cannot see cross-certificates.
  • Cannot force CRL regeneration.
  • Cannot cross-sign CAs.
  • Cannot manage operators.
  • Cannot read server configuration.
  • Cannot write certificate profiles or delegation objects.
  • Cannot manage ACME accounts (deactivate, set/clear profile grants).
  • Cannot query the audit log.
  • Cannot view or act on certificates from a CA other than its assigned ca_id.
  • Requires a ca_id scope to be assigned. An unscoped ca_ra operator is rejected at every restricted endpoint with 403 Forbidden.

Special requirement — ca_id: See CA scope for ca_ra below.

Typical use case: A registration authority system at a branch office that accepts certificate requests for a specific CA (for example, rsa), issues EAB keys for new ACME clients, and revokes certificates when devices are decommissioned, without having any visibility into the rest of the PKI.

Example:

akamuctl operator add \
    --name branch-ra \
    --role ca_ra \
    --ca-id rsa \
    --cert-file /etc/akamu/branch-ra.pem

auditor

The auditor role is for security auditors, compliance officers, and monitoring systems that need read access to the server’s operational data but must never be able to make changes.

Key capabilities:

  • Query the audit event log (GET /admin/audit).
  • List and view issued certificates (across all CAs).
  • List and view EAB keys.
  • List and view accounts, orders, and profiles.
  • View delegations.
  • View cross-certificates.
  • View server statistics.

Key restrictions:

  • No write operations at all.
  • Cannot view CA details or CA certificates.
  • Cannot download certificate content (PEM/DER).
  • Cannot view server configuration.
  • Cannot manage operators.

Typical use case: A security operations center tool that polls the audit log for anomalies, or a compliance dashboard that tracks certificate issuance counts and expiry windows.

Example:

akamuctl operator add \
    --name soc-monitor \
    --role auditor \
    --gssapi-principal soc-svc@EXAMPLE.COM

Permission matrix

The table below is the authoritative route-by-role matrix derived from the server source code. Y means the role is permitted. Where ca_ra is permitted on a cert or account endpoint, the server automatically enforces the CA scope filter — the operator only sees data belonging to its assigned CA.

MethodPathadministratorca_operationsca_raauditor
POST/admin/sessionYYYY
DELETE/admin/sessionYYYY
GET/admin/operatorsY
POST/admin/operatorsY
GET/admin/operators/{id}Y
PUT/admin/operators/{id}Y
PATCH/admin/operators/{id}Y
POST/admin/operators/{id}/unlockY
GET/admin/auditYY
GET/admin/profilesYYYY
GET/admin/profiles/{id}YYYY
POST/admin/profilesY
PUT/admin/profiles/{id}Y
DELETE/admin/profiles/{id}Y
GET/admin/accountsYYYY
GET/admin/account/{id}YYYY
POST/admin/account/{id}/deactivateY
GET/admin/account/{id}/profile-grantsYYYY
PUT/admin/account/{id}/profile-grantsYY
DELETE/admin/account/{id}/profile-grantsY
GET/admin/certsYYY (scoped)Y
GET/admin/certs/{id}YYY (scoped)Y
GET/admin/certs/{id}/downloadYYY (scoped)
POST/admin/eabYY
GET/admin/eab/{kid}YYYY
DELETE/admin/eab/{kid}YY
GET/admin/eabYYYY
GET/admin/ordersYYY (scoped)Y
GET/admin/orders/{id}YYYY
GET/admin/configY
POST/admin/crl/forceYY
POST/admin/revokeYYY (scoped)
GET/admin/statsYYYY
GET/admin/casYY
GET/admin/cas/{id}YY
GET/admin/cas/{id}/certYY
POST/admin/ca/{id}/crl/forceYY
POST/admin/ca/{id}/cross-signYY
GET/admin/cross-certsYYY
GET/admin/cross-certs/{id}YYY
GET/admin/delegationsYYYY
POST/admin/delegationsYY
GET/admin/delegations/{id}YYYY
PUT/admin/delegations/{id}YY
DELETE/admin/delegations/{id}YY

Note on ca_ra scoping: When ca_ra is listed as permitted on a cert, account, or order endpoint, the server silently overrides any ca_id query parameter the operator supplies and substitutes the operator’s assigned ca_id. An unscoped ca_ra (one with an empty ca_id) is rejected at all such endpoints with 403 Forbidden.

Creating operators

All operator creation requires the administrator role.

Create an mTLS operator

# administrator — full access human admin
akamuctl operator add \
    --name alice \
    --role administrator \
    --cert-file /etc/akamu/alice-client.pem

# ca_operations — automated CA pipeline
akamuctl operator add \
    --name ca-pipeline \
    --role ca_operations \
    --cert-file /etc/akamu/pipeline-client.pem

# ca_ra — scoped RA for the 'rsa' CA
akamuctl operator add \
    --name branch-ra \
    --role ca_ra \
    --ca-id rsa \
    --cert-file /etc/akamu/branch-ra.pem

# auditor — read-only monitoring
akamuctl operator add \
    --name soc-monitor \
    --role auditor \
    --cert-file /etc/akamu/soc-monitor.pem

The --cert-file flag accepts a PEM file. akamuctl computes the SHA-256 fingerprint of the DER-encoded leaf certificate locally and sends only the fingerprint to the server. The private key never leaves the operator’s machine.

Create a GSSAPI/Kerberos operator

akamuctl operator add \
    --name bob \
    --role auditor \
    --gssapi-principal bob@EXAMPLE.COM

Dual-credential operator

An operator can authenticate by either mTLS or GSSAPI by supplying both credentials at creation time:

akamuctl operator add \
    --name carol \
    --role ca_operations \
    --cert-file /etc/akamu/carol-client.pem \
    --gssapi-principal carol@EXAMPLE.COM

Change an operator’s role

# Promote an auditor to ca_operations
akamuctl operator update 4 --role ca_operations

# Assign a ca_ra operator to a different CA
akamuctl operator update 5 --role ca_ra --ca-id ec

When a role or CA scope changes, all active sessions for that operator are invalidated immediately.

Choosing a role

Use this guide to decide which role to assign:

ScenarioRole
Full PKI administrator who bootstraps the system and manages operatorsadministrator
CI/CD pipeline that provisions EAB keys for new devicesca_operations
Automated system that revokes certificates across all CAsca_operations
Branch RA service that views EAB keys and revokes certs for one CAca_ra
External ACME client (NDC) that registers via a specific CAca_ra
Security operations center monitoring toolauditor
Compliance dashboard that tracks issuance countsauditor
Human auditor reviewing the audit log for a compliance auditauditor

When in doubt, start with auditor and escalate only when a required operation fails. The server returns 403 Forbidden with a clear error message when a role is insufficient for a requested endpoint.

CA scope for ca_ra

The ca_id field on a ca_ra operator is a mandatory scope that restricts the operator to data belonging to a single CA. It must be the ID string of a CA configured in the server’s [ca.*] sections (for example, "rsa" or "ec").

Why it is required: Without a CA scope, a ca_ra operator would have server-wide revocation and EAB-issuance authority, which defeats the purpose of the role. The server enforces this: any ca_ra operator that reaches a restricted endpoint with an empty ca_id receives 403 Forbidden.

What it controls:

  • GET /admin/certs: the ca_id query parameter is ignored; only certs from the scoped CA are returned.
  • GET /admin/certs/{id}/download: the server returns 404 Not Found if the certificate belongs to a different CA.
  • POST /admin/revoke: 403 Forbidden if the target certificate belongs to a different CA.
  • GET /admin/accounts and GET /admin/orders: automatically filtered to the scoped CA.

Assigning a CA scope at creation:

akamuctl operator add \
    --name branch-ra \
    --role ca_ra \
    --ca-id rsa \
    --cert-file /etc/akamu/branch-ra.pem

Changing the CA scope after creation:

# Move the operator to a different CA
akamuctl operator update 5 --role ca_ra --ca-id ec

The --ca-id flag is only accepted when --role ca_ra is also provided (or the operator already has the ca_ra role). Supplying --ca-id for any other role is rejected by the server.

Revoking without a scope: If you need to create a ca_ra operator without a CA scope initially and assign the scope in a second step, use operator add followed by operator update:

# Step 1: create with scope (required at creation if role is ca_ra)
akamuctl operator add --name branch-ra --role ca_ra --ca-id rsa \
    --cert-file /etc/akamu/branch-ra.pem

# Later: reassign to a different CA
akamuctl operator update 7 --role ca_ra --ca-id ec

There is no way to store a ca_ra operator with an empty ca_id; the server rejects such a request at POST /admin/operators with 400 Bad Request.

Authentication methods

Operators authenticate to the admin API using one or more of three mechanisms:

  • mTLS client certificate — the operator presents a TLS client certificate during the handshake. The server looks up the SHA-256 fingerprint of the DER-encoded leaf in the operators table. This is the recommended mechanism for automated service accounts.

  • GSSAPI/Kerberos — the operator sends an Authorization: Negotiate header with a SPNEGO token. The server validates the token and looks up the extracted principal in the operators table. This is the recommended mechanism for human operators in organizations with Kerberos infrastructure.

  • Bearer session token — after a successful mTLS or GSSAPI login, the returned token is used for subsequent requests. Session tokens idle-expire after session_ttl_secs (default one hour).

An operator record can hold both a cert_fingerprint and a gssapi_principal, allowing the same logical operator to authenticate by either method.

For configuration details and security notes on GSSAPI, see EAB and Kerberos Authentication. For the full authentication protocol including session token expiry and the bounded session store, see Admin API and Operator Management.

Configuration Reference

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

akamu /etc/akamu/config.toml

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

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

Complete example

listen_addr  = "0.0.0.0:8080"
base_url     = "https://acme.example.com"
crdt_db_url  = "sqlite:///var/lib/akamu/crdt.db"

[database]
url = "sqlite:///var/lib/akamu/akamu.db"

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

[mtc]
log_path = "/var/lib/akamu/mtc.log"
enabled  = false
# log_number = 1
# tree_minimum_index = 0
# trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"

[server]
terms_of_service_url        = "https://acme.example.com/tos.html"
website_url                 = "https://acme.example.com"
caa_identities              = ["acme.example.com"]
external_account_required   = false
order_expiry_secs           = 86400
authz_expiry_secs           = 86400
max_body_bytes              = 65536
ari_retry_after_secs        = 21600
ari_explanation_url         = "https://acme.example.com/docs/renewal-policy"
allow_subdomain_auth        = false
account_scope               = "server"
star_min_lifetime_secs      = 86400
star_max_duration_secs      = 31536000
star_allow_certificate_get  = true
tor_connectivity_enabled    = false
dns_resolver_addr           = "1.1.1.1:853"
dns_dot_server_name         = "cloudflare-dns.com"
dns_persist01_resolver_addr = "127.0.0.1:5354"
validate_dnssec             = true
trusted_proxies             = ["127.0.0.1/32"]
eab_master_secret           = "Zm9vYmFyYmF6cXV4cXV1eGZvb2JhcmJhenF1eHF1dXg"
delegation_enabled          = false
allow_certificate_get       = false

[server.gssapi]
keytab_file  = "/etc/akamu/http.keytab"   # omit and set gssproxy = true to use gssproxy instead
service_name = "HTTP"

# [server.webui]
# static_dir = "/usr/share/akamu/webui"

[tls.client_auth]
ca_certs = ["/etc/akamu/operator-ca.pem"]
required = false

[admin]
session_ttl_secs = 3600

[email_challenge]
enabled             = true
from_address        = "acme-validation@example.com"
send_script         = "/etc/akamu/send-email.sh"
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"

# [delegation_upstream]
# directory_url            = "https://upstream-ca.example.com/acme/directory"
# account_key_file         = "/etc/akamu/upstream-acme.key.pem"
# contacts                 = ["mailto:admin@example.com"]
# challenge_solver         = "dns-01"
# challenge_deploy_script  = "/etc/akamu/upstream-dns-deploy.sh"
# challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"
# poll_interval_secs       = 10

[tkauth]
enabled                 = false
# trusted_ta_ca_files   = ["/etc/akamu/ta-root.pem"]
# token_authority_url   = "https://ta.example.com"
# max_validity_secs     = 3600
# jti_prune_interval_secs = 3600
# [[tkauth.claim_encoders]]
# claim   = "sub"
# encoder = "krb5-kpn"

[gossip]
peers                      = ["https://node2.example.com", "https://node3.example.com"]
interval_secs              = 15
tombstone_ttl_secs         = 604800
ownership_ttl_secs         = 150
gossip_envelope_max_age_secs = 300
clock_skew_tolerance_secs  = 30
fan_out                    = 3

[profiles]
refresh_interval_secs = 3600

[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.tlsserver]
description   = "Standard TLS server certificate"
validity_days = 90
key_usage     = ["digital_signature", "key_encipherment"]
eku           = ["server_auth"]

Top-level keys

listen_addr

Required. The address the server binds to. Two forms are accepted:

FormExampleDescription
"host:port""0.0.0.0:8080"TCP socket — bind on all interfaces, port 8080
"unix:/path" or "/path""unix:/run/akamu/akamu.sock"Unix domain socket at the given filesystem path
# TCP (bind on all interfaces)
listen_addr = "0.0.0.0:8080"

# TCP (localhost only — behind a reverse proxy on the same host)
listen_addr = "127.0.0.1:8080"

# Unix domain socket (reverse proxy on the same host)
listen_addr = "unix:/run/akamu/akamu.sock"

The AKAMU_LISTEN environment variable overrides this field without touching the config file — useful for socket-activated deployments or overriding the bind address at start time:

AKAMU_LISTEN=unix:/run/akamu/akamu.sock akamu /etc/akamu/config.toml

Constraint: Unix domain sockets and [tls] are mutually exclusive. When listen_addr is a Unix path and [tls].enabled = true, the server exits at startup with an error. TLS termination must be handled by the reverse proxy in front of the socket.

See Running behind a reverse proxy (Unix socket) in the quickstart for nginx, Apache, and systemd socket-activation examples.

base_url

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

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

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

crdt_db_url

Optional. Default: absent (uses [database].url).

SQLite connection URL for the separate CRDT database used when multi-node clustering is enabled (see [gossip]). When absent, CRDT tables are created in the main [database] database. A separate database is recommended in production clustering deployments to isolate CRDT write traffic from ACME traffic.

Only SQLite is supported for the CRDT database (sqlite://… URL format). The file and its WAL journal are created automatically if they do not exist.

crdt_db_url = "sqlite:///var/lib/akamu/crdt.db"

[database]

url

Required. Database connection URL. The format depends on the compiled backend:

BackendURL format
SQLitesqlite:///absolute/path/to/akamu.db or sqlite::memory:
PostgreSQLpostgres://user:pass@host/dbname
MariaDB/MySQLmariadb://user:pass@host/dbname or mysql://user:pass@host/dbname

For SQLite, the database file and its WAL journal are created automatically if they do not exist.

[database]
url = "sqlite:///var/lib/akamu/akamu.db"

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

max_connections

Optional. Default: 1 for SQLite, 10 for PostgreSQL/MariaDB.

Maximum number of pooled database connections. For SQLite, this must remain 1 to avoid SQLITE_BUSY_SNAPSHOT errors under concurrent writes; the default is correct for production SQLite deployments.

[database]
url      = "postgres://akamu:secret@localhost/akamu"
max_connections = 20

require_tls

Optional. Default: false.

When true, the server refuses to start unless the database URL explicitly enables SSL/TLS encryption:

BackendRequired URL parameter
PostgreSQLsslmode=require, sslmode=verify-ca, or sslmode=verify-full
MariaDB/MySQLssl-mode=REQUIRED, ssl-mode=VERIFY_CA, or ssl-mode=VERIFY_IDENTITY
SQLiteIgnored (local file, no network transport)

Set true in production deployments where the database is not co-located with the server process (FPT_ITT.1).

[database]
url         = "postgres://akamu:secret@db.example.com/akamu?sslmode=verify-full"
require_tls = true

[[ca]] / [ca]

Akāmu supports one or more CA instances. Use the TOML array-of-tables syntax [[ca]] to configure multiple CAs; the legacy single [ca] table continues to work and is treated as a single CA with id = "default" and is_default = true.

# Single CA (backward-compatible)
[ca]
key_file  = "/etc/akamu/ca.key.pem"
cert_file = "/etc/akamu/ca.cert.pem"

# Multiple CAs
[[ca]]
id         = "rsa"
is_default = true
key_file   = "/etc/akamu/rsa-ca.key.pem"
cert_file  = "/etc/akamu/rsa-ca.cert.pem"

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

id

Required when multiple [[ca]] entries are present. Optional for a single [ca] table (defaults to "default").

Unique identifier for this CA. Must be lowercase alphanumeric with _ or - allowed, at most 64 characters. Reserved ACME path segments (directory, new-nonce, new-account, new-order, new-authz, revoke-cert, key-change) are rejected at startup.

[[ca]]
id = "rsa"

is_default

Required (as true) for exactly one CA when multiple [[ca]] entries are configured. Implicit true for the legacy single [ca] table.

The default CA is also served at the backward-compatible /acme/directory alias (without a CA ID prefix).

[[ca]]
id         = "rsa"
is_default = true

caa_identities (per-CA)

Optional. Default: [] (inherit from [server].caa_identities).

Per-CA list of CA domain names for CAA record verification (RFC 8659). When non-empty, this list overrides [server].caa_identities for orders processed through this CA’s ACME endpoint. When empty (the default), the server-level list applies.

[[ca]]
id             = "rsa"
caa_identities = ["rsa.acme.example.com", "acme.example.com"]

key_file

Required. Path to the CA private key PEM file.

  • If both key_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
"ed448"Ed448
"ml-dsa-44"Pure ML-DSA-44 (FIPS 204 parameter set 2) — requires OpenSSL 3.5+
"ml-dsa-65"Pure ML-DSA-65 (FIPS 204 parameter set 3) — requires OpenSSL 3.5+
"ml-dsa-87"Pure ML-DSA-87 (FIPS 204 parameter set 5) — requires OpenSSL 3.5+

Composite ML-DSA variants (draft-ietf-lamps-pq-composite-sigs-19, sub-arcs 37–54 — requires OpenSSL 3.5+):

ValueAlgorithmOID sub-arc
"composite-mldsa44-rsa2048-pss-sha256"ML-DSA-44 + RSA-2048-PSS-SHA-25637
"composite-mldsa44-rsa2048-pkcs15-sha256"ML-DSA-44 + RSA-2048-PKCS#1v1.5-SHA-25638
"composite-mldsa44-ed25519-sha512"ML-DSA-44 + Ed25519-SHA-51239
"composite-mldsa44-ecdsa-p256-sha256"ML-DSA-44 + ECDSA-P256-SHA-25640
"composite-mldsa65-rsa3072-pss-sha512"ML-DSA-65 + RSA-3072-PSS-SHA-51241
"composite-mldsa65-rsa3072-pkcs15-sha512"ML-DSA-65 + RSA-3072-PKCS#1v1.5-SHA-51242
"composite-mldsa65-rsa4096-pss-sha512"ML-DSA-65 + RSA-4096-PSS-SHA-51243
"composite-mldsa65-rsa4096-pkcs15-sha512"ML-DSA-65 + RSA-4096-PKCS#1v1.5-SHA-51244
"composite-mldsa65-ecdsa-p256-sha512"ML-DSA-65 + ECDSA-P256-SHA-51245
"composite-mldsa65-ecdsa-p384-sha512"ML-DSA-65 + ECDSA-P384-SHA-51246
"composite-mldsa65-ecdsa-brainpoolp256r1-sha512"ML-DSA-65 + ECDSA-brainpoolP256r1-SHA-51247
"composite-mldsa65-ed25519-sha512"ML-DSA-65 + Ed25519-SHA-51248
"composite-mldsa87-ecdsa-p384-sha512"ML-DSA-87 + ECDSA-P384-SHA-51249
"composite-mldsa87-ecdsa-brainpoolp384r1-sha512"ML-DSA-87 + ECDSA-brainpoolP384r1-SHA-51250
"composite-mldsa87-ed448-shake256"ML-DSA-87 + Ed448-SHAKE-25651
"composite-mldsa87-rsa3072-pss-sha512"ML-DSA-87 + RSA-3072-PSS-SHA-51252
"composite-mldsa87-rsa4096-pss-sha512"ML-DSA-87 + RSA-4096-PSS-SHA-51253
"composite-mldsa87-ecdsa-p521-sha512"ML-DSA-87 + ECDSA-P521-SHA-51254

Each composite variant also accepts the canonical COMPSIG-* label (case-insensitive, with or without the COMPSIG- prefix). For example, sub-arc 40 accepts all three forms: "composite-mldsa44-ecdsa-p256-sha256", "COMPSIG-MLDSA44-ECDSA-P256-SHA256", and "mldsa44-ecdsa-p256-sha256".

hash_alg is ignored for composite CA keys. The hash algorithm is fixed by the composite algorithm specification (e.g. SHA-256 for sub-arc 40, SHA-512 for sub-arc 46). Set hash_alg to a valid value for any non-composite CA that shares the same config stanza; Akāmu silently ignores it for composite keys. For pure ML-DSA keys, hash_alg is also ignored — ML-DSA has no separate hash parameter.

OID stability note: The composite OIDs (sub-arcs 37–54) are defined by draft-ietf-lamps-pq-composite-sigs-19 and are provisional pending IANA allocation. They may change as the draft advances toward RFC publication.

key_type = "ec:P-256"

Example: composite CA key with ML-DSA-65 + ECDSA-P384:

[[ca]]
id       = "composite-pq"
key_file  = "/etc/akamu/composite-ca.key.pem"
cert_file = "/etc/akamu/composite-ca.cert.pem"
key_type  = "composite-mldsa65-ecdsa-p384-sha512"
hash_alg  = "sha512"   # ignored for composite keys; set for documentation clarity

hash_alg

Optional. Default: "sha256".

Hash algorithm used for signing certificates and CRLs.

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: "ACME Server CA".

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

common_name = "Example ACME CA"

organization

Optional. Default: "ACME Server".

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

organization = "Example Org"

crl_url

Optional. Default: absent (no CDP extension).

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

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

Set this to the URL of the built-in /ca/crl endpoint (i.e. {base_url}/ca/crl) to use the server’s built-in CRL endpoint. The endpoint is served by Akāmu and requires no external CRL generation.

ocsp_url

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

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

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

Set this to the URL of the built-in /ca/ocsp endpoint (i.e. {base_url}/ca/ocsp) to use the server’s built-in OCSP responder. Both GET and POST OCSP requests are handled at this base URL.

crl_next_update_secs

Optional. Default: 86400 (1 day).

Controls the nextUpdate field in the CRL served at /ca/crl. The nextUpdate is set to the current time plus this many seconds. Adjust to match how frequently clients are expected to re-fetch the CRL.

crl_next_update_secs = 86400   # one day (default)

enforce_validity_cap

Optional. Default: false.

When true, certificate issuance is rejected at the time of signing if the computed validity period exceeds 200 days (the current CA/B Forum BR §6.3.2 hard limit since 2026-03-15). When false (the default), the server only emits a startup warning for validity periods exceeding the limit, allowing private or enterprise PKI deployments to use longer validity periods without chaining to a public root.

Public WebPKI CAs should set this to true to enforce the limit at issuance time rather than relying solely on the startup warning.

[ca]
enforce_validity_cap = true

require_encrypted_key

Optional. Default: false.

When true, the server refuses to load a plaintext (unencrypted) PEM private key from a file (FCS_STG_EXT.1). Only PKCS#8 encrypted PEM (ENCRYPTED PRIVATE KEY) or PKCS#11 URIs are accepted. Use key_password_file to supply the decryption passphrase when using an encrypted PEM file.

[ca]
require_encrypted_key = true
key_password_file     = "/etc/akamu/ca-key-passphrase"

key_password_file

Optional. Default: absent.

Path to a file containing the passphrase used to decrypt an encrypted PEM CA private key. The file is read once at startup; trailing newlines are stripped. Required when require_encrypted_key = true and key_file points to a filesystem path (not a PKCS#11 URI).

[ca]
key_password_file = "/etc/akamu/ca-key-passphrase"

[mtc]

log_path

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

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

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

enabled

Optional. Default: false.

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

enabled = false

MTC standalone certificate format

When issue_as = "mtc" is set in a profile, the server builds a standalone certificate for each issued certificate. The standalone certificate is a standard X.509 v3 Certificate where:

  • signatureAlgorithm is id-alg-mtcProof (experimental OID 1.3.6.1.4.1.44363.47.0 from the Cloudflare PEN arc)
  • signatureValue carries a TLS-encoded MTCProof (inclusion proof + cosignature records); per draft-04 §4.3, MTCProof has a leading extensions field (uint16 length-prefixed; empty = \x00\x00), start/end as uint48 (6-byte big-endian), and a uint8-prefixed cosigner_id in each MtcSignature
  • serialNumber encodes the log entry index per draft §6.1

The GET /acme/mtc/cert/{cert_id}/standalone and GET /acme/mtc/landmarks/{seq}/cert endpoints return the DER-encoded certificate with Content-Type: application/pkix-cert and the X-MTC-Version: draft-04 response header.

OID stability note: The OIDs are experimental and pre-IANA. They will change when draft-ietf-plants-merkle-tree-certs is published as an RFC, requiring a coordinated update of the synta-mtc library and relying implementations. When [mtc] is enabled and [mtc.signing_key] is configured, the auto-generated CA certificate includes the id-pe-mtcCertificationAuthority extension (experimental OID 1.3.6.1.4.1.44363.47.2), identifying the CA as an MTC-capable issuer.

checkpoint_interval_secs

Optional. Default: 3600 (1 hour).

How often the checkpoint background task fires, in seconds. A checkpoint is produced only when the log has grown since the last one; if the tree size has not changed the task is a no-op. Requires [mtc.signing_key] to be configured.

checkpoint_interval_secs = 3600

checkpoint_retention_count

Optional. Default: 1000.

Maximum number of checkpoint rows to retain in the mtc_checkpoints database table. After each new checkpoint is produced, rows beyond this limit are pruned (oldest first). Their associated cosignature rows in mtc_cosignatures are also deleted via the foreign-key ON DELETE CASCADE constraint.

checkpoint_retention_count = 1000

landmark_interval_secs

Optional. Default: 86400 (1 day).

How often the landmark background task fires, in seconds. A new landmark is allocated only when the tree has grown since the last landmark; otherwise the task is a no-op. Requires [mtc.signing_key] to be configured.

landmark_interval_secs = 86400

max_active_landmarks

Optional. Default: 100.

Maximum number of landmark rows to retain in the mtc_landmarks table. After each new landmark is built, rows beyond this limit are pruned (oldest first by sequence number).

max_active_landmarks = 100

hash_alg

Optional. Default: "sha256".

Hash algorithm used for Merkle tree leaf hashing. Valid values:

ValueAlgorithm
"sha256"SHA-256 (32-byte leaf hashes)
"sha384"SHA-384 (48-byte leaf hashes)
"sha512"SHA-512 (64-byte leaf hashes)
"sha3-256"SHA3-256 (32-byte leaf hashes)
"sha3-384"SHA3-384 (48-byte leaf hashes)
"sha3-512"SHA3-512 (64-byte leaf hashes)

The algorithm is stored in the log file’s header at creation time and cannot be changed for an existing log. If you change hash_alg after the log file already exists, Akāmu will refuse to start with an error identifying the mismatch. To switch algorithms you must delete the log file (and its lock file, if any) and let the server recreate it from scratch.

[mtc]
hash_alg = "sha256"

log_number

Optional. Default: 1.

Log number for serialNumber encoding per draft-04 §6.1. The serial number of each standalone certificate is computed as (log_number << 48) | entry_index. Each CA log should receive a unique, consecutive-from-1 log number.

[mtc]
log_number = 1

tree_minimum_index

Optional. Default: absent.

Minimum valid entry index (§5.2.3 log pruning). When set, the value is included in the Checkpoint.treeMinimumIndex field, indicating that entries below this index may have been pruned from the log. Relying parties should not attempt to verify inclusion proofs for entries below this index.

[mtc]
tree_minimum_index = 100

trust_anchor_id

Optional. Default: absent.

The CA’s own TrustAnchorID OID in dotted-decimal notation. Per draft-04 §5.4, each CA MUST operate a CA cosigner whose cosigner ID is the same as its CA ID. When set, a self-cosignature is produced alongside any external cosignatures during checkpoint production. The signing key configured in [mtc.signing_key] is used for the self-cosignature.

When absent, no self-cosignature is produced.

[mtc]
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"

[mtc.signing_key]

Optional subsection. When present, enables checkpoint production and standalone/landmark certificate construction. The signing key must be distinct from the X.509 CA key (§5.5 of draft-ietf-plants-merkle-tree-certs).

key_file

Required within [mtc.signing_key]. Path to the MTC signing key PEM file. If absent on disk, a new key of key_type is generated and written here on startup.

key_type

Optional. Default: "ec:P-256".

Key algorithm for auto-generation. Accepts the same values as [ca].key_type. Per §5.4.2 of the draft, only ECDSA P-256/P-384, Ed25519, and ML-DSA are valid MTC signing algorithms; prefer EC or EdDSA.

hash_alg

Optional. Default: "sha256".

Hash algorithm used for ECDSA/RSA signing of MTC checkpoints and cosignatures: "sha256", "sha384", "sha512". Ignored for EdDSA and ML-DSA signing key types.

This field controls the signing hash only and is unrelated to the Merkle tree leaf-hash algorithm, which is configured separately via [mtc].hash_alg.

[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"
key_type = "ec:P-256"
hash_alg = "sha256"

[[mtc.cosigners]]

Optional array of external cosigner entries. After each checkpoint is produced, Akāmu POSTs the DER-encoded Checkpoint to each cosigner URL and stores the returned SubtreeSignature. Multiple entries are supported; all cosigners are contacted in parallel.

Each entry has the following fields:

url

Required. URL to POST the DER-encoded Checkpoint to (e.g. https://cosigner.example.com/sign).

cosigner_id_cert_pem

Optional. Path to the cosigner’s X.509 identity certificate PEM file. When set, the file is loaded at startup and added to the TLS trust store for that cosigner’s HTTPS connection, in addition to the system root CAs. This allows cosigners whose TLS certificate chains to an operator-provisioned CA to be used without installing that CA system-wide. The certificate is also used for cryptographic verification of received SubtreeSignature values.

trust_anchor_id

Optional. The expected TrustAnchorID OID of the cosigner in dotted-decimal notation (e.g. "1.3.6.1.4.1.44363.47.10.1"). Per draft-ietf-plants-merkle-tree-certs-04 §4.1, CosignerID is now an OBJECT IDENTIFIER (TrustAnchorID ::= OBJECT IDENTIFIER) rather than a SEQUENCE of hash algorithm and public key. When set, Akāmu verifies that the SubtreeSignature.cosigner OID in each response matches this value. When absent, the OID identity check is skipped; cryptographic verification via cosigner_id_cert_pem still applies when that field is set. Operators must agree on the OID value with their cosigner operator.

Security constraint: Setting trust_anchor_id without also setting cosigner_id_cert_pem is a hard startup error. OID-only verification provides no cryptographic assurance — anyone who knows the OID could forge a cosignature. Both fields must be set together to enable verified cosignature acceptance.

[[mtc.cosigners]]
url                  = "https://cosigner1.example.com/sign"
cosigner_id_cert_pem = "/etc/akamu/cosigner1-id.pem"
trust_anchor_id      = "1.3.6.1.4.1.44363.47.10.1"   # optional; TrustAnchorID OID

[[mtc.cosigners]]
url = "https://cosigner2.example.com/sign"

[server]

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

audit_log_file

Optional. Default: absent (use systemd journal namespace).

Path to a JSONL (JSON Lines) audit log file. When set, audit events are written as append-only JSON Lines to this file instead of the systemd journal namespace socket. Each line is a JSON object with occurred_at, AKAMU_EVENT_TYPE, AKAMU_OUTCOME, and optional AKAMU_SUBJECT, AKAMU_PRINCIPAL, AKAMU_DETAIL fields.

When absent (the default), the server writes to the akamu journal namespace (see contrib/systemd/journald@akamu.conf). Use this option on systems without systemd or when journal namespace sockets are not available.

External logrotate(8) with copytruncate is expected for log rotation. Each audit query scans at most 500,000 lines to prevent unbounded reads on unrotated files.

[server]
audit_log_file = "/var/log/akamu/audit.jsonl"

terms_of_service_url

Optional. Default: absent.

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

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

website_url

Optional. Default: absent.

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

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

caa_identities

Optional. Default: empty list.

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

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

caa_identities = ["acme.example.com"]

account_scope

Optional. Default: "server".

Controls whether ACME accounts are shared across all CAs or isolated per CA.

ValueBehaviour
"server"One account works with all CAs. This is the default and matches the behavior of single-CA deployments.
"ca"Accounts are isolated per CA. An account registered via one CA’s new-account endpoint cannot create orders via a different CA.
[server]
account_scope = "server"

external_account_required

Optional. Default: false.

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

When enabled, the server performs full HMAC verification: it resolves the kid in the eab_keys database table (populated either from [server.eab_keys] at startup or by HKDF derivation via GET /acme/eab), verifies the HS256/HS384/HS512 MAC, confirms the payload is the account key, and atomically consumes the key at account creation so each EAB key can only be used once.

external_account_required = true

eab_keys

Optional. Default: {}.

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

Keys are loaded at startup and persisted in the database. A key that has been consumed (used to create an account) is never overwritten on a subsequent restart, so spent keys remain invalidated across restarts.

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

To generate a key:

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

eab_master_secret

Optional. Default: absent.

Base64url-encoded master secret (must decode to at least 32 bytes) used to derive deterministic EAB credentials via HKDF-SHA-256 (RFC 5869). When set, the GET /acme/eab endpoint derives a unique (kid, hmac_key) pair for each authenticated principal using the following construction:

kid      = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-kid:<principal>", L=16) )
hmac_key = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-key:<principal>", L=32) )

The same (master_secret, principal) pair always produces the same (kid, hmac_key). Credentials are stored in the eab_keys table on first request and returned on subsequent requests. Once the kid has been consumed by an account registration, re-fetching GET /acme/eab for that principal returns HTTP 409 Conflict.

When eab_master_secret is absent, GET /acme/eab returns only {"principal":"…"} (backward-compatible stub behaviour, no EAB credentials).

Authentication for GET /acme/eab requires either [server.gssapi] (standalone GSSAPI/SPNEGO) or trusted_proxies (reverse-proxy mode supplying X-Remote-User).

Generate a suitable secret:

openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
[server]
external_account_required = true
eab_master_secret = "Zm9vYmFyYmF6cXV4cXV1eGZvb2JhcmJhenF1eHF1dXg"

order_expiry_secs

Optional. Default: 86400 (24 hours).

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

order_expiry_secs = 86400

authz_expiry_secs

Optional. Default: 86400 (24 hours).

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

authz_expiry_secs = 86400

max_body_bytes

Optional. Default: 65536 (64 KiB).

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

max_body_bytes = 65536   # 64 KiB

http_validation_port

Optional. Default: 80.

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

http_validation_port = 80

http_validation_allow_private_ips

Optional. Default: false.

When false (the default), the initial connection target and any redirect targets that resolve to private, link-local, or loopback IP addresses (RFC 1918, 169.254.0.0/16, 127.0.0.0/8, fc00::/7, fe80::/10, etc.) are blocked to prevent SSRF attacks against cloud metadata endpoints such as 169.254.169.254. Both IP literals and hostnames are checked: for hostnames, every resolved address must be globally routable.

Set to true only in isolated test environments where the http-01 challenge responder intentionally runs on a private address.

http_validation_allow_private_ips = false   # default — recommended for all production deployments

dns_persist_issuer_domains

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

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

Accepts either a single string or an array of strings. Multi-tenant or multi-identity deployments can list all accepted issuer domains; validation succeeds when the TXT record’s issuer domain matches any of the configured values.

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

# Single domain
dns_persist_issuer_domains = "acme.example.com"

# Multiple domains (multi-tenant or multi-identity deployments)
dns_persist_issuer_domains = ["acme.example.com", "acme.example.org"]

dns_resolver_addr

Optional. Default: absent (system resolver).

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

dns_resolver_addr = "127.0.0.1:5353"

dns_persist01_resolver_addr

Optional. Default: absent (falls back to dns_resolver_addr).

Resolver override used exclusively for dns-persist-01 TXT lookups at _validation-persist.*. When set, this address is used instead of dns_resolver_addr for dns-persist-01 validation only. Useful when persistent TXT records are served by a different DNS infrastructure than the one used for dns-01 and CAA lookups.

dns_resolver_addr           = "127.0.0.1:5353"   # used for dns-01 and CAA
dns_persist01_resolver_addr = "127.0.0.1:5354"   # used only for dns-persist-01

dns_dot_server_name

Optional. Default: absent (plain UDP).

TLS server name (SNI hostname) for DNS-over-TLS (DoT, RFC 7858). When set, all DNS challenge validation queries — dns-01, dns-persist-01, and CAA record lookups — are sent over TLS to the resolver specified by dns_resolver_addr instead of plain UDP.

Use DoT when the network path between the Akāmu server and its resolver is untrusted: for example, when the resolver is a public DNS provider reached over the Internet, when the operator wants to prevent on-path DNS hijacking by an ISP, or to satisfy privacy requirements that prohibit cleartext DNS queries.

dns_resolver_addr must be set to the DoT server’s IP address and port 853 when this field is present. The TLS certificate presented by the resolver is verified against the system root CA store (system OpenSSL). LDAP SRV lookups for certificate profile providers are unaffected — they always use plain UDP.

DoT and DNSSEC validation (validate_dnssec = true) are independent and can be enabled at the same time. DoT protects the query transport channel; DNSSEC authenticates the DNS response data itself.

# DoT only — encrypt queries to Cloudflare's resolver
dns_resolver_addr    = "1.1.1.1:853"
dns_dot_server_name  = "cloudflare-dns.com"
# DoT + DNSSEC — encrypted transport and cryptographic response validation
dns_resolver_addr    = "1.1.1.1:853"
dns_dot_server_name  = "cloudflare-dns.com"
validate_dnssec      = true

ari_retry_after_secs

Optional. Default: 21600 (6 hours).

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

ari_retry_after_secs = 21600

ari_explanation_url

Optional. Default: absent.

URL included in GET /acme/renewal-info/{cert-id} responses as the explanationURL field (RFC 9773 §4.1). When set, it points clients to a human-readable page explaining why early renewal is being suggested (for example, an incident notice or CA policy update). When absent, the field is omitted from the response entirely.

ari_explanation_url = "https://acme.example.com/docs/renewal-policy"

allow_subdomain_auth

Optional. Default: false.

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

  • Include "subdomainAuthAllowed": 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

delegation_enabled

Optional. Default: false.

When true, Akāmu activates the RFC 9115 delegation API surface:

  • The directory meta object includes "delegation-enabled": true.
  • Every account response includes a "delegations" URL.
  • The delegation listing and fetch endpoints become active:
    • POST /acme/delegations/{account_id} — list delegations for an account (POST-as-GET).
    • POST /acme/delegation/{id} — fetch a single delegation object (POST-as-GET).
  • POST /acme/new-order accepts the "delegation" field to link an order to a delegation.
  • Delegation orders start in ready status and return "authorizations": [].

Delegations themselves are managed via the Admin API (/admin/delegations). The [admin] section must be configured before delegations can be created.

[server]
delegation_enabled = true

allow_certificate_get

Optional. Default: false.

When true, the directory meta object includes "allow-certificate-get": true for RFC 9115 delegation orders (distinct from the star_allow_certificate_get flag, which covers RFC 8739 STAR certificates). When an order is placed with "allow-certificate-get": true in the new-order payload, the certificate endpoint for that order can be fetched with an unauthenticated GET rather than a POST-as-GET. The capability is advertised in the directory only when this flag is true.

[server]
allow_certificate_get = true

tor_connectivity_enabled

Optional. Default: false.

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

tor_connectivity_enabled = true

validate_dnssec

Optional. Default: true.

Controls whether DNSSEC validation is enforced during DNS-based challenge verification (dns-01, dns-persist-01) and CAA record lookups. CA/B Forum BR §3.2.2.4 and §3.2.2.8.1 require DNSSEC validation for publicly trusted CAs as of 2026-03-15. Set to false only for testing environments or deployments where the DNS infrastructure is not yet DNSSEC-signed; doing so makes the CA non-compliant.

validate_dnssec = true

trusted_proxies

Optional. Default: empty (proxy header mode disabled).

List of CIDR blocks (IPv4 or IPv6) whose connecting IP address is trusted to supply an X-Remote-User header. When a request arrives from one of these addresses, akamu reads the header value as the already-authenticated principal name — the reverse proxy is expected to have completed SPNEGO or another authentication step before forwarding the request.

Requests from addresses not in this list never have X-Remote-User honoured, regardless of what the header contains.

Mutually exclusive with [server.gssapi]. Setting both trusted_proxies and [server.gssapi] at the same time is a configuration error; the server exits at startup with an error message.

[server]
trusted_proxies = ["127.0.0.1/32", "::1/128", "10.0.0.0/8"]

Security note: keep this list tightly scoped to the IP addresses of your reverse proxy or load balancer. Adding broad ranges (e.g. 0.0.0.0/0) allows any client to impersonate any principal.

[server.gssapi]

Optional. When absent, standalone GSSAPI mode is disabled.

Mutually exclusive with trusted_proxies. Setting both at the same time is a configuration error; the server exits at startup with an error message.

Configures akamu to accept Authorization: Negotiate tokens directly, without a reverse proxy. At startup the server acquires a combined initiator+acceptor credential (GSS_C_BOTH) using gss_acquire_cred_from and then uses gss_accept_sec_context to validate each SPNEGO token. The GSS_C_BOTH usage flag is required for S4U2Self constrained delegation (used internally for LDAP profile lookups). Credentials are acquired either directly from keytab_file or, when gssproxy = true, via the gssproxy daemon (which intercepts the underlying GSSAPI call and supplies credentials from its own keytab configuration).

Use this mode when you want akamu to handle Kerberos authentication itself rather than delegating to a front-end proxy such as Apache or Nginx.

Security behaviors in standalone GSSAPI mode:

  • Token size limit. Authorization: Negotiate tokens larger than 128 KiB are rejected with 400 Bad Request. Legitimate Kerberos tickets are always smaller than this limit.
  • Case-insensitive scheme matching. The "Negotiate " prefix is matched case-insensitively per RFC 7235 §2.1.
  • TLS channel bindings (RFC 5929). When akamu terminates TLS itself, the tls-server-end-point binding is computed from the leaf certificate and passed to gss_accept_sec_context, binding the Kerberos exchange to the TLS channel. Channel bindings are disabled automatically when the server certificate uses ML-DSA (pure or composite) or Ed448, because RFC 5929 defines no canonical hash for those algorithms.
  • Replay detection. After a successful context acceptance, akamu checks whether GSS_C_REPLAY_FLAG is set. When the flag is absent (common when clients connect over TLS, because TLS already provides replay protection), a debug-level log entry is emitted and the authentication proceeds normally. This behaviour is intentional: browsers and TLS-first clients typically do not negotiate Kerberos-level replay protection.
  • GSSAPI without TLS. Running standalone GSSAPI without TLS is permitted but emits a warn-level log at startup: SPNEGO tokens are vulnerable to interception and relay attacks without TLS.
  • No mechanism configured. When neither trusted_proxies nor [server.gssapi] is set, authenticated endpoints return 404 Not Found.

keytab_file

Required when gssproxy = false (the default). Must be absent when gssproxy = true. Path to the HTTP service keytab file. The akamu process must be able to read this file; no other user should have read access to it. The path is logged at debug level only. Setting both keytab_file and gssproxy = true is a configuration error; the server exits at startup.

keytab_file = "/etc/akamu/http.keytab"

Generate the keytab for an IPA-managed host:

ipa-getkeytab -s ipa.example.com -p HTTP/akamu.example.com@EXAMPLE.COM \
    -k /etc/akamu/http.keytab
chmod 600 /etc/akamu/http.keytab
chown akamu: /etc/akamu/http.keytab

gssproxy

Optional. Default: false.

When true, GSSAPI credential acquisition is delegated to the gssproxy daemon instead of reading a keytab file directly. The akamu process must have a matching entry in /etc/gssproxy/conf.d/ (typically matched by UID). The server sets GSS_USE_PROXY=yes in its environment before the first GSSAPI call so that the GSSAPI library routes the credential request through gssproxy. No direct access to a keytab file on disk is needed. keytab_file must be absent when this is true.

# gssproxy mode — no keytab access required for the akamu process
[server.gssapi]
gssproxy     = true
service_name = "HTTP"

service_name

Optional. Default: "HTTP".

Host-based service name to acquire credentials for. MIT Kerberos appends @<local-hostname> when no realm is specified, so "HTTP" is correct for a single-homed host. Use "HTTP@akamu.example.com" to be explicit.

service_name = "HTTP"

Proxy mode example

[server]
trusted_proxies = ["192.168.1.10/32"]

In this configuration, only connections from 192.168.1.10 (the reverse proxy) are allowed to supply X-Remote-User. Requests from any other source that reach an authenticated endpoint return 404 Not Found (no authentication mechanism is configured for those connections).

Standalone GSSAPI examples

Keytab mode — akamu reads the keytab file directly:

[server.gssapi]
keytab_file  = "/etc/akamu/http.keytab"
service_name = "HTTP"

gssproxy mode — akamu delegates credential acquisition to gssproxy (no direct keytab access required for the akamu process):

[server.gssapi]
gssproxy     = true
service_name = "HTTP"

In both configurations, akamu handles Authorization: Negotiate directly. Clients must obtain a Kerberos service ticket for HTTP/<hostname> before calling authenticated endpoints.

[server.webui]

Optional. When absent, the /ui/ routes are not registered and return 404.

When present, Akāmu serves the built PatternFly management web UI from a directory of static files. The UI is mounted at /ui/* on the same listener as the ACME and admin APIs; no separate process or proxy is required.

When [server.webui] is absent (the default), no /ui/* routes are registered at all. Requests to /ui/ and GET / receive 404 responses as if the routes did not exist.

When [server.webui] is present:

  • GET /ui/* serves static files from static_dir.
  • Directory requests fall back to index.html inside that directory (SPA routing support).
  • GET / permanently redirects to /ui/.
  • Security headers (Content-Security-Policy, X-Frame-Options, etc.) are added to every /ui/* response.

The admin API (/admin/*) is served on the same listener and is called directly by the browser — no additional proxy is needed.

static_dir

Optional. Default: absent (web UI disabled).

Absolute path to the directory containing the built web UI files. The directory must contain at minimum an index.html file.

DeploymentTypical path
Fedora / RHEL package/usr/share/akamu/webui
Source buildwebui/dist/ (relative to the repository root, absolute path required)

When static_dir is absent but [server.webui] is present, the section is accepted without error and no routes are registered — the behavior is identical to omitting [server.webui] entirely. Set static_dir to actually serve the UI.

Config validation rejects a relative path with a startup error.

[server.webui]
static_dir = "/usr/share/akamu/webui"

Source build example (absolute path required):

[server.webui]
static_dir = "/home/user/akamu/webui/dist"

[tls]

The [tls] section enables Akāmu to terminate TLS directly on the main listen_addr socket, without a reverse proxy. When this section is absent or enabled = false, the server operates over plain HTTP.

[tls]
enabled    = true
cert_file  = "/etc/akamu/server.pem"
key_file   = "/etc/akamu/server-key.pem"
protocols  = ["TLSv1.2", "TLSv1.3"]

[tls.client_auth]
required          = false
ca_files          = ["/etc/akamu/client-ca.pem"]
profile           = "webpki"
allow_post_quantum = false
max_chain_depth   = 8
minimum_rsa_modulus = 2048

enabled

Optional. Default: false.

When true, the server listens with TLS on listen_addr. When false, the socket accepts plain HTTP connections.

[tls]
enabled = true

cert_file

Optional. Path to the PEM file containing the server TLS certificate chain (leaf certificate first). When this file is absent on disk and key_file is also absent, Akāmu generates a self-signed certificate on first run using bootstrap_key_type and server_name.

cert_file = "/etc/akamu/server.pem"

key_file

Optional. Path to the PEM file containing the server TLS private key (PKCS#8 or SEC1, unencrypted). Same auto-generation rules as cert_file.

key_file = "/etc/akamu/server-key.pem"

protocols

Optional. Default: ["TLSv1.2", "TLSv1.3"].

List of TLS protocol versions the server accepts. Both TLS 1.2 and TLS 1.3 are enabled by default.

protocols = ["TLSv1.2", "TLSv1.3"]

server_name

Optional. Default: "localhost".

Hostname placed in the CN and SAN of the auto-generated server certificate. Only used when cert_file and key_file are absent on disk.

server_name = "acme.example.com"

bootstrap_key_type

Optional. Default: "ec:P-256".

Key algorithm for the auto-generated server certificate. Only used when cert_file and key_file are absent on disk. Accepts the same values as [ca].key_type.

bootstrap_key_type = "ec:P-256"

[tls.client_auth]

Optional. When absent, client certificate authentication is disabled.

Configures mutual TLS (mTLS) client certificate authentication. When present, the server requests a client certificate during the TLS handshake.

required

Optional. Default: false.

When true, connections that present no client certificate are rejected. When false, client certificates are optional — presented certificates are still verified if provided.

required = true

ca_files

Required within [tls.client_auth]. List of PEM files containing the trusted CA certificates used to verify client certificates.

ca_files = ["/etc/akamu/client-ca.pem"]

profile

Optional. Default: "webpki".

Certificate validation profile. Accepted values:

ValueBehaviour
"webpki"CA/Browser Forum profile — enforces WebPKI policy rules (default).
"rfc5280"RFC 5280 profile — less restrictive, accepts private or enterprise PKI chains.
profile = "webpki"

allow_post_quantum

Optional. Default: false.

When true, ML-DSA and hybrid composite post-quantum signature algorithms (draft-ietf-lamps-pq-composite-sigs) are accepted in client certificates and CertificateVerify messages. When false, only classical algorithms are accepted.

allow_post_quantum = false

max_chain_depth

Optional. Default: 8.

Maximum certificate chain depth accepted for client certificates. Chains longer than this value are rejected.

max_chain_depth = 8

minimum_rsa_modulus

Optional. Default: 2048.

Minimum RSA modulus size in bits for RSA client certificates. Connections presenting an RSA certificate with a smaller key are rejected.

minimum_rsa_modulus = 2048

[email_challenge]

The [email_challenge] section enables the RFC 8823 email-reply-00 challenge type for S/MIME certificate issuance. When this section is absent or enabled = false, the server does not offer email-reply-00 challenges and rejects orders with email identifier types.

See email-reply-00 in the Challenges reference for the full protocol description, CSR requirements, and webhook payload format.

[email_challenge]
enabled             = true
from_address        = "acme-validation@example.com"
send_script         = "/etc/akamu/send-email.sh"
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"

enabled

Optional. Default: false.

Set to true to activate the email-reply-00 challenge type. When false, any POST to /acme/email-webhook returns 503 Service Unavailable.

from_address

Required when enabled = true. The email address the server sends challenge emails from. This value is returned to clients in the from field of the challenge object and passed to the send script as $ACME_FROM.

from_address = "acme-validation@example.com"

The domain portion of this address is used to construct the Message-ID header (<uuid@from-domain>).

send_script

Required when enabled = true. Absolute path to an executable that sends the challenge email. The server invokes it with no arguments; all parameters are passed as environment variables:

VariableValue
ACME_TORecipient email address (the identifier value)
ACME_FROMSender address (equals from_address)
ACME_SUBJECTACME: <base64url(token-part1)>
ACME_MESSAGE_IDServer-generated Message-ID — the script must use this exactly in the outbound Message-ID header
ACME_AUTO_SUBMITTEDauto-generated; type=acme
ACME_TOKEN_PART2token-part2 (base64url); the ACME challenge token field value, exposed for logging or advanced script use

Exit code 0 = success. Any non-zero exit code marks the challenge invalid and the client may retry.

The script is responsible for DKIM signing of the outbound email. Akāmu does not perform SMTP or DKIM internally.

send_script = "/etc/akamu/send-email.sh"

send_script_timeout_secs

Optional. Default: 30.

Maximum time in seconds the server waits for send_script to exit. If the script does not exit within this limit, the server kills it and marks the challenge invalid. Must be at least 1. Increase this if your mail transfer agent has a slow startup or must authenticate to a relay.

send_script_timeout_secs = 30

webhook_hmac_secret

Required when enabled = true. A shared secret used to authenticate POST /acme/email-webhook requests. The caller must include the header:

X-Akamu-Signature: sha256=<lowercase-hex(HMAC-SHA256(raw-body, webhook_hmac_secret))>

Choose a long random value (≥256 bits recommended). Requests with a missing, malformed, or incorrect signature are rejected with 403 Forbidden.

webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"

Keep this value secret. Anyone who knows it can submit webhook payloads and influence challenge outcomes.


[delegation_upstream]

The [delegation_upstream] section configures Akāmu to act as an ACME client toward an upstream CA when processing RFC 9115 delegation orders. When this section is present, a background task polls delegation orders in processing status and drives them through the full ACME flow on the upstream CA: account registration (if needed), order creation, dns-01 challenge deployment, finalization, and certificate retrieval.

When this section is absent, Akāmu operates only as an IdO ACME server — it issues delegation orders but does not drive an upstream CA leg. The background task is not started.

[delegation_upstream]
directory_url           = "https://upstream-ca.example.com/acme/directory"
account_key_file        = "/etc/akamu/upstream-acme.key.pem"
contacts                = ["mailto:admin@example.com"]
challenge_solver        = "dns-01"
challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"
# challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"
# poll_interval_secs = 10

directory_url

Required within [delegation_upstream]. ACME directory URL of the upstream CA. Akāmu fetches the directory at startup to discover the upstream CA’s endpoint URLs.

directory_url = "https://upstream-ca.example.com/acme/directory"

account_key_file

Required within [delegation_upstream]. Path to a PEM file containing the ACME account key used when registering with the upstream CA. The file is loaded at startup. If the key file is absent on disk, a new EC P-256 key is generated and written to this path on first run.

account_key_file = "/etc/akamu/upstream-acme.key.pem"

contacts

Optional. Default: [].

List of contact URIs (e.g. mailto: addresses) submitted to the upstream CA when registering the ACME account. Omit if the upstream CA does not require contacts.

contacts = ["mailto:admin@example.com"]

challenge_solver

Required within [delegation_upstream]. Challenge type used to satisfy the upstream CA’s authorizations. Only "dns-01" is currently supported.

challenge_solver = "dns-01"

challenge_deploy_script

Required within [delegation_upstream]. Absolute path to an executable that deploys the dns-01 TXT record at the upstream CA’s direction. The script is invoked with env_clear(); only the following environment variables are set:

VariableValue
CERTBOT_DOMAINThe domain name being validated (e.g. _acme-challenge.example.com)
CERTBOT_VALIDATIONThe TXT record value to publish

Exit code 0 = record deployed successfully. Any non-zero exit code marks the challenge attempt as failed.

challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"

The cleanup script is called only after the authorization has transitioned to valid at the upstream CA — not immediately after the deploy script exits. This ensures the TXT record remains queryable for the full upstream validation window.

challenge_cleanup_script

Optional. Default: absent (no cleanup).

Absolute path to an optional cleanup executable invoked after the upstream authorization has become valid. Receives the same CERTBOT_DOMAIN and CERTBOT_VALIDATION variables as the deploy script, plus CERTBOT_AUTH_OUTPUT="". Use it to remove the TXT record from DNS.

challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"

poll_interval_secs

Optional. Default: 10.

How often the background task polls the upstream CA for order and authorization status, in seconds.

poll_interval_secs = 10

[tkauth]

The [tkauth] section enables the RFC 9447 tkauth-01 challenge type for TNAuthList (RFC 9448) and JWTClaimConstraints (draft-ietf-acme-authority-token-jwtclaimcon) identifier types. When this section is absent or enabled = false, orders containing these identifier types are rejected.

See tkauth-01 in the Challenges reference for the full protocol description including the authority token format, validation steps, and claim encoder configuration.

[tkauth]
enabled                 = true
trusted_ta_ca_files     = ["/etc/akamu/ta-root.pem"]
token_authority_url     = "https://ta.example.com"   # optional hint
max_validity_secs       = 3600
jti_prune_interval_secs = 3600

[[tkauth.claim_encoders]]
claim   = "sub"
encoder = "krb5-kpn"

enabled

Optional. Default: false.

Set to true to activate the tkauth-01 challenge type. When false, any order with TNAuthList or JWTClaimConstraints identifiers is rejected with unsupportedIdentifier.

trusted_ta_ca_files

Required when enabled = true. List of absolute paths to PEM files containing trusted CA certificates for Token Authority signing certificate validation. The signing certificate presented in the authority token (via x5u or x5c) must chain to one of these CA roots. Not used for kid-signed tokens (which rely on trust_jwks_urls in per-profile configuration instead).

trusted_ta_ca_files = ["/etc/akamu/ta-root.pem"]

token_authority_url

Optional. Default: absent.

URL hint included in tkauth-01 challenge responses as the token-authority field. When set, ACME clients that read this field can use it to discover where to obtain an authority token. This is informational only; the server does not contact this URL itself.

token_authority_url = "https://ta.example.com"

max_validity_secs

Optional. Default: 3600 (1 hour).

Maximum accepted lifetime of an authority token: tokens with exp − now > max_validity_secs are rejected. This caps how long into the future a Token Authority may pre-issue tokens, limiting the window in which a stolen token could be replayed.

max_validity_secs = 3600

jti_prune_interval_secs

Optional. Default: 3600 (1 hour).

How often the background task purges expired JTI (JWT ID) entries from the replay-prevention cache in the database. Lower values reduce database growth at the cost of more frequent pruning queries. The background task only runs when [tkauth] is enabled.

jti_prune_interval_secs = 3600

Operators can also trigger manual pruning via the admin API or akamuctl:

POST /admin/tkauth/prune-jti
POST /admin/tkauth/prune-jti?dry_run=true
akamuctl tkauth prune-jti
akamuctl tkauth prune-jti --dry-run   # count without deleting

[[tkauth.claim_encoders]]

Optional array of claim-to-extension encoder entries. Each entry maps a JWT claim name in permittedValues of the validated JWTClaimConstraints token to a built-in certificate extension encoder. The encoder runs at finalize time and injects the claim value as a Subject Alternative Name in the issued certificate.

[[tkauth.claim_encoders]]
claim   = "sub"
encoder = "krb5-kpn"
# default_realm = "EXAMPLE.COM"   # appended when claim value has no '@'

[[tkauth.claim_encoders]]
claim   = "upn"
encoder = "ms-upn"

[[tkauth.claim_encoders]]
claim   = "dns"
encoder = "dns-san"

Built-in encoder names:

EncoderSAN typeNotes
krb5-kpnOtherName (id-pkinit-san, OID 1.3.6.1.5.2.2)principal@REALM; if @ is absent, default_realm is appended
ms-upnOtherName (OID 1.3.6.1.4.1.311.20.2.3)user@domain
dns-sandNSNamePlain hostname; wildcards rejected; lowercased before injection

Each encoder entry has these fields:

FieldRequiredDescription
claimyesJWT claim name in the authority token’s permittedValues
encoderyesEncoder name: "krb5-kpn", "ms-upn", or "dns-san"
default_realmnoKerberos realm appended when the claim value contains no @. Only meaningful for "krb5-kpn".

A permittedValues entry with exactly one value is injected as a SAN using the matching encoder. Entries with multiple permitted values are skipped (the server cannot determine which specific value to attest).


[gossip]

The [gossip] section enables multi-node clustering via CRDT replication. When present, Akāmu gossips CRDT deltas to the listed peer nodes over HTTP (POST /gossip/sync). All domain state — accounts, orders, authorizations, challenges, certificates, EAB keys, operators, delegations, and MTC data — is replicated to every cluster member. When absent, the node operates in single-node mode with no replication.

Gossip envelopes are signed with ML-KEM-768 + ECDSA-P256 to authenticate the source. Before gossip can proceed between two nodes, each node’s keys must be registered on the other node via POST /admin/gossip/register.

[gossip]
peers                        = ["https://node2.example.com", "https://node3.example.com"]
interval_secs                = 15
tombstone_ttl_secs           = 604800
ownership_ttl_secs           = 150
gossip_envelope_max_age_secs = 300
clock_skew_tolerance_secs    = 30
fan_out                      = 3

peers

Optional. Default: [].

List of peer gossip URLs to push CRDT state to. Each entry must be the HTTPS base URL of a peer Akāmu node (scheme, host, and optional port; no trailing path). Peers are contacted each gossip round; the fan_out setting limits how many are contacted per round.

peers = ["https://node2.acme.internal:8443", "https://node3.acme.internal:8443"]

interval_secs

Optional. Default: 15.

How often (in seconds) the background gossip loop fires and pushes CRDT deltas to peers. Lower values reduce replication lag at the cost of more network traffic.

interval_secs = 15

tombstone_ttl_secs

Optional. Default: 604800 (7 days).

How long tombstone records are retained in the CRDT before they are garbage-collected. Tombstones must be retained long enough to ensure every peer in the cluster has received the deletion before the record is purged.

tombstone_ttl_secs = 604800

ownership_ttl_secs

Optional. Default: 150.

Lease duration in seconds for write-ownership of orders and MTC entries. Each node refreshes its ownership lease every gossip round. When a lease expires (the owning node has been silent for ownership_ttl_secs), another node may take over.

ownership_ttl_secs = 150

gossip_envelope_max_age_secs

Optional. Default: 300 (5 minutes).

Maximum age in seconds of a gossip envelope. Envelopes timestamped more than this many seconds in the past are rejected as potential replays.

gossip_envelope_max_age_secs = 300

clock_skew_tolerance_secs

Optional. Default: 30.

Maximum acceptable clock difference between cluster nodes in seconds. Gossip envelopes timestamped more than this many seconds in the future are rejected. Ensure NTP synchronisation across all cluster members keeps skew well below this threshold.

clock_skew_tolerance_secs = 30

fan_out

Optional. Default: 0 (contact all peers).

Maximum number of peers contacted per gossip round. When set to a positive integer, only that many peers are selected at random each round; this reduces O(N²) gossip overhead in clusters larger than roughly five nodes while convergence still occurs transitively in O(log_k(N)) rounds. Set to 0 to contact all configured peers every round.

fan_out = 3   # recommended for clusters of 5+ nodes

[profiles]

The [profiles] section configures the certificate profile subsystem. Profiles are loaded from one or more providers at startup, cached in memory, and refreshed periodically by a background task. Akāmu’s own CA always signs; profiles only control which extensions are included and with what values. When no providers are configured, every order falls back to CA defaults (digitalSignature KeyUsage, serverAuth EKU, and the [ca] validity/URL settings).

When at least one provider is configured and a newOrder request omits the profile field, the server checks whether a profile named "default" exists in the registry. If it does, "default" is applied automatically and echoed back in the order response. If no "default" profile exists, the order falls back to the CA’s built-in defaults.

See Certificate Profiles for the complete reference including all provider types, key usage names, EKU OIDs, and three-state URL semantics.

refresh_interval_secs

Optional. Default: 3600 (1 hour).

How often the background task re-reads profiles from all providers. Set to 0 to disable automatic refresh (profiles are loaded once at startup and never refreshed).

[profiles]
refresh_interval_secs = 1800   # refresh every 30 minutes

[profiles.providers.<name>]

Each key under [profiles.providers] names a provider. The required type field selects the backend:

typeSource
"builtin"Inline TOML profile declarations in config.toml
"dogtag"Dogtag PKI .cfg files — filesystem or LDAP (simple bind or GSSAPI/Kerberos)
"ipa"FreeIPA/IPAThinCA — filesystem or LDAP (simple bind or GSSAPI/Kerberos)
# Builtin provider: inline declarations
[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.tlsserver]
description   = "Standard TLS server certificate"
validity_days = 90
key_usage     = ["digital_signature", "key_encipherment"]
eku           = ["server_auth"]

# Dogtag provider: load .cfg files from a directory
[profiles.providers.dogtag_prod]
type        = "dogtag"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles    = ["caServerCert"]   # empty = all .cfg files

# Dogtag provider: load profiles from LDAP (simple bind, single server)
# Setting tls_ca_cert_file triggers STARTTLS automatically on ldap:// URIs.
[profiles.providers.dogtag_ldap]
type     = "dogtag"
profiles = ["caServerCert"]

[profiles.providers.dogtag_ldap.ldap]
uri                = "ldap://dogtag.example.com:389"
base_dn            = "dc=example,dc=com"
bind_dn            = "uid=admin,ou=people,dc=example,dc=com"
bind_password_file = "/etc/akamu/ldap-password"
tls_ca_cert_file   = "/etc/ssl/certs/dogtag-ldap-ca.pem"   # triggers STARTTLS

# Dogtag provider: multiple servers for failover (GSSAPI)
[profiles.providers.dogtag_ha]
type     = "dogtag"
profiles = ["caServerCert"]

[profiles.providers.dogtag_ha.ldap]
uris    = ["ldap://dogtag1.example.com:389", "ldap://dogtag2.example.com:389"]
base_dn = "dc=example,dc=com"
gssapi  = true

# IPA provider: filesystem fallback
[profiles.providers.ipa_prod]
type        = "ipa"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles    = ["caIPAserviceCert"]

# IPA provider: SRV-based discovery with GSSAPI
[profiles.providers.ipa_ldap]
type     = "ipa"
profiles = ["caIPAserviceCert"]

[profiles.providers.ipa_ldap.ldap]
srv_domain = "example.com"   # resolves _ldap._tcp.example.com SRV records
base_dn    = "o=ipaca"
gssapi     = true

[ldap] sub-table fields (applies to both dogtag and ipa providers)

Server selection — at least one of the following is required

KeyTypeDefaultDescription
uristringabsentSingle LDAP URI (ldap://host:port or ldaps://host:636). Kept for backward compatibility; use uris when listing multiple servers explicitly.
urisarray of strings[]Ordered list of LDAP URIs tried in turn for failover. All URIs are passed to ldap_initialize as a space-separated string.
srv_domainstringabsentDNS domain for SRV discovery. Resolves _ldap._tcp.{srv_domain} SRV records; discovered servers are sorted by RFC 2782 priority/weight and appended after any explicit uris.

Explicit servers (uri / uris) are always tried before SRV-discovered servers. An error is returned at startup if none of the three keys is set.

Search parameters — required

KeyTypeDescription
base_dnstringBase DN for the profile search. Dogtag: directory root suffix (e.g. dc=example,dc=com). IPA: o=ipaca.

Authentication — choose one method

KeyTypeDefaultDescription
bind_dnstringabsentBind DN for LDAP simple bind. Required when using simple authentication.
bind_password_filestringabsentPath to a file containing the simple bind password (trailing newline is stripped). Required when bind_dn is set.
gssapibooleanfalseUse SASL GSSAPI (Kerberos) authentication. Pre-condition: the process must hold a valid Kerberos TGT in its credential cache. Mutually exclusive with bind_dn / bind_password_file.

TLS

KeyTypeDefaultDescription
tls_ca_cert_filestringabsentPath to a PEM CA certificate used to verify the LDAP server’s TLS certificate. When this is set on an ldap:// URI, STARTTLS is negotiated automatically before any credentials are sent. When set on an ldaps:// URI, the CA is used for the immediate TLS handshake. When absent, the system trust store is used.

Additional builtin profile fields

Beyond the core extension fields, each builtin profile supports four groups of optional settings:

Multi-CA restriction

KeyDefaultDescription
ca_ids[]List of CA IDs for which this profile is available. When non-empty, the profile is only accessible via the named CAs’ ACME endpoints; requests via other CAs receive invalidProfile. Empty = available via all CAs. Config validation rejects entries not matching a configured CA id.

Certificate format

KeyDefaultDescription
issue_asabsent / "x509"Set to "mtc" to issue a Merkle Tree Certificate standalone certificate instead of a PEM chain. Requires [mtc] to be enabled. The standalone certificate is a standard X.509 v3 Certificate where signatureAlgorithm is id-alg-mtcProof (OID 1.3.6.1.4.1.44363.47.0, experimental pre-IANA) and signatureValue carries a TLS-encoded MTCProof. Per draft-04 §4.3, MTCProof contains a leading extensions field (uint16 length-prefixed; empty = \x00\x00), start/end as uint48 (6-byte big-endian), and a uint8-prefixed cosigner_id in each MtcSignature. The OID will change when the draft is published as an RFC.

Per-profile authorization

KeyDefaultDescription
allowed_identifiers[]List of regex patterns. Each order identifier is matched as "type:value" (e.g. "dns:example.com"). Empty = no restriction.
identifier_match"all""all": every identifier must match a pattern. "any": at least one identifier must match. Ignored when allowed_identifiers is empty.
auth_hookabsentPath to an external executable. Receives JSON on stdin; exit 0 = permit, non-zero = deny.
auth_hook_timeout_secs30Seconds to wait for the hook before denying.
require_account_grantfalseWhen true, the account must have this profile’s name in its profile_grants attribute (set via the Admin API or inherited from its EAB key).

tkauth-01 JWKS trust

KeyDefaultDescription
trust_jwks_urls[]List of HTTPS or http+unix:// URLs of JWKS endpoints trusted for kid-signed authority tokens (RFC 9447 tkauth-01). Only meaningful when [tkauth] is enabled. When empty, kid-signed tokens are rejected for this profile.

The http+unix:// form allows co-located Token Authorities (for example, an Ekishib IdP on the same host) to be reached without a network port. Encode the socket path with / as %2F:

[profiles.providers.local.profiles.kerberos-svc]
description     = "Kerberos service certificate"
ca_ids          = ["kerberos-ca"]
trust_jwks_urls = [
    "https://idp.example.com/jwks",
    "http+unix://%2Frun%2Fekishib%2Fekishib.sock/jwks",
]

JWKS responses are cached in memory for 5 minutes and refreshed independently per URL.

See Certificate Profiles for detailed descriptions with examples.


[admin]

The [admin] section enables the server-side Admin API. Admin endpoints (/admin/*) are served on the same listener as the main ACME API — there is no separate admin listener. When this section is absent, all admin endpoints return 404. This is the default; no admin access is possible without explicit configuration.

Operator authentication uses one or both of:

  • mTLS client certificates — configure [tls.client_auth] with required = false and the operator CA(s); the connecting client presents a certificate signed by one of those CAs.
  • GSSAPI/Kerberos — configure [admin.gssapi]; clients authenticate via a Kerberos service ticket without requiring a client certificate.

At least one of [tls.client_auth] or [admin.gssapi] must be configured; the server exits at startup if neither is set.

# mTLS client authentication — operator CA(s) accepted for /admin/* requests.
[tls.client_auth]
ca_certs = ["/etc/akamu/operator-ca.pem"]
required = false   # allow GSSAPI-only clients that carry no cert

[admin]
session_ttl_secs = 3600

# Bootstrap operator (mTLS) — generated and registered on first run when operators table is empty.
# bootstrap_operator_cert_file = "/etc/akamu/admin-bootstrap.pem"
# bootstrap_operator_key_file  = "/etc/akamu/admin-bootstrap-key.pem"

# Bootstrap operator (GSSAPI) — mutually exclusive with cert bootstrap above.
# When operators table is empty at startup, registers this principal as Administrator.
# bootstrap_operator_gssapi_principal = "admin@EXAMPLE.COM"

# Optional: also accept GSSAPI-authenticated operators
[admin.gssapi]
keytab_file  = "/etc/akamu/http.keytab"   # omit and set gssproxy = true to use gssproxy instead
service_name = "HTTP"

bootstrap_key_type

Optional. Default: "ec:P-256".

Key algorithm used when auto-generating the bootstrap operator certificate. Same syntax as ca.key_type.

bootstrap_key_type = "ec:P-256"

bootstrap_operator_cert_file

Optional.

Path where the bootstrap Administrator operator’s client certificate will be written on first run. When this file (and bootstrap_operator_key_file) are absent and the operators table is empty, Akāmu generates a client certificate signed by the Akāmu CA and registers the operator automatically. Both fields must be set together.

bootstrap_operator_cert_file = "/etc/akamu/admin-bootstrap.pem"

bootstrap_operator_key_file

Optional.

Path where the bootstrap Administrator operator’s client private key will be written on first run. Must be set alongside bootstrap_operator_cert_file.

bootstrap_operator_key_file = "/etc/akamu/admin-bootstrap-key.pem"

bootstrap_operator_name

Optional. Default: "admin".

Name recorded in the operators table for the auto-provisioned bootstrap administrator.

bootstrap_operator_name = "admin"

bootstrap_operator_gssapi_principal

Optional. Default: unset.

Kerberos principal for the GSSAPI bootstrap Administrator operator (e.g. "admin@REALM"). When set and the operators table is empty at startup, Akāmu inserts an Administrator row with this principal so that the first akamuctl login --gssapi succeeds without a prior akamuctl operator add. Mutually exclusive with bootstrap_operator_cert_file / bootstrap_operator_key_file.

bootstrap_operator_gssapi_principal = "admin@EXAMPLE.COM"

auth_rate_limit

Optional. Default: 20.

Maximum credential presentations (Bearer session token, mTLS client certificate, or GSSAPI token) accepted from a single source IP in a rolling 5-minute window before that source receives 429 Too Many Requests. This limits audit-event floods that could otherwise trigger the audit_alarm_action or, when audit_overflow = "halt", refuse all new requests.

auth_rate_limit = 20

session_ttl_secs

Optional. Default: 3600 (1 hour).

Inactive session expiry in seconds. Operator sessions that have had no activity for this duration are invalidated and require re-authentication.

session_ttl_secs = 3600

session_lock_secs

Optional. Default: 900 (15 minutes).

Inactivity threshold before a session enters locked state (FTA_SSL_EXT.1). After this many idle seconds, requests that present the session token receive 423 Locked instead of 401 Unauthorized. The session is not destroyed; the operator must re-authenticate to obtain a fresh token. This value must be less than session_ttl_secs.

session_lock_secs = 900

max_failed_auth

Optional. Default: 5.

Maximum number of failed authentication attempts allowed for an operator before the account is locked (FIA_AFL.1). Once the threshold is exceeded, further authentication attempts return 423 Locked until an administrator calls POST /admin/operators/{id}/unlock (or uses akamuctl operator unlock).

max_failed_auth = 5

lockout_duration_secs

Optional. Default: 1800 (30 minutes).

How long in seconds the operator account remains locked after exceeding max_failed_auth (FIA_AFL.1). After this duration the lock is automatically cleared; an administrator may also clear it early with operator unlock.

lockout_duration_secs = 1800

audit_max_events

Optional. Default: absent (unlimited). Backward-compatible alias: audit_max_rows.

Maximum number of audit events since startup before the audit_overflow policy triggers. Audit events are written to the configured audit backend (systemd journal namespace, JSONL file, or in-process store), not to the database. The counter is in-memory and resets on restart. The audit backend manages its own disk retention independently (see contrib/systemd/journald@akamu.conf for journald, or external logrotate(8) for the file backend).

Negative values are treated as unlimited (with a startup warning). Zero is also treated as unlimited.

audit_max_events = 500000

audit_overflow

Optional. Default: "drop_oldest".

Policy applied when audit_max_events is reached. Accepted values:

ValueBehaviour
"drop_oldest"Continue recording events; journald manages its own retention (default). This is effectively a no-op.
"halt"Refuse new requests until the server is restarted.
audit_overflow = "drop_oldest"

audit_alarm_threshold

Optional. Default: 10.

Number of SecurityViolation audit events in a rolling 5-minute window that triggers the alarm response configured by audit_alarm_action.

audit_alarm_threshold = 10

audit_alarm_action

Optional. Default: "syslog".

Action taken when the audit_alarm_threshold is exceeded. Accepted values:

ValueBehaviour
"syslog"Log a CRIT-level message to syslog (default).
"halt"Halt the server process immediately.
audit_alarm_action = "syslog"

[admin.gssapi]

Optional. When absent, GSSAPI authentication for the admin interface is disabled.

Configures GSSAPI/Kerberos authentication for operators accessing the admin API. When set, operators can authenticate by presenting a Kerberos service ticket without requiring a client certificate.

keytab_file

Required when gssproxy = false (the default). Must be absent when gssproxy = true. Path to the Kerberos keytab file for the admin service principal. The akamu process must be able to read this file; no other user should have read access to it. Setting both keytab_file and gssproxy = true is a configuration error; the server exits at startup.

keytab_file = "/etc/akamu/http.keytab"

gssproxy

Optional. Default: false.

When true, GSSAPI credential acquisition for the admin service principal is delegated to the gssproxy daemon. The akamu process must have a matching entry in /etc/gssproxy/conf.d/. The server sets GSS_USE_PROXY=yes in its environment before the first GSSAPI call. No direct access to a keytab file on disk is needed. keytab_file must be absent when this is true.

# gssproxy mode for the admin interface
[admin.gssapi]
gssproxy     = true
service_name = "HTTP"

service_name

Optional. Default: "HTTP".

Host-based service name. MIT Kerberos appends @<local-hostname> when no realm is specified.

service_name = "HTTP"

Admin endpoints and RBAC roles:

MethodPathadministratorca_operationsca_raauditor
POST/admin/sessionYYYY
DELETE/admin/sessionYYYY
GET/admin/operatorsY
POST/admin/operatorsY
GET/admin/operators/{id}Y
PUT/admin/operators/{id}Y
PATCH/admin/operators/{id}Y
POST/admin/operators/{id}/unlockY
GET/admin/auditYY
GET/admin/profilesYYYY
POST/admin/profilesY
PUT/admin/profiles/{id}Y
DELETE/admin/profiles/{id}Y
GET/admin/accountsYYYY
GET/admin/account/{id}YYYY
POST/admin/account/{id}/deactivateY
GET/admin/account/{id}/profile-grantsYYYY
PUT/admin/account/{id}/profile-grantsYY
DELETE/admin/account/{id}/profile-grantsY
GET/admin/certsYYY
GET/admin/certs/{id}YYY
GET/admin/certs/{id}/downloadYY
POST/admin/eabYYY
GET/admin/eab/{kid}YYYY
DELETE/admin/eab/{kid}YY
GET/admin/eabYYYY
GET/admin/ordersYYYY
GET/admin/orders/{id}YYYY
GET/admin/configY
POST/admin/crl/forceYY
POST/admin/revokeYYY
GET/admin/statsYYYY
GET/admin/casYY
GET/admin/cas/{id}YY
GET/admin/cas/{id}/certYY
POST/admin/ca/{id}/crl/forceYY
POST/admin/ca/{id}/cross-signYY
GET/admin/cross-certsYYY
GET/admin/cross-certs/{id}YYY
GET/admin/delegationsYYYY
POST/admin/delegationsYY
GET/admin/delegations/{id}YYYY
PUT/admin/delegations/{id}YY
DELETE/admin/delegations/{id}YY
GET/admin/gossip/statusYYYY
POST/admin/gossip/registerY
POST/admin/tkauth/prune-jtiYY

See Admin API and Operator Management for the full request/response format of each endpoint.

Account Management

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

Account lifecycle

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

Accounts start in valid status. A valid account can:

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

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

Creating an account

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

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

Contact values must be URIs (containing :). The server does not restrict schemes — mailto:, tel:, and other URI schemes are accepted. Reachability is not verified.

Example payload:

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

Response on creation (201 Created):

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

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

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

Reading account details

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

Updating contact information

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

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

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

Deactivating an account

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

{
  "status": "deactivated"
}

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

Key rollover

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

  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 account key
    S-->>C: 200 OK (account object)

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

The server verifies:

  • The outer JWS is signed by the current account key.
  • The inner JWS is signed by the new key (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.

Profile grants

Accounts may have a profile_grants attribute that restricts which certificate profiles they are allowed to request. When a profile is configured with require_account_grant = true, the account’s profile_grants must include that profile’s name or the finalization request is denied.

An account with no grants (the default) can only request profiles that do not require a grant.

Viewing and modifying grants

Grants are managed through the Admin API (requires [admin] to be configured in config.toml):

GET    /admin/account/{id}/profile-grants   → {"profile_grants": ["p1"]}
PUT    /admin/account/{id}/profile-grants   ← {"profile_grants": ["p1", "p2"]}
DELETE /admin/account/{id}/profile-grants

All admin endpoints require Authorization: Bearer <token>.

EAB grant inheritance

When an EAB key is provisioned with profile_grants via POST /admin/eab, any account created using that EAB key automatically inherits those grants at account creation time. The transfer is atomic — the same database transaction that inserts the new account and marks the EAB key as used also sets the profile_grants on the account row.

POST /admin/eab
{"kid":"key-1","hmac_key_b64u":"<base64url>","profile_grants":["internal"]}

After an account is created using key-1, it will have profile_grants = ["internal"] without any additional admin action.

Delegation URL

When server.delegation_enabled = true, the account object includes an additional "delegations" URL:

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

POST-as-GET to the delegations URL returns the list of delegation objects available for that account. NDC clients use this URL to discover which CSR templates they are authorized to use. See Orders — Delegation orders and the RFC 9115 configuration reference for the full workflow.

Security considerations

  • Each account is identified by the SHA-256 thumbprint of its JWK public key. The server uses this thumbprint to look up accounts without needing to parse or compare full public key material on every request.
  • Key rollover is the only mechanism to change the signing key. There is no password or other credential; possession of the private key is the sole proof of identity.
  • Contact URIs are not validated for reachability. The server accepts any URI containing : (e.g. mailto:, tel:); it does not restrict the scheme or verify that the address is reachable.

Orders

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

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

Order lifecycle

stateDiagram-v2
    direction LR
    [*] --> pending : new-order
    pending --> ready : all authorizations valid
    pending --> invalid : any authorization fails
    ready --> processing : finalize (submit CSR)
    processing --> valid : certificate issued
    processing --> invalid : CSR validation failed
    valid --> [*]
    invalid --> [*]
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:

TypeValueSupported challenges
dnsRegular domain namehttp-01, dns-01, tls-alpn-01, dns-persist-01 (when configured)
dnsWildcard (*.example.com)dns-01, dns-persist-01 (when configured)
dns.onion v3 hidden serviceonion-csr-01; additionally http-01 and tls-alpn-01 when tor_connectivity_enabled = true
ipIP address literalhttp-01, tls-alpn-01

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

dns-persist-01 (draft-ietf-acme-dns-persist) is offered alongside the standard DNS challenges when the server operator has configured at least one entry in server.dns_persist_issuer_domains. If that list is empty the challenge type is not offered.

.onion identifiers (RFC 9799) are submitted as type: "dns" with a v3 .onion value (a 56-character base32 label followed by .onion, e.g. bbcweb3hytmzhn5d532owbu6oqadra5z3ar726vq5kgwwn6aucdccrad.onion). Only v3 addresses are accepted; v2 (16-character label) addresses are rejected. The server always offers onion-csr-01 for these identifiers. When server.tor_connectivity_enabled = true, it also offers http-01 and tls-alpn-01 (for CAs with Tor network connectivity). dns-01 and dns-persist-01 are never offered for .onion identifiers.

Response (201 Created):

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

The Location header contains the order URL.

Reading an order

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

Completing authorizations

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

For onion-csr-01 (RFC 9799), the challenge response payload must carry the validation CSR rather than being empty. The client POSTs:

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

The CSR must contain:

  • The .onion domain in a SubjectAlternativeName dNSName entry.
  • The cabf-onion-csr-nonce extension (OID 2.23.140.41) whose value is the key authorization string (token.thumbprint).
  • A self-signature by the CSR key.
  • An Ed25519 signature by the hidden-service key whose public key is encoded in the .onion address (the outer CSR signature, or the CSR key itself when it is the hidden-service key).

Finalizing an order

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

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

The CSR must:

  • Be a valid PKCS#10 DER structure.
  • Have a valid self-signature.
  • Contain a SubjectAlternativeName extension listing exactly the identifiers from the order (no more, no fewer).
  • Not assert cA=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.

Delegation orders (RFC 9115)

When the server has delegation_enabled = true, ACME clients can place a delegation order by including a "delegation" field in the new-order payload. The value is the URL of a delegation object previously created by the IdO via the Admin API.

{
  "identifiers": [
    { "type": "dns", "value": "cdn.example.com" }
  ],
  "delegation": "https://acme.example.com/acme/delegation/b1c2d3e4-…",
  "allow-certificate-get": true
}

Delegation order behaviour differences from regular orders:

PropertyRegular orderDelegation order
Initial statuspendingready
authorizations arrayOne URL per identifierAlways empty ([])
Challenge/authz flowRequiredSkipped entirely
CSR validation at finalizeSAN match checkSAN match + CSR template check
allow-certificate-getNot applicableSupported (unauthenticated cert GET)
Upstream CA legNoneDriven automatically by [delegation_upstream]

The NDC (delegate client) discovers the delegation URL by fetching the IdO’s account object and following the "delegations" URL, then POST-as-GETting POST /acme/delegations/{account_id} to list available delegation objects.

Response on creation (201 Created) for a delegation order:

{
  "status": "ready",
  "identifiers": [
    { "type": "dns", "value": "cdn.example.com" }
  ],
  "authorizations": [],
  "finalize": "https://acme.example.com/acme/order/<order-id>/finalize"
}

The NDC may immediately proceed to finalize the order with a CSR. The CSR is validated against the delegation’s stored CSR template before issuance proceeds.

Challenges

A challenge is the mechanism by which Akāmu verifies that an ACME client controls the identifier (domain name, IP address, email address, or telephone-number authority) in an authorization. The server supports seven challenge types: http-01, dns-01, tls-alpn-01, dns-persist-01, onion-csr-01, email-reply-00, and tkauth-01.

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

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

onion-csr-01 is offered exclusively for .onion identifiers (Tor hidden services) and uses a CSR-based proof-of-control mechanism rather than a network probe. See onion-csr-01 below for the full description.

email-reply-00 is offered exclusively for email identifiers (RFC 8823 S/MIME) and uses a two-channel token delivered by email. It requires explicit server configuration. See email-reply-00 below for the full description.

tkauth-01 is offered exclusively for TNAuthList and JWTClaimConstraints identifiers (RFC 9447/9448). The client obtains a signed JWT from an external Token Authority and submits it in the challenge response. It requires explicit server configuration. See tkauth-01 below for the full description.

Key authorization

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

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

Where:

  • 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.

onion-csr-01 uses the standard token.thumbprint key authorization formula. The server also includes an authKey field in the challenge object containing the JWK thumbprint so the client can construct the key authorization without a separate lookup.

Responding to a challenge

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

{}

onion-csr-01 is an exception: the POST body must include the DER-encoded CSR (see onion-csr-01 for the payload format).

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

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

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

Challenge status transitions

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

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

Challenge types at a glance

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

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

    C & D & E & F --> G["POST {} to challenge URL"]
    G2 --> G2P["POST {csr: base64url-DER} to challenge URL"]
    G & G2P --> H["Server validates in background"]

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

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

http-01

The server makes an HTTP/1.1 GET request to:

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

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

Provisioning

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

Example:

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

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

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

Constraints

  • Port 80 must be reachable from the ACME server.
  • The response body must be less than 1 MiB.
  • HTTP 3xx redirects are followed (up to 10 hops), including redirects to HTTPS targets.
  • IPv6 addresses are supported as 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 (RFC 8555 §7.1.3).

dns-01

The server queries the DNS TXT record at:

_acme-challenge.<domain>

At least one TXT record value must equal:

base64url(SHA-256(key_authorization))

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

Provisioning

Add a DNS TXT record:

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

Concrete example:

  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 (RFC 8737 §3).
  • 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 IETF draft draft-ietf-acme-dns-persist. It is available only for dns type identifiers; IP address identifiers are not supported.

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

How it works

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

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

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

TXT record format

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

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

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

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

dns_persist_issuer_domains

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

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

Accepts either a single string or an array of strings. Multi-tenant or multi-identity deployments can list all accepted issuer domains; validation succeeds when any configured domain matches.

[server]
# Single domain
dns_persist_issuer_domains = "acme.example.com"

# Multiple domains
dns_persist_issuer_domains = ["acme.example.com", "acme.example.org"]

dns_resolver_addr

Optional. Default: absent (system resolver).

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

[server]
dns_resolver_addr = "127.0.0.1:5353"

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

dns_persist01_resolver_addr

Optional. Default: absent (falls back to dns_resolver_addr).

Resolver override used exclusively for dns-persist-01 validation. When set, this address is used instead of dns_resolver_addr for TXT lookups at _validation-persist.*. Useful when persistent TXT records are served by a different DNS infrastructure than the one used for dns-01 and CAA lookups.

[server]
dns_resolver_addr        = "127.0.0.1:5353"   # used for dns-01 and CAA
dns_persist01_resolver_addr = "127.0.0.1:5354" # used only for dns-persist-01

dns_dot_server_name

Optional. Default: absent (plain UDP).

TLS server name (SNI hostname) for DNS-over-TLS (DoT, RFC 7858). When set, all DNS challenge validation queries — dns-01, dns-persist-01, and CAA record lookups — are sent over TLS instead of plain UDP. dns_resolver_addr must be set to the DoT server’s IP address and port 853.

Use DoT when the path between the Akāmu server and its resolver is untrusted (e.g. a public resolver reached over the open Internet, an ISP that intercepts cleartext DNS queries, or a network with strict privacy requirements). The TLS certificate presented by the resolver is verified against the system root CA store. LDAP SRV lookups for certificate profile providers are unaffected.

DoT and DNSSEC validation (validate_dnssec = true) are independent and can be used together: DoT protects the transport channel while DNSSEC authenticates the response data.

[server]
# DoT only
dns_resolver_addr   = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"

# DoT + DNSSEC
dns_resolver_addr   = "1.1.1.1:853"
dns_dot_server_name = "cloudflare-dns.com"
validate_dnssec     = true

Limitations

  • No IP address identifier support. dns-persist-01 is defined only for DNS name identifiers; IP address identifiers are not supported (no TXT record format is specified in draft-ietf-acme-dns-persist for IP identifiers).

onion-csr-01 (RFC 9799)

onion-csr-01 validates control of a Tor v3 hidden service (.onion) identifier. Instead of making a network probe, the server verifies a PKCS #10 CSR that the client constructs and submits in the challenge response body. The challenge type is defined by RFC 9799.

This challenge is offered only for .onion DNS identifiers. It cannot be used for regular DNS names or IP address identifiers. The identifier in the ACME order must use "type": "dns" and the value must be a valid v3 .onion address (56-character base32 label + .onion). v2 .onion addresses (16-character label) are rejected.

Challenge object

When the authorization is created for a .onion identifier, the server includes an onion-csr-01 challenge object. Unlike other challenge types, it also carries an authKey field containing the account’s JWK thumbprint:

{
  "type": "onion-csr-01",
  "url": "https://acme.example.com/acme/chall/<authz-id>/onion-csr-01",
  "status": "pending",
  "token": "<token>",
  "authKey": "<jwk-thumbprint>"
}

The authKey field is provided as a convenience: the client needs the JWK thumbprint to compute the key authorization, and authKey exposes it directly so the client does not need to derive it from the account key a second time.

Key authorization

Compute the key authorization using the standard formula:

key_authorization = token + "." + authKey

Where authKey is the authKey field from the challenge object (equal to base64url(SHA-256(JWK-thumbprint-of-account-key))).

Client provisioning

The client must build a DER-encoded PKCS #10 CSR that satisfies all of the following:

  1. Subject Alternative Name: contains the .onion domain as a dNSName.
  2. cabf-onion-csr-nonce extension (OID 2.23.140.41): the extension value is a DER UTF8String (tag 0x0C) containing the key authorization string (token.thumbprint). This extension does not need to be marked critical.
  3. Signature: the CSR must be signed by the hidden-service Ed25519 private key — the key whose public key is encoded in the v3 .onion address. The most common approach is to use the hidden-service key directly as the CSR key, producing a single self-signature that also proves hidden-service key control.

Example OID DER encoding for 2.23.140.41: 06 04 67 81 0C 29

Challenge response

POST to the challenge URL with the base64url-encoded DER CSR as the payload (this is the only challenge type where the POST body is not an empty {}):

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

The server decodes the csr field from base64url immediately and returns 400 Bad Request if the field is missing or the encoding is invalid.

Server validation

After the challenge is flipped to processing, the server performs these checks synchronously (no network probe is made):

  1. Decode the 32-byte Ed25519 public key from the v3 .onion address label (56-character lowercase base32, version byte 0x03).
  2. Parse the DER CSR structure.
  3. Verify the CSR self-signature.
  4. Locate the cabf-onion-csr-nonce extension (OID 2.23.140.41) and verify that its value decodes to the expected key authorization string.
  5. Verify that the Ed25519 signature over the CertificationRequestInfo DER is valid under the hidden-service public key. This succeeds if the CSR signing key is the hidden-service key (self-signed CSR), or if the outer CSR signature verifies directly with the hidden-service key.
  6. Verify that the CSR’s SAN extension contains the .onion domain as a dNSName.

Challenge types offered for .onion identifiers

RFC 9799 §4 requires that onion-csr-01 is always offered for .onion identifiers and that dns-01 is never offered. Whether http-01 and tls-alpn-01 are also offered depends on the server.tor_connectivity_enabled configuration:

  • tor_connectivity_enabled = false (default): only onion-csr-01 is offered. The server cannot reach .onion addresses, so HTTP and TLS probes would always fail.
  • tor_connectivity_enabled = true: onion-csr-01, http-01, and tls-alpn-01 are all offered, giving the client a choice.

dns-persist-01 is never offered for .onion identifiers.

Constraints

  • Only valid for dns type identifiers whose value ends in .onion.
  • Only v3 .onion addresses (56-character base32 label) are accepted; v2 addresses are rejected at order/pre-authorization creation time.
  • dns-01 and dns-persist-01 are never offered for .onion identifiers.
  • Wildcard .onion identifiers (*.xxx...xxx.onion) are not supported.

Configuration

No additional server configuration is required to enable onion-csr-01; it is always offered when an order or pre-authorization contains a .onion identifier. To additionally offer http-01 and tls-alpn-01 for .onion identifiers (requires Tor network access from the server), set:

[server]
tor_connectivity_enabled = true

Challenge failure

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

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

Common error types:

Error typeMeaning
connectionCould not connect to the applicant server (http-01, tls-alpn-01)
dnsDNS TXT lookup failed or name not found (dns-01, dns-persist-01)
incorrectResponseServer responded but the content did not match; or, for onion-csr-01, the CSR failed validation (missing nonce extension, nonce mismatch, invalid Ed25519 signature, missing SAN, or malformed .onion address)
tlsTLS handshake failed or extension verification failed (tls-alpn-01)

For onion-csr-01, the server also returns an immediate 400 Bad Request (before the background validation task starts) if the POST body is not valid JSON with a csr field, or if the csr value is not valid base64url.

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


email-reply-00 (RFC 8823)

email-reply-00 is defined by RFC 8823. It is the only challenge type offered for email identifier orders and proves email address control via a DKIM-authenticated reply email. Issuing S/MIME certificates using this challenge requires the [email_challenge] configuration section — see email_challenge configuration.

Protocol

  1. The client creates an order with {"type": "email", "value": "user@example.com"}.

  2. The server returns an authorization with an email-reply-00 challenge object:

    {
      "type": "email-reply-00",
      "url": "https://acme.example.com/acme/chall/<id>",
      "status": "pending",
      "token": "<base64url(token-part2)>",
      "from": "acme-validation@example.com"
    }
    

    from is the address the server will send the challenge email from. token is token-part2 (server-generated, ≥128 bits of random data).

  3. The client POSTs {} to the challenge URL to trigger the challenge. The server:

    • Generates token-part1 (≥128 bits of random data) and a Message-ID.
    • Stores both in the database.
    • Invokes the configured send_script to send an email to the identifier address with subject ACME: <base64url(token-part1)> and the generated Message-ID.
  4. The client reads the email, extracts token-part1 from the Subject header, then computes:

    full_token  = base64url(token-part1) || base64url(token-part2)
    key_auth    = full_token || "." || base64url(SHA-256(canonical-JWK))
    response    = base64url(SHA-256(key_auth))
    
  5. The client sends a reply email (preserving In-Reply-To and DKIM signing) with body:

    -----BEGIN ACME RESPONSE-----
    <response>
    -----END ACME RESPONSE-----
    
  6. Mail routing infrastructure (a filter script, procmail rule, or email service webhook) POSTs the reply to POST /acme/email-webhook. See Webhook endpoint below.

  7. The server verifies the DKIM domain, extracts and verifies the response digest, and marks the challenge and authorization valid.

  8. The client finalizes with a CSR containing an rfc822Name SAN matching the email address and the emailProtection EKU.

Key authorization formula

email-reply-00 uses a modified key authorization:

full_token = base64url(token-part1) || base64url(token-part2)
key_auth   = full_token || "." || base64url(SHA-256(canonical-JWK))
response   = base64url(SHA-256(key_auth as UTF-8 bytes))

Where base64url(SHA-256(canonical-JWK)) is the JWK thumbprint of the account key per RFC 7638 — the same value used as thumbprint in the standard key authorization formula. Do not hash the thumbprint string itself; hash the canonical JWK JSON.

This is different from the standard token.thumbprint formula used by http-01/dns-01/tls-alpn-01. The response value (not key_auth) is what the client sends in the reply body.

CSR requirements

The CSR submitted at finalize time must:

  • Contain an rfc822Name Subject Alternative Name matching the email identifier value (case-insensitive).
  • Use the emailProtection Extended Key Usage (OID 1.3.6.1.5.5.7.3.4).
  • Not contain any DNS/IP SANs that were not authorized by a separate authorization.

The certificate profile should be configured to enforce email_protection EKU. See the S/MIME profile example in the profiles documentation.

Webhook endpoint

POST /acme/email-webhook receives the client’s reply from any mail routing tool that can POST JSON:

{
  "from":        "user@example.com",
  "in_reply_to": "<uuid@acme-server.example.com>",
  "dkim_domain": "example.com",
  "dkim_status": "pass",
  "body":        "-----BEGIN ACME RESPONSE-----\nABC123==\n-----END ACME RESPONSE-----"
}
FieldDescription
fromEnvelope/header From address of the reply email
in_reply_toIn-Reply-To header of the reply email (must match the server’s Message-ID)
dkim_domainDKIM d= tag from a valid DKIM signature on the reply
dkim_status"pass" if DKIM verification succeeded; any other value fails the challenge
bodyFull text body of the reply email

DKIM trust model: The server does not perform DKIM verification itself. DKIM verification is the responsibility of the webhook caller (the mail routing script or MTA filter). The server enforces two properties on every request:

  1. dkim_domain must match the domain part of from (case-insensitively). This prevents a malicious script from claiming DKIM pass for a different domain.
  2. dkim_status must equal "pass" (case-insensitively; some MTAs report "Pass" or "PASS").

If your MTA or mail service provides DKIM results in a format other than "pass", normalize it in your routing script before POSTing to the webhook.

HMAC authentication: Every POST must include the header:

X-Akamu-Signature: sha256=<lowercase-hex(HMAC-SHA256(raw-body, webhook_hmac_secret))>

The webhook_hmac_secret is configured in [email_challenge]. Requests with a missing, malformed, or incorrect signature are rejected with 403 Forbidden. All other responses are 200 OK regardless of the challenge outcome (to prevent webhook callers from retrying indefinitely on validation failures).

Example send script

The server invokes the configured send_script with these environment variables:

VariableValue
ACME_TORecipient email address (the identifier)
ACME_FROMSender address (from_address in [email_challenge])
ACME_SUBJECTACME: <base64url(token-part1)>
ACME_MESSAGE_IDServer-generated Message-ID (script must preserve this exactly)
ACME_AUTO_SUBMITTEDauto-generated; type=acme
ACME_TOKEN_PART2token-part2 (base64url, from the challenge JSON token field); exposed so advanced scripts can pre-compute or log the expected response

Exit code 0 = success. Non-zero = the challenge is marked invalid and the client must retry.

A minimal script using sendmail:

#!/bin/bash
set -euo pipefail
sendmail -f "$ACME_FROM" "$ACME_TO" <<EOF
From: $ACME_FROM
To: $ACME_TO
Subject: $ACME_SUBJECT
Message-ID: $ACME_MESSAGE_ID
Auto-Submitted: $ACME_AUTO_SUBMITTED
MIME-Version: 1.0
Content-Type: text/plain

This email was sent automatically as part of an ACME S/MIME certificate
issuance request. If you did not request a certificate, ignore this email.
EOF

The script is responsible for DKIM signing. The server does not sign outbound email.


tkauth-01 (RFC 9447 / RFC 9448)

tkauth-01 validates control of a TNAuthList or JWTClaimConstraints identifier by verifying a signed JWT authority token issued by an external Token Authority (TA). This challenge type is defined by RFC 9447; the TNAuthList profile is defined by RFC 9448.

Unlike network-based challenges, tkauth-01 relies on the TA asserting the client’s authority over the identifier out-of-band. The server only verifies the cryptographic integrity of the token and the atc claim binding.

Challenge object

When an order contains a TNAuthList or JWTClaimConstraints identifier, the authorization contains a tkauth-01 challenge object:

{
  "type": "tkauth-01",
  "url": "https://acme.example.com/acme/chall/<authz-id>/tkauth-01",
  "status": "pending",
  "tkauth-type": "atc",
  "token-authority": "https://ta.example.com"
}
FieldDescription
tkauth-typeAlways "atc" — the authority token type profile
token-authorityOptional URL hint for the Token Authority, from tkauth.token_authority_url

Key authorization

tkauth-01 uses the standard key authorization formula: token.thumbprint where thumbprint is the base64url-encoded SHA-256 JWK thumbprint of the account key. The token field is not present in the challenge object; the thumbprint is used directly as the fingerprint check in the authority token’s atc claim.

The fingerprint field in the atc claim must be formatted as:

SHA256 XX:XX:XX:...

where XX:XX:XX:... is the colon-separated uppercase hex encoding of the raw SHA-256 JWK thumbprint bytes.

Identifier value format (JWTClaimConstraints)

For JWTClaimConstraints identifiers, the tkvalue in the atc claim and the ACME order identifier value must both be the base64url encoding of a DER-encoded JWTClaimConstraints structure as defined in RFC 8226 §3:

JWTClaimConstraints ::= SEQUENCE {
    mustInclude  [0] JWTClaimNames   OPTIONAL,
    permittedValues [1] JWTClaimValuesList OPTIONAL
}

The server validates both mustInclude (claim names that must be present in the JWT) and permittedValues (allowed values for specific claims). At least one of the two MUST be present.

Example: to require a specific Kerberos principal in the sub claim, encode:

JWTClaimConstraints {
    permittedValues [1]: [("sub", ["user@REALM"])]
}

The resulting DER bytes, base64url-encoded, become the identifier value.

Obtaining an authority token

The client contacts the Token Authority (identified by token-authority or by deployment convention) and requests an authority token for its identifier. The TA issues a compact JWT that must contain:

  • Header: alg and one of:

    • x5c: inline certificate chain (array of base64-encoded DER certs); the leaf signs the JWT
    • x5u: URL from which the server fetches the signing certificate chain
    • kid: key identifier used to locate the signing public key in a JWKS endpoint
  • Claims:

    • atc: an object with tktype (identifier type), tkvalue (identifier value, DER-encoded for JWTClaimConstraints), fingerprint (account key thumbprint in SHA256 XX:XX:... format), and optionally ca: false
    • jti: a unique token identifier (REQUIRED; enforces one-time use)
    • exp: expiry timestamp (REQUIRED)

Challenge response

POST to the challenge URL with the JWT in the tkauth field:

{
  "tkauth": "<compact-JWT>"
}

Example (header shown decoded):

{
  "alg": "ES256",
  "x5u": "https://ta.example.com/cert"
}

Validation steps

  1. Decode the JWT header to determine the key resolution path:
    • x5c: parse the inline certificate chain directly
    • x5u: fetch the certificate chain from the URL (HTTPS only; SSRF guard applies)
    • kid: look up the signing public key from a JWKS endpoint (see JWKS trust below)
  2. For x5c/x5u: validate the certificate chain against the configured trusted_ta_ca_files. For kid: the trust is established by the per-profile trust_jwks_urls list.
  3. Verify the JWT signature using the resolved public key. The public key algorithm must match alg; supported algorithms include ES256/ES384/ES512 (ECDSA), RS256/RS384/RS512/PS256/PS384/PS512 (RSA), EdDSA (Ed25519), and ML-DSA-44/ML-DSA-65/ML-DSA-87.
  4. Confirm exp has not elapsed and exp - now does not exceed max_validity_secs.
  5. Check the atc claim: tktype matches the identifier type, tkvalue matches the identifier value, fingerprint matches the account key thumbprint.
  6. For JWTClaimConstraints: parse the DER-encoded tkvalue and verify that the JWT’s own claims satisfy both mustInclude (all named claims are present) and permittedValues (each constrained claim’s value is in the allowed list).
  7. Record the jti in the replay-prevention store; duplicate JTIs are rejected.

JWKS trust (kid-based tokens)

When the JWT header contains kid instead of x5c/x5u, the server looks up the signing public key from a JWKS endpoint. Trust is configured per profile:

[profiles.providers.local.profiles.my-profile]
description     = "Example profile"
ca_ids          = ["my-ca"]
trust_jwks_urls = [
    "https://idp.example.com/jwks",
    "http+unix://%2Frun%2Fekishib%2Fekishib.sock/jwks",
]

The trust_jwks_urls list is searched in order. The first JWKS that contains a key with a matching kid is used. If no profile has trust_jwks_urls set, kid-signed tokens are rejected.

JWKS responses are cached in memory for 5 minutes. Each URL is refreshed independently when the cache entry expires.

URL schemes:

SchemeUsage
https://...Standard HTTPS fetch; SSRF guard applies (no RFC-1918 targets without explicit configuration)
http+unix://ENCODED_PATH/request-pathHTTP over a Unix domain socket; ENCODED_PATH is the socket file path with / encoded as %2F (e.g. %2Frun%2Fekishib%2Fekishib.sock)

The http+unix:// scheme is intended for co-located Token Authorities (e.g. Ekishib IdP running on the same host) where TLS is unnecessary.

Note: ML-DSA-44/ML-DSA-65/ML-DSA-87 keys are supported in JWKS entries (kty: "AKP"). This allows post-quantum Token Authorities to sign authority tokens without an X.509 certificate chain.

Certificate SAN injection (claim_encoders)

When the server issues a certificate for a JWTClaimConstraints identifier, it can inject Subject Alternative Names derived from the permittedValues of the validated token. This is controlled by the [[tkauth.claim_encoders]] configuration.

Each entry maps a JWT claim name to an encoder that knows how to produce an OtherName SAN:

[[tkauth.claim_encoders]]
claim   = "sub"
encoder = "krb5-kpn"   # Kerberos principal (id-pkinit-san OtherName, OID 1.3.6.1.5.2.2)

[[tkauth.claim_encoders]]
claim   = "upn"
encoder = "ms-upn"     # Microsoft UPN OtherName (OID 1.3.6.1.4.1.311.20.2.3)

[[tkauth.claim_encoders]]
claim   = "dns"
encoder = "dns-san"    # plain dNSName SAN; wildcards are rejected

Built-in encoders:

Encoder nameSAN typeDetails
krb5-kpnOtherName (id-pkinit-san, OID 1.3.6.1.5.2.2)principal@REALM; if @ is absent, default_realm is appended
ms-upnOtherName (OID 1.3.6.1.4.1.311.20.2.3)user@domain
dns-sandNSNamePlain hostname; wildcards rejected; lowercased before injection

A permittedValues entry with exactly one value is injected as a SAN using the matching encoder. Entries with multiple permitted values are skipped (the server cannot determine which specific value the TA attested).

For dns-san, the DNS name from the token constraint is added alongside any DNS SANs already in the CSR. The ACME client must still include the name in its CSR — the encoder only grants permission to inject it from the token, it does not bypass the CSR.

Configuration

[tkauth]
enabled                 = true
trusted_ta_ca_files     = ["/etc/akamu/ta-root.pem"]
token_authority_url     = "https://ta.example.com"   # optional hint surfaced in challenge object
max_validity_secs       = 3600   # reject tokens with exp - now > this value
jti_prune_interval_secs = 3600   # how often to purge expired JTI records from the database

# SAN injection: map JWT claim names to OtherName encoders
[[tkauth.claim_encoders]]
claim   = "sub"
encoder = "krb5-kpn"

Per-profile JWKS trust (set in each profile that should accept kid-signed tokens):

[profiles.providers.local.profiles.kerberos-svc]
description     = "Kerberos service certificate"
ca_ids          = ["kerberos-ca"]
trust_jwks_urls = ["https://idp.example.com/jwks"]

[profiles.providers.local.profiles.ipa-ldap]
description     = "IPA LDAP server certificate"
ca_ids          = ["ipa-ca"]
# Co-located Ekishib IdP via Unix socket
trust_jwks_urls = ["http+unix://%2Frun%2Fekishib%2Fekishib.sock/jwks"]

Certificates

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

Issuance

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

flowchart TD
    A(["POST /acme/order/ID/finalize<br/>csr = base64url DER"]) --> B[Decode + parse CSR]
    B --> C{"Self-signature<br/>valid?"}
    C -->|No| FAIL([400 badCSR])
    C -->|Yes| D{"BasicConstraints<br/>cA=FALSE?"}
    D -->|cA=TRUE found| FAIL
    D -->|OK| E{"SAN set equals<br/>order identifiers?"}
    E -->|Mismatch| FAIL
    E -->|Match| F["Resolve profile parameters<br/>(from requested profile, or server defaults)"]
    F --> AUTH{"Per-profile auth checks<br/>(patterns, hook, grants)"}
    AUTH -->|Denied| UNAUTH([401/403 unauthorized])
    AUTH -->|Permitted| G["Build end-entity certificate<br/>extensions from resolved profile"]
    G --> H[Sign with CA private key]
    H --> MTC{"issue_as = mtc?"}
    MTC -->|Yes| MTCB["Build MTC StandaloneCertificate<br/>DER; Content-Type: application/pkix-cert"]
    MTC -->|No| I["Store DER + PEM bundle<br/>in certificates table"]
    MTCB --> I
    I --> J["Update order: status=valid<br/>certificate_id set"]
    J --> K([Return order with certificate URL])
    K --> L(["Client: GET /acme/cert/ID<br/>Download certificate"])

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

The server:

  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. Applies certificate parameters from the requested profile, or from the server’s default policy if no profile was specified.
  7. Issues the certificate. When the resolved profile has issue_as = "mtc", a Merkle Tree Certificate StandaloneCertificate is built instead of a PEM chain; otherwise, a standard X.509 v3 certificate is issued. The extensions depend on the active profile:
ExtensionCriticalDefault (no profile)With profile
BasicConstraintsNocA=FALSEcA=FALSE
KeyUsageYesdigitalSignatureAs configured in profile
ExtendedKeyUsageNoserverAuthAs configured in profile
SubjectKeyIdentifierNoRFC 7093 §2 Method 1 (SHA-256)RFC 7093 §2 Method 1 (SHA-256)
AuthorityKeyIdentifierNoRFC 7093 §2 Method 1 (SHA-256)RFC 7093 §2 Method 1 (SHA-256)
SubjectAlternativeNameNoRebuilt from validated CSR SANsRebuilt from validated CSR SANs
AuthorityInfoAccess (OCSP)NoIf ocsp_url configuredIf profile or ocsp_url set
CRLDistributionPointsNoIf crl_url configuredIf profile or crl_url set
CertificatePoliciesNoAbsentIf profile includes policies

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

The validity period runs from the moment of issuance for validity_days days (default 90), or the profile-specific validity if a profile is active. See Certificate Profiles for how to configure per-profile extension content, MTC issuance, and per-profile authorization.

Downloading a certificate

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

GET /acme/cert/<cert-id>

No authentication is required. The response format depends on how the certificate was issued:

Standard X.509 certificate (Content-Type: application/pem-certificate-chain):

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

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

MTC StandaloneCertificate (Content-Type: application/pkix-cert):

When the order was finalized with a profile that sets issue_as = "mtc", the endpoint returns the raw DER-encoded StandaloneCertificate (§6.1 of draft-ietf-plants-merkle-tree-certs). The server detects MTC certificates automatically and sets the appropriate Content-Type. See Certificate Profiles — MTC certificate issuance for how to configure an MTC-issuing profile.

Certificate storage

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

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

Revocation

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

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

The payload:

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

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

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

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"
  }
}

Window computation

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

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

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

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

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

RFC 9773 allows an optional explanationURL field pointing to a human-readable page explaining the renewal recommendation. Set ari_explanation_url in [server] to include it:

[server]
ari_explanation_url = "https://acme.example.com/docs/renewal-policy"

When absent, the field is omitted from the response.

Using ARI with certbot

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

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

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

Certificate Profiles

Certificate profiles let Akāmu issue certificates with different extension sets, validity periods, and key usage policies depending on the use case. Without profiles every order gets the same default profile: digitalSignature KeyUsage, serverAuth EKU, and the validity and URL settings from [ca]. With profiles configured, clients can request a named policy at order time and the server enforces it at issuance.

Profiles implement draft-ietf-acme-profiles-01.


How it works

  1. At startup Akāmu loads profile definitions from one or more providers (see below) and caches them in memory.
  2. The directory endpoint advertises the available profiles in meta.profiles.
  3. A client includes "profile": "<name>" in its newOrder request.
  4. At finalize time the server resolves the profile’s CertificateParameters and issues the certificate with those extension values; Akāmu’s own CA always signs.
  5. A background task refreshes the cache every refresh_interval_secs seconds (default: 3600).

If no profile is requested, or no providers are configured, the server falls back to CA defaults unchanged.


Configuration overview

[profiles]
refresh_interval_secs = 3600   # how often to reload from providers (default)

# ── Provider 1: inline TOML definitions ─────────────────────────────────────
[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.tlsserver]
description   = "Standard TLS server certificate"
validity_days = 90
key_usage     = ["digital_signature", "key_encipherment"]
eku           = ["server_auth"]

[profiles.providers.local.profiles.clientauth]
description   = "Client authentication certificate"
validity_days = 365
key_usage     = ["digital_signature"]
eku           = ["client_auth"]

# ── Provider 2: Dogtag PKI profile files ────────────────────────────────────
[profiles.providers.dogtag_prod]
type        = "dogtag"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles    = ["caServerCert", "caIPAserviceCert"]   # empty = all

# ── Provider 3: FreeIPA/IPAThinCA via GSSAPI LDAP ───────────────────────────
[profiles.providers.ipa_prod]
type     = "ipa"
profiles = ["caIPAserviceCert", "IECUserRoles"]

[profiles.providers.ipa_prod.ldap]
uri     = "ldap://ipa.example.com:389"
base_dn = "o=ipaca"
gssapi  = true

Provider types

builtin — inline TOML

Define profiles directly in config.toml. No external system required.

[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.<profile-id>]
description   = "Human-readable description shown in meta.profiles"
validity_days = 90          # optional; inherits from [ca].validity_days
hash_alg      = "sha256"    # optional; inherits from [ca].hash_alg
key_usage     = ["digital_signature"]   # see table below
eku           = ["server_auth"]         # see table below
crl_url       = "http://crl.example.com/ca.crl"   # optional
ocsp_url      = "http://ocsp.example.com"          # optional
allowed_key_types = ["ec:P-256", "rsa:2048"]       # optional; empty = any
issue_as      = "mtc"       # optional; "mtc" or absent/"x509" for standard X.509

# Multi-CA restriction (optional; empty = available via all CAs)
ca_ids               = ["rsa", "ec"]              # restrict to specific CA IDs

# Per-profile authorization (all three checks are AND-combined)
allowed_identifiers  = ['^dns:.*\.example\.com$']  # optional; empty = no restriction
identifier_match     = "all"                        # "all" (default) or "any"
auth_hook            = "/etc/akamu/hooks/auth.sh"  # optional; path to executable
auth_hook_timeout_secs = 30                         # optional; default 30
require_account_grant  = false                      # optional; default false

[[profiles.providers.local.profiles.<profile-id>.certificate_policies]]
oid     = "2.23.140.1.2.1"                         # DV certificate
cps_uri = "https://example.com/cps"               # optional

key_usage names

NameKeyUsage bit
digital_signaturedigitalSignature (bit 0)
non_repudiation / content_commitmentnonRepudiation (bit 1)
key_enciphermentkeyEncipherment (bit 2)
data_enciphermentdataEncipherment (bit 3)
key_agreementkeyAgreement (bit 4)
key_cert_signkeyCertSign (bit 5)
crl_signcRLSign (bit 6)
encipher_onlyencipherOnly (bit 7)
decipher_onlydecipherOnly (bit 8)

eku names and dotted-decimal OIDs

NameOID
server_auth1.3.6.1.5.5.7.3.1
client_auth1.3.6.1.5.5.7.3.2
code_signing1.3.6.1.5.5.7.3.3
email_protection1.3.6.1.5.5.7.3.4
time_stamping1.3.6.1.5.5.7.3.8
ocsp_signing1.3.6.1.5.5.7.3.9
1.2.3.4.5.6raw dotted-decimal OID string

crl_url / ocsp_url three-state semantics

ValueEffect
Absent (key not set)Inherit from [ca].crl_url / [ca].ocsp_url
"" (empty string)Suppress the extension — no CDP / AIA in the certificate
"https://…"Override with the given URL

dogtag — Dogtag PKI profile files

Load profiles from a Dogtag PKI .cfg file directory. Each file is named <profile-id>.cfg and uses the Dogtag Java-properties format.

[profiles.providers.dogtag_prod]
type        = "dogtag"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles    = ["caServerCert", "caIPAserviceCert"]
# profiles = []   # empty = load all .cfg files in the directory
KeyRequiredDescription
profile_dirConditionalPath to directory of .cfg files
ldapConditionalLDAP connection sub-table; see LDAP options under ipa below
profilesNoAllowlist of profile IDs; empty = all

At least one of profile_dir or ldap must be set. When both are configured, ldap takes priority.

# LDAP source — simple bind, TLS via STARTTLS
[profiles.providers.dogtag_ldap]
type     = "dogtag"
profiles = ["caServerCert"]

[profiles.providers.dogtag_ldap.ldap]
uri                = "ldap://dogtag.example.com:389"
base_dn            = "dc=example,dc=com"
bind_dn            = "uid=admin,ou=people,dc=example,dc=com"
bind_password_file = "/etc/akamu/ldap-password"
tls_ca_cert_file   = "/etc/ssl/certs/ldap-ca.pem"   # triggers STARTTLS on ldap:// URIs

# LDAP source — multiple servers, GSSAPI
[profiles.providers.dogtag_ha]
type     = "dogtag"
profiles = ["caServerCert"]

[profiles.providers.dogtag_ha.ldap]
uris    = ["ldap://dogtag1.example.com:389", "ldap://dogtag2.example.com:389"]
base_dn = "dc=example,dc=com"
gssapi  = true

Supported Dogtag policy classes

ClassFields extracted
validityDefaultImplparams.range + params.rangeUnit → validity days
keyUsageExtDefaultImpl9 params.keyUsage* booleans → KeyUsage bitmask
extendedKeyUsageExtDefaultImplparams.exKeyUsageOIDs comma-separated OIDs → EKU list
authInfoAccessExtDefaultImplOCSP URL via method 1.3.6.1.5.5.7.48.1ocsp_url
crlDistributionPointsExtDefaultImplparams.crlDistPointsPointName_0crl_url

Unrecognised policy class IDs are silently skipped.


ipa — FreeIPA / IPAThinCA

Load profiles from a FreeIPA or IPAThinCA instance. Profile .cfg files use the same Dogtag format. The standard location for IPA-embedded Dogtag is /etc/pki/pki-tomcat/ca/profiles/ca on the IPA server, and LDAP profiles are stored at ou=certificateProfiles,ou=ca,o=ipaca — accessible on the standard LDAP ports (389 for plain/STARTTLS, 636 for LDAPS).

# Filesystem source
[profiles.providers.ipa_prod]
type        = "ipa"
profile_dir = "/etc/pki/pki-tomcat/ca/profiles/ca"
profiles    = ["caIPAserviceCert"]

# LDAP source — single server, simple bind
[profiles.providers.ipa_ldap]
type     = "ipa"
profiles = ["caIPAserviceCert"]

[profiles.providers.ipa_ldap.ldap]
uri                = "ldap://ipa.example.com:389"
base_dn            = "o=ipaca"
bind_dn            = "uid=admin,cn=users,cn=accounts,dc=example,dc=com"
bind_password_file = "/etc/akamu/ipa-ldap-password"

# LDAP source — multiple servers (failover list), GSSAPI
[profiles.providers.ipa_ha]
type     = "ipa"
profiles = ["caIPAserviceCert"]

[profiles.providers.ipa_ha.ldap]
uris    = ["ldap://ipa1.example.com:389", "ldap://ipa2.example.com:389"]
base_dn = "o=ipaca"
gssapi  = true

# LDAP source — SRV-based discovery, GSSAPI
[profiles.providers.ipa_gssapi]
type     = "ipa"
profiles = ["caIPAserviceCert"]

[profiles.providers.ipa_gssapi.ldap]
srv_domain = "example.com"   # resolves _ldap._tcp.example.com SRV records
base_dn    = "o=ipaca"
gssapi     = true

LDAP server selection

KeyDescription
uriSingle LDAP URI: ldap://host:port or ldaps://host:port.
urisList of LDAP URIs tried in order for failover.
srv_domainDiscover servers via DNS SRV (_ldap._tcp.{srv_domain}), sorted by RFC 2782 priority/weight. Appended after any explicit uris.

At least one of uri, uris, or srv_domain must be set.

LDAP authentication options

KeyDescription
bind_dnBind DN for simple authentication. Required for simple bind.
bind_password_filePath to a file containing the simple bind password. Required when bind_dn is set.
gssapi = trueUse SASL GSSAPI (Kerberos). Pre-condition: the process must have a valid Kerberos TGT in its credential cache (e.g. obtained via kinit or a system keytab). No explicit credentials are passed to the server.
tls_ca_cert_filePath to a PEM CA certificate for verifying the LDAP server’s TLS certificate. When set on an ldap:// URI, STARTTLS is negotiated automatically before any credentials are sent.

Attribute name lowercasing: the akamu-ldap library normalises all LDAP attribute names to lower case in the returned entries. Profile lookup keys such as cn and certProfileConfig are matched in lower case internally; this is transparent to the operator.


Refresh behaviour

Akāmu loads all providers once at startup and caches the results. A background tokio task wakes every refresh_interval_secs seconds and re-loads all providers, atomically replacing the cache. Certificates being issued concurrently always see a consistent snapshot.

If a refresh fails (e.g., a .cfg file is temporarily unreadable), the previous cache is kept and a warning is logged. The server never stops serving because of a failed refresh.

The refresh task exits automatically when the server shuts down (it holds a weak reference to the registry).

[profiles]
refresh_interval_secs = 1800   # refresh every 30 minutes instead of 1 hour

Precedence when multiple providers list the same profile

If two providers both export a profile with the same ID, the first provider listed in config.toml wins. The second is silently ignored. This is determined by HashMap iteration order over [profiles.providers], which is non-deterministic in TOML. To avoid ambiguity, give each profile a unique ID across providers, or use a single canonical provider.


Requesting a profile from an ACME client

Include "profile" in the newOrder payload:

{
  "identifiers": [{ "type": "dns", "value": "example.com" }],
  "profile": "tlsserver"
}

The server:

  1. Records the profile name on the order.
  2. Validates the profile name at finalize time (rejects with invalidProfile if no longer loaded).
  3. Runs per-profile authorization checks (see below).
  4. Issues the certificate using the profile’s CertificateParameters.

The profile name is echoed back in every order response:

{
  "status": "valid",
  "profile": "tlsserver",
  "certificate": "https://acme.example.com/acme/cert/…"
}

CA restriction (ca_ids)

When ca_ids is set on a builtin profile, the profile is only offered via the ACME endpoints of the listed CAs. Requests for this profile via any other CA’s endpoint receive urn:ietf:params:acme:error:invalidProfile at finalize time.

[profiles.providers.local.profiles.rsa-only]
description = "Certificate restricted to the RSA CA"
ca_ids      = ["rsa"]

Config validation rejects ca_ids entries that do not match any configured CA id. When ca_ids is empty (the default), the profile is available via all configured CAs.

Per-profile authorization

Three independent checks are applied at finalize time. All configured checks must pass (AND logic) for issuance to proceed. Checks that are not configured are skipped.

1. Identifier patterns

allowed_identifiers is a list of regular expressions. Each order identifier is formatted as "type:value" (e.g. "dns:example.com", "dns:*.example.com") before being tested against the patterns.

[profiles.providers.local.profiles.internal]
description         = "Internal services only"
allowed_identifiers = ['^dns:.*\.internal\.example\.com$', '^dns:internal\.example\.com$']
identifier_match    = "all"   # "all" (default) or "any"

identifier_match controls how the patterns are applied:

ValueBehaviour
"all" (default)Every identifier in the order must match at least one pattern.
"any"At least one identifier must match at least one pattern; the others are unrestricted.

When allowed_identifiers is empty (the default), no identifier restriction is applied.

An invalid regular expression in allowed_identifiers causes the finalize request to fail with invalidProfile.

2. External authorization hook

auth_hook is a path to an executable. The server spawns it at finalize time and sends a JSON object on stdin:

{
  "account_id": "abc123",
  "profile": "internal",
  "identifiers": [
    { "type": "dns", "value": "svc.internal.example.com" }
  ]
}
  • Exit code 0: issuance proceeds.
  • Non-zero exit code: issuance is denied. The hook’s standard output (trimmed) is forwarded to the ACME client as the denial reason.
  • Standard error is discarded.

auth_hook_timeout_secs (default: 30) sets the maximum time the server waits for the hook to exit. If the hook times out, issuance is denied.

[profiles.providers.local.profiles.internal]
description            = "Internal services"
auth_hook              = "/etc/akamu/hooks/check-service.sh"
auth_hook_timeout_secs = 10

3. Account grants

require_account_grant = true means the account requesting the order must have this profile’s name in its profile_grants attribute.

[profiles.providers.local.profiles.privileged]
description           = "Privileged cert profile"
require_account_grant = true

Grants are managed two ways:

  • Admin API: PUT /admin/account/{id}/profile-grants with body {"profile_grants":["privileged"]}. See Admin API below.
  • EAB key inheritance: when an EAB key is provisioned with profile_grants, those grants are automatically copied to any account created using that key.

An account whose profile_grants is NULL (the default) is considered to have no grants. When require_account_grant is true, such an account is denied.


MTC certificate issuance (issue_as = "mtc")

A builtin profile can issue a Merkle Tree Certificate (MTC) StandaloneCertificate instead of a standard X.509 PEM chain by setting issue_as = "mtc":

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

[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"

[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.mtc-tls]
description = "MTC TLS certificate"
validity_days = 90
key_usage   = ["digital_signature"]
eku         = ["server_auth"]
issue_as    = "mtc"

When issue_as = "mtc":

  1. The finalize handler issues the certificate as usual (X.509 TBSCertificate).
  2. The certificate is appended to the MTC log synchronously during finalization; the resulting leaf index is stored in the database.
  3. A StandaloneCertificate (per §6.1 of draft-ietf-plants-merkle-tree-certs) is built from the TBSCertificate, a Merkle inclusion proof, and a signature from the MTC signing key.
  4. The raw DER-encoded StandaloneCertificate is stored in the database and served at the certificate download URL with Content-Type: application/pkix-cert.

Requirements:

  • [mtc] must be configured and enabled = true.
  • [mtc.signing_key] must be configured (the standalone certificate requires a signature).
  • If either condition is not met, finalization returns invalidProfile.

The download endpoint auto-detects MTC certificates by their PEM marker and switches the response Content-Type accordingly:

Certificate typeContent-Type
Standard X.509 chainapplication/pem-certificate-chain
MTC StandaloneCertificateapplication/pkix-cert

Admin API

The admin API is enabled by adding an [admin] section to config.toml with a listen_addr and at least one of ca_certs (for mTLS client certificates) or [admin.gssapi] (for Kerberos). See Configuration Reference — [admin] for all configuration keys.

Operators authenticate via mTLS client certificate or GSSAPI/Kerberos session token. A successful login returns a session_token that is passed as Authorization: Bearer <token> on subsequent requests. Each endpoint enforces a role-based access policy; see Admin API — Endpoint reference for the full role matrix.

When [admin] is absent from the configuration, the admin listener is not started and all admin endpoints are unreachable.

Account profile grants

GET /admin/account/{id}/profile-grants

Returns the current grants for the account:

{ "profile_grants": ["p1", "p2"] }

Returns {"profile_grants": null} when the account has no grants. Returns 404 when the account is not found.

PUT /admin/account/{id}/profile-grants

Replace the account’s grants entirely. Body:

{ "profile_grants": ["p1", "p2"] }

Send {"profile_grants": null} or {"profile_grants": []} to clear all grants (equivalent to NULL — account may use any profile). Returns 204 on success, 404 when not found.

DELETE /admin/account/{id}/profile-grants

Clear all grants for the account (set to NULL). Returns 204 on success, 404 when not found.

EAB key provisioning

POST /admin/eab

Provision a new EAB key with optional profile grants:

{
  "kid": "key-id-1",
  "hmac_key_b64u": "<base64url-encoded-HMAC-key>",
  "profile_grants": ["p1", "p2"]
}

profile_grants is optional; omit it or pass null for no restriction. When present, any account created with this EAB key will automatically inherit these grants at account creation time.

Returns 201 with {"kid": "key-id-1", "created": <unix-epoch>} on success. Returns 409 when the kid already exists.

To generate a suitable HMAC key:

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

S/MIME profile example

To issue S/MIME end-user certificates via the RFC 8823 email-reply-00 challenge, configure a profile with email_protection EKU and restrict it to email identifiers. Combine with [email_challenge] in the server configuration.

[email_challenge]
enabled             = true
from_address        = "acme-validation@example.com"
send_script         = "/etc/akamu/send-email.sh"
webhook_hmac_secret = "replace-with-output-of--openssl-rand-hex-32"

[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.smime]
description          = "S/MIME end-user certificate (RFC 8823)"
key_usage            = ["digital_signature", "non_repudiation", "key_encipherment"]
eku                  = ["email_protection"]
allowed_identifiers  = ['^email:.*$']
validity_days        = 365

The allowed_identifiers pattern '^email:.*$' restricts this profile to email identifier orders; it is rejected for DNS/IP orders. The non_repudiation bit is optional but commonly included for S/MIME signing certificates per CA/Browser Forum S/MIME Baseline Requirements.

The CSR submitted at finalize time must include an rfc822Name SAN and the emailProtection EKU. The server validates both before issuing. See email-reply-00 in the Challenges reference for the complete protocol.


Legacy [server.profiles]

Prior to the [profiles] subsystem, profile names were declared as a flat string map under [server]:

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

This still works for advertising profile names in the directory (the meta.profiles field). However, the map is a pure label registry — no actual certificate parameters are loaded from it, and any profile name is accepted at order time (no enforcement of key usage or EKU). Use the new [profiles] section for real per-profile issuance policy.

When [profiles] providers are configured, meta.profiles is populated from the registry; [server.profiles] is ignored.

CRL and OCSP

Akāmu supports both Certificate Revocation List (CRL) and Online Certificate Status Protocol (OCSP) to communicate revocation status to relying parties. Both protocols are served directly by Akāmu at built-in endpoints.

CRL — GET /ca/{ca_id}/crl and GET /ca/crl

Akāmu generates and serves a signed v2 CRL (RFC 5280) for each configured CA. In multi-CA deployments each CA has its own CRL endpoint:

GET /ca/{ca_id}/crl   # per-CA CRL
GET /ca/crl           # backward-compatible alias → default CA

The CRL is built on each request from the current revocation database. No caching or pre-generation is required for typical issuance volumes. The response uses Content-Type: application/pkix-crl.

Configuring the CRL URL

Set crl_url in each [[ca]] (or [ca]) entry to the public URL of that CA’s CRL endpoint. This URL is embedded in every issued end-entity certificate in the CRLDistributionPoints extension.

For a single-CA deployment the URL is typically the /ca/crl alias:

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

For multi-CA deployments use the per-CA path so each CA’s certificates point to the correct revocation list:

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

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

Clients that check CRL status fetch this URL and verify the certificate’s serial number against the revocation list.

CRL validity window

The nextUpdate field in the CRL is set to the current time plus crl_next_update_secs (default: 86400 seconds, i.e. one day):

[ca]
crl_next_update_secs = 86400   # one day (default)

Adjust this value to match how frequently clients are expected to re-fetch the CRL.

What a CRL contains

Each CRL entry carries the certificate’s serial number, the revocation timestamp, and the reason code (if one was provided at revocation time). The CRL also includes a cRLNumber extension (RFC 5280 §5.2.3) derived from the current Unix timestamp.

CRL reason codes

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

Verifying the CRL manually

curl http://acme.example.com/ca/crl | openssl crl -inform DER -text -noout

OCSP — GET /ca/{ca_id}/ocsp/{request} and POST /ca/{ca_id}/ocsp

Akāmu includes a built-in OCSP responder (RFC 6960) for each configured CA. In multi-CA deployments each CA has its own OCSP endpoint:

POST /ca/{ca_id}/ocsp                  # per-CA OCSP (POST)
GET  /ca/{ca_id}/ocsp/{request}        # per-CA OCSP (GET)
POST /ca/ocsp                          # backward-compatible alias → default CA
GET  /ca/ocsp/{request}                # backward-compatible alias → default CA

The {request} path segment is the base64url-encoded DER OCSPRequest (RFC 6960 §A.1). Both endpoints return a signed OCSPResponse with Content-Type: application/ocsp-response. No authentication is required.

Both endpoints return a signed OCSPResponse with Content-Type: application/ocsp-response. No authentication is required — OCSP is a public protocol.

Configuring the OCSP URL

Set ocsp_url in each [[ca]] (or [ca]) entry to the public base URL of that CA’s OCSP endpoint. This URL is embedded in every issued end-entity certificate in the AuthorityInfoAccess extension.

For a single-CA deployment:

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

For multi-CA deployments use the per-CA path:

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

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

Clients sending GET requests append the base64url-encoded request to this URL. Clients sending POST requests target the URL directly.

OCSP response behaviour

For each serial number in an OCSPRequest:

DB stateOCSP CertStatus
Certificate not foundunknown (2)
Certificate found, status = "revoked"revoked (1)
Certificate found, any other statusgood (0)

The response is signed with the CA key. The responder identity is set to byName using the CA’s subject DER.

The nextUpdate field in each SingleResponse is fixed at 24 hours after the response is produced.

Verifying OCSP manually

openssl ocsp -issuer ca.pem -cert issued.pem -url http://acme.example.com/ca/ocsp -text

Cross-certificates — GET /ca/{ca_id}/cross-certs

When cross-signing is used, the resulting cross-certificates are available at a public (unauthenticated) endpoint:

GET /ca/{ca_id}/cross-certs

This returns a JSON list of cross-certificates where {ca_id} is the subject (the CA whose public key was cross-signed). Relying parties and ACME clients can use this endpoint to discover cross-certificates for path building.

{
  "cross_certs": [
    {
      "id": "a1b2c3d4-…",
      "issuer_ca_id": "rsa",
      "not_before": "2026-05-06T12:00:00Z",
      "not_after": "2031-05-06T12:00:00Z"
    }
  ]
}

To download the cross-certificate PEM, use the admin API: GET /admin/cross-certs/{id} (see Admin API — CA management endpoints) or akamuctl cross-cert download <id>.

Checking revocation status from the database

To verify whether a specific certificate is currently marked as revoked in Akāmu’s database, query the certificates table by serial number. The serial number is printed in hex by most certificate inspection tools (for example, openssl x509 -serial -noout -in cert.pem):

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

A status value of revoked indicates the certificate has been revoked. revoked_at is a Unix timestamp of when revocation occurred, and revocation_reason is the numeric CRL reason code (or NULL if no reason was specified).

Merkle Tree Certificate Log

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

What is an MTC log?

A Merkle Tree Certificate log is a tamper-evident, append-only data structure. Each leaf encodes an issued certificate in a way that allows efficient proofs of inclusion and consistency that third parties can verify independently.

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

Configuration

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

When enabled = true:

  • On startup, the server opens the existing log file at log_path, or creates a new one if the file does not exist.
  • After each successful certificate issuance, the certificate is appended to the log. The append happens in a background task so it does not delay the issuance response.
  • The resulting leaf index is stored in the certificates database table. If the append fails, a warning is logged but the certificate issuance response is not affected; the log index will be NULL for that certificate.

When enabled = false (the default):

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

Issuing MTC certificates directly from a profile

When [mtc] is enabled and [mtc.signing_key] is configured, a builtin certificate profile can be set to issue a Merkle Tree Certificate StandaloneCertificate instead of a standard X.509 PEM chain. Set issue_as = "mtc" on the profile:

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

[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"
key_type = "ec:P-256"

[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.mtc-tls]
description = "MTC TLS certificate"
validity_days = 90
key_usage   = ["digital_signature"]
eku         = ["server_auth"]
issue_as    = "mtc"

When a client finalizes an order with this profile, the finalize handler:

  1. Issues the X.509 TBSCertificate as usual.
  2. Appends the certificate to the MTC log synchronously (not in a background task, because the leaf index is needed immediately for the standalone certificate).
  3. Builds a StandaloneCertificate embedding the TBSCertificate, a Merkle inclusion proof, and a signature from the MTC signing key.
  4. Stores the DER-encoded StandaloneCertificate and returns the certificate URL to the client.

The certificate download endpoint (GET /acme/cert/{id}) detects MTC certificates and serves them as raw DER with Content-Type: application/pkix-cert.

If [mtc] is not enabled or [mtc.signing_key] is absent when a profile with issue_as = "mtc" is finalized, the server returns invalidProfile.

See Certificate Profiles — MTC certificate issuance for the full configuration reference.

Checkpoint signing

To enable periodic checkpoint production, add a [mtc.signing_key] section. The signing key must be distinct from the X.509 CA key (§5.5 of the MTC draft).

[mtc]
log_path                 = "/var/lib/akamu/mtc.log"
enabled                  = true
checkpoint_interval_secs = 3600    # default: 3600 (1 hour)
landmark_interval_secs   = 86400   # default: 86400 (1 day)
max_active_landmarks     = 100     # default: 100
hash_alg                 = "sha256"  # leaf hash algorithm: sha256 | sha384 | sha512 | sha3-256 | sha3-384 | sha3-512
log_number               = 1        # serial encoding: (log_number << 48) | entry_index
# tree_minimum_index     = 0        # §5.2.3 log pruning; absent = no pruning
# trust_anchor_id        = "1.3.6.1.4.1.44363.47.10.1"  # CA self-cosigner OID (§5.4)

[mtc.signing_key]
key_file = "/var/lib/akamu/mtc-signing.key"   # auto-generated if absent
key_type = "ec:P-256"                          # same values as [ca].key_type
hash_alg = "sha256"                            # sha256 | sha384 | sha512

Supported key_type values are the same set accepted for the CA key: ec:P-256, ec:P-384, ec:P-521, rsa:2048rsa:4096, ed25519, ed448, ml-dsa-44, ml-dsa-65, ml-dsa-87. Per §5.4.2 of the draft, only ECDSA P-256/P-384, Ed25519, and ML-DSA are listed as valid cosigner signature algorithms; prefer EC or EdDSA for the MTC signing key.

When [mtc.signing_key] is present:

  • At startup the server reads the PEM file at key_file, or auto-generates a new key of key_type and writes it there.
  • A background task fires every checkpoint_interval_secs seconds. If the log has grown since the last checkpoint, it computes the Merkle root, constructs a signed checkpoint, and stores it in the database.
  • Checkpoints are idempotent: if the tree size has not grown the task is a no-op.

When [mtc.signing_key] is absent, checkpoint production is disabled.

External cosigners

After each checkpoint, Akāmu can POST the checkpoint to external cosigner servers and embed their signatures in each StandaloneCertificate.

[[mtc.cosigners]]
url                  = "https://cosigner.example.com/sign"
cosigner_id_cert_pem = "/etc/akamu/cosigner1.pem"  # optional; path to cosigner X.509 cert PEM
trust_anchor_id      = "1.3.6.1.4.1.44363.47.10.1" # optional; expected TrustAnchorID OID

Multiple [[mtc.cosigners]] entries are supported. For each entry:

  • Akāmu POSTs the DER-encoded checkpoint with Content-Type: application/octet-stream.
  • The cosigner returns a DER-encoded signature with HTTP 200.
  • Each request has a 30-second per-cosigner timeout.
  • Failures are logged and skipped — partial success is acceptable; the standalone certificate is still built with whatever signatures arrive.

When cosigner_id_cert_pem is set, the PEM file is loaded at startup and added to the TLS trust store for that cosigner’s HTTPS connection, in addition to the system root CAs. The certificate’s public key is also used for cryptographic verification of received SubtreeSignature values. This allows cosigners whose TLS certificate chains to an operator-provisioned CA to be used without installing that CA system-wide.

When trust_anchor_id is set, the SubtreeSignature.cosigner OID in each response is compared against this value. Per draft-ietf-plants-merkle-tree-certs-04 §4.1, CosignerID is TrustAnchorID ::= OBJECT IDENTIFIER; a mismatch causes the signature to be rejected.

Security constraint: Setting trust_anchor_id without also setting cosigner_id_cert_pem is a hard startup error. OID-only verification provides no cryptographic assurance — anyone who knows the OID could forge a cosignature. Both fields must be set together to enable verified cosignature acceptance. When neither field is set, cosignatures are accepted without any verification and a warning is logged at startup.

Querying the log index

To find which MTC log slot a certificate occupies, query the database:

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

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

HTTP API

The following read-only endpoints expose the log state. All return 404 when MTC is disabled (enabled = false).

GET /acme/mtc/tree-size

Returns the current number of leaves in the log.

{ "treeSize": 42 }

GET /acme/mtc/root

Returns the current tree size and the Merkle root hash as a lowercase hex string.

{ "treeSize": 42, "rootHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }

GET /acme/mtc/inclusion-proof/{cert_id}

Returns a Merkle inclusion proof for the certificate identified by cert_id (the internal UUID stored in the certificates table). Returns 404 if the certificate does not exist or has no log index.

{
  "leafIndex": 7,
  "treeSize": 42,
  "proof": [
    { "hash": "a1b2c3..." },
    { "hash": "d4e5f6..." }
  ]
}

Each element of proof is an object with a single "hash" field containing the sibling hash as a lowercase hex string. The proof is ordered from the leaf up to the root. The sibling position (left or right) is determined algorithmically from the leaf index and tree size, following the standard RFC 6962 Merkle audit proof construction; it is not encoded in the response.

GET /acme/mtc/cert/{cert_id}/standalone

Returns the DER-encoded standalone certificate (§6.1 of the MTC draft) for the given certificate, with Content-Type: application/octet-stream.

The standalone certificate embeds the certificate’s TBS data, a Merkle inclusion proof, and a signature from the MTC signing key. Relying parties can verify the certificate’s presence in the log without querying the CA.

Returns 404 when:

  • MTC is disabled
  • The certificate does not exist
  • The certificate has no MTC log index (the log append failed at issuance)
  • A checkpoint covering the certificate has not yet been produced (the standalone certificate is built during the next checkpoint cycle)

GET /acme/mtc/landmarks

Returns a JSON array of all allocated landmarks, ordered by sequence number ascending.

[
  { "sequenceNo": 0, "treeSize": 100, "createdAt": 1700000000 },
  { "sequenceNo": 1, "treeSize": 250, "createdAt": 1700086400 }
]

Returns 404 when MTC is disabled.

GET /acme/mtc/landmarks/{seq}/cert

Returns the DER-encoded landmark certificate (§6.3.1 of the MTC draft) for the landmark with sequence number seq, with Content-Type: application/octet-stream.

Returns 404 when:

  • MTC is disabled
  • No landmark with that sequence number exists
  • The landmark certificate has not yet been built

GET /acme/mtc/consistency-proof?from={old_size}&to={new_size}

Returns the Merkle roots at two tree sizes so a monitor can verify that the tree at to extends the tree at from.

{
  "fromSize": 10,
  "toSize": 42,
  "fromRoot": "a1b2c3...",
  "toRoot": "d4e5f6..."
}

Both from and to must be positive integers with from < to and to <= current tree size. Returns 400 for invalid parameters.

GET /acme/mtc/subtree-root?start={start}&end={end}

Returns the Merkle root hash for the subtree [start, end). The subtree must satisfy the alignment constraint from draft-04 §4.3.1 (start is a multiple of BIT_CEIL(end - start)).

{
  "start": 0,
  "end": 256,
  "rootHash": "e3b0c442..."
}

GET /acme/mtc/revoked-ranges

Returns a JSON array of [start, end] pairs representing revoked log entry index ranges (draft-04 §5.6). Relying parties use these to reject standalone certificates whose serial number falls within a revoked range.

[[10, 15], [100, 120]]

Returns 404 when MTC is disabled.

C2SP tlog-tiles API

When [mtc.signing_key] is configured, three additional endpoints implement the C2SP tlog-tiles and C2SP signed-note specifications, enabling compatibility with transparency-log clients that speak the tlog-tiles protocol.

All three endpoints return 404 when MTC is disabled. GET /acme/mtc/tlog/checkpoint and GET /acme/mtc/tlog/cosignature additionally require a signing key to be configured; without one they return 503.

GET /acme/mtc/tlog/checkpoint

Returns the current tree as a C2SP signed-note checkpoint signed by the MTC signing key acting as the primary log operator.

  • Ed25519 key: signature type 0x01.
  • ECDSA key: signature type 0x02.

Response Content-Type is text/plain; charset=utf-8. The note body format is:

<log origin>
<tree_size>
<base64(root_hash)>

— <key_name> <base64(key_id || signature)>

GET /acme/mtc/tlog/tile/{*path}

Serves a C2SP hash tile. The path component encodes {level}/{tile_index_path}[.p/{width}]:

  • level is 0 for leaf-hash tiles, or L > 0 for Merkle subtree roots (covering 256^L leaves each).
  • tile_index_path is the C2SP multi-level decimal encoding (e.g. 000, x001/234).
  • The optional .p/{width} suffix requests a partial tile with fewer than 256 entries.

Response Content-Type is application/octet-stream; each hash entry is 32, 48, or 64 bytes depending on the [mtc].hash_alg configured for the log (SHA-256/SHA3-256, SHA-384/SHA3-384, or SHA-512/SHA3-512 respectively).

Returns 404 when the tile is entirely beyond the current log size. Returns 501 for tile/entries/... paths because Akāmu stores only leaf hashes, not raw entry data.

GET /acme/mtc/tlog/cosignature

Returns a C2SP cosignature note for the current checkpoint, produced by the MTC signing key acting as a cosigner. The current POSIX timestamp is embedded in the signature blob.

  • Ed25519 key: cosignature type 0x04 (cosignature/v1 signed-note format).
  • ML-DSA-44 key: cosignature type 0x06 (subtree/v1 binary cosigned message).
  • ECDSA key: uses the operator format (type 0x02) because no dedicated ECDSA cosignature type is defined by C2SP.

Response Content-Type is text/plain; charset=utf-8.

Landmark management

A landmark is a frozen snapshot of the tree size at a point in time. Relying parties use landmarks to anchor inclusion proofs across the log’s lifetime without tracking every checkpoint.

When [mtc.signing_key] is configured, a background task fires every landmark_interval_secs seconds (default: 86400 = 1 day). If the tree has grown since the last landmark, a new landmark is built and stored in the database. Rows beyond max_active_landmarks (default: 100) are pruned automatically, removing the oldest landmarks by sequence number.

Log integrity

The log is append-only by design. Once a leaf is appended it cannot be removed or modified without corrupting the file. A single Akāmu process is the exclusive writer. At startup, Akāmu acquires an exclusive advisory lock on <log_path>.lock; if another process already holds the lock the server exits immediately with a clear error rather than proceeding to corrupt the log.

For details on the internal log format, appending algorithm, checkpoint production, and concurrency model, see MTC Implementation in the Developer Guide.

MTC Cosigner Daemon

akamu-cosigner is a standalone binary that acts as an external MTC (Merkle Tree Certificate) cosigner. When the main akamu server produces a checkpoint, it POSTs the DER-encoded Checkpoint to each configured cosigner URL. akamu-cosigner signs the checkpoint with its own key and returns a DER-encoded SubtreeSignature. The signature is then embedded in every StandaloneCertificate produced from that checkpoint.

Operators who run an independent MTC log can expose akamu-cosigner as a public cosigning service. Operators who want additional signatures on their own log can run one or more cosigner instances as part of their infrastructure.

Binary

Build from source:

cargo build -p akamu-cosigner --release

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

Run:

akamu-cosigner /etc/akamu/cosigner.toml

If no argument is given, the daemon looks for cosigner.toml in the current working directory.

Configuration file

akamu-cosigner reads a single TOML configuration file. All sections are described below.

Complete example

[server]
listen_addr = "0.0.0.0:8080"
base_url    = "https://cosigner.example.com"

[tls]
cert_file = "/etc/akamu/cosigner-tls.crt"
key_file  = "/etc/akamu/cosigner-tls.key"

[signing_key]
key_file = "/var/lib/akamu/cosigner-signing.key"
key_type = "ec:P-256"
hash_alg = "sha256"

[cosigner_id]
cert_file       = "/var/lib/akamu/cosigner-id.crt"
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"

[acme_bootstrap]
server_url    = "https://acme.example.com/acme/directory"
account_email = "ops@example.com"
eab_kid       = "my-eab-key-id"
eab_hmac      = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg"
domain        = "cosigner.example.com"
challenge_type = "http-01"
cert_file     = "/var/lib/akamu/cosigner-tls.crt"
key_file      = "/var/lib/akamu/cosigner-tls.key"

[server]

listen_addr

Optional. Default: "0.0.0.0:8080".

Address the daemon binds to. Two forms are accepted:

FormExampleDescription
"host:port""0.0.0.0:8080"TCP socket
"unix:/path" or "/path""unix:/run/akamu/akamu-cosigner.sock"Unix domain socket
[server]
listen_addr = "0.0.0.0:8443"

# Or, for a Unix domain socket behind a reverse proxy:
# listen_addr = "unix:/run/akamu/akamu-cosigner.sock"

The AKAMU_COSIGNER_LISTEN environment variable overrides this field:

AKAMU_COSIGNER_LISTEN=unix:/run/akamu/akamu-cosigner.sock akamu-cosigner /etc/akamu/cosigner.toml

Constraint: Unix domain sockets and [tls] are mutually exclusive. When listen_addr is a Unix path and a [tls] section is present (or [acme_bootstrap] is configured, which implies TLS), the daemon exits at startup with an error.

Systemd socket activation: The provided akamu-cosigner.socket unit pre-binds /run/akamu/akamu-cosigner.sock (mode 0660, user/group akamu-cosigner). Enable with systemctl enable --now akamu-cosigner.socket akamu-cosigner.service. When socket activation is active, listen_addr in the config file is ignored — the pre-bound socket is passed via LISTEN_FDS.

base_url

Required.

Public HTTPS base URL of the cosigner. Used as the dNSName SAN when auto-generating the self-signed cosigner-id certificate.

[server]
base_url = "https://cosigner.example.com"

[tls]

Optional. When present, the daemon serves HTTPS using the given certificate and key. When absent, the daemon listens on plain HTTP — only suitable behind a TLS-terminating reverse proxy.

When [acme_bootstrap] is configured and the [tls] section is absent, the daemon derives TLS configuration from the ACME-issued certificate and key paths specified in [acme_bootstrap].

cert_file

Required within [tls]. Path to the TLS server certificate PEM file (leaf + chain).

key_file

Required within [tls]. Path to the TLS server private key PEM file.

[tls]
cert_file = "/etc/akamu/cosigner-tls.crt"
key_file  = "/etc/akamu/cosigner-tls.key"

[signing_key]

The MTC signing key. This key must be distinct from any TLS certificate key. Its public half is embedded in the cosigner-id certificate so that relying parties can verify the SubtreeSignature.

key_file

Required. Path to the signing key PEM file. If the file does not exist, a new key of key_type is generated and written here on startup.

key_type

Optional. Default: "ec:P-256".

Key algorithm for auto-generation. Accepts the same values as [[ca]].key_type in akamu: "ec:P-256", "ec:P-384", "ec:P-521", "rsa:2048", "rsa:3072", "rsa:4096", "ed25519", "ed448".

hash_alg

Optional. Default: "sha256".

Hash algorithm for ECDSA/RSA signing: "sha256", "sha384", "sha512". Ignored for EdDSA keys.

[signing_key]
key_file = "/var/lib/akamu/cosigner-signing.key"
key_type = "ec:P-256"
hash_alg = "sha256"

[cosigner_id]

The cosigner identity configuration. Per draft-ietf-plants-merkle-tree-certs-04 §4.1, CosignerID is now TrustAnchorID ::= OBJECT IDENTIFIER. The trust_anchor_id OID is embedded in every SubtreeSignature.cosigner field so that akamu servers and relying parties can identify which cosigner produced the signature. The certificate file is retained for cryptographic verification by relying parties.

cert_file

Required. Path to the cosigner-id PEM certificate file.

  • If the file exists, it is loaded as the identity certificate.
  • If the file is absent and [acme_bootstrap] is configured and the bootstrap certificate exists, that certificate is used as the cosigner-id.
  • Otherwise, a self-signed certificate is auto-generated from the [signing_key] and written to this path. The self-signed cert uses base_url’s hostname as its dNSName SAN and has 10-year validity.

trust_anchor_id

Required. The OID (dotted-decimal) that identifies this cosigner as a TrustAnchorID. This value is embedded in every SubtreeSignature.cosigner field. Operators must agree on this OID with log operators before deploying. Example: "1.3.6.1.4.1.44363.47.10.1".

[cosigner_id]
cert_file       = "/var/lib/akamu/cosigner-id.crt"
trust_anchor_id = "1.3.6.1.4.1.44363.47.10.1"

[acme_bootstrap]

Optional. When present, akamu-cosigner uses akamu-client to obtain a certificate from the configured ACME server at startup (if the certificate is absent or expiring within 30 days). The issued certificate is used as both the TLS server certificate and the source of the cosigner-id.

server_url

Required. ACME server directory URL, e.g. "https://acme.example.com/acme/directory".

account_email

Optional. Contact e-mail for the ACME account. The mailto: prefix is added automatically.

eab_kid

Required. External Account Binding key identifier provisioned by the CA.

eab_hmac

Required. EAB HMAC key, base64url-encoded without padding.

domain

Required. DNS name to certify. Must be publicly resolvable for the chosen challenge type.

challenge_type

Optional. Default: "http-01".

ACME challenge type to use: "http-01", "dns-01", "dns-persist-01", or "tls-alpn-01".

dns_hook

Optional. Shell command invoked for dns-01 DNS provisioning. Called with ACME_DOMAIN and ACME_TXT_VALUE environment variables set. An exit code of 0 indicates the record was provisioned. When absent and challenge_type = "dns-01", the daemon logs the required TXT record and exits with an error — the operator must set the record manually and restart.

dns_persist_hook

Optional. Shell command invoked for dns-persist-01 DNS provisioning. Called with the following environment variables:

VariableValue
ACME_DOMAINThe domain being certified
ACME_TXT_NAMEFull record name, e.g. _validation-persist.example.com
ACME_TXT_VALUEFull record value, e.g. "acme.example.com; accounturi=https://…"
ACME_ACCOUNT_URIThe ACME account URI
ACME_ISSUER_DOMAINThe issuer domain from the challenge

An exit code of 0 indicates the record was provisioned.

cert_file

Required. Where to write the issued certificate PEM chain.

key_file

Required. Where to write the private key PEM for the issued certificate.

csr_key_type

Optional. Default: "ec:P-256".

Key type for the ACME CSR key. Accepts the same values as [signing_key].key_type.

[acme_bootstrap]
server_url     = "https://acme.example.com/acme/directory"
account_email  = "ops@example.com"
eab_kid        = "my-eab-key-id"
eab_hmac       = "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg"
domain         = "cosigner.example.com"
challenge_type = "http-01"
cert_file      = "/var/lib/akamu/cosigner-tls.crt"
key_file       = "/var/lib/akamu/cosigner-tls.key"
csr_key_type   = "ec:P-256"

HTTP endpoints

akamu-cosigner exposes the following endpoints:

POST /sign

Accepts a DER-encoded Checkpoint (Content-Type: application/octet-stream). Returns a DER-encoded SubtreeSignature with HTTP 200.

The SubtreeSignature covers the full checkpoint range [0, tree_size) and is signed with the [signing_key]. The cosigner field in the response contains the TrustAnchorID OID configured in [cosigner_id].trust_anchor_id.

GET /.well-known/acme-challenge/:token

Serves http-01 challenge tokens during the ACME bootstrap phase. Only active while the bootstrap flow is running; this endpoint returns 404 at all other times.


Integrating with akamu

Add a [[mtc.cosigners]] entry in the akamu server configuration for each akamu-cosigner instance:

[[mtc.cosigners]]
url                  = "https://cosigner.example.com/sign"
cosigner_id_cert_pem = "/etc/akamu/cosigner-id.crt"
trust_anchor_id      = "1.3.6.1.4.1.44363.47.10.1"   # optional; must match cosigner's [cosigner_id].trust_anchor_id

See Configuration Reference — [[mtc.cosigners]] for the full field reference.


Startup sequence

On each startup, akamu-cosigner:

  1. Loads and validates the configuration file.
  2. Loads or generates the [signing_key].
  3. Runs the ACME bootstrap if [acme_bootstrap] is configured and the certificate is absent or expiring within 30 days.
  4. Loads or generates the cosigner-id certificate at [cosigner_id].cert_file.
  5. Starts the HTTP (or HTTPS) server.

The daemon does not persist any state other than the key and certificate files on disk.

Logging

akamu-cosigner uses the tracing crate. Control log verbosity with the RUST_LOG environment variable:

RUST_LOG=info  akamu-cosigner /etc/akamu/cosigner.toml
RUST_LOG=debug akamu-cosigner /etc/akamu/cosigner.toml

Admin interface

akamu-cosigner includes its own admin HTTP listener. When the [admin] section is present in the configuration file, the daemon starts a second HTTPS server for operator access. The admin listener is independent of the signing endpoint and supports the same mTLS and session-token authentication model as the main akamu server.

When the [admin] section is absent, all admin endpoints return 401 Unauthorized and a warning is logged at startup.

Configuration

Add an [admin] section and one or more [[admin.operators]] entries to cosigner.toml:

[admin]
listen_addr    = "127.0.0.1:9444"
cert_file      = "/etc/akamu/cosigner-admin-tls.pem"
key_file       = "/etc/akamu/cosigner-admin-tls.key"
ca_certs       = ["/etc/akamu/operator-ca.pem"]
session_ttl_secs = 3600

[[admin.operators]]
name             = "alice"
role             = "administrator"
cert_fingerprint = "a3b4c5…"    # SHA-256 hex of the DER leaf cert

[[admin.operators]]
name             = "bob"
role             = "auditor"
cert_fingerprint = "b2c3d4…"

Operators are defined statically in the config file rather than in a database. Changes to the operator list require a daemon restart.

[admin] fields

FieldRequiredDefaultDescription
listen_addrYesTCP address and port for the admin listener.
cert_fileYesPEM file for the admin listener’s TLS server certificate.
key_fileYesPEM file for the admin listener’s TLS private key.
ca_certsNo[]PEM CA files whose issued client certificates are accepted as operator credentials.
session_ttl_secsNo3600Idle session expiry in seconds.

[[admin.operators]] fields

FieldRequiredDescription
nameYesHuman-readable operator name (shown in logs).
roleYes"administrator" or "auditor".
cert_fingerprintAt least oneLowercase hex SHA-256 of the DER leaf certificate.
gssapi_principalAt least oneKerberos principal (reserved for future use).

Admin endpoints

MethodPathadministratorauditor
POST/admin/sessionYY
DELETE/admin/sessionYY
GET/admin/statusYY
GET/admin/statsYY
GET/admin/configY

POST /admin/session

Authenticate with a client certificate and receive a session token.

Response 200 OK:

{
  "session_token": "a4f1…64-hex-chars…",
  "role": "administrator",
  "expires_at": "2026-05-02T14:00:00Z"
}

DELETE /admin/session

Invalidate the current session token.

Response: 204 No Content.

GET /admin/status

Liveness check. All authenticated operators may call this endpoint.

Response 200 OK:

{ "status": "ok", "uptime_secs": 3600 }

GET /admin/stats

Return signing statistics.

Response 200 OK:

{
  "uptime_secs": 3600,
  "checkpoints_signed": 42,
  "last_checkpoint_at": "2026-05-02T13:45:00Z"
}

GET /admin/config

Return a redacted view of the running configuration: operator names, roles, and session TTL. Private key material and CA certificate paths are not included. Requires the administrator role.

Response 200 OK:

{
  "operators": [
    { "name": "alice", "role": "administrator" },
    { "name": "bob",   "role": "auditor" }
  ],
  "session_ttl_secs": 3600
}

Querying via akamuctl

The akamuctl cosigner subcommands target the cosigner’s admin listener. Configure the connection in the [cosigner] section of ~/.config/akamu/akamuctl.toml:

[cosigner]
url       = "https://cosigner.example.com:9444"
ca_cert   = "/etc/akamu/cosigner-admin-ca.pem"
cert_file = "/etc/akamu/operator.pem"
key_file  = "/etc/akamu/operator.key"
akamuctl cosigner status
akamuctl cosigner stats

See akamuctl — Cosigner administration for the full command reference.

TLS Configuration

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

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


When to use native TLS vs. a reverse proxy

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 server generates a TLS certificate automatically:

  1. A fresh server key is generated using the bootstrap_key_type algorithm (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", "ed448".
# Default: "ec:P-256".
bootstrap_key_type = "ec:P-256"

Mutual TLS client certificate authentication

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

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

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

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

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

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

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

profile — CAB Forum vs RFC 5280

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 via the OpenSSL backend.
  • Composite ML-DSA+classical TLS 1.3 CertificateVerify signatures: provisional code points from draft-reddy-tls-composite-mldsa are advertised and verified.

Classical algorithms are always verified using a standard cryptographic backend. Composite schemes are TLS 1.3 only and never appear in a TLS 1.2 handshake.


Full annotated example with mTLS

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

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

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

Known limitations

  • Composite scheme code points: the TLS SignatureScheme code points for composite ML-DSA+classical schemes are provisional values from draft-reddy-tls-composite-mldsa (all TBD pending IANA allocation). The corresponding X.509 OIDs are defined in draft-ietf-lamps-pq-composite-sigs. If assigned code points differ from the provisional values used here, a code update will be required before deploying to production.

  • Composite scheme support depends on the OpenSSL version: composite ML-DSA+classical CertificateVerify verification requires OpenSSL 3.5 or later with composite NID support. If the installed OpenSSL version does not support the required NIDs, verification will return an OpenSSL error at runtime.

  • Pure ML-DSA TLS signature schemes: no IANA code points exist yet for standalone ML-DSA (non-composite) TLS schemes. Even with allow_post_quantum = true, only composite schemes are advertised.

  • Client remote address: when native TLS is active, the client’s remote IP address is available to handlers through the standard axum connection-info mechanism.


Troubleshooting

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: …OpenSSL does not support the composite NID for the scheme used; see Known Limitations
TLS versions: …protocols list contains an unsupported value; use "TLSv1.2" and/or "TLSv1.3"

Performance

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

All numbers were collected on a single host — Intel Core i7-12800H (14 cores / 20 threads, 63 GB RAM, Fedora Linux 6.15, OpenSSL 3.5.6) — using the acme-bench tool in two modes:

  • Process mode (--spawn process): the server runs as a separate OS process with its own Tokio runtime, memory allocator, and SQLite :memory: database. This matches how a real deployment behaves. Heap allocation numbers reflect the client side only.
  • Inprocess mode (default): server and clients share a single process. This mode enables SQLite backend, connection pool, and read-only pool split benchmarks that require shared-process access to the database layer. Heap allocation numbers include both client and server.

Audit events are written to a JSONL file (/tmp/akamu-bench-audit.jsonl) in both modes.

The benchmark runs full ACME workflows (new-order → authz → challenge validate → finalize → certificate download). Latency is end-to-end wall time from new-order through certificate download; account creation is amortised and excluded. Default configuration uses ec:P-256 client keys, ec:P-256 CA key, and http-01 challenge.

The full benchmark suite can be run with contrib/performance/run_benchmarks.sh, which writes newline-delimited JSON results to a file for post-processing. Set SPAWN_MODE="--spawn process" to run the suite in process mode.


Concurrency

With ec:P-256 certificates, http-01 validation, and SQLite :memory:, throughput peaks at 5–10 concurrent clients in both modes and degrades under higher concurrency as queue depth grows.

Process mode

ClientsThroughput (iss/s)Mean (ms)p99 (ms)new_orderauthzchallengefinalizedownload
110010.213.71.31.04.33.40.4
59755.06.30.60.42.61.20.2
101,0988.714.71.60.64.02.20.3
251,20819.024.34.60.58.25.40.3
501,01534.573.79.20.517.76.90.2

Inprocess mode

ClientsThroughput (iss/s)Mean (ms)p99 (ms)new_orderauthzchallengefinalizedownload
11208.313.11.10.83.72.40.3
58186.18.00.60.52.81.80.3
1085411.513.91.21.14.34.10.8
2588927.536.33.23.28.111.11.9
5068167.080.57.57.020.126.65.7

Phase columns show mean milliseconds per ACME step.

Process mode peaks at c=5–10 (975–1,098 iss/s) with sub-9 ms mean latency, driven by read-only pool separation, crypto caching, and spawn_blocking for certificate signing. Inprocess mode peaks at c=5–25 (818–889 iss/s). Process mode shows lower download times (0.2 ms vs 1–6 ms) because certificate delivery bypasses the shared-process HTTP stack. Inprocess mode shows higher authz and download overhead at high concurrency due to Tokio task contention within the single runtime.


Client key type

The client key type is the largest single determinant of per-issuance latency. All runs use ec:P-256 CA; process mode uses 25 concurrent clients, inprocess mode uses 50.

Process mode

CSR key typeThroughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)Alloc/iss
ed2551956133.069.114.2166 KB
ec:P-25655633.657.615.5164 KB
ML-DSA-4452337.966.817.2243 KB
ML-DSA-6551141.152.222.4269 KB
ML-DSA-8741845.588.523.0313 KB
ec:P-38437753.271.728.2175 KB
rsa:2048153124.0266.888.9166 KB
rsa:4096131156.62345.5779.8223 KB

Inprocess mode

CSR key typeThroughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)Alloc/iss
ec:P-25677054.670.020.7434 KB
ed2551974254.172.820.2432 KB
ML-DSA-4468560.475.024.0575 KB
ML-DSA-6558969.998.830.0624 KB
ML-DSA-8754077.399.934.7696 KB
ec:P-38448785.797.346.3438 KB
rsa:2048157279.7554.2165.1454 KB
rsa:4096152506.94099.11496.2531 KB

In process mode ed25519 and ec:P-256 are effectively tied (~33 ms, 556–561 iss/s). ML-DSA variants perform well: ML-DSA-44 at 523 iss/s is only 6% slower than ec:P-256. EC P-384 is consistently slower than ML-DSA-87 in both modes due to its heavier finalize cost.

RSA 2048 is 3.6–4.7× slower than ec:P-256; RSA 4096 at ~1,160 ms mean is dominated entirely by key generation.

RSA 4096 is strongly discouraged for ACME clients in multi-client deployments.


RSA 4096 saturation

RSA 4096 key generation is CPU-wall-limited. Adding concurrency barely improves throughput while latency grows linearly.

Process mode

ClientsThroughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)
133751,215370
10136912,292666
25151,3343,4631,068
50152,4174,8311,283

Inprocess mode

ClientsThroughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)
13357922353
10146512,817647
25141,3404,5011,155
50132,8044,4411,749

Throughput saturates at ~13–15 iss/s regardless of concurrency or mode. At c=50, p99 reaches 4.4–4.8 seconds. This is entirely client-side key generation; the server is idle waiting for CSRs.


CA key type

CA signing is server-side. The CA key type directly affects the finalize phase; other phases are unaffected. All runs use ec:P-256 client keys; process mode uses 25 concurrent clients, inprocess mode uses 50.

Process mode

CA keyThroughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)
ec:P-25662431.048.514.9
rsa:204846642.761.425.5
ec:P-38430766.395.938.5
rsa:307226676.094.149.2
rsa:4096183116.5165.889.9

Inprocess mode

CA keyThroughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)
ec:P-25672958.368.422.8
ec:P-38466363.175.025.7
rsa:204864364.582.726.9
rsa:307259869.381.531.7
rsa:409651880.499.638.5

EC P-256 is the fastest CA key type and the recommended default. In process mode, RSA 2048 CA (466 iss/s) outperforms EC P-384 CA (307 iss/s) because OpenSSL’s RSA 2048 signing is faster than ECDSA P-384; in inprocess mode RSA 2048 and EC P-384 are close (643 vs 663 iss/s). RSA 4096 as CA reduces throughput to 183–518 iss/s.


Post-quantum chain

Akāmu supports ML-DSA (FIPS 204 / RFC 9881) CA keys at three NIST security levels. The table measures a full post-quantum chain (matching ML-DSA CA + ML-DSA client keys, with --verify-cert) and compares to an ec:P-256 baseline. Process mode uses 25 concurrent clients, inprocess mode uses 50.

Process mode

CA + clientNIST cat.Throughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)vs P-256Alloc/iss
ec:P-25652636.770.016.0170 KB
ML-DSA-44236255.775.134.6+52%257 KB
ML-DSA-65329868.686.046.0+87%312 KB
ML-DSA-87525080.5105.856.6+119%385 KB

Inprocess mode

CA + clientNIST cat.Throughput (iss/s)Mean (ms)p99 (ms)Finalize (ms)vs P-256Alloc/iss
ec:P-25673056.068.221.9438 KB
ML-DSA-44263764.184.328.4+14%671 KB
ML-DSA-65355175.689.532.3+35%770 KB
ML-DSA-87550883.896.940.3+50%914 KB

ML-DSA-44 shows a smaller overhead in process mode (+52% vs +14% inprocess) because the server’s larger ML-DSA signature is generated out-of-process without competing for the client’s Tokio runtime. Allocation pressure in inprocess mode (671–914 KB) reflects both client and server heap usage; process mode (257–385 KB) reflects client-side only.

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


Challenge type

All runs use ec:P-256 keys and SQLite :memory:; process mode uses 25 concurrent clients, inprocess mode uses 50.

Process mode

ChallengeThroughput (iss/s)Mean (ms)p99 (ms)Challenge phase (ms)
http-0161531.071.19.6
dns-persist-0154437.762.914.7

Inprocess mode

ChallengeThroughput (iss/s)Mean (ms)p99 (ms)Challenge phase (ms)
http-0174657.365.317.7
dns-persist-0162367.883.726.7

dns-persist-01 adds 5–9 ms to the challenge phase, reducing throughput by 12–16% in both modes. Both challenge types deliver zero errors across all runs.


Backend comparison

SQLite :memory: versus a tmpfs-backed WAL file (/dev/shm), sweeping concurrency with ec:P-256 keys and http-01. Inprocess mode only — process mode always uses :memory:. The tmpfs backend uses a write coalescer that batches concurrent writes through a single connection, eliminating BEGIN IMMEDIATE contention.

Clients:memory: (iss/s):memory: mean (ms)tmpfs (iss/s)tmpfs mean (ms)Delta
11148.71109.1−4%
57466.78455.9+13%
1083611.81,1128.9+33%
2587228.11,03023.6+18%
5074760.691048.7+22%
7568196.893268.6+37%

With the write coalescer, tmpfs WAL outperforms :memory: at c≥5: the coalescer serialises writes on a dedicated connection, avoiding contention that :memory: still experiences through the pool. Peak tmpfs throughput is 1,112 iss/s at c=10 versus 872 iss/s for :memory: at c=25. Tmpfs WAL is the recommended backend for deployments that need crash-recoverable state without the complexity of PostgreSQL.


Connection pool

Connection pool sizing affects throughput when multiple concurrent clients contend for database reads. The write coalescer handles all writes through a dedicated connection, so the pool primarily serves read operations. Inprocess mode with tmpfs WAL backend — process mode ignores pool settings.

Poolc=1 (iss/s)c=5 (iss/s)c=10 (iss/s)c=25 (iss/s)c=50 (iss/s)
11299171,0741,168934
21349541,4071,1751,141
41179701,0751,5881,295
81149421,1061,5311,127

At c=1 pool size is irrelevant. At c=25, pool=4 delivers the best throughput (1,588 iss/s) — a 36% improvement over pool=1 (1,168 iss/s). Pool=4 is the recommended choice: it delivers the highest peak throughput while maintaining reasonable p99 latency (22.8 ms at c=25).

Pool sizes above 4 show diminishing returns; pool=8 at c=25 reaches 1,531 iss/s (−4% vs pool=4) with slightly higher p99 variance.


Read-only pool split

Splitting read-only handlers (get_order, get_authz, download_cert, star_cert, renewal_info, ocsp) onto a separate ?mode=ro connection pool frees the write pool for write-path handlers. Inprocess mode with tmpfs WAL — process mode ignores pool settings.

ClientsNo split (iss/s)Split ro=4 (iss/s)Improvement
1113110−3%
5894990+11%
101,2531,154−8%
251,1971,548+29%
501,0091,430+42%

The split delivers significant gains at c≥25 where read contention competes with the write coalescer. Peak improvement is +42% at c=50 (1,430 vs 1,009 iss/s). At lower concurrency the overhead of managing a separate pool can slightly reduce throughput.

RO connection sweep at c=10

ro-connectionsThroughput (iss/s)Mean (ms)p99 (ms)
11,2018.212.8
21,2078.113.2
41,1498.614.8
81,1218.815.6
161,2238.114.3

At c=10, all RO connection counts perform similarly (1,121–1,223 iss/s). ro=1 or ro=2 is the recommended setting for typical deployments; higher counts add connection overhead without meaningful throughput gain.


Key type recommendations

ScenarioRecommended 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)

Capacity planning

Single-node throughput for ec:P-256 keys, http-01:

Target throughputConfigurationExpected mean latencyNotes
≤100 iss/s1 client, pool=1~9 msMinimal deployment
≤1,000 iss/s5–10 clients6–12 msSweet spot: low latency, high throughput
≤1,200 iss/s25 clients~20 msNear :memory: ceiling
≤1,600 iss/s25 clients, pool=4, tmpfs WAL~14 msCoalescer + pool tuning

Figures assume ec:P-256 keys and http-01 challenge. RSA or ML-DSA keys lower throughput proportionally.

For the database backend: SQLite :memory: suits nodes with no persistent state requirement (accounts, orders, and certificates are lost on restart). Tmpfs WAL (/dev/shm) with the write coalescer outperforms :memory: under concurrency (up to 1,588 iss/s vs ~889 iss/s) and provides crash-recoverable state. For persistent deployments, PostgreSQL is recommended; use a connection pool of 20–25 ([database] pool_connections = 25).


Memory

The benchmark instruments heap allocation using a custom GlobalAlloc wrapper. Per-issuance allocation pressure — bytes requested from the system allocator per certificate, including memory subsequently freed — varies by configuration and mode.

In process mode, allocation reflects the client side only (server runs in a separate process); in inprocess mode it includes both client and server.

Process mode (client-side allocation)

ConfigurationPer-issuance alloc
ec:P-256 CA + ec:P-256 client, c=1134 KB
ec:P-256 CA + ec:P-256 client, c=5134 KB
ec:P-256 CA + ec:P-256 client, c=10137 KB
ec:P-256 CA + ec:P-256 client, c=50190 KB
ec:P-256 CA + rsa:4096 client, c=25223 KB
ML-DSA-44 CA + ML-DSA-44 client, c=25257 KB
ML-DSA-65 CA + ML-DSA-65 client, c=25312 KB
ML-DSA-87 CA + ML-DSA-87 client, c=25385 KB

Inprocess mode (client + server allocation)

ConfigurationPer-issuance alloc
ec:P-256 CA + ec:P-256 client, c=1416 KB
ec:P-256 CA + ec:P-256 client, c=5416 KB
ec:P-256 CA + ec:P-256 client, c=10417 KB
ec:P-256 CA + ec:P-256 client, c=50426 KB
ec:P-256 CA + rsa:4096 client, c=50531 KB
ML-DSA-44 CA + ML-DSA-44 client, c=50671 KB
ML-DSA-65 CA + ML-DSA-65 client, c=50770 KB
ML-DSA-87 CA + ML-DSA-87 client, c=50914 KB

The difference between modes (e.g. 416 KB − 134 KB = 282 KB for ec:P-256) represents the server-side allocation per issuance: certificate construction, DER encoding, audit logging, and database writes.

JSON output

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

{
  "memory": {
    "start_live_bytes":           102400,
    "server_ready_live_bytes":    204800,
    "after_bench_live_bytes":     614400,
    "peak_live_bytes":           1572864,
    "server_overhead_bytes":      512000,
    "issuance_growth_bytes":      409600,
    "per_issuance_growth_bytes":    1900,
    "issuance_alloc_bytes":     87523328,
    "per_issuance_alloc_bytes":   150120,
    "total_alloc_count":         319099
  }
}
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

Running the benchmark

Full suite

The benchmark suite script runs all configurations and writes newline-delimited JSON results:

cargo build --release

# Inprocess mode (default)
contrib/performance/run_benchmarks.sh [OUTPUT_FILE]

# Process mode
SPAWN_MODE="--spawn process" contrib/performance/run_benchmarks.sh [OUTPUT_FILE]

Post-processing examples:

# Print throughput for all runs
jq -r '.label + ": " + (.summary.throughput_per_sec|round|tostring) + " iss/s"' results.ndjson

# Extract concurrency scaling table
jq 'select(.label | startswith("concurrency_"))
    | [.label, .summary.throughput_per_sec,
       .summary.total_latency_ms.mean, .summary.total_latency_ms.p95]' results.ndjson

Individual runs

cargo build --release

# Concurrency sweep (process mode)
for c in 1 5 10 25 50; do
  cargo bench --bench acme_bench -- --spawn process --clients $c --requests 300 --warmup 20
done

# Key type comparison at c=25
for kt in ec:P-256 ec:P-384 ed25519 rsa:2048 ml-dsa-44; do
  cargo bench --bench acme_bench -- --spawn process --clients 25 --key-type $kt --requests 100
done

# CA key type comparison
for cakt in ec:P-256 ec:P-384 rsa:2048 rsa:4096; do
  cargo bench --bench acme_bench -- --spawn process --clients 25 --ca-key-type $cakt --requests 100
done

# Post-quantum full chain with verification
cargo bench --bench acme_bench -- \
  --spawn process --clients 25 --ca-key-type ml-dsa-44 --key-type ml-dsa-44 --verify-cert

# Challenge type comparison
cargo bench --bench acme_bench -- --spawn process --clients 25 --challenge dns-persist-01

# Backend comparison (inprocess mode, tmpfs WAL)
cargo bench --bench acme_bench -- --clients 10 --db "sqlite:///dev/shm/bench.db" --requests 300

# RO pool split (inprocess mode)
cargo bench --bench acme_bench -- \
  --clients 10 --db "sqlite:///dev/shm/bench.db" --ro-connections 4 --requests 300

# JSON output for scripting
cargo bench --bench acme_bench -- --spawn process --clients 25 --requests 100 --output json | jq .summary

Available options

OptionDefaultDescription
--spawn MODEinprocessinprocess or process; process starts separate OS processes
--nodes N1Number of akamu nodes in the cluster
--clients N10Concurrent worker tasks
--requests N100Issuances to measure (warmup not counted)
--warmup N10Warmup issuances discarded before measurement
--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)
--topology MODEdirectdirect (round-robin) or proxy (single-node proxy)
--no-gossipoffDisable gossip in multi-node runs
--db PATH:memory:SQLite URL or PostgreSQL connection string
--pool-connections N1Write connection pool size
--ro-connections N0Read-only connection pool size (0 = no split)
--wildcardoffIssue *.bench-N.acme-bench.test (dns-persist-01 only)
--output FORMATtexttext or json
--verify-certoffParse and verify the SAN of every issued certificate
--poll-ms N100Challenge poll interval in milliseconds

RFC Support Reference

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

Summary

SpecificationTitleStatus
CA/B Forum BRCA/Browser Forum Baseline Requirements v2.xPartial
dns-persist-01Let’s Encrypt Persistent DNS ChallengeFull
draft-ietf-acme-profiles-01ACME Certificate ProfilesFull
RFC 9964ML-DSA for JSON Object Signing and Encryption (JOSE) and CBOR Object Signing and Encryption (COSE)Full
draft-ietf-lamps-pq-composite-sigs / draft-reddy-tls-composite-mldsaML-DSA Composite TLS Signature SchemesPartial (provisional code points)
RFC 7807Problem Details for HTTP APIsFull
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 8823ACME Extensions for S/MIME 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 6960Online Certificate Status Protocol (OCSP)Full
RFC 9115ACME Profile for Delegated CertificatesFull
RFC 9447ACME Challenges Using an Authority TokenFull
RFC 9448ACME TNAuthList Authority TokenFull
draft-ietf-acme-authority-token-jwtclaimconACME Authority Token: JWTClaimConstraintsFull
RFC 9538ACME Delegation Metadata for CDNINot implemented
RFC 9891ACME DTN Node ID Validation (Experimental)Not considered

RFC 8555 — Core ACME

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

What it covers

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 can be provisioned in two ways:

Static provisioning — keys are declared in the TOML configuration under [server.eab_keys] and loaded into the database at startup:

[server]
external_account_required = true

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

GSSAPI self-service derivation — when [server].eab_master_secret is set, authenticated clients call GET /acme/eab (authenticating via Authorization: Negotiate or via a trusted reverse proxy supplying X-Remote-User). The server derives deterministic (kid, hmac_key) pairs using HKDF-SHA-256 (RFC 5869) keyed by (master_secret, principal), stores them in the eab_keys table on first request, and returns them to the client:

[server]
external_account_required = true
eab_master_secret = "<base64url-encoded 32-byte secret>"   # see configuration reference

The response JSON is {"principal":"…","kid":"…","hmac_key":"…","alg":"HS256"}. The client uses the returned kid and hmac_key to construct the externalAccountBinding JWS for newAccount. Once a kid has been consumed by an account registration, re-fetching GET /acme/eab for the same principal returns HTTP 409 Conflict.

Regardless of the provisioning method, the server performs full HMAC verification per RFC 8555 §7.3.4: it checks the kid, validates the algorithm and URL, verifies the HMAC signature, and confirms the EAB payload contains the account public key. Account creation and EAB key consumption happen atomically so that a key can never be used more than once even under concurrent requests.

Certificate validity window

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


RFC 8659 — CAA DNS Resource Record

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

How Akāmu implements it

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

  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 8823 — S/MIME Certificates

RFC 8823 defines the email identifier type and the email-reply-00 challenge for issuing S/MIME end-user certificates. Proof of email address control is established via a DKIM-authenticated reply to a challenge email.

Identifier type

Orders may include {"type": "email", "value": "user@example.com"} identifiers. The server validates the format (non-empty local-part, non-empty domain, exactly one @, no wildcard prefix) and returns 400 unsupportedIdentifier for malformed addresses.

Challenge type

email-reply-00 is the only challenge offered for email identifiers. The challenge object includes a mandatory from field (the server’s validation address) in addition to the standard token and url fields:

{
  "type": "email-reply-00",
  "url": "https://acme.example.com/acme/chall/<id>",
  "status": "pending",
  "token": "<base64url(token-part2)>",
  "from": "acme-validation@example.com"
}

Two-channel token

The token is split across two channels per RFC 8823 §3:

  • token-part2 (≥128 bits): returned in the challenge JSON. The client stores it.
  • token-part1 (≥128 bits): sent by the server in the challenge email Subject: ACME: <base64url(token-part1)>. The client reads it from the email.

The client concatenates them: full_token = base64url(token-part1) || base64url(token-part2), then computes the key authorization and digest.

DKIM enforcement

RFC 8823 §3.2 requires that the DKIM d= tag on the reply email matches the domain of the From address. Akāmu enforces this via the webhook payload: dkim_domain must equal the domain portion of from, and dkim_status must be "pass".

DKIM verification itself is performed by the mail routing infrastructure (the webhook caller), not by Akāmu. The server trusts the dkim_domain and dkim_status fields in the webhook payload — secure HMAC authentication of the webhook endpoint is therefore essential.

Certificate requirements

Issued S/MIME certificates contain:

  • An rfc822Name Subject Alternative Name matching the validated email address.
  • The emailProtection Extended Key Usage (OID 1.3.6.1.5.5.7.3.4).

These are enforced at CSR validation time (the server rejects CSRs where the rfc822Name SANs do not match the authorized email identifiers).

Configuration

Requires [email_challenge] in the server configuration with enabled = true. See the email_challenge configuration reference and the challenges documentation for the full webhook payload format and send script interface.


RFC 9773 — ACME Renewal Information (ARI)

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

Endpoints

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

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

The response includes a suggested renewal window:

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

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

Renewal replacement

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

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

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

Configuration

[server]
ari_retry_after_secs = 21600  # 6 hours between renewal-info polls (default)
# ari_explanation_url = "https://acme.example.com/docs/renewal-policy"  # optional

RFC 9799 — ACME for .onion Domains

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

Supported challenges for .onion identifiers

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 6960 — OCSP Responder

RFC 6960 defines the Online Certificate Status Protocol (OCSP), which allows relying parties to query a CA for the real-time revocation status of a specific certificate.

Endpoints

POST /ca/ocsp                   # body: DER OCSPRequest, Content-Type: application/ocsp-request
GET  /ca/ocsp/{request}         # {request}: base64url-encoded DER OCSPRequest (RFC 6960 §A.1)

Both endpoints return a signed OCSPResponse with Content-Type: application/ocsp-response. No authentication is required.

Status mapping

For each serial number in the OCSPRequest, the server looks up the certificate in the database:

DB stateCertStatus
Certificate not foundunknown
status = "revoked"revoked
Any other statusgood

The response is signed with the CA private key. The responder identity is byName using the CA’s subject Name DER.

Configuration

Set ocsp_url in [ca] to the public URL of the OCSP endpoint so the URL is embedded in issued certificates:

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

See CRL and OCSP for the complete deployment guide.


RFC 5280 — X.509 Certificate Profile

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

Conformance includes:

  • Correct BasicConstraints (CA: false on end-entity certs).
  • 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.

CA/B Forum Baseline Requirements

The CA/Browser Forum Baseline Requirements for TLS Server Certificates (BR) is not an RFC but a policy document maintained by the CA/Browser Forum and enforced by browser trust-store membership. Any CA intending to issue publicly-trusted TLS certificates must comply with it. Akāmu enforces several BR requirements at startup and at certificate-issuance time.

Compliance status

RequirementSectionDeadlineStatusImplementation
Maximum validity 200 days§6.3.22026-03-15Enforced (warning)Startup warning when ca.validity_days > 200
Maximum validity 100 days§6.3.22027-03-15Enforced (warning)Startup warning when ca.validity_days > 100
SHA-1 prohibited in signatures§7.1.3.2.12026-09-15Enforced (hard error)Startup hard error when ca.hash_alg is sha1 or sha-1
DNSSEC validation for DNS challenges§3.2.2.4, §3.2.2.8.12026-03-15Enforced by defaultserver.validate_dnssec (default true)
Pre-issuance linting§4.3.1.22025-03-15EnforcedEvery issued certificate is verified via synta-x509-verification before delivery
Multi-perspective validation§3.2.2.92025-03-15To do, not a priorityRequires validation from multiple network vantage points

§6.3.2 — Certificate Validity Period

The CA/B Forum has progressively shortened the maximum certificate validity period:

  • 200 days — hard limit since 2026-03-15
  • 100 days — hard limit from 2027-03-15

Akāmu enforces these limits as startup warnings rather than hard errors, because the restriction applies only to publicly-trusted WebPKI certificates. Private or enterprise deployments may legitimately use longer validity periods when not chaining to a public root. The warning makes the misconfiguration visible without breaking private-CA use cases.

Configure ca.validity_days in your config.toml:

[ca]
validity_days = 90   # ≤ 100 is fully compliant through 2027-03-15

§7.1.3.2.1 — SHA-1 Sunset

SHA-1 signatures in certificates and CRLs are prohibited from 2026-09-15. Akāmu enforces this as a startup hard error: if ca.hash_alg is set to sha1 or sha-1, the server refuses to start with an explicit error message citing the BR section.

Compliant hash algorithms: sha256, sha384, sha512.

§3.2.2.4 / §3.2.2.8.1 — DNSSEC Validation

DNS-based challenge validation (dns-01, dns-persist-01) and CAA record checking must use DNSSEC-validated answers as of 2026-03-15.

Akāmu enables DNSSEC validation by default. The behaviour is controlled by server.validate_dnssec:

[server]
validate_dnssec = true   # default — required for BR compliance

Set validate_dnssec = false only for testing environments or private deployments where the DNS infrastructure is not DNSSEC-signed. Disabling DNSSEC makes the server non-compliant with CA/B Forum BR and ineligible for public WebPKI inclusion.

§4.3.1.2 — Pre-Issuance Linting

CAs must programmatically verify every certificate before signing and delivering it, using a linting tool that checks structural and policy conformance. Akāmu satisfies this requirement by running the synta-x509-verification policy engine against every issued certificate immediately after signing and before delivering it to the client.

The linter checks:

  • X.509 version = v3
  • Serial number: ≤ 20 octets, positive integer
  • Validity window present and well-formed
  • SPKI algorithm on the WebPKI allowlist (no SHA-1, no weak RSA)
  • RSA keys: minimum 2048 bits; EC keys: named curves only
  • Signature algorithm on the WebPKI allowlist (includes ML-DSA / composite post-quantum)
  • AuthorityKeyIdentifier extension present
  • BasicConstraints: cA=FALSE on end-entity certificates
  • CA signature is cryptographically valid over the certificate body

If linting fails, the certificate is not delivered and the order moves to the invalid state with an internal error. The malformed certificate is never exposed to the client.

§3.2.2.9 — Multi-Perspective Issuance Corroboration (MPIC)

As of 2025-03-15, CAs are required to validate domain control from multiple network vantage points — at minimum two remote perspectives in addition to the primary validation — to mitigate BGP hijacking attacks against ACME challenge responses.

To do, not a priority. Satisfying this requirement demands either integration with a set of geographically distributed MPIC agents or reliance on an external MPIC service. Akāmu is intended for private and enterprise deployments where the network topology is controlled; public CAs using Akāmu as a backend must implement MPIC at the infrastructure layer until this is supported natively.


RFC 7807 — Problem Details for HTTP APIs

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

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

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


Let’s Encrypt dns-persist-01

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

How it differs from dns-01

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_domains = "acme.example.com"

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

TXT record format

The domain owner publishes (and keeps permanently):

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

Optional extensions:

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

Validation

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


draft-ietf-acme-profiles-01

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

What it adds

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 [profiles] providers are configured, the directory meta includes a profiles object mapping each profile name to its description:

"meta": {
  "profiles": {
    "tlsserver":  "Standard TLS server certificate",
    "clientauth": "Client authentication certificate"
  }
}

Requesting a profile in newOrder

Clients include the profile field in the newOrder payload:

{
  "identifiers": [{ "type": "dns", "value": "example.com" }],
  "profile": "tlsserver"
}

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

{
  "type": "urn:ietf:params:acme:error:invalidProfile",
  "status": 400,
  "detail": "profile 'unknown-profile' is not served by any configured provider"
}

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

Default profile auto-selection

When a newOrder request omits the profile field and a profile named "default" exists in the registry, the server automatically applies "default" and echoes it in the order response. This means clients that do not specify a profile will receive "profile": "default" in the order JSON rather than an absent field, giving operators a clean way to enforce a baseline policy without requiring client-side changes.

If no "default" profile is configured and the client omits profile, the order is issued under the CA’s built-in defaults (no profile applied).

Finalize-time enforcement

At finalize time the server reads the profile registry once and uses the result for both authorization and certificate parameter construction. Per-profile authorization checks (allowed_identifiers, auth_hook, require_account_grant) run before CSR validation so that authorization failures are reported before the server expends effort parsing and validating the CSR.

The server resolves the profile’s CertificateParameters (key usage bits, EKU OIDs, validity, CRL/OCSP URLs, certificate policies) and issues the certificate with those exact extension values. If the profile is no longer loaded (e.g. removed since the order was placed), the request is rejected with invalidProfile.

Configuration

[profiles.providers.local]
type = "builtin"

[profiles.providers.local.profiles.tlsserver]
description   = "Standard TLS server certificate"
validity_days = 90
key_usage     = ["digital_signature", "key_encipherment"]
eku           = ["server_auth"]

See Certificate Profiles for the full configuration reference including Dogtag and IPA providers. When no providers are configured, the profile field in newOrder is accepted but ignored — the server issues under its default policy.


RFC 9964 — ML-DSA for JOSE and COSE

RFC 9964 defines how ML-DSA (Module-Lattice-Based Digital Signature Algorithm, formerly CRYSTALS-Dilithium, standardized in FIPS 204) keys and signatures are represented in JOSE (JSON Object Signing and Encryption) and COSE (CBOR Object Signing and Encryption). Akāmu implements it for ACME account key authentication, meaning ACME clients can register an ML-DSA key pair and sign every subsequent ACME request with it.

JWK key type: AKP

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

{
  "kty": "AKP",
  "alg": "ML-DSA-65",
  "pub": "<base64url-encoded raw public key bytes>"
}
JWK 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 RFC 9964 §6, the JWK thumbprint for an AKP key is the SHA-256 hash of the following canonical JSON object with members in lexicographic order:

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

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

Signature format

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

The signing context MUST be an empty byte string per RFC 9964 §4. Signature failures return HTTP 401 Unauthorized.

ACME client integration notes

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

  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-reddy-tls-composite-mldsa

draft-ietf-lamps-pq-composite-sigs defines the X.509/PKIX OIDs for hybrid ML-DSA+classical composite signature algorithms (sub-arcs 37–54 under the id-CompositeSig arc). The TLS 1.3 SignatureScheme code points for use in CertificateVerify are defined in the companion draft draft-reddy-tls-composite-mldsa.

CA signing keys (draft-ietf-lamps-pq-composite-sigs-19)

All 18 composite ML-DSA variants defined in sub-arcs 37–54 are supported as CA signing keys. When ca.key_type is set to a composite variant (e.g. "composite-mldsa65-ecdsa-p384-sha512"), Akāmu generates a composite CA key and issues all end-entity certificates with that composite signature. Issued certificates pass pre-issuance lint via synta-x509-verification.

Requires OpenSSL 3.5 or later (same requirement as pure ML-DSA keys). The full list of 18 supported variants and their OID sub-arcs is documented in the ca.key_type configuration reference.

Mutual TLS client authentication (draft-reddy-tls-composite-mldsa)

Composite ML-DSA schemes also appear in the TLS CertificateVerify message when a client presents a certificate signed with a composite ML-DSA scheme. The 11 composite scheme code points implemented for mTLS are:

Code pointScheme
0x0901MLDSA44-ECDSA-P256-SHA256
0x0902MLDSA44-RSA2048-PKCS15-SHA256
0x0903MLDSA44-RSA2048-PSS-SHA256
0x0904MLDSA44-Ed25519-SHA512
0x0905MLDSA65-ECDSA-P256-SHA512
0x0906MLDSA65-ECDSA-P384-SHA512
0x0907MLDSA65-RSA3072-PKCS15-SHA512
0x0908MLDSA65-RSA3072-PSS-SHA512
0x0909MLDSA65-Ed25519-SHA512
0x090AMLDSA87-ECDSA-P384-SHA512
0x090CMLDSA87-Ed448-SHAKE256

Stability warning

All OID sub-arcs (37–54) and all SignatureScheme code points (0x090x) are provisional pending IANA allocation. They may change as the drafts advance toward RFC publication. Before deploying to production, verify the current draft version against the values listed above.



RFC 9115 — ACME Profile for Delegated Certificates

RFC 9115 defines a three-party ACME delegation model in which an Identifier Owner (IdO) pre-authorizes a Name Delegation Consumer (NDC) to obtain certificates for the IdO’s domain names. The CA enforces a JSON CSR template that constrains what the NDC may request. Akāmu implements both roles: it acts as the IdO-facing ACME CA (serving NDC clients) and as an IdO ACME client that drives the upstream CA leg automatically.

Roles

RoleDescription
IdOThe domain owner. Creates delegation objects (CSR templates + CNAME maps) and holds the STAR or regular order on Akāmu.
NDCThe delegate (e.g., a CDN PoP). Discovers the delegation URL via the IdO’s account, submits a new-order referencing it, and finalizes with a CSR that satisfies the template.
Upstream CAAn external ACME CA that issues to the IdO. Akāmu drives this leg automatically using [delegation_upstream].

What it adds to the ACME API

FeatureRFC 9115 sectionStatus
delegation-enabled in directory meta§2.3.1Yes — when server.delegation_enabled = true
allow-certificate-get in directory meta§2.3.5Yes — when server.allow_certificate_get = true
delegations URL in account object§2.3.2Yes — appears when delegation_enabled = true
POST /acme/delegations/{account_id} — list delegations§2.3.2Yes
POST /acme/delegation/{id} — fetch one delegation§2.3.3Yes
"delegation" field in new-order payload§2.3.4Yes
"allow-certificate-get" field in new-order payload§2.3.5Yes
Delegation orders start in ready status (no challenge/authz flow)§2.3.4Yes
"authorizations": [] on delegation orders§2.3.4Yes
CSR template validation at finalize§4Yes
Unauthenticated GET /acme/cert/{id} when allow_cert_get = 1§2.3.5Yes

CSR template format (RFC 9115 §4)

The delegation object’s csr_template field is a JSON object that constrains what an NDC may put in its CSR:

{
  "keyTypes": [{"type": "EC", "curve": "P-256"}],
  "subject": {
    "commonName": {},
    "organization": "ExampleCorp"
  },
  "extensions": {
    "subjectAltName": {},
    "keyUsage": ["digitalSignature"],
    "extendedKeyUsage": ["1.3.6.1.5.5.7.3.1"]
  }
}

Field value semantics:

ValueMeaning
{}MandatoryWildcard — the field MUST be present in the CSR
nullOptionalWildcard — the field MAY be present in the CSR
"ExampleCorp"Literal — the field must equal this exact value
absentThe field is forbidden in the CSR

Akāmu validates the CSR against the stored template at finalize time. CSRs that violate the template are rejected with urn:ietf:params:acme:error:badCSR.

Server configuration (IdO-server role)

[server]
# Enable the delegation API surface and advertise it in the directory.
delegation_enabled      = true

# Advertise and allow unauthenticated GET of delegation order certificates.
allow_certificate_get   = true

When delegation_enabled = true, the directory meta object includes "delegation-enabled": true and every account response includes a "delegations" URL. The delegation endpoints become active:

POST /acme/delegations/{account_id}   — list delegations (POST-as-GET)
POST /acme/delegation/{id}             — fetch one delegation object (POST-as-GET)

When allow_certificate_get = true, the directory meta also includes "allow-certificate-get": true, and orders placed with "allow-certificate-get": true in their payload allow the NDC (or any bearer) to fetch the certificate with an unauthenticated GET.

Upstream CA configuration (IdO-client role)

The [delegation_upstream] section configures Akāmu to act as an ACME client toward an upstream CA. A background task polls orders whose status = 'processing' and a non-null delegation_id, drives the upstream ACME flow (account registration, order creation, dns-01 challenge, finalize), and stores the resulting certificate URL back on the order.

[delegation_upstream]
# ACME directory URL of the upstream CA.
directory_url = "https://upstream-ca.example.com/acme/directory"

# PEM file containing the ACME account key for the upstream CA.
account_key_file = "/etc/akamu/upstream-acme.key.pem"

# Contact email(s) used when registering the upstream account.
contacts = ["mailto:admin@example.com"]

# Challenge type for the upstream authz flow.  Only "dns-01" is supported.
challenge_solver = "dns-01"

# Executable that deploys the dns-01 TXT record.
# Called with env_clear(); receives CERTBOT_DOMAIN and CERTBOT_VALIDATION.
challenge_deploy_script = "/etc/akamu/upstream-dns-deploy.sh"

# Optional cleanup script called after the authz transitions to valid.
# Receives CERTBOT_DOMAIN, CERTBOT_VALIDATION, and CERTBOT_AUTH_OUTPUT="".
# challenge_cleanup_script = "/etc/akamu/upstream-dns-cleanup.sh"

# Polling interval for the upstream order status (seconds). Default: 10.
# poll_interval_secs = 10

The deploy script is invoked after Akāmu has triggered the challenge at the upstream CA. The cleanup script is called once the authorization has transitioned to valid — not immediately after the deploy script, which allows the TXT record to remain in place long enough for the upstream CA’s validators to query it.

Admin API — delegation CRUD

Delegations are managed through the Admin API. The delegation_enabled config flag must be set; the [admin] section must be configured with at least one operator.

MethodPathRole required
GET/admin/delegationsany authenticated role
GET/admin/delegations?account_id={id}any authenticated role
POST/admin/delegationsca_operations, administrator
GET/admin/delegations/{id}any authenticated role
PUT/admin/delegations/{id}ca_operations, administrator
DELETE/admin/delegations/{id}ca_operations, administrator

DELETE returns 409 Conflict when one or more orders still reference the delegation.

The CSR template syntax is validated at write time (POST and PUT). A malformed template is rejected with 400 Bad Request before it reaches the database.

Every write operation emits a structured audit event: delegation.create (POST), delegation.update (PUT), or delegation.delete (DELETE). These events are queryable via GET /admin/audit and the akamuctl audit --type delegation.* filter.

Delegation management is also available through akamuctl delegation — see akamuctl — Admin CLI for the full command reference.

Delegation order lifecycle

stateDiagram-v2
    direction LR
    [*] --> ready : new-order (with delegation URL)
    ready --> processing : finalize (NDC submits CSR)
    processing --> valid : upstream CA issues cert
    processing --> invalid : CSR template mismatch or upstream failure
    valid --> [*]
    invalid --> [*]

Delegation orders skip the pending state and the challenge/authorization flow entirely. The authorizations array in the order response is always empty. The order transitions from ready to processing when the NDC calls finalize, and from processing to valid when the background upstream task has retrieved the certificate from the upstream CA.


RFC 9447 — ACME Challenges Using an Authority Token

RFC 9447 defines the tkauth-01 ACME challenge type. Instead of a network probe, the client proves control of the identifier by presenting a signed JWT (an authority token) issued by an external Token Authority (TA). This enables ACME automation for identifier types — such as telephone numbers — that cannot be validated by http-01 or dns-01.

The authority token is a compact JWT carrying an atc claim that binds:

  • tktype — the identifier type (e.g., "TNAuthList")
  • tkvalue — the identifier value (base64url-encoded DER)
  • fingerprint — the ACME account’s JWK thumbprint
  • ca — must be absent or false (CA-cert issuance not supported)

Akāmu validates the TA’s signing certificate chain against a locally-configured set of trust anchors, verifies the JWT signature and expiry, and enforces one-time use via a JTI replay-prevention cache.

What it adds to the ACME API

FeatureStatus
tkauth-01 challenge typeYes
tkauth-type field in challenge objectYes — always "atc"
token-authority hint in challenge objectYes — optional, from tkauth.token_authority_url
x5u cert fetch for TA signing certYes
x5c inline cert for TA signing certYes
JTI replay preventionYes — database-backed tkauth_jti_cache table
Automatic JTI cache pruningYes — background task, interval from tkauth.jti_prune_interval_secs

Configuration

[tkauth]
enabled                 = true
trusted_ta_ca_files     = ["/etc/akamu/ta-root.pem"]
token_authority_url     = "https://ta.example.com"   # optional hint
max_validity_secs       = 3600
jti_prune_interval_secs = 3600

trusted_ta_ca_files must list one or more PEM files containing the CA certificates that sign Token Authority certificates. The signing cert presented in the authority token (via x5u or x5c) must chain to one of these anchors.

JTI cache management

Expired JTI entries accumulate over time. The background task prunes them automatically. Operators can also trigger manual pruning via:

akamuctl tkauth prune-jti
akamuctl tkauth prune-jti --dry-run   # count without deleting

Or via the Admin API:

POST /admin/tkauth/prune-jti
POST /admin/tkauth/prune-jti?dry_run=true

RFC 9448 — ACME TNAuthList Authority Token

RFC 9448 defines the TNAuthList ACME identifier type and its use with the RFC 9447 tkauth-01 challenge for STIR/SHAKEN telephone number automation. The identifier value is a base64url-encoded DER-encoded TNAuthorizationList structure as defined in RFC 8226.

When a new-order request contains a TNAuthList identifier, Akāmu creates a tkauth-01 challenge. The client obtains a signed authority token from the Token Authority — attesting that the account holds the telephone number authority — and submits it in the challenge response.


draft-ietf-acme-authority-token-jwtclaimcon

draft-ietf-acme-authority-token-jwtclaimcon defines a second RFC 9447 profile for the JWTClaimConstraints identifier type. The identifier value is a base64url-encoded DER-encoded JWTClaimConstraints ASN.1 structure (from RFC 8226), constraining which PASSporT claims may appear on issued certificates.

The tkauth-01 validation is identical to RFC 9448 — the only differences are the identifier type string ("JWTClaimConstraints") and the corresponding atc.tktype value in the authority token. Akāmu validates these generically; no separate configuration is required beyond enabling [tkauth].

An order may contain both TNAuthList and JWTClaimConstraints identifiers simultaneously. Each gets its own authorization and tkauth-01 challenge; all authorizations must be valid before the order may be finalized.


Not implemented

RFC 9538 — ACME Delegation Metadata for CDNI

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

Not implemented. RFC 9115 single-tier delegation is fully supported (see above). Chained multi-tier delegation across CDN interconnects as defined in RFC 9538 is not yet implemented.

RFC 9891 — ACME DTN Node ID Validation (Experimental)

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

Not considered. Experimental status; targets space/satellite networks using the Bundle Protocol (RFC 9171), outside the scope of Akāmu’s target deployments.

EAB and Kerberos Authentication

akamu can require callers to prove their Kerberos identity before issuing External Account Binding (EAB) credentials. Two authentication modes are supported: a reverse proxy that sets a header after completing SPNEGO, and standalone GSSAPI where akamu validates Negotiate tokens directly.

Authentication modes

Proxy header mode

In this mode a trusted reverse proxy (Apache, Nginx, HAProxy, etc.) terminates the SPNEGO / Kerberos exchange and sets an X-Remote-User header on every forwarded request. akamu accepts this header as the authenticated principal only when the request arrives from an IP address listed in trusted_proxies.

Requests from any other IP — including unauthenticated clients — never have the header honoured.

Standalone GSSAPI mode

In this mode akamu handles Authorization: Negotiate tokens directly using MIT Kerberos. At startup the server reads a keytab file and acquires an acceptor credential for the configured HTTP service principal. Each incoming token is validated with gss_accept_sec_context.

When the token is absent, akamu returns 401 Unauthorized with a WWW-Authenticate: Negotiate challenge. When the token is invalid or expired, akamu returns 403 Forbidden.

Additional behaviors of this mode:

  • Token size limit. Negotiate tokens larger than 128 KiB are rejected with 400 Bad Request. Legitimate Kerberos service tickets are always smaller than this limit.
  • Case-insensitive scheme matching. The "Negotiate " prefix in the Authorization header is matched case-insensitively per RFC 7235 §2.1.
  • TLS channel bindings. When akamu terminates TLS itself, the tls-server-end-point channel binding (RFC 5929 §4) is computed from the server certificate and passed to gss_accept_sec_context. This binds the Kerberos exchange to the TLS channel, preventing token relay attacks. When the server certificate uses ML-DSA (pure or composite) or Ed448 — algorithms for which RFC 5929 defines no canonical hash — channel bindings are disabled automatically.
  • Replay detection. After a successful gss_accept_sec_context call, akamu checks whether GSS_C_REPLAY_FLAG is set. When the flag is absent (common when clients connect over TLS, because TLS already provides replay protection), a debug-level log entry is emitted and the authentication proceeds normally. This behaviour is intentional: browsers and TLS-first clients typically do not negotiate Kerberos-level replay protection.
  • No authentication mechanism configured. When neither trusted_proxies nor [server.gssapi] is set, requests to authenticated endpoints return 404 Not Found rather than 403 Forbidden.
  • GSSAPI without TLS. Running standalone GSSAPI without TLS is permitted but emits a warn-level log at startup, because SPNEGO tokens are not protected against interception or relay attacks without TLS.

Only one mode may be active at a time. Enabling trusted_proxies and [server.gssapi] simultaneously is a configuration error: the server exits at startup with an error message if both are set.

Deployment prerequisites

Both modes require a working Kerberos environment:

  • A Kerberos realm (for example, managed by FreeIPA or Active Directory).
  • A service principal of the form HTTP/<hostname>@REALM registered in the KDC.
  • For standalone GSSAPI: either a keytab file readable only by the akamu process, or a gssproxy daemon entry that supplies the credential (no direct keytab access needed — see FreeIPA deployment).
  • For proxy mode: a reverse proxy configured to perform SPNEGO and set X-Remote-User.

Configuration

Proxy mode

[server]
trusted_proxies = ["192.168.1.10/32"]
  • trusted_proxies lists the IP addresses (CIDR notation) of your reverse proxy.
  • Keep this list as narrow as possible. Any host in the list can claim any principal name by forging the X-Remote-User header.
  • IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) are automatically normalised to plain IPv4 for matching purposes.

No additional configuration is needed on the akamu side. The reverse proxy must be configured separately to perform Kerberos/SPNEGO authentication and forward the authenticated username in X-Remote-User.

Example Apache configuration (mod_auth_gssapi):

<Location /acme/eab>
    AuthType GSSAPI
    AuthName "Kerberos"
    GssapiCredStore keytab:/etc/httpd/http.keytab
    Require valid-user
    RequestHeader set X-Remote-User %{REMOTE_USER}e
</Location>

Standalone GSSAPI mode

Two credential sources are supported: a keytab file read directly by akamu, or the gssproxy daemon (no direct file access needed).

Keytab mode — akamu reads the keytab at startup:

[server.gssapi]
keytab_file = "/etc/akamu/http.keytab"

Generate and install the keytab for an IPA-managed host:

ipa-getkeytab -s ipa.example.com \
    -p HTTP/akamu.example.com@EXAMPLE.COM \
    -k /etc/akamu/http.keytab
chmod 600 /etc/akamu/http.keytab
chown akamu: /etc/akamu/http.keytab

gssproxy mode — gssproxy supplies the credential; no keytab path is needed:

[server.gssapi]
gssproxy = true

Set GSS_USE_PROXY=yes is handled automatically; akamu sets it before the first GSSAPI call when gssproxy = true. Install the gssproxy service entry first — see FreeIPA deployment for a complete example.

Common optionservice_name selects the Kerberos service component. MIT Kerberos appends @<local-hostname> automatically when no realm is given. The default is "HTTP"; use "HTTP@akamu.example.com" to be explicit:

[server.gssapi]
keytab_file  = "/etc/akamu/http.keytab"
service_name = "HTTP@akamu.example.com"   # explicit hostname

keytab_file and gssproxy are mutually exclusive — the server exits at startup if both are set.

The GET /acme/eab endpoint

The GET /acme/eab endpoint is the entry point for EAB credential issuance. It requires a valid authenticated identity through one of the two modes above.

Behaviour with eab_master_secret configured (full mode)

When [server].eab_master_secret is set, the endpoint derives a deterministic EAB key identifier and HMAC secret from the master secret and the authenticated principal using HKDF-SHA-256 (RFC 5869):

kid      = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-kid:<principal>", L=16) )
hmac_key = base64url( HKDF-SHA256(IKM=master_secret, info="akamu-eab-v1-key:<principal>", L=32) )

Request:

GET /acme/eab
Authorization: Negotiate <base64-token>

Response (200 OK):

{
  "principal": "host/client.example.com@EXAMPLE.COM",
  "kid":       "…22-char base64url…",
  "hmac_key":  "…43-char base64url…",
  "alg":       "HS256"
}

The same (master_secret, principal) pair always produces the same kid and hmac_key. Credentials are stored in the eab_keys table on first request and returned unchanged on subsequent requests by the same principal.

Once the kid has been consumed by an account registration (newAccount with a valid externalAccountBinding), re-fetching returns 409 Conflict. Contact your CA administrator to reset the credential if you need to re-register.

Behaviour without eab_master_secret (stub / backward-compatible mode)

When eab_master_secret is absent, the endpoint confirms authentication succeeded but returns only the principal name:

{ "principal": "host/client.example.com@EXAMPLE.COM" }

This mode is useful for testing authentication configuration before enabling EAB enforcement.

Configuring eab_master_secret

Generate a random 32-byte secret and encode it as base64url:

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

Add the result to your configuration:

[server]
external_account_required = true
eab_master_secret         = "<base64url output from above>"

The decoded secret must be at least 32 bytes; the server exits at startup if it is shorter. Treat the master secret with the same care as a private key — anyone who holds it can derive valid EAB credentials for any principal.

Client-side usage with akamu-cli

The akamu-cli and akamu-client library support calling this endpoint using a Kerberos keytab. The CLI --gssapi-keytab flag (shared by account register and issue) authenticates to the endpoint, logs the returned principal, and automatically uses the returned kid and hmac_key to construct the externalAccountBinding field in newAccount (RFC 8555 §7.3.4). No manual copy-paste of EAB credentials is required.

The library exposes fetch_eab_via_gssapi(eab_url, keytab_file), which derives the target service name HTTP@<hostname> from the URL automatically and returns a GssapiEabResult containing principal, kid, hmac_key, and alg.

Using EAB credentials with other ACME clients

Any standard ACME client that supports External Account Binding can use credentials from GET /acme/eab. The pattern is to fetch the credentials in a pre-registration script and then pass them to the ACME client’s EAB flags.

Step 1 — fetch credentials with curl and a Kerberos ticket

# Obtain a Kerberos ticket first (if not already cached)
kinit host/client.example.com@EXAMPLE.COM -k -t /etc/client.keytab

# curl handles SPNEGO automatically with --negotiate
RESPONSE=$(curl -s --negotiate -u : \
    https://akamu.example.com/acme/eab)

KID=$(echo "$RESPONSE"      | python3 -c "import sys,json; print(json.load(sys.stdin)['kid'])")
HMAC_KEY=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['hmac_key'])")

Step 2 — pass the credentials to your ACME client

Certbot:

certbot register \
    --server https://akamu.example.com/acme/directory \
    --eab-kid     "$KID" \
    --eab-hmac-key "$HMAC_KEY"

acme.sh:

export EAB_KID="$KID"
export EAB_HMAC_KEY="$HMAC_KEY"
acme.sh --register-account \
    --server https://akamu.example.com/acme/directory \
    --eab

Lego:

lego --server https://akamu.example.com/acme/directory \
     --eab \
     --kid      "$KID" \
     --hmac     "$HMAC_KEY" \
     --email    "ops@example.com" \
     run --domains client.example.com ...

The kid and hmac_key values are valid until the first successful newAccount call that consumes them. After registration succeeds the account key is the ongoing credential; the EAB pair is not needed again. If registration fails before newAccount completes, re-running the script returns the same kid and hmac_key (derivation is deterministic), so it is safe to retry.

If GET /acme/eab returns 409 Conflict, the credentials have already been consumed by a prior registration. Contact your CA administrator to reset them.

Security notes

  • In keytab mode the keytab grants the ability to accept Kerberos service tickets for the HTTP principal. Treat it with the same care as a private key: permissions 600, owned by the akamu service account, never shared with other services.
  • In gssproxy mode the keytab is held by the gssproxy daemon; verify that the gssproxy service entry restricts access by euid = akamu so that no other process on the host can obtain the HTTP service credential.
  • The trusted_proxies list must be kept tightly scoped to the actual IP addresses of your reverse proxy. A broadly scoped list (for example, 0.0.0.0/0) allows any network client to assert any principal name.
  • Kerberos tickets have a finite lifetime (typically 10 hours). Clients must obtain fresh tickets before they expire; akamu returns 403 for expired tokens.

FreeIPA Co-deployment

This guide covers running akamu on the same host as a FreeIPA server, proxied behind IPA’s Apache httpd at the /acme path prefix. IPA manages TLS termination and DNS; akamu focuses on ACME certificate issuance and operator authentication via Kerberos.

The complete configuration files and Ansible automation described here live in contrib/demo/ipa/.

Topology

Client (certbot / acme.sh)
    │  HTTPS  /acme/acme/directory
    ▼
IPA Apache httpd (TLS termination)
    │  mod_proxy  /acme/ → unix socket
    ▼
akamu (Unix domain socket /run/akamu/akamu.sock)
    │  GSS_USE_PROXY=yes
    ▼
gssproxy daemon (reads /var/lib/ipa/gssproxy/http.keytab)
    │  S4U2Self / S4U2Proxy
    ▼
IPA KDC / IPA LDAP (optional profile fetch)

akamu never touches the HTTP keytab directly. gssproxy mediates all Kerberos operations and enforces that only the akamu OS user can obtain the HTTP service credential.

Prerequisites

  • FreeIPA server installed and running on the target host.

  • The HTTP/<fqdn>@REALM service principal registered in IPA (created automatically when IPA is installed on the host).

  • gssproxy installed (part of the standard IPA server package set on Fedora/RHEL).

  • akamu installed from the abbra/synta COPR:

    dnf copr enable abbra/synta
    dnf install akamu
    

Step 1 — gssproxy service entry

Create /etc/gssproxy/20-akamu.conf (from contrib/demo/ipa/akamu-gssproxy.conf):

[service/akamu]
  mechs = krb5
  cred_store = keytab:/var/lib/ipa/gssproxy/http.keytab
  cred_store = client_keytab:/var/lib/ipa/gssproxy/http.keytab
  allow_protocol_transition = true
  allow_constrained_delegation = true
  cred_usage = both
  euid = akamu

Then restart gssproxy:

systemctl restart gssproxy

cred_usage = both is required: akamu acts as both an acceptor (validating incoming SPNEGO tokens from ACME clients and admin operators) and an initiator (obtaining service tickets for LDAP profile fetches when [profiles.providers.ipa] is enabled).

Step 2 — akamu configuration

Create /etc/akamu/config.toml (from contrib/demo/ipa/akamu.toml, substituting your FQDN and realm):

listen_addr = "unix:/run/akamu/akamu.sock"
base_url    = "https://ipa.example.com/acme"

[database]
url = "sqlite:///var/lib/akamu/akamu.db"

[ca]
key_file  = "/etc/akamu/certs/ca.key.pem"
cert_file = "/etc/akamu/certs/ca.cert.pem"
key_type  = "ec:P-256"
hash_alg  = "sha256"

[server]
validate_dnssec = true

[server.gssapi]
gssproxy = true

[admin]
bootstrap_operator_gssapi_principal = "admin@EXAMPLE.COM"

[admin.gssapi]
gssproxy = true

gssproxy = true tells akamu to set GSS_USE_PROXY=yes at startup and acquire its acceptor credential via gssproxy rather than reading the keytab directly. service_name defaults to "HTTP" and does not need to be set explicitly.

bootstrap_operator_gssapi_principal seeds the first akamu Administrator from the IPA admin principal on first run (when the operators table is empty). Remove this line after the initial operator account has been created.

Step 3 — Apache proxy

Create /etc/httpd/conf.d/ipa-acme-proxy.conf (from contrib/demo/ipa/ipa-acme-proxy.conf):

ProxyPass        /acme/  unix:/run/akamu/akamu.sock|http://localhost/  nocanon
ProxyPassReverse /acme/  http://localhost/
ProxyPassReverseCookiePath / /acme/
ProxyPreserveHost On
RedirectMatch permanent ^/acme$ /acme/

<Location "/acme/">
    AuthType None
    Require all granted
    SSLOptions +StdEnvVars +ExportCertData +StrictRequire
    SSLVerifyClient none
    RequestHeader set X-Forwarded-Proto "https"
    Header always set    X-Content-Type-Options "nosniff"
    Header always append X-Frame-Options "DENY"
    Header always append Content-Security-Policy "frame-ancestors 'none'"
</Location>

RewriteCond %{SERVER_PORT} !^443$
RewriteRule ^/acme/(.*)  https://%{HTTP_HOST}/acme/$1 [L,R=307,NC]

Step 4 — socket permissions

The akamu Unix socket lives in /run/akamu/ (mode 0710, owned by akamu:akamu). Apache must be in the akamu group to connect:

usermod -aG akamu apache
systemctl reload httpd

Step 5 — start akamu

systemctl enable --now akamu

Verify startup in the journal:

journalctl -u akamu -n 50

Expected lines on a healthy start:

INFO gssproxy mode enabled: GSS_USE_PROXY=yes
INFO initializing GSSAPI credential for service 'HTTP'
INFO acquiring GSSAPI credential via gssproxy
INFO akamu listening on unix:/run/akamu/akamu.sock

The ACME directory is now available at:

https://ipa.example.com/acme/acme/directory

Bootstrapping the first admin

On first run, akamu registers admin@EXAMPLE.COM (or whichever principal you set in bootstrap_operator_gssapi_principal) as an Administrator. Confirm the account was created:

kinit admin
akamuctl --url https://ipa.example.com/acme --gssapi operators list

After the initial operator is confirmed, remove bootstrap_operator_gssapi_principal from config.toml and restart akamu so the boot-strap path is no longer active.

Optional — IPAThinCA certificate profiles

To expose FreeIPA CA certificate profiles via ACME, add a [profiles.providers.ipa] section. The HTTP service principal needs read access to o=ipaca; on Fedora/RHEL IPA this subtree is readable by any IPA-enrolled principal. See the commented block in contrib/demo/ipa/akamu.toml for the full LDAP connection configuration.

The ipa_permissions.yml Ansible playbook in contrib/demo/ipa/ansible/ creates an explicit Akamu IPA CA Read IPA privilege and assigns it to the HTTP service principal for auditability.

Automated deployment with Ansible

The contrib/demo/ipa/ansible/ directory contains a complete Ansible setup that installs FreeIPA, deploys akamu on every node, and grants IPA privileges in one command:

cd contrib/demo/ipa/ansible
cp inventory.ini.example inventory.ini
# Edit inventory.ini: set hostnames, ipa_domain, ipa_realm, passwords
ansible-playbook -i inventory.ini site.yml

See contrib/demo/ipa/ansible/README.md for the full reference, including standalone (non-replica) node setup with S4U2Proxy constrained delegation.

Troubleshooting

SymptomCheck
akamu not startingjournalctl -u akamu — often a config parse error or missing DB directory (/var/lib/akamu/)
502 Bad Gateway from Apachels -la /run/akamu/ — the socket must be group-accessible; run usermod -aG akamu apache && systemctl reload httpd
GSS_USE_PROXY not appearing in logsConfirm gssproxy = true is set in [server.gssapi] or [admin.gssapi]
gssproxy errorsjournalctl -u gssproxy — verify /etc/gssproxy/20-akamu.conf has euid = akamu and the keytab path is correct
401 Unauthorized on ACME requestsThe client’s Kerberos ticket may have expired; run kinit and retry
Admin operator login failsConfirm akamuctl --url ... --gssapi is used and the operator is in the akamu database: akamuctl operators list
Certificate issuance fails with profile errorsEnable [profiles.providers.ipa] and run ipa_permissions.yml to grant CA LDAP read access

Backup and Restore

This runbook describes how to back up an akamu deployment and restore it to a known-good state after hardware failure, corruption, or disaster recovery.

What to back up

AssetLocationNotes
Databasedatabase.url in configSQLite file or PostgreSQL/MariaDB dump
CA private key(s)[[ca]] key_filePEM or PKCS#11 — see below; one entry per configured CA
CA certificate(s)[[ca]] cert_filePEM; one entry per configured CA
Admin TLS key/certadmin.key_file, admin.cert_fileIf not auto-generated
Configuration fileakamu.tomlServer configuration
MTC log directorymtc.log_pathOnly if MTC is enabled
Cosigner config & keyakamu-cosigner.toml + key fileIf cosigner is deployed

Database backup

SQLite

SQLite in WAL mode supports online backup. Use the .backup command while the server is running:

sqlite3 /var/lib/akamu/db.sqlite3 ".backup /var/backups/akamu/db-$(date +%F).sqlite3"

Alternatively, stop the server and copy the database file and its WAL/SHM companions:

systemctl stop akamu
cp /var/lib/akamu/db.sqlite3{,-wal,-shm} /var/backups/akamu/
systemctl start akamu

PostgreSQL

Use pg_dump for logical backups:

pg_dump -Fc akamu > /var/backups/akamu/db-$(date +%F).pgdump

For point-in-time recovery, configure WAL archiving and base backups per the PostgreSQL documentation.

MariaDB

Use mariadb-dump (or mysqldump):

mariadb-dump --single-transaction akamu > /var/backups/akamu/db-$(date +%F).sql

Key material backup

File-based keys

Copy the PEM files to secure offline storage:

cp /etc/akamu/ca.key.pem /var/backups/akamu/
cp /etc/akamu/ca.cert.pem /var/backups/akamu/
chmod 600 /var/backups/akamu/ca.key.pem

When ca.require_encrypted_key is enabled, also back up the password file referenced by ca.key_password_file.

PKCS#11 / HSM keys

HSM key backup procedures are device-specific. Consult your HSM vendor’s documentation for key export or key wrapping operations. Ensure the wrapped key material is stored separately from the HSM.

Restore procedure

  1. Stop the server:

    systemctl stop akamu
    
  2. Restore the database:

    • SQLite: copy the backup file to the configured path.
    • PostgreSQL: pg_restore -d akamu /var/backups/akamu/db-YYYY-MM-DD.pgdump
    • MariaDB: mariadb akamu < /var/backups/akamu/db-YYYY-MM-DD.sql
  3. Restore key material and configuration:

    cp /var/backups/akamu/ca.key.pem /etc/akamu/
    cp /var/backups/akamu/ca.cert.pem /etc/akamu/
    cp /var/backups/akamu/akamu.toml /etc/akamu/
    chmod 600 /etc/akamu/ca.key.pem
    
  4. Verify schema version:

    The server applies pending migrations automatically on startup. No manual migration step is required.

  5. Start the server:

    systemctl start akamu
    
  6. Regenerate the CRL:

    After restoring from backup, the CRL may be stale. Force regeneration:

    akamuctl crl-force
    
  7. Verify the server is healthy:

    akamuctl stats
    

    Confirm certificate and account counts match expectations.

Disaster recovery testing

Periodically verify that backups can be restored to a staging environment:

  1. Provision a clean staging host.
  2. Restore the database and key material from the latest backup.
  3. Start the server with the production configuration (adjusted for the staging listener address).
  4. Run akamuctl stats and compare counts against production.
  5. Issue a test certificate using akamu-cli to confirm end-to-end functionality.

Schedule this verification at least quarterly, or after any change to the backup pipeline.

Rotation and retention

  • Keep at least 7 daily and 4 weekly database backups.
  • Rotate backups using your preferred tool (logrotate, cron cleanup script, or cloud object lifecycle rules).
  • Store backups on a different host or storage system from the production server.
  • Encrypt backup archives at rest using gpg or your storage provider’s server-side encryption.

akamuctl — Admin CLI

akamuctl is the command-line tool for administering a running akamu server or cosigner daemon. It talks to the admin REST API over HTTPS with mTLS or session token authentication and prints results as a human-readable table or as JSON.

Installation

Build from source alongside the rest of the workspace:

cargo build -p akamuctl --release

The binary is placed at target/release/akamuctl.

Quick start

# Log in via mTLS client certificate (caches a session token)
akamuctl --server-url https://admin.example.com:9443 \
          --ca-cert /etc/akamu/certs/ca.cert.pem \
          --cert    /etc/akamu/certs/operator.cert.pem \
          --key     /etc/akamu/certs/operator.key.pem \
          login

# Log in via Kerberos/GSSAPI (uses the ambient ccache from 'kinit')
akamuctl --server-url https://admin.example.com:9443 login --gssapi

# List operators
akamuctl operator list

# Add an EAB key
akamuctl eab add --kid acmeclient-001 \
                 --hmac-key c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg

# Query audit log for the last 20 failed events
akamuctl audit --outcome failure --limit 20

After login succeeds, the session token is written to ~/.config/akamu/session.json and reused automatically for subsequent commands until it expires (default 1 hour).

Configuration file

akamuctl reads ~/.config/akamu/akamuctl.toml if it exists. Command-line flags take precedence over the config file.

Use akamuctl config generate to print an annotated template you can save as a starting point:

akamuctl config generate > ~/.config/akamu/akamuctl.toml

Full example

[server]
url            = "https://admin.example.com:9443"
ca_cert        = "/etc/akamu/certs/ca.cert.pem"
cert_file      = "/etc/akamu/certs/operator.cert.pem"
key_file       = "/etc/akamu/certs/operator.key.pem"
# Optional: override the GSSAPI SPN used by 'akamuctl login --gssapi'.
# When absent the SPN is derived automatically as HTTP@<hostname>.
# gssapi_service = "HTTP@admin.example.com"

[cosigner]
url            = "https://cosigner.example.com:9444"
ca_cert        = "/etc/akamu/certs/ca.cert.pem"
cert_file      = "/etc/akamu/certs/operator.cert.pem"
key_file       = "/etc/akamu/certs/operator.key.pem"
# gssapi_service = "HTTP@cosigner.example.com"

[server]

KeyDescription
urlAdmin listener URL (e.g. https://127.0.0.1:9443).
ca_certPEM CA certificate used to verify the server’s TLS certificate. When absent, the system trust store is used.
cert_filePEM client certificate presented for mTLS authentication.
key_filePEM private key matching cert_file.
gssapi_serviceGSSAPI service principal name used by akamuctl login --gssapi. Overrides the automatic HTTP@<hostname> derivation from url.

[cosigner]

Same fields as [server], applied when running cosigner subcommands. Falls back to [server] values for any field that is absent.

Global flags

FlagShortDescription
--config FILE-cPath to the akamuctl.toml config file.
--server-url URLAdmin listener URL (overrides config).
--ca-cert FILECA certificate for server TLS verification.
--cert FILEmTLS client certificate.
--key FILEmTLS client private key.
--output FORMAT-oOutput format: table (default) or json.

Session management

login

Authenticate with the server and save a session token:

# mTLS — uses cert_file / key_file from config or --cert / --key flags
akamuctl login

# GSSAPI/Kerberos — uses the ambient Kerberos credential cache
kinit alice@EXAMPLE.COM   # obtain a TGT if not already present
akamuctl login --gssapi

Both forms POST to /admin/session. On success the returned token is saved to ~/.config/akamu/session.json with mode 0600 (user-readable only). Subsequent commands reuse this token without re-authenticating. A 30-second expiry margin triggers automatic re-authentication before the server would reject the token.

--gssapi flag

Sends an Authorization: Negotiate header built from the ambient Kerberos ccache instead of presenting an mTLS client certificate. No keytab is required — only a valid TGT (run kinit first).

The GSSAPI service principal name (SPN) is resolved as follows:

  1. If gssapi_service is set in [server] config, it is used as-is.
  2. Otherwise the SPN is derived from the server URL as HTTP@<hostname>, where <hostname> is determined by:
    • Loopback names (localhost, localhost.localdomain, ip6-localhost, ip6-loopback) and loopback IP addresses (127.x.x.x, ::1) → replaced with the machine’s own FQDN via gethostname(2) and forward/reverse DNS.
    • Non-loopback IP addresses → resolved to a hostname via a DNS PTR lookup using hickory-resolver; a warning is printed and the bare IP is used as a fallback if reverse DNS fails.
    • DNS hostnames → used directly.

logout

Invalidate the cached session token:

akamuctl logout

Calls DELETE /admin/session on the server and clears the local cache.

stats

Print live server counters:

akamuctl stats

Returns server version, uptime, and totals for accounts, certificates, EAB keys, and audit events. All authenticated roles may call this command.

whoami

Show the locally cached session identity without contacting the server:

akamuctl whoami

Displays the server URL and token expiry time for both the main server and cosigner sessions (if cached).

Operator management

Operator management requires the administrator role.

operator list

List all operators (active and inactive):

akamuctl operator list

operator show

Show the details of a single operator:

akamuctl operator show 3

Returns all fields including certificate fingerprint, GSSAPI principal, creation time, last-seen timestamp, and active status.

operator add

Register a new operator. At least one of --cert-file or --gssapi-principal must be provided.

# mTLS operator: extract fingerprint from the certificate file
akamuctl operator add \
    --name alice \
    --role administrator \
    --cert-file /etc/akamu/alice-client.pem

# GSSAPI/Kerberos operator
akamuctl operator add \
    --name bob \
    --role auditor \
    --gssapi-principal bob@EXAMPLE.COM

Accepted roles: administrator, ca_operations, ca_ra, auditor.

When --cert-file is given, akamuctl computes the SHA-256 fingerprint of the DER-encoded certificate leaf locally and sends only the fingerprint to the server. The private key never leaves the operator’s machine.

operator update

Update fields of an existing operator. Only provided flags are changed; omitted fields remain unchanged.

# Change role
akamuctl operator update 3 --role ca_operations

# Replace client certificate
akamuctl operator update 3 --cert-file /etc/akamu/alice-new.pem

# Assign a CA scope to a ca_ra operator
akamuctl operator update 5 --role ca_ra --ca-id rsa

# Update multiple fields at once
akamuctl operator update 3 --name "Alice Smith" --role administrator \
    --gssapi-principal alice@NEWREALM.COM

When the new role is ca_ra, --ca-id must also be provided. Setting --role ca_ra without a CA scope is rejected by the server.

operator remove

Deactivate an operator (the operator record is retained for audit purposes):

akamuctl operator remove 3

The numeric argument is the operator id shown by operator list. Deactivating an operator immediately invalidates any active sessions for that operator.

operator activate

Re-enable a previously deactivated operator:

akamuctl operator activate 3

operator unlock

Reset the failed-authentication counter for a locked-out operator:

akamuctl operator unlock 3

Use this when an operator has been locked out due to exceeding max_failed_auth in the server configuration. Calls POST /admin/operators/{id}/unlock (FIA_AFL.1).

Requires the administrator role.

EAB key management

eab list

List all EAB keys:

akamuctl eab list          # all keys
akamuctl eab list --used   # only consumed keys
akamuctl eab list --unused # only unconsumed keys

All authenticated roles may list EAB keys.

eab show

Show details of a single EAB key:

akamuctl eab show my-device-001

Returns the key identifier, creation time, usage status, and profile grants.

eab add

Provision a new EAB key, optionally restricting it to specific certificate profiles:

# Auto-generate kid and HMAC key on the server
akamuctl eab add

# Provide explicit values
akamuctl eab add --kid my-device-001 \
                 --hmac-key c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg

# Restrict to named profiles
akamuctl eab add --kid my-device-001 \
                 --profile tlsserver \
                 --profile codesigning

Requires the administrator, ca_operations, or ca_ra role.

eab remove

Deactivate an EAB key before it has been used:

akamuctl eab remove my-device-001

Requires the administrator or ca_operations role.

Certificate operations

cert list

Search issued certificates:

akamuctl cert list
akamuctl cert list --serial 0a1b2c3d --limit 5
akamuctl cert list --subject "CN=device.example.com" \
                   --status active --limit 50
akamuctl cert list --after 2026-01-01T00:00:00Z \
                   --before 2026-06-01T00:00:00Z
FlagDescription
--ca CA_IDFilter by CA ID. Only certificates issued by the named CA are returned.
--serial HEXFilter by hex serial number.
--subject TEXTFilter by subject distinguished name substring.
--after RFC3339Issued at or after this timestamp.
--before RFC3339Issued at or before this timestamp.
--status VALUEactive or revoked.
--limit NMaximum results (default 20).
--offset NPagination offset (default 0).

Requires the administrator, ca_operations, or auditor role.

cert show

Show a certificate’s metadata (no PEM/DER content):

akamuctl cert show <cert-uuid>

Returns order ID, account ID, serial number, validity window (not_before, not_after), revocation status, MTC log index, and ARI renewal window (suggested_window_start, suggested_window_end).

Requires the administrator, ca_operations, or auditor role.

cert download

Download a certificate’s content as PEM or DER:

# Print PEM to stdout (default)
akamuctl cert download <cert-uuid>

# Save DER to a file
akamuctl cert download <cert-uuid> --format der -o cert.der

# Save PEM to a file
akamuctl cert download <cert-uuid> --format pem -o cert.pem

Requires the administrator or ca_operations role.

revoke

Revoke a certificate by its internal ID:

akamuctl revoke <cert-id>
akamuctl revoke <cert-id> --reason 1   # keyCompromise

The revocation reason code follows RFC 5280 §5.3.1 (0 = unspecified, 1 = keyCompromise, 3 = affiliationChanged, 4 = superseded, etc.). Revoking immediately invalidates the CRL cache so the next CRL request reflects the change.

Requires the administrator, ca_operations, or ca_ra role.

crl-force

Force immediate CRL regeneration for the default CA without waiting for the next scheduled update:

akamuctl crl-force

To force regeneration for a specific CA use the ca crl-force subcommand (see CA management below).

Requires the administrator or ca_operations role.

Account management

account list

List ACME accounts with optional status filtering:

akamuctl account list
akamuctl account list --status valid --limit 50
akamuctl account list --status deactivated
FlagDescription
--ca CA_IDFilter by CA ID. Only accounts registered via the named CA’s new-account endpoint are returned.
--status VALUEvalid or deactivated.
--limit NMaximum results (default 100).
--offset NPagination offset (default 0).

All authenticated roles may list accounts.

account show

Show details of a single ACME account:

akamuctl account show <account-uuid>

Returns status, contact information, JWK thumbprint, creation and update times, and profile grants.

account deactivate

Admin-initiated account deactivation:

akamuctl account deactivate <account-uuid>

The account is set to deactivated status and can no longer create orders or issue certificates.

Requires the administrator role.

account grants get

Show current profile grants for an account:

akamuctl account grants get <account-uuid>

account grants set

Replace all profile grants for an account:

akamuctl account grants set <account-uuid> \
    --profile tlsserver \
    --profile codesigning

Requires the administrator or ca_operations role.

account grants clear

Remove all profile restrictions (restore unrestricted access):

akamuctl account grants clear <account-uuid>

Requires the administrator role.

CA management

In multi-CA deployments, these subcommands let you inspect the configured CAs, issue cross-certificates, and force per-CA CRL regeneration.

ca list

List all configured CAs:

akamuctl ca list

Returns each CA’s ID, default flag, key type, hash algorithm, CRL URL, and OCSP URL. All authenticated roles may call this command.

ca show

Show details of a single CA:

akamuctl ca show rsa

ca cert

Print the CA certificate PEM for the specified CA:

akamuctl ca cert rsa
akamuctl ca cert rsa -o /etc/akamu/rsa-ca.cert.pem

ca crl-force

Force immediate CRL regeneration for a specific CA:

akamuctl ca crl-force rsa
akamuctl ca crl-force ec

Requires the administrator or ca_operations role.

ca cross-sign

Issue a cross-certificate from one CA to another:

# Cross-sign another configured CA by its ID
akamuctl ca cross-sign rsa --subject-ca ec --validity-years 5

# Cross-sign an externally provided CA certificate
akamuctl ca cross-sign rsa --subject-cert /path/to/partner-ca.pem \
    --validity-years 3

The issuer CA (rsa in the examples above) signs the subject’s public key. The resulting cross-certificate has pathLenConstraint = 0.

Requires the administrator or ca_operations role.

Cross-certificate management

cross-cert list

List stored cross-certificates:

akamuctl cross-cert list
akamuctl cross-cert list --issuer-ca rsa
akamuctl cross-cert list --subject-ca ec
FlagDescription
--issuer-ca CA_IDFilter by issuing CA ID.
--subject-ca CA_IDFilter by subject CA ID.
--limit NMaximum results (default 100).
--offset NPagination offset (default 0).

cross-cert show

Show the metadata for a single cross-certificate by UUID:

akamuctl cross-cert show <cross-cert-uuid>

Returns the cross-certificate UUID, issuer CA ID, subject CA ID (or null for an external subject), creation timestamp, and the full PEM-encoded certificate.

Requires any authenticated role.

cross-cert download

Download a cross-certificate by UUID:

akamuctl cross-cert download <cross-cert-uuid>
akamuctl cross-cert download <cross-cert-uuid> --format pem -o cross.pem

Requires any authenticated role.

Delegation management

RFC 9115 delegation objects associate a CSR template (and optional CNAME map) with an ACME account, allowing NDC (Name Delegation Consumer) clients to obtain certificates without running a challenge responder. These commands require delegation_enabled = true in the server configuration.

Read operations (delegation list, delegation show) are available to all authenticated roles. Write operations (delegation add, delegation update, delegation remove) require the administrator or ca_operations role.

delegation list

List all delegation objects, optionally filtered to a single account:

akamuctl delegation list
akamuctl delegation list --account-id <account-uuid>

Returns each delegation’s UUID, account ID, CSR template, CNAME map, creation timestamp, and last-updated timestamp.

delegation show

Show a single delegation object by its UUID:

akamuctl delegation show <delegation-uuid>

delegation add

Create a delegation for an account. The CSR template is a JSON file following RFC 9115 §4. The CNAME map is an optional JSON object mapping source FQDNs to target FQDNs.

akamuctl delegation add \
    --account-id <account-uuid> \
    --csr-template /path/to/template.json

akamuctl delegation add \
    --account-id <account-uuid> \
    --csr-template /path/to/template.json \
    --cname-map /path/to/cname-map.json

Minimal CSR template example (template.json):

{
  "keyTypes": [{"type": "EC", "curve": "P-256"}],
  "subject": {
    "commonName": {},
    "organization": "ExampleCorp"
  },
  "extensions": {
    "subjectAltName": {},
    "keyUsage": ["digitalSignature"],
    "extendedKeyUsage": ["1.3.6.1.5.5.7.3.1"]
  }
}

CNAME map example (cname-map.json):

{
  "cdn.example.com": "cdn.provider.example"
}

The CSR template is validated against the RFC 9115 §4 schema at write time. A malformed template is rejected before it is stored.

delegation update

Replace the CSR template and optionally the CNAME map for an existing delegation. The account_id is immutable and cannot be changed.

# Replace template, keep existing CNAME map unchanged
akamuctl delegation update <delegation-uuid> \
    --csr-template /path/to/new-template.json

# Replace template and CNAME map
akamuctl delegation update <delegation-uuid> \
    --csr-template /path/to/new-template.json \
    --cname-map /path/to/new-cname-map.json

# Replace template and remove the CNAME map
akamuctl delegation update <delegation-uuid> \
    --csr-template /path/to/new-template.json \
    --clear-cname-map

--cname-map and --clear-cname-map are mutually exclusive.

delegation remove

Delete a delegation object. Returns an error if one or more active orders still reference this delegation (the orders must be finalized or expired first).

akamuctl delegation remove <delegation-uuid>

Returns a 409 Conflict error when active orders reference the delegation.

Profile management

profile list

List all loaded certificate profiles with their parameters:

akamuctl profile list

Returns the profile ID, description, validity period (days), hash algorithm, extended key usages, and whether MTC issuance is enabled.

All authenticated roles may list profiles.

profile show

Show the full parameters for a single certificate profile by ID:

akamuctl profile show codesigning

Returns all profile fields: ID, description, validity period, hash algorithm, key usage bits, extended key usages, CRL and OCSP URLs, allowed key types, certificate policies, MTC issuance flag, allowed identifier patterns, identifier_match_all, auth hook configuration, require_account_grant, and the list of CA IDs the profile is restricted to (if any).

Returns 404 Not Found when no profile with the given ID is loaded.

All authenticated roles may show a profile.

profile add

Add a new certificate profile to the runtime cache:

akamuctl profile add codesigning --params-file /tmp/codesigning.json

The JSON file must contain the profile parameters accepted by POST /admin/profiles. At minimum it should include description; all other fields have defaults. Example file:

{
  "description": "Code signing certificate",
  "validity_days": 365,
  "extended_key_usages": ["code_signing"],
  "require_account_grant": true
}

Requires the administrator role.

profile update

Replace an existing certificate profile in the runtime cache:

akamuctl profile update codesigning --params-file /tmp/codesigning-v2.json

The JSON file uses the same schema as profile add (without the id field). Requires the administrator role.

profile remove

Remove a certificate profile from the runtime cache:

akamuctl profile remove codesigning

Requires the administrator role.

Order management

order list

List certificate orders with optional filters:

akamuctl order list
akamuctl order list --account-id <account-uuid>
akamuctl order list --status pending --limit 50
FlagDescription
--ca CA_IDFilter by CA ID. Only orders placed against the named CA are returned.
--account-id UUIDFilter by account UUID.
--status VALUEFilter by order status (pending, ready, processing, valid, invalid).
--limit NMaximum results (default 100).
--offset NPagination offset (default 0).

All authenticated roles may list orders.

order show

Show a single order’s details:

akamuctl order show <order-uuid>

Returns identifiers, associated authorization IDs, certificate ID (if issued), profile, and timing fields (created, updated, expires, not_before, not_after).

All authenticated roles may view order details.

Server configuration

server-config

Show the server’s redacted runtime configuration:

akamuctl server-config

Returns the base URL, whether MTC is enabled, CAA identities, DNSSEC validation setting, and a masked database URL.

Requires the administrator role.

Audit log

audit

Query the structured audit event log:

akamuctl audit                                # most recent 100 events
akamuctl audit --type cert.issue              # certificate issuance events
akamuctl audit --outcome failure --limit 50   # failed operations
akamuctl audit --subject <account-uuid>       # events for a specific account
akamuctl audit --from 2026-05-01T00:00:00Z \
               --until 2026-05-02T00:00:00Z   # time range
FlagDescription
--type TYPEFilter by event type string (see event types).
--subject IDFilter by subject (JWK thumbprint, account UUID, certificate serial, etc.).
--from RFC3339Events at or after this timestamp.
--until RFC3339Events at or before this timestamp.
--outcome VALUEsuccess or failure.
--limit NMaximum results (default 100).
--offset NPagination offset (default 0).

Results are returned newest-first.

Requires the administrator or auditor role.

Audit event types

Event typeDescription
ca.startServer startup.
ca.stopServer shutdown.
account.createNew ACME account registered.
account.deactivateAccount deactivated.
order.createNew certificate order created.
order.finalizeOrder finalization attempted.
cert.issueCertificate issued.
cert.revokeCertificate revoked.
crl.generateCRL generated.
key.generateSigning or CA key generated.
key.loadSigning or CA key loaded from disk.
auth.jws.okACME JWS request authentication succeeded.
auth.jws.failACME JWS request authentication failed.
auth.challenge.okACME challenge validation succeeded.
auth.challenge.failACME challenge validation failed.
eab.useEAB key consumed by account registration.
eab.rejectEAB key rejected (unknown, used, or MAC mismatch).
admin.loginOperator authenticated to the admin API.
admin.logoutOperator session invalidated.
admin.actionAdministrative action performed (operator CRUD, EAB management, etc.).
delegation.createRFC 9115 delegation object created.
delegation.updateRFC 9115 delegation object updated.
delegation.deleteRFC 9115 delegation object deleted.
security.violationSecurity anomaly detected.

Cosigner administration

akamuctl can also administer the akamu-cosigner daemon. Configure the cosigner connection in [cosigner] in the config file, or pass --server-url and TLS flags directly.

cosigner login

Authenticate with the cosigner admin API and cache the session token:

akamuctl cosigner login

Uses the [cosigner] TLS settings from the config file (or falls back to [server] settings if [cosigner] is not configured).

cosigner logout

Invalidate the cosigner session token:

akamuctl cosigner logout

cosigner status

Check whether the cosigner is running:

akamuctl cosigner status

Returns {"status":"ok","uptime_secs":…}.

cosigner stats

Show cosigner signing statistics:

akamuctl cosigner stats

Returns uptime, total checkpoints signed, and the timestamp of the most recent signing operation.

cosigner config

Show the cosigner’s redacted runtime configuration:

akamuctl cosigner config

Requires the administrator role on the cosigner.

tkauth JTI cache management

These commands require administrator or ca_operations role and are only available when [tkauth] is enabled in the server configuration.

tkauth prune-jti

Delete expired entries from the JTI replay-prevention cache:

akamuctl tkauth prune-jti

Use --dry-run to see the count of expired entries without deleting them:

akamuctl tkauth prune-jti --dry-run

The background task prunes the cache automatically at the configured interval. This command provides on-demand pruning or lets operators inspect cache size before a scheduled maintenance window.

Configuration utilities

config generate

Print an annotated example akamuctl.toml to stdout:

akamuctl config generate > ~/.config/akamu/akamuctl.toml

config validate

Validate the current configuration file:

akamuctl config validate

Checks that the server URL is well-formed and that all referenced files (CA certificate, client certificate, private key) exist on disk. Reports warnings and errors for each issue found.

Shell completions

completions

Generate shell completion scripts:

# Bash
akamuctl completions bash > /etc/bash_completion.d/akamuctl

# Zsh
akamuctl completions zsh > ~/.zfunc/_akamuctl

# Fish
akamuctl completions fish > ~/.config/fish/completions/akamuctl.fish

Supported shells: bash, zsh, fish, elvish, powershell.

Output formats

By default, akamuctl prints results as aligned tables. Use --output json (or -o json) to get pretty-printed JSON suitable for scripting:

akamuctl -o json operator list | jq '.operators[] | select(.role == "auditor")'

Table output

When the server response is a JSON object with a single array-valued field (the common case for paginated endpoints such as cert list and audit), the array is rendered as an aligned table and any remaining scalar fields (e.g. total, offset, limit) are printed as a key-value footer below the table. When the response is a plain JSON array the table is printed directly. Scalar responses are printed as a single line.

When a paginated query returns no rows, (no results) is printed instead of an empty table.

Exit codes

CodeMeaning
0Success.
1General error (HTTP, network, JSON parse failure).
2Authentication error (session expired, certificate rejected).
3Configuration error (missing or invalid config file or flag).

Cluster Setup and Gossip Replication

Akamu supports multi-node deployments through CRDT-based gossip replication. Each node maintains its own local SQLite (or PostgreSQL/MariaDB) database and replicates state to peers over an authenticated, encrypted gossip channel.

Prerequisites

  • Separate database per node. Each node holds its own database; there is no shared database in a cluster. Provision one SQLite/PostgreSQL/MariaDB instance per node.
  • CA private keys on every node. CA keys are never replicated. Copy the CA PEM files to every node before starting it.
  • Network reachability. Each node must be able to reach every peer’s gossip URL (typically the admin socket or a dedicated internal port).
  • Firewall rules. Gossip traffic goes to the admin interface. Keep it off the public ACME listener.

Configuration

Add a [gossip] section to each node’s akamu.toml:

[gossip]
# URLs of all other cluster nodes (admin base URL, not the ACME URL).
peers = [
    "http://node2.acme.internal:8081",
    "http://node3.acme.internal:8081",
]

# How often to run a gossip round (seconds).  Default: 15.
interval_secs = 15

# How long to keep tombstoned entries before GC (seconds).  Default: 604800 = 7 days.
tombstone_ttl_secs = 604800

# How long a node may claim exclusive ownership of an order/MTC write slot
# before another node may take over.  Default: 150 seconds.
ownership_ttl_secs = 150

Omitting the [gossip] section entirely puts the node in single-node mode: no replication, no gossip background task.

Startup Sequence

On first start a new node:

  1. Generates an ML-KEM-768 key pair and an ECDSA P-256 gossip signing key pair.
  2. Stores both key pairs in the local database (node_keys table).
  3. Registers itself in the in-memory CRDT cluster node map.
  4. Starts the gossip background loop.

On the first successful gossip round with each peer the node logs:

INFO gossip: first-contact merge complete  peer="http://node2.acme.internal:8081"
    accounts=142 orders=891 certificates=734 authorizations=1023 cluster_nodes=2

After this log line the node has full knowledge of all existing ACME state and is ready to serve requests.

Adding a Node to a Running Cluster

  1. Provision the new node’s database and CA key files.
  2. Add the new node’s gossip URL to every existing node’s peers list and reload their configuration (SIGHUP or restart).
  3. Start the new node with a [gossip] section listing at least one existing peer.
  4. Wait for the “first-contact merge complete” log line. The new node is now in sync.

Troubleshooting

gossip: no KEM key for peer, skipping

The peer is not yet in the cluster node map. This is normal for 1–2 rounds after a new node starts. If it persists after 3 rounds, check that:

  • The peer node started successfully and its gossip loop is running.
  • The peer’s gossip URL is reachable from this node.
  • Both nodes list each other in their peers configuration.

gossip: verify_and_open response failed

The response could not be authenticated. Possible causes:

  • Clock skew between nodes (default tolerance is tombstone_ttl_secs; a node whose clock is wildly ahead will produce envelopes that look stale to peers).
  • A misconfigured or corrupted node_keys table.

X-Akamu-Node-Id header rejected by peer

The responding peer’s handler rejected the sender’s node ID because the sender is not yet in the peer’s cluster_nodes CRDT. This resolves automatically after the first successful full-state exchange. If it does not resolve, verify that gossip traffic is not blocked by a firewall between the nodes.

Gossip stalls entirely

Check that the [gossip] section is present in akamu.toml and that peers is non-empty. A node with no configured peers (or no [gossip] section) logs gossip: no peers configured — loop disabled and exits the gossip loop immediately.

Admin API and Operator Management

API Reference — This page documents the admin REST API for operators and automation tools. For the akamuctl CLI that wraps this API, see akamuctl — Admin CLI in the Operator Guide.

The akamu admin API is a separate HTTPS listener that exposes management endpoints for operators. It is completely independent of the main ACME listener: it binds to a different address, uses its own TLS certificate, and requires operator authentication on every request. When the [admin] section is absent from the configuration file, all admin endpoints return 404 Not Found and are unreachable.

See akamuctl for the command-line tool that wraps this API. See Configuration Reference — [admin] for all configuration keys.

Authentication

Every request to the admin API must be authenticated. Three mechanisms are supported.

mTLS client certificate

The client presents a certificate during the TLS handshake. The server computes the SHA-256 fingerprint of the DER-encoded leaf certificate and looks it up in the operators table. On success, the server issues a session token and returns it in the response body under session_token and in the X-Session-Token response header.

GSSAPI/Kerberos

The client sends an Authorization: Negotiate <base64-SPNEGO-token> header. The server validates the token against the keytab configured in [admin.gssapi], extracts the Kerberos principal, and looks it up in the operators table. On success the server issues a session token (same as the mTLS path) and may include a GSSAPI continuation token in a WWW-Authenticate: Negotiate <token> response header.

Bearer session token

After a successful mTLS or GSSAPI login, the client passes the returned token as Authorization: Bearer <token> on subsequent requests. The server looks up the token in its in-memory session store and refreshes the idle timer. Tokens that have been idle for longer than session_ttl_secs (default 1 hour) are expired and the client receives 401 Unauthorized.

The session store is bounded at 1 000 active sessions. When the cap is reached, the least-recently-active session is evicted.

Token comparisons use constant-time equality to prevent timing side-channels.

Roles

Each operator has exactly one role that determines which admin endpoints they may call. For a full description of each role, its capabilities, restrictions, and the complete route-by-role permission matrix, see Operator Roles.

Endpoint reference

All paths are relative to the admin listener base URL. The [admin].listen_addr field controls the address; the default in the configuration example is https://127.0.0.1:9443.

POST /admin/session

Authenticate and obtain a session token. The request must carry one of the three credential types described above.

Response 200 OK:

{
  "session_token": "a4f1…64-hex-chars…",
  "role": "auditor",
  "expires_at": "2026-05-02T14:00:00Z"
}

The token is also returned in the X-Session-Token response header.

DELETE /admin/session

Invalidate the current session token. The server removes the token from its in-memory store, records an admin.logout audit event, and returns a Set-Cookie: session=; Max-Age=0 header that instructs the browser to immediately expire the session cookie set at login.

Response: 204 No Content.

GET /admin/operators

List all registered operators, including deactivated ones.

Query parameters: limit (1–1000, default 1000), offset (default 0).

Response 200 OK:

{
  "operators": [
    {
      "id": 1,
      "name": "alice",
      "role": "administrator",
      "cert_fingerprint": "a3b4c5…",
      "gssapi_principal": null,
      "created_at": "2026-05-01T09:00:00Z",
      "last_seen_at": "2026-05-02T08:30:00Z",
      "active": true,
      "failed_attempts": 0,
      "locked_until": null
    }
  ]
}

POST /admin/operators

Register a new operator. At least one of cert_fingerprint or gssapi_principal must be provided.

Request body:

{
  "name": "bob",
  "role": "auditor",
  "cert_fingerprint": "b2c3d4…",
  "gssapi_principal": null
}

cert_fingerprint is the lowercase hex SHA-256 digest of the DER-encoded client certificate leaf. The akamuctl operator add --cert-file command computes this automatically.

Response 201 Created:

{ "name": "bob", "created_at": "2026-05-02T10:00:00Z" }

Returns 409 Conflict when an operator with the same fingerprint or principal already exists.

GET /admin/operators/{id}

Show a single operator’s details.

Response 200 OK:

{
  "id": 3,
  "name": "alice",
  "role": "administrator",
  "cert_fingerprint": "a3b4c5…",
  "gssapi_principal": null,
  "created_at": "2026-05-01T09:00:00Z",
  "last_seen_at": "2026-05-02T08:30:00Z",
  "active": true,
  "failed_attempts": 0,
  "locked_until": null
}

Returns 404 Not Found when the ID does not exist.

PUT /admin/operators/{id}

Update operator fields. Only provided fields are changed; omitted fields remain unchanged.

Request body:

{
  "name": "Alice Smith",
  "role": "ca_operations",
  "cert_fingerprint": "d4e5f6…",
  "gssapi_principal": "alice@NEWREALM.COM"
}

All fields are optional. role must be one of administrator, ca_operations, ca_ra, or auditor when provided.

Response: 204 No Content on success, 404 Not Found when the ID does not exist.

PATCH /admin/operators/{id}

Update the active status or ca_id scope of an operator.

Request body:

{ "active": false }

or, to assign a CA scope to a ca_ra operator:

{ "ca_id": "rsa" }

Set active to false to deactivate, true to reactivate. Deactivating an operator immediately invalidates all of that operator’s active session tokens.

When ca_id is provided, the operator’s CA scope is updated. Setting role = "ca_ra" without also providing a non-empty ca_id (either in this request or already stored) is rejected with 422 Unprocessable Entity.

Response: 204 No Content on success, 404 Not Found when the ID does not exist.

POST /admin/operators/{id}/unlock

Reset the operator’s failed-authentication counter and clear the lockout timestamp (FIA_AFL.1). Use this when an operator has been locked out due to exceeding max_failed_auth.

Response: 204 No Content on success, 404 Not Found when the ID does not exist.

GET /admin/audit

Query the structured audit event log. See Audit Trail for details on the event taxonomy.

Query parameters:

ParameterDescription
typeFilter by event type string (e.g. cert.issue).
subjectFilter by subject (account UUID, certificate serial, JWK thumbprint, etc.).
fromRFC 3339 lower bound for occurred_at.
untilRFC 3339 upper bound for occurred_at.
outcomesuccess or failure.
limit1–1000, default 100.
offsetDefault 0.

Results are ordered newest-first.

Response 200 OK:

{
  "events": [
    {
      "occurred_at": "2026-05-02T08:30:00Z",
      "event_type": "cert.issue",
      "subject": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "principal": "acme:xZ9gF…",
      "outcome": "success",
      "detail": "{\"profile\":\"tlsserver\"}"
    }
  ],
  "total_since_startup": 5000,
  "limit": 100,
  "offset": 0
}

Error responses:

StatusCondition
400 Bad RequestThe from or until parameter is not a valid RFC 3339 timestamp. The response body includes a detail field describing the error.
500 Internal Server ErrorThe audit backend (journal namespace socket, JSONL file, or journalctl subprocess) is inaccessible. The response body includes {"status": 500, "detail": "journal query error"}.

GET /admin/profiles

List all loaded certificate profiles with their parameters.

Response 200 OK:

{
  "profiles": [
    {
      "id": "tlsserver",
      "description": "TLS server certificate",
      "validity_days": 90,
      "hash_alg": "SHA256",
      "extended_key_usages": ["serverAuth"],
      "issue_as_mtc": false
    }
  ]
}

GET /admin/profiles/{id}

Return a single certificate profile by ID.

Response 200 OK:

{
  "id": "codesigning",
  "description": "Code signing certificate",
  "validity_days": 365,
  "hash_alg": "SHA256",
  "key_usage_bits": null,
  "extended_key_usages": ["code_signing"],
  "crl_url": null,
  "ocsp_url": null,
  "allowed_key_types": null,
  "certificate_policies": null,
  "issue_as_mtc": false,
  "allowed_identifier_patterns": null,
  "identifier_match_all": false,
  "auth_hook": null,
  "auth_hook_timeout_secs": null,
  "require_account_grant": true,
  "ca_ids": null
}

Returns 404 Not Found when no profile with the given ID is loaded.

Requires any authenticated role.

POST /admin/profiles

Add a new certificate profile to the runtime cache (FPT_NPE_EXT.1). Requires the administrator role.

Request body:

{
  "id": "codesigning",
  "description": "Code signing certificate",
  "validity_days": 365,
  "hash_alg": "sha256",
  "extended_key_usages": ["code_signing"],
  "require_account_grant": true
}

All fields except id are optional and have defaults (90 days validity, sha256 hash, no extended key usage restriction). Returns 409 Conflict when a profile with the same id already exists.

Response 201 Created:

{ "id": "codesigning", "description": "Code signing certificate" }

PUT /admin/profiles/{id}

Replace an existing certificate profile in the runtime cache (FPT_NPE_EXT.1). The profile is identified by {id} in the URL path; the request body uses the same schema as POST /admin/profiles but without the id field. Requires the administrator role.

Response: 204 No Content on success, 404 Not Found when the profile does not exist.

DELETE /admin/profiles/{id}

Remove a certificate profile from the runtime cache (FPT_NPE_EXT.1). Requires the administrator role.

Response: 204 No Content on success, 404 Not Found when the profile does not exist.

GET /admin/accounts

List ACME accounts with optional filtering and pagination.

Query parameters:

ParameterDescription
ca_idFilter by CA ID. Only accounts registered via the named CA’s new-account endpoint are returned.
statusFilter by account status (valid or deactivated).
limit1–1000, default 100.
offsetDefault 0.

Response 200 OK:

{
  "accounts": [
    {
      "id": "d290f1ee-…",
      "status": "valid",
      "contact": "[\"mailto:admin@example.com\"]",
      "jwk_thumbprint": "xZ9gF…",
      "created": 1746154800,
      "updated": 1746241200,
      "profile_grants": "[\"tlsserver\"]"
    }
  ],
  "limit": 100,
  "offset": 0
}

GET /admin/account/{id}

Show a single account’s details.

Response 200 OK:

{
  "id": "d290f1ee-…",
  "status": "valid",
  "contact": "[\"mailto:admin@example.com\"]",
  "jwk_thumbprint": "xZ9gF…",
  "created": 1746154800,
  "updated": 1746241200,
  "profile_grants": "[\"tlsserver\"]"
}

Returns 404 Not Found when the account does not exist.

POST /admin/account/{id}/deactivate

Admin-initiated account deactivation. Sets the account status to deactivated. The account can no longer create orders or issue certificates.

Response: 204 No Content on success, 404 Not Found when the account does not exist.

GET /admin/account/{id}/profile-grants

Return the profile grant list for account {id}. null means the account has no restrictions and may request any profile.

Response 200 OK:

{ "profile_grants": ["tlsserver", "codesigning"] }

or

{ "profile_grants": null }

PUT /admin/account/{id}/profile-grants

Replace the account’s profile grant list.

Request body:

{ "profile_grants": ["tlsserver"] }

Response: 204 No Content.

DELETE /admin/account/{id}/profile-grants

Clear all profile restrictions. Sets profile_grants to null (unrestricted).

Response: 204 No Content.

GET /admin/certs

Search the certificate table.

Query parameters: ca_id (filter by CA ID), serial, subject (subject DN substring match), account_id, after (RFC 3339), before (RFC 3339), status (active or revoked), limit (1–1000, default 100), offset.

Response 200 OK:

{
  "certs": [
    {
      "id": "3fa85f64-…",
      "account_id": "d290f1ee-…",
      "serial_number": "0a1b2c3d",
      "status": "active",
      "not_before": "2026-05-01T00:00:00Z",
      "not_after": "2026-07-30T00:00:00Z",
      "revoked_at": null,
      "revocation_reason": null
    }
  ],
  "limit": 100,
  "offset": 0
}

GET /admin/certs/{id}

Show a single certificate’s metadata. Does not return the PEM or DER content (use the download endpoint for that).

Response 200 OK:

{
  "id": "3fa85f64-…",
  "order_id": "7b2e1a3f-…",
  "account_id": "d290f1ee-…",
  "serial_number": "0a1b2c3d",
  "status": "active",
  "not_before": "2026-05-01T00:00:00Z",
  "not_after": "2026-07-30T00:00:00Z",
  "revoked_at": null,
  "revocation_reason": null,
  "mtc_log_index": null,
  "created": 1746154800,
  "suggested_window_start": 1750000000,
  "suggested_window_end": 1751000000,
  "replaced_by": null
}

Returns 404 Not Found when the certificate does not exist.

GET /admin/certs/{id}/download

Download a certificate’s content as PEM or DER.

Query parameters:

ParameterDescription
formatpem (default) or der.

Response 200 OK:

  • PEM format: Content-Type: application/pem-certificate-chain
  • DER format: Content-Type: application/pkix-cert

Returns 404 Not Found when the certificate does not exist.

POST /admin/eab

Provision a new External Account Binding key. Requires the administrator or ca_operations role.

Request body:

{
  "kid": "my-device-001",
  "hmac_key_b64u": "c2VjcmV0LWhtYWMta2V5LWJ1ZmZlcg",
  "profile_grants": ["tlsserver"],
  "alg": "sha256",
  "for_operator_id": 3
}
FieldRequiredDescription
kidYesUnique key identifier string.
hmac_key_b64uYesBase64url-encoded raw HMAC key bytes (no padding).
profile_grantsNoArray of profile names the EAB key pre-authorizes. Omit or set to null for an unrestricted key.
algNoHMAC algorithm: "sha256" (default), "sha384", or "sha512".
for_operator_idNoAdministrator only. When set, created_by_operator_id on the new key is set to this operator ID instead of the calling operator. This controls which operator the key is associated with for POST /admin/session/eab web UI login.

EAB keys are server-global and are not bound to any CA, even when created by a scoped ca_operations operator. Only administrator may set for_operator_id; a ca_operations caller that includes it receives 403 Forbidden.

Returns 409 Conflict when the kid already exists.

Response 201 Created:

{ "kid": "my-device-001", "created": 1746154800, "alg": "sha256" }

GET /admin/eab/{kid}

Show a single EAB key’s details.

Response 200 OK:

{
  "kid": "my-device-001",
  "created": 1746154800,
  "used_at": null,
  "profile_grants": "[\"tlsserver\"]"
}

Returns 404 Not Found when the key does not exist.

DELETE /admin/eab/{kid}

Deactivate an EAB key. The key is removed from the table; any previously issued HMAC credentials for this kid are permanently invalidated.

Response: 204 No Content, 404 Not Found when the key does not exist.

GET /admin/eab

List EAB keys.

Query parameters: used (true/false to filter by usage status), limit (1–1000, default 200), offset.

Response 200 OK:

{
  "eab_keys": [
    {
      "kid": "my-device-001",
      "created": 1746154800,
      "used_at": null,
      "profile_grants": "[\"tlsserver\"]"
    }
  ]
}

GET /admin/orders

List certificate orders with optional filtering and pagination.

Query parameters:

ParameterDescription
ca_idFilter by CA ID.
account_idFilter by account UUID.
statusFilter by order status (pending, ready, processing, valid, invalid).
limit1–1000, default 100.
offsetDefault 0.

Response 200 OK:

{
  "orders": [
    {
      "id": "7b2e1a3f-…",
      "account_id": "d290f1ee-…",
      "status": "valid",
      "identifiers": "[{\"type\":\"dns\",\"value\":\"example.com\"}]",
      "certificate_id": "3fa85f64-…",
      "profile": "tlsserver",
      "created": 1746154800,
      "updated": 1746241200,
      "expires": 1746760800
    }
  ],
  "limit": 100,
  "offset": 0
}

GET /admin/orders/{id}

Show a single order’s details, including authorization IDs.

Response 200 OK:

{
  "id": "7b2e1a3f-…",
  "account_id": "d290f1ee-…",
  "status": "valid",
  "identifiers": "[{\"type\":\"dns\",\"value\":\"example.com\"}]",
  "certificate_id": "3fa85f64-…",
  "profile": "tlsserver",
  "created": 1746154800,
  "updated": 1746241200,
  "expires": 1746760800,
  "not_before": null,
  "not_after": null,
  "replaces": null,
  "authorization_ids": ["a1b2c3d4-…", "e5f6a7b8-…"]
}

Returns 404 Not Found when the order does not exist.

GET /admin/config

Show the server’s redacted runtime configuration. Sensitive values such as the database URL are masked.

Response 200 OK:

{
  "base_url": "https://acme.example.com",
  "db_url": "***",
  "mtc_enabled": false,
  "caa_identities": ["example.com"],
  "validate_dnssec": true
}

POST /admin/crl/force

Force immediate CRL regeneration. The cached CRL is invalidated so the next GET /ca/crl request produces a fresh CRL reflecting all current revocations.

Response: 204 No Content.

POST /admin/revoke

Revoke a certificate by its internal ID.

Request body:

{ "cert_id": "3fa85f64-…", "reason": 1 }

reason is an RFC 5280 reason code (0 = unspecified, 1 = keyCompromise, 3 = affiliationChanged, 4 = superseded, 5 = cessationOfOperation, etc.). Revocation immediately invalidates the CRL cache.

Response: 204 No Content, 404 Not Found when the certificate is not found or is already revoked.

GET /admin/stats

Return live server statistics. All authenticated roles may call this endpoint.

Response 200 OK:

{
  "server_version": "0.1.0",
  "uptime_secs": 3600,
  "accounts": { "total": 42, "active": 40 },
  "certs":    { "total": 200, "active": 180, "revoked": 20 },
  "eab_keys": { "total": 10, "used": 8, "unused": 2 },
  "audit_events": { "since_startup": 5000 }
}

CA management endpoints

GET /admin/cas

List all configured CAs.

Response 200 OK:

{
  "cas": [
    {
      "id": "rsa",
      "is_default": true,
      "key_type": "rsa:4096",
      "hash_alg": "sha256",
      "crl_url": "http://acme.example.com/ca/rsa/crl",
      "ocsp_url": "http://acme.example.com/ca/rsa/ocsp"
    }
  ]
}

GET /admin/cas/{id}

Show details of a single CA including the CA certificate PEM.

Response 200 OK:

{
  "id": "rsa",
  "is_default": true,
  "key_type": "rsa:4096",
  "hash_alg": "sha256",
  "crl_url": "http://acme.example.com/ca/rsa/crl",
  "ocsp_url": "http://acme.example.com/ca/rsa/ocsp",
  "cert_pem": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n"
}

Returns 404 Not Found when the CA ID does not exist.

POST /admin/ca/{id}/crl/force

Force immediate CRL regeneration for the specified CA. The cached CRL is invalidated so the next GET /ca/{id}/crl request produces a fresh CRL reflecting all current revocations for that CA.

Requires the ca_operations or administrator role.

Response: 204 No Content. Returns 404 Not Found when the CA ID does not exist.

POST /admin/ca/{id}/cross-sign

Issue a cross-certificate: CA {id} (the issuer) signs the public key of another CA or an externally supplied certificate. The resulting cross-cert is stored in the database and retrievable via GET /admin/cross-certs/{id} and the public GET /ca/{subject_id}/cross-certs endpoint.

The issued cross-certificate always has pathLenConstraint = 0 — the subject CA cannot use it to issue further subordinate CAs.

Request body (exactly one of subject_ca_id or subject_cert_pem must be provided):

{ "subject_ca_id": "ec", "validity_years": 5 }

or

{ "subject_cert_pem": "-----BEGIN CERTIFICATE-----\n…", "validity_years": 5 }

Requires the administrator or ca_operations role.

Response 201 Created:

{ "id": "a1b2c3d4-…", "created_at": "2026-05-06T12:00:00Z" }

Returns 404 Not Found when the issuer CA ID or subject_ca_id does not exist.

GET /admin/cross-certs

List stored cross-certificates.

Query parameters:

ParameterDescription
issuer_ca_idFilter by issuing CA ID.
subject_ca_idFilter by subject CA ID.
limit1–1000, default 100.
offsetDefault 0.

Response 200 OK:

{
  "cross_certs": [
    {
      "id": "a1b2c3d4-…",
      "issuer_ca_id": "rsa",
      "subject_ca_id": "ec",
      "not_before": "2026-05-06T12:00:00Z",
      "not_after": "2031-05-06T12:00:00Z",
      "created_at": "2026-05-06T12:00:00Z"
    }
  ],
  "limit": 100,
  "offset": 0
}

GET /admin/cross-certs/{id}

Show a single cross-certificate by UUID, including its PEM.

Response 200 OK:

{
  "id": "a1b2c3d4-…",
  "issuer_ca_id": "rsa",
  "subject_ca_id": "ec",
  "not_before": "2026-05-06T12:00:00Z",
  "not_after": "2031-05-06T12:00:00Z",
  "created_at": "2026-05-06T12:00:00Z",
  "cert_pem": "-----BEGIN CERTIFICATE-----\n…\n-----END CERTIFICATE-----\n"
}

Returns 404 Not Found when the cross-cert ID does not exist.

Delegation management endpoints

These endpoints are active only when server.delegation_enabled = true is set in the configuration. Delegations represent pre-configured RFC 9115 IdO-to-NDC delegation policies: a CSR template and an optional CNAME map. Read operations (GET) are available to all authenticated roles; write operations (POST, PUT, DELETE) require the administrator or ca_operations role.

GET /admin/delegations

List all delegation objects. Optionally filter by account.

Query parameters:

ParameterDescription
account_idFilter by ACME account UUID.
limit1–1000, default 100.
offsetDefault 0.

Response 200 OK:

{
  "delegations": [
    {
      "id": "b1c2d3e4-…",
      "account_id": "d290f1ee-…",
      "csr_template": "{…}",
      "cname_map": null,
      "created": 1746154800,
      "updated": 1746154800
    }
  ],
  "limit": 100,
  "offset": 0
}

POST /admin/delegations

Create a new delegation object. The csr_template field is validated against the RFC 9115 §4 schema at write time; a malformed template is rejected with 400 Bad Request.

Request body:

{
  "account_id": "d290f1ee-…",
  "csr_template": {
    "keyTypes": [{"type": "EC", "curve": "P-256"}],
    "subject": {"commonName": {}, "organization": "ExampleCorp"},
    "extensions": {
      "subjectAltName": {},
      "keyUsage": ["digitalSignature"],
      "extendedKeyUsage": ["1.3.6.1.5.5.7.3.1"]
    }
  },
  "cname_map": null
}

cname_map is optional. When present it is a JSON object mapping source FQDNs to target FQDNs (e.g. {"cdn.example.com": "cdn.provider.example"}).

Response 201 Created:

{ "id": "b1c2d3e4-…", "created": 1746154800 }

GET /admin/delegations/{id}

Fetch a single delegation object by UUID.

Response 200 OK:

{
  "id": "b1c2d3e4-…",
  "account_id": "d290f1ee-…",
  "csr_template": "{…}",
  "cname_map": null,
  "created": 1746154800,
  "updated": 1746154800
}

Returns 404 Not Found when the ID does not exist.

PUT /admin/delegations/{id}

Replace the csr_template and/or cname_map of an existing delegation. The csr_template is re-validated at write time. Only csr_template and cname_map may be updated; account_id is immutable.

Request body:

{
  "csr_template": {…},
  "cname_map": {"cdn.example.com": "cdn.provider.example"}
}

Response: 204 No Content on success, 404 Not Found when the ID does not exist.

DELETE /admin/delegations/{id}

Delete a delegation object. Returns 409 Conflict when one or more orders still reference this delegation (the orders must be finalized or deleted first).

Response: 204 No Content, 404 Not Found when the ID does not exist, 409 Conflict when orders reference it.

Audit events

Every write operation emits a structured audit event to the configured audit backend (systemd journal namespace, JSONL file, or in-process store):

OperationEvent type
POST /admin/delegationsdelegation.create
PUT /admin/delegations/{id}delegation.update
DELETE /admin/delegations/{id}delegation.delete

Query these events with GET /admin/audit?type=delegation.create or akamuctl audit --type delegation.create.

CLI

All delegation management endpoints are wrapped by akamuctl delegation. See akamuctl — Admin CLI for the full command reference including flags and examples.

Audit trail

Every admin operation is written to a structured audit backend. Three backends are available:

  1. Systemd journal namespace (default) — when running under systemd with LogNamespace=akamu (see contrib/systemd/akamu.service), events are stored in /var/log/journal/<machine-id>.akamu/.
  2. JSONL file — when [server].audit_log_file is set, events are written as append-only JSON Lines to the specified file. External logrotate(8) with copytruncate is expected for rotation. Each query scans at most 500,000 lines to prevent unbounded reads on unrotated files.
  3. In-process store — in tests or development without systemd and without a configured file, an in-memory store is used automatically.

Each journal entry carries structured fields:

Journal fieldContent
AKAMU_EVENT_TYPEEvent type string (e.g. cert.issue, admin.login)
AKAMU_SUBJECTResource identifier (account UUID, certificate serial, etc.)
AKAMU_PRINCIPALAuthenticated operator name or acme:<jwk_thumbprint>
AKAMU_OUTCOMEsuccess or failure
AKAMU_DETAILJSON object with operation-specific fields

Query examples:

journalctl --namespace=akamu                              # all audit events
journalctl --namespace=akamu AKAMU_EVENT_TYPE=cert.issue   # by type
journalctl --namespace=akamu AKAMU_OUTCOME=failure         # failures only

Retention is managed by journald itself (see contrib/systemd/journald@akamu.conf for default settings: 500 MB disk, 1 year max age).

Overflow policy (FAU_STG.4)

When audit_max_events is set (backward-compatible alias: audit_max_rows), the server tracks an in-memory event count since startup. The audit_overflow policy determines what happens when the count reaches the limit. The default is "drop_oldest", which is effectively a no-op (journald or the file backend manages its own retention). The alternative "halt" refuses all new requests until the server is restarted.

Alarm response (FAU_ARP.1)

The server maintains an in-memory rolling 5-minute count of security.violation audit events. When the count reaches audit_alarm_threshold (default 10), the audit_alarm_action fires:

  • "syslog" (default) — a CRIT-level message is emitted via tracing, which is forwarded to the system log by the process manager.
  • "halt" — the server stops accepting new requests until restarted.

The halt flag is also set when the "halt" overflow policy is triggered.

Operator management workflow

Initial setup

Akāmu auto-provisions the first administrator on first run. Add two keys to [admin] that point to where the bootstrap certificate and key should live:

[admin]
listen_addr    = "127.0.0.1:9443"
cert_file      = "/etc/akamu/admin-tls.pem"
key_file       = "/etc/akamu/admin-tls-key.pem"
ca_certs       = ["/etc/akamu/ca.pem"]

# Bootstrap operator — generated automatically on first run.
bootstrap_operator_cert_file = "/etc/akamu/admin-bootstrap.pem"
bootstrap_operator_key_file  = "/etc/akamu/admin-bootstrap-key.pem"
# bootstrap_operator_name    = "admin"   # default
# bootstrap_key_type         = "ec:P-256"  # default

On the first startup, if both files are absent and the operators table is empty, Akāmu:

  1. Generates a fresh private key (using bootstrap_key_type).
  2. Issues a client certificate signed by the Akāmu CA with CN=<bootstrap_operator_name>.
  3. Writes the key and certificate PEM files to the configured paths.
  4. Registers the certificate’s SHA-256 fingerprint in the operators table with the administrator role.

Both the admin listener TLS certificate (cert_file/key_file) and the bootstrap operator cert are auto-generated if absent; the admin listener cert uses server_name (default "localhost") as the CN/SAN.

After first boot, use the bootstrap cert to authenticate and provision real operator accounts:

# Add a permanent operator with their own client cert.
akamuctl --cert /etc/akamu/admin-bootstrap.pem \
         --key  /etc/akamu/admin-bootstrap-key.pem \
    operator add --name alice --role administrator \
                 --cert-file /etc/akamu/alice-client.pem

# Deactivate the bootstrap operator once a permanent one is in place.
akamuctl --cert /etc/akamu/admin-bootstrap.pem \
         --key  /etc/akamu/admin-bootstrap-key.pem \
    operator remove 1

Note: If the bootstrap cert/key files are absent but the operators table already contains rows (e.g. after a mistaken file deletion), Akāmu refuses to start with an error rather than silently creating a duplicate administrator. Restore the files from backup, or remove bootstrap_operator_cert_file and bootstrap_operator_key_file from the config and manage operators entirely through akamuctl.

Revoking access

Deactivate an operator with akamuctl operator remove <id> or PATCH /admin/operators/{id} with {"active":false}. The record is preserved for audit trail continuity. The operator’s active sessions are invalidated immediately and they cannot authenticate again until reactivated.

ACME Protocol Reference

This page is the protocol-level API reference for clients interacting with Akāmu: which JWS algorithms the server accepts, which challenge types are offered and for which identifier types, the wire format for EAB credentials, and the endpoint contract for ARI and ACME STAR.

For implementation notes — how the server verifies these on the wire, DER encoding helpers, and pre-issuance linting — see RFC Compliance Internals.

JWS algorithm support (RFC 8555 §6.2)

All ACME POST requests must be signed with JWS flattened JSON serialization (RFC 7515 §7.2.6). The server accepts the following alg values in the JWS protected header:

algKey typeCurve / variant
RS256RSASHA-256
RS384RSASHA-384
RS512RSASHA-512
PS256RSA-PSSSHA-256
PS384RSA-PSSSHA-384
PS512RSA-PSSSHA-512
ES256ECP-256
ES384ECP-384
ES512ECP-521
EdDSAOKPEd25519 or Ed448
ML-DSA-44AKPFIPS 204 ML-DSA-44
ML-DSA-65AKPFIPS 204 ML-DSA-65
ML-DSA-87AKPFIPS 204 ML-DSA-87

Any other alg value returns badSignatureAlgorithm (HTTP 400). ECDSA signatures use IEEE P1363 encoding (raw r||s).

ML-DSA signature wire format (RFC 9964)

ML-DSA signatures in JOSE are raw bytes per FIPS 204 §7.2 — not DER-wrapped. The server checks the signature length before verification:

AlgorithmExpected signature length
ML-DSA-442420 bytes
ML-DSA-653309 bytes
ML-DSA-874627 bytes

A length mismatch causes an immediate badSignatureAlgorithm error. The signing context must be an empty byte string per RFC 9964 §4.

JWK thumbprint for AKP keys (ML-DSA)

Per RFC 9964 §6, the canonical JSON for computing the RFC 7638 thumbprint of an ML-DSA public key is:

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

Members in lexicographic order: alg, kty, pub. The pub field contains the raw public key bytes (no DER wrapping).

Supported challenge types

The server offers the following challenge types per identifier type:

Challenge typeIdentifier typesSpecification
http-01dns, ipRFC 8555 §8.3
dns-01dnsRFC 8555 §8.4
tls-alpn-01dns, ipRFC 8737 / RFC 8738 §4
dns-persist-01dnsdraft-ietf-acme-dns-persist
onion-csr-01dns (.onion only)RFC 9799 §3.2

dns-persist-01 is only offered when the server is configured with at least one dns_persist_issuer_domains entry.

onion-csr-01 is offered exclusively for .onion (Tor v3 hidden service) identifiers. The server rejects v2 .onion addresses.

IP identifiers (RFC 8738)

"type": "ip" identifiers in new-order requests are accepted per RFC 8738. Two challenge types are offered for IP identifiers:

  • http-01 — standard HTTP challenge connecting to the IP address directly.
  • tls-alpn-01 — per RFC 8738 §4, the TLS SNI is the reverse-DNS form of the IP (arpa. suffix), and the acmeIdentifier extension carries an iPAddress GeneralName rather than dNSName.

dns-01 is not offered for IP identifiers.

EAB JWS wire format (RFC 8555 §7.3.4)

When External Account Binding is required, the externalAccountBinding field of the newAccount payload must be a JWS Flattened JSON Serialization:

{
  "protected": "<base64url(JSON protected header)>",
  "payload":   "<base64url(JSON public JWK of the account key)>",
  "signature": "<base64url(HMAC over 'protected.payload')>"
}

The protected header must contain:

{ "alg": "HS256", "kid": "<eab-key-id>", "url": "<new-account endpoint URL>" }

The signing input is the ASCII concatenation "{protected}.{payload}". The payload must be the canonical JSON representation of the account’s public JWK. The server verifies the payload JWK thumbprint matches the outer account key.

EAB algorithm support

EAB algHMAC hash function
HS256SHA-256
HS384SHA-384
HS512SHA-512

Any other alg value returns badRequest (HTTP 400).

Renewal Information / ARI (RFC 9773)

Endpoint: GET /acme/renewal-info/{cert_id}
Per-CA variant: GET /acme/{ca_id}/renewal-info/{cert_id}

The cert_id path parameter is base64url(AKI) "." base64url(serial) per RFC 9773 §4.1. The server returns 404 if the AKI does not match this CA’s key identifier.

The response is plain JSON (not wrapped in a JWS envelope) with content type application/json:

{
  "suggestedWindow": {
    "start": "<RFC 3339 timestamp>",
    "end":   "<RFC 3339 timestamp>"
  },
  "explanationURL": "<url>"   // present only when configured
}

The default window starts at two-thirds of the certificate lifetime and ends one day before expiry. Operators can override this per-certificate via the admin API. The Retry-After response header is set per RFC 9773 §4.3.

ACME STAR — short-term auto-renewal (RFC 8739)

STAR certificates are issued and renewed automatically by the server. The auto-renewal object in the new-order payload accepts:

FieldMeaning
start-dateISO 8601 date when auto-renewal begins
end-dateISO 8601 date when auto-renewal stops
lifetimePer-certificate validity duration (seconds)
lifetime-adjustOptional clock-skew window (seconds)
allow-certificate-gettrue to allow unauthenticated certificate retrieval

The current STAR certificate is available at:

GET /acme/cert/star/{order_id}

Unauthenticated access is gated on both the allow-certificate-get order field and the server-level star_allow_certificate_get config flag. The response includes Cert-Not-Before and Cert-Not-After headers (RFC 8739 §3.3).

To cancel a STAR order, POST {"status":"canceled"} to POST /acme/order/{id}. Subsequent certificate requests return autoRenewalCanceled.

Client Libraries Overview

API Reference — This section is for developers consuming Akāmu’s HTTP APIs or using the Rust client libraries. It covers the Admin REST API, ACME protocol wire formats, and the akamu-jose / akamu-client / akamu-cli SDK. If you are deploying or operating the server, see the Operator Guide. If you are contributing to Akāmu itself, see the Implementation Guide.

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

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.

Getting started

Add crates to your Cargo.toml

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

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

Verify the build

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

Further reading

akamu-jose — JWK/JWS Primitives

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

What it provides

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

What it does NOT provide

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

For those, use akamu-client.

JwkPublic

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

Parsing a JWK

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

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

Computing a thumbprint

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

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

Converting to SPKI DER

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

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

Constructing from a public key

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

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

JwsFlattened

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

Signing

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

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

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

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

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

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

ML-DSA signatures are produced over the raw signing input using an empty context string as required by RFC 9964 §4.

Verifying

verify checks the signature against a provided SPKI DER:

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

Decoding header and payload

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

JwsProtectedHeader

The decoded ACME JWS protected header. Fields:

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-44LWERFC 9964
ML-DSAML-DSA-65LWERFC 9964
ML-DSAML-DSA-87LWERFC 9964

JoseError

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

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

  • 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)
  • DNS hook solver (DnsHookSolver) — delegates TXT record management to an external script
  • onion-csr-01 CSR builder (build_onion_csr, RFC 9799)
  • A ChallengeSolver trait for custom solvers
  • AccountKey::from_jwk_private — load an account key from a certbot-style private JWK
  • RenewalConfig — serialisable struct for persisting renewal parameters alongside a certificate

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

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

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

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

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

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

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

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

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

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

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

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

        solver.cleanup(token).await?;
    }

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

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

AccountKey

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

Generating a key

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

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

Saving and loading

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

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

Loading from a certbot JWK

AccountKey::from_jwk_private parses a private JWK JSON string (the format used by certbot’s private_key.json) and returns an AccountKey. Supported key types: EC P-256, P-384, P-521, and RSA.

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

let jwk_json = std::fs::read_to_string("/etc/letsencrypt/accounts/.../private_key.json")?;
let key = AccountKey::from_jwk_private(&jwk_json)?;
}

This is the same conversion performed internally by akamu-cli import certbot.

Thumbprint and key authorization

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

JWS algorithm

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

AcmeClient

Directory discovery

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

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

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

Account registration

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

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

The returned Account contains:

  • account.url — the account URL (a.k.a. kid), used in subsequent 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.

GSSAPI-authenticated EAB fetch

When the ACME server uses Kerberos to authenticate EAB requests, use fetch_eab_via_gssapi to retrieve the authenticated principal identity from the server’s GET /acme/eab endpoint before registering:

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

let result: GssapiEabResult = fetch_eab_via_gssapi(
    "https://acme.example.com/acme/eab",
    "/etc/akamu/client.keytab",
).await?;
println!("Authenticated as: {}", result.principal);
}

The function:

  1. Loads an initiator credential from keytab_file via akamu-gssapi.
  2. Derives the target service name as HTTP@<hostname> from the URL.
  3. Calls gss_init_sec_context (via tokio::task::spawn_blocking) to produce a Kerberos token.
  4. Sends GET <eab_url> with Authorization: Negotiate <base64-token>.
  5. Parses {"principal": "..."} from the response body.

The returned GssapiEabResult currently contains only the principal field. Full EAB key derivation is not yet implemented server-side; the endpoint echoes the authenticated principal name as confirmation that Kerberos authentication succeeded.

Both fetch_eab_via_gssapi and GssapiEabResult are re-exported from the crate root.

Order lifecycle

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

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

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

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

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

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

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

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

new_order

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

get_authorization

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

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

trigger_challenge

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

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

trigger_challenge_onion (RFC 9799)

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

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

poll_order

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

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

finalize

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

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

download_certificate

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

Certificate revocation (RFC 8555 §7.6)

Via account key

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

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

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

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

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

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

ARI renewal information (RFC 9773)

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

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

STAR order API (RFC 8739)

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

Placing a STAR order

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

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

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

DnsHookSolver

DnsHookSolver implements ChallengeSolver by delegating TXT record management to an external script. It is designed for automated pipelines where a registrar API or DDNS tool handles DNS updates.

The hook is invoked as:

<hook_script> add
<hook_script> remove

All values are passed through environment variables only (never as command-line arguments):

VariableValue
AKAMU_DOMAINDNS name being validated (wildcard prefix stripped)
AKAMU_TOKENACME challenge token
AKAMU_TXTTXT record value: base64url(SHA-256(key_authorization))
AKAMU_KEY_AUTHFull key authorization string (<token>.<jwk_thumbprint>)

Exit code 0 is success. Non-zero causes a ClientError that includes captured stderr.

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

let solver = DnsHookSolver::new("/etc/akamu/hooks/dns-update.sh".to_string());

// Before triggering the ACME challenge:
solver.deploy(&domain, token, &key_auth).await?;
client.trigger_challenge(&account, &challenge).await?;
client.poll_order(&account, &order.url).await?;

// After the challenge completes (success or failure):
solver.clean(&domain, token, &key_auth).await?;
}

For dns-persist-01, call only deploy; do not call clean on success because the TXT record is long-lived.

build_onion_csr (RFC 9799)

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

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

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

build_csr

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

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

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

pem_to_der

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

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

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

RenewalConfig

RenewalConfig is a serialisable/deserialisable struct that captures every parameter needed to repeat a certificate issuance. It is used by akamu-cli issue (written to <out>.renewal.toml) and by akamu-cli import certbot (generated from certbot’s renewal config).

#![allow(unused)]
fn main() {
use akamu_client::{RenewalConfig, Identifier};
use std::path::PathBuf;

let config = RenewalConfig {
    server: "https://acme.example.com/acme/directory".into(),
    domains: vec![Identifier::dns("example.com")],
    account_key: PathBuf::from("/etc/akamu/account.pem"),
    account_key_type: "ec:P-256".into(),
    cert_path: PathBuf::from("/etc/ssl/example.com/fullchain.pem"),
    cert_key_path: PathBuf::from("/etc/ssl/example.com/fullchain.pem.key.pem"),
    cert_key_type: "ec:P-256".into(),
    challenge_type: "dns-01".into(),
    http_port: 80,
    tls_port: 443,
    onion_key: None,
    poll_timeout: 120,
    contacts: vec!["mailto:admin@example.com".into()],
    eab_kid: None,
    eab_key: None,
    eab_alg: "HS256".into(),
    gssapi_keytab: None,
    dns_hook: Some("/etc/akamu/hooks/dns-update.sh".into()),
};

let toml = toml::to_string_pretty(&config)?;
std::fs::write("fullchain.pem.renewal.toml", &toml)?;

// Round-trip:
let loaded: RenewalConfig = toml::from_str(&toml)?;
}

Fields with defaults (account_key_type, cert_key_type, challenge_type, http_port, tls_port, poll_timeout, eab_alg) are optional in TOML files; missing fields are filled with sensible defaults on deserialisation. The gssapi_keytab field is also optional (defaults to absent when not present in the file, via #[serde(default)]).

ClientError

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

Handle Acme errors by inspecting acme_type:

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

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

akamu-cli — Command Reference

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

Installation

Build from source

cargo build -p akamu-cli --release

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

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

cargo install (from a local checkout)

cargo install --path crates/akamu-cli

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

Command tree

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

Global flags

FlagDescription
-vEnable debug logging for akamu_client. When --server-ca is in use (i.e., issue or renew), also logs TLS certificate details (subject, issuer, validity, signature algorithm) for the ACME server and any CA certificates loaded via --server-ca.
-vvAll of -v, plus enables debug logging for hyper_util and rustls (HTTP internals and TLS handshake details).

These flags are global and may be placed immediately after akamu-cli, before the subcommand name. The RUST_LOG environment variable still works for fine-grained control (see Logging).

Subcommands

account register

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

akamu-cli account register --server URL --account-key FILE [OPTIONS]
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. Mutually exclusive with --gssapi-keytab.
--eab-key KEY_B64UnoEAB HMAC key encoded as base64url (no padding). Mutually exclusive with --gssapi-keytab.
--eab-alg ALGnoEAB HMAC algorithm: HS256, HS384, or HS512. Default: HS256.
--gssapi-keytab PATHnoPath to a Kerberos keytab file. When set, the CLI performs a GSSAPI-authenticated GET /acme/eab request and logs the authenticated principal. Mutually exclusive with --eab-kid / --eab-key.

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

account deregister

Deactivate an existing account (RFC 8555 §7.3.7).

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

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

account show

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

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

Output example:

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

account update

Update the contact list for an existing account.

akamu-cli account update --server URL --account-key FILE [--contact URI ...]
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, tkauth-01. Default: http-01.
--dns-hook CMDnoHook script for dns-01 / dns-persist-01 automation. See DNS hook interface.
--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.
--tkauth-url URLrequired for tkauth-01Token Authority URL. Accepts https://, and http+unix://ENCODED_PATH/path for a local Token Authority reachable via a Unix domain socket.
--tkauth-keytab FILErequired for tkauth-01Path to a Kerberos keytab file used for SPNEGO authentication to the Token Authority.
--jwtcc B64URLnoBase64url-encoded DER JWTClaimConstraints blob. When provided with --challenge tkauth-01, the order uses a JWTClaimConstraints identifier instead of dns; --domain is not required.
--poll-timeout SECSnoMaximum seconds to wait for order/challenge validation. Default: 120.
--profile NAMEnoCertificate profile name (draft-aaron-acme-profiles-01). Sent as "profile" in the newOrder payload. The server may echo a different name (e.g. "default") if auto-selection applies; the echoed value is stored in the renewal sidecar.
--server-ca FILEnoPEM file containing an extra CA certificate to trust for the ACME server’s TLS connection. Use when the server uses a private CA not in the system trust store.
--eab-kid KIDnoEAB key ID (used if no account exists yet). Mutually exclusive with --gssapi-keytab.
--eab-key KEY_B64UnoEAB HMAC key (base64url). Mutually exclusive with --gssapi-keytab.
--gssapi-keytab PATHnoPath to a Kerberos keytab file. When set and no account URL sidecar exists, the CLI performs a GSSAPI-authenticated GET /acme/eab before registering. Mutually exclusive with --eab-kid / --eab-key.

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

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

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

After every successful issuance, a renewal configuration sidecar is written to <out>.renewal.toml.

renew

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

akamu-cli renew --server URL --account-key FILE -d DOMAIN --out FILE [OPTIONS]
FlagRequiredDescription
--server URLyes (unless --renewal-config is used)ACME directory URL
--account-key FILEyes (unless --renewal-config is used)Account key PEM file
-d DOMAINyes (unless --renewal-config is used)Domains for the new certificate
--out FILEyes (unless --renewal-config is used)Output path for the new PEM bundle
--renewal-config FILEnoLoad all settings from a .renewal.toml file. Explicit CLI flags override individual fields from the file.
--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.
--dns-hook CMDnoHook script for dns-01 / dns-persist-01 automation. See DNS hook interface.
--http-port PORTnoDefault: 80.
--tls-port PORTnoDefault: 443.
--onion-key FILEnoRequired for onion-csr-01.
--tkauth-url URLno (required for tkauth-01)Token Authority URL for tkauth-01 challenges. Loaded from the renewal sidecar when --renewal-config is used.
--tkauth-keytab FILEno (required for tkauth-01)Kerberos keytab for SPNEGO authentication to the Token Authority.
--jwtcc B64URLnoBase64url-encoded JWTClaimConstraints blob for tkauth-01 orders.
--poll-timeout SECSnoDefault: 120.
--key-type TYPEnoAccount key type. Default: ec:P-256.
--cert-key-type TYPEnoCertificate key type. Default: ec:P-256.
--cert-key FILEnoReuse an existing certificate private key PEM file instead of generating a new one. Useful for HPKP / TLSA key pinning scenarios where the public key must remain stable across renewals.
--profile NAMEnoCertificate profile name (draft-aaron-acme-profiles-01). Stored in the renewal sidecar so subsequent renew calls request the same profile.
--server-ca FILEnoPEM file containing an extra CA certificate to trust for the ACME server’s TLS connection. Not stored in the renewal sidecar; must be re-supplied on each invocation when needed.
--eab-kid KIDnoEAB key ID. Mutually exclusive with --gssapi-keytab.
--eab-key KEY_B64UnoEAB HMAC key (base64url). Mutually exclusive with --gssapi-keytab.
--gssapi-keytab PATHnoPath to a Kerberos keytab file for GSSAPI-authenticated EAB. Mutually exclusive with --eab-kid / --eab-key.

Behavior when --cert is given (or when --renewal-config is used and the cert file from the config exists) and --force is not:

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

When --renewal-config FILE is provided, all settings are loaded from the TOML file (written by issue or import certbot). This makes cron-based renewal straightforward:

akamu-cli renew --renewal-config /etc/ssl/example.com/fullchain.pem.renewal.toml

revoke

Revoke an issued certificate (RFC 8555 §7.6).

akamu-cli revoke --server URL --account-key FILE --cert FILE [OPTIONS]
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.

import certbot

Import ACME account keys and certificate renewal configurations from an existing certbot installation.

akamu-cli import certbot [OPTIONS]
FlagRequiredDescription
--certbot-dir DIRnoCertbot configuration directory. Default: /etc/letsencrypt.
--account-key FILEyes (unless --list)Output path for the imported account key PEM. Must not already exist.
--server URLnoImport only the account registered with this CA URL. Required when multiple accounts are found.
-d DOMAINno (repeatable)Limit certificate import to these domains. Default: all discovered domains.
--cert-dir DIRyes (when importing certificates)Directory to write imported certificate chains and keys. Created if absent.
--dns-challenge TYPEnoChallenge type to use for DNS-based certbot configs: dns-01 or dns-persist-01. Default: dns-01.
--dns-hook CMDnoHook script to embed in generated renewal configs for DNS TXT record management.
--dry-runnoShow what would be done without writing any files.
--listnoList all discoverable accounts and certificate renewal configurations, then exit.

What the importer does

  1. Walks <certbot-dir>/accounts/<ca-hostname>/<account-id>/ to discover accounts. Each account directory must contain private_key.json (the account key in JWK format) and regr.json (the registration response with account URL and contacts).

  2. Converts the certbot JWK private key to a PEM file written to --account-key. Writes the account URL to <account-key>.account-url as an account URL sidecar.

  3. Walks <certbot-dir>/renewal/*.conf to discover certificate configurations. For each matching domain, copies live/<domain>/fullchain.pem and live/<domain>/privkey.pem into --cert-dir with mode 0o600, then writes a .renewal.toml sidecar (see Renewal configuration sidecar). If either the certificate chain or the private key file cannot be read (for example, due to a permissions issue), the renewal sidecar is not written for that domain and a warning is printed.

  4. Prints the akamu-cli renew --renewal-config command for each imported certificate.

Challenge type mapping

Certbot’s authenticator field is mapped to an akamu challenge type as follows:

Certbot authenticatorakamu challenge type
standalone, webroot, nginx, apachehttp-01
manual with preferred_challenges = dnsvalue of --dns-challenge
manual (no DNS preference)http-01
Any dns-* plugin (dns-cloudflare, dns-route53, etc.)value of --dns-challenge
tls-sni-01tls-alpn-01 (with a deprecation warning)

When a DNS-based challenge type is selected but no --dns-hook was supplied, the importer emits a warning for each affected domain (unless --dry-run is set) reminding you to add a hook script before the next renewal.

Certificate key type detection

The importer reads the actual key PEM file from live/<domain>/privkey.pem to determine the key type (ec:P-256, rsa:2048, etc.) stored in the renewal sidecar. This ensures the sidecar accurately reflects the key material rather than relying on filename heuristics.

Wildcard domain handling

Certbot stores wildcard certificates under live/_wildcard.<domain>/. The importer decodes this convention automatically: _wildcard.example.com becomes *.example.com in the generated renewal configuration file.

Prerequisites

  • The importer reads certbot’s live/ and accounts/ directories. On most systems these are owned by root with mode 0700. Run the importer as root or with sufficient privilege to read them.
  • The --account-key output path must not already exist.
  • All output files (account key, certificate chain, certificate key, renewal sidecar) are written with mode 0o600.

Example

# List what would be imported
akamu-cli import certbot --list

# Dry run (shows actions without writing files)
sudo akamu-cli import certbot \
  --account-key /etc/akamu/acct.pem \
  --cert-dir /etc/akamu/certs \
  --dry-run

# Full import from Let's Encrypt production, with a DNS hook for future renewals
sudo akamu-cli import certbot \
  --account-key /etc/akamu/acct.pem \
  --server https://acme-v02.api.letsencrypt.org/directory \
  --cert-dir /etc/akamu/certs \
  --dns-challenge dns-01 \
  --dns-hook /etc/akamu/hooks/dns-update.sh

# Import only specific domains
sudo akamu-cli import certbot \
  --account-key /etc/akamu/acct.pem \
  --cert-dir /etc/akamu/certs \
  -d example.com \
  -d "*.example.com"

After import, renew each certificate with:

akamu-cli renew --renewal-config /etc/akamu/certs/example.com.pem.renewal.toml

File permissions

All output files written by akamu-cli — account keys, certificate private keys, account URL sidecars, and renewal configuration sidecars — are created with mode 0o600 (owner read/write only). When overwriting a pre-existing file, the mode is explicitly re-applied so that stale group or world permissions left by a previous tool are corrected.

Sidecar files

Account URL sidecar

When you register an account (or when issue or import certbot creates one), akamu-cli writes the account URL to a file named <account-key>.account-url in the same directory as the account key. For example, if your account key is ~/.akamu/account.pem, the sidecar is ~/.akamu/account.pem.account-url.

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

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

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

Renewal configuration sidecar

After every successful issue (and after import certbot), akamu-cli writes a TOML file named <out>.renewal.toml alongside the certificate chain. This file captures every parameter needed to repeat the issuance and is consumed by renew --renewal-config.

File format

server          = "https://acme.example.com/acme/directory"
account_key     = "/etc/akamu/account.pem"
account_key_type = "ec:P-256"
cert_path       = "/etc/ssl/example.com/fullchain.pem"
cert_key_path   = "/etc/ssl/example.com/fullchain.pem.key.pem"
cert_key_type   = "ec:P-256"
challenge_type  = "dns-01"
http_port       = 80
tls_port        = 443
poll_timeout    = 120
eab_alg         = "HS256"

[[domains]]
type  = "dns"
value = "example.com"

[[domains]]
type  = "dns"
value = "*.example.com"

# Optional fields (omit when not applicable):
onion_key       = "/path/to/hs_key.pem"
contacts        = ["mailto:admin@example.com"]
eab_kid         = "kid-from-ca"
# eab_key is intentionally omitted: the HMAC key is never written to this file.
gssapi_keytab   = "/etc/akamu/http.keytab"
dns_hook        = "/etc/akamu/hooks/dns-update.sh"
profile         = "tlsserver"
tkauth_url      = "https://ta.example.com"
tkauth_keytab   = "/etc/akamu/ta-client.keytab"
# jwtcc is stored when --jwtcc was used at issuance time

Fields

FieldDefaultDescription
serverACME directory URL
domainsArray of {type, value} identifier objects
account_keyPath to the account private key PEM
account_key_type"ec:P-256"Key type string for the account key
cert_pathOutput path for the certificate chain PEM
cert_key_pathOutput path for the certificate private key PEM
cert_key_type"ec:P-256"Key type string for the certificate key
challenge_type"http-01"ACME challenge type
http_port80Port for the http-01 solver
tls_port443Port for the tls-alpn-01 solver
onion_keyPath to the Ed25519 onion service key (onion-csr-01 only)
tkauth_urlToken Authority URL (tkauth-01 only). Saved from --tkauth-url at issuance time.
tkauth_keytabPath to the Kerberos keytab for the Token Authority (tkauth-01 only).
jwtccBase64url-encoded JWTClaimConstraints blob (tkauth-01 + JWTClaimConstraints orders only).
poll_timeout120Seconds to wait for challenge validation
contacts[]Contact URIs registered with the account
eab_kidEAB key ID
eab_alg"HS256"EAB HMAC algorithm
gssapi_keytabPath to a Kerberos keytab for GSSAPI-authenticated EAB (mutually exclusive with eab_kid)
dns_hookHook script path for DNS TXT record management
profileCertificate profile name (draft-aaron-acme-profiles-01). When set, passed as "profile" in the newOrder payload on renewal. Populated from the server’s echoed value after issuance.

The eab_key HMAC secret is never written to the renewal sidecar. Supply it again via --eab-key or --gssapi-keytab if EAB is required for the renewal account.

Fields that have defaults are optional in the TOML file. Existing configs with fewer fields remain forward-compatible as new optional fields are added.

DNS hook interface

When --dns-hook CMD is passed with a DNS-based challenge type (dns-01 or dns-persist-01), akamu-cli delegates TXT record management to an external script instead of waiting for manual input.

The hook script is invoked as:

<CMD> add
<CMD> remove

All values are passed through environment variables only (never as command-line arguments, which would be visible in /proc/<pid>/cmdline):

VariableValue
AKAMU_DOMAINDNS name being validated (base domain, wildcard prefix stripped)
AKAMU_TOKENACME challenge token
AKAMU_TXTTXT record value: base64url(SHA-256(key_authorization))
AKAMU_KEY_AUTHFull key authorization string (<token>.<account-key-thumbprint>)

An exit code of 0 means success. Any non-zero exit code is treated as a failure; stderr from the script is captured and included in the error message.

For dns-01, the hook is called with add before challenge validation and with remove after the challenge completes (whether it succeeds or fails). For dns-persist-01, the hook is only called with add; on success the TXT record is left in place (it is a long-lived record tied to the account key, not the token).

Minimal hook example

#!/usr/bin/env bash
# /etc/akamu/hooks/dns-nsupdate.sh
set -euo pipefail

ZONE="example.com."
RECORD="_acme-challenge.${AKAMU_DOMAIN}."

case "$1" in
  add)
    nsupdate -k /etc/akamu/ddns.key <<EOF
server ns1.example.com
zone ${ZONE}
update add ${RECORD} 60 TXT "${AKAMU_TXT}"
send
EOF
    ;;
  remove)
    nsupdate -k /etc/akamu/ddns.key <<EOF
server ns1.example.com
zone ${ZONE}
update delete ${RECORD} TXT
send
EOF
    ;;
esac

Make the hook executable:

chmod 0755 /etc/akamu/hooks/dns-nsupdate.sh

Then use it with issue or renew:

akamu-cli issue \
  --account-key ~/.akamu/account.pem \
  -d "*.example.com" \
  --challenge dns-01 \
  --dns-hook /etc/akamu/hooks/dns-nsupdate.sh \
  --out /etc/ssl/example.com/wildcard.pem

Example sessions

Register, issue (http-01), and deregister

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

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

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

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

Issue with tls-alpn-01

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

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

Issue with dns-01 (wildcard, manual)

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

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

Output:

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

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

Issue with dns-01 (wildcard, automated hook)

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

Issue with onion-csr-01 (RFC 9799)

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

Issue with tkauth-01 (RFC 9447 — authority token)

tkauth-01 is used for TNAuthList and JWTClaimConstraints identifier types. Use --jwtcc to supply a base64url-encoded JWTClaimConstraints blob when ordering a certificate constrained by JWT claims rather than DNS names.

akamu-cli issue \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --challenge tkauth-01 \
  --tkauth-url https://ta.example.com \
  --tkauth-keytab /etc/akamu/ta-client.keytab \
  --jwtcc "AAABBBCCC..." \
  --out /etc/ssl/service/fullchain.pem

The CLI:

  1. Computes the RFC 9447 fingerprint (SHA256 XX:XX:...) from the account key’s JWK thumbprint.
  2. POSTs {"atc": {"tktype":"EnhancedJWTClaimConstraints","tkvalue":"","fingerprint":"SHA256 ...","ca":false}} to the Token Authority with SPNEGO/Negotiate authentication using --tkauth-keytab.
  3. Extracts the tkvalue from the returned JWT.
  4. For each authorization, fetches a per-identifier authority token from the Token Authority and submits it to the challenge URL as {"tkauth": "<jwt>"}.

ARI-aware renewal (manual flags)

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

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

ARI-aware renewal from a renewal config

After issue has written a .renewal.toml sidecar, renewals require only one flag:

akamu-cli renew \
  --renewal-config /etc/ssl/example.com/fullchain.pem.renewal.toml

The command checks ARI using the certificate path stored in the config, and re-issues only if the renewal window is open (or if --force is added). A new .renewal.toml is written after each successful renewal.

This form is suitable for use from cron or a systemd timer:

# /etc/cron.d/akamu-renew
0 3 * * * root akamu-cli renew --renewal-config /etc/ssl/example.com/fullchain.pem.renewal.toml

Revoke a certificate

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

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

Roll the account key

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

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

Show and update account contacts

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

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

Migrate from certbot

# 1. Preview
akamu-cli import certbot --list

# 2. Import (run as root to read certbot's live/ and accounts/ directories)
sudo akamu-cli import certbot \
  --server https://acme-v02.api.letsencrypt.org/directory \
  --account-key /etc/akamu/acct.pem \
  --cert-dir /etc/akamu/certs \
  --dns-hook /etc/akamu/hooks/dns-update.sh

# 3. Renew each certificate with its generated config
akamu-cli renew --renewal-config /etc/akamu/certs/example.com.pem.renewal.toml

External Account Binding

Some CAs require EAB credentials before accepting a new account. Two methods are available: manual key/ID or GSSAPI (Kerberos) authentication.

Manual EAB (kid + HMAC key)

Obtain a KID and HMAC key from your CA’s operator, then pass them to account register or issue:

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

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

GSSAPI-authenticated EAB

When the CA uses Kerberos to authenticate EAB requests (via /acme/eab), supply a keytab instead:

akamu-cli account register \
  --server https://acme.example.com/acme/directory \
  --account-key ~/.akamu/account.pem \
  --agree-tos \
  --gssapi-keytab /etc/akamu/client.keytab

The CLI derives the target service name HTTP@<hostname> from the server URL and calls GET /acme/eab with an Authorization: Negotiate token. The authenticated Kerberos principal is logged. --gssapi-keytab and --eab-kid / --eab-key are mutually exclusive.

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

Key type selection

Use 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 an Akāmu server linked against OpenSSL 3.5 or later, which provides native ML-DSA support via the standard EVP interface. Vanilla Let’s Encrypt does not support ML-DSA.

Unix domain socket URLs

Both --server and --tkauth-url accept http+unix:// URLs in addition to https://. This allows connecting to a local Akāmu server or Token Authority through a Unix domain socket without opening a network port.

URL format:

http+unix://SOCKET_PATH_ENCODED/REQUEST_PATH

SOCKET_PATH_ENCODED is the socket file path with each / percent-encoded as %2F:

http+unix://%2Frun%2Fakamu%2Fakamu.sock/acme/default/directory

Examples:

# ACME server via Unix socket
akamu-cli issue \
  --server "http+unix://%2Frun%2Fakamu%2Fakamu.sock/acme/default" \
  --account-key ~/.akamu/account.pem \
  -d example.com \
  --out /etc/ssl/example.com/fullchain.pem

# Token Authority via Unix socket (tkauth-01)
akamu-cli issue \
  --server https://acme.example.com/acme/directory \
  --challenge tkauth-01 \
  --tkauth-url "http+unix://%2Frun%2Fekishib%2Fekishib.sock" \
  --tkauth-keytab /etc/akamu/ta-client.keytab \
  --jwtcc "AAAA..." \
  --out /etc/ssl/service/fullchain.pem

When the Token Authority is reached via a Unix socket URL, the GSSAPI service principal is derived as HTTP@<local-hostname> (using gethostname(2)).

Logging

Use the -v / -vv global flags for convenient verbosity control:

akamu-cli -v  issue ...    # akamu_client=debug (TLS cert details, request flow)
akamu-cli -vv issue ...    # additionally enables hyper_util=debug and rustls=debug

The RUST_LOG environment variable provides fine-grained control and overrides the -v flags:

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

The default level (no -v flag and no RUST_LOG) is warn, which prints only errors and warnings.

Error messages and troubleshooting

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

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

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

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

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

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

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

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

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

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

Failed to bind port 80: Permission denied

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

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

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

Unsupported algorithm: ML-DSA-65

The server does not support the requested key type. Use a classical key type such as ec:P-256, or connect to an Akāmu server linked against OpenSSL 3.5 or later (which provides native ML-DSA support).

dns hook '...' add exited with status 1: ...

The DNS hook script returned a non-zero exit code. Check the hook’s stderr output (included in the error message) and verify that the script has the correct permissions and that the DNS update API is reachable.

no certbot accounts found; check --certbot-dir and --server

The importer found no accounts under <certbot-dir>/accounts/. Verify the --certbot-dir path and that the directory is readable. If multiple accounts are present but no --server flag was given, run with --list first to identify the account URL, then pass --server.

Error Handling

All fallible operations in Akāmu return Result<T, AcmeError>. The AcmeError type is defined in src/error.rs and implements both std::error::Error (via thiserror) and axum’s IntoResponse.

AcmeError taxonomy

ACME-specific errors

These map to ACME problem type URNs (urn:ietf:params:acme:error:*):

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
CertAlreadyReplacedalreadyReplaced409
BadCsr(String)badCSR400
BadRevocationReasonbadRevocationReason400
AlreadyRevokedalreadyRevoked400
Caa(String)caa403
ExternalAccountRequiredexternalAccountRequired403
Connection(String)connection400
Dns(String)dns400
IncorrectResponse(String)incorrectResponse400
Tls(String)tls400
AutoRenewalCanceledautoRenewalCanceled403
AutoRenewalCancellationInvalidautoRenewalCancellationInvalid400
AutoRenewalRevocationNotSupportedautoRenewalRevocationNotSupported403
InvalidProfile(String)invalidProfile400

Generic HTTP-mapped errors

These do not have dedicated ACME error types and carry the appropriate HTTP status. NotFound maps to the malformed ACME type; all others fall through to serverInternal.

VariantACME typeHTTP status
NotFoundmalformed404
MethodNotAllowedserverInternal405
Conflict(String)serverInternal409
UnsupportedMediaTypeserverInternal415
PayloadTooLargeserverInternal413
BadRequest(String)serverInternal400
ServiceUnavailable(String)serverInternal503

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
Config(String)Configuration or startup error
Internal(String)General internal error

Response format

AcmeError::into_response builds a response with:

  • HTTP status from http_status().
  • Content-Type: application/problem+json (RFC 7807).
  • JSON body:
{
  "type": "urn:ietf:params:acme:error:badNonce",
  "status": 400,
  "detail": "bad nonce"
}

For responses with an HTTP 4xx status, the detail field is the Display string of the variant, which for parameterised variants includes the inner string.

For responses with an HTTP 5xx status (server errors), the detail field is always the fixed string "internal server error", regardless of the underlying cause. The actual error is logged server-side at ERROR level but is never included in the response body. This applies to ServiceUnavailable (503) as well as the 500-class internal errors.

From implementations

Two From implementations allow the ? operator to be used with library errors:

  • From<sqlx::Error> for AcmeError → wraps in AcmeError::Database.
  • From<akamu_jose::JoseError> for AcmeError → maps to AcmeError::BadRequest, AcmeError::Crypto, or AcmeError::BadSignatureAlgorithm depending on the JOSE error kind.

This means any sqlx::query!(...).fetch_one(&db).await? will automatically convert database errors to AcmeError::Database(msg), and any JOSE verification failure will convert to the appropriate ACME error without an explicit match.

Error propagation in handlers

Handlers return Result<Response, AcmeError>. axum automatically calls IntoResponse::into_response on the error variant when building the HTTP response.

Example handler error propagation chain:

db::accounts::get_by_id(&db, &id).await?
  ↓ sqlx::Error
  ↓ From<sqlx::Error> for AcmeError
  ↓ AcmeError::Database("...")
  ↓ returned from handler as Err(AcmeError::Database(...))
  ↓ axum calls AcmeError::into_response()
  ↓ HTTP 500 with application/problem+json body
  ↓ detail field = "internal server error" (not the database message)

Error handling in background tasks

Background validation tasks (tokio::spawn) must not panic and must not propagate errors. validation::validate_challenge is declared infallible: it calls on_valid or on_invalid internally and logs any database errors via tracing::warn!. Panics inside validation tasks are caught by the observer task pattern described in the Validation chapter.

Design principles

  • No unwrap() in production paths. All fallible operations use ? or explicit error handling.
  • Internal errors do not leak details to clients. AcmeError::Internal, AcmeError::Database, AcmeError::Crypto, AcmeError::Builder, AcmeError::Mtc, AcmeError::Config, and AcmeError::ServiceUnavailable all produce 5xx responses whose detail field is the fixed string "internal server error". The actual error message is written to the server log only.
  • Challenge errors are ACME errors. The validation layer converts hyper, hickory-resolver, and rustls errors into specific AcmeError variants (Connection, Dns, Tls, IncorrectResponse) so the client receives a meaningful ACME error type.

Architecture

Implementation Guide — This section is for contributors working on the Akāmu source code. It covers architecture, internal module design, database schema, CA internals, and testing. If you are deploying or operating the server, see the Operator Guide. If you are building an ACME client or integrating with the API, see the API Reference.

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

System architecture

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

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

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

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

    certbot -->|"HTTPS ACME requests (JWS-signed)"| tls
    acmesh --> tls
    akamucli --> tls
    custom --> tls
    tls --> acme
    acme --> jose
    acme --> ca
    acme --> db
    acme -->|"spawns tokio task"| val
    acme --> mtc

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

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

Crate layout

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

Cargo.toml          <- workspace root (members: ., crates/*)
src/                <- akamu server binary
crates/
  akamu-jose/       <- JWK/JWS primitives (no HTTP/DB deps)
  akamu-client/     <- async ACME client library (tokio, hyper)
  akamu-cli/        <- CLI binary wrapping akamu-client
  akamu-cosigner/   <- standalone MTC cosigner daemon
  akamu-ldap/       <- safe Rust wrapper around the OpenLDAP C library (libldap);
                       provides LdapConnection (sync) and AsyncLdapConnection
                       (tokio::task::spawn_blocking) with simple-bind and
                       SASL GSSAPI/Kerberos authentication
  akamu-gssapi/     <- safe Rust GSSAPI/SPNEGO bindings (FFI to libgssapi_krb5);
                       provides GssServerCred and GssClientCred for keytab- and
                       ccache-based credential acquisition, plus accept_token /
                       init_token convenience wrappers; MIT Kerberos thread-safety
                       guarantees allow an Arc<GssServerCred> to be shared across
                       all handler threads without a mutex
  akamuctl/         <- server administration CLI binary; talks to /admin/* endpoints
                       over mTLS or GSSAPI/Kerberos (ccache-based) authentication

Crate dependencies

graph LR
    SERVER["akamu (server)"]
    CLIENT["akamu-client"]
    CLI["akamu-cli"]
    COSIGNER["akamu-cosigner"]
    JOSE["akamu-jose"]
    SYNTA["synta-certificate"]
    SYNTABASE["synta"]
    SYNTAVERIF["synta-x509-verification"]
    LDAP["akamu-ldap"]
    GSSAPI["akamu-gssapi"]
    AKAMUCTL["akamuctl"]
    NATIVEOSSL["native-ossl"]
    HYPERRUSTLS["hyper-rustls"]

    SERVER --> JOSE
    SERVER --> SYNTA
    SERVER --> SYNTABASE
    SERVER --> SYNTAVERIF
    SERVER --> LDAP
    SERVER --> GSSAPI
    SERVER --> NATIVEOSSL
    SERVER --> HYPERRUSTLS
    CLIENT --> JOSE
    CLIENT --> SYNTA
    CLIENT --> GSSAPI
    CLIENT --> HYPERRUSTLS
    CLI --> CLIENT
    COSIGNER --> CLIENT
    COSIGNER --> SERVER
    COSIGNER --> NATIVEOSSL
    COSIGNER --> HYPERRUSTLS
    JOSE --> SYNTA
    SYNTA --> SYNTABASE
    AKAMUCTL --> GSSAPI
    AKAMUCTL --> SYNTA
    AKAMUCTL --> NATIVEOSSL

The server and akamu-client both depend directly on akamu-jose and synta-certificate. The server additionally depends on:

  • akamu-ldap for reading Dogtag and IPA certificate profiles from LDAP.
  • akamu-gssapi for standalone SPNEGO authentication (gss_cred, admin_gss_cred in AppState).
  • native-ossl directly for digest operations (SHA-256 fingerprinting, channel-binding hashes), HKDF key derivation (eab_derivation.rs), and asymmetric-key signature verification in the mTLS verifier. rustls-native-ossl (a thin rustls crypto-provider shim over native-ossl) supplies the OpenSSL backend to rustls.
  • synta (the base ASN.1 codec crate) for Decoder/Encoder and the Integer type used in certificate serial number and OID handling; synta-certificate is built on top of it.
  • synta-x509-verification for X.509 certification-path validation (trust anchor resolution, name constraints, key usage checking) used in mTLS client-certificate verification and OCSP response validation.
  • hyper-rustls as the HTTPS connector for outbound MTC cosigner requests; paired with rustls-native-certs to load the OS root CA store for verifying cosigner TLS certificates.

akamu-client uses akamu-gssapi for GSSAPI-authenticated ACME requests and hyper-rustls for all outbound HTTPS. akamu-cli depends only on akamu-client. akamu-cosigner depends on both akamu-client (for ACME EAB bootstrap) and akamu itself (to reuse TLS loader helpers and key generation utilities), and uses native-ossl directly for crypto operations. akamuctl is a standalone admin CLI that depends on akamu-gssapi for ccache-based Kerberos login to the /admin/* endpoints, on synta-certificate for certificate handling, and on native-ossl directly; it does not depend on akamu-client or akamu-jose.

Key external dependencies

The following external crates are direct server dependencies whose role is not otherwise obvious from the module descriptions above.

CratePurpose
uuidGenerates UUIDv4 identifiers for every ACME resource (orders, authorizations, challenges, certificates, nonces).
getrandomCryptographically random byte source for anti-replay nonce generation (NonceBucket).
subtleConstant-time byte comparison (subtle::ConstantTimeEq) used in admin session-token lookup to prevent timing-based token recovery.
zeroizeZeroizing<T> wrapper that zeroes memory on drop; applied to the EAB master secret in AppState to satisfy FDP_RIP.1 (residual information protection).
ipnetCIDR range type (IpNet) used to match incoming request source addresses against the [server].trusted_proxies allow-list.
regexCompiled regular expressions for profile identifier authorization (auth.rs check_profile_auth — identifier pattern matching against the allowed_identifiers field).
libcflock(2) call that takes an exclusive advisory lock on the MTC log file, preventing two server processes from writing the same log concurrently.
rustls-native-certsLoads the operating-system root certificate store into rustls for verifying cosigner HTTPS connections.
indexmapIndexMap preserves config-file insertion order for the multi-CA registry (AppState::cas), ensuring deterministic directory listing and default-CA fallback.

See Client Libraries for the standalone client API.

The server’s jose/ module

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

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

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

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

Server source layout

The src/ directory is organized as follows:

src/
  main.rs          Entry point; parses config, initializes subsystems, starts axum
  lib.rs           Re-exports public modules for integration tests
  config.rs        TOML configuration structs (Config, CaConfig, MtcConfig, ServerConfig,
                   ProfilesConfig, ProviderConfig, BuiltinProviderConfig, AdminConfig, …)
  state.rs         Shared application state (AppState, CaState, MtcState, NonceBucket,
                   CrlCache, TlsState, CachedAccount, OperatorRole, AdminSession, …)
  error.rs         AcmeError enum with HTTP mapping and problem+json serialization
  audit.rs         Structured audit trail (FAU family): AuditEvent, AuditState
                   (VecDeque-backed violation window), AuditPolicy, record_or_log,
                   track_record_result, overflow handling (FAU_STG.4)
  dns.rs           Thin DNS query helper (hickory-resolver; optional DNS-over-TLS)
  eab_derivation.rs HKDF-SHA-256 (RFC 5869) credential derivation for /acme/eab
  extract.rs       Axum extractors: RemoteUser (proxy header or standalone GSSAPI)
  star.rs          RFC 8739 ACME STAR background reissuance task
  delegation_upstream.rs  RFC 9115 upstream CA polling task (see delegation/ module)
  util.rs          Shared utilities: unix_now, unix_to_rfc3339, certificate helpers

  admin/
    mod.rs         Re-exports admin submodules
    auth.rs        Operator authentication (mTLS cert or GSSAPI/Kerberos session token),
                   OperatorContext extractor, session management (POST/DELETE /admin/session)
    init.rs        Admin operator bootstrap — seeds the first operator from config on startup

  db/
    mod.rs         Database initialization (open, migrations, WAL mode)
    schema.rs      Row types mirroring database columns
    accounts.rs    CRUD for accounts table
    (audit.rs removed — audit events are now written to the systemd journal namespace via src/journal.rs)
    authz.rs       CRUD for authorizations table
    certs.rs       CRUD for certificates table (includes mtc_standalone_der column)
    challenges.rs  CRUD for challenges table
    checkpoints.rs CRUD for mtc_checkpoints table (upsert, get_latest, prune_oldest)
    cosignatures.rs CRUD for mtc_cosignatures table
    cross_certs.rs CRUD for cross_certs table (insert, get_by_id, list by issuer/subject CA)
    eab.rs         CRUD for eab_keys table
    landmarks.rs   CRUD for mtc_landmarks table (insert, get_by_seq, list, prune_oldest)
    nonces.rs      Anti-replay nonce management
    operators.rs   CRUD for operators table (insert, get_by_fingerprint/principal, update,
                   failed-attempt tracking and account locking — FIA_AFL.1)
    orders.rs      CRUD for orders table
    delegations.rs CRUD for delegations table (insert, get_by_id, update, delete, list, list_by_account)
    stats.rs       Aggregate statistics queries for GET /admin/stats

  routes/
    mod.rs         Router assembly (build_router, build_admin_router), CaId extractor,
                   shared helpers (parse_jws, acme_headers, json_response, acme_prefix)
    directory.rs   GET /acme/directory
    nonce.rs       HEAD/GET /acme/new-nonce
    account.rs     POST /acme/new-account, POST /acme/account/{id}
    order.rs       POST /acme/new-order, POST /acme/order/{id}
    authz.rs       POST /acme/new-authz, POST /acme/authz/{id}
    challenge.rs   POST /acme/chall/{authz_id}/{type}
    finalize.rs    POST /acme/order/{id}/finalize
    certificate.rs GET /acme/cert/{id}, POST /acme/cert/{id}
                   (auto-detects MTC vs X.509 by PEM marker)
    star_cert.rs   GET/POST /acme/cert/star/{order_id} (RFC 8739 §3.3 STAR rolling cert URL)
    revoke.rs      POST /acme/revoke-cert
    key_change.rs  POST /acme/key-change
    renewal_info.rs GET /acme/renewal-info/{cert_id}
    mtc.rs         GET /acme/mtc/tree-size, /root, /inclusion-proof/{id},
                   /cert/{id}/standalone, /landmarks, /landmarks/{seq}/cert;
                   C2SP tlog-tiles: /acme/mtc/tlog/checkpoint, /tlog/cosignature,
                   /tlog/tile/{*path}
    crl.rs         GET /ca/crl, GET /ca/{ca_id}/crl — serve DER-encoded CRLs (cached);
                   GET /ca/cross-certs, GET /ca/{ca_id}/cross-certs — PEM cross-cert bundles
    ocsp.rs        POST /ca/ocsp, GET /ca/ocsp/{request},
                   POST /ca/{ca_id}/ocsp, GET /ca/{ca_id}/ocsp/{request} (RFC 6960)
    eab_identity.rs GET /acme/eab — derive and return EAB credentials for
                    the authenticated principal (proxy or standalone GSSAPI)
    delegation.rs   POST /acme/delegations/{account_id} (list; POST-as-GET),
                    POST /acme/delegation/{id} (fetch one; POST-as-GET) —
                    RFC 9115 NDC-facing delegation endpoints; active when
                    server.delegation_enabled = true
    admin.rs       All /admin/* endpoints served on the dedicated admin listener;
                   POST/DELETE /admin/session (auth);
                   GET/POST /admin/operators, GET/PUT/PATCH /admin/operators/{id},
                   POST /admin/operators/{id}/unlock;
                   GET/POST/PUT/DELETE /admin/account/{id}/profile-grants,
                   POST /admin/account/{id}/deactivate;
                   GET /admin/accounts, GET /admin/account/{id};
                   POST/GET/DELETE /admin/eab, GET/DELETE /admin/eab/{kid};
                   GET /admin/audit;
                   GET /admin/certs, GET /admin/certs/{id},
                   GET /admin/certs/{id}/download;
                   GET/POST /admin/profiles, PUT/DELETE /admin/profiles/{id};
                   GET /admin/orders, GET /admin/orders/{id};
                   GET /admin/config;
                   POST /admin/crl/force, POST /admin/revoke;
                   GET /admin/stats;
                   GET /admin/cas, GET /admin/cas/{id}, GET /admin/cas/{id}/cert;
                   POST /admin/ca/{id}/crl/force, POST /admin/ca/{id}/cross-sign;
                   GET /admin/cross-certs, GET /admin/cross-certs/{id};
                   GET/POST /admin/delegations, GET/PUT/DELETE /admin/delegations/{id}
                   (role-based access control; admin listener not started when [admin] absent)

  ca/
    mod.rs         Re-exports ca submodules
    init.rs        CA key and certificate load-or-generate
    key_loader.rs  CaKeyLoader — routes key loading to PEM file or PKCS#11 token URI
    csr.rs         PKCS#10 CSR parsing and validation
    issue.rs       End-entity and CA certificate issuance (issue_certificate,
                   issue_with_params, issue_ca_cert, check_is_ca_cert)
    revoke.rs      CRL generation

  profiles/
    mod.rs           ProfileRegistry — in-memory cache, background refresh, resolve()
    builtin.rs       Builtin provider: inline TOML profile declarations;
                     handles issue_as, allowed_identifiers, auth_hook, require_account_grant
    auth.rs          check_profile_auth — identifier pattern, external hook, account grant checks
    cfg.rs           Dogtag Java-properties .cfg parser and translator
    dogtag.rs        Dogtag PKI provider (filesystem or LDAP — simple bind and GSSAPI/Kerberos)
    ipa.rs           FreeIPA/IPAThinCA provider (filesystem or LDAP — simple bind and GSSAPI/Kerberos)
    ldap_resolve.rs  resolve_ldap_uris() — merges uri/uris/srv_domain into a space-separated
                     URI string for ldap_initialize; SRV records sorted per RFC 2782
    ldap_session.rs  Shared LDAP connect→bind→search→parse helper used by dogtag and ipa providers

  tls/
    mod.rs           Re-exports tls submodules; module-level doc for standalone TLS support
    channel_binding.rs TLS channel binding helpers (tls-unique / tls-exporter)
    init.rs          load_or_generate — loads or auto-generates the server TLS certificate
    loader.rs        Async TLS config reloader (hot-reload of cert/key without restart)
    schemes.rs       Custom rustls SignatureScheme negotiation for hybrid post-quantum keys
    verifier.rs      SyntaClientCertVerifier — mTLS client certificate verification
                     (CAB Forum or RFC 5280 profile; hybrid ML-DSA+classical chains)

  validation/
    mod.rs             Challenge dispatch and DB state transitions (validate_challenge)
    http01.rs          http-01 validation (hyper HTTP client)
    dns01.rs           dns-01 validation (hickory-resolver)
    tls_alpn01.rs      tls-alpn-01 validation (rustls TLS client)
    dns_persist_01.rs  dns-persist-01 validation (hickory-resolver; persistent TXT records)
    onion_csr_01.rs    onion-csr-01 validation (CSR-based; .onion identifiers)
    caa.rs             CAA record validation (RFC 8659 + RFC 8657)

  mtc/
    mod.rs         Re-exports mtc submodules
    log.rs         SharedLog type alias; tree_size, proof_and_tree_size async wrappers
    checkpoint.rs  Checkpoint production background task; signs and stores Merkle roots
    cosign.rs      CosignerClient; parallel cosignature gathering from external cosigners
    landmark.rs    Landmark background task; allocates and builds LandmarkCertificate DERs
    standalone.rs  StandaloneCertificate construction after each checkpoint
    tlog.rs        C2SP tlog-tiles and signed-note support: checkpoint format, key-ID
                   computation, cosignature production, hash tile computation for all
                   C2SP signature types (Ed25519, ECDSA, ML-DSA-44 cosignatures)

  delegation/
    mod.rs         Re-exports delegation submodules
    upstream.rs    DelegationUpstreamTask — background tokio task that polls
                   processing delegation orders and drives the upstream ACME
                   flow (account registration, new-order, dns-01 deploy/poll,
                   finalize, cert retrieval) using [delegation_upstream] config;
                   not started when [delegation_upstream] is absent

  jose/            Thin re-exports and helpers for crates/akamu-jose
    mod.rs         Module re-exports
    jwk.rs         Re-exports JwkPublic from akamu-jose
    jws.rs         Re-exports JwsFlattened, JwsKeyRef, JwsProtectedHeader from akamu-jose
    eab.rs         EAB (External Account Binding) HMAC verification helpers
    kid.rs         account_id_from_kid — extracts and validates the account ID from a JWS kid URL

Key types

AppState

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

  • config: Arc<Config> — immutable configuration parsed at startup.
  • db: crate::db::Db — write connection pool. All write transactions and most read queries use this pool.
  • db_ro: crate::db::Db — read-only connection pool for SQLite file-backed databases (opened with ?mode=ro URI parameter). Pure-read handlers (get_order, get_authz, download_cert) route through this pool so WAL concurrent reads do not contend on the write lock. For :memory: databases and non-SQLite backends this is a clone of db.
  • db_kind: DbKind — discriminant indicating the underlying database backend (SQLite, Postgres, MariaDB); used by a small number of queries that need backend-specific SQL.
  • cas: Arc<IndexMap<String, Arc<CaState>>> — all configured CAs keyed by their ID, in config-file insertion order. Replaces the old single ca field.
  • default_ca_id: Arc<String> — the CA ID that serves the backward-compatible /acme/directory and /ca/crl routes. Set to the entry with is_default = true in [[ca]] config.
  • crl_caches: Arc<HashMap<String, CrlCache>> — per-CA CRL cache keyed by CA ID. Each entry is None until the first CRL request for that CA. Replaces the old single crl_cache field.
  • link_headers: Arc<HashMap<String, Arc<HeaderValue>>> — per-CA precomputed Link: …; rel="index" header values keyed by CA ID. acme_headers(state, ca_id, nonce) looks up the header for the request’s CA, falling back to the default CA’s header. Replaces the old single link_header field.
  • mtc: Arc<MtcState> — MTC log handle, signing key, and pre-built cosigner HTTPS clients.
  • profiles: Arc<ProfileRegistry> — in-memory certificate profile cache; empty when no providers are configured, in which case every order falls back to CA defaults.
  • tls: Option<Arc<TlsState>> — present when [tls] is enabled and client auth is configured; holds the client-auth config for introspection by handlers.
  • nonces: Arc<NonceBucket> — in-memory anti-replay nonce store.
  • spki_cache: Arc<RwLock<HashMap<String, CachedAccount>>> — per-account SPKI/thumbprint cache to avoid a DB round-trip per authenticated request after the first.
  • validation_client: ValidationClient — shared hyper HTTP client for http-01 challenge validation; connection-pooled so TCP connections are reused across validations.
  • gss_cred: Option<Arc<GssServerCred>> — server-side GSSAPI credential for standalone SPNEGO authentication. None when [server.gssapi] is absent.
  • admin_gss_cred: Option<Arc<GssServerCred>> — admin-specific GSSAPI credential from [admin.gssapi]; takes precedence over gss_cred for admin SPNEGO. None when [admin.gssapi] is absent.
  • eab_master_secret: Option<Arc<Zeroizing<Vec<u8>>>> — decoded master secret for HKDF-based EAB key derivation. None when [server].eab_master_secret is absent.
  • journal: Arc<JournalWriter> — audit event writer. Connects to the systemd journal namespace socket, writes to a JSONL file ([server].audit_log_file), or uses an in-process store (development/CI). Always present.
  • audit: Arc<AuditState> — shared in-memory audit state (overflow flag, FAU_ARP.1 alarm counter, VecDeque-backed violation timestamp window). Always present.
  • audit_policy: Arc<AuditPolicy> — audit policy extracted from [admin] at startup.
  • admin_sessions: Option<Arc<tokio::sync::Mutex<HashMap<String, AdminSession>>>> — opaque session token store for admin operator sessions. None when [admin] is absent.
  • admin_auth_limiter: Option<AdminAuthLimiter> — per-source-IP credential-attempt timestamps for admin auth rate-limiting. None when [admin] is absent.
  • startup_time: Instant — time the server process started; used for uptime reporting in GET /admin/stats.

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

CaState

Holds the key material and issuance policy for a single CA. Key fields:

  • id: String — the CA’s unique identifier, matching CaConfig.id from the config file.
  • key_type: String — the key algorithm string (e.g. "ec:P-256", "rsa:2048").
  • key: BackendPrivateKey — CA private key used for certificate and CRL signing.
  • cert_der: Vec<u8> — DER-encoded CA certificate.
  • hash_alg: String — hash algorithm string for certificate and CRL signatures (e.g. "sha256").
  • validity_days: u32 — default validity period for issued end-entity certificates.
  • crl_url: Option<String> — optional CRL distribution point URL embedded in issued certificates.
  • ocsp_url: Option<String> — optional OCSP responder URL embedded in issued certificates.
  • aki_bytes: Vec<u8> — RFC 7093 §2 Method 1 key identifier bytes (leftmost 20 bytes of the SHA-256 of the CA public key BIT STRING). Used to validate the AKI component of ARI cert-ids (RFC 9773 §4.1).
  • enforce_validity_cap: bool — when true, issue_with_params rejects issuance when the computed validity exceeds 200 days (CA/B Forum BR §6.3.2).
  • crl_next_update_secs: u64 — validity window for signed CRLs (determines cache TTL).
  • caa_identities: Vec<String> — CAA domain identities specific to this CA; falls back to [server].caa_identities when empty.

CaState is shared across all concurrent handler tasks via Arc<CaState>. The underlying BackendPrivateKey delegates to the OpenSSL backend, which serializes concurrent signing operations internally.

Two helpers on AppState provide access to CA instances:

  • AppState::get_ca(id: &str) -> Option<&Arc<CaState>> — look up a CA by ID; returns None for unknown IDs.
  • AppState::default_ca() -> &Arc<CaState> — return the default CA (the one designated by is_default = true). Panics only if the server was constructed incorrectly.

AcmeError

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

  • An ACME problem type string (urn:ietf:params:acme:error:*).
  • An HTTP status code.
  • A human-readable 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. Two route sets are registered:

  • Legacy routes (/acme/directory, /acme/new-account, etc.) — served by the same handlers, using default_ca_id as the implicit CA context.
  • Per-CA routes (/acme/{ca_id}/directory, /acme/{ca_id}/new-account, etc.) — the same handlers, but the {ca_id} path parameter selects the CA. Axum resolves static path segments before dynamic ones, so the legacy /acme/directory route is always matched before /acme/{ca_id}/directory; config validation ensures no CA ID collides with reserved ACME path segments ("directory", "new-nonce", etc.).

The CaId extractor (src/routes/mod.rs) resolves the active CA for each request. When the {ca_id} path parameter is present it validates the value against state.cas and returns 404 Not Found for unknown IDs. On legacy routes without {ca_id}, it returns the default_ca_id. Every handler that is CA-aware accepts a CaId argument.

Each route maps to a handler function in the corresponding routes/ module. The handler receives the following extractors:

  • State(state): State<Arc<AppState>> — shared application state.
  • CaId(ca_id): CaId — resolved CA identifier for this request.
  • Path(...) — URL path parameters (e.g., order ID, authz ID).
  • body: Bytes — raw request body for JWS verification.

The account_scope setting in [server] affects account creation and JWS validation. When set to "server" (the default), one account registration is valid for all CAs. When set to "ca", accounts are isolated per CA and each CA directory advertises its own /acme/{ca_id}/new-account endpoint; JWS kid validation additionally checks that the account’s ca_id matches the request’s CA.

4. JWS verification (POST endpoints)

Almost every POST endpoint calls routes::parse_jws before processing the payload:

  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 in-memory NonceBucket and atomically replace it with a fresh nonce. A missing or already-used nonce returns badNonce. The nonce store is in-memory (a Mutex<HashMap> inside AppState::nonces); nonces issued before a server restart are silently dropped, and clients detect the resulting badNonce and retry per RFC 8555 §6.5.
  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 RFC 9964 §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 database transaction to ensure atomicity. On PostgreSQL, state-transition transactions (new-order, new-authz, challenge) additionally issue SET LOCAL synchronous_commit = off via db::pg_local_async_commit to eliminate per-commit WAL flush overhead; the certificate issuance transaction retains full durability.

6. Response construction

Handlers return Result<Response, AcmeError>. On success, they call routes::json_response, which:

  1. Generates a new anti-replay nonce.
  2. Stores it in the in-memory NonceBucket.
  3. Adds the Replay-Nonce and Link: <directory>; rel="index" headers, selecting the correct Link value from state.link_headers for the active CA.
  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.

A third background task (ProfileRegistry::spawn_refresh_task) wakes every refresh_interval_secs seconds, re-loads all configured profile providers, and atomically replaces the in-memory ProfileCache under a write lock. It holds a weak Arc reference to the registry so that dropping the server’s strong reference causes the task to exit cleanly on shutdown.

A fourth background task (DelegationUpstreamTask) is started at server startup when [delegation_upstream] is configured. It wakes every poll_interval_secs seconds (default 10), queries the database for delegation orders in processing status, and drives each one through the upstream ACME flow: it contacts the upstream CA’s directory, registers an account (if not already registered), submits a new-order, triggers the dns-01 challenge by invoking challenge_deploy_script, polls until the upstream authorization is valid, calls challenge_cleanup_script (if configured), finalizes the order, and stores the resulting certificate URL back into the order row. On success the order transitions to valid; on any unrecoverable upstream failure it transitions to invalid.

Database access model

All database access goes through sqlx::AnyPool. Queries are async and run directly on the tokio runtime. This means:

  • sqlx::query!(...) / sqlx::query_as!(...) are the primary way to issue queries.
  • For multi-statement atomicity, acquire a transaction with pool.begin().await? and commit with tx.commit().await?.

Foreign key enforcement is enabled at database open time (PRAGMA foreign_keys=ON). WAL journal mode is also enabled after migrations (PRAGMA journal_mode=WAL).

Async design

The server is fully async on the tokio runtime. All I/O — TCP, HTTP, DNS, TLS — is async. CPU-bound work (DER encoding for MTC) is offloaded to tokio::task::spawn_blocking.

The only shared mutable state in the async domain is the Mutex<DiskBackedLog> for the MTC log. All other state is either immutable after startup (AppState, Config, CaState) or encapsulated in the database background thread.

Database

Akāmu uses sqlx 0.8 as its database layer, with a runtime-dispatch AnyPool that supports SQLite, PostgreSQL, and MariaDB. The active backend is selected by a compile-time feature flag (backend-sqlite, backend-postgres, or backend-mariadb). Schema migrations are managed by sqlx’s built-in migrate! macro.

Connection model

The server holds two sqlx::AnyPool instances stored in AppState:

  • db (write pool) — all migrations run here; all write transactions use it; most read queries also use it.
  • db_ro (read-only pool) — opened with the ?mode=ro SQLite URI parameter. Pure-read handlers (get_order, get_authz, download_cert) route through this pool so concurrent reads do not contend on the WAL write lock. For :memory: databases and non-SQLite backends db_ro is a clone of db.

db::open_ro(url, max_connections) in src/db/mod.rs opens the read-only pool. It returns None for :memory: URLs (each connection sees an empty schema) and for non-SQLite URLs.

sqlx manages each pool’s connection count internally; callers pass &pool to query helpers or &mut *tx inside transactions.

All queries use the sqlx QueryBuilder or typed-query pattern:

#![allow(unused)]
fn main() {
sqlx::query_as!(Row, "SELECT … FROM …", param)
    .fetch_one(&db_ro)
    .await?
}

Initialization

db::open(url, max_connections, require_tls) in src/db/mod.rs performs the following in order:

  1. Registers all compiled-in sqlx drivers via sqlx::any::install_default_drivers().
  2. Optionally validates the URL for SSL/TLS parameters when require_tls is true (FPT_ITT.1).
  3. Opens the pool (creates the SQLite file if needed via the ?mode=rwc URI parameter; for :memory: a fresh in-memory database is used).
  4. Enables WAL mode and performance pragmas for SQLite: PRAGMA journal_mode=WAL, PRAGMA synchronous=NORMAL, PRAGMA foreign_keys=ON, PRAGMA mmap_size=134217728, PRAGMA cache_size=-65536.
  5. Runs all pending migrations via the compiled-in sqlx::migrate! macro, selecting the backend-specific migration directory (migrations/sqlite/, migrations/postgres/, or migrations/mariadb/).

At server startup, nonces older than 24 hours are swept from the in-memory NonceBucket.

Migration numbering

Each database backend has its own migration directory (migrations/sqlite/, migrations/postgres/, migrations/mariadb/). Two backend-specific migrations affect the numbering:

  • SQLite 0006 (0006_mtc_log_index.sql) — a WAL-mode index-tuning step that does not apply to PostgreSQL or MariaDB. All SQLite migrations from 0007 onward are therefore one higher than the corresponding PostgreSQL/MariaDB number.
  • PostgreSQL 0015 (0015_hot_indexes.sql) — two partial/compound indexes on authorizations that are specific to PostgreSQL concurrency characteristics. This migration has no SQLite or MariaDB counterpart.

The remainder of this document uses SQLite numbers as the canonical reference and notes the PostgreSQL/MariaDB equivalent where the numbers differ.

Schema

All seven core tables are created in a single initial migration (0001_initial.sql). Later migrations add columns and additional tables.

Migration 0001 — Initial schema

nonces — Anti-replay nonces. The in-memory NonceBucket is the hot path; this table exists for startup cleanup of nonces written by a previous process version.

CREATE TABLE nonces (
    nonce   TEXT    PRIMARY KEY,
    created INTEGER NOT NULL  -- Unix epoch seconds
);
CREATE INDEX idx_nonces_created ON nonces(created);

accounts — ACME accounts.

CREATE TABLE accounts (
    id             TEXT    PRIMARY KEY,      -- UUID
    status         TEXT    NOT NULL DEFAULT 'valid',  -- valid|deactivated|revoked
    contact        TEXT,                     -- JSON array of mailto: URIs
    public_key     BLOB    NOT NULL,         -- DER-encoded SubjectPublicKeyInfo
    jwk_thumbprint TEXT    NOT NULL UNIQUE,  -- base64url SHA-256 JWK thumbprint
    created        INTEGER NOT NULL,
    updated        INTEGER NOT NULL
);

jwk_thumbprint has a unique constraint so the database enforces that no two accounts share a key.

orders — ACME orders, including STAR (RFC 8739) auto-renewal fields.

CREATE TABLE orders (
    id                        TEXT    PRIMARY KEY,
    account_id                TEXT    NOT NULL REFERENCES accounts(id),
    status                    TEXT    NOT NULL DEFAULT 'pending',
    expires                   INTEGER,
    identifiers               TEXT    NOT NULL,   -- JSON [{type,value}]
    not_before                INTEGER,
    not_after                 INTEGER,
    error                     TEXT,               -- problem+json string if invalid
    certificate_id            TEXT,               -- FK to certificates.id when valid
    replaces                  TEXT,               -- RFC 9773 ARI: cert_id of predecessor
    created                   INTEGER NOT NULL,
    updated                   INTEGER NOT NULL,
    -- RFC 8739 STAR auto-renewal
    star_start_date           INTEGER,
    star_end_date             INTEGER,
    star_lifetime_secs        INTEGER,
    star_lifetime_adjust_secs INTEGER NOT NULL DEFAULT 0,
    star_allow_cert_get       INTEGER NOT NULL DEFAULT 0,
    star_canceled_at          INTEGER,
    star_csr_der              BLOB,               -- stored CSR DER for reissuance
    -- draft-ietf-acme-profiles-01
    profile                   TEXT
);
CREATE INDEX idx_orders_account  ON orders(account_id);
CREATE INDEX idx_orders_status   ON orders(status);
CREATE INDEX idx_orders_replaces ON orders(replaces) WHERE replaces IS NOT NULL;
CREATE INDEX idx_orders_star     ON orders(star_end_date) WHERE star_end_date IS NOT NULL;

authorizations — One per identifier per order. account_id is denormalized from the parent order to allow efficient per-account queries without joins. subdomain_auth_allowed records whether RFC 9444 subdomain authorization was granted.

CREATE TABLE authorizations (
    id                    TEXT    PRIMARY KEY,
    order_id              TEXT    NOT NULL REFERENCES orders(id),
    account_id            TEXT    NOT NULL REFERENCES accounts(id),
    status                TEXT    NOT NULL DEFAULT 'pending',
    identifier            TEXT    NOT NULL,   -- JSON {"type":"dns","value":"example.com"}
    expires               INTEGER,
    wildcard              INTEGER NOT NULL DEFAULT 0,
    subdomain_auth_allowed INTEGER NOT NULL DEFAULT 0,  -- RFC 9444
    created               INTEGER NOT NULL,
    updated               INTEGER NOT NULL
);
CREATE INDEX idx_authz_order   ON authorizations(order_id);
CREATE INDEX idx_authz_account ON authorizations(account_id);

challenges — One or more per authorization. All challenges for a given authorization share the same token.

CREATE TABLE challenges (
    id        TEXT    PRIMARY KEY,
    authz_id  TEXT    NOT NULL REFERENCES authorizations(id),
    type      TEXT    NOT NULL,     -- http-01|dns-01|tls-alpn-01
    status    TEXT    NOT NULL DEFAULT 'pending',
    token     TEXT    NOT NULL,
    validated INTEGER,
    error     TEXT,
    created   INTEGER NOT NULL,
    updated   INTEGER NOT NULL
);
CREATE INDEX idx_chall_authz ON challenges(authz_id);

certificates — Issued X.509 certificates. der stores only the leaf DER; pem stores the full chain (leaf + CA). Both are stored because CRL generation and MTC logging need DER while the download endpoint serves PEM.

CREATE TABLE certificates (
    id                    TEXT    PRIMARY KEY,   -- UUID used in the cert URL path
    order_id              TEXT    NOT NULL REFERENCES orders(id),
    account_id            TEXT    NOT NULL REFERENCES accounts(id),
    serial_number         TEXT    NOT NULL UNIQUE,
    status                TEXT    NOT NULL DEFAULT 'valid',  -- valid|revoked
    der                   BLOB    NOT NULL,
    pem                   TEXT    NOT NULL,
    not_before            INTEGER NOT NULL,
    not_after             INTEGER NOT NULL,
    revoked_at            INTEGER,
    revocation_reason     INTEGER,
    mtc_log_index         INTEGER,
    created               INTEGER NOT NULL,
    suggested_window_start INTEGER,  -- RFC 9773 ARI renewal window
    suggested_window_end   INTEGER,
    replaced_by           TEXT        -- RFC 9773: order_id that superseded this cert
);
CREATE INDEX idx_certs_account                  ON certificates(account_id);
CREATE INDEX idx_certs_serial                   ON certificates(serial_number);
CREATE INDEX idx_certs_order                    ON certificates(order_id);
CREATE INDEX idx_certs_status                   ON certificates(status);
CREATE INDEX idx_certs_account_status_not_after ON certificates(account_id, status, not_after);
CREATE INDEX idx_certs_replaced_by              ON certificates(replaced_by)
    WHERE replaced_by IS NOT NULL;

eab_keys — External Account Binding (RFC 8555 §7.3.4) pre-provisioned HMAC keys.

CREATE TABLE eab_keys (
    kid           TEXT    PRIMARY KEY,
    hmac_key_b64u TEXT    NOT NULL,
    created       INTEGER NOT NULL,
    used_at       INTEGER,
    profile_grants TEXT               -- JSON array of profile IDs; NULL = unrestricted
);

(profile_grants is added inline in the initial migration. The old 0007_profile_grants migration added it as ALTER TABLE in the original schema; it is now baked into the 0001 baseline for new installations.)

Migration 0002 — MTC checkpoints

Adds the Merkle Tree Certificate issuance-log checkpoint table:

CREATE TABLE mtc_checkpoints (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    tree_size INTEGER NOT NULL UNIQUE,
    root_hex  TEXT    NOT NULL,
    signature BLOB    NOT NULL,
    created   INTEGER NOT NULL
);

Migration 0003 — MTC standalone DER

ALTER TABLE certificates ADD COLUMN mtc_standalone_der BLOB;

Stores the standalone (non-chained) DER encoding of an MTC-logged certificate, used when serving MTC certificate downloads.

Migration 0004 — MTC cosignatures

CREATE TABLE mtc_cosignatures (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    checkpoint_id INTEGER NOT NULL REFERENCES mtc_checkpoints(id) ON DELETE CASCADE,
    cosigner_url  TEXT    NOT NULL,
    signature_der BLOB    NOT NULL,
    created       INTEGER NOT NULL,
    UNIQUE(checkpoint_id, cosigner_url)
);
CREATE INDEX idx_mtc_cosignatures_checkpoint ON mtc_cosignatures(checkpoint_id);

Migration 0005 — MTC landmarks

CREATE TABLE mtc_landmarks (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    sequence_no INTEGER NOT NULL UNIQUE,
    tree_size   INTEGER NOT NULL UNIQUE,
    cert_der    BLOB,     -- DER-encoded LandmarkCertificate; NULL until built
    created     INTEGER NOT NULL
);

Migration 0006 — MTC log index (SQLite only)

CREATE INDEX idx_certs_mtc_log_index
    ON certificates(mtc_log_index)
    WHERE mtc_log_index IS NOT NULL;

This index is specific to SQLite WAL mode and has no equivalent in the PostgreSQL/MariaDB migrations. All subsequent SQLite migration numbers are therefore one higher than their PostgreSQL/MariaDB counterparts.

Migration 0007 — Profile grants (PostgreSQL/MariaDB: 0006)

ALTER TABLE accounts  ADD COLUMN profile_grants TEXT;
ALTER TABLE eab_keys  ADD COLUMN profile_grants TEXT;

profile_grants is a JSON array of profile IDs (e.g. '["tls-server","mtc-tls"]'). NULL means no restriction. When an EAB key has grants set, they are copied to the account at registration time.

Migration 0008 — Audit events (PostgreSQL/MariaDB: 0007) — DROPPED

This migration originally created the audit_events database table for the PP CA v2.1 FAU structured audit trail. The table has been dropped by migration 0031 (SQLite) / 0032 (MariaDB) / 0033 (PostgreSQL). Audit events are now written to a dedicated systemd journal namespace (journalctl --namespace=akamu) via src/journal.rs. See contrib/systemd/journald@akamu.conf for retention settings.

Migration 0009 — Operators (PostgreSQL/MariaDB: 0008)

PP CA v2.1 FMT role-based access control. Operators authenticate via mTLS client certificate, Kerberos/GSSAPI, or both:

CREATE TABLE operators (
    id               INTEGER PRIMARY KEY AUTOINCREMENT,
    name             TEXT    NOT NULL UNIQUE,
    role             TEXT    NOT NULL
                             CHECK(role IN ('administrator','ca_operations','ca_ra','auditor')),
    cert_fingerprint TEXT    UNIQUE,   -- SHA-256 hex of DER leaf cert
    gssapi_principal TEXT    UNIQUE,   -- Kerberos principal e.g. alice@REALM
    created_at       TEXT    NOT NULL, -- RFC 3339
    last_seen_at     TEXT,             -- RFC 3339
    active           INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
    CHECK(cert_fingerprint IS NOT NULL OR gssapi_principal IS NOT NULL)
);

Migration 0010 — Certificate subject DN (PostgreSQL/MariaDB: 0009)

Adds a searchable subject DN column for FAU_SCR_EXT.1 audit queries:

ALTER TABLE certificates ADD COLUMN subject_dn TEXT;
CREATE INDEX idx_certs_subject_dn ON certificates(subject_dn);

Migration 0011 — Operator lockout (PostgreSQL/MariaDB: 0010)

FIA_AFL.1 per-operator authentication lockout after repeated failures:

ALTER TABLE operators ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE operators ADD COLUMN locked_until TEXT;

Migration 0012 — Multi-CA support (PostgreSQL/MariaDB: 0011)

Adds ca_id to accounts, orders, and certificates. Sentinel conventions:

  • accounts.ca_id = '' — server-wide account scope; the account may use any CA. The empty string is not a valid CA ID (config validator requires ^[a-z0-9]).
  • orders.ca_id = 'default' — backfills pre-migration rows to the canonical single-CA name.
  • certificates.ca_id = 'default' — same.
ALTER TABLE accounts     ADD COLUMN ca_id TEXT NOT NULL DEFAULT '';
ALTER TABLE orders       ADD COLUMN ca_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE certificates ADD COLUMN ca_id TEXT NOT NULL DEFAULT 'default';

CREATE INDEX idx_accounts_ca_id      ON accounts(ca_id);
CREATE INDEX idx_orders_ca_id        ON orders(ca_id);
CREATE INDEX idx_certs_ca_id         ON certificates(ca_id);
CREATE INDEX idx_certs_ca_id_revoked ON certificates(ca_id) WHERE status = 'revoked';

Migration 0013 — Cross-certificates (PostgreSQL/MariaDB: 0012)

Stores CA certificates issued by one akāmu CA for another CA’s public key. Rows are insert-only.

CREATE TABLE cross_certs (
    id             TEXT    PRIMARY KEY,   -- UUID
    issuer_ca_id   TEXT    NOT NULL,
    subject_ca_id  TEXT,                  -- akāmu CA ID if same-server; NULL if external
    subject_dn     TEXT    NOT NULL,      -- RFC 4514 subject DN
    subject_spki   BLOB    NOT NULL,      -- DER SubjectPublicKeyInfo of subject CA key
    cross_cert_der BLOB    NOT NULL,
    cross_cert_pem TEXT    NOT NULL,
    not_before     INTEGER NOT NULL,
    not_after      INTEGER NOT NULL,
    serial_number  TEXT    NOT NULL,
    created        INTEGER NOT NULL,
    UNIQUE (issuer_ca_id, serial_number)
);
CREATE INDEX idx_cross_certs_issuer  ON cross_certs(issuer_ca_id);
CREATE INDEX idx_cross_certs_subject ON cross_certs(subject_ca_id)
    WHERE subject_ca_id IS NOT NULL;

Migration 0014 — Authorization CA scope (PostgreSQL/MariaDB: 0013)

Records which CA owns each authorization, enabling per-CA namespace isolation. Pre-migration rows are backfilled from the parent order’s ca_id:

ALTER TABLE authorizations ADD COLUMN ca_id TEXT NOT NULL DEFAULT 'default';

UPDATE authorizations
   SET ca_id = COALESCE((SELECT ca_id FROM orders WHERE id = authorizations.order_id), 'default')
 WHERE order_id IS NOT NULL AND order_id != '';

CREATE INDEX idx_orders_ca_account ON orders(ca_id, account_id);
CREATE INDEX idx_authzs_ca_id      ON authorizations(ca_id);

Migration 0015 — Operator CA scope (PostgreSQL/MariaDB: 0014)

Scopes ca_ra operators to a single CA. Empty string = server-wide (the operator can act on any CA):

ALTER TABLE operators ADD COLUMN ca_id TEXT NOT NULL DEFAULT '';

Migration 0015 (PostgreSQL only) — Hot-path indexes

Two partial and compound indexes on authorizations that speed up the hot paths hit during every successful challenge validation. This migration has no SQLite or MariaDB equivalent because both databases perform adequately without it at typical concurrency levels; SQLite uses a single write connection that serialises concurrent writers, and MariaDB’s query planner handles these patterns differently.

-- Speeds up the NOT EXISTS subquery in on_valid: filters to non-valid rows only.
CREATE INDEX IF NOT EXISTS idx_authz_order_nonvalid
    ON authorizations(order_id)
    WHERE status != 'valid';

-- Speeds up find_valid_by_account_and_identifier: covers both filter columns.
CREATE INDEX IF NOT EXISTS idx_authz_acct_ident
    ON authorizations(account_id, identifier);

SQLite migration numbers remain one higher than the PostgreSQL/MariaDB equivalents from migration 0007 onward (due to the SQLite-only MTC log index at SQLite 0006). The PostgreSQL migration directory now contains 17 migrations (0001–0016, plus the PostgreSQL-only hot-indexes file); the SQLite directory contains 17 migrations (0001–0017). The SQLite offset means its 0017 corresponds to the RFC 9115 delegation changes, which is PostgreSQL/MariaDB 0016.

Migration 0016 — Email challenge state (SQLite) / Migration 0015 (PostgreSQL/MariaDB)

Adds two columns to the challenges table to support the two-channel token required by the RFC 8823 email-reply-00 challenge:

ALTER TABLE challenges ADD COLUMN email_token_part1 TEXT;
ALTER TABLE challenges ADD COLUMN email_message_id  TEXT;

CREATE UNIQUE INDEX IF NOT EXISTS idx_chall_email_message_id
    ON challenges(email_message_id)
    WHERE email_message_id IS NOT NULL;

email_token_part1 holds the server-generated first half of the RFC 8823 two-part token, delivered to the applicant in the challenge email subject. email_message_id is the Message-ID of the outbound challenge email, used to correlate the inbound webhook reply.

Migration 0017 — RFC 9115 delegation (SQLite) / Migration 0016 (PostgreSQL/MariaDB)

Adds the delegations table and four new columns to orders to support RFC 9115 ACME delegated certificates:

CREATE TABLE IF NOT EXISTS delegations (
    id           TEXT    PRIMARY KEY,
    account_id   TEXT    NOT NULL REFERENCES accounts(id),
    csr_template TEXT    NOT NULL,  -- JSON per RFC 9115 §4
    cname_map    TEXT,              -- JSON {fqdn: fqdn} or NULL
    created      INTEGER NOT NULL,
    updated      INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_delegations_account ON delegations(account_id);

ALTER TABLE orders ADD COLUMN delegation_id       TEXT REFERENCES delegations(id);
ALTER TABLE orders ADD COLUMN allow_cert_get      INTEGER NOT NULL DEFAULT 0;
ALTER TABLE orders ADD COLUMN upstream_order_url  TEXT;
ALTER TABLE orders ADD COLUMN upstream_cert_url   TEXT;

CREATE INDEX IF NOT EXISTS idx_orders_delegation ON orders(delegation_id)
    WHERE delegation_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_orders_delegation_status
    ON orders(delegation_id, status)
    WHERE delegation_id IS NOT NULL AND status = 'processing';

delegation_id is a nullable FK to delegations(id). Orders with a non-null delegation_id skip the authorization flow and start in ready status. allow_cert_get mirrors the "allow-certificate-get" field from the new-order payload — when set to 1, the certificate endpoint for that order accepts unauthenticated GET. upstream_order_url and upstream_cert_url are set by the background delegation task as it progresses through the upstream ACME flow.

The PostgreSQL version uses ALTER TABLE ... ADD COLUMN IF NOT EXISTS and CREATE INDEX CONCURRENTLY to allow the migration to run without an exclusive table lock.

Row types

src/db/schema.rs defines Rust structs mirroring each table row:

  • AccountRow — mirrors accounts. Includes ca_id: String (empty = server-wide; non-empty only when server.account_scope = "ca"), profile_grants: Option<String>.
  • OrderRow — mirrors orders. Includes ca_id: String (defaults to "default" for pre-migration rows), profile: Option<String>, all star_* fields, and the RFC 9115 delegation fields: delegation_id: Option<String>, allow_cert_get: bool, upstream_order_url: Option<String>, upstream_cert_url: Option<String>.
  • DelegationRow — mirrors delegations. Fields: id: String, account_id: String, csr_template: String (JSON), cname_map: Option<String> (JSON), created: i64, updated: i64.
  • AuthorizationRow — mirrors authorizations. Includes ca_id: String and subdomain_auth_allowed: bool.
  • ChallengeRow — mirrors challenges.
  • CertificateRow — mirrors certificates. Includes ca_id: String, subject_dn: Option<String>, suggested_window_start/end: Option<i64>, replaced_by: Option<String>.
  • CrossCertRow — mirrors cross_certs. subject_ca_id: Option<String> is None when the subject is an external CA.
  • OperatorRow — mirrors operators. Includes ca_id: String (CA scope for ca_ra operators; empty = server-wide), failed_attempts: i64, locked_until: Option<String>.

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, set_profile_grants, get_profile_grants, list
db::ordersinsert, get_by_id, update_status, list_authz_ids, list_pending_delegation_orders
db::delegationsinsert, get_by_id, update, delete, list, list_by_account
db::authzinsert, get_by_id, update_status
db::challengesinsert, get_by_id, list_by_authz, set_processing, set_invalid
db::certsinsert, get_by_id, get_by_serial, get_by_cert_id, mark_replaced, revoke, set_mtc_log_index, set_renewal_window, list_revoked, list_valid_for_account, get_latest_for_order, search
db::cross_certsinsert, get_by_id, list
db::eabinsert, get_by_kid, mark_used, list, delete
db::noncesinsert, consume, sweep_expired
db::operatorsinsert, insert_if_absent, is_empty, get_by_id, get_by_fingerprint, get_by_principal, list, update, set_active, update_last_seen, increment_failed, reset_failed, unlock, is_locked
db::auditRemoved — audit events are now written to the configured audit backend (systemd journal namespace, JSONL file, or in-process store) via src/journal.rs and src/audit.rs

CertSearchParams

db::certs::search accepts a CertSearchParams<'_> struct to satisfy clippy::too_many_arguments. All filter fields are optional; only Some values are emitted as WHERE clauses via QueryBuilder:

#![allow(unused)]
fn main() {
pub struct CertSearchParams<'a> {
    pub serial: Option<&'a str>,
    pub account_id: Option<&'a str>,
    pub status: Option<&'a str>,
    pub subject_dn: Option<&'a str>,  // LIKE-escaped substring match
    pub ca_id: Option<&'a str>,
    pub limit: i64,
    pub offset: i64,
}
}

The subject_dn filter uses LIKE with ! as the escape character; % and _ in the input are automatically escaped to prevent injection.

OperatorUpdateParams

db::operators::update accepts an OperatorUpdateParams<'_> struct. Only Some fields are included in the generated UPDATE statement:

#![allow(unused)]
fn main() {
pub struct OperatorUpdateParams<'a> {
    pub name: Option<&'a str>,
    pub role: Option<&'a str>,
    pub cert_fingerprint: Option<&'a str>,
    pub gssapi_principal: Option<&'a str>,
    pub ca_id: Option<&'a str>,  // Some("") clears CA scope; None leaves it unchanged
}
}

update is called by the PUT /admin/operators/{id} handler, which evicts any active session for that operator from AppState::admin_sessions on every successful update. This ensures that role and CA-scope changes take effect immediately rather than at the next session expiry.

Query helpers

src/db/mod.rs exports several helpers that make raw sqlx queries portable across backends.

pg_sql / query / query_as

PostgreSQL uses $N positional placeholders while SQLite and MariaDB use ?. sqlx’s AnyPool does not automatically rewrite ? for PostgreSQL because ? is also the JSONB existence operator there. The helpers below handle the rewrite transparently:

HelperUsage
pg_sql(sql)Rewrites ?$1, $2, … for PostgreSQL; returns the string unchanged for all other backends. The rewritten string is cached by static pointer identity, so each unique SQL literal is rewritten at most once.
query(sql)Calls pg_sql, then sqlx::query. Use everywhere a raw ?-parameterised query string is needed.
query_as::<O>(sql)Calls pg_sql, then sqlx::query_as. Use for typed row mapping.

DynQueryBuilder

For dynamically constructed queries (variable number of WHERE clauses, multi-row VALUES inserts), DynQueryBuilder emits $N for PostgreSQL and ? for all other backends, and tracks the bind count internally:

#![allow(unused)]
fn main() {
let mut q = DynQueryBuilder::new("SELECT id FROM certificates WHERE 1=1");
if let Some(serial) = params.serial {
    q.push(" AND serial_number = ").push_bind(serial);
}
let rows = q.fetch_all(&db).await?;
}

pg_local_async_commit

#![allow(unused)]
fn main() {
pub(crate) async fn pg_local_async_commit(
    tx: &mut sqlx::Transaction<'_, sqlx::Any>,
    kind: DbKind,
) -> Result<(), sqlx::Error>
}

Issues SET LOCAL synchronous_commit = off inside the current PostgreSQL transaction, eliminating the per-commit WAL flush (~1–4 ms on SSD) for writes on state-transition paths that are eventually consistent by ACME protocol design.

Called at the start of the following write transactions:

  • new-order — inserts the order, authorization, and challenge rows.
  • new-authz — inserts a standalone authorization row.
  • Challenge processing — updates challenge, authorization, and order status on on_valid and on_invalid.

The certificate issuance transaction (finalize) does not call this function; cert rows require full durability guarantees.

No-op on SQLite and MariaDB.

Transactions

Multi-table writes use explicit transactions to ensure atomicity:

  • Order creation: the order row, all authorization rows, and all challenge rows are inserted in a single transaction. For PostgreSQL, pg_local_async_commit is called at transaction start to defer WAL flush.
  • Challenge validation success: the challenge, authorization, and (if all authorizations are now valid) the order are updated in a single transaction. For PostgreSQL, pg_local_async_commit is called at transaction start.
  • Certificate issuance: the certificate row is inserted and the order is updated to valid in a single transaction. STAR re-issuance also stores the new CSR DER in the same transaction. Full WAL durability is retained for this transaction on all backends.

Schema diagram

The entity-relationship diagram below shows the ACME core tables and their foreign-key relationships. MTC tables (mtc_checkpoints, mtc_cosignatures, mtc_landmarks) and the standalone nonces table are omitted for readability. The audit_events table has been dropped; audit events are now written to the configured audit backend (systemd journal namespace, JSONL file, or in-process store).

erDiagram
    accounts {
        TEXT id PK
        TEXT status
        TEXT contact
        BLOB public_key
        TEXT jwk_thumbprint UK
        TEXT profile_grants
        TEXT ca_id
        INTEGER created
        INTEGER updated
    }
    orders {
        TEXT id PK
        TEXT account_id FK
        TEXT ca_id
        TEXT status
        INTEGER expires
        TEXT identifiers
        TEXT replaces
        TEXT error
        TEXT certificate_id
        TEXT profile
        INTEGER star_end_date
        INTEGER star_lifetime_secs
        TEXT delegation_id FK
        INTEGER allow_cert_get
        TEXT upstream_order_url
        TEXT upstream_cert_url
        INTEGER created
        INTEGER updated
    }
    authorizations {
        TEXT id PK
        TEXT order_id FK
        TEXT account_id FK
        TEXT ca_id
        TEXT status
        TEXT identifier
        INTEGER expires
        INTEGER wildcard
        INTEGER subdomain_auth_allowed
        INTEGER created
        INTEGER updated
    }
    challenges {
        TEXT id PK
        TEXT authz_id FK
        TEXT type
        TEXT status
        TEXT token
        INTEGER validated
        TEXT error
        INTEGER created
        INTEGER updated
    }
    certificates {
        TEXT id PK
        TEXT order_id FK
        TEXT account_id FK
        TEXT ca_id
        TEXT serial_number UK
        TEXT status
        BLOB der
        TEXT pem
        TEXT subject_dn
        INTEGER not_before
        INTEGER not_after
        INTEGER revoked_at
        INTEGER revocation_reason
        INTEGER mtc_log_index
        INTEGER suggested_window_start
        INTEGER suggested_window_end
        TEXT replaced_by
        INTEGER created
    }
    eab_keys {
        TEXT kid PK
        TEXT hmac_key_b64u
        TEXT profile_grants
        INTEGER created
        INTEGER used_at
    }
    operators {
        INTEGER id PK
        TEXT name UK
        TEXT role
        TEXT cert_fingerprint UK
        TEXT gssapi_principal UK
        TEXT ca_id
        INTEGER active
        INTEGER failed_attempts
        TEXT locked_until
        TEXT created_at
        TEXT last_seen_at
    }
    cross_certs {
        TEXT id PK
        TEXT issuer_ca_id
        TEXT subject_ca_id
        TEXT subject_dn
        BLOB subject_spki
        BLOB cross_cert_der
        TEXT cross_cert_pem
        TEXT serial_number
        INTEGER not_before
        INTEGER not_after
        INTEGER created
    }

    delegations {
        TEXT id PK
        TEXT account_id FK
        TEXT csr_template
        TEXT cname_map
        INTEGER created
        INTEGER updated
    }

    accounts ||--o{ orders : "account_id"
    accounts ||--o{ authorizations : "account_id (denormalized)"
    accounts ||--o{ certificates : "account_id"
    accounts ||--o{ delegations : "account_id"
    orders ||--o{ authorizations : "order_id"
    authorizations ||--o{ challenges : "authz_id"
    orders ||--o{ certificates : "order_id"
    delegations ||--o{ orders : "delegation_id"

Foreign key enforcement

Foreign key constraints are enabled at database open time. The constraint graph is:

  • orders.account_idaccounts.id
  • authorizations.order_idorders.id
  • authorizations.account_idaccounts.id
  • challenges.authz_idauthorizations.id
  • certificates.order_idorders.id
  • certificates.account_idaccounts.id
  • mtc_cosignatures.checkpoint_idmtc_checkpoints.id (with ON DELETE CASCADE)
  • delegations.account_idaccounts.id
  • orders.delegation_iddelegations.id (nullable)

Enabling foreign keys is done before running migrations so that any migration that would violate a constraint fails immediately rather than silently inserting orphaned rows.

Gossip Replication Protocol

Akamu replicates all ACME and cluster state across nodes using a CRDT-based push-pull gossip protocol. Each node maintains its own authoritative in-memory CRDT; the local database is a persistence cache for crash recovery only. Replication is eventually consistent: a write on one node appears on all peers within one or two gossip intervals.

Architecture Overview

flowchart TD
    subgraph Node A
        A_DB[(DB write)]
        A_Hook[crdt_hooks]
        A_CRDT[AkaCrdt\nin memory]
        A_Notify[write_notify]
        A_Loop[gossip_loop]
    end

    subgraph Node B
        B_Handler[POST /admin/gossip/sync]
        B_CRDT[AkaCrdt\nin memory]
        B_DB2[(DB persist\nevery 30 s)]
    end

    A_DB --> A_Hook --> A_CRDT
    A_Hook --> A_Notify --> A_Loop
    A_Loop -->|sign_and_seal\nCBOR+zstd+CMS| B_Handler
    B_Handler --> B_CRDT
    B_CRDT --> B_DB2
    B_Handler -->|delta response\nsign_and_seal| A_Loop
    A_Loop --> A_CRDT

CRDT Data Model

Top-Level: AkaCrdt

AkaCrdt (crates/akamu-crdt/src/crdt.rs) is the single replicated data structure holding all cluster state. It composes two CRDT primitive types:

FieldTypeSemantic
cluster_nodesOrMap<String, AkaNodeEntry>Node registry (gossip keys, URLs)
accountsOrMap<String, AccountEntry>ACME accounts
ordersOrMap<String, OrderEntry>ACME orders
authorizationsOrMap<String, AuthzEntry>ACME authorizations
challengesLwwMap<String, ChallengeEntry>Challenge state
certificatesOrMap<String, CertEntry>Issued certificates
eab_keysLwwMap<String, EabKeyEntry>EAB metadata (HMAC secret excluded)
operatorsOrMap<String, OperatorEntry>Admin operator accounts
delegationsOrMap<String, DelegationEntry>STAR delegation records
mtc_checkpointsLwwMap<u64, MtcCheckpointEntry>MTC checkpoint metadata
mtc_cosignaturesLwwMap<(String,String), MtcCosigEntry>External cosigner sigs
order_ownersLwwMap<String, OrderOwner>Processing-node ownership claims
mtc_writerLwwRegister<MtcWriter>Single elected MTC log writer

What is NOT replicated: nonces (single-node anti-replay), CA private keys, admin sessions, and EAB HMAC key bytes (the hmac_key_b64u field has #[serde(skip)] and is verified by a unit test to never appear in gossip CBOR).

OrMap<K, V>: Observed-Remove Map

or_map.rs — used for entities that can be soft-deleted (accounts, orders, etc.).

Each entry stores (value, added_at: i64, tombstone: bool, tombstone_at: Option<i64>).

Merge rules:

  • Tombstone always wins over live. A delete from any node propagates unconditionally.
  • Live vs live: higher added_at wins (Last-Write-Wins by wall-clock timestamp).
  • Tombstone before insert: remove() inserts a tombstone even when the key is absent, so an insert arriving later via a different gossip path cannot resurrect the entry.

LwwMap<K, V> + LwwRegister<T>: Last-Write-Wins

lww_map.rs / lww_register.rs — used for mutable state updated by a single authority (challenge status, EAB usage, ownership claims).

LwwRegister stores (value: Option<T>, timestamp: i64, node_id: String).

Merge rule: higher timestamp wins; on equal timestamps the lexicographically greater node_id wins. A None value with timestamp > 0 is a deletion tombstone.

Generation Counter

generation.rs exports a process-wide AtomicU64 CRDT_GENERATION.

next_gen() is called on every in-memory CRDT write (upsert, remove, set, merge of a new entry). The resulting local_gen is stored per-entry but never serialised to CBOR — it resets to 0 on reload. The counter is used exclusively for delta computation.

delta_since(gen) → sparse AkaCrdt with only entries where local_gen > gen
delta_range(since, until) → entries where local_gen ∈ (since, until]

After DB load, AkaCrdt::max_local_gen() seeds CRDT_GENERATION so deltas computed after startup do not collide with pre-existing generations.

Write Path

After each successful DB write, crdt_hooks.rs maintains the in-memory CRDT:

DB write succeeds
  → on_account_upsert(state, p)   [example]
      if gossip_enabled(state):
          crdt.write().await.accounts.upsert(id, entry, updated)
          state.write_notify.notify_one()

The guard gossip_enabled() is a single config check (state.config.gossip.is_some()). In single-node deployments the hooks are no-ops; no CRDT overhead is incurred.

Gossip Loop

src/gossip/gossip_loop.rs — spawned once from main, runs forever.

Wake Triggers

The loop wakes on whichever fires first:

  1. Periodic timer (interval_secs, default 15 s)
  2. Write notificationstate.write_notify.notified() fires after any CRDT hook write

The write-notify path has two rate controls:

ControlValuePurpose
Slide window20 msExtend wait on each additional notification within cap
Hard cap150 msMaximum debounce; coalesces ~8 writes per ACME issuance
Min interval500 msFloor between write-notify rounds (~2 Hz max)

Startup jitter: Each node derives a deterministic jitter from its node_id (hash(node_id) % min_interval_ms) and delays the first round by that amount. Without this, nodes started simultaneously create a gossip storm where all N nodes gossip simultaneously to each peer, causing N-1 merge write-lock requests to queue on every receiver.

Four-Phase Round

Phase A — Build envelopes (one CRDT read lock for all peers)
  For each peer in gossip_peers:
    1. Look up peer in cluster_nodes by URL; extract KEM + signing keys
    2. If peer not in cluster_nodes or keys absent → skip (warn first 3 rounds)
    3. Decide full vs delta:
         peer_last_gen absent → clone full CRDT
         peer_last_gen present → crdt.delta_since(peer_last_gen)
    4. CBOR-encode CRDT bytes
    5. Generate 16-byte random nonce
    6. Build GossipEnvelope{crdt, issued_at, is_delta, my_gen, request_delta_since, nonce}
    7. CBOR-encode envelope
  CRDT read lock released

Phase B — Sign envelopes (no lock)
  For each prepared peer:
    sign_and_seal(envelope_bytes, peer_kem_key, own_signing_priv, own_signing_cert)

Phase C — Parallel HTTP round-trips
  Spawn one JoinHandle per peer:
    POST {peer_url}/gossip/sync
    Headers: Content-Type: application/pkcs7-mime
             X-Akamu-Node-Id: {own_node_id}
    Body: signed CMS blob

Phase D — Validate + batch-merge (one write lock for all peers)
  Pass 1 (no lock):
    For each peer response:
      verify_and_open(response, own_kem_priv, peer_signing_pub)
      decode GossipEnvelope
      validate issued_at: reject if > now+clock_skew or < now-max_age
      decode inner AkaCrdt

  Pass 2 (single write lock):
    Pre-merge all valid peer CRDTs into scratch accumulator (no lock)
    Acquire write lock once
    Merge accumulator into live CRDT
    Release write lock

  Pass 3 (no lock):
    Update peer_last_gen → post_merge_gen
    Update peer_response_gen → peer's reported my_gen
    Log first-contact entry counts if is_first_contact

Batch-merge (N peers → 1 write lock acquisition) keeps the lock-hold duration proportional to one merge operation rather than N. The pre-merge accumulator is built lock-free.

Delta vs Full-State

ConditionPayload
First contact (no peer_last_gen)Full AkaCrdt clone
Subsequent roundsdelta_since(peer_last_gen) — sparse CRDT
Receiver response, delta requesteddelta_range(request_delta_since, pre_merge_gen)
Receiver response, no delta requestedFull CRDT

The sender’s my_gen at send time is echoed back by the receiver as the basis for future request_delta_since. This means the requester asks for only entries the peer received between the last exchange and the current one — not the full state history.

Fan-Out Limiting

fan_out = 0 (default): all peers contacted every round.

fan_out = K > 0: a rotating window of K peers is selected per round, indexed by current_gen % N. Every peer is reached within ⌈N/K⌉ rounds. This bounds the simultaneous inbound gossip handler count on receiving nodes to K × (number of sending nodes) per round.

Wire Protocol

GossipEnvelope

src/gossip/envelope.rs — CBOR-serialised using compact field names.

FieldCBOR keyTypeDescription
crdtpbytesCBOR-encoded AkaCrdt (full or delta)
issued_atti64Unix timestamp (seconds); anti-replay anchor
is_deltadboolTrue when crdt is a sparse delta
my_gengu64Sender’s CRDT_GENERATION at send time
request_delta_sinceru64?Ask receiver to respond with delta since this gen
noncenbytes16 random bytes; replay deduplication

Cryptographic Layer

src/gossip/crypto.rs — CMS SignedData(EnvelopedData).

Send — sign_and_seal(plaintext, recipients, signing_priv, signing_cert):

plaintext
  → zstd compress (level 3)
  → AES-256-GCM encrypt with fresh 32-byte CEK + 12-byte nonce
  → for each recipient:
      ML-KEM-768 encapsulate(peer_kem_pub)
        → shared_secret, kemct
      HKDF-SHA-256(key=shared_secret, salt=kemct, info="akamu-cms-kek") → 32-byte KEK
      RFC 3394 AES-256 Key Wrap(KEK, CEK) → 40-byte encrypted CEK
      KEMRecipientInfo{kem=ML-KEM-768, kemct, kdf=HKDF-SHA-256, encrypted_key}
  → EnvelopedData DER
  → ECDSA P-256 sign(enveloped_der) → SignedData DER

Receive — verify_and_open(signed_der, kem_priv, expected_sender_spki):

SignedData DER
  → extract embedded signer certificate
  → assert signer_cert.public_key == expected_sender_spki  (pinned — no TOFU)
  → ECDSA verify SignedData signature
  → extract EnvelopedData
  → iterate KEMRecipientInfo entries:
      ML-KEM-768 decapsulate(kem_priv, kemct) → shared_secret
      HKDF-SHA-256(shared_secret, kemct, "akamu-cms-kek") → KEK
      AES-256 Key Unwrap(KEK, encrypted_cek) → CEK
      AES-256-GCM decrypt(CEK, nonce, ciphertext) → compressed_plaintext
  → zstd decompress (64 MiB limit)

No Trust-On-First-Use. Both the sender’s ECDSA signing key and the receiver’s ML-KEM-768 public key must be pre-pinned via POST /admin/gossip/register before any gossip exchange. A node without pre-pinned keys will log a warning and skip the peer.

The KEM public key for the response is captured before the inbound CRDT merge. This prevents a compromised peer from redirecting the encrypted response by injecting a modified cluster_nodes entry in its CRDT payload.

Receiver Handler

src/gossip/handlers.rsPOST /admin/gossip/sync.

Processing order:

  1. Header validationX-Akamu-Node-Id header: required, ≤ 64 bytes.
  2. Pre-merge key lookup — sender’s signing pub + KEM pub read from CRDT under read lock. Returns 401 if either key is absent.
  3. Verify and decryptverify_and_open() with the pre-fetched signing key.
  4. CBOR decodeGossipEnvelope::decode().
  5. Timestamp validation:
    • Reject if issued_at > now + clock_skew_tolerance_secs (default 30 s)
    • Reject if issued_at < now - gossip_envelope_max_age_secs (default 300 s)
  6. Nonce deduplication — 16–32 byte nonce checked against gossip_nonce_cache (HashMap<Vec<u8>, i64>). Lazy eviction; max 10 000 entries; returns 429 when full. Old peers omitting the nonce field bypass dedup.
  7. Decode CRDT from envelope.
  8. Merge under write lock. CRDT_GENERATION captured inside the lock to avoid racing with concurrent hook writes.
  9. Build response:
    • If request_delta_since present: delta_range(request_delta_since, pre_merge_gen)
    • Otherwise: full CRDT
    • Encode as GossipEnvelope with own my_gen = post-merge gen
    • sign_and_seal() response for the sender’s KEM public key
  10. Return 200 with Content-Type: application/pkcs7-mime body.

DB persist is intentionally absent from this hot path. The CRDT is the source of truth; the DB is written on a 30-second timer (persist_crdt_cluster + persist_crdt_acme) on a dedicated pool (crdt_db) to avoid contending with ACME writes.

Node Identity and Key Bootstrap

Each node’s identity is derived from its gossip signing key using RFC 7093 §2 Method 1:

node_id = base64url_nopad(SHA-256(BIT_STRING_value_of_signing_pub_key)[0..20])

(compute_aki_from_spki in src/ca/init.rs implements the derivation.)

On first startup, src/ca/init.rs generates:

  • ML-KEM-768 key pair (PKCS8 DER + SPKI DER stored in node_keys table)
  • ECDSA P-256 gossip signing key pair + self-signed certificate

AppState loads both key pairs into:

  • node_kem_priv — PKCS8 DER, used by the receiver to decapsulate inbound messages
  • node_gossip_signing_priv — PEM, used by the sender to sign outbound messages
  • node_gossip_signing_cert — DER, embedded in outbound SignedData

Enrollment: Before two nodes can exchange gossip, each must pre-pin the other’s keys via POST /admin/gossip/register (requires administrator role):

{
  "node_id": "…",
  "gossip_url": "https://peer.acme.internal:8443",
  "kem_public_key_b64u": "<SPKI DER, base64url>",
  "gossip_signing_pub_key_b64u": "<SPKI DER, base64url>",
  "gossip_signing_cert_b64u": "<X.509 DER, base64url>"
}

This call upserts a AkaNodeEntry into crdt.cluster_nodes and immediately persists it to crdt_db. The entry then replicates to all other peers in the next gossip round.

Peer Discovery

The gossip loop builds its peer list each round by unioning two sources:

  1. config.gossip.peers — statically configured URLs from akamu.toml
  2. Live values from crdt.cluster_nodes — dynamically discovered peers enrolled via gossip/register and propagated through the cluster

Duplicate URLs are eliminated with a HashSet. Stale per-peer state maps (peer_last_gen, peer_response_gen) are pruned when a peer is no longer in the union.

Distributed Coordination

Two CRDT-based consensus mechanisms are built on top of gossip:

Order Processing Ownership

When a node finalises an ACME order it calls:

#![allow(unused)]
fn main() {
crdt.claim_order(order_id, node_id, now, ownership_ttl_secs)
}

This writes to order_owners: LwwMap<String, OrderOwner> and gossips within the debounce window. Another node calling claim_order for the same order will fail (return false) unless the incumbent’s claim has expired (claimed_at + ttl < now).

The TTL (default 150 s) is intentionally longer than a typical HTTP timeout so that a crashing node’s slot naturally expires and another node can take over, rather than requiring explicit release.

MTC Log Writer Election

Only one node should produce MTC checkpoints per CA. The election uses:

#![allow(unused)]
fn main() {
crdt.claim_mtc_writer(node_id, now, ownership_ttl_secs)
}

backed by mtc_writer: LwwRegister<MtcWriter>. The node that writes the highest timestamp wins; ties break by lexicographic node_id. A live incumbent blocks challengers until its claim lapses.

Both mechanisms rely on gossip propagating the claim before the TTL expires. With a 15-second gossip interval and a 150-second TTL, a claim survives at least nine missed rounds before another node can preempt.

Persistence and Recovery

WhatWhereWhen
ACME state (accounts, orders, …)db poolPeriodic, every 30 s
Cluster state (nodes, ownership)crdt_db poolOn gossip/register; periodic 30 s
CRDT_GENERATIONSeeded from AkaCrdt::max_local_gen() at startupDB load

max_local_gen() walks all CRDT sub-collections and returns the highest stored local_gen. Because local_gen is not serialised in CBOR (and therefore not in the DB schema for merged entries), entries received via gossip and persisted to DB will have local_gen = 0 after reload. This is safe: the startup seed ensures the post-load CRDT_GENERATION is ≥ the highest write seen locally, so deltas computed after startup will include any entries written before the restart.

Tombstone GC runs hourly in-place (not via snapshot) under a write lock, purging tombstones older than tombstone_ttl_secs (default 7 days). The in-place approach avoids a data-loss window that would exist if entries written between the snapshot read and the purge application were lost in a crash.

Concurrency Invariants

ResourceLockUsage
AppState::crdttokio::sync::RwLockRead: many concurrent ACME handlers; Write: hooks + gossip merge
AppState::gossip_nonce_cachestd::sync::MutexShort critical section; one lock per inbound gossip request
CRDT_GENERATIONAtomicU64 (AcqRel)Bumped on every CRDT write; read after merge under Acquire ordering

The gossip loop captures CRDT_GENERATION with Ordering::Acquire before building envelopes and after each merge. Acquire ordering ensures all preceding writes (from hooks) are visible before the generation value is read.

The receiver handler captures pre_merge_gen inside the write lock, not before acquiring it, to prevent a race where a concurrent hook write increments the generation between the read and the lock acquisition. This ensures delta_range(request_delta_since, pre_merge_gen) in the response does not include entries written after the merge.

Configuration Reference

[gossip]
# Peer admin base URLs (not ACME listener URLs).
peers = ["https://node2.acme.internal:8081"]

# Gossip interval (seconds). Default: 15.
interval_secs = 15

# Tombstone retention before GC (seconds). Default: 604800 = 7 days.
tombstone_ttl_secs = 604800

# Order/MTC-writer ownership TTL (seconds). Default: 150.
ownership_ttl_secs = 150

# Maximum age of an accepted gossip envelope (seconds). Default: 300.
gossip_envelope_max_age_secs = 300

# Clock skew tolerance (seconds). Default: 30.
clock_skew_tolerance_secs = 30

# Peers contacted per round. 0 = all (default). Use in large clusters.
fan_out = 0

See Cluster Setup and Gossip for deployment procedures.

Certificate Authority

The CA module (src/ca/) handles key generation, certificate issuance, CSR validation, and CRL generation. All CA operations are synchronous (no async); they are called from async handlers but do not perform I/O themselves (except during initialization).

Initialization (src/ca/init.rs)

ca::init::load_or_generate(config: &CaConfig) is called once at startup. It follows this logic:

key_file 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
SubjectKeyIdentifierLeftmost 20 bytes of SHA-256 hash of the public key bit string (RFC 7093 §2 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)

There are two issuance entry points:

FunctionUsed when
issue_certificate(…)Internal default path (tests, legacy callers)
issue_with_params(ca, csr, params, not_before, not_after)All order finalizations — receives CertificateParameters resolved from the profile registry

Both return an IssuedCert with the same structure. issue_certificate is kept for backward compatibility with existing unit tests.

issue_with_params

ca::issue::issue_with_params(ca, csr, params, not_before_override, not_after_override) builds and signs an X.509 v3 end-entity certificate using the resolved CertificateParameters. It is the primary issuance path called by routes::finalize.

CertificateParameters is resolved at finalize time from ProfileRegistry::resolve(profile_name) when a profile is requested, or from CertificateParameters::from_ca(ca) when no profile is given. The latter reproduces the pre-profile default: digitalSignature KeyUsage, serverAuth EKU, and the [ca] validity/URL settings.

Serial number

A random 16-byte serial is generated by getrandom. The high bit is cleared to ensure the value is non-negative in two’s complement (required by RFC 5280 §4.1.2.2).

Extensions added

ExtensionCriticalCondition
BasicConstraintsNoAlways; cA=FALSE
KeyUsageYesOnly when key_usage_bits ≠ 0
ExtendedKeyUsageNoOnly when extended_key_usages is non-empty
SubjectKeyIdentifierNoAlways; RFC 7093 §2 Method 1 (SHA-256) of SPKI from CSR
AuthorityKeyIdentifierNoAlways; RFC 7093 §2 Method 1 (SHA-256) of CA’s SPKI
SubjectAlternativeNameNoAlways; rebuilt from validated SANs
AuthorityInfoAccess (OCSP)NoOnly when ocsp_url is Some(_)
CRLDistributionPointsNoOnly when crl_url is Some(_)
CertificatePoliciesNoOnly when certificate_policies is non-empty

The default CertificateParameters::from_ca sets key_usage_bits = digitalSignature and extended_key_usages = ["server_auth"], so the KeyUsage and EKU extensions are always present in the default case.

Validity clamping

If not_before_override or not_after_override is given (from the newOrder payload), the validity window is set accordingly. The notBefore is granted a 5-minute clock-skew grace (it may be up to 5 minutes in the past). The total window cannot exceed params.validity_days from the moment of signing.

PEM bundle

The returned IssuedCert contains:

  • cert_der — the leaf certificate DER (stored 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).

build_crl is called by the CRL handler in src/routes/crl.rs, which serves both GET /ca/crl (legacy, defaults to the default CA) and GET /ca/{ca_id}/crl (per-CA). The handler uses a per-CA in-memory cache (AppState::crl_caches) keyed by CA ID:

  1. Fast path: if the cached DER is still within its TTL, it is returned immediately without a DB query or signing operation.
  2. Slow path: if the cache is empty or expired, db::certs::list_revoked(ca_id) fetches all revoked certificates for that CA (bounded to MAX_CRL_ENTRIES = 500,000), build_crl signs a fresh CRL, and the result is stored in the cache with a TTL of crl_next_update_secs / 2 (minimum 30 seconds).

The POST /admin/ca/{id}/crl/force endpoint invalidates the per-CA cache so operators can force an immediate rebuild after a revocation.

Multi-CA support

When more than one [[ca]] entry is present in config.toml, main.rs runs the ca::init::load_or_generate loop for each entry and builds an IndexMap<String, Arc<CaState>> stored in AppState::cas.

CaState structure

Each CaState instance carries all information needed to issue and revoke certificates for one CA. The fields added for multi-CA deployments are:

FieldTypeDescription
idStringUnique CA identifier; matches CaConfig.id and appears as the {ca_id} URL segment.
key_typeStringKey algorithm string from config (e.g. "ec:P-256", "rsa:2048"). Stored for logging and API responses.
crl_next_update_secsu64Validity window for signed CRLs in seconds; determines the CRL cache TTL.
caa_identitiesVec<String>CAA domain identities specific to this CA. When empty, falls back to [server].caa_identities.

All other CaState fields (key, cert_der, hash_alg, validity_days, crl_url, ocsp_url, aki_bytes, enforce_validity_cap) exist in single-CA deployments as well.

CA lookup helpers

Two methods on AppState are the canonical way to obtain a CaState reference inside handlers:

#![allow(unused)]
fn main() {
// Look up a CA by its string ID; returns None for unknown IDs.
let ca: Option<&Arc<CaState>> = state.get_ca("rsa");

// Return the default CA (the one with is_default = true in config).
// Panics only if the server was constructed incorrectly.
let ca: &Arc<CaState> = state.default_ca();
}

The CaId extractor in src/routes/mod.rs resolves the per-request CA ID from the {ca_id} URL path parameter or falls back to state.default_ca_id. Handlers pass this string to state.get_ca(ca_id) when they need the full CaState.

Per-CA initialization loop

main.rs builds the multi-CA state with a loop:

#![allow(unused)]
fn main() {
for ca_cfg in &config.cas {
    let (key, cert_der) = ca::init::load_or_generate(ca_cfg)?;
    let ca_state = Arc::new(CaState {
        id: ca_cfg.id.clone(),
        key_type: ca_cfg.key_type.clone(),
        // … other fields …
    });
    crl_caches_map.insert(ca_cfg.id.clone(), Default::default());
    link_headers_map.insert(ca_cfg.id.clone(), build_link_header(&config, &ca_cfg.id));
    cas_map.insert(ca_cfg.id.clone(), ca_state);
}
let default_ca_id = config.default_ca().id.clone();
}

Config::default_ca() returns the CaConfig entry with is_default = true. Config::validate() enforces that exactly one entry is default, all IDs are unique and lowercase, and no ID matches a reserved ACME path segment.

Profile filtering per CA

ProfileRegistry::profiles_for_ca(ca_id) returns only the profiles whose ca_ids list is empty (unrestricted) or explicitly contains ca_id. ProfileRegistry::resolve_for_ca(name, ca_id) applies the same filter to a single profile lookup. Handlers call these instead of the unfiltered all_profiles() / resolve() methods when operating on a specific CA.

Cross-signing

The admin API allows an operator to issue a cross-certificate: a CA certificate signed by one Akāmu CA for the public key of another CA (same-server or external). Cross-certificates are stored in the cross_certs table and served at /ca/{ca_id}/cross-certs.

Request body (CrossSignSubject)

POST /admin/ca/{id}/cross-sign accepts a JSON body with two variants (mutually exclusive; serde uses untagged dispatch):

// Variant 1 — same-server CA:
{ "subject_ca_id": "rsa", "validity_years": 5 }

// Variant 2 — external CA supplied as PEM:
{ "subject_cert_pem": "-----BEGIN CERTIFICATE-----\n…", "validity_years": 5 }

validity_years defaults to 5 when omitted. The {id} path parameter identifies the issuing CA; the subject_ca_id or subject_cert_pem identifies the subject whose public key is signed.

issue_ca_cert

#![allow(unused)]
fn main() {
pub fn issue_ca_cert(
    issuer_ca: &CaState,
    subject_cert_der: &[u8],
    validity_years: u32,
) -> Result<IssuedCaCert, AcmeError>
}

Issues a CA certificate signed by issuer_ca for the public key extracted from subject_cert_der. The issued certificate carries:

ExtensionValue
BasicConstraintsCritical; cA=TRUE, pathLen=0
KeyUsageCritical; keyCertSign + cRLSign
SubjectKeyIdentifierRFC 7093 §2 Method 1 hash of the subject CA’s SPKI
AuthorityKeyIdentifierRFC 7093 §2 Method 1 hash of the issuer CA’s SPKI

pathLen=0 limits the cross-certificate to a one-hop chain: the subject CA may sign end-entity certificates but may not sign further intermediate CAs. This is the narrowest cA=TRUE constraint that still allows the subject CA to fulfil its role while preventing the creation of unlimited additional CA layers beneath it.

Validity is computed as validity_years Julian years (365.25 days each) from now, with no 5-minute backdate clamp applied (cross-certificate issuance is operator-initiated, not time-sensitive).

The return value is an IssuedCaCert struct containing cert_der, cert_pem, the hex serial_number, Unix timestamps not_before / not_after, the subject_spki_der, and a subject_dn RFC 4514 string.

check_is_ca_cert

#![allow(unused)]
fn main() {
pub(crate) fn check_is_ca_cert(cert_der: &[u8], now: i64) -> Result<(), AcmeError>
}

Validates that a DER certificate has BasicConstraints cA=TRUE before it is accepted as a cross-signing subject. Uses ValidationProfile::Rfc5280 with ee_extension_policy = new_default_webpki_ca() so that CABF WebPKI end-entity restrictions are bypassed and cA=TRUE is required. The certificate is used as its own trust anchor (self-signed root CA scenario). Returns AcmeError::BadRequest when the check fails.

The admin cross-cert endpoint (POST /admin/ca/{id}/cross-sign) calls check_is_ca_cert before calling issue_ca_cert to reject requests that supply an end-entity certificate as the subject.

Challenge Validation

Challenge validation is the process by which Akāmu probes applicant servers to verify domain ownership. All validation is asynchronous and intentionally infallible — errors are recorded in the database rather than propagated.

Dispatch

The entry point is validation::validate_challenge in src/validation/mod.rs. It is called from routes::challenge::respond_challenge after marking the challenge as processing.

#![allow(unused)]
fn main() {
pub async fn validate_challenge(
    state: &Arc<AppState>,
    params: ChallengeParams<'_>,
) -> &'static str
}

ChallengeParams carries all per-challenge inputs as named fields:

#![allow(unused)]
fn main() {
pub struct ChallengeParams<'a> {
    pub challenge_id: &'a str,
    pub authz_id: &'a str,
    pub order_id: &'a str,
    pub chall_type: &'a str,
    pub id_type: &'a str,
    pub id_value: &'a str,
    pub key_auth: &'a str,
    pub token: &'a str,
    pub onion_csr_der: Option<&'a [u8]>,
    pub account_id: &'a str,
}
}

validate_challenge calls dispatch(...), which routes to one of five validators. email-reply-00 is handled separately — see email-reply-00 two-phase model below.

chall_typeModuleFunction
"http-01"validation::http01validate(domain, token, key_auth, port, allow_private_ips, client)
"dns-01"validation::dns01validate(domain, key_auth, validate_dnssec, dot_server_name)
"tls-alpn-01"validation::tls_alpn01validate(id_type, domain, key_auth)
"dns-persist-01"validation::dns_persist_01validate(domain, account_uri, issuer_domains, resolver_addr, validate_dnssec, dot_server_name)
"onion-csr-01"validation::onion_csr_01validate(domain, csr_der, key_auth)
Any otherReturns AcmeError::IncorrectResponse

Note: email-reply-00 does NOT appear in the dispatch table above. Client POST to the challenge URL triggers send_challenge_email (Phase 1), not a network probe. Validation completes later via the webhook endpoint. See the section below for the full model.

email-reply-00 two-phase model

email-reply-00 (src/validation/email_reply_00.rs) works differently from all other challenge types. Instead of a network probe, it uses a two-channel token delivered by email, with completion driven by an inbound webhook rather than by validate_challenge.

Phase 1 — client POST triggers send_challenge_email

When the ACME client POSTs to the challenge URL, the route handler calls email_reply_00::send_challenge_email(state, challenge_id, email_addr, token_part2_b64) instead of spawning a validate_challenge task. The function:

  1. Generates token-part1: 20 random bytes encoded as base64url (≥128 bits of entropy).
  2. Generates a unique Message-ID of the form <uuid@from-domain>.
  3. Writes both values to challenges.email_token_part1 and challenges.email_message_id in the database before invoking the send script, so a script failure leaves the token record in a recoverable state.
  4. Invokes the configured send_script with env_clear(), passing ACME_TO, ACME_FROM, ACME_SUBJECT, ACME_MESSAGE_ID, ACME_AUTO_SUBMITTED, and ACME_TOKEN_PART2 as environment variables. The script must exit 0 on success.
  5. If the script exits non-zero or times out, returns an error and the route handler marks the challenge "invalid". Otherwise the challenge stays "processing" and the client polls until Phase 2 completes it.

Phase 2 — webhook receives email reply via verify_response

The MTA that receives the applicant’s reply POSTs the parsed reply to POST /acme/email-webhook. This endpoint does not use ACME JWS authentication; instead it verifies the X-Akamu-Signature: sha256=<hex> header against the raw request body using HMAC-SHA256 with email_challenge.webhook_hmac_secret.

After the HMAC check passes, email_reply_00::verify_response(state, payload) is called. The payload is a WebhookPayload struct with fields from, in_reply_to, dkim_domain, dkim_status, and body. The function:

  1. Looks up the challenge via challenges.email_message_id = payload.in_reply_to using the write pool (state.db) to avoid stale WAL reads that could miss Phase 1 writes.
  2. Checks that the challenge is in "processing" state.
  3. Checks that the authorization has not expired.
  4. Verifies that payload.from matches the identifier’s email address (local-part case-sensitive, domain case-insensitive per RFC 5321 §2.4).
  5. Verifies that payload.dkim_domain matches the domain part of payload.from (case-insensitive), enforcing RFC 8823 §3.2.
  6. Verifies that payload.dkim_status is "pass" (case-insensitive to accommodate MTAs that report "Pass").
  7. Extracts the base64url payload between -----BEGIN ACME RESPONSE----- / -----END ACME RESPONSE----- delimiters; rejects if absent, whitespace-only, non-ASCII, or longer than 512 bytes.
  8. Computes the expected digest: SHA-256(token-part1 || token-part2 || "." || thumbprint) where both token parts are the stored base64url strings and thumbprint is the account’s JWK thumbprint (from accounts.jwk_thumbprint).
  9. Compares the decoded response bytes with the digest using constant_time_eq.
  10. On match, calls on_valid; on any mismatch or error, calls on_invalid.

The webhook handler always returns HTTP 200 regardless of outcome (to prevent oracle attacks on the HMAC or challenge state).

sequenceDiagram
    participant Client as ACME Client
    participant H as Route Handler
    participant E as send_challenge_email
    participant Script as send_script
    participant Inbox as Applicant Inbox
    participant MTA as Inbound MTA
    participant W as Webhook Handler
    participant V as verify_response
    participant DB as Database

    Client->>H: POST /acme/.../chall/... (email-reply-00)
    H->>E: send_challenge_email(challenge_id, email, token_part2)
    E->>DB: INSERT email_token_part1, email_message_id
    E->>Script: exec send_script (ACME_TO, ACME_FROM, ACME_SUBJECT, ...)
    Script->>Inbox: delivers challenge email
    E-->>H: Ok(())
    H->>DB: challenge status = processing
    H-->>Client: 200 processing

    Note over Client: Client polls authorization URL

    Inbox->>MTA: applicant replies to challenge email
    MTA->>W: POST /acme/email-webhook (X-Akamu-Signature: sha256=...)
    W->>W: verify HMAC-SHA256
    W->>V: verify_response(payload)
    V->>DB: lookup challenge by email_message_id
    V->>V: verify From, DKIM domain, DKIM status, response digest
    alt digest matches
        V->>DB: challenge/authz/order = valid
    else mismatch or error
        V->>DB: challenge/authz/order = invalid
    end
    W-->>MTA: 200 OK

dns-persist-01 account pre-check

For dns-persist-01 only, validate_challenge performs an account status check before calling dispatch. Because the TXT record is long-lived and may have been provisioned weeks before the order is placed, the account could have been deactivated or revoked in the intervening time. The pre-check queries the database:

SELECT status FROM accounts WHERE id = ?
  • If the account status is "valid", dispatch proceeds normally.
  • If the status is anything else (e.g., "deactivated", "revoked"), on_invalid is called immediately with AcmeError::Unauthorized and the challenge is marked invalid without any DNS query.
  • If the database query itself fails, on_invalid is called with AcmeError::Internal.

After dispatch returns, validate_challenge calls either on_valid or on_invalid to update the database.

Validation flow

sequenceDiagram
    participant Client as ACME Client
    participant H as Route Handler
    participant V as validate_challenge
    participant D as dispatch
    participant Ext as Applicant Server
    participant DB as SQLite

    Client->>H: POST /acme/.../chall/...
    H->>DB: challenge status = processing
    H->>V: tokio::spawn
    H-->>Client: 200 processing

    V->>D: chall_type, domain, key_auth

    alt http-01
        D->>Ext: GET /.well-known/acme-challenge/TOKEN
        Ext-->>D: 200 key_authorization body
    else dns-01
        D->>Ext: TXT _acme-challenge.DOMAIN
        Ext-->>D: TXT record value
    else tls-alpn-01
        D->>Ext: TLS connect port 443, ALPN acme-tls/1
        Ext-->>D: Certificate with id-pe-acmeIdentifier
    else dns-persist-01
        D->>Ext: TXT _validation-persist.DOMAIN
        Ext-->>D: TXT record value
    end

    alt probe succeeded
        V->>DB: BEGIN TRANSACTION
        V->>DB: challenge status = valid
        V->>DB: authorization status = valid
        V->>DB: order status = ready
        V->>DB: COMMIT
    else probe failed
        V->>DB: BEGIN TRANSACTION
        V->>DB: challenge status = invalid
        V->>DB: authorization status = invalid
        V->>DB: order status = invalid
        V->>DB: COMMIT
    end

    Note over Client: Client polls authorization URL

Background execution

Validation runs inside a tokio::spawn task, not in the request handler’s async context:

#![allow(unused)]
fn main() {
let handle = tokio::spawn(async move {
    validation::validate_challenge(
        &state_clone,
        ChallengeParams {
            challenge_id: &challenge_id,
            authz_id: &authz_id,
            order_id: &order_id,
            chall_type: &chall_type,
            id_type: &id_type,
            id_value: &id_value,
            key_auth: &key_auth,
            token: &token,
            onion_csr_der: onion_csr_der.as_deref(), // Some(der) for onion-csr-01, None otherwise
            account_id: &account_id,
        },
    )
    .await;
});

// Observer task: log panics without letting them go silent.
tokio::spawn(async move {
    if let Err(e) = handle.await {
        tracing::error!(
            "challenge {challenge_id_for_log}: validation task panicked: {e:?}"
        );
    }
});
}

The challenge handler returns the processing status immediately. The client must poll the authorization URL to detect completion.

The observer task pattern ensures that panics inside the validation task are logged via tracing::error! rather than silently discarded (which would happen if the JoinHandle were simply dropped).

State cascade diagram

stateDiagram-v2
    direction TB

    state "Challenge" as chall {
        [*] --> ch_pending
        ch_pending --> ch_processing : client responds
        ch_processing --> ch_valid : probe OK
        ch_processing --> ch_invalid : probe failed
    }

    state "Authorization" as authz {
        [*] --> az_pending
        az_pending --> az_valid : challenge valid
        az_pending --> az_invalid : challenge invalid
    }

    state "Order" as ord {
        [*] --> or_pending
        or_pending --> or_ready : all authzs valid
        or_pending --> or_invalid : any authz invalid
    }

    chall --> authz : atomic DB transaction
    authz --> ord : atomic DB transaction

State transitions on success (on_valid)

All three updates run inside a single database transaction:

  1. UPDATE challenges SET status = 'valid', validated = <now> WHERE id = <challenge_id>
  2. UPDATE authorizations SET status = 'valid' WHERE id = <authz_id>
  3. Conditionally advance the order to ready using a single UPDATE orders SET status = 'ready' WHERE id = <order_id> AND NOT EXISTS (SELECT 1 FROM authorizations WHERE order_id = <order_id> AND status != 'valid'). This replaces the previous SELECT COUNT(*) + conditional UPDATE pattern; it saves a round-trip on the common single-identifier path.

If any step fails (e.g., a database error), a warning is logged and the transaction is rolled back. The challenge remains in processing status.

State transitions on failure (on_invalid)

All three updates run inside a single database transaction:

  1. UPDATE challenges SET status = 'invalid', error = <json> WHERE id = <challenge_id>
  2. UPDATE authorizations SET status = 'invalid' WHERE id = <authz_id>
  3. Look up the parent order ID and mark the order invalid.

If the transaction fails (e.g., a database error), a warning is logged.

http-01 validator (src/validation/http01.rs)

Uses hyper (already a transitive dependency via axum) as the HTTP client.

Validation steps:

  1. Construct the URL http://<domain>/.well-known/acme-challenge/<token>.
  2. SSRF guard — initial target: before making any connection, resolve the target host and reject it if any returned address is in a private, loopback, link-local, or otherwise non-globally-routable range (RFC 1918, 169.254.0.0/16, ::1, fe80::/10, fc00::/7, etc.). This guard applies to both IP literals and hostnames. Bypassed only when http_validation_allow_private_ips = true.
  3. Send a GET request via hyper_util::client::legacy::Client.
  4. Check the response status. 3xx redirects are followed (up to 10 hops, including redirects to HTTPS targets).
  5. SSRF guard — redirect targets: each redirect target is also subjected to the same IP check before following it.
  6. Check that the final response status is 2xx.
  7. Read up to 1 MiB of the response body.
  8. Decode as UTF-8 and trim whitespace.
  9. Compare with key_auth. Any mismatch returns AcmeError::IncorrectResponse.

Error mapping:

  • Connection or parse failure → AcmeError::Connection
  • Non-2xx status → AcmeError::IncorrectResponse
  • Body exceeds 1 MiB → AcmeError::IncorrectResponse
  • Key auth mismatch → AcmeError::IncorrectResponse
  • Initial or redirect target resolves to blocked IP → AcmeError::IncorrectResponse

dns-01 validator (src/validation/dns01.rs)

Uses crate::dns::dns_query backed by hickory_resolver. DNS-over-TLS (DoT) is supported when server.dns_dot_server_name is set.

Validation steps:

  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 via crate::dns::dns_query (UDP, DNSSEC-aware, or DoT depending on config).
  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_addr, validate_dnssec, dot_server_name) accepts explicit resolver settings for testability. Unit tests provide a local UDP stub DNS server.

tls-alpn-01 validator (src/validation/tls_alpn01.rs)

Uses rustls and tokio-rustls. Supports both DNS identifiers (RFC 8737) and IP identifiers (RFC 8738).

Validation steps:

  1. Compute expected_hash = SHA-256(key_auth).
  2. For IP identifiers (RFC 8738 §4): convert the IP address to its reverse-DNS form for SNI (1.2.3.44.3.2.1.in-addr.arpa; IPv6 uses the nibble-expanded .ip6.arpa form). For DNS identifiers, the SNI is the identifier value directly.
  3. 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.
  4. TCP-connect to <id_value>:443 (the raw IP or DNS name, not the reverse-DNS SNI).
  5. Perform the TLS handshake with the SNI from step 2.
  6. Extract the end-entity certificate from the peer certificate chain.
  7. Call verify_acme_cert(id_type, id_value, 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.
  • For DNS identifiers: checks it contains id_value as a dNSName (tag 0x82).
  • For IP identifiers: checks it contains id_value as an iPAddress (tag 0x87) encoded as 4 (IPv4) or 16 (IPv6) raw bytes.

The DER walker (find_extension_value) navigates the Certificate → TBSCertificate → Extensions structure using hand-written TLV parsing helpers (read_tlv, decode_length, strip_sequence, etc.). This approach avoids requiring the full synta decoder for a security-critical path.

Error mapping:

  • Invalid server name or IP-to-reverse-DNS conversion failure → AcmeError::Tls
  • TCP connect failure → AcmeError::Connection
  • TLS handshake failure → AcmeError::Tls
  • Missing or non-critical id-pe-acmeIdentifierAcmeError::IncorrectResponse
  • Hash mismatch → AcmeError::IncorrectResponse
  • Missing SAN → AcmeError::IncorrectResponse
  • Identifier not found in SAN → AcmeError::IncorrectResponse

AcceptAnyCert

The AcceptAnyCert struct implements rustls::client::danger::ServerCertVerifier. It returns Ok(ServerCertVerified::assertion()) unconditionally for every certificate. Chain validation is intentionally bypassed because the tls-alpn-01 certificate is self-signed and issued by the ACME client for validation purposes only. All semantic checks are performed by verify_acme_cert instead.

dns-persist-01 validator (src/validation/dns_persist_01.rs)

Uses crate::dns::dns_query backed by hickory_resolver. The resolver address comes from server.dns_persist01_resolver_addr when set; if absent it falls back to server.dns_resolver_addr, and if that is also absent it uses the system default. DNS-over-TLS is supported when server.dns_dot_server_name is set.

Unlike the other challenge types, dns-persist-01 does not use a token · thumbprint key authorization. The key_auth value passed to the validator is the requesting account’s full URI (constructed as <base_url>/acme/account/<account_id> in routes::challenge). This URI is matched directly against the accounturi= field in the TXT record.

Validation steps:

  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 via crate::dns::dns_query.
  4. For each TXT record value, call matches_record(value, issuer_domains, 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.

#![allow(unused)]
fn main() {
pub(crate) fn matches_record(
    raw: &str,
    expected_issuers: &[&str],
    expected_account_uri: &str,
    require_wildcard_policy: bool,
    now: i64,
) -> bool
}

It splits the raw TXT value on ; and applies the following checks in order:

  1. The first token (trimmed, trailing dot stripped, lowercased) equals any entry in expected_issuers (same normalization applied). Multiple issuer domains are supported.
  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 (lowercase z is also accepted). It performs the proleptic Gregorian day count from the Unix epoch without using any external date/time crate. Returns None for malformed input (wrong separators, out-of-range fields, missing Z suffix).

Error mapping for dns-persist-01:

  • DNS lookup failure → AcmeError::Dns
  • No matching TXT record → AcmeError::IncorrectResponse

onion-csr-01 validator (src/validation/onion_csr_01.rs)

Implements server-side validation for hidden-service domain ownership per RFC 9799 §3.2. The client submits a DER-encoded CSR in the challenge response payload ({"csr": "<base64url>"}); the handler decodes it and passes it to this validator via onion_csr_der.

Validation steps:

  1. Decode hidden-service public key: extract the 32-byte Ed25519 public key from the v3 .onion address label. The label is 56 base32 characters encoding 35 bytes: [pubkey(32)] || [checksum(2)] || [version=0x03(1)]. Version byte must be 0x03; v2 addresses (16-character label) are rejected.
  2. Parse CSR: decode csr_der as a DER PKCS#10 CertificationRequest using synta.
  3. Re-encode CRI: re-encode the CertificationRequestInfo to obtain the exact bytes that were signed.
  4. Verify CSR self-signature: call BackendPublicKey::verify_signature on the CRI bytes using the CSR’s own public key. Returns IncorrectResponse if invalid.
  5. Verify cabf-onion-csr-nonce extension (OID 2.23.140.41): the extension value must be a DER UTF8String (or IA5String / raw bytes) whose decoded string equals key_auth (token.thumbprint). Returns IncorrectResponse if absent or mismatched.
  6. Verify hidden-service Ed25519 signature over the CRI bytes using the public key from step 1. Two cases are accepted:
    • If the outer CSR signature (the BIT STRING at the top level) verifies with the hidden-service Ed25519 key, the challenge passes.
    • Otherwise, if the CSR’s own public key matches the hidden-service key, the self-signature from step 4 already proves key control and no separate HS signature is required. If neither condition holds, IncorrectResponse is returned.
  7. Verify SAN: check that the .onion domain appears as a dNSName in the CSR’s SubjectAlternativeName extension.

The validate_onion_v3(domain) helper (exported as pub) can be called by the order/authorization handler to reject non-v3 .onion domains before creating a challenge.

Error mapping:

  • Cannot decode .onion public key → AcmeError::IncorrectResponse
  • CSR parse failure → AcmeError::IncorrectResponse
  • CSR self-signature invalid → AcmeError::IncorrectResponse
  • Missing or mismatched cabf-onion-csr-nonce extension → AcmeError::IncorrectResponse
  • Hidden-service signature verification failed → AcmeError::IncorrectResponse
  • Domain not found in CSR SAN → AcmeError::IncorrectResponse

MTC Implementation

This chapter describes the internal design of the Merkle Tree Certificate (MTC) log integration: how certificates are appended, how checkpoints are produced, and the concurrency model.

Log storage

The log file is a binary file managed by synta_mtc::storage::DiskBackedLog. Entries are written as fixed-size leaf hashes in leaf-order; the hash size (32, 48, or 64 bytes) is determined by [mtc].hash_alg and is stored in the log file’s header at creation time. The hash function includes Merkle tree domain separation (a \x00 prefix byte) to prevent second-preimage attacks.

The file is created by DiskBackedLog::create and opened by DiskBackedLog::open. The server uses a “try create first, fall back to open” strategy to eliminate time-of-check-to-time-of-use races at startup (src/mtc/log.rs::open_or_create).

A brand-new log is immediately seeded with a null_entry at index 0 (required by §5.3 of the MTC draft so that no real certificate ever receives log index 0 as its serial number).

Root-hash cache

DiskBackedLog is wrapped in a CachedLog struct (src/mtc/log.rs) which adds an in-memory (tree_size, root_hash) cache. Because compute_root is an O(N) disk read, the cache avoids repeated traversals when the tree has not grown since the last checkpoint or HTTP read. Cache coherence rules:

  • Warmed by compute_root() and tree_size_and_root().
  • Invalidated by append_leaf() (any write to the log).

Appending a certificate

Appending a certificate leaf involves:

  1. Parsing the DER-encoded certificate to extract the TBSCertificate.
  2. Building a TBSCertificateLogEntry manually from the parsed TBS fields, substituting the LogID issuer DN for the original CA issuer DN. The LogID issuer DN is pre-computed at startup by build_logid_issuer_dn_der (in src/mtc/standalone.rs) and passed to append_cert_to_log as the logid_issuer_dn_der parameter. This substitution ensures the Merkle leaf hash matches what a verifier computes from the standalone certificate’s TBS (which has the LogID as its issuer, not the CA DN).
  3. Wrapping the entry as a MerkleTreeCertEntry::TbsCertEntry and computing the Merkle leaf hash via hash_log_entry(algorithm, &entry). This function TLS wire-encodes the entry (per spec §4.2) and then hashes it with the \x00 domain separation prefix.
  4. Appending the fixed-size leaf hash (32, 48, or 64 bytes depending on [mtc].hash_alg) to the log file under a tokio::sync::Mutex guard.

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

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

Concurrency model

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

Multiple processes accessing the same log file concurrently are not supported. The server enforces single-process exclusive access via an advisory flock(LOCK_EX|LOCK_NB) on a sidecar lock file at {log_path}.lock (src/mtc/log.rs::acquire_log_lock). The lock file handle is stored in MtcState::_log_lock for the lifetime of the process; the kernel releases the lock automatically on exit or drop. A second process attempting to open the same log will receive an immediate error rather than blocking.

Checkpoint production

The checkpoint background task (src/mtc/checkpoint.rs) fires every checkpoint_interval_secs seconds. If the log has grown since the last checkpoint, produce_checkpoint runs the following phases:

Phase 1 (blocking thread):

  1. Acquires the SharedLog mutex via blocking_lock() and reads the current tree size and computes the Merkle root via compute_root (which also warms the root cache).
  2. Generates Merkle inclusion proofs for all certificates that are newly covered by the checkpoint.
  3. Builds and DER-encodes a Checkpoint structure (per §6.2 of the MTC draft).
  4. Signs the Checkpoint DER with the MTC signing key.

Async phase:

  1. Inserts a row into the mtc_checkpoints database table.
  2. Contacts all configured external cosigners in parallel to gather SubtreeSignature responses.

Phase 2 (blocking thread):

  1. Builds StandaloneCertificate DER blobs for each newly covered certificate (with cosignatures embedded) and persists them to the certificates.mtc_standalone_der database column.

Checkpoints are idempotent: if the tree size has not grown the task is a no-op.

After each new checkpoint is stored, rows beyond the checkpoint_retention_count limit are pruned from mtc_checkpoints. Associated cosignature rows in mtc_cosignatures are deleted via the ON DELETE CASCADE foreign-key constraint.

Cosignature gathering

After each checkpoint is produced, src/mtc/cosign.rs contacts all configured external cosigners in parallel. For each cosigner:

  • An HTTPS POST is made with Content-Type: application/octet-stream carrying the DER-encoded Checkpoint.
  • The cosigner is expected to return a DER-encoded SubtreeSignature with HTTP 200.
  • Each request uses a 30-second timeout.
  • Failures are logged and skipped; partial success is acceptable.

The CosignerClient struct (one per [[mtc.cosigners]] entry) is built once at server startup. This surfaces misconfigured cosigners at startup rather than silently at checkpoint time, and preserves the HTTP connection pool across checkpoint intervals.

When cosigner_id_cert_pem is set for a cosigner, an AkamuCosignerVerifier is built at startup and stored inside the CosignerClient. At checkpoint time, the received SubtreeSignature is verified before being stored:

  • OID identity check: When trust_anchor_id is also configured, the SubtreeSignature.cosigner field (a TrustAnchorID ::= OBJECT IDENTIFIER per draft-04 §4.1) is compared against the expected OID. A mismatch causes the signature to be rejected.
  • Cryptographic check: The public key is extracted from the cosigner_id_cert_pem PEM and used for signature verification. Verification uses synta_mtc::cosignature::validate_cosignature_quorum_with_crypto, which builds the TLS-framed CosignedMessage (per §5.4.1 of the MTC draft) internally from the checkpoint and signature fields, then delegates the actual signature check to OpensslSignatureVerifier.

Setting trust_anchor_id without cosigner_id_cert_pem is a hard startup error: OID-only verification provides no cryptographic assurance. When neither field is set, cosignatures are accepted without verification and a warning is logged.

Each SubtreeSignature is stored in the mtc_cosignatures table, keyed by checkpoint sequence number and cosigner URL.

Standalone certificate construction

There are two code paths that produce a StandaloneCertificate:

Checkpoint-driven (background): After cosignatures are gathered, produce_checkpoint in src/mtc/checkpoint.rs builds a StandaloneCertificate (§6.1) for every certificate covered by the new checkpoint that does not already have one. The DER is stored in certificates.mtc_standalone_der. This is the path for ordinary X.509 certificates issued with [mtc] enabled — logging is asynchronous and the standalone certificate is built during the next checkpoint cycle.

Profile-driven (synchronous): When a builtin profile has issue_as = "mtc", the finalize handler (src/routes/finalize.rs) builds the StandaloneCertificate synchronously during the request itself, before the database transaction:

  1. The X.509 TBSCertificate is issued as normal.
  2. The certificate is appended to the MTC log (synchronously, not via a background task) to obtain the leaf index immediately.
  3. crate::mtc::standalone::build_standalone_der constructs the StandaloneCertificate DER.
  4. The DER is stored in the certificates.mtc_standalone_der column; certificates.pem stores a PEM-armored wrapper with the STANDALONE MTC CERTIFICATE marker so the download handler can detect the format.
  5. certificates.mtc_log_index is set to the leaf index (not NULL), so the regular checkpoint-driven path skips this certificate.

The download handler (src/routes/certificate.rs::cert_pem_response) detects MTC certificates by the PEM marker prefix and returns the raw DER with Content-Type: application/pkix-cert instead of the PEM bundle.

In both paths the standalone certificate embeds:

  • The TBSCertificate from the issued certificate.
  • A Merkle inclusion proof (computed from the leaf hashes under the SharedLog mutex).
  • A signature from the MTC signing key.
  • Any gathered SubtreeSignature entries from external cosigners (empty slice for profile-driven issuance, which does not wait for cosigners).

Landmark construction

The landmark background task (src/mtc/landmark.rs) fires every landmark_interval_secs seconds. If the tree has grown since the last landmark:

  1. A new row is inserted into the mtc_landmarks table with the current tree size and a monotonically increasing sequence_no.
  2. A representative certificate (any leaf with mtc_log_index < tree_size) is selected.
  3. All leaf hashes up to tree_size are read from the log under the mutex.
  4. A LandmarkCertificate is built using LandmarkCertificateBuilder: it embeds the representative TBSCertificate, the leaf’s log index, all leaf hashes (for internal inclusion proof generation), the LandmarkID (log identity + frozen tree size), and a signature from the MTC signing key.
  5. The DER-encoded certificate is stored in the cert_der column of the landmark row.

After each new landmark is built, rows beyond max_active_landmarks are pruned by sequence number.

Root computation

The Merkle root is computed from all leaf hashes using the RFC 6962 / synta-mtc binary tree algorithm:

  • For a log with zero leaves the root is undefined.
  • For a log with one or more leaves the root is the Merkle root of all leaf hashes, computed using the configured [mtc].hash_alg algorithm.

The computation is performed under the SharedLog mutex and is exposed to handlers by src/mtc/log.rs::proof_and_tree_size, tree_size_and_root, and tree_size. The tree_size_and_root function reads both values under the same lock guard so that treeSize and rootHash in HTTP responses are always consistent; it also leverages the CachedLog root cache to avoid repeated O(N) traversals.

HTTP endpoints

The following read-only endpoints are served under /acme/mtc/ and return 404 when MTC is disabled:

EndpointHandler
GET /acme/mtc/tree-sizemtc::get_tree_size
GET /acme/mtc/rootmtc::get_root
GET /acme/mtc/inclusion-proof/{cert_id}mtc::get_inclusion_proof
GET /acme/mtc/cert/{cert_id}/standalonemtc::get_standalone
GET /acme/mtc/landmarksmtc::get_landmarks
GET /acme/mtc/landmarks/{seq}/certmtc::get_landmark_cert
GET /acme/mtc/tlog/checkpointmtc::get_tlog_checkpoint
GET /acme/mtc/tlog/tile/{*path}mtc::get_tlog_tile
GET /acme/mtc/tlog/cosignaturemtc::get_tlog_cosignature
GET /acme/mtc/consistency-proofmtc::get_consistency_proof
GET /acme/mtc/subtree-rootmtc::get_subtree_root
GET /acme/mtc/revoked-rangesmtc::get_revoked_ranges

C2SP tlog-tiles module (src/mtc/tlog.rs)

src/mtc/tlog.rs implements the C2SP tlog-tiles, signed-note, and tlog-cosignature specifications on top of the existing DiskBackedLog storage.

Signed-note key IDs

Key IDs are 4-byte prefixes derived from SHA-256 of a type-specific input:

Key typeRoleC2SP type byteKey ID formula
Ed25519Log operator0x01SHA-256(name | LF | 0x01 | 32-byte pubkey)[:4]
ECDSALog operator or cosigner0x02SHA-256(SPKI_DER)[:4]
Ed25519Cosigner0x04SHA-256(name | LF | 0x04 | 32-byte pubkey)[:4]
(RFC 6962 CT)CT log0x05per c2sp.org/static-ct-api — not produced by Akāmu
ML-DSA-44Cosigner0x06SHA-256(name | LF | 0x06 | 1312-byte pubkey)[:4]

ML-DSA-44 as a primary log operator key and Ed448/RSA keys are rejected — they have no assigned C2SP signed-note type byte.

Hash tiles

Level-0 tiles are leaf hashes read directly from the DiskBackedLog via the read_hash_range wrapper in src/mtc/log.rs. Level-L tiles are computed by applying MTH (RFC 9162 §2) recursively over 256 level-(L-1) entries. Partial tiles (.p/{width} suffix in the URL) return fewer than 256 entries when the log ends mid-tile.

HTTP route wiring

The three tlog-tiles endpoints are registered in src/routes/mod.rs and dispatched to handlers in src/routes/mtc.rs:

EndpointHandler
GET /acme/mtc/tlog/checkpointmtc::get_tlog_checkpoint
GET /acme/mtc/tlog/tile/{*path}mtc::get_tlog_tile
GET /acme/mtc/tlog/cosignaturemtc::get_tlog_cosignature

The log origin string used in checkpoint notes is {base_url}/acme/mtc/tlog.

RFC Compliance Internals

This chapter documents how specific RFC requirements are implemented in code. For the protocol-facing view — which algorithms are accepted, which challenge types are offered, error codes, and wire formats — see ACME Protocol Reference.

Topics covered here: JWS algorithm dispatch and ML-DSA verification internals, challenge validation code paths, DER encoding helpers, EAB constant-time HMAC verification, pre-issuance linting, ACME STAR and ARI source layout, and RFC 9115 CSR template validation.

JWS algorithm dispatch

All ACME POST requests are verified through the JWS path in crates/akamu-jose/src/jws.rs. The accepted alg values are listed in ACME Protocol Reference § JWS algorithm support. Any other alg returns JoseError::UnsupportedAlgorithm.

ECDSA signatures arrive as IEEE P1363 encoding (raw r||s) on the wire; the server converts them to DER before passing to the OpenSSL backend.

The JWK thumbprint computation (RFC 7638) supports key types RSA, EC, OKP, and AKP (ML-DSA). The canonical JSON fields and their order per key type are implemented in crates/akamu-jose/src/jwk.rs.

ML-DSA JWS verification internals (RFC 9964)

After detecting an ML-DSA-* algorithm in the protected header alg field, the server checks the raw signature length (see protocol reference for the byte counts). A length mismatch causes an immediate badSignatureAlgorithm error without attempting the verify call — this prevents malformed input from reaching the OpenSSL backend.

Per RFC 9964 §4, the signing context must be an empty byte string. The server calls:

#![allow(unused)]
fn main() {
BackendPublicKey::verify_ml_dsa_with_context(
    message_bytes,
    signature_bytes,
    &[],   // empty context
)
}

This is dispatched from crates/akamu-jose/src/jws.rs after the algorithm is detected.

Challenge validation code paths

The src/validation/mod.rs dispatch table routes each challenge type to its validator. The supported types and identifier constraints are listed in ACME Protocol Reference § Supported challenge types.

Any unrecognised challenge type returns AcmeError::IncorrectResponse("unsupported challenge type: …").

dns-persist-01 safety check

Beyond the standard TXT record content check, the server performs an extra safety step at validation time: it queries the account status from the database and rejects with unauthorized if the account is not in the valid state. This prevents a deactivated or revoked account from continuing to use a stale persistent TXT record that was provisioned before deactivation.

The separate per-challenge DNS resolver (dns_persist01_resolver_addr) is configured independently of the general resolver.

onion-csr-01 validation steps (RFC 9799)

src/validation/onion_csr_01.rs performs the following checks on the client-submitted CSR:

  1. Decodes the 32-byte Ed25519 public key from the .onion label (base32, 56 chars, version byte 0x03).
  2. Parses the DER CSR and verifies its self-signature.
  3. Extracts the cabf-onion-csr-nonce extension (OID 2.23.140.41) and compares its value to the key authorization.
  4. Verifies the hidden-service Ed25519 signature over the CertificationRequestInfo DER.
  5. Confirms the CSR SAN contains the .onion domain.

RFC 9799 §2 prohibits v2 .onion addresses (16-character label); this is enforced in both the new-order and pre-authorization paths.

tls-alpn-01 SNI encoding for IP identifiers (RFC 8738 §4)

For IP identifier challenges, the TLS SNI is the reverse-DNS form of the IP address (arpa. suffix), and the acmeIdentifier extension carries an iPAddress GeneralName rather than a dNSName. The SAN type switch is performed in src/validation/tls_alpn_01.rs.

ACME STAR implementation (RFC 8739)

The STAR protocol spans several source files:

  • src/routes/order.rs: accepts the auto-renewal object in the new-order payload (§3.1.1), stores start-date, end-date, lifetime, lifetime-adjust, and allow-certificate-get on the order row.
  • src/routes/finalize.rs: issues the first STAR certificate.
  • src/star.rs: background reissuance task that issues renewals automatically until end-date is reached or the order is canceled.
  • src/routes/star_cert.rs: serves the most recent certificate; includes Cert-Not-Before and Cert-Not-After headers per RFC 8739 §3.3.

The server-level star_allow_certificate_get config flag gates unauthenticated certificate retrieval globally (RFC 8739 §3.1.3).

For the endpoint URLs and request/response shape, see ACME Protocol Reference § ACME STAR.

Renewal Info / ARI implementation (RFC 9773)

The endpoint is implemented in src/routes/renewal_info.rs. The handler:

  1. Validates the AKI component against the CA’s key identifier; returns 404 on mismatch.
  2. Looks up the certificate by cert_id in the database.
  3. Computes the suggestedWindow: if explicit window fields are set in the database (operator override), uses them; otherwise defaults to start at two-thirds of the certificate lifetime, end one day before expiry.
  4. Includes explanationURL if ari_explanation_url is configured.
  5. Sets Retry-After to ari_retry_after_secs (RFC 9773 §4.3).

For the cert_id format and response shape, see ACME Protocol Reference § Renewal Information / ARI.

DER structures

Serial number encoding

Leaf certificate serials are 16 random bytes from getrandom. The high bit of the first byte is cleared (bitwise AND with 0x7f) to ensure the value is a non-negative DER INTEGER per RFC 5280 §4.1.2.2.

In src/ca/revoke.rs, encode_integer_der(n: u64) handles DER INTEGER encoding for the CRL Number extension. It:

  1. Converts the u64 to 8 big-endian bytes.
  2. Strips leading zero bytes (keeping at least one).
  3. Prepends 0x00 if the high bit of the first remaining byte is set (two’s complement positive padding).
  4. Prepends the DER INTEGER tag 0x02 and the length byte.
n=127 → 02 01 7f
n=128 → 02 02 00 80   (zero-pad because high bit set)
n=256 → 02 02 01 00

CSR extensions: manual DER walking

The extensionRequest attribute (OID 1.2.840.113549.1.9.14) inside a PKCS#10 CSR is nested in a SET OF ANY, which synta’s high-level decoder does not unwrap automatically. src/ca/csr.rs walks the attribute bytes manually using read_tlv, decode_length, and strip_sequence helpers to locate and extract the extension list. This is deliberate: the alternative of using a fully-general ASN.1 parser for this path would add complexity with no benefit.

EAB implementation internals

See EAB Internals for the database schema, insert_if_absent, and the two-step verification pipeline (parse_eab_kid + verify_eab_jws). For the EAB JWS wire format and algorithm table, see ACME Protocol Reference § EAB JWS wire format.

EAB HMAC verification: constant-time comparison

default_hmac_provider().hmac_verify(hash_alg, hmac_key, message, signature) uses OpenSSL’s HMAC_CTX and a constant-time byte comparison. The OpenSSL backend returns false rather than an early exit if the MAC does not match, preventing timing side-channels.

EAB error mapping

ConditionError variantACME typeHTTP
EAB required but absentAcmeError::ExternalAccountRequiredexternalAccountRequired403
Unknown kid, used kid, MAC failAcmeError::Unauthorized(msg)unauthorized401
Unsupported EAB algAcmeError::BadRequest(msg)(maps to serverInternal)400

Pre-issuance linting

After signing each certificate, ca::issue::issue_with_params runs synta_x509_verification policy checks before returning the IssuedCert:

  1. The DER-encoded certificate is decoded again by synta::Decoder.
  2. A PolicyDefinition is constructed for end-entity certificate validation.
  3. The CA’s public key is used as the trust anchor for the signature check.
  4. verify(leaf, &[], &policy, RevocationChecks::default()) is called.

If linting fails, AcmeError::Builder is returned and the certificate is not stored or delivered to the client. This satisfies CA/B Forum BR §4.3.1.2 (pre-issuance linting).

The checks include:

  • X.509 version = v3 (tag A2 03 02 01 02).
  • Serial number: ≤ 20 octets, positive (high bit not set without 0x00 prefix).
  • BasicConstraints: cA=FALSE on the end-entity certificate.
  • AuthorityKeyIdentifier extension present.
  • SPKI algorithm on the WebPKI allowlist.
  • RSA modulus ≥ 2048 bits; EC key on a named curve.
  • CA signature cryptographically valid over the certificate body.

RFC 9115 — CSR template validation

The CSR template validation in src/routes/finalize.rs enforces the RFC 9115 §4 constraints on delegation-order CSRs. When an order has a non-null delegation_id, finalize loads the delegation’s csr_template from the database and passes it to validate_csr_against_template.

Template semantics

Each field in the CSR template carries a JSON value whose type determines the constraint:

JSON value typeConstraint
{} (empty object)MandatoryWildcard — the field MUST appear in the CSR
nullOptionalWildcard — the field MAY appear; its content is not checked
"<literal>" (string)Literal — the field MUST appear with this exact value
absentForbidden — the field MUST NOT appear in the CSR

Validated fields

Template fieldCSR check
keyTypesAt least one entry in the array must match the CSR’s SPKI algorithm and curve
subject.commonNameMandatoryWildcard ({}) → must be present; Literal → must equal the string
subject.organizationSame semantics as commonName
extensions.subjectAltNameMandatoryWildcard → must be present; the SAN values themselves are constrained by the order identifiers (existing RFC 8555 check), not the template
extensions.keyUsageArray of allowed key usage bit names; the CSR’s requested KeyUsage must be a subset
extensions.extendedKeyUsageArray of allowed EKU OIDs; the CSR’s requested EKU must be a subset

A CSR that violates any constraint is rejected with AcmeError::BadCSR → HTTP 400 urn:ietf:params:acme:error:badCSR.

Template validation at Admin API write time

POST /admin/delegations and PUT /admin/delegations/{id} both parse the csr_template JSON against the schema and reject malformed templates before they reach the database. This keeps the finalize-time validation path clean — by the time a CSR is checked against a template, the template is guaranteed to be structurally valid.

AcmeError type strings

Every ACME-level error maps to a URN in the urn:ietf:params:acme:error: namespace. The mapping is defined in src/error.rs and is tested exhaustively — see Error Reference for the full table and HTTP status mapping.

Account Management Internals

This chapter describes the internal implementation of ACME account creation, key rollover, and the SPKI cache.

Database representation

Accounts are stored in the accounts table (defined in src/db/schema.rs and migration 0001_initial, extended in migrations 0007_profile_grants and 0012_multi_ca):

CREATE TABLE accounts (
    id             TEXT    PRIMARY KEY,           -- UUID v4
    status         TEXT    NOT NULL DEFAULT 'valid',
    contact        TEXT,                          -- JSON array e.g. ["mailto:a@b.com"]
    public_key     BLOB    NOT NULL,              -- DER-encoded SubjectPublicKeyInfo
    jwk_thumbprint TEXT    NOT NULL UNIQUE,       -- base64url SHA-256 JWK thumbprint (RFC 7638)
    created        INTEGER NOT NULL,
    updated        INTEGER NOT NULL,
    profile_grants TEXT,                          -- NULL = unrestricted; JSON array of profile IDs
    ca_id          TEXT    NOT NULL DEFAULT ''    -- empty = server-wide scope (any CA)
);

Two fields carry cryptographic identity:

  • public_key — the raw DER-encoded SubjectPublicKeyInfo extracted from the outer JWS jwk at account creation time. Stored once; used to verify subsequent signed requests via a cache-aside pattern.
  • jwk_thumbprint — the RFC 7638 SHA-256 thumbprint of the public JWK, base64url-encoded. Carries a UNIQUE constraint so the database enforces that no two accounts share the same key. This is the lookup key for “does an account already exist for this key?” checks at new-account time.

profile_grants stores a JSON array of profile ID strings (e.g. '["tls-server","mtc-tls"]'), or NULL when no restriction is in force. The NULL state is distinct from an empty array: NULL means “no restriction” while an empty array would grant access to no profiles. When a profile has require_account_grant = true, the finalize handler checks this column via db::accounts::get_profile_grants.

ca_id records the CA scope of the account. An empty string ('') means the account is server-wide and may place orders against any CA. A non-empty value restricts the account to a specific CA; this is set when the server is configured with server.account_scope = "ca". The column was added in migration 0012_multi_ca with DEFAULT '' so all pre-migration accounts are treated as server-wide.

Account creation flow (src/routes/account.rs)

routes::account::new_account handles POST /acme/new-account:

  1. parse_jws verifies the outer JWS and extracts the JwsKeyRef::Jwk { jwk }.
  2. jwk.thumbprint() computes the RFC 7638 thumbprint.
  3. db::accounts::get_by_thumbprint(&state.db, &thumbprint) checks for an existing account. If found, returns HTTP 200 with the existing account (idempotent creation).
  4. contacts are validated — any URI scheme is accepted (any string containing :); bare strings without a scheme separator are rejected per RFC 8555 §7.1.2. Note: this step happens before EAB validation in the code.
  5. If external_account_required is set, the EAB JWS is validated (see EAB Internals).
  6. A new UUID account ID is generated.
  7. Account insertion and EAB key consumption happen atomically in a single db::begin_write transaction:
#![allow(unused)]
fn main() {
let mut tx = db::begin_write(&state.db, state.db_kind).await?;
db::accounts::insert(&mut *tx, AccountRow { … }).await?;
if let Some((eab_kid, _)) = verified_eab {
    db::eab::mark_used(&mut *tx, &eab_kid, now).await?;
}
tx.commit().await.map_err(AcmeError::from)?;
}

SPKI cache (AppState::spki_cache)

Every authenticated POST endpoint (other than new-account) must look up the account’s public key to verify the JWS signature. Fetching public_key from the database on every request would add a read round-trip to every ACME operation.

AppState.spki_cache is an Arc<RwLock<HashMap<String, CachedAccount>>> that caches account key material keyed by account ID. CachedAccount is defined in src/state.rs and holds three fields:

#![allow(unused)]
fn main() {
pub struct CachedAccount {
    pub spki_der: Vec<u8>,        // DER-encoded SubjectPublicKeyInfo
    pub jwk_thumbprint: String,   // RFC 7638 JWK thumbprint (base64url SHA-256)
    pub status: String,           // "valid", "deactivated", or "revoked"
}
}

After the first authenticated request for an account, the full CachedAccount is stored here. Subsequent requests hit the in-memory cache instead of the database — and routes that need the JWK thumbprint (e.g., key-change audit events) avoid a second get_by_id call. The cache also enables fast status checks: if cached_account.status != "valid", the request is rejected immediately without a DB round-trip.

Cache eviction occurs in two places and uses a poison-guard pattern to avoid panicking on a poisoned RwLock:

  • Deactivation (update_account):

    #![allow(unused)]
    fn main() {
    match state.spki_cache.write() {
        Ok(mut cache) => { cache.remove(&id); }
        Err(e) => { e.into_inner().remove(&id); }
    }
    }

    This removes the entry immediately after marking the account deactivated, so subsequent requests with the deactivated account’s key are rejected at the database layer (where status='valid' is required for updates) rather than served from a stale cache entry.

  • Key rollover (key_change): the same poison-guard removal is applied after db::accounts::update_key succeeds, so the next request with the new key re-loads the CachedAccount from the database rather than finding the old SPKI bytes.

The cache is not bounded in size because the number of accounts is expected to be small relative to available memory. A future improvement could add LRU eviction.

Key rollover flow (src/routes/key_change.rs)

routes::key_change::key_change handles POST /acme/key-change per RFC 8555 §7.3.5. The outer JWS is signed with the old key (resolved via kid); the payload is itself an inner JWS signed with the new key. The steps are:

  1. Verify the outer JWS with parse_jws — uses the old key from the SPKI cache or database.
  2. Parse the payload as a JwsFlattened inner JWS.
  3. Extract the new JwkPublic from the inner JWS header (JwsKeyRef::Jwk).
  4. Convert the new JWK to SPKI DER: new_jwk.to_spki_der().
  5. Compute the new thumbprint: new_jwk.thumbprint().
  6. Verify the inner JWS signature over the new SPKI DER.
  7. Decode the inner payload: { "account": "<account_url>", "oldKey": <old_jwk> }.
  8. Check inner_payload.account == expected_account_url.
  9. Convert inner_payload.old_key to SPKI DER and compare with ctx.spki_der (the outer JWS’s key). This is the RFC-mandated proof that the requester controls the old key.
  10. Check that the new thumbprint is not already in use by another account: db::accounts::get_by_thumbprint(&state.db, &new_thumbprint).
  11. Call db::accounts::update_key(&state.db, &account_id, new_spki, new_thumbprint, now).
  12. Evict the old entry from the cache using the poison-guard pattern (see the SPKI cache section above).
  13. Emit an AccountKeyChange ("account.key-change") audit event with:
    • subject: the account ID.
    • principal: "acme:<old_thumbprint>" (the thumbprint of the key that was replaced).
    • detail: "new_key=<new_thumbprint>" (the thumbprint of the replacement key).

db::accounts::update_key updates both public_key (the DER BLOB) and jwk_thumbprint (the unique TEXT) atomically in a single SQL UPDATE:

UPDATE accounts SET public_key = ?, jwk_thumbprint = ?, updated = ?
WHERE id = ? AND status = 'valid'

The AND status = 'valid' guard ensures that a deactivated account’s key cannot be rotated.

Database module (src/db/accounts.rs)

The account DB module exposes:

FunctionSQL
insert(executor, row)INSERT INTO accounts …
get_by_id(executor, id)SELECT … FROM accounts WHERE id = ?
get_by_thumbprint(executor, thumbprint)SELECT … FROM accounts WHERE jwk_thumbprint = ?
update_contact(executor, id, contact, now)UPDATE accounts SET contact = ? … WHERE id = ? AND status = 'valid'
update_status(executor, id, status, now)UPDATE accounts SET status = ? …
update_key(executor, id, public_key, jwk_thumbprint, now)UPDATE accounts SET public_key = ?, jwk_thumbprint = ? … WHERE id = ? AND status = 'valid'
set_profile_grants(executor, id, grants, now)UPDATE accounts SET profile_grants = ? … WHERE id = ? AND status = 'valid'
get_profile_grants(executor, id)SELECT profile_grants FROM accounts WHERE id = ?
list(executor, status, ca_id, limit, offset)SELECT … FROM accounts WHERE 1=1 [AND status = ?] [AND (ca_id = ? OR id IN (SELECT …))] ORDER BY created DESC LIMIT ? OFFSET ?

get_profile_grants returns a nested Option:

  • Ok(None) — account not found.
  • Ok(Some(None)) — account exists, profile_grants IS NULL (no restriction).
  • Ok(Some(Some(json))) — account exists and has a JSON grant array.

All functions accept impl sqlx::Executor<'_, Database = sqlx::Any>, which allows them to be called with either a pool reference (&Db) or a mutable transaction reference (&mut *tx). This is the standard sqlx pattern for composing queries into transactions without changing the function signatures.


Profile authorization (src/profiles/auth.rs)

At finalize time, after the profile parameters are resolved from ProfileRegistry::resolve, the finalize handler calls crate::profiles::auth::check_profile_auth when the order carries a profile name. The function applies three checks in sequence; the first failure short-circuits with an AcmeError::Unauthorized or AcmeError::InvalidProfile:

#![allow(unused)]
fn main() {
pub async fn check_profile_auth(
    db: &db::Db,
    account_id: &str,
    profile_name: &str,
    params: &CertificateParameters,
    identifiers: &[(&str, &str)],
) -> Result<(), AcmeError>
}

Check 1 — Identifier patterns (check_identifier_patterns):

If params.allowed_identifier_patterns is non-empty, each identifier is formatted as "type:value" and matched against the compiled regex list. params.identifier_match_all controls whether every identifier must match (true) or just one (false). An invalid regex returns AcmeError::InvalidProfile.

Check 2 — External hook (check_auth_hook):

If params.auth_hook is Some(path), the handler spawns the executable at path, writes the following JSON to its stdin, and waits for it to exit within params.auth_hook_timeout_secs seconds (default: 30):

{
  "account_id": "<uuid>",
  "profile": "<name>",
  "identifiers": [{"type": "dns", "value": "example.com"}]
}

Exit 0 = permit. Non-zero = deny; the hook’s trimmed stdout is used as the denial detail. A timeout returns AcmeError::Unauthorized. Spawn failures return AcmeError::Internal.

Check 3 — Account grant (check_account_grant):

If params.require_account_grant is true, db::accounts::get_profile_grants is called. The three-way return value maps as follows:

get_profile_grants returnAuthorization result
Ok(None)Account not found → Unauthorized
Ok(Some(None))Account exists, profile_grants IS NULLUnauthorized
Ok(Some(Some(json)))JSON parsed as Vec<String>; Unauthorized unless profile_name is in the list

EAB Internals

This chapter describes the internal implementation of External Account Binding (RFC 8555 §7.3.4): the database schema, the startup seeding pattern, the verification pipeline, and the atomic key-consumption transaction.

eab_keys table schema

EAB keys are stored in the eab_keys table. The table was created in migration 0001_initial and the profile_grants column was added by migration 0007_profile_grants:

-- 0001_initial.sql
CREATE TABLE eab_keys (
    kid           TEXT    PRIMARY KEY,
    hmac_key_b64u TEXT    NOT NULL,    -- base64url-encoded raw HMAC key bytes
    created       INTEGER NOT NULL,    -- Unix epoch seconds
    used_at       INTEGER              -- NULL = unused; non-NULL = consumed timestamp
);

-- 0007_profile_grants.sql
ALTER TABLE eab_keys ADD COLUMN profile_grants TEXT;
-- NULL = no restriction; JSON array of profile IDs

The current effective schema after all migrations is therefore:

CREATE TABLE eab_keys (
    kid            TEXT    PRIMARY KEY,
    hmac_key_b64u  TEXT    NOT NULL,
    created        INTEGER NOT NULL,
    used_at        INTEGER,
    profile_grants TEXT
);

hmac_key_b64u stores the raw HMAC key in base64url encoding (no padding). The server base64url-decodes this before HMAC verification. A NULL used_at means the key is available for use; a non-NULL value means it has been consumed by an account-creation request and may not be reused.

profile_grants stores a JSON array of profile ID strings (e.g. '["tls-server","mtc-tls"]'), or NULL when no restriction applies. When an account is created with this EAB key, the profile_grants value is copied atomically to the new account’s profile_grants column.

Startup seeding: insert_if_absent

EAB keys configured in [server.eab_keys] are seeded into the database on every server start via db::eab::insert_if_absent:

#![allow(unused)]
fn main() {
pub async fn insert_if_absent(
    executor: impl sqlx::Executor<'_, Database = sqlx::Any>,
    kid: &str,
    hmac_key_b64u: &str,
    now: i64,
) -> Result<(), AcmeError>
}

The underlying SQL uses a portable WHERE NOT EXISTS subquery (not INSERT OR IGNORE, which is SQLite-specific) to avoid overwriting keys that were already modified or consumed at runtime:

INSERT INTO eab_keys (kid, hmac_key_b64u, created)
SELECT ?, ?, ?
WHERE NOT EXISTS (SELECT 1 FROM eab_keys WHERE kid = ?)

This means:

  • A config-file key that does not exist in the DB is inserted.
  • A config-file key that exists in the DB (whether unconsumed, consumed, or modified by the admin API) is silently skipped.
  • A restart never revives a consumed key.

The seeding loop in src/main.rs logs a tracing::warn! if insert_if_absent fails (e.g., due to a DB error), but does not abort startup.

EAB verification pipeline (src/jose/eab.rs)

The server’s EAB logic is split into two functions that are called in sequence from routes::account::new_account.

Step 1: parse_eab_kid

#![allow(unused)]
fn main() {
pub fn parse_eab_kid(eab: &serde_json::Value) -> Result<String, AcmeError>
}

Decodes and parses only the protected header of the EAB JWS to extract the kid. This is a partial parse that deliberately skips HMAC verification, so that the kid can be used for a database lookup before the full verification step.

The function:

  1. Deserializes the protected field as a base64url string.
  2. Decodes the base64url bytes and parses as JSON.
  3. Returns the kid string from the parsed header.

Step 2: verify_eab_jws

#![allow(unused)]
fn main() {
pub fn verify_eab_jws(
    eab: &serde_json::Value,
    expected_url: &str,
    expected_kid: &str,
    account_thumbprint: &str,
    hmac_key: &[u8],
) -> Result<(), AcmeError>
}

Performs full EAB verification per RFC 8555 §7.3.4:

  1. Decodes and parses the full EAB JWS (protected, payload, signature).
  2. Parses the protected header and extracts alg, kid, and url.
  3. Maps alg to a hash name: "HS256""sha256", "HS384""sha384", "HS512""sha512". Any other value returns AcmeError::BadRequest.
  4. Checks header.kid == expected_kid. A mismatch returns AcmeError::Unauthorized.
  5. Checks header.url == expected_url (the new-account endpoint URL). A mismatch returns AcmeError::Unauthorized.
  6. Decodes the payload from base64url, parses it as a JwkPublic, and computes its RFC 7638 thumbprint. The thumbprint must match account_thumbprint (the thumbprint of the outer JWS’s account key). This check ensures the EAB payload contains the actual account public key.
  7. Computes the signing input as "{protected}.{payload}" (ASCII bytes).
  8. Calls default_hmac_provider().hmac_verify(hash_alg, hmac_key, signing_input, &raw_sig). The OpenSSL backend performs a constant-time HMAC comparison.

Handler integration (src/routes/account.rs)

The calling code in new_account:

#![allow(unused)]
fn main() {
let kid = crate::jose::eab::parse_eab_kid(eab_val)?;
let key_row = db::eab::get_by_kid(&state.db, &kid)
    .await?
    .ok_or_else(|| AcmeError::Unauthorized(format!("EAB: unknown kid '{kid}'")))?;

if key_row.used_at.is_some() {
    return Err(AcmeError::Unauthorized(format!("EAB: kid '{kid}' has already been used")));
}

let hmac_key = URL_SAFE_NO_PAD
    .decode(&key_row.hmac_key_b64u)
    .map_err(|e| AcmeError::BadRequest(format!("EAB: invalid HMAC key encoding: {e}")))?;
if let Err(e) = crate::jose::eab::verify_eab_jws(eab_val, &url, &kid, &thumbprint, &hmac_key) {
    // On HMAC verification failure, two audit events are emitted:
    //   EabReject  ("eab.reject", failure)  — records the rejected kid.
    //   SecurityViolation ("security.violation", failure) — feeds the FAU_ARP.1 alarm counter.
    state.record_audit(AuditEvent::failure(AuditEventType::EabReject).with_subject(&kid)).await;
    state.record_audit(
        AuditEvent::failure(AuditEventType::SecurityViolation)
            .with_subject(&kid)
            .with_detail("EAB HMAC verification failed"),
    ).await;
    return Err(e);
}
}

On successful HMAC verification an EabUse ("eab.use", success) audit event is emitted for the kid.

After verification, verified_eab is Some((kid, profile_grants)) and the account insert, EAB mark, and profile grant transfer are committed atomically:

#![allow(unused)]
fn main() {
// Profile grants inherited from the EAB key (None when no EAB was used).
let eab_profile_grants = verified_eab.as_ref().and_then(|(_, g)| g.clone());

let mut tx = db::begin_write(&state.db, state.db_kind).await?;
db::accounts::insert(&mut *tx, AccountRow {
    profile_grants: eab_profile_grants,  // inherited from EAB key
    …
}).await?;
if let Some((eab_kid, _)) = verified_eab {
    db::eab::mark_used(&mut *tx, &eab_kid, now).await?;
}
tx.commit().await.map_err(AcmeError::from)?;
}

The atomicity guarantee: the account row insertion, EAB key consumption, and profile grant transfer all happen in a single transaction. Either all three succeed together, or none of them do. A concurrent second request using the same kid will find used_at IS NOT NULL after the first transaction commits and will be rejected with Unauthorized.

When the EAB key’s profile_grants is NULL, the new account’s profile_grants is also NULL (no restriction). When it contains a JSON array, the array is stored verbatim on the account row and immediately governs profile authorization for that account.

db::eab module

FunctionDescription
insert_if_absent(executor, kid, hmac_key_b64u, now)Seed from config; silent no-op if kid already exists
insert(executor, kid, hmac_key_b64u, now)Unconditional insert without grants; returns Conflict if kid exists
insert_with_grants(executor, kid, hmac_key_b64u, profile_grants, now)Unconditional insert with optional grants (used by the Admin API); returns Conflict if kid exists
get_by_kid(executor, kid)Fetch EabKeyRow; returns None for unknown kid
list(db, used_filter, limit, offset)List keys with optional used-status filter (Some(true) = used only, Some(false) = unused only, None = all) and pagination
mark_used(executor, kid, now)Set used_at; intended to be called within a write transaction. Returns Conflict when rows_affected == 0, meaning the key was already consumed by a concurrent request between the outer get_by_kid check and the transaction commit (TOCTOU guard).
delete(executor, kid)Remove the key entirely; returns Result<u64, AcmeError> where the u64 is the number of rows deleted (0 = not found)

EabKeyRow mirrors the table columns:

#![allow(unused)]
fn main() {
pub struct EabKeyRow {
    pub kid: String,
    pub hmac_key_b64u: String,
    pub created: i64,
    pub used_at: Option<i64>,        // None = unused
    pub profile_grants: Option<String>,  // None = no restriction; Some = JSON array
}
}

Admin API internals (src/routes/admin.rs)

The Admin API routes are served on a dedicated admin listener built by routes::build_admin_router. Each handler enforces role-based access using the require_role! macro, which delegates to the OperatorContext extractor. The OperatorContext verifies the operator’s session token and looks up their role in the operators table.

When the [admin] section is absent from the configuration, the admin router is not started and all admin endpoints are unreachable.

Role enforcement is applied per endpoint. A request from a role that is not authorised for that endpoint receives 403 Forbidden. The full role matrix is documented in Admin API and Operator Management.

Account profile grants endpoints

GET /admin/account/{id}/profile-grants calls db::accounts::get_profile_grants and returns:

{ "profile_grants": ["p1", "p2"] }

or {"profile_grants": null} for a NULL column. Returns 404 when the account ID is not found.

PUT /admin/account/{id}/profile-grants deserialises the body as {"profile_grants": <array or null>} and calls db::accounts::set_profile_grants. An empty JSON array and null both map to NULL in the database (the grants_to_json helper returns None for both). Returns 204 on success; 404 when the account is not found or is deactivated.

DELETE /admin/account/{id}/profile-grants calls set_profile_grants with grants = None, setting the column to NULL. Returns 204 on success; 404 when the account is not found or is deactivated.

EAB key provisioning endpoint

POST /admin/eab deserialises the body as:

{ "kid": "...", "hmac_key_b64u": "...", "profile_grants": ["p1"] }

profile_grants is optional (absent or null = no restriction). The handler calls db::eab::insert_with_grants, which inserts the key row with the profile_grants column set accordingly. Returns 201 with {"kid": "...", "created": <unix-epoch>}; returns 409 when the kid already exists (detected by a UNIQUE constraint violation). Requires administrator or ca_operations role; ca_ra is intentionally excluded because EAB keys are server-global (not CA-scoped).

Keys provisioned via this endpoint behave identically to keys seeded from [server.eab_keys] during EAB verification. The only difference is that config-file keys have profile_grants = NULL always (they are seeded via insert_if_absent, which does not write the profile_grants column), while admin-provisioned keys may carry grants.

EAB key listing endpoint

GET /admin/eab lists EAB keys. Query parameters:

  • used=true|false — filter by used status (omit for all keys)
  • limit=N — maximum rows returned (default 200, max 1000)
  • offset=N — skip first N rows (default 0)

Returns {"eab_keys": [...]} where each element contains kid, created, used_at, and profile_grants. Calls db::eab::list. Requires any role.

EAB key detail endpoint

GET /admin/eab/{kid} returns a single key’s details:

{ "kid": "...", "created": <unix-epoch>, "used_at": <unix-epoch or null>, "profile_grants": <array or null> }

Returns 404 when the kid is not found. Calls db::eab::get_by_kid. Requires any role.

EAB key deletion endpoint

DELETE /admin/eab/{kid} removes the key row entirely. Returns 204 on success; 404 when the kid is not found. Calls db::eab::delete. Requires administrator or ca_operations role.


HKDF-SHA-256 EAB credential derivation (src/eab_derivation.rs)

When [server].eab_master_secret is configured, EAB credentials for Kerberos-authenticated clients are derived deterministically rather than stored in advance. The derivation is implemented in src/eab_derivation.rs and called from the GET /acme/eab handler in src/routes/eab_identity.rs.

Public function

#![allow(unused)]
fn main() {
pub fn derive_eab_credentials(
    master_secret: &[u8],
    principal: &str,
) -> Result<(String, String), AcmeError>
}

Returns (kid_b64u, hmac_key_b64u) — both values are base64url-encoded (no padding).

Derivation scheme

Two independent HKDF-SHA-256 (RFC 5869) invocations are performed, one for the kid and one for the HMAC key:

kid      = base64url( HKDF-Extract-Expand(IKM=master_secret, salt=<none>, info="akamu-eab-v1-kid:" + principal, L=16) )
hmac_key = base64url( HKDF-Extract-Expand(IKM=master_secret, salt=<none>, info="akamu-eab-v1-key:" + principal, L=32) )

No explicit salt is supplied to HKDF-Extract; RFC 5869 §2.2 specifies that an absent salt is treated as a string of zeroes of length HashLen (32 bytes for SHA-256). This is acceptable because master_secret is already high-entropy input key material (at least 32 random bytes) and does not require additional salt-based extraction to achieve uniform randomness.

The info field domain-separates the two outputs, ensuring that the kid and HMAC key bytes are independent even though they share the same IKM and salt.

Output sizes

OutputRaw bytesBase64url chars
kid1622 (no padding)
hmac_key3243 (no padding)

Determinism guarantee

The same (master_secret, principal) pair always yields identical kid and hmac_key values. The GET /acme/eab handler inserts the derived key into eab_keys on the first call (using insert_if_absent) and returns the same values on subsequent calls. This lets clients retry a failed registration without administrator intervention.

Keying material lifetime

Once the kid has been consumed by a successful newAccount request, the eab_keys.used_at column is set and further calls to GET /acme/eab by the same principal return 409 Conflict. The administrator must delete the consumed key row (via the Admin API or akamuctl eab remove) to allow the principal to re-register.

TLS Layer

This chapter documents the internal implementation of Akāmu’s native TLS server, covering the crypto backend selection, certificate loading, composite ML-DSA scheme wiring, and the connection acceptance loop.

Module layout

src/tls/
  mod.rs              TLS module re-exports; build_rustls_server_config and
                      build_admin_rustls_server_config entry points; leaf_cert_der helper
  init.rs             tls::init::load_or_generate — certificate bootstrap
  loader.rs           PEM loading helpers (pem_to_der, BackendPrivateKey::from_pem)
  schemes.rs          Composite ML-DSA+classical code points (COMPOSITE_SCHEMES)
  verifier.rs         SyntaClientCertVerifier — rustls ClientCertVerifier impl
  channel_binding.rs  RFC 5929 tls-server-end-point channel binding computation

TLS is optional. When config.tls.enabled is false, the server uses a plain axum::serve call and the entire src/tls/ subsystem is never entered.

Crypto provider: rustls-native-ossl

The rustls ServerConfig is constructed with the rustls-native-ossl default provider, which delegates all cryptographic operations to the system OpenSSL library:

#![allow(unused)]
fn main() {
let provider = Arc::new(rustls_native_ossl::default_provider());
let builder = rustls::ServerConfig::builder_with_provider(provider)
    .with_protocol_versions(&versions)?;
}

rustls-native-ossl handles all classical TLS signature schemes (ECDSA, RSA-PSS, RSA-PKCS1, EdDSA) for both server certificate verification and client certificate CertificateVerify in TLS 1.2.

Composite ML-DSA+classical CertificateVerify messages in TLS 1.3 are routed through the same native-ossl OpenSSL backend via a dedicated dispatch path (see Composite scheme verification below).

tls::init::load_or_generate (src/tls/init.rs)

Called once at startup when config.tls.enabled is true. It mirrors the logic of ca::init::load_or_generate:

cert_file existskey_file existsAction
NoNoGenerate server key + CA-signed cert; write both files
YesYesReturn immediately — caller has supplied its own cert
YesNo (or No/Yes)Return Err — partial state rejected

When generating:

  1. ca::init::generate_backend_key(&tls.bootstrap_key_type) generates a fresh server key.
  2. ca::issue::sign_server_cert(&tls.server_name, &server_key, ca) produces a CA-signed certificate DER.
  3. server_key.to_pem(None) serialises the private key PEM; written to key_file first via crate::util::write_key_file.
  4. synta_certificate::der_to_pem("CERTIFICATE", &cert_der) converts the certificate to PEM.
  5. The PEM chain written to cert_file is leaf cert + CA cert (PEM-concatenated) so TLS clients see a complete chain without needing the CA cert separately.

The function signature is:

#![allow(unused)]
fn main() {
pub fn load_or_generate(tls: &TlsConfig, ca: &CaState) -> Result<(), String>
}

PEM loading (src/tls/loader.rs)

All PEM-to-DER conversion uses synta_certificate::pem_to_der — the same helper used throughout the server and CA subsystems. This avoids a second PEM parser dependency.

load_server_cert_chain

#![allow(unused)]
fn main() {
pub fn load_server_cert_chain(path: &str) -> Result<Vec<CertificateDer<'static>>, String>
}

Reads the file, calls pem_to_der, and maps each DER blob to rustls::pki_types::CertificateDer. Returns an error if the file contains no PEM blocks.

load_server_private_key

#![allow(unused)]
fn main() {
pub fn load_server_private_key(path: &str) -> Result<PrivateKeyDer<'static>, String>
}

Reads the PEM file and calls BackendPrivateKey::from_pem(&pem, None) to parse it — the same synta_certificate primitive used to load the CA key. The resulting BackendPrivateKey is then serialised to PKCS#8 DER via .to_der() and wrapped in rustls::pki_types::PrivateKeyDer::Pkcs8. This accepts both unencrypted PKCS#8 (-----BEGIN PRIVATE KEY-----) and SEC1 EC keys (-----BEGIN EC PRIVATE KEY-----).

load_ca_certs

#![allow(unused)]
fn main() {
pub fn load_ca_certs(ca_files: &[String]) -> Result<Vec<Vec<u8>>, String>
}

Iterates the configured CA PEM files, calls pem_to_der for each, and returns a flat Vec of DER blobs for the SyntaClientCertVerifier trust store.

SyntaClientCertVerifier (src/tls/verifier.rs)

Implements rustls::server::danger::ClientCertVerifier using synta-x509-verification for chain validation. Trust anchors are parsed once at startup via OwnedStore::try_new and reused across all connections with no DER re-parsing per handshake.

Construction

#![allow(unused)]
fn main() {
let verifier = SyntaClientCertVerifier::new(&ca_ders, client_auth_config)?;
}

OwnedStore::try_new parses each CA DER blob into an owned in-process trust store. The DN hints (root_hint_subjects) are also pre-computed once by parsing the subject Name from each CA DER using synta::Decoder.

verify_client_cert

On each TLS handshake, rustls calls this method. It:

  1. Clones the DER bytes out of the short-lived CertificateDer borrows into owned Vec<u8> allocations.
  2. Parses the leaf and each intermediate via synta::Decoder::decode::<Certificate>().
  3. Builds a PolicyDefinition via PolicyDefinition::new_client(OpensslSignatureVerifier, validation_time), then applies the configured profile, depth, minimum RSA modulus, and algorithm sets.
  4. Calls self.owned_store.verify(&leaf_vc, &inter_vcs, &policy, RevocationChecks::default()) — no re-parsing of trust anchors.

Algorithm sets are chosen based on allow_post_quantum:

allow_post_quantumSPKI algorithmsSignature algorithms
falseWEBPKI_PERMITTED_SPKI_ALGORITHMSWEBPKI_PERMITTED_SIGNATURE_ALGORITHMS
trueWEBPKI_PERMITTED_SPKI_ALGORITHMS_WITH_PQWEBPKI_PERMITTED_SIGNATURE_ALGORITHMS_WITH_PQ

verify_tls12_signature

All TLS 1.2 CertificateVerify schemes delegate to the rustls-native-ossl provider via the provider field cached at construction time — no new default_provider() call per handshake:

#![allow(unused)]
fn main() {
rustls::crypto::verify_tls12_signature(
    message, cert, dss,
    &self.provider.signature_verification_algorithms,
)
}

Composite ML-DSA schemes are TLS 1.3 only and never appear here.

verify_tls13_signature

TLS 1.3 CertificateVerify dispatch:

#![allow(unused)]
fn main() {
if crate::tls::schemes::is_composite(dss.scheme) {
    verify_composite_tls13_signature(message, cert, dss)
} else {
    rustls::crypto::verify_tls13_signature(
        message, cert, dss,
        &self.provider.signature_verification_algorithms,
    )
}
}

Classical schemes go to rustls-native-ossl; composite ML-DSA schemes go to the native-ossl EVP path. The provider is stored as Arc<rustls::crypto::CryptoProvider> in the verifier struct (built once at SyntaClientCertVerifier::new), so a single rustls_native_ossl::default_provider() call is shared across every connection.

Composite scheme code points (src/tls/schemes.rs)

#![allow(unused)]
fn main() {
pub const MLDSA44_ECDSA_P256_SHA256:     u16 = 0x0901;
pub const MLDSA44_RSA2048_PKCS15_SHA256: u16 = 0x0902;
// … 11 entries total
pub const MLDSA87_ED448_SHAKE256:        u16 = 0x090C;
}

These are provisional code points from draft-reddy-tls-composite-mldsa (all TBD pending IANA allocation). The X.509 OIDs for the same algorithm combinations are defined in the companion draft-ietf-lamps-pq-composite-sigs. They are advertised as SignatureScheme::Unknown(code) values because rustls does not have built-in named variants for these provisional code points.

COMPOSITE_SCHEMES is a &[SignatureScheme] slice of all 11 entries, returned by supported_verify_schemes when allow_post_quantum = true.

is_composite(scheme: SignatureScheme) -> bool checks whether a scheme’s code point is in COMPOSITE_SCHEMES:

#![allow(unused)]
fn main() {
pub fn is_composite(scheme: SignatureScheme) -> bool {
    if let SignatureScheme::Unknown(code) = scheme {
        COMPOSITE_SCHEMES.contains(&SignatureScheme::Unknown(code))
    } else {
        false
    }
}
}

Composite scheme verification (native-ossl)

When is_composite returns true, verification is routed to:

#![allow(unused)]
fn main() {
fn verify_composite_tls13_signature(
    message: &[u8],
    cert: &CertificateDer<'_>,
    dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TlsError>
}

This function:

  1. Extracts the composite SubjectPublicKeyInfo DER from the raw certificate bytes using synta_certificate::cert_byte_ranges to get the exact SPKI TLV byte range — avoiding a full certificate re-parse.
  2. Calls verify_composite_via_openssl(dss.scheme, message, spki_der, dss.signature()).

verify_composite_via_openssl uses native-ossl:

#![allow(unused)]
fn main() {
use native_ossl::pkey::{Pkey, Public, SignInit, Verifier};

let pkey = Pkey::<Public>::from_der(spki_der)?;
let digest = composite_digest(scheme)?;
let mut verifier = Verifier::new(&pkey, &SignInit { digest: Some(&digest), params: None })?;
verifier.update(message)?;
verifier.verify(sig_bytes)?
}

Pkey::<Public>::from_der loads the composite SubjectPublicKeyInfo DER via OpenSSL’s d2i_PUBKEY, which understands both the classical and ML-DSA components of the composite key. Verifier::verify dispatches to the OpenSSL provider, which applies “and” semantics — both the classical and ML-DSA components must verify.

composite_digest maps each code point to the correct hash algorithm name for native_ossl::digest::DigestAlg::fetch:

Code pointConstantHash
0x0901MLDSA44_ECDSA_P256_SHA256SHA2-256
0x0902MLDSA44_RSA2048_PKCS15_SHA256SHA2-256
0x0903MLDSA44_RSA2048_PSS_SHA256SHA2-256
0x0904MLDSA44_ED25519_SHA512SHA2-512
0x0905MLDSA65_ECDSA_P256_SHA512SHA2-512
0x0906MLDSA65_ECDSA_P384_SHA512SHA2-512
0x0907MLDSA65_RSA3072_PKCS15_SHA512SHA2-512
0x0908MLDSA65_RSA3072_PSS_SHA512SHA2-512
0x0909MLDSA65_ED25519_SHA512SHA2-512
0x090AMLDSA87_ECDSA_P384_SHA512SHA2-512
0x090CMLDSA87_ED448_SHAKE256SHAKE256

Channel binding (src/tls/channel_binding.rs)

Implements RFC 5929 §4 tls-server-end-point channel binding, used by the GSSAPI authentication layer to bind Kerberos tokens to the TLS session.

TlsServerEndpointBinding

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct TlsServerEndpointBinding(pub Vec<u8>);
}

A typed request extension injected per-connection. Contains the raw binding bytes (the hash of the leaf certificate DER per RFC 5929 §4). Absent when the server certificate uses an algorithm with no defined hash (ML-DSA pure or composite, Ed448, or any unrecognised algorithm) — in those cases the field is not inserted and the GSSAPI layer passes None channel bindings.

tls_server_endpoint_binding

#![allow(unused)]
fn main() {
pub fn tls_server_endpoint_binding(cert_der: &[u8]) -> Option<Vec<u8>>
}

Parses the leaf certificate DER with synta::Decoder, extracts the signature algorithm OID, and selects the appropriate hash:

Signature algorithmHash used
ecdsa-with-SHA256 / sha256WithRSAEncryptionSHA-256
md5WithRSAEncryption / sha1WithRSAEncryptionSHA-256 (RFC 5929 §4 override)
id-RSASSA-PSS with SHA-1 or SHA-256 paramsSHA-256 (SHA-1 overridden)
id-RSASSA-PSS with SHA-384 paramsSHA-384
id-RSASSA-PSS with SHA-512 paramsSHA-512
ecdsa-with-SHA384 / sha384WithRSAEncryptionSHA-384
ecdsa-with-SHA512 / sha512WithRSAEncryption / id-Ed25519SHA-512
ML-DSA pure (FIPS 204), Composite ML-DSA, id-Ed448None — no canonical hash

Returns None for unsupported algorithms; the caller logs an informational message and disables GSSAPI channel bindings for that server certificate.

TLS connection acceptance loop (src/main.rs)

When config.tls.enabled is true, the server does not use axum::serve. Instead it runs a manual accept loop:

#![allow(unused)]
fn main() {
let mut server_cfg = akamu::tls::build_rustls_server_config(&config.tls)?;
server_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg));

// Pre-compute RFC 5929 tls-server-end-point channel binding once at startup.
let tls_channel_binding: Option<Arc<Vec<u8>>> = { ... };

loop {
    tokio::select! {
        _ = &mut shutdown => { break; }
        result = listener.accept() => {
            let (stream, peer_addr) = result?;
            let acceptor = acceptor.clone();
            let router = router.clone();
            let tls_channel_binding = tls_channel_binding.clone();
            tokio::spawn(async move {
                let tls = match acceptor.accept(stream).await {
                    Ok(s) => s,
                    Err(e) => { tracing::warn!("TLS handshake failed: {e}"); return; }
                };
                let io = hyper_util::rt::TokioIo::new(tls);
                let svc = hyper::service::service_fn(move |mut req| {
                    // Inject peer address so axum::extract::ConnectInfo works.
                    req.extensions_mut().insert(axum::extract::ConnectInfo(peer_addr));
                    // Inject pre-computed channel binding if available.
                    if let Some(ref b) = tls_channel_binding {
                        req.extensions_mut().insert(TlsServerEndpointBinding(b.as_ref().clone()));
                    }
                    ...
                });
                hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
                    .serve_connection(io, svc)
                    .await
            });
        }
    }
}
}

Each accepted TCP connection is handed to tokio_rustls::TlsAcceptor::accept, which completes the TLS handshake (including client certificate verification if client_auth is configured). TLS handshake failures log a warning via tracing::warn! and the task returns without serving any HTTP.

For the plain HTTP path, axum::serve(listener, router.into_make_service_with_connect_info::<SocketAddr>()).await is used without modification.

ALPN protocols ["h2", "http/1.1"] are negotiated; hyper’s auto::Builder handles both HTTP/1.1 and HTTP/2.

ConnectInfo is available in the TLS path. The accept loop explicitly inserts axum::extract::ConnectInfo(peer_addr) into each request’s extensions before routing, so handlers can use axum’s ConnectInfo<SocketAddr> extractor normally regardless of whether TLS is enabled.

Channel binding injection. The tls-server-end-point binding bytes (see Channel binding above) are pre-computed once at startup from the leaf certificate DER and stored as Option<Arc<Vec<u8>>>. Each spawned connection task clones the Arc and injects a TlsServerEndpointBinding extension into the request so GSSAPI handlers can access it without re-reading the certificate.

build_rustls_server_config (src/tls/mod.rs)

The central assembly function for the ACME listener:

#![allow(unused)]
fn main() {
pub fn build_rustls_server_config(
    tls: &crate::config::TlsConfig,
) -> Result<rustls::ServerConfig, String>
}
  1. Calls loader::load_server_cert_chain and loader::load_server_private_key.
  2. Builds the provider: Arc::new(rustls_native_ossl::default_provider()).
  3. Filters tls.protocols to &rustls::version::TLS12 and/or &rustls::version::TLS13. Returns Err if the resulting list is empty.
  4. If tls.client_auth is present: builds SyntaClientCertVerifier and calls .with_client_cert_verifier(verifier).
  5. If absent: calls .with_no_client_auth().
  6. Calls .with_single_cert(certs, key) to install the server certificate and key.

build_admin_rustls_server_config (src/tls/mod.rs)

A parallel function for the dedicated admin listener:

#![allow(unused)]
fn main() {
pub fn build_admin_rustls_server_config(
    admin: &crate::config::AdminConfig,
) -> Result<rustls::ServerConfig, String>
}

Differences from build_rustls_server_config:

  • Always enables both TLS 1.2 and TLS 1.3 (not configurable via protocols).
  • Client auth is optional: if admin.ca_certs is empty, with_no_client_auth() is used; otherwise a SyntaClientCertVerifier is built with required = false so the same listener serves both mTLS (cert path) and GSSAPI (no cert presented) connections.
  • Uses a fixed ClientAuthConfig with profile = "rfc5280", max_chain_depth = 5, minimum_rsa_modulus = 2048, and allow_post_quantum = false.
  • Admin ALPN is ["http/1.1"] only (set by the caller after this function returns).

leaf_cert_der (src/tls/mod.rs)

#![allow(unused)]
fn main() {
pub fn leaf_cert_der(tls: &crate::config::TlsConfig) -> Result<Vec<u8>, String>
}

Returns the DER bytes of the first (leaf) certificate in the configured cert_file. Used at startup to pre-compute the tls-server-end-point channel binding without keeping a parsed certificate in memory.

Client Library Internals

This page documents the internal design of akamu-client and the akamu-cli command-line tool: the DnsHookSolver, the RenewalConfig type contract, AccountKey::from_jwk_private, and the certbot migration internals.

For the user-facing command reference, see akamu-cli — Command Reference.

Source layout

FileContents
crates/akamu-client/src/account.rsAccountKey, Account, key generation, alg_for_key, jwk_private_to_backend_key
crates/akamu-client/src/challenge.rsChallengeSolver trait, Http01Solver, TlsAlpn01Solver, Dns01Helper, DnsPersist01Helper, DnsHookSolver
crates/akamu-client/src/client.rsAcmeClient — directory-aware async HTTP client with nonce management
crates/akamu-client/src/csr.rsbuild_csr — DER-encoded CSR construction
crates/akamu-client/src/eab.rscreate_eab_jws — client-side EAB JWS construction (RFC 8555 §7.3.4)
crates/akamu-client/src/error.rsClientError — unified error type
crates/akamu-client/src/gssapi_eab.rsfetch_eab_via_gssapi, GssapiEabResult — GSSAPI-authenticated EAB credential fetch
crates/akamu-client/src/onion.rsbuild_onion_csr — DER-encoded CSR for onion-csr-01 challenges (RFC 9799)
crates/akamu-client/src/types.rsIdentifier, Order, Authorization, Challenge, RenewalConfig, AccountOptions, EabOptions, StarOrderParams, StarOrder, RenewalInfo
crates/akamu-cli/src/import/certbot.rsdiscover_accounts, discover_renewals, jwk_to_account_key, map_challenge_type, build_renewal_config, live_cert_paths

AccountKey

AccountKey is the central type that owns an account’s private key and its pre-computed derivative values. The struct holds four fields:

#![allow(unused)]
fn main() {
pub struct AccountKey {
    priv_key:   BackendPrivateKey,   // synta-certificate private key handle
    pub_jwk:    JwkPublic,           // pre-computed public JWK (from akamu-jose)
    thumbprint: String,              // pre-computed RFC 7638 thumbprint (base64url)
    alg:        &'static str,        // JWS alg string ("ES256", "EdDSA", "ML-DSA-65", …)
}
}

All four values are computed once inside AccountKey::from_backend_key and then stored. All subsequent callers (sign requests, key-authorization computation, EAB payload) read from cache without re-deriving.

Key generation

AccountKey::generate(key_type) dispatches to generate_backend_key, which calls the corresponding BackendPrivateKey::generate_* function:

key_type stringFunction called
"ec:P-256" (or "P-256")BackendPrivateKey::generate_ec("P-256")
"ec:P-384" (or "P-384")BackendPrivateKey::generate_ec("P-384")
"ec:P-521" (or "P-521")BackendPrivateKey::generate_ec("P-521")
"rsa:2048" (or "rsa2048")BackendPrivateKey::generate_rsa(2048, 65537)
"rsa:3072" (or "rsa3072")BackendPrivateKey::generate_rsa(3072, 65537)
"rsa:4096" (or "rsa4096")BackendPrivateKey::generate_rsa(4096, 65537)
"ed25519"BackendPrivateKey::generate_ed25519()
"ed448"BackendPrivateKey::generate_ed448()
"ml-dsa-44" (or "ML-DSA-44")BackendPrivateKey::generate_ml_dsa("ML-DSA-44")
"ml-dsa-65" (or "ML-DSA-65")BackendPrivateKey::generate_ml_dsa("ML-DSA-65")
"ml-dsa-87" (or "ML-DSA-87")BackendPrivateKey::generate_ml_dsa("ML-DSA-87")

Any other string returns ClientError::Crypto.

alg_for_key — JWS algorithm detection

alg_for_key derives the JWS alg string from the key material. It queries the public key’s key_type() string and branches:

  • "ec" — calls pub_key.ec_curve_name() to get the curve name and maps "P-256" / "prime256v1""ES256", "P-384" / "secp384r1""ES384", "P-521" / "secp521r1""ES512".
  • "rsa" — always returns "PS256" (RSA-PSS with SHA-256).
  • "ed25519" or "ed448" — returns "EdDSA".
  • Anything else — inspects the raw SPKI DER to detect ML-DSA.

ML-DSA SPKI OID detection. The FIPS 204 OIDs share a common prefix of eight bytes at offset 8 of the DER-encoded SPKI:

60 86 48 01 65 03 04 03

The byte at offset 16 distinguishes the parameter set:

Byte at offset 16Algorithm
0x11ML-DSA-44
0x12ML-DSA-65
0x13ML-DSA-87

Any other value returns ClientError::Crypto.


AccountKey::from_jwk_private

Builds an AccountKey from a raw private JWK JSON string — the format used by certbot’s accounts/…/private_key.json. Supports EC (P-256, P-384, P-521) and RSA. The entry point is:

#![allow(unused)]
fn main() {
pub fn from_jwk_private(json: &str) -> Result<Self, ClientError>
}

The implementation calls jwk_private_to_backend_key(json), which:

  1. Parses json as serde_json::Value.
  2. Reads kty (case-insensitive, uppercased for matching).
  3. EC path ("EC"):
    • Reads crv → maps "P-256" / "P-384" / "P-521" to the curve string.
    • Decodes d, x, y from base64url-no-padding.
    • Calls BackendPrivateKey::from_ec_private_scalar(&d, &x, &y, curve).
  4. RSA path ("RSA"):
    • Decodes all eight CRT components from base64url-no-padding: n, e, d, p, q, dp, dq, qi.
    • Constructs synta_certificate::RsaPrivateComponents { n, e, d, p, q, dp, dq, qi }.
    • Calls BackendPrivateKey::from_rsa_private_components(&components).
  5. Any other kty returns Err("unsupported JWK kty: …").

After constructing the BackendPrivateKey, from_backend_key runs the same path as generate: derives the public JWK, computes the thumbprint, and determines the JWS alg string.


ChallengeSolver trait

#![allow(unused)]
fn main() {
pub trait ChallengeSolver: Send + Sync {
    fn present(
        &self,
        token: &str,
        key_auth: &str,
    ) -> Pin<Box<dyn Future<Output = Result<(), ClientError>> + Send + '_>>;

    fn cleanup(
        &self,
        token: &str,
    ) -> Pin<Box<dyn Future<Output = Result<(), ClientError>> + Send + '_>>;
}
}

present is called before the ACME client triggers the challenge. cleanup is called after the challenge completes (success or failure). Both methods return boxed futures to allow dyn dispatch across the async boundary without requiring async_trait.


Http01Solver

Http01Solver binds a minimal HTTP/1.1 server and serves /.well-known/acme-challenge/<token> responses.

Internal design:

  • Token storage is Arc<RwLock<HashMap<String, String>>> (token → key_auth).
  • start() binds 0.0.0.0:<port> with TcpListener, then spawns a background accept loop with tokio::spawn. Each accepted connection gets its own tokio::spawn running hyper::server::conn::http1::Builder.
  • present(token, key_auth) writes to the RwLock under a write guard.
  • cleanup(token) removes the entry under a write guard.
  • handle_challenge strips the /.well-known/acme-challenge/ prefix and returns the stored value with HTTP 200, or HTTP 404 for any other path.
  • All responses use http_body_util::Full<Bytes>.

TlsAlpn01Solver

TlsAlpn01Solver serves ephemeral ACME challenge certificates for tls-alpn-01 (RFC 8737).

Internal design:

  • Certificate storage is Arc<RwLock<HashMap<String, Arc<rustls::sign::CertifiedKey>>>> (domain → certified key).
  • start() creates a rustls::ServerConfig using the rustls-native-ossl provider, sets alpn_protocols = vec![b"acme-tls/1"], and spawns a tokio_rustls::TlsAcceptor accept loop. The JoinHandle is stored in self.handle.
  • present(domain, id_type, key_auth) performs all certificate construction:
    1. Computes SHA-256(key_auth) to get a 32-byte hash.
    2. Encodes the id-pe-acmeIdentifier extension as an OCTET STRING containing the hash, then wraps it in synta_certificate::acme_types::Authorization to produce the DER extension value.
    3. Generates an ephemeral EC P-256 key with BackendPrivateKey::generate_ec.
    4. Builds a self-signed certificate using synta_certificate::CertificateBuilder with a 7-day validity window, the domain as CN, and the id-pe-acmeIdentifier extension marked critical. For id_type == "ip" the SAN is iPAddress; otherwise dNSName.
    5. Loads the key into rustls via rustls_native_ossl::default_provider().key_provider.load_private_key.
    6. Inserts the rustls::sign::CertifiedKey into the SNI store under the domain name.
  • SniResolver implements rustls::server::ResolvesServerCert by looking up client_hello.server_name() in the store.
  • cleanup() aborts the background JoinHandle.

Dns01Helper and DnsPersist01Helper

Dns01Helper exposes one static method:

#![allow(unused)]
fn main() {
pub fn txt_value(key_auth: &str) -> Result<String, ClientError>
}

This returns base64url(SHA-256(key_auth)). The computation is done by dns_txt_value in account.rs, which calls synta_certificate::default_data_hasher().hash_data("sha256", key_auth.as_bytes()) and base64url-encodes the result.

DnsPersist01Helper is a different design — it does not hash the key authorization. Instead, it builds the structured TXT record content specified by draft-ietf-acme-dns-persist. It exposes two static methods:

#![allow(unused)]
fn main() {
// Non-wildcard: placed at _validation-persist.<domain>
pub fn txt_record(issuer_domain: &str, account_url: &str) -> String
// Returns: "<issuer_domain>; accounturi=<account_url>"

// Wildcard / subdomain coverage:
pub fn txt_record_wildcard(issuer_domain: &str, account_url: &str) -> String
// Returns: "<issuer_domain>; accounturi=<account_url>; policy=wildcard"
}

issuer_domain is taken from the issuer-domain-names array in the server’s challenge object. account_url is the ACME account URL returned at registration time.

Unlike dns-01, the dns-persist-01 record is long-lived; it is provisioned once and left in place — there is no cleanup call.


DnsHookSolver

DnsHookSolver implements DNS-01 and dns-persist-01 TXT record management by delegating to an external hook script. The hook is never used for http-01 or tls-alpn-01.

Struct:

#![allow(unused)]
fn main() {
pub struct DnsHookSolver {
    hook: String,   // path or shell command
}
}

Hook invocation — run_hook:

#![allow(unused)]
fn main() {
let output = tokio::process::Command::new(&self.hook)
    .arg(operation)               // "add" or "remove"
    .env("AKAMU_DOMAIN",   domain)
    .env("AKAMU_TOKEN",    token)
    .env("AKAMU_TXT",      &txt)  // base64url(SHA-256(key_auth))
    .env("AKAMU_KEY_AUTH", key_auth)
    .output()
    .await?;
}

Values are passed exclusively via environment variables, not command-line arguments, to avoid leaking secrets through /proc/<pid>/cmdline.

Environment variables:

VariableValue
AKAMU_DOMAINDNS name being validated
AKAMU_TOKENACME challenge token
AKAMU_TXTbase64url(SHA-256(key_authorization))
AKAMU_KEY_AUTHFull key authorization string ({token}.{thumbprint})

Exit code semantics: exit code 0 is success. Any non-zero exit code produces ClientError::Crypto with the captured stderr included in the message.

Public API:

  • deploy(domain, token, key_auth) — calls run_hook("add", …) for dns-01.
  • clean(domain, token, key_auth) — calls run_hook("remove", …) for dns-01.
  • deploy_persist(domain, txt_record) — for dns-persist-01: invokes the hook with add and passes only AKAMU_DOMAIN and AKAMU_TXT (the full structured record content built by DnsPersist01Helper). There is no corresponding clean for dns-persist-01 because the record is long-lived.

Environment variables for dns-persist-01 (deploy_persist):

VariableValue
AKAMU_DOMAINDNS name being validated
AKAMU_TXTFull TXT record content ("issuer; accounturi=…[; policy=wildcard]")

DnsHookSolver does not implement the ChallengeSolver trait directly because the trait’s present/cleanup signatures do not carry the domain name. Callers use deploy, clean, and deploy_persist explicitly.


fetch_eab_via_gssapi (gssapi_eab.rs)

fetch_eab_via_gssapi performs a one-shot authenticated GET to the server’s /acme/eab endpoint using a Kerberos keytab and returns the EAB credentials ready for use in a newAccount request.

Public types

#![allow(unused)]
fn main() {
pub struct GssapiEabResult {
    pub principal: String,       // e.g. "host/client.example.com@REALM"
    pub kid:       Option<String>, // present when eab_master_secret is configured
    pub hmac_key:  Option<String>, // base64url-encoded HMAC key
    pub alg:       Option<String>, // e.g. "HS256"
}

pub async fn fetch_eab_via_gssapi(
    eab_url:      &str,
    keytab_file:  &str,
) -> Result<GssapiEabResult, ClientError>
}

Internal steps

  1. Calls GssClientCred::from_keytab(keytab_file) (from akamu-gssapi) to load the initiator credential from the keytab.
  2. Calls derive_service_name(eab_url) to compute the target SPN as HTTP@<hostname> by stripping the URL scheme, port, and path.
  3. Calls akamu_gssapi::init_token(&cred, &target, None) inside tokio::task::spawn_blocking to avoid blocking the async executor on the gss_init_sec_context FFI call.
  4. Base64-encodes the resulting token and sends a single GET request with Authorization: Negotiate <base64-token>.
  5. Parses the JSON response into GssapiEabResult.

Note: this function performs a single GSSAPI step (no multi-round-trip loop). Kerberos AP-REQ exchanges are typically single-round-trip, so one step is sufficient. Multi-round-trip SPNEGO is handled by AdminClient::session_token in akamuctl for the admin API path.

derive_service_name

A private helper that strips the URL scheme (https:// or http://), path, and port from eab_url to extract the bare hostname, then returns HTTP@<hostname>. Returns ClientError::Http when no non-empty host component can be extracted.


RenewalConfig

RenewalConfig is a Serialize + Deserialize struct that captures every parameter needed to repeat a certificate issuance without user interaction.

Fields and serde defaults:

FieldTypeSerde defaultNotes
serverString(required)ACME directory URL (base URL or full per-CA directory URL)
caOption<String>NoneCA identifier for akamu multi-CA servers; derives directory URL as {server}/acme/{ca}/directory; ignored when server already ends in /directory; omitted from TOML when absent
domainsVec<Identifier>(required)Identifiers to certify
account_keyPathBuf(required)Path to account private key PEM
account_key_typeString"ec:P-256"Key type string for account key
cert_pathPathBuf(required)Output path for certificate chain
cert_key_pathPathBuf(required)Output path for certificate private key
cert_key_typeString"ec:P-256"Key type string for certificate key
challenge_typeString"http-01"Challenge type
http_portu1680Port for http-01 challenge server
tls_portu16443Port for tls-alpn-01 challenge server
onion_keyOption<PathBuf>NoneOnion service private key path
poll_timeoutu64120Validation poll timeout in seconds
contactsVec<String>[]Contact URIs for account registration
eab_kidOption<String>NoneEAB key identifier
eab_keyOption<String>NoneEAB HMAC key (base64url)
eab_algString"HS256"EAB HMAC algorithm
gssapi_keytabOption<PathBuf>NonePath to a Kerberos keytab for GSSAPI-authenticated EAB fetch; mutually exclusive with eab_kid/eab_key
dns_hookOption<String>NoneDNS hook script path

Fields with serde defaults use #[serde(default = "defaults::…")] pointing to private free functions in the defaults module. Fields without a default must be present in the TOML file.

TOML sidecar convention: akamu-cli issue writes the renewal config to <cert-path>.renewal.toml (e.g. if cert_path is /etc/akamu/certs/example.com.pem, the sidecar is /etc/akamu/certs/example.com.pem.renewal.toml). akamu-cli renew --renewal-config reads this file.

TOML round-trip: all field types must survive a toml::to_string_prettytoml::from_str round-trip. Identifier serializes as an inline table { type = "dns", value = "example.com" }.


Certbot import internals

crates/akamu-cli/src/import/certbot.rs implements the akamu-cli import certbot subcommand.

Account directory structure

Certbot stores accounts under:

<certbot-dir>/accounts/<ca-hostname>/<account-id>/
    private_key.json   # raw private JWK
    regr.json          # registration response (contains uri and body.contact)
    meta.json          # metadata (contains creation_dt)

discover_accounts walks accounts/ two levels deep. For each account directory it:

  1. Reads private_key.json as a raw JWK JSON string.
  2. Calls parse_regr_json to extract uri (the account URL) and body.contact (the contact list) from regr.json.
  3. Calls parse_meta_json to extract creation_dt from meta.json.
  4. Returns a CertbotAccount struct.

Any directory missing private_key.json is silently skipped.

Renewal file structure

Certbot writes one .conf file per certificate to <certbot-dir>/renewal/. The file name stem is the primary domain name (with wildcard encoding; see below).

discover_renewals reads all *.conf files in the renewal/ directory. Each file is parsed by parse_ini_flat, which reads all key = value lines from both the flat top section and the [renewalparams] section (section headers and blank lines are skipped; # comments are stripped). The relevant keys are:

KeyMeaning
serverACME directory URL (default: Let’s Encrypt v2)
authenticatorChallenge authenticator string
preferred_challengesOptional override for manual authenticator

Challenge-type mapping

map_challenge_type(authenticator, preferred_challenges, dns_challenge) maps certbot’s authenticator string to an akamu challenge type:

certbot authenticatorpreferred_challengesakamu challenge typeWarning?
standaloneanyhttp-01No
webrootanyhttp-01No
nginxanyhttp-01No
apacheanyhttp-01No
manualcontains "dns"value of --dns-challenge argYes — manual DNS
manualanything elsehttp-01No
tls-sni-01anytls-alpn-01Yes — deprecated
dns-* (any prefix)anyvalue of --dns-challenge argYes — hook needed
anything elseanyhttp-01No

The --dns-challenge CLI argument controls whether DNS challenges map to "dns-01" (default) or "dns-persist-01". The canonical_dns_challenge helper performs this mapping.

Wildcard encoding convention

Certbot stores wildcard certificates under directory names that replace the leading *. with _wildcard.:

DomainCertbot directory name
*.example.com_wildcard.example.com
example.comexample.com

build_renewal_config decodes this by checking whether renewal.domain starts with "_wildcard." and, if so, substituting "*." at the start to reconstruct the original domain name.

live_cert_paths encodes the inverse: given a domain starting with "*.", it substitutes "_wildcard." to locate the certbot live/ subdirectory.

build_renewal_config

Takes a CertbotRenewal plus caller-supplied paths and options and constructs a RenewalConfig. It does not attempt to detect the certificate key type from the existing certbot certificate; it always writes "ec:P-256" for both account_key_type and cert_key_type. The caller must update these if the imported account uses a different key type.

The function returns (RenewalConfig, Option<&'static str>) where the second element is a human-readable warning when the mapping is ambiguous (deprecated authenticator, manual DNS required, etc.).

jwk_to_account_key

A thin wrapper around AccountKey::from_jwk_private. Used by the import subcommand after discover_accounts has read the raw JWK JSON string, to verify that the key can be loaded before writing it to disk.

Testing

Akāmu uses Rust’s built-in test framework (cargo test). Tests are organized at three levels:

  • Unit tests inside each source file (#[cfg(test)] mod tests).
  • Integration tests in tests/, which test the full HTTP stack.

Running tests

Run all tests:

cargo test

Run a specific test by name:

cargo test validate_csr

Run all tests in a specific module:

cargo test ca::csr::tests

Run with output visible (useful for debugging):

cargo test -- --nocapture

Library crate tests

Each library crate ships its own unit tests:

cargo test -p akamu-jose    # 66 tests: JWK parsing, JWS sign/verify, ML-DSA
cargo test -p akamu-client  # 25 tests: AccountKey, EAB, CSR, challenge helpers

akamu-jose tests cover every key type including ML-DSA-44/65/87 round-trip sign/verify. akamu-client tests use real OpenSSL key generation (no mocking). See crates/akamu-jose/src/ and crates/akamu-client/src/ for test modules.

Test dependencies

dev-dependencies in Cargo.toml:

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<sqlx::Error> converts 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.
  • not_before_override is honoured; values more than 5 minutes in the past are clamped.
  • not_after_override is honoured; invalid values (not after not_before) fall back to not_before + validity_days * 86400.
  • issue_with_params rejects issuance when enforce_validity_cap=true and validity_days > 200; allows exactly 200 days.

src/ca/revoke.rs

Tests verify:

  • An empty CRL is generated with correct PEM headers.
  • CRL with revoked entries is generated.
  • encode_integer_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 1 MiB returns AcmeError::IncorrectResponse.
  • Key auth mismatch returns AcmeError::IncorrectResponse.
  • Direct connection to a private IP (e.g. 192.168.1.1) with allow_private_ips = false returns AcmeError::IncorrectResponse (SSRF guard on initial target).
  • Redirect to a private/link-local IP (e.g. 169.254.169.254) returns AcmeError::IncorrectResponse (SSRF guard on redirect target).
  • Redirect followed successfully when allow_private_ips = true (test-only override).
  • More than 10 redirects returns AcmeError::IncorrectResponse.

src/validation/dns01.rs

Tests use a hand-crafted UDP DNS stub server:

  • Correct TXT value returns Ok.
  • Wrong TXT value returns AcmeError::IncorrectResponse.
  • Non-existent domain returns an error.
  • Wildcard prefix is stripped before querying.

src/validation/dns_persist_01.rs

Tests cover both the parse_persist_until timestamp parser and the matches_record record-matching logic:

  • parse_persist_until accepts epoch, known timestamps, and leap-year dates; rejects bad separators, missing Z, and out-of-range fields.
  • matches_record verifies issuer match (case-insensitive, trailing-dot stripped), accounturi match and mismatch, wildcard policy=deny handling, persist-until expiry, unknown key-value tokens, and multi-issuer lists.
  • Async integration tests using a UDP DNS stub server: matching record returns Ok, wrong issuer returns error, wildcard domain strips *. prefix, wildcard requires policy=deny, non-existent domain returns a DNS error.

src/validation/onion_csr_01.rs

Tests cover v3 onion address validation and CSR cryptographic binding:

  • validate_onion_v3 accepts a valid 56-char base32 label and rejects v2 (16-char), too-short, wrong-chars, and non-.onion addresses.
  • base32_decode_no_pad handles valid input, invalid chars, and non-zero trailing bits.
  • decode_onion_pubkey rejects wrong version bytes; decodes a synthetic v3 address correctly.
  • ed25519_spki_der produces the correct 44-byte DER structure.
  • decode_utf8string_or_raw handles DER UTF8String tags and falls back to raw UTF-8.
  • Full CSR validation: Ed25519 CSR key matches the onion address public key; missing nonce extension fails; wrong nonce value fails; wrong SAN fails.

src/validation/caa.rs

Tests cover the build_name_walk helper and check_caa async lookups using a UDP DNS stub server:

  • build_name_walk produces the correct ordered list of names to query (single subdomain, deep subdomain, trailing-dot stripped, single-label returns empty).
  • check_caa: empty ca_identities is a no-op; no CAA records returns Ok; matching issuer returns Ok; non-matching issuer returns error; wildcard falls back from issuewild to issue; validationmethods tag filtering; accounturi matching and mismatch; case-insensitive CA identity comparison.

src/validation/tls_alpn01.rs

Tests include both unit tests for the DER walker and integration tests using local TLS servers:

  • decode_length, read_tlv, strip_sequence, strip_octet_string, skip_tlv — edge cases.
  • find_extension_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

All integration test files live under tests/. Each builds a full AppState with an in-memory database, a generated CA, and a real axum router. They use tower::ServiceExt::oneshot to send HTTP requests directly to the router without binding a TCP port, except where a live TCP port is required by the protocol (tls-alpn-01 validation, cosigner HTTP server).

FileWhat it covers
tests/acme_flow.rsCore ACME lifecycle: account creation, order creation, challenge signaling, status transitions, and certificate download
tests/admin_auth.rsAdmin authentication paths: Bearer token, mTLS client-certificate, and expired-token rejection; operator deactivation purges live sessions; audit event end-to-end (write via JournalWriter, query via GET /admin/audit)
tests/admin_rbac.rsTable-driven RBAC: for every (route, method) pair and each of the four operator roles, verifies allowed roles are not 403 and disallowed roles get exactly 403
tests/ari_flow.rsACME Renewal Information (RFC 9773) query and renewal window logic
tests/dns_persist_flow.rsFull dns-persist-01 challenge flow against a local DNS stub server
tests/mtc_cosigner_flow.rsEnd-to-end ACME issuance followed by MTC checkpoint production, cosignature gathering from an inline cosigner HTTP server, and StandaloneCertificate verification
tests/multi_ca.rsMulti-CA routing: per-CA directory and CRL endpoints, legacy path falls through to default CA, unknown CA ID returns 404, CRL isolation across CAs, order CA isolation
tests/tls_server.rsHelper module providing a local TLS server for tls-alpn-01 integration tests
tests/mtc_playground_compat.rsWire-compatibility tests for the C2SP tlog-tiles and signed-note implementation (RFC 9162 Merkle hashing, tile path encoding, checkpoint/cosignature note format, live HTTP endpoint smoke tests); optional DigiCert playground integration gated behind MTC_PLAYGROUND_DIR env var and --ignored

Adding new tests

Place unit tests in a #[cfg(test)] mod tests { ... } block at the bottom of the source file being tested. Use tokio::test for async tests:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn my_async_test() {
        // ...
    }

    #[test]
    fn my_sync_test() {
        // ...
    }
}
}

For tests that need a database, call crate::db::open(":memory:").await.unwrap() to get a fresh in-memory database with the full schema applied.

For tests that need a CA, call crate::ca::init::load_or_generate(&config).unwrap() with a CaConfig pointing to a tempfile::TempDir.

For tests that need a full AppState with multi-CA support, build cas as an IndexMap and populate crl_caches and link_headers as HashMaps keyed by CA ID. See the “Building a test AppState” section below for the canonical pattern.

Building a test AppState

Integration tests that exercise ACME handlers need a full AppState. The multi-CA refactor changed several fields; the canonical test-setup pattern is:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::{Arc, Mutex, RwLock};
use indexmap::IndexMap;

// 1. Open an in-memory database with the full schema.
let db = crate::db::open(":memory:").await.unwrap();

// 2. Generate a test CA (or load from tempfile).
let ca_cfg = CaConfig { id: "default".to_string(), key_type: "ec:P-256".to_string(), .. };
let (key, cert_der) = crate::ca::init::load_or_generate(&ca_cfg).unwrap();
let ca_state = Arc::new(CaState {
    id: ca_cfg.id.clone(),
    key_type: ca_cfg.key_type.clone(),
    key,
    cert_der,
    hash_alg: "sha256".into(),
    validity_days: 90,
    crl_url: None,
    ocsp_url: None,
    aki_bytes: vec![],
    enforce_validity_cap: false,
    crl_next_update_secs: 86400,
    caa_identities: vec![],
});

// 3. Build the IndexMap of CAs.
let mut cas_map = IndexMap::new();
cas_map.insert(ca_cfg.id.clone(), ca_state.clone());
let cas = Arc::new(cas_map);
let default_ca_id = Arc::new(ca_cfg.id.clone());

// 4. Build per-CA CRL cache and Link headers.
let mut crl_caches_map: HashMap<String, crate::state::CrlCache> = HashMap::new();
crl_caches_map.insert(ca_cfg.id.clone(), Default::default());
let crl_caches = Arc::new(crl_caches_map);

let mut link_headers_map = HashMap::new();
let link_value = axum::http::HeaderValue::from_static(
    "<https://acme.test/acme/directory>;rel=\"index\""
);
link_headers_map.insert(ca_cfg.id.clone(), Arc::new(link_value));
let link_headers = Arc::new(link_headers_map);

// 5. Build the outbound HTTPS client for challenge validation.
let validation_client = {
    let https = hyper_rustls::HttpsConnectorBuilder::new()
        .with_native_roots()
        .expect("native roots")
        .https_or_http()
        .enable_http1()
        .build();
    hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
        .build(https)
};

// 6. Assemble AppState.
let state = Arc::new(AppState {
    config: Arc::new(config),
    db,
    db_kind: crate::db::DbKind::Sqlite,
    cas,
    default_ca_id,
    mtc: Arc::new(MtcState {
        log: None,
        algorithm: synta_mtc::crypto::HashAlgorithm::Sha256,
        signing_key: None,
        signing_hash_alg: "sha256".into(),
        cosigner_clients: vec![],
        _log_lock: None,
    }),
    profiles: crate::profiles::ProfileRegistry::empty(&ca_state),
    tls: None,
    spki_cache: Arc::new(RwLock::new(HashMap::new())),
    nonces: Arc::new(NonceBucket::new()),
    link_headers,
    validation_client,
    crl_caches,
    gss_cred: None,
    admin_gss_cred: None,
    eab_master_secret: None,
    audit: Arc::new(crate::audit::AuditState::new()),
    audit_policy: Arc::new(crate::audit::AuditPolicy::default()),
    admin_sessions: None,
    admin_auth_limiter: None,
    startup_time: std::time::Instant::now(),
});
}

Key points:

  • CaState requires all fields: id, key_type, key, cert_der, hash_alg, validity_days, crl_url, ocsp_url, aki_bytes, enforce_validity_cap, crl_next_update_secs, and caa_identities. None have defaults.
  • AppState::cas is an Arc<IndexMap<String, Arc<CaState>>>, not a single Arc<CaState>. All lookups go through state.get_ca(id) or state.default_ca().
  • AppState::db_kind must be set; use DbKind::Sqlite for in-memory test databases.
  • AppState::profiles holds the certificate profile registry; use ProfileRegistry::empty(&ca) to get a no-op registry that falls back to CA defaults for all issuance.
  • AppState::crl_caches is an Arc<HashMap<String, CrlCache>>. Each entry must be keyed by the same CA ID as the corresponding cas entry; Default::default() yields Arc::new(Mutex::new(None)).
  • AppState::link_headers is an Arc<HashMap<String, Arc<HeaderValue>>>. The acme_headers helper falls back to the default CA’s header when a per-CA header is missing, but tests should always populate the map for every registered CA ID to avoid log noise.
  • AppState::nonces holds the in-memory anti-replay nonce store; construct with Arc::new(NonceBucket::new()).
  • AppState::spki_cache holds account key material; initialise with an empty RwLock-protected HashMap.
  • AppState::validation_client is required even for tests that do not perform outbound challenge validation; build a standard HTTPS client as shown above.
  • AppState::audit and AppState::audit_policy are always required; use AuditState::new() and AuditPolicy::default() respectively.
  • Optional fields (tls, gss_cred, admin_gss_cred, eab_master_secret, admin_sessions, admin_auth_limiter) should be set to None unless the test exercises those features.
  • Tests that only need a single CA can keep using a single entry in the IndexMap; there is no requirement to configure multiple CAs in tests.

Coverage

Coverage measurement is supported via the measure_coverage.sh script in the repository root. It uses cargo llvm-cov or a similar tool. See that script for the exact invocation.

Test Data Generation (akamu-seedgen)

akamu-seedgen is a standalone binary that populates a SQLite database with realistic PKI test data. It runs an in-process Akāmu server, drives the full ACME protocol to issue real certificates, then applies direct database mutations to produce the complete range of PKI lifecycle states — revoked, expired, near-expiry, STAR, delegation, ARI replacement chains — that would accumulate naturally over months in a production deployment.

The output is a SQLite database and a ready-to-run akamu.toml config that can be dropped into a dev or test Akāmu instance with no further setup.

Quick start

# Build the tool
cargo build -p akamu-seedgen

# Run with built-in defaults (~200 certs, 2 CAs, < 30 s)
cargo run -p akamu-seedgen -- --output /tmp/mytest.sqlite3

# Run with a specific scale spec
cargo run -p akamu-seedgen -- \
    --spec contrib/seedgen/small.toml \
    --output /tmp/small.sqlite3

# Launch a dev akamu + webui dev server against the result
./contrib/seedgen/dev.sh /tmp/small

Browse to http://localhost:9000/ui/ once the dev server starts.

The run prints EAB credentials for the seeded administrator at the end:

Web UI login (EAB tab at /ui/login):
  Key ID (kid):         seedgen-admin
  HMAC key (base64url): <key>

Paste those two values into the EAB tab on the login page to authenticate.

Command-line reference

akamu-seedgen [OPTIONS]

Options:
  -s, --spec <FILE>        Population spec (TOML). Omit to use built-in defaults.
  -o, --output <FILE>      Output SQLite file [default: test-data.sqlite3]
      --seed <N>           Override the RNG seed from the spec
  -v, --verbose            Print per-cert issuance progress
      --output-format      text|json  [default: text]
  -h, --help

The output file and an artifacts directory (named after the output file with its extension stripped) are both produced. For --output foo.sqlite3 the layout is:

foo/                   ← artifacts directory
  akamu.toml           ← ready-to-run Akāmu config
  ca-<id>/
    ca.key             ← CA private key (mode 0640)
    ca.crt             ← CA certificate (mode 0644)
foo.sqlite3            ← database

Spec file format

A spec file is a TOML document describing the desired population. All sections are optional; omitting a section uses built-in defaults.

[global]

[global]
seed   = 42                   # RNG seed — same seed + same spec = identical output
output = "test-data.sqlite3"  # default output path (overridden by --output)

seed controls the CSPRNG used for key generation, domain names, and revocation reason selection. The same seed with the same spec always produces the same database.

[[ca]]

At least one CA is required. Exactly one must have is_default = true.

[[ca]]
id               = "ec-p256"    # URL prefix: /acme/ec-p256/directory
is_default       = true
key_type         = "ec:P-256"   # ec:P-{256,384,521}, rsa:{2048,3072,4096},
                                 # ed25519, ed448, ml-dsa-{44,65,87}
hash_alg         = "sha256"     # sha256 | sha384 | sha512
validity_days    = 90           # default end-entity cert validity
common_name      = "EC P-256 Test CA"
organization     = "Akamu Test PKI"
ca_validity_years = 10

[[cross_sign]]

Each entry makes the issuer CA sign the subject CA’s public key, producing a cross-certificate stored in the database.

[[cross_sign]]
issuer         = "ec-p256"
subject        = "rsa-2048"
validity_years = 5

Both issuer and subject must reference [[ca]] IDs defined in the same spec. Self-sign (issuer == subject) is rejected.

[[profile]]

Profiles are added to the in-process server and emitted in the generated akamu.toml so they are available when the instance is restarted.

[[profile]]
id                = "tls-server"
description       = "Standard TLS server certificate"
eku               = ["server_auth"]
key_usage         = ["digital_signature"]
validity_days     = 90
allowed_key_types = []     # empty = any key type accepted
ca_ids            = []     # empty = all CAs

allowed_key_types restricts the leaf certificate key algorithm. This is independent of the CA key type: an rsa:2048 CA can issue a certificate for an ec:P-256 subscriber key.

[[scenario]]

A scenario drives one batch of ACME accounts and certificates under a single CA + profile combination.

[[scenario]]
name         = "ec-tls"
ca_id        = "ec-p256"
profile_id   = "tls-server"
num_accounts = 25

[scenario.certs]
valid          = 300    # left in status=valid
revoked        = 75     # revoked; reasons spread across RFC 5280 codes 0,1,3,4,5
expired        = 60     # backdated 1–2 years into the past
near_expiry    = 25     # not_after within 3–30 days from now
ari_chains     = 8      # replacement chains; each chain = 3 certs (A→B→C)
star_active    = 8      # STAR orders, active
star_canceled  = 4      # STAR orders, canceled
delegation     = 2      # processing-state delegation orders (no cert issued)
pending_orders = 3      # stale pending orders, never finalized
invalid_orders = 3      # orders set to status=invalid

[scenario.certs.key_types]
# Relative weights for leaf certificate key selection.
"ec:P-256" = 5
"ec:P-384" = 2
"rsa:2048" = 2
"ed25519"  = 1

[scenario.accounts]
deactivated = 2    # this many accounts are deactivated after cert issuance

All cert counts are independent; they do not need to sum to any particular total. Each count causes that many issuance flows or DB mutations.

Pre-built specs

Ready-made specs for six scale points live in contrib/seedgen/:

SpecCAs~Certs~AccountsEst. runtime
tiny.toml1 (ec:P-256)10010< 30 s
small.toml2 (ec:P-256, rsa:2048)1 000502–5 min
small-pqc.toml2 (ec:P-256, ml-dsa-44)1 000502–5 min
medium.toml4 (ec:P-256/384, rsa:2048/4096)10 00010010–20 min
medium-pqc.toml4 (ec:P-256, rsa:2048, ml-dsa-44/65)10 00010010–20 min
large.toml8 (all classical)25 0001 00045–90 min
xlarge.toml16 (classical + PQC + hash variants)50 00010 0003–6 h
xxlarge.toml32 (all xlarge + long/short-lived variants)50 00010 0006–12 h

The *-pqc specs pair classical and post-quantum CAs with cross-signs in both directions, exercising hybrid trust chain building in the web UI.

Output layout

After a successful run the artifacts directory is structured so that akamu serve can be started directly from it:

<stem>/
  akamu.toml           ready-to-run config (HTTP, port 8080)
  ca-<id>/
    ca.key             PEM private key, mode 0640
    ca.crt             PEM certificate
  akamu.log            written by dev.sh (not by seedgen itself)
<stem>.sqlite3         database file

akamu.toml references the database with an absolute path so the server can be started from any working directory. CA key and certificate paths are also absolute.

The generated config also includes:

[admin]

[server]
http_validation_allow_private_ips = true
http_validation_port = 5002

The empty [admin] section enables the admin session store with all-default settings; without it the EAB web UI login cannot create sessions. The [server] settings allow new ACME orders to be issued against the seeded instance without additional config edits.

Dev workflow

contrib/seedgen/dev.sh starts both Akāmu and the Vite dev server in a single command:

./contrib/seedgen/dev.sh <artifacts-dir>

or equivalently from webui/:

npm run dev:seed -- <artifacts-dir>

The script:

  1. Validates that <artifacts-dir>/akamu.toml exists.
  2. Locates the akamu binary (target/debug/akamu, target/release/akamu, or builds it if absent; override with $AKAMU_BIN).
  3. Parses the listen_addr port from akamu.toml.
  4. Starts akamu akamu.toml from inside the artifacts directory so the relative database path resolves correctly.
  5. Polls GET /acme/directory until Akāmu accepts connections (timeout: 30 s; exits early with the log if the process crashes).
  6. Sets AKAMU_SERVER_URL=http://localhost:<port> and starts the Vite dev server at http://localhost:9000/ui/.
  7. On Ctrl-C or Vite exit, terminates the Akāmu process.

Akāmu stdout/stderr is written to <artifacts-dir>/akamu.log.

Environment variables:

VariableDefaultPurpose
AKAMU_BINauto-detectedPath to the akamu binary
AKAMU_LOGwarnRUST_LOG filter for the Akāmu process
VITE_PORT9000Port for the Vite dev server

The Vite proxy in vite.config.ts forwards /admin and /acme paths to AKAMU_SERVER_URL, so the browser sees a single origin with no CORS issues.

Admin credentials

Every run inserts one administrator operator (seedgen-admin) and one linked EAB key into the database, then prints them at the end of the summary:

Web UI login (EAB tab at /ui/login):
  Key ID (kid):         seedgen-admin
  HMAC key (base64url): <43-char base64url string>

The HMAC key is derived from the seeded RNG, so the same seed value in the spec always produces the same key. Paste kid and the HMAC key directly into the EAB tab on the login page; the UI computes the HMAC-SHA256 signature client-side.

The operator has role administrator and full access to every admin API endpoint. It is intended for local development only — never import a seeded database into a production instance.

Internal architecture

akamu-seedgen is a workspace crate at crates/akamu-seedgen/. Its modules map directly to implementation steps:

ModuleResponsibility
spec.rsDeserialises and validates the TOML spec
server.rsStarts the in-process Akāmu server; persists CA key/cert files
challenge.rsHTTP-01 challenge responder (axum on port 0, RwLock<HashMap>)
acme.rsThin wrappers over akamu-client: register account, issue cert
names.rsSeeded deterministic fake domain/org/contact names (ChaCha8Rng)
setup.rsRegisters profiles, issues cross-certs, creates the seeded admin operator + EAB key
scenarios.rsPer-scenario issuance loop; produces Vec<(IssuedCert, TargetState)>
postprocess.rsDirect sqlx mutations for non-ACME states; WAL checkpoint
config_writer.rsRenders and writes akamu.toml
summary.rsTallies counts; prints text or JSON summary
main.rsClap CLI; orchestrates all modules

Reproducibility

The ChaCha8Rng is seeded from global.seed at startup and threaded through names.rs, scenarios.rs, and setup.rs. All random choices (domain names, key type selection, revocation reasons, admin HMAC key) consume from this single RNG in deterministic order, so the same seed with the same spec always produces the same database and the same admin credentials.

The output database is opened directly as a file-backed SQLite pool at startup. All writes go straight to <output>.sqlite3; no in-memory copy is made. postprocess::run() finishes with PRAGMA wal_checkpoint(TRUNCATE) to merge any pending WAL frames into the main file before the process exits.

In-process server lifecycle

server::start() mirrors the pattern in benches/acme_bench.rs:

  1. Build one CaConfig per spec [[ca]] entry, pointing key/cert files to <artifacts-dir>/ca-<id>/.
  2. Call ca::init::load_or_generate() for each CA — generates key + self-signed cert on first run, loads existing files on subsequent runs.
  3. Assemble AppState with all CAs, the output SQLite pool, and MTC disabled.
  4. Bind a random TCP port; start axum::serve in a background task.
  5. Signal readiness via tokio::sync::oneshot before entering the accept loop.

Post-processing

After all ACME issuance completes, postprocess::run() applies state mutations directly to the database:

StateMechanism
revokeddb::certs::revoke() — sets status, revoked_at, revocation_reason
expiredUPDATE certificates SET not_before, not_after to 1–2 years ago
near_expiryUPDATE certificates SET not_after to 3–30 days from now
ARI chaindb::certs::mark_replaced() + UPDATE orders SET replaces
invalid_ordersUPDATE orders SET status='invalid', expires to the past

Finally, PRAGMA wal_checkpoint(TRUNCATE) is run so the database file is self-contained when the process exits.

Extending the tool

To add a new target state (e.g. on_hold revocation):

  1. Add the variant to TargetState in scenarios.rs.
  2. Assign the new state in scenarios::run_scenario() for the appropriate cert count.
  3. Add a matching arm in postprocess::run() that performs the database mutation.
  4. Add a counter to PostprocessStats and include it in summary::Summary.

Local CI

contrib/ci/local-ci.sh runs the same checks that a GitHub Actions workflow would run, in the same order, without needing a CI account or a push. Use it to catch failures before committing or to reproduce a CI failure locally.

Quick start

# Run the full pipeline
./contrib/ci/local-ci.sh all

# Run only formatting and lint checks
./contrib/ci/local-ci.sh fmt clippy

# List available jobs
./contrib/ci/local-ci.sh --list

Requirements

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 -- --check
clippycargo clippy -- -D warningsbuild
doccargo doc --no-deps + mdbook build docs/build
testcargo test --features test-utilsbuild
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 CI workflows

The project is hosted on Codeberg and uses Forgejo Actions (.github/workflows/ci.yml). Each workflow job runs on a self-hosted vdali runner inside the quay.io/hummingbird/rust:latest-builder container image. Required system packages (OpenSSL, SQLite, libldap, Kerberos, clang, etc.) are installed via dnf at the start of each job.

When running a single job from within a workflow step, pass --no-deps so the script does not re-run prerequisites that the workflow needs: graph has already enforced:

- name: Clippy
  run: ./contrib/ci/local-ci.sh --no-deps clippy

Without --no-deps, the script would automatically invoke build before clippy, duplicating work that another job already performed.

Example invocations

# Full pipeline
./contrib/ci/local-ci.sh all

# Only the fast checks (no compilation required)
./contrib/ci/local-ci.sh fmt lint-workflows

# Isolated build directory (keeps the main target/ clean)
CARGO_TARGET_DIR=/tmp/akamu-ci ./contrib/ci/local-ci.sh all

# CI-mode: run one job without triggering its prerequisites
./contrib/ci/local-ci.sh --no-deps test

# No colour output (e.g. when piping to a log file)
./contrib/ci/local-ci.sh --no-color all

Contributing

This chapter describes the conventions for code style, commit messages, and the pull request process for Akāmu.

Running CI locally

Before pushing, verify the pipeline locally with contrib/ci/local-ci.sh:

./contrib/ci/local-ci.sh all

This runs the same jobs the CI system runs: build, fmt, clippy, doc, test, bench (compile-only), and workflow linting. See Local CI for the full reference.

Code style

Formatting

All Rust code is formatted with rustfmt using the default configuration:

cargo fmt

Run this before committing, or let the fmt job catch it:

./contrib/ci/local-ci.sh fmt

Lints

Clippy is the linter. Address all warnings before submitting:

cargo clippy -- -D warnings

Or via the CI script:

./contrib/ci/local-ci.sh clippy

Documentation comments

Public types, functions, and modules should have doc comments (/// for items, //! for module-level). The standard format is:

#![allow(unused)]
fn main() {
/// Brief one-line summary.
///
/// Longer explanation if needed. Include:
/// - what the function does
/// - what each parameter means
/// - what the return value represents
/// - any notable edge cases or panics
pub fn my_function(arg: &str) -> Result<(), AcmeError> {
    // ...
}
}

No unwrap() in production code

Use ? or explicit error handling. Exceptions:

  • Test code may use unwrap() freely.
  • Truly infallible operations (e.g., serde_json::to_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 conventional commit format with a mandatory scope:

<type>(<scope>): <short summary (imperative, lowercase, no period)>

[optional body explaining why the change was made]

Signed-off-by: Your Name <email@example.com>

The scope identifies the subsystem or area affected (e.g. multi-ca, admin, routes, db, tls, developer, docs, cli). A Signed-off-by trailer is required on every commit (use git commit -s or git commit --signoff).

Types used in this repository:

TypeWhen to use
featA new feature visible to users or operators
fixA bug fix
docsDocumentation 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
styleFormatting-only changes (e.g. cargo fmt application)

Keep the summary line under 72 characters. Use the body to explain why the change was made, not what was changed (the diff explains what).

Examples from the repository:

docs(developer): update TLS chapter to match current implementation
fix(multi-ca): enforce ca_ra revocation scope, set pathLen=0 on cross-certs
feat(multi-ca): core multi-CA infrastructure, authz isolation, and ca_ra scoping
fix(security): prevent LIKE injection, STAR race, and header parse panics
test(admin): add admin auth and RBAC integration tests

Pull request process

  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, sqlx, or server-specific dependencies to akamu-jose or akamu-client — they must remain usable without a running server.