Googstr/README.md
Jason Tudisco fcfaec675a 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>
2026-09-29 08:33:02 -06:00

8.9 KiB

Goostr (static)

Your Google account is your nostr identity. No server, no key management.

Pure static site. No build step. No backend. No install required to run.

Sign in with Google and you get a nostr identity that follows you to every device. Then use it anywhere:

  • On this page: post notes, see your profile and recent notes.
  • In any nostr website: through the optional Goostr browser extension (NIP-07).
  • In any nostr app, including phones: through the built-in remote signer (NIP-46 "bunker").

How it works

  1. User clicks Sign in with Google. We request three OAuth scopes: profile, email, and drive.appdata. Google shows its consent screen the first time only; after that, sign-in is one click.
  2. We look in the user's Google Drive App Data folder for a file goostr-key-v1.json. (App Data is a hidden per-app folder in the user's Drive that only this app's Google Cloud project can read. WhatsApp uses it for backups; Enpass uses it for vault sync.)
    • If found → read it. The 32 bytes inside are the nostr private key. If the file is passkey-protected, the user unlocks it with their passkey first.
    • If missing → generate 32 fresh random bytes (crypto.getRandomValues), upload to App Data. User now has a brand-new nostr identity.
    • 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.

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.

Features

Browser extension (NIP-07), optional

The extension in extension/ adds window.nostr to every website, so "Login with extension" works in Coracle, Snort, Nostrudel, etc.

  • 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.

Remote signer (NIP-46)

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).

  • 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.

Key protection with a passkey

Protect with a passkey encrypts the Drive copy using the WebAuthn PRF extension (AES-GCM, key derived with HKDF). After that, access to your Google account alone isn't enough to read your key; you also need the passkey.

  • How to unlock: new devices ask for the passkey after Google sign-in. Synced passkeys (Google Password Manager, iCloud Keychain) cover all your devices.
  • Bound to one domain: a passkey only works on the site it was created on. If you move Goostr to another domain, turn protection off first.
  • Supported browsers: recent Chrome, Edge and Safari with a PRF-capable passkey provider.

Backups and export

  • Download backup: a NIP-49 ncryptsec file, encrypted with a password you choose. Most nostr signers can import it.
  • Save to Google Drive: the same ncryptsec saved as a normal, visible file in your Drive. It survives even if this app's Google project disappears. This asks for the extra drive.file scope, only when you click it.
  • Reveal nsec: for moving to another signer.

Security notes

  • Without a passkey, anyone who gets into your Google account can read your nostr key. With a passkey, they also need your device.
  • While you're signed in, the raw key is in the tab's sessionStorage and memory. A Content-Security-Policy stops every third-party script except Google's sign-in client from running. nostr-tools is vendored and pinned in public/vendor/, not loaded from a CDN.
  • Every event received from relays has its signature checked before it's used.
  • Not for situations where you want to be invisible to a single big platform: Google knows you own this Drive App Data file.

One-time Google setup (~2 minutes)

  1. Open https://console.cloud.google.com/apis/credentials.
  2. Create or select a project.
  3. Enable the Google Drive API under "Library."
  4. Under "OAuth consent screen", configure a minimal app (External + Testing is fine for prototyping; add yourself as a Test User). Add the scopes drive.appdata and drive.file.
  5. Back in "Credentials" → Create Credentials → OAuth client ID → Web application.
  6. Under Authorized JavaScript origins add:
    • http://localhost:5174 (for local dev)
    • your deploy URL (e.g. https://you.github.io)
  7. Copy the Client ID — looks like 1234567890-abcdef.apps.googleusercontent.com.
  8. Paste it into 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

python3 -m http.server -d public 5174

Then open http://localhost:5174. Google requires HTTPS for OAuth in production, but localhost is exempt for dev.

Deploy it

Static folder. Drop public/ into any host: GitHub Pages, Netlify, Vercel, Cloudflare Pages, S3. 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, e.g. "https://goostr.example.com/*".
  2. Set GOOSTR_URL in extension/config.js to your site.
  3. Run npm run build:extension to rebuild public/goostr-extension.zip, then redeploy.

Layout

public/
  index.html       — entry point, CSP
  app.js           — UI glue
  goostr.js        — library: signIn(), restoreSession(), clearSession(), makeSigner()
  drive.js         — Google Drive helpers
  passkey.js       — passkey (WebAuthn PRF) key wrapping
  bunker.js        — NIP-46 remote signer
  relay.js         — nostr WebSocket pool: replay on late connect, reconnect, signature checks
  vendor/          — pinned nostr-tools bundle
  goostr-extension.zip — the extension, as offered for download
  style.css        — dark UI
  config.js        — CLIENT_ID, relays, extension ID (edit this)
extension/         — the Chrome extension (Manifest V3)
scripts/           — vendor-nostr-tools.sh, build-extension.sh
test/              — bunker.test.mjs (node self-check for the NIP-46 signer)

Scripts

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

import { signIn, restoreSession } from "./goostr.js";

// On boot, try restoring first (sessionStorage):
let session = restoreSession({ clientId: "1234.apps.googleusercontent.com" });
if (!session) {
  session = await signIn({
    clientId: "1234.apps.googleusercontent.com",
    onStatus: (msg) => console.log(msg),
    // Only called for passkey-protected keys; resolve after a user click.
    onPasskeyNeeded: () => waitForClick(unlockButton),
  });
}
const { signer, profile } = session;
signer.sign({ kind: 1, content: "hello nostr", tags: [] });
await signer.nip44.encrypt(theirPubkey, "psst");

// Key management
await session.enablePasskey();
await session.backupToDrive("a good password");
session.ncryptsec("a good password");