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

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