feat: NIP-46 remote signer via QR (Primal, or any nostrconnect-compatible app)

Adds a second way to sign in besides a NIP-07 browser extension: generate
a nostrconnect:// URI, show it as a QR code, and resolve once a remote
signer app (Primal mobile, etc.) scans and approves it. Session persists
in localStorage so a page reload doesn't require re-scanning.

lib/nip07.ts renamed to lib/signer.ts and generalized — it now abstracts
over both signer kinds behind the same {getPublicKey, signEvent} shape,
so buildNip98Header and the rest of the app don't care which is active.

On a branch (feature/nip46-qr-signer) rather than main, since this is
explicitly experimental and easy rollback matters here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 00:51:34 +00:00
co-authored by Claude Sonnet 5
parent f6f16fbbda
commit ead3a9ae44
7 changed files with 346 additions and 59 deletions
+105
View File
@@ -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",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"nostr-tools": "^2.15.0",
"qrcode": "^1.5.4",
"vue": "^3.5.0"
},
+41 -8
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { hasNip07 } from './lib/nip07';
import { onMounted, reactive, ref } from 'vue';
import { activeSignerPubkey, disconnectNip46, hasNip46, hasSigner, restoreNip46Session } from './lib/signer';
import { parseCatalogOpml, type CatalogEntry } from './lib/opml';
import { parseFeedXml, type PlayerEpisode, type PlayerFeed } from './lib/rss';
import {
@@ -20,6 +20,7 @@ import {
savePurchase,
} from './lib/storage';
import PurchaseModal from './components/PurchaseModal.vue';
import ConnectSignerModal from './components/ConnectSignerModal.vue';
import Player from './components/Player.vue';
const subscriptions = ref(loadSubscriptions());
@@ -130,8 +131,8 @@ async function playOrBuy(episode: PlayerEpisode): Promise<void> {
return;
}
if (!hasNip07()) {
feedError.value = 'This episode is priced — install a NIP-07 Nostr extension (e.g. Alby, nos2x) to buy it.';
if (!hasSigner()) {
feedError.value = 'This episode is priced — connect a NIP-07 extension or a QR signer (e.g. Primal) to buy it.';
return;
}
@@ -183,7 +184,23 @@ async function checkPayment(): Promise<void> {
}
}
const nip07Available = computed(() => hasNip07());
const signerPubkey = ref<string | null>(null);
const showConnectModal = ref(false);
onMounted(async () => {
await restoreNip46Session();
signerPubkey.value = await activeSignerPubkey();
});
function onSignerConnected(pubkey: string): void {
signerPubkey.value = pubkey;
showConnectModal.value = false;
}
function disconnectSigner(): void {
disconnectNip46();
signerPubkey.value = null;
}
</script>
<template>
@@ -191,9 +208,19 @@ const nip07Available = computed(() => hasNip07());
<header>
<h1 class="text-2xl font-bold text-white">podsteadr player</h1>
<p class="text-sm text-white/60">Browse, pay, and play podsteadr feeds.</p>
<p v-if="!nip07Available" class="mt-1 text-xs text-orange-400">
No NIP-07 extension detected free episodes still play, but buying priced ones needs one.
</p>
<div v-if="signerPubkey" class="mt-1 flex items-center gap-2 text-xs text-white/50">
<span>Signed in as <span class="font-mono">{{ signerPubkey.slice(0, 12) }}</span></span>
<button v-if="hasNip46()" type="button" class="text-orange-400 underline" @click="disconnectSigner">
Disconnect
</button>
</div>
<div v-else class="mt-1 flex items-center gap-2 text-xs text-orange-400">
<span>No signer connected free episodes still play, but buying priced ones needs one.</span>
<button type="button" class="text-orange-400 underline" @click="showConnectModal = true">
Connect with QR
</button>
</div>
</header>
<section class="card space-y-3">
@@ -273,5 +300,11 @@ const nip07Available = computed(() => hasNip07());
@check="checkPayment"
@close="purchase = null"
/>
<ConnectSignerModal
v-if="showConnectModal"
@connected="onSignerConnected"
@close="showConnectModal = false"
/>
</div>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import QRCode from 'qrcode';
import { connectNip46ViaQr } from '../lib/signer';
const emit = defineEmits<{
connected: [pubkey: string];
close: [];
}>();
const qrDataUrl = ref<string | null>(null);
const connecting = ref(true);
const error = ref<string | null>(null);
async function start(): Promise<void> {
connecting.value = true;
error.value = null;
qrDataUrl.value = null;
try {
const pubkey = await connectNip46ViaQr(async (uri) => {
qrDataUrl.value = await QRCode.toDataURL(uri, { margin: 1, width: 320 });
});
emit('connected', pubkey);
} catch (err) {
error.value = (err as Error).message;
} finally {
connecting.value = false;
}
}
onMounted(start);
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div class="card w-full max-w-sm space-y-4 text-center">
<h2 class="text-lg font-semibold text-white">Connect a remote signer</h2>
<p class="text-sm text-white/60">Scan this with Primal (or any NIP-46-compatible signer app) to sign in without a browser extension.</p>
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Nostr Connect QR code" class="mx-auto rounded-lg" />
<p v-if="connecting && !error" class="text-xs text-white/50">Waiting for approval</p>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex gap-2">
<button type="button" class="btn-secondary flex-1" @click="emit('close')">Cancel</button>
<button v-if="error" type="button" class="btn-primary flex-1" @click="start">Retry</button>
</div>
</div>
</div>
</template>
+1 -1
View File
@@ -1,4 +1,4 @@
import { buildNip98Header } from './nip07';
import { buildNip98Header } from './signer';
export interface PurchaseQuote {
quoteId: string;
-50
View File
@@ -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<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
}
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<string> {
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))}`;
}
+147
View File
@@ -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<string>;
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
}
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<SignedEvent> {
if (nip46Signer) return nip46Signer.signEvent(event) as Promise<SignedEvent>;
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<string> {
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<string> {
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<boolean> {
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<string | null> {
if (nip46Signer) return nip46Pubkey;
if (hasNip07()) return nip07().getPublicKey();
return null;
}