---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.
12 KiB
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, seedocs/operator-guide-idp.md.
1. Quickstart (5 steps)
# 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).
nova auth signup --email alice@example.com --password '...' --owner team-a
What happens server-side (the nova-idp-auth Lambda):
- Validates the payload (
email,password,owner,roles). - Checks for a duplicate email →
409if already registered. hash_password(password)→ Argon2id hash string.PutItemintonova-users(user_id,email,password_hash,owner,roles,created_at).- Emits
auth.sign_upaudit event (carriesuser_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.
nova auth signin --email alice@example.com --password '...'
The Lambda:
- Looks up the user by email (GSI
email-indexonnova-users). verify_password(password, stored_hash)— Argon2id verify.- On mismatch or unknown email →
401 invalid_credentials(the same message for both, so an attacker can't enumerate emails by timing). - On success:
create_session(user_id)writes a row tonova-sessions(TTL 24h) and returnssession_id.
4. nova auth login
Exchanges a PAT (or session) for a Nova OIDC token and stores it locally.
nova auth login --pat <PAT>
# or
nova auth login --session <session_token>
The flow:
- The CLI calls the
nova-idp-token-vendLambda with the PAT. - The Lambda decodes the PAT's
jti, does a strongly-consistentGetItemonnova-pats(D-229 — revocation is reflected on the next vend, within 60s P95). - 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 returns403. - 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:
{
"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.
nova auth status
Output (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).
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):
--modeflag (always wins):nova apply --mode=agent.NOVA_CLIENT_MODEenv var:export NOVA_CLIENT_MODE=agent. Invalid values (anything other thanagent/interactive) are warned and ignored (fall through to the next level — not a silent fallback, because a warning is emitted).- Credential type (from
~/.nova/credentials.json): if the active credential isdeveloper_patornova_oidc_token, the mode isinteractiveif a TTY is attached,agentotherwise (INV-14). - TTY heuristic (
sys.stdin.isatty()):interactiveif stdin is a TTY,agentotherwise.
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:
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)
nova apply --local --sign-local-review --pat <PAT> --contract .nova/contract.yml
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).
- The JWS is appended to the apply output.
Verifying
Anyone holding the PAT can derive the same key and verify:
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.
# 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=agentmakes mode resolution deterministic (level 2 beats level 3/4), regardless of whether the CI runner attaches a TTY.- No TTY →
agentmode 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 |