Files
acdl/docs/threat-model.md
T
Jon Chery 04053df16e docs(P05): identity-layer threat model (REQ-347, C-6.2, C-9.2, security-engineer)
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: security-engineer
---
Add docs/threat-model.md covering 8 threats + mitigations: (T-1) password
compromise → Argon2id + fail-closed (D-228) + no raw passwords (INV-16);
(T-2) PAT theft → credentials.json stores OIDC token not raw PAT (C-7.3)
+ max TTL ≤24h dev/≤1h service-account (C-6.2) + strong-read revocation
(D-229); (T-3) JWKS DDoS → reserved concurrency 10 + 1h client cache +
optional CloudFront/WAF (C-6.2); (T-4) ABAC bypass → fail-closed
(C-6.1/C-7.1, INV-17, 7 tests); (T-5) KMS key compromise → key policy
restricts kms:Sign + 90-day rotation; (T-6) DER→raw ECDSA gotcha →
cryptography decode_dss_signature + CAP-037 round-trip test; (T-7) no
AWS-managed identity (INV-15); (T-8) audit trail integrity (INV-12 +
policy_sha D-231). Includes the C-9.2 INV-18..21 compression audit:
the spec's 4 attestation concerns (immutability, signature
verifiability, key derivation, no-managed-identity) are fully captured
by INV-6 + INV-15 + INV-16 + INV-17 + REQ-332 — no semantic gap.
2026-08-19 23:22:00 +00:00

22 KiB

Nova Identity Layer — Threat Model

REQ-347 — identity-layer threat model. Covers the 8 threats enumerated below + the C-9.2 INV-18..21 compression audit. The C-6.2 grill additions (JWKS DDoS, PAT max TTL, ABAC fail-closed) are integrated into the threat list, not appended.

Scope: the Nova-idp identity layer (nova-idp-auth + nova-idp-token-vend + nova-idp-jwks Lambdas, the KMS signing key, the 4 DynamoDB tables, the nova auth CLI, the PAT lifecycle). Out of scope: the downstream contract resolver, Terraform adapter, and consumer-side auth (those have their own threat models).

1. Assets

Asset Where Sensitivity
User passwords nova-users.password_hash (Argon2id) high — hash only; raw never stored
PATs (personal access tokens) nova-pats (hash only) + returned to caller once high — bearer token, ≤24h/≤1h TTL
OIDC tokens ~/.nova/credentials.json (0600) + in-flight to clients medium — short-lived (15 min default)
KMS signing key KMS alias/nova-oidc-signing (ECC_NIST_P256) high — the trust anchor for all OIDC tokens
ABAC policy platform/abac/token-vend.policy (git-tracked) high — the authorization rules
DynamoDB tables nova-users, nova-sessions, nova-password-resets, nova-pats high — the identity store
JWKS endpoint nova-idp-jwks function URL (AuthType: NONE) medium — public, must be available but is not secret
Audit stream stderr JSON from each Lambda + the CLI high — tamper-evidence for the whole layer

2. Trust boundaries

┌────────────────┐     IAM-auth function URL    ┌────────────────────┐
│  Developer CI  │ ───────────────────────────► │ nova-idp-auth      │
│  (nova CLI)    │                              │ nova-idp-token-vend│
│                │ ◄────── OIDC token ─────────  │ (KMS sign)         │
└────────┬───────┘                              └─────────┬──────────┘
         │                                                │
         │  ~/.nova/credentials.json (0600)               │ strong-read GetItem
         │  NOT the raw PAT                               ▼
         │                                     ┌────────────────────┐
         │                                     │ nova-pats (DDB)    │
         │                                     │ nova-users/sessions│
         │ JWKS fetch (unauthenticated)        └────────────────────┘
         │ ──────────────────────────────────► ┌────────────────────┐
         │                                     │ nova-idp-jwks      │
         │ ◄──── public key (JWK) ────────────  │ (AuthType: NONE)   │
         ▼                                     └────────────────────┘
┌────────────────┐
│ AWS KMS         │  kms:Sign (token-vend role only)
│ alias/nova-     │  kms:GetPublicKey (jwks role)
│ oidc-signing    │
└────────────────┘

The key boundary crossings:

  1. Internet → JWKS Lambda (unauthenticated function URL) — the DDoS surface (Threat T-4).
  2. CLI → auth/token-vend Lambdas (IAM-authenticated function URLs) — the credential-injection surface.
  3. token-vend Lambda → KMS (kms:Sign) — the key-use surface.
  4. token-vend Lambda → DDB (strong read on nova-pats) — the revocation surface.

