@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/CHANGELOG.md +371 -0
- package/EXTRACTION.md +549 -0
- package/README.md +178 -17
- package/dist/epoch.d.ts +42 -1
- package/dist/epoch.js +137 -70
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/invitation-decline.d.ts +26 -0
- package/dist/invitation-decline.js +74 -0
- package/dist/invitation.d.ts +56 -6
- package/dist/invitation.js +289 -67
- package/dist/mls-admission.d.ts +79 -0
- package/dist/mls-admission.js +400 -0
- package/dist/persistent-invitation-evidence.d.ts +83 -0
- package/dist/persistent-invitation-evidence.js +381 -0
- package/dist/quiet-reply.d.ts +45 -0
- package/dist/quiet-reply.js +82 -0
- package/dist/seal.d.ts +20 -0
- package/dist/seal.js +25 -0
- package/docs/extraction-plan-excerpt.md +56 -0
- package/docs/live-persistent-admission.md +192 -0
- package/docs/member-epoch-catch-up.md +346 -0
- package/docs/mls-admission-v4.md +84 -0
- package/docs/persistent-invitation-evidence.md +135 -0
- package/docs/quiet-replies.md +81 -0
- package/docs/room-destruct.md +142 -0
- package/docs/scheduled-rekey.md +142 -0
- package/docs/seal-key.md +143 -0
- package/llms.txt +98 -29
- package/package.json +22 -4
- package/vectors/fold-vectors.json +894 -0
- package/vectors/invitation-account-vectors.json +134 -0
- package/vectors/invitation-decline-vectors.json +110 -0
- package/vectors/mls-admission-v4.json +25 -0
- package/vectors/seal-vectors.json +304 -0
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:**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
};
|