@forgesworn/fold-kit 0.7.0 → 0.9.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
@@ -126,6 +126,16 @@ the rest).
126
126
  `requestPersistentRoomAdmission` keeps the relays of the newest signed
127
127
  copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
128
128
  `MAX_INVITATION_RELAYS` are the rule
129
+ - Self-destructing rooms (`docs/room-destruct.md`): pass `destruct: true`
130
+ and the encrypted body carries it after `ends` (no end needed: the room
131
+ then self-destructs when its authority closes it). The decoder returns
132
+ `destruct: true` and refuses the envelope on any other value;
133
+ `requestPersistentRoomAdmission` keeps it if any signed copy says it.
134
+ `encodeInvitationRetirement({ ended: true, destruct: true })` and
135
+ `encodeRekeyEvent({ closed: true, destruct: true })` carry it when the
136
+ room ends early, read back by `decodeInvitationRetirementNotice`,
137
+ `RekeyNotice.destruct` and `RekeyEvidence.destruct`. Vectors:
138
+ `vectors/destruct-vectors.json`
129
139
  - `withExpiration`, `isRoomEnds`, `requireRoomEnds`, `MAX_ROOM_ENDS_SECONDS` -
130
140
  the conference-room expiration rule: add the end as an `expiration` tag,
131
141
  keep an earlier one, lower a later one. `encodeInvitationRetirement` takes
@@ -168,6 +178,14 @@ the rest).
168
178
  `sealSks` on the readers; `credentialFor` on both desks;
169
179
  `credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
170
180
  `sealCredential`. Vectors: `vectors/seal-vectors.json`
181
+ - Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
182
+ `encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
183
+ removes nobody, read back as `RekeyNotice.scheduled` and
184
+ `RekeyEvidence.scheduled`; `HISTORY_WINDOW_SECONDS`, `MAX_HISTORY_EPOCHS`
185
+ and `epochsInWindow` are the rule for which left epochs a member reads;
186
+ `encodeEpochGrant({ passed })` and `hostRoomEpoch({ past })` hand those
187
+ epochs (`LeftEpoch`) to every device the authority grants, read back as
188
+ `EpochGrant.passed`. Vectors: `vectors/schedule-vectors.json`
171
189
  - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
172
190
  admin list
173
191
  - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
package/dist/epoch.d.ts CHANGED
@@ -111,6 +111,15 @@ 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
+ /** The room being closed self-destructs: every device deletes what it
115
+ * wrote and forgets the room. Inside the encrypted body. Refused
116
+ * without `closed`. Omitted, the event is byte-identical to before. */
117
+ destruct?: boolean;
118
+ /** Mark this as a scheduled turn of the key rather than a removal, so
119
+ * clients can let it pass quietly. Refused with a removal or a close: a
120
+ * rekey that removes somebody is never quiet. Omitted, the event is
121
+ * byte-identical to before. */
122
+ scheduled?: boolean;
114
123
  /** Write the epoch commitment into the body, so any current member can
115
124
  * bring a device that missed this rekey up to date (see
116
125
  * `member-epoch.ts`). Omitted, the event is byte-identical to before. */
