@forgesworn/fold-kit 0.0.0-seed.0 → 0.2.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.
Files changed (45) hide show
  1. package/README.md +180 -4
  2. package/dist/access.d.ts +34 -0
  3. package/dist/access.js +102 -0
  4. package/dist/channel.d.ts +27 -0
  5. package/dist/channel.js +38 -0
  6. package/dist/credential.d.ts +74 -0
  7. package/dist/credential.js +177 -0
  8. package/dist/display-name.d.ts +38 -0
  9. package/dist/display-name.js +78 -0
  10. package/dist/epoch.d.ts +387 -0
  11. package/dist/epoch.js +665 -0
  12. package/dist/hex.d.ts +28 -0
  13. package/dist/hex.js +32 -0
  14. package/dist/identity.d.ts +49 -0
  15. package/dist/identity.js +17 -0
  16. package/dist/index.d.ts +29 -0
  17. package/dist/index.js +19 -0
  18. package/dist/invitation.d.ts +217 -0
  19. package/dist/invitation.js +484 -0
  20. package/dist/kinds.d.ts +64 -0
  21. package/dist/kinds.js +64 -0
  22. package/dist/lane.d.ts +41 -0
  23. package/dist/lane.js +63 -0
  24. package/dist/link.d.ts +52 -0
  25. package/dist/link.js +107 -0
  26. package/dist/network-hints.d.ts +16 -0
  27. package/dist/network-hints.js +65 -0
  28. package/dist/persistent-invitation.d.ts +35 -0
  29. package/dist/persistent-invitation.js +118 -0
  30. package/dist/room.d.ts +53 -0
  31. package/dist/room.js +153 -0
  32. package/dist/scoped.d.ts +67 -0
  33. package/dist/scoped.js +83 -0
  34. package/dist/sub-cert.d.ts +117 -0
  35. package/dist/sub-cert.js +192 -0
  36. package/dist/transport.d.ts +24 -0
  37. package/dist/transport.js +1 -0
  38. package/dist/types.d.ts +62 -0
  39. package/dist/types.js +1 -0
  40. package/dist/verify.d.ts +26 -0
  41. package/dist/verify.js +46 -0
  42. package/llms.txt +106 -0
  43. package/package.json +77 -6
  44. package/vectors/circle-vectors.json +2174 -0
  45. package/vectors/kithmoot-vectors.json +1550 -0
