@dispatch-foo/protocol 0.0.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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +3 -0
- package/dist/conformance-adapter.d.ts +1 -0
- package/dist/conformance-adapter.js +11 -0
- package/dist/conformance-adapter.js.map +1 -0
- package/dist/conformance.d.ts +243 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +5 -0
- package/dist/constants-CRXF4f7g.js +89 -0
- package/dist/constants-CRXF4f7g.js.map +1 -0
- package/dist/constants-CUM7Sm5u.d.ts +36 -0
- package/dist/constants-CUM7Sm5u.d.ts.map +1 -0
- package/dist/federation/index.d.ts +360 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +628 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/serve-CVPZbCpT.js +672 -0
- package/dist/serve-CVPZbCpT.js.map +1 -0
- package/dist/sqliteStore-BtFP1f8O.d.ts +376 -0
- package/dist/sqliteStore-BtFP1f8O.d.ts.map +1 -0
- package/dist/untrusted-DJNiucEX-tUd9XZzz.d.ts +388 -0
- package/dist/untrusted-DJNiucEX-tUd9XZzz.d.ts.map +1 -0
- package/dist/version-Ds8ZFZLh.js +2490 -0
- package/dist/version-Ds8ZFZLh.js.map +1 -0
- package/package.json +54 -1
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, randomBytes, sign, verify } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/federation/hlc.ts
|
|
4
|
+
const MAX_HLC_COUNTER = 999999999;
|
|
5
|
+
const HLC = /^(\d{13})\.(\d{4,9})\.(.+)$/;
|
|
6
|
+
function parseOpHlc(hlc) {
|
|
7
|
+
const m = HLC.exec(hlc);
|
|
8
|
+
if (m === null) return null;
|
|
9
|
+
return {
|
|
10
|
+
ms: Number(m[1]),
|
|
11
|
+
counter: Number(m[2]),
|
|
12
|
+
replica: m[3] ?? ""
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function compareHlc(a, b) {
|
|
16
|
+
if (a.ms !== b.ms) return a.ms < b.ms ? -1 : 1;
|
|
17
|
+
if (a.counter !== b.counter) return a.counter < b.counter ? -1 : 1;
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
const MAX_CLOCK_LEAD_MS = 300 * 1e3;
|
|
21
|
+
/** Whether a reading's wall time is past the bound for the wall time `nowMs`. */
|
|
22
|
+
function aheadOfClock(hlc, nowMs) {
|
|
23
|
+
const clock = readClamped(hlc);
|
|
24
|
+
return clock !== null && clock.ms > nowMs + MAX_CLOCK_LEAD_MS;
|
|
25
|
+
}
|
|
26
|
+
function hlcWallMs(hlc) {
|
|
27
|
+
return parseOpHlc(hlc)?.ms ?? null;
|
|
28
|
+
}
|
|
29
|
+
function readClamped(hlc) {
|
|
30
|
+
const m = /^(\d{13})\.(\d{4,})\./.exec(hlc);
|
|
31
|
+
if (m === null) return null;
|
|
32
|
+
return {
|
|
33
|
+
ms: Number(m[1]),
|
|
34
|
+
counter: Math.min(Number(m[2]), MAX_HLC_COUNTER)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
var OpClock = class {
|
|
38
|
+
ms;
|
|
39
|
+
counter;
|
|
40
|
+
constructor(replica, last = null, now = Date.now) {
|
|
41
|
+
this.replica = replica;
|
|
42
|
+
this.now = now;
|
|
43
|
+
const start = last === null ? null : readClamped(last);
|
|
44
|
+
this.ms = start?.ms ?? 0;
|
|
45
|
+
this.counter = start?.counter ?? 0;
|
|
46
|
+
}
|
|
47
|
+
/** A reading for a change made now, after every reading seen so far. */
|
|
48
|
+
tick() {
|
|
49
|
+
const wall = this.now();
|
|
50
|
+
if (wall > this.ms) {
|
|
51
|
+
this.ms = wall;
|
|
52
|
+
this.counter = 0;
|
|
53
|
+
} else if (this.counter < MAX_HLC_COUNTER) this.counter += 1;
|
|
54
|
+
else {
|
|
55
|
+
this.ms += 1;
|
|
56
|
+
this.counter = 0;
|
|
57
|
+
}
|
|
58
|
+
return this.last;
|
|
59
|
+
}
|
|
60
|
+
/** Moves past a reading from elsewhere, so the next tick sorts after it,
|
|
61
|
+
* unless it is past the bound (FW-R21); false when it refused. */
|
|
62
|
+
observe(remote) {
|
|
63
|
+
const clock = readClamped(remote);
|
|
64
|
+
if (clock === null) return true;
|
|
65
|
+
if (clock.ms > this.now() + MAX_CLOCK_LEAD_MS) return false;
|
|
66
|
+
if (clock.ms > this.ms || clock.ms === this.ms && clock.counter > this.counter) {
|
|
67
|
+
this.ms = clock.ms;
|
|
68
|
+
this.counter = clock.counter;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
/** The latest reading, for persisting across restarts. */
|
|
73
|
+
get last() {
|
|
74
|
+
return `${String(this.ms).padStart(13, "0")}.${String(this.counter).padStart(4, "0")}.${this.replica}`;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/federation/jcs.ts
|
|
80
|
+
var CanonicalizeError = class extends TypeError {
|
|
81
|
+
name = "CanonicalizeError";
|
|
82
|
+
};
|
|
83
|
+
const MAX_JSON_DEPTH = 256;
|
|
84
|
+
const LONE_SURROGATE = /\p{Cs}/u;
|
|
85
|
+
function iJsonString(s) {
|
|
86
|
+
if (LONE_SURROGATE.test(s)) throw new CanonicalizeError("JCS refuses a lone surrogate");
|
|
87
|
+
return JSON.stringify(s);
|
|
88
|
+
}
|
|
89
|
+
function write(value, str, depth) {
|
|
90
|
+
if (value === null) return "null";
|
|
91
|
+
switch (typeof value) {
|
|
92
|
+
case "boolean": return value ? "true" : "false";
|
|
93
|
+
case "number":
|
|
94
|
+
if (!Number.isFinite(value)) throw new CanonicalizeError("JCS refuses non-finite numbers");
|
|
95
|
+
return JSON.stringify(value);
|
|
96
|
+
case "string": return str(value);
|
|
97
|
+
case "object": {
|
|
98
|
+
if (depth >= MAX_JSON_DEPTH) throw new CanonicalizeError(`JCS refuses nesting past ${MAX_JSON_DEPTH}`);
|
|
99
|
+
const inner = depth + 1;
|
|
100
|
+
if (Array.isArray(value)) return `[${value.map((v) => write(v === void 0 ? null : v, str, inner)).join(",")}]`;
|
|
101
|
+
const obj = value;
|
|
102
|
+
return `{${Object.keys(obj).filter((k) => obj[k] !== void 0).sort().map((k) => `${str(k)}:${write(obj[k], str, inner)}`).join(",")}}`;
|
|
103
|
+
}
|
|
104
|
+
default: throw new CanonicalizeError(`JCS cannot serialize a ${typeof value}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function canonicalize(value) {
|
|
108
|
+
return write(value, iJsonString, 0);
|
|
109
|
+
}
|
|
110
|
+
function canonicalizeLenient(value) {
|
|
111
|
+
return write(value, JSON.stringify, 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/federation/encoding.ts
|
|
116
|
+
const B64U = /^[A-Za-z0-9_-]*$/;
|
|
117
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
118
|
+
function b64u(bytes) {
|
|
119
|
+
return Buffer.from(bytes).toString("base64url");
|
|
120
|
+
}
|
|
121
|
+
function fromB64u(text) {
|
|
122
|
+
if (!B64U.test(text)) throw new TypeError("not base64url");
|
|
123
|
+
const bytes = Buffer.from(text, "base64url");
|
|
124
|
+
if (b64u(bytes) !== text) throw new TypeError("not canonical base64url");
|
|
125
|
+
return bytes;
|
|
126
|
+
}
|
|
127
|
+
function sha256Hex(data) {
|
|
128
|
+
return createHash("sha256").update(data).digest("hex");
|
|
129
|
+
}
|
|
130
|
+
function crockford32(bytes) {
|
|
131
|
+
let out = "";
|
|
132
|
+
let buffer = 0;
|
|
133
|
+
let bits = 0;
|
|
134
|
+
for (const byte of bytes) {
|
|
135
|
+
buffer = (buffer << 8 | byte) & 65535;
|
|
136
|
+
bits += 8;
|
|
137
|
+
while (bits >= 5) {
|
|
138
|
+
out += CROCKFORD.charAt(buffer >>> bits - 5 & 31);
|
|
139
|
+
bits -= 5;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (bits > 0) out += CROCKFORD.charAt(buffer << 5 - bits & 31);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/federation/keys.ts
|
|
148
|
+
const ED25519_PKCS8 = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
149
|
+
const FIELD_PRIME$1 = 2n ** 255n - 19n;
|
|
150
|
+
const ORDER_8_Y = 2707385501144840649318225287225658788936804267575313519463743609750303402022n;
|
|
151
|
+
const SMALL_ORDER_Y = new Set([
|
|
152
|
+
0n,
|
|
153
|
+
1n,
|
|
154
|
+
FIELD_PRIME$1 - 1n,
|
|
155
|
+
ORDER_8_Y,
|
|
156
|
+
FIELD_PRIME$1 - ORDER_8_Y
|
|
157
|
+
]);
|
|
158
|
+
function hasSmallOrder(raw) {
|
|
159
|
+
let y = 0n;
|
|
160
|
+
for (let i = raw.length - 1; i >= 0; i--) y = y << 8n | BigInt(raw[i] ?? 0);
|
|
161
|
+
return SMALL_ORDER_Y.has((y & (1n << 255n) - 1n) % FIELD_PRIME$1);
|
|
162
|
+
}
|
|
163
|
+
function rawPublic(key) {
|
|
164
|
+
const jwk = key.export({ format: "jwk" });
|
|
165
|
+
if (typeof jwk.x !== "string") throw new Error("expected an OKP public key");
|
|
166
|
+
return jwk.x;
|
|
167
|
+
}
|
|
168
|
+
function generateReplicaKeys() {
|
|
169
|
+
const s = generateKeyPairSync("ed25519");
|
|
170
|
+
const x = generateKeyPairSync("x25519");
|
|
171
|
+
return {
|
|
172
|
+
signPriv: s.privateKey.export({
|
|
173
|
+
type: "pkcs8",
|
|
174
|
+
format: "der"
|
|
175
|
+
}).toString("base64url"),
|
|
176
|
+
signPub: rawPublic(s.publicKey),
|
|
177
|
+
sealPriv: x.privateKey.export({
|
|
178
|
+
type: "pkcs8",
|
|
179
|
+
format: "der"
|
|
180
|
+
}).toString("base64url"),
|
|
181
|
+
sealPub: rawPublic(x.publicKey)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function ed25519FromSeed(seed) {
|
|
185
|
+
const der = Buffer.concat([ED25519_PKCS8, Buffer.from(seed)]);
|
|
186
|
+
const key = createPrivateKey({
|
|
187
|
+
key: der,
|
|
188
|
+
format: "der",
|
|
189
|
+
type: "pkcs8"
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
signPriv: der.toString("base64url"),
|
|
193
|
+
signPub: rawPublic(createPublicKey(key))
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function privateKeyOf(der) {
|
|
197
|
+
return createPrivateKey({
|
|
198
|
+
key: fromB64u(der),
|
|
199
|
+
format: "der",
|
|
200
|
+
type: "pkcs8"
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function publicKeyOf(raw, crv) {
|
|
204
|
+
return createPublicKey({
|
|
205
|
+
key: {
|
|
206
|
+
kty: "OKP",
|
|
207
|
+
crv,
|
|
208
|
+
x: raw
|
|
209
|
+
},
|
|
210
|
+
format: "jwk"
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function publicOfPrivate(der) {
|
|
214
|
+
return rawPublic(createPublicKey(privateKeyOf(der)));
|
|
215
|
+
}
|
|
216
|
+
function signText(signPriv, text) {
|
|
217
|
+
return b64u(sign(null, Buffer.from(text, "utf8"), privateKeyOf(signPriv)));
|
|
218
|
+
}
|
|
219
|
+
function verifyText(signPub, text, sig) {
|
|
220
|
+
try {
|
|
221
|
+
if (hasSmallOrder(fromB64u(signPub))) return false;
|
|
222
|
+
return verify(null, Buffer.from(text, "utf8"), publicKeyOf(signPub, "Ed25519"), fromB64u(sig));
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/federation/ops.ts
|
|
230
|
+
const OP_TYPES = [
|
|
231
|
+
"key",
|
|
232
|
+
"roster",
|
|
233
|
+
"task",
|
|
234
|
+
"presence",
|
|
235
|
+
"agent",
|
|
236
|
+
"channel",
|
|
237
|
+
"mail",
|
|
238
|
+
"state",
|
|
239
|
+
"memory",
|
|
240
|
+
"doc",
|
|
241
|
+
"a2a"
|
|
242
|
+
];
|
|
243
|
+
const SEALED_TYPES = new Set([
|
|
244
|
+
"mail",
|
|
245
|
+
"state",
|
|
246
|
+
"a2a"
|
|
247
|
+
]);
|
|
248
|
+
const STUBBABLE_TYPES = new Set([
|
|
249
|
+
"mail",
|
|
250
|
+
"state",
|
|
251
|
+
"presence",
|
|
252
|
+
"a2a"
|
|
253
|
+
]);
|
|
254
|
+
const MAX_OP_BYTES = 1048576;
|
|
255
|
+
const MAX_SEALED_RECIPIENTS = 256;
|
|
256
|
+
const MAX_STATE_ENTRIES = 500;
|
|
257
|
+
const REPLICA_ID = /^[A-Za-z0-9._-]{1,32}-[0-9a-f]{8}$/;
|
|
258
|
+
const ZERO_HASH = "0".repeat(64);
|
|
259
|
+
const TAG = {
|
|
260
|
+
op: "dispatch-op-v2",
|
|
261
|
+
sealed: "dispatch-sealed-v1",
|
|
262
|
+
ack: "dispatch-ack-v1",
|
|
263
|
+
relay: "dispatch-relay-v1",
|
|
264
|
+
invite: "dispatch-invite-v1",
|
|
265
|
+
recovery: "dispatch-recovery-v1",
|
|
266
|
+
fingerprint: "dispatch-fp-v1"
|
|
267
|
+
};
|
|
268
|
+
function isStub(e) {
|
|
269
|
+
return "pruned" in e && e.pruned === true;
|
|
270
|
+
}
|
|
271
|
+
function headerOf(e) {
|
|
272
|
+
const h = {
|
|
273
|
+
v: 2,
|
|
274
|
+
replica: e.replica,
|
|
275
|
+
seq: e.seq,
|
|
276
|
+
prev: e.prev,
|
|
277
|
+
hlc: e.hlc,
|
|
278
|
+
type: e.type,
|
|
279
|
+
bodyHash: e.bodyHash
|
|
280
|
+
};
|
|
281
|
+
if (e.to !== void 0) h.to = e.to;
|
|
282
|
+
return h;
|
|
283
|
+
}
|
|
284
|
+
function contentHash(content) {
|
|
285
|
+
return sha256Hex(canonicalize(content));
|
|
286
|
+
}
|
|
287
|
+
function signingInput(h) {
|
|
288
|
+
return `${TAG.op}\n${canonicalize(h)}`;
|
|
289
|
+
}
|
|
290
|
+
function opHash(e) {
|
|
291
|
+
return sha256Hex(canonicalize({
|
|
292
|
+
...headerOf(e),
|
|
293
|
+
sig: e.sig
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
function stubOf(op) {
|
|
297
|
+
return {
|
|
298
|
+
...headerOf(op),
|
|
299
|
+
sig: op.sig,
|
|
300
|
+
pruned: true
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/federation/chain.ts
|
|
306
|
+
const TYPE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
307
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
308
|
+
function buildOp(fields, signPriv) {
|
|
309
|
+
const problem = headerProblem(fields) ?? contentProblem(fields);
|
|
310
|
+
if (problem !== null) throw new RangeError(`cannot sign: ${problem}`);
|
|
311
|
+
const content = {};
|
|
312
|
+
if (fields.body !== void 0) content.body = fields.body;
|
|
313
|
+
if (fields.sealed !== void 0) content.sealed = fields.sealed;
|
|
314
|
+
const header = {
|
|
315
|
+
v: 2,
|
|
316
|
+
replica: fields.replica,
|
|
317
|
+
seq: fields.seq,
|
|
318
|
+
prev: fields.prev,
|
|
319
|
+
hlc: fields.hlc,
|
|
320
|
+
type: fields.type,
|
|
321
|
+
bodyHash: contentHash(content)
|
|
322
|
+
};
|
|
323
|
+
if (fields.to !== void 0) header.to = fields.to;
|
|
324
|
+
return {
|
|
325
|
+
...header,
|
|
326
|
+
...content,
|
|
327
|
+
sig: signText(signPriv, signingInput(header))
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const fail = (reason) => ({
|
|
331
|
+
ok: false,
|
|
332
|
+
reason
|
|
333
|
+
});
|
|
334
|
+
function sortedUnique(list, max) {
|
|
335
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > max) return false;
|
|
336
|
+
let last = "";
|
|
337
|
+
for (const r of list) {
|
|
338
|
+
if (typeof r !== "string" || !REPLICA_ID.test(r) || r <= last) return false;
|
|
339
|
+
last = r;
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
function sameKeys(sorted, keys) {
|
|
344
|
+
const other = [...keys].sort();
|
|
345
|
+
return sorted.length === other.length && sorted.every((r, i) => r === other[i]);
|
|
346
|
+
}
|
|
347
|
+
const isHex64 = (v) => typeof v === "string" && HEX64.test(v);
|
|
348
|
+
function headerProblem(h) {
|
|
349
|
+
if (typeof h.replica !== "string" || !REPLICA_ID.test(h.replica)) return "replica id outside the grammar";
|
|
350
|
+
if (typeof h.type !== "string" || !TYPE.test(h.type)) return "not a v2 op";
|
|
351
|
+
if (!Number.isSafeInteger(h.seq) || h.seq < 1) return "seq must rise";
|
|
352
|
+
if (!isHex64(h.prev)) return "malformed op";
|
|
353
|
+
const clock = typeof h.hlc === "string" ? parseOpHlc(h.hlc) : null;
|
|
354
|
+
if (clock === null) return "hlc outside the grammar";
|
|
355
|
+
if (clock.replica !== h.replica) return "hlc must name its own replica";
|
|
356
|
+
if (h.to !== void 0 && !sortedUnique(h.to, MAX_SEALED_RECIPIENTS)) return "bad recipient list";
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
function contentProblem(e) {
|
|
360
|
+
const sealedType = SEALED_TYPES.has(e.type);
|
|
361
|
+
if (sealedType && e.sealed === void 0) return "sealed types carry sealed content";
|
|
362
|
+
if (e.type === "state" && e.body !== void 0) return "state ops carry only sealed content";
|
|
363
|
+
if (!sealedType && (e.sealed !== void 0 || e.to !== void 0)) return "only mail and state are sealed";
|
|
364
|
+
if (sealedType && (e.to === void 0 || !sameKeys(e.to, Object.keys(e.sealed?.keys ?? {})))) return "keys must equal to";
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
function wellFormed(e) {
|
|
368
|
+
if (typeof e !== "object" || e === null || Array.isArray(e)) return false;
|
|
369
|
+
const { prev, bodyHash, sig } = e;
|
|
370
|
+
return typeof sig === "string" && isHex64(prev) && isHex64(bodyHash);
|
|
371
|
+
}
|
|
372
|
+
function orNull(step) {
|
|
373
|
+
try {
|
|
374
|
+
return step();
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function verifyEntry(head, e, signPub) {
|
|
380
|
+
if (!wellFormed(e)) return fail("malformed op");
|
|
381
|
+
if (e.v !== 2) return fail("not a v2 op");
|
|
382
|
+
const problem = headerProblem(e);
|
|
383
|
+
if (problem !== null) return fail(problem);
|
|
384
|
+
const bytes = orNull(() => Buffer.byteLength(canonicalize(e)));
|
|
385
|
+
if (bytes === null) return fail("malformed op");
|
|
386
|
+
if (bytes > MAX_OP_BYTES) return fail("over MAX_OP_BYTES");
|
|
387
|
+
if (head === null) {
|
|
388
|
+
if (isStub(e) || e.type !== "key" || e.prev !== ZERO_HASH) return fail("a log must start with its key op");
|
|
389
|
+
} else {
|
|
390
|
+
if (e.type === "key") return fail("a second key op");
|
|
391
|
+
if (e.prev !== head.hash) return fail("prev does not match");
|
|
392
|
+
if (e.seq <= head.seq) return fail("seq must rise");
|
|
393
|
+
const clock = parseOpHlc(e.hlc);
|
|
394
|
+
const before = parseOpHlc(head.hlc);
|
|
395
|
+
if (clock !== null && before !== null && compareHlc(clock, before) <= 0) return fail("hlc must rise");
|
|
396
|
+
}
|
|
397
|
+
if (!verifyText(signPub, signingInput(headerOf(e)), e.sig)) return fail("bad signature");
|
|
398
|
+
if (isStub(e)) {
|
|
399
|
+
if (!STUBBABLE_TYPES.has(e.type)) return fail(`a ${e.type} op cannot be a stub`);
|
|
400
|
+
if ("body" in e || "sealed" in e) return fail("a stub carries no content");
|
|
401
|
+
} else {
|
|
402
|
+
const content = {};
|
|
403
|
+
if (e.body !== void 0) content.body = e.body;
|
|
404
|
+
if (e.sealed !== void 0) content.sealed = e.sealed;
|
|
405
|
+
const hash = orNull(() => contentHash(content));
|
|
406
|
+
if (hash === null) return fail("malformed op");
|
|
407
|
+
if (hash !== e.bodyHash) return fail("bodyHash mismatch");
|
|
408
|
+
const broken = contentProblem(e);
|
|
409
|
+
if (broken !== null) return fail(broken);
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
head: {
|
|
414
|
+
seq: e.seq,
|
|
415
|
+
hash: opHash(e),
|
|
416
|
+
hlc: e.hlc
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region src/federation/fingerprint.ts
|
|
423
|
+
function fingerprint(signPub, sealPub) {
|
|
424
|
+
return (crockford32(createHash("sha256").update(`${TAG.fingerprint}\n${signPub}\n${sealPub}`).digest().subarray(0, 15)).match(/.{4}/g) ?? []).join("-");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/federation/hpke.ts
|
|
429
|
+
const i2osp2 = (n) => Buffer.from([n >> 8 & 255, n & 255]);
|
|
430
|
+
const KEM_SUITE = Buffer.concat([Buffer.from("KEM"), i2osp2(32)]);
|
|
431
|
+
const HPKE_SUITE = Buffer.concat([
|
|
432
|
+
Buffer.from("HPKE"),
|
|
433
|
+
i2osp2(32),
|
|
434
|
+
i2osp2(1),
|
|
435
|
+
i2osp2(2)
|
|
436
|
+
]);
|
|
437
|
+
const V1 = Buffer.from("HPKE-v1");
|
|
438
|
+
const NH = 32;
|
|
439
|
+
const NK = 32;
|
|
440
|
+
const NN = 12;
|
|
441
|
+
const TAG_BYTES = 16;
|
|
442
|
+
const EMPTY = Buffer.alloc(0);
|
|
443
|
+
function extract(salt, ikm) {
|
|
444
|
+
return createHmac("sha256", salt.length === 0 ? Buffer.alloc(NH) : salt).update(ikm).digest();
|
|
445
|
+
}
|
|
446
|
+
function expand(prk, info, length) {
|
|
447
|
+
const blocks = [];
|
|
448
|
+
let t = EMPTY;
|
|
449
|
+
for (let i = 1; blocks.length * NH < length; i++) {
|
|
450
|
+
t = createHmac("sha256", prk).update(Buffer.concat([
|
|
451
|
+
t,
|
|
452
|
+
info,
|
|
453
|
+
Buffer.from([i])
|
|
454
|
+
])).digest();
|
|
455
|
+
blocks.push(t);
|
|
456
|
+
}
|
|
457
|
+
return Buffer.concat(blocks).subarray(0, length);
|
|
458
|
+
}
|
|
459
|
+
function labeledExtract(suite, salt, label, ikm) {
|
|
460
|
+
return extract(salt, Buffer.concat([
|
|
461
|
+
V1,
|
|
462
|
+
suite,
|
|
463
|
+
Buffer.from(label),
|
|
464
|
+
ikm
|
|
465
|
+
]));
|
|
466
|
+
}
|
|
467
|
+
function labeledExpand(suite, prk, label, info, length) {
|
|
468
|
+
return expand(prk, Buffer.concat([
|
|
469
|
+
i2osp2(length),
|
|
470
|
+
V1,
|
|
471
|
+
suite,
|
|
472
|
+
Buffer.from(label),
|
|
473
|
+
info
|
|
474
|
+
]), length);
|
|
475
|
+
}
|
|
476
|
+
function x25519Public(raw) {
|
|
477
|
+
return publicKeyOf(raw.toString("base64url"), "X25519");
|
|
478
|
+
}
|
|
479
|
+
function freshEphemeral() {
|
|
480
|
+
const pair = generateKeyPairSync("x25519");
|
|
481
|
+
const { x } = pair.publicKey.export({ format: "jwk" });
|
|
482
|
+
if (typeof x !== "string") throw new Error("expected an OKP public key");
|
|
483
|
+
return {
|
|
484
|
+
privateKey: pair.privateKey,
|
|
485
|
+
publicRaw: fromB64u(x)
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function sharedSecretOf(dh, enc, pkR) {
|
|
489
|
+
return labeledExpand(KEM_SUITE, labeledExtract(KEM_SUITE, EMPTY, "eae_prk", dh), "shared_secret", Buffer.concat([enc, pkR]), NH);
|
|
490
|
+
}
|
|
491
|
+
function encap(pkR, ephemeral = freshEphemeral()) {
|
|
492
|
+
return {
|
|
493
|
+
sharedSecret: sharedSecretOf(diffieHellman({
|
|
494
|
+
privateKey: ephemeral.privateKey,
|
|
495
|
+
publicKey: x25519Public(pkR)
|
|
496
|
+
}), ephemeral.publicRaw, pkR),
|
|
497
|
+
enc: ephemeral.publicRaw
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function decap(enc, skR, pkR) {
|
|
501
|
+
return sharedSecretOf(diffieHellman({
|
|
502
|
+
privateKey: skR,
|
|
503
|
+
publicKey: x25519Public(enc)
|
|
504
|
+
}), enc, pkR);
|
|
505
|
+
}
|
|
506
|
+
function keySchedule(sharedSecret, info) {
|
|
507
|
+
const pskIdHash = labeledExtract(HPKE_SUITE, EMPTY, "psk_id_hash", EMPTY);
|
|
508
|
+
const infoHash = labeledExtract(HPKE_SUITE, EMPTY, "info_hash", info);
|
|
509
|
+
const context = Buffer.concat([
|
|
510
|
+
Buffer.from([0]),
|
|
511
|
+
pskIdHash,
|
|
512
|
+
infoHash
|
|
513
|
+
]);
|
|
514
|
+
const secret = labeledExtract(HPKE_SUITE, sharedSecret, "secret", EMPTY);
|
|
515
|
+
return {
|
|
516
|
+
key: labeledExpand(HPKE_SUITE, secret, "key", context, NK),
|
|
517
|
+
baseNonce: labeledExpand(HPKE_SUITE, secret, "base_nonce", context, NN)
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function aeadSeal(key, nonce, aad, pt) {
|
|
521
|
+
const c = createCipheriv("aes-256-gcm", key, nonce);
|
|
522
|
+
c.setAAD(aad);
|
|
523
|
+
return Buffer.concat([
|
|
524
|
+
c.update(pt),
|
|
525
|
+
c.final(),
|
|
526
|
+
c.getAuthTag()
|
|
527
|
+
]);
|
|
528
|
+
}
|
|
529
|
+
function aeadOpen(key, nonce, aad, ct) {
|
|
530
|
+
if (ct.length < TAG_BYTES) throw new RangeError("ciphertext too short");
|
|
531
|
+
const d = createDecipheriv("aes-256-gcm", key, nonce);
|
|
532
|
+
d.setAAD(aad);
|
|
533
|
+
d.setAuthTag(ct.subarray(ct.length - TAG_BYTES));
|
|
534
|
+
return Buffer.concat([d.update(ct.subarray(0, ct.length - TAG_BYTES)), d.final()]);
|
|
535
|
+
}
|
|
536
|
+
function sealBase(pkR, info, pt) {
|
|
537
|
+
const { sharedSecret, enc } = encap(pkR);
|
|
538
|
+
const { key, baseNonce } = keySchedule(sharedSecret, info);
|
|
539
|
+
return {
|
|
540
|
+
enc,
|
|
541
|
+
ct: aeadSeal(key, baseNonce, EMPTY, pt)
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function openBase(enc, skR, pkR, info, ct) {
|
|
545
|
+
const { key, baseNonce } = keySchedule(decap(enc, skR, pkR), info);
|
|
546
|
+
return aeadOpen(key, baseNonce, EMPTY, ct);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region src/federation/seal.ts
|
|
551
|
+
const CONTENT_KEY_BYTES = 32;
|
|
552
|
+
const NONCE_BYTES = 12;
|
|
553
|
+
const X25519_PUBLIC_BYTES = 32;
|
|
554
|
+
const FIELD_PRIME = 2n ** 255n - 19n;
|
|
555
|
+
function sealedAad(replica, seq, type) {
|
|
556
|
+
return `${TAG.sealed}\n${replica}\n${seq}\n${type}`;
|
|
557
|
+
}
|
|
558
|
+
function sealPayload(input) {
|
|
559
|
+
const to = [...input.recipients.keys()].sort();
|
|
560
|
+
if (to.length === 0 || to.length > MAX_SEALED_RECIPIENTS) throw new RangeError(`a sealed op names 1-${MAX_SEALED_RECIPIENTS} recipients`);
|
|
561
|
+
for (const r of to) {
|
|
562
|
+
if (!REPLICA_ID.test(r)) throw new RangeError(`${r} is not a replica id`);
|
|
563
|
+
if (!canSealTo(input.recipients.get(r) ?? "")) throw new RangeError(`cannot seal to ${r}: its sealPub is unusable`);
|
|
564
|
+
}
|
|
565
|
+
const key = randomBytes(CONTENT_KEY_BYTES);
|
|
566
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
567
|
+
const aad = sealedAad(input.replica, input.seq, input.type);
|
|
568
|
+
const ct = aeadSeal(key, nonce, Buffer.from(aad), Buffer.from(canonicalizeLenient(input.payload)));
|
|
569
|
+
const keys = {};
|
|
570
|
+
for (const r of to) keys[r] = wrapContentKey(key, aad, r, input.recipients.get(r) ?? "");
|
|
571
|
+
return {
|
|
572
|
+
to,
|
|
573
|
+
sealed: {
|
|
574
|
+
nonce: b64u(nonce),
|
|
575
|
+
ct: b64u(ct),
|
|
576
|
+
keys
|
|
577
|
+
},
|
|
578
|
+
key
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function canSealTo(sealPub) {
|
|
582
|
+
try {
|
|
583
|
+
const raw = fromB64u(sealPub);
|
|
584
|
+
if (raw.length !== X25519_PUBLIC_BYTES) return false;
|
|
585
|
+
if (BigInt(`0x${Buffer.from(raw).reverse().toString("hex")}`) >= FIELD_PRIME) return false;
|
|
586
|
+
encap(raw);
|
|
587
|
+
return true;
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function wrapContentKey(key, aad, recipient, sealPub) {
|
|
593
|
+
const { enc, ct } = sealBase(fromB64u(sealPub), Buffer.from(`${aad}\n${recipient}`), key);
|
|
594
|
+
return {
|
|
595
|
+
enc: b64u(enc),
|
|
596
|
+
ct: b64u(ct)
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function unwrapContentKey(op, me, sealPriv) {
|
|
600
|
+
const wrap = op.sealed?.keys[me];
|
|
601
|
+
if (wrap === void 0) return null;
|
|
602
|
+
try {
|
|
603
|
+
const skR = privateKeyOf(sealPriv);
|
|
604
|
+
const pkR = fromB64u(publicOfPrivate(sealPriv));
|
|
605
|
+
const aad = sealedAad(op.replica, op.seq, op.type);
|
|
606
|
+
return openBase(fromB64u(wrap.enc), skR, pkR, Buffer.from(`${aad}\n${me}`), fromB64u(wrap.ct));
|
|
607
|
+
} catch {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function openWithKey(op, key) {
|
|
612
|
+
if (op.sealed === void 0) return null;
|
|
613
|
+
try {
|
|
614
|
+
const aad = sealedAad(op.replica, op.seq, op.type);
|
|
615
|
+
const pt = aeadOpen(key, fromB64u(op.sealed.nonce), Buffer.from(aad), fromB64u(op.sealed.ct));
|
|
616
|
+
return JSON.parse(pt.toString("utf8"));
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function openPayload(op, me, sealPriv) {
|
|
622
|
+
const key = unwrapContentKey(op, me, sealPriv);
|
|
623
|
+
return key === null ? null : openWithKey(op, key);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
//#endregion
|
|
627
|
+
export { CanonicalizeError, MAX_CLOCK_LEAD_MS, MAX_HLC_COUNTER, MAX_JSON_DEPTH, MAX_OP_BYTES, MAX_SEALED_RECIPIENTS, MAX_STATE_ENTRIES, OP_TYPES, OpClock, REPLICA_ID, SEALED_TYPES, STUBBABLE_TYPES, TAG, ZERO_HASH, aheadOfClock, b64u, buildOp, canSealTo, canonicalize, compareHlc, contentHash, crockford32, ed25519FromSeed, fingerprint, fromB64u, generateReplicaKeys, headerOf, hlcWallMs, isStub, opHash, openPayload, openWithKey, parseOpHlc, publicOfPrivate, sealPayload, sealedAad, sha256Hex, signText, stubOf, unwrapContentKey, verifyEntry, verifyText, wrapContentKey };
|
|
628
|
+
//# sourceMappingURL=index.js.map
|