From fcfaec675a7e8474f3e460c792959eb2ca9b62ba Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Tue, 29 Sep 2026 08:33:02 -0600 Subject: [PATCH] Add NIP-46 signer, optional NIP-07 extension, passkey protection, backups - NIP-46 remote signer in the page (bunker:// and nostrconnect:// pairing) - Chrome extension (NIP-07) with per-site permission prompts, unlocked by the site via externally_connectable; downloadable from the page - Passkey (WebAuthn PRF) encryption of the Drive key file - NIP-49 backups: download or save to Drive; reveal nsec - Fix: oldest key file wins when two devices sign up at once - Fix: never overwrite an existing kind-0 because a relay was slow - Fix: EOSE tracking; relays reconnect with backoff; verify event signatures - One-click re-sign-in (consent only the first time) - Vendored, pinned nostr-tools; CSP; no third-party scripts but Google's Co-Authored-By: Claude Opus 5.5 --- README.md | 123 +++++++--- extension/background.js | 171 ++++++++++++++ extension/config.js | 6 + extension/content.js | 12 + extension/ext.css | 18 ++ extension/manifest.json | 27 +++ extension/popup.html | 28 +++ extension/popup.js | 30 +++ extension/prompt.html | 24 ++ extension/prompt.js | 46 ++++ extension/provider.js | 39 ++++ extension/vendor/nostr-tools.js | 2 + package.json | 22 +- public/app.js | 386 +++++++++++++++++++++++++++----- public/bunker.js | 193 ++++++++++++++++ public/config.js | 7 + public/drive.js | 29 ++- public/goostr-extension.zip | Bin 0 -> 36360 bytes public/goostr.js | 301 +++++++++++++++++++------ public/index.html | 143 ++++++++++-- public/passkey.js | 92 ++++++++ public/relay.js | 130 ++++++----- public/style.css | 128 +++++++---- public/vendor/nostr-tools.js | 2 + scripts/build-extension.sh | 10 + scripts/vendor-nostr-tools.sh | 24 ++ test/bunker.test.mjs | 90 ++++++++ 27 files changed, 1800 insertions(+), 283 deletions(-) create mode 100644 extension/background.js create mode 100644 extension/config.js create mode 100644 extension/content.js create mode 100644 extension/ext.css create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 extension/prompt.html create mode 100644 extension/prompt.js create mode 100644 extension/provider.js create mode 100644 extension/vendor/nostr-tools.js create mode 100644 public/bunker.js create mode 100644 public/goostr-extension.zip create mode 100644 public/passkey.js create mode 100644 public/vendor/nostr-tools.js create mode 100755 scripts/build-extension.sh create mode 100755 scripts/vendor-nostr-tools.sh create mode 100644 test/bunker.test.mjs 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 0000000000000000000000000000000000000000..2ccc4f1dca99cc5d1dcb0dc91e8d252224f0996c GIT binary patch literal 36360 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�MhNS!+u}0k^;p3Eb>944G=< z7JYj@KGXO1wdcEFwq;xN+@s|QcJ_oRV&sEy49!u7u5USkwY`=^F# zFzP%)me+wiF~U*3-~Cl5G&~oc{j0W+{&Zv{Kfu9dE$g|c(n=*tSvt8!z?&PH_L~)1 z!Trw&D}A47f8ymED03)WO7TeoZVF5b&sShER)hB_77su}Aw!{<2PUZzhn9?9J$0I}Fcs>YPB zcPv~t4Ut0VYw76%r1E^k75xyT+)-tZS%nZz-0uPiW(U?HhcwNO{ZA?pm0*PJgz}&q zvV5)^s!=)qp5GPPI>R%`Yx3IMGl#2q46=5e%r!NUAfZxIy+XGEn{^$Bk1#tqc>XBhA%J2}5b~7_=Kq|KNjpuNc?F9LGGsX>>=D$Vp~L zi=3A7w&s}~DgkB=Km#Y!k`44LsP!8{vX7aeX6Bh+M4Gyr4~5m>HkT~LdGO(4Ra7Nk zgD(Q{FUm#}g2xc3sDmm#k!dG}g!-t!fjkOyp1o6vrN%2QY2`mQ9C%bGmg$W%^imVa zl?Fevtj?7aBxqxn>8%@ZxeXWE5qhiJ1v77x&I(f7IY@UA(q~x}MsD6)W^>&@&a z0wPC!X`BH}CU~Xkrf#}*7fetWJjpfXx)IaX7V`z#U24J^dz9U+P?h)u{@T;(oQ-rF zF{s%LTY*V?DKJc;@=icc8X9oxmS9X?9l=6oVa0qgQ0o3{E1_IGE6=DjrH^67KtRKg zXT_L>#dyI$RbwULlR-0c5e)muH4TQ;W5lHCv<%UVey~w)j^csd&*D5mJ{Yv|hAHhq zw1phfj(S-DbDg3|_I*92g4Ua}zM@7hnvardzP8AMdx|Ay6(+)lr(t1R#8>a+$W zTLZp|$CtdnKZF_Ka6mWlAX$HZn{7Cu!_cf#Z!dELeclA#Hf)tclSLb|WPn_#CwjK7 zbep{(GX@Drc@J|4mUt}K!MjL>*hL-Lc`gQHs91A`vh|3R1P)(5`d!_>;s}1Oh3g6=2ir1X8ia)wzQ&a zPnu{8#DTGv+IT8s(BD1Tx|?A_kyi1i)miwZQ$FO1wQ8y#Qt%S{NpPs+Vk-58%p0Hz zqBdGG2tQ}75C!3uato^g4l}B9Vv=g_(J4&x4H~J*!-Q$FFDS^h8dt3&xo)9WCzbs0 zkvATVP6<1${8PKE1}Y0N&&HFTexjU{j$ni)cO`fjbR(|)6vN3WOzV|ah)oA~j$ySl z{fdf&VQ&N*gTb7z{(~(OD2wUVcWclZexNSyTCNB$2DLYOE@F6>t#tX4#8QAem|Vk= zdgjCjbULp$-C@=YEm;;(#2`6yDmvZ9@J=Z^@x#$~$@0z>;Yk)!M^I7n-2)@I?aWK- z%@aDL^1)|#zs*R(ImhX&Eo-@mFpPSs(J(&Q9g7mq5^@OdZ)B$aQz^0wRk!KoyCnM(^NTCE}vr`W1!+!C&{-<6|C2c2W6?O zW#BR%LwV&K;=*1$O0Ovl<_j|L5JVXn^eiK18|vm5+2di!o;7Z;7M~bs=T$JQyD=4_ z3XhZtM*f@zYLBj;?m@1|?_)_`kSMCmnaR5&=U4g!-C7k}P6a~Y+t?W9uM_zs&=t_VOZukkYW_8IY>L*h# zBGBKd`b(9VC41O0mS;Xo_yN&M?Cvj<&=#(jC($AD7gvYFn_$t9%@?58`xSf&<@&o3 zRWQ8ivY*;lr!M?;QTyaPuTseu#Pyve_g^P4jOj?Q<8_c=KD5TsFmW+(D6tPPZ34A4 zL*H5xgwq(q?l5x=l#1XyVd?e^?GrV_6fhR`sZj3uuaSZD9vBq_z&c9(y?MWfP{Jwd z*wa*&et^Af9D!DSDiOx{LEnUy`?dQhiN@vo97y5j#X<0LN=<>3I$NOkm>h*65Dc_` zgou{)GK676?oz<~AzTZ(`t_k&x7cu+-G+8r#V>%w9jlqK#m(DMom8>ddcJ#O)I$gy zgsmDWQqgeo0GK-A;2oo?V4hyA_L}?RpCU2}Bwg4g>F@A!KtuCzfT>sbLfk_~%tbWP zC_iOGE@i0jWUIahJJo*kgc}9}GlcYmKI|3G`UHI3R90zq>qTOzxO26f@%vCVKowwk zwBz8|QBw*VMa+m@53^nwwB=Fz+A2N}A#V-rC?YqEjWKJA%G_!{ZWR+ zq)}GsEuv^y9<^$a8x=TTlm>fmp0F7d?P(mo-web$98j6G=P4RiT&h&C>Vv&c5L?NK z>rxZ86TH{c_AAb<4^D%|2uWd1Mp?nqx<()w6#!wCUS^s@`=Rv$FTe+eUYLQNDSD1K z$37Y_{+zRn)2SpdQ6=?B)JXm0FZoOAa{vq1OnMjcJTTX^lp>t+N3O_YuHj?uK@Jr9 zR(KO2eOCyPFzdVP0ZZBT0W2Lx=#vZy+xMCNhf$gn&O&hRB`KchDQSpWyFr$}1PiSO z*Cb}6KI;42ib(T=y@2pf#-(*qwE1vz4?k@ULDWJTCpM_W(~=c(hoX>uNb~!Y^BMeC!pd_a-~_Ezb(CcT zOlhrq)Ion!fB<1=PITGOi-?ncMxK9mLN1ku9C^7#d=7O}C@&;;@xa}T(emoeS=Qg3 z#*;+8$2CYD$c1BY#^};2@+ng0iJ>zt36(fk%)7-#cf3t!n_P1RJV;>;?{3}i*OE68 z4hQ@_dwDX|Jz9P4UHB8!oC;6zD6!;+-5K*47VhA6kl0bo&IR1Hu~)3w0b~y5yDA1s z7f3e`m>O4@{%z82>AvJ0CxjKMDv5Z56B7$p#{=6t^ERZsPaf=P=s*-zn3pKlYI%c2 ztVuH2km~6y=b%&+@AGIhaN_bqm6|u5=d#pc6-;fB3(zA8C z#ecA~z?&0NRTz$f^(0;}t0>Iw`>oqRv57J?N~L*l^g)Kqzji= z7d9pL#aURfks$weiF|)gP_NtR6xU`w=TK!7Lu@e?d25U zyqtZ9yn>pJICYl;iCBV%9bx|CLMWY6g?1*2gaI`$NN9&Va$(*eHi)kN@KUfV*H)@& zIlWVIu}1N(K_kKy=-Q_a-%AZA%3R8_ItBd?yeY?-uF0$$4uEA&GjO3Ijt7+Qt@FrJ z>v%aXo7iW#x5e{j4c@;O*3}U@%=-Ld0q}PXZ~*40FhHy4$Wvi?3eWCC3Uf9cNUE#u za7A9x6XptbQ1SL3VV8UY#s3)<|GgwUa>6lil&H-%($WQKxm$>JMV*OM^Ey+is)pSu zJvtO!!c3H;AA8yw$d&HGm_p|9<_>Qj)Xg#6;J`=4See{mo2iS1QnQHIgGOxK2KZ;^ zF)~L#ZBa+P^tf1f-_c;0rl?A?1*O-8H${u1I>{xQan)e-Ko4wNLQhXOB$j zS1uW)i&w`mJu;Qr4lJU`J>AUlb7pbR5rrobn9*B`PdciJC`HlZ3GNc_iM%n*&c-w| z*9lEKe{?=|K9@F@#57d)_z(KttxPM3rMUlu_p=JL*w7!|`;3piRiMk#r!Qqywi-)# z(?!}k*Uznbu&H*_q$JYm)6-l^#&n63O1F1Es1=C>WTUfjx2&wZRzEP3r8ozS&!uiG z2j<3_pxAUhtzPfE!y9?#MXmtyfm|MYp3>ACOLb!x!`-?cf$-Y%QwSGTblsYa*2R=T ze!+3=+Mw|Z#8X4v8_TnJhq@o@F`Vhru0bKWwW(+@`+kF&RbvFIu#FbSH?7~)N~H=| zgoi&H*I@jrUXJvjp5_sC`G z^b2SIFOhPL>=!;8z@xj=sV~=c7J(VMmyrvp)aNAEzvm-N!6IG#hLQdqoWrK5h zgKsX$WnmVlc=KRvL`p`=D>+ zn-h5>vA!%2{Xjl=f)o;K>qY?3MEZaLG;R;r?6slTxxB$QukAjsv5%hIBlfv8Z#I`m zRl1Sv0F#Crar)w)^XckRi;GPx-7%lxK|JhI$=Jm;}EzlglW2@Y9Q2Jlh z4bHzKUWs$$oO~_bosiF}8>t_y4@v+vwMdNdsNRl)m8Qaq8)4Zu$l0>WE*hbGu8 z(K#ZVYOGRjv({$>l4`8vJH2L`h~k4;rcVVsh(&);oj%))^4VTm(d@G=AD>OLlY*d| zdLnY1uXeLfvKfwZqHjulL{TB$=!N@?016g5Dc+>qo)9y1K;RE{LwNPMAOmzONy3R{ zN?=KNgGXjKe(uOJK0;9mW;GRpo;dVWZlN@pKcIfR4q}{{>>9n?xzaZR$G}LKw$F z6a=aUZ_J_NJF!bqE6NVhntS$gTkE?*<71@pk)rWA()iKPxDG@g3!=vv5aW*6%E8bT zxe?7yDfjlKrm8m4$zB)_qTtk5+ErOB#RdYma=I2B5p$|f%>8h~52uGyKuXLuG{Xs? zJx@|@QA!6Z(B%~I8qf%$1iVB;wf-^UeSu$tc$|!f*-bXk3vM;j&n{#u((L1b&;4)Ob#XniHIQX&< zQT?AZ6-PwL8xi63P%LoEpr0NVH5Y$A54`YC!y7lE5Zo###t-w{SgGM`{b%CRfy)kq zuwnlrIhkDqXqEYKmFg>!v-K)lepX7-)~ms@l@_m}{9VQd@ig4ouYTd#(cKhU3Vq1`D{2lU)77&r@u}^7#>3kS|A!Ebe+B)5! zbo}%rZtL=PbhuFuH#S%a+wWu5bNg5{UPD57h7Do{QCOeI$E1RPMZt(7Qc)F(bF#Uf zNoF%ju1qw~h{LS~BtkkPSqgr-C4ZAdsN!F60Pyrw(I+`Ut2@coR$>l~O9GAUZ;qW( zoL{LbM%L%2aKdD)|LrhDT`a}A6M$I`0dsa+6c%Uk17;S*Xrh(?$RJj3 zP5l!HYtk=w7gSKa3gpo4wA?&d$ufqpHp7JTO>bT0)P(1p67$q^W3@@p1z;(h67q55wy@W52D|bVTUyMftd?$4%lR|Tuj3&zlX+)83H#g zI86sS4$%^x6BMyo4-W-89JuX1+D!4MFtNsHVjUvOl5i|t0ldCB>EyIp-{P&d|Z4C>&VcBcz7sfn2wc!*r$l563d^nglOJ-XzG*~Vify~DrWD1|tP@2kd z;e~FfDPh`nEvXtjk%KGcWi{5Q6;?-_!6QJK%jV26HX|PMz(o2(y^5-CcbGsk?lXN9 zlPxI<&4;pF&7bfKF=x9lXFF0CbKn}{bJ`sk%;G`upcSrJ7C>#hwcgaEvGaT`UWY)Sx72rOSn-&vp;=Z`h zf$FWHa<|`HT|Yyuflk<2CxyqHC?t;`Z!Mvcm`{H$j@+N*G4n6`?MSUW#fw8zR&Scl z<)_8d$|qDQ?+^Qf)AV?BBhh-&v(Ug;nFue3yo?R>l9b;a7hp9SE>Yr;yR~fYhlYm- zOnLg4s!r$GAn~PncafzuWTDE!vYdCM+>(c|!DA;dU>o8Pbpd;`+UOr1^i3wQD-G1O znP$1QBv>IK8z_%ch2|}o{VpyUWb-m}*|sQ2 zTv62pb%6au|N36W-quO4HU@+?o_m(FKj$}kAa2|w>?iUgH3+7+z!$M$8GDT;bm3hB zBL86feIbFbQiF_;C=df#WpX-cncoZ2@`uDgBE=3tVa7daH8P0#PPO`!_mV4%2zJO2I3t-7~QSep)VZjd#=l03HC4qiBD!{Iw z1YXOz-8vIjx!)H)>A^7j16+ZqYt<##X3~fMNT=y};ghJ~qk92Am-HsJJYzPVNIpaw z&iP~2N6Ylw3X_!id@BmQQkan9$}IAYk&P$o`uE$7Fj+qr{O8T$XV-y?rDD*rWV__p z)`Nsm_*R^*9fBNw61Z=)u(z|eb_1nYu>V5(kSsUw$gYFWZdvF|k5HL<60q^M7)R$i zP}ji4d3=>!+rZMD=f*kpP!N_r@KeSenekxeD#Ft&Fen(8jbH@`RYPczdOqb!irpE} zp+!lsl2u(q#|N5@Zy1Zp~04{b6Y}!nu27tf-Dq@|)Y?F7@MJ zZSB06ZWfkO8c6=W3jF=;%sq!4Zyy=nCrPJoQ7x}&>iN2`bLfOsOjsafx%qt*o8sAg zI!{tj@0(PQt(2V%u>)PMKCa%lnm8z{qt!(#d%@#BtBaU@3 zURkSFDs$Y#rGuDrd6^blDK+c9ZI``}HjB$~E|GGtJGb)k=f= z^-{USn@zh}@71b}p4TdQogS}tD|zdR8HJb2)vi}7^%`E2+r3H;+m3_axBP0g+w*bk z{!UY_u4e2cG#ueCckkzK(X{mPBL zidQ5RtbLQNNUpTbi91RPBZfE~>!T-hQIcRz)U%JzQ)oISc#zP?1fLqMa7?gIn=J9zGjk?zyA!3jrnZJLF9bUJp9!Wry0i93oQ zl~iC8E*$x<@y#okvZ!#DFuZvfr^VPy0-QU3$Hbr%=Mu=HFpT;y3#|a#x(jo3dGDx^`cqJvDY}qj^ z9$v`rzCJEhs->shfj7dPljj+IV_qoen)T!_iEZ41Z6LvLS72 zao2NUy&U6V8N(L9kD;m6tEig=QDy~=vhr?jGWQ`=2E%DB@x_?TeMwUi>LDIk>;p|z z7iVa}Dm@Vrus5F`(#o1z;?hMQmeF|T`lc74tQ4k~|hS zHfOV$RS+R^kA}2L&fo<9g6{bNQBRWICQ2rUp?Ow7#}Khz4T5k5QLt2b;B3K zdnFjjGEtE%133E_A=bo}JB$y#!$JVF6+Ejp;a^1C#6Pvh+FJ0e)zBr+w68TmjwkLZ zA*0K~;E?RI-$UA}GRI0fj2PkNRr^=IhlCucz)XwRu%WMLyl>+LhIZpYl7{y)-x1sJ zzaWUfl22i3fO^a?S8xDjy0vio4z^&gA6t(Nr+BUGw3(5`7ng~^f6A5_EGZ5z)<@3* zZSjSYQe0OASukQ5E}wuuLJ43b@569S-Kl!@c~TT8Ky&n8ATQ+f5XlXV<#U1CV!VTG zY#0ES`N`2CI2$V|bwUs$>Mj<5z)nGN=(}$ai35JZ!VuZWig{e1bc+C>g3Is86{4dA zGqIRujTzKi%3}n`R7Ph^*St5u>co|8gqPsZfJ{cjp_hCzaG@=tx&nq}}{y5le866xmOip3(zbrKAi=u}_p{OgXX0FC>J&ObTBjcE7v#e7|CU zIoW%)_rbxf;|=WFygR7ae|UFa)LX&&F=>yp0QSUaFVNo(V-^U=`!p?vv@qdDN(3n|SH0oLObs!0(lJs3NP%s| zNCAmZ!1Tmj*f}=g+|2)($|OZ&HDhpQICCnXJv`8#T@hLyWQrlbrP!53Cd$ZL47?jc zluJL%8!$4O(5c2nLN}Cz%kH4*G;#yh_SKkzml~XCZc|(`o8F@nIp!%|G)yW&T`g&h=&2|LMn!ZvHu`LY zFpIJUmVc)}Aqp%na7P{L1gnc-3B$Qd250QTQ(;)Z@H7;g#~T+ggJgwPCgu$xqK0NJ z!B|u-%AllJ36!Il51dBR79AMpY9rLFq?2vflMs!nkO#5`o}}@EwqUID-e^4VIy}iM zcx-{({0B5i0)FEO%57i5pDSy=IicGwyd>niQ1#+*s)|8iiV+JaZo7+N(!Q#eXiOk~ z*dHc@xO1n`^i?JqKeL$I5jVONIcgk=%lK-B@l^Sv?S#dpdoM>mk9fHwLf{qH)B-+t zm}+SjT74c|2jMMVRZJ3G6@SsL;mk5MBIhu31U@BSW1GIg86wZ{@{b&d1R9T1g)ekN z5lOFzDY(8=H!-IwBAZiC0MAtZO1Plr-L!t<3k-`u7#XA3Ih=e^0>!q-*K)4% z=t`!<(RKc$xn%{y+L)s1rWiC(j9G+gr{Rqz=@akxVrfe*_la*Eb%T~Et``r&$;cNt z61M{o@#Ip*MXjRN5DIS8F2M=qGTJO7culeuZEm6rU4R4&>zu<#`m1^eo-nE3OuB=d z8auH}DlUa*R}Q;`zfnTVXAsj?BfN@?S+% zGx|RG{qK{|l~J?tB7uNL{++M8z^8#tLsAbGS=tO$pvbU`pzW*ovRtf1AcK`y4F4ax zeGUgM*2TZU)n=LX%mMXs6w{p$f&)~0cJtC!g7 zeP{+$$68>?%3g)k8P-d=*_nU`lJE(Fk*T;6O9nX~$mfUV;uHuQB(DTT}Z?axT-3KEkSj$7b!k?TyKC zeli+S02?~yN~U)aD*0r-?#nB(M8kolUd@8SFaKaMC;J#Gu1Q;ahjQRXs!RmiPd{Jo zY<}`Kf0i~|?w8H0jn$`4CT-&>rlI*@TYf)@MtMh-wPondkQP^hVuHA-zcO3MPxX8) z?FWLDD^UbQypb0t&j*v>dfyBD5iSOjy(25%?#JWSg=ggH)TA) zJs;M{Z03L*3u~rsF%Tc~JG;+c?7e)o|N70_gFgZPrawz%yHc&y8_m|nQ#2-rSQtn&AkPRTB@bMv1dIPZ{uxbMcg=p! zPJZ~erMU7fs8z2AU0%PLc#TnBf5>hk_p;ThH|<)fTw?XA-7HsX)fTIjE6ql=YS&r0 zWH%bsX47u5Myb|nwBR2jROm&LO2fR}wrePz^R-H)R;yMkW$W3q+MFWoCNec?g{0k% zBz6#@@_lx1qRZ&Y5M)wkaOKpmV`&;uiOO?0(h&hj-wW|Fqf3=i=CexW;bGV=+x12E z_*%P#p@KM9K&jFK?ijik2s!jGz)Xj2q#D~u= z@C=Oz2Q1_=d;*(GrF3=0DkWA!Vlxg;9}E*WX#+Z>gvoE0odyt7rPZpmU_5~^YZPNz z@+-1HiaKETzQx!SM(l&agiV?6wn~;*`BbE0qGH7I7$p~&?i3xnrmn$xrU>B_&3%<; zQ^7X+?!=Hr`*Ttu>WP+5kVawZE5T#z_BR?EQ=>G0McjHCxBca19;iabGdPKMIrrn- zXm~Tk3wORM_Xb*+ga>fgh@Qi`FJRPQpvxN=5-tA0@%Cm`2Aa_mHMM4|_j|Li=0z$P z#rZK5qc1ih96||^Y>tWltoiUTm0j`M)8$>!Hz9<$EIxOrA4trTnN`ebZ2Cg8SX{lI z5`n8p$aE3woj{fSwKe>MZ_meL^>&dNi))M8cN|Y+_!7enCOFcgks=TtZ} z_i#mbjTP+`CfeN%^G{`df94{Y0!x`IP-l>y2NB*GG;h5DZKp2D@D$>@pJipUp%R(8 zQkSd4t2!h3N}2XN`7ErQPLGfuFQ^Go`dhw@SrUqOp%8|f+O$b+#0X2 zH=+CV^3OZFy!q!{WAEy|%$k*1`Ld*B8c&ElB=s0V&((dM&7P*D`qJ3Y=KL0_hBm5n zMY2eB&92p0a#^Owa$P>!^w^Y76?hUqjo^*whm)1F8Yw>Yt2w*bac^TcO=c? zKD6gQ{10YsMR)$!bypVa{_iH6O4V}>6OUdYk&{lSo_k{w9T7M?7Y>^!hMp?Cz?gN0oJ%dsv86!{TtY{@Ih5QB4n z`}MEqp^cSCeq zXsdx136&Dh;k{W}ttvREEk`w5(jqG`cd0S0o z+wPY4`s;zBIBRVLn#H?_Op3ZhvJ(0r2gCw^BtUf#Go zGA=cba`AZiNtLTQX z%U(eFVr%FCLm&wg^KE-0|1we0xRGdz)oiI6i{YwYb6dI6UM5c*KYJ6W&mK{zLLoYa zgx;nr#I6%D&$GuQRqdS%rfp|Nti}hSV;pxJ=W8)6q8%7NRn-bHObBByIgGI28@R^P zEW`a$c(3%2cRKDONnAvEzpT5qI$o_XtP*mk(?-j^UnF1ak>%psyW{pjv7hk9CTU%34gj#PlcyBJa*1m)t$u*c=!S6B&eh6O0RStnx)gPy*)Rp=T>UE{dX>l zO07GR?#4G@ZI7lgg+C0f5z}Z6GEi1RXcaYutl$0O(&^L68>&?IZhTjO+udxA>V;J^ zauh80oJ9R{p}OevpOqV%<92S`km|<{QiLk6S!YJBaVA}H{J8Vdi5Q1NRLZOQ_tKgG zD}rt0I8c)@_$TyG53c&dc*{-oFQ79_%97TuQ94YBy`LN4# zGd}lR*HmKn5vBmZWj!5^UffsgI z_wPU2HBL;W89T9Y0=@8*^?Bj+4lV%nxMlyKR&7I6>tW8GG!`3-?&gM81)9f7tqO^N z0Rzr>3k-D_&y@6b%1YDAF2V)P zR?u2UxGEvigkS1ylE6=T_~q!j+^zK4Cb(KM!D3uq5@pz_kv}yH+eLItgROPU?aIx~ z&DM`6h4WYHc!B>>>Xluue`V#fvBuz!jmsskan-lB8E*S0Giy6r7G!62`KdY5Ycm43 z*l(6MhkJWJyvIq9uUo$r3{hDprZhm8Z8*EVX=;vFJ-}YaaYl4T=JV;-S9x^`afibN zKS(Ahb=aX@fIjYr2#}sw%RM%^BhJm8+eAuNymXlGj~(VO)qRt0!F4}|ky@$BQu>Fi z*Pj+LIBq)!aB>^M(?1kJN3;VsUZ=tDmidZC1>XkpZzjw?yc2Zpv;*NpgCWwiEjye;ysR$b)19H1R*o$u3& z{ghUYmCSp+V^Q|e@IW^5^ZewbgMSD3cZh#S=XdXVP{fyS30pM*(STBi${*>tk4x!Y)xmXfb#jct~HSkI~)>u6pg zp5KV(#Rin5w^M#QHM6yl5o$K4VbWg=WZI7g=Q#~P#CXi`Sm1Gt#}Pd;V(E>&iiL25 zyC>);=#ItfRyP%|{cbK^2i;7(Zg&fM=XVo&Zg*p4nVYAQ=Hik#usmVHZ+mJ6>FYRL z@!aw2fYA6yjk#&cOnh3Z#W!>j~wBMm* z$ZM3-KnD%%clg85QX{BdEaV)TnW_5oLcEkke*xAsIH#}UMB5Gr=P^GhyW-${%uhjQ zVDaIc87PehFdvbw4x*(*s(XhA!EtMqR}Gi_j2~1+abj$l1Lx$1)1uQ}iDIi%)f~_l zn%f?(GL=+W_*|T6O5N5ff~|kFn3^#*ptK70#|ZeF+Ei{+nMs8T(dKKo)7cL!{wLJT zq!)z~gZ;U#bWW&Ba7%^mjF_!uC>)k>+o)&oYlsOFm{qcBjOO&u-ML z>%xT4qJp}py|yrywbvHnu=d)*Gpw&@Z&~x7r+G(aYiIe`x09EoU0v*>X4X?g)j=u>Fmne z?T0PeI+}$$b3*DtcV{%4mrHj7Hlsm93Qe)f!AqU}({tj1-)vQ#mZ;FAmgJFp9mIaV z-R%<+C4s}rNt2cTBQR!v<#>XxnP%2DW+~wsUluR;mUw8gECx*%GrY~itVhI@Q8#IM zfp}lrI9#Xl_pq-e6*VEKQQUDG) zU67ENbt9&Y>5Zfh0bg%|&p)May&toPv>^IiDiLV`DqVQSDe^I;#Y7HaQk~O}KOlBB z><0s4PQ!kCK&(jbp3a(`o*WyTc$0HE?w>VLO{Gv%rBGR=s6Ked1iWJo-Z2C3IK7dy zP{z2@UChKOkv4|Bjso~inW~h(j#c~NNEcO>7GBYuznTXU(j4=~q&m3-fj0;8+u!F+N ztDbmY@(Dy;r9c&guO?;zX!^F4$MlXS`*9-yBcB2d6Z-KnrBfhbMnCQ9oX{!F=_#hu zy2L8Xp2?wjMZ)Ggf9;L%)qvi8c zyAyuQ-!q{y2P!k5vH&U*rczoOFsVvw<#PaHtEjhZYv;jy5)ov5~`Tj^>A)-ts zp|!AuSAKXEgx6QKJKotiM)UFzNp%bN^_Awm{G^RAYwL;$PH9`0)=;o+gQum32u!yO zIe?Q_zI=h>`uZv0r?r}@oafyv=0jW~r38&H7G^gUs&}|g%iLSOMG|=U>-~q7?GX`P zU9%&Guh;Ii4w&;Nj(*iuZsJBYl_-TNT1Q%@!Z8|)rM*f28+Ouf_Z7p}LoW4Y*ww*t zT4}3jJ?R~YU|E>+mc5ZnpQOYee6LHRBv}z}wHT+))|R*>tE|=>;z7R3w|fXJ@NRe3 zxHszlru-A0@TEWQb+RPL|JK-T1++UslyLcLj>G$jT-Ue9B_vNMpFS3qpA-oS*J@d} zmY^6n)7`i3TWiy=?pXV3c4BPF&Vicf=3eqq-ug0i579jTXCvoJ72sm0?q@)Q@M-_I z_=tgz1jD>vR!)p+fW})A1G-K*!i}{$MV7BTFqcg zR(Jm?vYPu(mf@1MhkS~;;7HE6>I^xLbMZ0Y)HApUq0!Gd2yerVQr%fW3*<@Zo?2@m zG;1M06E&*Uf5$)EhrMP8g2Y)ZryW!|ud$PSLwNcHiL=5Hfqtnw1mJi!bo5l)S@S=o z-?k88G|xx!up{0Vl4P4(xeRXKrZ#->ESHW%gn$4J@Thq8NvT_6ysxekU+nGStkiQYCe7sJPKVFX{n+`6+5jNzu~`=P zl=~Ad&~XKBaTcET-WMcyH@!1CYo3Z>|7;+a^zVGyKOKaVCV_?UQ6}kvBq&{VMpaM4 zS#KWBsOG7hG->Zbe$ zV@K~!GxN-My^#Ew)8nv!4Ei*0the@qzgGRYUY+uF{h)QDk7s?~MMH6xk1F%)PF2!y zf_Ko(i+8+qK43P&=2rpEAtEA+1x=PbA>l1vbDelXOPhJ4E%fv*vhUyqtVv`kBS(o+ z-8n$=?sVhmh=*pT8{P%zBkiykxIt#|&!`kIjjeyV>BGm>w4XzRIJz!+iBmy)b60Q0i}xv3GUqkDyiuEIV(c6v?~Fi? zzqZp+^H+y^>&w{Vn{DHG+2QbzGPI7MRBjy^@Dv2lKS`3pna@5 z_e!Z5^jE|@9OHYXz8NSS)UJC7#fIaGUe=eIeo1?EHik5+G|Yv(oSdd+j|F~6ya8?n z){PJk+jmVcA?6##Sign9wAJXw1r;Z`gaLRA-MZ=s7AknB~$HeNw?mXQ)_V4;azo313BZVEQ zy~=yNwpYsxVA@A!YnAnShu+>1&X?;<+SBtrB1fyD*SlM<*r7aFxjL(acIknq&f}@P zgMm!YCMvYd%R{QbZ#%2F*9+>c9Z_bCOn)`*^$sYrb!eMArpz&nYpaN+yE?Nc+gG$=)I^7&IY0=MR_9oJxx}J`^+<)e zl?k*WWBTz2g!crRktzMOVSr2sAtYHOGyoAOj=Jaz6C00V66{o5bX|n~( zi%thPS*+7E`!7wJ=Krlp69qgqY0$uD`#`lH*$1lrUKZ5RX5*9*<%z%P*{A2msUm_i zO9Y;HL3si4&J+#2Vj56+SCrRA-YX`7mzD%PaZOnrWW8hxc&jKtD ze3YM>?{CM=zi&4VZqK~t3;fQ#<`sS?Uh^8iLt^dnBjZAR+>=aAADi+5c}uT(iQk3S ze1PAn*SwG4$ZI~wFKtafHPI~r3~5P_76fTZkRpOKCP)ue;fEU1LqLiZ zl$nMy*H9)J%8;SdD>9Tn8Oo5LED6ejpiBu$L{R=zh5yu0{sfdUL5UTFnT9ae5GD+v zUWp;RV+a$1FeC^|g0LV6Q-bhL6@I57yaR-YAdCq@tXiMx*5}-My#lvBD!KVNH9w)| zht&L%nqN@!qf!+gl^VdP1cWI;hzP=%AjGQm8MnS(p4@Hv)`Nz82tt@|FOnbW?Uy$bp%qn4jla``}P4BXxRcS zU4T#5oBF=Z_N(lGvsskj#?Vr3Tgq)qPWSw(dpUK5d)CajUTo{xE@Iu@M=~&)&+Ody z_T2_s^#=E35f<{litQyz19Goep|o;#Y~cS2Bct_+mb}A*gSNg~prJ?JUSYJ9-Kw$! z&JK`$xWZ)vz1~rk+veOha_>@ZhjKerZijO_JmP?|50nXm$c28LatB=Q5am$p2-$v> zeaP8I176!oA}?O}Ozsiq9uIg;ED4poVsfr=Ysaj?P@@o$=*;?b9W&|@v=yUtJXp27 zrjpSPJ^D--tEO%WCA*{kA1i$=cA~IPK+G*`TJcmEk=)n>IJU@|om-jWv(Ox^vWk*? zUrBBmRoky_8O_zXnlSH4dL0%`Hrb<~73Zk1)oi57DBvj5MbR`Wb85RvHDx0BpLqdb zI1cJIo{AREPF7`F)u~)IFIaUoRLv-KDcq>4`V^0mNxd8A9FvRFE8KLck}7zt-C(rp zD896~{X*S=E1ogFWC~GfKZB{$4>R~jX6$B{4d9iuF)kX50jBv^XlBUFRzm572q)oh zu0YFqKLH_vTHNR3%75M3Txu$OyGJvO@xNO;)~2?R{eZrquN*E5RXa%qFxmUgIXXb4m?Z(Sb*%UWKwPGyNg47B&IC-Vn%~XFf6;db2 zu)z!!p^-)XoCVk;Av@R;p^J=3VrD;W2L6ine}>FP1b;l_Ir_+Kgz#q-c@Esvz;oak z20RCzRlsxLp#(e!o<6{H;1L5n2OcHBbKsc)>;?xmgG9SW!l3;;Ao1=#tor*P0xlv3 z<{<=zVF--D6c~URQ2ITf=m$W-kAYGj0Y$zCq<05y&J(yskJwpou2;tkSRF66@=YN7 z{Q7>d@7};6auB?84!ZmFHgg$Vx(ASU18FziOJZQl`C8R%>&WfgyN7gxzjMBK@2R${ z;EHbbLr6R9UQuo3e64D>b!3-z-D{!)-Z{73U8-#-*df~B8q%)2J4~=u#eA)5wsqvU zF5EjvI)kLM?giDh9c;U2kah=YcinBOt(>n_&9*M^aj0|c>%N`oA?#?p+4)-4Y!YwR z8oT$pZwGn^+Zu0nzE(Ax#M_<5?yjEG?|KNA8gF*KRyCW%+l|KVg`U$}J@r=_Z+5;` zHQTxh0J{RD#VH;`iMtQKOCWOZ4G_D#1_<2?0}QDd2}}vQ5||NkB+#R=Nnqe!8er^R z8DQk@7+}xcHb95)DuEM1iv*5nR1!GH$u#d`M_LG?UMlE_$i)$nh$A8rM?@@+h)^66 zLvch*#St+RM?_B?5d(2VjKvW#634@yI37CUc)&BmQs_AcB%g2j@DFZt9&k5?K967~ zMqN&mI0Jl*AT5TpxI3dsEaz)gv#sO1SMG+8v-ct-xI;+lKvJihP;CR4u@0mSA#KSIrRbkxTQUC?L!V3a!l&e%g)q*N3+HCaU{^#})X{&S^UC3)CUKCe2G_#RfwxDjdJ zv_tfVzR^?Aw)0XG8mPP!b5t=i6uRfyPg4tOXWC zMIlOuQ`8|~Nl8(&plZ3KIO!XQndbra+vTL+tNvBDJjdrmfNZ` zF~|h|^Gx?)T_;gVyLhcx)Gqd!w(KQU7no2v1?NrOMLHpW6!J1|LO zuC3Thn8+Js&pt3AKN+b8N3SlTAy50lZAMjPBC;@8lBMJ%OSvW8&!OwFSnbpyDYZf8 zLd{>L`s+5W+YDW(wp_?KzAzX1Vyusgb}s4sjLzo+-7dTAGZT z&81tvX1ffTaJ#LKR0`MM`kJ=Oh^|8T{{r2*rKKpBO#ViD%>H%NXBgM`Y7|E0g3TsY zZb@A`e--Zt{qI!0`Wk07Wk)>H<--AuUm9EbjfP|SQlZZwANWpx$*b30k1P(i z(7gA}s^6Y0>&Jw&P>!-Mr&z!Y-KMl=$ZdhPVHTsMx~OMP_mUp5ZXaU@bg@Ds8*O zgSAIejsXO~xYR?uWi!PYcLk}D(IjLr2|s@1M%5*QHD@61cE}OIPqZzOb(0&_OiTZR ztIOYV-}r>rPM|{i4Y5?KN56y~!uI!&VMwzu+#?CC=Um=r%J)~|p=I+Q;F3Tjae8HI$^C?5e4tLe;; zkk2Edk_iTOB?n^Y^--NEuY(1zrufrSnX*5XHQcGJ)l~{xDFpPbhP&8CX zO~+C?RE);h+LY0;%pj^f9MRgNJV`rE(X=|K^j$CSyt!@qrjz`Wdy@0Lzw^tz`77sq z)R<^=$gqmWbn-l^d9i4I%Hdlc(SH_TI6bjE>>>pK_}q z(j7Q|D@lIXqym4VKDK>D1saNR?rg-?Lv$PtawhQWE@6&)FNjsaDk9dsO7_fj4Qr8B zX(uwX-z1Ot&(ND|YOO%b-1Ai6|^GLmutP-w33Xh0x?X~mS zI-oN_`=NsJ!{P4&33VQJo=I18=eiMmW&_=VUClw{r4NM#(KnX}-BOw|FhaL- z%~(V#U1#~iE3UIH!e3HYR&Z1a%NahW+g*-KI+sy^U5$tHQ{iJy`TuBVPMP=0u9p&4 zyC<~@yA#U2)nd>+B$BC_C5Qa$ew0`877TUGx(5w#Y}p!HY246-Z$XtG)z$OOs=1^=Rf#-3R*N5kMka(EZ`0xTAhTe9lFFs%) z)|14;vSO!EY)T(DU5-s7PD71`J=3S5iDh&NdZ`y5Jx7FH_Du@zH7@_;IXEL&*?az>aj@g;rls>N zX;b}o4-9m3m^gdMDpMj^FMtNM<<}JkQHR2c>RM z8-laj6i*L|yN^EjrwaGPy_Q@DgV-sYu{>#7xo{C3^>)2E*>6QRZ_4~|;^mAMOqK54 zQKKW5*tnQJOvSCJ!MXr^x%kLDVso&nGi%qqEk&zT-17+7w(_~u+St^ff^-XSV>4@a z#oVZX7A>mX=%SUBQNkws$UF17T;qPu>y<${2fjWu?j4h_9H`8zoHbg`;O);>P#wZp zmOgDvE1s*i_O+1=LMl>U`x(l+sSZghhiQAfn)vy!-*MMvox&^9qggJkV!w7Ia!kU< zudtxgrOh(+nrDxVapcds_GFC=)xCBeI?`TTBSLvUREccb?uf0By+ia5xbCyzC0^-; zyU5aI!hK~(66Z;~yd*{RG6(95gVd`w+|Ax`PyC&3IoU(D1T1kq_}yyvZ5y(?qf%#1 zShVK3&~=#`P36TiA-#P)?FIG>n_99;+cws3Q>N7BVIp-6l1Y2E>Ma*O@=7(lk$TIy z#z4W%{VGOguuC)YYfp+>cpurX0}~S$q3omo)o6(+A!gEkG(VD3SZG?Z_vFS*w#saE z0=HXr*u7LYPM4orJihN>{_a_mhRkMF-~IR}p-s0747)pz+HKclEH0e)|H6&GtbfQcWLwnnzHEfYLH6kKY(<|jzsowa! z;tRuMD7ols4Cy3+zu4(?Mnn_^706l!AIVx05IKn8AqC-D?YsiSxd1|RNCW`@w!|S& zA7OA#(Ax)m76#eT>+JJH7DNEOt^v?Ck^k)hupp#5-TH%`fV;;7m>tml1ZfWL?#!7J zmRqt4(8m{LcN9;-oPus4aJE0@^z+4q21P}DMezR*-u2gp&O0jheLqm+0PgF)58`9M zrv8Wvh87^OpC4P8b_b8mMto2PL+?O($65RcAq8(l*wzN8yZ3_AAZZ&2??~H!5*C)G z!C{0nI1F;9KfjEf@Q1uEI2;LFK%g}XK1?K()_X^&$u#O3i zhscBDU@Pmk`Jv;8_U#X!z#r9Q_-M#X@EP literal 0 HcmV?d00001 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");