3. Threats + mitigations

T-1 — Password compromise (storage)

Threat: an attacker with read access to nova-users (DDB export, backup, a leaked snapshot) recovers plaintext passwords.

Mitigations:

  • Argon2id hashing with OWASP-minimum parameters (time_cost=3, memory_cost=65536 KiB, parallelism=1) — core/lambda/nova_idp_auth.py:hash_password. Argon2id is the recommended PHC winner; the parameters are the OWASP minimum (C-7.2).
  • Fail-closed on Argon2 unavailable (D-228): if the argon2-cffi C extension fails to import, _ARGON2_AVAILABLE is False and hash_password/verify_password raise Argon2UnavailableError → the handler returns 503. No pure-Python fallback, no weak hash, no crash. Verified by tests/test_argon2_fail_closed.py.
  • No raw passwords anywhere (INV-16): the handler never logs the password argument; the audit scrubber (_emit_audit) pops any password/new_password/old_password kwarg defense-in-depth; the DDB item has password_hash, never password. Verified by tests/test_idp_auth.py:TestNoRawPasswordsInLogs.

Residual risk: low. Argon2id with the OWASP params is GPU-resistant at scale; the remaining risk is a parameter-weakness advisory (mitigated by the 90-day KMS rotation cadence's analog for hash params — revisit annually).

T-2 — PAT theft + max TTL (C-6.2)

Threat: an attacker exfiltrates a PAT (filesystem read of ~/.nova/credentials.json, a leaked CI env var, a phishing capture) and uses it to vend OIDC tokens until it expires.

Mitigations:

  • ~/.nova/credentials.json stores the OIDC token + PAT metadata (jti, exp, type) ONLY — NOT the raw PAT (C-7.3). The raw PAT is entered once at nova auth login and never persisted. An attacker who reads the credentials file gets a short-lived OIDC token (15 min default), not the long-lived PAT. Verified by tests/test_auth_commands.py:test_login_stores_oidc_token_not_raw_pat.
  • Max TTL (C-6.2): developer PATs ≤ 24h (86400s), service-account PATs ≤ 1h (3600s). Enforced in core.pat_lifecycle.issue_pat — requests above the max are clamped (with an audit event). The shorter service-account TTL bounds the CI blast radius.
  • Revocation via strong-read DDB (D-229): the token-vend Lambda does GetItem(PK=jti, ConsistentRead=True) on nova-pats on every vend. A revocation (status=revoked) is reflected on the next vend within 60s P95 (the strong read is synchronous — the 60s is the P95 propagation bound, not a polling delay). Verified by tests/test_pat_revocation.py:test_pat_revocation_slo (asserts <1s locally).
  • Emergency revocation at the DDB level (when the CLI is unavailable): aws dynamodb update-item --table-name nova-pats ... flips status to revoked — see docs/operator-guide-idp.md §9.

Residual risk: medium. The PAT is a bearer token — theft is undetectable until the attacker vends a token. Mitigation is TTL bounding + revocation, not prevention. The 1h service-account cap is the primary control for CI exposure.

T-3 — JWKS unauthenticated endpoint DDoS (C-6.2)

Threat: the JWKS endpoint (nova-idp-jwks function URL, AuthType: NONE) is a public, unauthenticated target. An attacker can flood it with requests, exhausting Lambda concurrency and making token verification fail for all clients (a cheap DoS).

Mitigations:

  • Reserved concurrency (10, max ~100 RPS): the JWKS Lambda has a reserved-concurrency limit of 10 (set in the CloudFormation template). This caps the blast radius — a flood saturates the JWKS Lambda but does NOT exhaust the account-wide concurrency pool, so nova-idp-auth and nova-idp-token-vend keep serving.
  • Client-side caching (1h): the JWKS response carries Cache-Control: max-age=3600. Clients (pyjwt.PyJWK client) cache the keys for 1h, so a JWKS outage does not immediately break verification — already-cached keys keep working.
  • Optional CloudFront + WAF (rate-based rule): nova idp setup --apply --public-jwks-domain <fqdn> fronts the function URL with a CloudFront distribution + a WAF web ACL with a rate-based rule (e.g. block an IP after 2000 req/5min). For any public deployment, set --public-jwks-domain. Without it the function URL is bare — fine for piloting, exposed for production.

Residual risk: medium. The reserved concurrency bounds the cost but a determined attacker can still keep the JWKS Lambda saturated. The WAF + CloudFront path is the production-grade control. JWKS is inherently public (clients MUST fetch it without auth) — this is a fundamental OIDC property, not a Nova design flaw.

T-4 — ABAC bypass (C-6.1 / C-7.1)

Threat: the ABAC policy engine (kyverno-json / kj) fails to load, crashes, or is misconfigured, and the token-vend Lambda vends a token anyway (fails open). This would bypass the authorization gate — every active PAT gets a token regardless of the policy.

Mitigations:

  • Fail-closed (C-6.1/C-7.1 — the grill's #1 finding): the token-vend Lambda's _evaluate_abac_fail_closed returns (False, [], "", "abac_eval_failed") if:
    • KyvernoJsonEngine.is_configured() returns False (kj absent),
    • get_engine() raises (engine registry error),
    • evaluate_token_vend_policy() raises (policy parse error, kj runtime error). In all three cases the Lambda returns 403 + an audit event token.vend.denied (reason abac_eval_failed). Never fails open. This is INV-17's runtime guarantee — without it, INV-17 is documentation, not a control.
  • Verified by tests/test_abac_fail_closed.py (7 tests): engine-not-configured, evaluate-raises, policy-parse-error, ABAC denies, revoked PAT, unknown PAT, audit-event-emitted-on-denial.
  • Policy version in every audit event (D-231): the git blob SHA of platform/abac/token-vend.policy is recorded in every token.vend.allowed/token.vend.denied event. An auditor can reconstruct which policy version governed each vend.

Residual risk: low (given the fail-closed semantics). The remaining risk is a policy-authoring bug (the policy allows too much) — mitigated by PR review (D-231: Platform Security owns the policy) and the policy-version audit trail.

T-5 — KMS signing key compromise

Threat: an attacker gains kms:Sign permission on alias/nova-oidc-signing and forges OIDC tokens.

Mitigations:

  • KMS key policy restricts kms:Sign to the token-vend Lambda role. No other principal (including the operator) can sign. The JWKS Lambda role has kms:GetPublicKey only (not Sign).
  • Key rotation (90 days): the alias is re-pointed to a new ECC_NIST_P256 key every 90 days (see docs/operator-guide-idp.md §6). The old key stays enabled during the overlap window (≥ max PAT TTL = 24h) so already-issued tokens keep verifying, then is disabled + scheduled for deletion.
  • JWKS serves both kids during the overlap window: the JWKS endpoint lists all keys the alias has pointed at that are still enabled. Clients verify against the kid in the token header.

Residual risk: low. KMS key policies are the primary control; rotation bounds the exposure window of a stolen key.

T-6 — DER → raw ECDSA signature conversion bug (C-5.2 gotcha)

Threat: KMS sign() returns a DER-encoded ASN.1 ECDSA signature. JWS (RFC 7515 §3.1.3) requires the raw r‖s concatenation, each coordinate 32 bytes big-endian (for P-256). If the conversion is wrong (wrong byte order, wrong padding, wrong coordinate length), the resulting JWT will not verify with standard libraries (pyjwt, jose) — or worse, verifies with a different signature than intended (a subtle correctness + security bug).

This is the #1 implementation risk identified in RESEARCH §5. The conversion is in core/kms_signing.py:der_to_raw_ecdsa:

r, s = decode_dss_signature(der_sig)   # cryptography's ASN.1 parser
return r.to_bytes(32, "big") + s.to_bytes(32, "big")   # raw r‖s

Mitigations:

  • decode_dss_signature from cryptography parses the DER (not a hand-rolled ASN.1 parser — that would be the real risk).
  • to_bytes(32, "big") zero-pads each coordinate to exactly 32 bytes. A coordinate shorter than 32 bytes (high-order zero bytes) is padded; a coordinate longer than 32 bytes raises ValueError (the guard at the top of der_to_raw_ecdsa).
  • Verified by tests/test_kms_roundtrip.py (CAP-037): sign a JWT via kms_signing.sign_jwt() (mock KMS with a test ECC keypair) → fetch JWKS via the JWKS Lambda → verify with pyjwt + the JWKS key. The round-trip succeeds only if the DER→raw conversion is byte-correct. This is the regression gate for any change to kms_signing.py.

Residual risk: low (given the round-trip test). A KMS-side format change (AWS changes the DER encoding) would break the test loudly.

T-7 — No AWS-managed identity (INV-15)

Threat: (architectural invariant, not an attack.) Nova-idp depends on Cognito, IAM Identity Center, or another AWS-managed identity service, creating a vendor lock-in and an opaque trust boundary.

Mitigation:

  • INV-15 (no AWS-managed identity in path): Nova-idp uses KMS + DDB + Lambda only. No Cognito, no IAM Identity Center, no managed user pools. The identity layer is greenfield and fully owned by Nova. This is a constraint, not a mitigation — it shapes the whole design (Argon2id in Lambda instead of Cognito user pools; KMS-signed JWTs instead of Cognito issued tokens; DDB nova-pats instead of IAM access keys).
  • Verified by inspection: core/lambda/nova_idp_auth.py + nova_idp_token_vend.py import only boto3 (DDB + KMS), argon2, cryptography, pyjwt, and core.*. No cognitoidp or identitystore client calls anywhere in the identity layer.

Residual risk: none (this is a satisfied constraint, not a residual). The trade-off is operational burden (Nova runs its own password hashing, token signing, revocation) in exchange for portability and no opaque trust boundary.

T-8 — Audit trail integrity

Threat: an attacker tampers with the audit stream to hide a malicious vend, a revocation, or a policy change.

Mitigations:

  • Every event emitted (INV-12): cli.invocation, auth.sign_up, auth.sign_in, auth.session_created, pat.issued, pat.revoked, token.vend.allowed, token.vend.denied, auth.login, auth.status, auth.revoke — each is a JSON line on stderr with a timestamp + the relevant identifiers (user_id, jti, sub, policy_sha).
  • Policy version (git SHA, D-231) in every token-vend event: the policy_sha field lets an auditor reconstruct which policy version governed each vend — a policy change is visible in the audit stream as a policy_sha change.
  • Raw credentials scrubbed (INV-16/INV-17 spirit): the _emit_audit functions in nova_idp_auth.py, nova_idp_token_vend.py, and pat_lifecycle.py pop any password/pat/token/raw_pat kwarg defense-in-depth. The audit stream carries identifiers, not secrets.
  • Revoked PATs retained (REQ-343): nova-pats rows are marked status=revoked, never deleted. The audit trail of "who was revoked, when" is queryable.

Residual risk: medium (audit integrity is only as strong as the log destination). The Lambdas emit to stderr (CloudWatch Logs by default); the integrity guarantee depends on the downstream log pipeline (immutability, retention). For high-assurance deployments, forward the audit stream to an append-only store (S3 Object Lock, a write-once log service). This is a deployment concern, documented in the operator guide.


4. C-9.2 — INV-18..21 compression audit

The source spec (the v1.28 design document that was re-mapped into this repo's REQ-323..353 / INV-12..17 — see REQUIREMENTS.md §v1.28 "ID re-mapping") referenced INV-18..21 as "attestation invariants." Those IDs do not exist in this repo (this repo's invariants run INV-1..11 for the blockchain/pilot work and INV-12..17 for v1.28). The grill (C-9.2) requires an audit verifying the spec's attestation invariant semantics were fully captured by the re-mapped INV-15/INV-16/INV-17 + REQ-332, with no semantic gap.

The spec's attestation invariant semantics (reconstructed)

The source spec's INV-18..21 expressed four attestation concerns:

  1. Immutability — an attestation, once made, cannot be silently altered.
  2. Signature verifiability — the attestation's signature can be independently verified by a third party holding the public key.
  3. Key derivation — the signing key is derived from a known input (the PAT) via a specified KDF, not ad-hoc.
  4. No AWS-managed identity — the attestation scheme does not depend on Cognito / IAM Identity Center (the greenfield constraint).

Mapping to the re-mapped invariants + requirements

Spec concern Re-mapped to Where enforced
Immutability INV-6 (existing, pre-v1.28 — the immutable audit ledger) + INV-17 (ABAC discipline — every vend is audited with policy_sha) the audit stream is append-only; policy_sha binds each vend to a policy version
Signature verifiability REQ-332 (JWS-from-PAT KDF) + REQ-337 (KMS-signed OIDC, JWKS verifiable) core/jws_attestation.py:verify_attestation (HS256, constant-time compare); core/kms_signing.py + JWKS endpoint
Key derivation REQ-332 (C-5.2 grill fix) — HKDF-SHA256(PAT, salt='nova-local-attestation', info='jws-signing-key') → 32-byte symmetric key core/jws_attestation.py:derive_signing_key; verified by tests/test_jws_attestation.py
No AWS-managed identity INV-15 (no Cognito / IAM Identity Center in path) inspection — the identity layer uses KMS + DDB + Lambda only

Conclusion: the compression is sound — no semantic gap

The spec's four attestation concerns are covered by:

  • INV-6 (immutability — the existing audit ledger, carried forward from pre-v1.28 milestones),
  • INV-15 (no AWS-managed identity — the greenfield constraint),
  • INV-16 (password storage — the Argon2id + no-raw-password rule, which is the attestation input integrity for signup),
  • INV-17 (ABAC discipline — every vend is policy-gated + audited with policy_sha),
  • REQ-332 (JWS-from-PAT KDF — the signature + key-derivation scheme for local-review attestations).

The re-mapping from INV-18..21INV-15/16/17 + REQ-332 is a compression (4 invariants → 3 invariants + 1 requirement), not a drop. The four original concerns (immutability, signature verifiability, key derivation, no-managed-identity) each have a load-bearing home in the re-mapped set. No attestation invariant semantics were silently dropped.

The compression is justified because:

  • INV-6 already covered audit immutability (re-stating it as INV-18 would have been a duplicate of an existing invariant).
  • INV-15 already covered the no-managed-identity constraint (re-stating it as INV-21 would have been a duplicate).
  • INV-16 + INV-17 cover the input-integrity + policy-discipline concerns that the spec's INV-19/20 expressed as attestation-specific invariants (they are in fact general identity-layer invariants, not attestation-specific).
  • REQ-332 carries the signature + KDF detail that the spec's INV-18 hand-waved ("public key derivable from the PAT") — and corrects it to a sound symmetric scheme (C-5.2).

Audit verification (how to re-run this audit)

# 1. Confirm INV-18..21 do not exist in this repo.
grep -rE 'INV-1[89]|INV-2[01]' .ciagent/ docs/ core/ tests/ \
  | grep -v 'INV-18..21'  # only the C-9.2 audit references should remain

# 2. Confirm the re-mapped invariants + REQ-332 exist + are tested.
pytest tests/test_jws_attestation.py tests/test_abac_fail_closed.py \
       tests/test_kms_roundtrip.py tests/test_argon2_fail_closed.py -q

5. Test coverage summary

Threat Test file What it verifies
T-1 (password) tests/test_argon2_fail_closed.py 503 on argon2 unavailable (no weak hash)
T-1 (password) tests/test_idp_auth.py no raw password in DDB item or logs (INV-16)
T-2 (PAT theft) tests/test_auth_commands.py credentials.json has OIDC token, NOT raw PAT (C-7.3)
T-2 (PAT theft) tests/test_pat_revocation.py revocation takes effect <1s (D-229 SLO)
T-3 (JWKS DDoS) (CloudFormation template inspection) reserved concurrency = 10; WAF with --public-jwks-domain
T-4 (ABAC bypass) tests/test_abac_fail_closed.py (7 tests) fail-closed on engine absent / error / deny (C-6.1)
T-5 (KMS key) tests/test_kms_roundtrip.py KMS sign → JWKS → pyjwt verify (CAP-037)
T-6 (DER→raw) tests/test_kms_roundtrip.py the round-trip succeeds only if DER→raw is byte-correct
T-7 (no managed id) (inspection) no cognitoidp / identitystore imports in the identity layer
T-8 (audit) tests/test_e2e_idp.py the full audit chain is present + linked (REQ-348)

6. Open items (deferred, not blocking v1.28)

  • WAF rate-limit tuning: the default rate-based rule threshold (2000 req/5min/IP) is a pilot-scale guess. Production tuning needs real traffic data. Tracked as a post-v1.28 ops task.
  • Audit log forwarding to an append-only store (S3 Object Lock): the Lambdas emit to stderr / CloudWatch Logs by default. High- assurance deployments should forward to a write-once destination. Documented in the operator guide; not enforced in code.
  • PAT theft detection: there is no anomaly detection on PAT usage (e.g. a vend from a new geography). The TTL + revocation is the control. Detection is a future milestone.