Decisions

ADR-144: Agent API key shape (gyrum-go APIKey primitive)

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…

#144

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 to readonly. The user MUST NOT upgrade to agent or admin.
  • Admin-mint → admin MAY grant any scope, including admin. Granting admin is 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 an admin key. 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-admin row inserted at first boot) → no expiry permitted. This MUST be an explicit ExpiresAt = nil choice 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:

  • Lookup must remain a single-column primary-key hit. Adding a revoked_at IS NULL predicate 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 keys table; distill (and future consumers) plug in their own APIKeyStore without 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: Create emits key_minted, Revoke emits key_revoked. Hard-delete + audit-log is the orthogonal split.

Harder:

  • Warp's existing keys table needs a small migration to make column names match the spec (currently key_hash, scope, label, expires_at — already aligned, so this is mostly a no-op verified by A2.1). The created_by column 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 ExpiresAt will 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_at column, lookup filters on IS 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 mints wrp_…). Rejected as a default — the primitive emits wrp_ 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.goSessionMinter interface (informs A1.1's narrower seam).
  • warp api/internal/auth/bootstrap.goKeyStore interface (informs APIKeyStore.Create shape).