diff --git a/README.md b/README.md index 2019fa3..35db532 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,74 @@ Your Google account is your nostr identity. No server, no key management. **Pure static site.** No build step. No backend. No install required to run. +Sign in with Google and you get a nostr identity that follows you to every device. Then use it anywhere: + +- **On this page:** post notes, see your profile and recent notes. +- **In any nostr website:** through the optional **Goostr browser extension** (NIP-07). +- **In any nostr app, including phones:** through the built-in **remote signer** (NIP-46 "bunker"). + ## How it works -1. User clicks **Sign in with Google**. We request three OAuth scopes: `profile`, `email`, and `drive.appdata`. -2. We look in the user's **Google Drive App Data folder** for a file `goostr-key-v1.json`. (App Data is a per-OAuth-client hidden folder in the user's Drive that only this app's client ID can read. WhatsApp uses it for backups; Enpass uses it for vault sync.) - - **If found** → download it. The 32 bytes inside are the nostr private key. User is now signed into their existing nostr identity. +1. User clicks **Sign in with Google**. We request three OAuth scopes: `profile`, `email`, and `drive.appdata`. Google shows its consent screen the first time only; after that, sign-in is one click. +2. We look in the user's **Google Drive App Data folder** for a file `goostr-key-v1.json`. (App Data is a hidden per-app folder in the user's Drive that only this app's Google Cloud project can read. WhatsApp uses it for backups; Enpass uses it for vault sync.) + - **If found** → read it. The 32 bytes inside are the nostr private key. If the file is passkey-protected, the user unlocks it with their passkey first. - **If missing** → generate 32 fresh random bytes (`crypto.getRandomValues`), upload to App Data. User now has a brand-new nostr identity. -3. We fetch the user's Google name + picture. For a brand-new identity we publish a kind-0 metadata event with them so it has a profile. For an existing one, if no relay returns a kind-0 within ~6s, we publish it then. + - If two devices create a key at the same moment, the **oldest** file wins everywhere, so everyone converges on one identity. +3. For a brand-new identity we publish a kind-0 profile with the Google name + picture. For an existing identity we only publish one if the relays confirm none exists, so a profile you edited in another app is never overwritten. 4. SessionStorage caches the key inside the tab (max 12h) so reloads don't require re-auth. Closing the tab logs out. -Note: the key is **stored**, not derived. It's random bytes that live in the Drive file. Delete that file (or revoke/delete the OAuth client, which makes App Data unreadable) and the identity is gone for good. +The key is **stored**, not derived: it's random bytes that live in the Drive file. Make a backup (see below). Without one, deleting that file or losing the Google Cloud project loses the identity for good. -### The demo app +## Features -Once signed in, [`app.js`](public/app.js) shows your name, avatar and npub, a dot per relay (connecting / open / closed / timeout), your last 50 notes (kind 1), and a box to publish a new note. +### Browser extension (NIP-07), optional -### Relays +The extension in [`extension/`](extension/) adds `window.nostr` to every website, so "Login with extension" works in Coracle, Snort, Nostrudel, etc. -[`relay.js`](public/relay.js) opens every relay in [`config.js`](public/config.js) in parallel (4s timeout each) and lets the UI continue as soon as **one** is open. Relays that connect later are sent every active subscription plus anything published in the last 60s, so nothing is lost to a slow relay. +- **Install from the page:** signed-in users see a *Browser extension* card with a download (or a Chrome Web Store button once `EXTENSION_STORE_URL` is set in `config.js`) and "Load unpacked" steps. +- **Unlocking:** the extension has no Google login of its own. The Goostr page hands it your key through Chrome's `externally_connectable` messaging, which only the origins listed in the manifest can use. The extension's "Unlock" button opens Goostr for this. +- **Where the key is kept:** `chrome.storage.session`, in memory only. It's forgotten when the browser quits. +- **Permissions:** every site asks first (*Allow once* / *Always allow this site* / *Reject*). Allowed sites can be revoked from the extension popup. +- **Stable ID:** the extension's ID is `bglfppdpknmhpnjjegkdncgeaaadmeic` for every unpacked install, because of the `key` field in `manifest.json`. -### Why this is good +### Remote signer (NIP-46) -- Works across devices: sign in on phone, laptop, anything — same Google account → same nostr identity. -- No nsec to lose, no seed phrase to back up. Recovery == "remember your Google password." -- The seed key never touches *this* device's disk. It lives in Google Drive (encrypted at rest by Google) and in tab-only sessionStorage. +The page itself can act as a "bunker". Apps send signing requests over relays as kind-24133 events, encrypted with NIP-44 (NIP-04 is also accepted from older apps). -### What this is *not* +- **To pair an app, either:** + - click *Create connection link* and paste the single-use `bunker://` link into the app, or + - paste the app's `nostrconnect://` link into Goostr. +- **Paired apps:** they're remembered per identity and can be removed at any time. Recent requests are shown in an activity log. +- **This tab has to stay open** for apps to sign. Relays that drop reconnect automatically, with backoff. +- Paired apps can sign without asking each time (like *Always allow* in the extension). Per-request approval isn't built yet. -- Not as secure as `keystr` (passkey-based). Anyone with your Google password + 2FA can read your nostr key. With keystr/passkeys, the user must physically be at the authenticator device. -- Not for situations where you want to be invisible to a single big platform — Google obviously knows you own this Drive App Data file. -- Not safe against script injection. While signed in, the raw key sits in `sessionStorage`, readable by any script on the page. That includes `nostr-tools`, loaded from `esm.sh` via the import map in `index.html` with no integrity check. Pin/self-host it before trusting this with a real identity. -- Every sign-in shows Google's consent screen (`prompt: "consent"`) unless `signIn({ silent: true })` is used. +### Key protection with a passkey + +*Protect with a passkey* encrypts the Drive copy using the WebAuthn **PRF** extension (AES-GCM, key derived with HKDF). After that, access to your Google account alone isn't enough to read your key; you also need the passkey. + +- **How to unlock:** new devices ask for the passkey after Google sign-in. Synced passkeys (Google Password Manager, iCloud Keychain) cover all your devices. +- **Bound to one domain:** a passkey only works on the site it was created on. If you move Goostr to another domain, turn protection off first. +- **Supported browsers:** recent Chrome, Edge and Safari with a PRF-capable passkey provider. + +### Backups and export + +- **Download backup:** a NIP-49 `ncryptsec` file, encrypted with a password you choose. Most nostr signers can import it. +- **Save to Google Drive:** the same `ncryptsec` saved as a normal, visible file in your Drive. It survives even if this app's Google project disappears. This asks for the extra `drive.file` scope, only when you click it. +- **Reveal nsec:** for moving to another signer. + +## Security notes + +- Without a passkey, anyone who gets into your Google account can read your nostr key. With a passkey, they also need your device. +- While you're signed in, the raw key is in the tab's `sessionStorage` and memory. A Content-Security-Policy stops every third-party script except Google's sign-in client from running. `nostr-tools` is vendored and pinned in [`public/vendor/`](public/vendor/), not loaded from a CDN. +- Every event received from relays has its signature checked before it's used. +- Not for situations where you want to be invisible to a single big platform: Google knows you own this Drive App Data file. ## One-time Google setup (~2 minutes) 1. Open . 2. Create or select a project. 3. Enable the **Google Drive API** under "Library." -4. Under "OAuth consent screen", configure a minimal app (External + Testing is fine for prototyping; add yourself as a Test User). +4. Under "OAuth consent screen", configure a minimal app (External + Testing is fine for prototyping; add yourself as a Test User). Add the scopes `drive.appdata` and `drive.file`. 5. Back in "Credentials" → **Create Credentials** → **OAuth client ID** → **Web application**. 6. Under **Authorized JavaScript origins** add: - `http://localhost:5174` (for local dev) @@ -49,46 +79,75 @@ Once signed in, [`app.js`](public/app.js) shows your name, avatar and npub, a do 7. Copy the **Client ID** — looks like `1234567890-abcdef.apps.googleusercontent.com`. 8. Paste it into [`public/config.js`](public/config.js) as the value of `CLIENT_ID`. +**Keep this Google Cloud project.** App Data is tied to it. If the project is deleted, nobody can read their key from Drive anymore; only backups will help. + ## Run it ```bash python3 -m http.server -d public 5174 -# or -npx serve public -l 5174 ``` Then open . Google requires HTTPS for OAuth in production, but `localhost` is exempt for dev. ## Deploy it -Static folder. Drop `public/` into any host. GitHub Pages, Netlify, Vercel, Cloudflare Pages, S3 — all fine. Make sure HTTPS is on, and add your deployed URL to the Google Cloud OAuth client's authorized origins. +Static folder. Drop `public/` into any host: GitHub Pages, Netlify, Vercel, Cloudflare Pages, S3. Make sure HTTPS is on, and add your deployed URL to the Google Cloud OAuth client's authorized origins. + +To let the extension work with your deployed site: + +1. Add your origin to `externally_connectable.matches` in [`extension/manifest.json`](extension/manifest.json), e.g. `"https://goostr.example.com/*"`. +2. Set `GOOSTR_URL` in [`extension/config.js`](extension/config.js) to your site. +3. Run `npm run build:extension` to rebuild `public/goostr-extension.zip`, then redeploy. ## Layout ``` public/ - index.html — entry point + setup banner - app.js — UI glue - goostr.js — library: signIn(), restoreSession(), clearSession() - drive.js — Google Drive App Data helpers - relay.js — minimal nostr WebSocket pool with replay-on-late-connect - style.css — dark UI - config.js — CLIENT_ID + relay list (edit this) + index.html — entry point, CSP + app.js — UI glue + goostr.js — library: signIn(), restoreSession(), clearSession(), makeSigner() + drive.js — Google Drive helpers + passkey.js — passkey (WebAuthn PRF) key wrapping + bunker.js — NIP-46 remote signer + relay.js — nostr WebSocket pool: replay on late connect, reconnect, signature checks + vendor/ — pinned nostr-tools bundle + goostr-extension.zip — the extension, as offered for download + style.css — dark UI + config.js — CLIENT_ID, relays, extension ID (edit this) +extension/ — the Chrome extension (Manifest V3) +scripts/ — vendor-nostr-tools.sh, build-extension.sh +test/ — bunker.test.mjs (node self-check for the NIP-46 signer) +``` + +## Scripts + +```bash +npm test # NIP-46 signer self-check +npm run build:extension # rebuild public/goostr-extension.zip +npm run vendor # rebuild the pinned nostr-tools bundle (when upgrading) ``` ## Library use ```js -import { signIn, restoreSession, clearSession } from "./goostr.js"; +import { signIn, restoreSession } from "./goostr.js"; // On boot, try restoring first (sessionStorage): -let session = restoreSession(); +let session = restoreSession({ clientId: "1234.apps.googleusercontent.com" }); if (!session) { session = await signIn({ clientId: "1234.apps.googleusercontent.com", onStatus: (msg) => console.log(msg), + // Only called for passkey-protected keys; resolve after a user click. + onPasskeyNeeded: () => waitForClick(unlockButton), }); } const { signer, profile } = session; -// signer.sign({ kind: 1, content: "hello nostr", tags: [] }) +signer.sign({ kind: 1, content: "hello nostr", tags: [] }); +await signer.nip44.encrypt(theirPubkey, "psst"); + +// Key management +await session.enablePasskey(); +await session.backupToDrive("a good password"); +session.ncryptsec("a good password"); ``` diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..1f9c2d2 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,171 @@ +// Goostr signer — background service worker. +// +// Holds the nostr key in chrome.storage.session (memory only, cleared when the +// browser quits) and answers NIP-07 requests from web pages, asking the user +// before a site may use it. The key arrives from the Goostr website via +// externally_connectable messaging — only origins listed in manifest.json can +// send it. + +import { getPublicKey, finalizeEvent, nip04, nip19, nip44, hexToBytes } from "./vendor/nostr-tools.js"; +import { GOOSTR_URL } from "./config.js"; + +const PROMPT_W = 380; +const PROMPT_H = 560; + +async function getKey() { + const { sk } = await chrome.storage.session.get("sk"); + return sk ? hexToBytes(sk) : null; +} + +async function getPerms() { + const { perms } = await chrome.storage.local.get("perms"); + return perms || {}; // origin -> "always" +} + +async function setBadge() { + const sk = await getKey(); + await chrome.action.setBadgeText({ text: sk ? "" : "off" }); + await chrome.action.setBadgeBackgroundColor({ color: "#6d28d9" }); +} +chrome.runtime.onStartup.addListener(setBadge); +chrome.runtime.onInstalled.addListener(setBadge); + +// ---------- messages from the Goostr website ---------- + +chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => { + (async () => { + switch (msg?.type) { + case "status": { + const sk = await getKey(); + return { installed: true, version: chrome.runtime.getManifest().version, pubkey: sk ? getPublicKey(sk) : null }; + } + case "setKey": { + if (!/^[0-9a-f]{64}$/.test(msg.sk || "")) throw new Error("bad key"); + await chrome.storage.session.set({ sk: msg.sk, profile: msg.profile || null, from: sender.origin }); + await setBadge(); + return { ok: true, pubkey: getPublicKey(hexToBytes(msg.sk)) }; + } + case "lock": + await chrome.storage.session.remove(["sk", "profile"]); + await setBadge(); + return { ok: true }; + default: + throw new Error("unknown message"); + } + })().then(respond, (e) => respond({ error: String(e.message || e) })); + return true; +}); + +// ---------- permission prompts ---------- + +const pending = new Map(); // id -> { resolve, windowId } + +function askUser(request) { + return new Promise(async (resolve) => { + const id = crypto.randomUUID(); + await chrome.storage.session.set({ [`prompt:${id}`]: request }); + const win = await chrome.windows.create({ + url: `prompt.html?id=${id}`, + type: "popup", + width: PROMPT_W, + height: PROMPT_H, + }); + pending.set(id, { resolve, windowId: win.id }); + }); +} + +function settle(id, decision) { + const p = pending.get(id); + if (!p) return; + pending.delete(id); + chrome.storage.session.remove(`prompt:${id}`); + p.resolve(decision); +} + +// An open port from prompt.html keeps this worker alive while the user decides. +chrome.runtime.onConnect.addListener(() => {}); + +chrome.windows.onRemoved.addListener((windowId) => { + for (const [id, p] of pending) if (p.windowId === windowId) settle(id, "deny"); +}); + +// ---------- NIP-07 ---------- + +async function nip07(method, params, origin) { + let sk = await getKey(); + const perms = await getPerms(); + + if (!sk || perms[origin] !== "always") { + const decision = await askUser({ origin, method, params, locked: !sk }); + if (decision === "deny") throw new Error("Rejected by user"); + if (decision === "always") { + const fresh = await getPerms(); + fresh[origin] = "always"; + await chrome.storage.local.set({ perms: fresh }); + } + sk = await getKey(); // the prompt may have unlocked us + if (!sk) throw new Error("Goostr is locked. Click the Goostr extension icon and unlock."); + } + + switch (method) { + case "getPublicKey": + return getPublicKey(sk); + case "signEvent": { + const e = params.event; + return finalizeEvent( + { kind: e.kind, content: e.content ?? "", tags: e.tags ?? [], created_at: e.created_at ?? Math.floor(Date.now() / 1000) }, + sk + ); + } + case "getRelays": + return {}; + case "nip04.encrypt": return nip04.encrypt(sk, params.pubkey, params.plaintext); + case "nip04.decrypt": return nip04.decrypt(sk, params.pubkey, params.ciphertext); + case "nip44.encrypt": return nip44.encrypt(params.plaintext, nip44.getConversationKey(sk, params.pubkey)); + case "nip44.decrypt": return nip44.decrypt(params.ciphertext, nip44.getConversationKey(sk, params.pubkey)); + default: + throw new Error(`Unsupported method ${method}`); + } +} + +// ---------- messages from our own pages and content scripts ---------- + +chrome.runtime.onMessage.addListener((msg, sender, respond) => { + if (sender.id !== chrome.runtime.id) return; + (async () => { + switch (msg?.type) { + case "nip07": + // sender.origin comes from the browser, not the page, so it can't be spoofed. + return { result: await nip07(msg.method, msg.params || {}, sender.origin) }; + case "prompt-decision": + settle(msg.id, msg.decision); + return { ok: true }; + case "popup-state": { + const sk = await getKey(); + const { profile } = await chrome.storage.session.get("profile"); + return { + npub: sk ? nip19.npubEncode(getPublicKey(sk)) : null, + profile: profile || null, + perms: await getPerms(), + goostrUrl: GOOSTR_URL, + }; + } + case "lock": + await chrome.storage.session.remove(["sk", "profile"]); + await setBadge(); + return { ok: true }; + case "revoke": { + const perms = await getPerms(); + delete perms[msg.origin]; + await chrome.storage.local.set({ perms }); + return { ok: true }; + } + case "open-goostr": + await chrome.tabs.create({ url: `${GOOSTR_URL}#extension-unlock` }); + return { ok: true }; + default: + throw new Error("unknown message"); + } + })().then(respond, (e) => respond({ error: String(e.message || e) })); + return true; +}); diff --git a/extension/config.js b/extension/config.js new file mode 100644 index 0000000..0f13364 --- /dev/null +++ b/extension/config.js @@ -0,0 +1,6 @@ +// Where the Goostr website lives. "Unlock" in the popup opens this page, which +// signs you in with Google and hands your key to the extension. +// +// When you deploy Goostr, change this AND add the site's origin to +// "externally_connectable.matches" in manifest.json (e.g. "https://goostr.example.com/*"). +export const GOOSTR_URL = "http://localhost:5174/"; diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..c60df00 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,12 @@ +// Bridge between the page (provider.js) and the background worker. +window.addEventListener("message", async (e) => { + if (e.source !== window || e.data?.__goostr !== "req") return; + const { id, method, params } = e.data; + let reply; + try { + reply = await chrome.runtime.sendMessage({ type: "nip07", method, params }); + } catch (err) { + reply = { error: String(err?.message || err) }; + } + window.postMessage({ __goostr: "res", id, ...(reply || { error: "No response from Goostr" }) }, location.origin); +}); diff --git a/extension/ext.css b/extension/ext.css new file mode 100644 index 0000000..966c3e1 --- /dev/null +++ b/extension/ext.css @@ -0,0 +1,18 @@ +:root { --bg:#0e0f12; --panel:#16181d; --panel-2:#1e2128; --text:#e8e8ea; --muted:#8b8f99; --accent:#8b5cf6; --accent-2:#6d28d9; --border:#262932; --danger:#ef4444; --ok:#10b981; } +* { box-sizing: border-box; } +body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; } +main { padding:18px; min-width:320px; } +h1 { font-size:17px; margin:0 0 4px; } +p { margin:6px 0; } +.muted { color:var(--muted); font-size:13px; } +.mono { font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:12px; word-break:break-all; } +.box { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:12px; margin:12px 0; } +button { font:inherit; cursor:pointer; border-radius:8px; padding:9px 12px; border:1px solid var(--border); background:var(--panel-2); color:var(--text); width:100%; margin-top:8px; } +button.primary { background:linear-gradient(180deg,var(--accent),var(--accent-2)); border:none; color:#fff; font-weight:600; } +button.danger { color:var(--danger); } +button.small { width:auto; padding:3px 8px; margin:0; font-size:12px; } +ul { list-style:none; padding:0; margin:0; } +li { display:flex; justify-content:space-between; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid var(--border); } +li:last-child { border-bottom:none; } +pre { white-space:pre-wrap; word-break:break-word; max-height:180px; overflow:auto; background:var(--panel-2); border-radius:8px; padding:8px; font-size:12px; margin:8px 0 0; } +.hidden { display:none !important; } diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..89397db --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 3, + "name": "Goostr signer", + "version": "0.1.0", + "description": "Use your Google-backed Goostr nostr identity on any nostr website (NIP-07).", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnRZ2LEEG8oBnIUJ6Xg/Dq1lLjVFCHrsM7NQy1Oo9XFibfg/CIsKkAlViNj0EAA2rxdJfFeUDMkLQ3MqbXT6/ST0u1q+sAYDVKgv2RZf0OeZK0C9zlP8Bx72q+odk/6Ut/aL9PGGKRrsbnsSIvMc+IkOt7xh8kKi7yZAwxu2e6d7Ng1CLIVRstz3aImezeWlke/odkUpe9L29eWC80U/NovnN6e41JFP3Dx60KtSaTM+T1atQ74nvpX/IzYuTIEa3fFRBKZsYpflz0eAmrDKNWb46KI1dB7NSfHtiXAXp2pLmITeQW0AcqYBpcG6Fq/msueAURMV5ZIL65Hr6cCsy2wIDAQAB", + "minimum_chrome_version": "111", + "permissions": ["storage"], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { "default_title": "Goostr signer", "default_popup": "popup.html" }, + "content_scripts": [ + { + "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"], + "js": ["content.js"], + "run_at": "document_start" + }, + { + "matches": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"], + "js": ["provider.js"], + "run_at": "document_start", + "world": "MAIN" + } + ], + "externally_connectable": { + "matches": ["http://localhost/*", "http://127.0.0.1/*"] + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..1ae9ac3 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,28 @@ + + + + + Goostr signer + + + +
+

Goostr signer

+ + +
+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..e79461b --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,30 @@ +const $ = (id) => document.getElementById(id); +const send = (msg) => chrome.runtime.sendMessage(msg); + +async function render() { + const s = await send({ type: "popup-state" }); + $("locked").classList.toggle("hidden", !!s.npub); + $("unlocked").classList.toggle("hidden", !s.npub); + if (!s.npub) return; + $("name").textContent = s.profile?.name || "Your nostr identity"; + $("npub").textContent = s.npub; + const sites = Object.keys(s.perms); + $("no-sites").classList.toggle("hidden", sites.length > 0); + $("sites").replaceChildren(...sites.map((origin) => { + const li = document.createElement("li"); + const span = document.createElement("span"); + span.className = "mono"; + span.textContent = origin; + const btn = document.createElement("button"); + btn.className = "small"; + btn.textContent = "Revoke"; + btn.onclick = async () => { await send({ type: "revoke", origin }); render(); }; + li.append(span, btn); + return li; + })); +} + +$("unlock").onclick = async () => { await send({ type: "open-goostr" }); window.close(); }; +$("lock").onclick = async () => { await send({ type: "lock" }); render(); }; +chrome.storage.session.onChanged.addListener(render); +render(); diff --git a/extension/prompt.html b/extension/prompt.html new file mode 100644 index 0000000..16d8ede --- /dev/null +++ b/extension/prompt.html @@ -0,0 +1,24 @@ + + + + + Goostr — permission request + + + +
+

Allow this site?

+

+

