ADR-144: Agent API key shape (gyrum-go APIKey primitive)
Status: Proposed Date: 2026-05-06
Context
The fleet currently has one production bearer-token implementation —
warp's internal/auth package (see refs section). It mints
wrp_<32-hex-chars> tokens, stores SHA-256 digests in
warp_api_keys.key_hash, and resolves bearers via a primary-key
lookup that returns Identity{KeyID, Scope, Label}. The shape is
known-good: MintToken, HashToken, Lookuper, plus four
coarse-grained scopes (admin/user/agent/readonly).
EPIC warp#1450 (Phase 0) lifts this primitive into gyrum-go/pkg/auth
so other consumers (distill, future fleet services) can adopt the same
bearer-token contract without copy-paste. Phase 0 is three ADRs that
must lock before A1.1 implements the primitive:
- A0.1 (warp#1451, sister) — audit log shape.
- A0.2 (warp#1452, sister) — RBAC matrix (scope → permission).
- A0.3 (this ADR) — APIKey primitive shape.
This ADR fixes the externally-observable shape of the APIKey primitive
so A1.1 (gyrum-go implementation) and A2.* (warp adoption) can build
against a stable contract. It does not relitigate the wrp_…
format choice — that ship sailed when the warp keys table went into
production. It does not specify the implementation; that's A1.1.
Decision
The gyrum-go APIKey primitive is an opaque-bearer model with the following externally-observable shape, captured here as the contract A1.1 must implement and A2.* must adopt.
Token format
Plaintext tokens are wrp_ + 32 lowercase hex characters
(16 bytes / 128 bits of cryptographic entropy via crypto/rand).
The wrp_ prefix is the existing fleet convention; preserving it
means the warp keys table stays valid through the gyrum-go cutover
without re-mint. Future consumers MAY introduce their own prefix
(e.g. dst_ for distill) but the primitive itself MUST emit wrp_
unless explicitly configured otherwise — the prefix is a configurable
constant on the primitive, not hardcoded into callers.
Hash storage
Stored hash is the SHA-256 hex digest of the plaintext token. Plain SHA-256 (not bcrypt / argon2) because:
- Tokens are 128-bit random strings — there is no low-entropy password to slow-hash; brute-force against an HTTP endpoint is intractable for the lifetime of the universe.
- Every authenticated request needs an O(1) primary-key lookup, not a per-request CPU grind.
The primitive's Hash(plaintext) string is the single source of truth
for the hash function. Migrating the hash function in future is an
ADR-level decision and a database migration; it is not a per-consumer
choice.
APIKeyStore interface
The pluggable persistence port. A1.1 ships the interface; warp adapts
its existing keys table to satisfy it. Future consumers plug in
their own backend.
package auth
// APIKeyStore is the persistence port for APIKey records.
//
// Implementations MUST be safe for concurrent use. Lookup is on the
// hot path of every authenticated request and MUST be O(1) on the
// hash column (primary-key or unique-indexed).
type APIKeyStore interface {
// Create persists a new APIKey row. The plaintext is never stored;
// only spec.KeyHash is. Returns the assigned KeyID.
Create(ctx context.Context, spec APIKeySpec) (KeyID string, err error)
// Lookup resolves a plaintext-hash to an Identity. Returns
// ErrNotFound when no row matches; ErrExpired when the row is
// present but expires_at has passed; ErrRevoked is unreachable
// because revocation is a hard delete (see "Revocation" below).
Lookup(ctx context.Context, keyHash string) (Identity, error)
// Revoke hard-deletes the row identified by keyID. Idempotent:
// revoking an already-deleted key returns nil.
Revoke(ctx context.Context, keyID string) error
// List returns metadata (no plaintext, no hash) for keys matching
// the filter. Used by admin UIs and rotation flows. Pagination is
// a future concern; first cut returns all matching rows.
List(ctx context.Context, filter APIKeyFilter) ([]APIKeyMetadata, error)
}
// APIKeySpec is the input to Create. Plaintext is hashed by the caller
// (via auth.Hash) before reaching the store.
type APIKeySpec struct {
KeyHash string // SHA-256 hex digest, lowercased
Label string // human-readable, e.g. "agent: claude-sonnet-46"
Scope Scope // one of admin/user/agent/readonly per A0.2
ExpiresAt *time.Time // nil = no expiry (admin bootstrap only)
CreatedBy string // KeyID of the minting identity, or "system"
}
// APIKeyFilter narrows List results.
type APIKeyFilter struct {
Scope *Scope // nil = any scope
LabelLike string // SQL LIKE pattern; "" = any
}
// APIKeyMetadata is the safe-to-display projection. Never contains
// plaintext or KeyHash.
type APIKeyMetadata struct {
KeyID string
Label string
Scope Scope
CreatedAt time.Time
ExpiresAt *time.Time
CreatedBy string
}
Identity retains its existing shape (KeyID, Scope, Label) so
the warp middleware and any consumer middleware stay drop-in.
Scope grants at mint time
Scope grants follow the A0.2 RBAC matrix. The mint-time defaulting rules are:
- User-mint-own (a logged-in user mints a key for themselves) →
default
Scope = user. The user MAY downgrade toreadonly. The user MUST NOT upgrade toagentoradmin. - Admin-mint → admin MAY grant any scope, including
admin. Grantingadminis logged at the A0.1 audit-log level. - Programmatic-mint (a service mints on behalf of an agent it
is provisioning) → the calling identity's scope caps the granted
scope: an
agent-scoped service cannot mint anadminkey. This is enforced by the primitive, not left to callers.
Expiry policy
ExpiresAt is optional. The primitive itself does not impose a
default; defaults are a per-flow concern enforced one layer up:
- Agent tokens (minted at agent provisioning) → 90 days. Rotation is the rotation flow's job; expiry is the safety-net.
- User session tokens (minted at OAuth callback / magic-link redeem) → 30 days. Matches existing warp behaviour.
- Admin bootstrap tokens (the
bootstrap-adminrow inserted at first boot) → no expiry permitted. This MUST be an explicitExpiresAt = nilchoice and MUST emit an audit-log entry per A0.1; the no-expiry path is not a default — it is a decision.
Expiry checks happen in Lookup: an expired row returns
ErrExpired, which the middleware translates to
401 {"error":"key_expired"}. The expired row is not deleted on
expiry — it stays for audit-log correlation until explicitly revoked
or until a future cleanup job removes rows N days past expiry.
Revocation
Revocation is a hard delete of the row from the store. There is
no revoked_at tombstone column. Rationale:
Lookupmust remain a single-column primary-key hit. Adding arevoked_at IS NULLpredicate adds an index variant for no user-visible benefit.- Audit-log correlation is the audit log's job (A0.1), not the keys
table's. The audit log records
key_revoked(key_id, by, at); the keys-table row's job is to authenticate live requests. - A revoked key must stop working immediately. Hard delete is the simplest enforcement; tombstones invite "did the lookup forget the predicate?" bugs.
Revoke is idempotent: revoking a non-existent or already-revoked
KeyID returns nil. This makes rotation safe to retry.
Rotation
Rotation is Create(new) → Revoke(old) performed atomically by the
rotation flow. The order matters: mint-new-first ensures the caller
gets a working token before the old one stops working, so the rotation
flow can swap the secret in the consumer (file, env, vault) before the
old token dies. The window between Create and Revoke is the
"rotation window"; both tokens are valid during it. The window MUST be
bounded — rotation flows MUST Revoke the old key on success, and MUST
NOT leave the old key alive on failure-to-swap (the swap failure is
the caller's bug; the old key dying is the safety property).
warp-keymint (warp#1350, shipped) is the warp-specific rotation
flow that integrates with this primitive. Future consumers ship their
own rotation CLI on the same shape.
APIKey vs Session JWT
Both APIKey and Session JWT (warp's existing OAuth-callback /
magic-link redemption mints session JWTs in addition to keys) resolve
to the same Identity shape, so middleware is uniform. They differ
only in lifecycle:
- APIKey — opaque random bearer; revocable instantly via store delete; no claims (the row IS the claims). Used for: agent tokens, long-lived service-to-service, admin bootstrap.
- Session JWT — signed claims; lives until
exp; cannot be revoked mid-flight (until a session-deny-list ships, future ADR). Used for: short-lived browser sessions where a token-leak attack surface is the operator's session-cookie, not a long-lived key.
The two are siblings, not a hierarchy. The same Scope semantics and
the same Identity shape apply to both. A0.2 RBAC permissions are
identical regardless of which token shape produced the Identity.
This ADR scopes only APIKey. Session JWT shape is documented where
it lives today (in warp's internal/auth package, alongside the
GitHub-OAuth callback); a future ADR may lift it into gyrum-go
alongside APIKey.
Consequences
Easier:
- A1.1 implements against a frozen contract; warp adopts via thin
adapter on its existing
keystable; distill (and future consumers) plug in their ownAPIKeyStorewithout re-deriving the bearer-token model. - Rotation flows compose: every consumer's rotation CLI is the same
three calls (
Create→ swap-secret →Revoke). - Audit-log integration (A0.1) hooks at exactly two seams:
Createemitskey_minted,Revokeemitskey_revoked. Hard-delete + audit-log is the orthogonal split.
Harder:
- Warp's existing
keystable needs a small migration to make column names match the spec (currentlykey_hash,scope,label,expires_at— already aligned, so this is mostly a no-op verified by A2.1). Thecreated_bycolumn may need backfill. - Hard-delete revocation means a future "show all keys ever issued to this user" admin query has to read the audit log, not the keys table. We have signed up to keep the audit log durable for that purpose (A0.1).
- The 90/30/no-expiry defaults are enforced in flow code, not in the
primitive. A consumer that forgets to set
ExpiresAtwill mint a no-expiry token. A1.1 ships a lint-style helper (MustExpireWithin(d)) that flow code uses to make this loud at the call site.
Operate / maintain:
- Token format change (away from
wrp_…) is an ADR-level decision — the prefix is configurable but the migration is a fleet-wide re-mint. Don't drift it casually. - Hash function change (away from SHA-256) is an ADR-level decision — it's a database migration plus a re-hash pass on existing rows (which we cannot do without plaintext, so in practice it's a forced re-mint).
- The "scope grants cap at caller's scope" rule is enforced by the primitive; it's not a per-consumer choice. A1.1 has the test for it; A2.* must not bypass.
Alternatives considered
- JWT-only (no opaque bearers). Loses instant-revoke; every key becomes a session-deny-list problem. Sessions already have this trade-off intentionally; agents can't afford it. Rejected.
- Bcrypt / Argon2 hash. Tokens are high-entropy random strings with no password to slow-hash. Adds 50–100ms per request for zero attacker cost. Rejected.
- Soft-delete tombstone (
revoked_atcolumn, lookup filters onIS NULL). Adds a per-request predicate, an index variant, and a "did the lookup forget the filter?" bug surface for no user-visible benefit. Audit-log correlation is the audit log's job. Rejected. - Per-consumer prefix hardcoded (e.g. distill mints
dst_…, warp mintswrp_…). Rejected as a default — the primitive emitswrp_unless explicitly configured. Per-consumer prefixes are available via the primitive's prefix-constant, not via a fork of the mint function. - Mint-old-first / revoke-on-create rotation. Reverses the rotation order so the old token dies before the new one is in place. A bad swap leaves the consumer with no working token. Rejected — the safer order is Create → swap → Revoke.
- Unified APIKey-and-JWT primitive. A single "Token" type with a discriminator. Rejected — the lifecycles are fundamentally different (instant-revoke vs exp-bound) and folding them into one type forces every call site to handle both code paths. Siblings, not a hierarchy.
Supersedes: none Superseded by: (leave blank until a later ADR reverses this one)
Refs
- warp#1450 — parent EPIC (fleet auth substrate).
- warp#1451 (A0.1, sister) — audit log shape.
- warp#1452 (A0.2, sister) — RBAC matrix.
- warp#1350 — warp-keymint CLI (sister; rotation flow integrates here).
- warp
api/internal/auth/auth.go— existing implementation this ADR ports.
- warp
api/internal/auth/github_oauth.go—SessionMinterinterface (informs A1.1's narrower seam).
- warp
api/internal/auth/bootstrap.go—KeyStoreinterface (informsAPIKeyStore.Createshape).