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

408 lines
22 KiB
Markdown

# 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 `kid`s 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`:
```python
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..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)
```sh
# 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.