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 | }); |