@wtfalch/keys
v0.3.0
Published
The estate's own bearer keys, issued to callers and held on their behalf: two entries, issued and held, nothing stored in common.
Readme
@wtfalch/keys
@wtfalch/keys issues the estate's own bearer keys to callers and holds keys
other parties issued to the estate, injecting held keys into a running
service at request time and into a build at build time. It owns no
authentication or authorisation of its own — every check runs through
@wtfalch/auth and @wtfalch/authz.
Install
pnpm add @wtfalch/keys drizzle-orm@^0.45.2Dev
pnpm build
pnpm typecheck
pnpm testSecurity upgrade
Apply the additive 0004_keys_security.sql migration in every consumer database before relying on the updated guards. All issuer processes must upgrade together for issuance/cascade serialization. See the remediation and rollout notes.
New bearer secrets have a 32-character base32 tail (160 random bits). Existing 20-character tails remain accepted. Rotation refuses invalid stored signatures and returns conflict if the row changes during signing.
Held keys use active cache expiry (60 seconds by default) and retain at most 256 versions per instance. Set cacheSeconds: 0 or maxCacheEntries: 0 to disable caching. Call dispose() to zero cached keys, clear timers and refuse further operations. A local forget/revocation invalidates pending opens; other processes remain bounded by their own cache TTL. Plaintext callbacks can copy or return values, so their lifetime cannot be enforced by the SDK.
Issued-key environments
Every issued credential carries a built-in environment, 'live' or 'test' (default 'live'), passed on issue() and reported by check() and lineageOf(). A credential can only mint a child in its own environment — issue() returns { ok: false, reason: 'environment_mismatch' } otherwise. Apply 0005_keys_environment.sql alongside 0004_keys_security.sql.
Issued-key usage tracking
Every successful check() stamps lastUsedAt and increments useCount on the credential's row — read them off KeysIssuedCredentialRow directly (an admin UI's own query, not a new check() return field). The write is best-effort: it never turns a genuine secret into a refusal, and a revoked, expired, tampered or wrong-secret check never bumps it. Apply 0006_keys_usage.sql alongside 0004_keys_security.sql and 0005_keys_environment.sql.
Per-issued-key rate limits
CreateCredentialIssuerOptions.rateLimiter (optional) is consulted on every check(), keyed by the credential's own id — spent on every attempt against a known credential, matched secret or not, so an unlimited number of wrong guesses can't get around it. Refused as { ok: false, reason: 'rate_limited', retryAfterMs }. createPostgresRateLimiter({ db, limitPerWindow, windowMs? }) is a ready-made fixed-window limiter backed by the host's own Postgres (0007_keys_rate_limit.sql, applied alongside 0004–0006); bring your own RateLimiter for anything else (Redis, D1, …). Omit it and check() behaves exactly as before this existed — this was previously only enforced on the host's own service credential (packages/worker/src/ratelimit.ts), never on an individual end-user issued key.
Outbound webhooks
@wtfalch/keys/webhooks's createWebhookDispatcher({ url, secret? }) returns a function that POSTs a lifecycle event as JSON — a drop-in for ./issued's audit option (credential.minted / .rotated / .revoked) or ./held's (key.used). With secret, the body is HMAC-SHA256-signed into an X-Keys-Signature header; the receiver checks it with verifyWebhookSignature(body, signature, secret). Delivery is fire-and-forget and best-effort — a subscriber outage never fails the call that produced the event, it only calls onDeliveryError.
