156 lines
8.9 KiB
Markdown
156 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.
|
|
|
|
**Live:** <https://arctic-prana-2wh7.here.now/> (hosted on here.now)
|
|
|
|
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");
|
|
```
|