packages/credential-broker/README.md

@clankie/credential-broker

The credential broker is the local secret and short-lived capability boundary. Provider credentials stay in the macOS Keychain by default; trusted adapters receive signed grants that name only their principal, profile hash, allowed capabilities, optional resources, and expiry.

The operator-facing credential guide owns token names, bot-versus-user setup, rotation, and the distinction between Discord account credentials and local Clankie bearers.

Managed evidence (offline slice)

evidence-storage is a dedicated internal typed credential, keyed by installation, destination and read/write purpose. Both backends validate it, lock mutations and publish their existing revision witnesses. Generic list() omits it; the dedicated summary returns only type/enabled, and model/bearer adapters cannot export it. The body owns nonce-bound encrypted reply decoding, half-life renewal and durable admission/tombstone fences. No production issuer is installed; see ADR 0297.

Clankie account refresh

The public gateway account provider coalesces concurrent token reads. Before refreshing, it probes the Cognito endpoint with an unauthenticated HEAD; a network failure or unavailable endpoint leaves the token untouched for connector backoff. The refresh request has a ten-second timeout. Uncertain delivery, including a lost response body or HTTP 5xx, gets three retries after 250, 500, and 1,000 ms with the same refresh token. A 50-second wall-clock deadline reserves margin inside the account client's 60-second rotation grace; timers resumed past that deadline return to normal reconnect backoff. This cannot recover a rotation lost across sleep longer than the grace period.

A received replacement is persisted before validating the access token. Rate limits remain retryable without the quick retry burst; explicit refresh rejection requires owner sign-in. The connector publishes that state once and exposes it through gateway status and doctor. Offline startup derives the route from the stored account identity and resolves tokens inside the connector's retry loop.

Credential storage

  • KeychainCredentialStore stores one generic-password item per provider and a serialized provider index. Operations sharing a service are locked across CLI/service processes and in-process store instances. Creation writes the index before the secret, and deletion removes the secret before pruning the index, so a partial failure cannot leave an unindexed credential behind.
  • FileCredentialStore is the non-macOS/CI fallback. It writes atomically with collision-proof temporary names, locks same-path writers across processes, enforces mode 0600 inside a mode-0700 directory, and never returns secrets from list().
  • createDefaultCredentialStore() selects Keychain on macOS. Setting CLANKIE_CREDENTIALS_FILE explicitly selects the file backend.
  • Credential summaries pass through redactCredential() before entering a UI or structured log.

Both stores implement update(providerId, transform): read, transform and persist an existing credential while holding the same lock as set/delete. Missing entries remain missing. The transform returns a new credential to change it, or the unchanged input; it must not call back into the store. Linear MCP bearer refresh and Pi's model OAuth refresh (through BrokerCredentialStore.modify) use this operation, rechecking expiry after taking the lock. Concurrent clients, including separate service processes, spend one rotating refresh token once. A failed refresh only falls back to an access token that has not expired; storage/locking failures propagate.

Disconnect waits for an admitted refresh, then deletes its result. Once disconnect completes, that refresh cannot restore the account. Replacement follows the same ordering. Token requests have a 20-second deadline. One lock covers the whole file or Keychain service, so a refresh can briefly delay unrelated credential writes. Keychain reads share a process cache (including missing entries) and single-flight reads across store instances. Each account has a secret-free revision witness; broker writes, rotations and deletes invalidate only that account across processes. Read-modify-write operations validate the publication witness under their mutation lock, re-reading only changed entries (including after waiting for another writer). Direct edits outside the broker require a process restart; use the broker's rotation commands. A pending or ambiguous failed publication refuses cached authorization. A write refused by the locked-keychain preflight preserves the unchanged cached credential.

A native, noninteractive SecKeychainGetStatus check precedes every cold read/write. A locked keychain spawns no security process or unlock dialog. A separate OS-account I/O lock serializes bounded security commands across processes. Noninteractive lock checks run before that lock so a locked CLI fails promptly; it never spans OAuth network requests. Commands time out after three seconds. Uncached read failures back off for 30 seconds across the store's accounts. Cached credentials remain usable while locked; Clankie never unlocks the keychain himself. Doctor exposes one credentialStoreHealth state, and the service records one owner notice per incident in the default conversation, visible in the app. Unlock in Keychain Access; the next cold read after backoff clears the incident.

Single-item Keychain reads do not acquire the OAuth write lock: Keychain atomically publishes each item, so a hook reading the operator bearer need not wait for an unrelated OAuth network refresh. A read can observe the previous or next complete credential; callers that refresh or modify credentials must still use update. Index enumeration and mutations remain serialized. proper-lockfile (also used by Pi) maintains and releases the lock; Keychain lock directories under ~/.config/clankie/credential-locks contain no credentials. All writer processes must run this implementation; restart older CLI/service processes to activate this boundary. This protects broker mutations, not calls already admitted to a provider. The service's MCP host separately checks the selected credential and replaces existing HTTP/stdio transports when it changes.

The broker is canonical, but provider consumers retain a compatibility fallback: when no broker entry exists they may read the provider's declared API-key environment variable. The clankie service also fills absent keys from the gitignored root .env.local; an existing shell value wins. Discord account tokens, bridge identities, the activity producer, and play voice do not use that fallback and reject their forbidden environment names. CLANKIE_OPERATOR_TOKEN and CLANKIE_CAPTAIN_TOKEN remain explicit test/CI overrides.

Local operator credential

The generic-password broker does not establish human presence for each action or isolate an operator bearer from a shell running as the same OS user. Proposed ADR 0253 requires separate protected authority and non-exportable signing; it is not implemented by this package. Never represent a software/FileCredentialStore fixture as Secure Enclave or isolated-enforcement proof.

