Credential security model
Status: authoritative reference for the Claude Code ↔ Nightjar credential/identity/OAuth integration (ADR-029). Audience: security reviewers and maintainers. Scope: the two credential planes, the credential-fill trust boundary, grant authority, the per-workspace OAuth Resource Server, the audit model, and the delicate invariants a future contributor must not break.
Read alongside:
ADR-029— Claude Code ↔ Nightjar credential/identity/OAuth integration (the design this document operationalizes).ADR-020— the two planes (platform identity vs. site-credential vault); ADR-020 Option C = control is both Authorization Server and Resource Server.ADR-019— agent identity / delegation (the spawn-fact provenance model that grant propagation gates).- The companion credentials & identity guide for setup and day-to-day usage.
This document was last verified against the merged tree on 2026-06-27. Cited
file:linenumbers in the fast-moving control crate (nightjar-control) drift as code lands — treat the symbol name (fn/struct/const) as authoritative and the line number as a hint. Migration numbers, scope strings, and node-side (nightjar-impl) refs are stable.
1. The two credential planes
Nightjar deliberately keeps two entirely separate notions of “credential.” Conflating them is the single most dangerous mistake a contributor can make, so they are named, typed, and stored apart.
| Plane 1 — platform identity | Plane 2 — site-credential vault | |
|---|---|---|
| What it is | Proof of who is calling Nightjar | A real secret for some third-party website (a password, a session token) |
| Wire form | njk_… API key, or njo_… OAuth access token (njr_… refresh) | Never on the wire to the model — stored sealed, filled server-side |
| Carries | An org + agent binding + a clamped scope set | A value, an origin_allowlist, and a {{credential:NAME}} placeholder |
| Stored as | api_keys.hashed_key / oauth_tokens.hashed_token — sha256-at-rest | identity_credentials.value_encrypted — per-org KMS envelope, base64 |
| Authority model | Scopes (sessions:drive, identities:read, …) | Grants (assume / manage) + per-credential origin allowlist |
| Audit handle | nkey_-shaped credential/grant id (presenter) | nident_/ncred_/ngrnt_ ids + placeholder NAME |
The cross-plane invariant
Possessing a Plane-1 credential never, by itself, grants a Plane-2 credential.
Holding an njk_ key or an njo_ access token only proves possession and resolves to an org/agent/scope set. To fill a Plane-2 secret you additionally need:
- a session bound to an identity (
sessions.identity_idnon-NULL), and - a live grant (
assume/manage) from your agent to that identity, and - the target origin inside both the credential’s and the winning grant’s allowlists.
The scope sessions:drive lets you drive a browser; it does not let you read, decrypt, or exfiltrate any stored secret. The secret is never returned by any API (only a masked last-4), so a Plane-1 credential — however broadly scoped — can at most cause a secret to be typed into an allow-listed editable field on the live origin, and nothing else.
2. The credential-fill trust boundary
The headline secure path is the structured credential_fill MCP tool → POST /v1/sessions/:id/fill-credential. Defense is layered so that no single layer’s failure leaks the secret, and the model is structurally incapable of placing it.
(a) The tool has no value field, by construction
The credential_fill MCP tool and the CredentialFillRequest body expose exactly sessionId, credentialName, selector (additionalProperties:false). There is no text/value field anywhere on the surface. The model names a credential; it cannot choose what string lands or smuggle a secret in.
- Generated input schema:
core/spec/mcp-tools.json(credential_fill) (exactly those three keys). - Call construction:
generated_tools::build_callsets onlybody[credentialName]+body[selector]. /v1struct:domain::CredentialFillRequest. AtimeoutMsfield exists on the/v1struct but the handler ignores it — the node body is hand-built.
(b) Control decrypts just-in-time into a zeroizing buffer and forwards a minimal body
sessions_golden::fill_credential authorizes, resolves the named credential, JIT-decrypts the per-org KMS envelope into a Zeroizing<String>, computes the effective origin allowlist (credential ∩ grant), then forwards to the node a body of exactly:
{ verb: "type", selector, text: <plaintext>, originAllowlist: <effective>, requireEditableField: true } The plaintext is drop()-ed immediately after the node call returns, before any audit row or response is produced. Decryption (sessions_golden::decrypt_credential) fails closed: a missing KMS object store or any KMS/base64 error maps to a generic Internal — the cause is never surfaced (it could hint at key state).
(c) The node enforces live-origin AND editable-field before any keystroke, then zeroizes
The node (core/crates/nightjar-impl) is the anti-TOCTOU backstop. Both gates run before the element is typed into, default-deny:
- Live-origin gate (
text_input::live_origin_on_allowlist): the current navigation host must be on the forwarded allowlist. Empty allowlist or unparseable URL → deny. Match is exact host or strict dot-boundary subdomain (live_host.ends_with('.'+entry)), soevilgithub.comdoes not matchgithub.com. On miss:origin_denied_error(ErrorCode::ActionabilityFailed,OutcomeCertainty::NotStarted) — no input dispatched. - Field gate (
session::is_editable_field_kind): whenrequireEditableField=true, the resolved element must be an editable text-ish field (closed set:input[password|text|email|tel|url|search|number],textarea,contenteditable/role=textbox).checkbox/radio/button/submit/file/none→field_denied_error. The probe (EDITABLE_INPUT_PROBE_JS) runs on the same resolved handle (no re-query).
Order at the node: live-origin gate first, then element resolution + actionability, then the field gate. The node also wraps the typed value in Zeroizing (belt-and-suspenders) and the existing password-field journal redaction keeps it out of the journal.
The two enforcement signals are control→node internal wire fields only — SessionActRequest { originAllowlist, requireEditableField } (session_wire::SessionActRequest) — not part of the public /v1 ActRequest.
(d) Free-text {{credential:}} is denied-by-default behind a per-org flag
The legacy path — a {{credential:NAME}} marker inside POST /v1/sessions/:id/act text — is deprecated. sessions_golden::resolve_credentials reads governance_settings.credential_freetext_fill (default FALSE); if disabled it returns Forbidden(FREETEXT_DENIED_SENTINEL) before any session lookup, decrypt, or node dial. Re-enabling is an org:admin-gated PUT /v1/governance decision. The structured path does not call this resolver and is unaffected.
What is enforced WHERE
| Check | Layer | Implementation (file::fn) |
|---|---|---|
Scope: sessions:drive | control | sessions_golden::authorize_session → ctx.require_scope("sessions:drive") |
| Org-ownership (404, no oracle) | control | sessions_golden::authorize_session — count(*) … WHERE id=$1 AND org_id=ctx.org_id |
| Bound-identity (else 400/404) | control | sessions_golden::resolve_named_credential / sessions_golden::resolve_credentials |
JIT decrypt, fail-closed into Zeroizing | control | sessions_golden::decrypt_credential (st.objects.open(org_id, …)) |
| Free-text deny-by-default (A3) | control + db | sessions_golden::resolve_credentials — gate on credential_freetext_fill |
| Fill-time origin intersection (C3) | control | sessions_golden::effective_fill_origins — credential ∩ winning-live-grant; disjoint = hard deny |
| Live-origin allowlist (anti-TOCTOU) | node | text_input::live_origin_on_allowlist |
| Editable-field gate (A1) | node | session::editable_input_kind / session::is_editable_field_kind |
Forward of originAllowlist + requireEditableField | control→node wire | session_wire::SessionActRequest, bound and consumed by the generated client |
org:admin to toggle the free-text flag | control | governance::put → ctx.require_scope("org:admin") |
| Value-free audit (best-effort) | control + db | audit::credential_audit → security_log |
Error map (structured path), all value-free: 403 missing sessions:drive; 404 cross-tenant/unknown session (no existence oracle); 400 anonymous session (“no bound identity”); 404 unknown credential NAME; 500 “decryption unavailable”/“decryption failed” (cause hidden); 403 GRANT_ORIGIN_DENIED_SENTINEL (disjoint intersection — secret never forwarded); node-relayed field_denied/origin_denied surfaced as the node’s 422-derived error. (Legacy path differs in two places: unknown name is 400 not 404, and free-text-disabled is 403 FREETEXT_DENIED_SENTINEL.)
3. Grant authority & constraints
A grant authorizes one principal to assume or manage an identity. Grants are authored on identities:write endpoints (POST/GET/DELETE /v1/identities/:id/grants, plus …/grants/:grant_id/revoke) — grant authoring is deliberately an owner/admin capability off the autonomous MCP surface.
Schema and the constraint columns
Base grant columns live in migration 0003_identities_environments.sql; the ADR-029 Lane C constraints are added (all additive, expand/contract-safe, no backfill) in 0022_identity_grant_constraints.sql:
expires_at(NULL = never),revoked_at(NULL = active)allowed_origins TEXT[] DEFAULT '{}'(empty = no grant-side narrowing)propagation TEXT DEFAULT 'descendants' CHECK IN ('none','same_conversation','descendants')max_session_duration_secs BIGINT(NULL = 1h platform default; records/caps lease TTL, does not force-end a live session)created_by_user_id,reason(audit only)- partial index
identity_grants_live_idx (identity_id, principal_agent_id) WHERE revoked_at IS NULL
CHECK num_nonnulls(principal_user_id, principal_org_role, principal_agent_id) = 1 — exactly one principal. Only the principal_agent_id grant is enforced at bind today (the /v1 path is key-based); user/org-role grants are stored and listed but not yet checked.
Most-restrictive-live-grant selection
A grant is live iff revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()), compared DB-side against now() (skew-safe). Both chokepoints compute “most restrictive live grant” independently, with orderings designed so that adding a tighter grant tightens and a stale broad grant never defeats a new narrow one:
- Bind-time (
http/sessions.rs::open_session_inner): orders by(propagation='none') DESC, (expires_at IS NULL) ASC, expires_at ASC, (max_session_duration_secs IS NULL) ASC, max_session_duration_secs ASC, created_at DESC— optimizing propagation/duration. - Fill-time (
sessions_golden::effective_fill_origins): orders byexpires_at ASC NULLS LAST, (cardinality=0) ASC, cardinality ASC, created_at DESC— optimizing origin narrowness, independent of the bind winner.
Bind-time vs. fill-time
Bind-time (when a session assumes an identity) enforces whether you may embody this identity at all:
- Existence: a coarse
EXISTS(… grant_role IN ('assume','manage'))→403 "agent not granted to assume identity". - Liveness: the winning-live SELECT;
winning=None→ distinguishgrant_expiredvsgrant_revokedvalue-free, writegrant.bind_denied,403 "identity grant not live (…)". - Propagation vs. assurance (see below).
- Lease TTL cap:
expires_at = now() + LEAST(interval '1 hour', make_interval(secs => COALESCE(cap, 3600)))— NULL cap reproduces today’s 1h byte-for-byte.
Fill-time (when a stored secret is typed) enforces where the secret may land: effective = credential.origin_allowlist ∩ winning-live-grant.allowed_origins.
- Empty/absent grant list ⇒ no narrowing ⇒ the credential’s own list verbatim (today’s behavior).
- Disjoint (empty intersection) ⇒ hard deny
Forbidden(GRANT_ORIGIN_DENIED_SENTINEL); the plaintext is dropped on the early return, never forwarded.
A grant can only ever tighten where a credential may fill, never widen it — the policy narrows monotonically.
Soft revoke
POST /v1/identities/:id/grants/:grant_id/revoke stamps revoked_at = now() (idempotent via WHERE revoked_at IS NULL — a second revoke leaves the original timestamp). Future binds fail closed at the liveness check and fill-time intersection skips the grant — but an already-live session keeps its lease until it ends (revoke ≠ force-end; force-end is a separate, out-of-lane identity-lock capability). Soft revoke preserves the audit trail; the hard DELETE …/grants/:grant_id erases it.
How propagation='none' severs spawned-child inheritance
A spawn (admit_spawn, ADR-019) mints a child agent-instance principal but keeps actor_entity_id = the parent agent for node authority — so the child’s bind passes the same principal_agent_id grant check as the parent by construction. Admission arrives assurance = 'parent_asserted'.
The only thing that severs this inheritance is the winning grant’s propagation:
if admission.assurance == 'parent_asserted' && propagation == 'none'
→ grant.bind_denied(propagation_blocked) + 403 "grant propagation='none' blocks a spawned-child bind" The direct create path (assurance = 'direct', admit_session) is never blocked by propagation. In v1, same_conversation and descendants both permit a spawn — the server cannot yet distinguish a same-conversation child from a deeper descendant (see §9).
4. The OAuth Resource Server
ADR-020 Option C: control is both Authorization Server and Resource Server. A per-developer-per-workspace, agent-bound, least-privilege OAuth credential replaces the shared static njk_. Tables: 0021_oauth_resource_server.sql (+ 0023_oauth_org_cascade.sql; Dynamic Client Registration governance in 0025_oauth_dynamic_client_registration.sql).
Token model
| Token | Wire prefix | TTL | At rest |
|---|---|---|---|
| Access | njo_<base64url(32B)> | 15 min | oauth_tokens.hashed_token = sha256, kind='access', audience bound |
| Refresh | njr_<…> | 30 days | sha256, kind='refresh', single-use rotation |
- Token rows are
ntok_<uuid>; the grant id isnkey_<uuid>-shaped (deliberately — it slots intosessions.opened_via_credential_id/Admission.presenter_credential_idwith no schema change → audit continuity with theapi_keysshape). - Audience-bound (RFC 8707):
verify_accessrequirest.audience = st.mcp_resource_uri; a token minted for another resource fails (audience_mismatch_does_not_verify). - Rotation:
rotate_refresh(tokens::rotate_refresh) selects the refreshFOR UPDATE, mints a replacement pair, then stamps the oldrevoked_at+refresh_rotated_to. Re-presenting a rotated-out refresh finds no live row →400 invalid_grant(reuse detection). verify_access(tokens::verify_access) is a fail-closed JOINtoken → grant → orgwith all expiry/revocation/audience predicates DB-side vsnow(). A token can never outlive its grant (g.revoked_at IS NULL) or its org (o.deleted_at IS NULL).
Deterministic prefix routing — no fallback (downgrade-oracle avoidance)
AuthContext::from_request_parts (auth::from_request_parts) strips Bearer then dispatches the raw token once by prefix:
njk_→api_keysJOIN path only.njo_→oauth::tokens::verify_accessonly.- neither →
Unauthorized.
An invalid njo_ never queries api_keys; an invalid njk_ never queries the oauth tables. There is no fallthrough between arms — no downgrade or timing oracle. Both arms fail closed to the same Unauthorized shape, and prefix mutual-exclusivity is unit-asserted.
Scope clamp at provisioning
Two clamps exist, and only the first is a hard floor. The least-privilege floor is auth::oauth_default_clamp, shared by every clamp site so the floor is defined in ONE place; the set it keeps is claude_code_default_scopes() = {sessions:write, sessions:drive, identities:read}, and that it excludes write/admin is proven by claude_code_default_scopes_exclude_write_and_admin_capabilities. Since the ADR-029 owner-consent addendum (2026-07-17), auth::clamp_consented_scopes can elevate beyond that floor: an owner-consented grant can carry a dangerous scope such as keys:write — asserted by clamp_consented_scopes_is_bounded_by_the_role_ceiling. It is called from oauth::provision (both the mint and the grant-reuse path) and from oauth::tokens for the refresh rebound. Elevation is triple-gated — the role must carry the scope, the human must consent, and the result is the INTERSECTION with role_scopes(role) — and the dangerous members are itemized and flagged on the consent screen the human approves, proven by consent_screen_itemizes_elevated_and_flags_dangerous. The elevation is separately audited value-free after approval (:345 records only the COUNT of dangerous scopes, never their names). No new scope was registered — the closed-registry invariant is intact.
Human-session-gated issuance
All issuance is gated on a signed-in human; org/agent are never caller-asserted. /oauth/authorize uses the nj_session cookie (Option<SessionUser>); no cookie → 302 to {public_url}/auth/github/login. The org is resolved only via resolve_active_org(memberships, X-Nightjar-Org). PKCE is S256-only; auth codes are single-use (consumed_at IS NULL conditional UPDATE, single-winner). Redirect URIs are pinned to loopback/oob at both authorize and token (oauth_rs::is_permitted_redirect) — exact 127.0.0.1/localhost host split on :, rejecting localhost.evil.com, https, javascript: ([::1] deliberately excluded for alpha).
Org-purge cascade
oauth_clients/oauth_grants/oauth_tokens are in purge::ORG_SCOPED_TABLES. Migration 0023 makes the intra-oauth client_id FKs (grants, auth_codes) ON DELETE CASCADE — Postgres does not topologically sort cascaded deletes (pg#18064), so a NO-ACTION FK would abort an org purge (half-purge: KEK crypto-erased but rows fail to delete). the fk_cascade_completeness_guard isolation test enforces that the cascade set equals the purge set.
Dynamic Client Registration (RFC 7591)
POST /oauth/register (migration 0025) lets a generic OAuth/MCP client that ships no pre-registered client_id (Codex, Gemini, a custom client) self-register a PUBLIC PKCE client (ncli_<uuid>, org_id NULL, token_endpoint_auth_method='none') so it can run the same login flow as the seeded claude-code client. The endpoint is public and unauthenticated by design — registration is the machine bootstrap before any human is in the loop — which is safe only because registration confers no authority:
validate_registrationrejects confidential clients (token_endpoint_auth_methodmust benone) and pins everyredirect_urito loopback/oob by REUSINGis_permitted_redirect— the exact same contract as the built-in client. A non-loopback redirect is rejectedinvalid_redirect_uri; ascopein the request is non-authoritative (clamped at issuance).- A token still requires a real human GitHub session + consent at
/oauth/authorize, and scopes are still clamped toclaude_code_default_scopes(). A registeredclient_idis only a redirect-pinning handle — it cannot, on its own, obtain a token or any authority. - A per-IP
DcrRateLimiterboundsoauth_clientsrow spam (control has no general-purpose limiter, so the endpoint carries its own). - Kill-switch
NIGHTJAR_OAUTH_DCR_ENABLED(→st.oauth_dcr_enabled, default on): when off, the route404s and the AS metadata dropsregistration_endpoint(prm). - Operators govern registered clients out-of-band with
nightjar-control list-oauth-clients/revoke-oauth-client --id ncli_…. Revocation stampsoauth_clients.disabled_at(a soft-disable — a hardDELETEwould hit theoauth_grants.client_idFK) and revokes the client’s grants; the built-inclaude-codeclient is refused. Every client-resolving path treats adisabled_at IS NOT NULLclient as absent (validate_client_redirect).
The gateway stays thin
The MCP gateway’s entire OAuth involvement is emitting one WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource" header on a 401 (mcp::resource_metadata_url). It runs no auth logic, holds no secret, and adds no request-path state. All authZ lives in control’s /v1 + verify_access.
5. The value-free audit model
Every credential.* and grant.* operation records ids, placeholder NAME, host, field-kind, and reason-code only — never the secret. All rows go through credential_audit(st, org_id, op, actor_key_id, outcome, detail) (audit::credential_audit) → INSERT security_log(org_id, operation, actor_key_id, outcome, detail).
| Op | When | detail (value-free) | Outcome |
|---|---|---|---|
credential.fill | secret resolved + node accepted | identity=<id> placeholder={{credential:NAME}} | ok |
credential.assume | session bound an identity | identity=<id> session=<id> | ok |
credential.origin_denied | (a) node live-origin refusal … host=<denied-host>; (b) control C3 disjoint … reason=grant_intersection | ids + host/reason | denied |
credential.field_denied | node refused non-editable target | … field_kind=<button\|checkbox\|none\|…> (a DOM label) | denied |
credential.freetext_denied | free-text path refused (flag off) | placeholder={{credential:NAME}} reason=freetext_disabled (before any decrypt) | denied |
grant.bind_denied | bind chokepoint deny | identity=<id> grant=<id?> reason=<grant_expired\|grant_revoked\|propagation_blocked> | denied |
Key properties:
- Best-effort: a failed
security_logINSERT logs a warning and is swallowed — a successful fill must never500on an audit failure. org_idis always the authenticated caller’s org;actor_key_idisctx.key_id(empty string for a human member with no key).- The decrypted value is structurally absent as an input to every
detailhelper — it lives only inZeroizing/resolved.textand is dropped before this module is reached. security_logis FK-less by contract and survives org hard-purge (a GDPR Art.17(3) record).- The OAuth RS path writes no
security_logrows at all (verified by grep); it emits only value-freetracing::info!(user/org/client/scopes — the clamped vocabulary, never the secret code).
6. The secret-never-to-model guarantee
Trace the plaintext from rest to keystroke:
identity_credentials.value_encrypted (sealed, per-org KMS envelope, base64)
│ decrypt_credential(st, org_id, …) — fails closed
▼
Zeroizing<String> (control, in-process only)
│ placed ONLY into the forwarded node JSON body {text:…}
▼
control→node TLS → node Zeroizing buffer → live-origin + field gates → keystroke
│
▼ drop(plaintext) on control BEFORE audit/return; node zeroizes after type
(gone) The plaintext does not appear in any of these places:
- The model —
credential_fillhas notext/valuefield by construction; the model names a credential and never sees a value. - The journal — node wraps the typed value in
Zeroizing; the existing password-field journal redaction keeps it out. - Screenshots — the secret is typed into a field; no API ever returns the cleartext for capture, and the fill flow does not screenshot the value.
- The audit — every
detailcarries ids/placeholder-NAME/host/field-kind/reason only; the secret is structurally absent input. - API responses — reads return only
masked_value(last-4).value_encryptedis never SELECTed by any read handler (CredentialRow/AssumableSlotRow/SELECT_CREDENTIAL/SELECT_ASSUMABLE_SLOThave no field for it). Discovery (/v1/identities/assumable) returns slot metadata only. - The node after zeroize — the
Zeroizingbuffer is cleared on drop once the keystroke is dispatched.
7. Threat model
| Threat | Mitigation | Residual risk |
|---|---|---|
| Prompt-injection steers a paste (model tries to dump a secret into a chat/comment/search box) | The model can never hold the secret (credential_fill has no value field); it can only name a credential + selector. The node refuses a non-editable target (field_denied) and any off-allowlist origin (origin_denied) before the keystroke. | A secret can still be typed into a legitimately-editable field on an allow-listed origin if the model picks the wrong selector there — bounded to that origin’s editable inputs; form-action/iframe gating is deferred (§9). |
| Malicious content on an allow-listed origin | Origin allowlist is the primary mitigation; the field gate additionally requires an editable text-ish input. | The lane gates origin + input-type only. A compromised allow-listed page that presents a password field still receives the fill. Mitigated operationally by keeping allowlists narrow; form-action-origin gating deferred. |
| Origin spoofing (dot-boundary) | Node match is exact host or strict live_host.ends_with('.'+entry); evilgithub.com ∉ github.com. Unparseable/about:blank/empty URL denies. | No public-suffix list in v0 — an attacker who controls a true subdomain of an allow-listed apex (evil.github.com when github.com is listed) is permitted. List apexes you trust narrowly. |
| Scope escalation | Closed scope registry; OAuth clamp at 3 points to {sessions:write, sessions:drive, identities:read}; the DEFAULT clamp holds no write/admin scope, and an owner-consented grant may carry one only through the triple-gated clamp_consented_scopes path (ADR-029 addendum); every /v1 endpoint has a require_scope gate. | A new endpoint that forgets its require_scope gate silently reopens a gap (registry-level, not OAuth-level) — caught only by review/tests. |
| Token / audience confusion | Deterministic prefix routing with no fallback; RFC 8707 audience binding checked DB-side; njk_/njo_ never cross stores. | If a deploy’s NIGHTJAR_MCP_RESOURCE_URI diverges from the mint-side audience byte-for-byte, every njo_ token 401s (fail-closed, availability not confidentiality). Mint+verify both read st.mcp_resource_uri, so they agree by construction. |
| Ambient-parent trap (a spawned child silently inheriting parent authority) | Spawn admission is parent_asserted; propagation='none' on the winning grant refuses the inherited bind. Lease single-writer PK prevents double-embodiment. | In v1 same_conversation is not distinguished from descendants — both permit a spawn; only none blocks. A deep descendant is treated like a same-conversation child until that enforcement lands (§9). |
Shared-key attribution ceiling (parent_asserted) | Per-developer-per-workspace OAuth grant gives each developer a distinct agent-bound credential; the grant id (nkey_) is the presenter handle for audit continuity. | The v2 delegation broker / child_key_proven (host-isolated keys) is deferred — until then, a child’s authority is asserted by its parent, not cryptographically proven (§9). |
8. DELICATE INVARIANTS — DO NOT BREAK
A future contributor must preserve all of the following. Each is load-bearing for confidentiality or tenant isolation.
credential_fill(the MCP tool andCredentialFillRequest) must never gain atext/valuefield. The model naming a credential — never a value — is the structural guarantee that prompt-injection cannot place a secret.additionalProperties:false+ exactly{sessionId, credentialName, selector}is mandatory; the generated schema andbuild_callare drift-gated.- The node must field-gate AND origin-gate BEFORE the keystroke, default-deny, on the same resolved handle, with the live-origin gate running first. Never dispatch input before both pass.
- Origin matching stays exact-host-or-strict-dot-boundary;
evilgithub.commust never matchgithub.com; an empty allowlist or unparseable URL must deny. - Effective fill origins narrow monotonically:
effective = credential.origin_allowlist ∩ winning-live-grant.allowed_origins. A grant may only tighten, never widen; a disjoint intersection is a hard deny and the plaintext is dropped before the early return — never forwarded. njk_andnjo_must never fall through to each other. Routing is deterministic by prefix with no fallback; an invalid token of one kind must never query the other store, and both must fail closed to the sameUnauthorized.- OAuth scopes are clamped to
claude_code_default_scopes()at every mint point (authorize, device, provision) against one source of truth. A grant carries a write/admin scope ONLY via explicit, server-observed owner consent, bounded by the human’s own role (ADR-029 owner-consent addendum, 2026-07-17). No new scope is registered for this lane. org_idis alwaysctx.org_id, never a request value — at the ownership gate, every credential/identity/grant lookup, and the KMS envelope open (st.objects.open(org_id, …)). Org B must never resolve, decrypt, or fill org A’s credential.- Crypto fails closed. No KMS object store →
Internal "decryption unavailable", never an unencrypted forward. The decrypt failure cause is never surfaced to the client. - A secret must never appear in an audit
detail(nor journal, screenshot, or any API response beyondmasked_value). Everydetailhelper takes ids/name/host/field-kind/reason only; the value is a structurally-absent input. - Audit is best-effort and must never change a request’s outcome. A failed
security_logINSERT is swallowed; a successful fill must not500on an audit failure. - Legacy free-text
{{credential:}}stays denied-by-default (credential_freetext_fill DEFAULT FALSE); a denial must occur before any decrypt or node dial; onlyorg:adminmay re-enable it. The structured path must not callresolve_credentials. - OAuth public clients stay pinned to loopback/oob at both authorize and token; an unknown client or non-permitted redirect must render the error directly (never
302to an unvalidated URI). - No cross-tenant existence oracle: cross-org and totally-unknown ids return the same
404;delete_identityorders ownership (404) before the live-lease409. - The grant id stays
nkey_-shaped so it slots intosessions.opened_via_credential_id/Admission.presenter_credential_idunchanged (audit continuity). - Org purge cascades the oauth tables with no half-purge: the intra-oauth
client_idFKs must stayON DELETE CASCADE, andpurge::ORG_SCOPED_TABLESmust equal the cascade-guard list. - The two denial sentinels stay pinned across node and control:
FIELD_DENIED_SENTINELandORIGIN_DENIED_SENTINELequal intext_input.rsand theaudit.rsclassifiers;GRANT_ORIGIN_DENIED_SENTINELandFREETEXT_DENIED_SENTINELdistinct. Tests drift-gate these. - The MCP gateway stays thin and stateless — no secret, no auth logic, no crypto; its only OAuth touch is the
WWW-Authenticatechallenge string. - Self-registration (RFC 7591
POST /oauth/register) confers no authority. A registeredclient_idis only a redirect-pinning handle: every registeredredirect_uristays pinned to loopback/oob via the sameis_permitted_redirect, only public-PKCE (token_endpoint_auth_method='none') clients are accepted, a token still requires a real human consent at/oauth/authorizewith scopes clamped toclaude_code_default_scopes(), registration is per-IP rate-limited andNIGHTJAR_OAUTH_DCR_ENABLED-gated (404when off), and a disabled (disabled_at) client must be treated as absent at every login leg.
9. The agent credential store (ADR-056)
Sections 1–8 describe where a credential lives on the server and how it reaches a page. This one describes where the agent’s own Plane-1 credential lives on the caller’s disk, and what that storage guarantees.
⚠ Status, stated precisely because the rest of this section is about guarantees that already hold. The store itself is merged (
nightjar-credential-store: theCredentialStoretrait and theFileStorefile backend), and its root and override are now fixed in the CLI. No user-facing command uses it yet — theauth loginsurface that populates it is a later slice and is not onmain. So read this as a reviewer’s account of a shipped mechanism, not as a workflow you can run today; the current documented way to present a platform credential is unchanged.
Why a store rather than an environment variable
The motivating incident is recorded in the crate’s own module doc: a credential routed through an environment variable resolves through a process tree and freezes at each process’s launch. Two consequences follow, and both are properties of the delivery mechanism rather than of the secret:
- Rotation propagates only by restart. Every already-running process holds the value it was launched with, so “rotate” means “restart the tree” and a missed process keeps presenting the old credential indefinitely.
- Neither side can observe which credential the server received. The presenter is opaque to the caller and unattributable to the server, so an incident cannot be reconstructed after the fact.
The store is read at request time rather than at launch time, which is what makes rotation a write rather than a restart.
Where the store lives, and what it does when it cannot tell
| Root | ~/.nightjar/credentials — $HOME on POSIX, %USERPROFILE% on Windows |
| Override | NIGHTJAR_CREDENTIALS_DIR, pointed at a directory only you can reach |
| Home unset | An error. There is no fallback, on purpose. |
An empty override is treated as unset, not as “the current directory” — a blank environment
variable is overwhelmingly an unset-it-wrong, and resolving it to . would put a credential store
wherever the shell happened to be standing.
The third row is the one worth reading twice, because it is a deliberate inconsistency with the
rest of the CLI. Nightjar’s other directories resolve home through a helper that falls back when the
variable is missing — to C:\Users\default on Windows, /tmp on POSIX. For a profiles directory that
is untidy. For a credential store it is the vulnerability, and the refusal says so in the message the
operator actually sees:
cannot locate the credential store:
$HOMEis unset, and unlike other Nightjar directories this one has no fallback on purpose — a credential store created under a world-writable path would be readable during its own setup. Set$HOME, or setNIGHTJAR_CREDENTIALS_DIRto a directory only you can reach.
Note precisely what the fallback would cost, because it is not “secrets end up somewhere surprising”. The backend creates its root and restricts it owner-only — but under a world-writable parent, the root’s own creation window sits inside a directory anyone can write, which defeats the create-owner-only guarantee in the table below before that guarantee gets to apply. The failure is in the setup, not in the steady state, so it would never show up in an inspection of the finished permissions.
A fallback is a convenience for a cache and a defect for a secret. Reaching for the shared helper is the correct instinct everywhere else in the same file; what changes is not the code but the threat model of the new caller, and nothing about reading a path helper tells you which case you are in. The error names the variable because that is the part an operator can fix — a silent relocation is the one outcome they cannot.
What the file backend actually guarantees
Each row is implemented in nightjar-credential-store/src/file.rs; treat the symbol as
authoritative per this document’s opening note.
| Guarantee | How it is obtained |
|---|---|
| Owner-only permissions, with no race window | The temp file is created owner-only from the moment it exists (create_owner_only), never created-then-chmod-ed. The alternative leaves a window in which the file exists readable. |
| Permissions are observed, not merely requested | After creation the backend re-reads the permissions actually in effect and confirms they are owner-only. In the crate’s words: “Setting them is a request; this is the observation.” |
| Durable, atomic replacement | Write to the temp file, fsync, then rename into place — so a reader sees either the old record or the new one, never a partial write. |
| Compare-and-swap on a generation | replace_profile(id, expected: Generation, next) -> Generation. A caller that believed a stale record fails the check rather than blind-overwriting; expected == 0 is the create case. |
| Locking per stored record, held across the whole refresh | acquire_profile_lock returns a guard released on Drop. Its contract is explicit that the lock is held across the entire network refresh, not merely the file write — a lock scoped to the write alone would still allow two concurrent refreshes to race. (The store’s ProfileId names a stored credential record; it is unrelated to the browser profiles managed by the profile command, which are a different thing that happens to share the word.) |
Secrets do not leak through Debug | Secret’s Debug prints Secret(<redacted>) and withholds the length too — the crate’s reasoning being that a length narrows a guess and buys the reader nothing. Pinned by tests, not convention. |
The invariant this section adds
A credential’s storage is part of its security model, not an implementation detail. Permissions that were requested but never verified, a write that can be observed half-finished, and a value recoverable from a debug log are each sufficient to lose a secret that every server-side control in sections 1–8 handled correctly.
This is the same shape as the cross-plane invariant in section 1: the weakest link decides, and it is usually the one nobody classed as security-relevant.
10. Explicitly deferred
Named honestly in ADR-029 — schema or hooks may be present but enforcement is not:
| Deferred | Status | Residual risk |
|---|---|---|
| Form-action / iframe origin gating | Not built — the lane gates origin + input-type only. | A fill into an editable field on an allow-listed page whose <form action> posts cross-origin (or inside a hostile iframe) is not blocked. The origin allowlist is the primary mitigation; keep allowlists narrow. |
| Per-fill / first-use human confirmation | Deferred (a host-side concern). | No interactive “are you sure” before a secret is typed; the structured tool + gates are the only barrier. |
The v2 delegation broker / delegations:exchange / child_key_proven | Reserved, not built; lane D is the token-forwarding-host ceiling. | A spawned child’s authority is parent_asserted, not cryptographically proven; attribution bottoms out at the parent agent until host-isolated child keys land. |
same_conversation enforcement | Treated identically to descendants at bind (server can’t yet distinguish). | A deep descendant is permitted wherever a same-conversation child would be; only propagation='none' blocks a spawn today. |
| TTL-aware reaper | max_session_duration_secs records/caps the lease expires_at but the reaper is liveness-based, not TTL-based. | A still-live session is not force-ended at its TTL horizon; the column is schema-present and dormant for force-end. |
allowed_credential_names / egress-mode grant columns | Not added. | A grant cannot yet restrict which named credentials it covers — assume/manage is identity-wide. |
| Browser e2e for the field/origin gates | Deferred (node-harness gap). | The node gates are proven by unit + Postgres tests (tests/credential_fill.rs), not a live browser e2e — a node-harness regression could slip a gate without a failing browser test. |
slow_down device-flow backoff | Mentioned in a comment, not implemented (authorization_pending/expired_token only). | Harmless minor RFC 8628 gap. |
[::1] IPv6 loopback redirect | Deliberately excluded for alpha. | An IPv6-only loopback client cannot complete the auth-code flow; use 127.0.0.1/localhost. |