# 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 # 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 ``` 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 # or nova auth login --session ``` 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": "", "credentials": [ { "jti": "", "type": "nova_oidc_token", "exp": 1787200000, "token": "", "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 ` 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 ``` 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:`, 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 --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= 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 → OIDC token, store in `~/.nova/credentials.json` (0600) | | `nova auth status` | active credential + mode + selection_reason | | `nova auth revoke --pat ` | mark a PAT revoked (D-229 SLO ≤ 60s P95) | | `nova apply --local --sign-local-review --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 |