tinyrelayfile
tinyrelay/tinyclient/blossom-encryption.js/

tinyclient/blossom-encryption.js raw
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})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs