@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.
@@ -61,6 +61,8 @@ export interface RekeyEvidence {
61
61
  closed: boolean;
62
62
  /** The epoch commitment, when the authority wrote one. */
63
63
  commit?: string;
64
+ /** The authority's member list, when it wrote one. */
65
+ members?: string[];
64
66
  }
65
67
  /**
66
68
  * Read an authority-signed rekey with the key of the epoch it leaves,
@@ -116,6 +118,9 @@ export interface EncodeMemberEpochGrantOptions {
116
118
  epochs: RoomEpoch[];
117
119
  /** The authority's rekey event for each of `epochs`, in the same order. */
118
120
  rekeys: Event[];
121
+ /** The newest credential the member holds for the device: the grant is
122
+ * sealed to its seal key when it names one (see `seal.ts`). */
123
+ credential?: DeviceCredential;
119
124
  now: number;
120
125
  expiresAt?: number;
121
126
  }
@@ -134,6 +139,9 @@ export interface DecodeMemberEpochGrantOptions {
134
139
  authority: string;
135
140
  /** The asking device's key. */
136
141
  deviceSk: Uint8Array;
142
+ /** The seal key secrets this device holds, newest first, tried before
143
+ * the device key (see `seal.ts`). */
144
+ sealSks?: readonly Uint8Array[];
137
145
  /** The ids of this device's own outstanding member requests. */
138
146
  requests: ReadonlySet<string>;
139
147
  /** Where this device is: the chain starts from this epoch's key. */
@@ -161,6 +169,8 @@ export interface MemberEpochGrant {
161
169
  * grant one epoch ahead. Kept, they let the requester read what was said
162
170
  * in the epochs it skipped, and hand them on from its own member desk. */
163
171
  passed: RoomEpoch[];
172
+ /** The newest member list in the chain, when any rekey in it had one. */
173
+ members?: string[];
164
174
  }
165
175
  /**
166
176
  * Verify a member's answer to one of this device's member requests. Null
@@ -190,6 +200,25 @@ export interface HostMemberEpochDeskOptions {
190
200
  removed: () => ReadonlySet<string>;
191
201
  /** True once the room has been closed. */
192
202
  closed?: () => boolean;
203
+ /**
204
+ * Whether the room knows this participant: on the authority's latest
205
+ * member list, in the room's current roster, or let in from this device.
206
+ * Consulted only once somebody has been removed; then nobody else is
207
+ * answered (#207). Without it, after a removal, nobody is known.
208
+ */
209
+ known?: (participant: string) => boolean;
210
+ /** The newest credential this device has seen for another device, from
211
+ * the roster: see `HostRoomEpochOptions.credentialFor`. Keep it
212
+ * monotone. */
213
+ credentialFor?: (device: string) => DeviceCredential | undefined;
214
+ /** Somebody the room does not know asked, after a removal. Called once
215
+ * per participant, and again every `reportUnknownEvery` seconds while
216
+ * they keep asking; nothing is published. Letting them in is making `known`
217
+ * say yes, and their next ask is answered. */
218
+ onUnknown?: (request: MemberEpochRequest) => void;
219
+ /** Seconds before a participant still unknown and still asking is
220
+ * reported again. Default 60 (`REPORT_UNKNOWN_EVERY_SECONDS`). */
221
+ reportUnknownEvery?: number;
193
222
  policy?: RoomPolicy;
194
223
  now?: () => number;
195
224
  /** Upper bound of the random wait before answering, so several members do
@@ -212,8 +241,10 @@ export interface HostMemberEpochDeskOptions {
212
241
  * Answer member epoch requests for as long as the handle is open: what any
213
242
  * member at the current epoch runs beside its session. It answers only an
214
243
  * admitted, credentialled device that is not removed, in a room that is not
215
- * closed, and only when it can hand over the whole chain from the
216
- * requester's epoch to its own with the authority's rekeys to prove it.
244
+ * closed, from a participant the room knows once anybody has been removed
245
+ * (see `hostRoomEpoch` for why), and only when it can hand over the whole
246
+ * chain from the requester's epoch to its own with the authority's rekeys
247
+ * to prove it.
217
248
  *
218
249
  * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
219
250
  * dropped if another member's grant to the same device appears meanwhile.
@@ -249,6 +280,9 @@ export interface MemberEpochRequestOptions {
249
280
  now?: () => number;
250
281
  /** How often a fresh request goes out. Default 4000 ms. */
251
282
  retryMs?: number;
283
+ /** The seal key secrets this device holds. Asked on every answer,
284
+ * because a credential may renew while the ask is out. */
285
+ sealSks?: () => readonly Uint8Array[];
252
286
  expiresAt?: number;
253
287
  }
