@forgesworn/fold-kit 0.10.0 → 0.12.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 +357 -0
- package/EXTRACTION.md +549 -0
- package/README.md +173 -17
- package/dist/epoch.d.ts +28 -1
- package/dist/epoch.js +99 -60
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -1
- 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/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 +104 -0
- package/llms.txt +97 -28
- 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/EXTRACTION.md
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# Extraction evidence
|
|
2
|
+
|
|
3
|
+
Prepared from KithMoot branch `test/circle-vectors` (PR #206, not merged at
|
|
4
|
+
extraction time), commit `5babfec`. That branch is origin/main `8260c60`
|
|
5
|
+
plus the T0 wire-compatibility work: `vectors/circle-vectors.json`,
|
|
6
|
+
`vectors/generate-circle.mjs`, `vectors/verify-circle.test.ts`,
|
|
7
|
+
`src/labels.test.ts` and the pure `*_LABELS`/`*_INFO` exports the extraction
|
|
8
|
+
plan required before any code moved. The source checkout used for the
|
|
9
|
+
extraction and for `scripts/diff-source.mjs` was not modified.
|
|
10
|
+
|
|
11
|
+
Prepared under a private extraction plan (task T1.1) that also covers work
|
|
12
|
+
outside this repository's scope; see `docs/extraction-plan-excerpt.md` for
|
|
13
|
+
the parts of it this repository's own docs refer to.
|
|
14
|
+
|
|
15
|
+
## What moved (§1.1)
|
|
16
|
+
|
|
17
|
+
Copied with only import paths changed (verified byte-identical against the
|
|
18
|
+
pinned commit by `scripts/diff-source.mjs`, whole-file comparison):
|
|
19
|
+
|
|
20
|
+
| Kit file | Source file |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `src/hex.ts` | `src/hex.ts` |
|
|
23
|
+
| `src/verify.ts` | `src/verify.ts` |
|
|
24
|
+
| `src/identity.ts` | `src/identity.ts` |
|
|
25
|
+
| `src/credential.ts` | `src/credential.ts` |
|
|
26
|
+
| `src/room.ts` | `src/room.ts` |
|
|
27
|
+
| `src/network-hints.ts` | `src/network-hints.ts` |
|
|
28
|
+
| `src/display-name.ts` | `src/display-name.ts` |
|
|
29
|
+
| `src/link.ts` | `src/link.ts` |
|
|
30
|
+
| `src/lane.ts` | `src/lane.ts` |
|
|
31
|
+
| `src/invitation.ts` | `src/invitation.ts` (`./relay-pool.js` import → `./transport.js`) |
|
|
32
|
+
| `src/persistent-invitation.ts` | `src/persistent-invitation.ts` (same rewrite) |
|
|
33
|
+
| `src/epoch.ts` | `src/epoch.ts` (same rewrite) |
|
|
34
|
+
| `test/sim-relay.ts` | `test/sim-relay.ts` (same rewrite) |
|
|
35
|
+
|
|
36
|
+
Unit tests moved with their module, unchanged, for every file above except
|
|
37
|
+
`persistent-invitation.test.ts` (see "Adaptations" below). `test/fake-socket.ts`
|
|
38
|
+
did not move: it is only used by `relay-pool.test.ts` and
|
|
39
|
+
`relay-pool-bad-relays.test.ts`, which belong to the relay subpath (T2.2,
|
|
40
|
+
not this task).
|
|
41
|
+
|
|
42
|
+
## Splits (§1.1)
|
|
43
|
+
|
|
44
|
+
Verified chunk-by-chunk (verbatim doc comment plus declaration) against the
|
|
45
|
+
pinned commit by `scripts/diff-source.mjs`:
|
|
46
|
+
|
|
47
|
+
- **`kinds.ts`** → this kit's `src/kinds.ts` is a subset: `CREDENTIAL`,
|
|
48
|
+
`CHAT`, `INVITATION_REQUEST`, `INVITATION_GRANT`, `INVITATION_RETIREMENT`,
|
|
49
|
+
`GROUP_INVITATION`, `ROOM_REKEY`, `EPOCH_REQUEST`, `EPOCH_GRANT`, each
|
|
50
|
+
copied with its doc comment unchanged. `CHAT` (1460) is included because
|
|
51
|
+
a downstream app's own board events are designed to share KithMoot's
|
|
52
|
+
chat kind (see `docs/extraction-plan-excerpt.md`).
|
|
53
|
+
- **`types.ts`** → this kit's `src/types.ts` is a subset: `DeviceCredential`,
|
|
54
|
+
`AccessTier`, `AgentRule`, `RoomPolicy`, `KindredProof`. `Reachability`
|
|
55
|
+
(imported by KithMoot's `types.ts` for `RosterEntry`, `AssistOffer` and
|
|
56
|
+
others) is not needed by this subset and does not move - the plan's
|
|
57
|
+
"Corrections after the T0 vector review" flags this explicitly.
|
|
58
|
+
- **`access.ts`** → this kit's `src/access.ts` keeps `issueKindredProof`,
|
|
59
|
+
`evaluateAccess`, the `canonicalMessage` helper and `ACCESS_LABELS`, all
|
|
60
|
+
unchanged. `evaluateAgentAccess` stayed in KithMoot: it needs
|
|
61
|
+
`ownership.ts` and `RosterEntry`, neither of which moved.
|
|
62
|
+
- **`chat.ts`** → `deriveChannel`, `CHANNEL_ID_INFO`, `CHANNEL_KEY_INFO` and
|
|
63
|
+
`MAX_CHANNEL_NAME_LENGTH` moved into this kit's new `src/channel.ts`
|
|
64
|
+
(renamed from the source's `CHAT_LABELS` to `CHANNEL_LABELS`, since the
|
|
65
|
+
export now lives in its own file - the two frozen string values are
|
|
66
|
+
unchanged). `ChatLog` and the chat event codecs stayed in KithMoot.
|
|
67
|
+
|
|
68
|
+
## What did not move (per the plan's corrections)
|
|
69
|
+
|
|
70
|
+
- The relay pool (`relay-pool.ts`, `relay-auth.ts`, `anonymous.ts`) stays in
|
|
71
|
+
KithMoot until T2.2, after its churn settles (Decision 8). This kit
|
|
72
|
+
declares its own `RelayTransport` interface in `src/transport.ts`
|
|
73
|
+
(`publish`/`subscribe`/`close`) rather than importing KithMoot's - the
|
|
74
|
+
three codecs that need a transport (`epoch.ts`, `invitation.ts`,
|
|
75
|
+
`persistent-invitation.ts`) only ever call those three methods.
|
|
76
|
+
- `Reachability` stays in KithMoot (see "Splits" above).
|
|
77
|
+
|
|
78
|
+
## Vectors
|
|
79
|
+
|
|
80
|
+
- `vectors/kithmoot-vectors.json`: the circle-layer subset of KithMoot's own
|
|
81
|
+
vector file - the groups `roomDerivation`, `channelDerivation`, `joinUrl`,
|
|
82
|
+
`deviceCredential`, `kindredProof`, `accessEvaluation`, `roomEpoch` and
|
|
83
|
+
`epochRequestAdmission`, copied group-for-group with no edits (same
|
|
84
|
+
`protocolVersion`/`nostrToolsVersion` header). The other groups in
|
|
85
|
+
KithMoot's file (roster event, signal wrap, room descriptor, TURN
|
|
86
|
+
credential, agent ownership, chat attachment, approval control,
|
|
87
|
+
verification words, the message layer) belong to modules that did not
|
|
88
|
+
move and are not carried here.
|
|
89
|
+
- `vectors/circle-vectors.json`: copied whole and unedited from the pinned
|
|
90
|
+
commit - every one of its 8 groups (`linkEnvelope`, `invitationEnvelope`,
|
|
91
|
+
`persistentInvitation`, `epochGrant`, `epochRequest`, `personCredential`,
|
|
92
|
+
`channelsSignature`, `accessEvaluation`) belongs to the circle layer.
|
|
93
|
+
- `vectors/lib/determinism.mjs`, `vectors/lib/fixtures.mjs`: copied
|
|
94
|
+
unchanged. Both are pure, labelled-input helpers with no KithMoot-specific
|
|
95
|
+
behaviour.
|
|
96
|
+
- `vectors/verify.test.ts`: adapted from KithMoot's `vectors/verify.test.ts`
|
|
97
|
+
- only the describe blocks for the 8 circle groups above, with
|
|
98
|
+
`deriveChannel`'s import moved from `../src/chat.js` to `../src/channel.js`.
|
|
99
|
+
Every retained assertion is unchanged.
|
|
100
|
+
- `vectors/verify-circle.test.ts`: copied unchanged except one comment line
|
|
101
|
+
naming the private planning document this repository does not name (see
|
|
102
|
+
"Public repository naming" below) - every import it needs (`kinds.js`,
|
|
103
|
+
`room.js`, `link.js`, `network-hints.js`, `display-name.js`,
|
|
104
|
+
`invitation.js`, `persistent-invitation.js`, `credential.js`, `epoch.js`,
|
|
105
|
+
`access.js`) already matches this kit's module layout one-to-one.
|
|
106
|
+
|
|
107
|
+
## Labels
|
|
108
|
+
|
|
109
|
+
`src/labels.test.ts` is adapted from KithMoot's own label test: same
|
|
110
|
+
scanning regex and per-module ownership check, cut down to the 15 labels
|
|
111
|
+
this kit's moved modules own (KithMoot's full frozen list has more, owned by
|
|
112
|
+
modules that stayed - roster, signalling, pairing, call bell, and so on).
|
|
113
|
+
Every label byte value is unchanged from KithMoot's frozen list.
|
|
114
|
+
|
|
115
|
+
## Adaptations (not wire changes)
|
|
116
|
+
|
|
117
|
+
- `src/persistent-invitation.test.ts` drops one upstream test ("lets an
|
|
118
|
+
agent join and send chat from only stored admission") that drives
|
|
119
|
+
`RoomAgent.join` - an app-level integration point (`agent.ts`, `chat.ts`)
|
|
120
|
+
that is not part of this kit's boundary. The `SimRelay`/`SimTransport`
|
|
121
|
+
replay path that test also exercises is already covered by the other
|
|
122
|
+
tests in the same file.
|
|
123
|
+
- `src/display-name.test.ts` drops one upstream describe block ("the render
|
|
124
|
+
path" / "never puts a display name through innerHTML"), which scans
|
|
125
|
+
KithMoot's `app/src/main.ts` - the PWA source, which does not exist in
|
|
126
|
+
this kit. The rest of the file, including the NUL-byte control-character
|
|
127
|
+
fixture (see its own header comment), is unchanged.
|
|
128
|
+
- `src/kinds.test.ts` and `src/channel.test.ts` are new files: the former
|
|
129
|
+
keeps the "circle-layer kind numbers" uniqueness check from KithMoot's
|
|
130
|
+
`src/api-surface.test.ts` (whose snapshot machinery tracks KithMoot's
|
|
131
|
+
whole library surface and stayed in KithMoot); the latter keeps the two
|
|
132
|
+
`deriveChannel`-only cases from KithMoot's `src/chat.test.ts` "channels"
|
|
133
|
+
describe block (the others need `ChatLog`/`encodeChatEvent`, which stayed
|
|
134
|
+
in KithMoot).
|
|
135
|
+
|
|
136
|
+
## Package
|
|
137
|
+
|
|
138
|
+
ESM only, `exports` for `.` (circle primitives), `./lane` (lane helpers),
|
|
139
|
+
`./mls-admission` (opt-in v4 codecs), `./persistent-invitation-evidence`
|
|
140
|
+
(bounded stored invitation observations) and `./package.json`.
|
|
141
|
+
Peer dependencies (`nostr-tools >=2.24.2 <3`,
|
|
142
|
+
`@noble/hashes ^1.8.0`, `@noble/curves ^2.0.1`) matching KithMoot's pinned
|
|
143
|
+
versions so a consumer keeps a single copy of each, `engines.node >=22.13`,
|
|
144
|
+
`sideEffects: false`. `@noble/hashes` is pinned to its 1.x major only:
|
|
145
|
+
this kit's `@noble/hashes` imports have no `.js` suffix and its `hkdf`
|
|
146
|
+
calls pass a string `info` argument, and 2.x's `exports` map only resolves
|
|
147
|
+
`.js`-suffixed subpaths while its `hkdf` rejects a string `info` - so 2.x
|
|
148
|
+
cannot satisfy these imports as written.
|
|
149
|
+
|
|
150
|
+
## Phase 3 additions (T3.1, T3.3): new code, not moved from KithMoot
|
|
151
|
+
|
|
152
|
+
The plan's Phase 3 (`docs/extraction-plan-excerpt.md`, gaps G1 and G3) calls
|
|
153
|
+
for new kit code beyond what moved from KithMoot. Two tasks are done:
|
|
154
|
+
|
|
155
|
+
- **T3.1, `src/scoped.ts`** (G1): `deriveScoped(epoch, label)`, an
|
|
156
|
+
HKDF-SHA256 derivation from an epoch's KEY under an app-defined labelled
|
|
157
|
+
namespace, so a downstream app (the private planning document's board
|
|
158
|
+
tool, for one) can derive its own payload keys and stream ids under the
|
|
159
|
+
same epoch as the roster and the chat - rotating together on a rekey -
|
|
160
|
+
without ever colliding with, or being mistaken for, one of this kit's own
|
|
161
|
+
derivations. Refuses this kit's own protocol namespace (checked via a
|
|
162
|
+
prefix built by string concatenation rather than a literal, so the check
|
|
163
|
+
itself does not add a new entry to `src/labels.test.ts`'s frozen scan -
|
|
164
|
+
see the comment at its call site) and any label outside
|
|
165
|
+
`^[a-z0-9-]{1,64}/v[0-9]{1,9}/[\x21-\x7e]{1,200}$` - the namespace and
|
|
166
|
+
version segments are capped (64 bytes, 9 digits) because a label is always
|
|
167
|
+
a short, fixed, hand-written string, never something worth accepting
|
|
168
|
+
unboundedly just because the regex could technically match it.
|
|
169
|
+
- **T3.3, `src/sub-cert.ts`** (G3): `createSubKeyCertificate` /
|
|
170
|
+
`verifySubKeyCertificate`, a sub-key certificate - a small, never-published
|
|
171
|
+
kind-20460 event with `scope: "sub"`, signed by a credentialled device,
|
|
172
|
+
binding an app-derived key (typically a `deriveScoped` output) to that
|
|
173
|
+
device for one scoped id. `verifyDeviceCredential` already refuses any
|
|
174
|
+
`scope` it does not recognise, so this new form can never be accepted as a
|
|
175
|
+
room or person credential by the existing, unmodified verifier - no change
|
|
176
|
+
to `credential.ts`'s verification logic was needed for this task.
|
|
177
|
+
Verification is strict and canonical: exactly four tags, each exactly
|
|
178
|
+
`[name, value]`, in a fixed order (`d`, `device`, `expiration`, `scope`);
|
|
179
|
+
`content` must be the empty string; `expiration` must be a canonical
|
|
180
|
+
decimal integer (no sign, no `0x` prefix, no fraction, no leading zeros,
|
|
181
|
+
no surrounding whitespace) compared as the EXACT TAG STRING - not
|
|
182
|
+
numerically - against the accompanying device credential's own
|
|
183
|
+
`expiration` tag. `verifySubKeyCertificate` takes that credential event
|
|
184
|
+
directly (`opts.credential`, already verified by the caller via
|
|
185
|
+
`verifyDeviceCredential`) and reads its `device` and `expiration` tags
|
|
186
|
+
itself, rather than trusting a caller to have copied them out correctly -
|
|
187
|
+
an earlier revision took `device`/`credentialExpiresAt` as separate
|
|
188
|
+
caller-supplied options, which an independent review found let the two
|
|
189
|
+
drift apart from whatever `verifyDeviceCredential` had actually accepted.
|
|
190
|
+
`createSubKeyCertificate` validates its own inputs before asking a signer
|
|
191
|
+
to sign anything (`signerId`/`subKeyPubkey` must be 32-byte hex,
|
|
192
|
+
`expiresAt` a safe integer strictly in the future).
|
|
193
|
+
|
|
194
|
+
Both are pure additions: no existing exported function's signature or body
|
|
195
|
+
changed, and both are covered by `src/scoped.test.ts` / `src/sub-cert.test.ts`
|
|
196
|
+
(unit tests, including a mutation-tested refusal for every security-relevant
|
|
197
|
+
check - see "Mutation testing" below) and `vectors/fold-vectors.json`
|
|
198
|
+
(known-answer vectors, generated by `scripts/generate-fold.mjs` and
|
|
199
|
+
independently recomputed against `src/` by `vectors/verify-fold.test.ts`).
|
|
200
|
+
Every negative certificate vector is validly signed (re-signed under its
|
|
201
|
+
mutated tags/content) rather than reusing the positive certificate's stale
|
|
202
|
+
signature over different bytes, so each one fails for exactly the reason
|
|
203
|
+
under test. `vectors/verify-fold.test.ts` also recomputes every
|
|
204
|
+
`deriveScoped` vector a second way, calling `@noble/hashes`' `hkdf` directly
|
|
205
|
+
rather than `deriveScoped`, so a shared bug between the generator and
|
|
206
|
+
`deriveScoped` itself cannot round-trip undetected. Unlike
|
|
207
|
+
`kithmoot-vectors.json` and `circle-vectors.json`, this vector file has no
|
|
208
|
+
upstream KithMoot counterpart and is not covered by `scripts/diff-source.mjs`.
|
|
209
|
+
|
|
210
|
+
The forgesworn/kithmoot#205 credential fix is a separate change, on its own
|
|
211
|
+
branch/PR (`fix/restamp-205`) based on this one: the `feat/scoped-keys` tip
|
|
212
|
+
this branch is based on makes no change to `credential.ts` at all; only the
|
|
213
|
+
commit below does.
|
|
214
|
+
|
|
215
|
+
### Mutation testing
|
|
216
|
+
|
|
217
|
+
Every security-relevant check in `src/scoped.ts` and `src/sub-cert.ts` was
|
|
218
|
+
mutated (the check deleted, or its condition forced to never fire) one at a
|
|
219
|
+
time, and the full targeted test suite (`src/scoped.test.ts`,
|
|
220
|
+
`src/sub-cert.test.ts`, `vectors/verify-fold.test.ts`) was re-run after each
|
|
221
|
+
mutation. All 29 mutations were killed (caused at least one test to fail):
|
|
222
|
+
3 in `scoped.ts` (the protocol-namespace refusal, the label-pattern check,
|
|
223
|
+
the epoch-key-length check) and 26 in `sub-cert.ts` (every tag-name check
|
|
224
|
+
individually - `d`, `device`, `expiration` and `scope`, each checked at its
|
|
225
|
+
own fixed position rather than only via a full reorder - the tag-count
|
|
226
|
+
check, the per-tag-shape check, the scope-value check, the signer-id check,
|
|
227
|
+
the sub-key/message-signer check, the credential-missing-device-tag check,
|
|
228
|
+
the wrong-device check, the canonical-expiration-format check, the
|
|
229
|
+
credential-missing-expiration-tag check, the expiration-equality check, the
|
|
230
|
+
expired check, the at/skew bound, the certificate signature check, both
|
|
231
|
+
mint-time hex-shape validations, the mint-time future-integer check, and
|
|
232
|
+
all four of `createSubKeyCertificate`'s post-signing equality checks -
|
|
233
|
+
signer pubkey, kind/content, tags, and `verifyEventUncached`). Each mutation
|
|
234
|
+
was reverted immediately after its run; none is present in the committed
|
|
235
|
+
code. The `RestampedCredentialExpiryError`/`overBySeconds` check the #205
|
|
236
|
+
fix below adds was likewise mutated and killed (see that section).
|
|
237
|
+
|
|
238
|
+
## The #205 fix: the one deliberate difference from the pinned source
|
|
239
|
+
|
|
240
|
+
`src/credential.ts` is not quite byte-identical to the pinned commit: it
|
|
241
|
+
carries one small, deliberate fix for
|
|
242
|
+
[forgesworn/kithmoot#205](https://github.com/forgesworn/kithmoot/issues/205).
|
|
243
|
+
|
|
244
|
+
The bug: `createDeviceCredential`'s 30-day cap on a person-scope credential
|
|
245
|
+
is checked against the *requested* `now` (`opts.expiresAt - now`), but
|
|
246
|
+
`verifyDeviceCredential`'s matching check is against the *signed*
|
|
247
|
+
`created_at` (`expiresAt - cred.created_at`). A remote signer (a bunker, a
|
|
248
|
+
phone) that restamps `created_at` to its own, earlier clock - which
|
|
249
|
+
`createDeviceCredential` deliberately tolerates, since some signers do this
|
|
250
|
+
and nothing here should assume otherwise - can then produce a credential
|
|
251
|
+
that mints successfully but is refused as `"longer than 30 days"` by every
|
|
252
|
+
verifier, including `verifyDeviceCredential` itself.
|
|
253
|
+
|
|
254
|
+
The fix is two small additions, both stripped by name in
|
|
255
|
+
`scripts/diff-source.mjs` (see below): a typed error,
|
|
256
|
+
`RestampedCredentialExpiryError` (exported from the package root, carrying
|
|
257
|
+
`overBySeconds`, so a caller can tell this failure apart from the function's
|
|
258
|
+
other, unrelated failure modes and retry specifically for it), and a check
|
|
259
|
+
right after the existing post-signing checks in `createDeviceCredential`
|
|
260
|
+
that re-measures the cap using the verifier's own calculation (against
|
|
261
|
+
`signed.created_at`) and throws that error immediately if it is over, rather
|
|
262
|
+
than returning a credential doomed to be refused everywhere. A caller that
|
|
263
|
+
hits this (as the downstream sync spec's own retry logic does) asks again
|
|
264
|
+
with a shorter `expiresAt` margin. `verifyDeviceCredential` itself is
|
|
265
|
+
untouched - its measurement was already correct; only the mint side was
|
|
266
|
+
checking the wrong clock.
|
|
267
|
+
|
|
268
|
+
`scripts/diff-source.mjs` proves these two additions are the *only*
|
|
269
|
+
difference: it strips exactly these two declared blocks (each matched
|
|
270
|
+
verbatim) from `src/credential.ts` before comparing the rest of the file,
|
|
271
|
+
byte for byte, against the pinned commit. If either block's text ever
|
|
272
|
+
drifted from what the script declares, the strip would stop matching and
|
|
273
|
+
the check would fail loudly rather than silently widening what counts as
|
|
274
|
+
"the declared difference".
|
|
275
|
+
|
|
276
|
+
This fix changes the behaviour one existing vector was written to pin: the
|
|
277
|
+
`personCredential`/`refused-over-30-days` vector in `circle-vectors.json`
|
|
278
|
+
(KithMoot's own `M15`) exercises exactly the restamp-at-the-cap scenario the
|
|
279
|
+
bug describes, and was frozen expecting a successful mint followed by a
|
|
280
|
+
verifier refusal. That vector's `output` keeps both halves of M15's
|
|
281
|
+
original coverage - `event` and `result` are still the exact over-cap event
|
|
282
|
+
a pre-#205 mint would have produced (built deterministically in the test,
|
|
283
|
+
since the real `createDeviceCredential` now refuses to produce it) and
|
|
284
|
+
`verifyDeviceCredential`'s own refusal of it, byte-identical to before - and
|
|
285
|
+
adds `mintThrew`, the new mint-time refusal message. The vector's `note` and
|
|
286
|
+
the corresponding test in `vectors/verify-circle.test.ts` are updated to
|
|
287
|
+
check all three - the issue itself anticipated this ("The vectors pin
|
|
288
|
+
current [i.e. buggy] behaviour; this fix is separate"). Every other vector
|
|
289
|
+
in both `kithmoot-vectors.json` and `circle-vectors.json`, and their
|
|
290
|
+
generators, are untouched. `src/credential-205.test.ts` adds two more cases
|
|
291
|
+
through the real function: the same at-the-cap restamp now throwing
|
|
292
|
+
(asserted as `RestampedCredentialExpiryError`, with a mutation-tested check
|
|
293
|
+
on `overBySeconds`), and a one-hour-margin request succeeding under the same
|
|
294
|
+
restamp.
|
|
295
|
+
|
|
296
|
+
**This is a breaking behaviour change for `createDeviceCredential`, called
|
|
297
|
+
out in `CHANGELOG.md`.** KithMoot's own `src/credential.ts` has the same
|
|
298
|
+
bug, unfixed; `CHANGELOG.md` says what has to change there (`M15` in its
|
|
299
|
+
`vectors/verify-circle.test.ts` and `vectors/generate-circle.mjs`) in a
|
|
300
|
+
paired PR.
|
|
301
|
+
|
|
302
|
+
## Conference rooms: declared additions to three moved modules
|
|
303
|
+
|
|
304
|
+
0.3.0 adds conference rooms - a persistent group with a fixed end, carried
|
|
305
|
+
as a NIP-40 expiration - to `invitation.ts` (retirement `endsAt`),
|
|
306
|
+
`persistent-invitation.ts` (invitation `endsAt`, body `ends`, the decoder's
|
|
307
|
+
tag/body agreement check, earliest end wins in
|
|
308
|
+
`requestPersistentRoomAdmission`) and `epoch.ts` (`expiresAt` on the rekey,
|
|
309
|
+
request and grant encoders and both desk functions). The rule itself lives
|
|
310
|
+
in a new module, `src/expiration.ts`, which moved from nowhere.
|
|
311
|
+
|
|
312
|
+
Each addition is optional and additive: with it absent, every event these
|
|
313
|
+
modules sign is byte-identical to before, and every existing vector still
|
|
314
|
+
verifies. `scripts/diff-source.mjs` lists each added or altered line in
|
|
315
|
+
`CONFERENCE_CHANGES` as `[kit text, pinned source text, count]`, checks
|
|
316
|
+
each occurs exactly `count` times, puts the source text back, and then
|
|
317
|
+
compares the whole file against the pinned commit as before - so these are
|
|
318
|
+
the only differences, and a drifted line fails the check rather than
|
|
319
|
+
widening it. KithMoot's own copies of these modules are re-export shims
|
|
320
|
+
since its cutover, so it picks this up on its next version bump.
|
|
321
|
+
|
|
322
|
+
## Room relays: declared additions to `persistent-invitation.ts`
|
|
323
|
+
|
|
324
|
+
0.4.0 adds the room's own relays to the group invitation body, after
|
|
325
|
+
`ends`: the relays a room is created on, which every member's pool then
|
|
326
|
+
includes. The rule (one to eight distinct, safe, canonical URLs) lives in a
|
|
327
|
+
new module, `src/invitation-relays.ts`, which moved from nowhere. With no
|
|
328
|
+
`relays` the body is byte-identical to 0.3.0's. `scripts/diff-source.mjs`
|
|
329
|
+
declares each added line in `ROOM_RELAY_CHANGES` as 0.4.0 text against
|
|
330
|
+
0.3.0 text, applied before `CONFERENCE_CHANGES`, so the whole-file
|
|
331
|
+
comparison against the pinned commit still holds.
|
|
332
|
+
|
|
333
|
+
## Member epoch catch-up: declared additions to `epoch.ts`
|
|
334
|
+
|
|
335
|
+
Unreleased. Any current member can now hand an admitted, non-removed device
|
|
336
|
+
the epochs it missed, with the authority's own rekeys as evidence (see
|
|
337
|
+
`docs/member-epoch-catch-up.md`). The new code lives in two new modules
|
|
338
|
+
that moved from nowhere: `src/epoch-commit.ts` (the commitment, a leaf with
|
|
339
|
+
no imports from `epoch.ts`, so there is no import cycle) and
|
|
340
|
+
`src/member-epoch.ts` (the member request and grant, the desk and the
|
|
341
|
+
requester). Its kinds, 20471 and 20472, are exported from there as
|
|
342
|
+
`MEMBER_EPOCH_KINDS` rather than added to `KINDS`, whose body
|
|
343
|
+
`scripts/diff-source.mjs` proves is exactly the pinned source's.
|
|
344
|
+
|
|
345
|
+
`epoch.ts` gains two optional, additive options: `commit` on
|
|
346
|
+
`encodeRekeyEvent` (writes the commitment into the body) and `members` on
|
|
347
|
+
`requestRoomEpoch` (a second source of answers). With both absent, every
|
|
348
|
+
event is byte-identical to 0.4.0's and every existing vector still verifies.
|
|
349
|
+
`scripts/diff-source.mjs` declares each added line in
|
|
350
|
+
`MEMBER_EPOCH_CHANGES`, as new text against 0.4.0 text, applied before
|
|
351
|
+
`ROOM_RELAY_CHANGES` and `CONFERENCE_CHANGES`, so the whole-file comparison
|
|
352
|
+
against the pinned commit still holds.
|
|
353
|
+
|
|
354
|
+
## Seal keys: declared additions to `credential.ts` and `epoch.ts`
|
|
355
|
+
|
|
356
|
+
0.7.0. A device credential may name the device's seal key, and rekeys and
|
|
357
|
+
epoch grants are sealed to it instead of the device key (see
|
|
358
|
+
`docs/seal-key.md`). The new code lives in `src/seal.ts`, which moved from
|
|
359
|
+
nowhere. `credential.ts` gains the optional `seal` on
|
|
360
|
+
`createDeviceCredential`; `epoch.ts` gains seal-keyed recipients, `sealSks`
|
|
361
|
+
on the decoders and `requestRoomEpoch`, `credential` on `encodeEpochGrant`,
|
|
362
|
+
`credentialFor` on `hostRoomEpoch`, and `sealCredential`. With no seal key
|
|
363
|
+
anywhere, every event is byte-identical to 0.6.0's. `scripts/diff-source.mjs`
|
|
364
|
+
declares each hunk in `SEAL_CHANGES`, as 0.7.0 text against 0.6.0 text,
|
|
365
|
+
applied before every other change set but `SCHEDULE_CHANGES` (and, for
|
|
366
|
+
`credential.ts`, before the #205 blocks are stripped).
|
|
367
|
+
|
|
368
|
+
## Scheduled rekeys: declared additions to `epoch.ts`
|
|
369
|
+
|
|
370
|
+
0.8.0. A rekey may be marked `scheduled`, the history window is a shared
|
|
371
|
+
rule (`HISTORY_WINDOW_SECONDS`, `MAX_HISTORY_EPOCHS`, `epochsInWindow`), and
|
|
372
|
+
the authority's grant carries the window's left epochs (`passed`, and `past`
|
|
373
|
+
on `hostRoomEpoch`). See `docs/scheduled-rekey.md`. A rekey without the
|
|
374
|
+
marker, and a grant without `passed`, is byte-identical to 0.7.0's.
|
|
375
|
+
`scripts/diff-source.mjs` declares each hunk in `SCHEDULE_CHANGES`, as 0.8.0
|
|
376
|
+
text against 0.7.0 text, applied before every other change set but `DESTRUCT_CHANGES`. The matching
|
|
377
|
+
reader changes in `member-epoch.ts` (`RekeyEvidence.scheduled`, and `leftAt`
|
|
378
|
+
on a member grant's passed epochs) need no declaration: that module is new
|
|
379
|
+
code and not compared against the pinned source.
|
|
380
|
+
|
|
381
|
+
## Self-destructing rooms: declared additions to three moved modules
|
|
382
|
+
|
|
383
|
+
0.9.0. An optional `destruct: true` in the group invitation body
|
|
384
|
+
(`persistent-invitation.ts`), in an ended room's retirement
|
|
385
|
+
(`invitation.ts`) and in a closing rekey's body (`epoch.ts`), each surfaced
|
|
386
|
+
on its reader, and kept by `requestPersistentRoomAdmission` if any signed
|
|
387
|
+
copy carries it. See `docs/room-destruct.md`. With the flag absent every
|
|
388
|
+
event is byte-identical to 0.8.0's. `scripts/diff-source.mjs` declares each
|
|
389
|
+
hunk in `DESTRUCT_CHANGES`, as 0.9.0 text against 0.8.0 text, applied before
|
|
390
|
+
every other change set. The matching reader change in `member-epoch.ts`
|
|
391
|
+
(`RekeyEvidence.destruct`) needs no declaration.
|
|
392
|
+
|
|
393
|
+
## Public repository naming
|
|
394
|
+
|
|
395
|
+
This repository is public. It does not name the private downstream app that
|
|
396
|
+
motivated some of the moves and split points above (the board-events kind
|
|
397
|
+
share, for instance) - see `docs/extraction-plan-excerpt.md`, which carries
|
|
398
|
+
the neutral wording used throughout this repository's docs, comments and
|
|
399
|
+
tests in place of that name.
|
|
400
|
+
|
|
401
|
+
## For KithMoot's cutover (T2.1, not this task)
|
|
402
|
+
|
|
403
|
+
Notes for whoever writes KithMoot's re-export shims, so the codec cutover
|
|
404
|
+
does not silently change KithMoot's own public surface:
|
|
405
|
+
|
|
406
|
+
- Shims must use named `export { … }` / `export type { … }`, not
|
|
407
|
+
`export * from '@forgesworn/fold-kit'`. A wildcard re-export would pull in
|
|
408
|
+
all of this kit's ~96 root exports, including several (such as `KINDS`
|
|
409
|
+
itself, or `RelayTransport`) that KithMoot's own modules already export
|
|
410
|
+
under the same name from elsewhere - and it would change KithMoot's
|
|
411
|
+
`src/api-surface.test.ts` snapshot for every shimmed file, which is
|
|
412
|
+
exactly what that snapshot exists to catch.
|
|
413
|
+
- The `chat.ts` shim must not re-export `CHANNEL_LABELS`: that name does
|
|
414
|
+
not exist in KithMoot today (its own label is `CHAT_LABELS`, kept in
|
|
415
|
+
KithMoot since `ChatLog` and the chat codecs did not move), and
|
|
416
|
+
`src/labels.test.ts` would otherwise see two different exported lists
|
|
417
|
+
claiming the same two label strings.
|
|
418
|
+
- The `kinds.ts` shim: KithMoot's `KINDS` object should spread this kit's
|
|
419
|
+
`KINDS` first, then KithMoot's own remaining kind entries, and drop
|
|
420
|
+
KithMoot's own now-duplicate `CHAT` field (this kit's `KINDS.CHAT` is
|
|
421
|
+
byte-identical to KithMoot's, so the spread order does not change the
|
|
422
|
+
value - only which module's object literal defines it).
|
|
423
|
+
- The `types.ts` shim must `import type` whichever of this kit's types
|
|
424
|
+
KithMoot's own `types.ts` still uses (`DeviceCredential`, `AccessTier`,
|
|
425
|
+
`RoomPolicy`, `KindredProof`) rather than redeclaring them, so there is
|
|
426
|
+
exactly one definition of each.
|
|
427
|
+
|
|
428
|
+
## Validation
|
|
429
|
+
|
|
430
|
+
- `npm run check` (typecheck + test + diff-source) on Node 22.13 and Node
|
|
431
|
+
24: green on both.
|
|
432
|
+
- `scripts/diff-source.mjs` against commit `5babfec`: zero differences.
|
|
433
|
+
- Both vector files verify against this kit's own functions.
|
|
434
|
+
- `scripts/bundle-check.mjs`: esbuild browser bundle of all entry points,
|
|
435
|
+
no `node:` import, within the recorded size budget.
|
|
436
|
+
- `scripts/tarball-smoke.mjs`: `npm pack`, install into a scratch directory,
|
|
437
|
+
import and exercise every export from all entry points.
|
|
438
|
+
|
|
439
|
+
KithMoot has cut over to this kit (T2.1) and pins an exact npm version;
|
|
440
|
+
its moved files are re-export shims. Any change here that alters behaviour
|
|
441
|
+
KithMoot's vectors record (for example the #205 fix) lands in KithMoot in
|
|
442
|
+
the same PR as the version bump.
|
|
443
|
+
|
|
444
|
+
## Live persistent admission
|
|
445
|
+
|
|
446
|
+
`src/live-persistent-admission.ts` is a new opt-in profile, not a change to a
|
|
447
|
+
moved invitation module. It uses kinds 20466/20467 with a distinct request KDF
|
|
448
|
+
and body profile, preserves kind 1463 bytes, and grants no delegation. Its
|
|
449
|
+
descriptor, challenge and answer codecs own no transport or storage. The host
|
|
450
|
+
lifecycle journal, client epoch gate and physical transport remain separate
|
|
451
|
+
integration requirements; see `docs/live-persistent-admission.md`.
|
|
452
|
+
|
|
453
|
+
## Grant publication callbacks
|
|
454
|
+
|
|
455
|
+
The host's publication callbacks now follow settlement of the injected
|
|
456
|
+
transport's `publish(grant)` promise. `onGrantPublished(request)` correlates
|
|
457
|
+
success to the request; the legacy `onAdmitted(device)` observes the same
|
|
458
|
+
acknowledgement. `onGrantFailed(request, error)` exposes a rejected or throwing
|
|
459
|
+
send. A closed or retired host suppresses outstanding callbacks, and observer
|
|
460
|
+
exceptions cannot change the publication outcome. These are local API and
|
|
461
|
+
timing changes; request/grant encoders, kinds, envelopes and derivation labels
|
|
462
|
+
are unchanged. `GRANT_PUBLICATION_CHANGES` in `scripts/diff-source.mjs` reverses
|
|
463
|
+
the two exact interface/host hunks before comparing with the extracted source.
|
|
464
|
+
|
|
465
|
+
## Invitation account proof and bounded request lifecycle
|
|
466
|
+
|
|
467
|
+
The live invitation request now accepts an optional `accountProof`. The new
|
|
468
|
+
proof profile signs kind 20466 with exactly the tags
|
|
469
|
+
`t=kithmoot/v2/invitation-account-proof`, `d=<invitation id>` and
|
|
470
|
+
`p=<pinned inviter>`, content `{"v":1,"device":"<request device>"}` and the
|
|
471
|
+
outer request's timestamp. Its account pubkey must match `participant`.
|
|
472
|
+
The version-1 proof body is a new profile under its own domain; the existing
|
|
473
|
+
version-1 encrypted request body retains its bytes when the field is absent.
|
|
474
|
+
Existing readers ignore the optional field. No existing derivation label,
|
|
475
|
+
kind or grant format changes. The proof is nested inside the bearer-encrypted
|
|
476
|
+
request and must never be independently published.
|
|
477
|
+
|
|
478
|
+
`encodeInvitationAccountProof` validates the matching signer's actual returned
|
|
479
|
+
signature and context. `decodeInvitationRequest` exposes `verifiedParticipant`
|
|
480
|
+
only after an uncached verification of the matching proof. The existing
|
|
481
|
+
`participant` remains an unverified claim; invalid proofs do not prevent a
|
|
482
|
+
host from considering an anonymous/manual request, but cannot establish an
|
|
483
|
+
account's authority. Consumers must use `verifiedParticipant` for automatic
|
|
484
|
+
account-based admission. New known-answer vectors record the new profile,
|
|
485
|
+
legacy requests and forged or substituted nested proofs.
|
|
486
|
+
|
|
487
|
+
The request helper optionally creates the proof using its caller's matching
|
|
488
|
+
identity. Account signing and relay waiting share a deadline no longer than
|
|
489
|
+
the 90-second request freshness window. Cancellation or retirement suppresses
|
|
490
|
+
late signing/publication; only a cloned request key is erased. Host decisions
|
|
491
|
+
recheck freshness before granting and report expired decisions via the failure
|
|
492
|
+
observer. `INVITATION_ACCOUNT_CHANGES` in `scripts/diff-source.mjs` reverses
|
|
493
|
+
these exact additions before the previously declared changes and the complete
|
|
494
|
+
comparison with the pinned source. This is an additive public API release,
|
|
495
|
+
0.11.0; it does not make an unmodified consumer's admission policy safe.
|
|
496
|
+
|
|
497
|
+
## Authenticated admission refusal (0.12.0)
|
|
498
|
+
|
|
499
|
+
The new `invitation-decline.ts` codec uses a version-3 refusal envelope on the
|
|
500
|
+
existing encrypted reply kind 20467. The version-2 grant body, existing keys,
|
|
501
|
+
derivation labels and invitation URLs are unchanged. A refusal is addressed to
|
|
502
|
+
one request device and event ID, signed by the pinned root or authenticated via
|
|
503
|
+
its current bounded delegation chain; it carries no traffic secret or newly
|
|
504
|
+
issued authority. Bearer possession alone cannot refuse another guest.
|
|
505
|
+
New request helpers stop their immutable retries and erase only the owned
|
|
506
|
+
exchange key on an authenticated refusal. Earlier readers reject this envelope
|
|
507
|
+
as a grant and retain their previous bounded timeout. Host false decisions
|
|
508
|
+
retain their historical silence unless a consumer explicitly publishes the new
|
|
509
|
+
refusal. Concurrent authorised replies use the first valid received outcome; a
|
|
510
|
+
refusal does not revoke an already granted capability. `INVITATION_DECLINE_CHANGES`
|
|
511
|
+
reverses the exact helper import and response handling before all earlier
|
|
512
|
+
declared normalisations and the full pinned-source comparison. Synthetic known
|
|
513
|
+
answers cover the new profile and hostile request, authority and version changes.
|
|
514
|
+
|
|
515
|
+
## Persistent invitation evidence
|
|
516
|
+
|
|
517
|
+
`persistent-invitation-evidence.ts` is an additive observation/custody API. It
|
|
518
|
+
uses the existing signed group invitation and retirement formats and leaves
|
|
519
|
+
`persistent-invitation.ts` and its legacy request/error behaviour unchanged.
|
|
520
|
+
The evidence path independently rechecks a complete bounded observed set and
|
|
521
|
+
holds on conflicting newest equal-time relay lists; its EOSE receipt does not
|
|
522
|
+
establish global absence or current membership. No storage or client effects
|
|
523
|
+
move into the kit. See `docs/persistent-invitation-evidence.md` for the carrier,
|
|
524
|
+
selection, refusal and caller integration boundaries.
|
|
525
|
+
|
|
526
|
+
## Quiet automatic replies: declared additions to `epoch.ts` and `invitation.ts`
|
|
527
|
+
|
|
528
|
+
Unreleased. The authority desk waits a random time before every answer and
|
|
529
|
+
rations its refusals, and the temporary-link responder gains an opt-in wait
|
|
530
|
+
and coordinated mode (see `docs/quiet-replies.md`). The shared helpers live
|
|
531
|
+
in `src/quiet-reply.ts`, new and moved from nowhere. No event changes: every
|
|
532
|
+
grant and refusal is encoded as before, so every vector still verifies.
|
|
533
|
+
`hostRoomEpoch` waits by default; `hostRoomInvitation` changes nothing
|
|
534
|
+
unless `grantDelayMs` or `coordinate` is passed. `scripts/diff-source.mjs`
|
|
535
|
+
declares each change in `QUIET_REPLY_CHANGES`, as new text against 0.12.0
|
|
536
|
+
text, applied before every other change set, so the whole-file comparison
|
|
537
|
+
against the pinned commit still holds.
|
|
538
|
+
|
|
539
|
+
## Live invitation grant retry handling
|
|
540
|
+
|
|
541
|
+
`hostRoomInvitation` now retains a bounded per-request decision and the exact
|
|
542
|
+
signed grant. A fresh duplicate can retry a lost or rejected ephemeral grant
|
|
543
|
+
without another approval, new delegation or repeated success callback. Pending
|
|
544
|
+
approval, quiet waits and publication coalesce duplicates. Every send still
|
|
545
|
+
checks request freshness, current delegated authority and host lifetime. Live
|
|
546
|
+
entries are not evicted to make room; only idle expired entries are pruned.
|
|
547
|
+
This changes local host behaviour, not any encoder or wire format.
|
|
548
|
+
`INVITATION_RETRY_CHANGES` in `scripts/diff-source.mjs` reverses these exact
|
|
549
|
+
hunks before earlier declared changes and the pinned-source comparison.
|