Files
netris-nestri/packages/auth/src/key.ts
Wanjohi f64f037574 fix(auth): keep one live key per kind, and report a key's own algorithm
Two problems found in review, both in the key store.

Nothing stopped a kind from having two live keys, and the bootstrap path
walks straight into it: two workers starting against an empty table both
find no key and both insert one. From then on each signs and encrypts with
its own. That is not the harmless split the comment here claimed — the
issuer reaches for a single key rather than the published set when it
decrypts a session cookie and when it verifies an access token, so a cookie
written by one worker is unreadable to the other and a token minted by one
is rejected by the other. It stays silent until someone cannot sign in.

A partial unique index over the kind, where the key has not been retired,
makes the second insert a dropped write instead. Both workers then read the
table again and use the key that won, which is all that matters. The
conflict clause stops naming a target: both indexes on the table mean the
same thing at this call site, that the row already exists in some form.

Creating a key is now attempted once rather than retried, because a store
declining the write is an expected answer and spinning on it would hang the
request instead of failing it.

Separately, a key pair reported the algorithm the issuer currently uses
rather than the one stored on the key it was built from, so a retained key
would advertise the wrong algorithm in a token header and in the JWKS after
a rotation — which defeats keeping it. The material was already being
imported with the stored value; only what was handed back disagreed.

Retiring a key and creating its replacement now have to happen together, so
that a kind never has two live keys and never has none.
2026-09-05 14:31:33 +03:00

98 lines
3.8 KiB
TypeScript

/**
* Where the issuer's signing and encryption keys live.
*
* These are the only records here that are meant to outlive everything else:
* every token this issuer has ever minted is verifiable only for as long as
* the public half is still published, so losing this store invalidates every
* session at once. That is the whole reason it is an interface — a store that
* a deployment can point at its own database, rather than at whatever
* key-value service the runtime happened to offer.
*
* Writes are append-only and rare: a key is created when no unexpired one of
* its kind exists, and retired by being marked expired rather than removed, so
* tokens it signed stay verifiable until they age out on their own.
*
* @packageDocumentation
*/
import type { StorageAdapter } from './storage/storage.js';
import { Storage } from './storage/storage.js';
/** Which half of the issuer's key material a record belongs to. */
export type KeyKind = 'signing' | 'encryption';
/**
* A key pair as stored: PEM text rather than a live key object.
*
* Kept serialized because the store is a database and not a process — the
* import back into a usable key happens in {@link ./keys.js}, once per issuer
* instance.
*/
export interface StoredKey {
id: string;
publicKey: string;
privateKey: string;
alg: string;
/** Epoch ms. */
created: number;
/** Epoch ms, set when the key is retired. Absent while it is still in use. */
expired?: number;
}
export interface KeyStore {
/** Every key of a kind, expired ones included. Order does not matter. */
list(kind: KeyKind): Promise<StoredKey[]>;
/**
* Add a key, unless the kind already has a live one.
*
* A store that lets two live keys of one kind exist at the same time
* breaks things that are hard to see. Two issuers starting against an empty
* store both find nothing, both generate a key, and from then on each signs
* and encrypts with its own: a session cookie written by one is
* undecryptable to the other, and an access token minted by one is rejected
* by the other as invalid — because both reach for a single key rather than
* trying the published set. Losing the second write is the whole point, so
* this is not an error and reports nothing; the caller reads the list again
* and uses whichever key survived.
*
* Retiring a key and creating its replacement therefore have to happen
* together, so that the kind never has two live keys and never has none.
*/
create(kind: KeyKind, key: StoredKey): Promise<void>;
}
/** The storage prefix a kind's keys have always been written under. */
function prefix(kind: KeyKind): string {
return kind === 'signing' ? 'signing:key' : 'encryption:key';
}
/**
* A key store backed by the generic {@link StorageAdapter}.
*
* The default, and what every deployment used before `keyStore` existed — the
* keys are read and written under exactly the prefixes they always were, so an
* issuer that does not pass a store keeps finding the keys it already had.
*
* It cannot honour the one-live-key rule `create` asks for: through get and
* set there is no way to make "write unless one exists" a single operation.
* Two issuers bootstrapping against an empty store at the same moment will
* therefore end up with a key each, with the consequences described above. A
* store that can express a conditional write does not have this problem, and
* is what a deployment running more than one instance wants.
*/
export function StorageKeyStore(storage: StorageAdapter): KeyStore {
return {
async list(kind) {
const results: StoredKey[] = [];
for await (const [, value] of Storage.scan<StoredKey>(storage, [prefix(kind)])) {
results.push(value);
}
return results;
},
async create(kind, key) {
await Storage.set(storage, [prefix(kind), key.id], key);
}
};
}