@forgesworn/fold-kit 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -160,6 +160,22 @@ the rest).
160
160
  authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
161
161
  through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
162
162
  carry the authority's member list (`readMemberList` reads one)
163
+ - Seal keys (`docs/seal-key.md`): a credential may name a seal key
164
+ (`createDeviceCredential({ seal })`, `generateSealKey`), and rekeys and
165
+ both grants are sealed to the newest one the sender holds for a device
166
+ instead of its device key, so a copied device heals once its credential
167
+ lapses and the room rekeys. Recipients as `{ device, credential }`;
168
+ `sealSks` on the readers; `credentialFor` on both desks;
169
+ `credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
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`
163
179
  - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
164
180
  admin list
165
181
  - `canonicalChannels`, `signChannels`, `verifyChannels`, `CHANNEL_NAME`,
@@ -25,6 +25,11 @@ export interface CreateCredentialOptions {
25
25
  expiresAt: number;
26
26
  /** Injectable clock, in unix seconds. Defaults to the real one. */
27
27
  now?: () => number;
28
+ /** The device's seal key for this credential: an x-only public key,
29
+ * minted fresh for each credential, to which rekeys and epoch grants are
30
+ * then sealed instead of the device key. See `seal.ts`. Omitted, the
31
+ * credential is byte-identical to one minted before seal keys. */
32
+ seal?: string;
28
33
  }
29
34
  /** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
30
35
  export declare const PERSON_CREDENTIAL_MAX_SECONDS: number;
@@ -1,6 +1,7 @@
1
1
  import { KINDS } from './kinds.js';
2
2
  import { verifyEventUncached } from './verify.js';
3
3
  import { hexEquals, normaliseHex } from './hex.js';
4
+ import { isSealPubkey, SEAL_TAG } from './seal.js';
4
5
  /** The longest a person-scoped credential may run. A phone that leaves the house is better at seven days. */
5
6
  export const PERSON_CREDENTIAL_MAX_SECONDS = 30 * 24 * 60 * 60;
6
7
  /**
@@ -43,6 +44,8 @@ export async function createDeviceCredential(opts) {
43
44
  throw new Error('a room credential needs a room');
44
45
  if (person && opts.expiresAt - now > PERSON_CREDENTIAL_MAX_SECONDS)
45
46
  throw new Error('a person credential may not run more than 30 days');
47
+ if (opts.seal !== undefined && !isSealPubkey(opts.seal))
48
+ throw new Error('a seal key must be an x-only public key on the curve');
46
49
  const unsigned = {
47
50
  kind: KINDS.CREDENTIAL,
48
51
  created_at: now,
@@ -52,6 +55,7 @@ export async function createDeviceCredential(opts) {
52
55
  ['expiration', String(opts.expiresAt)],
53
56
  ...(person ? [['scope', 'person']] : []),
54
57
  ...(opts.label !== undefined ? [['label', opts.label]] : []),
58
+ ...(opts.seal !== undefined ? [[SEAL_TAG, opts.seal.toLowerCase()]] : []),
55
59
  ],
56
60
  content: '',
57
61
  };
package/dist/epoch.d.ts CHANGED
@@ -98,8 +98,11 @@ export interface EncodeRekeyOptions {
98
98
  /** The epoch being entered. Must be `current.epoch + 1`. */
99
99
  next: RoomEpoch;
100
100
  /** Devices to seal the new secret for. Everybody still in the room,
101
- * except the removed participants' devices. */
102
- recipients: string[];
101
+ * except the removed participants' devices. A recipient given with its
102
+ * newest credential is sealed to that credential's seal key, when it
103
+ * names one; a bare device, to the device key (see `seal.ts`). The
104
+ * `keys` map is keyed by device either way. */
105
+ recipients: ReadonlyArray<string | RekeyRecipient>;
103
106
  /** Participants removed at this step. Empty on a rekey that only turns
104
107
  * the key over. */
105
108
  removed: string[];
