@forgesworn/fold-kit 0.5.1 → 0.7.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
@@ -154,6 +154,20 @@ the rest).
154
154
  `encodeMemberEpochRequest`, `decodeMemberEpochRequest`,
155
155
  `encodeMemberEpochGrant`, `decodeMemberEpochGrant`, `readRekeyEvidence`,
156
156
  `deriveMemberEpochRequestKey`. Vectors: `vectors/member-epoch-vectors.json`
157
+ - The known-members gate (#207, `docs/member-epoch-catch-up.md`): once a
158
+ room has removed anybody, both desks grant only to participants the room
159
+ knows (`known`), and send anybody else to approval (`onUnknown`; the
160
+ authority answers `refused: 'unknown'`, which `requestRoomEpoch` waits
161
+ through). `encodeRekeyEvent({ members })` and `hostRoomEpoch({ members })`
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`
157
171
  - `canonicalAdmins`, `signAdmins`, `verifyAdmins` - the authority's signed
158
172
  admin list
159
173
  - `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
@@ -73,6 +73,10 @@ export interface EpochKeys {
73
73
  /** The cipher key. The room key in epoch 0. */
74
74
  key: Uint8Array;
75
75
  }
76
+ /** A participant list as a body carries it: lower-case, deduplicated and
77
+ * sorted, or undefined when the field is absent or is not a list of keys.
78
+ * Undefined is the safe reading, since it makes nobody known. */
79
+ export declare function readMemberList(raw: unknown): string[] | undefined;
76
80
  /** A fresh secret for the next epoch. */
77
81
  export declare function generateEpochSecret(): Uint8Array;
78
82
  /**
@@ -94,8 +98,11 @@ export interface EncodeRekeyOptions {
94
98
  /** The epoch being entered. Must be `current.epoch + 1`. */
95
99
  next: RoomEpoch;
96
100
  /** Devices to seal the new secret for. Everybody still in the room,
97
- * except the removed participants' devices. */
98
- 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>;
99
106
  /** Participants removed at this step. Empty on a rekey that only turns
100
107
  * the key over. */
101
108
  removed: string[];
@@ -108,11 +115,22 @@ export interface EncodeRekeyOptions {
108
115
  * bring a device that missed this rekey up to date (see
109
116
  * `member-epoch.ts`). Omitted, the event is byte-identical to before. */
110
117
  commit?: boolean;
118
+ /** Every participant the authority knows to be in the room, not only the
119
+ * ones present now: a member who is offline as the room rekeys is still
120
+ * a member. Removed participants are dropped. Omitted, the event is
121
+ * byte-identical to before. */
122
+ members?: string[];
111
123
  now: number;
112
124
  /** A conference room's end, in unix seconds: the event carries it as a
113
125
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
114
126
  expiresAt?: number;
115
127
  }
128
+ /** A rekey recipient: a device, and the newest credential the sender holds
129
+ * for it. */
130
+ export interface RekeyRecipient {
131
+ device: string;
132
+ credential?: DeviceCredential;
133
+ }
116
134
  /** Announce the next epoch. See the module comment for what this is. */
117
135
  export declare function encodeRekeyEvent(opts: EncodeRekeyOptions): Event;
118
136
  /** What a rekey says, once read. */
@@ -125,6 +143,9 @@ export interface RekeyNotice {
125
143
  * device that was removed, or that was not in the room when the
126
144
  * authority rekeyed. */
127
145
  secret?: Uint8Array;
146
+ /** The participants the authority listed as in the room. Absent from a
147
+ * rekey written without the list. */
148
+ members?: string[];
128
149
  /** True for a current-state grant, whose removed list is cumulative.
129
150
  * It synchronises this session; it does not describe a new removal. */
130
151
  catchUp?: true;
@@ -148,6 +169,9 @@ export interface DecodeRekeyOptions extends PeekRekeyOptions {
148
169
  current: EpochKeys;
149
170
  /** This device's key, to open its own copy. */
150
171
  deviceSk: Uint8Array;
172
+ /** The seal key secrets this device holds, newest first, tried before
173
+ * the device key (see `seal.ts`). */
174
+ sealSks?: readonly Uint8Array[];
151
175
  }
152
176
  /**
153
177
  * Read a rekey. Null for anything that does not check out, and for a rekey
@@ -225,8 +249,14 @@ export interface EpochRequest {
225
249
  * cannot prove which participant it speaks for in this room, or from one
226
250
  * that cannot prove it was admitted to the room at all. */
227
251
  export declare function decodeEpochRequest(event: Event, opts: DecodeEpochRequestOptions): EpochRequest | null;
228
- /** Why the authority would not hand an epoch over. */
229
- export type EpochRefusal = 'removed' | 'closed';
252
+ /** How often a desk reports the same unknown participant while they keep
253
+ * asking: often enough that a missed "let them in?" comes back, rarely
254
+ * enough that it is not a nag. */
255
+ export declare const REPORT_UNKNOWN_EVERY_SECONDS = 60;
256
+ /** Why the authority would not hand an epoch over. `unknown` is not final:
257
+ * the room has removed somebody, and does not yet know this participant,
258
+ * so it waits for a member to let them in (#207). */
259
+ export type EpochRefusal = 'removed' | 'closed' | 'unknown';
230
260
  export interface EncodeEpochGrantOptions {
231
261
  roomId: string;
232
262
  authoritySk: Uint8Array;
@@ -236,7 +266,13 @@ export interface EncodeEpochGrantOptions {
236
266
  /** The current epoch. Omitted when refusing. */
237
267
  epoch?: RoomEpoch;
238
268
  removed?: string[];
269
+ /** The participants the authority knows, so the requester's own member
270
+ * desk knows them too. Omitted, the body is as before. */
271
+ members?: string[];
239
272
  refused?: EpochRefusal;
273
+ /** The newest credential the authority holds for the device: the grant
274
+ * is sealed to its seal key when it names one (see `seal.ts`). */
275
+ credential?: DeviceCredential;
240
276
  /** A conference room's end, in unix seconds: the event carries it as a
241
277
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
242
278
  expiresAt?: number;
@@ -248,6 +284,9 @@ export interface DecodeEpochGrantOptions {
248
284
  roomId: string;
249
285
  authority: string;
250
286
  deviceSk: Uint8Array;
287
+ /** The seal key secrets this device holds: see
288
+ * `DecodeRekeyOptions.sealSks`. */
289
+ sealSks?: readonly Uint8Array[];
251
290
  request: string;
252
291
  now: number;
253
292
  maxAgeSeconds?: number;
@@ -261,6 +300,8 @@ export type EpochGrant = {
261
300
  * the epochs it carried between the requester's and `epoch`. See
262
301
  * `MemberEpochGrant.passed`. The authority's own answer has none. */
263
302
  passed?: RoomEpoch[];
303
+ /** The participants the room knows, when the answer carried them. */
304
+ members?: string[];
264
305
  refused?: undefined;
265
306
  } | {
266
307
  refused: EpochRefusal;
@@ -281,6 +322,33 @@ export interface HostRoomEpochOptions {
281
322
  removed: () => ReadonlySet<string>;
282
323
  /** True once the room has been closed: every request is refused. */
283
324
  closed?: () => boolean;
325
+ /**
326
+ * Whether the room knows this participant: on the authority's member
327
+ * list, in the room now, or let in. Consulted only once somebody has been
328
+ * removed, and then everybody else is answered `unknown` (#207). Without
329
+ * it, after a removal, nobody is known. Asked on every request.
330
+ */
331
+ known?: (participant: string) => boolean;
332
+ /** The participants to tell a granted device about, so its own member
333
+ * desk knows them too. Asked on every grant. */
334
+ members?: () => readonly string[];
335
+ /**
336
+ * The newest credential the authority has seen for a device, from the
337
+ * roster. An answer is sealed to the newer of this and the one the
338
+ * request carries, so a thief replaying a device's older, still-live
339
+ * credential is answered under the seal key the device minted since.
340
+ * Keep it monotone: a roster entry carrying an older credential than one
341
+ * already seen must not replace it, or the thief can publish one.
342
+ */
343
+ credentialFor?: (device: string) => DeviceCredential | undefined;
344
+ /** Somebody the room does not know asked, after a removal: what an app
345
+ * turns into "let them in?". Called once per participant, and again
346
+ * every `reportUnknownEvery` seconds while they keep asking. Letting them in
347
+ * is making `known` say yes; the requester's next ask is then granted. */
348
+ onUnknown?: (request: EpochRequest) => void;
349
+ /** Seconds before a participant still unknown and still asking is
350
+ * reported again. Default 60. */
351
+ reportUnknownEvery?: number;
284
352
  policy?: RoomPolicy;
285
353
  legacyParticipants?: ReadonlySet<string>;
286
354
  now?: () => number;
@@ -289,6 +357,15 @@ export interface HostRoomEpochOptions {
289
357
  /** A conference room's end: every grant carries it as an expiration. */
290
358
  expiresAt?: number;
291
359
  }
360
+ /**
361
+ * The credential to seal an answer to: the one the request carries, unless
362
+ * the room has seen a newer one for the same device and participant that
363
+ * still verifies. Shared by both desks.
364
+ */
365
+ export declare function sealCredential(request: {
366
+ device: string;
367
+ participant: string;
368
+ }, presented: DeviceCredential, credentialFor: ((device: string) => DeviceCredential | undefined) | undefined, roomId: string, now: number): DeviceCredential;
292
369
  /**
293
370
  * Answer epoch requests for as long as the handle is open. What a keeper
294
371
  * runs beside its invitation desk: a member arriving, or returning, after
@@ -298,6 +375,13 @@ export interface HostRoomEpochOptions {
298
375
  * *secret*, which opens epoch 0 and nothing after it. A request with no
299
376
  * admission proof, or one made under some other key, is not answered at
300
377
  * all: a stranger learns nothing, not even that a desk is here.
378
+ *
379
+ * The admission proof is made under the epoch-0 room key, which a removed
380
+ * member still holds, so removal by participant alone is undone by a fresh
381
+ * key (#207). Once anyone has been removed the desk therefore grants only
382
+ * to participants the room knows (`known`), and answers anybody else
383
+ * `unknown` until a member lets them in. A room that has never removed
384
+ * anybody is answered exactly as before.
301
385
  */
302
386
  export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
303
387
  close(): void;
@@ -319,6 +403,12 @@ export interface RequestRoomEpochOptions {
319
403
  /** Ask the room's current members too (`memberEpochSource`): the first
320
404
  * answer that checks out, the authority's or a member's, settles it. */
321
405
  members?: MemberEpochSource;
406
+ /** The authority said the room does not know this participant yet. Not
407
+ * final: the ask goes on, and a member letting them in settles it. */
408
+ onUnknown?: () => void;
409
+ /** The seal key secrets this device holds. Asked on every answer,
410
+ * because a credential may renew while the ask is out. */
411
+ sealSks?: () => readonly Uint8Array[];
322
412
  }
323
413
  /** Thrown when the authority answered, and the answer was no. */
324
414
  export declare class EpochRefusedError extends Error {
@@ -327,7 +417,8 @@ export declare class EpochRefusedError extends Error {
327
417
  }
328
418
  /** Ask the authority where the room is, and wait for the answer. Rejects
329
419
  * with `EpochRefusedError` on a refusal, and with a plain error when
330
- * nobody answers inside the timeout. */
420
+ * nobody answers inside the timeout. `unknown` is not final: the ask goes
421
+ * on, and only a timeout after it rejects, with `EpochRefusedError`. */
331
422
  export declare function requestRoomEpoch(opts: RequestRoomEpochOptions): Promise<Exclude<EpochGrant, {
332
423
  refused: EpochRefusal;
333
424
  }>>;
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
  /**
@@ -80,6 +81,14 @@ function require32(bytes, what) {
80
81
  if (bytes.length !== 32)
81
82
  throw new Error(`${what} must be 32 bytes`);
82
83
  }
84
+ /** A participant list as a body carries it: lower-case, deduplicated and
85
+ * sorted, or undefined when the field is absent or is not a list of keys.
86
+ * Undefined is the safe reading, since it makes nobody known. */
87
+ export function readMemberList(raw) {
88
+ if (!Array.isArray(raw) || !raw.every((p) => typeof p === 'string' && HEX64.test(p)))
89
+ return undefined;
90
+ return [...new Set(raw.map(normaliseHex))].sort();
91
+ }
83
92
  function requireHex32(value, what) {
84
93
  if (!HEX64.test(value))
85
94
  throw new Error(`${what} must be 32-byte hex`);
@@ -119,6 +128,12 @@ export function deriveEpoch(epoch) {
119
128
  key: hkdf(sha256, epoch.secret, undefined, `${EPOCH_KEY_INFO}/${n}`, 32),
120
129
  };
121
130
  }
131
+ function memberListOf(members, removed) {
132
+ const out = new Set(members.map((p) => requireHex32(p, 'member participant')));
133
+ for (const p of removed)
134
+ out.delete(normaliseHex(p));
135
+ return [...out].sort();
136
+ }
122
137
  /** Announce the next epoch. See the module comment for what this is. */
123
138
  export function encodeRekeyEvent(opts) {
124
139
  require32(opts.authoritySk, 'authority secret key');
@@ -132,8 +147,9 @@ export function encodeRekeyEvent(opts) {
132
147
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
133
148
  const plaintext = JSON.stringify(sealed);
134
149
  for (const raw of opts.closed ? [] : opts.recipients) {
135
- const device = requireHex32(raw, 'recipient device');
136
- keys[device] = nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(opts.authoritySk, device));
150
+ const recipient = typeof raw === 'string' ? { device: raw } : raw;
151
+ const device = requireHex32(recipient.device, 'recipient device');
152
+ keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
137
153
  }
138
154
  const body = {
139
155
  v: 1,
@@ -142,6 +158,7 @@ export function encodeRekeyEvent(opts) {
142
158
  ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
143
159
  ...(opts.closed ? { closed: true } : {}),
144
160
  ...(opts.commit ? { commit: epochCommitment(roomId, epoch, opts.next.secret) } : {}),
161
+ ...(opts.members !== undefined ? { members: memberListOf(opts.members, removed) } : {}),
145
162
  keys,
146
163
  };
147
164
  return finalizeEvent({
@@ -206,14 +223,26 @@ export function decodeRekeyEvent(event, opts) {
206
223
  };
207
224
  if (typeof body.by === 'string' && HEX64.test(body.by))
208
225
  notice.by = normaliseHex(body.by);
226
+ const members = readMemberList(body.members);
227
+ if (members)
228
+ notice.members = members;
209
229
  const device = getPublicKey(opts.deviceSk);
210
230
  const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
211
231
  if (typeof mine === 'string') {
212
- const sealed = JSON.parse(nip44.v2.decrypt(mine, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
213
- if (sealed.v === 1 && typeof sealed.secret === 'string') {
214
- const secret = base64urlnopad.decode(sealed.secret);
215
- if (secret.length === 32)
216
- notice.secret = secret;
232
+ // A copy none of this device's keys opens is no copy: sealed to a
233
+ // seal key it has since erased, or one it never had. The rest of the
234
+ // rekey still says who was removed, so it is read without the secret,
235
+ // and the device asks for the epoch.
236
+ try {
237
+ const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
238
+ if (sealed.v === 1 && typeof sealed.secret === 'string') {
239
+ const secret = base64urlnopad.decode(sealed.secret);
240
+ if (secret.length === 32)
241
+ notice.secret = secret;
242
+ }
243
+ }
244
+ catch {
245
+ // No secret, as above.
217
246
  }
218
247
  }
219
248
  return notice;
@@ -296,6 +325,12 @@ export function encodeEpochRequest(opts) {
296
325
  * cannot prove which participant it speaks for in this room, or from one
297
326
  * that cannot prove it was admitted to the room at all. */
298
327
  export function decodeEpochRequest(event, opts) {
328
+ const read = readEpochRequest(event, opts);
329
+ return read && read.request;
330
+ }
331
+ /** `decodeEpochRequest`, keeping the credential the request was made
332
+ * under: what the desk seals its answer by. */
333
+ function readEpochRequest(event, opts) {
299
334
  try {
300
335
  if (event.kind !== KINDS.EPOCH_REQUEST)
301
336
  return null;
@@ -340,12 +375,16 @@ export function decodeEpochRequest(event, opts) {
340
375
  if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, opts.roomId).admitted)
341
376
  return null;
342
377
  }
343
- return { device: verdict.device, participant: verdict.participant, request: event.id };
378
+ return { request: { device: verdict.device, participant: verdict.participant, request: event.id }, credential: body.credential };
344
379
  }
345
380
  catch {
346
381
  return null;
347
382
  }
348
383
  }
384
+ /** How often a desk reports the same unknown participant while they keep
385
+ * asking: often enough that a missed "let them in?" comes back, rarely
386
+ * enough that it is not a nag. */
387
+ export const REPORT_UNKNOWN_EVERY_SECONDS = 60;
349
388
  /** Answer one request: the current epoch sealed to the asking device, or a
350
389
  * refusal it alone can read. */
351
390
  export function encodeEpochGrant(opts) {
@@ -366,6 +405,8 @@ export function encodeEpochGrant(opts) {
366
405
  body.secret = base64urlnopad.encode(opts.epoch.secret);
367
406
  }
368
407
  body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
408
+ if (opts.members !== undefined)
409
+ body.members = memberListOf(opts.members, body.removed);
369
410
  }
370
411
  return finalizeEvent({
371
412
  kind: KINDS.EPOCH_GRANT,
@@ -374,7 +415,7 @@ export function encodeEpochGrant(opts) {
374
415
  ['d', roomId],
375
416
  ['p', device],
376
417
  ], opts.expiresAt),
377
- content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.authoritySk, device)),
418
+ content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
378
419
  }, opts.authoritySk);
379
420
  }
380
421
  /** Read the authority's answer to this device's own request. Null for
@@ -396,29 +437,51 @@ export function decodeEpochGrant(event, opts) {
396
437
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
397
438
  if (addressed === undefined || !hexEquals(addressed, device))
398
439
  return null;
399
- const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
440
+ const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
400
441
  if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
401
442
  return null;
402
- if (body.refused === 'removed' || body.refused === 'closed')
443
+ if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
403
444
  return { refused: body.refused };
404
445
  if (!Number.isSafeInteger(body.epoch) || body.epoch < 0 || body.epoch > MAX_EPOCH)
405
446
  return null;
406
447
  const removed = Array.isArray(body.removed)
407
448
  ? [...new Set(body.removed.filter((p) => typeof p === 'string' && HEX64.test(p)).map(normaliseHex))].sort()
408
449
  : [];
450
+ const members = readMemberList(body.members);
451
+ const listed = members ? { members } : {};
409
452
  if (body.epoch === 0)
410
- return { epoch: { epoch: 0 }, removed };
453
+ return { epoch: { epoch: 0 }, removed, ...listed };
411
454
  if (typeof body.secret !== 'string')
412
455
  return null;
413
456
  const secret = base64urlnopad.decode(body.secret);
414
457
  if (secret.length !== 32)
415
458
  return null;
416
- return { epoch: { epoch: body.epoch, secret }, removed };
459
+ return { epoch: { epoch: body.epoch, secret }, removed, ...listed };
417
460
  }
418
461
  catch {
419
462
  return null;
420
463
  }
421
464
  }
465
+ /**
466
+ * The credential to seal an answer to: the one the request carries, unless
467
+ * the room has seen a newer one for the same device and participant that
468
+ * still verifies. Shared by both desks.
469
+ */
470
+ export function sealCredential(request, presented, credentialFor, roomId, now) {
471
+ let known;
472
+ try {
473
+ known = credentialFor?.(request.device);
474
+ }
475
+ catch {
476
+ known = undefined;
477
+ }
478
+ if (!known)
479
+ return presented;
480
+ const verdict = verifyDeviceCredential(known, { roomId, now });
481
+ if (!verdict.ok || verdict.device !== normaliseHex(request.device) || verdict.participant !== normaliseHex(request.participant))
482
+ return presented;
483
+ return newerCredential(presented, known);
484
+ }
422
485
  /**
423
486
  * Answer epoch requests for as long as the handle is open. What a keeper
424
487
  * runs beside its invitation desk: a member arriving, or returning, after
@@ -428,6 +491,13 @@ export function decodeEpochGrant(event, opts) {
428
491
  * *secret*, which opens epoch 0 and nothing after it. A request with no
429
492
  * admission proof, or one made under some other key, is not answered at
430
493
  * all: a stranger learns nothing, not even that a desk is here.
494
+ *
495
+ * The admission proof is made under the epoch-0 room key, which a removed
496
+ * member still holds, so removal by participant alone is undone by a fresh
497
+ * key (#207). Once anyone has been removed the desk therefore grants only
498
+ * to participants the room knows (`known`), and answers anybody else
499
+ * `unknown` until a member lets them in. A room that has never removed
500
+ * anybody is answered exactly as before.
431
501
  */
432
502
  export function hostRoomEpoch(opts) {
433
503
  require32(opts.authoritySk, 'authority secret key');
@@ -436,11 +506,21 @@ export function hostRoomEpoch(opts) {
436
506
  const authority = getPublicKey(opts.authoritySk);
437
507
  const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
438
508
  const answered = new Set();
509
+ /** Requests answered `unknown`, which the requester sends again while it
510
+ * waits: answered again only once they are known. */
511
+ const waiting = new Set();
512
+ /** When each unknown participant was last reported through `onUnknown`,
513
+ * so one still waiting is reported again only after `reportUnknownEvery`. */
514
+ const reported = new Map();
515
+ const bound = (set) => {
516
+ if (set.size > 256)
517
+ set.delete(set.values().next().value);
518
+ };
439
519
  let closed = false;
440
520
  const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
441
521
  if (closed)
442
522
  return;
443
- const request = decodeEpochRequest(event, {
523
+ const read = readEpochRequest(event, {
444
524
  roomId,
445
525
  authoritySk: opts.authoritySk,
446
526
  roomKey: opts.roomKey,
@@ -448,20 +528,40 @@ export function hostRoomEpoch(opts) {
448
528
  policy: opts.policy,
449
529
  legacyParticipants: opts.legacyParticipants,
450
530
  });
451
- if (!request || answered.has(request.request))
531
+ if (!read || answered.has(read.request.request))
452
532
  return;
453
- answered.add(request.request);
454
- if (answered.size > 256)
455
- answered.delete(answered.values().next().value);
533
+ const request = read.request;
456
534
  let refused;
457
535
  if (opts.closed?.())
458
536
  refused = 'closed';
459
537
  else if (opts.removed().has(request.participant))
460
538
  refused = 'removed';
539
+ else if (opts.removed().size > 0 && !opts.known?.(request.participant))
540
+ refused = 'unknown';
541
+ if (refused === 'unknown') {
542
+ if (waiting.has(request.request))
543
+ return;
544
+ waiting.add(request.request);
545
+ bound(waiting);
546
+ }
547
+ else {
548
+ waiting.delete(request.request);
549
+ answered.add(request.request);
550
+ bound(answered);
551
+ }
461
552
  let grant;
462
553
  try {
463
554
  grant = refused
464
- ? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused, expiresAt: opts.expiresAt })
555
+ ? encodeEpochGrant({
556
+ roomId,
557
+ authoritySk: opts.authoritySk,
558
+ device: request.device,
559
+ request: request.request,
560
+ now: now(),
561
+ refused,
562
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
563
+ expiresAt: opts.expiresAt,
564
+ })
465
565
  : encodeEpochGrant({
466
566
  roomId,
467
567
  authoritySk: opts.authoritySk,
@@ -470,6 +570,8 @@ export function hostRoomEpoch(opts) {
470
570
  now: now(),
471
571
  epoch: opts.current(),
472
572
  removed: [...opts.removed()],
573
+ ...(opts.members ? { members: [...opts.members()] } : {}),
574
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
473
575
  expiresAt: opts.expiresAt,
474
576
  });
475
577
  }
@@ -477,6 +579,14 @@ export function hostRoomEpoch(opts) {
477
579
  return;
478
580
  }
479
581
  opts.transport.publish(grant).catch(() => { });
582
+ if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
583
+ reported.set(request.participant, now());
584
+ if (reported.size > 256)
585
+ reported.delete(reported.keys().next().value);
586
+ opts.onUnknown?.(request);
587
+ }
588
+ if (!refused)
589
+ reported.delete(request.participant);
480
590
  if (refused)
481
591
  opts.onRefused?.(request, refused);
482
592
  else
@@ -495,14 +605,19 @@ export function hostRoomEpoch(opts) {
495
605
  export class EpochRefusedError extends Error {
496
606
  refused;
497
607
  constructor(refused) {
498
- super(refused === 'removed' ? 'you were removed from this room' : 'this room has been closed');
608
+ super(refused === 'removed'
609
+ ? 'you were removed from this room'
610
+ : refused === 'unknown'
611
+ ? 'nobody in this room has let you in yet'
612
+ : 'this room has been closed');
499
613
  this.name = 'EpochRefusedError';
500
614
  this.refused = refused;
501
615
  }
502
616
  }
503
617
  /** Ask the authority where the room is, and wait for the answer. Rejects
504
618
  * with `EpochRefusedError` on a refusal, and with a plain error when
505
- * nobody answers inside the timeout. */
619
+ * nobody answers inside the timeout. `unknown` is not final: the ask goes
620
+ * on, and only a timeout after it rejects, with `EpochRefusedError`. */
506
621
  export function requestRoomEpoch(opts) {
507
622
  const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
508
623
  const roomId = requireHex32(opts.roomId, 'room id');
@@ -523,6 +638,7 @@ export function requestRoomEpoch(opts) {
523
638
  let expiry;
524
639
  let unsub = () => { };
525
640
  let stopMembers = () => { };
641
+ let unknown = false;
526
642
  const finish = (settle) => {
527
643
  if (settled)
528
644
  return;
@@ -536,10 +652,22 @@ export function requestRoomEpoch(opts) {
536
652
  settle();
537
653
  };
538
654
  unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
539
- const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
655
+ const grant = decodeEpochGrant(event, {
656
+ roomId,
657
+ authority: opts.authority,
658
+ deviceSk: opts.deviceSk,
659
+ sealSks: opts.sealSks?.(),
660
+ request: request.id,
661
+ now: now(),
662
+ });
540
663
  if (!grant)
541
664
  return;
542
- if (grant.refused)
665
+ if (grant.refused === 'unknown') {
666
+ if (!unknown)
667
+ opts.onUnknown?.();
668
+ unknown = true;
669
+ }
670
+ else if (grant.refused)
543
671
  finish(() => reject(new EpochRefusedError(grant.refused)));
544
672
  else
545
673
  finish(() => resolve(grant));
@@ -555,7 +683,7 @@ export function requestRoomEpoch(opts) {
555
683
  if (!settled)
556
684
  opts.transport.publish(request).catch(() => { });
557
685
  };
558
- expiry = setTimeout(() => finish(() => reject(new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
686
+ expiry = setTimeout(() => finish(() => reject(unknown ? new EpochRefusedError('unknown') : new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
559
687
  expiry.unref?.();
560
688
  retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
561
689
  retry.unref?.();
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, 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, 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
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, 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, 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';