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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-09-29 08:33:02 -06:00
parent 21093eb800
commit fcfaec675a
27 changed files with 1800 additions and 283 deletions

119
README.md
View File

@ -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. **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 ## How it works
1. User clicks **Sign in with Google**. We request three OAuth scopes: `profile`, `email`, and `drive.appdata`. 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 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.) 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** → download it. The 32 bytes inside are the nostr private key. User is now signed into their existing nostr identity. - **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. - **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. 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. 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).
- 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.
### 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. ### Key protection with a passkey
- 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. *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.
- Every sign-in shows Google's consent screen (`prompt: "consent"`) unless `signIn({ silent: true })` is used.
- **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) ## One-time Google setup (~2 minutes)
1. Open <https://console.cloud.google.com/apis/credentials>. 1. Open <https://console.cloud.google.com/apis/credentials>.
2. Create or select a project. 2. Create or select a project.
3. Enable the **Google Drive API** under "Library." 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**. 5. Back in "Credentials" → **Create Credentials** → **OAuth client ID** → **Web application**.
6. Under **Authorized JavaScript origins** add: 6. Under **Authorized JavaScript origins** add:
- `http://localhost:5174` (for local dev) - `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`. 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`. 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 ## Run it
```bash ```bash
python3 -m http.server -d public 5174 python3 -m http.server -d public 5174
# or
npx serve public -l 5174
``` ```
Then open <http://localhost:5174>. Google requires HTTPS for OAuth in production, but `localhost` is exempt for dev. Then open <http://localhost:5174>. Google requires HTTPS for OAuth in production, but `localhost` is exempt for dev.
## Deploy it ## 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 ## Layout
``` ```
public/ public/
index.html — entry point + setup banner index.html — entry point, CSP
app.js — UI glue app.js — UI glue
goostr.js — library: signIn(), restoreSession(), clearSession() goostr.js — library: signIn(), restoreSession(), clearSession(), makeSigner()
drive.js — Google Drive App Data helpers drive.js — Google Drive helpers
relay.js — minimal nostr WebSocket pool with replay-on-late-connect 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 style.css — dark UI
config.js — CLIENT_ID + relay list (edit this) 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 ## Library use
```js ```js
import { signIn, restoreSession, clearSession } from "./goostr.js"; import { signIn, restoreSession } from "./goostr.js";
// On boot, try restoring first (sessionStorage): // On boot, try restoring first (sessionStorage):
let session = restoreSession(); let session = restoreSession({ clientId: "1234.apps.googleusercontent.com" });
if (!session) { if (!session) {
session = await signIn({ session = await signIn({
clientId: "1234.apps.googleusercontent.com", clientId: "1234.apps.googleusercontent.com",
onStatus: (msg) => console.log(msg), onStatus: (msg) => console.log(msg),
// Only called for passkey-protected keys; resolve after a user click.
onPasskeyNeeded: () => waitForClick(unlockButton),
}); });
} }
const { signer, profile } = session; 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");
``` ```

171
extension/background.js Normal file
View File

@ -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;
});

6
extension/config.js Normal file
View File

@ -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/";

12
extension/content.js Normal file
View File

@ -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);
});

18
extension/ext.css Normal file
View File

@ -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; }

27
extension/manifest.json Normal file
View File

@ -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/*"]
}
}

28
extension/popup.html Normal file
View File

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Goostr signer</title>
<link rel="stylesheet" href="ext.css" />
</head>
<body>
<main>
<h1>Goostr signer</h1>
<div id="locked" class="hidden">
<p class="muted">Locked. Sign in on the Goostr site to hand your key to this extension. It stays unlocked until you quit the browser.</p>
<button id="unlock" class="primary">Unlock with Goostr</button>
</div>
<div id="unlocked" class="hidden">
<div class="box">
<strong id="name"></strong>
<p id="npub" class="mono muted"></p>
</div>
<p class="muted">Sites you've allowed to use your key:</p>
<ul id="sites"></ul>
<p id="no-sites" class="muted">None yet. Sites ask the first time.</p>
<button id="lock" class="danger">Lock (forget key until next unlock)</button>
</div>
</main>
<script type="module" src="popup.js"></script>
</body>
</html>

30
extension/popup.js Normal file
View File

@ -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();

24
extension/prompt.html Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Goostr — permission request</title>
<link rel="stylesheet" href="ext.css" />
</head>
<body>
<main>
<h1>Allow this site?</h1>
<p class="mono" id="origin"></p>
<p id="what"></p>
<pre id="details" class="hidden"></pre>
<div id="locked-box" class="box hidden">
<p class="muted">Goostr is locked. Unlock it first, then choose below.</p>
<button id="unlock" class="primary">Unlock with Goostr</button>
</div>
<button id="once" class="primary">Allow once</button>
<button id="always">Always allow this site</button>
<button id="deny" class="danger">Reject</button>
</main>
<script type="module" src="prompt.js"></script>
</body>
</html>

46
extension/prompt.js Normal file
View File

@ -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();

39
extension/provider.js Normal file
View File

@ -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 }),
},
};
})();

2
extension/vendor/nostr-tools.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,19 +1,25 @@
{ {
"name": "goostr-static", "name": "goostr-static",
"version": "0.1.0", "version": "0.2.0",
"description": "Sign in with Google → derive a nostr identity from a key stored in your Drive App Data. Pure static site, no server.", "description": "Sign in with Google → a nostr identity stored in your Drive App Data. Includes a NIP-46 remote signer and an optional NIP-07 browser extension. Pure static site, no server.",
"type": "module", "type": "module",
"main": "public/goostr.js", "main": "public/goostr.js",
"exports": { "exports": {
".": "./public/goostr.js" ".": "./public/goostr.js"
}, },
"files": ["public/goostr.js", "public/drive.js"], "files": [
"peerDependencies": { "public/goostr.js",
"nostr-tools": "^2.7.0" "public/drive.js",
}, "public/passkey.js",
"public/bunker.js",
"public/vendor/nostr-tools.js"
],
"scripts": { "scripts": {
"serve": "npx --yes serve public -l 5174" "serve": "python3 -m http.server -d public 5174",
"test": "node test/bunker.test.mjs",
"build:extension": "sh scripts/build-extension.sh",
"vendor": "sh scripts/vendor-nostr-tools.sh"
}, },
"keywords": ["nostr", "google", "oauth", "drive-appdata", "static"], "keywords": ["nostr", "google", "oauth", "drive-appdata", "nip-46", "nip-07", "static"],
"license": "MIT" "license": "MIT"
} }

View File

@ -1,25 +1,39 @@
import { CLIENT_ID } from "./config.js"; import { CLIENT_ID, RELAYS, EXTENSION_ID, EXTENSION_STORE_URL } from "./config.js";
import { import { signIn, restoreSession, clearSession, isConfigured } from "./goostr.js";
signIn,
restoreSession,
clearSession,
isConfigured,
} from "./goostr.js";
import { pool } from "./relay.js"; import { pool } from "./relay.js";
import { createBunker } from "./bunker.js";
import { passkeysAvailable } from "./passkey.js";
const $ = (id) => document.getElementById(id); const $ = (id) => 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) { 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 = "") { function setStatus(id, msg, kind = "") {
const el = $(id); const el = $(id);
el.textContent = msg; el.textContent = msg;
el.className = "status" + (kind ? " " + kind : ""); 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 signer = null;
let currentProfile = null; let bunker = null;
let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered
const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent( 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").addEventListener("click", async () => {
$("signin-go").disabled = true; $("signin-go").disabled = true;
try { try {
const { signer: s, profile, isNewIdentity } = await signIn({ session = await signIn({
clientId: CLIENT_ID, clientId: CLIENT_ID,
onStatus: (msg) => setStatus("signin-status", msg), 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; signer = session.signer;
currentProfile = profile;
setStatus("signin-status", "Connecting to relays…"); setStatus("signin-status", "Connecting to relays…");
const unsubStatus = pool.onStatus((snap) => { const unsubStatus = pool.onStatus((snap) => {
@ -49,24 +69,24 @@ $("signin-go").addEventListener("click", async () => {
await pool.connectAll({ minRelays: 1 }); await pool.connectAll({ minRelays: 1 });
unsubStatus(); unsubStatus();
if (isNewIdentity) { if (session.isNewIdentity) {
// First time: publish a kind-0 with the Google name + picture so this // Brand-new key: nothing can exist on relays yet, so publish the Google
// user shows up properly on nostr. // name + picture right away. Late relays get it via the pool's replay.
publishProfileEvent(profile); publishProfileEvent(session.profile);
} }
enterHome(profile); enterHome();
} catch (err) { } catch (err) {
console.error(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; $("signin-go").disabled = false;
} }
}); });
$("sign-out").addEventListener("click", () => { $("sign-out").addEventListener("click", () => {
clearSession(); clearSession();
signer = null; location.replace(location.pathname);
location.reload();
}); });
// ---------- home / live profile / notes ---------- // ---------- home / live profile / notes ----------
@ -74,25 +94,25 @@ $("sign-out").addEventListener("click", () => {
function publishProfileEvent(profile) { function publishProfileEvent(profile) {
const event = signer.sign({ const event = signer.sign({
kind: 0, kind: 0,
content: JSON.stringify({ content: JSON.stringify({ name: profile.name, picture: profile.picture }),
name: profile.name,
picture: profile.picture,
// `nip05` would be nice here but we don't host one
}),
tags: [], tags: [],
}); });
pool.publish(event); pool.publish(event);
publishedProfileTs = event.created_at; publishedProfileTs = event.created_at;
} }
function enterHome(profile) { function enterHome() {
const { profile } = session;
$("me-name").textContent = profile.name || "anonymous"; $("me-name").textContent = profile.name || "anonymous";
$("me-pic").src = profile.picture || DEFAULT_AVATAR; $("me-pic").src = profile.picture || DEFAULT_AVATAR;
$("me-npub").textContent = signer.npub; $("me-npub").textContent = signer.npub;
show("home"); show("home");
pool.onStatus(renderRelayDots); pool.onStatus(renderRelayDots);
startLiveProfile(signer.pubkey, profile); startLiveProfile(profile);
loadNotes(); loadNotes();
initExtension();
initBunker();
renderKey();
} }
function renderRelayDots(snap) { function renderRelayDots(snap) {
@ -108,14 +128,12 @@ function renderRelayDots(snap) {
function safeJSON(s) { try { return JSON.parse(s); } catch { return {}; } } 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 // 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 // what's on screen, upgrade.
// seconds, our google-side profile may not have been published anywhere —
// republish to make sure it lands.
let sawOne = false; let sawOne = false;
pool.subscribe( pool.subscribe(
[{ kinds: [0], authors: [pubkey] }], [{ kinds: [0], authors: [signer.pubkey] }],
(ev) => { (ev) => {
sawOne = true; sawOne = true;
if (ev.created_at <= publishedProfileTs) return; if (ev.created_at <= publishedProfileTs) return;
@ -124,15 +142,13 @@ function startLiveProfile(pubkey, googleProfile) {
if (p.name) $("me-name").textContent = p.name; if (p.name) $("me-name").textContent = p.name;
if (p.picture) $("me-pic").src = p.picture; if (p.picture) $("me-pic").src = p.picture;
}, },
null (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.
setTimeout(() => { if (sawOne || publishedProfileTs || !googleProfile?.name) return;
if (!sawOne && googleProfile?.name) { if (answered >= Math.min(2, pool.sockets.size) && answered > 0) publishProfileEvent(googleProfile);
// No relay had our profile. Publish it now.
publishProfileEvent(googleProfile);
} }
}, 6000); );
} }
let notesById = new Map(); let notesById = new Map();
@ -151,14 +167,10 @@ function loadNotes() {
renderRows([...notesById.values()], tbody); renderRows([...notesById.values()], tbody);
setStatus("notes-status", ""); setStatus("notes-status", "");
}, },
null () => {
); if (notesById.size === 0) setStatus("notes-status", "No notes yet. Publish one above.");
setTimeout(() => {
if (notesById.size === 0) {
setStatus("notes-status", "No notes yet. Publish one above.");
} }
}, 8000); );
} }
function renderRows(rows, tbody) { function renderRows(rows, tbody) {
@ -179,16 +191,277 @@ function renderRows(rows, tbody) {
$("note-send").addEventListener("click", () => { $("note-send").addEventListener("click", () => {
const text = $("note-text").value.trim(); const text = $("note-text").value.trim();
if (!text) return; if (!text) return;
$("note-send").disabled = true;
const event = signer.sign({ kind: 1, content: text, tags: [] }); const event = signer.sign({ kind: 1, content: text, tags: [] });
pool.publish(event); pool.publish(event);
$("note-text").value = ""; $("note-text").value = "";
$("note-send").disabled = false;
notesById.set(event.id, event); notesById.set(event.id, event);
renderRows([...notesById.values()], $("notes-table").querySelector("tbody")); renderRows([...notesById.values()], $("notes-table").querySelector("tbody"));
setStatus("notes-status", ""); 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 ---------- // ---------- boot ----------
(async function boot() { (async function boot() {
@ -200,17 +473,18 @@ $("note-send").addEventListener("click", () => {
return; return;
} }
const restored = restoreSession(); session = restoreSession({ clientId: CLIENT_ID });
if (restored) { if (session) {
signer = restored.signer; signer = session.signer;
currentProfile = restored.profile;
show("loading"); show("loading");
$("loading-text").textContent = "Welcome back. Connecting to relays…"; $("loading-text").textContent = "Welcome back. Connecting to relays…";
pool.onStatus(renderRelayDots);
await pool.connectAll({ minRelays: 1 }); await pool.connectAll({ minRelays: 1 });
enterHome(restored.profile); enterHome();
return; return;
} }
if (extensionUnlockRequested) {
setStatus("signin-status", "Sign in to unlock the Goostr extension.");
}
show("signin"); show("signin");
})(); })();

193
public/bunker.js Normal file
View File

@ -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://<user pubkey>.
//
// 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 "?"; }
}

View File

@ -15,3 +15,10 @@ export const RELAYS = [
"wss://relay.nostr.band", "wss://relay.nostr.band",
"wss://nostr.mom", "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 = "";

View File

@ -1,6 +1,6 @@
// Google Drive AppData helpers. Files written to the appDataFolder space are // Google Drive helpers. Files written to the appDataFolder space are invisible
// invisible to the user in the regular Drive UI and only readable by the // to the user in the regular Drive UI and only readable by this app's Google
// OAuth client (i.e. *this* app's client_id) that created them. // Cloud project.
// //
// Docs: https://developers.google.com/drive/api/guides/appdata // 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 // 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) { export async function findAppDataFile(token, name) {
const url = const url =
`${DRIVE}/files?spaces=appDataFolder` + `${DRIVE}/files?spaces=appDataFolder` +
`&q=${encodeURIComponent(`name = '${name.replace(/'/g, "\\'")}'`)}` + `&q=${encodeURIComponent(`name = '${name.replace(/'/g, "\\'")}'`)}` +
`&fields=files(id,name,modifiedTime)`; `&fields=files(id,createdTime)`;
const res = await driveFetch(token, url); const res = await driveFetch(token, url);
const { files } = await res.json(); const { files } = await res.json();
if (!files || files.length === 0) return null; if (!files || files.length === 0) return null;
// If somehow multiple, prefer the most recent. files.sort((a, b) => (a.createdTime || "").localeCompare(b.createdTime || ""));
files.sort((a, b) => (b.modifiedTime || "").localeCompare(a.modifiedTime || ""));
return files[0].id; return files[0].id;
} }
@ -42,26 +42,25 @@ export async function downloadFile(token, fileId) {
return res.text(); return res.text();
} }
// Multipart upload of a small JSON blob to appDataFolder. If fileId is given, // Multipart upload of a small text blob. If fileId is given, replaces that
// updates that file (preserving parents); otherwise creates a new file. // file's content; otherwise creates a new file under `parents`
export async function uploadAppDataJson(token, name, content, fileId = null) { // (["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 boundary = "goostr-" + Math.random().toString(36).slice(2);
const metadata = fileId const metadata = fileId ? { name } : { name, mimeType, ...(parents ? { parents } : {}) };
? { name }
: { name, parents: ["appDataFolder"] };
const body = const body =
`--${boundary}\r\n` + `--${boundary}\r\n` +
`Content-Type: application/json; charset=UTF-8\r\n\r\n` + `Content-Type: application/json; charset=UTF-8\r\n\r\n` +
`${JSON.stringify(metadata)}\r\n` + `${JSON.stringify(metadata)}\r\n` +
`--${boundary}\r\n` + `--${boundary}\r\n` +
`Content-Type: application/json\r\n\r\n` + `Content-Type: ${mimeType}\r\n\r\n` +
`${content}\r\n` + `${content}\r\n` +
`--${boundary}--`; `--${boundary}--`;
const url = fileId const url = fileId
? `${UPLOAD}/files/${fileId}?uploadType=multipart` ? `${UPLOAD}/files/${fileId}?uploadType=multipart`
: `${UPLOAD}/files?uploadType=multipart`; : `${UPLOAD}/files?uploadType=multipart&fields=id,webViewLink`;
const res = await driveFetch(token, url, { const res = await driveFetch(token, url, {
method: fileId ? "PATCH" : "POST", method: fileId ? "PATCH" : "POST",

BIN
public/goostr-extension.zip Normal file

Binary file not shown.

View File

@ -2,26 +2,24 @@
// //
// Flow: // Flow:
// signIn({ clientId }) // signIn({ clientId })
// → opens Google sign-in popup // → opens Google sign-in popup (consent only the first time)
// → requests OAuth scopes: profile, email, drive.appdata // → requests OAuth scopes: profile, email, drive.appdata
// → looks in Drive App Data for goostr-key-v1.json // → 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 // · 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. // 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. // 2. sessionStorage for the current tab — survives reload, dies on tab close.
// It is never written to localStorage, IndexedDB, or any other disk-persistent // It is never written to localStorage, IndexedDB, or any other disk-persistent
// store on the local device. // store on the local device.
import { getPublicKey, finalizeEvent, nip19 } from "nostr-tools"; import { getPublicKey, finalizeEvent, nip04, nip19, nip44, nip49, bytesToHex } from "./vendor/nostr-tools.js";
import { import { findAppDataFile, downloadFile, uploadFile, fetchUserInfo } from "./drive.js";
findAppDataFile, import { createPasskeyKey, getPasskeyKey, wrapKey, unwrapKey } from "./passkey.js";
downloadFile,
uploadAppDataJson,
fetchUserInfo,
} from "./drive.js";
const KEY_FILE = "goostr-key-v1.json"; const KEY_FILE = "goostr-key-v1.json";
const SCOPES = [ const SCOPES = [
@ -30,6 +28,9 @@ const SCOPES = [
"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.email",
"openid", "openid",
].join(" "); ].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 ---------- // ---------- Google Identity Services token client ----------
@ -43,23 +44,39 @@ function ensureGisLoaded() {
s.async = true; s.async = true;
s.defer = true; s.defer = true;
s.onload = () => resolve(); 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); document.head.appendChild(s);
}); });
return gisReady; 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(); await ensureGisLoaded();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = google.accounts.oauth2.initTokenClient({ const client = google.accounts.oauth2.initTokenClient({
client_id: clientId, client_id: clientIdInUse,
scope: SCOPES, scope,
prompt: silent ? "" : "consent", // "" = 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) => { callback: (resp) => {
if (resp.error) return reject(new Error(`Google auth: ${resp.error}`)); 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.")); 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"}`)), error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)),
}); });
@ -67,112 +84,262 @@ async function requestAccessToken(clientId, { silent = false } = {}) {
}); });
} }
// ---------- key bytes / signer ---------- async function getToken(extraScope = null) {
const fresh = token && token.exp - Date.now() > 60_000;
function randomBytes(len) { if (fresh && (!extraScope || token.scope.includes(extraScope))) return token.value;
const buf = new Uint8Array(len); if (!clientIdInUse) throw new Error("Not configured: call signIn/restoreSession with a clientId first.");
crypto.getRandomValues(buf); return requestAccessToken(extraScope ? `${SCOPES} ${extraScope}` : SCOPES);
return buf;
} }
function b64encBytes(bytes) { // ---------- bytes helpers ----------
function randomBytes(len) {
return crypto.getRandomValues(new Uint8Array(len));
}
function b64enc(bytes) {
let s = ""; let s = "";
for (const b of bytes) s += String.fromCharCode(b); for (const b of bytes) s += String.fromCharCode(b);
return btoa(s); return btoa(s);
} }
function b64decBytes(b64) { function b64dec(b64) {
const bin = atob(b64); const bin = atob(b64);
const out = new Uint8Array(bin.length); const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out; return out;
} }
function makeSigner(privKeyBytes) { // ---------- signer ----------
export function makeSigner(privKeyBytes) {
const pubkey = getPublicKey(privKeyBytes); const pubkey = getPublicKey(privKeyBytes);
const convKey = (pk) => nip44.getConversationKey(privKeyBytes, pk);
return { return {
pubkey, pubkey,
npub: nip19.npubEncode(pubkey), npub: nip19.npubEncode(pubkey),
sign(unsignedEvent) { sign(unsignedEvent) {
return finalizeEvent( return finalizeEvent(
{ {
...unsignedEvent, kind: unsignedEvent.kind,
pubkey, content: unsignedEvent.content ?? "",
tags: unsignedEvent.tags ?? [],
created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000), created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000),
}, },
privKeyBytes 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_KEY = "goostr.session.v1";
const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling
function saveSession(privKeyBytes, profile) { function saveSession(bytes, profile, meta) {
try { try {
sessionStorage.setItem( sessionStorage.setItem(
SESSION_KEY, 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 {} } 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 { try {
const raw = sessionStorage.getItem(SESSION_KEY); const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null; 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) { if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) {
sessionStorage.removeItem(SESSION_KEY); sessionStorage.removeItem(SESSION_KEY);
return null; return null;
} }
return { signer: makeSigner(b64decBytes(k)), profile: p || {} }; return makeSession(b64dec(k), p || {}, m || { protection: { type: "none" } });
} catch { } catch {
return null; return null;
} }
} }
export function clearSession() { export function clearSession() {
token = null;
try { sessionStorage.removeItem(SESSION_KEY); } catch {} try { sessionStorage.removeItem(SESSION_KEY); } catch {}
} try { localStorage.removeItem(HINT_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 };
} }
// ---------- public API ---------- // ---------- public API ----------
export async function signIn({ clientId, silent = false, onStatus } = {}) { // onPasskeyNeeded: optional async callback, awaited before the passkey prompt
if (!clientId || clientId.startsWith("PASTE-")) { // 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."); 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…"); onStatus?.("Signing in with Google…");
const accessToken = await requestAccessToken(clientId, { silent }); const accessToken = await getToken();
onStatus?.("Reading your Google profile…"); onStatus?.("Reading your Google profile…");
const userInfo = await fetchUserInfo(accessToken); const userInfo = await fetchUserInfo(accessToken);
@ -182,11 +349,13 @@ export async function signIn({ clientId, silent = false, onStatus } = {}) {
picture: userInfo.picture || null, picture: userInfo.picture || null,
email: userInfo.email || 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); const meta = { fileId, protection, created };
return { signer: makeSigner(bytes), profile, isNewIdentity: created }; saveSession(bytes, profile, meta);
return makeSession(bytes, profile, meta);
} }
export function isConfigured(clientId) { export function isConfigured(clientId) {

View File

@ -3,15 +3,15 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- The nostr key lives in this page's memory, so no third-party scripts
except Google's sign-in client. Everything else is served from here. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' https://accounts.google.com/gsi/client; style-src 'self' 'unsafe-inline' https://accounts.google.com/gsi/style; connect-src 'self' https://www.googleapis.com https://accounts.google.com wss:; img-src 'self' data: https:; frame-src https://accounts.google.com; object-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>Goostr — Google to nostr</title> <title>Goostr — Google to nostr</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="./style.css" /> <link rel="stylesheet" href="./style.css" />
<script type="importmap">
{
"imports": {
"nostr-tools": "https://esm.sh/nostr-tools@2.7.2"
}
}
</script>
</head> </head>
<body> <body>
<main> <main>
@ -22,10 +22,12 @@
<div id="setup-banner" class="banner-warn hidden"> <div id="setup-banner" class="banner-warn hidden">
<strong>Setup required.</strong> Edit <code>public/config.js</code> and <strong>Setup required.</strong> Edit <code>public/config.js</code> and
paste your Google OAuth client ID into <code>CLIENT_ID</code>. See paste your Google OAuth client ID into <code>CLIENT_ID</code>. See the
<a href="./README.md">README</a> for the 2-minute setup. README for the 2-minute setup.
</div> </div>
<div id="ext-unlock-banner" class="banner-ok hidden"></div>
<section id="view-loading" class="view"> <section id="view-loading" class="view">
<p id="loading-text">Checking session…</p> <p id="loading-text">Checking session…</p>
</section> </section>
@ -33,10 +35,10 @@
<!-- Sign in --> <!-- Sign in -->
<section id="view-signin" class="view hidden"> <section id="view-signin" class="view hidden">
<h2>Create or unlock your nostr identity</h2> <h2>Create or unlock your nostr identity</h2>
<p style="color: var(--muted); font-size: 14px; margin-top: -8px;"> <p class="lead">
We'll save a secret key in your Google Drive's app data — a private We'll save a secret key in your Google Drive's app data — a private
folder only this app can read. The key is the seed for your nostr folder only this app can read. The key is your nostr identity. Sign
identity. Sign in on any device to use the same identity. in on any device to use the same identity.
</p> </p>
<button id="signin-go" class="google"> <button id="signin-go" class="google">
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
@ -47,7 +49,8 @@
</svg> </svg>
Sign in with Google Sign in with Google
</button> </button>
<p id="signin-status" class="status"></p> <button id="signin-passkey" class="primary hidden">Unlock with passkey</button>
<p id="signin-status" class="status" role="status"></p>
</section> </section>
<!-- Logged in --> <!-- Logged in -->
@ -62,7 +65,7 @@
</div> </div>
<div class="composer"> <div class="composer">
<textarea id="note-text" placeholder="Write something. It will be signed by your nostr key and published." maxlength="500"></textarea> <textarea id="note-text" aria-label="New note" placeholder="Write something. It will be signed by your nostr key and published." maxlength="500"></textarea>
<button id="note-send" class="primary">Publish</button> <button id="note-send" class="primary">Publish</button>
</div> </div>
@ -71,13 +74,123 @@
<span id="relay-dots"></span> <span id="relay-dots"></span>
</div> </div>
<h3>Your logs</h3> <h3>Your notes</h3>
<table id="notes-table"> <table id="notes-table">
<thead><tr><th>When</th><th>Note</th></tr></thead> <thead><tr><th>When</th><th>Note</th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
<p id="notes-status" class="status">Loading…</p> <p id="notes-status" class="status">Loading…</p>
</section> </section>
<!-- Use elsewhere -->
<section id="view-connect" class="view hidden">
<h2>Use this identity in other nostr apps</h2>
<div class="card">
<h4>Browser extension <span class="pill">desktop Chrome · Edge · Brave</span></h4>
<p class="muted">
Optional. Adds "Login with extension" (NIP-07) to any nostr website.
Each site asks your permission first. Nothing needs to stay open.
</p>
<p id="ext-status" class="status"></p>
<div id="ext-install" class="hidden">
<a id="ext-store" class="button primary hidden" target="_blank" rel="noopener">Add to Chrome</a>
<a id="ext-download" class="button primary hidden" href="./goostr-extension.zip" download>Download the extension (.zip)</a>
<ol id="ext-steps" class="steps hidden">
<li>Unzip the download.</li>
<li>Open <span class="mono">chrome://extensions</span> and turn on <b>Developer mode</b> (top right).</li>
<li>Click <b>Load unpacked</b> and pick the unzipped <span class="mono">extension</span> folder.</li>
<li>Come back here and click <b>Check again</b>.</li>
</ol>
<button id="ext-recheck" class="ghost">Check again</button>
</div>
<button id="ext-connect" class="primary hidden">Unlock extension with this identity</button>
<button id="ext-lock" class="ghost hidden">Lock extension</button>
</div>
<div class="card">
<h4>Remote signer <span class="pill">NIP-46 · any app, any device</span></h4>
<p class="muted">
Apps that support "bunker" or "Nostr Connect" login (Coracle, Nostrudel,
Amethyst, …) send signing requests here over relays.
<b>This tab has to stay open</b> while you use them.
</p>
<div class="row">
<span id="bunker-state" class="status"></span>
<button id="bunker-toggle" class="ghost"></button>
</div>
<p class="sub">Connect an app with a bunker link</p>
<button id="bunker-new" class="primary">Create connection link</button>
<div id="bunker-link-box" class="hidden">
<div class="row">
<input id="bunker-link" type="text" readonly aria-label="Bunker link" class="mono" />
<button id="bunker-copy" class="ghost">Copy</button>
</div>
<p class="muted small">Paste it into the app's "bunker" / "remote signer" login. It works once.</p>
</div>
<p class="sub">…or paste the app's nostrconnect:// link</p>
<div class="row">
<input id="nc-link" type="text" placeholder="nostrconnect://…" aria-label="nostrconnect link" class="mono" />
<button id="nc-go" class="ghost">Connect</button>
</div>
<p id="bunker-status" class="status"></p>
<p class="sub">Connected apps</p>
<ul id="bunker-clients" class="list"></ul>
<p id="bunker-no-clients" class="muted small">None yet.</p>
<p class="sub">Recent activity</p>
<ul id="bunker-log" class="list small"></ul>
<p id="bunker-no-log" class="muted small">Nothing yet.</p>
</div>
</section>
<!-- Key & backup -->
<section id="view-key" class="view hidden">
<h2>Your key</h2>
<div class="card">
<h4>Protection</h4>
<p id="key-protection"></p>
<p class="muted small" id="pk-help">
A passkey (Face ID, fingerprint or device PIN) encrypts the copy in
Google Drive, so your Google password alone can't unlock it. Passkeys
only work on this site's domain. <b>Make a backup below first</b> —
if you lose the passkey, the backup is the only way back.
</p>
<button id="pk-enable" class="primary hidden">Protect with a passkey</button>
<button id="pk-disable" class="ghost hidden">Remove passkey protection</button>
<p id="pk-status" class="status"></p>
</div>
<div class="card">
<h4>Backup</h4>
<p class="muted">
A password-encrypted copy (NIP-49 <span class="mono">ncryptsec</span>)
that works in most nostr apps. It keeps your identity safe even if
this Goostr site or its Google project goes away.
</p>
<input id="bk-pw1" type="password" placeholder="Backup password (8+ characters)" autocomplete="new-password" aria-label="Backup password" />
<input id="bk-pw2" type="password" placeholder="Repeat password" autocomplete="new-password" aria-label="Repeat backup password" />
<div class="row">
<button id="bk-download" class="primary">Download backup</button>
<button id="bk-drive" class="ghost">Save to Google Drive</button>
</div>
<p id="bk-status" class="status"></p>
</div>
<div class="card">
<h4>Private key (nsec)</h4>
<p class="muted">Anyone who sees this can post as you. Only reveal it to move to another signer.</p>
<button id="nsec-reveal" class="ghost">Reveal nsec</button>
<div id="nsec-box" class="hidden">
<p id="nsec-value" class="mono secret"></p>
<button id="nsec-copy" class="ghost">Copy</button>
</div>
</div>
</section>
</main> </main>
<script type="module" src="./app.js"></script> <script type="module" src="./app.js"></script>

92
public/passkey.js Normal file
View File

@ -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.");
}
}

View File

@ -6,23 +6,37 @@
// overall timeout). The rest keep opening in the background. // overall timeout). The rest keep opening in the background.
// * Late-arriving relays automatically replay anything we've already sent // * Late-arriving relays automatically replay anything we've already sent
// (active subscriptions + recent publishes), so the user never waits. // (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. // * 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"; import { RELAYS } from "./config.js";
const PER_RELAY_CONNECT_TIMEOUT_MS = 4000; const PER_RELAY_CONNECT_TIMEOUT_MS = 4000;
const OVERALL_CONNECT_TIMEOUT_MS = 4500; // hard ceiling for connectAll() 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 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 { class RelayPool {
constructor(urls = RELAYS) { constructor(urls = RELAYS) {
this.urls = urls; this.urls = [...urls];
this.sockets = new Map(); // url -> WebSocket (only OPEN ones) this.sockets = new Map(); // url -> WebSocket (only OPEN ones)
this.status = new Map(); // url -> "connecting"|"open"|"closed"|"timeout" 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.subs = new Map(); // subId -> { req, handler }
this.recentPublishes = []; // { msg, t } this.recentPublishes = []; // { msg, t }
this.subId = 0; this.subId = 0;
this._listeners = new Set(); // status change listeners 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) { onStatus(fn) {
@ -42,6 +56,28 @@ class RelayPool {
for (const fn of this._listeners) fn(snap); 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) { _open(url) {
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
@ -50,14 +86,19 @@ class RelayPool {
settled = true; settled = true;
resolve(ok); resolve(ok);
}; };
const failed = (state) => {
this.sockets.delete(url);
this.status.set(url, state);
this._notify();
this._scheduleRetry(url);
finish(false);
};
let ws; let ws;
try { try {
ws = new WebSocket(url); ws = new WebSocket(url);
} catch { } catch {
this.status.set(url, "closed"); return failed("closed");
this._notify();
return finish(false);
} }
this.status.set(url, "connecting"); this.status.set(url, "connecting");
@ -65,20 +106,17 @@ class RelayPool {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (ws.readyState === WebSocket.CONNECTING) { if (ws.readyState === WebSocket.CONNECTING) {
this.status.set(url, "timeout");
this._notify();
try { ws.close(); } catch {} try { ws.close(); } catch {}
finish(false); failed("timeout");
} }
}, PER_RELAY_CONNECT_TIMEOUT_MS); }, PER_RELAY_CONNECT_TIMEOUT_MS);
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
clearTimeout(timer); clearTimeout(timer);
const t0 = performance.now();
this.sockets.set(url, ws); this.sockets.set(url, ws);
this.status.set(url, "open"); this.status.set(url, "open");
this.retries.set(url, 0);
this._notify(); this._notify();
console.log(`[relay] open ${url} (${Math.round(t0)}ms since page)`);
// Replay active subscriptions and recent publishes to this new relay. // Replay active subscriptions and recent publishes to this new relay.
for (const { req } of this.subs.values()) { for (const { req } of this.subs.values()) {
@ -93,27 +131,18 @@ class RelayPool {
finish(true); 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", () => { ws.addEventListener("close", () => {
clearTimeout(timer); clearTimeout(timer);
this.sockets.delete(url); if (this.sockets.get(url) === ws || !settled) {
if (this.status.get(url) !== "timeout") this.status.set(url, "closed"); failed(this.status.get(url) === "timeout" ? "timeout" : "closed");
this._notify(); }
finish(false);
}); });
// Wire messages to active subscription handlers. // Wire messages to active subscription handlers.
ws.addEventListener("message", (ev) => { ws.addEventListener("message", (ev) => {
let msg; let msg;
try { msg = JSON.parse(ev.data); } catch { return; } try { msg = JSON.parse(ev.data); } catch { return; }
const subId = msg[1]; const sub = this.subs.get(msg[1]);
const sub = this.subs.get(subId);
if (sub) sub.handler(msg, url); 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 // Resolves as soon as we have at least `minRelays` open, or after the
// overall timeout. Always returns the number of relays that opened. // overall timeout. Always returns the number of relays that opened.
async connectAll({ minRelays = 1 } = {}) { async connectAll({ minRelays = 1 } = {}) {
this._started = true;
const attempts = this.urls.map((u) => this._open(u)); const attempts = this.urls.map((u) => this._open(u));
let opened = 0; let opened = 0;
@ -130,35 +160,25 @@ class RelayPool {
const finish = () => { const finish = () => {
if (done) return; if (done) return;
done = true; done = true;
clearTimeout(ceiling);
resolve(this.sockets.size); resolve(this.sockets.size);
}; };
const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS); const ceiling = setTimeout(finish, OVERALL_CONNECT_TIMEOUT_MS);
attempts.forEach((p) => p.then((ok) => { if (ok && ++opened >= minRelays) finish(); }));
attempts.forEach((p) => Promise.allSettled(attempts).then(finish);
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());
}); });
} }
// 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) { publish(event) {
const msg = JSON.stringify(["EVENT", event]); const msg = JSON.stringify(["EVENT", event]);
this.recentPublishes.push({ msg, t: Date.now() }); this.recentPublishes.push({ msg, t: Date.now() });
// trim
if (this.recentPublishes.length > 200) this.recentPublishes.shift(); if (this.recentPublishes.length > 200) this.recentPublishes.shift();
for (const ws of this.sockets.values()) { for (const ws of this.sockets.values()) {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@ -167,23 +187,32 @@ class RelayPool {
} }
} }
// filters: array of REQ filters. onEvent(event) per match. onEose() once // filters: array of REQ filters. onEvent(event) per verified match. onEose(n)
// every currently-open relay has signaled EOSE. // fires once: when every relay open at that moment has sent EOSE, or after
// EOSE_TIMEOUT_MS, whichever is first.
subscribe(filters, onEvent, onEose) { subscribe(filters, onEvent, onEose) {
const id = `s${++this.subId}`; const id = `s${++this.subId}`;
const req = JSON.stringify(["REQ", id, ...filters]); const req = JSON.stringify(["REQ", id, ...filters]);
const seen = new Set(); 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) => { const handler = (msg, url) => {
if (msg[0] === "EVENT" && msg[1] === id) { if (msg[0] === "EVENT") {
const e = msg[2]; const e = msg[2];
if (seen.has(e.id)) return; if (!e || seen.has(e.id) || !verifyEvent(e)) return;
seen.add(e.id); seen.add(e.id);
onEvent(e); onEvent(e);
} else if (msg[0] === "EOSE" && msg[1] === id) { } else if (msg[0] === "EOSE") {
eoseSet.add(url); eoseFrom.add(url);
if (eoseSet.size >= this.sockets.size && onEose) onEose(); if ([...this.sockets.keys()].every((u) => eoseFrom.has(u))) fireEose();
} }
}; };
@ -196,6 +225,7 @@ class RelayPool {
} }
return () => { return () => {
clearTimeout(eoseTimer);
const close = JSON.stringify(["CLOSE", id]); const close = JSON.stringify(["CLOSE", id]);
for (const ws of this.sockets.values()) { for (const ws of this.sockets.values()) {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {

View File

@ -54,10 +54,8 @@ h1 {
h2 { margin: 0 0 16px; font-size: 20px; font-weight: 600; } 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; } 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%; width: 100%;
background: var(--panel-2); background: var(--panel-2);
color: var(--text); color: var(--text);
@ -69,45 +67,11 @@ input[type="text"], textarea {
outline: none; outline: none;
transition: border-color 0.15s; 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; } 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 { button {
font-family: inherit; font-family: inherit;
@ -186,9 +150,6 @@ button.ghost:hover { color: var(--text); border-color: var(--muted); }
.status.error { color: var(--danger); } .status.error { color: var(--danger); }
.status.ok { color: var(--ok); } .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 { .profile {
display: grid; 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; } 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; } td:first-child { color: var(--muted); white-space: nowrap; width: 140px; font-variant-numeric: tabular-nums; }
tbody tr:last-child td { border-bottom: none; } 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; }

2
public/vendor/nostr-tools.js vendored Normal file

File diff suppressed because one or more lines are too long

10
scripts/build-extension.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# Package extension/ as public/goostr-extension.zip (the download offered on
# the site). Run after changing anything in extension/ or public/vendor/.
set -e
ROOT=$(cd "$(dirname "$0")/.." && pwd)
cp "$ROOT/public/vendor/nostr-tools.js" "$ROOT/extension/vendor/nostr-tools.js"
rm -f "$ROOT/public/goostr-extension.zip"
cd "$ROOT"
zip -qr public/goostr-extension.zip extension -x '*.DS_Store'
echo "Built public/goostr-extension.zip"

24
scripts/vendor-nostr-tools.sh Executable file
View File

@ -0,0 +1,24 @@
#!/bin/sh
# Rebuild public/vendor/nostr-tools.js — a pinned, self-contained ESM bundle of
# just the nostr-tools pieces Goostr uses. Only needed when upgrading.
set -e
VERSION=2.25.2
ROOT=$(cd "$(dirname "$0")/.." && pwd)
TMP=$(mktemp -d)
cd "$TMP"
npm init -y >/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"

90
test/bunker.test.mjs Normal file
View File

@ -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");