@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,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.
|
package/docs/seal-key.md
ADDED
|
@@ -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).
|