@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.
@@ -59,6 +59,8 @@ export interface RekeyEvidence {
59
59
  epoch: number;
60
60
  removed: string[];
61
61
  closed: boolean;
62
+ /** A scheduled turn of the key: see `RekeyNotice.scheduled`. */
63
+ scheduled?: true;
62
64
  /** The epoch commitment, when the authority wrote one. */
63
65
  commit?: string;
64
66
  /** The authority's member list, when it wrote one. */
@@ -118,6 +120,9 @@ export interface EncodeMemberEpochGrantOptions {
118
120
  epochs: RoomEpoch[];
119
121
  /** The authority's rekey event for each of `epochs`, in the same order. */
120
122
  rekeys: Event[];
123
+ /** The newest credential the member holds for the device: the grant is
124
+ * sealed to its seal key when it names one (see `seal.ts`). */
125
+ credential?: DeviceCredential;
121
126
  now: number;
122
127
  expiresAt?: number;
123
128
  }
@@ -136,6 +141,9 @@ export interface DecodeMemberEpochGrantOptions {
136
141
  authority: string;
137
142
  /** The asking device's key. */
138
143
  deviceSk: Uint8Array;
144
+ /** The seal key secrets this device holds, newest first, tried before
145
+ * the device key (see `seal.ts`). */
146
+ sealSks?: readonly Uint8Array[];
139
147
  /** The ids of this device's own outstanding member requests. */
140
148
  requests: ReadonlySet<string>;
141
149
  /** Where this device is: the chain starts from this epoch's key. */
@@ -161,8 +169,11 @@ export interface MemberEpochGrant {
161
169
  /** The epochs between the requester's and `epoch`, oldest first, each
162
170
  * proven by the next rekey in the chain decrypting under it. Empty for a
163
171
  * grant one epoch ahead. Kept, they let the requester read what was said
164
- * in the epochs it skipped, and hand them on from its own member desk. */
165
- passed: RoomEpoch[];
172
+ * in the epochs it skipped, and hand them on from its own member desk.
173
+ * `leftAt` is the `created_at` of the authority's rekey out of each. */
174
+ passed: Array<RoomEpoch & {
175
+ leftAt?: number;
176
+ }>;
166
177
  /** The newest member list in the chain, when any rekey in it had one. */
167
178
  members?: string[];
168
179
  }
@@ -201,6 +212,10 @@ export interface HostMemberEpochDeskOptions {
201
212
  * answered (#207). Without it, after a removal, nobody is known.
202
213
  */
203
214
  known?: (participant: string) => boolean;
215
+ /** The newest credential this device has seen for another device, from
216
+ * the roster: see `HostRoomEpochOptions.credentialFor`. Keep it
217
+ * monotone. */
218
+ credentialFor?: (device: string) => DeviceCredential | undefined;
204
219
  /** Somebody the room does not know asked, after a removal. Called once
205
220
  * per participant, and again every `reportUnknownEvery` seconds while
206
221
  * they keep asking; nothing is published. Letting them in is making `known`
@@ -270,6 +285,9 @@ export interface MemberEpochRequestOptions {
270
285
  now?: () => number;
271
286
  /** How often a fresh request goes out. Default 4000 ms. */
272
287
  retryMs?: number;
288
+ /** The seal key secrets this device holds. Asked on every answer,
289
+ * because a credential may renew while the ask is out. */
290
+ sealSks?: () => readonly Uint8Array[];
273
291
  expiresAt?: number;
274
292
  }
275
293
  /** 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, REPORT_UNKNOWN_EVERY_SECONDS, deriveEpoch, epochRequestAdmission, peekRekeyEvent, readMemberList, } 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.
@@ -116,6 +117,8 @@ export function readRekeyEvidence(event, opts) {
116
117
  removed: [...new Set(body.removed.map(normaliseHex))].sort(),
117
118
  closed: body.closed === true,
118
119
  };
120
+ if (body.scheduled === true && evidence.removed.length === 0 && !evidence.closed)
121
+ evidence.scheduled = true;
119
122
  if (typeof body.commit === 'string' && HEX64.test(body.commit))
120
123
  evidence.commit = normaliseHex(body.commit);
121
124
  const members = readMemberList(body.members);
@@ -153,6 +156,12 @@ export function encodeMemberEpochRequest(opts) {
153
156
  /** Null for anything malformed, stale, from a device that cannot prove
154
157
  * which participant it speaks for, or that cannot prove admission. */
155
158
  export function decodeMemberEpochRequest(event, opts) {
159
+ const read = readMemberEpochRequest(event, opts);
160
+ return read && read.request;
161
+ }
162
+ /** `decodeMemberEpochRequest`, keeping the credential the request was made
163
+ * under: what the desk seals its answer by. */
164
+ function readMemberEpochRequest(event, opts) {
156
165
  try {
157
166
  if (event.kind !== MEMBER_EPOCH_KINDS.REQUEST)
158
167
  return null;
@@ -187,7 +196,10 @@ export function decodeMemberEpochRequest(event, opts) {
187
196
  if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, roomId).admitted)
188
197
  return null;
189
198
  }
190
- return { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have };
199
+ return {
200
+ request: { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have },
201
+ credential: body.credential,
202
+ };
191
203
  }
192
204
  catch {
193
205
  return null;
@@ -239,7 +251,7 @@ export function encodeMemberEpochGrant(opts) {
239
251
  ['d', roomId],
240
252
  ['p', device],
241
253
  ], opts.expiresAt),
242
- content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(signerSk, device)),
254
+ content: sealTo(JSON.stringify(body), signerSk, device, opts.credential),
243
255
  }, signerSk);
