@proof-holdings/delegation-verifier 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The key-set rules, free of I/O: how a fetched JWKS is merged into what the verifier already
3
+ * knows, and which key — if any — may verify a token of a given family.
4
+ *
5
+ * Kept apart from `jwks.ts` (fetch, cache, cooldowns) because these are the rules every verifier
6
+ * must agree on — the JS, Python, Go and PHP SDKs port them function for function, and the shared
7
+ * fixtures in `sdks/shared/fixtures/jwks_rotation/` pin the outcome of each one.
8
+ *
9
+ * Four properties are load-bearing:
10
+ *
11
+ * - One response is never trusted for long. Anything that would outlive the next fetch — a key
12
+ * being remembered, a window narrowing, a tombstone — takes effect only when a second fetch, at
13
+ * least five minutes after the first sighting, says the same. A key verifies from its first
14
+ * sighting (an emergency key must work at once), but until it is confirmed the next document
15
+ * replaces or drops it, exactly as before rotation existed. Otherwise one poisoned response
16
+ * could plant a key the verifier keeps for as long as that key's own members claim, or revoke
17
+ * or re-date an honest key fleet-wide.
18
+ * - A fetch is MERGED, never swapped in. A CONFIRMED registry key that disappears keeps its
19
+ * last-known state, never gets new key material, and its window may narrow (by the rule above)
20
+ * but never widen — a document that widens one is refused whole, whatever key material it
21
+ * serves under that kid.
22
+ * - An applied tombstone is sticky until every token the key signed is dead.
23
+ * - "Registry live" is a property of the merged set (some key carries `proof_purpose`). Until
24
+ * then the set is in tolerant mode and behaves exactly as it did before rotation existed. An
25
+ * unconfirmed key counts too: it is gone with the next document that omits it.
26
+ */
27
+ import type { JwksEventType } from './types.js';
28
+ export declare const KEY_FAMILIES: readonly ["proof", "delegation", "status-list"];
29
+ export type KeyFamily = (typeof KEY_FAMILIES)[number];
30
+ /** Clock skew tolerated on each edge of a key's `[not_before, not_after]` window. */
31
+ export declare const KEY_WINDOW_SKEW_SECONDS = 300;
32
+ /** Minimum spacing between the two fetches that confirm a key, a narrowed window or a tombstone. */
33
+ export declare const CONFIRM_SPACING_MS: number;
34
+ /** An applied tombstone is kept until `revoked_at` plus this. */
35
+ export declare const TOMBSTONE_RETENTION_SECONDS: number;
36
+ /** Both clocks at one instant: monotonic for in-process intervals, wall for anything persisted. */
37
+ export interface KeySetClockReading {
38
+ monotonicMs: number;
39
+ wallMs: number;
40
+ }
41
+ export interface KeyRecord {
42
+ kid?: string;
43
+ /** The JWK as last accepted, with any narrowing of its window applied. */
44
+ jwk: Record<string, unknown>;
45
+ /** Identity of the key material: a protected kid keeps its first material, an unprotected one takes the latest. */
46
+ fingerprint: string;
47
+ /** `alg`, `use` and `key_ops` — the members that decide whether the key may verify at all. */
48
+ usage: string;
49
+ hasPurposeMember: boolean;
50
+ purpose?: KeyFamily;
51
+ legacy: boolean;
52
+ notBefore?: number;
53
+ notAfter?: number;
54
+ maxTokenLifetime?: number;
55
+ vanished: boolean;
56
+ /** When this exact description of the key was first served. */
57
+ firstSeen: KeySetClockReading;
58
+ /** Served unchanged by two fetches at least `CONFIRM_SPACING_MS` apart. */
59
+ confirmed: boolean;
60
+ /** A narrower window served for a confirmed key, applied once a later fetch confirms it. */
61
+ pendingWindow?: PendingWindow;
62
+ }
63
+ export interface PendingWindow {
64
+ notBefore: number;
65
+ notAfter: number;
66
+ firstSeen: KeySetClockReading;
67
+ }
68
+ export interface PendingTombstone {
69
+ kid: string;
70
+ revokedAt: number;
71
+ firstSeen: KeySetClockReading;
72
+ }
73
+ export interface AppliedTombstone {
74
+ kid: string;
75
+ revokedAt: number;
76
+ }
77
+ export interface KeySetState {
78
+ keys: Map<string, KeyRecord>;
79
+ /** Keys served without a `kid` — tolerant mode only ever needs them. */
80
+ anonymous: KeyRecord[];
81
+ pending: Map<string, PendingTombstone>;
82
+ applied: Map<string, AppliedTombstone>;
83
+ fetchedAt: KeySetClockReading | null;
84
+ /** A persisted `fetched_at` lay in the future: its age is unknown, so it counts as stale. */
85
+ fetchedAtUntrusted: boolean;
86
+ }
87
+ export type KeySetEventType = Exclude<JwksEventType, 'store_error'>;
88
+ export interface KeySetEvent {
89
+ type: KeySetEventType;
90
+ kid?: string;
91
+ message: string;
92
+ }
93
+ export interface MergeResult {
94
+ state: KeySetState;
95
+ events: KeySetEvent[];
96
+ /** False when the document was refused whole; `state` is then the unchanged input. */
97
+ accepted: boolean;
98
+ }
99
+ export type KeySelection = {
100
+ kind: 'candidates';
101
+ records: KeyRecord[];
102
+ } | {
103
+ kind: 'revoked';
104
+ kid: string;
105
+ } | {
106
+ kind: 'wrong_purpose';
107
+ kid: string;
108
+ } | {
109
+ kind: 'unknown';
110
+ };
111
+ export type KeyPolicyViolation = 'outside_key_window' | 'token_lifetime_exceeded';
112
+ export declare function emptyKeySetState(): KeySetState;
113
+ export declare function parseKeyRecord(jwk: Record<string, unknown>, firstSeen: KeySetClockReading): KeyRecord;
114
+ export declare function isRegistryLive(state: KeySetState): boolean;
115
+ /**
116
+ * Merge one fetched JWKS body (already known to be an object with a `keys` array) into `state`.
117
+ * Pure: returns a new state and the events the caller should report.
118
+ */
119
+ export declare function mergeFetchedDocument(state: KeySetState, body: Record<string, unknown>, now: KeySetClockReading): MergeResult;
120
+ /**
121
+ * Which keys may verify a token of `family` carrying `kid`. Checks the applied tombstone and the
122
+ * key's purpose; the signature and the window are the caller's next steps, in that order.
123
+ */
124
+ export declare function selectKeys(state: KeySetState, kid: string | undefined, family: KeyFamily): KeySelection;
125
+ /** The window and lifetime rules for a key whose signature has verified. */
126
+ export declare function checkKeyPolicy(record: KeyRecord, token: {
127
+ iat: unknown;
128
+ exp: unknown;
129
+ }, registryLive: boolean): KeyPolicyViolation | null;
130
+ /**
131
+ * Fold the tombstones of a persisted state into a set this process has already fetched. The
132
+ * fetched keys are newer than disk, but a tombstone is not something a later fetch can restore:
133
+ * dropping the persisted one would silently un-revoke a key until two more fetches agreed again.
134
+ */
135
+ export declare function withPersistedTombstones(state: KeySetState, persisted: KeySetState): KeySetState;
136
+ /**
137
+ * The part of a cached set that may answer after a refetch FAILED: confirmed keys only. An
138
+ * unconfirmed key is trusted until the next document, and a failing fetch must not stretch that to
139
+ * `maxJwksAgeMs` — otherwise one poisoned response followed by an outage would keep its key, and
140
+ * the registry mode it switched on, for a day.
141
+ */
142
+ export declare function withConfirmedKeysOnly(state: KeySetState): KeySetState;
143
+ /** Drop every applied and pending tombstone — the documented manual reset. */
144
+ export declare function withoutTombstones(state: KeySetState): KeySetState;
145
+ /**
146
+ * Persisted form. Every timestamp is wall-clock milliseconds: a monotonic reading means nothing
147
+ * to another process.
148
+ */
149
+ export declare function serializeKeySetState(state: KeySetState): string;
150
+ /**
151
+ * Rebuild a persisted state, or null when the blob is not one. A `fetched_at` in the future marks
152
+ * the set stale; a pending tombstone first seen in the future is dropped.
153
+ */
154
+ export declare function deserializeKeySetState(raw: string, now: KeySetClockReading): KeySetState | null;
155
+ //# sourceMappingURL=keyset.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyset.d.ts","sourceRoot":"","sources":["../src/keyset.ts"],"names":[],"mappings":"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"}
package/dist/keyset.js ADDED
@@ -0,0 +1,505 @@
1
+ /**
2
+ * The key-set rules, free of I/O: how a fetched JWKS is merged into what the verifier already
3
+ * knows, and which key — if any — may verify a token of a given family.
4
+ *
5
+ * Kept apart from `jwks.ts` (fetch, cache, cooldowns) because these are the rules every verifier
6
+ * must agree on — the JS, Python, Go and PHP SDKs port them function for function, and the shared
7
+ * fixtures in `sdks/shared/fixtures/jwks_rotation/` pin the outcome of each one.
8
+ *
9
+ * Four properties are load-bearing:
10
+ *
11
+ * - One response is never trusted for long. Anything that would outlive the next fetch — a key
12
+ * being remembered, a window narrowing, a tombstone — takes effect only when a second fetch, at
13
+ * least five minutes after the first sighting, says the same. A key verifies from its first
14
+ * sighting (an emergency key must work at once), but until it is confirmed the next document
15
+ * replaces or drops it, exactly as before rotation existed. Otherwise one poisoned response
16
+ * could plant a key the verifier keeps for as long as that key's own members claim, or revoke
17
+ * or re-date an honest key fleet-wide.
18
+ * - A fetch is MERGED, never swapped in. A CONFIRMED registry key that disappears keeps its
19
+ * last-known state, never gets new key material, and its window may narrow (by the rule above)
20
+ * but never widen — a document that widens one is refused whole, whatever key material it
21
+ * serves under that kid.
22
+ * - An applied tombstone is sticky until every token the key signed is dead.
23
+ * - "Registry live" is a property of the merged set (some key carries `proof_purpose`). Until
24
+ * then the set is in tolerant mode and behaves exactly as it did before rotation existed. An
25
+ * unconfirmed key counts too: it is gone with the next document that omits it.
26
+ */
27
+ import { isRecord } from './jwt.js';
28
+ export const KEY_FAMILIES = ['proof', 'delegation', 'status-list'];
29
+ /** Clock skew tolerated on each edge of a key's `[not_before, not_after]` window. */
30
+ export const KEY_WINDOW_SKEW_SECONDS = 300;
31
+ /** Minimum spacing between the two fetches that confirm a key, a narrowed window or a tombstone. */
32
+ export const CONFIRM_SPACING_MS = 5 * 60_000;
33
+ /** An applied tombstone is kept until `revoked_at` plus this. */
34
+ export const TOMBSTONE_RETENTION_SECONDS = 365 * 86_400;
35
+ export function emptyKeySetState() {
36
+ return {
37
+ keys: new Map(),
38
+ anonymous: [],
39
+ pending: new Map(),
40
+ applied: new Map(),
41
+ fetchedAt: null,
42
+ fetchedAtUntrusted: false,
43
+ };
44
+ }
45
+ function isWholeNumber(value) {
46
+ return typeof value === 'number' && Number.isInteger(value);
47
+ }
48
+ function isKeyFamily(value) {
49
+ return typeof value === 'string' && KEY_FAMILIES.includes(value);
50
+ }
51
+ const MATERIAL_MEMBERS = ['kty', 'crv', 'x', 'y', 'n', 'e'];
52
+ function fingerprintOf(jwk) {
53
+ return JSON.stringify(MATERIAL_MEMBERS.map((member) => jwk[member] ?? null));
54
+ }
55
+ function usageOf(jwk) {
56
+ return JSON.stringify([jwk.alg ?? null, jwk.use ?? null, jwk.key_ops ?? null]);
57
+ }
58
+ export function parseKeyRecord(jwk, firstSeen) {
59
+ const record = {
60
+ jwk,
61
+ fingerprint: fingerprintOf(jwk),
62
+ usage: usageOf(jwk),
63
+ hasPurposeMember: jwk.proof_purpose !== undefined,
64
+ legacy: jwk.proof_legacy === true,
65
+ vanished: false,
66
+ firstSeen,
67
+ confirmed: false,
68
+ };
69
+ if (typeof jwk.kid === 'string' && jwk.kid.length > 0)
70
+ record.kid = jwk.kid;
71
+ if (isKeyFamily(jwk.proof_purpose))
72
+ record.purpose = jwk.proof_purpose;
73
+ if (isWholeNumber(jwk.not_before))
74
+ record.notBefore = jwk.not_before;
75
+ if (isWholeNumber(jwk.not_after))
76
+ record.notAfter = jwk.not_after;
77
+ if (isWholeNumber(jwk.max_token_lifetime))
78
+ record.maxTokenLifetime = jwk.max_token_lifetime;
79
+ return record;
80
+ }
81
+ function hasWindow(record) {
82
+ return record.notBefore !== undefined && record.notAfter !== undefined && record.maxTokenLifetime !== undefined;
83
+ }
84
+ /**
85
+ * A key the issuer's registry describes — it carries `proof_purpose` or `proof_legacy` — AND that
86
+ * two fetches at least `CONFIRM_SPACING_MS` apart served unchanged. The ONE predicate behind every
87
+ * rule that outlives a fetch: material lock, no widening, pending narrowing, retained when it
88
+ * vanishes. Window members alone do not make a registry key: without a purpose such a key cannot
89
+ * verify anything once the registry is live, so it keeps tolerant semantics.
90
+ */
91
+ function isProtectedRecord(record) {
92
+ return record.confirmed && (record.hasPurposeMember || record.legacy);
93
+ }
94
+ /**
95
+ * Whether two records describe the same key: material, usage, purpose, legacy marker and window.
96
+ * Usage counts because the record kept is the one first seen: a poisoned first sighting marked
97
+ * `use: "enc"` must be replaced by the honest description, not confirmed by it.
98
+ */
99
+ function describesSameKey(a, b) {
100
+ return (a.fingerprint === b.fingerprint &&
101
+ a.usage === b.usage &&
102
+ a.hasPurposeMember === b.hasPurposeMember &&
103
+ a.purpose === b.purpose &&
104
+ a.legacy === b.legacy &&
105
+ a.notBefore === b.notBefore &&
106
+ a.notAfter === b.notAfter &&
107
+ a.maxTokenLifetime === b.maxTokenLifetime);
108
+ }
109
+ function spacingElapsed(since, now) {
110
+ return now.monotonicMs - since.monotonicMs >= CONFIRM_SPACING_MS;
111
+ }
112
+ export function isRegistryLive(state) {
113
+ for (const record of state.keys.values())
114
+ if (record.hasPurposeMember)
115
+ return true;
116
+ return state.anonymous.some((record) => record.hasPurposeMember);
117
+ }
118
+ function wallSeconds(reading) {
119
+ return Math.floor(reading.wallMs / 1000);
120
+ }
121
+ /** Whether a kid may still be signing: legacy, window unknown, or not yet past `not_after`. */
122
+ function isLiveKey(record, nowSeconds) {
123
+ if (record.legacy || !hasWindow(record))
124
+ return true;
125
+ return nowSeconds <= record.notAfter + KEY_WINDOW_SKEW_SECONDS;
126
+ }
127
+ function parseTombstones(value) {
128
+ if (!Array.isArray(value))
129
+ return [];
130
+ const tombstones = [];
131
+ for (const entry of value) {
132
+ if (!isRecord(entry))
133
+ continue;
134
+ if (typeof entry.kid !== 'string' || entry.kid.length === 0)
135
+ continue;
136
+ if (!isWholeNumber(entry.revoked_at))
137
+ continue;
138
+ tombstones.push({ kid: entry.kid, revokedAt: entry.revoked_at });
139
+ }
140
+ return tombstones;
141
+ }
142
+ function cloneState(state) {
143
+ return {
144
+ keys: new Map([...state.keys].map(([kid, record]) => [kid, { ...record }])),
145
+ anonymous: state.anonymous.map((record) => ({ ...record })),
146
+ pending: new Map(state.pending),
147
+ applied: new Map(state.applied),
148
+ fetchedAt: state.fetchedAt,
149
+ fetchedAtUntrusted: state.fetchedAtUntrusted,
150
+ };
151
+ }
152
+ /**
153
+ * Merge one fetched JWKS body (already known to be an object with a `keys` array) into `state`.
154
+ * Pure: returns a new state and the events the caller should report.
155
+ */
156
+ export function mergeFetchedDocument(state, body, now) {
157
+ const nowSeconds = wallSeconds(now);
158
+ const candidates = Array.isArray(body.keys) ? body.keys.filter(isRecord) : [];
159
+ const served = new Map();
160
+ const anonymous = [];
161
+ for (const jwk of candidates) {
162
+ const record = parseKeyRecord(jwk, now);
163
+ if (record.kid)
164
+ served.set(record.kid, record);
165
+ else
166
+ anonymous.push(record);
167
+ }
168
+ // Widening is checked before anything is touched: such a document is refused whole.
169
+ for (const [kid, incoming] of served) {
170
+ const known = state.keys.get(kid);
171
+ if (!known || !isProtectedRecord(known) || !hasWindow(known) || !hasWindow(incoming))
172
+ continue;
173
+ if (incoming.notBefore < known.notBefore || incoming.notAfter > known.notAfter) {
174
+ return {
175
+ state,
176
+ accepted: false,
177
+ events: [
178
+ {
179
+ type: 'window_widening_rejected',
180
+ kid,
181
+ message: `JWKS refused: it widens the window of confirmed key "${kid}"`,
182
+ },
183
+ ],
184
+ };
185
+ }
186
+ }
187
+ const next = cloneState(state);
188
+ const events = [];
189
+ const tombstones = parseTombstones(body.proof_tombstones);
190
+ const tombstonedNow = new Set(tombstones.map((tombstone) => tombstone.kid));
191
+ for (const [kid, incoming] of served) {
192
+ const known = next.keys.get(kid);
193
+ // Not protected yet: the latest document describes the key, exactly as before rotation
194
+ // existed. Serving the same description again, far enough apart, confirms it.
195
+ if (!known || !isProtectedRecord(known)) {
196
+ if (known && describesSameKey(known, incoming)) {
197
+ if (!known.confirmed && spacingElapsed(known.firstSeen, now))
198
+ known.confirmed = true;
199
+ }
200
+ else {
201
+ next.keys.set(kid, incoming);
202
+ }
203
+ continue;
204
+ }
205
+ known.vanished = false;
206
+ if (known.fingerprint !== incoming.fingerprint) {
207
+ delete known.pendingWindow;
208
+ events.push({
209
+ type: 'key_material_changed',
210
+ kid,
211
+ message: `JWKS serves different key material for confirmed key "${kid}"; the first-seen key is kept`,
212
+ });
213
+ continue;
214
+ }
215
+ if (!hasWindow(known) || !hasWindow(incoming))
216
+ continue;
217
+ if (incoming.notBefore === known.notBefore && incoming.notAfter === known.notAfter) {
218
+ delete known.pendingWindow;
219
+ continue;
220
+ }
221
+ const pending = known.pendingWindow;
222
+ if (!pending || pending.notBefore !== incoming.notBefore || pending.notAfter !== incoming.notAfter) {
223
+ known.pendingWindow = { notBefore: incoming.notBefore, notAfter: incoming.notAfter, firstSeen: now };
224
+ events.push({
225
+ type: 'window_narrowing_pending',
226
+ kid,
227
+ message: `JWKS narrows the window of confirmed key "${kid}"; applied only if a fetch at least 5 minutes later serves the same window`,
228
+ });
229
+ continue;
230
+ }
231
+ if (!spacingElapsed(pending.firstSeen, now))
232
+ continue;
233
+ known.notBefore = pending.notBefore;
234
+ known.notAfter = pending.notAfter;
235
+ known.jwk = { ...known.jwk, not_before: pending.notBefore, not_after: pending.notAfter };
236
+ delete known.pendingWindow;
237
+ events.push({
238
+ type: 'window_narrowing_applied',
239
+ kid,
240
+ message: `narrowed window of key "${kid}" confirmed by a second fetch and applied`,
241
+ });
242
+ }
243
+ for (const [kid, known] of [...next.keys]) {
244
+ if (served.has(kid))
245
+ continue;
246
+ if (!isProtectedRecord(known)) {
247
+ next.keys.delete(kid);
248
+ continue;
249
+ }
250
+ if (hasWindow(known)) {
251
+ if (nowSeconds > known.notAfter + known.maxTokenLifetime + KEY_WINDOW_SKEW_SECONDS) {
252
+ next.keys.delete(kid);
253
+ continue;
254
+ }
255
+ }
256
+ else if (!known.legacy) {
257
+ next.keys.delete(kid);
258
+ continue;
259
+ }
260
+ // A narrowing is confirmed by consecutive documents serving it; one that omits the kid breaks the run.
261
+ delete known.pendingWindow;
262
+ if (!known.vanished && !tombstonedNow.has(kid)) {
263
+ known.vanished = true;
264
+ events.push({
265
+ type: 'kid_vanished',
266
+ kid,
267
+ message: `key "${kid}" is no longer served before its retention ended; its last-known state is kept`,
268
+ });
269
+ }
270
+ }
271
+ next.anonymous = anonymous;
272
+ for (const [kid] of next.pending) {
273
+ if (!tombstonedNow.has(kid))
274
+ next.pending.delete(kid);
275
+ }
276
+ for (const tombstone of tombstones) {
277
+ if (next.applied.has(tombstone.kid))
278
+ continue;
279
+ const pending = next.pending.get(tombstone.kid);
280
+ if (pending) {
281
+ if (spacingElapsed(pending.firstSeen, now)) {
282
+ next.pending.delete(tombstone.kid);
283
+ next.applied.set(tombstone.kid, { kid: tombstone.kid, revokedAt: pending.revokedAt });
284
+ events.push({
285
+ type: 'tombstone_applied',
286
+ kid: tombstone.kid,
287
+ message: `tombstone for key "${tombstone.kid}" confirmed by a second fetch and applied`,
288
+ });
289
+ }
290
+ continue;
291
+ }
292
+ next.pending.set(tombstone.kid, { kid: tombstone.kid, revokedAt: tombstone.revokedAt, firstSeen: now });
293
+ events.push({
294
+ type: 'tombstone_pending',
295
+ kid: tombstone.kid,
296
+ message: `tombstone for key "${tombstone.kid}" seen; applied only if a fetch at least 5 minutes later still carries it`,
297
+ });
298
+ // "Last seen live" is read from what the verifier knew BEFORE this document, which may itself
299
+ // have dropped the key.
300
+ const known = state.keys.get(tombstone.kid);
301
+ if (known && isLiveKey(known, nowSeconds)) {
302
+ events.push({
303
+ type: 'tombstone_for_live_key',
304
+ kid: tombstone.kid,
305
+ message: `ANOMALY: tombstone names key "${tombstone.kid}", which was last seen live`,
306
+ });
307
+ }
308
+ }
309
+ for (const [kid, applied] of [...next.applied]) {
310
+ if (nowSeconds > applied.revokedAt + TOMBSTONE_RETENTION_SECONDS)
311
+ next.applied.delete(kid);
312
+ }
313
+ next.fetchedAt = now;
314
+ next.fetchedAtUntrusted = false;
315
+ return { state: next, events, accepted: true };
316
+ }
317
+ /**
318
+ * Which keys may verify a token of `family` carrying `kid`. Checks the applied tombstone and the
319
+ * key's purpose; the signature and the window are the caller's next steps, in that order.
320
+ */
321
+ export function selectKeys(state, kid, family) {
322
+ const registryLive = isRegistryLive(state);
323
+ if (kid !== undefined) {
324
+ if (state.applied.has(kid))
325
+ return { kind: 'revoked', kid };
326
+ const record = state.keys.get(kid);
327
+ if (!record)
328
+ return { kind: 'unknown' };
329
+ if (record.legacy || !registryLive)
330
+ return { kind: 'candidates', records: [record] };
331
+ if (record.purpose !== family)
332
+ return { kind: 'wrong_purpose', kid };
333
+ return { kind: 'candidates', records: [record] };
334
+ }
335
+ // No `kid`: once the registry is live only a key marked legacy may answer — trying every key
336
+ // would let a token borrow any family's key and skip the purpose check.
337
+ const all = [...state.anonymous, ...state.keys.values()].filter((record) => !(record.kid && state.applied.has(record.kid)));
338
+ const records = registryLive ? all.filter((record) => record.legacy) : all;
339
+ return records.length > 0 ? { kind: 'candidates', records } : { kind: 'unknown' };
340
+ }
341
+ /** The window and lifetime rules for a key whose signature has verified. */
342
+ export function checkKeyPolicy(record, token, registryLive) {
343
+ if (!registryLive || record.legacy)
344
+ return null;
345
+ if (!hasWindow(record))
346
+ return 'outside_key_window';
347
+ if (!isWholeNumber(token.iat))
348
+ return 'outside_key_window';
349
+ if (token.iat < record.notBefore - KEY_WINDOW_SKEW_SECONDS)
350
+ return 'outside_key_window';
351
+ if (token.iat > record.notAfter + KEY_WINDOW_SKEW_SECONDS)
352
+ return 'outside_key_window';
353
+ if (typeof token.exp !== 'number' || token.exp > token.iat + record.maxTokenLifetime) {
354
+ return 'token_lifetime_exceeded';
355
+ }
356
+ return null;
357
+ }
358
+ /**
359
+ * Fold the tombstones of a persisted state into a set this process has already fetched. The
360
+ * fetched keys are newer than disk, but a tombstone is not something a later fetch can restore:
361
+ * dropping the persisted one would silently un-revoke a key until two more fetches agreed again.
362
+ */
363
+ export function withPersistedTombstones(state, persisted) {
364
+ const next = cloneState(state);
365
+ for (const [kid, applied] of persisted.applied) {
366
+ if (!next.applied.has(kid))
367
+ next.applied.set(kid, applied);
368
+ }
369
+ for (const [kid, pending] of persisted.pending) {
370
+ if (!next.applied.has(kid) && !next.pending.has(kid))
371
+ next.pending.set(kid, pending);
372
+ }
373
+ return next;
374
+ }
375
+ /**
376
+ * The part of a cached set that may answer after a refetch FAILED: confirmed keys only. An
377
+ * unconfirmed key is trusted until the next document, and a failing fetch must not stretch that to
378
+ * `maxJwksAgeMs` — otherwise one poisoned response followed by an outage would keep its key, and
379
+ * the registry mode it switched on, for a day.
380
+ */
381
+ export function withConfirmedKeysOnly(state) {
382
+ const next = cloneState(state);
383
+ for (const [kid, record] of [...next.keys]) {
384
+ if (!record.confirmed)
385
+ next.keys.delete(kid);
386
+ }
387
+ next.anonymous = [];
388
+ return next;
389
+ }
390
+ /** Drop every applied and pending tombstone — the documented manual reset. */
391
+ export function withoutTombstones(state) {
392
+ const next = cloneState(state);
393
+ next.applied.clear();
394
+ next.pending.clear();
395
+ return next;
396
+ }
397
+ const PERSISTED_VERSION = 1;
398
+ /**
399
+ * Persisted form. Every timestamp is wall-clock milliseconds: a monotonic reading means nothing
400
+ * to another process.
401
+ */
402
+ export function serializeKeySetState(state) {
403
+ return JSON.stringify({
404
+ v: PERSISTED_VERSION,
405
+ fetched_at_wall_ms: state.fetchedAt && !state.fetchedAtUntrusted ? state.fetchedAt.wallMs : null,
406
+ keys: [...state.keys.values()].map((record) => ({
407
+ jwk: record.jwk,
408
+ vanished: record.vanished,
409
+ confirmed: record.confirmed,
410
+ first_seen_wall_ms: record.firstSeen.wallMs,
411
+ ...(record.pendingWindow
412
+ ? {
413
+ pending_window: {
414
+ not_before: record.pendingWindow.notBefore,
415
+ not_after: record.pendingWindow.notAfter,
416
+ first_seen_wall_ms: record.pendingWindow.firstSeen.wallMs,
417
+ },
418
+ }
419
+ : {}),
420
+ })),
421
+ anonymous: state.anonymous.map((record) => record.jwk),
422
+ pending: [...state.pending.values()].map((pending) => ({
423
+ kid: pending.kid,
424
+ revoked_at: pending.revokedAt,
425
+ first_seen_wall_ms: pending.firstSeen.wallMs,
426
+ })),
427
+ applied: [...state.applied.values()].map((applied) => ({ kid: applied.kid, revoked_at: applied.revokedAt })),
428
+ });
429
+ }
430
+ function fromWall(wallMs, now) {
431
+ return { wallMs, monotonicMs: now.monotonicMs - (now.wallMs - wallMs) };
432
+ }
433
+ /**
434
+ * Rebuild a persisted state, or null when the blob is not one. A `fetched_at` in the future marks
435
+ * the set stale; a pending tombstone first seen in the future is dropped.
436
+ */
437
+ export function deserializeKeySetState(raw, now) {
438
+ let parsed;
439
+ try {
440
+ parsed = JSON.parse(raw);
441
+ }
442
+ catch {
443
+ return null;
444
+ }
445
+ if (!isRecord(parsed) || parsed.v !== PERSISTED_VERSION)
446
+ return null;
447
+ const state = emptyKeySetState();
448
+ if (Array.isArray(parsed.keys)) {
449
+ for (const entry of parsed.keys) {
450
+ if (!isRecord(entry) || !isRecord(entry.jwk))
451
+ continue;
452
+ // A first sighting persisted in the future cannot be timed: the key starts over as unconfirmed.
453
+ const firstSeenWall = entry.first_seen_wall_ms;
454
+ const firstSeen = isWholeNumber(firstSeenWall) && firstSeenWall <= now.wallMs ? fromWall(firstSeenWall, now) : null;
455
+ const record = parseKeyRecord(entry.jwk, firstSeen ?? now);
456
+ if (!record.kid)
457
+ continue;
458
+ record.vanished = entry.vanished === true;
459
+ record.confirmed = firstSeen !== null && entry.confirmed === true;
460
+ const pendingWindow = entry.pending_window;
461
+ if (isRecord(pendingWindow) &&
462
+ isWholeNumber(pendingWindow.not_before) &&
463
+ isWholeNumber(pendingWindow.not_after) &&
464
+ isWholeNumber(pendingWindow.first_seen_wall_ms) &&
465
+ pendingWindow.first_seen_wall_ms <= now.wallMs) {
466
+ record.pendingWindow = {
467
+ notBefore: pendingWindow.not_before,
468
+ notAfter: pendingWindow.not_after,
469
+ firstSeen: fromWall(pendingWindow.first_seen_wall_ms, now),
470
+ };
471
+ }
472
+ state.keys.set(record.kid, record);
473
+ }
474
+ }
475
+ if (Array.isArray(parsed.anonymous)) {
476
+ state.anonymous = parsed.anonymous.filter(isRecord).map((jwk) => parseKeyRecord(jwk, now));
477
+ }
478
+ for (const applied of parseTombstones(parsed.applied))
479
+ state.applied.set(applied.kid, applied);
480
+ if (Array.isArray(parsed.pending)) {
481
+ for (const entry of parsed.pending) {
482
+ if (!isRecord(entry) || typeof entry.kid !== 'string' || !isWholeNumber(entry.revoked_at))
483
+ continue;
484
+ if (!isWholeNumber(entry.first_seen_wall_ms) || entry.first_seen_wall_ms > now.wallMs)
485
+ continue;
486
+ state.pending.set(entry.kid, {
487
+ kid: entry.kid,
488
+ revokedAt: entry.revoked_at,
489
+ firstSeen: fromWall(entry.first_seen_wall_ms, now),
490
+ });
491
+ }
492
+ }
493
+ const fetchedAtWall = parsed.fetched_at_wall_ms;
494
+ if (isWholeNumber(fetchedAtWall)) {
495
+ if (fetchedAtWall > now.wallMs) {
496
+ state.fetchedAt = now;
497
+ state.fetchedAtUntrusted = true;
498
+ }
499
+ else {
500
+ state.fetchedAt = fromWall(fetchedAtWall, now);
501
+ }
502
+ }
503
+ return state;
504
+ }
505
+ //# sourceMappingURL=keyset.js.map