| 1 | // BUD-15 client-side CHK encrypted blobs. Keys remain in the browser page and |
| 2 | // are passed to the caller for sharing or later decryption. |
| 3 | (() => { |
| 4 | "use strict" ; |
| 5 | // Browser encryption for Blossom content. The public API stores and checks |
| 6 | // content-addressed ciphertext while keeping keys in the caller's page. |
| 7 | |
| 8 | const encoder = new TextEncoder(); |
| 9 | const zeroNonce = new Uint8Array(12); |
| 10 | const salt = encoder.encode("hashtree-chk" ); |
| 11 | const info = encoder.encode("encryption-key" ); |
| 12 | |
| 13 | const {bytes, hex, sha256: digest} = globalThis.tiny.util; |
| 14 | const fromHex = value => globalThis.tiny.util.fromHex(value, "expected a 64-character lowercase hex key" ); |
| 15 | const equal = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]); |
| 16 | const aesKey = async key => { |
| 17 | const material = await crypto.subtle.importKey("raw" , key, "HKDF" , false, ["deriveKey" ]); |
| 18 | return crypto.subtle.deriveKey( |
| 19 | {name: "HKDF" , hash: "SHA-256" , salt, info}, |
| 20 | material, |
| 21 | {name: "AES-GCM" , length: 256}, |
| 22 | false, |
| 23 | ["encrypt" , "decrypt" ] |
| 24 | ); |
| 25 | }; |
| 26 | |
| 27 | const encryptCHK = async plaintext => { |
| 28 | const input = bytes(plaintext); |
| 29 | const key = await digest(input); |
| 30 | const encrypted = new Uint8Array( |
| 31 | await crypto.subtle.encrypt({name: "AES-GCM" , iv: zeroNonce}, await aesKey(key), input) |
| 32 | ); |
| 33 | return { |
| 34 | ciphertext: encrypted, |
| 35 | key: hex(key), |
| 36 | hash: hex(await digest(encrypted)) |
| 37 | }; |
| 38 | }; |
| 39 | |
| 40 | const decryptCHK = async (ciphertext, key, hash) => { |
| 41 | const input = bytes(ciphertext); |
| 42 | const chkKey = fromHex(key); |
| 43 | if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) |
| 44 | throw new Error("expected a 64-character lowercase blob hash" ); |
| 45 | if (hex(await digest(input)) !== hash) throw new Error("ciphertext hash mismatch" ); |
| 46 | let plain; |
| 47 | try { |
| 48 | plain = new Uint8Array( |
| 49 | await crypto.subtle.decrypt({name: "AES-GCM" , iv: zeroNonce}, await aesKey(chkKey), input) |
| 50 | ); |
| 51 | } catch { |
| 52 | throw new Error("CHK decryption failed" ); |
| 53 | } |
| 54 | if (!equal(await digest(plain), chkKey)) throw new Error("plaintext hash mismatch" ); |
| 55 | return plain; |
| 56 | }; |
| 57 | |
| 58 | const createURI = (hash, extension, key) => { |
| 59 | if (typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) |
| 60 | throw new Error("expected a 64-character lowercase blob hash" ); |
| 61 | const path = "blossom:" + hash + (extension ? "." + String(extension).replace(/^\.+/, "" ) : "" ); |
| 62 | fromHex(key); |
| 63 | const params = new URLSearchParams({enc: "chk-v1" , k: key}); |
| 64 | return path + "?" + params; |
| 65 | }; |
| 66 | |
| 67 | const parseURI = value => { |
| 68 | if (typeof value !== "string" || !value.startsWith("blossom:" )) throw new Error("expected a Blossom URI" ); |
| 69 | const parsed = new URL(value.slice("blossom:" .length), "https://blossom.invalid" ); |
| 70 | const match = parsed.pathname.match(/^\/([0-9a-f]{64})(?:\.([A-Za-z0-9]+))?$/); |
| 71 | if (!match || parsed.searchParams.get("enc" ) !== "chk-v1" ) throw new Error("invalid CHK Blossom URI" ); |
| 72 | const key = parsed.searchParams.get("k" ); |
| 73 | fromHex(key); |
| 74 | return {hash: match[1], extension: match[2] || "" , key}; |
| 75 | }; |
| 76 | |
| 77 | const api = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI}); |
| 78 | globalThis.tiny = globalThis.tiny || {}; |
| 79 | globalThis.tiny.blossom = {...globalThis.tiny.blossom, encryption: api}; |
| 80 | })(); |