254
288
  /** What `requestRoomEpoch({ members })` takes: a second source of answers
@@ -9,8 +9,9 @@ import { evaluateAccess } from './access.js';
9
9
  import { verifyEventUncached } from './verify.js';
10
10
  import { withExpiration } from './expiration.js';
11
11
  import { epochCommitment } from './epoch-commit.js';
12
+ import { openSealed, sealTo } from './seal.js';
12
13
  import { KINDS } from './kinds.js';
13
- import { MAX_EPOCH, deriveEpoch, epochRequestAdmission, peekRekeyEvent, } from './epoch.js';
14
+ import { MAX_EPOCH, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, sealCredential, } from './epoch.js';
14
15
  /**
15
16
  * Member-to-member epoch catch-up: any current member can bring an
16
17
  * admitted, non-removed member up to date.
@@ -118,6 +119,9 @@ export function readRekeyEvidence(event, opts) {
118
119
  };
119
120
  if (typeof body.commit === 'string' && HEX64.test(body.commit))
120
121
  evidence.commit = normaliseHex(body.commit);
122
+ const members = readMemberList(body.members);
123
+ if (members)
124
+ evidence.members = members;
121
125
  return evidence;
122
126
  }
123
127
  catch {
@@ -150,6 +154,12 @@ export function encodeMemberEpochRequest(opts) {
150
154
  /** Null for anything malformed, stale, from a device that cannot prove
151
155
  * which participant it speaks for, or that cannot prove admission. */
152
156
  export function decodeMemberEpochRequest(event, opts) {
157
+ const read = readMemberEpochRequest(event, opts);
158
+ return read && read.request;
159
+ }
160
+ /** `decodeMemberEpochRequest`, keeping the credential the request was made
161
+ * under: what the desk seals its answer by. */
162
+ function readMemberEpochRequest(event, opts) {
153
163
  try {
154
164
  if (event.kind !== MEMBER_EPOCH_KINDS.REQUEST)
155
165
  return null;
@@ -184,7 +194,10 @@ export function decodeMemberEpochRequest(event, opts) {
184
194
  if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, roomId).admitted)
185
195
  return null;
186
196
  }
187
- return { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have };
197
+ return {
198
+ request: { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have },
199
+ credential: body.credential,
200
+ };
188
201
  }
189
202
  catch {
190
203
  return null;
@@ -236,7 +249,7 @@ export function encodeMemberEpochGrant(opts) {
236
249
  ['d', roomId],
237
250
  ['p', device],
238
251
  ], opts.expiresAt),
239
- content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(signerSk, device)),
252
+ content: sealTo(JSON.stringify(body), signerSk, device, opts.credential),
240
253
  }, signerSk);
241
254
  }