package/dist/epoch.js ADDED
@@ -0,0 +1,665 @@
1
+ import { hkdf } from '@noble/hashes/hkdf';
2
+ import { hmac } from '@noble/hashes/hmac';
3
+ import { sha256 } from '@noble/hashes/sha2';
4
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
5
+ import { schnorr } from '@noble/curves/secp256k1.js';
6
+ import { base64urlnopad } from '@scure/base';
7
+ import { nip44 } from 'nostr-tools';
8
+ import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
9
+ import { hexEquals, normaliseHex } from './hex.js';
10
+ import { KINDS } from './kinds.js';
11
+ import { deriveRoom } from './room.js';
12
+ import { verifyDeviceCredential } from './credential.js';
13
+ import { evaluateAccess } from './access.js';
14
+ import { verifyEventUncached } from './verify.js';
15
+ /**
16
+ * Room epochs: how a member is removed.
17
+ *
18
+ * A room's traffic key is derived from its secret, and everybody who was
19
+ * ever admitted holds it. Link rotation retires the rendezvous and nothing
20
+ * else: a departed collaborator, or a leaked agent key, reads the room for
21
+ * as long as the room lives. The only thing that ends that is a new key
22
+ * that the departed party is not given, and that is an epoch.
23
+ *
24
+ * Epoch 0 is the room as it has always been: the id and key `deriveRoom`
25
+ * produces from the secret in the link, byte for byte. Every later epoch is
26
+ * a fresh 32-byte secret from which its own id and key are derived, under
27
+ * info strings that name the epoch number. The roster, the chat and its
28
+ * channels, the descriptor and the media keys all move to the new epoch's
29
+ * id and key together; the *room id* - what a credential binds to, what a
30
+ * signal names, what a forwarder is given - does not move, because it is
31
+ * the room's identity rather than its key.
32
+ *
33
+ * The move is announced by a rekey event: durable, addressed by the public
34
+ * room id, numbered in a tag, signed by the room's authority (the root
35
+ * inviter pinned in the link), and carrying the new secret sealed to each
36
+ * remaining device. Its body is encrypted to the epoch being left, so a
37
+ * member at that epoch reads it and moves, and a relay reads nothing but a
38
+ * number. A member that missed one asks the authority for the current
39
+ * epoch, proving who it is with its device credential and that it was
40
+ * admitted with a proof under the room key, and the authority answers
41
+ * everybody except the removed.
42
+ *
43
+ * The admission proof is what keeps the desk from being a back door. The
44
+ * room id and the authority's pubkey are public: both ride in the clear on
45
+ * every rekey event. A device credential is self-issued by whichever
46
+ * participant key signs it. So a request carrying only a credential proves
47
+ * nothing a stranger reading the relay could not produce, and in an open
48
+ * room the desk would hand that stranger the current epoch's secret, which
49
+ * opens the roster and the chat. The epoch-0 room key is the one thing the
50
+ * link hands out and nothing else does, so a MAC under a key derived from
51
+ * it is proof of having been admitted, and the desk demands one.
52
+ *
53
+ * Copies are sealed to the *device* pubkey, not the participant's, because
54
+ * the device key is the one every session holds in memory: a participant
55
+ * key may live in a browser extension or a bunker that signs and does
56
+ * nothing else, and the identity surface is deliberately a pubkey and one
57
+ * `signEvent` (see `identity.ts`). The participant is what a rekey names -
58
+ * the removed set, the refusal - and the device credential is how a device
59
+ * proves which participant it speaks for when it asks.
60
+ */
61
+ export const EPOCH_ID_INFO = 'kithmoot/v1/epoch-id';
62
+ export const EPOCH_KEY_INFO = 'kithmoot/v1/epoch-key';
63
+ const EPOCH_MAX_AGE_SECONDS = 90;
64
+ const DEFAULT_TIMEOUT_MS = 30_000;
65
+ const DEFAULT_RETRY_MS = 2_000;
66
+ /** A room that has been rekeyed more times than this has a problem that is
67
+ * not this module's to solve. Bounds the tag a client will parse. */
68
+ export const MAX_EPOCH = 1_000_000;
69
+ /**
70
+ * HKDF info string for the key an epoch request's admission proof is made
71
+ * under. Its own domain, like the media key's: a proof key that was the
72
+ * room key would put the room key itself into a MAC on every request, and
73
+ * a key that is only the epoch-request key opens nothing else if it leaks.
74
+ */
75
+ export const EPOCH_REQUEST_KEY_INFO = 'kithmoot/v1/epoch-request-key';
76
+ const HEX64 = /^[0-9a-f]{64}$/i;
77
+ function require32(bytes, what) {
78
+ if (bytes.length !== 32)
79
+ throw new Error(`${what} must be 32 bytes`);
80
+ }
81
+ function requireHex32(value, what) {
82
+ if (!HEX64.test(value))
83
+ throw new Error(`${what} must be 32-byte hex`);
84
+ return normaliseHex(value);
85
+ }
86
+ function requireEpochNumber(epoch) {
87
+ if (!Number.isSafeInteger(epoch) || epoch < 0 || epoch > MAX_EPOCH)
88
+ throw new Error('epoch must be a small non-negative integer');
89
+ return epoch;
90
+ }
91
+ function hex(bytes) {
92
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
93
+ }
94
+ /** A fresh secret for the next epoch. */
95
+ export function generateEpochSecret() {
96
+ return randomBytes(32);
97
+ }
98
+ /**
99
+ * The id and key a room's traffic rides under in `epoch`.
100
+ *
101
+ * Epoch 0 is exactly `deriveRoom(secret)`, so a room that has never been
102
+ * rekeyed is byte-identical on the wire to one from before epochs existed.
103
+ * Every later epoch expands its own secret under info strings that carry
104
+ * the epoch number, so the same secret at two numbers - which a correct
105
+ * authority never produces - would still derive two different keys.
106
+ */
107
+ export function deriveEpoch(epoch) {
108
+ require32(epoch.secret, 'epoch secret');
109
+ const n = requireEpochNumber(epoch.epoch);
110
+ if (n === 0) {
111
+ const { roomId, roomKey } = deriveRoom(epoch.secret);
112
+ return { epoch: 0, id: roomId, key: roomKey };
113
+ }
114
+ return {
115
+ epoch: n,
116
+ id: hex(hkdf(sha256, epoch.secret, undefined, `${EPOCH_ID_INFO}/${n}`, 32)),
117
+ key: hkdf(sha256, epoch.secret, undefined, `${EPOCH_KEY_INFO}/${n}`, 32),
118
+ };
119
+ }
120
+ /** Announce the next epoch. See the module comment for what this is. */
121
+ export function encodeRekeyEvent(opts) {
122
+ require32(opts.authoritySk, 'authority secret key');
123
+ require32(opts.next.secret, 'epoch secret');
124
+ const roomId = requireHex32(opts.roomId, 'room id');
125
+ const epoch = requireEpochNumber(opts.next.epoch);
126
+ if (epoch !== opts.current.epoch + 1)
127
+ throw new Error('a rekey moves the room forward by exactly one epoch');
128
+ const removed = [...new Set(opts.removed.map((p) => requireHex32(p, 'removed participant')))].sort();
129
+ const keys = {};
130
+ const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
131
+ const plaintext = JSON.stringify(sealed);
132
+ for (const raw of opts.closed ? [] : opts.recipients) {
133
+ const device = requireHex32(raw, 'recipient device');
134
+ keys[device] = nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(opts.authoritySk, device));
135
+ }
136
+ const body = {
137
+ v: 1,
138
+ epoch,
139
+ removed,
140
+ ...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
141
+ ...(opts.closed ? { closed: true } : {}),
142
+ keys,
143
+ };
144
+ return finalizeEvent({
145
+ kind: KINDS.ROOM_REKEY,
146
+ created_at: opts.now,
147
+ tags: [
148
+ ['d', roomId],
149
+ ['epoch', String(epoch)],
150
+ ],
151
+ content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
152
+ }, opts.authoritySk);
153
+ }
154
+ /**
155
+ * The epoch a rekey announces, checking everything that does not need a
156
+ * key: kind, room, signer, signature and the epoch tag. What a client uses
157
+ * to learn that the room is ahead of it before it can read why.
158
+ */
159
+ export function peekRekeyEvent(event, opts) {
160
+ try {
161
+ if (event.kind !== KINDS.ROOM_REKEY)
162
+ return null;
163
+ if (!hexEquals(event.pubkey, opts.authority))
164
+ return null;
165
+ if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== normaliseHex(opts.roomId))
166
+ return null;
167
+ const tag = event.tags.find((t) => t[0] === 'epoch')?.[1];
168
+ if (tag === undefined || !/^[1-9][0-9]{0,6}$/.test(tag))
169
+ return null;
170
+ const epoch = Number(tag);
171
+ if (epoch > MAX_EPOCH)
172
+ return null;
173
+ if (!verifyEventUncached(event))
174
+ return null;
175
+ return epoch;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ /**
182
+ * Read a rekey. Null for anything that does not check out, and for a rekey
183
+ * this device cannot read from where it stands: one for an epoch it is
184
+ * already past (a replay), or one sealed to an epoch it is not at.
185
+ */
186
+ export function decodeRekeyEvent(event, opts) {
187
+ try {
188
+ const epoch = peekRekeyEvent(event, opts);
189
+ if (epoch === null || epoch !== opts.current.epoch + 1)
190
+ return null;
191
+ const body = JSON.parse(nip44.v2.decrypt(event.content, opts.current.key));
192
+ if (body.v !== 1 || body.epoch !== epoch)
193
+ return null;
194
+ if (!Array.isArray(body.removed) || !body.removed.every((p) => typeof p === 'string' && HEX64.test(p)))
195
+ return null;
196
+ if (typeof body.keys !== 'object' || body.keys === null)
197
+ return null;
198
+ const notice = {
199
+ epoch,
200
+ removed: [...new Set(body.removed.map(normaliseHex))].sort(),
201
+ closed: body.closed === true,
202
+ at: event.created_at,
203
+ };
204
+ if (typeof body.by === 'string' && HEX64.test(body.by))
205
+ notice.by = normaliseHex(body.by);
206
+ const device = getPublicKey(opts.deviceSk);
207
+ const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
208
+ if (typeof mine === 'string') {
209
+ const sealed = JSON.parse(nip44.v2.decrypt(mine, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
210
+ if (sealed.v === 1 && typeof sealed.secret === 'string') {
211
+ const secret = base64urlnopad.decode(sealed.secret);
212
+ if (secret.length === 32)
213
+ notice.secret = secret;
214
+ }
215
+ }
216
+ return notice;
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Asking the authority for the current epoch
224
+ // ---------------------------------------------------------------------------
225
+ /**
226
+ * The key an epoch request's admission proof is computed under: the epoch-0
227
+ * room key, expanded under its own info string. Taken from the room key
228
+ * rather than the room secret for the same reason the media key is: a
229
+ * joined client holds the room key and need not keep the capability.
230
+ */
231
+ export function deriveEpochRequestKey(roomKey) {
232
+ require32(roomKey, 'room key');
233
+ return hkdf(sha256, roomKey, undefined, EPOCH_REQUEST_KEY_INFO, 32);
234
+ }
235
+ /**
236
+ * Prove, inside an epoch request, that the asking device was admitted to
237
+ * the room.
238
+ *
239
+ * `HMAC-SHA256(deriveEpochRequestKey(roomKey), "kithmoot/v1/epoch-request:" +
240
+ * roomId + ":" + authority + ":" + device + ":" + createdAt)`, as hex, with
241
+ * the three identifiers in lower-case hex. Bound to the device and the
242
+ * moment so that a proof lifted from one request is no use in another,
243
+ * though the body it rides in is already sealed to the authority. Every
244
+ * admitted device holds the epoch-0 room key, including one that has fallen
245
+ * several epochs behind, which is exactly who asks; a removed member holds
246
+ * it too, and is refused by name, not by this. What this refuses is the
247
+ * stranger: somebody with the room id and the authority's pubkey off a
248
+ * public rekey event, and no link.
249
+ */
250
+ export function epochRequestAdmission(opts) {
251
+ const roomId = requireHex32(opts.roomId, 'room id');
252
+ const authority = requireHex32(opts.authority, 'authority pubkey');
253
+ const device = requireHex32(opts.device, 'device pubkey');
254
+ if (!Number.isSafeInteger(opts.createdAt) || opts.createdAt < 0)
255
+ throw new Error('created_at must be a non-negative integer');
256
+ const message = new TextEncoder().encode(`kithmoot/v1/epoch-request:${roomId}:${authority}:${device}:${opts.createdAt}`);
257
+ return bytesToHex(hmac(sha256, deriveEpochRequestKey(opts.roomKey), message));
258
+ }
259
+ /** Equality that does not leak where two proofs first differ. */
260
+ function admissionEquals(presented, expected) {
261
+ const a = normaliseHex(presented);
262
+ if (a.length !== expected.length)
263
+ return false;
264
+ let diff = 0;
265
+ for (let i = 0; i < a.length; i += 1)
266
+ diff |= a.charCodeAt(i) ^ expected.charCodeAt(i);
267
+ return diff === 0;
268
+ }
269
+ /** Ask the authority which epoch the room is at, and for its secret. */
270
+ export function encodeEpochRequest(opts) {
271
+ require32(opts.deviceSk, 'device secret key');
272
+ const roomId = requireHex32(opts.roomId, 'room id');
273
+ const authority = requireHex32(opts.authority, 'authority pubkey');
274
+ const admission = epochRequestAdmission({
275
+ roomKey: opts.roomKey,
276
+ roomId,
277
+ authority,
278
+ device: getPublicKey(opts.deviceSk),
279
+ createdAt: opts.now,
280
+ });
281
+ const body = { v: 1, credential: opts.credential, ...(opts.proof ? { proof: opts.proof } : {}), admission };
282
+ return finalizeEvent({
283
+ kind: KINDS.EPOCH_REQUEST,
284
+ created_at: opts.now,
285
+ tags: [
286
+ ['d', roomId],
287
+ ['p', authority],
288
+ ],
289
+ content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.deviceSk, authority)),
290
+ }, opts.deviceSk);
291
+ }
292
+ /** Null for anything malformed, stale, misaddressed, from a device that
293
+ * cannot prove which participant it speaks for in this room, or from one
294
+ * that cannot prove it was admitted to the room at all. */
295
+ export function decodeEpochRequest(event, opts) {
296
+ try {
297
+ if (event.kind !== KINDS.EPOCH_REQUEST)
298
+ return null;
299
+ if (!verifyEventUncached(event))
300
+ return null;
301
+ if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? EPOCH_MAX_AGE_SECONDS))
302
+ return null;
303
+ if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== normaliseHex(opts.roomId))
304
+ return null;
305
+ const authority = getPublicKey(opts.authoritySk);
306
+ const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
307
+ if (addressed === undefined || !hexEquals(addressed, authority))
308
+ return null;
309
+ const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.authoritySk, event.pubkey)));
310
+ if (body.v !== 1 || typeof body.credential !== 'object' || body.credential === null)
311
+ return null;
312
+ const verdict = verifyDeviceCredential(body.credential, { roomId: opts.roomId, now: opts.now });
313
+ if (!verdict.ok)
314
+ return null;
315
+ if (!hexEquals(verdict.device, event.pubkey))
316
+ return null;
317
+ // Admission before policy: a stranger with no room key is turned away
318
+ // before anything about the room's tiers is consulted. A keeper may
319
+ // bridge its named admins from the last desktop release: their signed
320
+ // participant identity is configured out of band, while an arbitrary
321
+ // holder of the public room id still gets no answer.
322
+ if (typeof body.admission === 'string') {
323
+ const expected = epochRequestAdmission({
324
+ roomKey: opts.roomKey,
325
+ roomId: opts.roomId,
326
+ authority,
327
+ device: verdict.device,
328
+ createdAt: event.created_at,
329
+ });
330
+ if (!admissionEquals(body.admission, expected))
331
+ return null;
332
+ }
333
+ else if (!opts.legacyParticipants?.has(verdict.participant))
334
+ return null;
335
+ if (opts.policy) {
336
+ const proof = body.proof && typeof body.proof === 'object' ? body.proof : undefined;
337
+ if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, opts.roomId).admitted)
338
+ return null;
339
+ }
340
+ return { device: verdict.device, participant: verdict.participant, request: event.id };
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ }
346
+ /** Answer one request: the current epoch sealed to the asking device, or a
347
+ * refusal it alone can read. */
348
+ export function encodeEpochGrant(opts) {
349
+ require32(opts.authoritySk, 'authority secret key');
350
+ const roomId = requireHex32(opts.roomId, 'room id');
351
+ const device = requireHex32(opts.device, 'device pubkey');
352
+ const request = requireHex32(opts.request, 'request id');
353
+ const body = { v: 1, request };
354
+ if (opts.refused) {
355
+ body.refused = opts.refused;
356
+ }
357
+ else {
358
+ if (!opts.epoch)
359
+ throw new Error('a grant carries an epoch or a refusal');
360
+ body.epoch = requireEpochNumber(opts.epoch.epoch);
361
+ if (body.epoch > 0) {
362
+ require32(opts.epoch.secret, 'epoch secret');
363
+ body.secret = base64urlnopad.encode(opts.epoch.secret);
364
+ }
365
+ body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
366
+ }
367
+ return finalizeEvent({
368
+ kind: KINDS.EPOCH_GRANT,
369
+ created_at: opts.now,
370
+ tags: [
371
+ ['d', roomId],
372
+ ['p', device],
373
+ ],
374
+ content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.authoritySk, device)),
375
+ }, opts.authoritySk);
376
+ }
377
+ /** Read the authority's answer to this device's own request. Null for
378
+ * anything else. Note a grant for epoch 0 carries no secret: the room has
379
+ * never been rekeyed, and the requester already holds what it needs. */
380
+ export function decodeEpochGrant(event, opts) {
381
+ try {
382
+ if (event.kind !== KINDS.EPOCH_GRANT)
383
+ return null;
384
+ if (!hexEquals(event.pubkey, opts.authority))
385
+ return null;
386
+ if (!verifyEventUncached(event))
387
+ return null;
388
+ if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? EPOCH_MAX_AGE_SECONDS))
389
+ return null;
390
+ if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== normaliseHex(opts.roomId))
391
+ return null;
392
+ const device = getPublicKey(opts.deviceSk);
393
+ const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
394
+ if (addressed === undefined || !hexEquals(addressed, device))
395
+ return null;
396
+ const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
397
+ if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
398
+ return null;
399
+ if (body.refused === 'removed' || body.refused === 'closed')
400
+ return { refused: body.refused };
401
+ if (!Number.isSafeInteger(body.epoch) || body.epoch < 0 || body.epoch > MAX_EPOCH)
402
+ return null;
403
+ const removed = Array.isArray(body.removed)
404
+ ? [...new Set(body.removed.filter((p) => typeof p === 'string' && HEX64.test(p)).map(normaliseHex))].sort()
405
+ : [];
406
+ if (body.epoch === 0)
407
+ return { epoch: { epoch: 0 }, removed };
408
+ if (typeof body.secret !== 'string')
409
+ return null;
410
+ const secret = base64urlnopad.decode(body.secret);
411
+ if (secret.length !== 32)
412
+ return null;
413
+ return { epoch: { epoch: body.epoch, secret }, removed };
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ /**
420
+ * Answer epoch requests for as long as the handle is open. What a keeper
421
+ * runs beside its invitation desk: a member arriving, or returning, after
422
+ * a rekey is handed the current epoch on proof of who it is and that it was
423
+ * admitted, and a removed participant is told no. This is where removal is
424
+ * enforced against the link: the link still admits its holder to the room's
425
+ * *secret*, which opens epoch 0 and nothing after it. A request with no
426
+ * admission proof, or one made under some other key, is not answered at
427
+ * all: a stranger learns nothing, not even that a desk is here.
428
+ */
429
+ export function hostRoomEpoch(opts) {
430
+ require32(opts.authoritySk, 'authority secret key');
431
+ require32(opts.roomKey, 'room key');
432
+ const roomId = requireHex32(opts.roomId, 'room id');
433
+ const authority = getPublicKey(opts.authoritySk);
434
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
435
+ const answered = new Set();
436
+ let closed = false;
437
+ const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
438
+ if (closed)
439
+ return;
440
+ const request = decodeEpochRequest(event, {
441
+ roomId,
442
+ authoritySk: opts.authoritySk,
443
+ roomKey: opts.roomKey,
444
+ now: now(),
445
+ policy: opts.policy,
446
+ legacyParticipants: opts.legacyParticipants,
447
+ });
448
+ if (!request || answered.has(request.request))
449
+ return;
450
+ answered.add(request.request);
451
+ if (answered.size > 256)
452
+ answered.delete(answered.values().next().value);
453
+ let refused;
454
+ if (opts.closed?.())
455
+ refused = 'closed';
456
+ else if (opts.removed().has(request.participant))
457
+ refused = 'removed';
458
+ let grant;
459
+ try {
460
+ grant = refused
461
+ ? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused })
462
+ : encodeEpochGrant({
463
+ roomId,
464
+ authoritySk: opts.authoritySk,
465
+ device: request.device,
466
+ request: request.request,
467
+ now: now(),
468
+ epoch: opts.current(),
469
+ removed: [...opts.removed()],
470
+ });
471
+ }
472
+ catch {
473
+ return;
474
+ }
475
+ opts.transport.publish(grant).catch(() => { });
476
+ if (refused)
477
+ opts.onRefused?.(request, refused);
478
+ else
479
+ opts.onGranted?.(request);
480
+ });
481
+ return {
482
+ close() {
483
+ if (closed)
484
+ return;
485
+ closed = true;
486
+ unsub();
487
+ },
488
+ };
489
+ }
490
+ /** Thrown when the authority answered, and the answer was no. */
491
+ export class EpochRefusedError extends Error {
492
+ refused;
493
+ constructor(refused) {
494
+ super(refused === 'removed' ? 'you were removed from this room' : 'this room has been closed');
495
+ this.name = 'EpochRefusedError';
496
+ this.refused = refused;
497
+ }
498
+ }
499
+ /** Ask the authority where the room is, and wait for the answer. Rejects
500
+ * with `EpochRefusedError` on a refusal, and with a plain error when
501
+ * nobody answers inside the timeout. */
502
+ export function requestRoomEpoch(opts) {
503
+ const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
504
+ const roomId = requireHex32(opts.roomId, 'room id');
505
+ const device = getPublicKey(opts.deviceSk);
506
+ const request = encodeEpochRequest({
507
+ roomId,
508
+ authority: opts.authority,
509
+ deviceSk: opts.deviceSk,
510
+ roomKey: opts.roomKey,
511
+ credential: opts.credential,
512
+ proof: opts.proof,
513
+ now: now(),
514
+ });
515
+ return new Promise((resolve, reject) => {
516
+ let settled = false;
517
+ let retry;
518
+ let expiry;
519
+ let unsub = () => { };
520
+ const finish = (settle) => {
521
+ if (settled)
522
+ return;
523
+ settled = true;
524
+ if (retry !== undefined)
525
+ clearInterval(retry);
526
+ if (expiry !== undefined)
527
+ clearTimeout(expiry);
528
+ unsub();
529
+ settle();
530
+ };
531
+ unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
532
+ const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
533
+ if (!grant)
534
+ return;
535
+ if (grant.refused)
536
+ finish(() => reject(new EpochRefusedError(grant.refused)));
537
+ else
538
+ finish(() => resolve(grant));
539
+ });
540
+ if (settled)
541
+ unsub();
542
+ const ask = () => {
543
+ if (!settled)
544
+ opts.transport.publish(request).catch(() => { });
545
+ };
546
+ 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);
547
+ expiry.unref?.();
548
+ retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
549
+ retry.unref?.();
550
+ ask();
551
+ });
552
+ }
553
+ // ---------------------------------------------------------------------------
554
+ // The admin list
555
+ // ---------------------------------------------------------------------------
556
+ function adminsMessage(roomId, epoch, admins) {
557
+ return sha256(new TextEncoder().encode(`kithmoot/v1/admins:${roomId}:${epoch}:${admins.join(',')}`));
558
+ }
559
+ /** Canonical form of an admin list: lower-case, deduplicated, sorted. */
560
+ export function canonicalAdmins(admins) {
561
+ return [...new Set(admins.map((a) => requireHex32(a, 'admin pubkey')))].sort();
562
+ }
563
+ /**
564
+ * Channel names a room may be told about.
565
+ *
566
+ * Deliberately stricter than `deriveChannel`, which accepts any string of
567
+ * 1..64 characters because it is only doing HKDF and has no opinion. A name
568
+ * in the REGISTRY is different: it is announced to every client, rendered as
569
+ * a label, and handed to an agent as its thread id. So it is held to a shape
570
+ * that survives all three without escaping, normalising or truncating -
571
+ * lower case, digits and hyphens, opening on an alphanumeric.
572
+ *
573
+ * The consequence worth stating: a name is stored and compared exactly as
574
+ * written here, so a client, an agent adapter and a relay all mean the same
575
+ * string by it, and nothing anywhere has to guess at a canonical form.
576
+ */
577
+ export const CHANNEL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
578
+ /**
579
+ * Names the registry will not carry, because the room already means
580
+ * something by them: `agents` is the backchannel, `transcript` is what was
581
+ * said, `minutes` is what the scribe wrote. Deriving them still works - they
582
+ * are ordinary channels to the crypto - but announcing one as a place to
583
+ * chat would put two different things under one name.
584
+ */
585
+ export const RESERVED_CHANNELS = ['agents', 'minutes', 'transcript'];
586
+ /** Canonical form of a channel list: validated, deduplicated, sorted. */
587
+ export function canonicalChannels(channels) {
588
+ for (const name of channels) {
589
+ if (typeof name !== 'string' || !CHANNEL_NAME.test(name))
590
+ throw new Error(`bad channel name: ${String(name)}`);
591
+ if (RESERVED_CHANNELS.includes(name))
592
+ throw new Error(`reserved channel name: ${name}`);
593
+ }
594
+ return [...new Set(channels)].sort();
595
+ }
596
+ function channelsMessage(roomId, epoch, channels) {
597
+ return sha256(new TextEncoder().encode(`kithmoot/v1/channels:${roomId}:${epoch}:${channels.join(',')}`));
598
+ }
599
+ /**
600
+ * Sign the list of channels a room has.
601
+ *
602
+ * Signed by the authority and bound to the epoch for exactly the reasons the
603
+ * admin list is: every member holds the room key, so an unsigned list is the
604
+ * first thing a member would forge, and a list valid at one epoch must not
605
+ * be replayable into the next. This is what settles the rule that nothing a
606
+ * chat message says may change the structure of a room - a channel exists
607
+ * because the authority said so, or it does not exist.
608
+ */
609
+ export function signChannels(opts) {
610
+ require32(opts.authoritySk, 'authority secret key');
611
+ const roomId = requireHex32(opts.roomId, 'room id');
612
+ const channels = canonicalChannels(opts.channels);
613
+ return bytesToHex(schnorr.sign(channelsMessage(roomId, requireEpochNumber(opts.epoch), channels), opts.authoritySk));
614
+ }
615
+ /** Never throws: this runs on anything a relay hands over. */
616
+ export function verifyChannels(opts) {
617
+ try {
618
+ const roomId = requireHex32(opts.roomId, 'room id');
619
+ const channels = canonicalChannels(opts.channels);
620
+ const sig = hexToBytes(opts.sig);
621
+ if (sig.length !== 64)
622
+ return false;
623
+ return schnorr.verify(sig, channelsMessage(roomId, requireEpochNumber(opts.epoch), channels), hexToBytes(requireHex32(opts.authority, 'authority')));
624
+ }
625
+ catch {
626
+ return false;
627
+ }
628
+ }
629
+ /**
630
+ * Sign the list of participants who may act on the room, so a client can
631
+ * tell a keeper's announcement from anybody else's claim: the announcement
632
+ * rides the control channel, which every member can write to, and only the
633
+ * authority key pinned in the link can produce this.
634
+ */
635
+ export function signAdmins(opts) {
636
+ require32(opts.authoritySk, 'authority secret key');
637
+ const roomId = requireHex32(opts.roomId, 'room id');
638
+ const admins = canonicalAdmins(opts.admins);
639
+ return bytesToHex(schnorr.sign(adminsMessage(roomId, requireEpochNumber(opts.epoch), admins), opts.authoritySk));
640
+ }
641
+ export function verifyAdmins(opts) {
642
+ try {
643
+ const roomId = requireHex32(opts.roomId, 'room id');
644
+ const admins = canonicalAdmins(opts.admins);
645
+ const sig = hexToBytes(opts.sig);
646
+ if (sig.length !== 64)
647
+ return false;
648
+ return schnorr.verify(sig, adminsMessage(roomId, requireEpochNumber(opts.epoch), admins), hexToBytes(requireHex32(opts.authority, 'authority')));
649
+ }
650
+ catch {
651
+ return false;
652
+ }
653
+ }
654
+ /** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
655
+ * `src/labels.test.ts`, which checks each module against its own exported
656
+ * list rather than scanning file text for matching comments. Pure data -
657
+ * adding this export changes no runtime behaviour. */
658
+ export const EPOCH_LABELS = [
659
+ "kithmoot/v1/admins:",
660
+ "kithmoot/v1/channels:",
661
+ "kithmoot/v1/epoch-id",
662
+ "kithmoot/v1/epoch-key",
663
+ "kithmoot/v1/epoch-request-key",
664
+ "kithmoot/v1/epoch-request:",
665
+ ];
package/dist/hex.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Case-insensitive equality for hex identifiers.
3
+ *
4
+ * Nostr pubkeys, room ids, and every other hex field this protocol compares
5
+ * are canonically produced in lower case, but nothing on the wire enforces
6
+ * that: an allow-list entry typed or pasted by a person, in particular, may
7
+ * carry upper-case hex naming exactly the same key. Every place this
8
+ * protocol decides whether two hex identifiers name the same thing must go
9
+ * through this function rather than `===`/`!==`, so a case difference is
10
+ * never mistaken for a different identity - see `vectors/README.md`.
11
+ */
12
+ export declare function hexEquals(a: string, b: string): boolean;
13
+ /**
14
+ * Canonicalise a hex identifier to lower case.
15
+ *
16
+ * `hexEquals` makes an equality check safe regardless of case, but nothing
17
+ * protects a *lexicographic* comparison the same way: `Peer`'s glare
18
+ * tiebreak and `resolveSingularRoles`' device tiebreak both order hex
19
+ * strings with `<`, and two implementations that disagree on which of two
20
+ * differently-cased spellings of the same identifier sorts first can reach
21
+ * opposite answers from identical input - the exact deadlock perfect
22
+ * negotiation exists to prevent. Call this once, at the point a hex
23
+ * identifier enters the system - a decoded event, a parsed credential or
24
+ * proof, a pubkey read from storage or a URL - rather than at each
25
+ * comparison site, so every later equality or ordering check on it is
26
+ * correct by construction. See `vectors/README.md`.
27
+ */
28
+ export declare function normaliseHex(hex: string): string;