pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / js / identity.js

21,049 bytes · last changed 2026-10-28 in identity.js: the documents a root signs, and encrypted backups, agreeing with Python

1/* identity.js: a pyrtan identity in JavaScript, for the console.
2 *
3 * What the console needs of _meta/identity.md, and what Python's
4 * src/pyrtan/identity.py does the same way: identifiers (`pyr:` and the
5 * base32 of key, SHA3-256 checksum and version), canonical JSON, the
6 * message a request signature is over, and signing it; the documents a
7 * root signs (identity documents, device notes, name claims, releases,
8 * handovers); and the encrypted backup of a root key (PBKDF2-SHA256, then
9 * AES-256-GCM, all by crypto.subtle). tests/conformance/identity.json
10 * holds the vectors both must agree on.
11 *
12 * SHA-256 and SHA3-256 are written out here: crypto.subtle has no SHA3,
13 * and none of crypto.subtle on a plain http:// LAN address. Ed25519 is
14 * never written out: a key is made, held and used only by crypto.subtle,
15 * non-extractable, so a page without it (http:// on a LAN, an old
16 * browser) holds no key and says so. The device key lives in IndexedDB.
17 *
18 * Runs in a browser as `PyrtanIdentity` and in node.
19 */
20(function (root, factory) {
21 if (typeof module === "object" && module.exports) module.exports = factory();
22 else root.PyrtanIdentity = factory();
23})(typeof globalThis !== "undefined" ? globalThis : this, function () {
24 "use strict";
25
26 const PREFIX = "pyr:", ID_LENGTH = 56, VERSION_ED25519 = 1, REQUEST_TAG = "pyrtan-req-v1";
27 const DOC_TAG = "pyrtan-doc-v1\n", HANDOVER_TAG = "pyrtan-handover-v1\n", BACKUP_TAG = "pyrtan-key-backup-v1\n";
28 const ABILITIES = ["ask", "chat", "run", "operate"], YEAR = 365 * 24 * 3600, BACKUP_ITERATIONS = 600000, MIN_ITERATIONS = 100000;
29 const LOCAL = /^[a-z0-9][a-z0-9._-]{0,62}$/, LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
30 const enc = new TextEncoder();
31 const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
32 const concat = (...parts) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out; };
33
34 // ------------------------------------------------------------ SHA-256
35
36 const sha256 = (() => {
37 const K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
38 const rotr = (x, n) => (x >>> n) | (x << (32 - n));
39 return (bytes) => {
40 const l = bytes.length, bitLen = l * 8, padded = new Uint8Array(((l + 9 + 63) >> 6) << 6);
41 padded.set(bytes); padded[l] = 0x80;
42 const dv = new DataView(padded.buffer); dv.setUint32(padded.length - 4, bitLen >>> 0); dv.setUint32(padded.length - 8, Math.floor(bitLen / 4294967296));
43 let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
44 const w = new Uint32Array(64);
45 for (let o = 0; o < padded.length; o += 64) {
46 for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + i * 4);
47 for (let i = 16; i < 64; i++) { const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3), s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10); w[i] = (w[i-16] + s0 + w[i-7] + s1) >>> 0; }
48 let [a,b,c,d,e,f,g,hh] = h;
49 for (let i = 0; i < 64; i++) { const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25), ch = (e & f) ^ (~e & g), t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0, S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22), maj = (a & b) ^ (a & c) ^ (b & c), t2 = (S0 + maj) >>> 0; hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; }
50 h = h.map((v, i) => (v + [a,b,c,d,e,f,g,hh][i]) >>> 0);
51 }
52 return new Uint8Array(h.flatMap((v) => [v >>> 24 & 255, v >>> 16 & 255, v >>> 8 & 255, v & 255]));
53 };
54 })();
55
56 // ------------------------------------------------------------ SHA3-256 (FIPS 202), for the identifier's checksum
57
58 const sha3_256 = (() => {
59 const MASK = (1n << 64n) - 1n;
60 const RC = [0x1n, 0x8082n, 0x800000000000808an, 0x8000000080008000n, 0x808bn, 0x80000001n, 0x8000000080008081n, 0x8000000000008009n, 0x8an, 0x88n, 0x80008009n, 0x8000000an, 0x8000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n, 0x800an, 0x800000008000000an, 0x8000000080008081n, 0x8000000000008080n, 0x80000001n, 0x8000000080008008n];
61 const R = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]; // R[x][y]
62 const rot = (v, n) => (n === 0 ? v : ((v << BigInt(n)) | (v >> BigInt(64 - n))) & MASK);
63 function permute(A) {
64 const C = new Array(5), B = new Array(25);
65 for (let round = 0; round < 24; round++) {
66 for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20];
67 for (let x = 0; x < 5; x++) { const D = C[(x + 4) % 5] ^ rot(C[(x + 1) % 5], 1); for (let y = 0; y < 5; y++) A[x + 5 * y] ^= D; }
68 for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rot(A[x + 5 * y], R[x][y]);
69 for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ (~B[(x + 1) % 5 + 5 * y] & MASK & B[(x + 2) % 5 + 5 * y]);
70 A[0] ^= RC[round];
71 }
72 }
73 const RATE = 136;
74 return (bytes) => {
75 const padded = new Uint8Array(Math.floor(bytes.length / RATE) * RATE + RATE);
76 padded.set(bytes); padded[bytes.length] ^= 0x06; padded[padded.length - 1] ^= 0x80;
77 const A = new Array(25).fill(0n);
78 for (let o = 0; o < padded.length; o += RATE) {
79 for (let i = 0; i < RATE / 8; i++) { let lane = 0n; for (let b = 7; b >= 0; b--) lane = (lane << 8n) | BigInt(padded[o + i * 8 + b]); A[i] ^= lane; }
80 permute(A);
81 }
82 const out = new Uint8Array(32);
83 for (let i = 0; i < 4; i++) { let lane = A[i]; for (let b = 0; b < 8; b++) { out[i * 8 + b] = Number(lane & 0xffn); lane >>= 8n; } }
84 return out;
85 };
86 })();
87
88 // ------------------------------------------------------------ base32 and base64url
89
90 const B32 = "abcdefghijklmnopqrstuvwxyz234567";
91 function b32(bytes) {
92 let bits = 0, value = 0, out = "";
93 for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } }
94 if (bits > 0) out += B32[(value << (5 - bits)) & 31];
95 return out;
96 }
97 function unb32(text) {
98 let bits = 0, value = 0; const out = [];
99 for (const ch of text) { const v = B32.indexOf(ch); if (v < 0) return null; value = (value << 5) | v; bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } }
100 return new Uint8Array(out);
101 }
102 const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
103 const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4)), (c) => c.charCodeAt(0));
104
105 // ------------------------------------------------------------ identifiers
106
107 const checksum = (key, version) => sha3_256(concat(enc.encode(".pyrtan id"), key, new Uint8Array([version]))).slice(0, 2);
108 function encode(key, version = VERSION_ED25519) {
109 if (version !== VERSION_ED25519 || key.length !== 32) throw new Error(`a version ${version} key is 32 bytes, not ${key.length}`);
110 return PREFIX + b32(concat(key, checksum(key, version), new Uint8Array([version])));
111 }
112 function decode(text) {
113 if (typeof text !== "string" || !text.toLowerCase().startsWith(PREFIX)) throw new Error(`${JSON.stringify(text)} is not an identity: it starts ${PREFIX}`);
114 const body = text.slice(PREFIX.length).toLowerCase();
115 if (body.length !== ID_LENGTH) throw new Error(`not an identity: ${body.length} characters after ${PREFIX}, not ${ID_LENGTH}`);
116 const raw = unb32(body);
117 if (!raw || raw.length !== 35) throw new Error("not an identity: not base32");
118 const key = raw.slice(0, 32), version = raw[34];
119 if (version !== VERSION_ED25519) throw new Error(`version ${version}, which this console does not know`);
120 const sum = checksum(key, version);
121 if (sum[0] !== raw[32] || sum[1] !== raw[33]) throw new Error("not an identity: the checksum does not match (a typo?)");
122 return key;
123 }
124 const valid = (text) => { try { decode(text); return true; } catch (e) { return false; } };
125 const short = (id) => `${PREFIX}${id.slice(PREFIX.length, PREFIX.length + 4)}…${id.slice(-4)}`;
126
127 // ------------------------------------------------------------ canonical JSON and the request message
128
129 function canonical(v) {
130 if (v === null || typeof v !== "object") return JSON.stringify(v);
131 if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
132 return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
133 }
134 function requestMessage(audience, method, path, date, body) {
135 const digest = hex(sha256(body === null || body === undefined ? new Uint8Array() : enc.encode(canonical(body))));
136 return enc.encode([REQUEST_TAG, audience.replace(/\/+$/, ""), method.toUpperCase(), path, String(date), digest].join("\n"));
137 }
138
139 // ------------------------------------------------------------ keys (crypto.subtle only)
140
141 const subtle = () => (typeof crypto !== "undefined" && crypto.subtle) || null;
142 async function available() {
143 if (!subtle()) return false;
144 try { await subtle().generateKey({ name: "Ed25519" }, false, ["sign", "verify"]); return true; } catch (e) { return false; }
145 }
146 async function idOf(publicKey) { return encode(new Uint8Array(await subtle().exportKey("raw", publicKey))); }
147 async function generate() {
148 const pair = await subtle().generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
149 return { id: await idOf(pair.publicKey), privateKey: pair.privateKey, publicKey: pair.publicKey };
150 }
151 async function fromSeedAndPublic(seed, publicKey) { // for the conformance vectors; a page never holds a seed
152 const jwk = { kty: "OKP", crv: "Ed25519", d: b64url(seed), x: b64url(publicKey) };
153 const privateKey = await subtle().importKey("jwk", jwk, { name: "Ed25519" }, false, ["sign"]);
154 return { id: encode(publicKey), privateKey };
155 }
156 async function signRequest(key, audience, method, path, body, note, date) {
157 const when = String(date !== undefined ? date : Math.floor(Date.now() / 1000));
158 const sig = new Uint8Array(await subtle().sign({ name: "Ed25519" }, key.privateKey, requestMessage(audience, method, path, when, body)));
159 const headers = { "X-Pyrtan-Key": key.id, "X-Pyrtan-Date": when, "X-Pyrtan-Key-Signature": b64url(sig) };
160 if (note) {
161 if (note.device !== key.id) throw new Error(`the note is for ${note.device}, not this key`);
162 headers["X-Pyrtan-Device"] = b64url(enc.encode(canonical(note)));
163 }
164 return headers;
165 }
166 /** What is wrong with a pasted device note for this key, or null. The hub checks the signature. */
167 function noteProblem(note, deviceId, now) {
168 if (!note || typeof note !== "object" || note["pyrtan-device"] !== 1) return "not a device note";
169 if (note.device !== deviceId) return `the note is for ${note.device}, not this browser's key`;
170 if (!valid(note.root)) return "the note names no root";
171 if (typeof note.sig !== "string") return "the note is not signed";
172 if (!(note.expires > (now !== undefined ? now : Date.now() / 1000))) return "the note has expired";
173 return null;
174 }
175
176 // ------------------------------------------------------------ documents a root signs
177
178 const now = () => Math.floor(Date.now() / 1000);
179 const without = (doc, ...keys) => Object.fromEntries(Object.entries(doc).filter(([k]) => !keys.includes(k)));
180 async function sign(key, bytes) { return new Uint8Array(await subtle().sign({ name: "Ed25519" }, key.privateKey, bytes)); }
181 async function signDocument(key, doc) {
182 const body = without(doc, "sig");
183 return Object.assign({}, body, { sig: b64url(await sign(key, enc.encode(DOC_TAG + canonical(body)))) });
184 }
185 const abilities = (may) => { const bad = may.filter((a) => !ABILITIES.includes(a)); if (bad.length || new Set(may).size !== may.length) throw new Error(`may is some of ${ABILITIES.join(", ")}, each once`); return ABILITIES.filter((a) => may.includes(a)); };
186 function host(text) {
187 const h = String(text || "").toLowerCase(), labels = h.split(".");
188 if (h.length > 253 || !labels.every((l) => LABEL.test(l))) throw new Error(`${JSON.stringify(text)} is not a host name`);
189 if (labels[labels.length - 1] === "onion" && (labels.length < 2 || labels[labels.length - 2].length !== 56)) throw new Error("an onion address is 56 characters before .onion");
190 return h;
191 }
192 function parseName(text) {
193 const t = String(text || "").trim().toLowerCase(), at = t.lastIndexOf("@");
194 if (at < 1 || t.startsWith(PREFIX)) throw new Error(`${JSON.stringify(text)} is not a name (name@domain)`);
195 const name = t.slice(0, at);
196 if (!LOCAL.test(name)) throw new Error(`${JSON.stringify(name)} is not lowercase letters, digits, dot, dash, underscore, at most 63`);
197 return { name, domain: host(t.slice(at + 1)) };
198 }
199 async function makeDevice(root, device, may, opts) {
200 const o = opts || {}, issued = o.issued !== undefined ? o.issued : now();
201 decode(device);
202 if (device.toLowerCase() === root.id) throw new Error("a root does not delegate to itself; it signs for itself");
203 return signDocument(root, { "pyrtan-device": 1, root: root.id, device: device.toLowerCase(), label: o.label || "", may: abilities(may), issued, expires: o.expires !== undefined ? o.expires : issued + YEAR });
204 }
205 async function makeIdentity(root, opts) {
206 const o = opts || {};
207 return signDocument(root, { "pyrtan-identity": 1, id: root.id, kind: o.kind || "person", routes: o.routes || [], devices: o.devices || [], revoked: o.revoked || [], updated: o.updated !== undefined ? o.updated : now() });
208 }
209 // A new document, strictly newer than the one it replaces, or a holder would keep the old one.
210 const reissue = (doc, root, changes) => makeIdentity(root, Object.assign({ kind: doc.kind, routes: doc.routes, devices: doc.devices, revoked: doc.revoked }, changes, { updated: Math.max(now(), doc.updated + 1) }));
211 const withDevice = (doc, root, note) => reissue(doc, root, { devices: doc.devices.filter((n) => n.device !== note.device).concat([note]), revoked: doc.revoked.filter((r) => r !== note.device) });
212 const withRevoked = (doc, root, device) => reissue(doc, root, { devices: doc.devices.filter((n) => n.device !== device), revoked: doc.revoked.includes(device) ? doc.revoked : doc.revoked.concat([device]) });
213 async function makeName(key, name, opts) {
214 const o = opts || {}, n = parseName(name);
215 const claim = Object.assign({ "pyrtan-name": 1, name: n.name, domain: n.domain, id: key.id, issued: o.issued !== undefined ? o.issued : now() }, o.extra || {});
216 if (o.handoverFrom) claim.handover = { id: o.handoverFrom.id, sig: b64url(await sign(o.handoverFrom, enc.encode(HANDOVER_TAG + canonical(claim)))) };
217 return signDocument(key, claim);
218 }
219 async function makeRelease(key, name, opts) {
220 const o = opts || {}, n = parseName(name);
221 return signDocument(key, { "pyrtan-name-release": 1, name: n.name, domain: n.domain, id: key.id, issued: o.issued !== undefined ? o.issued : now() });
222 }
223
224 // ------------------------------------------------------------ roots and their backups
225 //
226 // A root made in a page is extractable exactly once: its seed is read out to
227 // be encrypted into the backup, then the key is imported again,
228 // non-extractable, and the seed is wiped. Restoring decrypts the seed and
229 // imports it non-extractable the same way.
230
231 async function createRoot() {
232 const pair = await subtle().generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
233 const jwk = await subtle().exportKey("jwk", pair.privateKey);
234 const seed = unb64url(jwk.d), publicKey = unb64url(jwk.x);
235 return { id: encode(publicKey), seed, publicKey };
236 }
237 async function backupKey(passphrase, salt, iterations) {
238 const base = await subtle().importKey("raw", enc.encode(passphrase), "PBKDF2", false, ["deriveKey"]);
239 return subtle().deriveKey({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
240 }
241 async function makeBackup(seed, id, passphrase, opts) {
242 const o = opts || {};
243 if (String(passphrase).length < 8) throw new Error("a passphrase is at least 8 characters; a long sentence is better");
244 const salt = o.salt || crypto.getRandomValues(new Uint8Array(16)), iv = o.iv || crypto.getRandomValues(new Uint8Array(12)), iterations = o.iterations || BACKUP_ITERATIONS;
245 const data = new Uint8Array(await subtle().encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(BACKUP_TAG + id) }, await backupKey(passphrase, salt, iterations), seed));
246 const backup = { "pyrtan-key-backup": 1, id, made: o.made !== undefined ? o.made : now(), kdf: { name: "PBKDF2-SHA256", iterations, salt: b64url(salt) }, cipher: { name: "AES-256-GCM", iv: b64url(iv) }, data: b64url(data) };
247 if (o.document) backup.document = o.document;
248 return backup;
249 }
250 function backupProblem(b) {
251 if (!b || typeof b !== "object" || b["pyrtan-key-backup"] !== 1) return "not a key backup (pyrtan-key-backup: 1)";
252 if (!valid(b.id)) return "the backup names no identity";
253 if (!b.kdf || b.kdf.name !== "PBKDF2-SHA256" || !(b.kdf.iterations >= MIN_ITERATIONS && b.kdf.iterations <= 10000000) || typeof b.kdf.salt !== "string") return `the backup's kdf is not PBKDF2-SHA256 with at least ${MIN_ITERATIONS} iterations`;
254 if (!b.cipher || b.cipher.name !== "AES-256-GCM" || typeof b.cipher.iv !== "string" || typeof b.data !== "string") return "the backup's cipher is not AES-256-GCM with an iv and data";
255 if (b.document && b.document.id !== b.id.toLowerCase()) return "the document beside the key is another identity's";
256 return null;
257 }
258 /** The seed in a backup; throws for a wrong passphrase or a damaged file. */
259 async function openBackup(b, passphrase) {
260 const bad = backupProblem(b);
261 if (bad) throw new Error(bad);
262 let seed;
263 try {
264 seed = new Uint8Array(await subtle().decrypt({ name: "AES-GCM", iv: unb64url(b.cipher.iv), additionalData: enc.encode(BACKUP_TAG + b.id.toLowerCase()) }, await backupKey(passphrase, unb64url(b.kdf.salt), b.kdf.iterations), unb64url(b.data)));
265 } catch (e) { throw new Error("the passphrase does not open this backup (or the file is damaged)"); }
266 return seed;
267 }
268 /** A non-extractable key from a seed, checked against the identity it should be. */
269 async function rootFromSeed(seed, id) {
270 const key = await fromSeedAndPublic(seed, decode(id));
271 const probe = enc.encode("pyrtan-probe"), sig = await sign(key, probe);
272 const pub = await subtle().importKey("raw", decode(id), { name: "Ed25519" }, false, ["verify"]);
273 if (!(await subtle().verify({ name: "Ed25519" }, pub, sig, probe))) throw new Error("the backup's key is not the identity it names");
274 seed.fill(0);
275 return key;
276 }
277
278 // ------------------------------------------------------------ the device key, in IndexedDB
279
280 const store = {
281 db: null,
282 async open() {
283 if (this.db) return this.db;
284 this.db = await new Promise((res, rej) => { const r = indexedDB.open("pyrtan-identity", 1); r.onupgradeneeded = () => r.result.createObjectStore("keys"); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
285 return this.db;
286 },
287 async get() { const db = await this.open(); return new Promise((res, rej) => { const r = db.transaction("keys").objectStore("keys").get("device"); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); },
288 async put(v) { const db = await this.open(); return new Promise((res, rej) => { const t = db.transaction("keys", "readwrite"); if (v) t.objectStore("keys").put(v, "device"); else t.objectStore("keys").delete("device"); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); },
289 };
290
291 return {
292 PREFIX, ABILITIES, sha256, sha3_256, hex, b64url, unb64url, encode, decode, valid, short, canonical, parseName, requestMessage,
293 available, generate, fromSeedAndPublic, signRequest, noteProblem, store,
294 signDocument, makeDevice, makeIdentity, withDevice, withRevoked, makeName, makeRelease,
295 createRoot, makeBackup, backupProblem, openBackup, rootFromSeed,
296 };
297});