242
255
  /**
@@ -259,7 +272,7 @@ export function decodeMemberEpochGrant(event, opts) {
259
272
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
260
273
  if (addressed === undefined || !hexEquals(addressed, device))
261
274
  return null;
262
- const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
275
+ const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
263
276
  if (body.v !== 1 || typeof body.request !== 'string' || !HEX64.test(body.request))
264
277
  return null;
265
278
  if (![...opts.requests].some((r) => hexEquals(r, body.request)))
@@ -279,6 +292,7 @@ export function decodeMemberEpochGrant(event, opts) {
279
292
  let previous = opts.current;
280
293
  let secret;
281
294
  const passed = [];
295
+ let members;
282
296
  for (let i = 0; i < length; i += 1) {
283
297
  const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
284
298
  if (!evidence)
@@ -289,6 +303,8 @@ export function decodeMemberEpochGrant(event, opts) {
289
303
  return null;
290
304
  for (const p of evidence.removed)
291
305
  removed.add(p);
306
+ if (evidence.members)
307
+ members = evidence.members;
292
308
  const raw = body.secrets[i];
293
309
  if (typeof raw !== 'string')
294
310
  return null;
@@ -308,7 +324,7 @@ export function decodeMemberEpochGrant(event, opts) {
308
324
  if (i < length - 1)
309
325
  passed.push({ epoch: evidence.epoch, secret });
310
326
  }
311
- return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed };
327
+ return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
312
328
  }
313
329
  catch {
314
330
  return null;
@@ -318,8 +334,10 @@ export function decodeMemberEpochGrant(event, opts) {
318
334
  * Answer member epoch requests for as long as the handle is open: what any
319
335
  * member at the current epoch runs beside its session. It answers only an
320
336
  * admitted, credentialled device that is not removed, in a room that is not
321
- * closed, and only when it can hand over the whole chain from the
322
- * requester's epoch to its own with the authority's rekeys to prove it.
337
+ * closed, from a participant the room knows once anybody has been removed
338
+ * (see `hostRoomEpoch` for why), and only when it can hand over the whole
339
+ * chain from the requester's epoch to its own with the authority's rekeys
340
+ * to prove it.
323
341
  *
324
342
  * Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
325
343
  * dropped if another member's grant to the same device appears meanwhile.
@@ -344,6 +362,10 @@ export function hostMemberEpochDesk(opts) {
344
362
  const grantSeen = new Set();
345
363
  /** Requesting devices this desk has already stood down for once. */
346
364
  const stoodDown = new Set();
365
+ /** When each unknown participant was last reported. A requester asks
366
+ * afresh every `retryMs` while it waits, so this is by participant, not
367
+ * by request: one "let them in?" a `reportUnknownEvery`, not one a retry. */
368
+ const reported = new Map();
347
369
  /** Ids of the grants this desk published: each is signed by a one-time
348
370
  * key, so its own grants are told apart by id, not by signer. */
349
371
  const mine = new Set();
@@ -386,7 +408,13 @@ export function hostMemberEpochDesk(opts) {
386
408
  }
387
409
  return { epochs, rekeys };
388
410
  };
