@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 +8 -0
- package/dist/epoch.d.ts +62 -4
- package/dist/epoch.js +130 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/member-epoch.d.ts +7 -2
- package/dist/member-epoch.js +5 -1
- package/llms.txt +1 -0
- package/package.json +3 -1
- package/vectors/schedule-vectors.json +882 -0
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
|
-
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
|
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';
|
package/dist/member-epoch.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/member-epoch.js
CHANGED
|
@@ -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.
|
|
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/",
|