@forgesworn/fold-kit 0.10.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,10 +13,12 @@ vectors (see EXTRACTION.md). Framework-free: owns no storage, UI, relay
13
13
  defaults or identity keys, and no relay I/O of its own - callers inject a
14
14
  transport.
15
15
 
16
- **Status:** T1 extraction complete (see EXTRACTION.md). Not yet published to
17
- npm under this API; pin an immutable Git commit until it is (see Install).
18
- KithMoot itself has not cut over to this kit yet - it still carries its own
19
- copy of these modules.
16
+ **Status:** Version 0.13.0 on `main`. Published on npm as
17
+ `@forgesworn/fold-kit`, but `main` can run ahead of the npm `latest` release, so
18
+ compare `npm view @forgesworn/fold-kit version` with this repository's
19
+ `package.json` before relying on an API listed here. KithMoot has cut over to
20
+ this kit and pins an exact npm version; its moved files are re-export shims
21
+ (see EXTRACTION.md).
20
22
 
21
23
  ## Install
22
24
 
@@ -24,14 +26,12 @@ copy of these modules.
24
26
  npm install @forgesworn/fold-kit
25
27
  ```
26
28
 
27
- Not yet published to npm. Until it is, pin an immutable Git commit:
29
+ To use a change that has not been released, pin an immutable Git commit (a full
30
+ SHA, for example from `git ls-remote https://github.com/forgesworn/fold-kit main`).
31
+ The package's `prepare` script builds `dist/` on install:
28
32
 
29
- ```json
30
- {
31
- "dependencies": {
32
- "@forgesworn/fold-kit": "github:forgesworn/fold-kit#<commit>"
33
- }
34
- }
33
+ ```bash
34
+ npm install "github:forgesworn/fold-kit#<commit>"
35
35
  ```
36
36
 
37
37
  ### Peer dependencies
@@ -46,6 +46,12 @@ Not yet published to npm. Until it is, pin an immutable Git commit:
46
46
  }
47
47
  ```
48
48
 
49
+ Install the peers yourself, keeping `@noble/hashes` on 1.x:
50
+
51
+ ```bash
52
+ npm install "nostr-tools@>=2.24.2 <3" "@noble/hashes@^1.8.0" "@noble/curves@^2.0.1"
53
+ ```
54
+
49
55
  Peers, not dependencies, so a consumer such as KithMoot keeps a single copy
50
56
  of `nostr-tools` (guarded by its own `overrides` and version-guard test) and
51
57
  of the noble libraries, at the same versions KithMoot already pins.
@@ -57,12 +63,69 @@ satisfy this kit's imports as written.
57
63
 
58
64
  ESM only (`"type": "module"`). Node.js `>=22.13`.
59
65
 
66
+ ## Quick start
67
+
68
+ Derive a room, then publish and load a stored group invitation through a
69
+ `RelayTransport` you supply (this one is an in-memory stub; a real transport
70
+ talks to relays and applies the filters):
71
+
72
+ ```ts
73
+ import {
74
+ generateRoomSecret,
75
+ deriveRoom,
76
+ deriveEpoch,
77
+ localIdentity,
78
+ createRoomInvitation,
79
+ encodePersistentInvitation,
80
+ requestPersistentRoomAdmission,
81
+ type RelayTransport,
82
+ } from '@forgesworn/fold-kit'
83
+ import { generateSecretKey, type Event } from 'nostr-tools/pure'
84
+
85
+ // The person: a local signer. An external one (NIP-07, NIP-46) fits the same seam.
86
+ const me = localIdentity(generateSecretKey())
87
+
88
+ // The room: one secret opens epoch 0; later epochs come from rekeys.
89
+ const roomSecret = generateRoomSecret()
90
+ const { roomId, roomKey } = deriveRoom(roomSecret)
91
+ const epoch0 = deriveEpoch({ epoch: 0, secret: roomSecret }) // { epoch: 0, id: roomId, key: roomKey }
92
+
93
+ // A RelayTransport stub: stores events, replays them on subscribe, then signals EOSE.
94
+ const stored: Event[] = []
95
+ const transport: RelayTransport = {
96
+ async publish(event) { stored.push(event) },
97
+ subscribe(_filters, onEvent, onEose) {
98
+ queueMicrotask(() => { for (const event of stored) onEvent(event); onEose?.() })
99
+ return () => {}
100
+ },
101
+ close() {},
102
+ }
103
+
104
+ // Invitation flow (v3 stored group invitation): the inviter publishes once, and a
105
+ // guest holding the link loads it from the relays with no responder online.
106
+ const { invitation, inviterSk } = createRoomInvitation(true)
107
+ await transport.publish(encodePersistentInvitation({
108
+ invitation, inviterSk, roomSecret, now: Math.floor(Date.now() / 1000),
109
+ }))
110
+ const admission = await requestPersistentRoomAdmission({ transport, invitation })
111
+ // admission.secret is the room secret: deriveRoom(admission.secret).roomId === roomId
112
+ ```
113
+
114
+ Invitation versions: v1 (`encodeJoinUrl`, the room secret in the link) is
115
+ legacy, and new links go through `createRoomInvitation`. Use
116
+ `createRoomInvitation()` for the v2 live rendezvous, which needs a responder
117
+ online (`hostRoomInvitation` answers, `requestRoomAdmissionCapability` joins),
118
+ or `createRoomInvitation(true)` for the v3 stored group invitation above, which
119
+ needs none.
120
+
60
121
  ## Exports