@@ -108,6 +111,11 @@ export interface EncodeRekeyOptions {
108
111
  /** True when the room is being closed: nobody is kept, and the event
109
112
  * says so rather than leaving everybody to wonder. */
110
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;
111
119
  /** Write the epoch commitment into the body, so any current member can
112
120
  * bring a device that missed this rekey up to date (see
113
121
  * `member-epoch.ts`). Omitted, the event is byte-identical to before. */
@@ -122,6 +130,12 @@ export interface EncodeRekeyOptions {
122
130
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
123
131
  expiresAt?: number;
124
132
  }
133
+ /** A rekey recipient: a device, and the newest credential the sender holds
134
+ * for it. */
135
+ export interface RekeyRecipient {
136
+ device: string;
137
+ credential?: DeviceCredential;
138
+ }
125
139
  /** Announce the next epoch. See the module comment for what this is. */
126
140
  export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
127
141
  /** What a rekey says, once read. */
@@ -130,6 +144,10 @@ export interface RekeyNotice {
130
144
  removed: string[];
131
145
  by?: string;
132
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;
133
151
  /** The new secret, when a copy was sealed for this device. Absent for a
134
152
  * device that was removed, or that was not in the room when the
135
153
  * authority rekeyed. */
@@ -160,6 +178,9 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
160
178
  current: EpochKeys;
161
179
  /** This device's key, to open its own copy. */
162
180
  deviceSk: Uint8Array;
181
+ /** The seal key secrets this device holds, newest first, tried before
182
+ * the device key (see `seal.ts`). */
183
+ sealSks?: readonly Uint8Array[];
163
184
  }
164
185
  /**
165
186
  * Read a rekey. Null for anything that does not check out, and for a rekey
@@ -167,6 +188,33 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
167
188
  * already past (a replay), or one sealed to an epoch it is not at.
168
189
  */
169
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[];
170
218
  /**
171
219
  * The key an epoch request's admission proof is computed under: the epoch-0
172
220
  * room key, expanded under its own info string. Taken from the room key
@@ -257,7 +305,19 @@ export interface EncodeEpochGrantOptions {
257
305
  /** The participants the authority knows, so the requester's own member
258
306
  * desk knows them too. Omitted, the body is as before. */
259
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[];
260
317
  refused?: EpochRefusal;
318
+ /** The newest credential the authority holds for the device: the grant
319
+ * is sealed to its seal key when it names one (see `seal.ts`). */
320
+ credential?: DeviceCredential;
261
321
  /** A conference room's end, in unix seconds: the event carries it as a
262
322
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
263
323
  expiresAt?: number;
@@ -269,6 +329,9 @@ export interface DecodeEpochGrantOptions {
269
329
  roomId: string;
270
330
  authority: string;
271
331
  deviceSk: Uint8Array;
332
+ /** The seal key secrets this device holds: see
333
+ * `DecodeRekeyOptions.sealSks`. */
334
+ sealSks?: readonly Uint8Array[];
272
335
  request: string;
273
336
  now: number;
274
337
  maxAgeSeconds?: number;
@@ -278,10 +341,15 @@ export type EpochGrant = {
278
341
  epoch: 0;
279
342
  };
280
343
  removed: string[];
281
- /** Present on a member's answer through `requestRoomEpoch({ members })`:
282
- * the epochs it carried between the requester's and `epoch`. See
283
- * `MemberEpochGrant.passed`. The authority's own answer has none. */
284
- passed?: RoomEpoch[];
344
+ /** Earlier epochs handed over with `epoch`, oldest first. A member's
345
+ * answer through `requestRoomEpoch({ members })` carries the epochs
346
+ * between the requester's and `epoch` (see `MemberEpochGrant.passed`);
347
+ * the authority's carries the window's (`EncodeEpochGrantOptions.passed`),
348
+ * when its desk was given them. `leftAt` is when the room left each,
349
+ * when the answer said. */
350
+ passed?: Array<RoomEpoch & {
351
+ leftAt?: number;
352
+ }>;
285
353
  /** The participants the room knows, when the answer carried them. */
286
354
  members?: string[];
287
355
  refused?: undefined;
@@ -314,6 +382,23 @@ export interface HostRoomEpochOptions {
314
382
  /** The participants to tell a granted device about, so its own member
315
383
  * desk knows them too. Asked on every grant. */
316
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[];
393
+ /**
394
+ * The newest credential the authority has seen for a device, from the
395
+ * roster. An answer is sealed to the newer of this and the one the
396
+ * request carries, so a thief replaying a device's older, still-live
397
+ * credential is answered under the seal key the device minted since.
398
+ * Keep it monotone: a roster entry carrying an older credential than one
399
+ * already seen must not replace it, or the thief can publish one.
400
+ */
401
+ credentialFor?: (device: string) => DeviceCredential | undefined;
317
402
  /** Somebody the room does not know asked, after a removal: what an app
318
403
  * turns into "let them in?". Called once per participant, and again
319
404
  * every `reportUnknownEvery` seconds while they keep asking. Letting them in
@@ -330,6 +415,15 @@ export interface HostRoomEpochOptions {
330
415
  /** A conference room's end: every grant carries it as an expiration. */
331
416
  expiresAt?: number;
332
417
  }
418
+ /**
419
+ * The credential to seal an answer to: the one the request carries, unless
420
+ * the room has seen a newer one for the same device and participant that
421
+ * still verifies. Shared by both desks.
422
+ */
423
+ export declare function sealCredential(request: {
424
+ device: string;
425
+ participant: string;
426
+ }, presented: DeviceCredential, credentialFor: ((device: string) => DeviceCredential | undefined) | undefined, roomId: string, now: number): DeviceCredential;
333
427
  /**
334
428
  * Answer epoch requests for as long as the handle is open. What a keeper
335
429
  * runs beside its invitation desk: a member arriving, or returning, after
@@ -370,6 +464,9 @@ export interface RequestRoomEpochOptions {
370
464
  /** The authority said the room does not know this participant yet. Not
371
465
  * final: the ask goes on, and a member letting them in settles it. */
372
466
  onUnknown?: () => void;
467
+ /** The seal key secrets this device holds. Asked on every answer,
468
+ * because a credential may renew while the ask is out. */
469
+ sealSks?: () => readonly Uint8Array[];
373
470
  }
