Googstr/public/goostr.js
Jason Tudisco fcfaec675a Add NIP-46 signer, optional NIP-07 extension, passkey protection, backups
- NIP-46 remote signer in the page (bunker:// and nostrconnect:// pairing)
- Chrome extension (NIP-07) with per-site permission prompts, unlocked by
  the site via externally_connectable; downloadable from the page
- Passkey (WebAuthn PRF) encryption of the Drive key file
- NIP-49 backups: download or save to Drive; reveal nsec
- Fix: oldest key file wins when two devices sign up at once
- Fix: never overwrite an existing kind-0 because a relay was slow
- Fix: EOSE tracking; relays reconnect with backoff; verify event signatures
- One-click re-sign-in (consent only the first time)
- Vendored, pinned nostr-tools; CSP; no third-party scripts but Google's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:33:02 -06:00

364 lines
13 KiB
JavaScript

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