@forgesworn/fold-kit 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -154,6 +154,12 @@ the rest).
154
154
  `encodeMemberEpochRequest`, `decodeMemberEpochRequest`,
155
155
  `encodeMemberEpochGrant`, `decodeMemberEpochGrant`, `readRekeyEvidence`,
156
156
  `deriveMemberEpochRequestKey`. Vectors: `vectors/member-epoch-vectors.json`
157
+ - The known-members gate (#207, `docs/member-epoch-catch-up.md`): once a
158
+ room has removed anybody, both desks grant only to participants the room
159
+ knows (`known`), and send anybody else to approval (`onUnknown`; the
160
+ authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
161
+ through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
162
+ carry the authority's member list (`readMemberList` reads one)
157
163
  - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
158
164
  admin list
159
165
  - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
package/dist/epoch.d.ts CHANGED
@@ -73,6 +73,10 @@ export interface EpochKeys {
73
73
  /** The cipher key. The room key in epoch 0. */
74
74
  key: Uint8Array;
75
75
  }
76
+ /** A participant list as a body carries it: lower-case, deduplicated and
77
+ * sorted, or undefined when the field is absent or is not a list of keys.
78
+ * Undefined is the safe reading, since it makes nobody known. */
79
+ export declare function readMemberList(raw: unknown): string[] | undefined;
76
80
  /** A fresh secret for the next epoch. */
77
81
  export declare function generateEpochSecret(): Uint8Array;
78
82
  /**
@@ -108,6 +112,11 @@ export interface EncodeRekeyOptions {
108
112
  * bring a device that missed this rekey up to date (see
109
113
  * `member-epoch.ts`). Omitted, the event is byte-identical to before. */
110
114
  commit?: boolean;
115
+ /** Every participant the authority knows to be in the room, not only the
116
+ * ones present now: a member who is offline as the room rekeys is still
117
+ * a member. Removed participants are dropped. Omitted, the event is
118
+ * byte-identical to before. */
119
+ members?: string[];
111
120
  now: number;
112
121
  /** A conference room's end, in unix seconds: the event carries it as a
113
122
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
@@ -125,6 +134,9 @@ export interface RekeyNotice {
125
134
  * device that was removed, or that was not in the room when the
126
135
  * authority rekeyed. */
127
136
  secret?: Uint8Array;
137
+ /** The participants the authority listed as in the room. Absent from a
138
+ * rekey written without the list. */
139
+ members?: string[];
128
140
  /** True for a current-state grant, whose removed list is cumulative.
129
141
  * It synchronises this session; it does not describe a new removal. */
130
142
  catchUp?: true;
@@ -225,8 +237,14 @@ export interface EpochRequest {
225
237
  * cannot prove which participant it speaks for in this room, or from one
226
238
  * that cannot prove it was admitted to the room at all. */
227
239
  export declare function decodeEpochRequest(event: Event, opts: DecodeEpochRequestOptions): EpochRequest | null;
228
- /** Why the authority would not hand an epoch over. */
229
- export type EpochRefusal = 'removed' | 'closed';
240
+ /** How often a desk reports the same unknown participant while they keep
241
+ * asking: often enough that a missed "let them in?" comes back, rarely
242
+ * enough that it is not a nag. */
243
+ export declare const REPORT_UNKNOWN_EVERY_SECONDS = 60;
244
+ /** Why the authority would not hand an epoch over. `unknown` is not final:
245
+ * the room has removed somebody, and does not yet know this participant,
246
+ * so it waits for a member to let them in (#207). */
247
+ export type EpochRefusal = 'removed' | 'closed' | 'unknown';
230
248
  export interface EncodeEpochGrantOptions {
231
249
  roomId: string;
232
250
  authoritySk: Uint8Array;
@@ -236,6 +254,9 @@ export interface EncodeEpochGrantOptions {
236
254
  /** The current epoch. Omitted when refusing. */
237
255
  epoch?: RoomEpoch;
238
256
  removed?: string[];
257
+ /** The participants the authority knows, so the requester's own member
258
+ * desk knows them too. Omitted, the body is as before. */
259
+ members?: string[];
239
260
  refused?: EpochRefusal;
240
261
  /** A conference room's end, in unix seconds: the event carries it as a
241
262
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
@@ -257,6 +278,12 @@ export type EpochGrant = {
257
278
  epoch: 0;
258
279
  };
259
280
  removed: string[];
281
+ /** Present on a member's answer through `requestRoomEpoch({ members })`:
282
+ * the epochs it carried between the requester's and `epoch`. See
283
+ * `MemberEpochGrant.passed`. The authority's own answer has none. */
284
+ passed?: RoomEpoch[];
285
+ /** The participants the room knows, when the answer carried them. */
286
+ members?: string[];
260
287
  refused?: undefined;
261
288
  } | {
262
289
  refused: EpochRefusal;
@@ -277,6 +304,24 @@ export interface HostRoomEpochOptions {
277
304
  removed: () => ReadonlySet<string>;
278
305
  /** True once the room has been closed: every request is refused. */
279
306
  closed?: () => boolean;
307
+ /**
308
+ * Whether the room knows this participant: on the authority's member
309
+ * list, in the room now, or let in. Consulted only once somebody has been
310
+ * removed, and then everybody else is answered `unknown` (#207). Without
311
+ * it, after a removal, nobody is known. Asked on every request.
312
+ */
313
+ known?: (participant: string) => boolean;
314
+ /** The participants to tell a granted device about, so its own member
315
+ * desk knows them too. Asked on every grant. */
316
+ members?: () => readonly string[];
317
+ /** Somebody the room does not know asked, after a removal: what an app
318
+ * turns into "let them in?". Called once per participant, and again
319
+ * every `reportUnknownEvery` seconds while they keep asking. Letting them in
320
+ * is making `known` say yes; the requester's next ask is then granted. */
321
+ onUnknown?: (request: EpochRequest) => void;
322
+ /** Seconds before a participant still unknown and still asking is
323
+ * reported again. Default 60. */
324
+ reportUnknownEvery?: number;
280
325
  policy?: RoomPolicy;
281
326
  legacyParticipants?: ReadonlySet<string>;
282
327
  now?: () => number;
@@ -294,6 +339,13 @@ export interface HostRoomEpochOptions {
294
339
  * *secret*, which opens epoch 0 and nothing after it. A request with no
295
340
  * admission proof, or one made under some other key, is not answered at
296
341
  * all: a stranger learns nothing, not even that a desk is here.
342
+ *
343
+ * The admission proof is made under the epoch-0 room key, which a removed
344
+ * member still holds, so removal by participant alone is undone by a fresh
345
+ * key (#207). Once anyone has been removed the desk therefore grants only
346
+ * to participants the room knows (`known`), and answers anybody else
347
+ * `unknown` until a member lets them in. A room that has never removed
348
+ * anybody is answered exactly as before.
297
349
  */
298
350
  export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
299
351
  close(): void;
@@ -315,6 +367,9 @@ export interface RequestRoomEpochOptions {
315
367
  /** Ask the room's current members too (`memberEpochSource`): the first
316
368
  * answer that checks out, the authority's or a member's, settles it. */
317
369
  members?: MemberEpochSource;
370
+ /** The authority said the room does not know this participant yet. Not
371
+ * final: the ask goes on, and a member letting them in settles it. */
372
+ onUnknown?: () => void;
318
373
  }
319
374
  /** Thrown when the authority answered, and the answer was no. */
320
375
  export declare class EpochRefusedError extends Error {
@@ -323,7 +378,8 @@ export declare class EpochRefusedError extends Error {
323
378
  }
324
379
  /** Ask the authority where the room is, and wait for the answer. Rejects
325
380
  * with `EpochRefusedError` on a refusal, and with a plain error when
326
- * nobody answers inside the timeout. */
381
+ * nobody answers inside the timeout. `unknown` is not final: the ask goes
382
+ * on, and only a timeout after it rejects, with `EpochRefusedError`. */
327
383
  export declare function requestRoomEpoch(opts: RequestRoomEpochOptions): Promise<Exclude<EpochGrant, {
328
384
  refused: EpochRefusal;
329
385
  }>>;
package/dist/epoch.js CHANGED
@@ -80,6 +80,14 @@ function require32(bytes, what) {
80
80
  if (bytes.length !== 32)
81
81
  throw new Error(`${what} must be 32 bytes`);
82
82
  }
83
+ /** A participant list as a body carries it: lower-case, deduplicated and
84
+ * sorted, or undefined when the field is absent or is not a list of keys.
85
+ * Undefined is the safe reading, since it makes nobody known. */
86
+ export function readMemberList(raw) {
87
+ if (!Array.isArray(raw) || !raw.every((p) => typeof p === 'string' && HEX64.test(p)))
88
+ return undefined;
89
+ return [...new Set(raw.map(normaliseHex))].sort();
90
+ }
83
91
  function requireHex32(value, what) {
84
92
  if (!HEX64.test(value))
85
93
  throw new Error(`${what} must be 32-byte hex`);
@@ -119,6 +127,12 @@ export function deriveEpoch(epoch) {
119
127
  key: hkdf(sha256, epoch.secret, undefined, `${EPOCH_KEY_INFO}/${n}`, 32),
120
128
  };
121
129
  }
130
+ function memberListOf(members, removed) {
131
+ const out = new Set(members.map((p) => requireHex32(p, 'member participant')));
132
+ for (const p of removed)
133
+ out.delete(normaliseHex(p));
134
+ return [...out].sort();
135
+ }
122
136
  /** Announce the next epoch. See the module comment for what this is. */
123
137
  export function encodeRekeyEvent(opts) {
124
138
  require32(opts.authoritySk, 'authority secret key');
@@ -142,6 +156,7 @@ export function encodeRekeyEvent(opts) {
142
156
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
143
157
  ...(opts.closed ? { closed: true } : {}),
144
158
  ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
159
+ ...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
145
160
  keys,
146
161
  };
147
162
  return finalizeEvent({
@@ -206,6 +221,9 @@ export function decodeRekeyEvent(event, opts) {
206
221
  };
207
222
  if (typeof body.by === 'string' && HEX64.test(body.by))
208
223
  notice.by = normaliseHex(body.by);
224
+ const members = readMemberList(body.members);
225
+ if (members)
226
+ notice.members = members;
209
227
  const device = getPublicKey(opts.deviceSk);
210
228
  const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
211
229
  if (typeof mine === 'string') {
@@ -346,6 +364,10 @@ export function decodeEpochRequest(event, opts) {
346
364
  return null;
347
365
  }
348
366
  }
367
+ /** How often a desk reports the same unknown participant while they keep
368
+ * asking: often enough that a missed "let them in?" comes back, rarely
369
+ * enough that it is not a nag. */
370
+ export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
349
371
  /** Answer one request: the current epoch sealed to the asking device, or a
350
372
  * refusal it alone can read. */
351
373
  export function encodeEpochGrant(opts) {
@@ -366,6 +388,8 @@ export function encodeEpochGrant(opts) {
366
388
  body.secret = base64urlnopad.encode(opts.epoch.secret);
367
389
  }
368
390
  body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
391
+ if (opts.members !== undefined)
392
+ body.members = memberListOf(opts.members, body.removed);
369
393
  }
370
394
  return finalizeEvent({
371
395
  kind: KINDS.EPOCH_GRANT,
@@ -399,21 +423,23 @@ export function decodeEpochGrant(event, opts) {
399
423
  const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
400
424
  if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
401
425
  return null;
402
- if (body.refused === 'removed' || body.refused === 'closed')
426
+ if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
403
427
  return { refused: body.refused };
404
428
  if (!Number.isSafeInteger(body.epoch) || body.epoch < 0 || body.epoch > MAX_EPOCH)
405
429
  return null;
406
430
  const removed = Array.isArray(body.removed)
407
431
  ? [...new Set(body.removed.filter((p) => typeof p === 'string' && HEX64.test(p)).map(normaliseHex))].sort()
408
432
  : [];
433
+ const members = readMemberList(body.members);
434
+ const listed = members ? { members } : {};
409
435
  if (body.epoch === 0)
410
- return { epoch: { epoch: 0 }, removed };
436
+ return { epoch: { epoch: 0 }, removed, ...listed };
411
437
  if (typeof body.secret !== 'string')
412
438
  return null;
413
439
  const secret = base64urlnopad.decode(body.secret);
414
440
  if (secret.length !== 32)
415
441
  return null;
416
- return { epoch: { epoch: body.epoch, secret }, removed };
442
+ return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
417
443
  }
418
444
  catch {
419
445
  return null;
@@ -428,6 +454,13 @@ export function decodeEpochGrant(event, opts) {
428
454
  * *secret*, which opens epoch 0 and nothing after it. A request with no
429
455
  * admission proof, or one made under some other key, is not answered at
430
456
  * all: a stranger learns nothing, not even that a desk is here.
457
+ *
458
+ * The admission proof is made under the epoch-0 room key, which a removed
459
+ * member still holds, so removal by participant alone is undone by a fresh
460
+ * key (#207). Once anyone has been removed the desk therefore grants only
461
+ * to participants the room knows (`known`), and answers anybody else
462
+ * `unknown` until a member lets them in. A room that has never removed
463
+ * anybody is answered exactly as before.
431
464
  */
432
465
  export function hostRoomEpoch(opts) {
433
466
  require32(opts.authoritySk, 'authority secret key');
@@ -436,6 +469,16 @@ export function hostRoomEpoch(opts) {
436
469
  const authority = getPublicKey(opts.authoritySk);
437
470
  const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
438
471
  const answered = new Set();
472
+ /** Requests answered `unknown`, which the requester sends again while it
473
+ * waits: answered again only once they are known. */
474
+ const waiting = new Set();
475
+ /** When each unknown participant was last reported through `onUnknown`,
476
+ * so one still waiting is reported again only after `reportUnknownEvery`. */
477
+ const reported = new Map();
478
+ const bound = (set) => {
479
+ if (set.size > 256)
480
+ set.delete(set.values().next().value);
481
+ };
439
482
  let closed = false;
440
483
  const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
441
484
  if (closed)
@@ -450,14 +493,24 @@ export function hostRoomEpoch(opts) {
450
493
  });
451
494
  if (!request || answered.has(request.request))
452
495
  return;
453
- answered.add(request.request);
454
- if (answered.size > 256)
455
- answered.delete(answered.values().next().value);
456
496
  let refused;
457
497
  if (opts.closed?.())
458
498
  refused = 'closed';
459
499
  else if (opts.removed().has(request.participant))
460
500
  refused = 'removed';
501
+ else if (opts.removed().size > 0 && !opts.known?.(request.participant))
502
+ refused = 'unknown';
503
+ if (refused === 'unknown') {
504
+ if (waiting.has(request.request))
505
+ return;
506
+ waiting.add(request.request);
507
+ bound(waiting);
508
+ }
509
+ else {
510
+ waiting.delete(request.request);
511
+ answered.add(request.request);
512
+ bound(answered);
513
+ }
461
514
  let grant;
462
515
  try {
463
516
  grant = refused
@@ -470,6 +523,7 @@ export function hostRoomEpoch(opts) {
470
523
  now: now(),
471
524
  epoch: opts.current(),
472
525
  removed: [...opts.removed()],
526
+ ...(opts.members ? { members: [...opts.members()] } : {}),
473
527
  expiresAt: opts.expiresAt,
474
528
  });
475
529
  }
@@ -477,6 +531,14 @@ export function hostRoomEpoch(opts) {
477
531
  return;
478
532
  }
479
533
  opts.transport.publish(grant).catch(() => { });
534
+ if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
535
+ reported.set(request.participant, now());
536
+ if (reported.size > 256)
537
+ reported.delete(reported.keys().next().value);
538
+ opts.onUnknown?.(request);
539
+ }
540
+ if (!refused)
541
+ reported.delete(request.participant);
480
542
  if (refused)
481
543
  opts.onRefused?.(request, refused);
482
544
  else
@@ -495,14 +557,19 @@ export function hostRoomEpoch(opts) {
495
557
  export class EpochRefusedError extends Error {
496
558
  refused;
497
559
  constructor(refused) {
498
- super(refused === 'removed' ? 'you were removed from this room' : 'this room has been closed');
560
+ super(refused === 'removed'
561
+ ? 'you were removed from this room'
562
+ : refused === 'unknown'
563
+ ? 'nobody in this room has let you in yet'
564
+ : 'this room has been closed');
499
565
  this.name = 'EpochRefusedError';
500
566
  this.refused = refused;
501
567
  }
502
568
  }
503
569
  /** Ask the authority where the room is, and wait for the answer. Rejects
504
570
  * with `EpochRefusedError` on a refusal, and with a plain error when
505
- * nobody answers inside the timeout. */
571
+ * nobody answers inside the timeout. `unknown` is not final: the ask goes
572
+ * on, and only a timeout after it rejects, with `EpochRefusedError`. */
506
573
  export function requestRoomEpoch(opts) {
507
574
  const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
508
575
  const roomId = requireHex32(opts.roomId, 'room id');
@@ -523,6 +590,7 @@ export function requestRoomEpoch(opts) {
523
590
  let expiry;
524
591
  let unsub = () => { };
525
592
  let stopMembers = () => { };
593
+ let unknown = false;
526
594
  const finish = (settle) => {
527
595
  if (settled)
528
596
  return;
@@ -539,7 +607,12 @@ export function requestRoomEpoch(opts) {
539
607
  const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
540
608
  if (!grant)
541
609
  return;
542
- if (grant.refused)
610
+ if (grant.refused === 'unknown') {
611
+ if (!unknown)
612
+ opts.onUnknown?.();
613
+ unknown = true;
614
+ }
615
+ else if (grant.refused)
543
616
  finish(() => reject(new EpochRefusedError(grant.refused)));
544
617
  else
545
618
  finish(() => resolve(grant));
@@ -555,7 +628,7 @@ export function requestRoomEpoch(opts) {
555
628
  if (!settled)
556
629
  opts.transport.publish(request).catch(() => { });
557
630
  };
558
- expiry = setTimeout(() => finish(() => reject(new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
631
+ expiry = setTimeout(() => finish(() => reject(unknown ? new EpochRefusedError('unknown') : new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
559
632
  expiry.unref?.();
560
633
  retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
561
634
  retry.unref?.();
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
24
24
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
25
25
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
26
26
  export type { RoomLink } from './link.js';
27
- export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
27
+ export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
28
28
  export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
29
29
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
30
30
  export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
16
16
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
17
17
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
18
18
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
19
- export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
19
+ export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
20
20
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
21
21
  export { MEMBER_EPOCH_KINDS, MEMBER_EPOCH_REQUEST_KEY_INFO, MAX_MEMBER_EPOCH_CHAIN, deriveMemberEpochRequestKey, readRekeyEvidence, encodeMemberEpochRequest, decodeMemberEpochRequest, encodeMemberEpochGrant, decodeMemberEpochGrant, hostMemberEpochDesk, memberEpochSource, requestMemberEpoch, MEMBER_EPOCH_LABELS, } from './member-epoch.js';
22
22
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
@@ -61,6 +61,8 @@ export interface RekeyEvidence {
61
61
  closed: boolean;
62
62
  /** The epoch commitment, when the authority wrote one. */
63
63
  commit?: string;
64
+ /** The authority's member list, when it wrote one. */
65
+ members?: string[];
64
66
  }
65
67
  /**
66
68
  * Read an authority-signed rekey with the key of the epoch it leaves,
@@ -156,6 +158,13 @@ export interface MemberEpochGrant {
156
158
  epoch: RoomEpoch;
157
159
  /** Cumulative: `opts.removed` plus every removal in the chain. */
158
160
  removed: string[];
161
+ /** The epochs between the requester's and `epoch`, oldest first, each
162
+ * proven by the next rekey in the chain decrypting under it. Empty for a
163
+ * grant one epoch ahead. Kept, they let the requester read what was said
164
+ * in the epochs it skipped, and hand them on from its own member desk. */
165
+ passed: RoomEpoch[];
166
+ /** The newest member list in the chain, when any rekey in it had one. */
167
+ members?: string[];
159
168
  }
160
169
  /**
161
170
  * Verify a member's answer to one of this device's member requests. Null
@@ -185,6 +194,21 @@ export interface HostMemberEpochDeskOptions {
185
194
  removed: () => ReadonlySet<string>;
186
195
  /** True once the room has been closed. */
187
196
  closed?: () => boolean;
197
+ /**
198
+ * Whether the room knows this participant: on the authority's latest
199
+ * member list, in the room's current roster, or let in from this device.
200
+ * Consulted only once somebody has been removed; then nobody else is
201
+ * answered (#207). Without it, after a removal, nobody is known.
202
+ */
203
+ known?: (participant: string) => boolean;
204
+ /** Somebody the room does not know asked, after a removal. Called once
205
+ * per participant, and again every `reportUnknownEvery` seconds while
206
+ * they keep asking; nothing is published. Letting them in is making `known`
207
+ * say yes, and their next ask is answered. */
208
+ onUnknown?: (request: MemberEpochRequest) => void;
209
+ /** Seconds before a participant still unknown and still asking is
210
+ * reported again. Default 60 (`REPORT_UNKNOWN_EVERY_SECONDS`). */
211
+ reportUnknownEvery?: number;
188
212
  policy?: RoomPolicy;
189
213
  now?: () => number;
190
214
  /** Upper bound of the random wait before answering, so several members do
@@ -207,8 +231,10 @@ export interface HostMemberEpochDeskOptions {
207
231
  * Answer member epoch requests for as long as the handle is open: what any
208
232
  * member at the current epoch runs beside its session. It answers only an
209
233
  * admitted, credentialled device that is not removed, in a room that is not
210
- * closed, and only when it can hand over the whole chain from the
211
- * requester's epoch to its own with the authority's rekeys to prove it.
234
+ * closed, from a participant the room knows once anybody has been removed
235
+ * (see `hostRoomEpoch` for why), and only when it can hand over the whole
236
+ * chain from the requester's epoch to its own with the authority's rekeys
237
+ * to prove it.
212
238
  *
213
239
  * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
214
240
  * dropped if another member's grant to the same device appears meanwhile.
@@ -10,7 +10,7 @@ import { verifyEventUncached } from './verify.js';
10
10
  import { withExpiration } from './expiration.js';
11
11
  import { epochCommitment } from './epoch-commit.js';
12
12
  import { KINDS } from './kinds.js';
13
- import { MAX_EPOCH, deriveEpoch, epochRequestAdmission, peekRekeyEvent, } from './epoch.js';
13
+ import { MAX_EPOCH, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, } from './epoch.js';
14
14
  /**
15
15
  * Member-to-member epoch catch-up: any current member can bring an
16
16
  * admitted, non-removed member up to date.
@@ -118,6 +118,9 @@ export function readRekeyEvidence(event, opts) {
118
118
  };
119
119
  if (typeof body.commit === 'string' && HEX64.test(body.commit))
120
120
  evidence.commit = normaliseHex(body.commit);
121
+ const members = readMemberList(body.members);
122
+ if (members)
123
+ evidence.members = members;
121
124
  return evidence;
122
125
  }
123
126
  catch {
@@ -278,6 +281,8 @@ export function decodeMemberEpochGrant(event, opts) {
278
281
  const removed = new Set([...(opts.removed ?? [])].map(normaliseHex));
279
282
  let previous = opts.current;
280
283
  let secret;
284
+ const passed = [];
285
+ let members;
281
286
  for (let i = 0; i < length; i += 1) {
282
287
  const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
283
288
  if (!evidence)
@@ -288,6 +293,8 @@ export function decodeMemberEpochGrant(event, opts) {
288
293
  return null;
289
294
  for (const p of evidence.removed)
290
295
  removed.add(p);
296
+ if (evidence.members)
297
+ members = evidence.members;
291
298
  const raw = body.secrets[i];
292
299
  if (typeof raw !== 'string')
293
300
  return null;
@@ -304,8 +311,10 @@ export function decodeMemberEpochGrant(event, opts) {
304
311
  return null;
305
312
  }
306
313
  previous = deriveEpoch({ epoch: evidence.epoch, secret });
314
+ if (i < length - 1)
315
+ passed.push({ epoch: evidence.epoch, secret });
307
316
  }
308
- return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort() };
317
+ return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
309
318
  }
310
319
  catch {
311
320
  return null;
@@ -315,8 +324,10 @@ export function decodeMemberEpochGrant(event, opts) {
315
324
  * Answer member epoch requests for as long as the handle is open: what any
316
325
  * member at the current epoch runs beside its session. It answers only an
317
326
  * 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.
327
+ * closed, from a participant the room knows once anybody has been removed
328
+ * (see `hostRoomEpoch` for why), and only when it can hand over the whole
329
+ * chain from the requester's epoch to its own with the authority's rekeys
330
+ * to prove it.
320
331
  *
321
332
  * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
322
333
  * dropped if another member's grant to the same device appears meanwhile.
@@ -341,6 +352,10 @@ export function hostMemberEpochDesk(opts) {
341
352
  const grantSeen = new Set();
342
353
  /** Requesting devices this desk has already stood down for once. */
343
354
  const stoodDown = new Set();
355
+ /** When each unknown participant was last reported. A requester asks
356
+ * afresh every `retryMs` while it waits, so this is by participant, not
357
+ * by request: one "let them in?" a `reportUnknownEvery`, not one a retry. */
358
+ const reported = new Map();
344
359
  /** Ids of the grants this desk published: each is signed by a one-time
345
360
  * key, so its own grants are told apart by id, not by signer. */
346
361
  const mine = new Set();
@@ -383,6 +398,12 @@ export function hostMemberEpochDesk(opts) {
383
398
  }
384
399
  return { epochs, rekeys };
385
400
  };
401
+ const admissible = (request) => {
402
+ const removed = opts.removed();
403
+ if ([...removed].some((p) => hexEquals(p, request.participant)))
404
+ return false;
405
+ return removed.size === 0 || opts.known?.(request.participant) === true;
406
+ };
386
407
  const answer = (request) => {
387
408
  if (closed)
388
409
  return;
@@ -395,7 +416,7 @@ export function hostMemberEpochDesk(opts) {
395
416
  // Asked again, now that the wait is over: the room may have moved.
396
417
  if (opts.closed?.())
397
418
  return;
398
- if ([...opts.removed()].some((p) => hexEquals(p, request.participant)))
419
+ if (!admissible(request))
399
420
  return;
400
421
  const current = opts.current();
401
422
  if (!current || request.have >= current.epoch)
@@ -440,16 +461,32 @@ export function hostMemberEpochDesk(opts) {
440
461
  const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
441
462
  if (!request || answered.has(request.request))
442
463
  return;
443
- answered.add(request.request);
444
- bound(answered);
445
464
  if (opts.closed?.()) {
465
+ answered.add(request.request);
466
+ bound(answered);
446
467
  opts.onRefused?.(request, 'closed');
447
468
  return;
448
469
  }
449
470
  if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
471
+ answered.add(request.request);
472
+ bound(answered);
450
473
  opts.onRefused?.(request, 'removed');
451
474
  return;
452
475
  }
476
+ if (!admissible(request)) {
477
+ const last = reported.get(request.participant);
478
+ if (last !== undefined && now() - last < (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS))
479
+ return;
480
+ reported.set(request.participant, now());
481
+ if (reported.size > 256)
482
+ reported.delete(reported.keys().next().value);
483
+ opts.onUnknown?.(request);
484
+ opts.onRefused?.(request, 'unknown');
485
+ return;
486
+ }
487
+ reported.delete(request.participant);
488
+ answered.add(request.request);
489
+ bound(answered);
453
490
  const current = opts.current();
454
491
  if (!current || request.have >= current.epoch)
455
492
  return;
package/llms.txt CHANGED
@@ -79,7 +79,8 @@ 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
+ - 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`. A verified grant is `{ epoch, removed, passed }`: `passed` holds the epochs between the asker's and `epoch`, oldest first, and `requestRoomEpoch` carries it as an optional `passed` when a member answered. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
83
+ - The known-members gate (#207, see `docs/member-epoch-catch-up.md`): once a room has removed anybody (`removed()` not empty), `hostRoomEpoch` and `hostMemberEpochDesk` grant only to a participant for which `known(participant)` is true; anybody else is reported through `onUnknown` (once per participant, and again every `reportUnknownEvery` seconds, default `REPORT_UNKNOWN_EVERY_SECONDS` = 60, while they keep asking) and refused: the authority answers `refused: 'unknown'` (an `EpochRefusal`), a member stays silent. `unknown` is not final: `requestRoomEpoch` keeps asking, calls its `onUnknown` once, and rejects with `EpochRefusedError('unknown')` only at its timeout; a desk looks at the requester's next ask afresh, so letting somebody in is making `known` say yes. No `known` after a removal means nobody is known. Member lists: `encodeRekeyEvent({ members })` writes the authority's list (removed dropped, sorted) into the body, read back as `RekeyNotice.members` and `RekeyEvidence.members`, and as `MemberEpochGrant.members` (newest in the chain); `hostRoomEpoch({ members })` puts it in its grants (`EpochGrant.members`). `readMemberList(raw)` reads one, undefined when malformed. Without `members` and with no removal every event is byte-identical to 0.5.1
83
84
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
84
85
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
85
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -136,6 +136,70 @@
136
136
  }
137
137
  }
138
138
  },
139
+ {
140
+ "name": "rekey-with-members",
141
+ "kind": "positive",
142
+ "note": "The rekey to epoch 2 with the authority's member list (`encodeRekeyEvent({ members })`, #207): the participants it knows to be in the room, the removed ones dropped even when passed in, lower-case, deduplicated and sorted. Body key order: v, epoch, removed, commit, members, keys. A desk hands an epoch on, once anybody has been removed, only to a participant the room knows; this list is where a member learns who that is. A list that is not an array of 32-byte hex keys is read as absent, which makes nobody known.",
143
+ "input": {
144
+ "roomId": "76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9",
145
+ "authority": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
146
+ "previousEpoch": 1,
147
+ "previousSecretHex": "cf6c5602daa51ecab29fbcbcef0da517b303cd59ee2904c15e226b7dd00e7752",
148
+ "previousKeyHex": "e038a7fca65628eebee35c562410ac63cce191b776ba0e8e5a1d241d9417f733",
149
+ "next": {
150
+ "epoch": 2,
151
+ "secretHex": "d4f5a0426e6880797fa29d914bb6977d193dfcd4f46b2a3acc576226eeec3ac9"
152
+ },
153
+ "recipients": [
154
+ "ef4396c842882c47ad1106d846d5c7e712c8928b69c92bc1798fa63deb9dd76b"
155
+ ],
156
+ "removed": [
157
+ "298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
158
+ ],
159
+ "members": [
160
+ "588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53",
161
+ "298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
162
+ ],
163
+ "authoritySkHex": "825cf62829b0262bc5b46870d5cc054d6049e54a68cf1ea393c3da56226c456b",
164
+ "createdAt": 1799999002,
165
+ "event": {
166
+ "id": "d8d5c810c2e22d87493d8a10f908e38e68b5e4611ad5a230103ca01d5ba7a954",
167
+ "pubkey": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
168
+ "created_at": 1799999002,
169
+ "kind": 1462,
170
+ "tags": [
171
+ [
172
+ "d",
173
+ "76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9"
174
+ ],
175
+ [
176
+ "epoch",
177
+ "2"
178
+ ]
179
+ ],
180
+ "content": "AjCp1V9xfa32TYx5FAmMp1toqAM2yKrdBWdygJs+bFNGNIls48ZUufb0m3Cs51qusvmVJF51AEvXiW82LyhO6o/2J9Sra65b80+cyj8Km+LrLCXx6qyLPIYjKdISORDS6UhDKXa9UInbnZnGf87KKruDujB82QKDo26ImrXPbzy246dYMCvon7dYLGa+y3Qk9u5OLVKSrYVhDhFRHZX2XMltl478SRQ7GuvzbibZbFrNz1EvWSJOqbxf8YLpvYzbq55xmr9CA1rjk9vBKTzaHGX23ZvPPdaQFHh53YXCzYGN4+p54P/gH9PgZREZt+5Jel5kDhVfk8YMinaSzaBQ7MvEUEEIGDUPc6xbmZ3twlam+y/4H0vuugQtOm85tU7sozohPj65hlpaxRmTdenWivldIAWyPAEsUjOtGcvnFur5Hbg9H+WEsyXAX6tLZZV3jNFcCPLkoWB9O3iFasN3m2P0larhRTr9lhReqzt3UxtGBp14rErj0uZEZlwrz4n3ZiFgH/qc1X/pFlskh4dQJV/PGCEe6F7v7fd4aChiYHA1ghq2cy4fKW44+FU2EKPEezWcD96sOshiCoh0xWxHJoNbWhKJ5P8EcyK7TCH9UJVk9OyAiGVYnnxh+cuDgVfCOdRR6VbxWopqclvhlV+NGBullxSv5aQJSj6fS5O+QxSCwZrR8DA2SMKRSFU+kXHQxOgEvZW1cPdHWMtUr1dwsRrMd3ayNyZqSIjjyktqzsmrD+Pl6Ikk5tbE3kyNYdWHX7Mz",
181
+ "sig": "90c5666a5ca4c5d4c1a0e4b3b4422aa7462d4300986f82afff435a88edb48a41e3191192c6404b17401712204de56ba2269a6b4376f0ef224ad9798728550710"
182
+ },
183
+ "randomHex": [
184
+ "ff9c267263a44961a1d49a09c831ef35fa2d53e4d4cfc572d72f506478e61045",
185
+ "30a9d55f717dadf64d8c7914098ca75b68a80336c8aadd056772809b3e6c5346",
186
+ "11ae6ba1ae1e25f1493588462e919538089a63c28e68b7f5734bd0df2ba395e3"
187
+ ]
188
+ },
189
+ "output": {
190
+ "evidence": {
191
+ "epoch": 2,
192
+ "removed": [
193
+ "298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
194
+ ],
195
+ "closed": false,
196
+ "commit": "3472885df839fc5d48d809476e28e36c458c3e4717e35c2ec0d69bbbdf94f896",
197
+ "members": [
198
+ "588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53"
199
+ ]
200
+ }
201
+ }
202
+ },
139
203
  {
140
204
  "name": "member-request",
141
205
  "kind": "positive",