244
256
  }
245
257
  /**
@@ -262,7 +274,7 @@ export function decodeMemberEpochGrant(event, opts) {
262
274
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
263
275
  if (addressed === undefined || !hexEquals(addressed, device))
264
276
  return null;
265
- const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
277
+ const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
266
278
  if (body.v !== 1 || typeof body.request !== 'string' || !HEX64.test(body.request))
267
279
  return null;
268
280
  if (![...opts.requests].some((r) => hexEquals(r, body.request)))
@@ -311,8 +323,10 @@ export function decodeMemberEpochGrant(event, opts) {
311
323
  return null;
312
324
  }
313
325
  previous = deriveEpoch({ epoch: evidence.epoch, secret });
326
+ // The next rekey, signed and checked on the next turn, is when the
327
+ // room left this epoch.
314
328
  if (i < length - 1)
315
- passed.push({ epoch: evidence.epoch, secret });
329
+ passed.push({ epoch: evidence.epoch, secret, leftAt: body.rekeys[i + 1].created_at });
316
330
  }
317
331
  return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed, ...(members ? { members } : {}) };
318
332
  }
@@ -404,7 +418,7 @@ export function hostMemberEpochDesk(opts) {
404
418
  return false;
405
419
  return removed.size === 0 || opts.known?.(request.participant) === true;
406
420
  };
407
- const answer = (request) => {
421
+ const answer = (request, presented) => {
408
422
  if (closed)
409
423
  return;
410
424
  if (grantSeen.has(request.device) && !stoodDown.has(request.device)) {
@@ -432,6 +446,7 @@ export function hostMemberEpochDesk(opts) {
432
446
  request: request.request,
433
447
  epochs: chain.epochs,
434
448
  rekeys: chain.rekeys,
449
+ credential: sealCredential(request, presented, opts.credentialFor, roomId, now()),
435
450
  now: now(),
436
451
  expiresAt: opts.expiresAt,
437
452
  });
@@ -458,9 +473,10 @@ export function hostMemberEpochDesk(opts) {
458
473
  const unsubRequests = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.REQUEST], '#d': [roomId] }], (event) => {
459
474
  if (closed || hexEquals(event.pubkey, self))
460
475
  return;
461
- const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
462
- if (!request || answered.has(request.request))
476
+ const read = readMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
477
+ if (!read || answered.has(read.request.request))
463
478
  return;
479
+ const { request, credential } = read;
464
480
  if (opts.closed?.()) {
465
481
  answered.add(request.request);
466
482
  bound(answered);
@@ -493,12 +509,12 @@ export function hostMemberEpochDesk(opts) {
493
509
  grantSeen.delete(request.device);
494
510
  const delay = jitterMs === 0 ? 0 : Math.floor(random() * jitterMs);
495
511
  if (delay === 0) {
496
- answer(request);
512
+ answer(request, credential);
497
513
  return;
498
514
  }
499
515
  const timer = setTimeout(() => {
500
516
  timers.delete(timer);
501
- answer(request);
517
+ answer(request, credential);
502
518
  }, delay);
503
519
  timer.unref?.();
504
520
  timers.add(timer);
@@ -589,6 +605,7 @@ export function memberEpochSource(opts) {
589
605
  roomId,
590
606
  authority,
591
607
  deviceSk: opts.deviceSk,
608
+ sealSks: opts.sealSks?.(),
592
609
  requests,
593
610
  current: current(),
594
611
  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
@@ -81,6 +81,8 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
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
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
85
+ - Scheduled rekeys and the history window (see `docs/scheduled-rekey.md`): `encodeRekeyEvent({ ..., scheduled: true })` writes `"scheduled": true` inside the encrypted body (never a tag), and throws beside a non-empty `removed` or `closed`; `decodeRekeyEvent` (`RekeyNotice.scheduled`) and `readRekeyEvidence` (`RekeyEvidence.scheduled`) report `true` only when `removed` is empty and the room is not closed, so a contradictory body is still announced. `HISTORY_WINDOW_SECONDS` (30 days) and `MAX_HISTORY_EPOCHS` (16); `epochsInWindow(left, now)` keeps entries with `leftAt >= now - HISTORY_WINDOW_SECONDS`, newest first by epoch, first of a doubled epoch, at most 16, never throws. `encodeEpochGrant({ ..., passed })` takes `LeftEpoch[]` (`{ epoch, secret, leftAt }`) and writes `passed: [{ epoch, secret (base64url), left }]` oldest first, only when non-empty; it throws on more than 16, epoch 0 (the room secret is never carried), an epoch not below the granted one, a doubled epoch, a non-32-byte secret or a bad `leftAt`. `hostRoomEpoch({ past })` is asked on every grant, filtered to what the encoder accepts and to `epochsInWindow`, so a bad `past()` costs the history, not the grant. `decodeEpochGrant` returns `EpochGrant.passed` (`leftAt` set) only when the list is exactly in that form, else drops the field and keeps the grant; `decodeMemberEpochGrant` sets `leftAt` on each passed epoch from the next rekey's `created_at`. `MAX_MEMBER_EPOCH_CHAIN` (32) is a different limit. Without the marker and `passed` every event is byte-identical to 0.7.0, and a 0.7.0 reader ignores both
84
86
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
85
87
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
86
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -21,6 +21,8 @@
21
21
  "vectors/kithmoot-vectors.json",
22
22
  "vectors/circle-vectors.json",
23
23
  "vectors/member-epoch-vectors.json",
24
+ "vectors/seal-vectors.json",
25
+ "vectors/schedule-vectors.json",
24
26
  "LICENSE",
25
27
  "README.md",
26
28
  "llms.txt"
@@ -33,6 +35,8 @@
33
35
  "diff-source": "node scripts/diff-source.mjs",
34
36
  "generate-fold": "node scripts/generate-fold.mjs",
35
37
  "generate-member-epoch": "node scripts/generate-member-epoch.mjs",
38
+ "generate-seal": "node scripts/generate-seal.mjs",
39
+ "generate-schedule": "node scripts/generate-schedule.mjs",
36
40
  "bundle-check": "node scripts/bundle-check.mjs",
37
41
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
38
42
  "vectors": "vitest run vectors/",