+ + + + + +
+ + + diff --git a/extension/prompt.js b/extension/prompt.js new file mode 100644 index 0000000..c4c3ea6 --- /dev/null +++ b/extension/prompt.js @@ -0,0 +1,46 @@ +const $ = (id) => document.getElementById(id); +const id = new URLSearchParams(location.search).get("id"); + +// Keeps the background worker alive while the user decides. +chrome.runtime.connect({ name: "prompt" }); + +const WHAT = { + getPublicKey: "wants to know your nostr public key.", + signEvent: "wants to sign an event as you.", + getRelays: "wants your relay list.", + "nip04.encrypt": "wants to encrypt a message as you (NIP-04).", + "nip04.decrypt": "wants to decrypt a message sent to you (NIP-04).", + "nip44.encrypt": "wants to encrypt a message as you (NIP-44).", + "nip44.decrypt": "wants to decrypt a message sent to you (NIP-44).", +}; + +async function refreshLock() { + const { sk } = await chrome.storage.session.get("sk"); + const locked = !sk; + $("locked-box").classList.toggle("hidden", !locked); + for (const b of ["once", "always"]) $(b).disabled = locked; +} + +async function init() { + const req = (await chrome.storage.session.get(`prompt:${id}`))[`prompt:${id}`]; + if (!req) return window.close(); + $("origin").textContent = req.origin; + $("what").textContent = WHAT[req.method] || `wants to call ${req.method}.`; + if (req.method === "signEvent") { + const e = req.params.event || {}; + $("details").textContent = `kind ${e.kind}\n\n${String(e.content ?? "").slice(0, 1000)}`; + $("details").classList.remove("hidden"); + } + await refreshLock(); +} + +const decide = async (decision) => { + await chrome.runtime.sendMessage({ type: "prompt-decision", id, decision }); + window.close(); +}; +$("once").onclick = () => decide("once"); +$("always").onclick = () => decide("always"); +$("deny").onclick = () => decide("deny"); +$("unlock").onclick = () => chrome.runtime.sendMessage({ type: "open-goostr" }); +chrome.storage.session.onChanged.addListener(refreshLock); +init(); diff --git a/extension/provider.js b/extension/provider.js new file mode 100644 index 0000000..420244a --- /dev/null +++ b/extension/provider.js @@ -0,0 +1,39 @@ +// Runs in the page's own JavaScript world and defines window.nostr (NIP-07). +// Calls are forwarded to content.js with postMessage, then to the background +// worker, which asks the user before any site can use the key. +(() => { + if (window.nostr) return; // another signer extension got here first + + const pending = new Map(); + let seq = 0; + + window.addEventListener("message", (e) => { + if (e.source !== window || e.data?.__goostr !== "res") return; + const p = pending.get(e.data.id); + if (!p) return; + pending.delete(e.data.id); + if (e.data.error) p.reject(new Error(e.data.error)); + else p.resolve(e.data.result); + }); + + const call = (method, params) => + new Promise((resolve, reject) => { + const id = `goostr-${++seq}-${Math.random().toString(36).slice(2)}`; + pending.set(id, { resolve, reject }); + window.postMessage({ __goostr: "req", id, method, params }, location.origin); + }); + + window.nostr = { + getPublicKey: () => call("getPublicKey"), + signEvent: (event) => call("signEvent", { event }), + getRelays: () => call("getRelays"), + nip04: { + encrypt: (pubkey, plaintext) => call("nip04.encrypt", { pubkey, plaintext }), + decrypt: (pubkey, ciphertext) => call("nip04.decrypt", { pubkey, ciphertext }), + }, + nip44: { + encrypt: (pubkey, plaintext) => call("nip44.encrypt", { pubkey, plaintext }), + decrypt: (pubkey, ciphertext) => call("nip44.decrypt", { pubkey, ciphertext }), + }, + }; +})(); diff --git a/extension/vendor/nostr-tools.js b/extension/vendor/nostr-tools.js new file mode 100644 index 0000000..8eb4cbc --- /dev/null +++ b/extension/vendor/nostr-tools.js @@ -0,0 +1,2 @@ +// nostr-tools@2.25.2 (MIT) — built by scripts/vendor-nostr-tools.sh +var lo=Object.defineProperty;var Ft=(e,t)=>{for(var n in t)lo(e,n,{get:t[n],enumerable:!0})};/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */function Dt(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function X(e,t=""){if(!Number.isSafeInteger(e)||e<0){let n=t&&`"${t}" `;throw new Error(`${n}expected integer >= 0, got ${e}`)}}function V(e,t,n=""){let r=Dt(e),o=e?.length,s=t!==void 0;if(!r||s&&o!==t){let i=n&&`"${n}" `,c=s?` of length ${t}`:"",f=r?`length=${o}`:`type=${typeof e}`;throw new Error(i+"expected Uint8Array"+c+", got "+f)}return e}function xt(e){if(typeof e!="function"||typeof e.create!="function")throw new Error("Hash must wrapped by utils.createHasher");X(e.outputLen),X(e.blockLen)}function kt(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function Hn(e,t){V(e,void 0,"digestInto() output");let n=t.outputLen;if(e.length='+n)}function te(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function ft(...e){for(let t=0;t>>t}function M(e,t){return e<>>32-t>>>0}var uo=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;function ho(e){return e<<24&4278190080|e<<8&16711680|e>>>8&65280|e>>>24&255}function yo(e){for(let t=0;te:yo,Nn=typeof Uint8Array.from([]).toHex=="function"&&typeof Uint8Array.fromHex=="function",go=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function J(e){if(V(e),Nn)return e.toHex();let t="";for(let n=0;n=bt._0&&e<=bt._9)return e-bt._0;if(e>=bt.A&&e<=bt.F)return e-(bt.A-10);if(e>=bt.a&&e<=bt.f)return e-(bt.a-10)}function P(e){if(typeof e!="string")throw new Error("hex string expected, got "+typeof e);if(Nn)return Uint8Array.fromHex(e);let t=e.length,n=t/2;if(t%2)throw new Error("hex string expected, got unpadded hex of length "+t);let r=new Uint8Array(n);for(let o=0,s=0;oe(s).update(o).digest(),r=e(void 0);return n.outputLen=r.outputLen,n.blockLen=r.blockLen,n.create=o=>e(o),Object.assign(n,t),Object.freeze(n)}function ct(e=32){let t=typeof globalThis=="object"?globalThis.crypto:null;if(typeof t?.getRandomValues!="function")throw new Error("crypto.getRandomValues must be defined");return t.getRandomValues(new Uint8Array(e))}var qn=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function Vn(e,t,n){return e&t^~e&n}function Mn(e,t,n){return e&t^e&n^t&n}var ne=class{blockLen;outputLen;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(t,n,r,o){this.blockLen=t,this.outputLen=n,this.padOffset=r,this.isLE=o,this.buffer=new Uint8Array(t),this.view=Rt(this.buffer)}update(t){kt(this),V(t);let{view:n,buffer:r,blockLen:o}=this,s=t.length;for(let i=0;io-i&&(this.process(r,0),i=0);for(let u=i;ul.length)throw new Error("_sha2: outputLen bigger than state");for(let u=0;u>>3,p=dt(d,17)^dt(d,19)^d>>>10;Et[u]=p+Et[u-7]+g+Et[u-16]|0}let{A:r,B:o,C:s,D:i,E:c,F:f,G:a,H:l}=this;for(let u=0;u<64;u++){let y=dt(c,6)^dt(c,11)^dt(c,25),d=l+y+Vn(c,f,a)+bo[u]+Et[u]|0,p=(dt(r,2)^dt(r,13)^dt(r,22))+Mn(r,o,s)|0;l=a,a=f,f=c,c=i+d|0,i=s,s=o,o=r,r=d+p|0}r=r+this.A|0,o=o+this.B|0,s=s+this.C|0,i=i+this.D|0,c=c+this.E|0,f=f+this.F|0,a=a+this.G|0,l=l+this.H|0,this.set(r,o,s,i,c,f,a,l)}roundClean(){ft(Et)}destroy(){this.set(0,0,0,0,0,0,0,0),ft(this.buffer)}},_e=class extends Ie{A=wt[0]|0;B=wt[1]|0;C=wt[2]|0;D=wt[3]|0;E=wt[4]|0;F=wt[5]|0;G=wt[6]|0;H=wt[7]|0;constructor(){super(32)}};var at=Cn(()=>new _e,qn(1));/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var Re=BigInt(0),ke=BigInt(1);function Kt(e,t=""){if(typeof e!="boolean"){let n=t&&`"${t}" `;throw new Error(n+"expected boolean, got type="+typeof e)}return e}function Dn(e){if(typeof e=="bigint"){if(!re(e))throw new Error("positive bigint expected, got "+e)}else X(e);return e}function zt(e){let t=Dn(e).toString(16);return t.length&1?"0"+t:t}function Kn(e){if(typeof e!="string")throw new Error("hex string expected, got "+typeof e);return e===""?Re:BigInt("0x"+e)}function Bt(e){return Kn(J(e))}function Oe(e){return Kn(J(xo(V(e)).reverse()))}function oe(e,t){X(t),e=Dn(e);let n=P(e.toString(16).padStart(t*2,"0"));if(n.length!==t)throw new Error("number too large");return n}function He(e,t){return oe(e,t).reverse()}function xo(e){return Uint8Array.from(e)}function zn(e){return Uint8Array.from(e,(t,n)=>{let r=t.charCodeAt(0);if(t.length!==1||r>127)throw new Error(`string contains non-ASCII character "${e[n]}" with code ${r} at position ${n}`);return r})}var re=e=>typeof e=="bigint"&&Re<=e;function wo(e,t,n){return re(e)&&re(t)&&re(n)&&t<=e&&eRe;e>>=ke,t+=1);return t}var $t=e=>(ke<new Uint8Array(m),o=Uint8Array.of(),s=Uint8Array.of(0),i=Uint8Array.of(1),c=1e3,f=r(e),a=r(e),l=0,u=()=>{f.fill(1),a.fill(0),l=0},y=(...m)=>n(a,Q(f,...m)),d=(m=o)=>{a=y(s,m),f=y(),m.length!==0&&(a=y(i,m),f=y())},g=()=>{if(l++>=c)throw new Error("drbg: tried max amount of iterations");let m=0,E=[];for(;m{u(),d(m);let S;for(;!(S=E(g()));)d();return u(),S}}function Zt(e,t={},n={}){if(!e||typeof e!="object")throw new Error("expected valid options object");function r(s,i,c){let f=e[s];if(c&&f===void 0)return;let a=typeof f;if(a!==i||f===null)throw new Error(`param "${s}" is invalid: expected ${i}, got ${a}`)}let o=(s,i)=>Object.entries(s).forEach(([c,f])=>r(c,f,i));o(t,!1),o(n,!0)}function Ce(e){let t=new WeakMap;return(n,...r)=>{let o=t.get(n);if(o!==void 0)return o;let s=e(n,...r);return t.set(n,s),s}}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var it=BigInt(0),st=BigInt(1),Ut=BigInt(2),Gn=BigInt(3),Wn=BigInt(4),Yn=BigInt(5),mo=BigInt(7),Xn=BigInt(8),Eo=BigInt(9),Qn=BigInt(16);function ht(e,t){let n=e%t;return n>=it?n:t+n}function lt(e,t,n){let r=e;for(;t-- >it;)r*=r,r%=n;return r}function jn(e,t){if(e===it)throw new Error("invert: expected non-zero number");if(t<=it)throw new Error("invert: expected positive modulus, got "+t);let n=ht(e,t),r=t,o=it,s=st,i=st,c=it;for(;n!==it;){let a=r/n,l=r%n,u=o-i*a,y=s-c*a;r=n,n=l,o=i,s=c,i=u,c=y}if(r!==st)throw new Error("invert: does not exist");return ht(o,t)}function Ve(e,t,n){if(!e.eql(e.sqr(t),n))throw new Error("Cannot find square root")}function Jn(e,t){let n=(e.ORDER+st)/Wn,r=e.pow(t,n);return Ve(e,r,t),r}function Bo(e,t){let n=(e.ORDER-Yn)/Xn,r=e.mul(t,Ut),o=e.pow(r,n),s=e.mul(t,o),i=e.mul(e.mul(s,Ut),o),c=e.mul(s,e.sub(i,e.ONE));return Ve(e,c,t),c}function vo(e){let t=Ot(e),n=Fn(e),r=n(t,t.neg(t.ONE)),o=n(t,r),s=n(t,t.neg(r)),i=(e+mo)/Qn;return(c,f)=>{let a=c.pow(f,i),l=c.mul(a,r),u=c.mul(a,o),y=c.mul(a,s),d=c.eql(c.sqr(l),f),g=c.eql(c.sqr(u),f);a=c.cmov(a,l,d),l=c.cmov(y,u,g);let p=c.eql(c.sqr(l),f),m=c.cmov(a,l,p);return Ve(c,m,f),m}}function Fn(e){if(e1e3)throw new Error("Cannot find square root: probably non-prime P");if(n===1)return Jn;let s=o.pow(r,t),i=(t+st)/Ut;return function(f,a){if(f.is0(a))return a;if(Pn(f,a)!==1)throw new Error("Cannot find square root");let l=n,u=f.mul(f.ONE,s),y=f.pow(a,t),d=f.pow(a,i);for(;!f.eql(y,f.ONE);){if(f.is0(y))return f.ZERO;let g=1,p=f.sqr(y);for(;!f.eql(p,f.ONE);)if(g++,p=f.sqr(p),g===l)throw new Error("Cannot find square root");let m=st<(r[o]="function",r),t);return Zt(e,n),e}function Uo(e,t,n){if(nit;)n&st&&(r=e.mul(r,o)),o=e.sqr(o),n>>=st;return r}function se(e,t,n=!1){let r=new Array(t.length).fill(n?e.ZERO:void 0),o=t.reduce((i,c,f)=>e.is0(c)?i:(r[f]=i,e.mul(i,c)),e.ONE),s=e.inv(o);return t.reduceRight((i,c,f)=>e.is0(c)?i:(r[f]=e.mul(i,r[f]),e.mul(i,c)),s),r}function Pn(e,t){let n=(e.ORDER-st)/Ut,r=e.pow(t,n),o=e.eql(r,e.ONE),s=e.eql(r,e.ZERO),i=e.eql(r,e.neg(e.ONE));if(!o&&!s&&!i)throw new Error("invalid Legendre symbol result");return o?1:s?0:-1}function So(e,t){t!==void 0&&X(t);let n=t!==void 0?t:e.toString(2).length,r=Math.ceil(n/8);return{nBitLength:n,nByteLength:r}}var qe=class{ORDER;BITS;BYTES;isLE;ZERO=it;ONE=st;_lengths;_sqrt;_mod;constructor(t,n={}){if(t<=it)throw new Error("invalid field: expected ORDER > 0, got "+t);let r;this.isLE=!1,n!=null&&typeof n=="object"&&(typeof n.BITS=="number"&&(r=n.BITS),typeof n.sqrt=="function"&&(this.sqrt=n.sqrt),typeof n.isLE=="boolean"&&(this.isLE=n.isLE),n.allowedLengths&&(this._lengths=n.allowedLengths?.slice()),typeof n.modFromBytes=="boolean"&&(this._mod=n.modFromBytes));let{nBitLength:o,nByteLength:s}=So(t,r);if(s>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");this.ORDER=t,this.BITS=o,this.BYTES=s,this._sqrt=void 0,Object.preventExtensions(this)}create(t){return ht(t,this.ORDER)}isValid(t){if(typeof t!="bigint")throw new Error("invalid field element: expected bigint, got "+typeof t);return it<=t&&to)throw new Error("Field.fromBytes: expected "+r+" bytes, got "+t.length);let a=new Uint8Array(o);a.set(t,s?0:a.length-t.length),t=a}if(t.length!==o)throw new Error("Field.fromBytes: expected "+o+" bytes, got "+t.length);let f=s?Oe(t):Bt(t);if(c&&(f=ht(f,i)),!n&&!this.isValid(f))throw new Error("invalid field element: outside of range 0..ORDER");return f}invertBatch(t){return se(this,t)}cmov(t,n,r){return r?n:t}};function Ot(e,t={}){return new qe(e,t)}function tr(e){if(typeof e!="bigint")throw new Error("field order must be bigint");let t=e.toString(2).length;return Math.ceil(t/8)}function De(e){let t=tr(e);return t+Math.ceil(t/2)}function ie(e,t,n=!1){V(e);let r=e.length,o=tr(t),s=De(t);if(r<16||r1024)throw new Error("expected "+s+"-1024 bytes of input, got "+r);let i=n?Oe(e):Bt(e),c=ht(i,t-st)+st;return n?He(c,o):oe(c,o)}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var Ht=BigInt(0),St=BigInt(1);function jt(e,t){let n=t.negate();return e?n:t}function Ze(e,t){let n=se(e.Fp,t.map(r=>r.Z));return t.map((r,o)=>e.fromAffine(r.toAffine(n[o])))}function or(e,t){if(!Number.isSafeInteger(e)||e<=0||e>t)throw new Error("invalid window size, expected [1.."+t+"], got W="+e)}function Ke(e,t){or(e,t);let n=Math.ceil(t/e)+1,r=2**(e-1),o=2**e,s=$t(e),i=BigInt(e);return{windows:n,windowSize:r,mask:s,maxNumber:o,shiftBy:i}}function er(e,t,n){let{windowSize:r,mask:o,maxNumber:s,shiftBy:i}=n,c=Number(e&o),f=e>>i;c>r&&(c-=s,f+=St);let a=t*r,l=a+Math.abs(c)-1,u=c===0,y=c<0,d=t%2!==0;return{nextN:f,offset:l,isZero:u,isNeg:y,isNegF:d,offsetF:a}}var ze=new WeakMap,sr=new WeakMap;function $e(e){return sr.get(e)||1}function nr(e){if(e!==Ht)throw new Error("invalid wNAF")}var ce=class{BASE;ZERO;Fn;bits;constructor(t,n){this.BASE=t.BASE,this.ZERO=t.ZERO,this.Fn=t.Fn,this.bits=n}_unsafeLadder(t,n,r=this.ZERO){let o=t;for(;n>Ht;)n&St&&(r=r.add(o)),o=o.double(),n>>=St;return r}precomputeWindow(t,n){let{windows:r,windowSize:o}=Ke(n,this.bits),s=[],i=t,c=i;for(let f=0;fHt||r>Ht;)n&St&&(s=s.add(o)),r&St&&(i=i.add(o)),o=o.double(),n>>=St,r>>=St;return{p1:s,p2:i}}function rr(e,t,n){if(t){if(t.ORDER!==e)throw new Error("Field.ORDER must match order: Fp == p, Fn == n");return Me(t),t}else return Ot(e,{isLE:n})}function cr(e,t,n={},r){if(r===void 0&&(r=e==="edwards"),!t||typeof t!="object")throw new Error(`expected valid ${e} CURVE object`);for(let f of["p","n","h"]){let a=t[f];if(!(typeof a=="bigint"&&a>Ht))throw new Error(`CURVE.${f} must be positive bigint`)}let o=rr(t.p,n.Fp,r),s=rr(t.n,n.Fn,r),c=["Gx","Gy","a",e==="weierstrass"?"b":"d"];for(let f of c)if(!o.isValid(t[f]))throw new Error(`CURVE.${f} must be valid field element of CURVE.Fp`);return t=Object.freeze(Object.assign({},t)),{CURVE:t,Fp:o,Fn:s}}function fe(e,t){return function(r){let o=e(r);return{secretKey:o,publicKey:t(o)}}}var ae=class{oHash;iHash;blockLen;outputLen;finished=!1;destroyed=!1;constructor(t,n){if(xt(t),V(n,void 0,"key"),this.iHash=t.create(),typeof this.iHash.update!="function")throw new Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let r=this.blockLen,o=new Uint8Array(r);o.set(n.length>r?t.create().update(n).digest():n);for(let s=0;snew ae(e,t).update(n).digest();gt.create=(e,t)=>new ae(e,t);/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var fr=(e,t)=>(e+(e>=0?t:-t)/ar)/t;function To(e,t,n){let[[r,o],[s,i]]=t,c=fr(i*e,n),f=fr(-o*e,n),a=e-c*r-f*s,l=-c*o-f*i,u=a=d||l=d)throw new Error("splitScalar (endomorphism): failed, k="+e);return{k1neg:u,k1:a,k2neg:y,k2:l}}function Pe(e){if(!["compact","recovered","der"].includes(e))throw new Error('Signature format must be "compact", "recovered", or "der"');return e}function je(e,t){let n={};for(let r of Object.keys(t))n[r]=e[r]===void 0?t[r]:e[r];return Kt(n.lowS,"lowS"),Kt(n.prehash,"prehash"),n.format!==void 0&&Pe(n.format),n}var Ge=class extends Error{constructor(t=""){super(t)}},vt={Err:Ge,_tlv:{encode:(e,t)=>{let{Err:n}=vt;if(e<0||e>256)throw new n("tlv.encode: wrong tag");if(t.length&1)throw new n("tlv.encode: unpadded data");let r=t.length/2,o=zt(r);if(o.length/2&128)throw new n("tlv.encode: long form length too big");let s=r>127?zt(o.length/2|128):"";return zt(e)+s+o+t},decode(e,t){let{Err:n}=vt,r=0;if(e<0||e>256)throw new n("tlv.encode: wrong tag");if(t.length<2||t[r++]!==e)throw new n("tlv.decode: wrong tlv");let o=t[r++],s=!!(o&128),i=0;if(!s)i=o;else{let f=o&127;if(!f)throw new n("tlv.decode(long): indefinite length not supported");if(f>4)throw new n("tlv.decode(long): byte length is too big");let a=t.subarray(r,r+f);if(a.length!==f)throw new n("tlv.decode: length bytes not complete");if(a[0]===0)throw new n("tlv.decode(long): zero leftmost byte");for(let l of a)i=i<<8|l;if(r+=f,i<128)throw new n("tlv.decode(long): not minimal encoding")}let c=t.subarray(r,r+i);if(c.length!==i)throw new n("tlv.decode: wrong value length");return{v:c,l:t.subarray(r+i)}}},_int:{encode(e){let{Err:t}=vt;if(e{let{X:b,Y:h,Z:B}=k;if(r.eql(B,r.ONE))return{x:b,y:h};let A=k.is0();x==null&&(x=A?r.ONE:r.inv(B));let L=r.mul(b,x),w=r.mul(h,x),v=r.mul(B,x);if(A)return{x:r.ZERO,y:r.ZERO};if(!r.eql(v,r.ONE))throw new Error("invZ was invalid");return{x:L,y:w}}),z=Ce(k=>{if(k.is0()){if(t.allowInfinityPoint&&!r.is0(k.Y))return;throw new Error("bad point: ZERO")}let{x,y:b}=k.toAffine();if(!r.isValid(x)||!r.isValid(b))throw new Error("bad point: x or y not field elements");if(!m(x,b))throw new Error("bad point: equation left != right");if(!k.isTorsionFree())throw new Error("bad point: not in prime-order subgroup");return!0});function D(k,x,b,h,B){return b=new _(r.mul(b.X,k),b.Y,b.Z),x=jt(h,x),b=jt(B,b),x.add(b)}class _{static BASE=new _(s.Gx,s.Gy,r.ONE);static ZERO=new _(r.ZERO,r.ONE,r.ZERO);static Fp=r;static Fn=o;X;Y;Z;constructor(x,b,h){this.X=R("x",x),this.Y=R("y",b,!0),this.Z=R("z",h),Object.freeze(this)}static CURVE(){return s}static fromAffine(x){let{x:b,y:h}=x||{};if(!x||!r.isValid(b)||!r.isValid(h))throw new Error("invalid affine point");if(x instanceof _)throw new Error("projective point not allowed");return r.is0(b)&&r.is0(h)?_.ZERO:new _(b,h,r.ONE)}static fromBytes(x){let b=_.fromAffine(g(V(x,void 0,"point")));return b.assertValidity(),b}static fromHex(x){return _.fromBytes(P(x))}get x(){return this.toAffine().x}get y(){return this.toAffine().y}precompute(x=8,b=!0){return N.createCache(this,x),b||this.multiply(le),this}assertValidity(){z(this)}hasEvenY(){let{y:x}=this.toAffine();if(!r.isOdd)throw new Error("Field doesn't support isOdd");return!r.isOdd(x)}equals(x){O(x);let{X:b,Y:h,Z:B}=this,{X:A,Y:L,Z:w}=x,v=r.eql(r.mul(b,w),r.mul(A,B)),U=r.eql(r.mul(h,w),r.mul(L,B));return v&&U}negate(){return new _(this.X,r.neg(this.Y),this.Z)}double(){let{a:x,b}=s,h=r.mul(b,le),{X:B,Y:A,Z:L}=this,w=r.ZERO,v=r.ZERO,U=r.ZERO,T=r.mul(B,B),$=r.mul(A,A),K=r.mul(L,L),C=r.mul(B,A);return C=r.add(C,C),U=r.mul(B,L),U=r.add(U,U),w=r.mul(x,U),v=r.mul(h,K),v=r.add(w,v),w=r.sub($,v),v=r.add($,v),v=r.mul(w,v),w=r.mul(C,w),U=r.mul(h,U),K=r.mul(x,K),C=r.sub(T,K),C=r.mul(x,C),C=r.add(C,U),U=r.add(T,T),T=r.add(U,T),T=r.add(T,K),T=r.mul(T,C),v=r.add(v,T),K=r.mul(A,L),K=r.add(K,K),T=r.mul(K,C),w=r.sub(w,T),U=r.mul(K,$),U=r.add(U,U),U=r.add(U,U),new _(w,v,U)}add(x){O(x);let{X:b,Y:h,Z:B}=this,{X:A,Y:L,Z:w}=x,v=r.ZERO,U=r.ZERO,T=r.ZERO,$=s.a,K=r.mul(s.b,le),C=r.mul(b,A),Z=r.mul(h,L),Y=r.mul(B,w),et=r.add(b,h),j=r.add(A,L);et=r.mul(et,j),j=r.add(C,Z),et=r.sub(et,j),j=r.add(b,B);let ot=r.add(A,w);return j=r.mul(j,ot),ot=r.add(C,Y),j=r.sub(j,ot),ot=r.add(h,B),v=r.add(L,w),ot=r.mul(ot,v),v=r.add(Z,Y),ot=r.sub(ot,v),T=r.mul($,j),v=r.mul(K,Y),T=r.add(v,T),v=r.sub(Z,T),T=r.add(Z,T),U=r.mul(v,T),Z=r.add(C,C),Z=r.add(Z,C),Y=r.mul($,Y),j=r.mul(K,j),Z=r.add(Z,Y),Y=r.sub(C,Y),Y=r.mul($,Y),j=r.add(j,Y),C=r.mul(Z,j),U=r.add(U,C),C=r.mul(ot,j),v=r.mul(et,v),v=r.sub(v,C),C=r.mul(et,Z),T=r.mul(ot,T),T=r.add(T,C),new _(v,U,T)}subtract(x){return this.add(x.negate())}is0(){return this.equals(_.ZERO)}multiply(x){let{endo:b}=t;if(!o.isValidNot0(x))throw new Error("invalid scalar: out of range");let h,B,A=L=>N.cached(this,L,w=>Ze(_,w));if(b){let{k1neg:L,k1:w,k2neg:v,k2:U}=W(x),{p:T,f:$}=A(w),{p:K,f:C}=A(U);B=$.add(C),h=D(b.beta,T,K,L,v)}else{let{p:L,f:w}=A(x);h=L,B=w}return Ze(_,[h,B])[0]}multiplyUnsafe(x){let{endo:b}=t,h=this;if(!o.isValid(x))throw new Error("invalid scalar: out of range");if(x===mt||h.is0())return _.ZERO;if(x===Nt)return h;if(N.hasCache(this))return this.multiply(x);if(b){let{k1neg:B,k1:A,k2neg:L,k2:w}=W(x),{p1:v,p2:U}=ir(_,h,A,w);return D(b.beta,v,U,B,L)}else return N.unsafe(h,x)}toAffine(x){return H(this,x)}isTorsionFree(){let{isTorsionFree:x}=t;return i===Nt?!0:x?x(_,this):N.unsafe(this,c).is0()}clearCofactor(){let{clearCofactor:x}=t;return i===Nt?this:x?x(_,this):this.multiplyUnsafe(i)}isSmallOrder(){return this.multiplyUnsafe(i).is0()}toBytes(x=!0){return Kt(x,"isCompressed"),this.assertValidity(),d(_,this,x)}toHex(x=!0){return J(this.toBytes(x))}toString(){return``}}let q=o.BITS,N=new ce(_,t.endo?Math.ceil(q/2):q);return _.BASE.precompute(8),_}function ur(e){return Uint8Array.of(e?2:3)}function dr(e,t){return{secretKey:t.BYTES,publicKey:1+e.BYTES,publicKeyUncompressed:1+2*e.BYTES,publicKeyHasPrefix:!0,signature:2*t.BYTES}}function _o(e,t={}){let{Fn:n}=e,r=t.randomBytes||ct,o=Object.assign(dr(e.Fp,n),{seed:De(n.ORDER)});function s(d){try{let g=n.fromBytes(d);return n.isValidNot0(g)}catch{return!1}}function i(d,g){let{publicKey:p,publicKeyUncompressed:m}=o;try{let E=d.length;return g===!0&&E!==p||g===!1&&E!==m?!1:!!e.fromBytes(d)}catch{return!1}}function c(d=r(o.seed)){return ie(V(d,o.seed,"seed"),n.ORDER)}function f(d,g=!0){return e.BASE.multiply(n.fromBytes(d)).toBytes(g)}function a(d){let{secretKey:g,publicKey:p,publicKeyUncompressed:m}=o;if(!Dt(d)||"_lengths"in n&&n._lengths||g===p)return;let E=V(d,void 0,"key").length;return E===p||E===m}function l(d,g,p=!0){if(a(d)===!0)throw new Error("first arg must be private key");if(a(g)===!1)throw new Error("second arg must be public key");let m=n.fromBytes(d);return e.fromBytes(g).multiply(m).toBytes(p)}let u={isValidSecretKey:s,isValidPublicKey:i,randomSecretKey:c},y=fe(c,f);return Object.freeze({getPublicKey:f,getSharedSecret:l,keygen:y,Point:e,utils:u,lengths:o})}function hr(e,t,n={}){xt(t),Zt(n,{},{hmac:"function",lowS:"boolean",randomBytes:"function",bits2int:"function",bits2int_modN:"function"}),n=Object.assign({},n);let r=n.randomBytes||ct,o=n.hmac||((b,h)=>gt(t,b,h)),{Fp:s,Fn:i}=e,{ORDER:c,BITS:f}=i,{keygen:a,getPublicKey:l,getSharedSecret:u,utils:y,lengths:d}=_o(e,n),g={prehash:!0,lowS:typeof n.lowS=="boolean"?n.lowS:!0,format:"compact",extraEntropy:!1},p=c*ar>Nt;return b>h}function E(b,h){if(!i.isValidNot0(h))throw new Error(`invalid signature ${b}: out of range 1..Point.Fn.ORDER`);return h}function S(){if(p)throw new Error('"recovered" sig type is not supported for cofactor >2 curves')}function R(b,h){Pe(h);let B=d.signature,A=h==="compact"?B:h==="recovered"?B+1:void 0;return V(b,A)}class O{r;s;recovery;constructor(h,B,A){if(this.r=E("r",h),this.s=E("s",B),A!=null){if(S(),![0,1,2,3].includes(A))throw new Error("invalid recovery id");this.recovery=A}Object.freeze(this)}static fromBytes(h,B=g.format){R(h,B);let A;if(B==="der"){let{r:U,s:T}=vt.toSig(V(h));return new O(U,T)}B==="recovered"&&(A=h[0],B="compact",h=h.subarray(1));let L=d.signature/2,w=h.subarray(0,L),v=h.subarray(L,L*2);return new O(i.fromBytes(w),i.fromBytes(v),A)}static fromHex(h,B){return this.fromBytes(P(h),B)}assertRecovery(){let{recovery:h}=this;if(h==null)throw new Error("invalid recovery id: must be present");return h}addRecoveryBit(h){return new O(this.r,this.s,h)}recoverPublicKey(h){let{r:B,s:A}=this,L=this.assertRecovery(),w=L===2||L===3?B+c:B;if(!s.isValid(w))throw new Error("invalid recovery id: sig.r+curve.n != R.x");let v=s.toBytes(w),U=e.fromBytes(Q(ur((L&1)===0),v)),T=i.inv(w),$=H(V(h,void 0,"msgHash")),K=i.create(-$*T),C=i.create(A*T),Z=e.BASE.multiplyUnsafe(K).add(U.multiplyUnsafe(C));if(Z.is0())throw new Error("invalid recovery: point at infinify");return Z.assertValidity(),Z}hasHighS(){return m(this.s)}toBytes(h=g.format){if(Pe(h),h==="der")return P(vt.hexFromSig(this));let{r:B,s:A}=this,L=i.toBytes(B),w=i.toBytes(A);return h==="recovered"?(S(),Q(Uint8Array.of(this.assertRecovery()),L,w)):Q(L,w)}toHex(h){return J(this.toBytes(h))}}let W=n.bits2int||function(h){if(h.length>8192)throw new Error("input is too large");let B=Bt(h),A=h.length*8-f;return A>0?B>>BigInt(A):B},H=n.bits2int_modN||function(h){return i.create(W(h))},z=$t(f);function D(b){return $n("num < 2^"+f,b,mt,z),i.toBytes(b)}function _(b,h){return V(b,void 0,"message"),h?V(t(b),void 0,"prehashed message"):b}function q(b,h,B){let{lowS:A,prehash:L,extraEntropy:w}=je(B,g);b=_(b,L);let v=H(b),U=i.fromBytes(h);if(!i.isValidNot0(U))throw new Error("invalid private key");let T=[D(U),D(v)];if(w!=null&&w!==!1){let Z=w===!0?r(d.secretKey):w;T.push(V(Z,void 0,"extraEntropy"))}let $=Q(...T),K=v;function C(Z){let Y=W(Z);if(!i.isValidNot0(Y))return;let et=i.inv(Y),j=e.BASE.multiply(Y).toAffine(),ot=i.create(j.x);if(ot===mt)return;let Jt=i.create(et*i.create(K+ot*U));if(Jt===mt)return;let kn=(j.x===ot?0:2)|Number(j.y&Nt),Rn=Jt;return A&&m(Jt)&&(Rn=i.neg(Jt),kn^=1),new O(ot,Rn,p?void 0:kn)}return{seed:$,k2sig:C}}function N(b,h,B={}){let{seed:A,k2sig:L}=q(b,h,B);return Zn(t.outputLen,i.BYTES,o)(A,L).toBytes(B.format)}function k(b,h,B,A={}){let{lowS:L,prehash:w,format:v}=je(A,g);if(B=V(B,void 0,"publicKey"),h=_(h,w),!Dt(b)){let U=b instanceof O?", use sig.toBytes()":"";throw new Error("verify expects Uint8Array signature"+U)}R(b,v);try{let U=O.fromBytes(b,v),T=e.fromBytes(B);if(L&&U.hasHighS())return!1;let{r:$,s:K}=U,C=H(h),Z=i.inv(K),Y=i.create(C*Z),et=i.create($*Z),j=e.BASE.multiplyUnsafe(Y).add(T.multiplyUnsafe(et));return j.is0()?!1:i.create(j.x)===$}catch{return!1}}function x(b,h,B={}){let{prehash:A}=je(B,g);return h=_(h,A),O.fromBytes(b,"recovered").recoverPublicKey(h).toBytes()}return Object.freeze({keygen:a,getPublicKey:l,getSharedSecret:u,utils:y,lengths:d,Point:e,sign:N,verify:k,recoverPublicKey:x,Signature:O,hash:t})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var he={p:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:BigInt(1),a:BigInt(0),b:BigInt(7),Gx:BigInt("0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),Gy:BigInt("0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")},ko={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),basises:[[BigInt("0x3086d221a7d46bcde86c90e49284eb15"),-BigInt("0xe4437ed6010e88286f547fa90abfe4c3")],[BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),BigInt("0x3086d221a7d46bcde86c90e49284eb15")]]},Ro=BigInt(0),We=BigInt(2);function Oo(e){let t=he.p,n=BigInt(3),r=BigInt(6),o=BigInt(11),s=BigInt(22),i=BigInt(23),c=BigInt(44),f=BigInt(88),a=e*e*e%t,l=a*a*e%t,u=lt(l,n,t)*l%t,y=lt(u,n,t)*l%t,d=lt(y,We,t)*a%t,g=lt(d,o,t)*d%t,p=lt(g,s,t)*g%t,m=lt(p,c,t)*p%t,E=lt(m,f,t)*m%t,S=lt(E,c,t)*p%t,R=lt(S,n,t)*l%t,O=lt(R,i,t)*g%t,W=lt(O,r,t)*a%t,H=lt(W,We,t);if(!ue.eql(ue.sqr(H),e))throw new Error("Cannot find square root");return H}var ue=Ot(he.p,{sqrt:Oo}),Tt=lr(he,{Fp:ue,endo:ko}),Gt=hr(Tt,at),yr={};function de(e,...t){let n=yr[e];if(n===void 0){let r=at(zn(e));n=Q(r,r),yr[e]=n}return at(Q(n,...t))}var Xe=e=>e.toBytes(!0).slice(1),Qe=e=>e%We===Ro;function Ye(e){let{Fn:t,BASE:n}=Tt,r=t.fromBytes(e),o=n.multiply(r);return{scalar:Qe(o.y)?r:t.neg(r),bytes:Xe(o)}}function pr(e){let t=ue;if(!t.isValidNot0(e))throw new Error("invalid x: Fail if x \u2265 p");let n=t.create(e*e),r=t.create(n*e+BigInt(7)),o=t.sqrt(r);Qe(o)||(o=t.neg(o));let s=Tt.fromAffine({x:e,y:o});return s.assertValidity(),s}var Pt=Bt;function br(...e){return Tt.Fn.create(Pt(de("BIP0340/challenge",...e)))}function gr(e){return Ye(e).bytes}function Ho(e,t,n=ct(32)){let{Fn:r}=Tt,o=V(e,void 0,"message"),{bytes:s,scalar:i}=Ye(t),c=V(n,32,"auxRand"),f=r.toBytes(i^Pt(de("BIP0340/aux",c))),a=de("BIP0340/nonce",f,s,o),{bytes:l,scalar:u}=Ye(a),y=br(l,s,o),d=new Uint8Array(64);if(d.set(l,0),d.set(r.toBytes(r.create(u+y*i)),32),!xr(d,o,s))throw new Error("sign: Invalid signature produced");return d}function xr(e,t,n){let{Fp:r,Fn:o,BASE:s}=Tt,i=V(e,64,"signature"),c=V(t,void 0,"message"),f=V(n,32,"publicKey");try{let a=pr(Pt(f)),l=Pt(i.subarray(0,32));if(!r.isValidNot0(l))return!1;let u=Pt(i.subarray(32,64));if(!o.isValidNot0(u))return!1;let y=br(o.toBytes(l),Xe(a),c),d=s.multiplyUnsafe(u).add(a.multiplyUnsafe(o.neg(y))),{x:g,y:p}=d.toAffine();return!(d.is0()||!Qe(p)||g!==l)}catch{return!1}}var Ct=(()=>{let n=(r=ct(48))=>ie(r,he.n);return{keygen:fe(n,gr),getPublicKey:gr,sign:Ho,verify:xr,Point:Tt,utils:{randomSecretKey:n,taggedHash:de,lift_x:pr,pointToBytes:Xe},lengths:{secretKey:32,publicKey:32,publicKeyHasPrefix:!1,signature:64,seed:48}}})();var Oi=new TextDecoder("utf-8"),No=new TextEncoder;function Co(e){if(e.length!==64)return!1;for(let t=0;t<64;t++){let n=e.charCodeAt(t);if(isNaN(n)||n<48||n>102||n>57&&n<97)return!1}return!0}var qt=Symbol("verified"),qo=e=>e instanceof Object;function Vo(e){if(!qo(e)||typeof e.kind!="number"||typeof e.content!="string"||typeof e.created_at!="number"||typeof e.pubkey!="string"||!Co(e.pubkey)||!Array.isArray(e.tags))return!1;for(let t=0;tJo,Bech32MaxSize:()=>sn,NostrTypeGuard:()=>Qo,decode:()=>Ir,decodeNostrURI:()=>ts,encodeBytes:()=>Ee,naddrEncode:()=>is,neventEncode:()=>ss,noteEncode:()=>rs,nprofileEncode:()=>os,npubEncode:()=>ns,nsecEncode:()=>es});/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */function nn(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function jo(e){if(!nn(e))throw new Error("Uint8Array expected")}function Br(e,t){return Array.isArray(t)?t.length===0?!0:e?t.every(n=>typeof n=="string"):t.every(n=>Number.isSafeInteger(n)):!1}function Po(e){if(typeof e!="function")throw new Error("function expected");return!0}function It(e,t){if(typeof t!="string")throw new Error(`${e}: string expected`);return!0}function rn(e){if(!Number.isSafeInteger(e))throw new Error(`invalid integer: ${e}`)}function Fe(e){if(!Array.isArray(e))throw new Error("array expected")}function pe(e,t){if(!Br(!0,t))throw new Error(`${e}: array of strings expected`)}function vr(e,t){if(!Br(!1,t))throw new Error(`${e}: array of numbers expected`)}function Ar(...e){let t=s=>s,n=(s,i)=>c=>s(i(c)),r=e.map(s=>s.encode).reduceRight(n,t),o=e.map(s=>s.decode).reduce(n,t);return{encode:r,decode:o}}function Lr(e){let t=typeof e=="string"?e.split(""):e,n=t.length;pe("alphabet",t);let r=new Map(t.map((o,s)=>[o,s]));return{encode:o=>(Fe(o),o.map(s=>{if(!Number.isSafeInteger(s)||s<0||s>=n)throw new Error(`alphabet.encode: digit index outside alphabet "${s}". Allowed: ${e}`);return t[s]})),decode:o=>(Fe(o),o.map(s=>{It("alphabet.decode",s);let i=r.get(s);if(i===void 0)throw new Error(`Unknown letter: "${s}". Allowed: ${e}`);return i}))}}function Ur(e=""){return It("join",e),{encode:t=>(pe("join.decode",t),t.join(e)),decode:t=>(It("join.decode",t),t.split(e))}}function Go(e,t="="){return rn(e),It("padding",t),{encode(n){for(pe("padding.encode",n);n.length*e%8;)n.push(t);return n},decode(n){pe("padding.decode",n);let r=n.length;if(r*e%8)throw new Error("padding: invalid, string should have whole number of bytes");for(;r>0&&n[r-1]===t;r--)if((r-1)*e%8===0)throw new Error("padding: invalid, string has too much padding");return n.slice(0,r)}}}var Sr=(e,t)=>t===0?e:Sr(t,e%t),be=(e,t)=>e+(t-Sr(e,t)),ge=(()=>{let e=[];for(let t=0;t<40;t++)e.push(2**t);return e})();function tn(e,t,n,r){if(Fe(e),t<=0||t>32)throw new Error(`convertRadix2: wrong from=${t}`);if(n<=0||n>32)throw new Error(`convertRadix2: wrong to=${n}`);if(be(t,n)>32)throw new Error(`convertRadix2: carry overflow from=${t} to=${n} carryBits=${be(t,n)}`);let o=0,s=0,i=ge[t],c=ge[n]-1,f=[];for(let a of e){if(rn(a),a>=i)throw new Error(`convertRadix2: invalid data word=${a} from=${t}`);if(o=o<32)throw new Error(`convertRadix2: carry overflow pos=${s} from=${t}`);for(s+=t;s>=n;s-=n)f.push((o>>s-n&c)>>>0);let l=ge[s];if(l===void 0)throw new Error("invalid carry");o&=l-1}if(o=o<=t)throw new Error("Excess padding");if(!r&&o>0)throw new Error(`Non-zero padding: ${o}`);return r&&s>0&&f.push(o>>>0),f}function Tr(e,t=!1){if(rn(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(be(8,e)>32||be(e,8)>32)throw new Error("radix2: carry overflow");return{encode:n=>{if(!nn(n))throw new Error("radix2.encode input should be Uint8Array");return tn(Array.from(n),8,e,!t)},decode:n=>(vr("radix2.decode",n),Uint8Array.from(tn(n,e,8,t)))}}function wr(e){return Po(e),function(...t){try{return e.apply(null,t)}catch{}}}var Wo=typeof Uint8Array.from([]).toBase64=="function"&&typeof Uint8Array.fromBase64=="function",Yo=(e,t)=>{It("base64",e);let n=t?/^[A-Za-z0-9=_-]+$/:/^[A-Za-z0-9=+/]+$/,r=t?"base64url":"base64";if(e.length>0&&!n.test(e))throw new Error("invalid base64");return Uint8Array.fromBase64(e,{alphabet:r,lastChunkHandling:"strict"})},At=Wo?{encode(e){return jo(e),e.toBase64()},decode(e){return Yo(e,!1)}}:Ar(Tr(6),Lr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),Go(6),Ur(""));var en=Ar(Lr("qpzry9x8gf2tvdw0s3jn54khce6mua7l"),Ur("")),mr=[996825010,642813549,513874426,1027748829,705979059];function Wt(e){let t=e>>25,n=(e&33554431)<<5;for(let r=0;r>r&1)===1&&(n^=mr[r]);return n}function Er(e,t,n=1){let r=e.length,o=1;for(let s=0;s126)throw new Error(`Invalid prefix (${e})`);o=Wt(o)^i>>5}o=Wt(o);for(let s=0;sd)throw new TypeError(`Length ${p} exceeds limit ${d}`);let m=u.toLowerCase(),E=Er(m,y,t);return`${m}1${en.encode(y)}${E}`}function c(u,y=90){It("bech32.decode input",u);let d=u.length;if(d<8||y!==!1&&d>y)throw new TypeError(`invalid string length: ${d} (${u}). Expected (8..${y})`);let g=u.toLowerCase();if(u!==g&&u!==u.toUpperCase())throw new Error("String must be lowercase or uppercase");let p=g.lastIndexOf("1");if(p===0||p===-1)throw new Error('Letter "1" must be present between prefix and data only');let m=g.slice(0,p),E=g.slice(p+1);if(E.length<6)throw new Error("Data must be at least 6 characters long");let S=en.decode(E).slice(0,-6),R=Er(m,S,t);if(!E.endsWith(R))throw new Error(`Invalid checksum in ${u}: expected "${R}"`);return{prefix:m,words:S}}let f=wr(c);function a(u){let{prefix:y,words:d}=c(u,!1);return{prefix:y,words:d,bytes:r(d)}}function l(u,y){return i(u,o(y))}return{encode:i,decode:c,encodeFromBytes:l,decodeToBytes:a,decodeUnsafe:f,fromWords:r,fromWordsUnsafe:s,toWords:o}}var yt=Xo("bech32");var xe=new TextDecoder("utf-8"),we=new TextEncoder,Qo={isNProfile:e=>/^nprofile1[a-z\d]+$/.test(e||""),isNEvent:e=>/^nevent1[a-z\d]+$/.test(e||""),isNAddr:e=>/^naddr1[a-z\d]+$/.test(e||""),isNSec:e=>/^nsec1[a-z\d]{58}$/.test(e||""),isNPub:e=>/^npub1[a-z\d]{58}$/.test(e||""),isNote:e=>/^note1[a-z\d]+$/.test(e||""),isNcryptsec:e=>/^ncryptsec1[a-z\d]+$/.test(e||"")},sn=5e3,Jo=/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/;function Fo(e){let t=new Uint8Array(4);return t[0]=e>>24&255,t[1]=e>>16&255,t[2]=e>>8&255,t[3]=e&255,t}function ts(e){try{return e.startsWith("nostr:")&&(e=e.substring(6)),Ir(e)}catch{return{type:"invalid",data:null}}}function Ir(e){let{prefix:t,words:n}=yt.decode(e,sn),r=new Uint8Array(yt.fromWords(n));switch(t){case"nprofile":{let o=on(r);if(!o[0]?.[0])throw new Error("missing TLV 0 for nprofile");if(o[0][0].length!==32)throw new Error("TLV 0 should be 32 bytes");return{type:"nprofile",data:{pubkey:J(o[0][0]),relays:o[1]?o[1].map(s=>xe.decode(s)):[]}}}case"nevent":{let o=on(r);if(!o[0]?.[0])throw new Error("missing TLV 0 for nevent");if(o[0][0].length!==32)throw new Error("TLV 0 should be 32 bytes");if(o[2]&&o[2][0].length!==32)throw new Error("TLV 2 should be 32 bytes");if(o[3]&&o[3][0].length!==4)throw new Error("TLV 3 should be 4 bytes");return{type:"nevent",data:{id:J(o[0][0]),relays:o[1]?o[1].map(s=>xe.decode(s)):[],author:o[2]?.[0]?J(o[2][0]):void 0,kind:o[3]?.[0]?parseInt(J(o[3][0]),16):void 0}}}case"naddr":{let o=on(r);if(!o[0]?.[0])throw new Error("missing TLV 0 for naddr");if(!o[2]?.[0])throw new Error("missing TLV 2 for naddr");if(o[2][0].length!==32)throw new Error("TLV 2 should be 32 bytes");if(!o[3]?.[0])throw new Error("missing TLV 3 for naddr");if(o[3][0].length!==4)throw new Error("TLV 3 should be 4 bytes");return{type:"naddr",data:{identifier:xe.decode(o[0][0]),pubkey:J(o[2][0]),kind:parseInt(J(o[3][0]),16),relays:o[1]?o[1].map(s=>xe.decode(s)):[]}}}case"nsec":return{type:t,data:r};case"npub":case"note":return{type:t,data:J(r)};default:throw new Error(`unknown prefix ${t}`)}}function on(e){let t={},n=e;for(;n.length>0;){if(n.length<2)throw new Error("not enough data to read TLV");let r=n[0],o=n[1],s=n.slice(2,2+o);if(n=n.slice(2+o),s.lengthwe.encode(n))});return me("nprofile",t)}function ss(e){let t;e.kind!==void 0&&(t=Fo(e.kind));let n=cn({0:[P(e.id)],1:(e.relays||[]).map(r=>we.encode(r)),2:e.author?[P(e.author)]:[],3:t?[new Uint8Array(t)]:[]});return me("nevent",n)}function is(e){let t=new ArrayBuffer(4);new DataView(t).setUint32(0,e.kind,!1);let n=cn({0:[we.encode(e.identifier)],1:(e.relays||[]).map(r=>we.encode(r)),2:[P(e.pubkey)],3:[new Uint8Array(t)]});return me("naddr",n)}function cn(e){let t=[];return Object.entries(e).reverse().forEach(([n,r])=>{r.forEach(o=>{let s=new Uint8Array(o.length+2);s.set([parseInt(n)],0),s.set([o.length],1),s.set(o,2),t.push(s)})}),Q(...t)}var Dr={};Ft(Dr,{decrypt:()=>Ls,encrypt:()=>As});/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */function cs(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function Be(e){if(typeof e!="boolean")throw new Error(`boolean expected, not ${e}`)}function Yt(e){if(!Number.isSafeInteger(e)||e<0)throw new Error("positive integer expected, got "+e)}function G(e,t,n=""){let r=cs(e),o=e?.length,s=t!==void 0;if(!r||s&&o!==t){let i=n&&`"${n}" `,c=s?` of length ${t}`:"",f=r?`length=${o}`:`type=${typeof e}`;throw new Error(i+"expected Uint8Array"+c+", got "+f)}return e}function fn(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function kr(e,t){G(e,void 0,"output");let n=t.outputLen;if(e.length{function n(r,...o){if(G(r,void 0,"key"),!fs)throw new Error("Non little-endian hardware is not yet supported");if(e.nonceLength!==void 0){let l=o[0];G(l,e.varSizeNonce?void 0:e.nonceLength,"nonce")}let s=e.tagLength;s&&o[1]!==void 0&&G(o[1],void 0,"AAD");let i=t(r,...o),c=(l,u)=>{if(u!==void 0){if(l!==2)throw new Error("cipher output not supported");G(u,void 0,"output")}},f=!1;return{encrypt(l,u){if(f)throw new Error("cannot encrypt() twice with same key + nonce");return f=!0,G(l),c(i.encrypt.length,u),i.encrypt(l,u)},decrypt(l,u){if(G(l),s&&l.length(d-1)*32/u)throw new Error('"p" expected integer 1..((2^32 - 1) * 32) / (128 * r)');if(c<1||c>(d-1)*32)throw new Error('"dkLen" expected integer 1..(2^32 - 1) * 32');if(u*(o+i)>a)throw new Error('"maxmem" limit was hit, expected 128*r*(N+p) <= "maxmem"='+a);let p=Tn(at,e,t,{c:1,dkLen:u*i}),m=te(p),E=te(new Uint8Array(u*o)),S=te(new Uint8Array(u)),R=()=>{};if(l){let O=2*o*i,W=Math.max(Math.floor(O/1e4),1),H=0;R=()=>{H++,l&&(!(H%W)||H===O)&&l(H/O)}}return{N:o,r:s,p:i,dkLen:c,blockSize32:y,V:E,B32:m,B:p,tmp:S,blockMixCb:R,asyncTick:f}}function Ps(e,t,n,r,o){let s=Tn(at,e,n,{c:1,dkLen:t});return ft(n,r,o),s}function _n(e,t,n){let{N:r,r:o,p:s,dkLen:i,blockSize32:c,V:f,B32:a,B:l,tmp:u,blockMixCb:y}=js(e,t,n);Se(a);for(let d=0;d>>0;for(let E=0;E document.getElementById(id); -const views = ["loading", "signin", "home"]; +const VIEWS = { + loading: ["view-loading"], + signin: ["view-signin"], + home: ["view-home", "view-connect", "view-key"], +}; function show(view) { - for (const v of views) $(`view-${v}`).classList.toggle("hidden", v !== view); + const on = new Set(VIEWS[view]); + for (const ids of Object.values(VIEWS)) for (const id of ids) $(id).classList.toggle("hidden", !on.has(id)); } function setStatus(id, msg, kind = "") { const el = $(id); el.textContent = msg; el.className = "status" + (kind ? " " + kind : ""); } +const errMsg = (err) => err?.message || String(err); +const ago = (t) => { + const s = Math.round((Date.now() - t) / 1000); + if (s < 60) return "just now"; + if (s < 3600) return `${Math.round(s / 60)}m ago`; + if (s < 86400) return `${Math.round(s / 3600)}h ago`; + return new Date(t).toLocaleDateString(); +}; +// Opened by the extension's "Unlock" button: hand the key over once signed in. +const extensionUnlockRequested = location.hash === "#extension-unlock"; + +let session = null; let signer = null; -let currentProfile = null; +let bunker = null; let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent( @@ -34,12 +48,18 @@ const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent( $("signin-go").addEventListener("click", async () => { $("signin-go").disabled = true; try { - const { signer: s, profile, isNewIdentity } = await signIn({ + session = await signIn({ clientId: CLIENT_ID, onStatus: (msg) => setStatus("signin-status", msg), + // Passkey prompts need a fresh click (Safari), so wait for one. + onPasskeyNeeded: () => + new Promise((resolve) => { + const b = $("signin-passkey"); + b.classList.remove("hidden"); + b.onclick = () => { b.classList.add("hidden"); resolve(); }; + }), }); - signer = s; - currentProfile = profile; + signer = session.signer; setStatus("signin-status", "Connecting to relays…"); const unsubStatus = pool.onStatus((snap) => { @@ -49,24 +69,24 @@ $("signin-go").addEventListener("click", async () => { await pool.connectAll({ minRelays: 1 }); unsubStatus(); - if (isNewIdentity) { - // First time: publish a kind-0 with the Google name + picture so this - // user shows up properly on nostr. - publishProfileEvent(profile); + if (session.isNewIdentity) { + // Brand-new key: nothing can exist on relays yet, so publish the Google + // name + picture right away. Late relays get it via the pool's replay. + publishProfileEvent(session.profile); } - enterHome(profile); + enterHome(); } catch (err) { console.error(err); - setStatus("signin-status", err.message || String(err), "error"); + $("signin-passkey").classList.add("hidden"); + setStatus("signin-status", errMsg(err), "error"); $("signin-go").disabled = false; } }); $("sign-out").addEventListener("click", () => { clearSession(); - signer = null; - location.reload(); + location.replace(location.pathname); }); // ---------- home / live profile / notes ---------- @@ -74,25 +94,25 @@ $("sign-out").addEventListener("click", () => { function publishProfileEvent(profile) { const event = signer.sign({ kind: 0, - content: JSON.stringify({ - name: profile.name, - picture: profile.picture, - // `nip05` would be nice here but we don't host one - }), + content: JSON.stringify({ name: profile.name, picture: profile.picture }), tags: [], }); pool.publish(event); publishedProfileTs = event.created_at; } -function enterHome(profile) { +function enterHome() { + const { profile } = session; $("me-name").textContent = profile.name || "anonymous"; $("me-pic").src = profile.picture || DEFAULT_AVATAR; $("me-npub").textContent = signer.npub; show("home"); pool.onStatus(renderRelayDots); - startLiveProfile(signer.pubkey, profile); + startLiveProfile(profile); loadNotes(); + initExtension(); + initBunker(); + renderKey(); } function renderRelayDots(snap) { @@ -108,14 +128,12 @@ function renderRelayDots(snap) { function safeJSON(s) { try { return JSON.parse(s); } catch { return {}; } } -function startLiveProfile(pubkey, googleProfile) { +function startLiveProfile(googleProfile) { // Long-lived sub for the latest kind-0. If a relay has a newer event than - // what's on screen, upgrade. If no relay returns anything within a few - // seconds, our google-side profile may not have been published anywhere — - // republish to make sure it lands. + // what's on screen, upgrade. let sawOne = false; pool.subscribe( - [{ kinds: [0], authors: [pubkey] }], + [{ kinds: [0], authors: [signer.pubkey] }], (ev) => { sawOne = true; if (ev.created_at <= publishedProfileTs) return; @@ -124,15 +142,13 @@ function startLiveProfile(pubkey, googleProfile) { if (p.name) $("me-name").textContent = p.name; if (p.picture) $("me-pic").src = p.picture; }, - null - ); - - setTimeout(() => { - if (!sawOne && googleProfile?.name) { - // No relay had our profile. Publish it now. - publishProfileEvent(googleProfile); + (answered) => { + // Fill in a profile only when relays positively report there is none. + // A slow relay must never overwrite a profile set in another app. + if (sawOne || publishedProfileTs || !googleProfile?.name) return; + if (answered >= Math.min(2, pool.sockets.size) && answered > 0) publishProfileEvent(googleProfile); } - }, 6000); + ); } let notesById = new Map(); @@ -151,14 +167,10 @@ function loadNotes() { renderRows([...notesById.values()], tbody); setStatus("notes-status", ""); }, - null - ); - - setTimeout(() => { - if (notesById.size === 0) { - setStatus("notes-status", "No notes yet. Publish one above."); + () => { + if (notesById.size === 0) setStatus("notes-status", "No notes yet. Publish one above."); } - }, 8000); + ); } function renderRows(rows, tbody) { @@ -179,16 +191,277 @@ function renderRows(rows, tbody) { $("note-send").addEventListener("click", () => { const text = $("note-text").value.trim(); if (!text) return; - $("note-send").disabled = true; const event = signer.sign({ kind: 1, content: text, tags: [] }); pool.publish(event); $("note-text").value = ""; - $("note-send").disabled = false; notesById.set(event.id, event); renderRows([...notesById.values()], $("notes-table").querySelector("tbody")); setStatus("notes-status", ""); }); +// ---------- browser extension (NIP-07) ---------- + +const hasExtensionApi = () => !!globalThis.chrome?.runtime?.sendMessage; + +async function extensionCall(msg) { + if (!EXTENSION_ID || !hasExtensionApi()) return null; + try { + const r = await chrome.runtime.sendMessage(EXTENSION_ID, msg); + if (r?.error) throw new Error(r.error); + return r; + } catch (err) { + if (msg.type === "status") return null; // not installed + throw err; + } +} + +async function renderExtension() { + for (const id of ["ext-install", "ext-connect", "ext-lock", "ext-store", "ext-download", "ext-steps"]) $(id).classList.add("hidden"); + + if (!globalThis.chrome) { + setStatus("ext-status", "This browser can't run the extension. Use the remote signer below instead."); + return null; + } + const st = await extensionCall({ type: "status" }); + if (!st) { + setStatus("ext-status", "Not installed."); + $("ext-install").classList.remove("hidden"); + if (EXTENSION_STORE_URL) { + $("ext-store").href = EXTENSION_STORE_URL; + $("ext-store").classList.remove("hidden"); + } else { + $("ext-download").classList.remove("hidden"); + $("ext-steps").classList.remove("hidden"); + } + } else if (st.pubkey === signer.pubkey) { + setStatus("ext-status", "✓ Installed and signing as you.", "ok"); + $("ext-lock").classList.remove("hidden"); + } else { + setStatus("ext-status", st.pubkey ? "Installed, but holding a different identity." : "Installed and locked."); + $("ext-connect").classList.remove("hidden"); + } + return st; +} + +async function unlockExtension() { + await extensionCall({ + type: "setKey", + sk: session.keyHex(), + profile: { name: session.profile.name, picture: session.profile.picture }, + }); + return renderExtension(); +} + +async function initExtension() { + const st = await renderExtension(); + if (extensionUnlockRequested) { + history.replaceState(null, "", location.pathname); + if (!st) return; + try { + await unlockExtension(); + const b = $("ext-unlock-banner"); + b.textContent = "✓ The Goostr extension is unlocked. You can close this tab."; + b.classList.remove("hidden"); + } catch (err) { + setStatus("ext-status", errMsg(err), "error"); + } + } +} + +$("ext-connect").addEventListener("click", () => + unlockExtension().catch((err) => setStatus("ext-status", errMsg(err), "error")) +); +$("ext-lock").addEventListener("click", async () => { + await extensionCall({ type: "lock" }).catch(() => {}); + renderExtension(); +}); +// A page only gets chrome.runtime if a matching extension was installed +// before it loaded, so a fresh install needs a reload to be seen. +$("ext-recheck").addEventListener("click", () => (hasExtensionApi() ? renderExtension() : location.reload())); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible" && signer) renderExtension(); +}); + +// ---------- remote signer (NIP-46) ---------- + +function initBunker() { + bunker = createBunker({ signer, pool, relays: RELAYS.slice(0, 3), onChange: renderBunker }); + if (localStorage.getItem(bunkerOnKey()) === "1") bunker.start(); + renderBunker(); +} +const bunkerOnKey = () => `goostr.bunker.on.${signer.pubkey}`; +function setBunkerOn(on) { + try { localStorage.setItem(bunkerOnKey(), on ? "1" : "0"); } catch {} + on ? bunker.start() : bunker.stop(); +} + +function renderBunker() { + if (!bunker) return; + setStatus("bunker-state", bunker.running ? "● Running in this tab" : "Off", bunker.running ? "ok" : ""); + $("bunker-toggle").textContent = bunker.running ? "Turn off" : "Turn on"; + + const clients = Object.entries(bunker.clients); + $("bunker-no-clients").classList.toggle("hidden", clients.length > 0); + $("bunker-clients").replaceChildren( + ...clients.map(([pk, c]) => { + const li = document.createElement("li"); + const who = document.createElement("span"); + who.className = "who"; + who.textContent = c.name; + who.title = pk; + const when = document.createElement("span"); + when.className = "when"; + when.textContent = `used ${ago(c.lastSeen)}`; + const rm = document.createElement("button"); + rm.className = "ghost"; + rm.textContent = "Remove"; + rm.onclick = () => bunker.revoke(pk); + li.append(who, when, rm); + return li; + }) + ); + + $("bunker-no-log").classList.toggle("hidden", bunker.log.length > 0); + $("bunker-log").replaceChildren( + ...bunker.log.map((e) => { + const li = document.createElement("li"); + if (!e.ok) li.className = "fail"; + const what = document.createElement("span"); + what.className = "what"; + what.textContent = `${e.who}: ${e.method}${e.detail ? ` (${e.detail})` : ""}`; + const when = document.createElement("span"); + when.className = "when"; + when.textContent = new Date(e.t).toLocaleTimeString(); + li.append(what, when); + return li; + }) + ); +} + +$("bunker-toggle").addEventListener("click", () => setBunkerOn(!bunker.running)); +$("bunker-new").addEventListener("click", () => { + setBunkerOn(true); + $("bunker-link").value = bunker.newBunkerUrl(); + $("bunker-link-box").classList.remove("hidden"); + $("bunker-link").select(); +}); +$("bunker-copy").addEventListener("click", async () => { + await navigator.clipboard.writeText($("bunker-link").value); + $("bunker-copy").textContent = "Copied"; + setTimeout(() => ($("bunker-copy").textContent = "Copy"), 1500); +}); +$("nc-go").addEventListener("click", async () => { + try { + setBunkerOn(true); + await bunker.acceptNostrConnect($("nc-link").value); + $("nc-link").value = ""; + setStatus("bunker-status", "Connected. Switch back to the app.", "ok"); + } catch (err) { + setStatus("bunker-status", errMsg(err), "error"); + } +}); +// Warn before closing the tab while apps depend on it. +addEventListener("beforeunload", (e) => { + if (bunker?.running && Object.keys(bunker.clients).length) e.preventDefault(); +}); + +// ---------- key protection, backup, export ---------- + +function renderKey() { + const p = session.protection; + $("key-protection").textContent = + p.type === "passkey" + ? `🔒 Encrypted with a passkey for ${p.rpId}.` + : "Stored in your Google Drive app data, not encrypted. Anyone who gets into your Google account can read it."; + $("pk-enable").classList.toggle("hidden", p.type === "passkey" || !passkeysAvailable()); + $("pk-disable").classList.toggle("hidden", p.type !== "passkey"); + if (!passkeysAvailable()) setStatus("pk-status", "This browser doesn't support passkeys."); +} + +$("pk-enable").addEventListener("click", async () => { + $("pk-enable").disabled = true; + setStatus("pk-status", "Follow your browser's passkey prompt…"); + try { + await session.enablePasskey(); + setStatus("pk-status", "Done. Other devices will ask for this passkey when you sign in.", "ok"); + } catch (err) { + setStatus("pk-status", errMsg(err), "error"); + } + $("pk-enable").disabled = false; + renderKey(); +}); + +$("pk-disable").addEventListener("click", async () => { + if (!confirm("Store the key in Google Drive without passkey encryption again?")) return; + try { + await session.disablePasskey(); + setStatus("pk-status", "Passkey protection removed.", "ok"); + } catch (err) { + setStatus("pk-status", errMsg(err), "error"); + } + renderKey(); +}); + +function backupPassword() { + const a = $("bk-pw1").value; + if (a.length < 8) throw new Error("Use at least 8 characters."); + if (a !== $("bk-pw2").value) throw new Error("The passwords don't match."); + return a; +} + +$("bk-download").addEventListener("click", () => { + try { + const pw = backupPassword(); + setStatus("bk-status", "Encrypting…"); + // Let the status paint before scrypt blocks the thread for a moment. + setTimeout(() => { + const text = `${session.ncryptsec(pw)}\n\nGoostr backup for ${signer.npub}\nEncrypted with NIP-49. Import it into any NIP-49 signer with your backup password.\n`; + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + a.download = `goostr-backup-${signer.npub.slice(0, 14)}.txt`; + a.click(); + URL.revokeObjectURL(a.href); + setStatus("bk-status", "Backup downloaded. Keep the file and the password somewhere safe (separately).", "ok"); + }, 30); + } catch (err) { + setStatus("bk-status", errMsg(err), "error"); + } +}); + +$("bk-drive").addEventListener("click", async () => { + try { + const pw = backupPassword(); + setStatus("bk-status", "Saving to Google Drive (Google will ask for file access)…"); + const link = await session.backupToDrive(pw); + const el = $("bk-status"); + el.className = "status ok"; + el.textContent = "Saved to your Google Drive. "; + if (link) { + const a = document.createElement("a"); + a.href = link; + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = "Open it"; + el.append(a); + } + } catch (err) { + setStatus("bk-status", errMsg(err), "error"); + } +}); + +$("nsec-reveal").addEventListener("click", () => { + const box = $("nsec-box"); + const showing = !box.classList.contains("hidden"); + $("nsec-value").textContent = showing ? "" : session.nsec(); + box.classList.toggle("hidden", showing); + $("nsec-reveal").textContent = showing ? "Reveal nsec" : "Hide nsec"; +}); +$("nsec-copy").addEventListener("click", async () => { + await navigator.clipboard.writeText(session.nsec()); + $("nsec-copy").textContent = "Copied"; + setTimeout(() => ($("nsec-copy").textContent = "Copy"), 1500); +}); + // ---------- boot ---------- (async function boot() { @@ -200,17 +473,18 @@ $("note-send").addEventListener("click", () => { return; } - const restored = restoreSession(); - if (restored) { - signer = restored.signer; - currentProfile = restored.profile; + session = restoreSession({ clientId: CLIENT_ID }); + if (session) { + signer = session.signer; show("loading"); $("loading-text").textContent = "Welcome back. Connecting to relays…"; - pool.onStatus(renderRelayDots); await pool.connectAll({ minRelays: 1 }); - enterHome(restored.profile); + enterHome(); return; } + if (extensionUnlockRequested) { + setStatus("signin-status", "Sign in to unlock the Goostr extension."); + } show("signin"); })(); diff --git a/public/bunker.js b/public/bunker.js new file mode 100644 index 0000000..9d13986 --- /dev/null +++ b/public/bunker.js @@ -0,0 +1,193 @@ +// NIP-46 remote signer ("bunker") running inside this tab. +// +// Other nostr apps talk to us over relays with kind-24133 events, encrypted +// with NIP-44 (NIP-04 accepted from older clients). We use the user's own key +// as the remote-signer key, so bunker://. +// +// Pairing: +// * bunker:// link — we mint a one-time secret; the first client to send +// `connect` with it becomes authorized. +// * nostrconnect:// link — the app shows it; we authorize that client and +// reply to `connect` with its secret, per the spec. +// Authorized clients are remembered per identity in localStorage (only their +// pubkeys and names; no secrets). Pending bunker secrets live in +// sessionStorage and die with the tab. +// +// ponytail: authorized clients get every method without per-request prompts, +// like NIP-07 "always allow". Add per-kind approval if users ask for it. + +const KIND = 24133; +const LOG_MAX = 30; + +const load = (store, key, fallback) => { + try { return JSON.parse(store.getItem(key)) ?? fallback; } catch { return fallback; } +}; +const save = (store, key, value) => { + try { store.setItem(key, JSON.stringify(value)); } catch {} +}; +const randomHex = (n) => [...crypto.getRandomValues(new Uint8Array(n))].map((b) => b.toString(16).padStart(2, "0")).join(""); + +export function createBunker({ signer, pool, relays, onChange = () => {} }) { + const clientsKey = `goostr.bunker.clients.${signer.pubkey}`; + const secretsKey = `goostr.bunker.secrets.${signer.pubkey}`; + const clients = load(localStorage, clientsKey, {}); // pubkey -> { name, addedAt, lastSeen } + const log = []; + const handled = new Set(); + let unsub = null; + + const persist = () => { save(localStorage, clientsKey, clients); onChange(); }; + const note = (entry) => { + log.unshift({ t: Date.now(), ...entry }); + if (log.length > LOG_MAX) log.pop(); + onChange(); + }; + + async function reply(clientPk, scheme, payload) { + const plain = JSON.stringify(payload); + const content = scheme === "nip04" + ? await signer.nip04.encrypt(clientPk, plain) + : await signer.nip44.encrypt(clientPk, plain); + pool.publish(signer.sign({ kind: KIND, content, tags: [["p", clientPk]] })); + } + + async function run(method, params, clientPk) { + switch (method) { + case "connect": { + if (clients[clientPk]) return "ack"; + const secret = params[1]; + const pending = load(sessionStorage, secretsKey, []); + if (!secret || !pending.includes(secret)) throw new Error("unauthorized: invalid or already used connection secret"); + save(sessionStorage, secretsKey, pending.filter((s) => s !== secret)); + clients[clientPk] = { name: `App ${clientPk.slice(0, 8)}`, addedAt: Date.now(), lastSeen: Date.now() }; + persist(); + return "ack"; + } + case "ping": + return "pong"; + case "get_public_key": + return signer.pubkey; + case "sign_event": { + const ev = JSON.parse(params[0]); + if (!Number.isInteger(ev.kind)) throw new Error("sign_event: missing kind"); + return JSON.stringify(signer.sign(ev)); + } + case "nip04_encrypt": return signer.nip04.encrypt(params[0], params[1]); + case "nip04_decrypt": return signer.nip04.decrypt(params[0], params[1]); + case "nip44_encrypt": return signer.nip44.encrypt(params[0], params[1]); + case "nip44_decrypt": return signer.nip44.decrypt(params[0], params[1]); + case "logout": + delete clients[clientPk]; + persist(); + return "ack"; + default: + throw new Error(`unsupported method: ${method}`); + } + } + + async function handle(ev) { + if (handled.has(ev.id)) return; + handled.add(ev.id); + if (ev.kind !== KIND) return; // signature already checked by the pool + const clientPk = ev.pubkey; + + const scheme = ev.content.includes("?iv=") ? "nip04" : "nip44"; + let req; + try { + const plain = scheme === "nip04" + ? await signer.nip04.decrypt(clientPk, ev.content) + : await signer.nip44.decrypt(clientPk, ev.content); + req = JSON.parse(plain); + } catch { + return; // not for us, or garbage + } + if (!req?.id || !req.method) return; + const params = Array.isArray(req.params) ? req.params : []; + + const known = !!clients[clientPk]; + if (!known && req.method !== "connect") { + // Unknown apps get an error rather than silence, so they can tell the + // user to pair first. Nothing is signed or decrypted for them. + await reply(clientPk, scheme, { id: req.id, error: "unauthorized: pair this app with Goostr first" }); + return; + } + + const who = clients[clientPk]?.name || `App ${clientPk.slice(0, 8)}`; + try { + const result = await run(req.method, params, clientPk); + await reply(clientPk, scheme, { id: req.id, result }); + if (clients[clientPk]) { clients[clientPk].lastSeen = Date.now(); persist(); } + const detail = req.method === "sign_event" ? `kind ${safeKind(params[0])}` : ""; + note({ who, method: req.method, detail, ok: true }); + } catch (err) { + await reply(clientPk, scheme, { id: req.id, error: String(err.message || err) }); + note({ who, method: req.method, detail: String(err.message || err), ok: false }); + } + } + + return { + get running() { return !!unsub; }, + get clients() { return clients; }, + get log() { return log; }, + + start() { + if (unsub) return; + unsub = pool.subscribe( + [{ kinds: [KIND], "#p": [signer.pubkey], since: Math.floor(Date.now() / 1000) - 60 }], + (ev) => { handle(ev).catch((e) => console.warn("[bunker]", e)); } + ); + onChange(); + }, + + stop() { + unsub?.(); + unsub = null; + onChange(); + }, + + // One-time bunker:// link for pasting into another app. + newBunkerUrl() { + const secret = randomHex(16); + save(sessionStorage, secretsKey, [...load(sessionStorage, secretsKey, []), secret]); + const qs = relays.map((r) => `relay=${encodeURIComponent(r)}`).join("&"); + return `bunker://${signer.pubkey}?${qs}&secret=${secret}`; + }, + + // Accept a nostrconnect:// link shown by another app. + async acceptNostrConnect(uri) { + const u = new URL(uri.trim()); + if (u.protocol !== "nostrconnect:") throw new Error("That isn't a nostrconnect:// link."); + const clientPk = (u.hostname || u.pathname.replace(/^\/+/, "")).toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(clientPk)) throw new Error("The link has no valid app public key."); + const secret = u.searchParams.get("secret"); + if (!secret) throw new Error("The link has no secret; the app may use an old NIP-46 version."); + const linkRelays = u.searchParams.getAll("relay"); + if (!linkRelays.length) throw new Error("The link names no relays."); + linkRelays.forEach((r) => pool.addRelay(r)); + + clients[clientPk] = { + name: u.searchParams.get("name") || `App ${clientPk.slice(0, 8)}`, + addedAt: Date.now(), + lastSeen: Date.now(), + }; + persist(); + this.start(); + await reply(clientPk, "nip44", { id: randomHex(8), result: secret }); + note({ who: clients[clientPk].name, method: "connect", detail: "via nostrconnect link", ok: true }); + }, + + rename(clientPk, name) { + if (!clients[clientPk]) return; + clients[clientPk].name = name; + persist(); + }, + + revoke(clientPk) { + delete clients[clientPk]; + persist(); + }, + }; +} + +function safeKind(json) { + try { return JSON.parse(json).kind; } catch { return "?"; } +} diff --git a/public/config.js b/public/config.js index 322c593..08610a7 100644 --- a/public/config.js +++ b/public/config.js @@ -15,3 +15,10 @@ export const RELAYS = [ "wss://relay.nostr.band", "wss://nostr.mom", ]; + +// The Goostr browser extension (NIP-07 signer). The ID is fixed by the "key" +// in extension/manifest.json, so it's the same for every unpacked install. +export const EXTENSION_ID = "bglfppdpknmhpnjjegkdncgeaaadmeic"; +// Chrome Web Store link once the extension is published. Empty = offer the +// .zip download with "Load unpacked" instructions instead. +export const EXTENSION_STORE_URL = ""; diff --git a/public/drive.js b/public/drive.js index 4812c4a..ba82091 100644 --- a/public/drive.js +++ b/public/drive.js @@ -1,6 +1,6 @@ -// Google Drive AppData helpers. Files written to the appDataFolder space are -// invisible to the user in the regular Drive UI and only readable by the -// OAuth client (i.e. *this* app's client_id) that created them. +// Google Drive helpers. Files written to the appDataFolder space are invisible +// to the user in the regular Drive UI and only readable by this app's Google +// Cloud project. // // Docs: https://developers.google.com/drive/api/guides/appdata @@ -23,17 +23,17 @@ async function driveFetch(token, url, init = {}) { } // Find a file by exact name inside the appDataFolder. Returns the file id, or -// null if not found. +// null if not found. If several exist (two devices signed up at the same +// moment), the OLDEST wins, so every device converges on the same key. export async function findAppDataFile(token, name) { const url = `${DRIVE}/files?spaces=appDataFolder` + `&q=${encodeURIComponent(`name = '${name.replace(/'/g, "\\'")}'`)}` + - `&fields=files(id,name,modifiedTime)`; + `&fields=files(id,createdTime)`; const res = await driveFetch(token, url); const { files } = await res.json(); if (!files || files.length === 0) return null; - // If somehow multiple, prefer the most recent. - files.sort((a, b) => (b.modifiedTime || "").localeCompare(a.modifiedTime || "")); + files.sort((a, b) => (a.createdTime || "").localeCompare(b.createdTime || "")); return files[0].id; } @@ -42,26 +42,25 @@ export async function downloadFile(token, fileId) { return res.text(); } -// Multipart upload of a small JSON blob to appDataFolder. If fileId is given, -// updates that file (preserving parents); otherwise creates a new file. -export async function uploadAppDataJson(token, name, content, fileId = null) { +// Multipart upload of a small text blob. If fileId is given, replaces that +// file's content; otherwise creates a new file under `parents` +// (["appDataFolder"] for the hidden key file, omit for My Drive). +export async function uploadFile(token, { name, content, mimeType = "application/json", parents, fileId = null }) { const boundary = "goostr-" + Math.random().toString(36).slice(2); - const metadata = fileId - ? { name } - : { name, parents: ["appDataFolder"] }; + const metadata = fileId ? { name } : { name, mimeType, ...(parents ? { parents } : {}) }; const body = `--${boundary}\r\n` + `Content-Type: application/json; charset=UTF-8\r\n\r\n` + `${JSON.stringify(metadata)}\r\n` + `--${boundary}\r\n` + - `Content-Type: application/json\r\n\r\n` + + `Content-Type: ${mimeType}\r\n\r\n` + `${content}\r\n` + `--${boundary}--`; const url = fileId ? `${UPLOAD}/files/${fileId}?uploadType=multipart` - : `${UPLOAD}/files?uploadType=multipart`; + : `${UPLOAD}/files?uploadType=multipart&fields=id,webViewLink`; const res = await driveFetch(token, url, { method: fileId ? "PATCH" : "POST", diff --git a/public/goostr-extension.zip b/public/goostr-extension.zip new file mode 100644 index 0000000..2ccc4f1 Binary files /dev/null and b/public/goostr-extension.zip differ diff --git a/public/goostr.js b/public/goostr.js index 7eab7a0..cb411a7 100644 --- a/public/goostr.js +++ b/public/goostr.js @@ -2,26 +2,24 @@ // // Flow: // signIn({ clientId }) -// → opens Google sign-in popup +// → opens Google sign-in popup (consent only the first time) // → requests OAuth scopes: profile, email, drive.appdata // → looks in Drive App Data for goostr-key-v1.json -// · if found, downloads the 32-byte seed +// · if found, reads the 32-byte key (asking for the passkey if the +// file is passkey-protected) // · if missing, generates 32 fresh bytes and uploads them -// → returns { signer, profile } where profile is the Google name/picture/email +// → returns a Session: { signer, profile, isNewIdentity, protection, … } // -// The 32-byte seed is the nostr private key. It lives in: +// The 32-byte key is the nostr private key. It lives in: // 1. The user's Drive App Data — survives device loss, syncs across devices. +// Optionally encrypted with a passkey (see passkey.js). // 2. sessionStorage for the current tab — survives reload, dies on tab close. // It is never written to localStorage, IndexedDB, or any other disk-persistent // store on the local device. -import { getPublicKey, finalizeEvent, nip19 } from "nostr-tools"; -import { - findAppDataFile, - downloadFile, - uploadAppDataJson, - fetchUserInfo, -} from "./drive.js"; +import { getPublicKey, finalizeEvent, nip04, nip19, nip44, nip49, bytesToHex } from "./vendor/nostr-tools.js"; +import { findAppDataFile, downloadFile, uploadFile, fetchUserInfo } from "./drive.js"; +import { createPasskeyKey, getPasskeyKey, wrapKey, unwrapKey } from "./passkey.js"; const KEY_FILE = "goostr-key-v1.json"; const SCOPES = [ @@ -30,6 +28,9 @@ const SCOPES = [ "https://www.googleapis.com/auth/userinfo.email", "openid", ].join(" "); +// Only requested when the user saves a visible backup to their Drive. +const BACKUP_SCOPE = "https://www.googleapis.com/auth/drive.file"; +const HINT_KEY = "goostr.hint"; // last Google email, for one-click re-sign-in // ---------- Google Identity Services token client ---------- @@ -43,23 +44,39 @@ function ensureGisLoaded() { s.async = true; s.defer = true; s.onload = () => resolve(); - s.onerror = () => reject(new Error("Failed to load Google Identity Services script.")); + s.onerror = () => { gisReady = null; reject(new Error("Failed to load Google Identity Services script.")); }; document.head.appendChild(s); }); return gisReady; } -async function requestAccessToken(clientId, { silent = false } = {}) { +let clientIdInUse = null; +let token = null; // { value, exp, scope } + +function getHint() { + try { return localStorage.getItem(HINT_KEY) || ""; } catch { return ""; } +} + +async function requestAccessToken(scope) { await ensureGisLoaded(); return new Promise((resolve, reject) => { const client = google.accounts.oauth2.initTokenClient({ - client_id: clientId, - scope: SCOPES, - prompt: silent ? "" : "consent", + client_id: clientIdInUse, + scope, + // "" = consent screen only the first time; afterwards the popup just + // confirms the account and closes. + prompt: "", + login_hint: getHint() || undefined, + include_granted_scopes: true, callback: (resp) => { if (resp.error) return reject(new Error(`Google auth: ${resp.error}`)); if (!resp.access_token) return reject(new Error("Google auth: no access token returned.")); - resolve(resp.access_token); + token = { + value: resp.access_token, + exp: Date.now() + (Number(resp.expires_in) || 3600) * 1000, + scope: resp.scope || scope, + }; + resolve(token.value); }, error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)), }); @@ -67,112 +84,262 @@ async function requestAccessToken(clientId, { silent = false } = {}) { }); } -// ---------- key bytes / signer ---------- - -function randomBytes(len) { - const buf = new Uint8Array(len); - crypto.getRandomValues(buf); - return buf; +async function getToken(extraScope = null) { + const fresh = token && token.exp - Date.now() > 60_000; + if (fresh && (!extraScope || token.scope.includes(extraScope))) return token.value; + if (!clientIdInUse) throw new Error("Not configured: call signIn/restoreSession with a clientId first."); + return requestAccessToken(extraScope ? `${SCOPES} ${extraScope}` : SCOPES); } -function b64encBytes(bytes) { +// ---------- bytes helpers ---------- + +function randomBytes(len) { + return crypto.getRandomValues(new Uint8Array(len)); +} +function b64enc(bytes) { let s = ""; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); } -function b64decBytes(b64) { +function b64dec(b64) { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } -function makeSigner(privKeyBytes) { +// ---------- signer ---------- + +export function makeSigner(privKeyBytes) { const pubkey = getPublicKey(privKeyBytes); + const convKey = (pk) => nip44.getConversationKey(privKeyBytes, pk); return { pubkey, npub: nip19.npubEncode(pubkey), sign(unsignedEvent) { return finalizeEvent( { - ...unsignedEvent, - pubkey, + kind: unsignedEvent.kind, + content: unsignedEvent.content ?? "", + tags: unsignedEvent.tags ?? [], created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000), }, privKeyBytes ); }, + nip04: { + encrypt: async (pk, text) => nip04.encrypt(privKeyBytes, pk, text), + decrypt: async (pk, data) => nip04.decrypt(privKeyBytes, pk, data), + }, + nip44: { + encrypt: async (pk, text) => nip44.encrypt(text, convKey(pk)), + decrypt: async (pk, data) => nip44.decrypt(data, convKey(pk)), + }, }; } -// ---------- session persistence (tab-scoped) ---------- +// ---------- key file on Drive ---------- +// +// v1: { v: 1, k: base64(key), created_at } +// v2: { v: 2, pk: hex pubkey, created_at, +// passkey: { rpId, credId, salt }, iv, ct } (all binary fields base64) + +function keyFileV1(bytes, createdAt = new Date().toISOString()) { + return JSON.stringify({ v: 1, k: b64enc(bytes), created_at: createdAt }); +} + +async function readKeyFile(accessToken, fileId, { onStatus, onPasskeyNeeded }) { + const text = await downloadFile(accessToken, fileId); + let f; + try { f = JSON.parse(text); } catch { f = null; } + + if (f?.v === 1 && f.k) { + return { bytes: b64dec(f.k), protection: { type: "none" } }; + } + + if (f?.v === 2 && f.passkey && f.pk) { + const { rpId } = f.passkey; + if (rpId !== location.hostname) { + throw new Error(`Your key is protected by a passkey for ${rpId}. Open Goostr on ${rpId} to unlock it.`); + } + onStatus?.("Your key is protected by a passkey."); + await onPasskeyNeeded?.(); + onStatus?.("Waiting for your passkey…"); + const aesKey = await getPasskeyKey({ credId: b64dec(f.passkey.credId), salt: b64dec(f.passkey.salt) }); + const bytes = await unwrapKey(aesKey, { iv: b64dec(f.iv), ct: b64dec(f.ct) }, f.pk); + if (getPublicKey(bytes) !== f.pk) throw new Error("Decrypted key doesn't match the stored public key."); + return { bytes, protection: { type: "passkey", rpId } }; + } + + throw new Error("Stored key file is malformed or from a newer version of Goostr."); +} + +async function loadOrCreateSeed(accessToken, opts) { + const { onStatus } = opts; + onStatus?.("Looking for your key in Google Drive…"); + const fileId = await findAppDataFile(accessToken, KEY_FILE); + + if (fileId) { + onStatus?.("Found your key. Unlocking your nostr identity…"); + return { ...(await readKeyFile(accessToken, fileId, opts)), fileId, created: false }; + } + + onStatus?.("First time on this Google account — generating a new identity…"); + const bytes = randomBytes(32); + const { id } = await uploadFile(accessToken, { + name: KEY_FILE, + parents: ["appDataFolder"], + content: keyFileV1(bytes), + }); + + // Another device may have created a key at the same moment. The oldest file + // wins everywhere, so adopt it if it isn't ours. + const winner = await findAppDataFile(accessToken, KEY_FILE); + if (winner && winner !== id) { + return { ...(await readKeyFile(accessToken, winner, opts)), fileId: winner, created: false }; + } + return { bytes, protection: { type: "none" }, fileId: id, created: true }; +} + +// ---------- session (tab-scoped persistence + key management) ---------- const SESSION_KEY = "goostr.session.v1"; const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling -function saveSession(privKeyBytes, profile) { +function saveSession(bytes, profile, meta) { try { sessionStorage.setItem( SESSION_KEY, - JSON.stringify({ k: b64encBytes(privKeyBytes), p: profile, t: Date.now() }) + JSON.stringify({ k: b64enc(bytes), p: profile, m: meta, t: Date.now() }) ); } catch {} } -export function restoreSession() { +function makeSession(bytes, profile, meta) { + const signer = makeSigner(bytes); + + async function keyFileId(accessToken) { + if (meta.fileId) return meta.fileId; + meta.fileId = await findAppDataFile(accessToken, KEY_FILE); + if (!meta.fileId) throw new Error("Key file not found in Google Drive."); + return meta.fileId; + } + + const session = { + signer, + profile, + isNewIdentity: !!meta.created, + get protection() { return meta.protection; }, + + // Raw key as hex — only for handing to the Goostr browser extension. + keyHex: () => bytesToHex(bytes), + nsec: () => nip19.nsecEncode(bytes), + // NIP-49 password-encrypted key; importable by most nostr apps. + ncryptsec: (password) => nip49.encrypt(bytes, password, 16), + + // Encrypt the Drive copy with a new passkey. Passkey first (it needs the + // click's user activation), Google token second. + async enablePasskey() { + const userId = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(profile.sub || signer.pubkey))); + const { credId, salt, aesKey } = await createPasskeyKey({ + userId, + name: profile.email || signer.npub, + displayName: `Goostr — ${profile.name || signer.npub.slice(0, 16)}`, + }); + const wrapped = await wrapKey(aesKey, bytes, signer.pubkey); + // Round-trip before touching Drive, so a bad passkey can't lock anyone out. + const check = await unwrapKey(aesKey, wrapped, signer.pubkey); + if (bytesToHex(check) !== bytesToHex(bytes)) throw new Error("Passkey check failed; nothing was changed."); + + const accessToken = await getToken(); + const rpId = location.hostname; + await uploadFile(accessToken, { + fileId: await keyFileId(accessToken), + name: KEY_FILE, + content: JSON.stringify({ + v: 2, + pk: signer.pubkey, + created_at: new Date().toISOString(), + passkey: { rpId, credId: b64enc(credId), salt: b64enc(salt) }, + iv: b64enc(wrapped.iv), + ct: b64enc(wrapped.ct), + }), + }); + meta.protection = { type: "passkey", rpId }; + saveSession(bytes, profile, meta); + }, + + async disablePasskey() { + const accessToken = await getToken(); + await uploadFile(accessToken, { fileId: await keyFileId(accessToken), name: KEY_FILE, content: keyFileV1(bytes) }); + meta.protection = { type: "none" }; + saveSession(bytes, profile, meta); + }, + + // Save a password-encrypted (NIP-49) backup as a normal, visible file in + // the user's Drive. Survives even if this app's Google project goes away. + async backupToDrive(password) { + const ncryptsec = session.ncryptsec(password); + const accessToken = await getToken(BACKUP_SCOPE); + const content = [ + "Goostr nostr identity backup", + "", + `Public key (npub): ${signer.npub}`, + `Created: ${new Date().toISOString()}`, + "", + "Encrypted private key (NIP-49, needs your backup password):", + ncryptsec, + "", + "To restore: paste the ncryptsec1… line into any nostr app or signer that", + "supports NIP-49 (e.g. nsec.app, Amber, Alby, nos2x-fox) and enter the password.", + "", + ].join("\n"); + const file = await uploadFile(accessToken, { + name: `Goostr backup ${signer.npub.slice(0, 14)}.txt`, + mimeType: "text/plain", + content, + }); + return file.webViewLink || null; + }, + }; + return session; +} + +export function restoreSession({ clientId } = {}) { + if (clientId) clientIdInUse = clientId; try { const raw = sessionStorage.getItem(SESSION_KEY); if (!raw) return null; - const { k, p, t } = JSON.parse(raw); + const { k, p, m, t } = JSON.parse(raw); if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) { sessionStorage.removeItem(SESSION_KEY); return null; } - return { signer: makeSigner(b64decBytes(k)), profile: p || {} }; + return makeSession(b64dec(k), p || {}, m || { protection: { type: "none" } }); } catch { return null; } } export function clearSession() { + token = null; try { sessionStorage.removeItem(SESSION_KEY); } catch {} -} - -// ---------- key file on Drive ---------- - -async function loadOrCreateSeed(accessToken, onStatus) { - onStatus?.("Looking for your key in Google Drive…"); - const fileId = await findAppDataFile(accessToken, KEY_FILE); - - if (fileId) { - onStatus?.("Found existing key. Unlocking your nostr identity…"); - const text = await downloadFile(accessToken, fileId); - let parsed; - try { parsed = JSON.parse(text); } catch { parsed = null; } - if (!parsed?.k) throw new Error("Stored key file is malformed."); - return { bytes: b64decBytes(parsed.k), created: false }; - } - - onStatus?.("First time on this Google account — generating a new identity…"); - const bytes = randomBytes(32); - const payload = JSON.stringify({ - v: 1, - k: b64encBytes(bytes), - created_at: new Date().toISOString(), - }); - await uploadAppDataJson(accessToken, KEY_FILE, payload, null); - return { bytes, created: true }; + try { localStorage.removeItem(HINT_KEY); } catch {} } // ---------- public API ---------- -export async function signIn({ clientId, silent = false, onStatus } = {}) { - if (!clientId || clientId.startsWith("PASTE-")) { +// onPasskeyNeeded: optional async callback, awaited before the passkey prompt +// when the stored key is passkey-protected. Use it to wait for a button click +// (Safari only allows passkey prompts right after a user gesture). +export async function signIn({ clientId, onStatus, onPasskeyNeeded } = {}) { + if (!isConfigured(clientId)) { throw new Error("CLIENT_ID is not configured. Edit public/config.js with your Google OAuth client ID. See README.md."); } + clientIdInUse = clientId; onStatus?.("Signing in with Google…"); - const accessToken = await requestAccessToken(clientId, { silent }); + const accessToken = await getToken(); onStatus?.("Reading your Google profile…"); const userInfo = await fetchUserInfo(accessToken); @@ -182,11 +349,13 @@ export async function signIn({ clientId, silent = false, onStatus } = {}) { picture: userInfo.picture || null, email: userInfo.email || null, }; + try { if (profile.email) localStorage.setItem(HINT_KEY, profile.email); } catch {} - const { bytes, created } = await loadOrCreateSeed(accessToken, onStatus); + const { bytes, protection, fileId, created } = await loadOrCreateSeed(accessToken, { onStatus, onPasskeyNeeded }); - saveSession(bytes, profile); - return { signer: makeSigner(bytes), profile, isNewIdentity: created }; + const meta = { fileId, protection, created }; + saveSession(bytes, profile, meta); + return makeSession(bytes, profile, meta); } export function isConfigured(clientId) { diff --git a/public/index.html b/public/index.html index 81ec906..c106844 100644 --- a/public/index.html +++ b/public/index.html @@ -3,15 +3,15 @@ + + Goostr — Google to nostr + -
@@ -22,10 +22,12 @@ + +

