@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.
- package/CHANGELOG.md +357 -0
- package/EXTRACTION.md +549 -0
- package/README.md +198 -15
- package/dist/epoch.d.ts +28 -1
- package/dist/epoch.js +99 -60
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/invitation-decline.d.ts +26 -0
- package/dist/invitation-decline.js +74 -0
- package/dist/invitation.d.ts +61 -5
- package/dist/invitation.js +300 -57
- package/dist/live-persistent-admission.d.ts +52 -0
- package/dist/live-persistent-admission.js +183 -0
- 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 -9
- package/package.json +24 -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/live-persistent-vectors.json +1240 -0
- package/vectors/mls-admission-v4.json +25 -0
|
@@ -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.
|