@forgesworn/fold-kit 0.12.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.
- package/CHANGELOG.md +114 -0
- package/EXTRACTION.md +22 -0
- package/README.md +27 -1
- package/dist/epoch.d.ts +18 -0
- package/dist/epoch.js +40 -10
- package/dist/evidence-carrier.d.ts +10 -0
- package/dist/evidence-carrier.js +19 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/invitation-storage.d.ts +65 -0
- package/dist/invitation-storage.js +217 -0
- package/dist/persistent-invitation-evidence.d.ts +25 -2
- package/dist/persistent-invitation-evidence.js +126 -40
- package/dist/persistent-invitation.d.ts +12 -0
- package/dist/persistent-invitation.js +25 -3
- package/dist/seal.d.ts +20 -0
- package/dist/seal.js +25 -0
- package/docs/persistent-invitation-evidence.md +46 -13
- package/docs/seal-key.md +39 -0
- package/llms.txt +3 -2
- package/package.json +3 -1
- package/vectors/seal-vectors.json +304 -0
- package/vectors/storage-vectors.json +3843 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,120 @@ This kit is pre-1.0 (see AGENTS.md "Release Notes"); a behaviour change on
|
|
|
5
5
|
byte-identical copy of a KithMoot function (see EXTRACTION.md) and this is
|
|
6
6
|
the one place its behaviour has deliberately diverged.
|
|
7
7
|
|
|
8
|
+
## 0.14.0
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Room storage nodes: the group storage nodes a room keeps its files on,
|
|
13
|
+
carried in its signed group invitation (kind 1463) so a client uploads to
|
|
14
|
+
the group's own nodes instead of a public server.
|
|
15
|
+
`encodePersistentInvitation` takes an optional `storage`: zero to eight
|
|
16
|
+
canonical node base URLs on distinct hosts, else it throws. The encrypted v3
|
|
17
|
+
body carries them last, after `relays` (key order `v, room, secret, ends,
|
|
18
|
+
destruct, relays, storage`; the version stays 3, so every lenient reader,
|
|
19
|
+
including shipped Android, ignores the key). `[]` says the room's authority
|
|
20
|
+
withdrew every node; a body without the key says nothing about nodes.
|
|
21
|
+
With no `storage`, the event is byte-identical to 0.13.0's.
|
|
22
|
+
- An entry is a base URL and nothing else. Canonical is a grammar, pinned by
|
|
23
|
+
`vectors/storage-vectors.json`: `https`, or `http` only for `localhost`, a
|
|
24
|
+
`127.x.y.z` address or an onion v3 host; lower-case host; no default port,
|
|
25
|
+
trailing slash, user info, query or fragment; at most 256 characters; a host
|
|
26
|
+
ending in a number must be four plain decimal octets. The grammar alone
|
|
27
|
+
decides on the wire: no URL parser is consulted, so every client gets the
|
|
28
|
+
same answer, `xn--` labels included (valid Punycode or not). New exports:
|
|
29
|
+
`storageNodeFromBase` (an operator's URL to `{ base, server }`),
|
|
30
|
+
`storageNodeServer` (the `server` of a canonical base, by grammar alone, for
|
|
31
|
+
a client that must not need a URL parser), `isStorageNodeBase`, `isInvitationStorage`, `requireInvitationStorage`,
|
|
32
|
+
`MAX_INVITATION_STORAGE_NODES` (8), `MAX_STORAGE_NODE_URL_LENGTH` (256) and
|
|
33
|
+
the `StorageNode` type.
|
|
34
|
+
- `decodePersistentInvitation` returns `storage` and `storageAt` (the
|
|
35
|
+
`created_at` of the copy whose list stood; a client uses it as the record's
|
|
36
|
+
version), and returns null for the whole envelope on a malformed list.
|
|
37
|
+
`requestPersistentRoomAdmission` keeps the list of the newest signed copy
|
|
38
|
+
that names any, `[]` included; a copy without the key says nothing, and on
|
|
39
|
+
equal timestamps the first heard stays. Relays and storage merge
|
|
40
|
+
independently.
|
|
41
|
+
- The strict evidence path (`persistent-invitation-evidence`) accepts the new
|
|
42
|
+
key and merges it the same way. Two newest copies of one second that
|
|
43
|
+
disagree hold with the new reason `conflicting-storage`, and
|
|
44
|
+
`PersistentInvitationContributors` gains an optional `storage`, present
|
|
45
|
+
exactly when a copy names storage, so a room with none has contributors
|
|
46
|
+
byte-identical to 0.13.0's and a stored proof still reads back unchanged.
|
|
47
|
+
Compatibility with 0.13.0 strict readers is only partial. A live query on
|
|
48
|
+
0.13.0 skips a copy it does not know, so with a plain copy beside it the room
|
|
49
|
+
still admits (the vector `storage-strict-old-reader`), and with only a
|
|
50
|
+
storage copy it reads as unavailable. But evidence that 0.14.0 retained
|
|
51
|
+
holds every valid copy, the storage copy included, and 0.13.0's
|
|
52
|
+
`revalidatePersistentInvitationEvidence` throws on a carrier it cannot read,
|
|
53
|
+
plain copy or not. So evidence stored by 0.14.0 cannot be read by 0.13.0: a
|
|
54
|
+
stale tab, a cached older bundle or a rollback meets it. Keep older readers
|
|
55
|
+
off evidence a newer version stored, or isolate each read failure.
|
|
56
|
+
- `vectors/storage-vectors.json` (`npm run generate-storage`).
|
|
57
|
+
- `hostRoomEpoch` takes `oneTimeSender`, passed to every grant the desk
|
|
58
|
+
writes. Off by default; off, every grant is byte-identical to 0.13.0's.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- `requestPersistentRoomAdmissionEvidence` no longer fails a room on repeats,
|
|
63
|
+
strangers or junk. It charged every delivered event, before de-duplicating
|
|
64
|
+
and before checking the author, kind and `d` tag, against the 64 retained
|
|
65
|
+
events and 256 KiB, and one malformed or oversized event failed the whole
|
|
66
|
+
query. A room on four relays whose keeper had republished 17 times failed to
|
|
67
|
+
load, and any relay could fail a query with one oversized event or 64 events
|
|
68
|
+
of another author. Now a delivery is only counted against a new bound,
|
|
69
|
+
`MAX_PERSISTENT_INVITATION_EVIDENCE_DELIVERIES` (512, which bounds parse
|
|
70
|
+
work). An event that is malformed, or out of scope (not the inviter, not
|
|
71
|
+
kind 1463 or 1461, not exactly one `d` tag naming the invitation), is dropped
|
|
72
|
+
without crypto, as is an exact repeat or another carrier under an id already
|
|
73
|
+
held (the held event was verified, so its signed fields are the same).
|
|
74
|
+
Signature checks have their own bound,
|
|
75
|
+
`MAX_PERSISTENT_INVITATION_EVIDENCE_VERIFICATIONS` (128: 64 retained events
|
|
76
|
+
plus 64 in-scope events that fail). Those are checks charged, not Schnorr
|
|
77
|
+
verifications: a retained retirement is verified twice and the finished query
|
|
78
|
+
revalidates what it kept, so a query that succeeds can do up to 128 + 64,
|
|
79
|
+
while a hostile relay can cause at most 128. Nothing is truncated: a 65th
|
|
80
|
+
unique valid authority event, 256 KiB of retained carriers, more than 512
|
|
81
|
+
deliveries or more than 128 signature checks still fail the query.
|
|
82
|
+
- An in-scope event over the evidence size limits (16,384 bytes, 64 tags, eight
|
|
83
|
+
strings in a tag) can never be retained, so it is checked rather than
|
|
84
|
+
dropped unseen: one hash and one signature check, charged to the same bound
|
|
85
|
+
of 128. If the authority signed it, the query fails with `invitation evidence
|
|
86
|
+
query refused an event signed by the authority that exceeds the evidence size
|
|
87
|
+
limits`, because it may be a retirement or a newer list the query cannot
|
|
88
|
+
hold. If it only claims the authority's key it is dropped, and a repeat costs
|
|
89
|
+
nothing. Before this fix such an event also failed the query, as any
|
|
90
|
+
oversized event did; failing only for the authority's own is the change.
|
|
91
|
+
- Error messages. Gone from this query: `conflicting duplicate invitation event
|
|
92
|
+
identity` (a second carrier under a held id is now dropped, since the held
|
|
93
|
+
one was verified), and the carrier errors `oversized evidence event`,
|
|
94
|
+
`invalid or oversized evidence event`, `invalid or oversized evidence tag`,
|
|
95
|
+
`invalid evidence field`, `invalid evidence record` and the like (a malformed
|
|
96
|
+
delivery is now dropped). New: `invitation evidence query capacity exceeded:
|
|
97
|
+
too many deliveries`, `...: too many signature checks` and the size-limit
|
|
98
|
+
refusal above. Unchanged: `invitation evidence query capacity exceeded` for a
|
|
99
|
+
retained-event or retained-byte overflow. The revalidators still throw the
|
|
100
|
+
carrier errors, and the evidence format and its limits are unchanged, so
|
|
101
|
+
stored evidence revalidates exactly as before.
|
|
102
|
+
- `encodePersistentInvitation` throws when the event it would sign exceeds
|
|
103
|
+
those size limits (only possible with very long relay URLs: 27,813 bytes at
|
|
104
|
+
eight 2,048-character relays), so the authority cannot sign what the strict
|
|
105
|
+
reader must refuse. `encodeInvitationRetirement` is unchanged: its body and
|
|
106
|
+
tags are fixed and small, so it cannot exceed them.
|
|
107
|
+
|
|
108
|
+
## 0.13.0
|
|
109
|
+
|
|
110
|
+
### Added
|
|
111
|
+
|
|
112
|
+
- One-time senders for rekeys (1462) and authority grants (20469):
|
|
113
|
+
`oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
|
|
114
|
+
copy from a fresh key named in a `from` tag, so a later copy of the
|
|
115
|
+
authority's key no longer opens past copies. Off by default; off, events
|
|
116
|
+
are byte-identical to 0.12.0. Readers (`decodeRekeyEvent`,
|
|
117
|
+
`decodeEpochGrant`) open either form now, through the new `sealSender`
|
|
118
|
+
(also exported, with `SENDER_TAG`). Turn writers on only once these readers
|
|
119
|
+
have shipped everywhere. See docs/seal-key.md, "One-time senders"; vectors
|
|
120
|
+
in `seal-vectors.json`.
|
|
121
|
+
|
|
8
122
|
## 0.12.0
|
|
9
123
|
|
|
10
124
|
### Fixed
|
package/EXTRACTION.md
CHANGED
|
@@ -330,6 +330,28 @@ declares each added line in `ROOM_RELAY_CHANGES` as 0.4.0 text against
|
|
|
330
330
|
0.3.0 text, applied before `CONFERENCE_CHANGES`, so the whole-file
|
|
331
331
|
comparison against the pinned commit still holds.
|
|
332
332
|
|
|
333
|
+
## Room storage nodes: declared additions to `persistent-invitation.ts`
|
|
334
|
+
|
|
335
|
+
0.14.0 adds the room's group storage nodes to the group invitation body,
|
|
336
|
+
after `relays`: the base URLs of the nodes a room keeps its files on, so a
|
|
337
|
+
client can upload to the group's own nodes instead of a public server. The
|
|
338
|
+
rule (0 to 8 canonical node URLs on distinct hosts, defined by a grammar
|
|
339
|
+
rather than by what a URL parser prints, and decided by the grammar alone on
|
|
340
|
+
the wire) lives in a new module, `src/invitation-storage.ts`,
|
|
341
|
+
which moved from nowhere; `storageNodeServer` there gives the `server` of a
|
|
342
|
+
canonical base by grammar alone, for clients that must not need a URL parser.
|
|
343
|
+
With no `storage` the body, and the whole event, is
|
|
344
|
+
byte-identical to 0.13.0's (`vectors/storage-vectors.json`, vector
|
|
345
|
+
`storage-absent`, pins two events recorded from 0.13.0's own encoder). The
|
|
346
|
+
admission gains `storage` and `storageAt`; the strict evidence path
|
|
347
|
+
(`src/persistent-invitation-evidence.ts`, not a moved module) allows the key
|
|
348
|
+
and merges it as it merges `relays`, holding a same-second disagreement as
|
|
349
|
+
`conflicting-storage`; its `contributors.storage` is present only when a copy
|
|
350
|
+
names storage, so contributors without one match 0.13.0's byte for byte. `scripts/diff-source.mjs` declares each added line in
|
|
351
|
+
`STORAGE_CHANGES` as 0.14.0 text against 0.13.0 text, applied before every
|
|
352
|
+
other change set, so the whole-file comparison against the pinned commit
|
|
353
|
+
still holds.
|
|
354
|
+
|
|
333
355
|
## Member epoch catch-up: declared additions to `epoch.ts`
|
|
334
356
|
|
|
335
357
|
Unreleased. Any current member can now hand an admitted, non-removed device
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ 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:** Version 0.
|
|
16
|
+
**Status:** Version 0.14.0 on `main`. Published on npm as
|
|
17
17
|
`@forgesworn/fold-kit`, but `main` can run ahead of the npm `latest` release, so
|
|
18
18
|
compare `npm view @forgesworn/fold-kit version` with this repository's
|
|
19
19
|
`package.json` before relying on an API listed here. KithMoot has cut over to
|
|
@@ -250,6 +250,26 @@ decision completes, so approval cannot extend an expired request's authority.
|
|
|
250
250
|
`requestPersistentRoomAdmission` keeps the relays of the newest signed
|
|
251
251
|
copy that names any. `isInvitationRelays`, `requireInvitationRelays` and
|
|
252
252
|
`MAX_INVITATION_RELAYS` are the rule
|
|
253
|
+
- Room storage nodes: pass `storage` (zero to eight canonical node base URLs
|
|
254
|
+
on distinct hosts, e.g. `https://grove.example.net` or
|
|
255
|
+
`http://127.0.0.1:8080`) and the encrypted body carries them last, after
|
|
256
|
+
`relays`, so a room that keeps its files on its own computers says where.
|
|
257
|
+
An entry is only a base URL; the grammar (no trailing slash, no default
|
|
258
|
+
port, `http` only on this machine or an onion address) is in
|
|
259
|
+
`src/invitation-storage.ts` and pinned by vectors; no URL parser is
|
|
260
|
+
consulted, so every client gets the same answer. `[]` says the authority withdrew every node; a body with no
|
|
261
|
+
`storage` says nothing about nodes, and is byte-identical to 0.13.0's. The
|
|
262
|
+
decoder returns `storage` and `storageAt` (the `created_at` of the copy
|
|
263
|
+
whose list stood) and refuses the whole envelope on a malformed list.
|
|
264
|
+
`requestPersistentRoomAdmission` and the strict evidence path keep the list
|
|
265
|
+
of the newest signed copy that names any, independently of the relays; the
|
|
266
|
+
strict path holds a same-second disagreement as `conflicting-storage`.
|
|
267
|
+
`storageNodeFromBase(text)` canonicalises an operator's URL and gives its
|
|
268
|
+
`server`; `storageNodeServer(base)` gives the `server` of a canonical base by
|
|
269
|
+
grammar alone (`undefined` when not canonical); `isStorageNodeBase`, `isInvitationStorage`,
|
|
270
|
+
`requireInvitationStorage`, `MAX_INVITATION_STORAGE_NODES` and
|
|
271
|
+
`MAX_STORAGE_NODE_URL_LENGTH` are the rule. Vectors:
|
|
272
|
+
`vectors/storage-vectors.json`
|
|
253
273
|
- Self-destructing rooms (`docs/room-destruct.md`): pass `destruct: true`
|
|
254
274
|
and the encrypted body carries it after `ends` (no end needed: the room
|
|
255
275
|
then self-destructs when its authority closes it). The decoder returns
|
|
@@ -310,6 +330,11 @@ decision completes, so approval cannot extend an expired request's authority.
|
|
|
310
330
|
`sealSks` on the readers; `credentialFor` on both desks;
|
|
311
331
|
`credentialSeal`, `sealTarget`, `sealTo`, `openSealed`, `newerCredential`,
|
|
312
332
|
`sealCredential`. Vectors: `vectors/seal-vectors.json`
|
|
333
|
+
- One-time senders (`docs/seal-key.md`, "One-time senders"):
|
|
334
|
+
`oneTimeSender` on `encodeRekeyEvent` and `encodeEpochGrant` seals every
|
|
335
|
+
copy from a fresh key named in a `from` tag, so a later copy of the
|
|
336
|
+
authority's key opens no past copy; readers use `sealSender`. Off by
|
|
337
|
+
default until every client's readers have shipped.
|
|
313
338
|
- Scheduled rekeys and the history window (`docs/scheduled-rekey.md`):
|
|
314
339
|
`encodeRekeyEvent({ scheduled: true })` marks a turn of the key that
|
|
315
340
|
removes nobody, read back as `RekeyNotice.scheduled` and
|
|
@@ -386,6 +411,7 @@ npm run typecheck # type-check src/ and test/ (matching KithMoot, vectors/ is
|
|
|
386
411
|
npm run vectors # run only the vector-verification suites
|
|
387
412
|
npm run diff-source # compare moved modules against the pinned source commit (needs FOLD_KIT_SOURCE_DIR)
|
|
388
413
|
npm run generate-fold # regenerate vectors/fold-vectors.json (needs a prior build)
|
|
414
|
+
npm run generate-storage # regenerate vectors/storage-vectors.json (needs a prior build)
|
|
389
415
|
npm run bundle-check # esbuild browser bundle check (needs a prior build)
|
|
390
416
|
npm run check # typecheck + test + diff-source
|
|
391
417
|
```
|
package/dist/epoch.d.ts
CHANGED
|
@@ -134,6 +134,13 @@ export interface EncodeRekeyOptions {
|
|
|
134
134
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
135
135
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
136
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;
|
|
137
144
|
}
|
|
138
145
|
/** A rekey recipient: a device, and the newest credential the sender holds
|
|
139
146
|
* for it. */
|
|
@@ -330,6 +337,13 @@ export interface EncodeEpochGrantOptions {
|
|
|
330
337
|
/** A conference room's end, in unix seconds: the event carries it as a
|
|
331
338
|
* NIP-40 expiration (see `withExpiration`). Omit for a room with no end. */
|
|
332
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;
|
|
333
347
|
}
|
|
334
348
|
/** Answer one request: the current epoch sealed to the asking device, or a
|
|
335
349
|
* refusal it alone can read. */
|
|
@@ -427,6 +441,10 @@ export interface HostRoomEpochOptions {
|
|
|
427
441
|
onRefused?: (request: EpochRequest, why: EpochRefusal) => void;
|
|
428
442
|
/** A conference room's end: every grant carries it as an expiration. */
|
|
429
443
|
expiresAt?: number;
|
|
444
|
+
/** Seal every grant from a one-time key (`EncodeEpochGrantOptions.oneTimeSender`).
|
|
445
|
+
* Off by default; turn it on only once every client reads one-time
|
|
446
|
+
* senders (fold-kit 0.13.0 and the clients built on it). */
|
|
447
|
+
oneTimeSender?: boolean;
|
|
430
448
|
/** Seconds before the same participant is sent a `removed` or `closed`
|
|
431
449
|
* refusal again. Default 3600 (`REFUSE_AGAIN_AFTER_SECONDS`). A request
|
|
432
450
|
* inside it is not answered, so the asker times out instead. */
|
package/dist/epoch.js
CHANGED
|
@@ -5,14 +5,14 @@ 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
18
|
import { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, RefusalLimiter, defaultReplySchedule, replyDelay } from './quiet-reply.js';
|
|
@@ -135,6 +135,14 @@ function memberListOf(members, removed) {
|
|
|
135
135
|
out.delete(normaliseHex(p));
|
|
136
136
|
return [...out].sort();
|
|
137
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
|
+
}
|
|
138
146
|
/** Announce the next epoch. See the module comment for what this is. */
|
|
139
147
|
export function encodeRekeyEvent(opts) {
|
|
140
148
|
require32(opts.authoritySk, 'authority secret key');
|
|
@@ -151,10 +159,16 @@ export function encodeRekeyEvent(opts) {
|
|
|
151
159
|
const keys = {};
|
|
152
160
|
const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
|
|
153
161
|
const plaintext = JSON.stringify(sealed);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
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();
|
|
158
172
|
}
|
|
159
173
|
const body = {
|
|
160
174
|
v: 1,
|
|
@@ -174,6 +188,7 @@ export function encodeRekeyEvent(opts) {
|
|
|
174
188
|
tags: withExpiration([
|
|
175
189
|
['d', roomId],
|
|
176
190
|
['epoch', String(epoch)],
|
|
191
|
+
...sender.tags,
|
|
177
192
|
], opts.expiresAt),
|
|
178
193
|
content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
|
|
179
194
|
}, opts.authoritySk);
|
|
@@ -239,13 +254,14 @@ export function decodeRekeyEvent(event, opts) {
|
|
|
239
254
|
notice.members = members;
|
|
240
255
|
const device = getPublicKey(opts.deviceSk);
|
|
241
256
|
const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
|
|
242
|
-
|
|
257
|
+
const sender = sealSender(event);
|
|
258
|
+
if (typeof mine === 'string' && sender !== null) {
|
|
243
259
|
// A copy none of this device's keys opens is no copy: sealed to a
|
|
244
260
|
// seal key it has since erased, or one it never had. The rest of the
|
|
245
261
|
// rekey still says who was removed, so it is read without the secret,
|
|
246
262
|
// and the device asks for the epoch.
|
|
247
263
|
try {
|
|
248
|
-
const sealed = JSON.parse(openSealed(mine,
|
|
264
|
+
const sealed = JSON.parse(openSealed(mine, sender, opts.deviceSk, opts.sealSks));
|
|
249
265
|
if (sealed.v === 1 && typeof sealed.secret === 'string') {
|
|
250
266
|
const secret = base64urlnopad.decode(sealed.secret);
|
|
251
267
|
if (secret.length === 32)
|
|
@@ -506,14 +522,23 @@ export function encodeEpochGrant(opts) {
|
|
|
506
522
|
if (opts.passed !== undefined && opts.passed.length > 0)
|
|
507
523
|
body.passed = passedListOf(opts.passed, body.epoch);
|
|
508
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
|
+
}
|
|
509
533
|
return finalizeEvent({
|
|
510
534
|
kind: KINDS.EPOCH_GRANT,
|
|
511
535
|
created_at: opts.now,
|
|
512
536
|
tags: withExpiration([
|
|
513
537
|
['d', roomId],
|
|
514
538
|
['p', device],
|
|
539
|
+
...sender.tags,
|
|
515
540
|
], opts.expiresAt),
|
|
516
|
-
content
|
|
541
|
+
content,
|
|
517
542
|
}, opts.authoritySk);
|
|
518
543
|
}
|
|
519
544
|
/** Read the authority's answer to this device's own request. Null for
|
|
@@ -535,7 +560,10 @@ export function decodeEpochGrant(event, opts) {
|
|
|
535
560
|
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
536
561
|
if (addressed === undefined || !hexEquals(addressed, device))
|
|
537
562
|
return null;
|
|
538
|
-
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));
|
|
539
567
|
if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
540
568
|
return null;
|
|
541
569
|
if (body.refused === 'removed' || body.refused === 'closed' || body.refused === 'unknown')
|
|
@@ -706,6 +734,7 @@ export function hostRoomEpoch(opts) {
|
|
|
706
734
|
refused,
|
|
707
735
|
credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
|
|
708
736
|
expiresAt: opts.expiresAt,
|
|
737
|
+
...(opts.oneTimeSender ? { oneTimeSender: true } : {}),
|
|
709
738
|
});
|
|
710
739
|
}
|
|
711
740
|
else {
|
|
@@ -722,6 +751,7 @@ export function hostRoomEpoch(opts) {
|
|
|
722
751
|
...(opts.past ? { passed: passedFor(opts.past, epoch.epoch, now()) } : {}),
|
|
723
752
|
credential: sealCredential(request, read.credential, opts.credentialFor, roomId, now()),
|
|
724
753
|
expiresAt: opts.expiresAt,
|
|
754
|
+
...(opts.oneTimeSender ? { oneTimeSender: true } : {}),
|
|
725
755
|
});
|
|
726
756
|
}
|
|
727
757
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Event } from 'nostr-tools/pure';
|
|
2
|
+
/** Per-event ceiling of the strict evidence carrier, in canonical UTF-8 bytes. */
|
|
3
|
+
export declare const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16384;
|
|
4
|
+
/** Tag arrays a strict evidence carrier may hold, and strings in each. */
|
|
5
|
+
export declare const MAX_EVIDENCE_CARRIER_TAGS = 64;
|
|
6
|
+
export declare const MAX_EVIDENCE_CARRIER_TAG_STRINGS = 8;
|
|
7
|
+
/** Returns `event` if the strict evidence reader could retain it, else throws.
|
|
8
|
+
* A writer calls this before it signs and publishes, so the authority never
|
|
9
|
+
* signs what a strict reader must refuse. */
|
|
10
|
+
export declare function requireEvidenceCarrierLimits(event: Event, what: string): Event;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Per-event ceiling of the strict evidence carrier, in canonical UTF-8 bytes. */
|
|
2
|
+
export const MAX_PERSISTENT_INVITATION_EVENT_BYTES = 16_384;
|
|
3
|
+
/** Tag arrays a strict evidence carrier may hold, and strings in each. */
|
|
4
|
+
export const MAX_EVIDENCE_CARRIER_TAGS = 64;
|
|
5
|
+
export const MAX_EVIDENCE_CARRIER_TAG_STRINGS = 8;
|
|
6
|
+
/** Returns `event` if the strict evidence reader could retain it, else throws.
|
|
7
|
+
* A writer calls this before it signs and publishes, so the authority never
|
|
8
|
+
* signs what a strict reader must refuse. */
|
|
9
|
+
export function requireEvidenceCarrierLimits(event, what) {
|
|
10
|
+
const bytes = new TextEncoder().encode(JSON.stringify({ id: event.id, pubkey: event.pubkey, created_at: event.created_at,
|
|
11
|
+
kind: event.kind, tags: event.tags, content: event.content, sig: event.sig })).length;
|
|
12
|
+
if (bytes > MAX_PERSISTENT_INVITATION_EVENT_BYTES) {
|
|
13
|
+
throw new Error(`the ${what} is ${bytes} bytes, over the ${MAX_PERSISTENT_INVITATION_EVENT_BYTES} a strict reader accepts; use fewer or shorter relays or storage nodes`);
|
|
14
|
+
}
|
|
15
|
+
if (event.tags.length > MAX_EVIDENCE_CARRIER_TAGS || event.tags.some(tag => tag.length > MAX_EVIDENCE_CARRIER_TAG_STRINGS)) {
|
|
16
|
+
throw new Error(`the ${what} has more tags, or more strings in a tag, than a strict reader accepts`);
|
|
17
|
+
}
|
|
18
|
+
return event;
|
|
19
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type { DeviceCredential, AccessTier, AgentRule, RoomPolicy, KindredProof
|
|
|
7
7
|
export type { RelayTransport } from './transport.js';
|
|
8
8
|
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
9
9
|
export type { CreateCredentialOptions, VerifyResult } from './credential.js';
|
|
10
|
-
export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
10
|
+
export { SEAL_TAG, SENDER_TAG, sealSender, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
11
11
|
export type { SealKey } from './seal.js';
|
|
12
12
|
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
13
13
|
export type { ScopedKeys } from './scoped.js';
|
|
@@ -26,6 +26,8 @@ export { encodePersistentInvitation, decodePersistentInvitation, requestPersiste
|
|
|
26
26
|
export type { PersistentRoomAdmission } from './persistent-invitation.js';
|
|
27
27
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
28
28
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
29
|
+
export { isStorageNodeBase, storageNodeFromBase, storageNodeServer, isInvitationStorage, requireInvitationStorage, MAX_INVITATION_STORAGE_NODES, MAX_STORAGE_NODE_URL_LENGTH, } from './invitation-storage.js';
|
|
30
|
+
export type { StorageNode } from './invitation-storage.js';
|
|
29
31
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
30
32
|
export type { RoomLink } from './link.js';
|
|
31
33
|
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { verifyEventUncached, boundedEventVerifier } from './verify.js';
|
|
|
5
5
|
export { localIdentity } from './identity.js';
|
|
6
6
|
export { KINDS } from './kinds.js';
|
|
7
7
|
export { createDeviceCredential, verifyDeviceCredential, PERSON_CREDENTIAL_MAX_SECONDS, RestampedCredentialExpiryError, } from './credential.js';
|
|
8
|
-
export { SEAL_TAG, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
8
|
+
export { SEAL_TAG, SENDER_TAG, sealSender, isSealPubkey, generateSealKey, credentialSeal, sealTarget, sealTo, openSealed, newerCredential, } from './seal.js';
|
|
9
9
|
export { deriveScoped, SCOPED_LABEL_PATTERN } from './scoped.js';
|
|
10
10
|
export { createSubKeyCertificate, verifySubKeyCertificate, SUB_KEY_CERTIFICATE_SCOPE, } from './sub-cert.js';
|
|
11
11
|
export { generateRoomSecret, deriveRoom, encodeJoinUrl, decodeJoinUrl, parseRoomPolicy, ROOM_LABELS, } from './room.js';
|
|
@@ -17,6 +17,7 @@ export { encodeInvitationDecline, decodeInvitationDecline, InvitationDeclinedErr
|
|
|
17
17
|
export { encodePersistentInvitation, decodePersistentInvitation, requestPersistentRoomAdmission, PERSISTENT_INVITATION_LABELS, } from './persistent-invitation.js';
|
|
18
18
|
export { withExpiration, isRoomEnds, requireRoomEnds, MAX_ROOM_ENDS_SECONDS } from './expiration.js';
|
|
19
19
|
export { isInvitationRelays, requireInvitationRelays, MAX_INVITATION_RELAYS } from './invitation-relays.js';
|
|
20
|
+
export { isStorageNodeBase, storageNodeFromBase, storageNodeServer, isInvitationStorage, requireInvitationStorage, MAX_INVITATION_STORAGE_NODES, MAX_STORAGE_NODE_URL_LENGTH, } from './invitation-storage.js';
|
|
20
21
|
export { MAX_ROOM_LINK_FRAGMENT_LENGTH, parseRoomLink, encodeRoomLink, } from './link.js';
|
|
21
22
|
export { EPOCH_ID_INFO, EPOCH_KEY_INFO, MAX_EPOCH, EPOCH_REQUEST_KEY_INFO, generateEpochSecret, deriveEpoch, encodeRekeyEvent, peekRekeyEvent, readMemberList, REPORT_UNKNOWN_EVERY_SECONDS, decodeRekeyEvent, HISTORY_WINDOW_SECONDS, MAX_HISTORY_EPOCHS, epochsInWindow, deriveEpochRequestKey, epochRequestAdmission, encodeEpochRequest, decodeEpochRequest, encodeEpochGrant, decodeEpochGrant, hostRoomEpoch, sealCredential, EpochRefusedError, requestRoomEpoch, canonicalAdmins, CHANNEL_NAME, RESERVED_CHANNELS, canonicalChannels, signChannels, verifyChannels, signAdmins, verifyAdmins, EPOCH_LABELS, } from './epoch.js';
|
|
22
23
|
export { DEFAULT_REPLY_DELAY_MS, REFUSE_AGAIN_AFTER_SECONDS, MAX_REFUSALS_PER_MINUTE, REFUSAL_LIMITER_CAPACITY, RefusalLimiter, replyDelay, } from './quiet-reply.js';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A room's own storage nodes, carried in its signed group invitation (kind 1463).
|
|
3
|
+
*
|
|
4
|
+
* A room that keeps its files on its own computers lists their base URLs
|
|
5
|
+
* here, so a client can upload to the group's own nodes instead of a public
|
|
6
|
+
* server. The list is signed by the room's authority and rides in the
|
|
7
|
+
* encrypted body, after `relays`; a body without it says nothing about
|
|
8
|
+
* storage.
|
|
9
|
+
*
|
|
10
|
+
* An entry is one string, a node's canonical base URL, and nothing else: the
|
|
11
|
+
* `server` a token names is its host, and scopes, ceilings and expiry belong
|
|
12
|
+
* to the grant. Canonical is defined by this grammar, not by what a URL parser
|
|
13
|
+
* prints, and the wire answer is the grammar's alone: no URL parser is
|
|
14
|
+
* consulted when a list is read or written, so a port written in another
|
|
15
|
+
* language gets the same answer from the same bytes. (`new URL` is used only
|
|
16
|
+
* to read an operator's text in `storageNodeFromBase`, whose output must
|
|
17
|
+
* itself pass the grammar.) A client that cannot parse a listed host when it
|
|
18
|
+
* makes a request skips that node.
|
|
19
|
+
*
|
|
20
|
+
* entry = scheme "://" host [ ":" port ] *( "/" segment )
|
|
21
|
+
* scheme = "https" / "http"
|
|
22
|
+
* host = label *( "." label ) ; 1 to 253 characters
|
|
23
|
+
* label = 1*63( a-z / 0-9 / "-" ) ; not starting or ending with "-"
|
|
24
|
+
* port = "1"-"9" *4DIGIT ; 1 to 65535, no leading zero
|
|
25
|
+
* segment = 1*( A-Z / a-z / 0-9 / "." / "_" / "~" / "-" ) ; not "." or ".."
|
|
26
|
+
*
|
|
27
|
+
* and: at most 256 characters; `http` only for `localhost`, a loopback
|
|
28
|
+
* address `127.a.b.c` or an onion v3 host; no default port; a host whose last
|
|
29
|
+
* label is all digits or `0x` hex must be exactly four decimal octets with no
|
|
30
|
+
* leading zero; no trailing slash; no user info, query or fragment.
|
|
31
|
+
*
|
|
32
|
+
* The list is strict: 0 to `MAX_INVITATION_STORAGE_NODES` entries, no two on
|
|
33
|
+
* one host (grants are per host), order preserved and significant. `[]` means
|
|
34
|
+
* the authority withdrew every node. Anything else refuses the whole envelope.
|
|
35
|
+
*/
|
|
36
|
+
/** At most this many group storage nodes in a room's invitation. */
|
|
37
|
+
export declare const MAX_INVITATION_STORAGE_NODES = 8;
|
|
38
|
+
/** The longest canonical node base URL. */
|
|
39
|
+
export declare const MAX_STORAGE_NODE_URL_LENGTH = 256;
|
|
40
|
+
/** A canonical node base URL and the `server` tag it implies (its host). */
|
|
41
|
+
export interface StorageNode {
|
|
42
|
+
base: string;
|
|
43
|
+
server: string;
|
|
44
|
+
}
|
|
45
|
+
/** True when `value` is a canonical node base URL. Never throws. */
|
|
46
|
+
export declare function isStorageNodeBase(value: unknown): value is string;
|
|
47
|
+
/**
|
|
48
|
+
* The `server` a token names for a node: the host of a canonical base,
|
|
49
|
+
* exactly as written, or `undefined` when `base` is not canonical. Decided by
|
|
50
|
+
* the grammar alone, with no URL parser, so a client derives the same server
|
|
51
|
+
* from a listed base on every runtime. Never throws.
|
|
52
|
+
*/
|
|
53
|
+
export declare function storageNodeServer(base: string): string | undefined;
|
|
54
|
+
/** True for a list the invitation may carry (0 to 8 canonical entries, one per host), including []. Never throws. */
|
|
55
|
+
export declare function isInvitationStorage(value: unknown): value is string[];
|
|
56
|
+
/** Throws unless `nodes` is a list the invitation may carry; returns a copy. */
|
|
57
|
+
export declare function requireInvitationStorage(nodes: readonly string[]): string[];
|
|
58
|
+
/**
|
|
59
|
+
* The canonical form of an operator's URL, and its server. Lower-cases the
|
|
60
|
+
* scheme and host, drops a default port and trailing slashes, and throws a
|
|
61
|
+
* message fit to show an operator on anything the grammar refuses. It does not
|
|
62
|
+
* refuse onion hosts: a keeper that cannot reach one says so itself. Text that
|
|
63
|
+
* is already canonical is returned as it stands, without a URL parser.
|
|
64
|
+
*/
|
|
65
|
+
export declare function storageNodeFromBase(text: string): StorageNode;
|