@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,135 @@
1
+ # Persistent invitation evidence
2
+
3
+ The additive `requestPersistentRoomAdmissionEvidence` API retains what an
4
+ injected stored-event query observed, so protected clients can revalidate the
5
+ signed source of a fixed relay list after restart. It performs no publication,
6
+ storage or routing effects and supplies no relay defaults. The existing
7
+ `requestPersistentRoomAdmission`, encoder, decoder and wire formats are unchanged.
8
+ Import from `@forgesworn/fold-kit/persistent-invitation-evidence`; the existing
9
+ main entry and its bundle budget remain unchanged.
10
+
11
+ ## A single creator source
12
+
13
+ `revalidatePersistentInvitationSource({ event, invitation, expectedRoomSecret })`
14
+ validates one actual `encodePersistentInvitation` Event, or the exact canonical
15
+ carrier string retained from it. The exact 32-byte room-root pin is mandatory;
16
+ missing, malformed or mismatching pins fail rather than disabling the check.
17
+ The capability and root are copied before carrier verification, and temporary
18
+ copies and derived keys are erased. The returned `admission.secret` is a fresh
19
+ owned buffer that the caller must erase. Caller buffers are never erased.
20
+
21
+ `PersistentInvitationSource` is a frozen envelope with `version: 1`,
22
+ `invitationId`, `inviter`, `eventId`, `createdAt`, the canonical `event` string
23
+ and owned `admission` fields. It authenticates the supported encoder's exact
24
+ signature, inviter, invitation ID, root, end/expiration, destruction flag and
25
+ relay list. Retirement events and noncanonical reload strings are rejected.
26
+ The same plain-data boundary and 16,384-byte carrier limit below apply, with
27
+ string bounds checked before parsing and object bounds before verification.
28
+
29
+ This source has **no completion/EOSE claim**. It is not a query observation,
30
+ latest-event selection, publication receipt, current membership authority or
31
+ evidence of absence of retirement. A creator retains the canonical event,
32
+ actual owner/fixed metadata and a bounded pending publication intent in
33
+ acknowledged protected custody before opening any relay pool. After uncertain
34
+ delivery it retries these exact event bytes without signing again or changing
35
+ the timestamp. Every underlying write still needs a fresh client authority,
36
+ generation and lifecycle guard. These storage and effect rules are client
37
+ responsibilities; this validator performs no signing, relay I/O or storage.
38
+
39
+ ## Carrier and bounds
40
+
41
+ `PersistentInvitationEvidence` version 1 contains `invitationId`, `inviter`,
42
+ `completion: 'eose'`, sorted unique `eventIds` and corresponding `events`.
43
+ Each event string is JSON in this exact field order:
44
+ `id, pubkey, created_at, kind, tags, content, sig`. This preserves every signed
45
+ field, tag order and ciphertext. It is an allowlisted representation of the
46
+ Event supplied by the transport, **not the original relay wire JSON bytes**.
47
+ The optional Nostr verification-cache symbol is discarded and never trusted.
48
+ Unknown fields, non-data descriptors, custom prototypes, sparse arrays and
49
+ noncanonical reload strings are refused. JavaScript proxies are outside this
50
+ plain-data transport boundary; callers must not pass executable objects.
51
+
52
+ Queries admit at most 64 delivered candidates, including invalid candidates and
53
+ duplicates, with 16,384 UTF-8 bytes per canonical carrier and 262,144 UTF-8 bytes
54
+ across all delivered canonical candidates. Work/bytes are charged before crypto;
55
+ candidate 65 is refused before carrier copying. Retained evidence has the same
56
+ limits. Event tags are bounded to 64 arrays of at most eight strings. These are
57
+ evidence-profile limits, not new wire limits for the legacy API. Exact duplicates
58
+ cost no additional retained capacity but consume query work and byte budget;
59
+ the same ID with a different carrier fails the entire query. Capacity is checked
60
+ before admitting another candidate, with no eviction or truncated success.
61
+ Malformed/oversized object framing fails the query. Well-framed events that
62
+ fail signature, capability or scope validation are ignored, never retained.
63
+
64
+ Evidence records, carrier strings and ID arrays are frozen owned copies.
65
+ String memory is not claimed securely erased. Each admitted result has a fresh
66
+ owned secret buffer; callers erase it after use. Temporary decoded secrets,
67
+ derived welcome/room keys and the query's invitation-bearer copy are erased.
68
+
69
+ ## Authentication and selection
70
+
71
+ Both the query and `revalidatePersistentInvitationEvidence` recompute event IDs
72
+ and verify signatures without a cached verdict, require the exact inviter and
73
+ single two-element invitation `d` tag, and validate capability decryption and
74
+ room derivation. Group bodies and tags must exactly match the canonical shape
75
+ of the supported encoder, including the exact end/expiration, self-destruct
76
+ and relay-list fields. An unknown group body cannot contribute authority.
77
+ Authenticated retirements use the existing retirement semantics, including
78
+ legacy-valid extended bodies, so stricter group parsing cannot weaken a known
79
+ retirement. Every observed valid retirement carrier is retained.
80
+
81
+ At EOSE the entire retained set is processed in sorted event-ID order:
82
+
83
+ - Any observed retirement yields `status: 'held', reason: 'retired'`, with
84
+ its event IDs and no admission. No valid group event yields `unavailable`.
85
+ - Different decoded room roots yield `conflicting-rooms`. The optional
86
+ `expectedRoomSecret` binds an existing physical room to its exact root;
87
+ a mismatch yields `wrong-room`.
88
+ - The earliest end across all signed copies stands. Self-destruct is sticky
89
+ if any copy carries it. A copy without relays makes no relay-list assertion.
90
+ - Relay-bearing copies with the greatest `created_at` select the list.
91
+ Different lists at that greatest timestamp yield `conflicting-relays`
92
+ regardless of arrival order; identical lists are accepted. A strictly newer
93
+ list can supersede an older tie. The legacy API still keeps first arrival
94
+ on a tie, preserving existing compatibility.
95
+
96
+ An admitted outcome exposes `contributors.room`, `.ends`, `.destruct` and
97
+ `.relays` as exact event-ID arrays. The aggregate can combine assertions from
98
+ several events; it is not represented as a single synthetic signed envelope.
99
+ Held results still retain the complete observed evidence for protected refusal
100
+ custody and later independent revalidation.
101
+
102
+ ## Completion and failure
103
+
104
+ The query waits for the transport's EOSE even after an invitation or retirement
105
+ arrives. Timeout, abort, subscription failure, contradictory duplicate or
106
+ capacity failure rejects with `PersistentInvitationEvidenceQueryError`.
107
+ Its bounded `observation` is explicitly `completion: 'incomplete'`, diagnostic
108
+ custody only. The revalidator always refuses incomplete observations. They
109
+ cannot be relabelled to establish completeness; completion metadata itself is
110
+ local and must be protected by the client's custody transaction.
111
+
112
+ The query is exact to the two source kinds, inviter and invitation `d` ID.
113
+ EOSE binds that local query observation and the listed event identities. It
114
+ does not prove that a relay disclosed every retirement or competing event,
115
+ global non-retirement, current membership, freshness or current routing
116
+ permission. Revalidation after restart proves signed historical consistency
117
+ within that retained set. The same room under a rotated invitation has a
118
+ different invitation/evidence identity and cannot alias this record.
119
+
120
+ ## Client integration remains required
121
+
122
+ Clients must retain the exact selected invitation capability and evidence in
123
+ protected storage, bind them to the physical room root, and await durable
124
+ coherent retention before using decoded fields. A `held` or incomplete result
125
+ never grants permission to dial, publish, migrate, clean up or resume admission.
126
+ Creator paths must separately prepare and retain the exact signed event with
127
+ owner/access bindings, fixed list and publication intent before network effects,
128
+ retrying uncertain delivery with the same event. This API does not fabricate
129
+ EOSE for a locally prepared event.
130
+
131
+ Browser/tab coherence, capacity and client/version fences, selected-lane
132
+ consent, supported legacy-row reconciliation, startup ordering, current
133
+ authority and actual app/physical acceptance remain client integration work.
134
+ This additive library API alone does not activate KithMoot protected recovery,
135
+ close DG08 or satisfy authentic MLS admission/independent continuity gates.
@@ -0,0 +1,81 @@
1
+ # Quiet automatic replies
2
+
3
+ Unreleased. A desk that answers automatically, at once, under a key that does
4
+ not change, tells anybody who can make it answer when the device holding
5
+ that key is online and how quickly it responds. A removed member still holds
6
+ the epoch-0 room key, and anybody with a temporary link holds its bearer, so
7
+ both can ask as often as they like. This is the timing side channel
8
+ described as "Careless Whisper" for silent delivery receipts.
9
+
10
+ None of what follows changes an event's content, tags or kind, and no
11
+ vector changes. It only changes whether a reply is sent, and when.
12
+
13
+ ## The authority desk (`hostRoomEpoch`)
14
+
15
+ On by default.
16
+
17
+ - **Every answer waits.** Grants and refusals alike wait a random
18
+ `replyDelayMs` first, default `[500, 2000]` milliseconds
19
+ (`DEFAULT_REPLY_DELAY_MS`). If only refusals waited, the wait itself would
20
+ give the answer away. The desk decides when the wait ends, not when the
21
+ request arrived, so a removal or a rekey during the wait applies. Closing
22
+ the desk cancels every wait. `[0, 0]` answers at once, as before.
23
+ - **A refusal goes out once per participant per window.** A `removed` or
24
+ `closed` refusal is sent at most once every `refuseAgainAfter` seconds,
25
+ default 3600 (`REFUSE_AGAIN_AFTER_SECONDS`). An `unknown` refusal is sent
26
+ at most once every `reportUnknownEvery` seconds (default 60, the same
27
+ cadence that already limits `onUnknown`). A request inside the window gets
28
+ no reply, and its asker times out with the plain "not answering" error
29
+ rather than `EpochRefusedError`.
30
+ - **Refusals are capped in total.** At most `maxRefusalsPerMinute`
31
+ refusals of any kind go out in any 60 seconds, default 20
32
+ (`MAX_REFUSALS_PER_MINUTE`). Anybody can mint a participant key, so a
33
+ per-participant limit alone would not stop a room-key holder asking as
34
+ somebody new each time.
35
+ - **Grants are never limited.** A participant who is granted is forgotten
36
+ by the limiter. Letting an `unknown` participant in still works on their
37
+ next ask, because a grant is never held back.
38
+ - **Memory is bounded.** The limiter remembers at most 256 participants
39
+ (`REFUSAL_LIMITER_CAPACITY`) and forgets the oldest first. The
40
+ per-minute cap still holds after that. No more than 256 requests wait at
41
+ once, and a request turned away while the queue is full is answered when
42
+ it is retried.
43
+ - `onRefused` and `onUnknown` still fire on the desk for a refusal the
44
+ limits kept off the wire, so an application sees everything it saw
45
+ before.
46
+ - `random` and `schedule` can be injected for tests.
47
+
48
+ ## The temporary-link responder (`hostRoomInvitation`)
49
+
50
+ Off by default. Without the new options, nothing waits, no subscription is
51
+ added, and grants are published exactly as before.
52
+
53
+ - `grantDelayMs: [min, max]` waits a random time in that range before every
54
+ grant, after `admit` when one is set. This is for a device that answers
55
+ without asking anyone.
56
+ - `coordinate: true` is for a room where several delegated devices answer
57
+ the same link. Each one waits (`grantDelayMs`, default `[500, 2000]`),
58
+ watches for grants to the same requester (kind 20467, the link's `d` tag,
59
+ the requester's `p` tag) from any other key, and stands down if one
60
+ appears. Normally only one device answers. It cannot read the other
61
+ grant, and does not need to.
62
+ - Anyone holding the link could post a false grant, so a responder stands
63
+ down at most once per request. It then forgets the request, so the
64
+ requester's next retry of the same request is answered without asking
65
+ `admit` again.
66
+ - No more than 256 grants wait at once; a request turned away while the
67
+ queue is full is answered when it is retried.
68
+
69
+ ## The member desk (`hostMemberEpochDesk`)
70
+
71
+ Unchanged. It already waits a random `[0, jitterMs)` (default 1500 ms),
72
+ stands down once per requesting device when another member answers, and
73
+ publishes nothing for a refusal (see `docs/member-epoch-catch-up.md`,
74
+ "Anti-amplification").
75
+
76
+ ## What this does not hide
77
+
78
+ A desk that answers at all still shows that some device holding its key is
79
+ online at some point in the window. The delay hides how quickly it answers,
80
+ and the limits stop a key holder polling it. It does not make the desk's
81
+ presence invisible.
@@ -0,0 +1,142 @@
1
+ # Self-destructing rooms
2
+
3
+ Status: 0.9.0. The wire format for KithMoot's room self-destruct: a room
4
+ whose devices, when it ends, delete what they wrote and forget the room.
5
+ This release adds only the flag and its readers. It deletes nothing itself:
6
+ the tidy-up (NIP-09 deletions, blob deletions, local wipe) is the client's.
7
+
8
+ ## The flag
9
+
10
+ `destruct: true` rides in three places, each one already signed by the
11
+ room's authority (the root inviter):
12
+
13
+ | Event | Where | Encoder | Reader |
14
+ |-------|-------|---------|--------|
15
+ | Group invitation (1463) | encrypted body, after `ends`, before `relays` | `encodePersistentInvitation({ destruct: true })` | `PersistentRoomAdmission.destruct` |
16
+ | Invitation retirement (1461) | plain JSON content, after `ended` | `encodeInvitationRetirement({ ended: true, destruct: true })` | `decodeInvitationRetirementNotice(...).destruct` |
17
+ | Closing rekey (1462) | encrypted body, after `closed` | `encodeRekeyEvent({ closed: true, destruct: true })` | `RekeyNotice.destruct`, `RekeyEvidence.destruct` |
18
+
19
+ Each is `true` or absent. Omitted, or passed as `false`, every event is
20
+ byte-identical to 0.8.0's.
21
+
22
+ ### The group invitation
23
+
24
+ The body order is `v, room, secret, ends, destruct, relays`. `destruct`
25
+ without `ends` is valid: a room with no fixed end self-destructs when its
26
+ authority closes it.
27
+
28
+ - **Strict, as `ends` is.** `decodePersistentInvitation` refuses the whole
29
+ envelope (null) when `destruct` is present and not `true`: `false`,
30
+ `"true"`, `1` and `null` are all refused. The encoder never writes them.
31
+ - **Never a tag.** The NIP-40 `expiration` tag is unchanged; nothing on the
32
+ outside of the event says the room self-destructs.
33
+
34
+ ### Two signed copies
35
+
36
+ `requestPersistentRoomAdmission` reads every stored copy of the invitation
37
+ before it admits. When two copies disagree on `destruct`, the flag sticks:
38
+ any valid copy from the authority that says `destruct` makes the admission
39
+ `destruct: true`, whichever order the copies arrive in.
40
+
41
+ This is the rule `ends` already follows ("the earlier end stands, so a stale
42
+ copy can never keep a room open longer"): for a claim that limits the room,
43
+ the more limiting copy wins, regardless of timestamp. A stale or careless
44
+ copy can never keep a room's content alive. Only `relays` follows
45
+ newest-wins, because relays are a routing preference rather than a limit.
46
+ The three merge independently: earliest end, sticky destruct, newest relays.
47
+
48
+ The consequence is that an authority cannot take back a self-destruct by
49
+ republishing the invitation without it. It can only close the room.
50
+
51
+ ### The retirement
52
+
53
+ An ended room's retirement may say the room self-destructs:
54
+ `{"v":1,"ended":true,"destruct":true}`.
55
+
56
+ - **Only beside `ended`.** The encoder throws on `destruct` without `ended`,
57
+ and `decodeInvitationRetirementNotice` reports `destruct` only beside
58
+ `ended: true`. A retirement whose flag is missing, malformed or without
59
+ `ended` is still a valid retirement (a retired link must stay retired); the
60
+ flag is simply not believed.
61
+ - **Not encrypted.** Unlike the other two, the retirement's content is plain
62
+ JSON, as `ended` always was. It names no member and links no rooms: the
63
+ event is signed by the inviter key and tagged with the invitation id, which
64
+ nobody without the link can tie to a room. What it does tell a relay is
65
+ that whoever holds this link was in a room that self-destructed, the same
66
+ order of fact `ended` already gives away. A client that does not want even
67
+ that can leave the retirement unflagged: the invitation and the closing
68
+ rekey carry the flag inside the encryption, so members learn it without
69
+ the retirement.
70
+ - `retirementError` is unchanged: a joiner is told the room was ended.
71
+
72
+ ### The closing rekey
73
+
74
+ The body order is `v, epoch, removed, (by), closed, destruct, (scheduled),
75
+ (commit), (members), keys`. A closure seals the next secret to nobody, so
76
+ `keys` is empty.
77
+
78
+ - **Only beside `closed`.** The encoder throws on `destruct` without
79
+ `closed`. With `scheduled` it throws twice over: `scheduled` refuses a
80
+ close, and `destruct` needs one.
81
+ - **Not believed without it.** `decodeRekeyEvent` and `readRekeyEvidence`
82
+ report `destruct` only when the body says `closed: true`, and only for the
83
+ value `true`. A body that carries the flag on an open rekey (beside a
84
+ removal, or beside the scheduled marker) is still read in full, without
85
+ the flag. Refusing it would leave the device on the old epoch and hide the
86
+ removal, and a 0.8.0 reader reads it anyway, so the readers follow the
87
+ `scheduled` precedent: drop the contradictory flag, keep the rekey.
88
+
89
+ ## Compatibility
90
+
91
+ Every reader of these three formats, in 0.8.0 and in KithMoot Android's
92
+ port, reads named fields off the parsed body and never enumerates its keys,
93
+ so an unknown key is ignored. Both directions, for each:
94
+
95
+ - **New writer, 0.8.0 reader.** The invitation admits with the same secret,
96
+ end and relays and no `destruct`; the retirement reads as `{ ended: true }`;
97
+ the closure reads as an ordinary close. Checked against a real 0.8.0 build
98
+ and recorded per vector as `oldReader`. An old client in a self-destructing
99
+ room ends it the old way and tidies nothing.
100
+ - **0.8.0 writer, new reader.** No flag, so `destruct` is absent everywhere.
101
+ - **The one tightening.** A 0.9.0 reader refuses a group invitation whose
102
+ `destruct` is present and not `true`. No released writer produces one;
103
+ a 0.8.0 reader admits it (`destruct-invitation-malformed`).
104
+ - **Types.** `PersistentRoomAdmission.destruct`, `RekeyNotice.destruct` and
105
+ `RekeyEvidence.destruct` are optional `true`; the retirement notice's type
106
+ is now `{ ended: boolean; destruct?: true }`, which still satisfies
107
+ `retirementError`.
108
+
109
+ ## What this does not cover
110
+
111
+ - **A device that missed the closure.** A device offline at the close learns
112
+ of it from the authority's epoch refusal (`refused: 'closed'`), which
113
+ carries no flag. A room flagged on its invitation is covered by the stored
114
+ admission. A room first flagged on its closing rekey is covered only for a
115
+ device that reads that rekey (it is stored; `readRekeyEvidence` reads it
116
+ from the previous epoch's key).
117
+ - **Deletion itself.** Each device can delete only what it signed, and
118
+ relays may keep copies. The flag is an instruction to cooperating clients.
119
+
120
+ ## Vectors
121
+
122
+ `vectors/destruct-vectors.json` (generated by `scripts/generate-destruct.mjs`,
123
+ checked by `vectors/verify-destruct.test.ts`), one group, `destruct`:
124
+
125
+ - `destruct-invitation`: a conference invitation with `ends`, `destruct` and
126
+ `relays`; `unflagged` is the same invitation with the same draws and no
127
+ flag, so its body is the flagged body without `"destruct":true,`.
128
+ - `destruct-invitation-no-end`: `destruct` with no `ends`.
129
+ - `destruct-invitation-malformed`: `false`, `"true"`, `1`, `null`, built by
130
+ hand; each refused, each admitted by the 0.8.0 reader.
131
+ - `destruct-invitation-copies`: an older flagged copy and a newer unflagged
132
+ one naming the relays; admitted with `destruct` and the relays in either
133
+ order.
134
+ - `destruct-retirement`: `ended` and `destruct`; `unflagged` is 0.8.0's ended
135
+ retirement with the same draw.
136
+ - `destruct-retirement-not-ended`: the flag without `ended`, built by hand;
137
+ the encoder refuses it, a reader keeps the retirement and drops the flag.
138
+ - `destruct-closure`: a closing rekey with the flag, read by both readers;
139
+ `unflagged` is the same closure with the same draws.
140
+ - `destruct-rekey-open`: the flag beside a removal and beside the scheduled
141
+ marker, built by hand; the encoder refuses both, a reader reads both
142
+ without the flag.
@@ -0,0 +1,142 @@
1
+ # Scheduled rekeys and the history window
2
+
3
+ Status: 0.8.0. The readers and wire format for phase 2a of KithMoot's key
4
+ schedule parity plan (`kithmoot/docs/2026-10-04-key-schedule-parity.md`).
5
+ This release adds nothing that rekeys on a schedule. It gives clients what
6
+ they need to read such a rekey quietly, and to hand a newcomer the room's
7
+ last month, before any authority starts rekeying on a timer.
8
+
9
+ It makes no forward-secrecy claim and erases nothing. `MAX_MEMBER_EPOCH_CHAIN`
10
+ stays at 32.
11
+
12
+ ## The problem
13
+
14
+ Seal keys (`docs/seal-key.md`) heal a copied device only at a rekey, and a
15
+ room rekeys only when somebody is removed. A quiet room heals never. The fix
16
+ is an authority that turns the key over on a schedule, which raises three
17
+ questions that this release answers on the wire:
18
+
19
+ - **Every rekey is announced.** A client cannot tell a weekly turn of the key
20
+ from a removal, so it would announce one every week.
21
+ - **A newcomer reads only the current epoch.** With the key turning weekly,
22
+ somebody joining on a Friday sees nothing said before Monday.
23
+ - **No two clients agree on how much history to keep.** KithMoot reads four
24
+ left epochs; with weekly rekeys that is a month, and with removals it is
25
+ less.
26
+
27
+ ## The design
28
+
29
+ ### The marker
30
+
31
+ A scheduled rekey carries `"scheduled": true` inside its encrypted body
32
+ (`encodeRekeyEvent({ scheduled: true })`), between where `closed` and
33
+ `commit` would be.
34
+
35
+ - **Inside the body, not a tag.** A clear tag would tell the relay that
36
+ nobody was removed. The body is part of the content the event id hashes
37
+ (NIP-01), so the marker is signed.
38
+ - **Never beside a removal or a close.** The encoder throws when `scheduled`
39
+ is combined with a non-empty `removed` or with `closed`. A rekey that
40
+ removes somebody is never quiet.
41
+ - **Not believed beside one either.** `decodeRekeyEvent` reports
42
+ `RekeyNotice.scheduled` and `readRekeyEvidence` reports
43
+ `RekeyEvidence.scheduled` only when `removed` is empty and the rekey does
44
+ not close the room. A body that contradicts itself is read as a removal or
45
+ a close, so it is still announced.
46
+ - **Byte-identical without it.** A rekey with no marker, or with `scheduled:
47
+ false`, is exactly 0.7.0's.
48
+
49
+ A scheduled rekey is otherwise an ordinary rekey: it carries the commitment
50
+ and the member list when asked, so a member grant's chain runs through it
51
+ unbroken (`vectors/schedule-vectors.json`, `scheduled-rekey`).
52
+
53
+ ### The history window
54
+
55
+ `HISTORY_WINDOW_SECONDS` is 30 days, as long as a chat log keeps a message.
56
+ `MAX_HISTORY_EPOCHS` is 16. `epochsInWindow(left, now)` is the one rule
57
+ every client applies:
58
+
59
+ - it keeps the epochs left within the window (one left exactly 30 days ago
60
+ is kept, one a second older is not);
61
+ - newest first by epoch number, the first given of a doubled epoch;
62
+ - at most 16, however many fall inside the window;
63
+ - it is pure and never throws: an entry with no usable number or time is
64
+ dropped.
65
+
66
+ A weekly schedule puts about five epochs in the window; removals push it
67
+ higher, and the cap bounds the relay filters a client opens.
68
+ `MAX_HISTORY_EPOCHS` (how far back a member reads) is a different limit from
69
+ `MAX_MEMBER_EPOCH_CHAIN` (how far behind a member desk will bring somebody).
70
+
71
+ ### The grant carries the window
72
+
73
+ The authority's grant (kind 20469) gains an optional `passed`: the window's
74
+ left epochs, oldest first, each `{ epoch, secret (base64url), left (unix
75
+ seconds) }`.
76
+
77
+ - **Every grant carries it.** An epoch request does not say which epoch the
78
+ device holds, so the desk cannot tell a newcomer from a device coming back.
79
+ Both are given the window. `hostRoomEpoch({ past })` asks for the room's
80
+ left epochs (`LeftEpoch`: `{ epoch, secret, leftAt }`) on every grant.
81
+ - **Never epoch 0.** Its secret is the room secret, which the requester
82
+ already holds and which is never handed over. Nothing at or above the
83
+ granted epoch is carried either.
84
+ - **The encoder is strict.** `encodeEpochGrant({ passed })` sorts the list
85
+ and throws on more than 16 entries, epoch 0, an epoch not below the one
86
+ granted, a doubled epoch, a secret that is not 32 bytes, or a `leftAt`
87
+ that is not a non-negative integer. An empty or absent list writes nothing,
88
+ so the body is 0.7.0's.
89
+ - **The desk is forgiving.** Before encoding, `hostRoomEpoch` drops whatever
90
+ the encoder would refuse and applies `epochsInWindow`, so a bad `past()`
91
+ (or one that throws) costs the history and never the grant.
92
+ - **The reader is strict and forgiving at once.** `decodeEpochGrant` takes
93
+ `passed` only in exactly the form the encoder writes (strictly increasing,
94
+ each in `[1, epoch)`, at most 16, 32-byte secrets, a non-negative integer
95
+ `left`), and returns it as `EpochGrant.passed` with `leftAt`. Anything else
96
+ drops the field and keeps the grant: the current epoch still stands.
97
+
98
+ `decodeMemberEpochGrant` now sets `leftAt` on each passed epoch too, from the
99
+ `created_at` of the authority's rekey out of it, so both kinds of answer say
100
+ when each epoch was left.
101
+
102
+ ## Compatibility
103
+
104
+ Both directions, for each change:
105
+
106
+ - **New writer, old reader (rekey).** 0.7.0's `decodeRekeyEvent` and
107
+ `readRekeyEvidence` build their result from named fields, so they ignore
108
+ the marker. An old client announces a scheduled rekey as it would any
109
+ other.
110
+ - **Old writer, new reader (rekey).** No marker, so `scheduled` is absent and
111
+ the rekey is announced as before.
112
+ - **New writer, old reader (grant).** 0.7.0's `decodeEpochGrant` reads named
113
+ fields and gets the current epoch, as from any grant before
114
+ (`epoch-grant-window-old-reader`). It reads the window once it updates.
115
+ - **Old writer, new reader (grant).** No `passed`, so `EpochGrant.passed` is
116
+ absent, as before.
117
+ - **Types.** `EpochGrant.passed` and `MemberEpochGrant.passed` are now
118
+ `Array<RoomEpoch & { leftAt?: number }>`, which still accepts a plain
119
+ `RoomEpoch[]`. `RekeyNotice.scheduled` and `RekeyEvidence.scheduled` are
120
+ optional and absent unless set.
121
+
122
+ ## Vectors
123
+
124
+ `vectors/schedule-vectors.json` (generated by `scripts/generate-schedule.mjs`,
125
+ checked by `vectors/verify-schedule.test.ts`), one group, `schedule`:
126
+
127
+ - `scheduled-rekey`: empty `removed`, the member list carried, the commitment
128
+ present, the marker present; read as scheduled with its secret, and read
129
+ through `readRekeyEvidence` from the previous epoch. `unflagged` is the
130
+ same rekey with the same draws and no marker: its body is the scheduled
131
+ body without `"scheduled":true,`.
132
+ - `scheduled-rekey-contradictory`: the marker beside a removal and beside a
133
+ close, built by hand; both read as not scheduled, and the encoder refuses
134
+ both.
135
+ - `epoch-grant-window`: a grant of epoch 18 carrying epochs 2 to 17, the cap.
136
+ The encoder refuses a 17th; a hand-built grant with 17 entries, one with
137
+ two out of order, and one carrying epoch 0 each read as the grant without
138
+ `passed`.
139
+ - `epoch-grant-window-old-reader`: the same grant read as 0.7.0 reads it: the
140
+ current epoch only.
141
+ - `history-window`: `epochsInWindow` at the 30-day edge, at the cap of 16, on
142
+ a weekly schedule, on unsorted input with a doubled epoch, and on nothing.
@@ -0,0 +1,104 @@
1
+ # Seal keys: healing after a device is copied
2
+
3
+ Status: 0.7.0. The first phase of KithMoot's key schedule parity plan
4
+ (`kithmoot/docs/2026-10-04-key-schedule-parity.md`).
5
+
6
+ ## The problem
7
+
8
+ A device key does two jobs. It signs, and every rekey copy (1462, the `keys`
9
+ map) and every epoch grant (20469 from the authority, 20472 from a member)
10
+ was NIP-44-sealed to it. Whoever copies a device key opens every rekey and
11
+ grant after it, for as long as nobody notices and removes the device.
12
+
13
+ Rotating a receiving key that the device key signs heals nothing: the thief
14
+ holds the device key and signs their own rotation.
15
+
16
+ ## The design
17
+
18
+ The receiving key rides in the device credential (kind 20460), which the
19
+ **participant** signs. The participant key usually lives in a signer (an
20
+ extension, a bunker, Heartwood) and not on the device, so it is the one thing
21
+ a device thief does not get.
22
+
23
+ - **Wire.** A credential may end with `["seal", <x-only pubkey>]`. The device
24
+ mints a fresh seal key at each renewal. `createDeviceCredential({ seal })`
25
+ appends the tag last, and refuses a value that is not a point on the curve.
26
+ A credential with no seal tag is byte-identical to one from 0.6.0.
27
+ - **Sending.** A rekey copy and both grants are sealed to the seal key of the
28
+ newest credential the sender holds for the device (`sealTarget`), falling
29
+ back to the device key when that credential names none. The `keys` map stays
30
+ keyed by device. A seal tag that is doubled or off the curve reads as
31
+ unusable (`credentialSeal` returns null), and the sender seals to the device
32
+ key rather than failing the whole rekey: only the participant can have
33
+ signed it.
34
+ - **Keeping the secrets.** A device keeps its seal secrets as it keeps its
35
+ device key. A device that reads a room again from the start (as a browser
36
+ does, following the rekeys from epoch 0) opens each old copy with the seal
37
+ key it was sealed to, so dropping them would leave it asking a desk for
38
+ every epoch. Keeping them costs healing nothing: a thief who copies the
39
+ device holds the seal keys minted up to then, which is what the newest of
40
+ them already gave away, and none minted after. Retiring them belongs to
41
+ forward secrecy (the plan's phase 2), alongside epoch secrets.
42
+ - **Never back to the device key.** `sealTarget` does not check expiry. A
43
+ device quiet for longer than its credential still holds that seal key.
44
+ Falling back to the device key would hand the copy to the thief.
45
+ - **Reading.** Every reader tries each seal key it holds, then the device key
46
+ (`openSealed`). A rekey copy none of them opens reads as no copy: the rekey
47
+ is still read (who was removed, whether the room closed), without a secret,
48
+ and the device asks for the epoch.
49
+ - **Desks.** `hostRoomEpoch` and `hostMemberEpochDesk` take `credentialFor
50
+ (device)`, the newest credential the room has seen for that device.
51
+ `sealCredential` seals the answer to the newer of that and the one the
52
+ request carries (`newerCredential`: later expiry, then later `created_at`),
53
+ when the known one verifies for the same room, device and participant. A
54
+ thief replaying the device's older, still-live credential is answered under
55
+ the seal key the device has minted since.
56
+
57
+ `credentialFor` must be monotone. Roster entries are device-signed, so a
58
+ thief can publish one carrying the older credential. If that replaced the
59
+ newer one, the desk would seal to the copied seal key again.
60
+
61
+ ## What healing covers
62
+
63
+ A thief who copied a device once holds the seal keys minted up to then, and
64
+ none minted after. They read until the next rekey and until the
65
+ copied credential lapses (up to twelve hours in KithMoot). After both, rekeys
66
+ are sealed where they cannot reach, and the desks answer the device's new
67
+ seal key.
68
+
69
+ Not covered here:
70
+
71
+ - **The link outlives healing.** The thief also holds epoch 0, which is the
72
+ link, and can come back as a new participant. Healing turns an invisible
73
+ reader into a visible stranger. Keeping them out as well needs the link
74
+ replaced.
75
+ - **Rekeys.** Healing completes only at a rekey. KithMoot rekeys on removal
76
+ today; scheduled rekeys are the plan's phase 2.
77
+ - **A participant key on the device** (a local nsec, no signer) heals only by
78
+ removal: the thief can sign a fresh credential with a seal key of their own.
79
+ - **Secondary devices** whose credential another device mints, and which do
80
+ not renew, carry no seal key and are sealed to as before.
81
+
82
+ ## Compatibility
83
+
84
+ - Old senders ignore the tag and seal to the device key; new readers try the
85
+ device key last, so they still read.
86
+ - New senders seal to the seal key only when the credential names one, and a
87
+ client mints a seal key only once it reads seal-sealed copies, so an old
88
+ reader never receives one.
89
+ - `decodeEpochRequest` and `decodeMemberEpochRequest` return what they did in
90
+ 0.6.0. The desks read the credential internally.
91
+
92
+ ## Vectors
93
+
94
+ `vectors/seal-vectors.json` (generated by `scripts/generate-seal.mjs`,
95
+ checked by `vectors/verify-seal.test.ts`):
96
+
97
+ - `credential-seal-key`, `credential-without-seal`,
98
+ `credential-seal-doubled`, `credential-seal-off-curve`;
99
+ - `newer-credential`;
100
+ - `rekey-seal` (a seal-keyed and a bare recipient; the renewed device, the
101
+ thief and the bare device reading it), and `rekey-without-seal-keys`
102
+ (byte-identical to a rekey with bare devices);
103
+ - `epoch-grant-seal` and `member-grant-seal` (the thief replays the copied
104
+ credential; the desk seals to the renewed one).