| 1 | diff --git a/internal/webui/tiny.js b/internal/webui/tiny.js |
| 2 | index 6716f3e..748e838 100644 |
| 3 | --- a/internal/webui/tiny.js |
| 4 | +++ b/internal/webui/tiny.js |
| 5 | @@ -56,7 +56,37 @@ |
| 6 | const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4); |
| 7 | return Uint8Array.from(atob(text), char => char.charCodeAt(0)); |
| 8 | }; |
| 9 | - tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url}); |
| 10 | + // bech32Encode renders bytes as a NIP-19 bare string such as an nsec or |
| 11 | + // npub. The browser bundle decodes these but has no plain encoder. |
| 12 | + const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; |
| 13 | + const bech32Polymod = values => { |
| 14 | + const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; |
| 15 | + let checksum = 1; |
| 16 | + for (const value of values) { |
| 17 | + const top = checksum >>> 25; |
| 18 | + checksum = (((checksum & 0x1ffffff) << 5) ^ value) >>> 0; |
| 19 | + generators.forEach((generator, index) => { if ((top >>> index) & 1) checksum = (checksum ^ generator) >>> 0; }); |
| 20 | + } |
| 21 | + return checksum; |
| 22 | + }; |
| 23 | + const bech32Encode = (prefix, value) => { |
| 24 | + const words = []; |
| 25 | + let accumulator = 0, bits = 0; |
| 26 | + for (const byte of bytes(value)) { |
| 27 | + accumulator = ((accumulator << 8) | byte) >>> 0; |
| 28 | + bits += 8; |
| 29 | + while (bits >= 5) { |
| 30 | + bits -= 5; |
| 31 | + words.push((accumulator >>> bits) & 31); |
| 32 | + } |
| 33 | + } |
| 34 | + if (bits > 0) words.push((accumulator << (5 - bits)) & 31); |
| 35 | + const expanded = [...prefix].map(char => char.charCodeAt(0) >>> 5).concat(0, [...prefix].map(char => char.charCodeAt(0) & 31)); |
| 36 | + const polymod = bech32Polymod(expanded.concat(words, [0, 0, 0, 0, 0, 0])) ^ 1; |
| 37 | + const checksum = Array.from({length: 6}, (_, index) => (polymod >>> (5 * (5 - index))) & 31); |
| 38 | + return prefix + "1" + words.concat(checksum).map(word => bech32Charset[word]).join(""); |
| 39 | + }; |
| 40 | + tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode}); |
| 41 | // signer returns the active signer: a resumed remote signer first, then a |
| 42 | // NIP-07 extension. |
| 43 | tiny.signer = () => globalThis.tinySigner || globalThis.nostr; |
| 44 | diff --git a/scripts/tiny-namespace.test.mjs b/scripts/tiny-namespace.test.mjs |
| 45 | index 7b0346d..7f49e6b 100644 |
| 46 | --- a/scripts/tiny-namespace.test.mjs |
| 47 | +++ b/scripts/tiny-namespace.test.mjs |
| 48 | @@ -3,6 +3,7 @@ import {webcrypto} from "node:crypto"; |
| 49 | import {readFile} from "node:fs/promises"; |
| 50 | import test from "node:test"; |
| 51 | import vm from "node:vm"; |
| 52 | +import {generateSecretKey, getPublicKey, nip19} from "nostr-tools"; |
| 53 | |
| 54 | const read = name => readFile(new URL(`../internal/webui/${name}`, import.meta.url), "utf8"); |
| 55 | const [tiny, encryption, manifests, upload] = await Promise.all([ |
| 56 | @@ -23,3 +24,19 @@ test("feature modules bootstrap through the shared tiny namespace", () => { |
| 57 | vm.runInNewContext(tiny + encryption + manifests + upload, sandbox); |
| 58 | assert.equal(typeof sandbox.tiny.blossom.manifests.decodeManifest, "function"); |
| 59 | }); |
| 60 | + |
| 61 | +test("bech32Encode matches the reference nsec and npub encodings", () => { |
| 62 | + const sandbox = {crypto: webcrypto, TextEncoder, TextDecoder, URL, URLSearchParams, Uint8Array, ArrayBuffer}; |
| 63 | + sandbox.globalThis = sandbox; |
| 64 | + vm.runInNewContext(tiny, sandbox); |
| 65 | + const {bech32Encode} = sandbox.tiny.util; |
| 66 | + for (let i = 0; i < 8; i++) { |
| 67 | + const secret = generateSecretKey(); |
| 68 | + assert.equal(bech32Encode("nsec", secret), nip19.nsecEncode(secret)); |
| 69 | + const pubkey = getPublicKey(secret); |
| 70 | + assert.equal(bech32Encode("npub", Uint8Array.from(Buffer.from(pubkey, "hex"))), nip19.npubEncode(pubkey)); |
| 71 | + } |
| 72 | + // The NIP-19 example key. |
| 73 | + const example = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; |
| 74 | + assert.equal(bech32Encode("npub", Uint8Array.from(Buffer.from(example, "hex"))), "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"); |
| 75 | +}); |