61
122
 
62
- Two entry points: `@forgesworn/fold-kit` (everything) and
123
+ Four entry points: `@forgesworn/fold-kit` (circle primitives),
63
124
  `@forgesworn/fold-kit/lane` (just the lane helpers, for a consumer that only
64
125
  wants to classify a relay as `public`/`sheltered`/`direct` without pulling in
65
- the rest).
126
+ the rest), `@forgesworn/fold-kit/mls-admission` (opt-in v4 evidence codec), and
127
+ `@forgesworn/fold-kit/persistent-invitation-evidence` (bounded stored invitation
128
+ observation and independent revalidation).
66
129
 
67
130
  ### Identity, verification, hex
68
131
 
@@ -107,19 +170,54 @@ the rest).
107
170
  `hostRoomInvitation` distinguishes approval from grant publication. Its
108
171
  `onGrantPublished(request)` and legacy `onAdmitted(device)` callbacks run only
109
172
  after the injected transport acknowledges the grant's publication.
110
- `onGrantFailed(request, error)` reports a rejected or throwing publication;
173
+ `onGrantFailed(request, error)` reports a rejected or throwing publication,
174
+ or approval of a request which has expired;
111
175
  neither success callback runs for that failure. Closing or retiring the host
112
176
  suppresses callbacks from outstanding sends. Publication acknowledgement does
113
177
  not prove that a guest received the grant or joined; the guest validates its
114
- grant independently. Observer exceptions do not change the publish outcome.
178
+ grant independently. Observer exceptions do not change the publish outcome. Fresh retries of the same
179
+ signed request resend the exact cached grant after the configured quiet delay;
180
+ overlapping approval, wait and publication work is coalesced. Approval and
181
+ success callbacks each occur at most once per request. Failed sends may recover
182
+ on a later retry. The host retains up to 256 live decisions, including declines;
183
+ at capacity it refuses new work until settled requests expire. Close, retirement,
184
+ request freshness and responder authority still gate every send.
185
+
186
+ For a device that answers automatically, `grantDelayMs: [min, max]` waits a
187
+ random time before each grant, and `coordinate: true` lets several delegated
188
+ devices share one link: each waits (default 500 to 2000 ms) and stands down,
189
+ at most once per request, if another responder's grant to the same
190
+ requester appears. Both are off by default, and the wire format is
191
+ unchanged. See `docs/quiet-replies.md`.
192
+
193
+ An invitation request's `participant` is an unverified account claim.
194
+ Automatic admission of a previously invited account must use
195
+ `verifiedParticipant`, which is present only after a fresh signature check of
196
+ an account proof bound to this invitation, ephemeral request device and request
197
+ timestamp. `encodeInvitationAccountProof` creates that proof through the
198
+ supplied matching `ParticipantIdentity`. It grants no room access itself and
199
+ stays inside the bearer-encrypted request, rather than being published as a
200
+ public account event. Invalid or missing proofs leave a request unverified;
201
+ hosts can still make an explicit decision for anonymous and older clients.
202
+
203
+ Pass `identity` to `requestRoomAdmissionCapability` (or `requestRoomAdmission`,
204
+ its secret-only wrapper) to create the proof automatically. If `participant` is also supplied, it must
205
+ match the signer; a signer failure rejects the request without falling back to
206
+ another identity. `signal` cancels signing or waiting. Signing and relay waiting
207
+ share one deadline, capped at the request's 90-second freshness window, and
208
+ late signatures or grants cannot revive a cancelled, expired or retired request.
209
+ Retrying publication uses the same signed request. A fresh human retry starts a
210
+ new exchange. The helper copies a supplied `requesterSk` before signing and
211
+ erases only its owned copy on settlement. Hosts recheck freshness when a human
212
+ decision completes, so approval cannot extend an expired request's authority.
115
213
 
116
214
  - `parseRoomLink`, `encodeRoomLink`, `RoomLink` - the v1/v2/v3 link envelope
117
215
  - `createRoomInvitation`, `roomInvitation`, `deriveInvitationId`,
118
- `encodeInvitationRequest`, `decodeInvitationRequest`,
216
+ `encodeInvitationAccountProof`, `encodeInvitationRequest`, `decodeInvitationRequest`,
119
217
  `verifyInvitationDelegation`, `encodeInvitationGrant`,
120
218
  `decodeRoomAdmissionGrant`, `encodeInvitationRetirement`,
121
219
  `decodeInvitationRetirementNotice`, `hostRoomInvitation`,
122
- `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
220
+ `requestRoomAdmissionCapability`, `requestRoomAdmission` - the v2 live rendezvous invitation, its delegation
123
221
  chain and retirement
124
222
  - `encodePersistentInvitation`, `decodePersistentInvitation`,
125
223
  `requestPersistentRoomAdmission` - the v3 stored group invitation (1463).
@@ -127,6 +225,23 @@ grant independently. Observer exceptions do not change the publish outcome.
127
225
  conference room: the encrypted body carries `ends` and the event a NIP-40
128
226
  `expiration` tag, so relays drop it when the room ends; the decoder returns
129
227
  `endsAt` and refuses a tag that disagrees with the body
228
+ - From `@forgesworn/fold-kit/persistent-invitation-evidence`,
229
+ `requestPersistentRoomAdmissionEvidence` and
230
+ `revalidatePersistentInvitationEvidence` retain and independently recheck a
231
+ bounded set of canonical signed invitation/retirement carriers, with exact
232
+ invitation and contributing event IDs. The additive evidence path holds on
233
+ conflicting newest equal-time relay lists; the legacy request is unchanged.
234
+ EOSE is local observation metadata, never proof of current membership or
235
+ global non-retirement. Persist the evidence in protected custody before
236
+ using its decoded fields. See [the evidence contract](docs/persistent-invitation-evidence.md).
237
+ - The same optional entry exports `revalidatePersistentInvitationSource` for
238
+ an actual encoder event or its exact canonical retained string. It requires
239
+ the invitation capability and exact room-root pin, independently verifies
240
+ the signature and supported body/tags, and returns one canonical carrier
241
+ plus owned decoded fields. This single source has no EOSE, publication,
242
+ latest-selection or current-membership claim. Creators retain that event
243
+ and their publication intent before opening a pool, then retry the same
244
+ bytes; protected persistence and fresh effect guards belong to the client.
130
245
  - Room relays: pass `relays` (one to eight distinct safe relay URLs in
131
246
  canonical `normalizeURL` form, e.g. `wss://relay.example.com/`) and the
132
247
  encrypted body carries them after `ends`. Every member's pool includes
@@ -161,6 +276,14 @@ grant independently. Observer exceptions do not change the publish outcome.
161
276
  catch-up for a device that missed a rekey
162
277
  - `hostRoomEpoch`, `requestRoomEpoch`, `EpochRefusedError` - the live
163
278
  request/grant desk
279
+ - Quiet replies (`docs/quiet-replies.md`): `hostRoomEpoch` waits a random
280
+ `replyDelayMs` (default `[500, 2000]`) before any answer, sends a
281
+ participant a `removed` or `closed` refusal once per `refuseAgainAfter`
282
+ (default 3600 s) and `unknown` once per `reportUnknownEvery` (60 s), and
283
+ caps refusals at `maxRefusalsPerMinute` (20). Grants are never limited.
284
+ `RefusalLimiter`, `replyDelay` and the `DEFAULT_REPLY_DELAY_MS`,
285
+ `REFUSE_AGAIN_AFTER_SECONDS`, `MAX_REFUSALS_PER_MINUTE` and
286
+ `REFUSAL_LIMITER_CAPACITY` constants are exported
164
287
  - Member epoch catch-up (`docs/member-epoch-catch-up.md`): any current
165
288
  member can bring an admitted, non-removed device up to date when the
166
289
  authority is offline, and the device checks the answer against the
@@ -187,6 +310,11 @@ grant independently. Observer exceptions do not change the publish outcome.
187
310
  `sealSks` on the readers; `credentialFor` on both desks;
188
311
  `credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
189
312
  `sealCredential`. Vectors: `vectors/seal-vectors.json`
313
+ - One-time senders (`docs/seal-key.md`, "One-time senders"):
314
+ `oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
315
+ copy from a fresh key named in a `from` tag, so a later copy of the
316
+ authority's key opens no past copy; readers use `sealSender`. Off by
317
+ default until every client's readers have shipped.
190
318
  - Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
191
319
  `encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
192
320
  removes nobody, read back as `RekeyNotice.scheduled` and
@@ -290,3 +418,36 @@ admission before publishing, including at epoch zero. No application enables
290
418
  this profile automatically. See [the wire profile and integration gates](docs/live-persistent-admission.md).
291
419
  Frozen synthetic fixtures are in `vectors/live-persistent-vectors.json`; run
292
420
  `npm run build && npm run generate-live-persistent` to regenerate them.
421
+
422
+ ## Authenticated invitation refusal (0.12.0)
423
+
424
+ `encodeInvitationDecline` and `decodeInvitationDecline` exchange a private,
425
+ request-bound refusal from the pinned inviter or a current delegated responder.
426
+ The version-3 refusal body uses reply kind 20467 and contains no room secret or
427
+ new delegation. Legacy version-2 grant readers ignore it.
428
+ `requestRoomAdmissionCapability` stops retries and rejects with
429
+ `InvitationDeclinedError` after verifying a refusal. Existing host `admit` hooks
430
+ returning false still leave requests unanswered; hosts must explicitly publish
431
+ a refusal using their guarded, acknowledged decision path. No UI integration or
432
+ physical acceptance is claimed by this protocol release.
433
+
434
+ ### MLS admission v4 evidence codec
435
+
436
+ The separate opt-in v4 codec binds an encrypted account/device request to signed
437
+ keeper consent and an expected MLS group. It uses existing 20466/20467 carriers,
438
+ strict canonical arrays and a fixed payload bucket. Existing room links/codecs
439
+ are unchanged. Import from `@forgesworn/fold-kit/mls-admission`; see [API and boundaries](docs/mls-admission-v4.md).
440
+
441
+ Exports: `encodeMlsInvitationV4`, `readMlsInvitationV4`,
442
+ `mlsAdmissionProofTemplatesV4`, `encodeMlsAdmissionRequestV4`,
443
+ `decodeMlsAdmissionRequestV4`, `readMlsAdmissionRequestV4`,
444
+ `encodeMlsAdmissionAnswerV4`, `decodeMlsAdmissionAnswerV4`,
445
+ `readMlsAdmissionAnswerV4`, `MLS_ADMISSION_LABELS`,
446
+ `MLS_ADMISSION_PLAINTEXT_BYTES`, `MLS_ADMISSION_CIPHERTEXT_CHARACTERS`;
447
+ types `VerifiedMlsAdmissionRequest` and `VerifiedAdmissionAnswer`.
448
+
449
+ Verified answers are evidence only. Actual admission requires new purpose-bound
450
+ vault proof and authoritative live box/Link role-witness APIs, a conditional
451
+ keeper revision at the effect boundary, current custody/consent, durable counter
452
+ and one-shot operation storage, and VMLS/Welcome verification. No browser
453
+ admission, authority factory, transport, storage or automatic retry is provided.
package/dist/epoch.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type Event } from 'nostr-tools/pure';
2
2
  import type { RelayTransport } from './transport.js';
3
3
  import type { DeviceCredential, KindredProof, RoomPolicy } from './types.js';
4
4
  import type { MemberEpochSource } from './member-epoch.js';
5
+ import { type ReplyDelayRange, type ReplySchedule } from './quiet-reply.js';
5
6
  /**
6
7
  * Room epochs: how a member is removed.
7
8
  *
@@ -133,6 +134,13 @@ export interface EncodeRekeyOptions {
133
134
  /** A conference room's end, in unix seconds: the event carries it as a
134
135
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
135
136
  expiresAt?: number;
137
+ /** Seal the copies from a one-time key, named in a `from` tag and
138
+ * discarded once they are sealed, instead of from the authority's key.
139
+ * Readers from fold-kit 0.13.0 open either; an older reader opens no copy
140
+ * from a one-time key, so turn this on only once readers have shipped
141
+ * (docs/seal-key.md, "One-time senders"). Omitted, the event is
142
+ * byte-identical to before. */
143
+ oneTimeSender?: boolean;
136
144
  }
137
145
  /** A rekey recipient: a device, and the newest credential the sender holds
138
146
  * for it. */
@@ -329,6 +337,13 @@ export interface EncodeEpochGrantOptions {
329
337
  /** A conference room's end, in unix seconds: the event carries it as a
330
338
  * NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
331
339
  expiresAt?: number;
340
+ /** Seal the copies from a one-time key, named in a `from` tag and
341
+ * discarded once they are sealed, instead of from the authority's key.
342
+ * Readers from fold-kit 0.13.0 open either; an older reader opens no copy
343
+ * from a one-time key, so turn this on only once readers have shipped
344
+ * (docs/seal-key.md, "One-time senders"). Omitted, the event is
345
+ * byte-identical to before. */
346
+ oneTimeSender?: boolean;
332
347
  }
333
348
  /** Answer one request: the current epoch sealed to the asking device, or a
334
349
  * refusal it alone can read. */
@@ -413,15 +428,34 @@ export interface HostRoomEpochOptions {
413
428
  * is making `known` say yes; the requester's next ask is then granted. */
414
429
  onUnknown?: (request: EpochRequest) => void;
415
430
  /** Seconds before a participant still unknown and still asking is
416
- * reported again. Default 60. */
431
+ * reported again. Default 60. An `unknown` answer goes on the wire at
432
+ * the same cadence: a fresh request inside it is reported to nobody and
433
+ * answered with silence. */
417
434
  reportUnknownEvery?: number;
418
435
  policy?: RoomPolicy;
419
436
  legacyParticipants?: ReadonlySet<string>;
420
437
  now?: () => number;
421
438
  onGranted?: (request: EpochRequest) => void;
439
+ /** Called for every refusal, including one the limits below kept off the
440
+ * wire. */
422
441
  onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
423
442
  /** A conference room's end: every grant carries it as an expiration. */
424
443
  expiresAt?: number;
444
+ /** Seconds before the same participant is sent a `removed` or `closed`
445
+ * refusal again. Default 3600 (`REFUSE_AGAIN_AFTER_SECONDS`). A request
446
+ * inside it is not answered, so the asker times out instead. */
447
+ refuseAgainAfter?: number;
448
+ /** Refusals of any kind published in any 60 seconds, whoever asks.
449
+ * Default 20 (`MAX_REFUSALS_PER_MINUTE`). Grants are never limited. */
450
+ maxRefusalsPerMinute?: number;
451
+ /** The random wait, in milliseconds, before any answer is published,
452
+ * grant or refusal. Default `[500, 2000]` (`DEFAULT_REPLY_DELAY_MS`);
453
+ * `[0, 0]` answers at once, as before. */
454
+ replyDelayMs?: ReplyDelayRange;
455
+ /** For tests: a source of numbers in [0, 1). Default `Math.random`. */
456
+ random?: () => number;
457
+ /** For tests: how the wait is timed. Default `setTimeout`. */
458
+ schedule?: ReplySchedule;
425
459
  }
426
460
  /**
427
461
  * The credential to seal an answer to: the one the request carries, unless
@@ -448,6 +482,13 @@ export declare function sealCredential(request: {
448
482
  * to participants the room knows (`known`), and answers anybody else
449
483
  * `unknown` until a member lets them in. A room that has never removed
450
484
  * anybody is answered exactly as before.
485
+ *
486
+ * Every answer waits a random `replyDelayMs` first, and refusals are
487
+ * rationed: one a participant per `refuseAgainAfter` (per
488
+ * `reportUnknownEvery` for `unknown`), and `maxRefusalsPerMinute` in all.
489
+ * A removed member who still holds the room key can otherwise ask with
490
+ * fresh requests for ever and time each signed reply, which tells them
491
+ * when the authority is online. What an answer says is unchanged.
451
492
  */
452
493
  export declare function hostRoomEpoch(opts: HostRoomEpochOptions): {
453
494
  close(): void;
package/dist/epoch.js CHANGED
@@ -5,16 +5,17 @@ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
5
5
  import { schnorr } from '@noble/curves/secp256k1.js';
6
6
  import { base64urlnopad } from '@scure/base';
7
7
  import { nip44 } from 'nostr-tools';
8
- import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
8
+ import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
9
9
  import { hexEquals, normaliseHex } from './hex.js';
10
10
  import { KINDS } from './kinds.js';
11
11
  import { deriveRoom } from './room.js';
12
12
  import { verifyDeviceCredential } from './credential.js';
13
13
  import { evaluateAccess } from './access.js';
14
14
  import { verifyEventUncached } from './verify.js';
15
- import { newerCredential, openSealed, sealTo } from './seal.js';
15
+ import { SENDER_TAG, newerCredential, openSealed, sealSender, sealTo } from './seal.js';
16
16
  import { withExpiration } from './expiration.js';
17
17
  import { epochCommitment } from './epoch-commit.js';
18
+ import { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, RefusalLimiter, defaultReplySchedule, replyDelay } from './quiet-reply.js';
18
19
  /**
19
20
  * Room epochs: how a member is removed.
20
21
  *
@@ -134,6 +135,14 @@ function memberListOf(members, removed) {
134
135
  out.delete(normaliseHex(p));
135
136
  return [...out].sort();
136
137
  }
138
+ /** The key a rekey or grant seals from: the authority's own, or, with
139
+ * `oneTimeSender`, a fresh one named in a `from` tag and zeroed once used. */
140
+ function oneTimeSenderOf(opts) {
141
+ if (!opts.oneTimeSender)
142
+ return { sk: opts.authoritySk, tags: [], discard: () => { } };
143
+ const sk = generateSecretKey();
144
+ return { sk, tags: [[SENDER_TAG, getPublicKey(sk)]], discard: () => sk.fill(0) };
145
+ }
137
146
  /** Announce the next epoch. See the module comment for what this is. */
138
147
  export function encodeRekeyEvent(opts) {
139
148
  require32(opts.authoritySk, 'authority secret key');
@@ -150,10 +159,16 @@ export function encodeRekeyEvent(opts) {
150
159
  const keys = {};
151
160
  const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
152
161
  const plaintext = JSON.stringify(sealed);
153
- for (const raw of opts.closed ? [] : opts.recipients) {
154
- const recipient = typeof raw === 'string' ? { device: raw } : raw;
155
- const device = requireHex32(recipient.device, 'recipient device');
156
- keys[device] = sealTo(plaintext, opts.authoritySk, device, recipient.credential);
162
+ const sender = oneTimeSenderOf(opts);
163
+ try {
164
+ for (const raw of opts.closed ? [] : opts.recipients) {
165
+ const recipient = typeof raw === 'string' ? { device: raw } : raw;
166
+ const device = requireHex32(recipient.device, 'recipient device');
167
+ keys[device] = sealTo(plaintext, sender.sk, device, recipient.credential);
168
+ }
169
+ }
170
+ finally {
171
+ sender.discard();
157
172
  }
158
173
  const body = {
159
174
  v: 1,
@@ -173,6 +188,7 @@ export function encodeRekeyEvent(opts) {
173
188
  tags: withExpiration([
174
189
  ['d', roomId],
175
190
  ['epoch', String(epoch)],
191
+ ...sender.tags,
176
192
  ], opts.expiresAt),
177
193
  content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
178
194
  }, opts.authoritySk);
@@ -238,13 +254,14 @@ export function decodeRekeyEvent(event, opts) {
238
254
  notice.members = members;
239
255
  const device = getPublicKey(opts.deviceSk);
240
256
  const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
241
- if (typeof mine === 'string') {
257
+ const sender = sealSender(event);
258
+ if (typeof mine === 'string' && sender !== null) {
242
259
  // A copy none of this device's keys opens is no copy: sealed to a
243
260
  // seal key it has since erased, or one it never had. The rest of the
244
261
  // rekey still says who was removed, so it is read without the secret,
245
262
  // and the device asks for the epoch.
246
263
  try {
247
- const sealed = JSON.parse(openSealed(mine, event.pubkey, opts.deviceSk, opts.sealSks));
264
+ const sealed = JSON.parse(openSealed(mine, sender, opts.deviceSk, opts.sealSks));
248
265
  if (sealed.v === 1 && typeof sealed.secret === 'string') {
249
266
  const secret = base64urlnopad.decode(sealed.secret);
250
267
  if (secret.length === 32)
@@ -505,14 +522,23 @@ export function encodeEpochGrant(opts) {
505
522
  if (opts.passed !== undefined && opts.passed.length > 0)
506
523
  body.passed = passedListOf(opts.passed, body.epoch);
507
524
  }
525
+ const sender = oneTimeSenderOf(opts);
526
+ let content;
527
+ try {
528
+ content = sealTo(JSON.stringify(body), sender.sk, device, opts.credential);
529
+ }
530
+ finally {
531
+ sender.discard();
532
+ }
508
533
  return finalizeEvent({
509
534
  kind: KINDS.EPOCH_GRANT,
510
535
  created_at: opts.now,
511
536
  tags: withExpiration([
512
537
  ['d', roomId],
513
538
  ['p', device],
539
+ ...sender.tags,
514
540
  ], opts.expiresAt),
515
- content: sealTo(JSON.stringify(body), opts.authoritySk, device, opts.credential),
541
+ content,
516
542
  }, opts.authoritySk);
517
543
  }
518
544
  /** Read the authority's answer to this device's own request. Null for
@@ -534,7 +560,10 @@ export function decodeEpochGrant(event, opts) {
534
560
  const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
535
561
  if (addressed === undefined || !hexEquals(addressed, device))
536
562
  return null;
537
- const body = JSON.parse(openSealed(event.content, event.pubkey, opts.deviceSk, opts.sealSks));
563
+ const sender = sealSender(event);
564
+ if (sender === null)
565
+ return null;
566
+ const body = JSON.parse(openSealed(event.content, sender, opts.deviceSk, opts.sealSks));
538
567
  if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
539
568
  return null;
540
569
  if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
@@ -622,6 +651,13 @@ export function sealCredential(request, presented, credentialFor, roomId, now) {
622
651
  * to participants the room knows (`known`), and answers anybody else
623
652
  * `unknown` until a member lets them in. A room that has never removed
624
653
  * anybody is answered exactly as before.
654
+ *
655
+ * Every answer waits a random `replyDelayMs` first, and refusals are
656
+ * rationed: one a participant per `refuseAgainAfter` (per
657
+ * `reportUnknownEvery` for `unknown`), and `maxRefusalsPerMinute` in all.
658
+ * A removed member who still holds the room key can otherwise ask with
659
+ * fresh requests for ever and time each signed reply, which tells them
660
+ * when the authority is online. What an answer says is unchanged.
625
661
  */
626
662
  export function hostRoomEpoch(opts) {
627
663
  require32(opts.authoritySk, 'authority secret key');
@@ -640,6 +676,13 @@ export function hostRoomEpoch(opts) {
640
676
  if (set.size > 256)
641
677
  set.delete(set.values().next().value);
642
678
  };
679
+ const refusals = new RefusalLimiter({ perMinute: opts.maxRefusalsPerMinute });
680
+ const delayRange = opts.replyDelayMs ?? DEFAULT_REPLY_DELAY_MS;
681
+ const random = opts.random ?? Math.random;
682
+ const schedule = opts.schedule ?? defaultReplySchedule;
683
+ /** Requests waiting out their delay, each with how to cancel it. Bounded:
684
+ * a request turned away while it is full is answered on its retry. */
685
+ const pending = new Map();
643
686
  let closed = false;
644
687
  const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
645
688
  if (closed)
@@ -652,80 +695,104 @@ export function hostRoomEpoch(opts) {
652
695
  policy: opts.policy,
653
696
  legacyParticipants: opts.legacyParticipants,
654
697
  });
655
- if (!read || answered.has(read.request.request))
698
+ if (!read || answered.has(read.request.request) || pending.has(read.request.request))
656
699
  return;
657
700
  const request = read.request;
658
- let refused;
659
- if (opts.closed?.())
660
- refused = 'closed';
661
- else if (opts.removed().has(request.participant))
662
- refused = 'removed';
663
- else if (opts.removed().size > 0 && !opts.known?.(request.participant))
664
- refused = 'unknown';
665
- if (refused === 'unknown') {
666
- if (waiting.has(request.request))
701
+ const reply = () => {
702
+ pending.delete(request.request);
703
+ if (closed)
667
704
  return;
668
- waiting.add(request.request);
669
- bound(waiting);
670
- }
671
- else {
672
- waiting.delete(request.request);
673
- answered.add(request.request);
674
- bound(answered);
675
- }
676
- let grant;
677
- try {
678
- if (refused) {
679
- grant = encodeEpochGrant({
680
- roomId,
681
- authoritySk: opts.authoritySk,
682
- device: request.device,
683
- request: request.request,
684
- now: now(),
685
- refused,
686
- credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
687
- expiresAt: opts.expiresAt,
688
- });
705
+ let refused;
706
+ if (opts.closed?.())
707
+ refused = 'closed';
708
+ else if (opts.removed().has(request.participant))
709
+ refused = 'removed';
710
+ else if (opts.removed().size > 0 && !opts.known?.(request.participant))
711
+ refused = 'unknown';
712
+ if (refused === 'unknown') {
713
+ if (waiting.has(request.request))
714
+ return;
715
+ waiting.add(request.request);
716
+ bound(waiting);
689
717
  }
690
718
  else {
691
- const epoch = opts.current();
692
- grant = encodeEpochGrant({
693
- roomId,
694
- authoritySk: opts.authoritySk,
695
- device: request.device,
696
- request: request.request,
697
- now: now(),
698
- epoch,
699
- removed: [...opts.removed()],
700
- ...(opts.members ? { members: [...opts.members()] } : {}),
701
- ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
702
- credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
703
- expiresAt: opts.expiresAt,
704
- });
719
+ waiting.delete(request.request);
720
+ answered.add(request.request);
721
+ bound(answered);
705
722
  }
706
- }
707
- catch {
723
+ const quiet = refused !== undefined && !refusals.allow(request.participant, refused === 'unknown' ? (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS) : (opts.refuseAgainAfter ?? REFUSE_AGAIN_AFTER_SECONDS), now());
724
+ if (!quiet) {
725
+ let grant;
726
+ try {
727
+ if (refused) {
728
+ grant = encodeEpochGrant({
729
+ roomId,
730
+ authoritySk: opts.authoritySk,
731
+ device: request.device,
732
+ request: request.request,
733
+ now: now(),
734
+ refused,
735
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
736
+ expiresAt: opts.expiresAt,
737
+ });
738
+ }
739
+ else {
740
+ const epoch = opts.current();
741
+ grant = encodeEpochGrant({
742
+ roomId,
743
+ authoritySk: opts.authoritySk,
744
+ device: request.device,
745
+ request: request.request,
746
+ now: now(),
747
+ epoch,
748
+ removed: [...opts.removed()],
749
+ ...(opts.members ? { members: [...opts.members()] } : {}),
750
+ ...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
751
+ credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
752
+ expiresAt: opts.expiresAt,
753
+ });
754
+ }
755
+ }
756
+ catch {
757
+ return;
758
+ }
759
+ opts.transport.publish(grant).catch(() => { });
760
+ }
761
+ if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
762
+ reported.set(request.participant, now());
763
+ if (reported.size > 256)
764
+ reported.delete(reported.keys().next().value);
765
+ opts.onUnknown?.(request);
766
+ }
767
+ if (!refused)
768
+ reported.delete(request.participant);
769
+ if (!refused)
770
+ refusals.forget(request.participant);
771
+ if (refused)
772
+ opts.onRefused?.(request, refused);
773
+ else
774
+ opts.onGranted?.(request);
775
+ };
776
+ const delay = replyDelay(delayRange, random);
777
+ if (delay === 0) {
778
+ reply();
708
779
  return;
709
780
  }
710
- opts.transport.publish(grant).catch(() => { });
711
- if (refused === 'unknown' && now() - (reported.get(request.participant) ?? -Infinity) >= (opts.reportUnknownEvery ?? REPORT_UNKNOWN_EVERY_SECONDS)) {
712
- reported.set(request.participant, now());
713
- if (reported.size > 256)
714
- reported.delete(reported.keys().next().value);
715
- opts.onUnknown?.(request);
716
- }
717
- if (!refused)
718
- reported.delete(request.participant);
719
- if (refused)
720
- opts.onRefused?.(request, refused);
721
- else
722
- opts.onGranted?.(request);
781
+ if (pending.size >= 256)
782
+ return;
783
+ pending.set(request.request, () => { });
784
+ const cancel = schedule(reply, delay);
785
+ if (pending.has(request.request))
786
+ pending.set(request.request, cancel);
723
787
  });
724
788
  return {
725
789
  close() {
726
790
  if (closed)
727
791
  return;
728
792
  closed = true;
793
+ for (const cancel of pending.values())
794
+ cancel();
795
+ pending.clear();
729
796
  unsub();
730
797
  },
731
798
  };