AAuth attestation

Attestation is how Neotoma cryptographically verifies that an AAuth-signing key is bound to a hardware root of trust before promoting a request to the hardware tier. This page specifies the JSON-native cnf.attestation envelope, the per-format verifiers, the verification cascade, and the operator configuration knobs that select which roots are trusted.

For the wider AAuth wire format and trust-tier definitions see the AAuth spec. For CLI-side attestation generation see CLI keys.

Verification cascade

Server-side tier resolution after the AAuth signature is verified:

  1. If the JWT carries cnf.attestation AND the verifier returns { verified: true }, resolve to hardware.
  2. Else, if the verified iss (or iss:sub composite) is in the operator allowlist (NEOTOMA_OPERATOR_ATTESTED_ISSUERS / NEOTOMA_OPERATOR_ATTESTED_SUBS), resolve to operator_attested.
  3. Else, resolve to software.

Verifier failures (chain invalid, key not bound, format unsupported) MUST fall through rather than rejecting the request, the underlying signature is still valid, the client just does not earn the higher tier. The decision.attestation diagnostic block records the verifier reason so operators can debug failed promotions.

cnf.attestation envelope

JSON-native (no CBOR). Lives inside the cnf claim of the aa-agent+jwt agent token alongside cnf.jwk. Discriminator-routed so the same envelope shape carries every supported format.

{
  "cnf": {
    "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
    "attestation": {
      "format": "apple-secure-enclave",
      "statement": {
        "attestation_chain": ["<base64url DER>", "<base64url DER>"],
        "signature": "<base64url over SHA-256(challenge || jkt)>"
      },
      "challenge": "<base64url SHA-256(jwt.iss || jwt.sub || jwt.iat)>"
    }
  }
}

Field rules

  • format (string, required), discriminator. One of apple-secure-enclave, webauthn-packed, tpm2. Unknown values fail with unsupported_format.
  • statement (object, required), opaque to the envelope; the per-format verifier defines the inner shape.
  • challenge (base64url, required), server recomputes from JWT claims (SHA-256(iss || sub || iat)) and compares against the per-format statement. Mismatch fails with challenge_mismatch.

Key-binding rule

The credential public key extracted from statement MUST match cnf.jwk by RFC 7638 thumbprint. Without this binding, an attacker who replayed a leaked attestation could ride a different signing key past the verifier. Mismatch fails with key_binding_failed.

Verifier responsibility ordering inside one format dispatch:

  1. Parse statement, extract credential public key.
  2. Compare its RFC 7638 thumbprint to the JWT's cnf.jwk thumbprint. Mismatch → key_binding_failed.
  3. Recompute challenge from JWT claims and compare to the value in the statement. Mismatch → challenge_mismatch.
  4. Verify the format-specific cryptographic chain / quote.

Supported formats

After v0.12.0 every attestation format is fully verified end-to-end and participates in revocation under the policy described below.

  • apple-secure-enclave , verifier ships in v0.8.0; CLI sources via aauth-mac-se on darwin; trust root bundled at config/aauth/apple_attestation_root.pem; revocation via Apple's anonymous-attestation revocation endpoint.
  • webauthn-packed , verifier ships in v0.9.0; CLI sources via aauth-yubikey (cross-platform, v0.10.x); trust = AAGUID allowlist plus operator CA bundle; revocation via OCSP from the leaf's AIA, CRL fallback via the leaf's CDP.
  • tpm2 , verifier ships in v0.9.0; CLI sources via aauth-tpm2 (linux) and aauth-win-tbs (win32) from v0.10.0; trust roots bundled in config/aauth/tpm_attestation_roots/; revocation via OCSP from the AIK leaf's AIA, CRL fallback via the AIK leaf's CDP.

apple-secure-enclave

Implemented by src/services/aauth_attestation_apple_se.ts. Statement carries:

  • attestation_chain, base64url-encoded DER X.509 certificates, leaf first, terminating at an Apple-rooted intermediate.
  • signature, base64url-encoded ECDSA-P256 signature over SHA-256(challenge || jkt) where jkt is the RFC 7638 thumbprint of the credential public key.

After the shared key-binding and challenge checks, the verifier:

  1. Decodes the chain. Rejects if the leaf does not declare an EC P-256 public key.
  2. Walks the chain with node:crypto.X509Certificate#verify(issuerKey). Rejects (chain_invalid) on any signature failure or if the chain terminates outside the merged trust set.
  3. Verifies the leaf's ECDSA signature over SHA-256(challenge || jkt). Rejects (signature_invalid) on mismatch.

webauthn-packed

Implemented in src/services/aauth_attestation_webauthn_packed.ts. Statement layout mirrors W3C WebAuthn §8.2:

