aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-26 21:03:07 -0700
committerAdam Soltys <[email protected]>2026-08-26 21:03:07 -0700
commit358ffb856892bff8e99287d3debc5937ed5183ef (patch)
tree21cd9a381ebd1aa620d1db4ae965ca27a338ac1b /src
parent181cf4d5d0ffb7ae3ccd337745a16619a35eaae1 (diff)
downloadcoinosv3-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')
-rw-r--r--src/features/nostrlogin.js66
-rw-r--r--src/i18n.js8
-rw-r--r--src/passkey.js71
-rw-r--r--src/pomegranate.js163
4 files changed, 307 insertions, 1 deletions
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 };
+}