@forgesworn/fold-kit 0.13.0 → 0.14.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.
@@ -2,17 +2,38 @@ import { hkdf } from '@noble/hashes/hkdf';
2
2
  import { sha256 } from '@noble/hashes/sha2';
3
3
  import { base64urlnopad } from '@scure/base';
4
4
  import { nip44 } from 'nostr-tools';
5
- import { verifiedSymbol } from 'nostr-tools/pure';
5
+ import { getEventHash, verifiedSymbol } from 'nostr-tools/pure';
6
6
  import { deriveInvitationId, roomInvitation, decodeInvitationRetirementNotice } from './invitation.js';
7
7
  import { PERSISTENT_INVITATION_LABELS } from './persistent-invitation.js';
8
8
  import { isRoomEnds } from './expiration.js';
9
9
  import { isInvitationRelays } from './invitation-relays.js';
10
+ import { isInvitationStorage } from './invitation-storage.js';
10
11
  import { deriveRoom } from './room.js';
11
12
  import { KINDS } from './kinds.js';
12
13
  import { verifyEventUncached } from './verify.js';
14
+ import { MAX_PERSISTENT_INVITATION_EVENT_BYTES, MAX_EVIDENCE_CARRIER_TAGS, MAX_EVIDENCE_CARRIER_TAG_STRINGS } from './evidence-carrier.js';
13
15
  export const MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS = 64;
14
- export const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16_384;
16
+ export { MAX_PERSISTENT_INVITATION_EVENT_BYTES };
15
17
  export const MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES = 262_144;
18
+ /** Raw deliveries one query reads, counted before anything is parsed: every
19
+ * relay's copy of every event, repeats, other authors and junk alike. It bounds
20
+ * parse work only. It is eight times the retained-event limit, so 64 events
21
+ * from each of eight relays still fit. Past it the query fails closed, because
22
+ * an event that could matter may be among those it did not read. */
23
+ export const MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES = 512;
24
+ /** Signature checks one query is charged. Only an event that is in scope and
25
+ * not already held or refused costs one, so a repeat costs none. It is twice
26
+ * the retained-event limit: room for 64 retained events and 64 more in-scope
27
+ * events that fail (forged, over the evidence size limits, or signed but not a
28
+ * supported body). Beyond it the query fails closed rather than skip an
29
+ * unchecked event.
30
+ *
31
+ * It bounds checks, not Schnorr verifications. A retained retirement is
32
+ * verified twice (its scope check, then the retirement decoder), and the
33
+ * finished query revalidates every retained event, so the worst case is
34
+ * 128 + 64 verifications on a query that succeeds. A forgery fails at its
35
+ * first verification, so a hostile relay can cause at most 128. */
36
+ export const MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS = 128;
16
37
  /** Incomplete observations are bounded diagnostic custody only. They are
17
38
  * never accepted by revalidatePersistentInvitationEvidence. */