374
471
  /** Thrown when the authority answered, and the answer was no. */
375
472
  export declare class EpochRefusedError extends Error {
package/dist/epoch.js CHANGED
@@ -12,6 +12,7 @@ import { deriveRoom } from './room.js';
12
12
  import { verifyDeviceCredential } from './credential.js';
13
13
  import { evaluateAccess } from './access.js';
14
14
  import { verifyEventUncached } from './verify.js';
15
+ import { newerCredential, openSealed, sealTo } from './seal.js';
15
16
  import { withExpiration } from './expiration.js';
16
17
  import { epochCommitment } from './epoch-commit.js';
17
18
  /**
@@ -142,12 +143,15 @@ export function encodeRekeyEvent(opts) {
142
143
  if (epoch !== opts.current.epoch + 1)
143
144
  throw new Error('a rekey moves the room forward by exactly one epoch');
144
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');
145
148
  const keys = {};
146
149
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
147
150
  const plaintext = JSON.stringify(sealed);
148
151
  for (const raw of opts.closed ? [] : opts.recipients) {
149
- const device = requireHex32(raw, 'recipient device');
150
- keys[device] = nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(opts.authoritySk, device));
152
+ const recipient = typeof raw === 'string' ? { device: raw } : raw;
153
+ const device = requireHex32(recipient.device, 'recipient device');
154
+ keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
151
155
  }
152
156
  const body = {
153
157
  v: 1,
@@ -155,6 +159,7 @@ export function encodeRekeyEvent(opts) {
155
159
  removed,
156
160
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
157
161
  ...(opts.closed ? { closed: true } : {}),
162
+ ...(opts.scheduled ? { scheduled: true } : {}),
158
163
  ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
159
164
  ...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
160
165
  keys,
@@ -221,17 +226,28 @@ export function decodeRekeyEvent(event, opts) {
221
226
  };
222
227
  if (typeof body.by === 'string' && HEX64.test(body.by))
223
228
  notice.by = normaliseHex(body.by);
229
+ if (body.scheduled === true && notice.removed.length === 0 && !notice.closed)
230
+ notice.scheduled = true;
224
231
  const members = readMemberList(body.members);
225
232
  if (members)
226
233
  notice.members = members;
227
234
  const device = getPublicKey(opts.deviceSk);
228
235
  const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
229
236
  if (typeof mine === 'string') {
230
- const sealed = JSON.parse(nip44.v2.decrypt(mine, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
231
- if (sealed.v === 1 && typeof sealed.secret === 'string') {
232
- const secret = base64urlnopad.decode(sealed.secret);
233
- if (secret.length === 32)
234
- notice.secret = secret;
237
+ // A copy none of this device's keys opens is no copy: sealed to a
238
+ // seal key it has since erased, or one it never had. The rest of the
239
+ // rekey still says who was removed, so it is read without the secret,
240
+ // and the device asks for the epoch.
241
+ try {
242
+ const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
243
+ if (sealed.v === 1 && typeof sealed.secret === 'string') {
244
+ const secret = base64urlnopad.decode(sealed.secret);
245
+ if (secret.length === 32)
246
+ notice.secret = secret;
247
+ }
248
+ }
249
+ catch {
250
+ // No secret, as above.
235
251
  }
236
252
  }
237
253
  return notice;
@@ -241,6 +257,41 @@ export function decodeRekeyEvent(event, opts) {
241
257
  }
242
258
  }
243
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
+ // ---------------------------------------------------------------------------
244
295
  // Asking the authority for the current epoch
245
296
  // ---------------------------------------------------------------------------
246
297
  /**
@@ -314,6 +365,12 @@ export function encodeEpochRequest(opts) {
314
365
  * cannot prove which participant it speaks for in this room, or from one
315
366
  * that cannot prove it was admitted to the room at all. */
316
367
  export function decodeEpochRequest(event, opts) {
368
+ const read = readEpochRequest(event, opts);
369
+ return read && read.request;
370
+ }
371
+ /** `decodeEpochRequest`, keeping the credential the request was made
372
+ * under: what the desk seals its answer by. */
373
+ function readEpochRequest(event, opts) {
317
374
  try {
318
375
  if (event.kind !== KINDS.EPOCH_REQUEST)
319
376
  return null;
@@ -358,7 +415,7 @@ export function decodeEpochRequest(event, opts) {
358
415
  if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, opts.roomId).admitted)
359
416
  return null;
360
417
  }
361
- return { device: verdict.device, participant: verdict.participant, request: event.id };
418
+ return { request: { device: verdict.device, participant: verdict.participant, request: event.id }, credential: body.credential };
362
419
  }
