diff --git a/package-lock.json b/package-lock.json index 78d26ab..5407e4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "podsteadr-player", "version": "0.1.0", "dependencies": { + "nostr-tools": "^2.15.0", "qrcode": "^1.5.4", "vue": "^3.5.0" }, @@ -561,6 +562,45 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -956,6 +996,42 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz", + "integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz", + "integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1926,6 +2002,35 @@ "node": ">=0.10.0" } }, + "node_modules/nostr-tools": { + "version": "2.24.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.1.tgz", + "integrity": "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "2.1.1", + "@noble/curves": "2.0.1", + "@noble/hashes": "2.0.1", + "@scure/base": "2.0.0", + "@scure/bip32": "2.0.1", + "@scure/bip39": "2.0.1", + "nostr-wasm": "0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 44fe578..9dbdd84 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "nostr-tools": "^2.15.0", "qrcode": "^1.5.4", "vue": "^3.5.0" }, diff --git a/src/App.vue b/src/App.vue index e8fe931..2bdbe21 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,6 @@ diff --git a/src/components/ConnectSignerModal.vue b/src/components/ConnectSignerModal.vue new file mode 100644 index 0000000..994a988 --- /dev/null +++ b/src/components/ConnectSignerModal.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/lib/api.ts b/src/lib/api.ts index eb945c0..4c97d97 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import { buildNip98Header } from './nip07'; +import { buildNip98Header } from './signer'; export interface PurchaseQuote { quoteId: string; diff --git a/src/lib/nip07.ts b/src/lib/nip07.ts deleted file mode 100644 index fcd48bd..0000000 --- a/src/lib/nip07.ts +++ /dev/null @@ -1,50 +0,0 @@ -// NIP-07 browser extension bridge + NIP-98 header construction. -// Copied from podsteadr/frontend/src/lib/nip07.ts — same bridge, same header format, so a -// purchase/confirm/download-url request signed here verifies identically server-side. - -export interface UnsignedEvent { - kind: number; - created_at: number; - content: string; - tags: string[][]; -} - -export interface SignedEvent extends UnsignedEvent { - id: string; - pubkey: string; - sig: string; -} - -interface Nip07Provider { - getPublicKey(): Promise; - signEvent(event: UnsignedEvent): Promise; -} - -declare global { - interface Window { - nostr?: Nip07Provider; - } -} - -export function hasNip07(): boolean { - return typeof window !== 'undefined' && !!window.nostr; -} - -export function nip07(): Nip07Provider { - if (!window.nostr) throw new Error('No NIP-07 nostr extension found'); - return window.nostr; -} - -/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */ -export async function buildNip98Header(url: string, method: string): Promise { - const event = await nip07().signEvent({ - kind: 27235, - created_at: Math.floor(Date.now() / 1000), - content: '', - tags: [ - ['u', url], - ['method', method], - ], - }); - return `Nostr ${btoa(JSON.stringify(event))}`; -} diff --git a/src/lib/signer.ts b/src/lib/signer.ts new file mode 100644 index 0000000..f79ab82 --- /dev/null +++ b/src/lib/signer.ts @@ -0,0 +1,147 @@ +// Signing abstraction: either a NIP-07 browser extension, or a NIP-46 remote signer connected +// by scanning a "nostrconnect://" QR code (Primal mobile, or any other NIP-46-compatible +// signer app). Both are exposed through the same shape so the rest of the app (NIP-98 header +// construction, the "do we have a signer at all" check) doesn't care which one is active. + +import { generateSecretKey, getPublicKey as pubkeyFromSecret } from 'nostr-tools/pure'; +import { BunkerSigner, createNostrConnectURI } from 'nostr-tools/nip46'; +import { hexToBytes, bytesToHex } from 'nostr-tools/utils'; + +export interface UnsignedEvent { + kind: number; + created_at: number; + content: string; + tags: string[][]; +} + +export interface SignedEvent extends UnsignedEvent { + id: string; + pubkey: string; + sig: string; +} + +interface Nip07Provider { + getPublicKey(): Promise; + signEvent(event: UnsignedEvent): Promise; +} + +declare global { + interface Window { + nostr?: Nip07Provider; + } +} + +export function hasNip07(): boolean { + return typeof window !== 'undefined' && !!window.nostr; +} + +function nip07(): Nip07Provider { + if (!window.nostr) throw new Error('No NIP-07 nostr extension found'); + return window.nostr; +} + +const NIP46_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']; +const NIP46_STORAGE_KEY = 'podsteadr-player:nip46-session'; + +interface Nip46Session { + clientSecretKeyHex: string; + bunkerPubkey: string; + relays: string[]; + secret: string; +} + +let nip46Signer: BunkerSigner | null = null; +let nip46Pubkey: string | null = null; + +export function hasNip46(): boolean { + return nip46Signer !== null; +} + +/** True if either a NIP-07 extension or a connected NIP-46 remote signer is available. */ +export function hasSigner(): boolean { + return hasNip07() || hasNip46(); +} + +async function signWithActiveSigner(event: UnsignedEvent): Promise { + if (nip46Signer) return nip46Signer.signEvent(event) as Promise; + return nip07().signEvent(event); +} + +/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */ +export async function buildNip98Header(url: string, method: string): Promise { + const event = await signWithActiveSigner({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: [ + ['u', url], + ['method', method], + ], + }); + return `Nostr ${btoa(JSON.stringify(event))}`; +} + +/** + * Starts a NIP-46 handshake: generates a "nostrconnect://" URI (passed to `onUri` so the caller + * can render it as a QR code), then resolves once a remote signer scans it and approves the + * connection. The session is persisted so a page reload doesn't require scanning again. + */ +export async function connectNip46ViaQr(onUri: (uri: string) => void, timeoutMs = 120_000): Promise { + const clientSecretKey = generateSecretKey(); + const secret = Math.random().toString(36).slice(2); + const uri = createNostrConnectURI({ + clientPubkey: pubkeyFromSecret(clientSecretKey), + relays: NIP46_RELAYS, + secret, + name: 'podsteadr player', + }); + onUri(uri); + + const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, timeoutMs); + nip46Signer = signer; + nip46Pubkey = await signer.getPublicKey(); + + const session: Nip46Session = { + clientSecretKeyHex: bytesToHex(clientSecretKey), + bunkerPubkey: signer.bp.pubkey, + relays: signer.bp.relays, + secret: signer.bp.secret ?? '', + }; + localStorage.setItem(NIP46_STORAGE_KEY, JSON.stringify(session)); + + return nip46Pubkey; +} + +/** Reconnects to a previously-approved NIP-46 session without needing to scan a QR again. */ +export async function restoreNip46Session(): Promise { + const raw = localStorage.getItem(NIP46_STORAGE_KEY); + if (!raw) return false; + try { + const session = JSON.parse(raw) as Nip46Session; + const signer = BunkerSigner.fromBunker(hexToBytes(session.clientSecretKeyHex), { + pubkey: session.bunkerPubkey, + relays: session.relays, + secret: session.secret || null, + }); + nip46Pubkey = await signer.getPublicKey(); + nip46Signer = signer; + return true; + } catch { + localStorage.removeItem(NIP46_STORAGE_KEY); + return false; + } +} + +export function disconnectNip46(): void { + void nip46Signer?.close(); + nip46Signer = null; + nip46Pubkey = null; + localStorage.removeItem(NIP46_STORAGE_KEY); +} + +/** Pubkey of whichever signer is currently active, or null if none. */ +export async function activeSignerPubkey(): Promise { + if (nip46Signer) return nip46Pubkey; + if (hasNip07()) return nip07().getPublicKey(); + return null; +}