23b8ff81d3
---ci---
project: acdl
phase: 5
milestone: v1.28
status: execute
persona: security-engineer
---
Verification results (combined suite, .venv/bin/python -m pytest):
REQ-349 (mode_resolver property tests, tests/test_mode_resolver.py):
12 passed — all four priority levels + edge cases (TTY/piped stdout,
missing credential, conflicting flag/env, invalid env value).
REQ-350 (KMS round-trip, tests/test_kms_roundtrip.py): 1 passed —
sign JWT via mock KMS → JWKS Lambda → pyjwt verify (CAP-037, DER→raw
byte-correct).
REQ-351 (PAT revocation SLO, tests/test_pat_revocation.py): 2 passed
— issue → vend → revoke → 403 pat_revoked in <1s (D-229 strong-read).
Combined suite regression run
(pytest tests/ --ignore=tests/test_pptx_generator.py -k 'not live_aws
and not slow'): 1000 passed, 5 deselected, 0 failed.
Fix: the first regression run flagged 1 failure —
test_no_forge_mentions_in_synced_files (REQ-230 v1.20 guard) caught two
forbidden forge-name mentions in the new docs (operator-guide §7,
developer-guide §9). Rephrased both to 'internal forge' / 'internal
forge runner' to keep the docs sync-safe. No code changes. Re-ran the
full suite: 1000 passed, 0 failed.
334 lines
12 KiB
Markdown
334 lines
12 KiB
Markdown
# Developer Guide — Nova Auth (`nova auth`)
|
|
|
|
> **REQ-346** — developer guide for `nova auth login`. Covers signup,
|
|
> signin, login, mode resolution, TTY vs piped stdout behavior, and the
|
|
> JWS-from-PAT KDF (REQ-332, C-5.2).
|
|
>
|
|
> Audience: developers using the Nova CLI to authenticate and run
|
|
> `nova apply`. For operator-side identity stack deployment, see
|
|
> `docs/operator-guide-idp.md`.
|
|
|
|
## 1. Quickstart (5 steps)
|
|
|
|
```sh
|
|
# 1. Sign up (one-time per user).
|
|
nova auth signup --email alice@example.com --owner team-a
|
|
|
|
# 2. Sign in (returns a session — valid 24h).
|
|
nova auth signin --email alice@example.com
|
|
|
|
# 3. Issue a PAT and log in (session → OIDC token, stored locally).
|
|
nova auth login --pat <PAT>
|
|
|
|
# 4. Initialize a project (one-time per repo).
|
|
nova init
|
|
|
|
# 5. Apply locally + sign a local-review attestation.
|
|
nova apply --local --sign-local-review --contract .nova/contract.yml --pat <PAT>
|
|
```
|
|
|
|
After step 3, `~/.nova/credentials.json` holds your active OIDC token
|
|
(see §4). After step 5, the attestation is a JWS verifiable with the
|
|
PAT-derived key (see §7).
|
|
|
|
## 2. `nova auth signup`
|
|
|
|
Creates a user in the `nova-users` DynamoDB table. The password is
|
|
hashed with **Argon2id** (OWASP-minimum parameters: `time_cost=3,
|
|
memory_cost=65536 KiB, parallelism=1`) — the raw password is **never**
|
|
stored, logged, or put in any env var (INV-16).
|
|
|
|
```sh
|
|
nova auth signup --email alice@example.com --password '...' --owner team-a
|
|
```
|
|
|
|
What happens server-side (the `nova-idp-auth` Lambda):
|
|
1. Validates the payload (`email`, `password`, `owner`, `roles`).
|
|
2. Checks for a duplicate email → `409` if already registered.
|
|
3. `hash_password(password)` → Argon2id hash string.
|
|
4. `PutItem` into `nova-users` (`user_id`, `email`, `password_hash`,
|
|
`owner`, `roles`, `created_at`).
|
|
5. Emits `auth.sign_up` audit event (carries `user_id` + `email`,
|
|
never the password).
|
|
|
|
If the Argon2 C extension is unavailable, the Lambda returns **503**
|
|
(fail-closed — no weak hash, no pure-Python fallback; D-228).
|
|
|
|
## 3. `nova auth signin`
|
|
|
|
Verifies the password and returns a session token.
|
|
|
|
```sh
|
|
nova auth signin --email alice@example.com --password '...'
|
|
```
|
|
|
|
The Lambda:
|
|
1. Looks up the user by email (GSI `email-index` on `nova-users`).
|
|
2. `verify_password(password, stored_hash)` — Argon2id verify.
|
|
3. On mismatch or unknown email → `401 invalid_credentials` (the same
|
|
message for both, so an attacker can't enumerate emails by timing).
|
|
4. On success: `create_session(user_id)` writes a row to `nova-sessions`
|
|
(TTL 24h) and returns `session_id`.
|
|
|
|
## 4. `nova auth login`
|
|
|
|
Exchanges a PAT (or session) for a Nova OIDC token and stores it
|
|
locally.
|
|
|
|
```sh
|
|
nova auth login --pat <PAT>
|
|
# or
|
|
nova auth login --session <session_token>
|
|
```
|
|
|
|
The flow:
|
|
1. The CLI calls the `nova-idp-token-vend` Lambda with the PAT.
|
|
2. The Lambda decodes the PAT's `jti`, does a **strongly-consistent**
|
|
`GetItem` on `nova-pats` (D-229 — revocation is reflected on the
|
|
next vend, within 60s P95).
|
|
3. Evaluates the ABAC policy (`platform/abac/token-vend.policy`) —
|
|
fail-closed (C-6.1). If the policy engine is unavailable or the
|
|
policy denies, the vend returns `403`.
|
|
4. Signs the OIDC token via KMS (`alias/nova-oidc-signing`,
|
|
`ECC_NIST_P256`, `ECDSA_SHA_256`) and returns it.
|
|
|
|
### The credentials file (`~/.nova/credentials.json`)
|
|
|
|
**C-7.3 (grill):** the file stores the OIDC token + PAT metadata
|
|
(`jti`, `exp`, `type`) **ONLY — NOT the raw PAT.** The raw PAT is
|
|
entered once at `nova auth login` and never persisted. This reduces the
|
|
filesystem-compromise blast radius: an attacker who reads
|
|
`credentials.json` gets a short-lived OIDC token (default 15 min), not
|
|
the long-lived PAT.
|
|
|
|
The file is `0600` (owner read/write only). Shape:
|
|
|
|
```json
|
|
{
|
|
"active_credential_jti": "<jti>",
|
|
"credentials": [
|
|
{
|
|
"jti": "<jti>",
|
|
"type": "nova_oidc_token",
|
|
"exp": 1787200000,
|
|
"token": "<oidc jwt>",
|
|
"stored_at": 1787199000
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
"Most recent wins": `active_credential_jti` points at the
|
|
most-recently-stored credential. A subsequent `nova auth login`
|
|
replaces the entry with the same `jti` (or adds a new one).
|
|
|
|
## 5. `nova auth status`
|
|
|
|
Shows the active credential, the resolved mode, and the
|
|
`selection_reason`.
|
|
|
|
```sh
|
|
nova auth status
|
|
```
|
|
|
|
Output (JSON):
|
|
```json
|
|
{
|
|
"mode": "interactive",
|
|
"selection_reason": "credential:developer_pat",
|
|
"type": "nova_oidc_token",
|
|
"jti": "...",
|
|
"exp": 1787200000
|
|
}
|
|
```
|
|
|
|
If no credential is stored: `{"status": "no active credential"}`.
|
|
|
|
## 6. `nova auth revoke --pat <jti>`
|
|
|
|
Revokes a PAT by `jti`. Marks the `nova-pats` row `status=revoked`
|
|
(the row is **retained** for audit, not deleted). The next
|
|
`nova auth login` with that PAT returns `403 pat_revoked` within 60s
|
|
P95 (D-229 strong read).
|
|
|
|
```sh
|
|
nova auth revoke --pat <jti>
|
|
```
|
|
|
|
For emergency DDB-level revocation (when the CLI is unavailable), see
|
|
`docs/operator-guide-idp.md` §9.
|
|
|
|
## 7. Mode resolution (D-226)
|
|
|
|
The CLI resolves a client mode (`interactive` or `agent`) on every
|
|
invocation. The mode drives audit observability (INV-12) and some
|
|
behavioral defaults. The priority is **strict** — no silent fallbacks
|
|
(INV-13):
|
|
|
|
1. **`--mode` flag** (always wins): `nova apply --mode=agent`.
|
|
2. **`NOVA_CLIENT_MODE` env var**: `export NOVA_CLIENT_MODE=agent`.
|
|
Invalid values (anything other than `agent` / `interactive`) are
|
|
**warned and ignored** (fall through to the next level — not a
|
|
silent fallback, because a warning is emitted).
|
|
3. **Credential type** (from `~/.nova/credentials.json`): if the active
|
|
credential is `developer_pat` or `nova_oidc_token`, the mode is
|
|
`interactive` if a TTY is attached, `agent` otherwise (INV-14).
|
|
4. **TTY heuristic** (`sys.stdin.isatty()`): `interactive` if stdin is
|
|
a TTY, `agent` otherwise.
|
|
|
|
Every resolution returns a non-empty `selection_reason` (`flag`, `env`,
|
|
`credential:<type>`, or `tty`) so the audit event is self-explanatory.
|
|
|
|
### TTY vs piped stdout — the Edge 3 case
|
|
|
|
The TTY check is **`sys.stdin.isatty()`**, not `sys.stdout.isatty()`.
|
|
This matters when stdout is piped but stdin is still a terminal:
|
|
|
|
```sh
|
|
nova apply | tee log.txt
|
|
```
|
|
|
|
Here `stdout` is a pipe (to `tee`), but `stdin` is still the terminal.
|
|
So `sys.stdin.isatty()` returns `True` → **interactive mode**. This is
|
|
the common "I want to see the output AND save it" pattern, and it
|
|
correctly resolves to interactive because the human is driving.
|
|
|
|
The inverse — `echo '...' | nova apply` — has `stdin` piped, so
|
|
`sys.stdin.isatty()` is `False` → **agent mode** (no human at the
|
|
keyboard; the pipe is the driver).
|
|
|
|
### `developer_pat` + TTY → interactive; + no TTY → agent
|
|
|
|
A developer PAT (`type: developer_pat`) is a human credential. When a
|
|
TTY is attached, the CLI runs in `interactive` mode (prompts, human
|
|
confirmation). When no TTY is attached (piped stdin, CI, a scheduled
|
|
job), the same PAT runs in `agent` mode (no prompts, non-interactive).
|
|
This is INV-14: the credential type encodes the role, and the TTY
|
|
encodes the context.
|
|
|
|
A service-account PAT behaves the same way by type, but the max TTL is
|
|
much shorter (≤ 1h vs ≤ 24h for developer PATs — C-6.2) and CI systems
|
|
typically set `NOVA_CLIENT_MODE=agent` explicitly so the resolution is
|
|
deterministic regardless of the TTY state.
|
|
|
|
## 8. JWS-from-PAT key derivation (REQ-332, C-5.2)
|
|
|
|
`nova apply --local --sign-local-review` produces a JWS attestation — a
|
|
symmetric (HMAC-SHA256) signature over the attestation payload, keyed
|
|
by a key derived from the PAT.
|
|
|
|
### Why symmetric?
|
|
|
|
The grill (C-5.2) found that the original REQ-332 acceptance criterion
|
|
("public key derivable from the PAT") is unimplementable as an
|
|
asymmetric scheme — a PAT is a JWT, not a keypair. The fix: the PAT is
|
|
the **shared secret**. Both the signing key and the verification key
|
|
are derived from the PAT via the same KDF. The JWS uses `HS256`
|
|
(HMAC-SHA256), not `ES256`.
|
|
|
|
### The KDF
|
|
|
|
```
|
|
key = HKDF-SHA256(
|
|
input_key_material = PAT.encode('utf-8'),
|
|
salt = b'nova-local-attestation',
|
|
info = b'jws-signing-key',
|
|
length = 32,
|
|
)
|
|
```
|
|
|
|
(RFC 5869 / NIST SP 800-56C.) The `salt` and `info` are fixed
|
|
constants — they bind the derived key to the "nova-local-attestation /
|
|
jws-signing-key" purpose (key separation, INV-16). The same PAT always
|
|
yields the same key (deterministic); the key is never cached or
|
|
persisted (INV-15 — recomputed on each sign/verify call).
|
|
|
|
### Signing (`nova apply --local --sign-local-review`)
|
|
|
|
```sh
|
|
nova apply --local --sign-local-review --pat <PAT> --contract .nova/contract.yml
|
|
```
|
|
|
|
1. `core.jws_attestation.sign_attestation(payload, pat)`:
|
|
- `derive_signing_key(pat)` → 32-byte key.
|
|
- `header = {"alg":"HS256","typ":"JWT"}`.
|
|
- `signing_input = b64url(header) + "." + b64url(payload)`.
|
|
- `signature = HMAC-SHA256(key, signing_input)`.
|
|
- Returns `b64url(header).b64url(payload).b64url(signature)` (the
|
|
compact JWS serialization).
|
|
2. The JWS is appended to the apply output.
|
|
|
|
### Verifying
|
|
|
|
Anyone holding the PAT can derive the same key and verify:
|
|
|
|
```python
|
|
from core.jws_attestation import verify_attestation
|
|
payload = verify_attestation(jws_string, pat)
|
|
# raises JWSValidationError on tampering or wrong PAT
|
|
```
|
|
|
|
`verify_attestation` recomputes the HMAC and compares in constant time
|
|
(`hmac.compare_digest`). Without the PAT, the HMAC cannot be forged —
|
|
this is the integrity guarantee for local-review attestations.
|
|
|
|
### What this is NOT
|
|
|
|
- **Not a non-repudiation scheme.** Anyone with the PAT can sign, so
|
|
the signature proves "someone with the PAT signed this payload" —
|
|
not a specific individual. Non-repudiation is the job of the audit
|
|
trail (INV-12), not the JWS.
|
|
- **Not a replacement for the OIDC token.** The OIDC token (from
|
|
`nova auth login`) is the credential for remote operations; the JWS
|
|
is for local-review attestation integrity only.
|
|
|
|
## 9. Service-account PATs (CI usage)
|
|
|
|
A CI system (GitHub Actions, or an internal forge runner) uses a service-account
|
|
PAT to run `nova apply` non-interactively.
|
|
|
|
```sh
|
|
# In CI:
|
|
export NOVA_PAT=<service-account-pat>
|
|
export NOVA_CLIENT_MODE=agent
|
|
nova auth login --pat "$NOVA_PAT"
|
|
nova apply --contract contracts/microservice.yml
|
|
```
|
|
|
|
- `NOVA_CLIENT_MODE=agent` makes mode resolution deterministic (level 2
|
|
beats level 3/4), regardless of whether the CI runner attaches a TTY.
|
|
- No TTY → `agent` mode anyway, but the env var is belt-and-suspenders.
|
|
- **Max TTL: ≤ 1h for service-account PATs** (C-6.2). The
|
|
`issue_pat(subject_type="service-account", ttl_seconds=3600)` call
|
|
clamps any higher request to 3600s. Rotate the PAT before it expires
|
|
(CI should mint a fresh one per run or daily).
|
|
|
|
### TTL summary (C-6.2)
|
|
|
|
| Subject type | Max TTL | Typical use |
|
|
|--------------|---------|-------------|
|
|
| `developer` | ≤ 24h (86400s) | local dev, interactive |
|
|
| `service-account` | ≤ 1h (3600s) | CI, automated pipelines |
|
|
|
|
The TTL is enforced in `core.pat_lifecycle.issue_pat` — a request for
|
|
more than the max is silently clamped (with an audit event recording
|
|
the requested vs actual TTL).
|
|
|
|
---
|
|
|
|
## Appendix — command reference
|
|
|
|
| Command | What it does |
|
|
|---------|--------------|
|
|
| `nova auth signup` | create a user (Argon2id hash) |
|
|
| `nova auth signin` | verify password → session token |
|
|
| `nova auth login --pat <PAT>` | PAT → OIDC token, store in `~/.nova/credentials.json` (0600) |
|
|
| `nova auth status` | active credential + mode + selection_reason |
|
|
| `nova auth revoke --pat <jti>` | mark a PAT revoked (D-229 SLO ≤ 60s P95) |
|
|
| `nova apply --local --sign-local-review --pat <PAT>` | local apply + JWS attestation (HS256, PAT-derived key) |
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `~/.nova/credentials.json` | OIDC token + PAT metadata (NOT raw PAT); 0600 |
|
|
| `~/.nova/contract.yml` | project contract (scaffolded by `nova init`) |
|
|
| `~/.nova/contract.yml.attestations/` | local attestation outputs | |