// 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"); }