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

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.