389
- const answer = (request) => {
411
+ const admissible = (request) => {
412
+ const removed = opts.removed();
413
+ if ([...removed].some((p) => hexEquals(p, request.participant)))
414
+ return false;
415
+ return removed.size === 0 || opts.known?.(request.participant) === true;
416
+ };
417
+ const answer = (request, presented) => {
390
418
  if (closed)
391
419
  return;
392
420
  if (grantSeen.has(request.device) && !stoodDown.has(request.device)) {
@@ -398,7 +426,7 @@ export function hostMemberEpochDesk(opts) {
398
426
  // Asked again, now that the wait is over: the room may have moved.
399
427
  if (opts.closed?.())
400
428
  return;
401
- if ([...opts.removed()].some((p) => hexEquals(p, request.participant)))
429
+ if (!admissible(request))
402
430
  return;
403
431
  const current = opts.current();
404
432
  if (!current || request.have >= current.epoch)
@@ -414,6 +442,7 @@ export function hostMemberEpochDesk(opts) {
414
442
  request: request.request,
415
443
  epochs: chain.epochs,
416
444
  rekeys: chain.rekeys,
445
+ credential: sealCredential(request, presented, opts.credentialFor, roomId, now()),
417
446
  now: now(),
418
447
  expiresAt: opts.expiresAt,
419
448
  });
@@ -440,31 +469,48 @@ export function hostMemberEpochDesk(opts) {
440
469
  const unsubRequests = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.REQUEST], '#d': [roomId] }], (event) => {
441
470
  if (closed || hexEquals(event.pubkey, self))
442
471
  return;
443
- const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
444
- if (!request || answered.has(request.request))
472
+ const read = readMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
473
+ if (!read || answered.has(read.request.request))
445
474
  return;
446
- answered.add(request.request);
447
- bound(answered);
475
+ const { request, credential } = read;
448
476
  if (opts.closed?.()) {
477
+ answered.add(request.request);
478
+ bound(answered);
449
479
  opts.onRefused?.(request, 'closed');
450
480
  return;
451
481
  }
452
482
  if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
483
+ answered.add(request.request);
484
+ bound(answered);
453
485
  opts.onRefused?.(request, 'removed');
454
486
  return;
455
487
  }
488
+ if (!admissible(request)) {
489
+ const last = reported.get(request.participant);
490
+ if (last !== undefined && now() - last < (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS))
491
+ return;
492
+ reported.set(request.participant, now());
493
+ if (reported.size > 256)
494
+ reported.delete(reported.keys().next().value);
495
+ opts.onUnknown?.(request);
496
+ opts.onRefused?.(request, 'unknown');
497
+ return;
498
+ }
499
+ reported.delete(request.participant);
500
+ answered.add(request.request);
501
+ bound(answered);
456
502
  const current = opts.current();
457
503
  if (!current || request.have >= current.epoch)
458
504
  return;
459
505
  grantSeen.delete(request.device);
460
506
  const delay = jitterMs === 0 ? 0 : Math.floor(random() * jitterMs);
461
507
  if (delay === 0) {
462
- answer(request);
508
+ answer(request, credential);
463
509
  return;
464
510
  }
465
511
  const timer = setTimeout(() => {
466
512
  timers.delete(timer);
467
- answer(request);
513
+ answer(request, credential);
468
514
  }, delay);
469
515
  timer.unref?.();
470
516
  timers.add(timer);
@@ -555,6 +601,7 @@ export function memberEpochSource(opts) {
555
601
  roomId,
556
602
  authority,
557
603
  deviceSk: opts.deviceSk,
604
+ sealSks: opts.sealSks?.(),
558
605
  requests,
559
606
  current: current(),
560
607
  participant,
package/dist/seal.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { DeviceCredential } from './types.js';
2
+ /**
3
+ * Seal keys: how a device heals after its key is copied.
4
+ *
5
+ * A device key signs, and until now every rekey copy and every epoch grant
6
+ * was also sealed to it. Whoever copied it opened every one after, until
7
+ * somebody noticed and removed the device. Rotating a receiving key signed
8
+ * by the device key would heal nothing: the thief holds that key and signs
9
+ * their own rotation.
10
+ *
11
+ * So the receiving key rides in the device credential (kind 20460), which
12
+ * the participant signs. A device mints a fresh seal key at every renewal,
13
+ * and its participant's signer, not the device, binds it. Rekeys and grants
14
+ * are sealed to the seal key of the newest credential the sender holds for
15
+ * the device. A thief who copied the device once holds the seal keys minted
16
+ * up to then, and none minted after: once the copied credential has lapsed
17
+ * and the room has rekeyed, they are out.
18
+ *
19
+ * A device keeps its seal secrets as it keeps its device key, to open what
20
+ * was sealed to them when it reads the room again. Healing needs only that
21
+ * each new one is fresh; retiring old ones is a question of forward
22
+ * secrecy, not of healing.
23
+ *
24
+ * A credential with no `seal` tag is sealed to as before, to the device
25
+ * key, so a client that knows nothing of this keeps working, unhealed. See
26
+ * docs/seal-key.md.
27
+ */
28
+ /** The credential tag that names a device's seal key. */
29
+ export declare const SEAL_TAG = "seal";
30
+ /** True for an x-only secp256k1 public key: 32-byte hex that lifts to a
31
+ * point on the curve, so NIP-44 can agree a key with it. */
32
+ export declare function isSealPubkey(value: unknown): value is string;
33
+ export interface SealKey {
34
+ secretKey: Uint8Array;
35
+ /** x-only, lower-case hex: what `createDeviceCredential({ seal })` takes. */
36
+ pubkey: string;
37
+ }
38
+ /** A fresh seal key, for the next credential this device asks for. */
39
+ export declare function generateSealKey(): SealKey;
40
+ /**
41
+ * The seal key a credential names. Undefined when it names none, and null
42
+ * when its `seal` tags are unusable: more than one, or one that is not a
43
+ * point on the curve. Only the participant could have signed either, so
44
+ * nothing here is an attack, but a sender must not throw on it mid-rekey:
45
+ * it treats null as none.
46
+ */
47
+ export declare function credentialSeal(credential: DeviceCredential): string | undefined | null;
48
+ /**
49
+ * Where to seal something for `device`: the seal key `credential` names, or
50
+ * the device key when it names none, or is not this device's.
51
+ *
52
+ * Expiry is deliberately not checked. A device that has gone quiet for
53
+ * longer than its credential kept that seal key, and opens the copy when it
54
+ * is back. Falling back to the device key instead would hand that copy to
55
+ * anybody holding a stolen device key, which is the one person healing
56
+ * exists to shut out.
57
+ */
58
+ export declare function sealTarget(device: string, credential?: DeviceCredential): string;
59
+ /** Seal `plaintext` from `senderSk` to `device`, by `sealTarget`. */
60
+ export declare function sealTo(plaintext: string, senderSk: Uint8Array, device: string, credential?: DeviceCredential): string;
61
+ /**
62
+ * Open something sealed to this device by `sender`: under each seal key in
63
+ * turn, then under the device key. Throws when none of them opens it.
64
+ * NIP-44's MAC verifies under the right key only, so a wrong one fails
65
+ * rather than producing garbage.
66
+ */
67
+ export declare function openSealed(ciphertext: string, sender: string, deviceSk: Uint8Array, sealSks?: readonly Uint8Array[]): string;
68
+ /**
69
+ * The newer of two credentials: the later expiry, then the later
70
+ * `created_at`. Expiry first because some remote signers restamp
71
+ * `created_at` with their own clock (kithmoot#205), while the expiry is the
72
+ * device's own request, inside the signature either way. Ties keep `a`.
73
+ *
74
+ * Neither is checked here; compare only credentials already verified for
75
+ * the same device.
76
+ */
77
+ export declare function newerCredential(a: DeviceCredential, b: DeviceCredential): DeviceCredential;
package/dist/seal.js ADDED
@@ -0,0 +1,128 @@
1
+ import { schnorr } from '@noble/curves/secp256k1.js';
2
+ import { nip44 } from 'nostr-tools';
3
+ import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
4
+ import { hexEquals, normaliseHex } from './hex.js';
5
+ /**
6
+ * Seal keys: how a device heals after its key is copied.
7
+ *
8
+ * A device key signs, and until now every rekey copy and every epoch grant
9
+ * was also sealed to it. Whoever copied it opened every one after, until
10
+ * somebody noticed and removed the device. Rotating a receiving key signed
11
+ * by the device key would heal nothing: the thief holds that key and signs
12
+ * their own rotation.
13
+ *
14
+ * So the receiving key rides in the device credential (kind 20460), which
15
+ * the participant signs. A device mints a fresh seal key at every renewal,
16
+ * and its participant's signer, not the device, binds it. Rekeys and grants
17
+ * are sealed to the seal key of the newest credential the sender holds for
18
+ * the device. A thief who copied the device once holds the seal keys minted
19
+ * up to then, and none minted after: once the copied credential has lapsed
20
+ * and the room has rekeyed, they are out.
21
+ *
22
+ * A device keeps its seal secrets as it keeps its device key, to open what
23
+ * was sealed to them when it reads the room again. Healing needs only that
24
+ * each new one is fresh; retiring old ones is a question of forward
25
+ * secrecy, not of healing.
26
+ *
27
+ * A credential with no `seal` tag is sealed to as before, to the device
28
+ * key, so a client that knows nothing of this keeps working, unhealed. See
29
+ * docs/seal-key.md.
30
+ */
31
+ /** The credential tag that names a device's seal key. */
32
+ export const SEAL_TAG = 'seal';
33
+ const HEX64 = /^[0-9a-f]{64}$/i;
34
+ /** True for an x-only secp256k1 public key: 32-byte hex that lifts to a
35
+ * point on the curve, so NIP-44 can agree a key with it. */
36
+ export function isSealPubkey(value) {
37
+ if (typeof value !== 'string' || !HEX64.test(value))
38
+ return false;
39
+ try {
40
+ schnorr.utils.lift_x(BigInt(`0x${value}`));
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** A fresh seal key, for the next credential this device asks for. */
48
+ export function generateSealKey() {
49
+ const secretKey = generateSecretKey();
50
+ return { secretKey, pubkey: getPublicKey(secretKey) };
51
+ }
52
+ /**
53
+ * The seal key a credential names. Undefined when it names none, and null
54
+ * when its `seal` tags are unusable: more than one, or one that is not a
55
+ * point on the curve. Only the participant could have signed either, so
56
+ * nothing here is an attack, but a sender must not throw on it mid-rekey:
57
+ * it treats null as none.
58
+ */
59
+ export function credentialSeal(credential) {
60
+ const tags = credential.tags.filter((t) => t[0] === SEAL_TAG);
61
+ if (tags.length === 0)
62
+ return undefined;
63
+ if (tags.length > 1)
64
+ return null;
65
+ const value = tags[0][1];
66
+ return isSealPubkey(value) ? normaliseHex(value) : null;
67
+ }
68
+ /**
69
+ * Where to seal something for `device`: the seal key `credential` names, or
70
+ * the device key when it names none, or is not this device's.
71
+ *
72
+ * Expiry is deliberately not checked. A device that has gone quiet for
73
+ * longer than its credential kept that seal key, and opens the copy when it
74
+ * is back. Falling back to the device key instead would hand that copy to
75
+ * anybody holding a stolen device key, which is the one person healing
76
+ * exists to shut out.
77
+ */
78
+ export function sealTarget(device, credential) {
79
+ const to = normaliseHex(device);
80
+ if (!credential)
81
+ return to;
82
+ const named = credential.tags.find((t) => t[0] === 'device')?.[1];
83
+ if (named === undefined || !hexEquals(named, to))
84
+ return to;
85
+ return credentialSeal(credential) ?? to;
86
+ }
87
+ /** Seal `plaintext` from `senderSk` to `device`, by `sealTarget`. */
88
+ export function sealTo(plaintext, senderSk, device, credential) {
89
+ return nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(senderSk, sealTarget(device, credential)));
90
+ }
91
+ /**
92
+ * Open something sealed to this device by `sender`: under each seal key in
93
+ * turn, then under the device key. Throws when none of them opens it.
94
+ * NIP-44's MAC verifies under the right key only, so a wrong one fails
95
+ * rather than producing garbage.
96
+ */
97
+ export function openSealed(ciphertext, sender, deviceSk, sealSks = []) {
98
+ for (const sk of sealSks) {
99
+ try {
100
+ return nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(sk, sender));
101
+ }
102
+ catch {
103
+ // Not this one: try the next, then the device key.
104
+ }
105
+ }
106
+ return nip44.v2.decrypt(ciphertext, nip44.v2.utils.getConversationKey(deviceSk, sender));
107
+ }
108
+ function expirationOf(credential) {
109
+ const raw = credential.tags.find((t) => t[0] === 'expiration')?.[1];
110
+ const at = raw === undefined ? NaN : Number(raw);
111
+ return Number.isFinite(at) ? at : -Infinity;
112
+ }
113
+ /**
114
+ * The newer of two credentials: the later expiry, then the later
115
+ * `created_at`. Expiry first because some remote signers restamp
116
+ * `created_at` with their own clock (kithmoot#205), while the expiry is the
117
+ * device's own request, inside the signature either way. Ties keep `a`.
118
+ *
119
+ * Neither is checked here; compare only credentials already verified for
120
+ * the same device.
121
+ */
122
+ export function newerCredential(a, b) {
123
+ const ea = expirationOf(a);
124
+ const eb = expirationOf(b);
125
+ if (eb !== ea)
126
+ return eb > ea ? b : a;
127
+ return b.created_at > a.created_at ? b : a;
128
+ }
package/llms.txt CHANGED
@@ -80,6 +80,8 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
80
80
  - `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
81
81
  - `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
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
+ - 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
+ - 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
83
85
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
84
86
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
85
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.5.1",
3
+ "version": "0.7.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",
@@ -21,6 +21,7 @@
21
21
  "vectors/kithmoot-vectors.json",
22
22
  "vectors/circle-vectors.json",
23
23
  "vectors/member-epoch-vectors.json",
24
+ "vectors/seal-vectors.json",
24
25
  "LICENSE",
25
26
  "README.md",
26
27
  "llms.txt"
@@ -33,6 +34,7 @@
33
34
  "diff-source": "node scripts/diff-source.mjs",
34
35
  "generate-fold": "node scripts/generate-fold.mjs",
35
36
  "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
37
+ "generate-seal": "node scripts/generate-seal.mjs",
36
38
  "bundle-check": "node scripts/bundle-check.mjs",
37
39
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
38
40
  "vectors": "vitest run vectors/",
@@ -136,6 +136,70 @@
136
136
  }
137
137
  }
