@forgesworn/fold-kit 0.4.0 → 0.5.1
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/README.md +12 -0
- package/dist/epoch-commit.d.ts +31 -0
- package/dist/epoch-commit.js +46 -0
- package/dist/epoch.d.ts +12 -0
- package/dist/epoch.js +9 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/member-epoch.d.ts +278 -0
- package/dist/member-epoch.js +646 -0
- package/llms.txt +1 -0
- package/package.json +3 -1
- package/vectors/member-epoch-vectors.json +927 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
3
|
+
import { base64urlnopad } from '@scure/base';
|
|
4
|
+
import { nip44 } from 'nostr-tools';
|
|
5
|
+
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
6
|
+
import { hexEquals, normaliseHex } from './hex.js';
|
|
7
|
+
import { verifyDeviceCredential } from './credential.js';
|
|
8
|
+
import { evaluateAccess } from './access.js';
|
|
9
|
+
import { verifyEventUncached } from './verify.js';
|
|
10
|
+
import { withExpiration } from './expiration.js';
|
|
11
|
+
import { epochCommitment } from './epoch-commit.js';
|
|
12
|
+
import { KINDS } from './kinds.js';
|
|
13
|
+
import { MAX_EPOCH, deriveEpoch, epochRequestAdmission, peekRekeyEvent, } from './epoch.js';
|
|
14
|
+
/**
|
|
15
|
+
* Member-to-member epoch catch-up: any current member can bring an
|
|
16
|
+
* admitted, non-removed member up to date.
|
|
17
|
+
*
|
|
18
|
+
* `hostRoomEpoch` answers an epoch request only where the authority's key
|
|
19
|
+
* is, and the request is ephemeral, so a device that missed a rekey - a
|
|
20
|
+
* phone that came back with a new device key, which no stored rekey seals
|
|
21
|
+
* to - recovers only while the authority's device is online with the room
|
|
22
|
+
* open. Every other member at the current epoch holds the secret and, until
|
|
23
|
+
* now, could not pass it on in a form the requester could check.
|
|
24
|
+
*
|
|
25
|
+
* It can now, because the requester does not trust the member at all. The
|
|
26
|
+
* grant carries the authority-signed rekey events from the requester's
|
|
27
|
+
* epoch to the current one, and the secret of every epoch in between:
|
|
28
|
+
*
|
|
29
|
+
* - each rekey's signature is the authority's (`peekRekeyEvent`);
|
|
30
|
+
* - each rekey decrypts under the key of the epoch before it, starting from
|
|
31
|
+
* the requester's own - and NIP-44's MAC verifies under no other key, so
|
|
32
|
+
* a decrypting rekey j+1 proves the secret offered for epoch j is the one
|
|
33
|
+
* the authority encrypted under;
|
|
34
|
+
* - the last epoch, which has no successor to vouch for it, is checked
|
|
35
|
+
* against the commitment the authority wrote into its rekey body
|
|
36
|
+
* (`epochCommitment`, `encodeRekeyEvent({ commit: true })`). A last rekey
|
|
37
|
+
* with no commitment - every rekey written before this existed - is not
|
|
38
|
+
* accepted from a member: that epoch stays authority-only.
|
|
39
|
+
*
|
|
40
|
+
* The removed and closed facts come from the same authority-signed bodies,
|
|
41
|
+
* so a member can neither hide a removal from the requester nor invent
|
|
42
|
+
* one. Answerers refuse the removed and a closed room by not answering at
|
|
43
|
+
* all; only the authority's refusal is something a requester can believe.
|
|
44
|
+
* See `docs/member-epoch-catch-up.md` for the threat model.
|
|
45
|
+
*/
|
|
46
|
+
/** The member path's two kinds. Ephemeral, like 20468/20469: a live
|
|
47
|
+
* handshake, never a record. */
|
|
48
|
+
export const MEMBER_EPOCH_KINDS = {
|
|
49
|
+
/** A device behind the room asking any current member for the epochs it
|
|
50
|
+
* missed. Encrypted under a key from the epoch-0 room key, so any
|
|
51
|
+
* admitted device can read it and a relay cannot. */
|
|
52
|
+
REQUEST: 20471,
|
|
53
|
+
/** A member's answer, signed by a one-time key made for that grant and
|
|
54
|
+
* sealed to the asking device: secrets plus the authority-signed rekeys
|
|
55
|
+
* that prove them. */
|
|
56
|
+
GRANT: 20472,
|
|
57
|
+
};
|
|
58
|
+
/** HKDF info for the key a member epoch request's body is sealed under. */
|
|
59
|
+
export const MEMBER_EPOCH_REQUEST_KEY_INFO = 'kithmoot/v1/member-epoch-request-key';
|
|
60
|
+
/** The most epochs one member grant will carry. A device further behind
|
|
61
|
+
* than this asks the authority. */
|
|
62
|
+
export const MAX_MEMBER_EPOCH_CHAIN = 32;
|
|
63
|
+
const MAX_AGE_SECONDS = 90;
|
|
64
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
65
|
+
const DEFAULT_RETRY_MS = 4_000;
|
|
66
|
+
const DEFAULT_JITTER_MS = 1_500;
|
|
67
|
+
/** How long a requester waits for its relays to finish replaying the room's
|
|
68
|
+
* rekeys before it asks anyway. */
|
|
69
|
+
const REKEY_REPLAY_WAIT_MS = 1_500;
|
|
70
|
+
/** Many relays refuse events much over 64 KiB. A grant inlines whole rekey
|
|
71
|
+
* events, each with a seal per device, so a long chain in a big room can
|
|
72
|
+
* pass that; a desk does not send one that would. */
|
|
73
|
+
const DEFAULT_MAX_GRANT_BYTES = 60_000;
|
|
74
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
75
|
+
function requireHex32(value, what) {
|
|
76
|
+
if (typeof value !== 'string' || !HEX64.test(value))
|
|
77
|
+
throw new Error(`${what} must be 32-byte hex`);
|
|
78
|
+
return normaliseHex(value);
|
|
79
|
+
}
|
|
80
|
+
/** Equality that does not leak where two proofs first differ. */
|
|
81
|
+
function constantTimeEquals(presented, expected) {
|
|
82
|
+
const a = normaliseHex(presented);
|
|
83
|
+
if (a.length !== expected.length)
|
|
84
|
+
return false;
|
|
85
|
+
let diff = 0;
|
|
86
|
+
for (let i = 0; i < a.length; i += 1)
|
|
87
|
+
diff |= a.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
88
|
+
return diff === 0;
|
|
89
|
+
}
|
|
90
|
+
/** The key a member epoch request is sealed under: the epoch-0 room key,
|
|
91
|
+
* expanded under its own info string. */
|
|
92
|
+
export function deriveMemberEpochRequestKey(roomKey) {
|
|
93
|
+
if (roomKey.length !== 32)
|
|
94
|
+
throw new Error('room key must be 32 bytes');
|
|
95
|
+
return hkdf(sha256, roomKey, undefined, MEMBER_EPOCH_REQUEST_KEY_INFO, 32);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read an authority-signed rekey with the key of the epoch it leaves,
|
|
99
|
+
* without needing a seal of one's own. Null for anything that does not
|
|
100
|
+
* check out: not the authority, not this room, not epoch `previous.epoch
|
|
101
|
+
* + 1`, or not encrypted under `previous.key`. What both sides of a member
|
|
102
|
+
* grant use to check the chain.
|
|
103
|
+
*/
|
|
104
|
+
export function readRekeyEvidence(event, opts) {
|
|
105
|
+
try {
|
|
106
|
+
const epoch = peekRekeyEvent(event, opts);
|
|
107
|
+
if (epoch === null || epoch !== opts.previous.epoch + 1)
|
|
108
|
+
return null;
|
|
109
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, opts.previous.key));
|
|
110
|
+
if (body.v !== 1 || body.epoch !== epoch)
|
|
111
|
+
return null;
|
|
112
|
+
if (!Array.isArray(body.removed) || !body.removed.every((p) => typeof p === 'string' && HEX64.test(p)))
|
|
113
|
+
return null;
|
|
114
|
+
const evidence = {
|
|
115
|
+
epoch,
|
|
116
|
+
removed: [...new Set(body.removed.map(normaliseHex))].sort(),
|
|
117
|
+
closed: body.closed === true,
|
|
118
|
+
};
|
|
119
|
+
if (typeof body.commit === 'string' && HEX64.test(body.commit))
|
|
120
|
+
evidence.commit = normaliseHex(body.commit);
|
|
121
|
+
return evidence;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Ask any current member for the epochs after `have`. */
|
|
128
|
+
export function encodeMemberEpochRequest(opts) {
|
|
129
|
+
if (opts.deviceSk.length !== 32)
|
|
130
|
+
throw new Error('device secret key must be 32 bytes');
|
|
131
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
132
|
+
const authority = requireHex32(opts.authority, 'authority pubkey');
|
|
133
|
+
if (!Number.isSafeInteger(opts.have) || opts.have < 0 || opts.have > MAX_EPOCH)
|
|
134
|
+
throw new Error('have must be a small non-negative integer');
|
|
135
|
+
const admission = epochRequestAdmission({ roomKey: opts.roomKey, roomId, authority, device: getPublicKey(opts.deviceSk), createdAt: opts.now });
|
|
136
|
+
const body = {
|
|
137
|
+
v: 1,
|
|
138
|
+
credential: opts.credential,
|
|
139
|
+
...(opts.proof ? { proof: opts.proof } : {}),
|
|
140
|
+
admission,
|
|
141
|
+
have: opts.have,
|
|
142
|
+
};
|
|
143
|
+
return finalizeEvent({
|
|
144
|
+
kind: MEMBER_EPOCH_KINDS.REQUEST,
|
|
145
|
+
created_at: opts.now,
|
|
146
|
+
tags: withExpiration([['d', roomId]], opts.expiresAt),
|
|
147
|
+
content: nip44.v2.encrypt(JSON.stringify(body), deriveMemberEpochRequestKey(opts.roomKey)),
|
|
148
|
+
}, opts.deviceSk);
|
|
149
|
+
}
|
|
150
|
+
/** Null for anything malformed, stale, from a device that cannot prove
|
|
151
|
+
* which participant it speaks for, or that cannot prove admission. */
|
|
152
|
+
export function decodeMemberEpochRequest(event, opts) {
|
|
153
|
+
try {
|
|
154
|
+
if (event.kind !== MEMBER_EPOCH_KINDS.REQUEST)
|
|
155
|
+
return null;
|
|
156
|
+
if (!verifyEventUncached(event))
|
|
157
|
+
return null;
|
|
158
|
+
if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? MAX_AGE_SECONDS))
|
|
159
|
+
return null;
|
|
160
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
161
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== roomId)
|
|
162
|
+
return null;
|
|
163
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, deriveMemberEpochRequestKey(opts.roomKey)));
|
|
164
|
+
if (body.v !== 1 || typeof body.credential !== 'object' || body.credential === null)
|
|
165
|
+
return null;
|
|
166
|
+
if (!Number.isSafeInteger(body.have) || body.have < 0 || body.have > MAX_EPOCH)
|
|
167
|
+
return null;
|
|
168
|
+
const verdict = verifyDeviceCredential(body.credential, { roomId, now: opts.now });
|
|
169
|
+
if (!verdict.ok || !hexEquals(verdict.device, event.pubkey))
|
|
170
|
+
return null;
|
|
171
|
+
if (typeof body.admission !== 'string')
|
|
172
|
+
return null;
|
|
173
|
+
const expected = epochRequestAdmission({
|
|
174
|
+
roomKey: opts.roomKey,
|
|
175
|
+
roomId,
|
|
176
|
+
authority: requireHex32(opts.authority, 'authority pubkey'),
|
|
177
|
+
device: verdict.device,
|
|
178
|
+
createdAt: event.created_at,
|
|
179
|
+
});
|
|
180
|
+
if (!constantTimeEquals(body.admission, expected))
|
|
181
|
+
return null;
|
|
182
|
+
if (opts.policy) {
|
|
183
|
+
const proof = body.proof && typeof body.proof === 'object' ? body.proof : undefined;
|
|
184
|
+
if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, roomId).admitted)
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return { device: verdict.device, participant: verdict.participant, request: event.id, have: body.have };
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function plainEvent(event) {
|
|
194
|
+
const { id, pubkey, created_at, kind, tags, content, sig } = event;
|
|
195
|
+
return { id, pubkey, created_at, kind, tags, content, sig };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Answer one member epoch request with the epochs it missed.
|
|
199
|
+
*
|
|
200
|
+
* The grant is signed, and sealed to the asking device, by a fresh key made
|
|
201
|
+
* for it alone and then dropped, not by the answering member's device key.
|
|
202
|
+
* Nothing about a grant is trusted on its signer (the authority's
|
|
203
|
+
* signatures are what the requester checks), so the member's key would add
|
|
204
|
+
* nothing but a public line from that device to the room id in the `d` tag.
|
|
205
|
+
*/
|
|
206
|
+
export function encodeMemberEpochGrant(opts) {
|
|
207
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
208
|
+
const device = requireHex32(opts.device, 'device pubkey');
|
|
209
|
+
const request = requireHex32(opts.request, 'request id');
|
|
210
|
+
if (opts.epochs.length < 1 || opts.epochs.length > MAX_MEMBER_EPOCH_CHAIN)
|
|
211
|
+
throw new Error('a member grant carries 1 to 32 epochs');
|
|
212
|
+
if (opts.rekeys.length !== opts.epochs.length)
|
|
213
|
+
throw new Error('one rekey per epoch');
|
|
214
|
+
for (let i = 0; i < opts.epochs.length; i += 1) {
|
|
215
|
+
const e = opts.epochs[i];
|
|
216
|
+
if (e.secret.length !== 32)
|
|
217
|
+
throw new Error('epoch secret must be 32 bytes');
|
|
218
|
+
if (i > 0 && e.epoch !== opts.epochs[i - 1].epoch + 1)
|
|
219
|
+
throw new Error('epochs must be consecutive');
|
|
220
|
+
if (e.epoch < 1)
|
|
221
|
+
throw new Error('a member grant never carries epoch 0');
|
|
222
|
+
}
|
|
223
|
+
// Drawn before anything else, so a vector records it first.
|
|
224
|
+
const signerSk = generateSecretKey();
|
|
225
|
+
const body = {
|
|
226
|
+
v: 1,
|
|
227
|
+
request,
|
|
228
|
+
epoch: opts.epochs[opts.epochs.length - 1].epoch,
|
|
229
|
+
secrets: opts.epochs.map((e) => base64urlnopad.encode(e.secret)),
|
|
230
|
+
rekeys: opts.rekeys.map(plainEvent),
|
|
231
|
+
};
|
|
232
|
+
return finalizeEvent({
|
|
233
|
+
kind: MEMBER_EPOCH_KINDS.GRANT,
|
|
234
|
+
created_at: opts.now,
|
|
235
|
+
tags: withExpiration([
|
|
236
|
+
['d', roomId],
|
|
237
|
+
['p', device],
|
|
238
|
+
], opts.expiresAt),
|
|
239
|
+
content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(signerSk, device)),
|
|
240
|
+
}, signerSk);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Verify a member's answer to one of this device's member requests. Null
|
|
244
|
+
* for anything that does not check out - see the module comment and
|
|
245
|
+
* `docs/member-epoch-catch-up.md` "Verification rules" for each check.
|
|
246
|
+
*/
|
|
247
|
+
export function decodeMemberEpochGrant(event, opts) {
|
|
248
|
+
try {
|
|
249
|
+
if (event.kind !== MEMBER_EPOCH_KINDS.GRANT)
|
|
250
|
+
return null;
|
|
251
|
+
if (!verifyEventUncached(event))
|
|
252
|
+
return null;
|
|
253
|
+
if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? MAX_AGE_SECONDS))
|
|
254
|
+
return null;
|
|
255
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
256
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== roomId)
|
|
257
|
+
return null;
|
|
258
|
+
const device = getPublicKey(opts.deviceSk);
|
|
259
|
+
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
260
|
+
if (addressed === undefined || !hexEquals(addressed, device))
|
|
261
|
+
return null;
|
|
262
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
|
|
263
|
+
if (body.v !== 1 || typeof body.request !== 'string' || !HEX64.test(body.request))
|
|
264
|
+
return null;
|
|
265
|
+
if (![...opts.requests].some((r) => hexEquals(r, body.request)))
|
|
266
|
+
return null;
|
|
267
|
+
if (!Array.isArray(body.secrets) || !Array.isArray(body.rekeys))
|
|
268
|
+
return null;
|
|
269
|
+
const length = body.secrets.length;
|
|
270
|
+
if (length < 1 || length > MAX_MEMBER_EPOCH_CHAIN || body.rekeys.length !== length)
|
|
271
|
+
return null;
|
|
272
|
+
const top = opts.current.epoch + length;
|
|
273
|
+
if (body.epoch !== top || top > MAX_EPOCH)
|
|
274
|
+
return null;
|
|
275
|
+
if (opts.expected !== undefined && top < opts.expected)
|
|
276
|
+
return null;
|
|
277
|
+
const participant = normaliseHex(opts.participant);
|
|
278
|
+
const removed = new Set([...(opts.removed ?? [])].map(normaliseHex));
|
|
279
|
+
let previous = opts.current;
|
|
280
|
+
let secret;
|
|
281
|
+
const passed = [];
|
|
282
|
+
for (let i = 0; i < length; i += 1) {
|
|
283
|
+
const evidence = readRekeyEvidence(body.rekeys[i], { roomId, authority: opts.authority, previous });
|
|
284
|
+
if (!evidence)
|
|
285
|
+
return null;
|
|
286
|
+
if (evidence.closed)
|
|
287
|
+
return null;
|
|
288
|
+
if (evidence.removed.includes(participant))
|
|
289
|
+
return null;
|
|
290
|
+
for (const p of evidence.removed)
|
|
291
|
+
removed.add(p);
|
|
292
|
+
const raw = body.secrets[i];
|
|
293
|
+
if (typeof raw !== 'string')
|
|
294
|
+
return null;
|
|
295
|
+
secret = base64urlnopad.decode(raw);
|
|
296
|
+
if (secret.length !== 32)
|
|
297
|
+
return null;
|
|
298
|
+
// Every secret but the last is proven by the next rekey decrypting
|
|
299
|
+
// under it, on the next turn of this loop. The last has no next rekey:
|
|
300
|
+
// the authority's commitment is the only thing that can vouch for it.
|
|
301
|
+
if (i === length - 1) {
|
|
302
|
+
if (evidence.commit === undefined)
|
|
303
|
+
return null;
|
|
304
|
+
if (!constantTimeEquals(evidence.commit, epochCommitment(roomId, evidence.epoch, secret)))
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
previous = deriveEpoch({ epoch: evidence.epoch, secret });
|
|
308
|
+
if (i < length - 1)
|
|
309
|
+
passed.push({ epoch: evidence.epoch, secret });
|
|
310
|
+
}
|
|
311
|
+
return { epoch: { epoch: top, secret: secret }, removed: [...removed].sort(), passed };
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Answer member epoch requests for as long as the handle is open: what any
|
|
319
|
+
* member at the current epoch runs beside its session. It answers only an
|
|
320
|
+
* admitted, credentialled device that is not removed, in a room that is not
|
|
321
|
+
* closed, and only when it can hand over the whole chain from the
|
|
322
|
+
* requester's epoch to its own with the authority's rekeys to prove it.
|
|
323
|
+
*
|
|
324
|
+
* Anti-amplification: each answer waits a random `[0, jitterMs)`, and is
|
|
325
|
+
* dropped if another member's grant to the same device appears meanwhile.
|
|
326
|
+
* It is dropped at most once per requesting device: the requester asks again
|
|
327
|
+
* with a fresh request if what it got did not verify, and the second time
|
|
328
|
+
* every desk answers, so a stranger posting junk grants costs one round.
|
|
329
|
+
*/
|
|
330
|
+
export function hostMemberEpochDesk(opts) {
|
|
331
|
+
if (opts.deviceSk.length !== 32)
|
|
332
|
+
throw new Error('device secret key must be 32 bytes');
|
|
333
|
+
if (opts.roomKey.length !== 32)
|
|
334
|
+
throw new Error('room key must be 32 bytes');
|
|
335
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
336
|
+
const authority = requireHex32(opts.authority, 'authority pubkey');
|
|
337
|
+
const self = getPublicKey(opts.deviceSk);
|
|
338
|
+
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
339
|
+
const random = opts.random ?? Math.random;
|
|
340
|
+
const jitterMs = Math.max(0, opts.jitterMs ?? DEFAULT_JITTER_MS);
|
|
341
|
+
const answered = new Set();
|
|
342
|
+
/** Requesting devices another member's grant was seen for, since their
|
|
343
|
+
* latest request reached this desk. */
|
|
344
|
+
const grantSeen = new Set();
|
|
345
|
+
/** Requesting devices this desk has already stood down for once. */
|
|
346
|
+
const stoodDown = new Set();
|
|
347
|
+
/** Ids of the grants this desk published: each is signed by a one-time
|
|
348
|
+
* key, so its own grants are told apart by id, not by signer. */
|
|
349
|
+
const mine = new Set();
|
|
350
|
+
const timers = new Set();
|
|
351
|
+
let closed = false;
|
|
352
|
+
const bound = (set) => {
|
|
353
|
+
if (set.size > 256)
|
|
354
|
+
set.delete(set.values().next().value);
|
|
355
|
+
};
|
|
356
|
+
const chainFor = (have, current) => {
|
|
357
|
+
if (current.epoch - have > MAX_MEMBER_EPOCH_CHAIN)
|
|
358
|
+
return null;
|
|
359
|
+
const epochs = [];
|
|
360
|
+
const rekeys = [];
|
|
361
|
+
for (let n = have + 1; n <= current.epoch; n += 1) {
|
|
362
|
+
const secret = n === current.epoch ? current.secret : opts.secretAt?.(n);
|
|
363
|
+
const rekey = opts.rekeyAt(n);
|
|
364
|
+
if (!secret || secret.length !== 32 || !rekey)
|
|
365
|
+
return null;
|
|
366
|
+
if (peekRekeyEvent(rekey, { roomId, authority }) !== n)
|
|
367
|
+
return null;
|
|
368
|
+
epochs.push({ epoch: n, secret });
|
|
369
|
+
rekeys.push(rekey);
|
|
370
|
+
}
|
|
371
|
+
// When this device can read the last rekey itself, it checks the
|
|
372
|
+
// commitment is there: a legacy epoch is the authority's to hand on, and
|
|
373
|
+
// a grant the requester will refuse is noise. One it cannot read (it
|
|
374
|
+
// joined at this epoch) it sends, and the requester decides.
|
|
375
|
+
const before = current.epoch - 1;
|
|
376
|
+
const beforeSecret = before === 0 ? undefined : opts.secretAt?.(before);
|
|
377
|
+
const beforeKey = before === 0 ? opts.roomKey : beforeSecret?.length === 32 ? deriveEpoch({ epoch: before, secret: beforeSecret }).key : undefined;
|
|
378
|
+
if (beforeKey) {
|
|
379
|
+
const evidence = readRekeyEvidence(rekeys[rekeys.length - 1], {
|
|
380
|
+
roomId,
|
|
381
|
+
authority,
|
|
382
|
+
previous: { epoch: before, id: '', key: beforeKey },
|
|
383
|
+
});
|
|
384
|
+
if (!evidence || evidence.commit === undefined)
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
return { epochs, rekeys };
|
|
388
|
+
};
|
|
389
|
+
const answer = (request) => {
|
|
390
|
+
if (closed)
|
|
391
|
+
return;
|
|
392
|
+
if (grantSeen.has(request.device) && !stoodDown.has(request.device)) {
|
|
393
|
+
stoodDown.add(request.device);
|
|
394
|
+
bound(stoodDown);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
stoodDown.delete(request.device);
|
|
398
|
+
// Asked again, now that the wait is over: the room may have moved.
|
|
399
|
+
if (opts.closed?.())
|
|
400
|
+
return;
|
|
401
|
+
if ([...opts.removed()].some((p) => hexEquals(p, request.participant)))
|
|
402
|
+
return;
|
|
403
|
+
const current = opts.current();
|
|
404
|
+
if (!current || request.have >= current.epoch)
|
|
405
|
+
return;
|
|
406
|
+
const chain = chainFor(request.have, current);
|
|
407
|
+
if (!chain)
|
|
408
|
+
return;
|
|
409
|
+
let grant;
|
|
410
|
+
try {
|
|
411
|
+
grant = encodeMemberEpochGrant({
|
|
412
|
+
roomId,
|
|
413
|
+
device: request.device,
|
|
414
|
+
request: request.request,
|
|
415
|
+
epochs: chain.epochs,
|
|
416
|
+
rekeys: chain.rekeys,
|
|
417
|
+
now: now(),
|
|
418
|
+
expiresAt: opts.expiresAt,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (JSON.stringify(grant).length > (opts.maxGrantBytes ?? DEFAULT_MAX_GRANT_BYTES))
|
|
425
|
+
return;
|
|
426
|
+
mine.add(grant.id);
|
|
427
|
+
bound(mine);
|
|
428
|
+
opts.transport.publish(grant).catch(() => { });
|
|
429
|
+
opts.onGranted?.(request);
|
|
430
|
+
};
|
|
431
|
+
const unsubGrants = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.GRANT], '#d': [roomId] }], (event) => {
|
|
432
|
+
if (closed || mine.has(event.id))
|
|
433
|
+
return;
|
|
434
|
+
const to = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
435
|
+
if (to === undefined || !HEX64.test(to))
|
|
436
|
+
return;
|
|
437
|
+
grantSeen.add(normaliseHex(to));
|
|
438
|
+
bound(grantSeen);
|
|
439
|
+
});
|
|
440
|
+
const unsubRequests = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.REQUEST], '#d': [roomId] }], (event) => {
|
|
441
|
+
if (closed || hexEquals(event.pubkey, self))
|
|
442
|
+
return;
|
|
443
|
+
const request = decodeMemberEpochRequest(event, { roomId, authority, roomKey: opts.roomKey, now: now(), policy: opts.policy });
|
|
444
|
+
if (!request || answered.has(request.request))
|
|
445
|
+
return;
|
|
446
|
+
answered.add(request.request);
|
|
447
|
+
bound(answered);
|
|
448
|
+
if (opts.closed?.()) {
|
|
449
|
+
opts.onRefused?.(request, 'closed');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if ([...opts.removed()].some((p) => hexEquals(p, request.participant))) {
|
|
453
|
+
opts.onRefused?.(request, 'removed');
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const current = opts.current();
|
|
457
|
+
if (!current || request.have >= current.epoch)
|
|
458
|
+
return;
|
|
459
|
+
grantSeen.delete(request.device);
|
|
460
|
+
const delay = jitterMs === 0 ? 0 : Math.floor(random() * jitterMs);
|
|
461
|
+
if (delay === 0) {
|
|
462
|
+
answer(request);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const timer = setTimeout(() => {
|
|
466
|
+
timers.delete(timer);
|
|
467
|
+
answer(request);
|
|
468
|
+
}, delay);
|
|
469
|
+
timer.unref?.();
|
|
470
|
+
timers.add(timer);
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
close() {
|
|
474
|
+
if (closed)
|
|
475
|
+
return;
|
|
476
|
+
closed = true;
|
|
477
|
+
for (const timer of timers)
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
timers.clear();
|
|
480
|
+
unsubRequests();
|
|
481
|
+
unsubGrants();
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The member side of a catch-up, for `requestRoomEpoch({ members })`. Each
|
|
487
|
+
* round publishes a fresh request (a new id, so a desk that stood down once
|
|
488
|
+
* answers it), and the first grant that verifies is handed to `onGrant`.
|
|
489
|
+
*
|
|
490
|
+
* It subscribes to the room's rekeys (kind 1462) before it asks, and refuses
|
|
491
|
+
* any grant that stops short of the newest one the authority signed: rule 3
|
|
492
|
+
* of `docs/member-epoch-catch-up.md`, enforced here rather than left to the
|
|
493
|
+
* caller, because a member removed at epoch E still holds E-1 and every
|
|
494
|
+
* rekey up to it, and would otherwise hold the requester on a key it shares.
|
|
495
|
+
*/
|
|
496
|
+
export function memberEpochSource(opts) {
|
|
497
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
498
|
+
const authority = requireHex32(opts.authority, 'authority pubkey');
|
|
499
|
+
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
500
|
+
const device = getPublicKey(opts.deviceSk);
|
|
501
|
+
const current = () => (typeof opts.current === 'function' ? opts.current() : opts.current);
|
|
502
|
+
const expected = () => (typeof opts.expected === 'function' ? opts.expected() : opts.expected);
|
|
503
|
+
const knownRemoved = typeof opts.removed === 'function' || opts.removed === undefined ? undefined : [...opts.removed];
|
|
504
|
+
const removedNow = () => (typeof opts.removed === 'function' ? opts.removed() : knownRemoved);
|
|
505
|
+
const verdict = verifyDeviceCredential(opts.credential, { roomId, now: now() });
|
|
506
|
+
if (!verdict.ok)
|
|
507
|
+
throw new Error(`device credential refused: ${verdict.reason}`);
|
|
508
|
+
const participant = verdict.participant;
|
|
509
|
+
return {
|
|
510
|
+
start(onGrant) {
|
|
511
|
+
const requests = new Set();
|
|
512
|
+
let stopped = false;
|
|
513
|
+
let begun = false;
|
|
514
|
+
/** Set once everything below is wired up; EOSE can arrive before. */
|
|
515
|
+
let ready = false;
|
|
516
|
+
let replayed = false;
|
|
517
|
+
let retry;
|
|
518
|
+
let replayWait;
|
|
519
|
+
const stop = () => {
|
|
520
|
+
if (stopped)
|
|
521
|
+
return;
|
|
522
|
+
stopped = true;
|
|
523
|
+
if (retry !== undefined)
|
|
524
|
+
clearInterval(retry);
|
|
525
|
+
if (replayWait !== undefined)
|
|
526
|
+
clearTimeout(replayWait);
|
|
527
|
+
unsub();
|
|
528
|
+
unsubRekeys();
|
|
529
|
+
};
|
|
530
|
+
// The floor: the newest epoch the authority has signed a rekey into, as
|
|
531
|
+
// the relays replay it. `peekRekeyEvent` checks the signer, the room and
|
|
532
|
+
// the signature, so nobody else can raise it.
|
|
533
|
+
let seen = 0;
|
|
534
|
+
// The first request waits for the relays to finish replaying them
|
|
535
|
+
// (EOSE), or for REKEY_REPLAY_WAIT_MS, so that a member answering
|
|
536
|
+
// faster than the replay cannot slip a stale chain in under the floor.
|
|
537
|
+
const unsubRekeys = opts.transport.subscribe([{ kinds: [KINDS.ROOM_REKEY], '#d': [roomId] }], (event) => {
|
|
538
|
+
const epoch = peekRekeyEvent(event, { roomId, authority });
|
|
539
|
+
if (epoch !== null && epoch > seen)
|
|
540
|
+
seen = epoch;
|
|
541
|
+
}, () => {
|
|
542
|
+
replayed = true;
|
|
543
|
+
if (ready)
|
|
544
|
+
begin();
|
|
545
|
+
});
|
|
546
|
+
const floor = () => {
|
|
547
|
+
const given = expected();
|
|
548
|
+
const top = Math.max(seen, given ?? 0);
|
|
549
|
+
return top > 0 ? top : undefined;
|
|
550
|
+
};
|
|
551
|
+
const unsub = opts.transport.subscribe([{ kinds: [MEMBER_EPOCH_KINDS.GRANT], '#d': [roomId], '#p': [device] }], (event) => {
|
|
552
|
+
if (stopped)
|
|
553
|
+
return;
|
|
554
|
+
const grant = decodeMemberEpochGrant(event, {
|
|
555
|
+
roomId,
|
|
556
|
+
authority,
|
|
557
|
+
deviceSk: opts.deviceSk,
|
|
558
|
+
requests,
|
|
559
|
+
current: current(),
|
|
560
|
+
participant,
|
|
561
|
+
removed: removedNow(),
|
|
562
|
+
expected: floor(),
|
|
563
|
+
now: now(),
|
|
564
|
+
});
|
|
565
|
+
if (!grant)
|
|
566
|
+
return;
|
|
567
|
+
stop();
|
|
568
|
+
onGrant(grant);
|
|
569
|
+
});
|
|
570
|
+
const ask = () => {
|
|
571
|
+
if (stopped)
|
|
572
|
+
return;
|
|
573
|
+
let request;
|
|
574
|
+
try {
|
|
575
|
+
request = encodeMemberEpochRequest({
|
|
576
|
+
roomId,
|
|
577
|
+
authority,
|
|
578
|
+
deviceSk: opts.deviceSk,
|
|
579
|
+
roomKey: opts.roomKey,
|
|
580
|
+
credential: opts.credential,
|
|
581
|
+
proof: opts.proof,
|
|
582
|
+
have: current().epoch,
|
|
583
|
+
now: now(),
|
|
584
|
+
expiresAt: opts.expiresAt,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
requests.add(request.id);
|
|
591
|
+
if (requests.size > 16)
|
|
592
|
+
requests.delete(requests.values().next().value);
|
|
593
|
+
opts.transport.publish(request).catch(() => { });
|
|
594
|
+
};
|
|
595
|
+
function begin() {
|
|
596
|
+
if (begun || stopped)
|
|
597
|
+
return;
|
|
598
|
+
begun = true;
|
|
599
|
+
if (replayWait !== undefined)
|
|
600
|
+
clearTimeout(replayWait);
|
|
601
|
+
retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
|
|
602
|
+
retry.unref?.();
|
|
603
|
+
ask();
|
|
604
|
+
}
|
|
605
|
+
ready = true;
|
|
606
|
+
if (replayed)
|
|
607
|
+
begin();
|
|
608
|
+
else {
|
|
609
|
+
replayWait = setTimeout(begin, REKEY_REPLAY_WAIT_MS);
|
|
610
|
+
replayWait.unref?.();
|
|
611
|
+
}
|
|
612
|
+
return stop;
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/** Ask the room's current members alone (no authority) for the epochs this
|
|
617
|
+
* device missed. Rejects with a plain error when no verifiable answer comes
|
|
618
|
+
* inside the timeout. Most callers want `requestRoomEpoch({ members })`. */
|
|
619
|
+
export function requestMemberEpoch(opts) {
|
|
620
|
+
const source = memberEpochSource(opts);
|
|
621
|
+
return new Promise((resolve, reject) => {
|
|
622
|
+
let settled = false;
|
|
623
|
+
let stop = () => { };
|
|
624
|
+
const timer = setTimeout(() => {
|
|
625
|
+
if (settled)
|
|
626
|
+
return;
|
|
627
|
+
settled = true;
|
|
628
|
+
stop();
|
|
629
|
+
reject(new Error('no current member answered with an epoch that checks out'));
|
|
630
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
631
|
+
timer.unref?.();
|
|
632
|
+
stop = source.start((grant) => {
|
|
633
|
+
if (settled)
|
|
634
|
+
return;
|
|
635
|
+
settled = true;
|
|
636
|
+
clearTimeout(timer);
|
|
637
|
+
resolve(grant);
|
|
638
|
+
});
|
|
639
|
+
if (settled)
|
|
640
|
+
stop();
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/** Every wire-format literal this module owns, frozen for `src/labels.test.ts`. */
|
|
644
|
+
export const MEMBER_EPOCH_LABELS = [
|
|
645
|
+
"kithmoot/v1/member-epoch-request-key",
|
|
646
|
+
];
|
package/llms.txt
CHANGED
|
@@ -79,6 +79,7 @@ Not yet published to npm under this API. Until it is, pin an immutable Git commi
|
|
|
79
79
|
- `encodeEpochGrant(opts)` / `decodeEpochGrant(event, opts)` - the authority's answer (secret, epoch-0, or a refusal `'removed' | 'closed'`)
|
|
80
80
|
- `epochRequestAdmission(opts)`, `deriveEpochRequestKey(roomKey)`
|
|
81
81
|
- `hostRoomEpoch(opts)` / `requestRoomEpoch(opts)` / `EpochRefusedError` - the live request/grant desk, both sides
|
|
82
|
+
- Member epoch catch-up (see `docs/member-epoch-catch-up.md`): `encodeRekeyEvent({ ..., commit: true })` writes `epochCommitment(roomId, epoch, secret)` into the rekey body; `hostMemberEpochDesk(opts)` lets any current member answer; `requestRoomEpoch({ ..., members: memberEpochSource(opts) })` asks the authority and members, first verifiable answer wins; `requestMemberEpoch(opts)` asks members alone. Kinds `MEMBER_EPOCH_KINDS` = `{ REQUEST: 20471, GRANT: 20472 }`. A member grant carries the authority-signed rekeys from the asker's epoch to the current one; the asker verifies the chain itself (`decodeMemberEpochGrant`) and refuses a last epoch with no commitment (legacy: authority only), a chain that removes it or closes the room, and one that stops short of `expected`. A verified grant is `{ epoch, removed, passed }`: `passed` holds the epochs between the asker's and `epoch`, oldest first, and `requestRoomEpoch` carries it as an optional `passed` when a member answered. Codecs: `encodeMemberEpochRequest`, `decodeMemberEpochRequest`, `encodeMemberEpochGrant`, `readRekeyEvidence`, `deriveMemberEpochRequestKey`, `MAX_MEMBER_EPOCH_CHAIN` (32)
|
|
82
83
|
- `canonicalAdmins(admins)` / `signAdmins(opts)` / `verifyAdmins(opts)` - the authority's signed admin list
|
|
83
84
|
- `canonicalChannels(channels)` / `signChannels(opts)` / `verifyChannels(opts)` - the authority's signed channel list; `CHANNEL_NAME`, `RESERVED_CHANNELS`
|
|
84
85
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgesworn/fold-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Circle primitives for private Nostr groups nobody operates: epoch keys, link invites, removal by rekey, device credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"dist",
|
|
21
21
|
"vectors/kithmoot-vectors.json",
|
|
22
22
|
"vectors/circle-vectors.json",
|
|
23
|
+
"vectors/member-epoch-vectors.json",
|
|
23
24
|
"LICENSE",
|
|
24
25
|
"README.md",
|
|
25
26
|
"llms.txt"
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
32
33
|
"diff-source": "node scripts/diff-source.mjs",
|
|
33
34
|
"generate-fold": "node scripts/generate-fold.mjs",
|
|
35
|
+
"generate-member-epoch": "node scripts/generate-member-epoch.mjs",
|
|
34
36
|
"bundle-check": "node scripts/bundle-check.mjs",
|
|
35
37
|
"tarball-smoke": "node scripts/tarball-smoke.mjs",
|
|
36
38
|
"vectors": "vitest run vectors/",
|