Files
acdl/docs/developer-guide-auth.md
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 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
  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