clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / tinyclient / blossom-encryption.js
blossom-encryption.js 3.2K
1// BUD-15 client-side CHK encrypted blobs. Keys remain in the browser page and2// 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 checks6 // content-addressed ciphertext while keeping keys in the caller's page.78 const encoder = new TextEncoder();9 const zeroNonce = new Uint8Array(12);10 const salt = encoder.encode("hashtree-chk");11 const info = encoder.encode("encryption-key");1213 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 };2627 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 };3940 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 };5758 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 };6667 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 };7677 const api = Object.freeze({encryptCHK, decryptCHK, createURI, parseURI});78 globalThis.tiny = globalThis.tiny || {};79 globalThis.tiny.blossom = {...globalThis.tiny.blossom, encryption: api};80})();