-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdht.ts
More file actions
214 lines (187 loc) · 9.74 KB
/
Copy pathdht.ts
File metadata and controls
214 lines (187 loc) · 9.74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
//// DHT: signed, content-addressed rows ////////////////////////////////////////
// Shared by server.tsx, ding.ts (CLI), node.tsx, and tests. The canonical bytes
// defined here are LOAD-BEARING: server, CLI, and node must hash byte-identically
// or dedup and gap-fill break silently. Golden vectors live in server.test.ts.
const enc = new TextEncoder();
const dec = new TextDecoder();
// A row the node refuses (bad sig / hash / shape / policy). Distinguishes a
// per-row drop from an infrastructure error that should surface as a 5xx.
export class DhtReject extends Error {}
export const nowSec = () => Math.floor(Date.now() / 1000); // row ts are integer epoch-seconds
export const hex = (b: Uint8Array) => Array.from(b).map((x) => x.toString(16).padStart(2, "0")).join("");
const unhex = (s: string) => {
if (!/^[0-9a-f]*$/.test(s) || s.length % 2) throw new Error(`bad hex: ${s.slice(0, 16)}…`);
return new Uint8Array(s.match(/../g)?.map((h) => parseInt(h, 16)) ?? []);
};
const sha256hex = async (b: Uint8Array) => {
const buf = new ArrayBuffer(b.byteLength);
new Uint8Array(buf).set(b);
return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", buf)));
};
// Deterministic serialization: sorted object keys, no floats, no unsafe ints.
// canon([kind, pubkey, ts, payload]) IS the signed/hashed string (positional outer
// array kills key-order ambiguity; kind+pubkey are inside the signed bytes).
export const canon = (v: unknown): string => {
if (typeof v === "number") {
if (!Number.isInteger(v)) throw new Error(`canon: floats forbidden (${v}); use integer epoch-seconds`);
if (!Number.isSafeInteger(v)) throw new Error(`canon: unsafe integer ${v}`);
return String(v);
}
if (v === null || typeof v === "boolean" || typeof v === "string") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(canon).join(",") + "]";
if (typeof v === "object") {
return "{" + Object.keys(v as object).sort()
.map((k) => JSON.stringify(k) + ":" + canon((v as Record<string, unknown>)[k]))
.join(",") +
"}";
}
throw new Error(`canon: unserializable ${typeof v}`);
};
//// LABELS (shared by server.tsx + ding.ts so both build identical msg payloads) //
export type Labels = { tag: string[]; org: string[]; usr: string[]; www: string[]; text: string };
export const PFX: Record<string, keyof Labels> = { "#": "tag", "*": "org", "@": "usr", "~": "www" };
export const parseLabels = (input: string): Labels => {
const labels: Labels = { tag: [], org: [], usr: [], www: [], text: "" };
input.split(/\s+/).filter(Boolean).forEach((t) => {
const k = PFX[t[0]];
if (k) (labels[k] as string[]).push(k === "usr" ? t.slice(1) : t.slice(1).toLowerCase());
else labels.text = labels.text ? labels.text + " " + t : t;
});
return labels;
};
export type Kind = "peer" | "usr" | "org" | "msg" | "flag" | "mark";
export const KINDS: Kind[] = ["peer", "usr", "org", "msg", "flag", "mark"];
export type Row = { k: string; kind: Kind; pubkey: string; ts: number; sig: string; [field: string]: unknown };
const bytesOf = (kind: Kind, pubkey: string, ts: number, payload: unknown) =>
enc.encode(canon([kind, pubkey, ts, payload]));
export const idOf = (pubkeyHex: string) => sha256hex(unhex(pubkeyHex));
// set semantics: lowercased, deduped, sorted — so ["b","a"] ≡ ["a","b"] when signed
const normLabels = (xs: string[] = []) => [...new Set(xs.map((s) => s.toLowerCase()))].sort();
export const buildMsg = (p: { parent?: string; tags?: string[]; orgs?: string[]; usrs?: string[]; body: string }) => {
const payload: Record<string, unknown> = {
tags: normLabels(p.tags),
orgs: normLabels(p.orgs),
usrs: normLabels(p.usrs),
body: p.body,
};
if (p.parent) payload.parent = p.parent;
return payload;
};
// A checkmark: issuer (the signer) endorses `subject` (an id) with a TTL'd claim.
export const buildMark = (subject: string, claim: string, exp: number) => ({ subject, mark: { v: claim, exp } });
//// KEYS ////////////////////////////////////////////////////////////////////////
export const genKey = () =>
crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]) as Promise<CryptoKeyPair>;
export const pubHexOf = async (kp: CryptoKeyPair) =>
hex(new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey)));
export const exportJwk = (kp: CryptoKeyPair) => crypto.subtle.exportKey("jwk", kp.privateKey);
export const importPriv = (jwk: JsonWebKey) => crypto.subtle.importKey("jwk", jwk, { name: "Ed25519" }, true, ["sign"]);
const importPub = (pubkeyHex: string) =>
crypto.subtle.importKey("raw", unhex(pubkeyHex), { name: "Ed25519" }, false, ["verify"]);
// Verify an Ed25519 signature over an arbitrary string (e.g. a node-auth challenge nonce).
export const verifyBytes = async (pubkeyHex: string, sigHex: string, msg: string): Promise<boolean> => {
try {
return await crypto.subtle.verify({ name: "Ed25519" }, await importPub(pubkeyHex), unhex(sigHex), enc.encode(msg));
} catch {
return false;
}
};
//// SIGN / VERIFY ////////////////////////////////////////////////////////////////
export const signRow = async (
kind: Kind,
ts: number,
payload: Record<string, unknown>,
priv: CryptoKey,
pubkey: string,
): Promise<Row> => {
const bytes = bytesOf(kind, pubkey, ts, payload);
const sig = hex(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, priv, bytes)));
const k = await sha256hex(bytes);
return { k, kind, pubkey, ts, sig, ...payload };
};
// Strict: throws Elm-style on any mismatch (let it crash). Returns {kind,pubkey,ts,payload}.
export const verifyRow = async (row: Row) => {
const { k, kind, pubkey, ts, sig, ...payload } = row;
if (!KINDS.includes(kind)) throw new Error(`unknown kind "${kind}". expected one of ${KINDS.join(", ")}.`);
if (!/^[0-9a-f]{64}$/.test(pubkey)) throw new Error(`row ${String(k).slice(0, 8)}: pubkey must be 64 hex chars.`);
if (!/^[0-9a-f]{128}$/.test(sig)) throw new Error(`row ${String(k).slice(0, 8)}: sig must be 128 hex chars.`);
if (!Number.isSafeInteger(ts) || ts <= 0)
throw new Error(`row ${String(k).slice(0, 8)}: ts must be a positive integer (unix seconds), got ${ts}.`);
const bytes = bytesOf(kind, pubkey, ts, payload);
const expect = await sha256hex(bytes);
if (expect !== k) {
throw new Error(
`row ${String(k).slice(0, 8)}…: content-hash mismatch. k claims ${String(k).slice(0, 8)}… ` +
`but body hashes to ${expect.slice(0, 8)}…. k must equal sha256 of the canonical signed bytes.`,
);
}
if (!(await crypto.subtle.verify({ name: "Ed25519" }, await importPub(pubkey), unhex(sig), bytes)))
throw new Error(`row ${k.slice(0, 8)}…: bad signature from ${pubkey.slice(0, 8)}…. signed by the wrong key?`);
return { k, kind, pubkey, ts, payload: payload as Record<string, unknown> };
};
//// CUSTODIAL KEY WRAP (AES-256-GCM, one env secret, random IV per row) //////////
const aesKey = async (secret: string) =>
crypto.subtle.importKey(
"raw",
await crypto.subtle.digest("SHA-256", enc.encode(secret)),
{ name: "AES-GCM" },
false,
[
"encrypt",
"decrypt",
],
);
export const wrapSecret = async (plaintext: string, secret: string) => {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await aesKey(secret), enc.encode(plaintext)),
);
const out = new Uint8Array(12 + ct.length);
out.set(iv);
out.set(ct, 12);
return out;
};
export const unwrapSecret = async (buf: Uint8Array, secret: string) => {
const b = new Uint8Array(buf);
try {
return dec.decode(
await crypto.subtle.decrypt({ name: "AES-GCM", iv: b.slice(0, 12) }, await aesKey(secret), b.slice(12)),
);
} catch {
throw new Error(
"custodial key decrypt failed — KEY_WRAP_SECRET is wrong/rotated or seckey_enc is corrupt; " +
"the key cannot be recovered without the original KEY_WRAP_SECRET.",
);
}
};
export const unwrapPriv = async (enc: Uint8Array, secret: string) =>
importPriv(JSON.parse(await unwrapSecret(enc, secret)));
// Minimal structural view of a postgres.js `sql` tag, so dht.ts stays dependency-free.
// deno-lint-ignore no-explicit-any
type SqlTag = (s: TemplateStringsArray, ...args: any[]) => PromiseLike<any[]>;
// Mint-on-demand custodial keypair for a local user. The mint is atomic
// (`where pubkey is null`) so concurrent first-posts can't fork an identity; on a lost
// race the winner's key is adopted. usr.id (= sha256 pubkey) is maintained here so
// private @recipient ids resolve to names. Returns null when the user is self-custody
// (server holds no key) — callers decide whether that's a 409 or a crash.
export const ensureCustodialKey = async (
sql: SqlTag,
name: string,
secret: string,
): Promise<{ priv: CryptoKey; pub: string; id: string } | null> => {
const [u] = await sql`select pubkey, seckey_enc, id from usr where name = ${name}`;
if (u?.pubkey && u?.seckey_enc) {
const id = u.id ?? await idOf(u.pubkey);
if (!u.id) await sql`update usr set id = ${id} where name = ${name}`;
return { priv: await unwrapPriv(u.seckey_enc, secret), pub: u.pubkey, id };
}
const kp = await genKey(), pub = await pubHexOf(kp), id = await idOf(pub);
const enc2 = await wrapSecret(JSON.stringify(await exportJwk(kp)), secret);
const claimed = await sql`
update usr set pubkey = ${pub}, seckey_enc = ${enc2}, id = ${id} where name = ${name} and pubkey is null
returning pubkey`;
if (claimed.length) return { priv: kp.privateKey, pub, id };
const [u2] = await sql`select pubkey, seckey_enc, id from usr where name = ${name}`; // lost the race → adopt theirs
if (!u2?.seckey_enc) return null; // self-custody: server holds no key
return { priv: await unwrapPriv(u2.seckey_enc, secret), pub: u2.pubkey, id: u2.id ?? await idOf(u2.pubkey) };
};