Account Management Internals
This chapter describes the internal implementation of ACME account creation, key rollover, and the SPKI cache.
Database representation
Accounts are stored in the accounts table (defined in src/db/schema.rs and migration 0001_initial, extended in migrations 0007_profile_grants and 0012_multi_ca):
CREATE TABLE accounts (
id TEXT PRIMARY KEY, -- UUID v4
status TEXT NOT NULL DEFAULT 'valid',
contact TEXT, -- JSON array e.g. ["mailto:a@b.com"]
public_key BLOB NOT NULL, -- DER-encoded SubjectPublicKeyInfo
jwk_thumbprint TEXT NOT NULL UNIQUE, -- base64url SHA-256 JWK thumbprint (RFC 7638)
created INTEGER NOT NULL,
updated INTEGER NOT NULL,
profile_grants TEXT, -- NULL = unrestricted; JSON array of profile IDs
ca_id TEXT NOT NULL DEFAULT '' -- empty = server-wide scope (any CA)
);
Two fields carry cryptographic identity:
public_key— the raw DER-encodedSubjectPublicKeyInfoextracted from the outer JWSjwkat account creation time. Stored once; used to verify subsequent signed requests via a cache-aside pattern.jwk_thumbprint— the RFC 7638 SHA-256 thumbprint of the public JWK, base64url-encoded. Carries aUNIQUEconstraint so the database enforces that no two accounts share the same key. This is the lookup key for “does an account already exist for this key?” checks atnew-accounttime.
profile_grants stores a JSON array of profile ID strings (e.g. '["tls-server","mtc-tls"]'), or NULL when no restriction is in force. The NULL state is distinct from an empty array: NULL means “no restriction” while an empty array would grant access to no profiles. When a profile has require_account_grant = true, the finalize handler checks this column via db::accounts::get_profile_grants.
ca_id records the CA scope of the account. An empty string ('') means the account is server-wide and may place orders against any CA. A non-empty value restricts the account to a specific CA; this is set when the server is configured with server.account_scope = "ca". The column was added in migration 0012_multi_ca with DEFAULT '' so all pre-migration accounts are treated as server-wide.
Account creation flow (src/routes/account.rs)
routes::account::new_account handles POST /acme/new-account:
parse_jwsverifies the outer JWS and extracts theJwsKeyRef::Jwk { jwk }.jwk.thumbprint()computes the RFC 7638 thumbprint.db::accounts::get_by_thumbprint(&state.db, &thumbprint)checks for an existing account. If found, returns HTTP 200 with the existing account (idempotent creation).contactsare validated — any URI scheme is accepted (any string containing:); bare strings without a scheme separator are rejected per RFC 8555 §7.1.2. Note: this step happens before EAB validation in the code.- If
external_account_requiredis set, the EAB JWS is validated (see EAB Internals). - A new UUID account ID is generated.
- Account insertion and EAB key consumption happen atomically in a single
db::begin_writetransaction:
#![allow(unused)]
fn main() {
let mut tx = db::begin_write(&state.db, state.db_kind).await?;
db::accounts::insert(&mut *tx, AccountRow { … }).await?;
if let Some((eab_kid, _)) = verified_eab {
db::eab::mark_used(&mut *tx, &eab_kid, now).await?;
}
tx.commit().await.map_err(AcmeError::from)?;
}
SPKI cache (AppState::spki_cache)
Every authenticated POST endpoint (other than new-account) must look up the account’s public key to verify the JWS signature. Fetching public_key from the database on every request would add a read round-trip to every ACME operation.
AppState.spki_cache is an Arc<RwLock<HashMap<String, CachedAccount>>> that caches account key material keyed by account ID. CachedAccount is defined in src/state.rs and holds three fields:
#![allow(unused)]
fn main() {
pub struct CachedAccount {
pub spki_der: Vec<u8>, // DER-encoded SubjectPublicKeyInfo
pub jwk_thumbprint: String, // RFC 7638 JWK thumbprint (base64url SHA-256)
pub status: String, // "valid", "deactivated", or "revoked"
}
}
After the first authenticated request for an account, the full CachedAccount is stored here. Subsequent requests hit the in-memory cache instead of the database — and routes that need the JWK thumbprint (e.g., key-change audit events) avoid a second get_by_id call. The cache also enables fast status checks: if cached_account.status != "valid", the request is rejected immediately without a DB round-trip.
Cache eviction occurs in two places and uses a poison-guard pattern to avoid panicking on a poisoned RwLock:
-
Deactivation (
update_account):#![allow(unused)] fn main() { match state.spki_cache.write() { Ok(mut cache) => { cache.remove(&id); } Err(e) => { e.into_inner().remove(&id); } } }This removes the entry immediately after marking the account
deactivated, so subsequent requests with the deactivated account’s key are rejected at the database layer (wherestatus='valid'is required for updates) rather than served from a stale cache entry. -
Key rollover (
key_change): the same poison-guard removal is applied afterdb::accounts::update_keysucceeds, so the next request with the new key re-loads theCachedAccountfrom the database rather than finding the old SPKI bytes.
The cache is not bounded in size because the number of accounts is expected to be small relative to available memory. A future improvement could add LRU eviction.
Key rollover flow (src/routes/key_change.rs)
routes::key_change::key_change handles POST /acme/key-change per RFC 8555 §7.3.5. The outer JWS is signed with the old key (resolved via kid); the payload is itself an inner JWS signed with the new key. The steps are:
- Verify the outer JWS with
parse_jws— uses the old key from the SPKI cache or database. - Parse the payload as a
JwsFlattenedinner JWS. - Extract the new
JwkPublicfrom the inner JWS header (JwsKeyRef::Jwk). - Convert the new JWK to SPKI DER:
new_jwk.to_spki_der(). - Compute the new thumbprint:
new_jwk.thumbprint(). - Verify the inner JWS signature over the new SPKI DER.
- Decode the inner payload:
{ "account": "<account_url>", "oldKey": <old_jwk> }. - Check
inner_payload.account == expected_account_url. - Convert
inner_payload.old_keyto SPKI DER and compare withctx.spki_der(the outer JWS’s key). This is the RFC-mandated proof that the requester controls the old key. - Check that the new thumbprint is not already in use by another account:
db::accounts::get_by_thumbprint(&state.db, &new_thumbprint). - Call
db::accounts::update_key(&state.db, &account_id, new_spki, new_thumbprint, now). - Evict the old entry from the cache using the poison-guard pattern (see the SPKI cache section above).
- Emit an
AccountKeyChange("account.key-change") audit event with:subject: the account ID.principal:"acme:<old_thumbprint>"(the thumbprint of the key that was replaced).detail:"new_key=<new_thumbprint>"(the thumbprint of the replacement key).
db::accounts::update_key updates both public_key (the DER BLOB) and jwk_thumbprint (the unique TEXT) atomically in a single SQL UPDATE:
UPDATE accounts SET public_key = ?, jwk_thumbprint = ?, updated = ?
WHERE id = ? AND status = 'valid'
The AND status = 'valid' guard ensures that a deactivated account’s key cannot be rotated.
Database module (src/db/accounts.rs)
The account DB module exposes:
| Function | SQL |
|---|---|
insert(executor, row) | INSERT INTO accounts … |
get_by_id(executor, id) | SELECT … FROM accounts WHERE id = ? |
get_by_thumbprint(executor, thumbprint) | SELECT … FROM accounts WHERE jwk_thumbprint = ? |
update_contact(executor, id, contact, now) | UPDATE accounts SET contact = ? … WHERE id = ? AND status = 'valid' |
update_status(executor, id, status, now) | UPDATE accounts SET status = ? … |
update_key(executor, id, public_key, jwk_thumbprint, now) | UPDATE accounts SET public_key = ?, jwk_thumbprint = ? … WHERE id = ? AND status = 'valid' |
set_profile_grants(executor, id, grants, now) | UPDATE accounts SET profile_grants = ? … WHERE id = ? AND status = 'valid' |
get_profile_grants(executor, id) | SELECT profile_grants FROM accounts WHERE id = ? |
list(executor, status, ca_id, limit, offset) | SELECT … FROM accounts WHERE 1=1 [AND status = ?] [AND (ca_id = ? OR id IN (SELECT …))] ORDER BY created DESC LIMIT ? OFFSET ? |
get_profile_grants returns a nested Option:
Ok(None)— account not found.Ok(Some(None))— account exists,profile_grants IS NULL(no restriction).Ok(Some(Some(json)))— account exists and has a JSON grant array.
All functions accept impl sqlx::Executor<'_, Database = sqlx::Any>, which allows them to be called with either a pool reference (&Db) or a mutable transaction reference (&mut *tx). This is the standard sqlx pattern for composing queries into transactions without changing the function signatures.
Profile authorization (src/profiles/auth.rs)
At finalize time, after the profile parameters are resolved from ProfileRegistry::resolve, the finalize handler calls crate::profiles::auth::check_profile_auth when the order carries a profile name. The function applies three checks in sequence; the first failure short-circuits with an AcmeError::Unauthorized or AcmeError::InvalidProfile:
#![allow(unused)]
fn main() {
pub async fn check_profile_auth(
db: &db::Db,
account_id: &str,
profile_name: &str,
params: &CertificateParameters,
identifiers: &[(&str, &str)],
) -> Result<(), AcmeError>
}
Check 1 — Identifier patterns (check_identifier_patterns):
If params.allowed_identifier_patterns is non-empty, each identifier is formatted as "type:value" and matched against the compiled regex list. params.identifier_match_all controls whether every identifier must match (true) or just one (false). An invalid regex returns AcmeError::InvalidProfile.
Check 2 — External hook (check_auth_hook):
If params.auth_hook is Some(path), the handler spawns the executable at path, writes the following JSON to its stdin, and waits for it to exit within params.auth_hook_timeout_secs seconds (default: 30):
{
"account_id": "<uuid>",
"profile": "<name>",
"identifiers": [{"type": "dns", "value": "example.com"}]
}
Exit 0 = permit. Non-zero = deny; the hook’s trimmed stdout is used as the denial detail. A timeout returns AcmeError::Unauthorized. Spawn failures return AcmeError::Internal.
Check 3 — Account grant (check_account_grant):
If params.require_account_grant is true, db::accounts::get_profile_grants is called. The three-way return value maps as follows:
get_profile_grants return | Authorization result |
|---|---|
Ok(None) | Account not found → Unauthorized |
Ok(Some(None)) | Account exists, profile_grants IS NULL → Unauthorized |
Ok(Some(Some(json))) | JSON parsed as Vec<String>; Unauthorized unless profile_name is in the list |