@forgesworn/fold-kit 0.5.1 → 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. */
@@ -261,6 +282,8 @@ export type EpochGrant = {
261
282
  * the epochs it carried between the requester's and `epoch`. See
262
283
  * `MemberEpochGrant.passed`. The authority's own answer has none. */
263
284
  passed?: RoomEpoch[];
285
+ /** The participants the room knows, when the answer carried them. */
286
+ members?: string[];
264
287
  refused?: undefined;
265
288
  } | {
266
289
  refused: EpochRefusal;
@@ -281,6 +304,24 @@ export interface HostRoomEpochOptions {
281
304
  removed: () => ReadonlySet<string>;
282
305
  /** True once the room has been closed: every request is refused. */
283
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;
284
325
  policy?: RoomPolicy;
285
326
  legacyParticipants?: ReadonlySet<string>;
286
327
  now?: () => number;
@@ -298,6 +339,13 @@ export interface HostRoomEpochOptions {
298
339
  * *secret*, which opens epoch 0 and nothing after it. A request with no
299
340
  * admission proof, or one made under some other key, is not answered at
300
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.
301
349
  */
302
350
  export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
303
351
  close(): void;
@@ -319,6 +367,9 @@ export interface RequestRoomEpochOptions {
319
367
  /** Ask the room's current members too (`memberEpochSource`): the first
320
368
  * answer that checks out, the authority's or a member's, settles it. */
321
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;
322
373
  }
323
374
  /** Thrown when the authority answered, and the answer was no. */
324
375
  export declare class EpochRefusedError extends Error {
@@ -327,7 +378,8 @@ export declare class EpochRefusedError extends Error {
327
378
  }
328
379
  /** Ask the authority where the room is, and wait for the answer. Rejects
329
380
  * with `EpochRefusedError` on a refusal, and with a plain error when
330
- * 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`. */
331
383
  export declare function requestRoomEpoch(opts: RequestRoomEpochOptions): Promise<Exclude<EpochGrant, {
332
384
  refused: EpochRefusal;
333
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,
@@ -161,6 +163,8 @@ export interface MemberEpochGrant {
161
163
  * grant one epoch ahead. Kept, they let the requester read what was said
162
164
  * in the epochs it skipped, and hand them on from its own member desk. */
163
165
  passed: RoomEpoch[];
166
+ /** The newest member list in the chain, when any rekey in it had one. */
167
+ members?: string[];
164
168
  }
165
169
  /**
166
170
  * Verify a member's answer to one of this device's member requests. Null
@@ -190,6 +194,21 @@ export interface HostMemberEpochDeskOptions {
190
194
  removed: () => ReadonlySet<string>;
191
195
  /** True once the room has been closed. */
192
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;
193
212
  policy?: RoomPolicy;
194
213
  now?: () => number;
195
214
  /** Upper bound of the random wait before answering, so several members do
@@ -212,8 +231,10 @@ export interface HostMemberEpochDeskOptions {
212
231
  * Answer member epoch requests for as long as the handle is open: what any
213
232
  * member at the current epoch runs beside its session. It answers only an
214
233
  * admitted, credentialled device that is not removed, in a room that is not
215
- * closed, and only when it can hand over the whole chain from the
216
- * 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.
217
238
  *
218
239
  * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
219
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 {
@@ -279,6 +282,7 @@ export function decodeMemberEpochGrant(event, opts) {
279
282
  let previous = opts.current;
280
283
  let secret;
281
284
  const passed = [];
285
+ let members;
282
286
  for (let i = 0; i < length; i += 1) {
283
287
  const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
284
288
  if (!evidence)
@@ -289,6 +293,8 @@ export function decodeMemberEpochGrant(event, opts) {
289
293
  return null;
290
294
  for (const p of evidence.removed)
291
295
  removed.add(p);
296
+ if (evidence.members)
297
+ members = evidence.members;
292
298
  const raw = body.secrets[i];
293
299
  if (typeof raw !== 'string')
294
300
  return null;
@@ -308,7 +314,7 @@ export function decodeMemberEpochGrant(event, opts) {
308
314
  if (i < length - 1)
309
315
  passed.push({ epoch: evidence.epoch, secret });
310
316
  }
311
- return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed };
317
+ return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
312
318
  }
313
319
  catch {
314
320
  return null;
@@ -318,8 +324,10 @@ export function decodeMemberEpochGrant(event, opts) {
318
324
  * Answer member epoch requests for as long as the handle is open: what any
319
325
  * member at the current epoch runs beside its session. It answers only an
320
326
  * admitted, credentialled device that is not removed, in a room that is not
321
- * closed, and only when it can hand over the whole chain from the
322
- * 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.
323
331
  *
324
332
  * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
325
333
  * dropped if another member's grant to the same device appears meanwhile.
@@ -344,6 +352,10 @@ export function hostMemberEpochDesk(opts) {
344
352
  const grantSeen = new Set();
345
353
  /** Requesting devices this desk has already stood down for once. */
346
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();
347
359
  /** Ids of the grants this desk published: each is signed by a one-time
348
360
  * key, so its own grants are told apart by id, not by signer. */
349
361
  const mine = new Set();
@@ -386,6 +398,12 @@ export function hostMemberEpochDesk(opts) {
386
398
  }
387
399
  return { epochs, rekeys };
388
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
+ };
389
407
  const answer = (request) => {
390
408
  if (closed)
391
409
  return;
@@ -398,7 +416,7 @@ export function hostMemberEpochDesk(opts) {
398
416
  // Asked again, now that the wait is over: the room may have moved.
399
417
  if (opts.closed?.())
400
418
  return;
401
- if ([...opts.removed()].some((p) => hexEquals(p, request.participant)))
419
+ if (!admissible(request))
402
420
  return;
403
421
  const current = opts.current();
404
422
  if (!current || request.have >= current.epoch)
@@ -443,16 +461,32 @@ export function hostMemberEpochDesk(opts) {
443
461
  const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
444
462
  if (!request || answered.has(request.request))
445
463
  return;
446
- answered.add(request.request);
447
- bound(answered);
448
464
  if (opts.closed?.()) {
465
+ answered.add(request.request);
466
+ bound(answered);
449
467
  opts.onRefused?.(request, 'closed');
450
468
  return;
451
469
  }
452
470
  if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
471
+ answered.add(request.request);
472
+ bound(answered);
453
473
  opts.onRefused?.(request, 'removed');
454
474
  return;
455
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);
456
490
  const current = opts.current();
457
491
  if (!current || request.have >= current.epoch)
458
492
  return;
package/llms.txt CHANGED
@@ -80,6 +80,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
80
80
  - `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
81
81
  - `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
82
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.1",
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",