363
420
  catch {
364
421
  return null;
@@ -368,6 +425,56 @@ export function decodeEpochRequest(event, opts) {
368
425
  * asking: often enough that a missed "let them in?" comes back, rarely
369
426
  * enough that it is not a nag. */
370
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
+ }
371
478
  /** Answer one request: the current epoch sealed to the asking device, or a
372
479
  * refusal it alone can read. */
373
480
  export function encodeEpochGrant(opts) {
@@ -390,6 +497,8 @@ export function encodeEpochGrant(opts) {
390
497
  body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
391
498
  if (opts.members !== undefined)
392
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);
393
502
  }
394
503
  return finalizeEvent({
395
504
  kind: KINDS.EPOCH_GRANT,
@@ -398,7 +507,7 @@ export function encodeEpochGrant(opts) {
398
507
  ['d', roomId],
399
508
  ['p', device],
400
509
  ], opts.expiresAt),
401
- content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.authoritySk, device)),
510
+ content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
402
511
  }, opts.authoritySk);
403
512
  }
404
513
  /** Read the authority's answer to this device's own request. Null for
@@ -420,7 +529,7 @@ export function decodeEpochGrant(event, opts) {
420
529
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
421
530
  if (addressed === undefined || !hexEquals(addressed, device))
422
531
  return null;
423
- const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
532
+ const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
424
533
  if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
425
534
  return null;
426
535
  if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
@@ -439,12 +548,59 @@ export function decodeEpochGrant(event, opts) {
439
548
  const secret = base64urlnopad.decode(body.secret);
440
549
  if (secret.length !== 32)
441
550
  return null;
442
- return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
551
+ const passed = readPassedList(body.passed, body.epoch);
552
+ return { epoch: { epoch: body.epoch, secret }, removed, ...listed, ...(passed ? { passed } : {}) };
443
553
  }
444
554
  catch {
445
555
  return null;
446
556
  }
447
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
+ }
584
+ /**
585
+ * The credential to seal an answer to: the one the request carries, unless
586
+ * the room has seen a newer one for the same device and participant that
587
+ * still verifies. Shared by both desks.
588
+ */
589
+ export function sealCredential(request, presented, credentialFor, roomId, now) {
590
+ let known;
591
+ try {
592
+ known = credentialFor?.(request.device);
593
+ }
594
+ catch {
595
+ known = undefined;
596
+ }
597
+ if (!known)
598
+ return presented;
599
+ const verdict = verifyDeviceCredential(known, { roomId, now });
600
+ if (!verdict.ok || verdict.device !== normaliseHex(request.device) || verdict.participant !== normaliseHex(request.participant))
601
+ return presented;
602
+ return newerCredential(presented, known);
603
+ }
448
604
  /**
449
605
  * Answer epoch requests for as long as the handle is open. What a keeper
450
606
  * runs beside its invitation desk: a member arriving, or returning, after
@@ -483,7 +639,7 @@ export function hostRoomEpoch(opts) {
483
639
  const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
484
640
  if (closed)
485
641
  return;
486
- const request = decodeEpochRequest(event, {
642
+ const read = readEpochRequest(event, {
487
643
  roomId,
488
644
  authoritySk: opts.authoritySk,
489
645
  roomKey: opts.roomKey,
@@ -491,8 +647,9 @@ export function hostRoomEpoch(opts) {
491
647
  policy: opts.policy,
492
648
  legacyParticipants: opts.legacyParticipants,
493
649
  });
494
- if (!request || answered.has(request.request))
650
+ if (!read || answered.has(read.request.request))
495
651
  return;
652
+ const request = read.request;
496
653
  let refused;
497
654
  if (opts.closed?.())
498
655
  refused = 'closed';
@@ -513,19 +670,34 @@ export function hostRoomEpoch(opts) {
513
670
  }
514
671
  let grant;
515
672
  try {
516
- grant = refused
517
- ? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused, expiresAt: opts.expiresAt })
518
- : encodeEpochGrant({
673
+ if (refused) {
674
+ grant = encodeEpochGrant({
519
675
  roomId,
520
676
  authoritySk: opts.authoritySk,
521
677
  device: request.device,
522
678
  request: request.request,
523
679
  now: now(),
524
- epoch: opts.current(),
680
+ refused,
681
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
682
+ expiresAt: opts.expiresAt,
683
+ });
684
+ }
685
+ else {
686
+ const epoch = opts.current();
687
+ grant = encodeEpochGrant({
688
+ roomId,
689
+ authoritySk: opts.authoritySk,
690
+ device: request.device,
691
+ request: request.request,
692
+ now: now(),
693
+ epoch,
525
694
  removed: [...opts.removed()],
526
695
  ...(opts.members ? { members: [...opts.members()] } : {}),
696
+ ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
697
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
527
698
  expiresAt: opts.expiresAt,
528
699
  });
700
+ }
529
701
  }
530
702
  catch {
531
703
  return;
@@ -604,7 +776,14 @@ export function requestRoomEpoch(opts) {
604
776
  settle();
605
777
  };
606
778
  unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
607
- const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
779
+ const grant = decodeEpochGrant(event, {
780
+ roomId,
781
+ authority: opts.authority,
782
+ deviceSk: opts.deviceSk,
783
+ sealSks: opts.sealSks?.(),
784
+ request: request.id,
785
+ now: now(),
786
+ });
608
787
  if (!grant)
609
788
  return;
610
789
  if (grant.refused === 'unknown') {
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof
7
7
  export type { RelayTransport } from './transport.js';
8
8
  export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
9
9
  export type { CreateCredentialOptions, VerifyResult } from './credential.js';
10
+ export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
11
+ export type { SealKey } from './seal.js';
10
12
  export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
11
13
  export type { ScopedKeys } from './scoped.js';
12
14
  export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
@@ -24,8 +26,8 @@ export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } fr
24
26
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
25
27
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
26
28
  export type { RoomLink } from './link.js';
27
- export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
28
- export type { RoomEpoch, EpochKeys, EncodeRekeyOptions, RekeyNotice, PeekRekeyOptions, DecodeRekeyOptions, EpochRequestAdmissionOptions, EncodeEpochRequestOptions, DecodeEpochRequestOptions, EpochRequest, EpochRefusal, EncodeEpochGrantOptions, DecodeEpochGrantOptions, EpochGrant, HostRoomEpochOptions, RequestRoomEpochOptions, SignAdminsOptions, SignChannelsOptions, VerifyChannelsOptions, VerifyAdminsOptions, } from './epoch.js';
29
+ 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';
29
31
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
30
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';
31
33
  export type { RekeyEvidence, EncodeMemberEpochRequestOptions, DecodeMemberEpochRequestOptions, MemberEpochRequest, EncodeMemberEpochGrantOptions, DecodeMemberEpochGrantOptions, MemberEpochGrant, HostMemberEpochDeskOptions, MemberEpochRequestOptions, MemberEpochSource, } from './member-epoch.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { verifyEventUncached, boundedEventVerifier } from './verify.js';
5
5
  export { localIdentity } from './identity.js';
6
6
  export { KINDS } from './kinds.js';
7
7
  export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
8
+ export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
8
9
  export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
9
10
  export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
10
11
  export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
@@ -16,7 +17,7 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
16
17
  export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
17
18
  export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
18
19
  export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
19
- export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
20
+ 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';
20
21
  export { EPOCH_COMMIT_PREFIX, epochCommitment, EPOCH_COMMIT_LABELS } from './epoch-commit.js';
21
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';
22
23
  export { deriveChannel, CHANNEL_ID_INFO, CHANNEL_KEY_INFO, MAX_CHANNEL_NAME_LENGTH, CHANNEL_LABELS } from './channel.js';