@@ -139,6 +148,14 @@ export interface RekeyNotice {
139
148
  removed: string[];
140
149
  by?: string;
141
150
  closed: boolean;
151
+ /** True when the closed room self-destructs. Absent unless `closed`:
152
+ * beside an open room the flag is not believed, and the rekey is read
153
+ * as if it were not there. */
154
+ destruct?: true;
155
+ /** True for a scheduled turn of the key: nobody removed, the room still
156
+ * open. What lets a client move on without saying so. Absent when the
157
+ * body contradicts itself, so a removal is always announced. */
158
+ scheduled?: true;
142
159
  /** The new secret, when a copy was sealed for this device. Absent for a
143
160
  * device that was removed, or that was not in the room when the
144
161
  * authority rekeyed. */
@@ -179,6 +196,33 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
179
196
  * already past (a replay), or one sealed to an epoch it is not at.
180
197
  */
181
198
  export declare function decodeRekeyEvent(event: Event, opts: DecodeRekeyOptions): RekeyNotice | null;
199
+ /** How long a member goes on reading an epoch the room has left: 30 days,
200
+ * as long as a chat log keeps a message. */
201
+ export declare const HISTORY_WINDOW_SECONDS: number;
202
+ /** The most left epochs a member goes on reading, and an authority's grant
203
+ * hands over, however many fall inside the window. A weekly schedule puts
204
+ * about five in it; removals are what push it higher. Not
205
+ * `MAX_MEMBER_EPOCH_CHAIN`, which bounds how far behind a member desk will
206
+ * bring somebody. */
207
+ export declare const MAX_HISTORY_EPOCHS = 16;
208
+ /** An epoch the room has moved past, and when. */
209
+ export interface LeftEpoch extends RoomEpoch {
210
+ /** When the room left it, in unix seconds: the `created_at` of the rekey
211
+ * out of it. */
212
+ leftAt: number;
213
+ }
214
+ /**
215
+ * The left epochs still read at `now`: those left within
216
+ * `HISTORY_WINDOW_SECONDS` (one left exactly that long ago is kept), newest
217
+ * first, one per epoch number (the first given), at most
218
+ * `MAX_HISTORY_EPOCHS`. Every client applies the same rule, so what an
219
+ * authority hands over is what a member goes on reading. Pure, and never
220
+ * throws: an entry with no usable number or time is dropped.
221
+ */
222
+ export declare function epochsInWindow<T extends {
223
+ epoch: number;
224
+ leftAt: number;
225
+ }>(left: readonly T[], now: number): T[];
182
226
  /**
183
227
  * The key an epoch request's admission proof is computed under: the epoch-0
184
228
  * room key, expanded under its own info string. Taken from the room key
@@ -269,6 +313,15 @@ export interface EncodeEpochGrantOptions {
269
313
  /** The participants the authority knows, so the requester's own member
270
314
  * desk knows them too. Omitted, the body is as before. */
271
315
  members?: string[];
316
+ /**
317
+ * The epochs the room has left that are still read (`epochsInWindow`),
318
+ * so a newcomer, or a device back after missing several rekeys, reads
319
+ * the last month and not only the current epoch. Each is after epoch 0,
320
+ * whose secret is the room secret and is never handed over, and before
321
+ * `epoch`; at most `MAX_HISTORY_EPOCHS`, each once. Omitted or empty,
322
+ * the body is as before.
323
+ */
324
+ passed?: readonly LeftEpoch[];
272
325
  refused?: EpochRefusal;
273
326
  /** The newest credential the authority holds for the device: the grant
274
327
  * is sealed to its seal key when it names one (see `seal.ts`). */
@@ -296,10 +349,15 @@ export type EpochGrant = {
296
349
  epoch: 0;
297
350
  };
298
351
  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[];
352
+ /** Earlier epochs handed over with `epoch`, oldest first. A member's
353
+ * answer through `requestRoomEpoch({ members })` carries the epochs
354
+ * between the requester's and `epoch` (see `MemberEpochGrant.passed`);
355
+ * the authority's carries the window's (`EncodeEpochGrantOptions.passed`),
356
+ * when its desk was given them. `leftAt` is when the room left each,
357
+ * when the answer said. */
358
+ passed?: Array<RoomEpoch & {
359
+ leftAt?: number;
360
+ }>;
303
361
  /** The participants the room knows, when the answer carried them. */
304
362
  members?: string[];
305
363
  refused?: undefined;
@@ -332,6 +390,14 @@ export interface HostRoomEpochOptions {
332
390
  /** The participants to tell a granted device about, so its own member
333
391
  * desk knows them too. Asked on every grant. */
334
392
  members?: () => readonly string[];
393
+ /**
394
+ * The epochs the room has left, with their secrets and when it left
395
+ * each: every grant carries those still in the window, so a newcomer
396
+ * reads the last month as a returning member does. Asked on every grant.
397
+ * Whatever a grant could not carry (epoch 0, an epoch not before the
398
+ * current one, a bad secret) is left out rather than costing the grant.
399
+ */
400
+ past?: () => readonly LeftEpoch[];
335
401
  /**
336
402
  * The newest credential the authority has seen for a device, from the
337
403
  * roster. An answer is sealed to the newer of this and the one the
package/dist/epoch.js CHANGED
@@ -143,6 +143,10 @@ 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');
148
+ if (opts.destruct && !opts.closed)
149
+ throw new Error('only a closing rekey can self-destruct the room');
146
150
  const keys = {};
147
151
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
148
152
  const plaintext = JSON.stringify(sealed);
@@ -157,6 +161,8 @@ export function encodeRekeyEvent(opts) {
157
161
  removed,
158
162
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
159
163
  ...(opts.closed ? { closed: true } : {}),
164
+ ...(opts.destruct ? { destruct: true } : {}),
165
+ ...(opts.scheduled ? { scheduled: true } : {}),
160
166
  ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
161
167
  ...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
162
168
  keys,
@@ -223,6 +229,10 @@ export function decodeRekeyEvent(event, opts) {
223
229
  };
224
230
  if (typeof body.by === 'string' && HEX64.test(body.by))
225
231
  notice.by = normaliseHex(body.by);
232
+ if (body.destruct === true && notice.closed)
233
+ notice.destruct = true;
234
+ if (body.scheduled === true && notice.removed.length === 0 && !notice.closed)
235
+ notice.scheduled = true;
226
236
  const members = readMemberList(body.members);
227
237
  if (members)
228
238
  notice.members = members;
@@ -252,6 +262,41 @@ export function decodeRekeyEvent(event, opts) {
252
262
  }
253
263
  }
254
264
  // ---------------------------------------------------------------------------
265
+ // The history window
266
+ // ---------------------------------------------------------------------------
267
+ /** How long a member goes on reading an epoch the room has left: 30 days,
268
+ * as long as a chat log keeps a message. */
269
+ export const HISTORY_WINDOW_SECONDS = 30 * 86_400;
270
+ /** The most left epochs a member goes on reading, and an authority's grant
271
+ * hands over, however many fall inside the window. A weekly schedule puts
272
+ * about five in it; removals are what push it higher. Not
273
+ * `MAX_MEMBER_EPOCH_CHAIN`, which bounds how far behind a member desk will
274
+ * bring somebody. */
275
+ export const MAX_HISTORY_EPOCHS = 16;
276
+ /**
277
+ * The left epochs still read at `now`: those left within
278
+ * `HISTORY_WINDOW_SECONDS` (one left exactly that long ago is kept), newest
279
+ * first, one per epoch number (the first given), at most
280
+ * `MAX_HISTORY_EPOCHS`. Every client applies the same rule, so what an
281
+ * authority hands over is what a member goes on reading. Pure, and never
282
+ * throws: an entry with no usable number or time is dropped.
283
+ */
284
+ export function epochsInWindow(left, now) {
285
+ const since = now - HISTORY_WINDOW_SECONDS;
286
+ const usable = left.filter((e) => typeof e === 'object' && e !== null && Number.isSafeInteger(e.epoch) && e.epoch >= 0 && Number.isFinite(e.leftAt) && e.leftAt >= since);
287
+ const seen = new Set();
288
+ const out = [];
289
+ for (const e of usable.sort((a, b) => b.epoch - a.epoch)) {
290
+ if (seen.has(e.epoch))
291
+ continue;
292
+ seen.add(e.epoch);
293
+ out.push(e);
294
+ if (out.length === MAX_HISTORY_EPOCHS)
295
+ break;
296
+ }
297
+ return out;
298
+ }
299
+ // ---------------------------------------------------------------------------
255
300
  // Asking the authority for the current epoch
256
301
  // ---------------------------------------------------------------------------
257
302
  /**
@@ -385,6 +430,56 @@ function readEpochRequest(event, opts) {
385
430
  * asking: often enough that a missed "let them in?" comes back, rarely
386
431
  * enough that it is not a nag. */
387
432
  export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
433
+ function passedListOf(passed, granted) {
434
+ if (passed.length > MAX_HISTORY_EPOCHS)
435
+ throw new Error(`a grant carries at most ${MAX_HISTORY_EPOCHS} passed epochs`);
436
+ const out = passed
437
+ .map((e) => {
438
+ const epoch = requireEpochNumber(e.epoch);
439
+ if (epoch < 1 || epoch >= granted)
440
+ throw new Error('a passed epoch comes after epoch 0 and before the one granted');
441
+ require32(e.secret, 'epoch secret');
442
+ if (!Number.isSafeInteger(e.leftAt) || e.leftAt < 0)
443
+ throw new Error('leftAt must be a non-negative integer');
444
+ return { epoch, secret: base64urlnopad.encode(e.secret), left: e.leftAt };
445
+ })
446
+ .sort((a, b) => a.epoch - b.epoch);
447
+ for (let i = 1; i < out.length; i += 1) {
448
+ if (out[i].epoch === out[i - 1].epoch)
449
+ throw new Error('a passed epoch is listed once');
450
+ }
451
+ return out;
452
+ }
453
+ /** A grant's passed epochs, or undefined unless every entry is in the form
454
+ * `encodeEpochGrant` writes. One bad entry costs the history, not the
455
+ * grant: the current epoch still stands. */
456
+ function readPassedList(raw, granted) {
457
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_HISTORY_EPOCHS)
458
+ return undefined;
459
+ const out = [];
460
+ for (const entry of raw) {
461
+ if (typeof entry !== 'object' || entry === null)
462
+ return undefined;
463
+ const { epoch, secret, left } = entry;
464
+ if (!Number.isSafeInteger(epoch) || epoch < 1 || epoch >= granted)
465
+ return undefined;
466
+ if (out.length > 0 && epoch <= out[out.length - 1].epoch)
467
+ return undefined;
468
+ if (typeof secret !== 'string' || !Number.isSafeInteger(left) || left < 0)
469
+ return undefined;
470
+ let bytes;
471
+ try {
472
+ bytes = base64urlnopad.decode(secret);
473
+ }
474
+ catch {
475
+ return undefined;
476
+ }
477
+ if (bytes.length !== 32)
478
+ return undefined;
479
+ out.push({ epoch: epoch, secret: bytes, leftAt: left });
480
+ }
481
+ return out;
482
+ }
388
483
  /** Answer one request: the current epoch sealed to the asking device, or a
389
484
  * refusal it alone can read. */
390
485
  export function encodeEpochGrant(opts) {
@@ -407,6 +502,8 @@ export function encodeEpochGrant(opts) {
407
502
  body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
408
503
  if (opts.members !== undefined)
409
504
  body.members = memberListOf(opts.members, body.removed);
505
+ if (opts.passed !== undefined && opts.passed.length > 0)
506
+ body.passed = passedListOf(opts.passed, body.epoch);
410
507
  }
411
508
  return finalizeEvent({
412
509
  kind: KINDS.EPOCH_GRANT,
@@ -456,12 +553,39 @@ export function decodeEpochGrant(event, opts) {
456
553
  const secret = base64urlnopad.decode(body.secret);
457
554
  if (secret.length !== 32)
458
555
  return null;
459
- return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
556
+ const passed = readPassedList(body.passed, body.epoch);
557
+ return { epoch: { epoch: body.epoch, secret }, removed, ...listed, ...(passed ? { passed } : {}) };
460
558
  }
461
559
  catch {
462
560
  return null;
463
561
  }
464
562
  }
563
+ /** What a desk hands over of `past()`: the window's epochs below the one it
564
+ * grants, leaving out anything `encodeEpochGrant` would refuse, so a bad
565
+ * entry costs the history and never the grant. */
566
+ function passedFor(past, granted, now) {
567
+ if (!past)
568
+ return [];
569
+ let given;
570
+ try {
571
+ given = past();
572
+ }
573
+ catch {
574
+ return [];
575
+ }
576
+ if (!Array.isArray(given))
577
+ return [];
578
+ const carried = given.filter((e) => typeof e === 'object' &&
579
+ e !== null &&
580
+ Number.isSafeInteger(e.epoch) &&
581
+ e.epoch >= 1 &&
582
+ e.epoch < granted &&
583
+ e.secret instanceof Uint8Array &&
584
+ e.secret.length === 32 &&
585
+ Number.isSafeInteger(e.leftAt) &&
586
+ e.leftAt >= 0);
587
+ return epochsInWindow(carried, now);
588
+ }
465
589
  /**
466
590
  * The credential to seal an answer to: the one the request carries, unless
467
591
  * the room has seen a newer one for the same device and participant that
@@ -551,8 +675,8 @@ export function hostRoomEpoch(opts) {
551
675
  }
552
676
  let grant;
553
677
  try {
554
- grant = refused
555
- ? encodeEpochGrant({
678
+ if (refused) {
679
+ grant = encodeEpochGrant({
556
680
  roomId,
557
681
  authoritySk: opts.authoritySk,
558
682
  device: request.device,
@@ -561,19 +685,24 @@ export function hostRoomEpoch(opts) {
561
685
  refused,
562
686
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
563
687
  expiresAt: opts.expiresAt,
564
- })
565
- : encodeEpochGrant({
688
+ });
689
+ }
690
+ else {
691
+ const epoch = opts.current();
692
+ grant = encodeEpochGrant({
566
693
  roomId,
567
694
  authoritySk: opts.authoritySk,
568
695
  device: request.device,
569
696
  request: request.request,
570
697
  now: now(),
571
- epoch: opts.current(),
698
+ epoch,
572
699
  removed: [...opts.removed()],
573
700
  ...(opts.members ? { members: [...opts.members()] } : {}),
701
+ ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
574
702
  credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
575
703
  expiresAt: opts.expiresAt,
576
704
  });
705
+ }
577
706
  }
578
707
  catch {
579
708
  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';
@@ -165,6 +165,12 @@ export interface EncodeInvitationRetirementOptions {
165
165
  /** The room itself was ended, not just this link replaced. Additive: a
166
166
  * reader that predates it still sees an ordinary retirement. */
167
167
  ended?: boolean;
168
+ /** The ended room self-destructs: every device deletes what it wrote and
169
+ * forgets the room. Only beside `ended`, else it throws. Unlike the
170
+ * invitation's own flag this content is not encrypted: it says no more
171
+ * than `ended` does about a link nobody outside the room can name.
172
+ * Omitted or false, the event is byte-identical to 0.8.0's. */
173
+ destruct?: boolean;
168
174
  /** A conference room's end, in unix seconds: the tombstone carries the
169
175
  * same NIP-40 expiration as the invitation it retires, and lapses with it. */
170
176
  endsAt?: number;
@@ -176,10 +182,12 @@ export declare function encodeInvitationRetirement(opts: EncodeInvitationRetirem
176
182
  /** A valid tombstone never expires: invitation ids are random and unique,
177
183
  * and a retired bearer must not become usable again after a timeout. */
178
184
  export declare function decodeInvitationRetirement(event: Event, invitation: RoomInvitation): boolean;
179
- /** A valid retirement, and whether it says the room was ended. Undefined
180
- * for anything that is not a valid retirement of this invitation. */
185
+ /** A valid retirement, whether it says the room was ended, and whether the
186
+ * ended room self-destructs (`destruct`, believed only beside `ended`).
187
+ * Undefined for anything that is not a valid retirement of this invitation. */
181
188
  export declare function decodeInvitationRetirementNotice(event: Event, invitation: RoomInvitation): {
182
189
  ended: boolean;
190
+ destruct?: true;
183
191
  } | undefined;
184
192
  /** The error a joiner rejects with on a retirement. */
185
193
  export declare function retirementError(notice: {
@@ -279,11 +279,13 @@ export function encodeInvitationRetirement(opts) {
279
279
  if (!hexEquals(getPublicKey(opts.inviterSk), opts.invitation.inviter)) {
280
280
  throw new Error('only the root inviter can retire an invitation');
281
281
  }
282
+ if (opts.destruct && !opts.ended)
283
+ throw new Error('only an ended room can self-destruct');
282
284
  return finalizeEvent({
283
285
  kind: KINDS.INVITATION_RETIREMENT,
284
286
  created_at: opts.now,
285
287
  tags: withExpiration([['d', deriveInvitationId(opts.invitation)]], opts.endsAt),
286
- content: JSON.stringify(opts.ended ? { v: 1, ended: true } : { v: 1 }),
288
+ content: JSON.stringify(opts.ended ? (opts.destruct ? { v: 1, ended: true, destruct: true } : { v: 1, ended: true }) : { v: 1 }),
287
289
  }, opts.inviterSk);
288
290
  }
289
291
  /** A valid tombstone never expires: invitation ids are random and unique,
@@ -291,8 +293,9 @@ export function encodeInvitationRetirement(opts) {
291
293
  export function decodeInvitationRetirement(event, invitation) {
292
294
  return decodeInvitationRetirementNotice(event, invitation) !== undefined;
293
295
  }
294
- /** A valid retirement, and whether it says the room was ended. Undefined
295
- * for anything that is not a valid retirement of this invitation. */
296
+ /** A valid retirement, whether it says the room was ended, and whether the
297
+ * ended room self-destructs (`destruct`, believed only beside `ended`).
298
+ * Undefined for anything that is not a valid retirement of this invitation. */
296
299
  export function decodeInvitationRetirementNotice(event, invitation) {
297
300
  try {
298
301
  if (event.kind !== KINDS.INVITATION_RETIREMENT)
@@ -304,7 +307,9 @@ export function decodeInvitationRetirementNotice(event, invitation) {
304
307
  if (event.tags.find((tag) => tag[0] === 'd')?.[1] !== deriveInvitationId(invitation))
305
308
  return undefined;
306
309
  const body = JSON.parse(event.content);
307
- return body.v === 1 ? { ended: body.ended === true } : undefined;
310
+ if (body.v !== 1)
311
+ return undefined;
312
+ return body.ended === true && body.destruct === true ? { ended: true, destruct: true } : { ended: body.ended === true };
308
313
  }
309
314
  catch {
310
315
  return undefined;
@@ -59,6 +59,10 @@ export interface RekeyEvidence {
59
59
  epoch: number;
60
60
  removed: string[];
61
61
  closed: boolean;
62
+ /** The closed room self-destructs: see `RekeyNotice.destruct`. */
63
+ destruct?: true;
64
+ /** A scheduled turn of the key: see `RekeyNotice.scheduled`. */
65
+ scheduled?: true;
62
66
  /** The epoch commitment, when the authority wrote one. */
63
67
  commit?: string;
64
68
  /** The authority's member list, when it wrote one. */
@@ -167,8 +171,11 @@ export interface MemberEpochGrant {
167
171
  /** The epochs between the requester's and `epoch`, oldest first, each
168
172
  * proven by the next rekey in the chain decrypting under it. Empty for a
169
173
  * 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[];
174
+ * in the epochs it skipped, and hand them on from its own member desk.
175
+ * `leftAt` is the `created_at` of the authority's rekey out of each. */
176
+ passed: Array<RoomEpoch & {
177
+ leftAt?: number;
178
+ }>;
172
179
  /** The newest member list in the chain, when any rekey in it had one. */
173
180
  members?: string[];
174
181
  }
@@ -117,6 +117,10 @@ 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.destruct === true && evidence.closed)
121
+ evidence.destruct = true;
122
+ if (body.scheduled === true && evidence.removed.length === 0 && !evidence.closed)
123
+ evidence.scheduled = true;
120
124
  if (typeof body.commit === 'string' && HEX64.test(body.commit))
121
125
  evidence.commit = normaliseHex(body.commit);
122
126
  const members = readMemberList(body.members);
@@ -321,8 +325,10 @@ export function decodeMemberEpochGrant(event, opts) {
321
325
  return null;
322
326
  }
323
327
  previous = deriveEpoch({ epoch: evidence.epoch, secret });
328
+ // The next rekey, signed and checked on the next turn, is when the
329
+ // room left this epoch.
324
330
  if (i < length - 1)
325
- passed.push({ epoch: evidence.epoch, secret });
331
+ passed.push({ epoch: evidence.epoch, secret, leftAt: body.rekeys[i + 1].created_at });
326
332
  }
327
333
  return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
328
334
  }
@@ -13,6 +13,9 @@ export interface PersistentRoomAdmission {
13
13
  /** The room's own relays, as its inviter signed them: every member's pool
14
14
  * includes them. Absent from an invitation written before 0.4.0. */
15
15
  relays?: string[];
16
+ /** The room self-destructs: when it ends, every device deletes what it
17
+ * wrote and forgets the room. Absent for a room that simply ends. */
18
+ destruct?: true;
16
19
  }
17
20
  /** Publish once before sharing. The link plus this envelope is a durable
18
21
  * bearer capability: anybody holding both can learn epoch 0. Retirement
@@ -30,6 +33,10 @@ export declare function encodePersistentInvitation(opts: {
30
33
  * form (see `isInvitationRelays`), else it throws. Omitted, the body is
31
34
  * byte-identical to 0.3.0's. */
32
35
  relays?: readonly string[];
36
+ /** The room self-destructs when it ends, by its time or by its authority
37
+ * closing it. Written inside the encrypted body only, never as a tag.
38
+ * Omitted or false, the event is byte-identical to 0.8.0's. */
39
+ destruct?: boolean;
33
40
  }): Event;
34
41
  export declare function decodePersistentInvitation(event: Event, invitation: RoomInvitation): PersistentRoomAdmission | null;
35
42
  /** Fetch the signed envelope and retirement in ONE stored-event query.
@@ -30,7 +30,7 @@ export function encodePersistentInvitation(opts) {
30
30
  created_at: opts.now,
31
31
  tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
32
32
  content: nip44.v2.encrypt(JSON.stringify({
33
- v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(relays === undefined ? {} : { relays }),
33
+ v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }),
34
34
  }), welcomeKey(opts.invitation)),
35
35
  }, opts.inviterSk);
36
36
  }
@@ -62,9 +62,15 @@ export function decodePersistentInvitation(event, invitation) {
62
62
  // would write; a malformed one refuses the envelope rather than half of it.
63
63
  if (body.relays !== undefined && !isInvitationRelays(body.relays))
64
64
  return null;
65
+ // Self-destruct is `true` or absent; anything else is a malformed
66
+ // envelope, refused as a malformed end is.
67
+ if (body.destruct !== undefined && body.destruct !== true)
68
+ return null;
65
69
  const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
66
70
  if (body.relays !== undefined)
67
71
  admission.relays = [...body.relays];
72
+ if (body.destruct === true)
73
+ admission.destruct = true;
68
74
  return admission;
69
75
  }
70
76
  catch {
@@ -119,6 +125,10 @@ export function requestPersistentRoomAdmission(opts) {
119
125
  // end stands, so a stale copy can never keep a room open longer.
120
126
  if (admission?.endsAt !== undefined && (decoded.endsAt === undefined || decoded.endsAt > admission.endsAt))
121
127
  decoded.endsAt = admission.endsAt;
128
+ // Two signed copies that disagree on self-destruct: it sticks, for
129
+ // the same reason, so a stale copy can never keep a room's content.
130
+ if (admission?.destruct)
131
+ decoded.destruct = true;
122
132
  // Two signed copies that disagree on the room's relays: the newest
123
133
  // copy that names any stands. A copy naming none (an older writer)
124
134
  // says nothing about them, and on equal timestamps the first heard stays.
package/llms.txt CHANGED
@@ -68,6 +68,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
68
68
  - `requestPersistentRoomAdmission(opts)` - joins from stored admission alone, no live desk needed
69
69
  - Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
70
70
  - Room relays: `encodePersistentInvitation({ ..., relays })` puts the room's own relays (1-8 distinct canonical `wss://host/` URLs, else it throws) in the body after `ends`; the decoder returns `relays` and is null on a malformed list; `requestPersistentRoomAdmission` keeps the newest signed copy's list (a copy without one says nothing); `isInvitationRelays`, `requireInvitationRelays`, `MAX_INVITATION_RELAYS` (8)
71
+ - Self-destructing rooms (see `docs/room-destruct.md`): `encodePersistentInvitation({ ..., destruct: true })` puts `"destruct": true` in the encrypted body after `ends` (valid without `ends`), never a tag; `decodePersistentInvitation` returns `destruct: true` and is null when the field is present and not `true`; `requestPersistentRoomAdmission` keeps `destruct` if any valid copy carries it (sticky, like the earliest `ends`). `encodeInvitationRetirement({ ..., ended: true, destruct: true })` writes `{"v":1,"ended":true,"destruct":true}` (plain JSON, like `ended`) and throws on `destruct` without `ended`; `decodeInvitationRetirementNotice` returns `{ ended, destruct?: true }`, believing `destruct` only beside `ended`. `encodeRekeyEvent({ ..., closed: true, destruct: true })` writes it in the encrypted body after `closed` and throws without `closed`; `RekeyNotice.destruct` / `RekeyEvidence.destruct` are set only on a closing body, and a flag on an open rekey is dropped, not refused. Without the flag every event is byte-identical to 0.8.0, and a 0.8.0 reader ignores it
71
72
  - `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
72
73
  - Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
73
74
 
@@ -82,6 +83,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
82
83
  - 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
84
  - 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
85
  - 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
86
+ - 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
87
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
86
88
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
87
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.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,8 @@
22
22
  "vectors/circle-vectors.json",
23
23
  "vectors/member-epoch-vectors.json",
24
24
  "vectors/seal-vectors.json",
25
+ "vectors/schedule-vectors.json",
26
+ "vectors/destruct-vectors.json",
25
27
  "LICENSE",
26
28
  "README.md",
27
29
  "llms.txt"
@@ -35,6 +37,8 @@
35
37
  "generate-fold": "node scripts/generate-fold.mjs",
36
38
  "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
37
39
  "generate-seal": "node scripts/generate-seal.mjs",
40
+ "generate-schedule": "node scripts/generate-schedule.mjs",
41
+ "generate-destruct": "node scripts/generate-destruct.mjs",
38
42
  "bundle-check": "node scripts/bundle-check.mjs",
39
43
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
40
44
  "vectors": "vitest run vectors/",