138
138
  },
139
+ {
140
+ "name": "rekey-with-members",
141
+ "kind": "positive",
142
+ "note": "The rekey to epoch 2 with the authority's member list (`encodeRekeyEvent({ members })`, #207): the participants it knows to be in the room, the removed ones dropped even when passed in, lower-case, deduplicated and sorted. Body key order: v, epoch, removed, commit, members, keys. A desk hands an epoch on, once anybody has been removed, only to a participant the room knows; this list is where a member learns who that is. A list that is not an array of 32-byte hex keys is read as absent, which makes nobody known.",
143
+ "input": {
144
+ "roomId": "76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9",
145
+ "authority": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
146
+ "previousEpoch": 1,
147
+ "previousSecretHex": "cf6c5602daa51ecab29fbcbcef0da517b303cd59ee2904c15e226b7dd00e7752",
148
+ "previousKeyHex": "e038a7fca65628eebee35c562410ac63cce191b776ba0e8e5a1d241d9417f733",
149
+ "next": {
150
+ "epoch": 2,
151
+ "secretHex": "d4f5a0426e6880797fa29d914bb6977d193dfcd4f46b2a3acc576226eeec3ac9"
152
+ },
153
+ "recipients": [
154
+ "ef4396c842882c47ad1106d846d5c7e712c8928b69c92bc1798fa63deb9dd76b"
155
+ ],
156
+ "removed": [
157
+ "298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
158
+ ],
159
+ "members": [
160
+ "588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53",
161
+ "298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
162
+ ],
163
+ "authoritySkHex": "825cf62829b0262bc5b46870d5cc054d6049e54a68cf1ea393c3da56226c456b",
164
+ "createdAt": 1799999002,
165
+ "event": {
166
+ "id": "d8d5c810c2e22d87493d8a10f908e38e68b5e4611ad5a230103ca01d5ba7a954",
167
+ "pubkey": "a5098de007349f8c12d6a854dfc069074b803470f59484697c50c5211b13c810",
168
+ "created_at": 1799999002,
169
+ "kind": 1462,
170
+ "tags": [
171
+ [
172
+ "d",
173
+ "76c7081ef2322b7619d5cb5e690779fb942d63964f0b819e3d852db6c6a264f9"
174
+ ],
175
+ [
176
+ "epoch",
177
+ "2"
178
+ ]
179
+ ],
180
+ "content": "AjCp1V9xfa32TYx5FAmMp1toqAM2yKrdBWdygJs+bFNGNIls48ZUufb0m3Cs51qusvmVJF51AEvXiW82LyhO6o/2J9Sra65b80+cyj8Km+LrLCXx6qyLPIYjKdISORDS6UhDKXa9UInbnZnGf87KKruDujB82QKDo26ImrXPbzy246dYMCvon7dYLGa+y3Qk9u5OLVKSrYVhDhFRHZX2XMltl478SRQ7GuvzbibZbFrNz1EvWSJOqbxf8YLpvYzbq55xmr9CA1rjk9vBKTzaHGX23ZvPPdaQFHh53YXCzYGN4+p54P/gH9PgZREZt+5Jel5kDhVfk8YMinaSzaBQ7MvEUEEIGDUPc6xbmZ3twlam+y/4H0vuugQtOm85tU7sozohPj65hlpaxRmTdenWivldIAWyPAEsUjOtGcvnFur5Hbg9H+WEsyXAX6tLZZV3jNFcCPLkoWB9O3iFasN3m2P0larhRTr9lhReqzt3UxtGBp14rErj0uZEZlwrz4n3ZiFgH/qc1X/pFlskh4dQJV/PGCEe6F7v7fd4aChiYHA1ghq2cy4fKW44+FU2EKPEezWcD96sOshiCoh0xWxHJoNbWhKJ5P8EcyK7TCH9UJVk9OyAiGVYnnxh+cuDgVfCOdRR6VbxWopqclvhlV+NGBullxSv5aQJSj6fS5O+QxSCwZrR8DA2SMKRSFU+kXHQxOgEvZW1cPdHWMtUr1dwsRrMd3ayNyZqSIjjyktqzsmrD+Pl6Ikk5tbE3kyNYdWHX7Mz",
181
+ "sig": "90c5666a5ca4c5d4c1a0e4b3b4422aa7462d4300986f82afff435a88edb48a41e3191192c6404b17401712204de56ba2269a6b4376f0ef224ad9798728550710"
182
+ },
183
+ "randomHex": [
184
+ "ff9c267263a44961a1d49a09c831ef35fa2d53e4d4cfc572d72f506478e61045",
185
+ "30a9d55f717dadf64d8c7914098ca75b68a80336c8aadd056772809b3e6c5346",
186
+ "11ae6ba1ae1e25f1493588462e919538089a63c28e68b7f5734bd0df2ba395e3"
187
+ ]
188
+ },
189
+ "output": {
190
+ "evidence": {
191
+ "epoch": 2,
192
+ "removed": [
193
+ "298cf780bc6d1b93c4900b20c89a911286ee3488ab94f543d96d567c70eb9ed2"
194
+ ],
195
+ "closed": false,
196
+ "commit": "3472885df839fc5d48d809476e28e36c458c3e4717e35c2ec0d69bbbdf94f896",
197
+ "members": [
198
+ "588c59130ea8b9d81e836d0c5a89daa6b888923357c1415359b48595b4937f53"
199
+ ]
200
+ }
201
+ }
202
+ },
139
203
  {
140
204
  "name": "member-request",
141
205
  "kind": "positive",