- 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>
364 lines
13 KiB
JavaScript
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");
|
|
}
|