@forgesworn/fold-kit 0.4.0 → 0.5.0

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,643 @@
1
+ import { hkdf } from '@noble/hashes/hkdf';
2
+ import { sha256 } from '@noble/hashes/sha2';
3
+ import { base64urlnopad } from '@scure/base';
4
+ import { nip44 } from 'nostr-tools';
5
+ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
6
+ import { hexEquals, normaliseHex } from './hex.js';
7
+ import { verifyDeviceCredential } from './credential.js';
8
+ import { evaluateAccess } from './access.js';
9
+ import { verifyEventUncached } from './verify.js';
10
+ import { withExpiration } from './expiration.js';
11
+ import { epochCommitment } from './epoch-commit.js';
12
+ import { KINDS } from './kinds.js';
13
+ import { MAX_EPOCH, deriveEpoch, epochRequestAdmission, peekRekeyEvent, } from './epoch.js';
14
+ /**
15
+ * Member-to-member epoch catch-up: any current member can bring an
16
+ * admitted, non-removed member up to date.
17
+ *
18
+ * `hostRoomEpoch` answers an epoch request only where the authority's key
19
+ * is, and the request is ephemeral, so a device that missed a rekey - a
20
+ * phone that came back with a new device key, which no stored rekey seals
21
+ * to - recovers only while the authority's device is online with the room
22
+ * open. Every other member at the current epoch holds the secret and, until
23
+ * now, could not pass it on in a form the requester could check.
24
+ *
25
+ * It can now, because the requester does not trust the member at all. The
26
+ * grant carries the authority-signed rekey events from the requester's
27
+ * epoch to the current one, and the secret of every epoch in between:
28
+ *
29
+ * - each rekey's signature is the authority's (`peekRekeyEvent`);
30
+ * - each rekey decrypts under the key of the epoch before it, starting from
31
+ * the requester's own - and NIP-44's MAC verifies under no other key, so
32
+ * a decrypting rekey j+1 proves the secret offered for epoch j is the one
33
+ * the authority encrypted under;
34
+ * - the last epoch, which has no successor to vouch for it, is checked
35
+ * against the commitment the authority wrote into its rekey body
36
+ * (`epochCommitment`, `encodeRekeyEvent({ commit: true })`). A last rekey
37
+ * with no commitment - every rekey written before this existed - is not
38
+ * accepted from a member: that epoch stays authority-only.
39
+ *
40
+ * The removed and closed facts come from the same authority-signed bodies,
41
+ * so a member can neither hide a removal from the requester nor invent
42
+ * one. Answerers refuse the removed and a closed room by not answering at
43
+ * all; only the authority's refusal is something a requester can believe.
44
+ * See `docs/member-epoch-catch-up.md` for the threat model.
45
+ */
46
+ /** The member path's two kinds. Ephemeral, like 20468/20469: a live
47
+ * handshake, never a record. */
48
+ export const MEMBER_EPOCH_KINDS = {
49
+ /** A device behind the room asking any current member for the epochs it
50
+ * missed. Encrypted under a key from the epoch-0 room key, so any
51
+ * admitted device can read it and a relay cannot. */
52
+ REQUEST: 20471,
53
+ /** A member's answer, signed by a one-time key made for that grant and
54
+ * sealed to the asking device: secrets plus the authority-signed rekeys
55
+ * that prove them. */
56
+ GRANT: 20472,
57
+ };
58
+ /** HKDF info for the key a member epoch request's body is sealed under. */
59
+ export const MEMBER_EPOCH_REQUEST_KEY_INFO = 'kithmoot/v1/member-epoch-request-key';
60
+ /** The most epochs one member grant will carry. A device further behind
61
+ * than this asks the authority. */
62
+ export const MAX_MEMBER_EPOCH_CHAIN = 32;
63
+ const MAX_AGE_SECONDS = 90;
64
+ const DEFAULT_TIMEOUT_MS = 30_000;
65
+ const DEFAULT_RETRY_MS = 4_000;
66
+ const DEFAULT_JITTER_MS = 1_500;
67
+ /** How long a requester waits for its relays to finish replaying the room's
68
+ * rekeys before it asks anyway. */
69
+ const REKEY_REPLAY_WAIT_MS = 1_500;
70
+ /** Many relays refuse events much over 64 KiB. A grant inlines whole rekey
71
+ * events, each with a seal per device, so a long chain in a big room can
72
+ * pass that; a desk does not send one that would. */
73
+ const DEFAULT_MAX_GRANT_BYTES = 60_000;
74
+ const HEX64 = /^[0-9a-f]{64}$/i;
75
+ function requireHex32(value, what) {
76
+ if (typeof value !== 'string' || !HEX64.test(value))
77
+ throw new Error(`${what} must be 32-byte hex`);
78
+ return normaliseHex(value);
79
+ }
80
+ /** Equality that does not leak where two proofs first differ. */
81
+ function constantTimeEquals(presented, expected) {
82
+ const a = normaliseHex(presented);
83
+ if (a.length !== expected.length)
84
+ return false;
85
+ let diff = 0;
86
+ for (let i = 0; i < a.length; i += 1)
87
+ diff |= a.charCodeAt(i) ^ expected.charCodeAt(i);
88
+ return diff === 0;
89
+ }
90
+ /** The key a member epoch request is sealed under: the epoch-0 room key,
91
+ * expanded under its own info string. */
92
+ export function deriveMemberEpochRequestKey(roomKey) {
93
+ if (roomKey.length !== 32)
94
+ throw new Error('room key must be 32 bytes');
95
+ return hkdf(sha256, roomKey, undefined, MEMBER_EPOCH_REQUEST_KEY_INFO, 32);
96
+ }
97
+ /**
98
+ * Read an authority-signed rekey with the key of the epoch it leaves,
99
+ * without needing a seal of one's own. Null for anything that does not
100
+ * check out: not the authority, not this room, not epoch `previous.epoch
101
+ * + 1`, or not encrypted under `previous.key`. What both sides of a member
102
+ * grant use to check the chain.
103
+ */
104
+ export function readRekeyEvidence(event, opts) {
105
+ try {
106
+ const epoch = peekRekeyEvent(event, opts);
107
+ if (epoch === null || epoch !== opts.previous.epoch + 1)
108
+ return null;
109
+ const body = JSON.parse(nip44.v2.decrypt(event.content, opts.previous.key));
110
+ if (body.v !== 1 || body.epoch !== epoch)
111
+ return null;
112
+ if (!Array.isArray(body.removed) || !body.removed.every((p) => typeof p === 'string' && HEX64.test(p)))
113
+ return null;
114
+ const evidence = {
115
+ epoch,
116
+ removed: [...new Set(body.removed.map(normaliseHex))].sort(),
117
+ closed: body.closed === true,
118
+ };
119
+ if (typeof body.commit === 'string' && HEX64.test(body.commit))
120
+ evidence.commit = normaliseHex(body.commit);
121
+ return evidence;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /** Ask any current member for the epochs after `have`. */
128
+ export function encodeMemberEpochRequest(opts) {
129
+ if (opts.deviceSk.length !== 32)
130
+ throw new Error('device secret key must be 32 bytes');
131
+ const roomId = requireHex32(opts.roomId, 'room id');
132
+ const authority = requireHex32(opts.authority, 'authority pubkey');
133
+ if (!Number.isSafeInteger(opts.have) || opts.have < 0 || opts.have > MAX_EPOCH)
134
+ throw new Error('have must be a small non-negative integer');
135
+ const admission = epochRequestAdmission({ roomKey: opts.roomKey, roomId, authority, device: getPublicKey(opts.deviceSk), createdAt: opts.now });
136
+ const body = {
137
+ v: 1,
138
+ credential: opts.credential,
139
+ ...(opts.proof ? { proof: opts.proof } : {}),
140
+ admission,
141
+ have: opts.have,
142
+ };
143
+ return finalizeEvent({
144
+ kind: MEMBER_EPOCH_KINDS.REQUEST,
145
+ created_at: opts.now,
146
+ tags: withExpiration([['d', roomId]], opts.expiresAt),
147
+ content: nip44.v2.encrypt(JSON.stringify(body), deriveMemberEpochRequestKey(opts.roomKey)),
148
+ }, opts.deviceSk);
149
+ }
150
+ /** Null for anything malformed, stale, from a device that cannot prove
151
+ * which participant it speaks for, or that cannot prove admission. */
152
+ export function decodeMemberEpochRequest(event, opts) {
153
+ try {
154
+ if (event.kind !== MEMBER_EPOCH_KINDS.REQUEST)
155
+ return null;
156
+ if (!verifyEventUncached(event))
157
+ return null;
158
+ if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? MAX_AGE_SECONDS))
159
+ return null;
160
+ const roomId = requireHex32(opts.roomId, 'room id');
161
+ if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== roomId)
162
+ return null;
163
+ const body = JSON.parse(nip44.v2.decrypt(event.content, deriveMemberEpochRequestKey(opts.roomKey)));
164
+ if (body.v !== 1 || typeof body.credential !== 'object' || body.credential === null)
165
+ return null;
166
+ if (!Number.isSafeInteger(body.have) || body.have < 0 || body.have > MAX_EPOCH)
167
+ return null;
168
+ const verdict = verifyDeviceCredential(body.credential, { roomId, now: opts.now });
169
+ if (!verdict.ok || !hexEquals(verdict.device, event.pubkey))
170
+ return null;
171
+ if (typeof body.admission !== 'string')
172
+ return null;
173
+ const expected = epochRequestAdmission({
174
+ roomKey: opts.roomKey,
175
+ roomId,
176
+ authority: requireHex32(opts.authority, 'authority pubkey'),
177
+ device: verdict.device,
178
+ createdAt: event.created_at,
179
+ });
180
+ if (!constantTimeEquals(body.admission, expected))
181
+ return null;
182
+ if (opts.policy) {
183
+ const proof = body.proof && typeof body.proof === 'object' ? body.proof : undefined;
184
+ if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, roomId).admitted)
185
+ return null;
186
+ }
187
+ return { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have };
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ function plainEvent(event) {
194
+ const { id, pubkey, created_at, kind, tags, content, sig } = event;
195
+ return { id, pubkey, created_at, kind, tags, content, sig };
196
+ }
197
+ /**
198
+ * Answer one member epoch request with the epochs it missed.
199
+ *
200
+ * The grant is signed, and sealed to the asking device, by a fresh key made
201
+ * for it alone and then dropped, not by the answering member's device key.
202
+ * Nothing about a grant is trusted on its signer (the authority's
203
+ * signatures are what the requester checks), so the member's key would add
204
+ * nothing but a public line from that device to the room id in the `d` tag.
205
+ */
206
+ export function encodeMemberEpochGrant(opts) {
207
+ const roomId = requireHex32(opts.roomId, 'room id');
208
+ const device = requireHex32(opts.device, 'device pubkey');
209
+ const request = requireHex32(opts.request, 'request id');
210
+ if (opts.epochs.length < 1 || opts.epochs.length > MAX_MEMBER_EPOCH_CHAIN)
211
+ throw new Error('a member grant carries 1 to 32 epochs');
212
+ if (opts.rekeys.length !== opts.epochs.length)
213
+ throw new Error('one rekey per epoch');
214
+ for (let i = 0; i < opts.epochs.length; i += 1) {
215
+ const e = opts.epochs[i];
216
+ if (e.secret.length !== 32)
217
+ throw new Error('epoch secret must be 32 bytes');
218
+ if (i > 0 && e.epoch !== opts.epochs[i - 1].epoch + 1)
219
+ throw new Error('epochs must be consecutive');
220
+ if (e.epoch < 1)
221
+ throw new Error('a member grant never carries epoch 0');
222
+ }
223
+ // Drawn before anything else, so a vector records it first.
224
+ const signerSk = generateSecretKey();
225
+ const body = {
226
+ v: 1,
227
+ request,
228
+ epoch: opts.epochs[opts.epochs.length - 1].epoch,
229
+ secrets: opts.epochs.map((e) => base64urlnopad.encode(e.secret)),
230
+ rekeys: opts.rekeys.map(plainEvent),
231
+ };
232
+ return finalizeEvent({
233
+ kind: MEMBER_EPOCH_KINDS.GRANT,
234
+ created_at: opts.now,
235
+ tags: withExpiration([
236
+ ['d', roomId],
237
+ ['p', device],
238
+ ], opts.expiresAt),
239
+ content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(signerSk, device)),
240
+ }, signerSk);
241
+ }
242
+ /**
243
+ * Verify a member's answer to one of this device's member requests. Null
244
+ * for anything that does not check out - see the module comment and
245
+ * `docs/member-epoch-catch-up.md` "Verification rules" for each check.
246
+ */
247
+ export function decodeMemberEpochGrant(event, opts) {
248
+ try {
249
+ if (event.kind !== MEMBER_EPOCH_KINDS.GRANT)
250
+ return null;
251
+ if (!verifyEventUncached(event))
252
+ return null;
253
+ if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? MAX_AGE_SECONDS))
254
+ return null;
255
+ const roomId = requireHex32(opts.roomId, 'room id');
256
+ if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== roomId)
257
+ return null;
258
+ const device = getPublicKey(opts.deviceSk);
259
+ const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
260
+ if (addressed === undefined || !hexEquals(addressed, device))
261
+ return null;
262
+ const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
263
+ if (body.v !== 1 || typeof body.request !== 'string' || !HEX64.test(body.request))
264
+ return null;
265
+ if (![...opts.requests].some((r) => hexEquals(r, body.request)))
266
+ return null;
267
+ if (!Array.isArray(body.secrets) || !Array.isArray(body.rekeys))
268
+ return null;
269
+ const length = body.secrets.length;
270
+ if (length < 1 || length > MAX_MEMBER_EPOCH_CHAIN || body.rekeys.length !== length)
271
+ return null;
272
+ const top = opts.current.epoch + length;
273
+ if (body.epoch !== top || top > MAX_EPOCH)
274
+ return null;
275
+ if (opts.expected !== undefined && top < opts.expected)
276
+ return null;
277
+ const participant = normaliseHex(opts.participant);
278
+ const removed = new Set([...(opts.removed ?? [])].map(normaliseHex));
279
+ let previous = opts.current;
280
+ let secret;
281
+ for (let i = 0; i < length; i += 1) {
282
+ const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
283
+ if (!evidence)
284
+ return null;
285
+ if (evidence.closed)
286
+ return null;
287
+ if (evidence.removed.includes(participant))
288
+ return null;
289
+ for (const p of evidence.removed)
290
+ removed.add(p);
291
+ const raw = body.secrets[i];
292
+ if (typeof raw !== 'string')
293
+ return null;
294
+ secret = base64urlnopad.decode(raw);
295
+ if (secret.length !== 32)
296
+ return null;
297
+ // Every secret but the last is proven by the next rekey decrypting
298
+ // under it, on the next turn of this loop. The last has no next rekey:
299
+ // the authority's commitment is the only thing that can vouch for it.
300
+ if (i === length - 1) {
301
+ if (evidence.commit === undefined)
302
+ return null;
303
+ if (!constantTimeEquals(evidence.commit, epochCommitment(roomId, evidence.epoch, secret)))
304
+ return null;
305
+ }
306
+ previous = deriveEpoch({ epoch: evidence.epoch, secret });
307
+ }
308
+ return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort() };
309
+ }
310
+ catch {
311
+ return null;
312
+ }
313
+ }
314
+ /**
315
+ * Answer member epoch requests for as long as the handle is open: what any
316
+ * member at the current epoch runs beside its session. It answers only an
317
+ * admitted, credentialled device that is not removed, in a room that is not
318
+ * closed, and only when it can hand over the whole chain from the
319
+ * requester's epoch to its own with the authority's rekeys to prove it.
320
+ *
321
+ * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
322
+ * dropped if another member's grant to the same device appears meanwhile.
323
+ * It is dropped at most once per requesting device: the requester asks again
324
+ * with a fresh request if what it got did not verify, and the second time
325
+ * every desk answers, so a stranger posting junk grants costs one round.
326
+ */
327
+ export function hostMemberEpochDesk(opts) {
328
+ if (opts.deviceSk.length !== 32)
329
+ throw new Error('device secret key must be 32 bytes');
330
+ if (opts.roomKey.length !== 32)
331
+ throw new Error('room key must be 32 bytes');
332
+ const roomId = requireHex32(opts.roomId, 'room id');
333
+ const authority = requireHex32(opts.authority, 'authority pubkey');
334
+ const self = getPublicKey(opts.deviceSk);
335
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
336
+ const random = opts.random ?? Math.random;
337
+ const jitterMs = Math.max(0, opts.jitterMs ?? DEFAULT_JITTER_MS);
338
+ const answered = new Set();
339
+ /** Requesting devices another member's grant was seen for, since their
340
+ * latest request reached this desk. */
341
+ const grantSeen = new Set();
342
+ /** Requesting devices this desk has already stood down for once. */
343
+ const stoodDown = new Set();
344
+ /** Ids of the grants this desk published: each is signed by a one-time
345
+ * key, so its own grants are told apart by id, not by signer. */
346
+ const mine = new Set();
347
+ const timers = new Set();
348
+ let closed = false;
349
+ const bound = (set) => {
350
+ if (set.size > 256)
351
+ set.delete(set.values().next().value);
352
+ };
353
+ const chainFor = (have, current) => {
354
+ if (current.epoch - have > MAX_MEMBER_EPOCH_CHAIN)
355
+ return null;
356
+ const epochs = [];
357
+ const rekeys = [];
358
+ for (let n = have + 1; n <= current.epoch; n += 1) {
359
+ const secret = n === current.epoch ? current.secret : opts.secretAt?.(n);
360
+ const rekey = opts.rekeyAt(n);
361
+ if (!secret || secret.length !== 32 || !rekey)
362
+ return null;
363
+ if (peekRekeyEvent(rekey, { roomId, authority }) !== n)
364
+ return null;
365
+ epochs.push({ epoch: n, secret });
366
+ rekeys.push(rekey);
367
+ }
368
+ // When this device can read the last rekey itself, it checks the
369
+ // commitment is there: a legacy epoch is the authority's to hand on, and
370
+ // a grant the requester will refuse is noise. One it cannot read (it
371
+ // joined at this epoch) it sends, and the requester decides.
372
+ const before = current.epoch - 1;
373
+ const beforeSecret = before === 0 ? undefined : opts.secretAt?.(before);
374
+ const beforeKey = before === 0 ? opts.roomKey : beforeSecret?.length === 32 ? deriveEpoch({ epoch: before, secret: beforeSecret }).key : undefined;
375
+ if (beforeKey) {
376
+ const evidence = readRekeyEvidence(rekeys[rekeys.length - 1], {
377
+ roomId,
378
+ authority,
379
+ previous: { epoch: before, id: '', key: beforeKey },
380
+ });
381
+ if (!evidence || evidence.commit === undefined)
382
+ return null;
383
+ }
384
+ return { epochs, rekeys };
385
+ };
386
+ const answer = (request) => {
387
+ if (closed)
388
+ return;
389
+ if (grantSeen.has(request.device) && !stoodDown.has(request.device)) {
390
+ stoodDown.add(request.device);
391
+ bound(stoodDown);
392
+ return;
393
+ }
394
+ stoodDown.delete(request.device);
395
+ // Asked again, now that the wait is over: the room may have moved.
396
+ if (opts.closed?.())
397
+ return;
398
+ if ([...opts.removed()].some((p) => hexEquals(p, request.participant)))
399
+ return;
400
+ const current = opts.current();
401
+ if (!current || request.have >= current.epoch)
402
+ return;
403
+ const chain = chainFor(request.have, current);
404
+ if (!chain)
405
+ return;
406
+ let grant;
407
+ try {
408
+ grant = encodeMemberEpochGrant({
409
+ roomId,
410
+ device: request.device,
411
+ request: request.request,
412
+ epochs: chain.epochs,
413
+ rekeys: chain.rekeys,
414
+ now: now(),
415
+ expiresAt: opts.expiresAt,
416
+ });
417
+ }
418
+ catch {
419
+ return;
420
+ }
421
+ if (JSON.stringify(grant).length > (opts.maxGrantBytes ?? DEFAULT_MAX_GRANT_BYTES))
422
+ return;
423
+ mine.add(grant.id);
424
+ bound(mine);
425
+ opts.transport.publish(grant).catch(() => { });
426
+ opts.onGranted?.(request);
427
+ };
428
+ const unsubGrants = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.GRANT], '#d': [roomId] }], (event) => {
429
+ if (closed || mine.has(event.id))
430
+ return;
431
+ const to = event.tags.find((t) => t[0] === 'p')?.[1];
432
+ if (to === undefined || !HEX64.test(to))
433
+ return;
434
+ grantSeen.add(normaliseHex(to));
435
+ bound(grantSeen);
436
+ });
437
+ const unsubRequests = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.REQUEST], '#d': [roomId] }], (event) => {
438
+ if (closed || hexEquals(event.pubkey, self))
439
+ return;
440
+ const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
441
+ if (!request || answered.has(request.request))
442
+ return;
443
+ answered.add(request.request);
444
+ bound(answered);
445
+ if (opts.closed?.()) {
446
+ opts.onRefused?.(request, 'closed');
447
+ return;
448
+ }
449
+ if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
450
+ opts.onRefused?.(request, 'removed');
451
+ return;
452
+ }
453
+ const current = opts.current();
454
+ if (!current || request.have >= current.epoch)
455
+ return;
456
+ grantSeen.delete(request.device);
457
+ const delay = jitterMs === 0 ? 0 : Math.floor(random() * jitterMs);
458
+ if (delay === 0) {
459
+ answer(request);
460
+ return;
461
+ }
462
+ const timer = setTimeout(() => {
463
+ timers.delete(timer);
464
+ answer(request);
465
+ }, delay);
466
+ timer.unref?.();
467
+ timers.add(timer);
468
+ });
469
+ return {
470
+ close() {
471
+ if (closed)
472
+ return;
473
+ closed = true;
474
+ for (const timer of timers)
475
+ clearTimeout(timer);
476
+ timers.clear();
477
+ unsubRequests();
478
+ unsubGrants();
479
+ },
480
+ };
481
+ }
482
+ /**
483
+ * The member side of a catch-up, for `requestRoomEpoch({ members })`. Each
484
+ * round publishes a fresh request (a new id, so a desk that stood down once
485
+ * answers it), and the first grant that verifies is handed to `onGrant`.
486
+ *
487
+ * It subscribes to the room's rekeys (kind 1462) before it asks, and refuses
488
+ * any grant that stops short of the newest one the authority signed: rule 3
489
+ * of `docs/member-epoch-catch-up.md`, enforced here rather than left to the
490
+ * caller, because a member removed at epoch E still holds E-1 and every
491
+ * rekey up to it, and would otherwise hold the requester on a key it shares.
492
+ */
493
+ export function memberEpochSource(opts) {
494
+ const roomId = requireHex32(opts.roomId, 'room id');
495
+ const authority = requireHex32(opts.authority, 'authority pubkey');
496
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
497
+ const device = getPublicKey(opts.deviceSk);
498
+ const current = () => (typeof opts.current === 'function' ? opts.current() : opts.current);
499
+ const expected = () => (typeof opts.expected === 'function' ? opts.expected() : opts.expected);
500
+ const knownRemoved = typeof opts.removed === 'function' || opts.removed === undefined ? undefined : [...opts.removed];
501
+ const removedNow = () => (typeof opts.removed === 'function' ? opts.removed() : knownRemoved);
502
+ const verdict = verifyDeviceCredential(opts.credential, { roomId, now: now() });
503
+ if (!verdict.ok)
504
+ throw new Error(`device credential refused: ${verdict.reason}`);
505
+ const participant = verdict.participant;
506
+ return {
507
+ start(onGrant) {
508
+ const requests = new Set();
509
+ let stopped = false;
510
+ let begun = false;
511
+ /** Set once everything below is wired up; EOSE can arrive before. */
512
+ let ready = false;
513
+ let replayed = false;
514
+ let retry;
515
+ let replayWait;
516
+ const stop = () => {
517
+ if (stopped)
518
+ return;
519
+ stopped = true;
520
+ if (retry !== undefined)
521
+ clearInterval(retry);
522
+ if (replayWait !== undefined)
523
+ clearTimeout(replayWait);
524
+ unsub();
525
+ unsubRekeys();
526
+ };
527
+ // The floor: the newest epoch the authority has signed a rekey into, as
528
+ // the relays replay it. `peekRekeyEvent` checks the signer, the room and
529
+ // the signature, so nobody else can raise it.
530
+ let seen = 0;
531
+ // The first request waits for the relays to finish replaying them
532
+ // (EOSE), or for REKEY_REPLAY_WAIT_MS, so that a member answering
533
+ // faster than the replay cannot slip a stale chain in under the floor.
534
+ const unsubRekeys = opts.transport.subscribe([{ kinds: [KINDS.ROOM_REKEY], '#d': [roomId] }], (event) => {
535
+ const epoch = peekRekeyEvent(event, { roomId, authority });
536
+ if (epoch !== null && epoch > seen)
537
+ seen = epoch;
538
+ }, () => {
539
+ replayed = true;
540
+ if (ready)
541
+ begin();
542
+ });
543
+ const floor = () => {
544
+ const given = expected();
545
+ const top = Math.max(seen, given ?? 0);
546
+ return top > 0 ? top : undefined;
547
+ };
548
+ const unsub = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.GRANT], '#d': [roomId], '#p': [device] }], (event) => {
549
+ if (stopped)
550
+ return;
551
+ const grant = decodeMemberEpochGrant(event, {
552
+ roomId,
553
+ authority,
554
+ deviceSk: opts.deviceSk,
555
+ requests,
556
+ current: current(),
557
+ participant,
558
+ removed: removedNow(),
559
+ expected: floor(),
560
+ now: now(),
561
+ });
562
+ if (!grant)
563
+ return;
564
+ stop();
565
+ onGrant(grant);
566
+ });
567
+ const ask = () => {
568
+ if (stopped)
569
+ return;
570
+ let request;
571
+ try {
572
+ request = encodeMemberEpochRequest({
573
+ roomId,
574
+ authority,
575
+ deviceSk: opts.deviceSk,
576
+ roomKey: opts.roomKey,
577
+ credential: opts.credential,
578
+ proof: opts.proof,
579
+ have: current().epoch,
580
+ now: now(),
581
+ expiresAt: opts.expiresAt,
582
+ });
583
+ }
584
+ catch {
585
+ return;
586
+ }
587
+ requests.add(request.id);
588
+ if (requests.size > 16)
589
+ requests.delete(requests.values().next().value);
590
+ opts.transport.publish(request).catch(() => { });
591
+ };
592
+ function begin() {
593
+ if (begun || stopped)
594
+ return;
595
+ begun = true;
596
+ if (replayWait !== undefined)
597
+ clearTimeout(replayWait);
598
+ retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
599
+ retry.unref?.();
600
+ ask();
601
+ }
602
+ ready = true;
603
+ if (replayed)
604
+ begin();
605
+ else {
606
+ replayWait = setTimeout(begin, REKEY_REPLAY_WAIT_MS);
607
+ replayWait.unref?.();
608
+ }
609
+ return stop;
610
+ },
611
+ };
612
+ }
613
+ /** Ask the room's current members alone (no authority) for the epochs this
614
+ * device missed. Rejects with a plain error when no verifiable answer comes
615
+ * inside the timeout. Most callers want `requestRoomEpoch({ members })`. */
616
+ export function requestMemberEpoch(opts) {
617
+ const source = memberEpochSource(opts);
618
+ return new Promise((resolve, reject) => {
619
+ let settled = false;
620
+ let stop = () => { };
621
+ const timer = setTimeout(() => {
622
+ if (settled)
623
+ return;
624
+ settled = true;
625
+ stop();
626
+ reject(new Error('no current member answered with an epoch that checks out'));
627
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
628
+ timer.unref?.();
629
+ stop = source.start((grant) => {
630
+ if (settled)
631
+ return;
632
+ settled = true;
633
+ clearTimeout(timer);
634
+ resolve(grant);
635
+ });
636
+ if (settled)
637
+ stop();
638
+ });
639
+ }
640
+ /** Every wire-format literal this module owns, frozen for `src/labels.test.ts`. */
641
+ export const MEMBER_EPOCH_LABELS = [
642
+ "kithmoot/v1/member-epoch-request-key",
643
+ ];
package/llms.txt CHANGED
@@ -79,6 +79,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
79
79
  - `encodeEpochGrant(opts)` / `decodeEpochGrant(event, opts)` - the authority's answer (secret, epoch-0, or a refusal `'removed' | 'closed'`)
80
80
  - `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
81
81
  - `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
82
+ - Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
82
83
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
83
84
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
84
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,6 +20,7 @@
20
20
  "dist",
21
21
  "vectors/kithmoot-vectors.json",
22
22
  "vectors/circle-vectors.json",
23
+ "vectors/member-epoch-vectors.json",
23
24
  "LICENSE",
24
25
  "README.md",
25
26
  "llms.txt"
@@ -31,6 +32,7 @@
31
32
  "typecheck": "tsc -p tsconfig.typecheck.json",
32
33
  "diff-source": "node scripts/diff-source.mjs",
33
34
  "generate-fold": "node scripts/generate-fold.mjs",
35
+ "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
34
36
  "bundle-check": "node scripts/bundle-check.mjs",
35
37
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
36
38
  "vectors": "vitest run vectors/",