Keyring
ReferencePackages

@keyring/core

Framework-free verification, the key format and the hashing. Every adapter wraps it.

Generated from packages/core/src/index.ts and the declarations it exports, by packages/docs/tools/generate-reference.mjs. Every signature below is printed from the source; the prose is the source's own doc comment.
npm install @keyring/core

Functions

bunyanRedactSerializers

export function bunyanRedactSerializers(options?: RedactOptions): Record<string, (input: unknown) => unknown>;

bunyanRedactStream

export function bunyanRedactStream(target: WritableLike, options?: RedactOptions): NodeJS.WritableStream;

constantTimeEqual

Length-safe wrapper around crypto.timingSafeEqual, which throws when the inputs differ in length. Hash lengths are public, so comparing them first leaks nothing.

Report section 8.3 measured the alternative: naive === on a raw secret spreads 0.42 ns to 8.22 ns depending on where the strings diverge — an 1,862 % oracle. timingSafeEqual flattens it to 2 %, i.e. noise.

export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;

crc32

CRC-32/ISO-HDLC (the zlib/PNG variant): reflected polynomial 0xEDB88320, init 0xFFFFFFFF, final XOR 0xFFFFFFFF. Chosen because every language in the SDK roadmap has it in its standard library (Python zlib.crc32, Go hash/crc32.ChecksumIEEE), so a port never reimplements it.

export function crc32(input: string | Uint8Array): number;

crc32Hex

export function crc32Hex(input: string | Uint8Array): string;

displayPrefixOf

Derives the loggable display prefix from a raw key without validating it, for redactors that must not care whether the key was well-formed.

The bound is DISPLAY_BODY_CHARS body characters whatever the key turned out to be, so a mistyped or truncated key -- which still starts with kr_ and so still lands here -- reveals no more than a valid one.

export function displayPrefixOf(raw: string): string;

encodeHashKey

export function encodeHashKey(hash: Uint8Array): string;

formatKey

export function formatKey(input: FormatKeyInput): string;

generateKey

crypto.randomBytes only — never a PRNG, never a UUID (report section 8.4).

export function generateKey(input: {
    kind?: KeyKind;
    env: Environment;
}): string;

hasAllScopes

All required scopes must be satisfied; an empty requirement is satisfied.

export function hasAllScopes(granted: readonly string[], required: readonly string[]): boolean;

hasScope

export function hasScope(granted: readonly string[], required: string): boolean;

isAuthoritativeMiss

Whether a get() returning undefined is evidence that the key does not exist, rather than evidence that we do not know.

This is the whole of finding 11, as a predicate. It decides nothing: what to do with a non-authoritative miss is the onUnavailable policy, which is week 4's adapter's to own. Week 3 owns making the distinction expressible.

export function isAuthoritativeMiss(status: PolicyStoreStatus, options: FreshnessOptions = {}): boolean;

isRateLimitRule

The same bounds migration 0008 checks, in the SDK.

