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:
Generated
+105
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "^2.15.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
|
||||
+41
-8
@@ -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>
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { buildNip98Header } from './nip07';
|
||||
import { buildNip98Header } from './signer';
|
||||
|
||||
export interface PurchaseQuote {
|
||||
quoteId: string;
|
||||
|
||||
@@ -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))}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user