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

154 lines
8.9 KiB
Markdown

# 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/`](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/`](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`](public/config.js) as the value of `CLIENT_ID`.
**Keep this Google Cloud project.** App Data is tied to it. If the project is deleted, nobody can read their key from Drive anymore; only backups will help.
## Run it
```bash
python3 -m http.server -d public 5174
```
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`](extension/manifest.json), e.g. `"https://goostr.example.com/*"`.
2. Set `GOOSTR_URL` in [`extension/config.js`](extension/config.js) to your site.
3. Run `npm run build:extension` to rebuild `public/goostr-extension.zip`, then redeploy.
## Layout
```
public/
index.html — entry point, CSP
app.js — UI glue
goostr.js — library: signIn(), restoreSession(), clearSession(), makeSigner()
drive.js — Google Drive helpers
passkey.js — passkey (WebAuthn PRF) key wrapping
bunker.js — NIP-46 remote signer
relay.js — nostr WebSocket pool: replay on late connect, reconnect, signature checks
vendor/ — pinned nostr-tools bundle
goostr-extension.zip — the extension, as offered for download
style.css — dark UI
config.js — CLIENT_ID, relays, extension ID (edit this)
extension/ — the Chrome extension (Manifest V3)
scripts/ — vendor-nostr-tools.sh, build-extension.sh
test/ — bunker.test.mjs (node self-check for the NIP-46 signer)
```
## Scripts
```bash
npm test # NIP-46 signer self-check
npm run build:extension # rebuild public/goostr-extension.zip
npm run vendor # rebuild the pinned nostr-tools bundle (when upgrading)
```
## Library use
```js
import { signIn, restoreSession } 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");
```