18
39
  export class PersistentInvitationEvidenceQueryError extends Error {
@@ -27,8 +48,9 @@ const fields = ['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'];
27
48
  const utf8 = new TextEncoder();
28
49
  const hex32 = /^[0-9a-f]{64}$/;
29
50
  const hex64 = /^[0-9a-f]{128}$/;
30
- /** JSON string byte cost including escaping, without serialising content. */
31
- function jsonStringBytes(value) {
51
+ /** JSON string byte cost including escaping, without serialising content.
52
+ * `limited` throws past the per-event ceiling; unlimited only measures. */
53
+ function jsonStringBytes(value, limited = true) {
32
54
  let bytes = 2;
33
55
  for (let i = 0; i < value.length; i++) {
34
56
  const c = value.charCodeAt(i);
@@ -48,7 +70,7 @@ function jsonStringBytes(value) {
48
70
  bytes += 6;
49
71
  else
50
72
  bytes += 3;
51
- if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
73
+ if (limited && bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
52
74
  throw new Error('oversized evidence event');
53
75
  }
54
76
  return bytes;
@@ -82,37 +104,53 @@ function dataArray(raw, limit) {
82
104
  return values;
83
105
  }
84
106
  /** Fresh carrier copy, with no trusted verification-cache symbol. Bounds
85
- * precede copying or serialising potentially large strings/tag arrays. */
86
- function carrier(raw) {
107
+ * precede copying or serialising potentially large strings/tag arrays.
108
+ *
109
+ * `tolerant` lifts only the size ceilings (bytes, tag count, tag width): every
110
+ * plain-data, prototype, type and hex refusal stays. The copy of an event that
111
+ * exceeds a ceiling comes back with `over` set and no canonical form, because
112
+ * it can never be retained as evidence. The query uses that to check whether
113
+ * the authority signed it. */
114
+ function carrier(raw, tolerant = false) {
115
+ let over = false;
116
+ const exceed = (message) => { if (!tolerant)
117
+ throw new Error(message); over = true; };
87
118
  const d = dataRecord(raw, fields, verifiedSymbol);
88
119
  if (Object.keys(d).length !== fields.length || typeof d.id !== 'string' || !hex32.test(d.id) ||
89
120
  typeof d.pubkey !== 'string' || !hex32.test(d.pubkey) || typeof d.sig !== 'string' || !hex64.test(d.sig) ||
90
121
  !Number.isSafeInteger(d.created_at) || d.created_at < 0 || !Number.isSafeInteger(d.kind) || d.kind < 0 ||
91
- typeof d.content !== 'string' || d.content.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
122
+ typeof d.content !== 'string')
92
123
  throw new Error('invalid or oversized evidence event');
124
+ if (d.content.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
125
+ exceed('invalid or oversized evidence event');
93
126
  let bytes = '{"id":,"pubkey":,"created_at":,"kind":,"tags":,"content":,"sig":}'.length +
94
- String(d.created_at).length + String(d.kind).length + jsonStringBytes(d.id) + jsonStringBytes(d.pubkey) + jsonStringBytes(d.sig) + jsonStringBytes(d.content);
127
+ String(d.created_at).length + String(d.kind).length + jsonStringBytes(d.id) + jsonStringBytes(d.pubkey) + jsonStringBytes(d.sig) + jsonStringBytes(d.content, !tolerant);
95
128
  if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
96
- throw new Error('oversized evidence event');
97
- const outer = dataArray(d.tags, 64);
129
+ exceed('oversized evidence event');
130
+ const outer = dataArray(d.tags, tolerant ? Infinity : MAX_EVIDENCE_CARRIER_TAGS);
131
+ if (outer.length > MAX_EVIDENCE_CARRIER_TAGS)
132
+ over = true;
98
133
  bytes += 2 + Math.max(0, outer.length - 1);
99
134
  const tags = outer.map(tag => {
100
- const inner = dataArray(tag, 8);
135
+ const inner = dataArray(tag, tolerant ? Infinity : MAX_EVIDENCE_CARRIER_TAG_STRINGS);
136
+ if (inner.length > MAX_EVIDENCE_CARRIER_TAG_STRINGS)
137
+ over = true;
101
138
  bytes += 2 + Math.max(0, inner.length - 1);
102
139
  return inner.map(value => {
103
- if (typeof value !== 'string' || value.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
140
+ if (typeof value !== 'string')
104
141
  throw new Error('invalid or oversized evidence tag');
105
- bytes += jsonStringBytes(value);
142
+ if (value.length > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
143
+ exceed('invalid or oversized evidence tag');
144
+ bytes += jsonStringBytes(value, !tolerant);
106
145
  if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
107
- throw new Error('oversized evidence event');
146
+ exceed('oversized evidence event');
108
147
  return value;
109
148
  });
110
149
  });
111
150
  if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES)
112
- throw new Error('oversized evidence event');
151
+ exceed('oversized evidence event');
113
152
  const event = { id: d.id, pubkey: d.pubkey, created_at: d.created_at, kind: d.kind, tags, content: d.content, sig: d.sig };
114
- const canonical = JSON.stringify(event);
115
- return { event, canonical, bytes };
153
+ return { event, canonical: over ? '' : JSON.stringify(event), bytes, over };
116
154
  }
117
155
  function copyInvitation(invitation) {
118
156
  if (!invitation.persistent || !(invitation.bearer instanceof Uint8Array) || invitation.bearer.length !== 32 || !hex32.test(invitation.inviter))
@@ -124,10 +162,16 @@ function roomId(secret) {
124
162
  room.roomKey.fill(0);
125
163
  return room.roomId;
126
164
  }
165
+ /** The cheap, unsigned part of scope: the authority, a source kind and exactly
166
+ * one two-element `d` tag naming this invitation. No crypto. */
167
+ function inAuthorityScope(event, inviter, invitationId) {
168
+ if (event.pubkey !== inviter || (event.kind !== KINDS.GROUP_INVITATION && event.kind !== KINDS.INVITATION_RETIREMENT))
169
+ return false;
170
+ const d = event.tags.filter(tag => tag[0] === 'd');
171
+ return d.length === 1 && d[0].length === 2 && d[0][1] === invitationId;
172
+ }
127
173
  function signedScope(event, invitation) {
128
- return event.pubkey === invitation.inviter && (event.kind === KINDS.GROUP_INVITATION || event.kind === KINDS.INVITATION_RETIREMENT) &&
129
- event.tags.filter(tag => tag[0] === 'd').length === 1 && event.tags.find(tag => tag[0] === 'd')?.length === 2 &&
130
- event.tags.find(tag => tag[0] === 'd')?.[1] === deriveInvitationId(invitation) && verifyEventUncached(event);
174
+ return inAuthorityScope(event, invitation.inviter, deriveInvitationId(invitation)) && verifyEventUncached(event);
131
175
  }
132
176
  /** Strict evidence path accepts exactly the bodies/tags of supported writers;
133
177
  * permissive legacy decoding and its public request remain unchanged. */
@@ -142,19 +186,21 @@ function decodeEvidenceEvent(event, invitation) {
142
186
  const key = hkdf(sha256, invitation.bearer, undefined, PERSISTENT_INVITATION_LABELS[0], 32);
143
187
  try {
144
188
  const plaintext = nip44.v2.decrypt(event.content, key);
145
- const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays']);
189
+ const body = dataRecord(JSON.parse(plaintext), ['v', 'room', 'secret', 'ends', 'destruct', 'relays', 'storage']);
146
190
  if (body.v !== 3 || typeof body.secret !== 'string' || (body.ends !== undefined && !isRoomEnds(body.ends)) ||
147
- (body.destruct !== undefined && body.destruct !== true) || (body.relays !== undefined && !isInvitationRelays(body.relays)))
191
+ (body.destruct !== undefined && body.destruct !== true) || (body.relays !== undefined && !isInvitationRelays(body.relays)) ||
192
+ (body.storage !== undefined && !isInvitationStorage(body.storage)))
148
193
  return null;
149
194
  secret = base64urlnopad.decode(body.secret);
150
195
  if (secret.length !== 32 || body.room !== roomId(secret))
151
196
  return null;
152
197
  const decoded = { secret, persistent: true, epoch: 0,
153
198
  ...(body.ends === undefined ? {} : { endsAt: body.ends }), ...(body.destruct ? { destruct: true } : {}),
154
- ...(body.relays === undefined ? {} : { relays: [...body.relays] }) };
199
+ ...(body.relays === undefined ? {} : { relays: [...body.relays] }),
200
+ ...(body.storage === undefined ? {} : { storage: [...body.storage], storageAt: event.created_at }) };
155
201
  const expected = JSON.stringify({ v: 3, room: body.room, secret: base64urlnopad.encode(secret),
156
202
  ...(decoded.endsAt === undefined ? {} : { ends: decoded.endsAt }), ...(decoded.destruct ? { destruct: true } : {}),
157
- ...(decoded.relays === undefined ? {} : { relays: decoded.relays }) });
203
+ ...(decoded.relays === undefined ? {} : { relays: decoded.relays }), ...(decoded.storage === undefined ? {} : { storage: decoded.storage }) });
158
204
  const tags = decoded.endsAt === undefined ? [['d', deriveInvitationId(invitation)]] : [['d', deriveInvitationId(invitation)], ['expiration', String(decoded.endsAt)]];
159
205
  accepted = plaintext === expected && JSON.stringify(event.tags) === JSON.stringify(tags);
160
206
  return accepted ? decoded : null;
@@ -277,12 +323,20 @@ export function revalidatePersistentInvitationEvidence(opts) {
277
323
  if (new Set(winners.map(d => JSON.stringify(d.admission.relays))).size > 1)
278
324
  return held('conflicting-relays');
279
325
  const relays = winners[0]?.admission.relays;
326
+ const storageEvents = decoded.filter(d => d.admission.storage !== undefined);
327
+ const newestStorage = storageEvents.length ? Math.max(...storageEvents.map(d => d.event.created_at)) : undefined;
328
+ const storageWinners = storageEvents.filter(d => d.event.created_at === newestStorage);
329
+ if (new Set(storageWinners.map(d => JSON.stringify(d.admission.storage))).size > 1)
330
+ return held('conflicting-storage');
331
+ const storage = storageWinners[0]?.admission.storage;
280
332
  const admission = { secret: first.slice(), persistent: true, epoch: 0,
281
333
  ...(endsAt === undefined ? {} : { endsAt }), ...(destruct.length ? { destruct: true } : {}),
282
- ...(relays === undefined ? {} : { relays: [...relays] }) };
334
+ ...(relays === undefined ? {} : { relays: [...relays] }),
335
+ ...(storage === undefined ? {} : { storage: [...storage], storageAt: newestStorage }) };
283
336
  const contributors = Object.freeze({ room: Object.freeze(decoded.map(d => d.event.id)),
284
337
  ends: Object.freeze(ends.filter(d => d.admission.endsAt === endsAt).map(d => d.event.id)),
285
- destruct: Object.freeze(destruct.map(d => d.event.id)), relays: Object.freeze(winners.map(d => d.event.id)) });
338
+ destruct: Object.freeze(destruct.map(d => d.event.id)), relays: Object.freeze(winners.map(d => d.event.id)),
339
+ ...(storageWinners.length ? { storage: Object.freeze(storageWinners.map(d => d.event.id)) } : {}) });
286
340
  return Object.freeze({ evidence, outcome: Object.freeze({ status: 'admitted', admission, contributors }) });
287
341
  }
288
342
  finally {
@@ -333,29 +387,61 @@ export async function requestPersistentRoomAdmissionEvidence(opts) {
333
387
  return;
334
388
  }
335
389
  try {
336
- unsub = opts.transport.subscribe([{ kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT], authors: [invitation.inviter], '#d': [deriveInvitationId(invitation)] }], event => {
390
+ const invitationId = deriveInvitationId(invitation);
391
+ // Canonical forms that were checked and refused, so a repeat of one costs no crypto.
392
+ const refused = new Set();
393
+ let verifications = 0;
394
+ unsub = opts.transport.subscribe([{ kinds: [KINDS.GROUP_INVITATION, KINDS.INVITATION_RETIREMENT], authors: [invitation.inviter], '#d': [invitationId] }], event => {
337
395
  if (settled)
338
396
  return;
339
397
  try {
340
- // Charge invalid candidates and duplicates before carrier/crypto.
341
- if (received >= MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS)
342
- throw new Error('invitation evidence query capacity exceeded');
398
+ // Every delivery is charged first and bounds parse work. Retained
399
+ // events, retained bytes and signature checks have their own limits.
400
+ if (received >= MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES)
401
+ throw new Error('invitation evidence query capacity exceeded: too many deliveries');
343
402
  received++;
344
- const c = carrier(event);
345
- if (bytes + c.bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
346
- throw new Error('invitation evidence query capacity exceeded');
347
- bytes += c.bytes;
348
- const prior = events.get(c.event.id);
349
- if (prior !== undefined) {
350
- if (prior !== c.canonical)
351
- throw new Error('conflicting duplicate invitation event identity');
403
+ let c;
404
+ try {
405
+ c = carrier(event, true);
406
+ }
407
+ catch {
408
+ return;
409
+ } // malformed beyond size: not evidence, not a failure
410
+ if (!inAuthorityScope(c.event, invitation.inviter, invitationId))
411
+ return;
412
+ // An id already held was verified, so any other carrier with that id
413
+ // has the same signed fields and nothing new to say. A carrier that
414
+ // only claims it cannot verify. Dropping it loses no signed event.
415
+ // An over-limit carrier is remembered by its computed hash and signature,
416
+ // never by the id it claims: a tampered copy that claims a real event's
417
+ // id and signature has a different hash, so it cannot mask the real one.
418
+ const overKey = c.over ? `${getEventHash(c.event)}:${c.event.sig}` : '';
419
+ if (events.has(c.event.id) || refused.has(c.over ? overKey : c.canonical))
420
+ return;
421
+ if (verifications >= MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS)
422
+ throw new Error('invitation evidence query capacity exceeded: too many signature checks');
423
+ verifications++;
424
+ if (c.over) {
425
+ // Over the size limits, so it can never be retained. If the authority
426
+ // signed it, it may be a retirement or a newer list that this query
427
+ // cannot hold: fail closed rather than select without it. An event
428
+ // that only claims the authority's key is dropped.
429
+ if (verifyEventUncached(c.event))
430
+ throw new Error('invitation evidence query refused an event signed by the authority that exceeds the evidence size limits');
431
+ refused.add(overKey);
352
432
  return;
353
433
  }
354
434
  const decoded = decodeEvidenceEvent(c.event, invitation);
355
- if (!decoded)
435
+ if (!decoded) {
436
+ refused.add(c.canonical);
356
437
  return;
438
+ }
357
439
  if (decoded !== 'retired')
358
440
  decoded.secret.fill(0);
441
+ // A valid authority event that no longer fits fails the query: never evict or truncate.
442
+ if (events.size >= MAX_PERSISTENT_INVITATION_EVIDENCE_EVENTS || bytes + c.bytes > MAX_PERSISTENT_INVITATION_EVIDENCE_BYTES)
443
+ throw new Error('invitation evidence query capacity exceeded');
444
+ bytes += c.bytes;
359
445
  events.set(c.event.id, c.canonical);
360
446
  }
361
447
  catch (error) {
@@ -16,6 +16,13 @@ export interface PersistentRoomAdmission {
16
16
  /** The room self-destructs: when it ends, every device deletes what it
17
17
  * wrote and forgets the room. Absent for a room that simply ends. */
18
18
  destruct?: true;
19
+ /** The room's group storage nodes, as its authority last signed them; `[]`
20
+ * when it withdrew them all. Absent from an invitation written before
21
+ * 0.14.0, or when no copy names them. */
22
+ storage?: string[];
23
+ /** The `created_at` of the copy whose `storage` stood: present exactly when
24
+ * `storage` is. A client uses it as the record's version. */
25
+ storageAt?: number;
19
26
  }
20
27
  /** Publish once before sharing. The link plus this envelope is a durable
21
28
  * bearer capability: anybody holding both can learn epoch 0. Retirement
@@ -33,6 +40,11 @@ export declare function encodePersistentInvitation(opts: {
33
40
  * form (see `isInvitationRelays`), else it throws. Omitted, the body is
34
41
  * byte-identical to 0.3.0's. */
35
42
  relays?: readonly string[];
43
+ /** The room's group storage nodes: zero to eight canonical node base URLs,
44
+ * one per host (see `isInvitationStorage`), else it throws. `[]` says the
45
+ * authority withdrew every node. Written after `relays`. Omitted, the body
46
+ * is byte-identical to 0.13.0's. */
47
+ storage?: readonly string[];
36
48
  /** The room self-destructs when it ends, by its time or by its authority
37
49
  * closing it. Written inside the encrypted body only, never as a tag.
38
50
  * Omitted or false, the event is byte-identical to 0.8.0's. */
@@ -9,6 +9,8 @@ import { deriveRoom } from './room.js';
9
9
  import { verifyEventUncached } from './verify.js';
10
10
  import { isRoomEnds, requireRoomEnds } from './expiration.js';
11
11
  import { isInvitationRelays, requireInvitationRelays } from './invitation-relays.js';
12
+ import { isInvitationStorage, requireInvitationStorage } from './invitation-storage.js';
13
+ import { requireEvidenceCarrierLimits } from './evidence-carrier.js';
12
14
  function welcomeKey(invitation) {
13
15
  if (!invitation.persistent)
14
16
  throw new Error('a persistent group invitation is required');
@@ -25,14 +27,15 @@ export function encodePersistentInvitation(opts) {
25
27
  const room = deriveRoom(opts.roomSecret).roomId;
26
28
  const ends = opts.endsAt === undefined ? undefined : requireRoomEnds(opts.endsAt, opts.now);
27
29
  const relays = opts.relays === undefined ? undefined : requireInvitationRelays(opts.relays);
28
- return finalizeEvent({
30
+ const storage = opts.storage === undefined ? undefined : requireInvitationStorage(opts.storage);
31
+ return requireEvidenceCarrierLimits(finalizeEvent({
29
32
  kind: KINDS.GROUP_INVITATION,
30
33
  created_at: opts.now,
31
34
  tags: ends === undefined ? [['d', deriveInvitationId(opts.invitation)]] : [['d', deriveInvitationId(opts.invitation)], ['expiration', String(ends)]],
32
35
  content: nip44.v2.encrypt(JSON.stringify({
33
- v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }),
36
+ v: 3, room, secret: base64urlnopad.encode(opts.roomSecret), ...(ends === undefined ? {} : { ends }), ...(opts.destruct ? { destruct: true } : {}), ...(relays === undefined ? {} : { relays }), ...(storage === undefined ? {} : { storage }),
34
37
  }), welcomeKey(opts.invitation)),
35
- }, opts.inviterSk);
38
+ }, opts.inviterSk), 'group invitation');
36
39
  }
37
40
  export function decodePersistentInvitation(event, invitation) {
38
41
  try {
@@ -62,6 +65,10 @@ export function decodePersistentInvitation(event, invitation) {
62
65
  // would write; a malformed one refuses the envelope rather than half of it.
63
66
  if (body.relays !== undefined && !isInvitationRelays(body.relays))
64
67
  return null;
68
+ // The same for the room's storage nodes: `[]` is valid (withdrawn), a
69
+ // malformed list refuses the envelope.
70
+ if (body.storage !== undefined && !isInvitationStorage(body.storage))
71
+ return null;
65
72
  // Self-destruct is `true` or absent; anything else is a malformed
66
73
  // envelope, refused as a malformed end is.
67
74
  if (body.destruct !== undefined && body.destruct !== true)
@@ -69,6 +76,10 @@ export function decodePersistentInvitation(event, invitation) {
69
76
  const admission = ends === undefined ? { secret, persistent: true, epoch: 0 } : { secret, persistent: true, epoch: 0, endsAt: ends };
70
77
  if (body.relays !== undefined)
71
78
  admission.relays = [...body.relays];
79
+ if (body.storage !== undefined) {
80
+ admission.storage = [...body.storage];
81
+ admission.storageAt = event.created_at;
82
+ }
72
83
  if (body.destruct === true)
73
84
  admission.destruct = true;
74
85
  return admission;
@@ -89,6 +100,7 @@ export function requestPersistentRoomAdmission(opts) {
89
100
  let settled = false;
90
101
  let admission;
91
102
  let relaysAt = -1;
103
+ let storageAt = -1;
92
104
  let unsub = () => { };
93
105
  const timer = setTimeout(() => finish(new Error('the group invitation could not be loaded from its relays')), opts.timeoutMs ?? 15_000);
94
106
  function finish(error) {
@@ -136,6 +148,16 @@ export function requestPersistentRoomAdmission(opts) {
136
148
  relaysAt = event.created_at;
137
149
  else if (admission?.relays !== undefined)
138
150
  decoded.relays = admission.relays;
151
+ // The storage nodes merge the same way, independently of the relays:
152
+ // the newest copy that names them stands, `[]` included; a copy that
153
+ // names none says nothing, so the keeper's plain copy never erases
154
+ // the list; on equal timestamps the first heard stays.
155
+ if (decoded.storage !== undefined && event.created_at > storageAt)
156
+ storageAt = event.created_at;
157
+ else if (admission?.storage !== undefined) {
158
+ decoded.storage = admission.storage;
159
+ decoded.storageAt = admission.storageAt;
160
+ }
139
161
  admission = decoded;
140
162
  }, () => {
141
163
  if (admission)
@@ -49,17 +49,50 @@ Unknown fields, non-data descriptors, custom prototypes, sparse arrays and
49
49
  noncanonical reload strings are refused. JavaScript proxies are outside this
50
50
  plain-data transport boundary; callers must not pass executable objects.
51
51
 
52
- Queries admit at most 64 delivered candidates, including invalid candidates and
53
- duplicates, with 16,384 UTF-8 bytes per canonical carrier and 262,144 UTF-8 bytes
54
- across all delivered canonical candidates. Work/bytes are charged before crypto;
55
- candidate 65 is refused before carrier copying. Retained evidence has the same
56
- limits. Event tags are bounded to 64 arrays of at most eight strings. These are
57
- evidence-profile limits, not new wire limits for the legacy API. Exact duplicates
58
- cost no additional retained capacity but consume query work and byte budget;
59
- the same ID with a different carrier fails the entire query. Capacity is checked
60
- before admitting another candidate, with no eviction or truncated success.
61
- Malformed/oversized object framing fails the query. Well-framed events that
62
- fail signature, capability or scope validation are ignored, never retained.
52
+ Retained evidence holds at most 64 events, with 16,384 UTF-8 bytes per
53
+ canonical carrier and 262,144 UTF-8 bytes across all retained carriers. Event
54
+ tags are bounded to 64 arrays of at most eight strings. These are
55
+ evidence-profile limits, not new wire limits for the legacy API.
56
+
57
+ A query reads at most 512 deliveries
58
+ (`MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES`): every relay's copy of every
59
+ event, repeats, other authors and junk alike, each charged before it is parsed.
60
+ Past that the query fails, because an event that could matter may be among
61
+ those it did not read; delivery 513 is never inspected. The bound limits parse
62
+ work only. It is eight times the retained limit so that 64 events heard from
63
+ each of eight relays still fit.
64
+
65
+ What is delivered is then sorted cheaply, before any signature check:
66
+
67
+ - A malformed carrier (not plain data, a wrong type, bad hex), an event not by the inviter, not kind 1463
68
+ or 1461, or without exactly one `d` tag naming the invitation, is dropped.
69
+ It is not evidence and it does not fail the query.
70
+ - An exact repeat, another carrier under an id already held, and a carrier
71
+ already checked and refused, are dropped. A held id was verified, so a second
72
+ carrier with it has the same signed fields.
73
+ - An event in scope but over the carrier size limits (16,384 bytes, 64 tags,
74
+ eight strings in a tag) can never be retained. It is hashed and verified once,
75
+ charged to the signature bound below. If the authority signed it, the query
76
+ fails, because it may be a retirement or a newer list that no evidence can
77
+ hold. If not, it is dropped, and a repeat of it costs nothing.
78
+ - Anything else in scope costs one signature check. A query is charged at most
79
+ 128 (`MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS`): room for 64 retained
80
+ events and 64 more in-scope events that fail, forged or not a supported body.
81
+ The 129th is never checked and the query fails. The bound counts checks, not
82
+ Schnorr verifications. A retained retirement is verified twice, and the
83
+ finished query revalidates every event it kept, so a query that succeeds can
84
+ do up to 128 + 64 verifications. A forgery fails at its first, so a hostile
85
+ relay can cause at most 128.
86
+ - A checked, valid authority event is retained. If that would be a 65th event
87
+ or pass 262,144 bytes, the query fails. Nothing is evicted or truncated.
88
+
89
+ An in-scope, well-formed, validly signed authority event is never dropped, and
90
+ neither is an over-limit one: each is retained or fails the query. What the
91
+ query drops is what cannot be an authority event (malformed, out of scope,
92
+ unsigned by the authority) or repeats what it already holds.
93
+ `encodePersistentInvitation` refuses to sign an event over the size limits, so
94
+ only another writer can produce one. The failures above reject with
95
+ `PersistentInvitationEvidenceQueryError`.
63
96
 
64
97
  Evidence records, carrier strings and ID arrays are frozen owned copies.
65
98
  String memory is not claimed securely erased. Each admitted result has a fresh
@@ -102,8 +135,8 @@ custody and later independent revalidation.
102
135
  ## Completion and failure
103
136
 
104
137
  The query waits for the transport's EOSE even after an invitation or retirement
105
- arrives. Timeout, abort, subscription failure, contradictory duplicate or
106
- capacity failure rejects with `PersistentInvitationEvidenceQueryError`.
138
+ arrives. Timeout, abort, subscription failure or a capacity failure (deliveries,
139
+ signature checks, retained events or retained bytes) rejects with `PersistentInvitationEvidenceQueryError`.
107
140
  Its bounded `observation` is explicitly `completion: 'incomplete'`, diagnostic
108
141
  custody only. The revalidator always refuses incomplete observations. They
109
142
  cannot be relabelled to establish completeness; completion metadata itself is
package/docs/seal-key.md CHANGED
@@ -121,8 +121,8 @@ it costs the epoch. So `oneTimeSender` stays off in every writer until readers
121
121
  have shipped on every client and had a release in the field. Off, every
122
122
  event is byte-identical to before.
123
123
 
124
- `hostRoomEpoch` has no switch for it yet; adding one is part of turning
125
- writers on, alongside the keeper's own rekeys.
124
+ `hostRoomEpoch` takes `oneTimeSender` too and passes it to every grant it
125
+ writes; it is off by default, like the encoders.
126
126
 
127
127
  ## Vectors
128
128
 
package/llms.txt CHANGED
@@ -10,7 +10,7 @@ ESM-only, Node.js 22.13+. Four entry points: `@forgesworn/fold-kit` (circle prim
10
10
  npm install @forgesworn/fold-kit
11
11
  ```
12
12
 
13
- This file describes `main` (version 0.13.0), which can run ahead of the npm `latest` release; compare `npm view @forgesworn/fold-kit version` with the version of the API you need. To use an unreleased change, pin an immutable Git commit (a full SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`); the `prepare` script builds `dist/` on install:
13
+ This file describes `main` (version 0.14.0), which can run ahead of the npm `latest` release; compare `npm view @forgesworn/fold-kit version` with the version of the API you need. To use an unreleased change, pin an immutable Git commit (a full SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`); the `prepare` script builds `dist/` on install:
14
14
 
15
15
  ```bash
16
16
  npm install "github:forgesworn/fold-kit#<commit>"
@@ -124,6 +124,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
124
124
  - Same optional entry: `revalidatePersistentInvitationSource({ event, invitation, expectedRoomSecret })` independently authenticates one actual encoder Event or exact canonical stored carrier against a mandatory room-root pin. Returns a frozen `PersistentInvitationSource` with `version`, `invitationId`, `inviter`, `eventId`, `createdAt`, `event` string and owned `admission`. No completion/EOSE, publication, newest-selection, currentness or non-retirement claim. Rejects retirement, unsupported bodies/tags, forged signature caches, noncanonical strings and oversize before parsing/crypto. Creator clients retain exact event bytes and publication intent before pools; retry those bytes under fresh effect guards. Erase returned admission.secret after use.
125
125
  - Conference rooms: `encodePersistentInvitation({ ..., endsAt })` puts `ends` in the body and `['expiration', String(endsAt)]` on the 1463; `decodePersistentInvitation` returns `endsAt` and is null when an `expiration` tag disagrees with the body; `encodeInvitationRetirement({ ..., endsAt })` tags the 1461 the same way
126
126
  - Room relays: `encodePersistentInvitation({ ..., relays })` puts the room's own relays (1-8 distinct canonical `wss://host/` URLs, else it throws) in the body after `ends`; the decoder returns `relays` and is null on a malformed list; `requestPersistentRoomAdmission` keeps the newest signed copy's list (a copy without one says nothing); `isInvitationRelays`, `requireInvitationRelays`, `MAX_INVITATION_RELAYS` (8)
127
+ - Room storage nodes: `encodePersistentInvitation({ ..., storage })` puts the room's group storage nodes (0-8 canonical base URLs on distinct hosts, e.g. `https://grove.example.net`; `http` only for `localhost`, `127.x.y.z` or an onion host; no default port, no trailing slash, no user info, query or fragment; else it throws) last in the body after `relays`, never a tag; `[]` means the authority withdrew every node, an absent key says nothing and leaves the body byte-identical to 0.13.0's. The decoder returns `storage` and `storageAt` (the `created_at` of the copy whose list stood) and is null on a malformed list; `requestPersistentRoomAdmission` and the strict evidence path keep the newest signed copy that names storage (a copy without the key says nothing), independently of `relays`; the strict path holds a same-second disagreement as `conflicting-storage` and `contributors.storage` names the copy that stood (present only when a copy names storage). `storageNodeFromBase(text)` → `{ base, server }` canonicalises an operator's URL (throws a message fit to show an operator); `storageNodeServer(base)` → the `server` (host) of a canonical base by grammar alone, `undefined` when not canonical, no URL parser; `isStorageNodeBase`, `isInvitationStorage`, `requireInvitationStorage`, `MAX_INVITATION_STORAGE_NODES` (8), `MAX_STORAGE_NODE_URL_LENGTH` (256). Vectors: `vectors/storage-vectors.json`
127
128
  - Self-destructing rooms (see `docs/room-destruct.md`): `encodePersistentInvitation({ ..., destruct: true })` puts `"destruct": true` in the encrypted body after `ends` (valid without `ends`), never a tag; `decodePersistentInvitation` returns `destruct: true` and is null when the field is present and not `true`; `requestPersistentRoomAdmission` keeps `destruct` if any valid copy carries it (sticky, like the earliest `ends`). `encodeInvitationRetirement({ ..., ended: true, destruct: true })` writes `{"v":1,"ended":true,"destruct":true}` (plain JSON, like `ended`) and throws on `destruct` without `ended`; `decodeInvitationRetirementNotice` returns `{ ended, destruct?: true }`, believing `destruct` only beside `ended`. `encodeRekeyEvent({ ..., closed: true, destruct: true })` writes it in the encrypted body after `closed` and throws without `closed`; `RekeyNotice.destruct` / `RekeyEvidence.destruct` are set only on a closing body, and a flag on an open rekey is dropped, not refused. Without the flag every event is byte-identical to 0.8.0, and a 0.8.0 reader ignores it
128
129
  - `withExpiration(tags, expiresAt?)` - NIP-40 rule for a room that ends: add the tag, keep an earlier one, lower a later one; `isRoomEnds`, `requireRoomEnds(endsAt, now)`, `MAX_ROOM_ENDS_SECONDS` (30 days)
129
130
  - Epoch encoders (`encodeRekeyEvent`, `encodeEpochRequest`, `encodeEpochGrant`) and `hostRoomEpoch` / `requestRoomEpoch` take `expiresAt?` for a conference room
@@ -139,7 +140,7 @@ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is legacy
139
140
  - Quiet replies (see `docs/quiet-replies.md`): `hostRoomEpoch` waits a random `replyDelayMs` (`[min, max]` ms, default `DEFAULT_REPLY_DELAY_MS` = `[500, 2000]`, `[0, 0]` answers at once) before every grant or refusal, deciding when the wait ends; closing cancels waits. A participant is sent `removed`/`closed` at most once per `refuseAgainAfter` seconds (default `REFUSE_AGAIN_AFTER_SECONDS` = 3600) and `unknown` once per `reportUnknownEvery` (60); at most `maxRefusalsPerMinute` refusals (default `MAX_REFUSALS_PER_MINUTE` = 20) go out in any minute in all. Grants are never limited. A suppressed refusal still calls `onRefused`/`onUnknown`; its asker times out with the plain error. `random` and `schedule` (`ReplySchedule`) are injectable. `RefusalLimiter` (bounded to `REFUSAL_LIMITER_CAPACITY` = 256 keys) and `replyDelay(range, random)` are exported. Events and vectors are unchanged
140
141
  - 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)
141
142
  - 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
142
- - 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 - One-time senders (see `docs/seal-key.md`, "One-time senders"): `encodeRekeyEvent` and `encodeEpochGrant` take `oneTimeSender: true`, sealing every copy from a fresh key named in one `["from", <x-only pubkey>]` tag (still authority-signed) and zeroing it, so a later copy of the authority key opens no past copy; readers open with `sealSender(event)` (the `from` key, else the signer; null when doubled or off the curve: a rekey reads without a secret, a grant is refused). Keep writers off until every client's readers have shipped; off, events are byte-identical to 0.12.0.
143
+ - 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 - One-time senders (see `docs/seal-key.md`, "One-time senders"): `encodeRekeyEvent` and `encodeEpochGrant` take `oneTimeSender: true`, sealing every copy from a fresh key named in one `["from", <x-only pubkey>]` tag (still authority-signed) and zeroing it, so a later copy of the authority key opens no past copy; readers open with `sealSender(event)` (the `from` key, else the signer; null when doubled or off the curve: a rekey reads without a secret, a grant is refused). `hostRoomEpoch({ oneTimeSender: true })` does the same for every grant the desk writes. Keep writers off until every client's readers have shipped; off, events are byte-identical to 0.12.0.
143
144
  - 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
144
145
  - `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
145
146
  - `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgesworn/fold-kit",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -32,6 +32,7 @@
32
32
  "vectors/seal-vectors.json",
33
33
  "vectors/schedule-vectors.json",
34
34
  "vectors/destruct-vectors.json",
35
+ "vectors/storage-vectors.json",
35
36
  "LICENSE",
36
37
  "README.md",
37
38
  "llms.txt",
@@ -55,6 +56,7 @@
55
56
  "generate-seal": "node scripts/generate-seal.mjs",
56
57
  "generate-schedule": "node scripts/generate-schedule.mjs",
57
58
  "generate-destruct": "node scripts/generate-destruct.mjs",
59
+ "generate-storage": "node scripts/generate-storage.mjs",
58
60
  "bundle-check": "node scripts/bundle-check.mjs",
59
61
  "tarball-smoke": "node scripts/tarball-smoke.mjs",
60
62
  "vectors": "vitest run vectors/",