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:
parent
21093eb800
commit
fcfaec675a
119
README.md
119
README.md
@ -4,44 +4,74 @@ Your Google account is your nostr identity. No server, no key management.
|
|||||||
|
|
||||||
**Pure static site.** No build step. No backend. No install required to run.
|
**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
171
extension/background.js
Normal 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
6
extension/config.js
Normal 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
12
extension/content.js
Normal 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
18
extension/ext.css
Normal 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
27
extension/manifest.json
Normal 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
28
extension/popup.html
Normal 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
30
extension/popup.js
Normal 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
24
extension/prompt.html
Normal 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
46
extension/prompt.js
Normal 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
39
extension/provider.js
Normal 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
2
extension/vendor/nostr-tools.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
package.json
22
package.json
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
386
public/app.js
386
public/app.js
@ -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
193
public/bunker.js
Normal 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 "?"; }
|
||||||
|
}
|
||||||
@ -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 = "";
|
||||||
|
|||||||
@ -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
BIN
public/goostr-extension.zip
Normal file
Binary file not shown.
301
public/goostr.js
301
public/goostr.js
@ -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) {
|
||||||
|
|||||||
@ -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
92
public/passkey.js
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
130
public/relay.js
130
public/relay.js
@ -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) {
|
||||||
|
|||||||
128
public/style.css
128
public/style.css
@ -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
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
10
scripts/build-extension.sh
Executable 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
24
scripts/vendor-nostr-tools.sh
Executable 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
90
test/bunker.test.mjs
Normal 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");
|
||||||
Loading…
x
Reference in New Issue
Block a user