---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.
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-jwksLambdas, the KMS signing key, the 4 DynamoDB tables, thenova authCLI, 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:
- Internet → JWKS Lambda (unauthenticated function URL) — the DDoS surface (Threat T-4).
- CLI → auth/token-vend Lambdas (IAM-authenticated function URLs) — the credential-injection surface.
- token-vend Lambda → KMS (
kms:Sign) — the key-use surface. - 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-cffiC extension fails to import,_ARGON2_AVAILABLEisFalseandhash_password/verify_passwordraiseArgon2UnavailableError→ the handler returns 503. No pure-Python fallback, no weak hash, no crash. Verified bytests/test_argon2_fail_closed.py. - No raw passwords anywhere (INV-16): the handler never logs the
password argument; the audit scrubber (
_emit_audit) pops anypassword/new_password/old_passwordkwarg defense-in-depth; the DDB item haspassword_hash, neverpassword. Verified bytests/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.jsonstores the OIDC token + PAT metadata (jti,exp,type) ONLY — NOT the raw PAT (C-7.3). The raw PAT is entered once atnova auth loginand never persisted. An attacker who reads the credentials file gets a short-lived OIDC token (15 min default), not the long-lived PAT. Verified bytests/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)onnova-patson 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 bytests/test_pat_revocation.py:test_pat_revocation_slo(asserts<1slocally). - Emergency revocation at the DDB level (when the CLI is
unavailable):
aws dynamodb update-item --table-name nova-pats ...flipsstatustorevoked— seedocs/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-authandnova-idp-token-vendkeep serving. - Client-side caching (1h): the JWKS response carries
Cache-Control: max-age=3600. Clients (pyjwt.PyJWKclient) 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_closedreturns(False, [], "", "abac_eval_failed")if:KyvernoJsonEngine.is_configured()returnsFalse(kjabsent),get_engine()raises (engine registry error),evaluate_token_vend_policy()raises (policy parse error,kjruntime error). In all three cases the Lambda returns 403 + an audit eventtoken.vend.denied(reasonabac_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.policyis recorded in everytoken.vend.allowed/token.vend.deniedevent. 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:Signto the token-vend Lambda role. No other principal (including the operator) can sign. The JWKS Lambda role haskms:GetPublicKeyonly (notSign). - Key rotation (90 days): the alias is re-pointed to a new
ECC_NIST_P256key every 90 days (seedocs/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 thekidin 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_signaturefromcryptographyparses 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 raisesValueError(the guard at the top ofder_to_raw_ecdsa).- Verified by
tests/test_kms_roundtrip.py(CAP-037): sign a JWT viakms_signing.sign_jwt()(mock KMS with a test ECC keypair) → fetch JWKS via the JWKS Lambda → verify withpyjwt+ the JWKS key. The round-trip succeeds only if the DER→raw conversion is byte-correct. This is the regression gate for any change tokms_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-patsinstead of IAM access keys). - Verified by inspection:
core/lambda/nova_idp_auth.py+nova_idp_token_vend.pyimport onlyboto3(DDB + KMS),argon2,cryptography,pyjwt, andcore.*. Nocognitoidporidentitystoreclient 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_shafield lets an auditor reconstruct which policy version governed each vend — a policy change is visible in the audit stream as apolicy_shachange. - Raw credentials scrubbed (INV-16/INV-17 spirit): the
_emit_auditfunctions innova_idp_auth.py,nova_idp_token_vend.py, andpat_lifecycle.pypop anypassword/pat/token/raw_patkwarg defense-in-depth. The audit stream carries identifiers, not secrets. - Revoked PATs retained (REQ-343):
nova-patsrows are markedstatus=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:
- Immutability — an attestation, once made, cannot be silently altered.
- Signature verifiability — the attestation's signature can be independently verified by a third party holding the public key.
- Key derivation — the signing key is derived from a known input (the PAT) via a specified KDF, not ad-hoc.
- 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..21 → INV-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.