@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 +18 -0
- package/dist/epoch.d.ts +70 -4
- package/dist/epoch.js +135 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/invitation.d.ts +10 -2
- package/dist/invitation.js +9 -4
- package/dist/member-epoch.d.ts +9 -2
- package/dist/member-epoch.js +7 -1
- package/dist/persistent-invitation.d.ts +7 -0
- package/dist/persistent-invitation.js +11 -1
- package/llms.txt +2 -0
- package/package.json +5 -1
- package/vectors/destruct-vectors.json +675 -0
- package/vectors/schedule-vectors.json +882 -0
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
|
-
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
|
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';
|
package/dist/invitation.d.ts
CHANGED
|
@@ -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,
|
|
180
|
-
*
|
|
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: {
|
package/dist/invitation.js
CHANGED
|
@@ -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,
|
|
295
|
-
*
|
|
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
|
-
|
|
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;
|
package/dist/member-epoch.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/member-epoch.js
CHANGED
|
@@ -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.
|
|
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/",
|