@forgesworn/fold-kit 0.9.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.
@@ -0,0 +1,192 @@
1
+ # Live persistent admission, version 1
2
+
3
+ This profile lets a first-time device obtain a persistent room's signed
4
+ invitation without claiming that a mesh cache contains complete history. It is
5
+ a new, opt-in exchange. Existing v3 stored invitations, legacy live delegation,
6
+ and the epoch desk are unchanged. The codecs alone do not enable offline joining.
7
+
8
+ ## Authority and the remaining gate
9
+
10
+ Only the root inviter pinned by the original persistent link may answer. The
11
+ answer supplies the existing signed kind 1463 envelope and a current epoch hint;
12
+ it supplies no delegation, inviter key or current epoch secret. A delegate or
13
+ cache cannot assert that an invitation has not been retired.
14
+
15
+ Before making an answer, the host must recover its durable invitation lifecycle
16
+ and room epoch state, including pending writes, retirement, closure, earliest
17
+ room end and sticky self-destruct policy. It serialises the final state check,
18
+ answer construction and handoff with retirement, rekey and closure. An old
19
+ export or a keeper file without lifecycle evidence cannot become ready merely
20
+ because it contains the root key. A host with unknown, pending, corrupt, retired,
21
+ closed or expired state refuses. Key possession alone is insufficient.
22
+
23
+ The existing keeper v2 file does not record a complete invitation lifecycle or
24
+ an atomic pending transition. Host integration therefore needs a versioned
25
+ durable journal before enabling this profile across a cold start. Older state
26
+ must not be silently interpreted as proof of an active invitation. Multiple
27
+ independently writable copies of a root key cannot provide global freshness
28
+ while partitioned; this profile requires one logical authority writer with
29
+ exclusive durable ownership. Restoring an old backup needs explicit recovery.
30
+
31
+ A verified answer is evidence of the root's state at the time it was signed,
32
+ not a lease guaranteeing the room cannot change afterwards. Retirement learned
33
+ locally always wins, even over a newer answer. A response already in transit
34
+ cannot be recalled. A copied bearer/envelope already reveals epoch zero;
35
+ retirement cannot erase it. No offline protocol defeats a lying root or hidden
36
+ revocation indefinitely.
37
+
38
+ After the exchange the client must run an authenticated, current epoch desk
39
+ exchange **even for an epoch-zero hint**, before roster, chat or forwarding.
40
+ The current client's settle delay / expected-epoch fast path is insufficient
41
+ for this entry mode. A hint is a minimum recovery target, never a current-key
42
+ grant. An unknown participant stays pending for the existing admission decision;
43
+ the new challenge must not call `letIn` from an unproved name or participant key.
44
+ Removal, access policy and device credential checks remain the epoch desk's job.
45
+ The client stays non-publishing if this gate cannot finish. Rekey racing the
46
+ gate retains the room's existing authenticated transition rules and limitations.
47
+
48
+ ## Routing descriptor
49
+
50
+ The original invitation URL remains intact. Alongside it carry a separate
51
+ base64url-without-padding UTF-8 JSON descriptor, at most 512 encoded characters:
52
+
53
+ ```json
54
+ {"v":1,"room":"<root room id>","invitation":"<derived invitation id>","inviter":"<pinned root public key>"}
55
+ ```
56
+
57
+ All three values are 64 lowercase hex characters. These are the only fields.
58
+ Encoders use the displayed order without whitespace. Decoders accept JSON field
59
+ order differences but require minified JSON that round-trips through JSON.stringify
60
+ without changes. They refuse duplicate fields, whitespace, unknown fields,
61
+ non-canonical base64url and wrong invitation/authority. The decoded JSON is at
62
+ most 384 bytes. The expected room is checked again against the authenticated
63
+ response and the decrypted kind 1463 envelope. A modified descriptor can deny
64
+ discovery but cannot substitute a room. Derive the existing nearby scope/UUID
65
+ from this root room ID; never advertise the bearer or original URL. The room ID
66
+ and persistent discovery scope reveal correlation. This descriptor is routing
67
+ metadata, not admission evidence. A client must select Nearby before parsing it
68
+ into any transport; signed relay hints never override that route.
69
+
70
+ The first implementation exposes the descriptor separately; it does not invent
71
+ a new universal-link envelope that an old app might open through the internet.
72
+ App QR/share integration must preserve the original link and explicit route.
73
+
74
+ ## Wire exchange
75
+
76
+ Reuse ephemeral kinds 20466 / 20467 with a distinct encrypted profile. This
77
+ allocates no MeshCore data type. No public relay publication is required.
78
+ Integers are nonnegative safe integers; timestamps are Unix seconds. All hex
79
+ fields are lowercase. Signatures are checked without trusting cached verdicts.
80
+ Tags contain exactly one two-element `d`, `p` and `expiration`, no others;
81
+ ordering does not matter to readers. Writers emit that order.
82
+
83
+ The requester generates a fresh one-use secp256k1 key and signs kind 20466.
84
+ `d` is the existing bearer-derived invitation ID, `p` the root inviter,
85
+ `expiration` is `created_at + 90` as canonical decimal. Its NIP-44 v2 plaintext:
86
+
87
+ ```json
88
+ {"v":1,"profile":"persistent-live","room":"<expected root room id>","requester":"<event pubkey>"}
89
+ ```
90
+
91
+ The symmetric request key is HKDF-SHA256(bearer, empty salt,
92
+ `kithmoot/v1/persistent-live/request-key`, 32). The body has exactly these fields, in minified JSON that round-trips through
93
+ JSON.stringify without changes (field order may differ). The same rule applies
94
+ to the response body, rejecting duplicate fields and ambiguous number spellings.
95
+ An event ID binds all request fields and acts as the challenge identifier.
96
+ Retries use that same event; a new user attempt uses a new key and randomness.
97
+ The host rejects a request over 90 seconds old, more than five seconds in the
98
+ future, or at/after its expiry. Legacy request decoders use another key and
99
+ cannot treat this request as a delegation request.
100
+
101
+ The root signs kind 20467, addressed by the same `d` and the requester's `p`.
102
+ Its expiry is `min(request.expiration, response.created_at + 30)`. Its NIP-44
103
+ conversation key is derived from the root secret and requester public key.
104
+ Plaintext, with exactly these fields:
105
+
106
+ ```json
107
+ {"v":1,"profile":"persistent-live","request":"<request event id>","room":"<expected root room id>","epoch":0,"invitation":{}}
108
+ ```
109
+
110
+ `invitation` is the complete existing signed kind 1463 event, with exactly its
111
+ seven wire fields (`id`, `pubkey`, `created_at`, `kind`, `tags`, `content`, `sig`).
112
+ Do not re-sign it merely to answer a retry. The response's `epoch` is the host's
113
+ current nonnegative safe integer epoch, separately returned as `epochHint`;
114
+ the decoded persistent invitation still opens epoch zero.
115
+
116
+ The requester validates its original live request, matches its private reply
117
+ key, verifies the pinned root signature, exact tags and expiry, decrypts, and
118
+ checks the exact request ID, profile, room and bounded embedded event. The
119
+ response may be at most five seconds earlier than the request (clock skew),
120
+ at most five seconds ahead of local time, and must not be expired. Its expiry
121
+ must equal the formula above. The signed invitation must decrypt under the
122
+ original bearer, name the expected room, and be neither future-dated beyond
123
+ five seconds nor ended. End/relay/self-destruct semantics are unchanged.
124
+ No malformed response escapes as an exception or supplies another authority.
125
+
126
+ Legacy v2 grants, delegated responses, cached kind 1463 alone, another request's
127
+ answer, and an answer from an earlier attempt cannot satisfy this exchange.
128
+ The decoder is stateless: the request owner must consume success at most once,
129
+ remove its subscription and delete the ephemeral key on success, cancel or
130
+ timeout. Do not persist reply keys for restarting a challenge.
131
+
132
+ ## Resource and transport limits
133
+
134
+ Before cryptography, bound request content to 2,048 characters and complete
135
+ request JSON to 4,096 UTF-8 bytes; response content to 16,384 characters and
136
+ complete response JSON to 20,480 bytes. An embedded invitation is at most 8,192
137
+ UTF-8 bytes, content at most 6,144 characters, eight tags, four strings per tag,
138
+ each at most 256 characters. Bound complete raw input before parsing JSON;
139
+ `parseLivePersistentEvent` provides that raw boundary and requires minified JSON
140
+ without duplicate fields. Typed-event decoders can only bound the already
141
+ allocated event object they receive.
142
+ Refuse oversize envelopes instead of trimming relay policy or authority fields.
143
+
144
+ The exchange owner allows one outstanding challenge per device/room, a
145
+ 90-second monotonic deadline, and at most three identical offers at 0, 10 and
146
+ 20 seconds. Retries must fit inside the cached response's 30-second lifetime:
147
+ the previously specified 0/30/60 schedule could recover a lost request but
148
+ could never recover a lost response. The shared-mesh integration exposed this
149
+ on 2026-10-09. Response bytes and expiry remain unchanged; an expired response
150
+ is never re-signed to extend it. Wall-clock rollback cancels it; time changes never extend the
151
+ monotonic deadline. Silence is unavailable, not admission or an automatic
152
+ internet fallback. There is no cache-complete/EOSE substitute.
153
+
154
+ The responder deduplicates at most 128 request IDs for their remaining lifetime
155
+ and stores at most one signed reply per accepted request. Admission refuses at
156
+ capacity instead of evicting live guards. Each retry rechecks lifecycle state
157
+ and expiry before reoffering identical bytes. No re-sign on expiry. Global
158
+ limits precede per-peer limits because fresh requester keys are cheap: at most
159
+ 16 accepted challenges and 64 KiB of reply offers per rolling minute per
160
+ authority process, divided among at most eight active rooms. Limit pre-crypto
161
+ input work too. A process restart must not reset these budgets. The authority's
162
+ durable control ledger is separate from any radio airtime ledger.
163
+
164
+ These are application bounds, not LoRa duty-cycle compliance. A radio route
165
+ must reserve its own airtime and fragmentation budget and can decline a large
166
+ reply. BLE qualification comes first. Native text-channel bridging continues
167
+ to use its separate gateway identity and trust boundary. Ordinary mixed room
168
+ transport does not forward these control messages automatically. A later
169
+ transit owner must opt in to exact request/response selectors, directions,
170
+ deadlines and quotas; no wildcard control forwarding.
171
+
172
+ ## Implementation and acceptance gates
173
+
174
+ 1. Independent TypeScript/Kotlin codecs and frozen deterministic wire vectors:
175
+ request and response bytes, descriptor, wrong bearer/root/room/request/key,
176
+ duplicate/extra tags and JSON fields, version/profile confusion, stale/future
177
+ times, malformed expiry, end/destruct/relay policy, oversized nested objects
178
+ and forged verification-cache flags. Preserve all existing protocol vectors.
179
+ 2. Request lifecycle with real mesh transport: all initial packets lost, retry,
180
+ cancel, no peers, last-moment response, clock rollback, process death,
181
+ duplicate answer and zero fallback sockets.
182
+ 3. Durable authority owner and exact retirement/closure/rekey transaction:
183
+ SIGKILL before/after persistence and handoff, old state migration refusal,
184
+ competing root owners, corrupt storage and requester-rotation floods.
185
+ 4. Actual fresh RoomSession admission and encrypted chat over mesh, with the
186
+ mandatory authenticated epoch gate and existing unknown/removed policy.
187
+ No delegation appears and no pre-gate roster/chat leaves. Repeat after both
188
+ parties cold-start, and with explicit mixed-path forwarding.
189
+ 5. Native permission/route/share UI, offline network isolation and physical BLE
190
+ qualification. LoRa measurements and original radio restrictions remain
191
+ independent gates. Codec/vector success must never be labelled a completed
192
+ offline joining or radio result.
@@ -0,0 +1,346 @@
1
+ # Member epoch catch-up
2
+
3
+ Status: implemented in `src/epoch-commit.ts`, `src/member-epoch.ts` and two
4
+ optional additions to `src/epoch.ts`. Unreleased. Vectors:
5
+ `vectors/member-epoch-vectors.json`.
6
+
7
+ ## The problem
8
+
9
+ Only the room's authority (the root inviter key, or a keeper hosting
10
+ `hostRoomEpoch` with that key) answers an epoch request (kind 20468, answered
11
+ with 20469). Requests are ephemeral, so a device that missed a rekey can
12
+ recover only while the authority's device is online with the room open.
13
+
14
+ What happened on 2 October 2026: room "The moot" was at epoch 1 and its
15
+ creating phone was offline. A member's phone had rejoined with a new device
16
+ key. Stored rekeys seal only to the devices that were present, so none of
17
+ them sealed to the new key, and the phone stayed at epoch 0. Call bells
18
+ (keyed per epoch), chat and presence silently stopped crossing between
19
+ devices at epoch 0 and devices at epoch 1. A desktop online at epoch 1 held
20
+ the secret the phone needed, and nothing let it pass that secret on in a
21
+ form the phone could check.
22
+
23
+ ## What the existing format already proves, and the one gap
24
+
25
+ A rekey into epoch j+1 (kind 1462) is signed by the authority. Its content
26
+ is NIP-44 v2 under epoch j's key, and NIP-44's HMAC verifies under no other
27
+ key. So a third party holding a candidate secret for epoch j can derive
28
+ `deriveEpoch(j, secret).key`, decrypt the authority-signed rekey j+1 and
29
+ learn whether the candidate is genuine. **Every epoch that has a successor
30
+ is therefore already committed to by an authority signature.**
31
+
32
+ The current epoch N has no successor. Rekey N refers to secret N only
33
+ through its per-device seals (NIP-44 between the authority and each kept
34
+ device). Only the authority and that one device can open a seal. So the
35
+ existing protocol cannot prove secret N to anybody else, and a member's
36
+ answer for the top epoch cannot be checked. That gap is the reason for the
37
+ one wire addition below. The rest of the scheme is the existing rekey
38
+ chain, read in a new way.
39
+
40
+ Alternatives considered and not built:
41
+
42
+ - *A member reveals its NIP-44 conversation key with the authority* so the
43
+ requester can open the member's own seal in rekey N. This is ruled out
44
+ because that key also opens every future seal to that device. A requester
45
+ removed later could read the next secret through it, which defeats
46
+ removal.
47
+ - *A member reveals only the per-message NIP-44 keys of its own seal in
48
+ rekey N* (the HKDF-expand output for that seal's nonce). This would work
49
+ for legacy rooms today, and it reveals nothing beyond that one message.
50
+ It was left out because it needs a raw ChaCha20 primitive that this kit
51
+ does not take as a peer dependency, and it works only for a member whose
52
+ device was sealed to at N. A member that caught up through a grant has no
53
+ seal to open. It remains an option if legacy rooms turn out to matter
54
+ more than the remedy below.
55
+
56
+ ## Wire additions
57
+
58
+ All of these are additive. No existing event changes byte-for-byte unless a
59
+ caller opts in, and no existing body version changes.
60
+
61
+ ### 1. The epoch commitment (inside the rekey body)
62
+
63
+ ```
64
+ commit = hex( HMAC-SHA256( key = secret_N,
65
+ msg = "kithmoot/v1/epoch-commit:" + roomId + ":" + N ) )
66
+ ```
67
+
68
+ `roomId` is lower-case hex and `N` is decimal. `encodeRekeyEvent({ commit:
69
+ true })` writes it into the encrypted body after `by` and `closed` and
70
+ before `keys`. The body stays `v: 1`: a reader from before this change
71
+ ignores the key, and the label carries the version. It sits inside the
72
+ ciphertext rather than in a tag, so a relay still sees only the room id,
73
+ the epoch number, the authority and a size. The commitment is used nowhere
74
+ else on the wire. A removed member who reads rekey N (it holds key N-1)
75
+ learns a PRF output of secret N, which gives it nothing to locate epoch N
76
+ traffic with. The epoch id would have done that, which is why the
77
+ commitment is not the epoch id.
78
+
79
+ Without `commit`, `encodeRekeyEvent` produces exactly the 0.4.0 event (the
80
+ `roomEpoch/rekey` vector still reproduces byte for byte).
81
+
82
+ ### 2. Member epoch request, kind 20471 (ephemeral)
83
+
84
+ - Tags: `["d", roomId]`, plus the NIP-40 `expiration` of a conference room.
85
+ It has no `p` tag because no single answerer is addressed.
86
+ - Signed by the asking device.
87
+ - Content: NIP-44 v2 with the conversation key
88
+ `HKDF-SHA256(roomKey₀, info = "kithmoot/v1/member-epoch-request-key", 32)`,
89
+ where `roomKey₀` is the epoch-0 room key.
90
+ - Body: `{"v":1,"credential":<device credential>,"proof"?:<kindred proof>,
91
+ "admission":<epochRequestAdmission(...)>,"have":<the asker's epoch>}`. The
92
+ admission proof is the existing one, binding room, authority, device and
93
+ `created_at`. Members hold `roomKey₀` and check it exactly as the
94
+ authority's desk does.
95
+ - Each retry is a fresh event with a new id (see "Anti-amplification").
96
+
97
+ ### 3. Member epoch grant, kind 20472 (ephemeral)
98
+
99
+ - Tags: `["d", roomId]`, `["p", asking device]`, plus `expiration`.
100
+ - Signed by a **one-time key** made for this grant alone and then dropped,
101
+ never by the answering member's device key, and NIP-44 v2 from that key
102
+ to the asking device. Nothing about a grant is trusted on its signer, so
103
+ a device key would add only a public line from the answering device to
104
+ the room id in `d`. A desk tells its own grants apart by event id. Vectors
105
+ record the 48-byte draw the key is made from, before the body nonce and
106
+ the signature aux-rand.
107
+ - Body: `{"v":1,"request":<request id>,"epoch":N,"secrets":[base64url secret
108
+ for have+1 .. N],"rekeys":[the authority's kind-1462 events for have+1 ..
109
+ N]}`. One grant carries at most 32 epochs (`MAX_MEMBER_EPOCH_CHAIN`).
110
+
111
+ There is no member refusal event. A refusal from a member is a claim the
112
+ requester cannot check, so members decline by not answering. Only the
113
+ authority's 20469 refusal means anything.
114
+
115
+ ## Verification rules (requester, `decodeMemberEpochGrant`)
116
+
117
+ The requester is at epoch `k` and holds key `k`. It accepts a grant only if
118
+ all of these hold:
119
+
120
+ 1. The kind is 20472, the signature is valid, the event is fresh (within 90
121
+ s), `d` is the room, `p` is this device, and the body opens with this
122
+ device's key. `request` is one of this device's own outstanding member
123
+ request ids.
124
+ 2. `secrets` and `rekeys` have the same length L, with 1 <= L <= 32, and
125
+ `epoch = k + L`.
126
+ 3. If the requester has seen a valid rekey up to epoch E, then `epoch >=
127
+ E`. This blocks a rollback: a member removed at E still holds secret E-1
128
+ and could otherwise keep the requester one epoch back on a key it shares.
129
+ `decodeMemberEpochGrant` takes E as `expected`; `memberEpochSource` (and
130
+ so `requestRoomEpoch({ members })` and `requestMemberEpoch`) subscribes to
131
+ the room's kind-1462 events and sets E itself, from `peekRekeyEvent` on
132
+ whatever the relays replay, raised further by any `expected` the caller
133
+ passes. Its first request waits for that replay to end (EOSE, or 1.5 s
134
+ at most), so a member cannot answer before the floor is known. A relay that withholds rekey E from the
135
+ requester while serving it a removed member's grant can still hold it at
136
+ E-1, so a client should keep watching rekeys after it settles, as it
137
+ already does to learn of the next one.
138
+ 4. For each j = k+1 .. N, in order:
139
+ - `peekRekeyEvent(rekeys[j])` returns j. That means kind 1462, the
140
+ authority's signature, this room and an epoch tag of j.
141
+ - `rekeys[j]` decrypts under key j-1. For j = k+1 that is the
142
+ requester's own key. Later ones derive from the offered secret j-1. A
143
+ decryption failure means the offered secret j-1 is not the one the
144
+ authority encrypted under, and the grant is refused. **This is what
145
+ proves every secret except the last.**
146
+ - The body is `v: 1` with `epoch: j`. If it is `closed`, refuse. If its
147
+ `removed` names the requester's participant, refuse. Otherwise its
148
+ removals are added to the cumulative set.
149
+ - At j = N the body must carry `commit`, equal to
150
+ `epochCommitment(roomId, N, secret N)`. If it is absent, refuse: that
151
+ is a legacy epoch, and only the authority can hand it on. **This is
152
+ what proves the last secret.**
153
+ 5. Result: `{ epoch: { epoch: N, secret: secret N }, removed: known ∪ every
154
+ removal in the chain }`. `removed` is cumulative, with the same meaning
155
+ as the authority grant's. Nothing names the member that answered: its
156
+ grant was signed by a one-time key.
157
+
158
+ The answering member's identity plays no part in correctness. The trust
159
+ root is the authority's signatures plus the key-committing NIP-44 MAC and
160
+ the commitment. A member, a relay or a stranger can withhold an answer.
161
+ None of them can make a requester accept a secret the authority did not
162
+ issue, an epoch it did not sign, or a removed list it did not write.
163
+
164
+ The first grant that passes all of these wins (`memberEpochSource` stops on
165
+ it). `requestRoomEpoch({ members })` races it against the authority's grant
166
+ and settles on whichever verifies first. An authority refusal still
167
+ rejects the call.
168
+
169
+ ## Desk rules (`hostMemberEpochDesk`)
170
+
171
+ A member answers a request only when all of these hold:
172
+
173
+ - the request decodes: fresh, credentialled for this room, with a valid
174
+ admission proof under `roomKey₀`, and passing the room policy if one is
175
+ given;
176
+ - the room is not closed (`closed()`). Otherwise it calls
177
+ `onRefused('closed')` and stays silent;
178
+ - the requester's participant is not in this member's cumulative removed
179
+ set, which it learnt from the authority's rekeys. Otherwise it calls
180
+ `onRefused('removed')` and stays silent;
181
+ - once anybody has been removed (the removed set is not empty), the room
182
+ knows the requester's participant (`known(participant)`): on the
183
+ authority's latest member list, in the current roster, or let in from
184
+ this device. Otherwise it calls `onUnknown` and `onRefused('unknown')`
185
+ once per participant (again a minute later if they are still asking, `reportUnknownEvery`) and stays silent; the requester's next ask is looked at
186
+ afresh, so letting them in is making `known` say yes. See "The
187
+ known-members gate" below;
188
+ - this device is in step (`current()` is defined) and ahead of `have`;
189
+ - it holds every secret and every authority rekey from `have + 1` to its
190
+ own epoch (`secretAt`, `rekeyAt`), and the chain is no more than 32 long;
191
+ - when it can read the last rekey itself (it holds the key before it), that
192
+ rekey carries a commitment. A member that joined at the current epoch
193
+ cannot check this, so it sends the grant and the requester decides;
194
+ - the serialised grant fits its byte budget (`maxGrantBytes`, default
195
+ 60000, under the 64 KiB many relays enforce). The grant inlines whole
196
+ rekey events, each with one seal per kept device, so a long chain in a
197
+ large room can outgrow a relay's limit, and such a chain is left to the
198
+ authority. The requester can already fetch the durable 1462s from its
199
+ relays, so a later revision could carry rekey ids in place of the events.
200
+
201
+ The requester re-checks everything, so a desk bug cannot weaken anything.
202
+ At worst it costs bandwidth.
203
+
204
+ ## The known-members gate (#207)
205
+
206
+ The admission proof is made under `roomKey₀`, which a removed member still
207
+ holds. Refusing by participant alone is therefore undone by a fresh
208
+ participant key and a credential for it: to a desk, that is a newcomer
209
+ with the link. An honest member who was offline through a rekey, and a
210
+ removed member back under a new key, hold exactly the same keys, so no
211
+ key-based rule tells them apart. What does is whether the room has seen
212
+ the participant before.
213
+
214
+ So once a room has removed anybody, the authority's desk (`hostRoomEpoch`)
215
+ and every member desk grant only to a participant the room knows
216
+ (`known`). Anybody else is sent to approval: the authority answers
217
+ `refused: 'unknown'`, which is not final (`requestRoomEpoch` keeps asking
218
+ and calls `onUnknown`), and a member desk stays silent. A member letting
219
+ them in makes `known` say yes, and the requester's next ask is granted.
220
+ A room that has never removed anybody behaves exactly as before, and a
221
+ desk given no `known` lets nobody through after a removal.
222
+ The authority sends a participant `unknown` at most once a
223
+ `reportUnknownEvery`, after a random wait, within a cap on refusals a
224
+ minute (see `docs/quiet-replies.md`).
225
+
226
+ Where a desk learns who is known is the application's business, with two
227
+ sources here: `encodeRekeyEvent({ members })` writes the authority's
228
+ member list into the rekey body (`RekeyNotice.members`,
229
+ `RekeyEvidence.members`, and the newest one in a chain as
230
+ `MemberEpochGrant.members`), and the authority's own grant can carry it
231
+ (`hostRoomEpoch({ members })`, `EpochGrant.members`). The list is every
232
+ participant the authority knows to be in the room, not only those present
233
+ as it rekeys; the encoder drops removed participants from it. It is
234
+ inside the body, sealed under the epoch being left, so a removed member
235
+ reads the list in the one rekey that removed them, as it reads that
236
+ rekey's removed set. They saw the same people in that epoch's roster.
237
+
238
+ What this does not do: in a room whose link is still live, a removed
239
+ person can come back through the invitation as anybody holding the link
240
+ can, and then knocks like any stranger. Removal plus a replaced link, or
241
+ removal in a room that asks before letting people in, now keeps them out.
242
+
243
+ ## Anti-amplification
244
+
245
+ - Each desk waits a random `[0, jitterMs)` (default 1500 ms) before
246
+ answering.
247
+ - If, during that wait, it sees another device's 20472 addressed to the same
248
+ requester, it stands down. It cannot read that grant and does not need
249
+ to.
250
+ - It stands down **at most once per requesting device**. The requester sends
251
+ a fresh request every `retryMs` (default 4000 ms, a new id each time)
252
+ until something verifies, and on the second round every desk answers. A
253
+ stranger who posts junk 20472s to suppress honest answers therefore buys
254
+ one round's delay, not a denial of service.
255
+ - Requests are deduplicated by id, and each desk answers a given request at
256
+ most once.
257
+
258
+ ## Threat model, and why confidentiality is not weakened
259
+
260
+ What this protects is the epoch secret: the roster, chat, presence, call
261
+ bells and media keys of an epoch. The parties involved are:
262
+
263
+ - **A current member** already holds secret N and can leak it to anyone.
264
+ Handing it to an admitted, non-removed device gives that device nothing a
265
+ malicious member could not give it anyway. The member path adds no
266
+ capability an insider lacks.
267
+ - **A removed member** is refused twice: by every honest desk (its removed
268
+ set comes from authority-signed rekeys), and by the requester's own
269
+ check, which refuses any chain that names it as removed. Its old link
270
+ still opens epoch 0, as it always did. With that it can read member
271
+ requests (sealed under a key derived from `roomKey₀`) and learn the
272
+ credential (participant and device) of a device asking to catch up. That
273
+ is the same in-room exposure a roster at epoch 0 always had, and it is
274
+ stated here rather than hidden. It cannot answer convincingly beyond the
275
+ epoch before its removal, and rule 3 refuses that answer once the
276
+ requester has seen the later rekey.
277
+ - **A closed room stays closed.** A closing rekey seals to nobody, so nobody
278
+ holds a secret past it. Desks refuse, and a chain containing `closed` is
279
+ refused.
280
+ - **A stranger** with the room id and the authority pubkey from a public
281
+ rekey cannot read a member request (no `roomKey₀`), cannot produce an
282
+ admission proof, and is never answered.
283
+ - **A newly admitted device** that claims a low `have` is handed every
284
+ secret from there to the current epoch (up to 32), where the authority's
285
+ grant hands over only the current one. Verification has to start from a
286
+ key the requester already holds, and every admitted device holds the
287
+ epoch-0 key, so a desk cannot tell a device that really is at epoch 0 from
288
+ one that only says so. The member path therefore gives a newcomer that
289
+ passes the room's policy the keys to the history of earlier epochs, which
290
+ the authority path did not. Epochs exist to shut the removed out, not to
291
+ hide the past from the admitted, so this is accepted, but it is a
292
+ difference.
293
+ - **Any holder of the link**, including a removed member, can open a member
294
+ request and read the asking device's credential and kindred proof: its
295
+ participant, its current device key, and that it is behind. The
296
+ authority's request shows that to the authority alone. For a removed
297
+ member this includes device keys made after its removal, which the epoch-0
298
+ roster it can still read does not.
299
+ - **A relay** sees, per request, the room id, the asking device's pubkey and
300
+ a time. The existing 20468 already shows it exactly that, plus the
301
+ authority's pubkey in `p`. Per grant it sees the room id, the asking
302
+ device and a one-time key, so it learns nothing about which member
303
+ answered. Grant contents are NIP-44 to the requester's device, as the
304
+ authority's grant is.
305
+ - **Forgery.** Without the authority's key, nobody can mint a rekey that
306
+ passes `peekRekeyEvent`. Without the real key j-1, nobody can find another
307
+ secret under which an authority-signed ciphertext decrypts (NIP-44's MAC
308
+ is HMAC-SHA256). Without inverting HMAC, nobody can find another secret
309
+ matching the commitment.
310
+
311
+ What is weakened: nothing about confidentiality. The availability the
312
+ authority's absence used to deny is what is restored.
313
+
314
+ ## Legacy rooms (rekeys written before `commit`)
315
+
316
+ - A legacy rekey **in the middle** of a chain is fine: the next rekey's
317
+ decryption vouches for its secret.
318
+ - A legacy rekey **at the top** cannot be vouched for by a member.
319
+ Requesters refuse such grants, and desks that can see the missing
320
+ commitment do not send them. The epoch stays authority-only, exactly as
321
+ today.
322
+ - Remedy for such a room ("The moot"): once the authority's device runs a
323
+ version that writes `commit`, a single turnover rekey (no removals) makes
324
+ the whole history member-recoverable. The new top carries a commitment,
325
+ and every earlier epoch, legacy or not, is proven by its successor. Until
326
+ then the room behaves as it does today, and the authority path is
327
+ untouched.
328
+
329
+ ## API
330
+
331
+ - `epochCommitment(roomId, epoch, secret)`, `EPOCH_COMMIT_PREFIX`
332
+ - `encodeRekeyEvent({ ..., commit: true })`
333
+ - `readRekeyEvidence(event, { roomId, authority, previous })` reads a rekey
334
+ with the key of the epoch it leaves, with no seal needed: `{ epoch,
335
+ removed, closed, commit? }`
336
+ - `encodeMemberEpochRequest` / `decodeMemberEpochRequest`,
337
+ `deriveMemberEpochRequestKey`
338
+ - `encodeMemberEpochGrant` / `decodeMemberEpochGrant`
339
+ - `hostMemberEpochDesk(opts)` is the answering side
340
+ - `memberEpochSource(opts)`, used as `requestRoomEpoch({ members })`, and
341
+ `requestMemberEpoch(opts)` are the asking side. Their `removed` (the set
342
+ the device already knows) is a function, asked on every grant, or any
343
+ iterable, read once when the source is made, so a generator is not used
344
+ up by the first grant.
345
+ - `MEMBER_EPOCH_KINDS` (`{ REQUEST: 20471, GRANT: 20472 }`),
346
+ `MAX_MEMBER_EPOCH_CHAIN`
@@ -0,0 +1,84 @@
1
+ # MLS admission v4 evidence codec
2
+
3
+ Import from `@forgesworn/fold-kit/mls-admission`.
4
+
5
+ This implements only the pure codec from the independently reviewed
6
+ [Vennel design](https://github.com/forgesworn/vennel/blob/e9a90c6ae724138f15b377a0b22d022c56303bc5/docs/2026-10-11-mls-admission-codec-design.md).
7
+ No relay I/O, browser admission, box role service, counter storage, vault signing,
8
+ MLS Add or Welcome adoption is implemented here. Existing v2/v3 codecs stay
9
+ unchanged; a v4 link is a separate three-element array fragment, not a legacy
10
+ room traffic secret or an extended legacy link.
11
+
12
+ `encodeMlsInvitationV4(root, bearer)` encodes a fragment. Supply a fresh valid
13
+ root and 32 CSPRNG bytes, retain their custody, and wipe caller copies on settled
14
+ termination. `readMlsInvitationV4(fragment)` returns root, invitation id and a
15
+ caller-owned bearer copy. Zero bearers and invalid x-only keys are refused;
16
+ these checks cannot establish the entropy of a supplied bearer.
17
+
18
+ All core/proof inputs are canonical compact JSON of the fixed arrays defined
19
+ in the design. Nested signed events are seven-field arrays. Outer wire inputs
20
+ and outputs are compact JSON NIP-01 **objects**, with exactly seven fields;
21
+ field order may vary, but duplicate keys, whitespace and alternate encodings
22
+ are refused. Decoders accept strings only, so caller objects, proxies, accessors
23
+ and cached-verification markers never cross the wire decoder boundary.
24
+
25
+ 1. Reserve a global durable counter, stable operation and challenge in the
26
+ actual owner before building the request array. Call
27
+ `mlsAdmissionProofTemplatesV4({fragment, core, now})` for the three purpose-bound
28
+ role templates and expected public keys. Settle the account/browser/enrolled
29
+ device signers inside actual custody. No sealed scalar or generic vault
30
+ signer should be exposed to satisfy this API.
31
+ 2. `encodeMlsAdmissionRequestV4({fragment, core, proofs, requestSecretKey, now})`
32
+ verifies those exact signed proofs, then encrypts with fresh NIP44 randomness
33
+ and signs using the one-operation ephemeral request key.
34
+ 3. `decodeMlsAdmissionRequestV4({fragment, event, localSecretKey, side, now})`
35
+ independently verifies the request at either endpoint (`side` is `root` or
36
+ `joiner`). Only this decoder issues `VerifiedMlsAdmissionRequest`.
37
+ `readMlsAdmissionRequestV4(handle)` returns immutable core/digest/event-id
38
+ strings for evidence; they cannot rehydrate its nominal identity.
39
+ 4. Construct the Allow array against that retained request, get its two keeper
40
+ proof templates with `{fragment, core, request, now}`, and settle actual
41
+ keeper account/device consent after exact current saved-room/leaf checks.
42
+ Deny has an empty proof array. Encode with
43
+ `encodeMlsAdmissionAnswerV4({fragment, request, core, proofs, rootSecretKey, now})`.
44
+ 5. `decodeMlsAdmissionAnswerV4({fragment, request, event, requestSecretKey, now})` returns
45
+ evidence-only `VerifiedAdmissionAnswer`. `readMlsAdmissionAnswerV4(handle)`
46
+ returns decision, core/digest and event ids. There is no constructor,
47
+ registration hook, capability conversion or admission factory.
48
+
49
+ Verified handles retain only public root/invitation id and protocol evidence,
50
+ never the invitation fragment, bearer or direction keys. Supply the fragment
51
+ transiently to each answer operation; its root/id must match the retained request.
52
+ Decoded owned fragment/bearer bytes are wiped on parse failure and on settled
53
+ operation return. Only `readMlsInvitationV4` transfers its successful bearer copy
54
+ to the caller. Immutable caller input strings cannot be overwritten by JavaScript.
55
+
56
+ The engine device is the enrolled Nostr key named in its person credential,
57
+ not the MLS leaf's Ed25519 signature key. All signed events are freshly verified;
58
+ strict duplicate/tag/decimal/curve/credential checks apply across both roles.
59
+ The application must still pass VMLS credential and binding policy, revocation
60
+ checks, current clock/custody checks and typed consent.
61
+
62
+ The application must obtain a fresh authenticated box/Link keeper witness with
63
+ conditional keeper revision at the effect boundary, then consume each operation
64
+ once in durable storage. This library cannot attest role, currentness, child
65
+ revision, route, counter nonreuse, revocation, durable idempotence or saved-room
66
+ truth. Root signature correlates the answer; it does not authorise a keeper.
67
+ Repeated decoder calls can return fresh evidence handles; this is deliberately
68
+ not a durable replay barrier. Same-answer replay must return existing operation
69
+ state without repeated effects. Recovery fetches fresh custody/witness evidence
70
+ and reconciles the existing operation with `findJoin`, never a second capability.
71
+ Hold when the required vault or authoritative conditional service is absent.
72
+
73
+ All ciphertexts use a 16,384-byte canonical padded wrapper and 21,936-character
74
+ NIP44 payload bucket. Timing, carrier, invitation pseudonym/root, ephemeral
75
+ request identity, event correlation, relay/network addresses and volume remain
76
+ visible. There is no forward-secrecy claim. Do not log raw evidence, account or
77
+ group ids, capability bytes or invitation/request secrets.
78
+
79
+ `vectors/mls-admission-v4.json` uses synthetic public test keys and independent
80
+ Node SHA256/HKDF composition, deterministic test-only Schnorr auxiliary bytes
81
+ and NIP44 nonces. Production encoders expose no fixed nonce/randomness option.
82
+ `npm run generate-mls-admission` deterministically regenerates that file;
83
+ normal tests verify its frozen wire bytes without regeneration. Codec vectors
84
+ and local tests are not browser, deployed, two-account or phone acceptance.