From 70b02f00c63ba6f6cae939a5ebdedac6e3552eb6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 23:19:23 -0700 Subject: Nostr login: connect a signer app by deep link or QR (nostrconnect://) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-initiated NIP-46 flow: coinos mints a nostrconnect:// URI — rendered as a tappable deep link (launches Amber & friends directly, no bunker URL to copy) and a QR for cross-device scanning — then waits on the relay; when the signer app answers with the secret, login continues by itself. One live attempt at a time, cancellable, three-minute timeout, and kind 24133 joins the relay whitelist so our own relay carries the handshake. The bunker:// paste path stays for signers that prefer it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/nostr-login.js | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) (limited to 'src/nostr-login.js') diff --git a/src/nostr-login.js b/src/nostr-login.js index 077d9a6..b142dd7 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -38,7 +38,7 @@ import { entropyToMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english'; import { getPublicKey, finalizeEvent, nip44 } from './nostr.js'; import { SimplePool } from 'nostr-tools/pool'; -import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'; +import { BunkerSigner, parseBunkerInput, createNostrConnectURI } from 'nostr-tools/nip46'; import { generateSecretKey } from 'nostr-tools/pure'; const SEED_DTAG = 'coinos:wallet:v1'; @@ -102,6 +102,10 @@ export async function bunkerSigner(uri, { onAuth } = {}) { }); await signer.connect(); const pubkey = await signer.getPublicKey(); + return bunkerAdapter(signer, pubkey); +} + +function bunkerAdapter(signer, pubkey) { return { kind: 'bunker', pubkey, label: 'remote signer', signEvent: (e) => signer.signEvent(e), @@ -113,6 +117,33 @@ export async function bunkerSigner(uri, { onAuth } = {}) { }; } +// Client-initiated NIP-46: WE mint a nostrconnect:// URI; a signer app +// (Amber, nsec.app, …) opens or scans it and connects back to us over the +// relay — no bunker URL to copy. Returns the URI (render it as a deep link +// and a QR), a promise that resolves to a signer adapter when the app +// answers, and a cancel. +export function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol'], timeoutMs = 180_000 } = {}) { + const local = generateSecretKey(); + const secret = hex.encode(crypto.getRandomValues(new Uint8Array(16))); + const uri = createNostrConnectURI({ + clientPubkey: getPublicKey(local), + relays, + secret, + name: 'coinos', + url: 'https://v3.coinos.io', + perms: ['get_public_key', 'sign_event', 'nip44_encrypt', 'nip44_decrypt'], + }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error('signer connect timed out')), timeoutMs); + const ready = (async () => { + const signer = await BunkerSigner.fromURI(local, uri, {}, controller.signal); + const pubkey = await signer.getPublicKey(); + return bunkerAdapter(signer, pubkey); + })(); + ready.finally(() => clearTimeout(timer)).catch(() => {}); + return { uri, ready, cancel: (reason) => controller.abort(reason || new Error('cancelled')) }; +} + // A pasted key: signing happens locally, and the key is never persisted. export function keySigner(sk) { const pubkey = getPublicKey(sk); -- cgit v1.2.3