diff options
| author | Adam Soltys <[email protected]> | 2026-08-26 21:03:07 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-26 21:03:07 -0700 |
| commit | 358ffb856892bff8e99287d3debc5937ed5183ef (patch) | |
| tree | 21cd9a381ebd1aa620d1db4ae965ca27a338ac1b /src/pomegranate.js | |
| parent | 181cf4d5d0ffb7ae3ccd337745a16619a35eaae1 (diff) | |
| download | coinosv3-358ffb856892bff8e99287d3debc5937ed5183ef.tar.gz coinosv3-358ffb856892bff8e99287d3debc5937ed5183ef.zip | |
Two new doors, no new locks: "Sign in with Google" (pomegranate — the nostr key FROST-sharded 2-of-3 across community operators, Google as coordinator and recovery, a plain bunker:// URI feeding the existing signer flow) and passkey sign-in (WebAuthn PRF derives the key deterministically, treated exactly like a pasted nsec, synced wherever the platform carries the passkey) — both lazy chunks, neither touching the first paint
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
Diffstat (limited to 'src/pomegranate.js')
| -rw-r--r-- | src/pomegranate.js | 163 |
1 files changed, 163 insertions, 0 deletions
diff --git a/src/pomegranate.js b/src/pomegranate.js new file mode 100644 index 0000000..59a1d3b --- /dev/null +++ b/src/pomegranate.js @@ -0,0 +1,163 @@ +// "Sign in with Google" via pomegranate (fiatjaf): the user's nostr key is +// FROST-sharded across independent operators, and a central server +// coordinates NIP-46 signing behind Google OAuth — nobody, central included, +// holds the whole key, yet the Google account alone recovers it (threshold +// shards come back from the operators' /po/recover/google). The output here +// is a plain bunker:// URI: the rest of the login is hal's ordinary bunker +// flow, so the wallet-seed backup (encrypted to the user's own key, on +// relays) works unchanged and Google recovers the wallet too. +// +// Dynamically imported (its FROST dealer dependency rides in this chunk) — +// nothing here loads before the button is pressed. +// +// Reference: https://viewsource.win/fiatjaf.com/pomegranate — the +// implementation-guide steps are numbered in comments below. The kind-16440 +// cross-central discovery step is deliberately skipped: it needs argon2id for +// nothing while the ecosystem has a single central server. +import { sha256 } from '@noble/hashes/sha256'; +import { hex } from '@scure/base'; +import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; +import { hexPubShard, hexShard, trustedKeyDeal } from '@jsr/fiatjaf__promenade-trusted-dealer'; + +export const CENTRAL = 'https://auth.njump.me'; +const OPERATORS = [ + 'https://po.f7z.io', + 'https://po.coracle.social', + 'https://po.njump.me', + 'https://po.jumble.social', +]; +const THRESHOLD = 2; +const PICK = 3; + +const api = (token) => async (path, opts = {}) => { + const r = await fetch(CENTRAL + path, { + ...opts, + headers: { Authorization: 'Token ' + token, ...(opts.headers || {}) }, + }); + return r; +}; + +// Step 2-4: the popup is opened by the CALLER inside the click's user +// activation (a popup opened after our chunk finishes loading would be +// blocked); we point it at the login page and wait for the postMessage. +export function authenticate(popup) { + return new Promise((resolve, reject) => { + if (!popup) return reject(new Error('popup blocked')); + popup.location = CENTRAL + '/login/google'; + const timer = setInterval(() => { + if (popup.closed) { cleanup(); reject(new Error('login window closed')); } + }, 500); + const handler = (event) => { + if (event.origin !== CENTRAL || !event.data || !event.data.token) return; + cleanup(); + try { popup.close(); } catch {} + resolve(event.data.token); + }; + const cleanup = () => { clearInterval(timer); window.removeEventListener('message', handler); }; + window.addEventListener('message', handler); + }); +} + +const emailOf = (token) => { + try { + const evt = JSON.parse(atob(token)); + return (evt.tags.find((t) => t[0] === 'email') || [])[1] || null; + } catch { return null; } +}; + +// Steps 8-14: shard a fresh key across randomly-picked operators and register +// everywhere. The secret key exists only inside this function and is wiped on +// the way out — from here on the key lives as shards, nowhere whole. +async function createAccount(token, email, onStatus) { + const ops = [...OPERATORS].sort(() => Math.random() - 0.5).slice(0, PICK); + const session = crypto.randomUUID(); + const sk = generateSecretKey(); + try { + onStatus && onStatus('splitting'); + const skBig = Array.from(sk).reduce((acc, b) => (acc << 8n) + BigInt(b), 0n); + const { shards } = trustedKeyDeal(skBig, THRESHOLD, ops.length); + + onStatus && onStatus('registering'); + const regEvent = finalizeEvent({ + kind: 20445, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['threshold', String(THRESHOLD)], + ...ops.map((op, i) => ['operator', op, hexPubShard(shards[i].pubShard)]), + ], + content: '', + }, sk); + const reg = await api(token)('/register', { + method: 'POST', + body: JSON.stringify(regEvent), + headers: { 'Content-Type': 'application/json', 'X-Pomegranate-Session': session }, + }); + if (!reg.ok) throw new Error('central registration failed (' + reg.status + ')'); + + for (let i = 0; i < ops.length; i++) { + const opEvent = finalizeEvent({ + kind: 20444, + created_at: Math.floor(Date.now() / 1000), + tags: [['central', CENTRAL], ['email', email]], + content: hexShard(shards[i]), + }, sk); + const r = await fetch(ops[i] + '/po/register', { + method: 'POST', + body: JSON.stringify(opEvent), + headers: { + 'Content-Type': 'application/json', + 'X-Pomegranate-Operator-Token': hex.encode(sha256(new TextEncoder().encode(session + ':' + ops[i]))), + }, + }); + if (!r.ok) throw new Error('operator registration failed for ' + ops[i]); + } + } finally { + sk.fill(0); // step 17 + } + + // the operators confirm with central in the background — wait for it + for (let i = 0; i < 20; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const acc = await api(token)('/account'); + if (acc.ok) return acc.json(); + } + throw new Error('operators did not confirm the account in time'); +} + +// Steps 15-16: a reusable "default" profile is the bunker. +async function bunkerUri(token) { + const get = async () => { + const r = await api(token)('/profiles'); + if (!r.ok) throw new Error('could not load signer profiles'); + return r.json(); + }; + let profiles = await get(); + if (!profiles.some((p) => p.name === 'default')) { + const r = await api(token)('/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'default' }), + }); + if (!r.ok) throw new Error('could not create a signer profile'); + profiles = await get(); + } + const def = profiles.find((p) => p.name === 'default') || profiles[0]; + if (!def) throw new Error('no signer profile available'); + return `bunker://${def.handler_pubkey}?relay=${encodeURIComponent(CENTRAL.replace('http', 'ws'))}`; +} + +// The whole flow: authenticate, find-or-create the sharded account, hand back +// a bunker URI. `popup` must come from the caller's click handler. +export async function loginWithGoogle(popup, { onStatus } = {}) { + const token = await authenticate(popup); + const email = emailOf(token); + if (!email) throw new Error('Google login returned no email'); + onStatus && onStatus('checking'); + const acc = await api(token)('/account'); + if (!acc.ok && acc.status !== 404 && acc.status !== 401) { + throw new Error('signer server unavailable (' + acc.status + ')'); + } + if (!acc.ok) await createAccount(token, email, onStatus); + onStatus && onStatus('connecting'); + return { uri: await bunkerUri(token), email }; +} |
