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 | |
| 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
| -rwxr-xr-x | bun.lockb | bin | 17469 -> 21762 bytes | |||
| -rw-r--r-- | bunfig.toml | 3 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 66 | ||||
| -rw-r--r-- | src/i18n.js | 8 | ||||
| -rw-r--r-- | src/passkey.js | 71 | ||||
| -rw-r--r-- | src/pomegranate.js | 163 |
7 files changed, 311 insertions, 1 deletions
| Binary files differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..92864b8 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ + +[install.scopes] +"@jsr" = "https://npm.jsr.io" diff --git a/package.json b/package.json index e492b12..3ba2103 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:minimal": "HAL_FEATURES=none bun run build.js" }, "dependencies": { + "@jsr/fiatjaf__promenade-trusted-dealer": "^0.4.3", "@noble/ciphers": "^2.1.1", "@scure/base": "^1.2.6", "@scure/bip32": "^1.7.0", diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 9c4156f..fdab973 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -214,10 +214,74 @@ export function nostrLoginFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => { stopNostrConnect(); ui.nostrConnectOpen = false; render(); } }, t('cancel')))); } - // The three ways in, as buttons. `run` receives a signer factory. + // "Sign in with Google" — pomegranate: the code (and its FROST dealer) + // rides in a lazy chunk, so the popup must open HERE, inside the click's + // user activation, and the module navigates it once loaded. + async function googleLogin(run) { + const popup = window.open('about:blank', 'OAuth', 'width=600,height=600'); + ui.nostrLoginError = ''; + try { + const pome = await import('../pomegranate.js'); + const { uri } = await pome.loginWithGoogle(popup, { + onStatus: (s) => { ui.nostrLoginStatus = t('nlGoogleStatus_' + s); render(); }, + }); + ui.nostrLoginStatus = ''; + await run(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } })); + } catch (e) { + try { popup && popup.close(); } catch {} + ui.nostrLoginStatus = ''; + ui.nostrLoginError = e.message; + render(); + } + } + + // Passkey sign-in: try an existing credential first; when the device has + // none (or the user dismisses the picker), offer to create one instead of + // showing a bare error. + async function passkeyLogin(run) { + ui.nostrLoginError = ''; + const pass = await import('../passkey.js'); + try { + const sk = await pass.passkeySignIn(); + await run(() => keySigner(sk)); + } catch (e) { + if (/derive a wallet key/.test(e.message)) { ui.nostrLoginError = e.message; render(); return; } + ui.passkeyOffer = true; // no credential picked — offer creation + render(); + } + } + async function passkeyCreateLogin(run) { + ui.nostrLoginError = ''; + ui.passkeyOffer = null; + try { + const pass = await import('../passkey.js'); + const sk = await pass.passkeyCreate(); + await run(() => keySigner(sk)); + } catch (e) { + ui.nostrLoginError = e.message; + render(); + } + } + + // The ways in, as buttons. `run` receives a signer factory. function signerButtons(run) { const hasExt = typeof window !== 'undefined' && !!window.nostr; + const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; return h('div', { class: 'col', style: 'gap:8px' }, + h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => googleLogin(run) }, t('nlGoogle')), + ui.nostrLoginStatus ? h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, + h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, ui.nostrLoginStatus)) : null, + hasPasskey + ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => passkeyLogin(run) }, t('nlPasskey')) + : null, + ui.passkeyOffer + ? h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small muted grow' }, t('nlPasskeyNone')), + h('button', { class: 'btn-sm', disabled: ui.nostrLoginBusy, onClick: () => passkeyCreateLogin(run) }, t('nlPasskeyCreate')), + h('button', { class: 'btn-sm btn-ghost', onClick: () => { ui.passkeyOffer = null; render(); } }, t('cancel'))) + : null, hasExt ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => extensionSigner()) }, t('nlExtension')) diff --git a/src/i18n.js b/src/i18n.js index 44dbde8..3e10f4f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -626,6 +626,14 @@ const STR = { nlTitle: 'Log in with Nostr', nlDesc: 'Use your Nostr account to open your wallet — on any device, with the same account.', nlExtension: 'Use browser extension', + nlGoogle: 'Sign in with Google', + nlGoogleStatus_checking: 'Checking your account…', + nlGoogleStatus_splitting: 'Creating your key (split across signers)…', + nlGoogleStatus_registering: 'Registering with signers…', + nlGoogleStatus_connecting: 'Connecting…', + nlPasskey: 'Sign in with a passkey', + nlPasskeyNone: 'No passkey was used. Create one for this wallet?', + nlPasskeyCreate: 'Create passkey', nlKeyOrBunker: 'nsec, private key, or bunker://…', nlGo: 'Log in', nlHint: 'Works with Amber, Amethyst and other remote signers (paste their bunker:// address), or a browser extension. A pasted key is used once and never stored.', diff --git a/src/passkey.js b/src/passkey.js new file mode 100644 index 0000000..4bbd01d --- /dev/null +++ b/src/passkey.js @@ -0,0 +1,71 @@ +// Passkey sign-in: WebAuthn's PRF extension turns a passkey into a +// deterministic 32-byte secret, which hashes into a nostr key — and the +// login flow then treats it exactly like a pasted nsec, so the wallet seed +// derives from it the usual way. No server anywhere: the wallet follows the +// passkey wherever the platform syncs it (iCloud Keychain, Google Password +// Manager), and the same passkey always opens the same wallet because the +// PRF salt is a fixed app-scoped constant. +import { sha256 } from '@noble/hashes/sha256'; +import { secp256k1 } from '@noble/curves/secp256k1'; + +const PRF_SALT = sha256(new TextEncoder().encode('coinos-passkey-v1')); +const DERIVE_TAG = new TextEncoder().encode('coinos-passkey-key:v1'); + +export const passkeySupported = () => + typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; + +// Hash the PRF output into a valid secp256k1 scalar. An out-of-range hash is +// a ~2^-128 event, but the loop makes the derivation total rather than lucky. +function skFromPrf(prf) { + let buf = new Uint8Array(DERIVE_TAG.length + prf.length); + buf.set(DERIVE_TAG, 0); + buf.set(prf, DERIVE_TAG.length); + let sk = sha256(buf); + while (!secp256k1.utils.isValidPrivateKey(sk)) sk = sha256(sk); + return sk; +} + +const NO_PRF = 'this device’s passkeys can’t derive a wallet key (no PRF support)'; + +async function assertionPrf(allowCredentials) { + const cred = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + userVerification: 'required', + ...(allowCredentials ? { allowCredentials } : {}), + extensions: { prf: { eval: { first: PRF_SALT } } }, + }, + }); + const out = cred && cred.getClientExtensionResults().prf?.results?.first; + if (!out) throw new Error(NO_PRF); + return new Uint8Array(out); +} + +// Sign in with an existing (discoverable) passkey. +export async function passkeySignIn() { + return skFromPrf(await assertionPrf(null)); +} + +// Create a passkey, then derive the key from it. Some platforms evaluate PRF +// during creation; the rest need a follow-up assertion against the new +// credential — both paths end at the same bytes. +export async function passkeyCreate() { + const cred = await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { name: 'Coinos', id: location.hostname }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: 'coinos', displayName: 'Coinos wallet', + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], + authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, + extensions: { prf: { eval: { first: PRF_SALT } } }, + }, + }); + const ext = cred.getClientExtensionResults(); + const direct = ext.prf?.results?.first; + if (direct) return skFromPrf(new Uint8Array(direct)); + if (ext.prf && ext.prf.enabled === false) throw new Error(NO_PRF); + return skFromPrf(await assertionPrf([{ type: 'public-key', id: cred.rawId }])); +} 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 }; +} |