The broker owns the clankie_operator bearer used by trusted local operator surfaces. The first service start and the fullscreen launcher mint 256 bits of cryptographic entropy when the entry is absent. The clankie service and TUI resolve that same entry automatically; CLANKIE_OPERATOR_TOKEN is an explicit CI/test override and is never persisted by bootstrap.

clankie health reports only presence, selected source, and whether an env override matches the stored value. clankie operator-credential rotate atomically replaces the broker entry without printing it. The service resolves the entry at each authenticated operator request, so rotation rejects the old token immediately and the next CLI request loads the replacement. Rotation refuses to run while an env override is active because a stored replacement cannot invalidate an independently managed environment secret.

The Keychain implementation invokes /usr/bin/security through execFile, not a shell. Secret JSON is passed as a single argv value because the CLI has no non-interactive stdin form; it is never placed in a child process environment or written to a plaintext config file.

Discord providers

discord_bot stores the official bot token as an API credential. The trusted presence adapter issues discord.presence.act or discord.presence.read grants bound to a principal, profile hash, expiry, and explicit discord:guild:* / discord:channel:* resources. The provider refuses resources outside its configured allowlists and resolves connection material only after a matching grant is verified. The token is never placed in a captain, service, or bridge process environment.

clankie_discord_bridge is a separate, broker-owned local bearer. The clankie service creates it on first start and authenticates it only as the discord-bridge captain on the discord_text lane. The bridge resolves it directly from the broker after the service starts. It is never supplied through CLANKIE_CAPTAIN_TOKEN, so Discord cannot inherit another captain's identity or source lane.

clankie_discord_voice_bridge is a second broker-owned local bearer with a distinct discord_voice service identity. Official-bot voice ingress cannot reuse the text bridge bearer, and the text bridge cannot submit a voice_event. The bridge resolves both internal credentials directly from the store after the service starts; neither enters an environment variable. OpenAI transcription and speech reuse the brokered openai API credential; Grok streaming transcription and speech reuse the brokered xai API credential. The selected key stays inside the voice process and is never exposed to the captain. OAuth entries do not satisfy the voice boundary. When the external voice is configured (ADR 0070), ElevenLabs speech synthesis uses the brokered elevenlabs API credential the same way — connection headers only, and the ELEVENLABS_API_KEY / XI_API_KEY environment names are hard startup errors in the bridge. OPENAI_API_KEY and XAI_API_KEY are likewise hard errors while voice is active.

discord_user_session holds the personal-lab normal-user credential (ADR 0048). DiscordUserSessionCredentialProvider mirrors the bot provider — expiring, resource-scoped grants exchanged only by the trusted transport adapter — and adds one gate the bot plane does not need: a durable owner opt-in bound to the recorded character and scope, re-checked at redemption so a revocation stops the very next action instead of waiting for grant expiry. A DISCORD_USER_TOKEN environment variable is a startup error in every process that could reach it.

clankie_discord_user_bridge and clankie_discord_user_voice_bridge are the user plane's local bearers, distinct from the bot plane's pair. The service derives transportKind from which bearer authenticated, so a request body cannot claim a transport it does not hold. All four bearer patterns are mutually exclusive: clankie_discord_ prefixes every one of them, and an unanchored match would let a user-plane bearer authenticate as the bot bridge.

Play voice bearer

clankie_play_voice authenticates only Clankie's hosted-world play loop to the active Discord body's loopback @clankie/play-voice listener. The Discord body owns first-run minting; play resolves the stored value. The bearer has no environment fallback, and CLANKIE_PLAY_VOICE_TOKEN is a hard error.

External harnesses neither receive nor depend on this credential. The retired clankie_possessor_voice id is not accepted as the current bearer; there is no path from owning an emulator process to hearing or speaking in Clankie's room (ADR 0129).

Capability boundary

Hosted account connections store GitHub device-flow credentials under github and registered Linear API OAuth under linear-api. The latter uses S256 PKCE, verified app/workspace identity, actual granted scopes and locked refresh. It must never be used as the MCP-audience linear credential. Broker-only github-oauth-app may hold only an owner-run self-hosted body's own OAuth app secret. Hosted bodies never receive Clankie's shared developer secret; their GitHub disconnect deletes local access and returns a permission-management URL. Self-hosted GitHub revocation affects only the stored token, never the app's whole grant. Public client configuration lives in settings/bootstrap. Disconnect clears pending flows and deletes local credentials, reporting actual provider revocation separately. See ADR 0232.

API and OAuth records optionally carry a verified provider account: connection UUID, provider user/workspace IDs, email, names and verification time. verifyLinearApiAccount uses Linear's API-key GraphQL identity query. The MCP host verifies MCP-audience OAuth through the official server's get_user and get_workspace tools under the broker mutation lock. Display names alone do not establish identity. Redacted summaries include account metadata; refresh preserves it. Clankie's worker MCP boundary reuses CapabilityTokenIssuer, with its signing key stored as clankie_worker_mcp_signing. Grant records and durable revocation live in service state. See worker access.

CapabilityTokenIssuer signs and verifies bounded HMAC grants. Resource-scoped grants require the caller to present an exact resource; omitting a resource does not widen a grant. Tokens are rejected before issuedAt and at or after expiresAt, use canonical base64url encoding, and have a maximum 15-minute lifetime. Signed obligation identifiers travel with the grant so a consumer receives the constraints attached to the original allow decision. A profile-hash mismatch invalidates the grant immediately.

CapabilityTokenIssuer is a signing/verification primitive, not a runtime broker. Callers own issuance policy and any evidence they require; this package validates grant shape, lifetime, signature, and exact resource matching.