{
  "alg": -7,
  "sig": "<base64url DER signature>",
  "x5c": ["<base64url leaf>", "<base64url intermediates...>"]
}

Verifier flow:

  1. Parse and validate the statement. malformed on missing fields; unsupported_format for ECDAA-only attestations (no x5c) or COSE algs outside the v0.9.0 admission set.
  2. Resolve COSE alg to a Node crypto primitive. Supported values: -7 (ES256), -35 (ES384), -36 (ES512), -8 (EdDSA), -257 (RS256), -258 (RS384), -259 (RS512), -37 (PS256).
  3. Walk the leaf → intermediates chain against the merged trust roots from aauth_attestation_trust_config.ts. Untrusted chains return chain_invalid.
  4. Extract the FIDO id-fido-gen-ce-aaguid extension (1.3.6.1.4.1.45724.1.1.4) using a hand-written DER parser. When the operator allowlist (NEOTOMA_AAUTH_AAGUID_TRUST_LIST_PATH) is non-empty, the parsed AAGUID MUST match an entry; mismatches return aaguid_not_trusted. When empty, AAGUID is logged but not gated, so operators can ramp gradually.
  5. Bind the leaf's public key to the JWT-bound cnf.jwk via RFC 7638 thumbprint. Mismatches return key_binding_failed.
  6. Verify the statement signature over the bound challenge using the resolved primitive. Mismatches return signature_invalid.

Successful runs return verified: true with aaguid and key_model populated, plus the human-readable trust-chain summary.

tpm2

Verifies WebAuthn tpm attestation statements (TPM 2.0 quotes + AIK chains). Implementation in src/services/aauth_attestation_tpm2.ts; uses the in-repo big-endian length-prefixed parser at src/services/aauth_tpm_structures.ts (no external TPM library, so the parsing surface remains auditable in TypeScript).

{
  "ver": "2.0",
  "alg": -7,
  "x5c": ["…AIK leaf…", "…intermediates…", "…root…"],
  "sig": "…raw AIK signature over certInfo bytes…",
  "certInfo": "…raw TPMS_ATTEST bytes…",
  "pubArea": "…raw TPMT_PUBLIC bytes describing the bound key…"
}

Pipeline:

  1. Parse the statement. Missing or non-string fields → malformed. Unsupported verunsupported_format.
  2. Resolve the COSE alg to a Node crypto digest + primitive. Unsupported algs return signature_invalid rather than throwing so the cascade keeps moving.
  3. Decode the x5c chain into X509Certificate objects. Decode failures or missing certs → chain_invalid.
  4. Parse pubArea as TPMT_PUBLIC and lift it into a Node KeyObject (RSA (n, e) and ECC P-256/P-384/P-521 supported). Truncated or unsupported key types → malformed.
  5. RFC 7638 thumbprint of the lifted public key MUST equal ctx.boundJkt. Mismatches return key_binding_failed.
  6. Walk the AIK chain against the merged trust set. Roots come from config/aauth/tpm_attestation_roots/ plus operator PEMs from NEOTOMA_AAUTH_ATTESTATION_CA_PATH. Untrusted chains return chain_invalid.
  7. Verify sig over the raw certInfo bytes using the AIK leaf's public key. Mismatches return signature_invalid.
  8. Parse certInfo as TPMS_ATTEST. Magic MUST be TPM_GENERATED_VALUE (0xff544347) and type MUST be TPM_ST_ATTEST_QUOTE (0x8018) or TPM_ST_ATTEST_CERTIFY (0x8017); other shapes → malformed.
  9. extraData MUST equal SHA-256(challenge || jkt). Mismatches return challenge_mismatch.
  10. For TPM_ST_ATTEST_CERTIFY only, the certified attested.name MUST equal nameAlg || digest(pubArea). Mismatches return pubarea_mismatch, this catches CLI sources that quote a different key than the one they signed.

Revocation

Implemented in src/services/aauth_attestation_revocation.ts. After every successful chain validation the per-format verifier consults the shared revocation service and folds the result back into the AttestationOutcome via applyRevocationPolicy().

Channels

  • apple-secure-enclave, Apple anonymous-attestation revocation endpoint (https://data.appattest.apple.com/v1/revoked-list, override via NEOTOMA_AAUTH_APPLE_REVOCATION_URL). POST { "serial_numbers": [...] }; returns { "revoked": [...] }. Cache keyed by SHA-256(leaf DER).
  • webauthn-packed, OCSP via the leaf's AIA, CRL fallback via the leaf's CDP. Standard X.509 path used by YubiKey and external authenticators.
  • tpm2, OCSP via the AIK leaf's AIA, CRL fallback via the AIK leaf's CDP. Vendor AIK chains (Infineon, STMicro, Intel, AMD, Microsoft) consistently advertise OCSP responders.

Lookups short-circuit when NEOTOMA_AAUTH_REVOCATION_MODE is disabled , the verifier never opens a network socket and the diagnostic stays absent. In log_only and enforce modes the service runs and the diagnostic rides on the outcome regardless of verification result.

Policy

  • disabled , diagnostic omitted; outcome forwarded as-is.
  • log_only , diagnostic attached (checked: true, mode: "log_only", demoted: false) but never demotes the tier. Operator-audit window before the v0.12.0 flip.
  • enforce , a revoked status (and unknown when NEOTOMA_AAUTH_REVOCATION_FAIL_OPEN=0) demotes a previously-verified outcome to { verified: false, reason: "revoked"... }. The cascade then falls through to the operator allowlist or software tier exactly as it does for any other verified: false reason.

Caching

In-memory LRU keyed by SHA-256(leaf DER fingerprint || channel). TTL via NEOTOMA_AAUTH_REVOCATION_CACHE_TTL_SECONDS (default 3600). Cache hits surface as source: "cache". Process-local; restarts re-validate every chain through the upstream channel before re-populating.

Diagnostics

interface AttestationRevocationDiagnostic {
  checked: boolean;             // false when mode === "disabled"
  status?: "good" | "revoked" | "unknown";
  source?: "disabled" | "cache" | "apple" | "ocsp" | "crl" | "no_endpoint" | "error";
  detail?: string;              // free-form responder/network detail
  mode?: "disabled" | "log_only" | "enforce";
  demoted?: boolean;            // true iff enforce mode demoted hardware → software
}

Migration plan

  • v0.10.x, default disabled. No network calls, no diagnostic. Pre-FU-7 behaviour.
  • v0.11.0, default log_only. Network calls run, diagnostic surfaces, tiers unchanged. Operators audit before the flip.
  • v0.12.0, default enforce. Revoked / fail-closed-unknown demote hardware to software. Operators who need to fall back can pin NEOTOMA_AAUTH_REVOCATION_MODE=log_only.

Trust configuration

Loaded by src/services/aauth_attestation_trust_config.ts. Always includes the bundled Apple Attestation Root; operator inputs are additive. The trust loader is fail-open: missing or unreadable operator inputs log a single warning and continue with the bundled root.

  • NEOTOMA_AAUTH_ATTESTATION_CA_PATH , absolute path to a PEM file or directory of PEM files. Adds operator-managed CAs to the merged trust set used for chain validation.
  • config/aauth/tpm_attestation_roots/ , bundled TPM 2.0 AIK root CAs (.pem / .crt, recursive). Always merged into the trust set for the tpm2 verifier. Vendor sub-directories document provenance per README.md.
  • NEOTOMA_AAUTH_AAGUID_TRUST_LIST_PATH , absolute path to a JSON file containing an array of WebAuthn AAGUIDs (RFC 4122 lower-case hyphenated). Restricts which authenticator AAGUIDs the webauthn-packed verifier admits. Empty/missing file = no AAGUID gating.
  • NEOTOMA_OPERATOR_ATTESTED_ISSUERS , CSV of iss values. Promotes verified AAuth signatures whose iss matches to operator_attested.
  • NEOTOMA_OPERATOR_ATTESTED_SUBS , CSV of iss:sub composite values. Same as above but pinned to a specific (iss, sub) pair.

config/aauth/apple_attestation_root.pem ships in-repo. The first lines of the file MUST be a comment referencing the Apple documentation page the cert was sourced from and the SHA-256 fingerprint, so operators can audit the bundled material at a glance.

Diagnostics surface

The verifier returns a structured outcome:

type AttestationOutcome =
  | { verified: true; format: AttestationFormat; revocation?: AttestationRevocationDiagnostic }
  | { verified: false; format: AttestationFormat | "unknown"; reason: AttestationFailureReason; revocation?: AttestationRevocationDiagnostic };

type AttestationFailureReason =
  | "not_present"
  | "unsupported_format"
  | "key_binding_failed"
  | "challenge_mismatch"
  | "chain_invalid"
  | "signature_invalid"
  | "aaguid_not_trusted"
  | "pubarea_mismatch"
  | "not_implemented"
  | "malformed"
  | "revoked";

The middleware mirrors this onto AttributionDecisionDiagnostics.attestation so operators can see exactly why a promotion failed without log-spelunking. not_present means the JWT did not carry cnf.attestation at all (the cascade then evaluates the operator allowlist). revoked is populated by the revocation service in enforce mode. AttributionDecisionDiagnostics.attestation.revocation carries the underlying status, channel, mode, and demoted flag.

Back to AAuth overview. See also AAuth spec, CLI keys, integration.