Files
acdl/docs/developer-guide-auth.md
T
Jon Chery 23b8ff81d3 docs(P05): verify REQ-349/350/351 pass in combined suite (security-engineer)
---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.
2026-08-19 23:30:41 +00:00

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, see docs/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):

  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.

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.

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:

{
  "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):

  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:

nova apply | tee log.txt

Here stdout is a pipe (to tee), but stdin is still the terminal. So sys.stdin.isatty() returns Trueinteractive 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 Falseagent 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
  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:

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=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