@forgesworn/fold-kit 0.0.0-seed.0 → 0.2.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/README.md +180 -4
- package/dist/access.d.ts +34 -0
- package/dist/access.js +102 -0
- package/dist/channel.d.ts +27 -0
- package/dist/channel.js +38 -0
- package/dist/credential.d.ts +74 -0
- package/dist/credential.js +177 -0
- package/dist/display-name.d.ts +38 -0
- package/dist/display-name.js +78 -0
- package/dist/epoch.d.ts +387 -0
- package/dist/epoch.js +665 -0
- package/dist/hex.d.ts +28 -0
- package/dist/hex.js +32 -0
- package/dist/identity.d.ts +49 -0
- package/dist/identity.js +17 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +19 -0
- package/dist/invitation.d.ts +217 -0
- package/dist/invitation.js +484 -0
- package/dist/kinds.d.ts +64 -0
- package/dist/kinds.js +64 -0
- package/dist/lane.d.ts +41 -0
- package/dist/lane.js +63 -0
- package/dist/link.d.ts +52 -0
- package/dist/link.js +107 -0
- package/dist/network-hints.d.ts +16 -0
- package/dist/network-hints.js +65 -0
- package/dist/persistent-invitation.d.ts +35 -0
- package/dist/persistent-invitation.js +118 -0
- package/dist/room.d.ts +53 -0
- package/dist/room.js +153 -0
- package/dist/scoped.d.ts +67 -0
- package/dist/scoped.js +83 -0
- package/dist/sub-cert.d.ts +117 -0
- package/dist/sub-cert.js +192 -0
- package/dist/transport.d.ts +24 -0
- package/dist/transport.js +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +46 -0
- package/llms.txt +106 -0
- package/package.json +77 -6
- package/vectors/circle-vectors.json +2174 -0
- package/vectors/kithmoot-vectors.json +1550 -0
package/dist/epoch.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { hmac } from '@noble/hashes/hmac';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
4
|
+
import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
|
|
5
|
+
import { schnorr } from '@noble/curves/secp256k1.js';
|
|
6
|
+
import { base64urlnopad } from '@scure/base';
|
|
7
|
+
import { nip44 } from 'nostr-tools';
|
|
8
|
+
import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
|
9
|
+
import { hexEquals, normaliseHex } from './hex.js';
|
|
10
|
+
import { KINDS } from './kinds.js';
|
|
11
|
+
import { deriveRoom } from './room.js';
|
|
12
|
+
import { verifyDeviceCredential } from './credential.js';
|
|
13
|
+
import { evaluateAccess } from './access.js';
|
|
14
|
+
import { verifyEventUncached } from './verify.js';
|
|
15
|
+
/**
|
|
16
|
+
* Room epochs: how a member is removed.
|
|
17
|
+
*
|
|
18
|
+
* A room's traffic key is derived from its secret, and everybody who was
|
|
19
|
+
* ever admitted holds it. Link rotation retires the rendezvous and nothing
|
|
20
|
+
* else: a departed collaborator, or a leaked agent key, reads the room for
|
|
21
|
+
* as long as the room lives. The only thing that ends that is a new key
|
|
22
|
+
* that the departed party is not given, and that is an epoch.
|
|
23
|
+
*
|
|
24
|
+
* Epoch 0 is the room as it has always been: the id and key `deriveRoom`
|
|
25
|
+
* produces from the secret in the link, byte for byte. Every later epoch is
|
|
26
|
+
* a fresh 32-byte secret from which its own id and key are derived, under
|
|
27
|
+
* info strings that name the epoch number. The roster, the chat and its
|
|
28
|
+
* channels, the descriptor and the media keys all move to the new epoch's
|
|
29
|
+
* id and key together; the *room id* - what a credential binds to, what a
|
|
30
|
+
* signal names, what a forwarder is given - does not move, because it is
|
|
31
|
+
* the room's identity rather than its key.
|
|
32
|
+
*
|
|
33
|
+
* The move is announced by a rekey event: durable, addressed by the public
|
|
34
|
+
* room id, numbered in a tag, signed by the room's authority (the root
|
|
35
|
+
* inviter pinned in the link), and carrying the new secret sealed to each
|
|
36
|
+
* remaining device. Its body is encrypted to the epoch being left, so a
|
|
37
|
+
* member at that epoch reads it and moves, and a relay reads nothing but a
|
|
38
|
+
* number. A member that missed one asks the authority for the current
|
|
39
|
+
* epoch, proving who it is with its device credential and that it was
|
|
40
|
+
* admitted with a proof under the room key, and the authority answers
|
|
41
|
+
* everybody except the removed.
|
|
42
|
+
*
|
|
43
|
+
* The admission proof is what keeps the desk from being a back door. The
|
|
44
|
+
* room id and the authority's pubkey are public: both ride in the clear on
|
|
45
|
+
* every rekey event. A device credential is self-issued by whichever
|
|
46
|
+
* participant key signs it. So a request carrying only a credential proves
|
|
47
|
+
* nothing a stranger reading the relay could not produce, and in an open
|
|
48
|
+
* room the desk would hand that stranger the current epoch's secret, which
|
|
49
|
+
* opens the roster and the chat. The epoch-0 room key is the one thing the
|
|
50
|
+
* link hands out and nothing else does, so a MAC under a key derived from
|
|
51
|
+
* it is proof of having been admitted, and the desk demands one.
|
|
52
|
+
*
|
|
53
|
+
* Copies are sealed to the *device* pubkey, not the participant's, because
|
|
54
|
+
* the device key is the one every session holds in memory: a participant
|
|
55
|
+
* key may live in a browser extension or a bunker that signs and does
|
|
56
|
+
* nothing else, and the identity surface is deliberately a pubkey and one
|
|
57
|
+
* `signEvent` (see `identity.ts`). The participant is what a rekey names -
|
|
58
|
+
* the removed set, the refusal - and the device credential is how a device
|
|
59
|
+
* proves which participant it speaks for when it asks.
|
|
60
|
+
*/
|
|
61
|
+
export const EPOCH_ID_INFO = 'kithmoot/v1/epoch-id';
|
|
62
|
+
export const EPOCH_KEY_INFO = 'kithmoot/v1/epoch-key';
|
|
63
|
+
const EPOCH_MAX_AGE_SECONDS = 90;
|
|
64
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
65
|
+
const DEFAULT_RETRY_MS = 2_000;
|
|
66
|
+
/** A room that has been rekeyed more times than this has a problem that is
|
|
67
|
+
* not this module's to solve. Bounds the tag a client will parse. */
|
|
68
|
+
export const MAX_EPOCH = 1_000_000;
|
|
69
|
+
/**
|
|
70
|
+
* HKDF info string for the key an epoch request's admission proof is made
|
|
71
|
+
* under. Its own domain, like the media key's: a proof key that was the
|
|
72
|
+
* room key would put the room key itself into a MAC on every request, and
|
|
73
|
+
* a key that is only the epoch-request key opens nothing else if it leaks.
|
|
74
|
+
*/
|
|
75
|
+
export const EPOCH_REQUEST_KEY_INFO = 'kithmoot/v1/epoch-request-key';
|
|
76
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
77
|
+
function require32(bytes, what) {
|
|
78
|
+
if (bytes.length !== 32)
|
|
79
|
+
throw new Error(`${what} must be 32 bytes`);
|
|
80
|
+
}
|
|
81
|
+
function requireHex32(value, what) {
|
|
82
|
+
if (!HEX64.test(value))
|
|
83
|
+
throw new Error(`${what} must be 32-byte hex`);
|
|
84
|
+
return normaliseHex(value);
|
|
85
|
+
}
|
|
86
|
+
function requireEpochNumber(epoch) {
|
|
87
|
+
if (!Number.isSafeInteger(epoch) || epoch < 0 || epoch > MAX_EPOCH)
|
|
88
|
+
throw new Error('epoch must be a small non-negative integer');
|
|
89
|
+
return epoch;
|
|
90
|
+
}
|
|
91
|
+
function hex(bytes) {
|
|
92
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
93
|
+
}
|
|
94
|
+
/** A fresh secret for the next epoch. */
|
|
95
|
+
export function generateEpochSecret() {
|
|
96
|
+
return randomBytes(32);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The id and key a room's traffic rides under in `epoch`.
|
|
100
|
+
*
|
|
101
|
+
* Epoch 0 is exactly `deriveRoom(secret)`, so a room that has never been
|
|
102
|
+
* rekeyed is byte-identical on the wire to one from before epochs existed.
|
|
103
|
+
* Every later epoch expands its own secret under info strings that carry
|
|
104
|
+
* the epoch number, so the same secret at two numbers - which a correct
|
|
105
|
+
* authority never produces - would still derive two different keys.
|
|
106
|
+
*/
|
|
107
|
+
export function deriveEpoch(epoch) {
|
|
108
|
+
require32(epoch.secret, 'epoch secret');
|
|
109
|
+
const n = requireEpochNumber(epoch.epoch);
|
|
110
|
+
if (n === 0) {
|
|
111
|
+
const { roomId, roomKey } = deriveRoom(epoch.secret);
|
|
112
|
+
return { epoch: 0, id: roomId, key: roomKey };
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
epoch: n,
|
|
116
|
+
id: hex(hkdf(sha256, epoch.secret, undefined, `${EPOCH_ID_INFO}/${n}`, 32)),
|
|
117
|
+
key: hkdf(sha256, epoch.secret, undefined, `${EPOCH_KEY_INFO}/${n}`, 32),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Announce the next epoch. See the module comment for what this is. */
|
|
121
|
+
export function encodeRekeyEvent(opts) {
|
|
122
|
+
require32(opts.authoritySk, 'authority secret key');
|
|
123
|
+
require32(opts.next.secret, 'epoch secret');
|
|
124
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
125
|
+
const epoch = requireEpochNumber(opts.next.epoch);
|
|
126
|
+
if (epoch !== opts.current.epoch + 1)
|
|
127
|
+
throw new Error('a rekey moves the room forward by exactly one epoch');
|
|
128
|
+
const removed = [...new Set(opts.removed.map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
129
|
+
const keys = {};
|
|
130
|
+
const sealed = { v: 1, secret: base64urlnopad.encode(opts.next.secret) };
|
|
131
|
+
const plaintext = JSON.stringify(sealed);
|
|
132
|
+
for (const raw of opts.closed ? [] : opts.recipients) {
|
|
133
|
+
const device = requireHex32(raw, 'recipient device');
|
|
134
|
+
keys[device] = nip44.v2.encrypt(plaintext, nip44.v2.utils.getConversationKey(opts.authoritySk, device));
|
|
135
|
+
}
|
|
136
|
+
const body = {
|
|
137
|
+
v: 1,
|
|
138
|
+
epoch,
|
|
139
|
+
removed,
|
|
140
|
+
...(opts.by !== undefined ? { by: requireHex32(opts.by, 'admin') } : {}),
|
|
141
|
+
...(opts.closed ? { closed: true } : {}),
|
|
142
|
+
keys,
|
|
143
|
+
};
|
|
144
|
+
return finalizeEvent({
|
|
145
|
+
kind: KINDS.ROOM_REKEY,
|
|
146
|
+
created_at: opts.now,
|
|
147
|
+
tags: [
|
|
148
|
+
['d', roomId],
|
|
149
|
+
['epoch', String(epoch)],
|
|
150
|
+
],
|
|
151
|
+
content: nip44.v2.encrypt(JSON.stringify(body), opts.current.key),
|
|
152
|
+
}, opts.authoritySk);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The epoch a rekey announces, checking everything that does not need a
|
|
156
|
+
* key: kind, room, signer, signature and the epoch tag. What a client uses
|
|
157
|
+
* to learn that the room is ahead of it before it can read why.
|
|
158
|
+
*/
|
|
159
|
+
export function peekRekeyEvent(event, opts) {
|
|
160
|
+
try {
|
|
161
|
+
if (event.kind !== KINDS.ROOM_REKEY)
|
|
162
|
+
return null;
|
|
163
|
+
if (!hexEquals(event.pubkey, opts.authority))
|
|
164
|
+
return null;
|
|
165
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== normaliseHex(opts.roomId))
|
|
166
|
+
return null;
|
|
167
|
+
const tag = event.tags.find((t) => t[0] === 'epoch')?.[1];
|
|
168
|
+
if (tag === undefined || !/^[1-9][0-9]{0,6}$/.test(tag))
|
|
169
|
+
return null;
|
|
170
|
+
const epoch = Number(tag);
|
|
171
|
+
if (epoch > MAX_EPOCH)
|
|
172
|
+
return null;
|
|
173
|
+
if (!verifyEventUncached(event))
|
|
174
|
+
return null;
|
|
175
|
+
return epoch;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read a rekey. Null for anything that does not check out, and for a rekey
|
|
183
|
+
* this device cannot read from where it stands: one for an epoch it is
|
|
184
|
+
* already past (a replay), or one sealed to an epoch it is not at.
|
|
185
|
+
*/
|
|
186
|
+
export function decodeRekeyEvent(event, opts) {
|
|
187
|
+
try {
|
|
188
|
+
const epoch = peekRekeyEvent(event, opts);
|
|
189
|
+
if (epoch === null || epoch !== opts.current.epoch + 1)
|
|
190
|
+
return null;
|
|
191
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, opts.current.key));
|
|
192
|
+
if (body.v !== 1 || body.epoch !== epoch)
|
|
193
|
+
return null;
|
|
194
|
+
if (!Array.isArray(body.removed) || !body.removed.every((p) => typeof p === 'string' && HEX64.test(p)))
|
|
195
|
+
return null;
|
|
196
|
+
if (typeof body.keys !== 'object' || body.keys === null)
|
|
197
|
+
return null;
|
|
198
|
+
const notice = {
|
|
199
|
+
epoch,
|
|
200
|
+
removed: [...new Set(body.removed.map(normaliseHex))].sort(),
|
|
201
|
+
closed: body.closed === true,
|
|
202
|
+
at: event.created_at,
|
|
203
|
+
};
|
|
204
|
+
if (typeof body.by === 'string' && HEX64.test(body.by))
|
|
205
|
+
notice.by = normaliseHex(body.by);
|
|
206
|
+
const device = getPublicKey(opts.deviceSk);
|
|
207
|
+
const mine = Object.entries(body.keys).find(([to]) => hexEquals(to, device))?.[1];
|
|
208
|
+
if (typeof mine === 'string') {
|
|
209
|
+
const sealed = JSON.parse(nip44.v2.decrypt(mine, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
|
|
210
|
+
if (sealed.v === 1 && typeof sealed.secret === 'string') {
|
|
211
|
+
const secret = base64urlnopad.decode(sealed.secret);
|
|
212
|
+
if (secret.length === 32)
|
|
213
|
+
notice.secret = secret;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return notice;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Asking the authority for the current epoch
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* The key an epoch request's admission proof is computed under: the epoch-0
|
|
227
|
+
* room key, expanded under its own info string. Taken from the room key
|
|
228
|
+
* rather than the room secret for the same reason the media key is: a
|
|
229
|
+
* joined client holds the room key and need not keep the capability.
|
|
230
|
+
*/
|
|
231
|
+
export function deriveEpochRequestKey(roomKey) {
|
|
232
|
+
require32(roomKey, 'room key');
|
|
233
|
+
return hkdf(sha256, roomKey, undefined, EPOCH_REQUEST_KEY_INFO, 32);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Prove, inside an epoch request, that the asking device was admitted to
|
|
237
|
+
* the room.
|
|
238
|
+
*
|
|
239
|
+
* `HMAC-SHA256(deriveEpochRequestKey(roomKey), "kithmoot/v1/epoch-request:" +
|
|
240
|
+
* roomId + ":" + authority + ":" + device + ":" + createdAt)`, as hex, with
|
|
241
|
+
* the three identifiers in lower-case hex. Bound to the device and the
|
|
242
|
+
* moment so that a proof lifted from one request is no use in another,
|
|
243
|
+
* though the body it rides in is already sealed to the authority. Every
|
|
244
|
+
* admitted device holds the epoch-0 room key, including one that has fallen
|
|
245
|
+
* several epochs behind, which is exactly who asks; a removed member holds
|
|
246
|
+
* it too, and is refused by name, not by this. What this refuses is the
|
|
247
|
+
* stranger: somebody with the room id and the authority's pubkey off a
|
|
248
|
+
* public rekey event, and no link.
|
|
249
|
+
*/
|
|
250
|
+
export function epochRequestAdmission(opts) {
|
|
251
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
252
|
+
const authority = requireHex32(opts.authority, 'authority pubkey');
|
|
253
|
+
const device = requireHex32(opts.device, 'device pubkey');
|
|
254
|
+
if (!Number.isSafeInteger(opts.createdAt) || opts.createdAt < 0)
|
|
255
|
+
throw new Error('created_at must be a non-negative integer');
|
|
256
|
+
const message = new TextEncoder().encode(`kithmoot/v1/epoch-request:${roomId}:${authority}:${device}:${opts.createdAt}`);
|
|
257
|
+
return bytesToHex(hmac(sha256, deriveEpochRequestKey(opts.roomKey), message));
|
|
258
|
+
}
|
|
259
|
+
/** Equality that does not leak where two proofs first differ. */
|
|
260
|
+
function admissionEquals(presented, expected) {
|
|
261
|
+
const a = normaliseHex(presented);
|
|
262
|
+
if (a.length !== expected.length)
|
|
263
|
+
return false;
|
|
264
|
+
let diff = 0;
|
|
265
|
+
for (let i = 0; i < a.length; i += 1)
|
|
266
|
+
diff |= a.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
267
|
+
return diff === 0;
|
|
268
|
+
}
|
|
269
|
+
/** Ask the authority which epoch the room is at, and for its secret. */
|
|
270
|
+
export function encodeEpochRequest(opts) {
|
|
271
|
+
require32(opts.deviceSk, 'device secret key');
|
|
272
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
273
|
+
const authority = requireHex32(opts.authority, 'authority pubkey');
|
|
274
|
+
const admission = epochRequestAdmission({
|
|
275
|
+
roomKey: opts.roomKey,
|
|
276
|
+
roomId,
|
|
277
|
+
authority,
|
|
278
|
+
device: getPublicKey(opts.deviceSk),
|
|
279
|
+
createdAt: opts.now,
|
|
280
|
+
});
|
|
281
|
+
const body = { v: 1, credential: opts.credential, ...(opts.proof ? { proof: opts.proof } : {}), admission };
|
|
282
|
+
return finalizeEvent({
|
|
283
|
+
kind: KINDS.EPOCH_REQUEST,
|
|
284
|
+
created_at: opts.now,
|
|
285
|
+
tags: [
|
|
286
|
+
['d', roomId],
|
|
287
|
+
['p', authority],
|
|
288
|
+
],
|
|
289
|
+
content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.deviceSk, authority)),
|
|
290
|
+
}, opts.deviceSk);
|
|
291
|
+
}
|
|
292
|
+
/** Null for anything malformed, stale, misaddressed, from a device that
|
|
293
|
+
* cannot prove which participant it speaks for in this room, or from one
|
|
294
|
+
* that cannot prove it was admitted to the room at all. */
|
|
295
|
+
export function decodeEpochRequest(event, opts) {
|
|
296
|
+
try {
|
|
297
|
+
if (event.kind !== KINDS.EPOCH_REQUEST)
|
|
298
|
+
return null;
|
|
299
|
+
if (!verifyEventUncached(event))
|
|
300
|
+
return null;
|
|
301
|
+
if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? EPOCH_MAX_AGE_SECONDS))
|
|
302
|
+
return null;
|
|
303
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== normaliseHex(opts.roomId))
|
|
304
|
+
return null;
|
|
305
|
+
const authority = getPublicKey(opts.authoritySk);
|
|
306
|
+
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
307
|
+
if (addressed === undefined || !hexEquals(addressed, authority))
|
|
308
|
+
return null;
|
|
309
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.authoritySk, event.pubkey)));
|
|
310
|
+
if (body.v !== 1 || typeof body.credential !== 'object' || body.credential === null)
|
|
311
|
+
return null;
|
|
312
|
+
const verdict = verifyDeviceCredential(body.credential, { roomId: opts.roomId, now: opts.now });
|
|
313
|
+
if (!verdict.ok)
|
|
314
|
+
return null;
|
|
315
|
+
if (!hexEquals(verdict.device, event.pubkey))
|
|
316
|
+
return null;
|
|
317
|
+
// Admission before policy: a stranger with no room key is turned away
|
|
318
|
+
// before anything about the room's tiers is consulted. A keeper may
|
|
319
|
+
// bridge its named admins from the last desktop release: their signed
|
|
320
|
+
// participant identity is configured out of band, while an arbitrary
|
|
321
|
+
// holder of the public room id still gets no answer.
|
|
322
|
+
if (typeof body.admission === 'string') {
|
|
323
|
+
const expected = epochRequestAdmission({
|
|
324
|
+
roomKey: opts.roomKey,
|
|
325
|
+
roomId: opts.roomId,
|
|
326
|
+
authority,
|
|
327
|
+
device: verdict.device,
|
|
328
|
+
createdAt: event.created_at,
|
|
329
|
+
});
|
|
330
|
+
if (!admissionEquals(body.admission, expected))
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
else if (!opts.legacyParticipants?.has(verdict.participant))
|
|
334
|
+
return null;
|
|
335
|
+
if (opts.policy) {
|
|
336
|
+
const proof = body.proof && typeof body.proof === 'object' ? body.proof : undefined;
|
|
337
|
+
if (!evaluateAccess(opts.policy, verdict.participant, proof, opts.now, opts.roomId).admitted)
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
return { device: verdict.device, participant: verdict.participant, request: event.id };
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Answer one request: the current epoch sealed to the asking device, or a
|
|
347
|
+
* refusal it alone can read. */
|
|
348
|
+
export function encodeEpochGrant(opts) {
|
|
349
|
+
require32(opts.authoritySk, 'authority secret key');
|
|
350
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
351
|
+
const device = requireHex32(opts.device, 'device pubkey');
|
|
352
|
+
const request = requireHex32(opts.request, 'request id');
|
|
353
|
+
const body = { v: 1, request };
|
|
354
|
+
if (opts.refused) {
|
|
355
|
+
body.refused = opts.refused;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
if (!opts.epoch)
|
|
359
|
+
throw new Error('a grant carries an epoch or a refusal');
|
|
360
|
+
body.epoch = requireEpochNumber(opts.epoch.epoch);
|
|
361
|
+
if (body.epoch > 0) {
|
|
362
|
+
require32(opts.epoch.secret, 'epoch secret');
|
|
363
|
+
body.secret = base64urlnopad.encode(opts.epoch.secret);
|
|
364
|
+
}
|
|
365
|
+
body.removed = [...new Set((opts.removed ?? []).map((p) => requireHex32(p, 'removed participant')))].sort();
|
|
366
|
+
}
|
|
367
|
+
return finalizeEvent({
|
|
368
|
+
kind: KINDS.EPOCH_GRANT,
|
|
369
|
+
created_at: opts.now,
|
|
370
|
+
tags: [
|
|
371
|
+
['d', roomId],
|
|
372
|
+
['p', device],
|
|
373
|
+
],
|
|
374
|
+
content: nip44.v2.encrypt(JSON.stringify(body), nip44.v2.utils.getConversationKey(opts.authoritySk, device)),
|
|
375
|
+
}, opts.authoritySk);
|
|
376
|
+
}
|
|
377
|
+
/** Read the authority's answer to this device's own request. Null for
|
|
378
|
+
* anything else. Note a grant for epoch 0 carries no secret: the room has
|
|
379
|
+
* never been rekeyed, and the requester already holds what it needs. */
|
|
380
|
+
export function decodeEpochGrant(event, opts) {
|
|
381
|
+
try {
|
|
382
|
+
if (event.kind !== KINDS.EPOCH_GRANT)
|
|
383
|
+
return null;
|
|
384
|
+
if (!hexEquals(event.pubkey, opts.authority))
|
|
385
|
+
return null;
|
|
386
|
+
if (!verifyEventUncached(event))
|
|
387
|
+
return null;
|
|
388
|
+
if (Math.abs(opts.now - event.created_at) > (opts.maxAgeSeconds ?? EPOCH_MAX_AGE_SECONDS))
|
|
389
|
+
return null;
|
|
390
|
+
if (event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() !== normaliseHex(opts.roomId))
|
|
391
|
+
return null;
|
|
392
|
+
const device = getPublicKey(opts.deviceSk);
|
|
393
|
+
const addressed = event.tags.find((t) => t[0] === 'p')?.[1];
|
|
394
|
+
if (addressed === undefined || !hexEquals(addressed, device))
|
|
395
|
+
return null;
|
|
396
|
+
const body = JSON.parse(nip44.v2.decrypt(event.content, nip44.v2.utils.getConversationKey(opts.deviceSk, event.pubkey)));
|
|
397
|
+
if (body.v !== 1 || typeof body.request !== 'string' || !hexEquals(body.request, opts.request))
|
|
398
|
+
return null;
|
|
399
|
+
if (body.refused === 'removed' || body.refused === 'closed')
|
|
400
|
+
return { refused: body.refused };
|
|
401
|
+
if (!Number.isSafeInteger(body.epoch) || body.epoch < 0 || body.epoch > MAX_EPOCH)
|
|
402
|
+
return null;
|
|
403
|
+
const removed = Array.isArray(body.removed)
|
|
404
|
+
? [...new Set(body.removed.filter((p) => typeof p === 'string' && HEX64.test(p)).map(normaliseHex))].sort()
|
|
405
|
+
: [];
|
|
406
|
+
if (body.epoch === 0)
|
|
407
|
+
return { epoch: { epoch: 0 }, removed };
|
|
408
|
+
if (typeof body.secret !== 'string')
|
|
409
|
+
return null;
|
|
410
|
+
const secret = base64urlnopad.decode(body.secret);
|
|
411
|
+
if (secret.length !== 32)
|
|
412
|
+
return null;
|
|
413
|
+
return { epoch: { epoch: body.epoch, secret }, removed };
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Answer epoch requests for as long as the handle is open. What a keeper
|
|
421
|
+
* runs beside its invitation desk: a member arriving, or returning, after
|
|
422
|
+
* a rekey is handed the current epoch on proof of who it is and that it was
|
|
423
|
+
* admitted, and a removed participant is told no. This is where removal is
|
|
424
|
+
* enforced against the link: the link still admits its holder to the room's
|
|
425
|
+
* *secret*, which opens epoch 0 and nothing after it. A request with no
|
|
426
|
+
* admission proof, or one made under some other key, is not answered at
|
|
427
|
+
* all: a stranger learns nothing, not even that a desk is here.
|
|
428
|
+
*/
|
|
429
|
+
export function hostRoomEpoch(opts) {
|
|
430
|
+
require32(opts.authoritySk, 'authority secret key');
|
|
431
|
+
require32(opts.roomKey, 'room key');
|
|
432
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
433
|
+
const authority = getPublicKey(opts.authoritySk);
|
|
434
|
+
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
435
|
+
const answered = new Set();
|
|
436
|
+
let closed = false;
|
|
437
|
+
const unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_REQUEST], '#d': [roomId], '#p': [authority] }], (event) => {
|
|
438
|
+
if (closed)
|
|
439
|
+
return;
|
|
440
|
+
const request = decodeEpochRequest(event, {
|
|
441
|
+
roomId,
|
|
442
|
+
authoritySk: opts.authoritySk,
|
|
443
|
+
roomKey: opts.roomKey,
|
|
444
|
+
now: now(),
|
|
445
|
+
policy: opts.policy,
|
|
446
|
+
legacyParticipants: opts.legacyParticipants,
|
|
447
|
+
});
|
|
448
|
+
if (!request || answered.has(request.request))
|
|
449
|
+
return;
|
|
450
|
+
answered.add(request.request);
|
|
451
|
+
if (answered.size > 256)
|
|
452
|
+
answered.delete(answered.values().next().value);
|
|
453
|
+
let refused;
|
|
454
|
+
if (opts.closed?.())
|
|
455
|
+
refused = 'closed';
|
|
456
|
+
else if (opts.removed().has(request.participant))
|
|
457
|
+
refused = 'removed';
|
|
458
|
+
let grant;
|
|
459
|
+
try {
|
|
460
|
+
grant = refused
|
|
461
|
+
? encodeEpochGrant({ roomId, authoritySk: opts.authoritySk, device: request.device, request: request.request, now: now(), refused })
|
|
462
|
+
: encodeEpochGrant({
|
|
463
|
+
roomId,
|
|
464
|
+
authoritySk: opts.authoritySk,
|
|
465
|
+
device: request.device,
|
|
466
|
+
request: request.request,
|
|
467
|
+
now: now(),
|
|
468
|
+
epoch: opts.current(),
|
|
469
|
+
removed: [...opts.removed()],
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
opts.transport.publish(grant).catch(() => { });
|
|
476
|
+
if (refused)
|
|
477
|
+
opts.onRefused?.(request, refused);
|
|
478
|
+
else
|
|
479
|
+
opts.onGranted?.(request);
|
|
480
|
+
});
|
|
481
|
+
return {
|
|
482
|
+
close() {
|
|
483
|
+
if (closed)
|
|
484
|
+
return;
|
|
485
|
+
closed = true;
|
|
486
|
+
unsub();
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Thrown when the authority answered, and the answer was no. */
|
|
491
|
+
export class EpochRefusedError extends Error {
|
|
492
|
+
refused;
|
|
493
|
+
constructor(refused) {
|
|
494
|
+
super(refused === 'removed' ? 'you were removed from this room' : 'this room has been closed');
|
|
495
|
+
this.name = 'EpochRefusedError';
|
|
496
|
+
this.refused = refused;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/** Ask the authority where the room is, and wait for the answer. Rejects
|
|
500
|
+
* with `EpochRefusedError` on a refusal, and with a plain error when
|
|
501
|
+
* nobody answers inside the timeout. */
|
|
502
|
+
export function requestRoomEpoch(opts) {
|
|
503
|
+
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
|
|
504
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
505
|
+
const device = getPublicKey(opts.deviceSk);
|
|
506
|
+
const request = encodeEpochRequest({
|
|
507
|
+
roomId,
|
|
508
|
+
authority: opts.authority,
|
|
509
|
+
deviceSk: opts.deviceSk,
|
|
510
|
+
roomKey: opts.roomKey,
|
|
511
|
+
credential: opts.credential,
|
|
512
|
+
proof: opts.proof,
|
|
513
|
+
now: now(),
|
|
514
|
+
});
|
|
515
|
+
return new Promise((resolve, reject) => {
|
|
516
|
+
let settled = false;
|
|
517
|
+
let retry;
|
|
518
|
+
let expiry;
|
|
519
|
+
let unsub = () => { };
|
|
520
|
+
const finish = (settle) => {
|
|
521
|
+
if (settled)
|
|
522
|
+
return;
|
|
523
|
+
settled = true;
|
|
524
|
+
if (retry !== undefined)
|
|
525
|
+
clearInterval(retry);
|
|
526
|
+
if (expiry !== undefined)
|
|
527
|
+
clearTimeout(expiry);
|
|
528
|
+
unsub();
|
|
529
|
+
settle();
|
|
530
|
+
};
|
|
531
|
+
unsub = opts.transport.subscribe([{ kinds: [KINDS.EPOCH_GRANT], '#d': [roomId], '#p': [device] }], (event) => {
|
|
532
|
+
const grant = decodeEpochGrant(event, { roomId, authority: opts.authority, deviceSk: opts.deviceSk, request: request.id, now: now() });
|
|
533
|
+
if (!grant)
|
|
534
|
+
return;
|
|
535
|
+
if (grant.refused)
|
|
536
|
+
finish(() => reject(new EpochRefusedError(grant.refused)));
|
|
537
|
+
else
|
|
538
|
+
finish(() => resolve(grant));
|
|
539
|
+
});
|
|
540
|
+
if (settled)
|
|
541
|
+
unsub();
|
|
542
|
+
const ask = () => {
|
|
543
|
+
if (!settled)
|
|
544
|
+
opts.transport.publish(request).catch(() => { });
|
|
545
|
+
};
|
|
546
|
+
expiry = setTimeout(() => finish(() => reject(new Error('the room has moved to a newer epoch and its keeper is not answering'))), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
547
|
+
expiry.unref?.();
|
|
548
|
+
retry = setInterval(ask, opts.retryMs ?? DEFAULT_RETRY_MS);
|
|
549
|
+
retry.unref?.();
|
|
550
|
+
ask();
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// The admin list
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
function adminsMessage(roomId, epoch, admins) {
|
|
557
|
+
return sha256(new TextEncoder().encode(`kithmoot/v1/admins:${roomId}:${epoch}:${admins.join(',')}`));
|
|
558
|
+
}
|
|
559
|
+
/** Canonical form of an admin list: lower-case, deduplicated, sorted. */
|
|
560
|
+
export function canonicalAdmins(admins) {
|
|
561
|
+
return [...new Set(admins.map((a) => requireHex32(a, 'admin pubkey')))].sort();
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Channel names a room may be told about.
|
|
565
|
+
*
|
|
566
|
+
* Deliberately stricter than `deriveChannel`, which accepts any string of
|
|
567
|
+
* 1..64 characters because it is only doing HKDF and has no opinion. A name
|
|
568
|
+
* in the REGISTRY is different: it is announced to every client, rendered as
|
|
569
|
+
* a label, and handed to an agent as its thread id. So it is held to a shape
|
|
570
|
+
* that survives all three without escaping, normalising or truncating -
|
|
571
|
+
* lower case, digits and hyphens, opening on an alphanumeric.
|
|
572
|
+
*
|
|
573
|
+
* The consequence worth stating: a name is stored and compared exactly as
|
|
574
|
+
* written here, so a client, an agent adapter and a relay all mean the same
|
|
575
|
+
* string by it, and nothing anywhere has to guess at a canonical form.
|
|
576
|
+
*/
|
|
577
|
+
export const CHANNEL_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
578
|
+
/**
|
|
579
|
+
* Names the registry will not carry, because the room already means
|
|
580
|
+
* something by them: `agents` is the backchannel, `transcript` is what was
|
|
581
|
+
* said, `minutes` is what the scribe wrote. Deriving them still works - they
|
|
582
|
+
* are ordinary channels to the crypto - but announcing one as a place to
|
|
583
|
+
* chat would put two different things under one name.
|
|
584
|
+
*/
|
|
585
|
+
export const RESERVED_CHANNELS = ['agents', 'minutes', 'transcript'];
|
|
586
|
+
/** Canonical form of a channel list: validated, deduplicated, sorted. */
|
|
587
|
+
export function canonicalChannels(channels) {
|
|
588
|
+
for (const name of channels) {
|
|
589
|
+
if (typeof name !== 'string' || !CHANNEL_NAME.test(name))
|
|
590
|
+
throw new Error(`bad channel name: ${String(name)}`);
|
|
591
|
+
if (RESERVED_CHANNELS.includes(name))
|
|
592
|
+
throw new Error(`reserved channel name: ${name}`);
|
|
593
|
+
}
|
|
594
|
+
return [...new Set(channels)].sort();
|
|
595
|
+
}
|
|
596
|
+
function channelsMessage(roomId, epoch, channels) {
|
|
597
|
+
return sha256(new TextEncoder().encode(`kithmoot/v1/channels:${roomId}:${epoch}:${channels.join(',')}`));
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Sign the list of channels a room has.
|
|
601
|
+
*
|
|
602
|
+
* Signed by the authority and bound to the epoch for exactly the reasons the
|
|
603
|
+
* admin list is: every member holds the room key, so an unsigned list is the
|
|
604
|
+
* first thing a member would forge, and a list valid at one epoch must not
|
|
605
|
+
* be replayable into the next. This is what settles the rule that nothing a
|
|
606
|
+
* chat message says may change the structure of a room - a channel exists
|
|
607
|
+
* because the authority said so, or it does not exist.
|
|
608
|
+
*/
|
|
609
|
+
export function signChannels(opts) {
|
|
610
|
+
require32(opts.authoritySk, 'authority secret key');
|
|
611
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
612
|
+
const channels = canonicalChannels(opts.channels);
|
|
613
|
+
return bytesToHex(schnorr.sign(channelsMessage(roomId, requireEpochNumber(opts.epoch), channels), opts.authoritySk));
|
|
614
|
+
}
|
|
615
|
+
/** Never throws: this runs on anything a relay hands over. */
|
|
616
|
+
export function verifyChannels(opts) {
|
|
617
|
+
try {
|
|
618
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
619
|
+
const channels = canonicalChannels(opts.channels);
|
|
620
|
+
const sig = hexToBytes(opts.sig);
|
|
621
|
+
if (sig.length !== 64)
|
|
622
|
+
return false;
|
|
623
|
+
return schnorr.verify(sig, channelsMessage(roomId, requireEpochNumber(opts.epoch), channels), hexToBytes(requireHex32(opts.authority, 'authority')));
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Sign the list of participants who may act on the room, so a client can
|
|
631
|
+
* tell a keeper's announcement from anybody else's claim: the announcement
|
|
632
|
+
* rides the control channel, which every member can write to, and only the
|
|
633
|
+
* authority key pinned in the link can produce this.
|
|
634
|
+
*/
|
|
635
|
+
export function signAdmins(opts) {
|
|
636
|
+
require32(opts.authoritySk, 'authority secret key');
|
|
637
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
638
|
+
const admins = canonicalAdmins(opts.admins);
|
|
639
|
+
return bytesToHex(schnorr.sign(adminsMessage(roomId, requireEpochNumber(opts.epoch), admins), opts.authoritySk));
|
|
640
|
+
}
|
|
641
|
+
export function verifyAdmins(opts) {
|
|
642
|
+
try {
|
|
643
|
+
const roomId = requireHex32(opts.roomId, 'room id');
|
|
644
|
+
const admins = canonicalAdmins(opts.admins);
|
|
645
|
+
const sig = hexToBytes(opts.sig);
|
|
646
|
+
if (sig.length !== 64)
|
|
647
|
+
return false;
|
|
648
|
+
return schnorr.verify(sig, adminsMessage(roomId, requireEpochNumber(opts.epoch), admins), hexToBytes(requireHex32(opts.authority, 'authority')));
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/** Every wire-format literal this module owns (each one a kithmoot protocol string), frozen for
|
|
655
|
+
* `src/labels.test.ts`, which checks each module against its own exported
|
|
656
|
+
* list rather than scanning file text for matching comments. Pure data -
|
|
657
|
+
* adding this export changes no runtime behaviour. */
|
|
658
|
+
export const EPOCH_LABELS = [
|
|
659
|
+
"kithmoot/v1/admins:",
|
|
660
|
+
"kithmoot/v1/channels:",
|
|
661
|
+
"kithmoot/v1/epoch-id",
|
|
662
|
+
"kithmoot/v1/epoch-key",
|
|
663
|
+
"kithmoot/v1/epoch-request-key",
|
|
664
|
+
"kithmoot/v1/epoch-request:",
|
|
665
|
+
];
|
package/dist/hex.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Case-insensitive equality for hex identifiers.
|
|
3
|
+
*
|
|
4
|
+
* Nostr pubkeys, room ids, and every other hex field this protocol compares
|
|
5
|
+
* are canonically produced in lower case, but nothing on the wire enforces
|
|
6
|
+
* that: an allow-list entry typed or pasted by a person, in particular, may
|
|
7
|
+
* carry upper-case hex naming exactly the same key. Every place this
|
|
8
|
+
* protocol decides whether two hex identifiers name the same thing must go
|
|
9
|
+
* through this function rather than `===`/`!==`, so a case difference is
|
|
10
|
+
* never mistaken for a different identity - see `vectors/README.md`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hexEquals(a: string, b: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Canonicalise a hex identifier to lower case.
|
|
15
|
+
*
|
|
16
|
+
* `hexEquals` makes an equality check safe regardless of case, but nothing
|
|
17
|
+
* protects a *lexicographic* comparison the same way: `Peer`'s glare
|
|
18
|
+
* tiebreak and `resolveSingularRoles`' device tiebreak both order hex
|
|
19
|
+
* strings with `<`, and two implementations that disagree on which of two
|
|
20
|
+
* differently-cased spellings of the same identifier sorts first can reach
|
|
21
|
+
* opposite answers from identical input - the exact deadlock perfect
|
|
22
|
+
* negotiation exists to prevent. Call this once, at the point a hex
|
|
23
|
+
* identifier enters the system - a decoded event, a parsed credential or
|
|
24
|
+
* proof, a pubkey read from storage or a URL - rather than at each
|
|
25
|
+
* comparison site, so every later equality or ordering check on it is
|
|
26
|
+
* correct by construction. See `vectors/README.md`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function normaliseHex(hex: string): string;
|