// Goostr — Google-account-to-nostr login library. // // Flow: // signIn({ clientId }) // → opens Google sign-in popup // → requests OAuth scopes: profile, email, drive.appdata // → looks in Drive App Data for goostr-key-v1.json // · if found, downloads the 32-byte seed // · if missing, generates 32 fresh bytes and uploads them // → returns { signer, profile } where profile is the Google name/picture/email // // The 32-byte seed is the nostr private key. It lives in: // 1. The user's Drive App Data — survives device loss, syncs across devices. // 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, nip19 } from "nostr-tools"; import { findAppDataFile, downloadFile, uploadAppDataJson, fetchUserInfo, } from "./drive.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(" "); // ---------- 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 = () => reject(new Error("Failed to load Google Identity Services script.")); document.head.appendChild(s); }); return gisReady; } async function requestAccessToken(clientId, { silent = false } = {}) { await ensureGisLoaded(); return new Promise((resolve, reject) => { const client = google.accounts.oauth2.initTokenClient({ client_id: clientId, scope: SCOPES, prompt: silent ? "" : "consent", 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.")); resolve(resp.access_token); }, error_callback: (err) => reject(new Error(`Google auth: ${err.type || "error"}`)), }); client.requestAccessToken(); }); } // ---------- key bytes / signer ---------- function randomBytes(len) { const buf = new Uint8Array(len); crypto.getRandomValues(buf); return buf; } function b64encBytes(bytes) { let s = ""; for (const b of bytes) s += String.fromCharCode(b); return btoa(s); } function b64decBytes(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; } function makeSigner(privKeyBytes) { const pubkey = getPublicKey(privKeyBytes); return { pubkey, npub: nip19.npubEncode(pubkey), sign(unsignedEvent) { return finalizeEvent( { ...unsignedEvent, pubkey, created_at: unsignedEvent.created_at ?? Math.floor(Date.now() / 1000), }, privKeyBytes ); }, }; } // ---------- session persistence (tab-scoped) ---------- const SESSION_KEY = "goostr.session.v1"; const SESSION_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12h ceiling function saveSession(privKeyBytes, profile) { try { sessionStorage.setItem( SESSION_KEY, JSON.stringify({ k: b64encBytes(privKeyBytes), p: profile, t: Date.now() }) ); } catch {} } export function restoreSession() { try { const raw = sessionStorage.getItem(SESSION_KEY); if (!raw) return null; const { k, p, t } = JSON.parse(raw); if (!k || !t || Date.now() - t > SESSION_MAX_AGE_MS) { sessionStorage.removeItem(SESSION_KEY); return null; } return { signer: makeSigner(b64decBytes(k)), profile: p || {} }; } catch { return null; } } export function clearSession() { try { sessionStorage.removeItem(SESSION_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 ---------- export async function signIn({ clientId, silent = false, onStatus } = {}) { if (!clientId || clientId.startsWith("PASTE-")) { throw new Error("CLIENT_ID is not configured. Edit public/config.js with your Google OAuth client ID. See README.md."); } onStatus?.("Signing in with Google…"); const accessToken = await requestAccessToken(clientId, { silent }); 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, }; const { bytes, created } = await loadOrCreateSeed(accessToken, onStatus); saveSession(bytes, profile); return { signer: makeSigner(bytes), profile, isNewIdentity: created }; } export function isConfigured(clientId) { return !!clientId && !clientId.startsWith("PASTE-") && clientId.endsWith(".apps.googleusercontent.com"); }