@forgesworn/fold-kit 0.7.0 → 0.8.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
@@ -168,6 +168,14 @@ the rest).
168
168
  `sealSks` on the readers; `credentialFor` on both desks;
169
169
  `credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
170
170
  `sealCredential`. Vectors: `vectors/seal-vectors.json`
171
+ - Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
172
+ `encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
173
+ removes nobody, read back as `RekeyNotice.scheduled` and
174
+ `RekeyEvidence.scheduled`; `HISTORY_WINDOW_SECONDS`, `MAX_HISTORY_EPOCHS`
175
+ and `epochsInWindow` are the rule for which left epochs a member reads;
176
+ `encodeEpochGrant({ passed })` and `hostRoomEpoch({ past })` hand those
177
+ epochs (`LeftEpoch`) to every device the authority grants, read back as
178
+ `EpochGrant.passed`. Vectors: `vectors/schedule-vectors.json`
171
179
  - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
172
180
  admin list
173
181
  - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
package/dist/epoch.d.ts CHANGED
@@ -111,6 +111,11 @@ export interface EncodeRekeyOptions {
111
111
  /** True when the room is being closed: nobody is kept, and the event
112
112
  * says so rather than leaving everybody to wonder. */
113
113
  closed?: boolean;
114
+ /** Mark this as a scheduled turn of the key rather than a removal, so
115
+ * clients can let it pass quietly. Refused with a removal or a close: a
116
+ * rekey that removes somebody is never quiet. Omitted, the event is
117
+ * byte-identical to before. */
118
+ scheduled?: boolean;
114
119
  /** Write the epoch commitment into the body, so any current member can
115
120
  * bring a device that missed this rekey up to date (see
116
121
  * `member-epoch.ts`). Omitted, the event is byte-identical to before. */
@@ -139,6 +144,10 @@ export interface RekeyNotice {
139
144
  removed: string[];
140
145
  by?: string;
141
146
  closed: boolean;
147
+ /** True for a scheduled turn of the key: nobody removed, the room still
148
+ * open. What lets a client move on without saying so. Absent when the
149
+ * body contradicts itself, so a removal is always announced. */
150
+ scheduled?: true;
142
151
  /** The new secret, when a copy was sealed for this device. Absent for a
143
152
  * device that was removed, or that was not in the room when the
144
153
  * authority rekeyed. */
@@ -179,6 +188,33 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
179
188
  * already past (a replay), or one sealed to an epoch it is not at.
180
189
  */
181
190
  export declare function decodeRekeyEvent(event: Event, opts: DecodeRekeyOptions): RekeyNotice | null;
191
+ /** How long a member goes on reading an epoch the room has left: 30 days,
192
+ * as long as a chat log keeps a message. */
193
+ export declare const HISTORY_WINDOW_SECONDS: number;
194
+ /** The most left epochs a member goes on reading, and an authority's grant
195
+ * hands over, however many fall inside the window. A weekly schedule puts
196
+ * about five in it; removals are what push it higher. Not
197
+ * `MAX_MEMBER_EPOCH_CHAIN`, which bounds how far behind a member desk will
198
+ * bring somebody. */
199
+ export declare const MAX_HISTORY_EPOCHS = 16;
200
+ /** An epoch the room has moved past, and when. */
201
+ export interface LeftEpoch extends RoomEpoch {
202
+ /** When the room left it, in unix seconds: the `created_at` of the rekey
203
+ * out of it. */
204
+ leftAt: number;
205
+ }
206
+ /**
207
+ * The left epochs still read at `now`: those left within
208
+ * `HISTORY_WINDOW_SECONDS` (one left exactly that long ago is kept), newest
209
+ * first, one per epoch number (the first given), at most
210
+ * `MAX_HISTORY_EPOCHS`. Every client applies the same rule, so what an
211
+ * authority hands over is what a member goes on reading. Pure, and never
212
+ * throws: an entry with no usable number or time is dropped.
213
+ */
214
+ export declare function epochsInWindow<T extends {
215
+ epoch: number;
216
+ leftAt: number;
217
+ }>(left: readonly T[], now: number): T[];
182
218
  /**
183
219
  * The key an epoch request's admission proof is computed under: the epoch-0
184
220
  * room key, expanded under its own info string. Taken from the room key
@@ -269,6 +305,15 @@ export interface EncodeEpochGrantOptions {
269
305
  /** The participants the authority knows, so the requester's own member
270
306
  * desk knows them too. Omitted, the body is as before. */
271
307
  members?: string[];
308
+ /**
309
+ * The epochs the room has left that are still read (`epochsInWindow`),
310
+ * so a newcomer, or a device back after missing several rekeys, reads
311
+ * the last month and not only the current epoch. Each is after epoch 0,
312
+ * whose secret is the room secret and is never handed over, and before
313
+ * `epoch`; at most `MAX_HISTORY_EPOCHS`, each once. Omitted or empty,
314
+ * the body is as before.
315
+ */
316
+ passed?: readonly LeftEpoch[];
272
317
  refused?: EpochRefusal;
273
318
  /** The newest credential the authority holds for the device: the grant
274
319
  * is sealed to its seal key when it names one (see `seal.ts`). */
@@ -296,10 +341,15 @@ export type EpochGrant = {
296
341
  epoch: 0;
297
342
  };
298
343
  removed: string[];
299
- /** Present on a member's answer through `requestRoomEpoch({ members })`:
300
- * the epochs it carried between the requester's and `epoch`. See
301
- * `MemberEpochGrant.passed`. The authority's own answer has none. */
302
- passed?: RoomEpoch[];
344
+ /** Earlier epochs handed over with `epoch`, oldest first. A member's
345
+ * answer through `requestRoomEpoch({ members })` carries the epochs
346
+ * between the requester's and `epoch` (see `MemberEpochGrant.passed`);
347
+ * the authority's carries the window's (`EncodeEpochGrantOptions.passed`),
348
+ * when its desk was given them. `leftAt` is when the room left each,
349
+ * when the answer said. */
350
+ passed?: Array<RoomEpoch & {
351
+ leftAt?: number;
352
+ }>;
303
353
  /** The participants the room knows, when the answer carried them. */
304
354
  members?: string[];
305
355
  refused?: undefined;
@@ -332,6 +382,14 @@ export interface HostRoomEpochOptions {
332
382
  /** The participants to tell a granted device about, so its own member
333
383
  * desk knows them too. Asked on every grant. */
334
384
  members?: () => readonly string[];
385
+ /**
386
+ * The epochs the room has left, with their secrets and when it left
387
+ * each: every grant carries those still in the window, so a newcomer
388
+ * reads the last month as a returning member does. Asked on every grant.
389
+ * Whatever a grant could not carry (epoch 0, an epoch not before the
390
+ * current one, a bad secret) is left out rather than costing the grant.
391
+ */
392
+ past?: () => readonly LeftEpoch[];
335
393
  /**
336
394
  * The newest credential the authority has seen for a device, from the
337
395
  * roster. An answer is sealed to the newer of this and the one the
package/dist/epoch.js CHANGED
@@ -143,6 +143,8 @@ export function encodeRekeyEvent(opts) {
143
143
  if (epoch !== opts.current.epoch + 1)
144
144
  throw new Error('a rekey moves the room forward by exactly one epoch');
145
145
  const removed = [...new Set(opts.removed.map((p) => requireHex32(p, 'removed participant')))].sort();
146
+ if (opts.scheduled && (removed.length > 0 || opts.closed))
147
+ throw new Error('a scheduled rekey removes nobody and does not close the room');
146
148
  const keys = {};
147
149
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
148
150
  const plaintext = JSON.stringify(sealed);
@@ -157,6 +159,7 @@ export function encodeRekeyEvent(opts) {
157
159
  removed,
158
160
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
159
161
  ...(opts.closed ? { closed: true } : {}),
162
+ ...(opts.scheduled ? { scheduled: true } : {}),
160
163
  ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
161
164
  ...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
162
165
  keys,
@@ -223,6 +226,8 @@ export function decodeRekeyEvent(event, opts) {
223
226
  };
224
227
  if (typeof body.by === 'string' && HEX64.test(body.by))
225
228
  notice.by = normaliseHex(body.by);
229
+ if (body.scheduled === true && notice.removed.length === 0 && !notice.closed)
230
+ notice.scheduled = true;
226
231
  const members = readMemberList(body.members);
227
232
  if (members)
228
233
  notice.members = members;
@@ -252,6 +257,41 @@ export function decodeRekeyEvent(event, opts) {
252
257
  }
253
258
  }
254
259
  // ---------------------------------------------------------------------------
260
+ // The history window
261
+ // ---------------------------------------------------------------------------
262
+ /** How long a member goes on reading an epoch the room has left: 30 days,
263
+ * as long as a chat log keeps a message. */
264
+ export const HISTORY_WINDOW_SECONDS = 30 * 86_400;
265
+ /** The most left epochs a member goes on reading, and an authority's grant
266
+ * hands over, however many fall inside the window. A weekly schedule puts
267
+ * about five in it; removals are what push it higher. Not
268
+ * `MAX_MEMBER_EPOCH_CHAIN`, which bounds how far behind a member desk will
269
+ * bring somebody. */
270
+ export const MAX_HISTORY_EPOCHS = 16;
271
+ /**
272
+ * The left epochs still read at `now`: those left within
273
+ * `HISTORY_WINDOW_SECONDS` (one left exactly that long ago is kept), newest
274
+ * first, one per epoch number (the first given), at most
275
+ * `MAX_HISTORY_EPOCHS`. Every client applies the same rule, so what an
276
+ * authority hands over is what a member goes on reading. Pure, and never
277
+ * throws: an entry with no usable number or time is dropped.
278
+ */
279
+ export function epochsInWindow(left, now) {
280
+ const since = now - HISTORY_WINDOW_SECONDS;
281
+ const usable = left.filter((e) => typeof e === 'object' && e !== null && Number.isSafeInteger(e.epoch) && e.epoch >= 0 && Number.isFinite(e.leftAt) && e.leftAt >= since);
282
+ const seen = new Set();
283
+ const out = [];
284
+ for (const e of usable.sort((a, b) => b.epoch - a.epoch)) {
285
+ if (seen.has(e.epoch))
286
+ continue;
287
+ seen.add(e.epoch);
288
+ out.push(e);
289
+ if (out.length === MAX_HISTORY_EPOCHS)
290
+ break;
291
+ }
292
+ return out;
293
+ }
294
+ // ---------------------------------------------------------------------------
255
295
  // Asking the authority for the current epoch
256
296
  // ---------------------------------------------------------------------------
257
297
  /**
@@ -385,6 +425,56 @@ function readEpochRequest(event, opts) {
385
425
  * asking: often enough that a missed "let them in?" comes back, rarely
386
426
  * enough that it is not a nag. */
387
427
  export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
428
+ function passedListOf(passed, granted) {
429
+ if (passed.length > MAX_HISTORY_EPOCHS)
430
+ throw new Error(`a grant carries at most ${MAX_HISTORY_EPOCHS} passed epochs`);
431
+ const out = passed
432
+ .map((e) => {
433
+ const epoch = requireEpochNumber(e.epoch);
434
+ if (epoch < 1 || epoch >= granted)
435
+ throw new Error('a passed epoch comes after epoch 0 and before the one granted');
436
+ require32(e.secret, 'epoch secret');
437
+ if (!Number.isSafeInteger(e.leftAt) || e.leftAt < 0)
438
+ throw new Error('leftAt must be a non-negative integer');
439
+ return { epoch, secret: base64urlnopad.encode(e.secret), left: e.leftAt };
440
+ })
441
+ .sort((a, b) => a.epoch - b.epoch);
442
+ for (let i = 1; i < out.length; i += 1) {
443
+ if (out[i].epoch === out[i - 1].epoch)
444
+ throw new Error('a passed epoch is listed once');
445
+ }
446
+ return out;
447
+ }
448
+ /** A grant's passed epochs, or undefined unless every entry is in the form
449
+ * `encodeEpochGrant` writes. One bad entry costs the history, not the
450
+ * grant: the current epoch still stands. */
451
+ function readPassedList(raw, granted) {
452
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_HISTORY_EPOCHS)
453
+ return undefined;
454
+ const out = [];
455
+ for (const entry of raw) {
456
+ if (typeof entry !== 'object' || entry === null)
457
+ return undefined;
458
+ const { epoch, secret, left } = entry;
459
+ if (!Number.isSafeInteger(epoch) || epoch < 1 || epoch >= granted)
460
+ return undefined;
461
+ if (out.length > 0 && epoch <= out[out.length - 1].epoch)
462
+ return undefined;
463
+ if (typeof secret !== 'string' || !Number.isSafeInteger(left) || left < 0)
464
+ return undefined;
465
+ let bytes;
466
+ try {
467
+ bytes = base64urlnopad.decode(secret);
468
+ }
469
+ catch {
470
+ return undefined;
471
+ }
472
+ if (bytes.length !== 32)
473
+ return undefined;
474
+ out.push({ epoch: epoch, secret: bytes, leftAt: left });
475
+ }
476
+ return out;
477
+ }
388
478
  /** Answer one request: the current epoch sealed to the asking device, or a
389
479
  * refusal it alone can read. */
390
480
  export function encodeEpochGrant(opts) {
@@ -407,6 +497,8 @@ export function encodeEpochGrant(opts) {
407
497
  body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
408
498
  if (opts.members !== undefined)
409
499
  body.members = memberListOf(opts.members, body.removed);
500
+ if (opts.passed !== undefined && opts.passed.length > 0)
501
+ body.passed = passedListOf(opts.passed, body.epoch);
410
502
  }
411
503
  return finalizeEvent({
412
504
  kind: KINDS.EPOCH_GRANT,
@@ -456,12 +548,39 @@ export function decodeEpochGrant(event, opts) {
456
548
  const secret = base64urlnopad.decode(body.secret);
457
549
  if (secret.length !== 32)
458
550
  return null;
459
- return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
551
+ const passed = readPassedList(body.passed, body.epoch);
552
+ return { epoch: { epoch: body.epoch, secret }, removed, ...listed, ...(passed ? { passed } : {}) };
460
553
  }
461
554
  catch {
462
555
  return null;
463
556
  }
464
557
  }
558
+ /** What a desk hands over of `past()`: the window's epochs below the one it
559
+ * grants, leaving out anything `encodeEpochGrant` would refuse, so a bad
560
+ * entry costs the history and never the grant. */
561
+ function passedFor(past, granted, now) {
562
+ if (!past)
563
+ return [];
564
+ let given;
565
+ try {
566
+ given = past();
567
+ }
568
+ catch {
569
+ return [];
570
+ }
571
+ if (!Array.isArray(given))
572
+ return [];
573
+ const carried = given.filter((e) => typeof e === 'object' &&
574
+ e !== null &&
575
+ Number.isSafeInteger(e.epoch) &&
576
+ e.epoch >= 1 &&
577
+ e.epoch < granted &&
578
+ e.secret instanceof Uint8Array &&
579
+ e.secret.length === 32 &&
580
+ Number.isSafeInteger(e.leftAt) &&
581
+ e.leftAt >= 0);
582
+ return epochsInWindow(carried, now);
583
+ }
465
584
  /**
466
585
  * The credential to seal an answer to: the one the request carries, unless
467
586
  * the room has seen a newer one for the same device and participant that
@@ -551,8 +670,8 @@ export function hostRoomEpoch(opts) {
551
670
  }
552
671
  let grant;
553
672
  try {
554
- grant = refused
555
- ? encodeEpochGrant({
673
+ if (refused) {
674
+ grant = encodeEpochGrant({
556
675
  roomId,
557
676
  authoritySk: opts.authoritySk,
558
677
  device: request.device,
@@ -561,19 +680,24 @@ export function hostRoomEpoch(opts) {
561
680
  refused,
562
681
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
563
682
  expiresAt: opts.expiresAt,
564
- })
565
- : encodeEpochGrant({
683
+ });
684
+ }
685
+ else {
686
+ const epoch = opts.current();
687
+ grant = encodeEpochGrant({
566
688
  roomId,
567
689
  authoritySk: opts.authoritySk,
568
690
  device: request.device,
569
691
  request: request.request,
570
692
  now: now(),
571
- epoch: opts.current(),
693
+ epoch,
572
694
  removed: [...opts.removed()],
573
695
  ...(opts.members ? { members: [...opts.members()] } : {}),
696
+ ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
574
697
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
575
698
  expiresAt: opts.expiresAt,
576
699
  });
700
+ }
577
701
  }
578
702
  catch {
579
703
  return;
package/dist/index.d.ts CHANGED
@@ -26,8 +26,8 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
26
26
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
27
27
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
28
28
  export type { RoomLink } from './link.js';
29
- 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, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
30
- export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyRecipient, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
29
+ export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
30
+ export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyRecipient, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, LeftEpoch, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
31
31
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
32
32
  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';
33
33
  export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochRequestOptions, MemberEpochRequest, EncodeMemberEpochGrantOptions, DecodeMemberEpochGrantOptions, MemberEpochGrant, HostMemberEpochDeskOptions, MemberEpochRequestOptions, MemberEpochSource, } from './member-epoch.js';
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
17
17
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
18
18
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
19
19
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
20
- 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, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
20
+ export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
21
21
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
22
22
  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';
23
23
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';
@@ -59,6 +59,8 @@ export interface RekeyEvidence {
59
59
  epoch: number;
60
60
  removed: string[];
61
61
  closed: boolean;
62
+ /** A scheduled turn of the key: see `RekeyNotice.scheduled`. */
63
+ scheduled?: true;
62
64
  /** The epoch commitment, when the authority wrote one. */
63
65
  commit?: string;
64
66
  /** The authority's member list, when it wrote one. */
@@ -167,8 +169,11 @@ export interface MemberEpochGrant {
167
169
  /** The epochs between the requester's and `epoch`, oldest first, each
168
170
  * proven by the next rekey in the chain decrypting under it. Empty for a
169
171
  * grant one epoch ahead. Kept, they let the requester read what was said
170
- * in the epochs it skipped, and hand them on from its own member desk. */
171
- passed: RoomEpoch[];
172
+ * in the epochs it skipped, and hand them on from its own member desk.
173
+ * `leftAt` is the `created_at` of the authority's rekey out of each. */
174
+ passed: Array<RoomEpoch & {
175
+ leftAt?: number;
176
+ }>;
172
177
  /** The newest member list in the chain, when any rekey in it had one. */
173
178
  members?: string[];
174
179
  }
@@ -117,6 +117,8 @@ export function readRekeyEvidence(event, opts) {
117
117
  removed: [...new Set(body.removed.map(normaliseHex))].sort(),
118
118
  closed: body.closed === true,
119
119
  };
120
+ if (body.scheduled === true && evidence.removed.length === 0 && !evidence.closed)
121
+ evidence.scheduled = true;
120
122
  if (typeof body.commit === 'string' && HEX64.test(body.commit))
121
123
  evidence.commit = normaliseHex(body.commit);
122
124
  const members = readMemberList(body.members);
@@ -321,8 +323,10 @@ export function decodeMemberEpochGrant(event, opts) {
321
323
  return null;
322
324
  }
323
325
  previous = deriveEpoch({ epoch: evidence.epoch, secret });
326
+ // The next rekey, signed and checked on the next turn, is when the
327
+ // room left this epoch.
324
328
  if (i < length - 1)
325
- passed.push({ epoch: evidence.epoch, secret });
329
+ passed.push({ epoch: evidence.epoch, secret, leftAt: body.rekeys[i + 1].created_at });
326
330
  }
327
331
  return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
328
332
  }
package/llms.txt CHANGED
@@ -82,6 +82,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
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
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
84
84
  - Seal keys (see `docs/seal-key.md`): `createDeviceCredential({ ..., seal })` appends `["seal", <x-only pubkey>]` last, under the participant's signature; mint a fresh one per credential with `generateSealKey()` and keep its secret as you keep the device key (old copies are sealed to old seal keys; healing needs only that each new one is fresh). `encodeRekeyEvent` recipients may be `{ device, credential }`; `encodeEpochGrant` and `encodeMemberEpochGrant` take `credential`; each copy is sealed to `sealTarget(device, credential)`: the credential's seal key, else the device key (never back to the device key because the credential lapsed). Readers take `sealSks` (`decodeRekeyEvent`, `decodeEpochGrant`, `decodeMemberEpochGrant`) or a `sealSks()` getter (`requestRoomEpoch`, `memberEpochSource`) and try them before the device key (`openSealed`); a rekey copy nothing opens reads as no copy. Desks take `credentialFor(device)`, the newest credential the room has seen (keep it monotone), and seal to the newer of that and the presented one (`sealCredential`, `newerCredential`: later expiration, then later created_at). `credentialSeal(c)` is the pubkey, undefined for none, null when doubled or off the curve (sealed to the device key). Without seal keys every event is byte-identical to 0.6.0
85
+ - Scheduled rekeys and the history window (see `docs/scheduled-rekey.md`): `encodeRekeyEvent({ ..., scheduled: true })` writes `"scheduled": true` inside the encrypted body (never a tag), and throws beside a non-empty `removed` or `closed`; `decodeRekeyEvent` (`RekeyNotice.scheduled`) and `readRekeyEvidence` (`RekeyEvidence.scheduled`) report `true` only when `removed` is empty and the room is not closed, so a contradictory body is still announced. `HISTORY_WINDOW_SECONDS` (30 days) and `MAX_HISTORY_EPOCHS` (16); `epochsInWindow(left, now)` keeps entries with `leftAt >= now - HISTORY_WINDOW_SECONDS`, newest first by epoch, first of a doubled epoch, at most 16, never throws. `encodeEpochGrant({ ..., passed })` takes `LeftEpoch[]` (`{ epoch, secret, leftAt }`) and writes `passed: [{ epoch, secret (base64url), left }]` oldest first, only when non-empty; it throws on more than 16, epoch 0 (the room secret is never carried), an epoch not below the granted one, a doubled epoch, a non-32-byte secret or a bad `leftAt`. `hostRoomEpoch({ past })` is asked on every grant, filtered to what the encoder accepts and to `epochsInWindow`, so a bad `past()` costs the history, not the grant. `decodeEpochGrant` returns `EpochGrant.passed` (`leftAt` set) only when the list is exactly in that form, else drops the field and keeps the grant; `decodeMemberEpochGrant` sets `leftAt` on each passed epoch from the next rekey's `created_at`. `MAX_MEMBER_EPOCH_CHAIN` (32) is a different limit. Without the marker and `passed` every event is byte-identical to 0.7.0, and a 0.7.0 reader ignores both
85
86
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
86
87
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
87
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -22,6 +22,7 @@
22
22
  "vectors/circle-vectors.json",
23
23
  "vectors/member-epoch-vectors.json",
24
24
  "vectors/seal-vectors.json",
25
+ "vectors/schedule-vectors.json",
25
26
  "LICENSE",
26
27
  "README.md",
27
28
  "llms.txt"
@@ -35,6 +36,7 @@
35
36
  "generate-fold": "node scripts/generate-fold.mjs",
36
37
  "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
37
38
  "generate-seal": "node scripts/generate-seal.mjs",
39
+ "generate-schedule": "node scripts/generate-schedule.mjs",
38
40
  "bundle-check": "node scripts/bundle-check.mjs",
39
41
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
40
42
  "vectors": "vitest run vectors/",