diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c19d27c..ee51a36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,17 @@ jobs: - name: Install run: npm ci + # public/js/vendor/* is generated by scripts/build-vendor.mjs but + # COMMITTED (deploy never builds). Rebuild and fail on any drift between + # the entry/deps and the committed artifact. `git diff` catches modified + # tracked files but silently ignores NEW untracked output, so also + # require `git status --porcelain` to be empty for the directory. + - name: Vendor bundle drift check + run: | + npm run build:vendor + git diff --exit-code public/js/vendor/ + test -z "$(git status --porcelain public/js/vendor/)" + - name: Typecheck run: npm run typecheck diff --git a/docs/manual-signer-tests.md b/docs/manual-signer-tests.md new file mode 100644 index 0000000..2cd3c1a --- /dev/null +++ b/docs/manual-signer-tests.md @@ -0,0 +1,127 @@ +# Manual signer test checklist + +Real-device acceptance checks for the signer expansion (NIP-46 remote +signers, NIP-55 Amber, pasted local key) plus the NIP-07 regression. Run +against a deployed instance (workers.dev preview or nbread.lol) — the +NIP-55 flows need a real Android device with Amber installed, and the +NIP-46 flows need live wss:// relays. Automated coverage (unit + +integration) already gates CI; this list covers only what a browser and a +phone can prove. + +Conventions used below: + +- "editor" = `/dashboard/posts/new` or `/dashboard/editor?slug=…` +- Signer state lives under `localStorage` keys prefixed `nbread:` — clear + them (or use "Forget this signer" on `/login`) to reset between sections. + +## 1. Amber via bunker:// (NIP-46) — issue #2 acceptance + +End-to-end on any browser (desktop is fine; Amber runs on the phone and +talks over relays). + +- [ ] In Amber, create a bunker:// connection URI and copy it. +- [ ] On `/login`, choose "Remote signer — Amber, nsec.app (NIP-46)", + paste the `bunker://…` URI, press Connect; approve the connect + request in Amber. +- [ ] Sign-in completes: approving the kind 22242 challenge in Amber lands + you on `/dashboard` with your handle/npub shown. +- [ ] Publish: write a post in the editor, press "Sign & publish", approve + the kind 30023 in Amber → redirected to `/dashboard`, post renders at + `https://.nbread.lol/`. +- [ ] Edit: open the same post, change the body, "Sign & republish", + approve in Amber → the blog page shows the new content (same slug). +- [ ] Delete: "Delete post", confirm the dialog, approve the kind 5 in + Amber → post disappears from the blog and the dashboard list. + +## 2. nsec.app remote signer (NIP-46, incl. auth_url) + +- [ ] Pair via `bunker://` from nsec.app (or "Generate nostrconnect:// + link" and paste it into nsec.app) on `/login`. +- [ ] When nsec.app answers with an auth_url, the login page surfaces the + approval link as a link/prompt — it must NOT auto-navigate. Open it, + approve, and confirm the original request then completes. +- [ ] Sign in, publish a post, and delete it — each sign request appears + in nsec.app for approval and completes after approving. + +## 3. NIP-55 — Amber on Android Chrome + +All on the phone; the flow is full-page redirects into the Amber app and +back. + +- [ ] Sign in: `/login` → "Amber on this device (Android)" → "Sign in with + Amber" → Amber opens (get_public_key), approve → returned to + `/login`, then approve the challenge signature → session established. +- [ ] Publish: editor → "Sign & publish" → page hands off to Amber → + approve → returned to the editor, which resumes ("Resuming…"), + mirrors, broadcasts, and lands on `/dashboard`; post is live. +- [ ] Delete: open a post → "Delete post" → confirm → approve the kind 5 + in Amber → resumed on return, post gone. +- [ ] Cancel mid-sign: start a publish, but REJECT (or back out of) the + request in Amber. Back in the editor the status says signing was + cancelled, nothing was published, and the draft-notice offers to + restore your text — restoring brings the full draft back. +- [ ] Oversized post: paste content past the 256 KiB publish cap (the + counter turns red: "too large to publish") and attempt "Sign & + publish" with Amber. The flow must refuse cleanly — either the + intent hand-off fails or `/api/mirror` rejects the event on resume — + with an error message, no partial publish, and the draft intact. +- [ ] Reload safety: after any completed Amber round-trip, reloading the + page must not replay the signature (the callback params are stripped + from the URL). + +## 4. Pasted secret key (local) — desktop + Android + +- [ ] Desktop: `/login` → "Paste secret key (not recommended)" → the red + unencrypted-localStorage warning is visible → paste an `nsec1…` → + the derived npub is shown for confirmation → "Sign in as this key" + completes login. +- [ ] Publish and delete a post from the editor — signing happens in-page + with no prompts and no redirects. +- [ ] "Forget this signer" on `/login` clears the stored key + (`nbread:signer:nsec` gone from localStorage) and returns you to the + method picker. +- [ ] Re-import: paste the same nsec again and sign in — works identically + (and hex-form input works too). +- [ ] Repeat sign-in + publish on Android Chrome. + +## 5. NIP-07 regression (Alby or nos2x) + +Behavior must be identical to before the signer expansion. + +- [ ] `/login` → "Browser extension (NIP-07)" → "Sign in with extension" → + extension approval prompt → `/dashboard`. +- [ ] Publish, edit, and delete a post from the editor; each action + prompts the extension once and completes. +- [ ] With the extension disabled, the panel reports that no NIP-07 + extension (window.nostr) was found instead of failing silently. + +## 6. Negative checks (any signer unless noted) + +- [ ] Expired challenge: fetch `/login`, wait more than 5 minutes (the + nonce TTL is 300 s), then complete the sign-in — the server rejects + the stale challenge and the page shows the error; retrying fresh + succeeds. +- [ ] Dead bunker relay (NIP-46): pair using a `bunker://` URI whose relay + is unreachable — the connect times out with a clear error (60 s + request / 120 s pairing budget), no hang, no crash; the page stays + usable. +- [ ] Wrong-identity signer vs session: sign in as key A, then on `/login` + reconfigure the browser's signer to key B WITHOUT logging out. In the + editor, publishing must refuse with "This browser's signer is a + different Nostr identity than the one signed in…" and publish + nothing. +- [ ] NIP-55 expired pending record: start an Amber sign, leave the phone + idle past the pending TTL, then complete the callback — the editor + reports the request expired, nothing publishes, the draft survives. + +## 7. Devtools network audit (secret never leaves the page) + +With the pasted-key (local) signer configured: + +- [ ] Open devtools → Network, sign in, publish, and delete a post. +- [ ] Inspect EVERY request (fetches, WebSocket frames, and navigations, + including `intent:`/callback URLs on Android): no request body, URL, + or header ever contains the nsec or the 64-hex secret key — only + signed events (pubkey + sig) and the public key ever leave the page. +- [ ] Repeat the sweep once for a NIP-46 session: WebSocket frames to the + bunker relay carry only encrypted kind 24133 envelopes. diff --git a/docs/ops.md b/docs/ops.md index 2c13b81..ef748fc 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -320,3 +320,11 @@ see the manual-check notes at the bottom of `scripts/smoke.sh`). Rollback: `wrangler deployments list` + `wrangler rollback` restore the previous Worker version; DNS records can stay (the guard 404s anything it does not recognize). + +## 7. Vendor crypto bundle + +`public/js/vendor/nostr-crypto.js` is generated from +`scripts/vendor/crypto-entry.js` by `npm run build:vendor` (esbuild, +devDependency only — deploy never builds) and committed unminified. Never +edit the artifact by hand: edit the entry, rebuild, commit both. CI rebuilds +and fails on any drift between the entry and the committed bundle. diff --git a/package-lock.json b/package-lock.json index 61fe90a..e435378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,8 +22,10 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.18.4", + "@noble/ciphers": "2.1.1", "@types/markdown-it": "^14.1.2", "@types/node": "^26.1.1", + "esbuild": "0.28.1", "fast-xml-parser": "^5.10.0", "nostr-tools": "^2.23.9", "typescript": "^7.0.2", diff --git a/package.json b/package.json index c6fa41e..e6faa87 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "vitest run", "test:watch": "vitest", "fixtures": "node --experimental-strip-types scripts/gen-fixtures.ts", + "build:vendor": "node scripts/build-vendor.mjs", "migrate:local": "wrangler d1 migrations apply nbread --local", "smoke": "bash scripts/smoke.sh local", "cf-typegen": "wrangler types" @@ -30,8 +31,10 @@ }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.18.4", + "@noble/ciphers": "2.1.1", "@types/markdown-it": "^14.1.2", "@types/node": "^26.1.1", + "esbuild": "0.28.1", "fast-xml-parser": "^5.10.0", "nostr-tools": "^2.23.9", "typescript": "^7.0.2", diff --git a/public/css/style.css b/public/css/style.css index 913d830..2561e6b 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -532,3 +532,41 @@ body.editor-wide { border-left: 3px solid var(--border); color: var(--muted); } + +/* --- Login: signer method picker + panels --------------------------------- + The picker is a vertical stack of method buttons; each panel is a small + form block. The .danger paragraph follows the existing danger color + convention (see .editor-meta.danger above). */ + +.signer-picker button { + display: block; + width: 100%; + max-width: 28rem; + margin: 0.5rem 0; + text-align: left; +} + +.signer-panel { + margin: 1rem 0; +} + +.signer-panel input[type="text"], +.signer-panel input[type="password"] { + max-width: 28rem; +} + +.signer-npub { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85rem; + word-break: break-all; +} + +.signer-panel p.danger { + color: #c62828; +} + +@media (prefers-color-scheme: dark) { + .signer-panel p.danger { + color: #ef5350; + } +} diff --git a/public/js/README.md b/public/js/README.md index ab5ee49..4325bff 100644 --- a/public/js/README.md +++ b/public/js/README.md @@ -1,13 +1,28 @@ # public/js -Client-side NIP-07 glue plus the hand-rolled editor (no build step, no -dependencies — every file is a plain IIFE served as-is): +Client-side signer glue plus the hand-rolled editor (no build step at serve +time, no runtime dependencies — every file is a plain IIFE served as-is): +- `signer-core.js` — DOM-free pure helpers (`globalThis.NbreadSignerCore`): + nsec decoding, pubkey normalization, the NIP-55 (Amber) intent URL + builder + callback parser, and the pending-record make/validate pair the + redirect flow persists across page unloads. Unit-tested directly. +- `signer.js` — the dispatcher (`globalThis.NbreadSigner`): one signing + seam (`ready`/`getPublicKey`/`signEvent`) over four backends — NIP-07 + extension, pasted local key (signs in-page, key never leaves the browser), + NIP-55 Amber redirects (incl. `resumePending()` after the callback), and + a `register()`ed NIP-46 backend. Owns the `nbread:signer:*` localStorage + keys and `forget()`. +- `signer-nip46.js` — NIP-46 remote-signer client + (`globalThis.NbreadNip46`): bunker:// / nostrconnect:// pairing, the + NIP-44 (with legacy NIP-04 fallback) request envelope, auth_url + surfacing, and schnorr verification of every returned event; registers + itself as the dispatcher's "nip46" backend on load. - `login.js` (P4) — fetches a one-time challenge, signs the kind 22242 auth - event via `window.nostr`, POSTs it to `/login`. Keys never leave the - extension. -- `editor.js` (P5) — builds kind 30023 / kind 5 events, signs via the - extension, broadcasts to relays, POSTs to `/api/mirror`. Also owns the + event via `NbreadSigner`, POSTs it to `/login`. Also owns the login-page + method picker and per-method panels. +- `editor.js` (P5) — builds kind 30023 / kind 5 events, signs via + `NbreadSigner`, broadcasts to relays, POSTs to `/api/mirror`. Also owns the server-rendered preview fetch: it listens for the `nbread:preview-requested` event (dispatched by the Preview tab), caches the last previewed value, and calls @@ -24,5 +39,24 @@ dependencies — every file is a plain IIFE served as-is): draft autosave (`window.NbreadDraft`). All textarea mutations go through one `execCommand("insertText")` seam so native undo survives. -Load order on the editor page matters: `editor-md.js` → -`editor-toolbar.js` → `editor.js`. +Load order matters (classic ` + + + + diff --git a/src/views/main/home.tsx b/src/views/main/home.tsx index 19fa03e..5d8844f 100644 --- a/src/views/main/home.tsx +++ b/src/views/main/home.tsx @@ -48,9 +48,10 @@ export function MainHome() { handle@nbread.lol everywhere on Nostr.
  • - Write anywhere — the built-in markdown editor - with NIP-07 signing, or any Nostr client; your blog stays in sync - with the relays. + Write anywhere — the built-in markdown editor, + signing in with your Nostr key (browser extension, Amber, or a + remote signer), or any Nostr client; your blog stays in sync with + the relays.
  • Make it yours — custom theme CSS, no build step diff --git a/src/views/main/login.tsx b/src/views/main/login.tsx index 760077f..bd590aa 100644 --- a/src/views/main/login.tsx +++ b/src/views/main/login.tsx @@ -2,8 +2,15 @@ import { Layout } from "../layout"; import { SiteHeader, SiteFooter } from "./chrome"; /** - * Login page: NIP-07 challenge-response. All logic lives in /js/login.js - * (fetch challenge → window.nostr.signEvent kind 22242 → POST /login). + * Login page: signer method picker + per-method panels. All logic lives in + * /js/login.js on top of the NbreadSigner dispatcher (signer.js): fetch a + * one-time challenge, sign a kind 22242 event with the chosen backend + * (NIP-07 extension, NIP-46 remote signer, NIP-55 Amber redirect, or a + * pasted local key), POST it to /login. + * + * Every panel is server-rendered statically and hidden via the `hidden` + * attribute — login.js reveals the right one. No inline executable script + * (CSP: script-src 'self'); nothing here renders user/relay-sourced values. */ export function LoginPage() { return ( @@ -12,20 +19,141 @@ export function LoginPage() {

    Sign in

    - Sign in with your Nostr key. You need a{" "} + Sign in with your Nostr key. Choose how you want to sign: a{" "} NIP-07 browser extension {" "} - (Alby, nos2x, …) — your key never leaves it; you only sign a - one-time login challenge. + (Alby, nos2x, …), a remote signer, Amber on Android, or a pasted + key. Whichever you pick, you only sign a one-time login challenge.

    - + + + {/* Shown when a signer is already configured in this browser. */} + + + + + + + + + + + +

    + + + +
    diff --git a/src/views/main/privacy.tsx b/src/views/main/privacy.tsx index 01bec3a..8a5aae0 100644 --- a/src/views/main/privacy.tsx +++ b/src/views/main/privacy.tsx @@ -30,8 +30,11 @@ export function PrivacyPage() {

    Sessions

    - Signing in with your NIP-07 extension sets one first-party cookie, - valid for up to 90 days, that maps to your public key. Nothing else. + Signing in with your Nostr key sets one first-party cookie, valid + for up to 90 days, that maps to your public key. Nothing else. Your + signer settings — and, if you choose the pasted-key option, your + secret key — are stored in your browser's localStorage and never + sent to the server.

    Operational data

    diff --git a/test/integration/__snapshots__/discover.spec.ts.snap b/test/integration/__snapshots__/discover.spec.ts.snap index 1d36ba3..163f36b 100644 --- a/test/integration/__snapshots__/discover.spec.ts.snap +++ b/test/integration/__snapshots__/discover.spec.ts.snap @@ -2,4 +2,4 @@ exports[`discover feed (P6) > matches the snapshot 1`] = `"Discover — nbread.lol

    Discover

    Recent posts from blogs on nbread.lol.

    "`; -exports[`landing page (P6 polish) > pitches the product and carries the login CTA 1`] = `"nbread.lol — Nostr-native blogging

    nbread.lol

    Nostr-native, no-nonsense, super-fast blogging.

    Your posts are NIP-23 events signed by your key — we just render them beautifully at you.nbread.lol.

    Sign in with NostrDiscover blogs →

    • Your keys, your words — every post is a signed event on public relays; leave any time and take everything with you.
    • A real blog, not a feed — a clean site at handle.nbread.lol with RSS, Atom, and a sitemap.
    • Free NIP-05 — be handle@nbread.lol everywhere on Nostr.
    • Write anywhere — the built-in markdown editor with NIP-07 signing, or any Nostr client; your blog stays in sync with the relays.
    • Make it yours — custom theme CSS, no build step required.
    • No ads, no trackers, no JavaScript on blogs — tiny pages, fast everywhere.
    "`; +exports[`landing page (P6 polish) > pitches the product and carries the login CTA 1`] = `"nbread.lol — Nostr-native blogging

    nbread.lol

    Nostr-native, no-nonsense, super-fast blogging.

    Your posts are NIP-23 events signed by your key — we just render them beautifully at you.nbread.lol.

    Sign in with NostrDiscover blogs →

    • Your keys, your words — every post is a signed event on public relays; leave any time and take everything with you.
    • A real blog, not a feed — a clean site at handle.nbread.lol with RSS, Atom, and a sitemap.
    • Free NIP-05 — be handle@nbread.lol everywhere on Nostr.
    • Write anywhere — the built-in markdown editor, signing in with your Nostr key (browser extension, Amber, or a remote signer), or any Nostr client; your blog stays in sync with the relays.
    • Make it yours — custom theme CSS, no build step required.
    • No ads, no trackers, no JavaScript on blogs — tiny pages, fast everywhere.
    "`; diff --git a/test/unit/signer-core.spec.ts b/test/unit/signer-core.spec.ts new file mode 100644 index 0000000..1373988 --- /dev/null +++ b/test/unit/signer-core.spec.ts @@ -0,0 +1,534 @@ +// Packet B: the pure signer core (public/js/signer-core.js) behind the +// NbreadSigner dispatcher. The IIFE is imported for its side effect (it +// assigns globalThis.NbreadSignerCore) and exercised directly against the +// committed crypto vendor bundle. signer.js itself is deliberately NOT +// imported here — it touches window/localStorage/location and cannot load in +// workerd. +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +// @ts-ignore — plain browser IIFE, intentionally shipped without types +import "../../public/js/vendor/nostr-crypto.js"; +// @ts-ignore — plain browser IIFE, intentionally shipped without types +import "../../public/js/signer-core.js"; +import keys from "../fixtures/keys.json"; + +type Unsigned = { + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + id?: string; +}; + +type PendingRecord = { + kind: string; + unsigned: (Unsigned & { id: string }) | null; + returnTo: string; + ts: number; +}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const C = (globalThis as any).NbreadCrypto as { + hexToBytes: (hex: string) => Uint8Array; + npubEncode: (pkHex: string) => string; + nsecEncode: (skHex: string) => string; + eventId: (unsigned: Unsigned) => string; + getPublicKeyHex: (skBytes: Uint8Array) => string; + finalizeEvent: ( + unsigned: Partial, + skBytes: Uint8Array, + ) => Unsigned & { id: string; sig: string }; +}; + +const Core = (globalThis as any).NbreadSignerCore as { + decodeNsec: (input: string) => { skHex: string; pkHex: string; npub: string }; + normalizePubkey: (input: string) => string; + buildNip55Intent: (opts: { + type: string; + callbackUrl: string; + eventJson?: string; + }) => string; + parseNip55Callback: (search: string) => { + kind: "pubkey" | "sign" | null; + value: string | null; + }; + makePendingRecord: (opts: { + kind: string; + unsigned: (Unsigned & { id: string }) | null; + returnTo: string; + nowSec: number; + }) => PendingRecord; + validatePending: (record: unknown, nowSec: number, ttlSec?: number) => boolean; + completeUnsigned: ( + unsigned: Partial, + pubkeyHex: string, + nowSec: number, + ) => Unsigned & { id: string }; +}; + +const SIG_128 = "ab".repeat(64); // shape-valid 128-hex signature stand-in + +describe("NbreadSignerCore.decodeNsec", () => { + const aliceNsec = C.nsecEncode(keys.alice.sk); + + it("decodes a valid nsec1 string and derives pubkey + npub", () => { + const decoded = Core.decodeNsec(aliceNsec); + expect(decoded.skHex).toBe(keys.alice.sk); + expect(decoded.pkHex).toBe(keys.alice.pk); + expect(decoded.npub).toBe(C.npubEncode(keys.alice.pk)); + }); + + it("accepts raw 64-hex (either case, surrounding whitespace)", () => { + expect(Core.decodeNsec(keys.bob.sk).pkHex).toBe(keys.bob.pk); + expect(Core.decodeNsec(` ${keys.bob.sk.toUpperCase()} `).skHex).toBe(keys.bob.sk); + }); + + it("derived pkHex matches NbreadCrypto.getPublicKeyHex", () => { + const decoded = Core.decodeNsec(keys.mallory.sk); + expect(decoded.pkHex).toBe(C.getPublicKeyHex(C.hexToBytes(keys.mallory.sk))); + }); + + it("rejects the wrong hrp (an npub is not a secret key)", () => { + expect(() => Core.decodeNsec(C.npubEncode(keys.alice.pk))).toThrow(); + }); + + it("rejects a corrupted checksum", () => { + const last = aliceNsec.slice(-1); + const flipped = aliceNsec.slice(0, -1) + (last === "q" ? "p" : "q"); + expect(() => Core.decodeNsec(flipped)).toThrow(); + }); + + it("rejects wrong lengths and junk", () => { + expect(() => Core.decodeNsec(keys.alice.sk.slice(0, 62))).toThrow(); // 62 hex chars + expect(() => Core.decodeNsec(keys.alice.sk + "00")).toThrow(); // 66 hex chars + expect(() => Core.decodeNsec("not-a-key")).toThrow(); + expect(() => Core.decodeNsec("")).toThrow(); + }); + + it("rejects out-of-range scalars the curve refuses", () => { + expect(() => Core.decodeNsec("00".repeat(32))).toThrow(); // zero scalar + expect(() => Core.decodeNsec("ff".repeat(32))).toThrow(); // >= group order + }); +}); + +describe("NbreadSignerCore.normalizePubkey", () => { + it("accepts npub1… and 64-hex, normalizing to lowercase hex", () => { + expect(Core.normalizePubkey(C.npubEncode(keys.alice.pk))).toBe(keys.alice.pk); + expect(Core.normalizePubkey(keys.alice.pk)).toBe(keys.alice.pk); + expect(Core.normalizePubkey(keys.alice.pk.toUpperCase())).toBe(keys.alice.pk); + }); + + it("rejects nsec strings, short hex, and junk", () => { + expect(() => Core.normalizePubkey(C.nsecEncode(keys.alice.sk))).toThrow(); + expect(() => Core.normalizePubkey("abc123")).toThrow(); + expect(() => Core.normalizePubkey("")).toThrow(); + }); +}); + +describe("NbreadSignerCore.buildNip55Intent", () => { + const callbackUrl = "https://nbread.lol/write?nip55=sign&event="; + + it("builds the exact get_public_key intent URL", () => { + const url = Core.buildNip55Intent({ + type: "get_public_key", + callbackUrl: "https://nbread.lol/login?nip55=pubkey&event=", + }); + expect(url).toBe( + "intent:" + + "#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;" + + "S.type=get_public_key;S.appName=nbread;" + + "S.callbackUrl=https://nbread.lol/login?nip55=pubkey&event=;end", + ); + }); + + it("builds the exact sign_event intent URL with the event JSON URL-encoded", () => { + // Special characters that must survive encodeURIComponent round-trips: + // quotes, ampersands, hashes, semicolons, unicode. + const eventJson = JSON.stringify({ + id: "cd".repeat(32), + pubkey: keys.alice.pk, + kind: 1, + created_at: 1700000000, + tags: [["t", "a&b"], ["client", "nbread"]], + content: 'hello "world" — #tags & ; semicolons', + }); + const url = Core.buildNip55Intent({ + type: "sign_event", + callbackUrl, + eventJson, + }); + expect(url).toBe( + "intent:" + + encodeURIComponent(eventJson) + + "#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;" + + "S.type=sign_event;S.appName=nbread;" + + "S.callbackUrl=https://nbread.lol/write?nip55=sign&event=;end", + ); + // The encoded payload must not leak raw intent-URL metacharacters. + const payload = url.slice("intent:".length, url.indexOf("#Intent;")); + expect(payload).not.toMatch(/[;#&" ]/); + expect(decodeURIComponent(payload)).toBe(eventJson); + }); + + it("rejects unknown intent types and missing callback", () => { + expect(() => Core.buildNip55Intent({ type: "nip04_decrypt", callbackUrl })).toThrow(); + expect(() => Core.buildNip55Intent({ type: "", callbackUrl })).toThrow(); + expect(() => + Core.buildNip55Intent({ type: "sign_event", callbackUrl: "" }), + ).toThrow(); + }); +}); + +describe("NbreadSignerCore.parseNip55Callback", () => { + it("extracts the signature from the marked sign form", () => { + expect(Core.parseNip55Callback(`?nip55=sign&event=${SIG_128}`)).toEqual({ + kind: "sign", + value: SIG_128, + }); + }); + + it("extracts the pubkey from the marked pubkey form (hex and npub)", () => { + const npub = C.npubEncode(keys.alice.pk); + expect(Core.parseNip55Callback(`?nip55=pubkey&event=${keys.alice.pk}`)).toEqual({ + kind: "pubkey", + value: keys.alice.pk, + }); + expect(Core.parseNip55Callback(`?nip55=pubkey&event=${npub}`)).toEqual({ + kind: "pubkey", + value: npub, + }); + }); + + it("tolerates alternate result params across Amber versions once marked", () => { + // With the nip55 marker present, the value is extracted from whichever + // param this Amber version appended. + expect(Core.parseNip55Callback(`?nip55=sign&sig=${SIG_128}`)).toEqual({ + kind: "sign", + value: SIG_128, + }); + expect(Core.parseNip55Callback(`?nip55=sign&signature=${SIG_128}`)).toEqual({ + kind: "sign", + value: SIG_128, + }); + expect(Core.parseNip55Callback(`?nip55=pubkey&result=${keys.bob.pk}`)).toEqual({ + kind: "pubkey", + value: keys.bob.pk, + }); + const npub = C.npubEncode(keys.bob.pk); + expect(Core.parseNip55Callback(`?nip55=pubkey&pubkey=${npub}`)).toEqual({ + kind: "pubkey", + value: npub, + }); + // Long-form marker values still name the flow. + expect(Core.parseNip55Callback(`?nip55=sign_event&event=${SIG_128}`)).toEqual({ + kind: "sign", + value: SIG_128, + }); + expect( + Core.parseNip55Callback(`?nip55=get_public_key&event=${keys.bob.pk}`), + ).toEqual({ kind: "pubkey", value: keys.bob.pk }); + }); + + it("ignores marker-less URLs even when they carry result-shaped params", () => { + // Regression: an innocent link with ?pubkey=/?sig=/… must NOT be treated + // as a NIP-55 callback (it would consume/strip signer state). + const none = { kind: null, value: null }; + expect(Core.parseNip55Callback(`?sig=${SIG_128}`)).toEqual(none); + expect(Core.parseNip55Callback(`?signature=${SIG_128}`)).toEqual(none); + expect(Core.parseNip55Callback(`?event=${SIG_128}`)).toEqual(none); + expect(Core.parseNip55Callback(`?result=${keys.bob.pk}`)).toEqual(none); + expect(Core.parseNip55Callback(`?pubkey=${keys.bob.pk}`)).toEqual(none); + expect(Core.parseNip55Callback(`?npub=${C.npubEncode(keys.bob.pk)}`)).toEqual(none); + // Unknown marker values do not count either. + expect(Core.parseNip55Callback(`?nip55=wat&event=${SIG_128}`)).toEqual(none); + }); + + it("keeps the marker's kind even when a value is missing (cancel case)", () => { + expect(Core.parseNip55Callback("?nip55=sign")).toEqual({ + kind: "sign", + value: null, + }); + expect(Core.parseNip55Callback("?nip55=sign&event=")).toEqual({ + kind: "sign", + value: null, + }); + }); + + it("returns null kind when nothing NIP-55-shaped is present", () => { + expect(Core.parseNip55Callback("")).toEqual({ kind: null, value: null }); + expect(Core.parseNip55Callback("?")).toEqual({ kind: null, value: null }); + expect(Core.parseNip55Callback("?utm_source=x&page=2")).toEqual({ + kind: null, + value: null, + }); + // A value that is neither a signature nor a pubkey shape, with no marker. + expect(Core.parseNip55Callback("?event=hello")).toEqual({ + kind: null, + value: null, + }); + }); +}); + +describe("NbreadSignerCore pending records", () => { + const NOW = 1_700_000_000; + + function unsignedWithId(): Unsigned & { id: string } { + const unsigned: Unsigned = { + pubkey: keys.alice.pk, + created_at: NOW, + kind: 30023, + tags: [["d", "my-post"]], + content: "hello", + }; + return { ...unsigned, id: C.eventId(unsigned) }; + } + + it("makePendingRecord builds a stashable record", () => { + const unsigned = unsignedWithId(); + const record = Core.makePendingRecord({ + kind: "publish", + unsigned, + returnTo: "/write?slug=my-post", + nowSec: NOW, + }); + expect(record).toEqual({ + kind: "publish", + unsigned, + returnTo: "/write?slug=my-post", + ts: NOW, + }); + // Survives the JSON round-trip through localStorage. + expect(Core.validatePending(JSON.parse(JSON.stringify(record)), NOW)).toBe(true); + }); + + it("makePendingRecord rejects unknown kinds and half-built events", () => { + const unsigned = unsignedWithId(); + expect(() => + Core.makePendingRecord({ kind: "evil", unsigned, returnTo: "/", nowSec: NOW }), + ).toThrow(); + const noId = { ...unsigned } as Partial; + delete noId.id; + expect(() => + Core.makePendingRecord({ + kind: "publish", + unsigned: noId as Unsigned & { id: string }, + returnTo: "/", + nowSec: NOW, + }), + ).toThrow(); + }); + + it("allows a null unsigned for get_public_key round-trips", () => { + const record = Core.makePendingRecord({ + kind: "login", + unsigned: null, + returnTo: "/login", + nowSec: NOW, + }); + expect(record.unsigned).toBeNull(); + expect(Core.validatePending(record, NOW + 5)).toBe(true); + }); + + it("validatePending: fresh valid, expired invalid (10-min TTL)", () => { + const record = Core.makePendingRecord({ + kind: "delete", + unsigned: unsignedWithId(), + returnTo: "/", + nowSec: NOW, + }); + expect(Core.validatePending(record, NOW)).toBe(true); + expect(Core.validatePending(record, NOW + 599)).toBe(true); + expect(Core.validatePending(record, NOW + 600)).toBe(true); // exactly at TTL + expect(Core.validatePending(record, NOW + 601)).toBe(false); // past TTL + expect(Core.validatePending(record, NOW + 3, 2)).toBe(false); // custom ttlSec + }); + + it("validatePending: wrong kind / tampered record invalid", () => { + const record = Core.makePendingRecord({ + kind: "publish", + unsigned: unsignedWithId(), + returnTo: "/", + nowSec: NOW, + }); + expect(Core.validatePending({ ...record, kind: "install-malware" }, NOW)).toBe(false); + expect(Core.validatePending({ ...record, ts: "yesterday" }, NOW)).toBe(false); + expect( + Core.validatePending( + { ...record, unsigned: { ...record.unsigned, id: "nope" } }, + NOW, + ), + ).toBe(false); + expect(Core.validatePending(null, NOW)).toBe(false); + expect(Core.validatePending("garbage", NOW)).toBe(false); + // A record stamped far in the future (clock rollback / tampering). + expect(Core.validatePending({ ...record, ts: NOW + 3600 }, NOW)).toBe(false); + }); +}); + +describe("NbreadSignerCore.completeUnsigned", () => { + const NOW = 1_700_000_123; + + it("fills pubkey/defaults and precomputes the NIP-01 id", () => { + const full = Core.completeUnsigned( + { kind: 1, content: "gm", tags: [["t", "gm"]], created_at: NOW }, + keys.alice.pk, + NOW, + ); + expect(full.pubkey).toBe(keys.alice.pk); + expect(full.id).toBe( + C.eventId({ + pubkey: keys.alice.pk, + created_at: NOW, + kind: 1, + tags: [["t", "gm"]], + content: "gm", + }), + ); + }); + + it("defaults created_at to nowSec and tags/content to empty", () => { + const full = Core.completeUnsigned({ kind: 22242 }, keys.bob.pk, NOW); + expect(full.created_at).toBe(NOW); + expect(full.tags).toEqual([]); + expect(full.content).toBe(""); + expect(full.id).toHaveLength(64); + }); + + it("rejects events without an integer kind", () => { + expect(() => Core.completeUnsigned({ content: "x" }, keys.alice.pk, NOW)).toThrow(); + expect(() => + Core.completeUnsigned({ kind: 1.5, content: "x" }, keys.alice.pk, NOW), + ).toThrow(); + }); +}); + +// --- NbreadSigner.resumePending: callback-type <-> pending-record binding ---- +// +// signer.js touches window/localStorage/location, so (like signer-nip46.spec) +// it is imported dynamically in beforeAll after minimal shims are installed — +// static imports hoist above module-body statements. `history` stays +// undefined; stripCallbackParams swallows that (cosmetic only). +describe("NbreadSigner.resumePending (NIP-55 callback binding)", () => { + const KEY_PENDING = "nbread:nip55:pending"; + const KEY_NIP55 = "nbread:signer:nip55"; + const KEY_METHOD = "nbread:signer:method"; + + const store = new Map(); + const loc = { + href: "https://nbread.lol/login", + origin: "https://nbread.lol", + pathname: "/login", + search: "", + }; + + type ResumeResult = { + kind: string | null; + unsigned: unknown; + signed?: Record; + pubkey?: string; + error?: string; + } | null; + + let Signer: { resumePending: () => ResumeResult }; + + beforeAll(async () => { + (globalThis as any).localStorage = { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + }; + (globalThis as any).location = loc; + // @ts-ignore — plain browser IIFE, intentionally shipped without types + await import("../../public/js/signer.js"); + Signer = (globalThis as any).NbreadSigner; + }); + + beforeEach(() => { + store.clear(); + setSearch(""); + }); + + function nowSec() { + return Math.floor(Date.now() / 1000); + } + function stash(record: unknown) { + store.set(KEY_PENDING, JSON.stringify(record)); + } + function setSearch(search: string) { + loc.search = search; + loc.href = "https://nbread.lol/login" + search; + } + function signFlowUnsigned() { + const unsigned: Unsigned = { + pubkey: keys.alice.pk, + created_at: 1_700_000_000, + kind: 1, + tags: [], + content: "hi", + }; + return { ...unsigned, id: C.eventId(unsigned) }; + } + + it("returns null and leaves all state alone on a marker-less URL", () => { + stash({ kind: "login", unsigned: null, returnTo: "/login", ts: nowSec() }); + setSearch(`?pubkey=${keys.alice.pk}`); + expect(Signer.resumePending()).toBeNull(); + expect(store.has(KEY_PENDING)).toBe(true); // NOT consumed + expect(store.has(KEY_NIP55)).toBe(false); + expect(store.has(KEY_METHOD)).toBe(false); + }); + + it("completes a legitimate get_public_key round-trip and persists the pubkey", () => { + stash({ kind: "login", unsigned: null, returnTo: "/login", ts: nowSec() }); + setSearch(`?nip55=pubkey&event=${keys.alice.pk}`); + const res = Signer.resumePending(); + expect(res).toEqual({ kind: "login", unsigned: null, pubkey: keys.alice.pk }); + expect(store.has(KEY_PENDING)).toBe(false); // one shot + expect(JSON.parse(store.get(KEY_NIP55)!)).toEqual({ pkHex: keys.alice.pk }); + expect(store.get(KEY_METHOD)).toBe("nip55"); + }); + + it("completes a legitimate sign_event round-trip (contract shape unchanged)", () => { + const full = signFlowUnsigned(); + // finalizeEvent rederives pubkey/id from the same fields, so its sig is + // exactly what Amber would append for this stashed unsigned event. + const signed = C.finalizeEvent(full, C.hexToBytes(keys.alice.sk)); + stash({ kind: "publish", unsigned: full, returnTo: "/write", ts: nowSec() }); + setSearch(`?nip55=sign&event=${signed.sig}`); + const res = Signer.resumePending(); + expect(res?.error).toBeUndefined(); + expect(res?.kind).toBe("publish"); + expect(res?.signed).toEqual({ ...full, sig: signed.sig }); + }); + + it("rejects a sign callback against a pubkey-flow pending record and consumes it", () => { + stash({ kind: "login", unsigned: null, returnTo: "/login", ts: nowSec() }); + setSearch(`?nip55=sign&event=${SIG_128}`); + const res = Signer.resumePending(); + expect(res?.error).toBe("unexpected signer callback"); + expect(res?.signed).toBeUndefined(); + expect(res?.pubkey).toBeUndefined(); + expect(store.has(KEY_PENDING)).toBe(false); // consumed even on rejection + expect(store.has(KEY_NIP55)).toBe(false); + expect(store.has(KEY_METHOD)).toBe(false); + }); + + it("rejects a pubkey callback against a sign-flow pending record — nothing persisted", () => { + stash({ kind: "publish", unsigned: signFlowUnsigned(), returnTo: "/write", ts: nowSec() }); + setSearch(`?nip55=pubkey&event=${keys.mallory.pk}`); + const res = Signer.resumePending(); + expect(res?.error).toBe("unexpected signer callback"); + expect(res?.pubkey).toBeUndefined(); + expect(store.has(KEY_PENDING)).toBe(false); + expect(store.has(KEY_NIP55)).toBe(false); // attacker pubkey never stored + expect(store.has(KEY_METHOD)).toBe(false); + }); + + it("never persists a pubkey when no pending record exists at all", () => { + setSearch(`?nip55=pubkey&event=${keys.mallory.pk}`); + const res = Signer.resumePending(); + expect(res?.error).toMatch(/expired/); + expect(store.has(KEY_NIP55)).toBe(false); + expect(store.has(KEY_METHOD)).toBe(false); + }); +}); diff --git a/test/unit/signer-nip46.spec.ts b/test/unit/signer-nip46.spec.ts new file mode 100644 index 0000000..18fc403 --- /dev/null +++ b/test/unit/signer-nip46.spec.ts @@ -0,0 +1,731 @@ +// Packet C: NIP-46 remote-signer client (public/js/signer-nip46.js). The +// PURE surface — bunker:// parsing, nostrconnect:// building, the encrypted +// request/response envelope, and the transport-injectable state machine — +// is exercised with committed fixture keys and a scripted fake transport; +// no real sockets, no DOM, no localStorage. +// +// Load order matters: the crypto vendor bundle must exist before the IIFE +// runs, and a minimal NbreadSigner.register stub must exist before its +// load-time side effect fires — so signer-nip46.js is imported dynamically +// in beforeAll (static imports are hoisted above module-body statements). +import { beforeAll, describe, expect, it } from "vitest"; +// @ts-ignore — plain browser IIFE, intentionally shipped without types +import "../../public/js/vendor/nostr-crypto.js"; +import keys from "../fixtures/keys.json"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const C = (globalThis as any).NbreadCrypto; + +type Nip46Msg = { + id: string | number; + method?: string; + params?: string[]; + result?: string; + error?: string; +}; + +type Nip46Api = { + parseBunkerUri: (uri: string) => { + remoteSignerPubkey: string; + relays: string[]; + secret?: string; + }; + buildNostrconnectUri: (opts: { + clientPubkey: string; + relays: string[]; + secret: string; + name?: string; + }) => string; + encodeRequest: ( + convKey: Uint8Array, + req: { id: string | number; method: string; params: unknown[] }, + ) => string; + decodeResponse: ( + convKey: Uint8Array, + content: string, + nip04Keys?: { skBytes: Uint8Array; peerPubkey: string }, + ) => Promise; + createClient: (options: any) => { + clientPubkey: string; + start: () => void; + close: () => void; + connect: (pk?: string, secret?: string) => Promise; + get_public_key: () => Promise; + sign_event: (unsignedJson: string) => Promise; + isLegacyNip04: () => boolean; + }; + backend: { + ready: () => { ok: boolean; reason?: string }; + getPublicKey: () => string; + signEvent: (unsigned: unknown) => Promise; + configure: (input: unknown, opts?: unknown) => Promise<{ userPubkey: string }>; + }; +}; + +let N: Nip46Api; +const registered: Record = {}; + +beforeAll(async () => { + (globalThis as any).NbreadSigner = { + register(name: string, backend: unknown) { + registered[name] = backend; + }, + }; + // @ts-ignore — plain browser IIFE, intentionally shipped without types + await import("../../public/js/signer-nip46.js"); + N = (globalThis as any).NbreadNip46 as Nip46Api; +}); + +// Fixture personas: alice is the nbread client (ephemeral key), bob is the +// remote signer, mallory's pubkey doubles as the "user identity" the signer +// holds — three distinct keys keeps the roles honest. +const ALICE_SK = keys.alice.sk; +const ALICE_PK = keys.alice.pk; +const BOB_SK = keys.bob.sk; +const BOB_PK = keys.bob.pk; +const USER_PK = keys.mallory.pk; + +const aliceSk = C.hexToBytes(ALICE_SK) as Uint8Array; +const bobSk = C.hexToBytes(BOB_SK) as Uint8Array; +/** NIP-44 conversation keys are symmetric: alice↔bob derive the same key. */ +const convKey = C.nip44ConversationKey(aliceSk, BOB_PK) as Uint8Array; + +/** Let the async handleFrame/crypto.subtle chains settle (macrotask flush). */ +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** + * Pre-attach a no-op handler so a promise that rejects during a flush (before + * `expect(p).rejects` observes it) never trips workerd's unhandled-rejection + * reporting. Returns the same promise — assertions still see the rejection. + */ +function expectRejection(p: Promise): Promise { + p.catch(() => {}); + return p; +} + +// --------------------------------------------------------------------------- +// Scripted fakes +// --------------------------------------------------------------------------- + +type SentFrame = { relay: string; frame: any[] }; + +function makeFakeTransport() { + const sent: SentFrame[] = []; + let handler: ((frame: any[]) => void) | null = null; + return { + sent, + send(relay: string, frame: any[]) { + sent.push({ relay, frame }); + }, + onMessage(cb: (frame: any[]) => void) { + handler = cb; + }, + close() {}, + /** Test hook: deliver a relay frame to the client. */ + deliver(frame: any[]) { + if (handler) handler(frame); + }, + }; +} + +function makeFakeTimers() { + let nextHandle = 1; + const timers = new Map void>(); + return { + timers, + set: (fn: () => void, _ms: number) => { + const handle = nextHandle++; + timers.set(handle, fn); + return handle; + }, + clear: (handle: number) => { + timers.delete(handle); + }, + /** Fire every armed timer (i.e. "60 seconds pass"). */ + fireAll: () => { + const pending = [...timers.values()]; + timers.clear(); + for (const fn of pending) fn(); + }, + }; +} + +function makeClient(overrides: Record = {}) { + const transport = makeFakeTransport(); + const timers = makeFakeTimers(); + const authUrls: string[] = []; + const client = N.createClient({ + transport, + clientSkHex: ALICE_SK, + remoteSignerPubkey: BOB_PK, + relays: ["wss://relay.test"], + nowSec: () => 1700000000, + setTimeoutFn: timers.set, + clearTimeoutFn: timers.clear, + onAuthUrl: (url: string) => authUrls.push(url), + ...overrides, + }); + return { client, transport, timers, authUrls }; +} + +/** Bob (the remote signer) decrypts a published NIP-44 request event. */ +function decryptRequest(ev: any): Nip46Msg { + const ck = C.nip44ConversationKey(bobSk, ev.pubkey); + return JSON.parse(C.nip44Decrypt(ck, ev.content)); +} + +/** All kind-24133 request events the client has published, oldest first. */ +function sentEvents(transport: ReturnType): any[] { + return transport.sent.filter((s) => s.frame[0] === "EVENT").map((s) => s.frame[1]); +} + +/** Bob answers: encrypt msg (NIP-44 or legacy NIP-04) and deliver the frame. */ +async function respond( + transport: ReturnType, + msg: Nip46Msg, + opts: { nip04?: boolean } = {}, +) { + const json = JSON.stringify(msg); + const content = opts.nip04 + ? await C.nip04Encrypt(bobSk, ALICE_PK, json) + : C.nip44Encrypt(C.nip44ConversationKey(bobSk, ALICE_PK), json); + const ev = C.finalizeEvent( + { kind: 24133, created_at: 1700000001, tags: [["p", ALICE_PK]], content }, + bobSk, + ); + transport.deliver(["EVENT", "sub-x", ev]); + await flush(); +} + +// --------------------------------------------------------------------------- +// parseBunkerUri +// --------------------------------------------------------------------------- + +describe("parseBunkerUri", () => { + it("parses a single-relay URI without a secret", () => { + const out = N.parseBunkerUri(`bunker://${BOB_PK}?relay=wss://relay.example.com`); + expect(out.remoteSignerPubkey).toBe(BOB_PK); + expect(out.relays).toEqual(["wss://relay.example.com"]); + expect(out.secret).toBeUndefined(); + }); + + it("parses multiple relays (percent-encoded too) plus a secret", () => { + const out = N.parseBunkerUri( + `bunker://${BOB_PK.toUpperCase()}?relay=wss%3A%2F%2Fr1.example.com&relay=wss://r2.example.com&secret=s3cret-token`, + ); + expect(out.remoteSignerPubkey).toBe(BOB_PK); // lowercased + expect(out.relays).toEqual(["wss://r1.example.com", "wss://r2.example.com"]); + expect(out.secret).toBe("s3cret-token"); + }); + + it("rejects a non-hex pubkey", () => { + const zz = "z".repeat(64); + expect(() => N.parseBunkerUri(`bunker://${zz}?relay=wss://r.example.com`)).toThrow( + /hex/, + ); + }); + + it("rejects a short pubkey", () => { + expect(() => + N.parseBunkerUri(`bunker://${BOB_PK.slice(0, 63)}?relay=wss://r.example.com`), + ).toThrow(/hex/); + }); + + it("rejects a URI with no relay at all", () => { + expect(() => N.parseBunkerUri(`bunker://${BOB_PK}`)).toThrow(/relay/); + expect(() => N.parseBunkerUri(`bunker://${BOB_PK}?secret=abc`)).toThrow(/relay/); + }); + + it("rejects http:// and ws:// relays", () => { + expect(() => + N.parseBunkerUri(`bunker://${BOB_PK}?relay=http://r.example.com`), + ).toThrow(/wss/); + expect(() => + N.parseBunkerUri(`bunker://${BOB_PK}?relay=ws://r.example.com`), + ).toThrow(/wss/); + }); + + it("rejects non-bunker schemes", () => { + expect(() => N.parseBunkerUri(`nostrconnect://${BOB_PK}?relay=wss://r.io`)).toThrow( + /bunker/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// buildNostrconnectUri +// --------------------------------------------------------------------------- + +describe("buildNostrconnectUri", () => { + it("builds the exact pairing URI (relays percent-encoded, given name)", () => { + expect( + N.buildNostrconnectUri({ + clientPubkey: ALICE_PK, + relays: ["wss://r1.test", "wss://r2.test"], + secret: "s3cret", + name: "nbread", + }), + ).toBe( + `nostrconnect://${ALICE_PK}?relay=wss%3A%2F%2Fr1.test&relay=wss%3A%2F%2Fr2.test&secret=s3cret&name=nbread`, + ); + }); + + it('defaults name to "nbread"', () => { + expect( + N.buildNostrconnectUri({ + clientPubkey: ALICE_PK, + relays: ["wss://r.test"], + secret: "abc", + }), + ).toBe(`nostrconnect://${ALICE_PK}?relay=wss%3A%2F%2Fr.test&secret=abc&name=nbread`); + }); + + it("rejects a missing secret and non-wss relays", () => { + expect(() => + N.buildNostrconnectUri({ clientPubkey: ALICE_PK, relays: ["wss://r.test"], secret: "" }), + ).toThrow(/secret/); + expect(() => + N.buildNostrconnectUri({ + clientPubkey: ALICE_PK, + relays: ["http://r.test"], + secret: "abc", + }), + ).toThrow(/wss/); + }); +}); + +// --------------------------------------------------------------------------- +// encodeRequest / decodeResponse +// --------------------------------------------------------------------------- + +describe("encodeRequest/decodeResponse", () => { + it("round-trips a request through NIP-44 (bob can read what alice sends)", async () => { + const content = N.encodeRequest(convKey, { + id: "7", + method: "connect", + params: [BOB_PK, "s3cret"], + }); + // decodeResponse is decrypt+parse of the same envelope, usable both ways. + const msg = await N.decodeResponse(C.nip44ConversationKey(bobSk, ALICE_PK), content); + expect(msg).toEqual({ id: "7", method: "connect", params: [BOB_PK, "s3cret"] }); + }); + + it("round-trips a NIP-44 response", async () => { + const content = C.nip44Encrypt(convKey, JSON.stringify({ id: "1", result: "ack" })); + const msg = await N.decodeResponse(convKey, content); + expect(msg).toEqual({ id: "1", result: "ack" }); + }); + + it('auto-detects a legacy NIP-04 response by its "?iv=" marker', async () => { + const content = await C.nip04Encrypt( + bobSk, + ALICE_PK, + JSON.stringify({ id: "2", result: USER_PK }), + ); + expect(content).toContain("?iv="); + const msg = await N.decodeResponse(convKey, content, { + skBytes: aliceSk, + peerPubkey: BOB_PK, + }); + expect(msg).toEqual({ id: "2", result: USER_PK }); + }); + + it("requires nip04 keys for a NIP-04 response", async () => { + const content = await C.nip04Encrypt(bobSk, ALICE_PK, JSON.stringify({ id: "3" })); + await expect(N.decodeResponse(convKey, content)).rejects.toThrow(/nip04/); + }); + + it("rejects tampered NIP-44 content (MAC failure)", async () => { + const content = C.nip44Encrypt(convKey, JSON.stringify({ id: "4", result: "ack" })); + const i = 50; // inside nonce/ciphertext, away from padding + const flipped = content[i] === "A" ? "B" : "A"; + const tampered = content.slice(0, i) + flipped + content.slice(i + 1); + await expect(N.decodeResponse(convKey, tampered)).rejects.toThrow(); + }); + + it("rejects corrupted NIP-04 content", async () => { + const content = await C.nip04Encrypt(bobSk, ALICE_PK, JSON.stringify({ id: "5" })); + const tampered = "!" + content.slice(1); // invalid base64 — deterministic reject + await expect( + N.decodeResponse(convKey, tampered, { skBytes: aliceSk, peerPubkey: BOB_PK }), + ).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// createClient — scripted fake transport, injected timers, no sockets +// --------------------------------------------------------------------------- + +describe("createClient", () => { + it("connect: subscribes, publishes a signed kind-24133 request, resolves on ack", async () => { + const { client, transport } = makeClient(); + const p = client.connect(BOB_PK, "s3cret"); + await flush(); + + const req = transport.sent.find((s) => s.frame[0] === "REQ"); + expect(req).toBeDefined(); + expect(req!.relay).toBe("wss://relay.test"); + expect(req!.frame[2]).toEqual({ kinds: [24133], "#p": [ALICE_PK] }); + + const [ev] = sentEvents(transport); + expect(ev.kind).toBe(24133); + expect(ev.pubkey).toBe(ALICE_PK); + expect(ev.created_at).toBe(1700000000); // injected nowSec + expect(ev.tags).toEqual([["p", BOB_PK]]); + expect(C.schnorrVerify(ev.sig, ev.id, ev.pubkey)).toBe(true); + + const rpc = decryptRequest(ev); + expect(rpc.method).toBe("connect"); + expect(rpc.params).toEqual([BOB_PK, "s3cret"]); + + await respond(transport, { id: rpc.id, result: "ack" }); + await expect(p).resolves.toBe("ack"); + }); + + it("get_public_key resolves with the signer-held user pubkey", async () => { + const { client, transport } = makeClient(); + const p = client.get_public_key(); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + expect(rpc.method).toBe("get_public_key"); + await respond(transport, { id: rpc.id, result: USER_PK }); + await expect(p).resolves.toBe(USER_PK); + }); + + it("get_public_key rejects a malformed pubkey result", async () => { + const { client, transport } = makeClient(); + const p = expectRejection(client.get_public_key()); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + await respond(transport, { id: rpc.id, result: "not-a-pubkey" }); + await expect(p).rejects.toThrow(/malformed pubkey/); + }); + + it("sign_event round-trips and the result verifies cryptographically", async () => { + const { client, transport } = makeClient(); + const unsigned = { kind: 1, created_at: 1700000000, tags: [], content: "hello nip46" }; + const p = client.sign_event(JSON.stringify(unsigned)); + await flush(); + + const rpc = decryptRequest(sentEvents(transport)[0]); + expect(rpc.method).toBe("sign_event"); + expect(JSON.parse(rpc.params![0]!)).toEqual(unsigned); + + // Bob really signs it (as himself — the "user key" he holds). + const signed = C.finalizeEvent(unsigned, bobSk); + await respond(transport, { id: rpc.id, result: JSON.stringify(signed) }); + + const returned = JSON.parse(await p); + expect(returned).toEqual(signed); + expect(returned.id).toBe(C.eventId(returned)); + expect(C.schnorrVerify(returned.sig, returned.id, returned.pubkey)).toBe(true); + }); + + it("sign_event rejects a response whose content was swapped (id mismatch)", async () => { + const { client, transport } = makeClient(); + const unsigned = { kind: 1, created_at: 1700000000, tags: [], content: "original" }; + const p = expectRejection(client.sign_event(JSON.stringify(unsigned))); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + const signed = C.finalizeEvent(unsigned, bobSk); + signed.content = "evil replacement"; // id no longer matches + await respond(transport, { id: rpc.id, result: JSON.stringify(signed) }); + await expect(p).rejects.toThrow(/id mismatch/); + }); + + it("sign_event rejects a response with a forged signature", async () => { + const { client, transport } = makeClient(); + const unsigned = { kind: 1, created_at: 1700000000, tags: [], content: "original" }; + const p = expectRejection(client.sign_event(JSON.stringify(unsigned))); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + const signed = C.finalizeEvent(unsigned, bobSk); + signed.sig = (signed.sig[0] === "0" ? "1" : "0") + signed.sig.slice(1); + await respond(transport, { id: rpc.id, result: JSON.stringify(signed) }); + await expect(p).rejects.toThrow(/signature/); + }); + + it("surfaces an error response as a rejection carrying nip46Error", async () => { + const { client, transport } = makeClient(); + const p = expectRejection(client.get_public_key()); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + await respond(transport, { id: rpc.id, error: "unauthorized" }); + await expect(p).rejects.toMatchObject({ nip46Error: "unauthorized" }); + }); + + it("times out an unanswered request when the injected clock fires", async () => { + const { client, transport, timers } = makeClient(); + const p = expectRejection(client.get_public_key()); + await flush(); + expect(sentEvents(transport)).toHaveLength(1); + timers.fireAll(); // 60 injected seconds pass + await expect(p).rejects.toThrow(/timed out/); + // A late response for the dead id is ignored (no crash, still rejected). + await respond(transport, { id: "1", result: USER_PK }); + await expect(p).rejects.toThrow(/timed out/); + }); + + it("surfaces auth_url via the callback without settling the request", async () => { + const { client, transport, authUrls } = makeClient(); + const p = client.get_public_key(); + let settled = false; + p.then( + () => (settled = true), + () => (settled = true), + ); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + + await respond(transport, { + id: rpc.id, + result: "auth_url", + error: "https://signer.example/authorize?token=abc", + }); + expect(authUrls).toEqual(["https://signer.example/authorize?token=abc"]); + expect(settled).toBe(false); // still waiting — and definitely no navigation + + await respond(transport, { id: rpc.id, result: USER_PK }); + await expect(p).resolves.toBe(USER_PK); + }); + + it("matches out-of-order responses by id and ignores duplicates", async () => { + const { client, transport } = makeClient(); + const p1 = client.get_public_key(); + const p2 = client.get_public_key(); + await flush(); + + const events = sentEvents(transport); + expect(events).toHaveLength(2); + const rpc1 = decryptRequest(events[0]); + const rpc2 = decryptRequest(events[1]); + expect(rpc1.id).not.toBe(rpc2.id); // unique per request + + // Answer the second request first. + await respond(transport, { id: rpc2.id, result: USER_PK }); + await respond(transport, { id: rpc1.id, result: BOB_PK }); + await expect(p1).resolves.toBe(BOB_PK); + await expect(p2).resolves.toBe(USER_PK); + + // A duplicate response with a different value changes nothing. + await respond(transport, { id: rpc1.id, result: ALICE_PK }); + await expect(p1).resolves.toBe(BOB_PK); + }); + + it("uses unguessable random request ids (not a predictable counter)", async () => { + // Replay defense: a relay that recorded an earlier session must not be + // able to predict the id of a fresh request ("1", "2", …). + const { client, transport } = makeClient(); + const p = client.get_public_key(); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + expect(String(rpc.id)).toMatch(/^[0-9a-f]{16}$/); + expect(String(rpc.id)).not.toBe("1"); + await respond(transport, { id: rpc.id, result: USER_PK }); + await expect(p).resolves.toBe(USER_PK); + }); + + it("sign_event rejects a validly-signed event that does not match the request", async () => { + // A replayed/substituted response: genuinely signed by the user key, but + // NOT the event we asked to sign (different kind + content). + const { client, transport } = makeClient(); + const unsigned = { kind: 1, created_at: 1700000000, tags: [], content: "fresh post" }; + const p = expectRejection(client.sign_event(JSON.stringify(unsigned))); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + const stale = C.finalizeEvent( + { kind: 22242, created_at: 1690000000, tags: [], content: "old login" }, + bobSk, + ); + await respond(transport, { id: rpc.id, result: JSON.stringify(stale) }); + await expect(p).rejects.toThrow(/does not match/); + }); + + it("sign_event rejects matching content but swapped tags", async () => { + const { client, transport } = makeClient(); + const unsigned = { + kind: 1, + created_at: 1700000000, + tags: [["t", "good"]], + content: "hello", + }; + const p = expectRejection(client.sign_event(JSON.stringify(unsigned))); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + const swapped = C.finalizeEvent( + { kind: 1, created_at: 1700000000, tags: [["t", "evil"]], content: "hello" }, + bobSk, + ); + await respond(transport, { id: rpc.id, result: JSON.stringify(swapped) }); + await expect(p).rejects.toThrow(/does not match/); + }); + + it("ignores events from a pubkey other than the remote signer", async () => { + const { client, transport } = makeClient(); + const p = client.get_public_key(); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + + // Mallory publishes a matching-id "response" — wrong sender, ignored + // (she couldn't have the conversation key either). + const mallorySk = C.hexToBytes(keys.mallory.sk); + const forged = C.finalizeEvent( + { + kind: 24133, + created_at: 1700000001, + tags: [["p", ALICE_PK]], + content: C.nip44Encrypt( + C.nip44ConversationKey(mallorySk, ALICE_PK), + JSON.stringify({ id: rpc.id, result: keys.mallory.pk }), + ), + }, + mallorySk, + ); + transport.deliver(["EVENT", "sub-x", forged]); + await flush(); + + await respond(transport, { id: rpc.id, result: USER_PK }); + await expect(p).resolves.toBe(USER_PK); + }); + + it("auto-detects a NIP-04 response and switches request encryption to legacy", async () => { + const { client, transport } = makeClient(); + expect(client.isLegacyNip04()).toBe(false); + + const p = client.connect(BOB_PK, "s3cret"); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + await respond(transport, { id: rpc.id, result: "ack" }, { nip04: true }); + await expect(p).resolves.toBe("ack"); + expect(client.isLegacyNip04()).toBe(true); + + // The next request goes out NIP-04-encrypted for the legacy signer. + const q = client.get_public_key(); + await flush(); + const ev2 = sentEvents(transport)[1]; + expect(ev2.content).toContain("?iv="); + const rpc2: Nip46Msg = JSON.parse(await C.nip04Decrypt(bobSk, ALICE_PK, ev2.content)); + expect(rpc2.method).toBe("get_public_key"); + await respond(transport, { id: rpc2.id, result: USER_PK }, { nip04: true }); + await expect(q).resolves.toBe(USER_PK); + }); + + it("drops forged envelopes (valid-looking pubkey, invalid signature) without downgrading", async () => { + // A malicious relay injects a garbage event with ev.pubkey set to the + // remote signer but no real signature. It must be dropped BEFORE the + // decrypt-failure path: no NIP-04 connect retry, no legacy flip. + const { client, transport } = makeClient(); + const p = client.connect(BOB_PK, "s3cret"); + await flush(); + expect(sentEvents(transport)).toHaveLength(1); + + transport.deliver([ + "EVENT", + "sub-x", + { + id: "00".repeat(32), + pubkey: BOB_PK, + sig: "00".repeat(64), + kind: 24133, + created_at: 1700000001, + tags: [["p", ALICE_PK]], + content: "undecryptable-garbage?iv=junk", + }, + ]); + await flush(); + + expect(client.isLegacyNip04()).toBe(false); + expect(sentEvents(transport)).toHaveLength(1); // no NIP-04 retry fired + + const rpc = decryptRequest(sentEvents(transport)[0]); + await respond(transport, { id: rpc.id, result: "ack" }); + await expect(p).resolves.toBe("ack"); + expect(client.isLegacyNip04()).toBe(false); + }); + + it("retries connect over NIP-04 on a genuine undecryptable reply without a session downgrade", async () => { + const { client, transport } = makeClient(); + const p = client.connect(BOB_PK, "s3cret"); + await flush(); + expect(sentEvents(transport)).toHaveLength(1); + + // Bob genuinely signs a blob the client cannot decrypt (e.g. an error + // encrypted for the wrong key) — envelope verifies, decryption fails. + const junk = C.finalizeEvent( + { kind: 24133, created_at: 1700000001, tags: [["p", ALICE_PK]], content: "not-a-ciphertext" }, + bobSk, + ); + transport.deliver(["EVENT", "sub-x", junk]); + await flush(); + + // The connect was re-sent NIP-04-encrypted… + const events = sentEvents(transport); + expect(events).toHaveLength(2); + expect(events[1].content).toContain("?iv="); + // …but the session itself was NOT downgraded by the failure alone. + expect(client.isLegacyNip04()).toBe(false); + + // Bob answers the retry in NIP-44: the session stays modern. + const rpc: Nip46Msg = JSON.parse(await C.nip04Decrypt(bobSk, ALICE_PK, events[1].content)); + await respond(transport, { id: rpc.id, result: "ack" }); + await expect(p).resolves.toBe("ack"); + expect(client.isLegacyNip04()).toBe(false); + }); + + it("resets legacy mode when a NIP-44 response later decrypts successfully", async () => { + const { client, transport } = makeClient(); + const p = client.connect(BOB_PK, "s3cret"); + await flush(); + const rpc = decryptRequest(sentEvents(transport)[0]); + await respond(transport, { id: rpc.id, result: "ack" }, { nip04: true }); + await expect(p).resolves.toBe("ack"); + expect(client.isLegacyNip04()).toBe(true); + + // The signer proves it speaks NIP-44 after all — undo the downgrade. + const q = client.get_public_key(); + await flush(); + const ev2 = sentEvents(transport)[1]; + const rpc2: Nip46Msg = JSON.parse(await C.nip04Decrypt(bobSk, ALICE_PK, ev2.content)); + await respond(transport, { id: rpc2.id, result: USER_PK }); + await expect(q).resolves.toBe(USER_PK); + expect(client.isLegacyNip04()).toBe(false); + }); + + it("close() rejects everything pending and closes the subscription", async () => { + const { client, transport } = makeClient(); + const p = expectRejection(client.get_public_key()); + await flush(); + client.close(); + await expect(p).rejects.toThrow(/closed/); + const closeFrame = transport.sent.find((s) => s.frame[0] === "CLOSE"); + expect(closeFrame).toBeDefined(); + await expect(client.get_public_key()).rejects.toThrow(/closed/); + }); +}); + +// --------------------------------------------------------------------------- +// Load-time side effect + backend guards (workerd has no localStorage) +// --------------------------------------------------------------------------- + +describe("registration side effect and backend guards", () => { + it("registers the backend as NbreadSigner method 'nip46' on load", () => { + expect(registered["nip46"]).toBe(N.backend); + expect(typeof N.backend.ready).toBe("function"); + expect(typeof N.backend.getPublicKey).toBe("function"); + expect(typeof N.backend.signEvent).toBe("function"); + expect(typeof N.backend.configure).toBe("function"); + }); + + it("ready() fails closed where localStorage does not exist (this runtime)", () => { + const r = N.backend.ready(); + expect(r.ok).toBe(false); + expect(r.reason).toBeTruthy(); + }); + + it("getPublicKey/configure guard against the missing storage too", async () => { + expect(() => N.backend.getPublicKey()).toThrow(/not configured/); + await expect(N.backend.configure({ relays: ["wss://r.test"] })).rejects.toThrow( + /localStorage/, + ); + }); +}); diff --git a/test/unit/vendor-crypto.spec.ts b/test/unit/vendor-crypto.spec.ts new file mode 100644 index 0000000..6c8c2db Binary files /dev/null and b/test/unit/vendor-crypto.spec.ts differ