Customer Authentication control mapping¶
Status: Current | Last reviewed: 2026-08-05
This document maps the Customer Authentication capability's implemented controls to three frameworks: NIST SP 800-63B (digital identity, authenticator assurance levels), the FFIEC guidance on Authentication and Access to Financial Institution Services and Systems (2021), and the HIPAA Security Rule (45 CFR §164.312). It is written for an auditor or a customer security review: every claim names the code that enforces it and the test that proves it. Technical background is in customer-authentication.md.
Scope: verification of a tenant's customer during a live conversation with an AI agent. Platform-user authentication (SSO/Authentik) is out of scope here.
Assurance model¶
Every method is tagged with the assurance it actually earns, and the achieved level is recomputed from evidence rows on every read — never trusted from mutable state. The composition rules live in assurance.py and are exercised by test_customer_auth_assurance.py.
| Method | Tagged assurance | Rationale |
|---|---|---|
| Passkey (WebAuthn, user verification required) | aal2_pr |
Multi-factor cryptographic authenticator, phishing-resistant (800-63B §5.1.9, §4.2.1) |
| OTP to SMS | aal1, flagged restricted_channel |
Out-of-band via the PSTN, a restricted channel (800-63B §5.1.3.3) |
| OTP to email | aal1 |
Possession of the mailbox; not an out-of-band device per 800-63B |
| Tool-based KBA | not_a_factor |
Knowledge sourced from records; FFIEC treats KBA as insufficient alone |
| External OIDC | aal1 floor; provider acr/amr may raise, never lower |
The IdP's claim is honoured only upward |
Methods compose by factor category (possession_crypto, possession_channel, knowledge_sor, federated): two OTPs are one factor; OTP + passing KBA (where the tenant marks KBA counts_as_factor) composes to aal2.
NIST SP 800-63B¶
| Requirement | Control | Where enforced | Evidence |
|---|---|---|---|
| §5.1.4.2 / §5.1.9 — verifier stores no recoverable secret | OTP codes stored only as HMAC-SHA256 over (challenge_id, code) with an env-only pepper (CUSTOMER_AUTH_OTP_PEPPER); plaintext never stored or logged |
otp.py code_hash |
test_customer_auth_otp.py::test_the_same_code_hashes_differently_per_challenge, ...::test_issuing_without_a_pepper_is_fatal (an unkeyed digest of a six-digit space is reversible, so a missing pepper refuses issuance rather than degrading) |
| §5.1.4.2 — codes generated with an approved RNG | secrets-based CSPRNG, numeric, zero-padded, length clamped ≥6 |
otp.py generate_code |
test_customer_auth_otp.py generation tests |
| §5.1.4.2 — out-of-band secret validity ≤10 min | Authored TTL clamped at the 600 s ceiling at runtime, not just at authoring | config.py parse_node_policy |
test_customer_auth_otp.py clamp tests |
| §5.2.2 — throttling: ≤100 consecutive failures | Per-challenge attempt cap (atomic SQL increment, no read-modify-write race) plus cross-conversation per-target Redis lockout (FAIL_LIMIT=5 / 15 min window / 15 min lock) |
store.py record_failed_attempt; lockout.py |
test_customer_auth_lockout.py::test_the_failure_limit_sits_under_the_800_63b_ceiling, test_customer_auth_passkey_service.py::test_burning_every_attempt_fails_the_challenge |
| §5.2.2 — throttling must not fail open | Issuance fails closed on a lockout-store outage (503, never unthrottled); verification fails open deliberately because the SQL cap still bounds it | lockout.py LockoutUnavailable |
test_customer_auth_lockout.py::test_checking_fails_closed_when_redis_is_down, ...::test_verifying_fails_open_when_redis_is_down |
| §5.1.3.3 — restricted channel (SMS) disclosed | restricted_channel_only computed per grant and stamped into state + audit detail; ai-manager warns at publish when SMS is the sole AAL2 route |
assurance.restricted_channel_only; validate-node-capabilities.ts |
assurance + publish-validation tests |
| §7.1 / replay resistance — challenges are single-use | mark_challenge_verified is a conditional pending → verified UPDATE; a replayed assertion/attestation finds nothing to claim; new OTP supersedes pending |
store.py |
test_customer_auth_passkey_service.py::test_a_claimed_challenge_cannot_be_verified_twice, ...::test_a_replayed_attestation_finds_nothing_to_claim |
| §5.1.9 — AAL2 passkey requires user verification | required_assurance ∈ {aal2, aal2_pr} forces require_user_verification; a UV-less assertion is refused even if the library flag disagreed (defence in depth) |
service.py _require_user_verification |
test_customer_auth_passkey_service.py::test_uv_missing_at_aal2_is_refused_even_if_the_library_let_it_through |
| §5.1.9 — verifier compromise / clone detection | Positive sign_count held to strict monotonicity via conditional UPDATE; regression refused and audited (sign_count_regression); zero counters (platform passkeys) exempt per spec |
service.py verify_passkey |
...::test_a_positive_sign_count_that_does_not_advance_is_refused, ...::test_a_zero_sign_count_is_allowed_and_only_touches |
| §4.2.1 — verifier origin binding | RP ID = surface-app hostname; override accepted only as a registrable suffix; expected origins are exactly the configured origin (loopback forms only when the origin is itself local) | webauthn_ops.py | test_customer_auth_webauthn.py RP/origin tests |
FFIEC (Authentication and Access, 2021)¶
| Expectation | Control | Where enforced |
|---|---|---|
| Layered authentication; MFA for higher-risk access | required_assurance per agent; aal2 satisfiable only by passkey or by two composed factor categories |
assurance.compose + satisfies; publish validation errors on an aal2 config with no two-factor path |
| KBA is not sufficient alone | KBA maps to not_a_factor; a lone passing KBA leaves the conversation unauthenticated with a factor_recorded audit event — the rule is control flow, not documentation |
service.py _grant; test_customer_auth_assurance.py |
| The authentication decision cannot be made by the customer-facing channel | The LLM has no tool that asserts verification; KBA outcomes are read from the tool's own result frame by a server-side observer and reported over an authenticated channel; the observer accepts only a strict boolean attestation | customer_auth_tool_handler.py; tests/test_customer_auth_kba.py::test_no_tool_lets_the_model_assert_verification, ...::test_anything_that_is_not_a_clear_pass_counts_as_a_failure |
| Call-back / caller-ID risk acknowledged | ANI-defaulted OTP delivery is documented as possession-of-calling-number (spoofable) and earns aal1 only; reaching aal2 requires composition |
_resolve_target docstring; assurance table above |
| Monitoring and logging of authentication events | Append-only customer_auth_events (see audit section); lockout enforcement, expiry, failures, grants each write an event |
store.record_event call sites throughout service.py |
| Enumeration resistance | Unknown credential, cross-tenant credential, and wrong-subject all return an identical vague failure; the distinguishing reason exists only in the audit row | service.py _fail_passkey_attempt; test_customer_auth_passkey_service.py::test_the_three_vague_failures_are_indistinguishable_to_the_caller |
HIPAA Security Rule (45 CFR §164.312)¶
| Standard | Control |
|---|---|
| §164.312(a)(1) Access control — unique user identification | Grants bind to a customer_identities row (customer_id + tenant-scoped external_ref); a passkey subject mismatch (link sent to A, B's key asserted) is refused |
| §164.312(b) Audit controls | customer_auth_events records who/what/when/method/result/assurance/IP/UA for every transition. INSERT-only grants plus a DB trigger rejecting UPDATE/DELETE (027_customer_auth.sql). Audit detail carries masked targets only — never a code, an assertion, a token, or the caller's KBA answers (tests/test_customer_auth_kba.py::test_the_audit_detail_carries_no_caller_answers). A failed audit write logs the full event at ERROR as the compensating control rather than denying a decided authentication |
| §164.312(d) Person or entity authentication | The capability itself; assurance level recorded per grant so the tenant can require aal2 for PHI-adjacent agents |
| §164.312(a)(2)(iv) / (e) Encryption | OIDC client secrets AES-256-GCM under MASTER_ENCRYPTION_KEY in tenant_settings; OTP pepper env-only; WebAuthn public keys are not secrets; transport is TLS end to end |
| §164.312(c) Integrity | State of record recomputed from challenge rows on every read; a tampered session_variables blob cannot claim a grant (service.load_state) |
Cross-cutting properties¶
- Session binding. The browser never holds a conversation id: the passkey link resolves through a share code, and surface-app's per-code server routes proxy to conversation-api, which re-checks tenant and conversation against the challenge row. A share code resolving to another tenant's challenge is refused and logged.
- Tenant isolation. All four tables are RLS'd;
customer_webauthn_credentials.credential_idis globally unique by necessity (a discoverable assertion carries only the credential id), so the service re-checks the resolved credential's tenant against the challenge tenant — the one place cross-tenant use can be caught (test_customer_auth_passkey_service.py::test_a_cross_tenant_credential_is_refused_before_the_signature_is_checked). - Lockout privacy. Redis lockout keys are peppered fingerprints — a keyspace dump does not enumerate who has been challenged (
test_customer_auth_lockout.py::test_keys_do_not_contain_the_target). - Enrollment is not authentication. Passkey enrollment is offered only to an already-verified conversation (verified possession of the on-file contact during the call is the identity proofing) and never itself grants.
- Retention.
cleanup_expired_auth_challenges()clears stale challenge rows; audit events default to 2-year retention (operational policy; the table carriescreated_atfor retention jobs).
Known limitations (disclosed by design)¶
| Limitation | Posture |
|---|---|
| SMS OTP over the PSTN (SIM swap, SS7) | Tagged restricted channel per 800-63B; publish warning when it is the sole AAL2 route; passkey is the recommended AAL2 method |
| ANI spoofing | ANI-defaulted delivery earns AAL1 only; never sufficient for AAL2 alone |
| Shared RP ID across tenants | Passkeys are scoped to the platform surface-app domain; per-tenant RP domains are a planned hardening (Phase 5) |
| Customer phone/email stored plaintext under RLS | Needed for deterministic ANI lookup in v1; column-level HKDF-per-tenant encryption is the documented Phase-5 hardening |
| Share codes are multi-resolve until claimed | Mitigated by short TTL, single-use challenge claim, and WebAuthn itself as the real gate — the code opens the ceremony, it does not pass it |