Checking session…

@@ -33,10 +35,10 @@ @@ -62,7 +65,7 @@
- +
@@ -71,13 +74,123 @@ -

Your logs

+

Your notes

WhenNote

Loading…

+ + + + + +
diff --git a/public/passkey.js b/public/passkey.js new file mode 100644 index 0000000..d41a889 --- /dev/null +++ b/public/passkey.js @@ -0,0 +1,92 @@ +// Passkey-based key wrapping via the WebAuthn PRF extension. +// +// A passkey with PRF returns a stable 32-byte secret for a given salt, but only +// after the user verifies (Face ID, fingerprint, device PIN). We turn that into +// an AES-GCM key with HKDF and use it to encrypt the nostr key before it goes +// to Drive. Result: Google access alone is no longer enough to read the key. +// +// Passkeys are bound to the site's domain (rpId). A key protected on +// goostr.example.com can only be unlocked on goostr.example.com. + +const enc = new TextEncoder(); +const rand = (n) => crypto.getRandomValues(new Uint8Array(n)); + +export const passkeysAvailable = () => !!window.PublicKeyCredential; + +async function toAesKey(prfOutput) { + const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: enc.encode("goostr key-wrap v1") }, + ikm, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +function notSupported() { + return new Error( + "This passkey provider can't do key encryption (the WebAuthn PRF extension). " + + "Use a recent Chrome, Edge or Safari with Google Password Manager, iCloud Keychain or a compatible security key." + ); +} + +// Create a new passkey and return { credId, salt, aesKey }. Asks for the PRF +// output during creation where supported; otherwise follows up with get(). +export async function createPasskeyKey({ userId, name, displayName }) { + const salt = rand(32); + const cred = await navigator.credentials.create({ + publicKey: { + rp: { name: "Goostr" }, + user: { id: userId, name, displayName }, + challenge: rand(32), + pubKeyCredParams: [ + { type: "public-key", alg: -7 }, // ES256 + { type: "public-key", alg: -257 }, // RS256 + ], + authenticatorSelection: { residentKey: "preferred", userVerification: "required" }, + extensions: { prf: { eval: { first: salt } } }, + }, + }); + const ext = cred.getClientExtensionResults().prf; + if (ext?.enabled === false) throw notSupported(); + const credId = new Uint8Array(cred.rawId); + const first = ext?.results?.first; + const aesKey = first ? await toAesKey(first) : await getPasskeyKey({ credId, salt }); + return { credId, salt, aesKey }; +} + +// Ask the user to verify with an existing passkey and derive the AES key. +export async function getPasskeyKey({ credId, salt }) { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: rand(32), + allowCredentials: [{ type: "public-key", id: credId }], + userVerification: "required", + extensions: { prf: { eval: { first: salt } } }, + }, + }); + const first = assertion.getClientExtensionResults().prf?.results?.first; + if (!first) throw notSupported(); + return toAesKey(first); +} + +// AES-GCM with the pubkey as associated data, so a ciphertext can't be +// swapped between identities unnoticed. +export async function wrapKey(aesKey, keyBytes, pubkeyHex) { + const iv = rand(12); + const ct = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(pubkeyHex) }, aesKey, keyBytes) + ); + return { iv, ct }; +} + +export async function unwrapKey(aesKey, { iv, ct }, pubkeyHex) { + try { + return new Uint8Array( + await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: enc.encode(pubkeyHex) }, aesKey, ct) + ); + } catch { + throw new Error("That passkey doesn't unlock this key. Use the passkey you protected it with."); + } +} diff --git a/public/relay.js b/public/relay.js index e26f7fa..7d2d217 100644 --- a/public/relay.js +++ b/public/relay.js @@ -6,23 +6,37 @@ // overall timeout). The rest keep opening in the background. // * Late-arriving relays automatically replay anything we've already sent // (active subscriptions + recent publishes), so the user never waits. +// * Dropped relays reconnect with backoff (2s → 60s), and immediately when +// the browser comes back online. The NIP-46 signer relies on this. +// * Every incoming event's signature is verified before handlers see it. // * Per-relay status is exposed via onStatus() so the UI can show progress. +import { verifyEvent } from "./vendor/nostr-tools.js"; import { RELAYS } from "./config.js"; const PER_RELAY_CONNECT_TIMEOUT_MS = 4000; const OVERALL_CONNECT_TIMEOUT_MS = 4500; // hard ceiling for connectAll() const PUBLISH_REPLAY_WINDOW_MS = 60_000; // re-send recent publishes to late relays +const EOSE_TIMEOUT_MS = 5000; // onEose fires by then even if a relay is silent +const MAX_RETRY_DELAY_MS = 60_000; class RelayPool { constructor(urls = RELAYS) { - this.urls = urls; + this.urls = [...urls]; this.sockets = new Map(); // url -> WebSocket (only OPEN ones) this.status = new Map(); // url -> "connecting"|"open"|"closed"|"timeout" + this.retries = new Map(); // url -> consecutive failures + this.retryTimers = new Map(); this.subs = new Map(); // subId -> { req, handler } this.recentPublishes = []; // { msg, t } this.subId = 0; this._listeners = new Set(); // status change listeners + this._started = false; + + addEventListener("online", () => this._reconnectNow()); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") this._reconnectNow(); + }); } onStatus(fn) { @@ -42,6 +56,28 @@ class RelayPool { for (const fn of this._listeners) fn(snap); } + _scheduleRetry(url) { + if (!this._started || this.retryTimers.has(url)) return; + const n = (this.retries.get(url) || 0) + 1; + this.retries.set(url, n); + const delay = Math.min(MAX_RETRY_DELAY_MS, 2000 * 2 ** (n - 1)); + this.retryTimers.set(url, setTimeout(() => { + this.retryTimers.delete(url); + this._open(url); + }, delay)); + } + + _reconnectNow() { + if (!this._started) return; + for (const url of this.urls) { + if (this.status.get(url) === "open" || this.status.get(url) === "connecting") continue; + clearTimeout(this.retryTimers.get(url)); + this.retryTimers.delete(url); + this.retries.set(url, 0); + this._open(url); + } + } + _open(url) { return new Promise((resolve) => { let settled = false; @@ -50,14 +86,19 @@ class RelayPool { settled = true; resolve(ok); }; + const failed = (state) => { + this.sockets.delete(url); + this.status.set(url, state); + this._notify(); + this._scheduleRetry(url); + finish(false); + }; let ws; try { ws = new WebSocket(url); } catch { - this.status.set(url, "closed"); - this._notify(); - return finish(false); + return failed("closed"); } this.status.set(url, "connecting"); @@ -65,20 +106,17 @@ class RelayPool { const timer = setTimeout(() => { if (ws.readyState === WebSocket.CONNECTING) { - this.status.set(url, "timeout"); - this._notify(); try { ws.close(); } catch {} - finish(false); + failed("timeout"); } }, PER_RELAY_CONNECT_TIMEOUT_MS); ws.addEventListener("open", () => { clearTimeout(timer); - const t0 = performance.now(); this.sockets.set(url, ws); this.status.set(url, "open"); + this.retries.set(url, 0); this._notify(); - console.log(`[relay] open ${url} (${Math.round(t0)}ms since page)`); // Replay active subscriptions and recent publishes to this new relay. for (const { req } of this.subs.values()) { @@ -93,27 +131,18 @@ class RelayPool { finish(true); }); - ws.addEventListener("error", () => { - clearTimeout(timer); - if (this.status.get(url) !== "open") this.status.set(url, "closed"); - this._notify(); - finish(false); - }); - ws.addEventListener("close", () => { clearTimeout(timer); - this.sockets.delete(url); - if (this.status.get(url) !== "timeout") this.status.set(url, "closed"); - this._notify(); - finish(false); + if (this.sockets.get(url) === ws || !settled) { + failed(this.status.get(url) === "timeout" ? "timeout" : "closed"); + } }); // Wire messages to active subscription handlers. ws.addEventListener("message", (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } - const subId = msg[1]; - const sub = this.subs.get(subId); + const sub = this.subs.get(msg[1]); if (sub) sub.handler(msg, url); }); }); @@ -122,6 +151,7 @@ class RelayPool { // Resolves as soon as we have at least `minRelays` open, or after the // overall timeout. Always returns the number of relays that opened. async connectAll({ minRelays = 1 } = {}) { + this._started = true; const attempts = this.urls.map((u) => this._open(u)); let opened = 0; @@ -130,35 +160,25 @@ class RelayPool { const finish = () => { if (done) return; done = true; + clearTimeout(ceiling); resolve(this.sockets.size); }; - const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS); - - attempts.forEach((p) => - p.then((ok) => { - if (ok) opened++; - if (opened >= minRelays) { - clearTimeout(ceiling); - // Resolve quickly once we hit the threshold; other relays keep - // opening in the background and self-register via _open(). - finish(); - } - if (opened === 0 && attempts.every((_) => false)) { - /* unreachable; here for clarity */ - } - }) - ); - - // If every attempt fully settled with zero opens, resolve too. - Promise.allSettled(attempts).then(() => finish()); + attempts.forEach((p) => p.then((ok) => { if (ok && ++opened >= minRelays) finish(); })); + Promise.allSettled(attempts).then(finish); }); } + // Add a relay at runtime (e.g. one named in a nostrconnect:// link). + addRelay(url) { + if (!/^wss?:\/\//.test(url) || this.urls.includes(url)) return; + this.urls.push(url); + this._open(url); + } + publish(event) { const msg = JSON.stringify(["EVENT", event]); this.recentPublishes.push({ msg, t: Date.now() }); - // trim if (this.recentPublishes.length > 200) this.recentPublishes.shift(); for (const ws of this.sockets.values()) { if (ws.readyState === WebSocket.OPEN) { @@ -167,23 +187,32 @@ class RelayPool { } } - // filters: array of REQ filters. onEvent(event) per match. onEose() once - // every currently-open relay has signaled EOSE. + // filters: array of REQ filters. onEvent(event) per verified match. onEose(n) + // fires once: when every relay open at that moment has sent EOSE, or after + // EOSE_TIMEOUT_MS, whichever is first. subscribe(filters, onEvent, onEose) { const id = `s${++this.subId}`; const req = JSON.stringify(["REQ", id, ...filters]); const seen = new Set(); - const eoseSet = new Set(); + const eoseFrom = new Set(); + let eoseFired = false; + const fireEose = () => { + if (eoseFired) return; + eoseFired = true; + clearTimeout(eoseTimer); + onEose?.(eoseFrom.size); // how many relays actually answered + }; + const eoseTimer = setTimeout(fireEose, EOSE_TIMEOUT_MS); const handler = (msg, url) => { - if (msg[0] === "EVENT" && msg[1] === id) { + if (msg[0] === "EVENT") { const e = msg[2]; - if (seen.has(e.id)) return; + if (!e || seen.has(e.id) || !verifyEvent(e)) return; seen.add(e.id); onEvent(e); - } else if (msg[0] === "EOSE" && msg[1] === id) { - eoseSet.add(url); - if (eoseSet.size >= this.sockets.size && onEose) onEose(); + } else if (msg[0] === "EOSE") { + eoseFrom.add(url); + if ([...this.sockets.keys()].every((u) => eoseFrom.has(u))) fireEose(); } }; @@ -196,6 +225,7 @@ class RelayPool { } return () => { + clearTimeout(eoseTimer); const close = JSON.stringify(["CLOSE", id]); for (const ws of this.sockets.values()) { if (ws.readyState === WebSocket.OPEN) { diff --git a/public/style.css b/public/style.css index 40ae1d6..b018052 100644 --- a/public/style.css +++ b/public/style.css @@ -54,10 +54,8 @@ h1 { h2 { margin: 0 0 16px; font-size: 20px; font-weight: 600; } h3 { margin: 24px 0 8px; font-size: 15px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } -.field { display: block; margin-bottom: 20px; } -.field > span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; } -input[type="text"], textarea { +input[type="text"], input[type="password"], textarea { width: 100%; background: var(--panel-2); color: var(--text); @@ -69,45 +67,11 @@ input[type="text"], textarea { outline: none; transition: border-color 0.15s; } -input[type="text"]:focus, textarea:focus { border-color: var(--accent); } +input[type="text"]:focus, input[type="password"]:focus, textarea:focus { border-color: var(--accent); } textarea { resize: vertical; min-height: 84px; } -.picker { - display: grid; - grid-template-columns: repeat(8, 1fr); - gap: 6px; - margin-bottom: 10px; -} -.picker button { - background: var(--panel-2); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 0; - font-size: 22px; - cursor: pointer; - transition: transform 0.1s, border-color 0.1s; -} -.picker button:hover { transform: scale(1.08); } -.picker button.selected { border-color: var(--accent); background: rgba(139, 92, 246, 0.12); } -.upload { - display: inline-block; - font-size: 13px; - color: var(--accent); - cursor: pointer; - margin-bottom: 10px; -} -.upload:hover { text-decoration: underline; } -.preview img { - width: 64px; - height: 64px; - border-radius: 50%; - background: var(--panel-2); - object-fit: cover; - display: block; -} -.preview img:empty, .preview img[src=""] { display: none; } button { font-family: inherit; @@ -186,9 +150,6 @@ button.ghost:hover { color: var(--text); border-color: var(--muted); } .status.error { color: var(--danger); } .status.ok { color: var(--ok); } -.secondary { font-size: 13px; color: var(--muted); margin-top: 18px; } -.secondary a { color: var(--accent); text-decoration: none; } -.secondary a:hover { text-decoration: underline; } .profile { display: grid; @@ -256,3 +217,88 @@ th, td { th { color: var(--muted); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; } td:first-child { color: var(--muted); white-space: nowrap; width: 140px; font-variant-numeric: tabular-nums; } tbody tr:last-child td { border-bottom: none; } + +.lead { color: var(--muted); font-size: 14px; margin-top: -8px; } +#signin-passkey { margin-top: 10px; } + +.banner-ok { + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.4); + color: #c9f7e6; + border-radius: 10px; + padding: 14px 16px; + font-size: 14px; + margin-bottom: 16px; +} + +.card { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; + margin-top: 14px; +} +.card h4 { margin: 0 0 6px; font-size: 15px; font-weight: 600; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.card p { margin: 6px 0; } +.card input { margin-top: 8px; } +.card > button, .card > div > button.primary, a.button { margin-top: 10px; } +.pill { + font-size: 11px; + font-weight: 500; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 1px 8px; +} +.muted { color: var(--muted); font-size: 14px; } +.small { font-size: 12px; } +.sub { margin: 18px 0 4px !important; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } +.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; } +.secret { word-break: break-all; background: var(--bg); border-radius: 8px; padding: 10px; } + +.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.row > input { flex: 1; min-width: 0; margin-top: 0; } +.row > button { width: auto; flex-shrink: 0; margin-top: 0; } +.row .status { margin: 0; flex: 1; } +.card .row button.primary { flex: 1; } + +a.button { + display: block; + text-align: center; + text-decoration: none; + border-radius: 10px; + padding: 12px 18px; + font-weight: 600; + font-size: 14px; +} +a.button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color: white; } + +.steps { margin: 12px 0 4px; padding-left: 20px; color: var(--muted); font-size: 14px; } +.steps li { margin: 4px 0; } + +.list { list-style: none; padding: 0; margin: 0; } +.list li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.list li:last-child { border-bottom: none; } +.list li .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.list li .when { color: var(--muted); font-size: 12px; white-space: nowrap; } +.list li.fail .what { color: var(--danger); } + +@media (max-width: 520px) { + main { padding: 28px 16px 60px; } + .view { padding: 20px; } + .profile { grid-template-columns: 48px 1fr; } + .profile img { width: 48px; height: 48px; } + .profile #sign-out { grid-column: 1 / -1; } + .profile .npub { max-width: 100%; } + .row { flex-wrap: wrap; } +} + +.profile > div { min-width: 0; } +#sign-out { white-space: nowrap; } diff --git a/public/vendor/nostr-tools.js b/public/vendor/nostr-tools.js new file mode 100644 index 0000000..8eb4cbc --- /dev/null +++ b/public/vendor/nostr-tools.js @@ -0,0 +1,2 @@ +// nostr-tools@2.25.2 (MIT) — built by scripts/vendor-nostr-tools.sh +var lo=Object.defineProperty;var Ft=(e,t)=>{for(var n in t)lo(e,n,{get:t[n],enumerable:!0})};/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */function Dt(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function X(e,t=""){if(!Number.isSafeInteger(e)||e<0){let n=t&&`"${t}" `;throw new Error(`${n}expected integer >= 0, got ${e}`)}}function V(e,t,n=""){let r=Dt(e),o=e?.length,s=t!==void 0;if(!r||s&&o!==t){let i=n&&`"${n}" `,c=s?` of length ${t}`:"",f=r?`length=${o}`:`type=${typeof e}`;throw new Error(i+"expected Uint8Array"+c+", got "+f)}return e}function xt(e){if(typeof e!="function"||typeof e.create!="function")throw new Error("Hash must wrapped by utils.createHasher");X(e.outputLen),X(e.blockLen)}function kt(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function Hn(e,t){V(e,void 0,"digestInto() output");let n=t.outputLen;if(e.length='+n)}function te(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function ft(...e){for(let t=0;t>>t}function M(e,t){return e<>>32-t>>>0}var uo=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;function ho(e){return e<<24&4278190080|e<<8&16711680|e>>>8&65280|e>>>24&255}function yo(e){for(let t=0;te:yo,Nn=typeof Uint8Array.from([]).toHex=="function"&&typeof Uint8Array.fromHex=="function",go=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function J(e){if(V(e),Nn)return e.toHex();let t="";for(let n=0;n=bt._0&&e<=bt._9)return e-bt._0;if(e>=bt.A&&e<=bt.F)return e-(bt.A-10);if(e>=bt.a&&e<=bt.f)return e-(bt.a-10)}function P(e){if(typeof e!="string")throw new Error("hex string expected, got "+typeof e);if(Nn)return Uint8Array.fromHex(e);let t=e.length,n=t/2;if(t%2)throw new Error("hex string expected, got unpadded hex of length "+t);let r=new Uint8Array(n);for(let o=0,s=0;oe(s).update(o).digest(),r=e(void 0);return n.outputLen=r.outputLen,n.blockLen=r.blockLen,n.create=o=>e(o),Object.assign(n,t),Object.freeze(n)}function ct(e=32){let t=typeof globalThis=="object"?globalThis.crypto:null;if(typeof t?.getRandomValues!="function")throw new Error("crypto.getRandomValues must be defined");return t.getRandomValues(new Uint8Array(e))}var qn=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function Vn(e,t,n){return e&t^~e&n}function Mn(e,t,n){return e&t^e&n^t&n}var ne=class{blockLen;outputLen;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(t,n,r,o){this.blockLen=t,this.outputLen=n,this.padOffset=r,this.isLE=o,this.buffer=new Uint8Array(t),this.view=Rt(this.buffer)}update(t){kt(this),V(t);let{view:n,buffer:r,blockLen:o}=this,s=t.length;for(let i=0;io-i&&(this.process(r,0),i=0);for(let u=i;ul.length)throw new Error("_sha2: outputLen bigger than state");for(let u=0;u>>3,p=dt(d,17)^dt(d,19)^d>>>10;Et[u]=p+Et[u-7]+g+Et[u-16]|0}let{A:r,B:o,C:s,D:i,E:c,F:f,G:a,H:l}=this;for(let u=0;u<64;u++){let y=dt(c,6)^dt(c,11)^dt(c,25),d=l+y+Vn(c,f,a)+bo[u]+Et[u]|0,p=(dt(r,2)^dt(r,13)^dt(r,22))+Mn(r,o,s)|0;l=a,a=f,f=c,c=i+d|0,i=s,s=o,o=r,r=d+p|0}r=r+this.A|0,o=o+this.B|0,s=s+this.C|0,i=i+this.D|0,c=c+this.E|0,f=f+this.F|0,a=a+this.G|0,l=l+this.H|0,this.set(r,o,s,i,c,f,a,l)}roundClean(){ft(Et)}destroy(){this.set(0,0,0,0,0,0,0,0),ft(this.buffer)}},_e=class extends Ie{A=wt[0]|0;B=wt[1]|0;C=wt[2]|0;D=wt[3]|0;E=wt[4]|0;F=wt[5]|0;G=wt[6]|0;H=wt[7]|0;constructor(){super(32)}};var at=Cn(()=>new _e,qn(1));/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var Re=BigInt(0),ke=BigInt(1);function Kt(e,t=""){if(typeof e!="boolean"){let n=t&&`"${t}" `;throw new Error(n+"expected boolean, got type="+typeof e)}return e}function Dn(e){if(typeof e=="bigint"){if(!re(e))throw new Error("positive bigint expected, got "+e)}else X(e);return e}function zt(e){let t=Dn(e).toString(16);return t.length&1?"0"+t:t}function Kn(e){if(typeof e!="string")throw new Error("hex string expected, got "+typeof e);return e===""?Re:BigInt("0x"+e)}function Bt(e){return Kn(J(e))}function Oe(e){return Kn(J(xo(V(e)).reverse()))}function oe(e,t){X(t),e=Dn(e);let n=P(e.toString(16).padStart(t*2,"0"));if(n.length!==t)throw new Error("number too large");return n}function He(e,t){return oe(e,t).reverse()}function xo(e){return Uint8Array.from(e)}function zn(e){return Uint8Array.from(e,(t,n)=>{let r=t.charCodeAt(0);if(t.length!==1||r>127)throw new Error(`string contains non-ASCII character "${e[n]}" with code ${r} at position ${n}`);return r})}var re=e=>typeof e=="bigint"&&Re<=e;function wo(e,t,n){return re(e)&&re(t)&&re(n)&&t<=e&&eRe;e>>=ke,t+=1);return t}var $t=e=>(ke<new Uint8Array(m),o=Uint8Array.of(),s=Uint8Array.of(0),i=Uint8Array.of(1),c=1e3,f=r(e),a=r(e),l=0,u=()=>{f.fill(1),a.fill(0),l=0},y=(...m)=>n(a,Q(f,...m)),d=(m=o)=>{a=y(s,m),f=y(),m.length!==0&&(a=y(i,m),f=y())},g=()=>{if(l++>=c)throw new Error("drbg: tried max amount of iterations");let m=0,E=[];for(;m{u(),d(m);let S;for(;!(S=E(g()));)d();return u(),S}}function Zt(e,t={},n={}){if(!e||typeof e!="object")throw new Error("expected valid options object");function r(s,i,c){let f=e[s];if(c&&f===void 0)return;let a=typeof f;if(a!==i||f===null)throw new Error(`param "${s}" is invalid: expected ${i}, got ${a}`)}let o=(s,i)=>Object.entries(s).forEach(([c,f])=>r(c,f,i));o(t,!1),o(n,!0)}function Ce(e){let t=new WeakMap;return(n,...r)=>{let o=t.get(n);if(o!==void 0)return o;let s=e(n,...r);return t.set(n,s),s}}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var it=BigInt(0),st=BigInt(1),Ut=BigInt(2),Gn=BigInt(3),Wn=BigInt(4),Yn=BigInt(5),mo=BigInt(7),Xn=BigInt(8),Eo=BigInt(9),Qn=BigInt(16);function ht(e,t){let n=e%t;return n>=it?n:t+n}function lt(e,t,n){let r=e;for(;t-- >it;)r*=r,r%=n;return r}function jn(e,t){if(e===it)throw new Error("invert: expected non-zero number");if(t<=it)throw new Error("invert: expected positive modulus, got "+t);let n=ht(e,t),r=t,o=it,s=st,i=st,c=it;for(;n!==it;){let a=r/n,l=r%n,u=o-i*a,y=s-c*a;r=n,n=l,o=i,s=c,i=u,c=y}if(r!==st)throw new Error("invert: does not exist");return ht(o,t)}function Ve(e,t,n){if(!e.eql(e.sqr(t),n))throw new Error("Cannot find square root")}function Jn(e,t){let n=(e.ORDER+st)/Wn,r=e.pow(t,n);return Ve(e,r,t),r}function Bo(e,t){let n=(e.ORDER-Yn)/Xn,r=e.mul(t,Ut),o=e.pow(r,n),s=e.mul(t,o),i=e.mul(e.mul(s,Ut),o),c=e.mul(s,e.sub(i,e.ONE));return Ve(e,c,t),c}function vo(e){let t=Ot(e),n=Fn(e),r=n(t,t.neg(t.ONE)),o=n(t,r),s=n(t,t.neg(r)),i=(e+mo)/Qn;return(c,f)=>{let a=c.pow(f,i),l=c.mul(a,r),u=c.mul(a,o),y=c.mul(a,s),d=c.eql(c.sqr(l),f),g=c.eql(c.sqr(u),f);a=c.cmov(a,l,d),l=c.cmov(y,u,g);let p=c.eql(c.sqr(l),f),m=c.cmov(a,l,p);return Ve(c,m,f),m}}function Fn(e){if(e1e3)throw new Error("Cannot find square root: probably non-prime P");if(n===1)return Jn;let s=o.pow(r,t),i=(t+st)/Ut;return function(f,a){if(f.is0(a))return a;if(Pn(f,a)!==1)throw new Error("Cannot find square root");let l=n,u=f.mul(f.ONE,s),y=f.pow(a,t),d=f.pow(a,i);for(;!f.eql(y,f.ONE);){if(f.is0(y))return f.ZERO;let g=1,p=f.sqr(y);for(;!f.eql(p,f.ONE);)if(g++,p=f.sqr(p),g===l)throw new Error("Cannot find square root");let m=st<(r[o]="function",r),t);return Zt(e,n),e}function Uo(e,t,n){if(nit;)n&st&&(r=e.mul(r,o)),o=e.sqr(o),n>>=st;return r}function se(e,t,n=!1){let r=new Array(t.length).fill(n?e.ZERO:void 0),o=t.reduce((i,c,f)=>e.is0(c)?i:(r[f]=i,e.mul(i,c)),e.ONE),s=e.inv(o);return t.reduceRight((i,c,f)=>e.is0(c)?i:(r[f]=e.mul(i,r[f]),e.mul(i,c)),s),r}function Pn(e,t){let n=(e.ORDER-st)/Ut,r=e.pow(t,n),o=e.eql(r,e.ONE),s=e.eql(r,e.ZERO),i=e.eql(r,e.neg(e.ONE));if(!o&&!s&&!i)throw new Error("invalid Legendre symbol result");return o?1:s?0:-1}function So(e,t){t!==void 0&&X(t);let n=t!==void 0?t:e.toString(2).length,r=Math.ceil(n/8);return{nBitLength:n,nByteLength:r}}var qe=class{ORDER;BITS;BYTES;isLE;ZERO=it;ONE=st;_lengths;_sqrt;_mod;constructor(t,n={}){if(t<=it)throw new Error("invalid field: expected ORDER > 0, got "+t);let r;this.isLE=!1,n!=null&&typeof n=="object"&&(typeof n.BITS=="number"&&(r=n.BITS),typeof n.sqrt=="function"&&(this.sqrt=n.sqrt),typeof n.isLE=="boolean"&&(this.isLE=n.isLE),n.allowedLengths&&(this._lengths=n.allowedLengths?.slice()),typeof n.modFromBytes=="boolean"&&(this._mod=n.modFromBytes));let{nBitLength:o,nByteLength:s}=So(t,r);if(s>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");this.ORDER=t,this.BITS=o,this.BYTES=s,this._sqrt=void 0,Object.preventExtensions(this)}create(t){return ht(t,this.ORDER)}isValid(t){if(typeof t!="bigint")throw new Error("invalid field element: expected bigint, got "+typeof t);return it<=t&&to)throw new Error("Field.fromBytes: expected "+r+" bytes, got "+t.length);let a=new Uint8Array(o);a.set(t,s?0:a.length-t.length),t=a}if(t.length!==o)throw new Error("Field.fromBytes: expected "+o+" bytes, got "+t.length);let f=s?Oe(t):Bt(t);if(c&&(f=ht(f,i)),!n&&!this.isValid(f))throw new Error("invalid field element: outside of range 0..ORDER");return f}invertBatch(t){return se(this,t)}cmov(t,n,r){return r?n:t}};function Ot(e,t={}){return new qe(e,t)}function tr(e){if(typeof e!="bigint")throw new Error("field order must be bigint");let t=e.toString(2).length;return Math.ceil(t/8)}function De(e){let t=tr(e);return t+Math.ceil(t/2)}function ie(e,t,n=!1){V(e);let r=e.length,o=tr(t),s=De(t);if(r<16||r1024)throw new Error("expected "+s+"-1024 bytes of input, got "+r);let i=n?Oe(e):Bt(e),c=ht(i,t-st)+st;return n?He(c,o):oe(c,o)}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var Ht=BigInt(0),St=BigInt(1);function jt(e,t){let n=t.negate();return e?n:t}function Ze(e,t){let n=se(e.Fp,t.map(r=>r.Z));return t.map((r,o)=>e.fromAffine(r.toAffine(n[o])))}function or(e,t){if(!Number.isSafeInteger(e)||e<=0||e>t)throw new Error("invalid window size, expected [1.."+t+"], got W="+e)}function Ke(e,t){or(e,t);let n=Math.ceil(t/e)+1,r=2**(e-1),o=2**e,s=$t(e),i=BigInt(e);return{windows:n,windowSize:r,mask:s,maxNumber:o,shiftBy:i}}function er(e,t,n){let{windowSize:r,mask:o,maxNumber:s,shiftBy:i}=n,c=Number(e&o),f=e>>i;c>r&&(c-=s,f+=St);let a=t*r,l=a+Math.abs(c)-1,u=c===0,y=c<0,d=t%2!==0;return{nextN:f,offset:l,isZero:u,isNeg:y,isNegF:d,offsetF:a}}var ze=new WeakMap,sr=new WeakMap;function $e(e){return sr.get(e)||1}function nr(e){if(e!==Ht)throw new Error("invalid wNAF")}var ce=class{BASE;ZERO;Fn;bits;constructor(t,n){this.BASE=t.BASE,this.ZERO=t.ZERO,this.Fn=t.Fn,this.bits=n}_unsafeLadder(t,n,r=this.ZERO){let o=t;for(;n>Ht;)n&St&&(r=r.add(o)),o=o.double(),n>>=St;return r}precomputeWindow(t,n){let{windows:r,windowSize:o}=Ke(n,this.bits),s=[],i=t,c=i;for(let f=0;fHt||r>Ht;)n&St&&(s=s.add(o)),r&St&&(i=i.add(o)),o=o.double(),n>>=St,r>>=St;return{p1:s,p2:i}}function rr(e,t,n){if(t){if(t.ORDER!==e)throw new Error("Field.ORDER must match order: Fp == p, Fn == n");return Me(t),t}else return Ot(e,{isLE:n})}function cr(e,t,n={},r){if(r===void 0&&(r=e==="edwards"),!t||typeof t!="object")throw new Error(`expected valid ${e} CURVE object`);for(let f of["p","n","h"]){let a=t[f];if(!(typeof a=="bigint"&&a>Ht))throw new Error(`CURVE.${f} must be positive bigint`)}let o=rr(t.p,n.Fp,r),s=rr(t.n,n.Fn,r),c=["Gx","Gy","a",e==="weierstrass"?"b":"d"];for(let f of c)if(!o.isValid(t[f]))throw new Error(`CURVE.${f} must be valid field element of CURVE.Fp`);return t=Object.freeze(Object.assign({},t)),{CURVE:t,Fp:o,Fn:s}}function fe(e,t){return function(r){let o=e(r);return{secretKey:o,publicKey:t(o)}}}var ae=class{oHash;iHash;blockLen;outputLen;finished=!1;destroyed=!1;constructor(t,n){if(xt(t),V(n,void 0,"key"),this.iHash=t.create(),typeof this.iHash.update!="function")throw new Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let r=this.blockLen,o=new Uint8Array(r);o.set(n.length>r?t.create().update(n).digest():n);for(let s=0;snew ae(e,t).update(n).digest();gt.create=(e,t)=>new ae(e,t);/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var fr=(e,t)=>(e+(e>=0?t:-t)/ar)/t;function To(e,t,n){let[[r,o],[s,i]]=t,c=fr(i*e,n),f=fr(-o*e,n),a=e-c*r-f*s,l=-c*o-f*i,u=a=d||l=d)throw new Error("splitScalar (endomorphism): failed, k="+e);return{k1neg:u,k1:a,k2neg:y,k2:l}}function Pe(e){if(!["compact","recovered","der"].includes(e))throw new Error('Signature format must be "compact", "recovered", or "der"');return e}function je(e,t){let n={};for(let r of Object.keys(t))n[r]=e[r]===void 0?t[r]:e[r];return Kt(n.lowS,"lowS"),Kt(n.prehash,"prehash"),n.format!==void 0&&Pe(n.format),n}var Ge=class extends Error{constructor(t=""){super(t)}},vt={Err:Ge,_tlv:{encode:(e,t)=>{let{Err:n}=vt;if(e<0||e>256)throw new n("tlv.encode: wrong tag");if(t.length&1)throw new n("tlv.encode: unpadded data");let r=t.length/2,o=zt(r);if(o.length/2&128)throw new n("tlv.encode: long form length too big");let s=r>127?zt(o.length/2|128):"";return zt(e)+s+o+t},decode(e,t){let{Err:n}=vt,r=0;if(e<0||e>256)throw new n("tlv.encode: wrong tag");if(t.length<2||t[r++]!==e)throw new n("tlv.decode: wrong tlv");let o=t[r++],s=!!(o&128),i=0;if(!s)i=o;else{let f=o&127;if(!f)throw new n("tlv.decode(long): indefinite length not supported");if(f>4)throw new n("tlv.decode(long): byte length is too big");let a=t.subarray(r,r+f);if(a.length!==f)throw new n("tlv.decode: length bytes not complete");if(a[0]===0)throw new n("tlv.decode(long): zero leftmost byte");for(let l of a)i=i<<8|l;if(r+=f,i<128)throw new n("tlv.decode(long): not minimal encoding")}let c=t.subarray(r,r+i);if(c.length!==i)throw new n("tlv.decode: wrong value length");return{v:c,l:t.subarray(r+i)}}},_int:{encode(e){let{Err:t}=vt;if(e{let{X:b,Y:h,Z:B}=k;if(r.eql(B,r.ONE))return{x:b,y:h};let A=k.is0();x==null&&(x=A?r.ONE:r.inv(B));let L=r.mul(b,x),w=r.mul(h,x),v=r.mul(B,x);if(A)return{x:r.ZERO,y:r.ZERO};if(!r.eql(v,r.ONE))throw new Error("invZ was invalid");return{x:L,y:w}}),z=Ce(k=>{if(k.is0()){if(t.allowInfinityPoint&&!r.is0(k.Y))return;throw new Error("bad point: ZERO")}let{x,y:b}=k.toAffine();if(!r.isValid(x)||!r.isValid(b))throw new Error("bad point: x or y not field elements");if(!m(x,b))throw new Error("bad point: equation left != right");if(!k.isTorsionFree())throw new Error("bad point: not in prime-order subgroup");return!0});function D(k,x,b,h,B){return b=new _(r.mul(b.X,k),b.Y,b.Z),x=jt(h,x),b=jt(B,b),x.add(b)}class _{static BASE=new _(s.Gx,s.Gy,r.ONE);static ZERO=new _(r.ZERO,r.ONE,r.ZERO);static Fp=r;static Fn=o;X;Y;Z;constructor(x,b,h){this.X=R("x",x),this.Y=R("y",b,!0),this.Z=R("z",h),Object.freeze(this)}static CURVE(){return s}static fromAffine(x){let{x:b,y:h}=x||{};if(!x||!r.isValid(b)||!r.isValid(h))throw new Error("invalid affine point");if(x instanceof _)throw new Error("projective point not allowed");return r.is0(b)&&r.is0(h)?_.ZERO:new _(b,h,r.ONE)}static fromBytes(x){let b=_.fromAffine(g(V(x,void 0,"point")));return b.assertValidity(),b}static fromHex(x){return _.fromBytes(P(x))}get x(){return this.toAffine().x}get y(){return this.toAffine().y}precompute(x=8,b=!0){return N.createCache(this,x),b||this.multiply(le),this}assertValidity(){z(this)}hasEvenY(){let{y:x}=this.toAffine();if(!r.isOdd)throw new Error("Field doesn't support isOdd");return!r.isOdd(x)}equals(x){O(x);let{X:b,Y:h,Z:B}=this,{X:A,Y:L,Z:w}=x,v=r.eql(r.mul(b,w),r.mul(A,B)),U=r.eql(r.mul(h,w),r.mul(L,B));return v&&U}negate(){return new _(this.X,r.neg(this.Y),this.Z)}double(){let{a:x,b}=s,h=r.mul(b,le),{X:B,Y:A,Z:L}=this,w=r.ZERO,v=r.ZERO,U=r.ZERO,T=r.mul(B,B),$=r.mul(A,A),K=r.mul(L,L),C=r.mul(B,A);return C=r.add(C,C),U=r.mul(B,L),U=r.add(U,U),w=r.mul(x,U),v=r.mul(h,K),v=r.add(w,v),w=r.sub($,v),v=r.add($,v),v=r.mul(w,v),w=r.mul(C,w),U=r.mul(h,U),K=r.mul(x,K),C=r.sub(T,K),C=r.mul(x,C),C=r.add(C,U),U=r.add(T,T),T=r.add(U,T),T=r.add(T,K),T=r.mul(T,C),v=r.add(v,T),K=r.mul(A,L),K=r.add(K,K),T=r.mul(K,C),w=r.sub(w,T),U=r.mul(K,$),U=r.add(U,U),U=r.add(U,U),new _(w,v,U)}add(x){O(x);let{X:b,Y:h,Z:B}=this,{X:A,Y:L,Z:w}=x,v=r.ZERO,U=r.ZERO,T=r.ZERO,$=s.a,K=r.mul(s.b,le),C=r.mul(b,A),Z=r.mul(h,L),Y=r.mul(B,w),et=r.add(b,h),j=r.add(A,L);et=r.mul(et,j),j=r.add(C,Z),et=r.sub(et,j),j=r.add(b,B);let ot=r.add(A,w);return j=r.mul(j,ot),ot=r.add(C,Y),j=r.sub(j,ot),ot=r.add(h,B),v=r.add(L,w),ot=r.mul(ot,v),v=r.add(Z,Y),ot=r.sub(ot,v),T=r.mul($,j),v=r.mul(K,Y),T=r.add(v,T),v=r.sub(Z,T),T=r.add(Z,T),U=r.mul(v,T),Z=r.add(C,C),Z=r.add(Z,C),Y=r.mul($,Y),j=r.mul(K,j),Z=r.add(Z,Y),Y=r.sub(C,Y),Y=r.mul($,Y),j=r.add(j,Y),C=r.mul(Z,j),U=r.add(U,C),C=r.mul(ot,j),v=r.mul(et,v),v=r.sub(v,C),C=r.mul(et,Z),T=r.mul(ot,T),T=r.add(T,C),new _(v,U,T)}subtract(x){return this.add(x.negate())}is0(){return this.equals(_.ZERO)}multiply(x){let{endo:b}=t;if(!o.isValidNot0(x))throw new Error("invalid scalar: out of range");let h,B,A=L=>N.cached(this,L,w=>Ze(_,w));if(b){let{k1neg:L,k1:w,k2neg:v,k2:U}=W(x),{p:T,f:$}=A(w),{p:K,f:C}=A(U);B=$.add(C),h=D(b.beta,T,K,L,v)}else{let{p:L,f:w}=A(x);h=L,B=w}return Ze(_,[h,B])[0]}multiplyUnsafe(x){let{endo:b}=t,h=this;if(!o.isValid(x))throw new Error("invalid scalar: out of range");if(x===mt||h.is0())return _.ZERO;if(x===Nt)return h;if(N.hasCache(this))return this.multiply(x);if(b){let{k1neg:B,k1:A,k2neg:L,k2:w}=W(x),{p1:v,p2:U}=ir(_,h,A,w);return D(b.beta,v,U,B,L)}else return N.unsafe(h,x)}toAffine(x){return H(this,x)}isTorsionFree(){let{isTorsionFree:x}=t;return i===Nt?!0:x?x(_,this):N.unsafe(this,c).is0()}clearCofactor(){let{clearCofactor:x}=t;return i===Nt?this:x?x(_,this):this.multiplyUnsafe(i)}isSmallOrder(){return this.multiplyUnsafe(i).is0()}toBytes(x=!0){return Kt(x,"isCompressed"),this.assertValidity(),d(_,this,x)}toHex(x=!0){return J(this.toBytes(x))}toString(){return``}}let q=o.BITS,N=new ce(_,t.endo?Math.ceil(q/2):q);return _.BASE.precompute(8),_}function ur(e){return Uint8Array.of(e?2:3)}function dr(e,t){return{secretKey:t.BYTES,publicKey:1+e.BYTES,publicKeyUncompressed:1+2*e.BYTES,publicKeyHasPrefix:!0,signature:2*t.BYTES}}function _o(e,t={}){let{Fn:n}=e,r=t.randomBytes||ct,o=Object.assign(dr(e.Fp,n),{seed:De(n.ORDER)});function s(d){try{let g=n.fromBytes(d);return n.isValidNot0(g)}catch{return!1}}function i(d,g){let{publicKey:p,publicKeyUncompressed:m}=o;try{let E=d.length;return g===!0&&E!==p||g===!1&&E!==m?!1:!!e.fromBytes(d)}catch{return!1}}function c(d=r(o.seed)){return ie(V(d,o.seed,"seed"),n.ORDER)}function f(d,g=!0){return e.BASE.multiply(n.fromBytes(d)).toBytes(g)}function a(d){let{secretKey:g,publicKey:p,publicKeyUncompressed:m}=o;if(!Dt(d)||"_lengths"in n&&n._lengths||g===p)return;let E=V(d,void 0,"key").length;return E===p||E===m}function l(d,g,p=!0){if(a(d)===!0)throw new Error("first arg must be private key");if(a(g)===!1)throw new Error("second arg must be public key");let m=n.fromBytes(d);return e.fromBytes(g).multiply(m).toBytes(p)}let u={isValidSecretKey:s,isValidPublicKey:i,randomSecretKey:c},y=fe(c,f);return Object.freeze({getPublicKey:f,getSharedSecret:l,keygen:y,Point:e,utils:u,lengths:o})}function hr(e,t,n={}){xt(t),Zt(n,{},{hmac:"function",lowS:"boolean",randomBytes:"function",bits2int:"function",bits2int_modN:"function"}),n=Object.assign({},n);let r=n.randomBytes||ct,o=n.hmac||((b,h)=>gt(t,b,h)),{Fp:s,Fn:i}=e,{ORDER:c,BITS:f}=i,{keygen:a,getPublicKey:l,getSharedSecret:u,utils:y,lengths:d}=_o(e,n),g={prehash:!0,lowS:typeof n.lowS=="boolean"?n.lowS:!0,format:"compact",extraEntropy:!1},p=c*ar>Nt;return b>h}function E(b,h){if(!i.isValidNot0(h))throw new Error(`invalid signature ${b}: out of range 1..Point.Fn.ORDER`);return h}function S(){if(p)throw new Error('"recovered" sig type is not supported for cofactor >2 curves')}function R(b,h){Pe(h);let B=d.signature,A=h==="compact"?B:h==="recovered"?B+1:void 0;return V(b,A)}class O{r;s;recovery;constructor(h,B,A){if(this.r=E("r",h),this.s=E("s",B),A!=null){if(S(),![0,1,2,3].includes(A))throw new Error("invalid recovery id");this.recovery=A}Object.freeze(this)}static fromBytes(h,B=g.format){R(h,B);let A;if(B==="der"){let{r:U,s:T}=vt.toSig(V(h));return new O(U,T)}B==="recovered"&&(A=h[0],B="compact",h=h.subarray(1));let L=d.signature/2,w=h.subarray(0,L),v=h.subarray(L,L*2);return new O(i.fromBytes(w),i.fromBytes(v),A)}static fromHex(h,B){return this.fromBytes(P(h),B)}assertRecovery(){let{recovery:h}=this;if(h==null)throw new Error("invalid recovery id: must be present");return h}addRecoveryBit(h){return new O(this.r,this.s,h)}recoverPublicKey(h){let{r:B,s:A}=this,L=this.assertRecovery(),w=L===2||L===3?B+c:B;if(!s.isValid(w))throw new Error("invalid recovery id: sig.r+curve.n != R.x");let v=s.toBytes(w),U=e.fromBytes(Q(ur((L&1)===0),v)),T=i.inv(w),$=H(V(h,void 0,"msgHash")),K=i.create(-$*T),C=i.create(A*T),Z=e.BASE.multiplyUnsafe(K).add(U.multiplyUnsafe(C));if(Z.is0())throw new Error("invalid recovery: point at infinify");return Z.assertValidity(),Z}hasHighS(){return m(this.s)}toBytes(h=g.format){if(Pe(h),h==="der")return P(vt.hexFromSig(this));let{r:B,s:A}=this,L=i.toBytes(B),w=i.toBytes(A);return h==="recovered"?(S(),Q(Uint8Array.of(this.assertRecovery()),L,w)):Q(L,w)}toHex(h){return J(this.toBytes(h))}}let W=n.bits2int||function(h){if(h.length>8192)throw new Error("input is too large");let B=Bt(h),A=h.length*8-f;return A>0?B>>BigInt(A):B},H=n.bits2int_modN||function(h){return i.create(W(h))},z=$t(f);function D(b){return $n("num < 2^"+f,b,mt,z),i.toBytes(b)}function _(b,h){return V(b,void 0,"message"),h?V(t(b),void 0,"prehashed message"):b}function q(b,h,B){let{lowS:A,prehash:L,extraEntropy:w}=je(B,g);b=_(b,L);let v=H(b),U=i.fromBytes(h);if(!i.isValidNot0(U))throw new Error("invalid private key");let T=[D(U),D(v)];if(w!=null&&w!==!1){let Z=w===!0?r(d.secretKey):w;T.push(V(Z,void 0,"extraEntropy"))}let $=Q(...T),K=v;function C(Z){let Y=W(Z);if(!i.isValidNot0(Y))return;let et=i.inv(Y),j=e.BASE.multiply(Y).toAffine(),ot=i.create(j.x);if(ot===mt)return;let Jt=i.create(et*i.create(K+ot*U));if(Jt===mt)return;let kn=(j.x===ot?0:2)|Number(j.y&Nt),Rn=Jt;return A&&m(Jt)&&(Rn=i.neg(Jt),kn^=1),new O(ot,Rn,p?void 0:kn)}return{seed:$,k2sig:C}}function N(b,h,B={}){let{seed:A,k2sig:L}=q(b,h,B);return Zn(t.outputLen,i.BYTES,o)(A,L).toBytes(B.format)}function k(b,h,B,A={}){let{lowS:L,prehash:w,format:v}=je(A,g);if(B=V(B,void 0,"publicKey"),h=_(h,w),!Dt(b)){let U=b instanceof O?", use sig.toBytes()":"";throw new Error("verify expects Uint8Array signature"+U)}R(b,v);try{let U=O.fromBytes(b,v),T=e.fromBytes(B);if(L&&U.hasHighS())return!1;let{r:$,s:K}=U,C=H(h),Z=i.inv(K),Y=i.create(C*Z),et=i.create($*Z),j=e.BASE.multiplyUnsafe(Y).add(T.multiplyUnsafe(et));return j.is0()?!1:i.create(j.x)===$}catch{return!1}}function x(b,h,B={}){let{prehash:A}=je(B,g);return h=_(h,A),O.fromBytes(b,"recovered").recoverPublicKey(h).toBytes()}return Object.freeze({keygen:a,getPublicKey:l,getSharedSecret:u,utils:y,lengths:d,Point:e,sign:N,verify:k,recoverPublicKey:x,Signature:O,hash:t})}/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */var he={p:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:BigInt(1),a:BigInt(0),b:BigInt(7),Gx:BigInt("0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),Gy:BigInt("0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")},ko={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),basises:[[BigInt("0x3086d221a7d46bcde86c90e49284eb15"),-BigInt("0xe4437ed6010e88286f547fa90abfe4c3")],[BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),BigInt("0x3086d221a7d46bcde86c90e49284eb15")]]},Ro=BigInt(0),We=BigInt(2);function Oo(e){let t=he.p,n=BigInt(3),r=BigInt(6),o=BigInt(11),s=BigInt(22),i=BigInt(23),c=BigInt(44),f=BigInt(88),a=e*e*e%t,l=a*a*e%t,u=lt(l,n,t)*l%t,y=lt(u,n,t)*l%t,d=lt(y,We,t)*a%t,g=lt(d,o,t)*d%t,p=lt(g,s,t)*g%t,m=lt(p,c,t)*p%t,E=lt(m,f,t)*m%t,S=lt(E,c,t)*p%t,R=lt(S,n,t)*l%t,O=lt(R,i,t)*g%t,W=lt(O,r,t)*a%t,H=lt(W,We,t);if(!ue.eql(ue.sqr(H),e))throw new Error("Cannot find square root");return H}var ue=Ot(he.p,{sqrt:Oo}),Tt=lr(he,{Fp:ue,endo:ko}),Gt=hr(Tt,at),yr={};function de(e,...t){let n=yr[e];if(n===void 0){let r=at(zn(e));n=Q(r,r),yr[e]=n}return at(Q(n,...t))}var Xe=e=>e.toBytes(!0).slice(1),Qe=e=>e%We===Ro;function Ye(e){let{Fn:t,BASE:n}=Tt,r=t.fromBytes(e),o=n.multiply(r);return{scalar:Qe(o.y)?r:t.neg(r),bytes:Xe(o)}}function pr(e){let t=ue;if(!t.isValidNot0(e))throw new Error("invalid x: Fail if x \u2265 p");let n=t.create(e*e),r=t.create(n*e+BigInt(7)),o=t.sqrt(r);Qe(o)||(o=t.neg(o));let s=Tt.fromAffine({x:e,y:o});return s.assertValidity(),s}var Pt=Bt;function br(...e){return Tt.Fn.create(Pt(de("BIP0340/challenge",...e)))}function gr(e){return Ye(e).bytes}function Ho(e,t,n=ct(32)){let{Fn:r}=Tt,o=V(e,void 0,"message"),{bytes:s,scalar:i}=Ye(t),c=V(n,32,"auxRand"),f=r.toBytes(i^Pt(de("BIP0340/aux",c))),a=de("BIP0340/nonce",f,s,o),{bytes:l,scalar:u}=Ye(a),y=br(l,s,o),d=new Uint8Array(64);if(d.set(l,0),d.set(r.toBytes(r.create(u+y*i)),32),!xr(d,o,s))throw new Error("sign: Invalid signature produced");return d}function xr(e,t,n){let{Fp:r,Fn:o,BASE:s}=Tt,i=V(e,64,"signature"),c=V(t,void 0,"message"),f=V(n,32,"publicKey");try{let a=pr(Pt(f)),l=Pt(i.subarray(0,32));if(!r.isValidNot0(l))return!1;let u=Pt(i.subarray(32,64));if(!o.isValidNot0(u))return!1;let y=br(o.toBytes(l),Xe(a),c),d=s.multiplyUnsafe(u).add(a.multiplyUnsafe(o.neg(y))),{x:g,y:p}=d.toAffine();return!(d.is0()||!Qe(p)||g!==l)}catch{return!1}}var Ct=(()=>{let n=(r=ct(48))=>ie(r,he.n);return{keygen:fe(n,gr),getPublicKey:gr,sign:Ho,verify:xr,Point:Tt,utils:{randomSecretKey:n,taggedHash:de,lift_x:pr,pointToBytes:Xe},lengths:{secretKey:32,publicKey:32,publicKeyHasPrefix:!1,signature:64,seed:48}}})();var Oi=new TextDecoder("utf-8"),No=new TextEncoder;function Co(e){if(e.length!==64)return!1;for(let t=0;t<64;t++){let n=e.charCodeAt(t);if(isNaN(n)||n<48||n>102||n>57&&n<97)return!1}return!0}var qt=Symbol("verified"),qo=e=>e instanceof Object;function Vo(e){if(!qo(e)||typeof e.kind!="number"||typeof e.content!="string"||typeof e.created_at!="number"||typeof e.pubkey!="string"||!Co(e.pubkey)||!Array.isArray(e.tags))return!1;for(let t=0;tJo,Bech32MaxSize:()=>sn,NostrTypeGuard:()=>Qo,decode:()=>Ir,decodeNostrURI:()=>ts,encodeBytes:()=>Ee,naddrEncode:()=>is,neventEncode:()=>ss,noteEncode:()=>rs,nprofileEncode:()=>os,npubEncode:()=>ns,nsecEncode:()=>es});/*! scure-base - MIT License (c) 2022 Paul Miller (paulmillr.com) */function nn(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function jo(e){if(!nn(e))throw new Error("Uint8Array expected")}function Br(e,t){return Array.isArray(t)?t.length===0?!0:e?t.every(n=>typeof n=="string"):t.every(n=>Number.isSafeInteger(n)):!1}function Po(e){if(typeof e!="function")throw new Error("function expected");return!0}function It(e,t){if(typeof t!="string")throw new Error(`${e}: string expected`);return!0}function rn(e){if(!Number.isSafeInteger(e))throw new Error(`invalid integer: ${e}`)}function Fe(e){if(!Array.isArray(e))throw new Error("array expected")}function pe(e,t){if(!Br(!0,t))throw new Error(`${e}: array of strings expected`)}function vr(e,t){if(!Br(!1,t))throw new Error(`${e}: array of numbers expected`)}function Ar(...e){let t=s=>s,n=(s,i)=>c=>s(i(c)),r=e.map(s=>s.encode).reduceRight(n,t),o=e.map(s=>s.decode).reduce(n,t);return{encode:r,decode:o}}function Lr(e){let t=typeof e=="string"?e.split(""):e,n=t.length;pe("alphabet",t);let r=new Map(t.map((o,s)=>[o,s]));return{encode:o=>(Fe(o),o.map(s=>{if(!Number.isSafeInteger(s)||s<0||s>=n)throw new Error(`alphabet.encode: digit index outside alphabet "${s}". Allowed: ${e}`);return t[s]})),decode:o=>(Fe(o),o.map(s=>{It("alphabet.decode",s);let i=r.get(s);if(i===void 0)throw new Error(`Unknown letter: "${s}". Allowed: ${e}`);return i}))}}function Ur(e=""){return It("join",e),{encode:t=>(pe("join.decode",t),t.join(e)),decode:t=>(It("join.decode",t),t.split(e))}}function Go(e,t="="){return rn(e),It("padding",t),{encode(n){for(pe("padding.encode",n);n.length*e%8;)n.push(t);return n},decode(n){pe("padding.decode",n);let r=n.length;if(r*e%8)throw new Error("padding: invalid, string should have whole number of bytes");for(;r>0&&n[r-1]===t;r--)if((r-1)*e%8===0)throw new Error("padding: invalid, string has too much padding");return n.slice(0,r)}}}var Sr=(e,t)=>t===0?e:Sr(t,e%t),be=(e,t)=>e+(t-Sr(e,t)),ge=(()=>{let e=[];for(let t=0;t<40;t++)e.push(2**t);return e})();function tn(e,t,n,r){if(Fe(e),t<=0||t>32)throw new Error(`convertRadix2: wrong from=${t}`);if(n<=0||n>32)throw new Error(`convertRadix2: wrong to=${n}`);if(be(t,n)>32)throw new Error(`convertRadix2: carry overflow from=${t} to=${n} carryBits=${be(t,n)}`);let o=0,s=0,i=ge[t],c=ge[n]-1,f=[];for(let a of e){if(rn(a),a>=i)throw new Error(`convertRadix2: invalid data word=${a} from=${t}`);if(o=o<32)throw new Error(`convertRadix2: carry overflow pos=${s} from=${t}`);for(s+=t;s>=n;s-=n)f.push((o>>s-n&c)>>>0);let l=ge[s];if(l===void 0)throw new Error("invalid carry");o&=l-1}if(o=o<=t)throw new Error("Excess padding");if(!r&&o>0)throw new Error(`Non-zero padding: ${o}`);return r&&s>0&&f.push(o>>>0),f}function Tr(e,t=!1){if(rn(e),e<=0||e>32)throw new Error("radix2: bits should be in (0..32]");if(be(8,e)>32||be(e,8)>32)throw new Error("radix2: carry overflow");return{encode:n=>{if(!nn(n))throw new Error("radix2.encode input should be Uint8Array");return tn(Array.from(n),8,e,!t)},decode:n=>(vr("radix2.decode",n),Uint8Array.from(tn(n,e,8,t)))}}function wr(e){return Po(e),function(...t){try{return e.apply(null,t)}catch{}}}var Wo=typeof Uint8Array.from([]).toBase64=="function"&&typeof Uint8Array.fromBase64=="function",Yo=(e,t)=>{It("base64",e);let n=t?/^[A-Za-z0-9=_-]+$/:/^[A-Za-z0-9=+/]+$/,r=t?"base64url":"base64";if(e.length>0&&!n.test(e))throw new Error("invalid base64");return Uint8Array.fromBase64(e,{alphabet:r,lastChunkHandling:"strict"})},At=Wo?{encode(e){return jo(e),e.toBase64()},decode(e){return Yo(e,!1)}}:Ar(Tr(6),Lr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),Go(6),Ur(""));var en=Ar(Lr("qpzry9x8gf2tvdw0s3jn54khce6mua7l"),Ur("")),mr=[996825010,642813549,513874426,1027748829,705979059];function Wt(e){let t=e>>25,n=(e&33554431)<<5;for(let r=0;r>r&1)===1&&(n^=mr[r]);return n}function Er(e,t,n=1){let r=e.length,o=1;for(let s=0;s126)throw new Error(`Invalid prefix (${e})`);o=Wt(o)^i>>5}o=Wt(o);for(let s=0;sd)throw new TypeError(`Length ${p} exceeds limit ${d}`);let m=u.toLowerCase(),E=Er(m,y,t);return`${m}1${en.encode(y)}${E}`}function c(u,y=90){It("bech32.decode input",u);let d=u.length;if(d<8||y!==!1&&d>y)throw new TypeError(`invalid string length: ${d} (${u}). Expected (8..${y})`);let g=u.toLowerCase();if(u!==g&&u!==u.toUpperCase())throw new Error("String must be lowercase or uppercase");let p=g.lastIndexOf("1");if(p===0||p===-1)throw new Error('Letter "1" must be present between prefix and data only');let m=g.slice(0,p),E=g.slice(p+1);if(E.length<6)throw new Error("Data must be at least 6 characters long");let S=en.decode(E).slice(0,-6),R=Er(m,S,t);if(!E.endsWith(R))throw new Error(`Invalid checksum in ${u}: expected "${R}"`);return{prefix:m,words:S}}let f=wr(c);function a(u){let{prefix:y,words:d}=c(u,!1);return{prefix:y,words:d,bytes:r(d)}}function l(u,y){return i(u,o(y))}return{encode:i,decode:c,encodeFromBytes:l,decodeToBytes:a,decodeUnsafe:f,fromWords:r,fromWordsUnsafe:s,toWords:o}}var yt=Xo("bech32");var xe=new TextDecoder("utf-8"),we=new TextEncoder,Qo={isNProfile:e=>/^nprofile1[a-z\d]+$/.test(e||""),isNEvent:e=>/^nevent1[a-z\d]+$/.test(e||""),isNAddr:e=>/^naddr1[a-z\d]+$/.test(e||""),isNSec:e=>/^nsec1[a-z\d]{58}$/.test(e||""),isNPub:e=>/^npub1[a-z\d]{58}$/.test(e||""),isNote:e=>/^note1[a-z\d]+$/.test(e||""),isNcryptsec:e=>/^ncryptsec1[a-z\d]+$/.test(e||"")},sn=5e3,Jo=/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/;function Fo(e){let t=new Uint8Array(4);return t[0]=e>>24&255,t[1]=e>>16&255,t[2]=e>>8&255,t[3]=e&255,t}function ts(e){try{return e.startsWith("nostr:")&&(e=e.substring(6)),Ir(e)}catch{return{type:"invalid",data:null}}}function Ir(e){let{prefix:t,words:n}=yt.decode(e,sn),r=new Uint8Array(yt.fromWords(n));switch(t){case"nprofile":{let o=on(r);if(!o[0]?.[0])throw new Error("missing TLV 0 for nprofile");if(o[0][0].length!==32)throw new Error("TLV 0 should be 32 bytes");return{type:"nprofile",data:{pubkey:J(o[0][0]),relays:o[1]?o[1].map(s=>xe.decode(s)):[]}}}case"nevent":{let o=on(r);if(!o[0]?.[0])throw new Error("missing TLV 0 for nevent");if(o[0][0].length!==32)throw new Error("TLV 0 should be 32 bytes");if(o[2]&&o[2][0].length!==32)throw new Error("TLV 2 should be 32 bytes");if(o[3]&&o[3][0].length!==4)throw new Error("TLV 3 should be 4 bytes");return{type:"nevent",data:{id:J(o[0][0]),relays:o[1]?o[1].map(s=>xe.decode(s)):[],author:o[2]?.[0]?J(o[2][0]):void 0,kind:o[3]?.[0]?parseInt(J(o[3][0]),16):void 0}}}case"naddr":{let o=on(r);if(!o[0]?.[0])throw new Error("missing TLV 0 for naddr");if(!o[2]?.[0])throw new Error("missing TLV 2 for naddr");if(o[2][0].length!==32)throw new Error("TLV 2 should be 32 bytes");if(!o[3]?.[0])throw new Error("missing TLV 3 for naddr");if(o[3][0].length!==4)throw new Error("TLV 3 should be 4 bytes");return{type:"naddr",data:{identifier:xe.decode(o[0][0]),pubkey:J(o[2][0]),kind:parseInt(J(o[3][0]),16),relays:o[1]?o[1].map(s=>xe.decode(s)):[]}}}case"nsec":return{type:t,data:r};case"npub":case"note":return{type:t,data:J(r)};default:throw new Error(`unknown prefix ${t}`)}}function on(e){let t={},n=e;for(;n.length>0;){if(n.length<2)throw new Error("not enough data to read TLV");let r=n[0],o=n[1],s=n.slice(2,2+o);if(n=n.slice(2+o),s.lengthwe.encode(n))});return me("nprofile",t)}function ss(e){let t;e.kind!==void 0&&(t=Fo(e.kind));let n=cn({0:[P(e.id)],1:(e.relays||[]).map(r=>we.encode(r)),2:e.author?[P(e.author)]:[],3:t?[new Uint8Array(t)]:[]});return me("nevent",n)}function is(e){let t=new ArrayBuffer(4);new DataView(t).setUint32(0,e.kind,!1);let n=cn({0:[we.encode(e.identifier)],1:(e.relays||[]).map(r=>we.encode(r)),2:[P(e.pubkey)],3:[new Uint8Array(t)]});return me("naddr",n)}function cn(e){let t=[];return Object.entries(e).reverse().forEach(([n,r])=>{r.forEach(o=>{let s=new Uint8Array(o.length+2);s.set([parseInt(n)],0),s.set([o.length],1),s.set(o,2),t.push(s)})}),Q(...t)}var Dr={};Ft(Dr,{decrypt:()=>Ls,encrypt:()=>As});/*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) */function cs(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function Be(e){if(typeof e!="boolean")throw new Error(`boolean expected, not ${e}`)}function Yt(e){if(!Number.isSafeInteger(e)||e<0)throw new Error("positive integer expected, got "+e)}function G(e,t,n=""){let r=cs(e),o=e?.length,s=t!==void 0;if(!r||s&&o!==t){let i=n&&`"${n}" `,c=s?` of length ${t}`:"",f=r?`length=${o}`:`type=${typeof e}`;throw new Error(i+"expected Uint8Array"+c+", got "+f)}return e}function fn(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function kr(e,t){G(e,void 0,"output");let n=t.outputLen;if(e.length{function n(r,...o){if(G(r,void 0,"key"),!fs)throw new Error("Non little-endian hardware is not yet supported");if(e.nonceLength!==void 0){let l=o[0];G(l,e.varSizeNonce?void 0:e.nonceLength,"nonce")}let s=e.tagLength;s&&o[1]!==void 0&&G(o[1],void 0,"AAD");let i=t(r,...o),c=(l,u)=>{if(u!==void 0){if(l!==2)throw new Error("cipher output not supported");G(u,void 0,"output")}},f=!1;return{encrypt(l,u){if(f)throw new Error("cannot encrypt() twice with same key + nonce");return f=!0,G(l),c(i.encrypt.length,u),i.encrypt(l,u)},decrypt(l,u){if(G(l),s&&l.length(d-1)*32/u)throw new Error('"p" expected integer 1..((2^32 - 1) * 32) / (128 * r)');if(c<1||c>(d-1)*32)throw new Error('"dkLen" expected integer 1..(2^32 - 1) * 32');if(u*(o+i)>a)throw new Error('"maxmem" limit was hit, expected 128*r*(N+p) <= "maxmem"='+a);let p=Tn(at,e,t,{c:1,dkLen:u*i}),m=te(p),E=te(new Uint8Array(u*o)),S=te(new Uint8Array(u)),R=()=>{};if(l){let O=2*o*i,W=Math.max(Math.floor(O/1e4),1),H=0;R=()=>{H++,l&&(!(H%W)||H===O)&&l(H/O)}}return{N:o,r:s,p:i,dkLen:c,blockSize32:y,V:E,B32:m,B:p,tmp:S,blockMixCb:R,asyncTick:f}}function Ps(e,t,n,r,o){let s=Tn(at,e,n,{c:1,dkLen:t});return ft(n,r,o),s}function _n(e,t,n){let{N:r,r:o,p:s,dkLen:i,blockSize32:c,V:f,B32:a,B:l,tmp:u,blockMixCb:y}=js(e,t,n);Se(a);for(let d=0;d>>0;for(let E=0;E/dev/null +npm install --silent "nostr-tools@$VERSION" esbuild@0.25 +cat > entry.js <<'JS' +export { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent } from "nostr-tools/pure"; +export * as nip19 from "nostr-tools/nip19"; +export * as nip04 from "nostr-tools/nip04"; +export * as nip44 from "nostr-tools/nip44"; +export * as nip49 from "nostr-tools/nip49"; +export { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +JS +mkdir -p "$ROOT/public/vendor" "$ROOT/extension/vendor" +npx esbuild entry.js --bundle --format=esm --minify --legal-comments=inline \ + --banner:js="// nostr-tools@$VERSION (MIT) — built by scripts/vendor-nostr-tools.sh" \ + --outfile="$ROOT/public/vendor/nostr-tools.js" +cp "$ROOT/public/vendor/nostr-tools.js" "$ROOT/extension/vendor/" +rm -rf "$TMP" diff --git a/test/bunker.test.mjs b/test/bunker.test.mjs new file mode 100644 index 0000000..1bb332f --- /dev/null +++ b/test/bunker.test.mjs @@ -0,0 +1,90 @@ +// Self-check for the NIP-46 signer: node test/bunker.test.mjs +// Plays a client app against createBunker() through an in-memory "relay". +import assert from "node:assert/strict"; +import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, nip44 } from "../public/vendor/nostr-tools.js"; + +const memStore = () => { const m = new Map(); return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }; }; +globalThis.localStorage = memStore(); +globalThis.sessionStorage = memStore(); +globalThis.addEventListener = () => {}; +globalThis.document = { addEventListener() {} }; + +const { makeSigner } = await import("../public/goostr.js"); +const { createBunker } = await import("../public/bunker.js"); + +// In-memory pool: subscribe() captures the handler, publish() collects replies. +const published = []; +let deliver = null; +const pool = { + subscribe: (_f, onEvent) => { deliver = onEvent; return () => { deliver = null; }; }, + publish: (ev) => published.push(ev), + addRelay() {}, +}; + +const userSk = generateSecretKey(); +const signer = makeSigner(userSk); +const bunker = createBunker({ signer, pool, relays: ["wss://relay.example"] }); +bunker.start(); + +function client() { + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const ck = nip44.getConversationKey(sk, signer.pubkey); + return { + pk, + async call(method, params) { + const id = Math.random().toString(36).slice(2); + const req = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", signer.pubkey]], + content: nip44.encrypt(JSON.stringify({ id, method, params }), ck) }, sk); + const before = published.length; + await deliver(req); + await new Promise((r) => setTimeout(r, 20)); + const res = published.slice(before).find((e) => e.tags.some((t) => t[0] === "p" && t[1] === pk)); + assert.ok(res, `no reply to ${method}`); + assert.ok(verifyEvent(res)); + assert.equal(res.pubkey, signer.pubkey); + const body = JSON.parse(nip44.decrypt(res.content, ck)); + assert.equal(body.id, id); + return body; + }, + }; +} + +// bunker:// pairing +const url = new URL(bunker.newBunkerUrl().replace("bunker://", "https://")); +assert.equal(url.hostname, signer.pubkey); +const secret = url.searchParams.get("secret"); +const app = client(); + +assert.match((await app.call("get_public_key", [])).error, /unauthorized/, "unpaired app must be refused"); +assert.match((await app.call("connect", [signer.pubkey, "wrong"])).error, /unauthorized/); +assert.equal((await app.call("connect", [signer.pubkey, secret])).result, "ack"); +assert.equal((await app.call("get_public_key", [])).result, signer.pubkey); +assert.equal((await app.call("ping", [])).result, "pong"); + +const signed = JSON.parse((await app.call("sign_event", [JSON.stringify({ kind: 1, content: "hi", tags: [], created_at: 1 })])).result); +assert.ok(verifyEvent(signed)); +assert.equal(signed.pubkey, signer.pubkey); + +const other = client(); +const ct = (await app.call("nip44_encrypt", [other.pk, "secret msg"])).result; +assert.equal((await app.call("nip44_decrypt", [other.pk, ct])).result, "secret msg"); + +// the secret is single-use +const thief = client(); +assert.match((await thief.call("connect", [signer.pubkey, secret])).error, /unauthorized/); + +// revoke +bunker.revoke(app.pk); +assert.match((await app.call("get_public_key", [])).error, /unauthorized/); + +// nostrconnect:// pairing: we must answer with the app's secret +const ncSk = generateSecretKey(); +const ncPk = getPublicKey(ncSk); +const before = published.length; +await bunker.acceptNostrConnect(`nostrconnect://${ncPk}?relay=wss%3A%2F%2Frelay.example&secret=s3cr3t&name=Test%20App`); +const ack = published.slice(before)[0]; +assert.equal(JSON.parse(nip44.decrypt(ack.content, nip44.getConversationKey(ncSk, signer.pubkey))).result, "s3cr3t"); +assert.equal(bunker.clients[ncPk].name, "Test App"); + +console.log("bunker: all checks passed");