Not belt and braces: a rate-limit set reaches this process from the network and from a file in a world-writable directory (@keyring/cache's disk snapshot), and a planted limit: 1e9 is an authorisation decision made from attacker-controlled data. The disk store already validates scopes for exactly that reason; this is the same rule for the same threat.

export function isRateLimitRule(value: unknown): value is RateLimitRule;

isRateLimitRuleSet

export function isRateLimitRuleSet(value: unknown): value is readonly RateLimitRule[];

looksLikeKeyringKey

Cheap sniff used by log redactors and by adapters deciding whether a bearer token is even ours. Never a security decision.

export function looksLikeKeyringKey(raw: string): boolean;

lookupHash

The value the SDK computes on the hot path and the value the control plane distributes in a policy snapshot. SHA-256 over the entire key string, prefix included, so kr_test_x and kr_live_x are structurally different credentials and no boolean can promote one to the other (report section 5.2).

Deliberately un-peppered: it has to be computable inside the customer's process, which must never hold our pepper. The peppered hash is the control-plane-side value and lives in @keyring/pepper.

A slow KDF is the right answer for passwords and the wrong one here: an API key carries 192 bits of entropy, so there is no dictionary to attack, and argon2id at 19.9 ms would consume the entire 20 ms latency budget on its own (report section 2.2).

export function lookupHash(rawKey: string): Buffer;

lookupHashKey

The cache index. base64 rather than hex: 44 bytes per entry instead of 64.

export function lookupHashKey(rawKey: string): string;

mintKey

Generation and hashing in one step, so no caller ever holds a raw key without also having the values it is supposed to persist instead of it.

The peppered hash that actually goes in the database is deliberately not produced here: computing it requires the KMS pepper, which lives in @keyring/pepper and must never be importable from a package that ships to a customer's process.

export function mintKey(input: {
    kind?: KeyKind;
    env: Environment;
}): MintedKey;

missingScopes

export function missingScopes(granted: readonly string[], required: readonly string[]): string[];

parseKey

Throwing variant for control-plane code. The message never contains raw.

export function parseKey(raw: string): ParsedKey;

pinoRedact

export function pinoRedact(options?: RedactOptions): PinoRedactHooks;

prefixFor

export function prefixFor(kind: KeyKind, env: Environment): string;

rateLimitHeaders

export function rateLimitHeaders(states: readonly RateLimitState[], options: RateLimitHeaderOptions = {}): Record<string, string>;

rateLimitSubject

The subject whose counter a rule spends, as an opaque string.

A tenant-scoped rule on a key whose tenant is unknown -- the fail-open branch, where nothing about the policy is known -- has no subject and cannot be charged. The caller decides what that means; this only refuses to invent one, because inventing one puts every unverified request on a single shared counter and turns a Keyring outage into a global 429.

export function rateLimitSubject(rule: RateLimitRule, key: {
    keyId: string | null;
    tenantId: string | null;
}): string | null;

redact

export function redact(value: unknown, options: RedactOptions = {}): unknown;

redactString

export function redactString(value: string, replacement: string = DEFAULT_REPLACEMENT): string;

scopeMatches

Scope matching, defined here once so that the Python and Go SDKs can be held to it by the conformance fixtures rather than by prose.

Scopes are colon-separated segments. A granted scope authorises a required scope when it is identical, or when it ends in a * segment that covers the required scope at a segment boundary. * alone grants everything. There is no infix or suffix wildcard: read:*:eu is not a pattern, it is a literal scope that will never match anything else.

export function scopeMatches(granted: string, required: string): boolean;

secret

export function secret<T>(value: T): Secret<T>;

stalenessMs

How stale the store's contents are, in milliseconds, or null for "never loaded" -- which is not "infinitely stale" but "nothing to be stale from", the genuinely dangerous cold-start case of report section 9.5.

export function stalenessMs(status: PolicyStoreStatus, now: number = Date.now()): number | null;

toRateLimitRule

export function toRateLimitRule(wire: WireRateLimit): RateLimitRule;

toWireRateLimit

export function toWireRateLimit(rule: RateLimitRule): WireRateLimit;

tryParseKey

Parses without ever throwing and without ever echoing the input. The whole function is allocation-light and I/O-free: it is step 1 of the hot path.

export function tryParseKey(raw: string): ParseResult;

verify

The whole hot path, and the only function every framework adapter has to wrap. No HTTP types, no framework imports, no I/O, no promises: report section 6.1 measures this shape at 2.2 us p50 / 4.8 us p99 with 1 M keys cached, and the absence of an await here is what guarantees it stays that way.

export function verify(rawKey: string, options: VerifyOptions): VerifyResult;

winstonRedact

export function winstonRedact(options?: RedactOptions): WinstonTransformFunction;

Classes

KeyringError

export class KeyringError extends Error {
    readonly code: KeyringErrorCode;
    constructor(code: KeyringErrorCode, message: string);
}

MemoryPolicyStore

The simplest store that satisfies the contract: an unbounded Map. Fine for tests and for a fixed key set; not fine for a customer's process, which is what @keyring/cache's maxCachedKeys and LRU eviction are for.

Its default status says the contents came from nowhere and are not complete, because for a hand-assembled map that is the truth. A caller that has loaded a real snapshot into one says so with markLoaded.

export class MemoryPolicyStore implements PolicyStore {
    constructor(policies: Iterable<KeyPolicy> = []);
    get(lookupHashKey: string): KeyPolicy | undefined;
    status(): PolicyStoreStatus;
    markLoaded(status: PolicyStoreStatus): void;
    set(policy: KeyPolicy): void;
    delete(policy: Pick<KeyPolicy, 'lookupHash'>): boolean;
    clear(): void;
    get size(): number;
}

Secret

A value that must never reach a log line, an error message, a span attribute or a JSON body by accident (report section 8.1). Reading it is deliberate and greppable; every implicit stringification yields [redacted].

export class Secret<T> {
    constructor(value: T);
    expose(): T;
    toString(): string;
    toJSON(): string;
    [Symbol.for('nodejs.util.inspect.custom')](): string;
}

Interfaces

FormatKeyInput

export interface FormatKeyInput {
  readonly kind?: KeyKind;
  readonly env: Environment;
  /** Exactly {@link SECRET_BYTES} bytes of CSPRNG output. */
  readonly secret: Uint8Array;
}

FreshnessOptions

export interface FreshnessOptions {
  /** Epoch ms. */
  readonly now?: number;
  /** Report section 2.6's belt and braces: 60 s by default. */
  readonly maxStalenessMs?: number;
}

KeyPolicy

One cached key, as distributed by a policy snapshot. This is the record whose size the SDK's memory footprint is a multiple of (report section 6.1 measured 582 B/key and calls that the finding, not the speed); week 3 owns the LRU and the packed representation, so keep new fields off it unless the hot path reads them.

export interface KeyPolicy {
  readonly keyId: string;
  readonly workspaceId: string;
  readonly projectId: string;
  readonly tenantId: string;
  readonly env: Environment;
  /** SHA-256 of the raw key, 32 bytes. */
  readonly lookupHash: Uint8Array;
  readonly displayPrefix: string;
  readonly scopes: readonly string[];
  /** Epoch milliseconds, or null for "never expires". */
  readonly expiresAt: number | null;
  /** Epoch milliseconds, or null for "not revoked". */
  readonly revokedAt: number | null;
  /**
   * The limits that apply to this key, already resolved by the control plane
   * against the project default (migration 0008) so the layering happens in one
   * place rather than in three SDKs. An empty array is "no limits", which is
   * the common case and the reason a rate-limited product can still answer most
   * requests without a network call.
   */
  readonly rateLimits: readonly RateLimitRule[];
}

MintedKey

export interface MintedKey {
  /** The plaintext. It exists in exactly one response body and is never stored. */
  readonly plaintext: Secret<string>;
  readonly kind: KeyKind;
  readonly env: Environment;
  /** Safe to store and to show in a dashboard. */
  readonly displayPrefix: string;
  /** SHA-256 of the plaintext; the cache index and the snapshot value. */
  readonly lookupHash: Buffer;
}

ParsedKey

export interface ParsedKey {
  readonly kind: KeyKind;
  readonly env: Environment;
  /** e.g. `kr_live_` — includes the trailing underscore. */
  readonly prefix: string;
  /** 32 base64url characters. */
  readonly body: string;
  /** 8 lowercase hex characters. */
  readonly checksum: string;
  /** e.g. `kr_live_a1b2c3` — safe to log, safe to store, useless as a credential. */
  readonly displayPrefix: string;
}

PinoRedactHooks

export interface PinoRedactHooks {
  readonly logMethod: LogMethodHook;
  readonly streamWrite: (s: string) => string;
}

PolicyStore

The read side of the local policy cache. verify() depends on this and nothing else, which is what keeps the hot path free of I/O.

Both methods are synchronous on purpose. An async signature here would make a network call on the hot path expressible, and the whole architecture rests on it not being (report section 2.7).

export interface PolicyStore {
  get(lookupHashKey: string): KeyPolicy | undefined;
  status(): PolicyStoreStatus;
}

PolicyStoreStatus

What a caller needs to know about the cache behind get() in order to read a miss correctly.

The week 1 review's finding 11: with get() returning only KeyPolicy | undefined, a caller cannot tell "absent from a fresh, complete snapshot" -- which means the key does not exist and denying is right -- from "I have no fresh snapshot", which is report section 6.4's stale-then-open case, where the right answer is to allow and flag the request degraded. Those are opposite decisions behind the same undefined.

It is here rather than in week 4 because PolicyStore is about to be frozen into a cross-language conformance contract. Adding freshness now costs a field; adding it after the Python and Go ports exist costs a coordinated release of three SDKs.

export interface PolicyStoreStatus {
  /** The snapshot version these contents came from; null if none ever loaded. */
  readonly snapshotVersion: number | null;
  /** Epoch ms of the last successful load or refresh; null if none. */
  readonly fetchedAt: number | null;
  readonly source: PolicyStoreSource;
  /**
   * True when the store holds *every* key in its scope at `snapshotVersion`.
   *
   * This is the load-bearing field, and the reason section 6.4 can defend
   * `stale-then-open` as a default: the argument is "the cache is complete for
   * the project, so a key that is not in it does not exist". A store that has
   * evicted under `maxCachedKeys`, or that was filled from a tenant-filtered
   * snapshot, is not complete, and a miss from it proves nothing.
   */
  readonly complete: boolean;
}

RateLimitHeaderOptions

Report section 3.6, and both spellings ship.

The current IETF draft (draft-ietf-httpapi-ratelimit-headers-11) is RFC 9651 structured fields: RateLimit-Policy is a dictionary of the configured limits and RateLimit a dictionary of what is left. That is the spelling a new client should read.

The legacy RateLimit-Limit / -Remaining / -Reset triple is the pre-08 draft, and it is what every client library actually in production parses. It ships by default for that reason, and it is a config flag rather than a permanent fixture because it will eventually be right to turn it off.

The triple can carry only one policy, so it carries the most constrained one -- the policy that refused, if one did, and otherwise the one with the least remaining. Reporting the first configured limit instead would tell a caller they have 9,000 requests left in the day while the per-second limit is the one refusing them.

export interface RateLimitHeaderOptions {
  /** Report section 3.6: on by default. */
  readonly legacy?: boolean;
  /** The policy that refused, when one did. */
  readonly limitedId?: string | null;
  readonly retryAfterSeconds?: number | null;
}

RateLimitRule

One limit, as the customer configures it and as the SDK enforces it.

This shape is the same in the database (migration 0008's keyring.rate_limits_valid), on the policy wire, in the SDK's cache and in the RateLimit-Policy header, deliberately: it is about to be re-implemented in Python and Go, and four spellings of one field is how that goes wrong.

Report section 3.1's finding is that the algorithms cost within 11 % of each other, so this is a semantic choice and not a performance one:

  • sliding -- the current window's count plus a weighted slice of the previous window's. What "10 per second" should mean, because a fixed window lets a caller spend two windows' quota across a boundary.
  • fixed -- the current window alone, aligned to the epoch. What "10,000 per day" should mean, because a quota is a period and a boundary burst at day granularity is not a thing anyone is defending against.

scope decides whose counter is spent. tenant is shared by every key that tenant holds, which is what a customer means by "this organisation gets 10,000 a day" and is the scope that survives a key rotation.

export interface RateLimitRule {
  /**
   * The name the caller sees in `RateLimit-Policy` and in
   * `Keyring-Rate-Limited-Policy`. Report section 3.6 follows Stripe in naming
   * which limiter fired; it costs nothing and saves a support ticket.
   */
  readonly id: string;
  /** Requests permitted per window. */
  readonly limit: number;
  readonly windowMs: number;
  readonly algorithm: 'sliding' | 'fixed';
  readonly scope: 'key' | 'tenant';
}

RateLimitState

What the control plane says about one rule after charging it.

export interface RateLimitState {
  readonly id: string;
  readonly limit: number;
  readonly windowMs: number;
  readonly scope: 'key' | 'tenant';
  /** Requests left in the window. Never negative. */
  readonly remaining: number;
  /** Milliseconds until the window that bounds `remaining` resets. */
  readonly resetMs: number;
}

RateLimitTarget

Where a rate-limit decision is anchored, for the control plane's counters.

export interface RateLimitTarget {
  readonly projectId: string;
  readonly env: Environment;
  readonly keyId: string | null;
  readonly tenantId: string | null;
}

RedactOptions

export interface RedactOptions {
  readonly replacement?: string;
  readonly maxDepth?: number;
}

VerifiedKey

What an adapter hands to the host handler as req.keyring.

export interface VerifiedKey {
  readonly keyId: string;
  readonly workspaceId: string;
  readonly projectId: string;
  readonly tenantId: string;
  readonly env: Environment;
  readonly scopes: readonly string[];
  readonly displayPrefix: string;
  readonly expiresAt: number | null;
  readonly rateLimits: readonly RateLimitRule[];
}

VerifyFailure

export interface VerifyFailure {
  readonly ok: false;
  /** Safe to return to the caller. */
  readonly code: PublicErrorCode;
  /** Safe to return to the caller. */
  readonly status: 401 | 403;
  /** Safe to return to the caller. Never names the actual cause of a 401. */
  readonly message: string;
  /** For the vendor's request log only. Never serialise this to the caller. */
  readonly reason: DenialReason;
  /** Present when the key parsed; useful for the request log. */
  readonly displayPrefix?: string;
  /** Scopes the key was missing, when `reason` is `insufficient_scope`. */
  readonly missingScopes?: readonly string[];
}

VerifyOptions

export interface VerifyOptions {
  /** The local policy cache. Never a network client. */
  readonly store: PolicyStore;
  /**
   * The environment this call site serves. When set, a key minted for the other
   * environment is rejected even if it is otherwise valid — the second of the
   * three enforcement layers in report section 5.2.
   */
  readonly env?: Environment;
  readonly requiredScopes?: readonly string[];
  /** Epoch milliseconds. Injectable so expiry is testable and fixture-driven. */
  readonly now?: number;
}

VerifySuccess

export interface VerifySuccess {
  readonly ok: true;
  readonly key: VerifiedKey;
}

WireRateLimit

How a rate-limit set travels on the policy wire and on disk.

export interface WireRateLimit {
  readonly id: string;
  readonly limit: number;
  readonly window_ms: number;
  readonly algorithm: 'sliding' | 'fixed';
  readonly scope: 'key' | 'tenant';
}

Types

DenialReason

Why a verification was denied. Internal only: it belongs in the vendor's request log, never in a response to the caller. Report section 8.1 — "never differentiate key-not-found from revoked from expired to an unauthenticated caller".

export type DenialReason =
  | 'malformed'
  | 'bad_checksum'
  | 'unknown_key'
  | 'hash_mismatch'
  | 'env_mismatch'
  | 'revoked'
  | 'expired'
  | 'insufficient_scope';

Environment

The two environments are a closed set, deliberately (report section 5.2). They are values, not rows: a third environment is a schema migration and a major SDK version, not a configuration option.

export type Environment = 'live' | 'test';

KeyKind

api keys are held by a tenant and presented to the vendor's API. secret keys are held by the vendor's own backend and presented to the Keyring control plane. Two different planes (report section 7.1); giving them different prefixes makes confusing them a parse error rather than an audit finding.

export type KeyKind = 'api' | 'secret';

KeyringErrorCode

export type KeyringErrorCode =
  'invalid_configuration' | 'invalid_key_format' | 'unsupported_pepper_version';

ParseResult

export type ParseResult =
  | { readonly ok: true; readonly key: ParsedKey }
  | {
      readonly ok: false;
      readonly reason: Extract<DenialReason, 'malformed' | 'bad_checksum'>;
    };

PolicyStoreSource

Where a store's contents came from.

export type PolicyStoreSource = 'none' | 'disk' | 'network';

PublicErrorCode

What the caller is allowed to be told.

export type PublicErrorCode = 'invalid_key' | 'insufficient_scope';

VerifyResult

export type VerifyResult = VerifySuccess | VerifyFailure;

Constants

BODY_LENGTH

base64url of 24 bytes, unpadded.

const BODY_LENGTH: 32;

CHECKSUM_LENGTH

CRC-32 rendered as lowercase hex.

const CHECKSUM_LENGTH: 8;

DEFAULT_MAX_STALENESS_MS

Report section 2.6: cachedUntil = now + 60 s on every cached record.

const DEFAULT_MAX_STALENESS_MS: 60000;

DEFAULT_REPLACEMENT

const DEFAULT_REPLACEMENT: "[redacted]";

DISPLAY_BODY_CHARS

prefix + first 6 body characters is what may appear in a log (section 8.1).

const DISPLAY_BODY_CHARS: 6;

ENVIRONMENTS

const ENVIRONMENTS: readonly Environment[];

KIND_PREFIX

const KIND_PREFIX: Record<KeyKind, string>;

LOOKUP_HASH_BYTES

const LOOKUP_HASH_BYTES: 32;

MAX_RATE_LIMIT

const MAX_RATE_LIMIT: 1000000000;

MAX_RATE_LIMIT_WINDOW_MS

31 days: the longest period anyone means by "per month".

const MAX_RATE_LIMIT_WINDOW_MS: 2678400000;

MAX_RATE_LIMITS_PER_KEY

const MAX_RATE_LIMITS_PER_KEY: 8;

MIN_RATE_LIMIT_WINDOW_MS

const MIN_RATE_LIMIT_WINDOW_MS: 1000;

NO_SNAPSHOT

const NO_SNAPSHOT: PolicyStoreStatus;

PUBLIC_MESSAGES

const PUBLIC_MESSAGES: Record<PublicErrorCode, string>;

SECRET_BYTES

24 random bytes = 192 bits (report section 8.4).

const SECRET_BYTES: 24;

On this page