From 73cb08d8568593de926930a9ab4a950054403a11 Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 5 Aug 2026 19:28:26 -0500 Subject: [PATCH] Add NIP-46 remote signer (external signing) Add a remote-signer (bunker) role so other Nostr apps can delegate signing to this app's active profile keys via nostrconnect:// links. Backend: new src/signer.rs implementing the NIP-46 protocol (kind 24133 events encrypted with NIP-44 v2 conversation keys). It parses nostrconnect:// connect URIs, spawns an async task in the serve process that reads relay requests, auto-approves once the handshake completes, signs delegate events, and publishes responses. Exposes status, connect, and disconnect via IPC and CLI (signer status / signer connect). Other: ipc.rs serve/handle now share Arc>; main.rs adds the signer CLI commands; Cargo.toml enables nostr nip46 feature. Frontend: new Signer screen (nav item + sidebar entry with key icon) to paste a nostrconnect:// link, connect/disconnect, and show the connected peer and relays; wires signer_connect/_disconnect/_status through api.ts and AppProvider; adds tests and test mocks. --- Cargo.lock | 2 + Cargo.toml | 1 + frontend/src/App.tsx | 2 + frontend/src/components/Sidebar.tsx | 1 + frontend/src/lib/api.ts | 4 + frontend/src/lib/navigation.ts | 3 +- frontend/src/lib/types.ts | 14 + frontend/src/screens/SignerScreen.tsx | 204 ++++++++ frontend/src/state/AppProvider.tsx | 13 + frontend/src/styles.css | 28 ++ frontend/src/test/SignerScreen.test.tsx | 71 +++ frontend/src/test/apiMock.ts | 35 ++ frontend/src/test/fakeBackend.ts | 32 +- src/ipc.rs | 65 ++- src/lib.rs | 1 + src/main.rs | 51 ++ src/signer.rs | 605 ++++++++++++++++++++++++ 17 files changed, 1125 insertions(+), 7 deletions(-) create mode 100644 frontend/src/screens/SignerScreen.tsx create mode 100644 frontend/src/test/SignerScreen.test.tsx create mode 100644 src/signer.rs diff --git a/Cargo.lock b/Cargo.lock index 46e4c23..76b7e21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -751,6 +751,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f900ddcdc28395759fcd44b18a03255e7deee8858551bfe5d5d5a07311d82ea" dependencies = [ + "aes", "base64", "bech32", "bip39", @@ -789,6 +790,7 @@ dependencies = [ "base64", "getrandom 0.2.17", "hex", + "nostr", "nostr-sdk", "rpassword", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0de3789..d920b4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +nostr = { version = "0.40", features = ["nip44", "nip46"] } nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bc59576..49ae62b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { HomeScreen } from './screens/HomeScreen'; import { ProfilesScreen } from './screens/ProfilesScreen'; import { ComposeScreen } from './screens/ComposeScreen'; import { RelaysScreen } from './screens/RelaysScreen'; +import { SignerScreen } from './screens/SignerScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { CreateProfileModal } from './screens/CreateProfileModal'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; @@ -70,6 +71,7 @@ function Shell() { {screen === 'profiles' && setCreateOpen(true)} />} {screen === 'compose' && } {screen === 'relays' && } + {screen === 'signer' && } {screen === 'settings' && } setCreateOpen(false)} /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 3d980b1..1d1aa0a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -10,6 +10,7 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [ { id: 'profiles', label: 'Profiles', icon: 'users' }, { id: 'compose', label: 'Compose', icon: 'edit' }, { id: 'relays', label: 'Relays', icon: 'relay' }, + { id: 'signer', label: 'Signer', icon: 'key' }, { id: 'settings', label: 'Settings', icon: 'settings' }, ]; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 401c824..e457a6c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import type { RelayTestResult, RevealedKey, Settings, + SignerStatus, UploadedImage, } from './types'; @@ -70,5 +71,8 @@ export const api = { pickImages: () => call('pick_image'), uploadImage: (path: string) => call('upload_image', { path }), linkPreview: (url: string) => call('link_preview', { url }), + signerConnect: (uri: string) => call('signer_connect', { uri }), + signerDisconnect: () => call('signer_disconnect'), + signerStatus: () => call('signer_status'), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index f1858de..b0a3173 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -1,9 +1,10 @@ -export type Screen = 'home' | 'profiles' | 'compose' | 'relays' | 'settings'; +export type Screen = 'home' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings'; export const SCREEN_TITLES: Record = { home: 'Home', profiles: 'Profiles', compose: 'Compose', relays: 'Relays', + signer: 'Signer', settings: 'Settings', }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bd535b4..c0ac97a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,5 +1,19 @@ export type Theme = 'light' | 'dark' | 'system'; +/** Lifecycle of the NIP-46 remote signer. */ +export type SignerPhase = 'stopped' | 'connecting' | 'connected'; + +/** Non-secret snapshot of the NIP-46 remote signer for display. */ +export interface SignerStatus { + phase: SignerPhase; + /** The connected client's hex public key, if any. */ + peer: string | null; + /** Relays used for the connection. */ + relays: string[]; + /** A user-facing error if the signer stopped because of one. */ + error: string | null; +} + /** A safe view of a profile with no secret key material. */ export interface ProfileSummary { label: string; diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx new file mode 100644 index 0000000..937e07b --- /dev/null +++ b/frontend/src/screens/SignerScreen.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { Alert } from '../components/Alert'; +import { Badge } from '../components/Badge'; +import { Button } from '../components/Button'; +import { ErrorText } from '../components/ErrorText'; +import { Icon } from '../components/Icon'; +import type { SignerStatus } from '../lib/types'; +import { useApp } from '../state/AppProvider'; + +const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], error: null }; + +/** Shorten a 64-char hex key for display. */ +function shortHex(value: string): string { + return value.length > 16 ? `${value.slice(0, 8)}…${value.slice(-8)}` : value; +} + +export function SignerScreen() { + const { state, signerConnect, signerDisconnect, signerStatus } = useApp(); + const [status, setStatus] = useState(EMPTY_STATUS); + const [uri, setUri] = useState(''); + const [error, setError] = useState(null); + const [connecting, setConnecting] = useState(false); + const [loading, setLoading] = useState(true); + + const refresh = async () => { + try { + setStatus(await signerStatus()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const vaultLocked = state?.vault_locked ?? false; + + const onConnect = async (event: FormEvent) => { + event.preventDefault(); + const trimmed = uri.trim(); + if (!trimmed.startsWith('nostrconnect://')) { + setError('Paste the nostrconnect:// link that the Nostr app generated.'); + return; + } + setError(null); + setConnecting(true); + try { + setStatus(await signerConnect(trimmed)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setStatus(await signerStatus().catch(() => EMPTY_STATUS)); + } finally { + setConnecting(false); + } + }; + + const onDisconnect = async () => { + setError(null); + try { + setStatus(await signerDisconnect()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const badge = () => { + switch (status.phase) { + case 'connected': + return Connected; + case 'connecting': + return Connecting…; + default: + return Not connected; + } + }; + + const isActive = status.phase === 'connected' || status.phase === 'connecting'; + + return ( +
+
+
+
+

Signer

+

+ Securely sign for another Nostr app. Paste its nostrconnect:// link to let this app + approve its requests with the active profile's keys. +

+
+
+ + {vaultLocked && ( + + The signer signs with the active profile's key, which is locked. Unlock the vault + before connecting. + + )} + +
+
+

Status

+
{badge()}
+
+
+
+
+
Client
+
+ {status.peer ? ( + + {shortHex(status.peer)} + + ) : ( + None yet + )} +
+
+
+
Relays
+
+ {status.relays.length > 0 ? ( + status.relays.map((relay) => ( + + {relay} + + )) + ) : ( + None + )} +
+
+
+ {status.error && ( + + {status.error} + + )} +
+
+ +
+
+

Connect a Nostr app

+
+
+ {isActive ? ( +
+

+ The signer is listening. Requests from the connected app are approved + automatically. +

+ +
+ ) : ( +
+
+ + setUri(event.target.value)} + autoComplete="off" + spellCheck={false} + /> +

+ In the Nostr app, choose “use a remote signer” and copy the link it + generates here. +

+
+ {error && {error}} +
+ + +
+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 724319f..57adc26 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -17,6 +17,7 @@ import type { RelayTestResult, RevealedKey, Settings, + SignerStatus, Theme, UploadedImage, } from '../lib/types'; @@ -55,6 +56,9 @@ interface AppContextValue { pickImages: () => Promise; uploadImage: (path: string) => Promise; linkPreview: (url: string) => Promise; + signerConnect: (uri: string) => Promise; + signerDisconnect: () => Promise; + signerStatus: () => Promise; copyText: (text: string) => Promise; } @@ -168,6 +172,9 @@ export function AppProvider({ children }: { children: ReactNode }) { const pickImages = useCallback(() => api.pickImages(), []); const uploadImage = useCallback((path: string) => api.uploadImage(path), []); const linkPreview = useCallback((url: string) => api.linkPreview(url), []); + const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []); + const signerDisconnect = useCallback(() => api.signerDisconnect(), []); + const signerStatus = useCallback(() => api.signerStatus(), []); const copyText = useCallback((text: string) => api.copyText(text), []); @@ -199,6 +206,9 @@ export function AppProvider({ children }: { children: ReactNode }) { pickImages, uploadImage, linkPreview, + signerConnect, + signerDisconnect, + signerStatus, copyText, }), [ @@ -226,6 +236,9 @@ export function AppProvider({ children }: { children: ReactNode }) { pickImages, uploadImage, linkPreview, + signerConnect, + signerDisconnect, + signerStatus, copyText, ], ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index af03727..ed4f42c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1481,3 +1481,31 @@ select { font-size: 13px; word-break: break-word; } + +.signer-badge { + display: flex; + align-items: center; +} + +.signer-status { + display: flex; + flex-direction: column; + gap: 14px; +} + +.signer-relay { + display: inline-block; + margin-right: 8px; + padding: 2px 8px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 12px; +} + +.signer-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} diff --git a/frontend/src/test/SignerScreen.test.tsx b/frontend/src/test/SignerScreen.test.tsx new file mode 100644 index 0000000..e6bcb8a --- /dev/null +++ b/frontend/src/test/SignerScreen.test.tsx @@ -0,0 +1,71 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SignerScreen } from '../screens/SignerScreen'; +import { renderWithApp } from './render'; +import { createFakeBackend, installFakeBackend } from './fakeBackend'; + +describe('SignerScreen', () => { + it('shows the not-connected state by default', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + renderWithApp(); + + expect(await screen.findByText('Not connected')).toBeInTheDocument(); + expect(backend.requests.some((r) => r.method === 'signer_status')).toBe(true); + expect(screen.getByPlaceholderText('nostrconnect://…')).toBeInTheDocument(); + }); + + it('rejects a link that does not start with nostrconnect://', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Not connected'); + await user.type(screen.getByPlaceholderText('nostrconnect://…'), 'https://example.com'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('nostrconnect://'); + expect(backend.signer.phase).toBe('stopped'); + }); + + it('connects via a valid nostrconnect:// link and shows the peer + relays', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Not connected'); + await user.type( + screen.getByPlaceholderText('nostrconnect://…'), + 'nostrconnect://alice@relay.damus.io?relay=wss%3A%2F%2Frelay.damus.io', + ); + await user.click(screen.getByRole('button', { name: 'Connect' })); + + expect(await screen.findByText('Connected')).toBeInTheDocument(); + expect(screen.getByText('wss://relay.damus.io')).toBeInTheDocument(); + expect(backend.signer.phase).toBe('connected'); + expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true); + }); + + it('disconnects an active connection', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Not connected'); + await user.type( + screen.getByPlaceholderText('nostrconnect://…'), + 'nostrconnect://alice@relay.damus.io?relay=wss%3A%2F%2Frelay.damus.io', + ); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await screen.findByText('Connected'); + await user.click(screen.getByRole('button', { name: 'Disconnect' })); + + await waitFor(() => { + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + expect(backend.signer.phase).toBe('stopped'); + }); +}); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index b30f5a1..0a41b29 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -4,6 +4,7 @@ import type { ProfileSummary, RelayTestResult, Settings, + SignerStatus, } from '../lib/types'; export const ALICE = 'npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -69,6 +70,10 @@ export function makeRelayTest(url: string, overrides?: Partial) return { url, connected: true, latency_ms: 42, ...overrides }; } +export function makeSignerStatus(overrides?: Partial): SignerStatus { + return { phase: 'stopped', peer: null, relays: [], error: null, ...overrides }; +} + /** * A configurable mock of the `../lib/api` module. Each test creates one, * registers it with `vi.mock`, and can inspect/override behaviour. @@ -94,15 +99,31 @@ export interface ApiMock { pickImages: ReturnType; uploadImage: ReturnType; linkPreview: ReturnType; + signerConnect: ReturnType; + signerDisconnect: ReturnType; + signerStatus: ReturnType; copyText: ReturnType; }; /** Current state object backing init/getState. */ state: AppState; setState: (next: AppState) => void; + /** State of the NIP-46 signer backing the signer_* mocks. */ + signer: SignerStatus; + setSigner: (next: SignerStatus) => void; } export function createApiMock(initial: AppState = makeState()): ApiMock { let state: AppState = initial; + let signer: SignerStatus = makeSignerStatus(); + + /** + * Fold a new signer status into the snapshot, mirroring the real backend's + * auto-approve behaviour: a successful connect reports phase 'connected'. + */ + const applySigner = (next: SignerStatus): SignerStatus => { + signer = next; + return signer; + }; const api = { init: vi.fn(async () => state), @@ -198,6 +219,16 @@ export function createApiMock(initial: AppState = makeState()): ApiMock { image: 'https://example.com/cover.jpg', site_name: 'Example', })), + signerConnect: vi.fn(async () => + applySigner({ + phase: 'connected', + peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', + relays: ['wss://relay.damus.io'], + error: null, + }), + ), + signerDisconnect: vi.fn(async () => applySigner(makeSignerStatus())), + signerStatus: vi.fn(async () => signer), copyText: vi.fn(async () => undefined), }; @@ -207,5 +238,9 @@ export function createApiMock(initial: AppState = makeState()): ApiMock { setState: (next: AppState) => { state = next; }, + signer, + setSigner: (next: SignerStatus) => { + signer = next; + }, }; } diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index aab63b3..ddfc740 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -5,8 +5,9 @@ import type { PublishReport, RelayTestResult, Settings, + SignerStatus, } from '../lib/types'; -import { makePublishReport, makeRelayTest, makeState } from './apiMock'; +import { makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock'; /** * An in-memory stand-in for the Rust `serve` IPC server. Exposes the same @@ -34,6 +35,9 @@ export interface FakeBackend { pickedImages: { path: string; name: string; mime: string }[]; /** URLs returned by `upload_image`, one per call. */ uploadUrls: string[]; + /** Current NIP-46 signer status. */ + signer: SignerStatus; + setSigner: (next: SignerStatus) => void; } export function createFakeBackend(initial?: AppState): FakeBackend { @@ -85,6 +89,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend { requests: [], pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }], uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'], + signer: makeSignerStatus(), + setSigner(next) { + backend.signer = next; + }, }; async function dispatch(method: string, params: Record): Promise { @@ -162,6 +170,28 @@ export function createFakeBackend(initial?: AppState): FakeBackend { site_name: 'Example', }; + case 'signer_connect': + if (String(params.uri ?? '').startsWith('nostrconnect://')) { + const next: SignerStatus = { + phase: 'connected', + peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', + relays: ['wss://relay.damus.io'], + error: null, + }; + backend.setSigner(next); + return next; + } + throw new Error('Invalid nostrconnect:// link.'); + + case 'signer_disconnect': { + const next = makeSignerStatus(); + backend.setSigner(next); + return next; + } + + case 'signer_status': + return backend.signer; + case 'relay_add': { const url = String(params.url); const nextSettings: Settings = { diff --git a/src/ipc.rs b/src/ipc.rs index bafe9ba..22ce617 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,3 +1,4 @@ +use std::sync::{Arc, Mutex}; use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -9,6 +10,7 @@ use crate::profiles; use crate::publish; use crate::relays; use crate::settings::Theme; +use crate::signer::Signer; /// How long to wait for a relay connection test. const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -85,6 +87,14 @@ pub enum Request { url: String, http_method: String, }, + /// Start the NIP-46 remote signer for a `nostrconnect://` link. + SignerConnect { + uri: String, + }, + /// Stop the NIP-46 remote signer. + SignerDisconnect, + /// Report the remote signer's current status. + SignerStatus, } /// A reply envelope carrying either data or a safe user-facing error. @@ -119,7 +129,11 @@ pub struct ReplyEnvelope { pub async fn serve() -> Result<(), AppError> { use tokio::io::AsyncBufReadExt; - let mut app = App::load()?; + // Shared state, so the NIP-46 signer's background task and the request loop + // both see the same vault (including its unlock key) without racing writes. + let app = Arc::new(Mutex::new(App::load()?)); + let signer = Signer::new(); + let stdin = tokio::io::stdin(); let mut lines = tokio::io::BufReader::new(stdin).lines(); let mut stdout = tokio::io::stdout(); @@ -146,7 +160,7 @@ pub async fn serve() -> Result<(), AppError> { } }; - let reply = handle(&mut app, envelope.request).await; + let reply = handle(app.clone(), &signer, envelope.request).await; write_line( &mut stdout, ReplyEnvelope { @@ -178,8 +192,12 @@ where Ok(()) } -async fn handle(app: &mut App, request: Request) -> Reply { - let result = run(app, request).await; +async fn handle( + app: Arc>, + signer: &Signer, + request: Request, +) -> Reply { + let result = run(&app, signer, request).await; match result { Ok(value) => Reply::Ok { data: value }, Err(err) => Reply::Error { @@ -202,7 +220,41 @@ fn error_code(err: &AppError) -> String { .unwrap_or_else(|_| "error".to_string()) } -async fn run(app: &mut App, request: Request) -> Result { +/// Signer control commands never touch the vault directly, so they take the +/// shared handle (a clone) rather than locking the state. Everything else +/// locks the vault for the duration of the call, mirroring the old +/// single-threaded model. +#[allow(clippy::await_holding_lock)] +async fn run( + app: &Arc>, + signer: &Signer, + request: Request, +) -> Result { + // Signer control commands never touch the vault directly, so they take the + // shared handle (a clone) rather than locking the state. Everything else + // locks the vault for the duration of the call, mirroring the old + // single-threaded model. + match request { + Request::SignerConnect { uri } => { + signer.connect(app.clone(), &uri)?; + Ok(json!(signer.status())) + } + Request::SignerDisconnect => { + signer.disconnect(); + Ok(json!(signer.status())) + } + Request::SignerStatus => Ok(json!(signer.status())), + other => { + let mut guard = app.lock().expect("app mutex poisoned"); + run_with_app(&mut guard, other).await + } + } +} + +/// Requests dispatched to the vault state. The shared mutex guard is held across +/// the awaited operation on purpose: requests remain effectively sequential, and +/// a concurrent `await` never yields back into a state the loop expects to own. +async fn run_with_app(app: &mut App, request: Request) -> Result { match request { Request::Init | Request::GetState => Ok(json!(app.state_view())), @@ -315,6 +367,9 @@ async fn run(app: &mut App, request: Request) -> Result Err(AppError::internal("Unexpected signer request.")), } } diff --git a/src/lib.rs b/src/lib.rs index d3d4448..18eedbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod profiles; pub mod publish; pub mod relays; pub mod settings; +pub mod signer; pub mod uploads; pub mod vault; diff --git a/src/main.rs b/src/main.rs index c118b69..92a790c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::process::ExitCode; +use std::sync::{Arc, Mutex}; use nostr_manager_backend::app::App; use nostr_manager_backend::errors::{AppError, ErrorKind}; @@ -7,6 +8,7 @@ use nostr_manager_backend::profiles; use nostr_manager_backend::publish; use nostr_manager_backend::relays; use nostr_manager_backend::settings::Theme; +use nostr_manager_backend::signer::Signer; use nostr_manager_backend::vault; const USAGE: &str = "\ @@ -31,6 +33,8 @@ Commands: remove-password Remove the vault password (keys back to plaintext) unlock Verify the vault password for this process show-secret Show a profile's secret key (hex + nsec) after unlocking + signer status Show the profile and relays the signer would use + signer connect Run the NIP-46 signer for a nostrconnect:// link info Show storage locations and version serve Run the JSON-lines IPC server @@ -64,6 +68,7 @@ async fn main() -> ExitCode { "remove-password" => cli_remove_password(), "unlock" => cli_unlock(), "show-secret" => cli_show_secret(&args), + "signer" => cli_signer(&args).await, "info" => cli_info(), "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -338,6 +343,52 @@ fn cli_show_secret(args: &[String]) -> Result { )) } +async fn cli_signer(args: &[String]) -> Result { + let sub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: nostr-manager-backend signer "))?; + + match sub.as_str() { + "status" => { + let app = App::load()?; + let active_label = profiles::active_summary(&app.vault) + .map(|a| format!("\"{}\" ({})", a.label, a.npub)) + .unwrap_or_else(|| "none selected".to_string()); + let relays = relays::enabled_urls(&app.settings); + Ok([ + format!("Signer profile: {active_label}"), + format!( + "Signer relays: {}", + if relays.is_empty() { + "none".to_string() + } else { + relays.join(", ") + } + ), + "In the GUI, the signer listens as long as the app is running. From here, run:" + .to_string(), + " nostr-manager-backend signer connect ".to_string(), + ] + .join("\n")) + } + "connect" => { + let uri = args + .get(3) + .ok_or_else(|| AppError::config("Usage: signer connect "))?; + let app = Arc::new(Mutex::new(load_app_with_unlock()?)); + let signer = Signer::new(); + signer.connect(app, uri)?; + println!("Connecting to the NIP-46 app… (interrupt with Ctrl-C to stop)"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + } + signer.disconnect(); + Ok("Signer stopped.".to_string()) + } + other => Err(AppError::config(format!("Unknown signer command: {other}"))), + } +} + fn cli_info() -> Result { let app = App::load()?; let mut lines = vec![ diff --git a/src/signer.rs b/src/signer.rs new file mode 100644 index 0000000..71e48ce --- /dev/null +++ b/src/signer.rs @@ -0,0 +1,605 @@ +//! NIP-46 remote signer ("bunker" external signer). +//! +//! This turns the vault into a remote signer: another Nostr client ("app") pastes +//! its `nostrconnect://` link into this app, and this app signs events and +//! answers cryptographic requests on its behalf over encrypted `kind: 24133` +//! messages relayed through the client's chosen relays. +//! +//! This implements the client-initiated (`nostrconnect://`) flow. From the URI +//! we learn the requesting client's pubkey, its relays and an optional secret. +//! We connect to those relays, subscribe to the client's kind 24133 events, and +//! answer its requests. A mistaken `bunker://` link (the opposite role) is +//! rejected with a clear message. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use nostr::nips::nip44::v2; +use nostr::nips::nip44::v2::ConversationKey; +use nostr::JsonUtil; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use nostr_sdk::prelude::*; + +use crate::app::App; +use crate::errors::AppError; +use crate::profiles; + +/// How long to wait for relays to accept a connection attempt. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Lifecycle of the remote signer, for display in the GUI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SignerPhase { + /// Not listening. + Stopped, + /// Connected to relays, waiting for the client to acknowledge us. + Connecting, + /// A client connected; requests are auto-approved. + Connected, +} + +/// A non-secret snapshot of the signer for the UI. +#[derive(Debug, Clone, Serialize)] +pub struct SignerStatus { + pub phase: SignerPhase, + /// The connected client's hex public key, if any. + pub peer: Option, + /// Relays used for the connection. + pub relays: Vec, + /// A user-facing error if the signer stopped because of one. + pub error: Option, +} + +/// Shareable control handle for the remote signer. +#[derive(Clone)] +pub struct Signer { + inner: Arc>, +} + +struct SignerInner { + phase: SignerPhase, + peer: Option, + relays: Vec, + error: Option, + task: Option>, +} + +impl Default for Signer { + fn default() -> Self { + Self::new() + } +} + +impl Signer { + /// A signer that is not yet listening. + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(SignerInner { + phase: SignerPhase::Stopped, + peer: None, + relays: Vec::new(), + error: None, + task: None, + })), + } + } + + /// A non-secret snapshot of the current state for a UI. + pub fn status(&self) -> SignerStatus { + let inner = self.inner.lock().expect("signer mutex poisoned"); + SignerStatus { + phase: inner.phase, + peer: inner.peer.map(|pk| pk.to_hex()), + relays: inner.relays.clone(), + error: inner.error.clone(), + } + } + + /// Stop listening and cancel the running task. + pub fn disconnect(&self) { + let mut inner = self.inner.lock().expect("signer mutex poisoned"); + if let Some(task) = inner.task.take() { + task.abort(); + } + inner.phase = SignerPhase::Stopped; + inner.peer = None; + inner.relays.clear(); + inner.error = None; + } + + /// Parse a `nostrconnect://` link and listen as the active profile. + /// + /// `app` is the shared vault state, so the signer reflects the current unlock + /// key and active profile. A locked vault cannot sign until it is unlocked. + pub fn connect(&self, app: Arc>, uri: &str) -> Result<(), AppError> { + if uri.trim().starts_with("bunker://") { + return Err(AppError::config( + "That is a bunker:// link, which means routing through *another* signer. \ + This app acts as the signer: paste the nostrconnect:// link that the \ + Nostr app shows when you pick “use a remote signer”.", + )); + } + + let parsed = parse_connect_uri(uri)?; + + { + let mut inner = self.inner.lock().expect("signer mutex poisoned"); + if inner.task.is_some() { + return Err(AppError::config( + "The signer is already connected. Disconnect it before connecting again.", + )); + } + inner.phase = SignerPhase::Connecting; + inner.peer = Some(parsed.peer); + inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect(); + inner.error = None; + } + + let task = tokio::spawn(run_sign_task(self.clone(), app, parsed)); + self.inner.lock().expect("signer mutex poisoned").task = Some(task); + Ok(()) + } + + fn fail(&self, message: impl Into) { + let mut inner = self.inner.lock().expect("signer mutex poisoned"); + inner.phase = SignerPhase::Stopped; + inner.error = Some(message.into()); + inner.task = None; + } + + fn set_connected(&self) { + self.inner.lock().expect("signer mutex poisoned").phase = SignerPhase::Connected; + } +} + +/// The parsed `nostrconnect://` connection request. +struct ConnectUri { + peer: PublicKey, + relays: Vec, + secret: Option, +} + +/// Parse a `nostrconnect://?relay=...&relay=...&secret=...` link. +fn parse_connect_uri(raw: &str) -> Result { + let rest = raw.trim().strip_prefix("nostrconnect://").ok_or_else(|| { + AppError::config( + "Paste the nostrconnect:// link that the Nostr app generates for remote signing.", + ) + })?; + + let (authority, query) = match rest.split_once('?') { + Some((a, q)) => (a, Some(q)), + None => (rest, None), + }; + + let peer = PublicKey::from_hex(authority).map_err(|_| { + AppError::config("The nostrconnect:// link does not contain a valid public key.") + })?; + + let mut relays: Vec = Vec::new(); + let mut secret: Option = None; + + if let Some(query) = query { + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + let decoded = percent_decode(value); + match key { + "relay" => { + if let Some(value) = decoded { + if let Ok(url) = RelayUrl::parse(&value) { + relays.push(url); + } + } + } + "secret" => secret = decoded, + _ => {} + } + } + } + + if relays.is_empty() { + return Err(AppError::config( + "The nostrconnect:// link does not name any relays to connect through.", + )); + } + + Ok(ConnectUri { + peer, + relays, + secret, + }) +} + +/// Decode a single percent-encoded query value into UTF-8. +fn percent_decode(raw: &str) -> Option { + let mut out: Vec = Vec::with_capacity(raw.len()); + let bytes = raw.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } else if bytes[i] == b'+' { + out.push(b' '); + i += 1; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// NIP-44 encrypt with the conversation key, returned base64-encoded. +fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result { + let payload = v2::encrypt_to_bytes(conversation, plaintext.as_bytes()) + .map_err(|e| AppError::internal(format!("Could not encrypt a message: {e}")))?; + Ok(B64.encode(payload)) +} + +/// NIP-44 decrypt a base64-encoded payload into a UTF-8 string. +fn nip44_decrypt(conversation: &ConversationKey, content: &str) -> Result { + let bytes = B64 + .decode(content) + .map_err(|e| AppError::internal(format!("Could not decode an incoming message: {e}")))?; + let plaintext = v2::decrypt_to_bytes(conversation, &bytes) + .map_err(|e| AppError::internal(format!("Could not decrypt an incoming message: {e}")))?; + String::from_utf8(plaintext) + .map_err(|_| AppError::internal("An incoming message was not valid text.")) +} + +/// A minimal decrypted NIP-46 request payload. +#[derive(Debug, Deserialize)] +struct RawRequest { + id: String, + method: String, + #[serde(default)] + params: Vec, +} + +/// `{"id":..,"result":,"error":null}` +fn response_ok(id: &str, result: String) -> String { + json!({ "id": id, "result": result, "error": null }).to_string() +} + +/// `{"id":..,"result":null,"error":}` +fn response_err(id: &str, error: String) -> String { + json!({ "id": id, "result": null, "error": error }).to_string() +} + +/// Route a decrypted request and return the response JSON to publish back. +fn handle_request( + signer: &Signer, + keys: &Keys, + uri: &ConnectUri, + request: &RawRequest, +) -> Option { + // Seen a client request ⇒ the handshake succeeded; auto-approve from here. + signer.set_connected(); + + match request.method.as_str() { + "connect" => Some(response_ok(&request.id, "ack".to_string())), + "get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())), + "ping" => Some(response_ok(&request.id, "pong".to_string())), + "sign_event" => match sign_event(keys, request) { + Ok(event) => Some(response_ok(&request.id, event)), + Err(e) => Some(response_err(&request.id, e)), + }, + "nip44_encrypt" => match nip44(keys, request) { + Ok(value) => Some(response_ok(&request.id, value)), + Err(e) => Some(response_err(&request.id, e)), + }, + "nip44_decrypt" => match nip44(keys, request) { + Ok(value) => Some(response_ok(&request.id, value)), + Err(e) => Some(response_err(&request.id, e)), + }, + "get_relays" | "switch_relays" => { + let list = serde_json::to_string(&uri.relays).unwrap_or_default(); + Some(response_ok(&request.id, list)) + } + "logout" => Some(response_ok(&request.id, "ack".to_string())), + other => Some(response_err( + &request.id, + format!("Unsupported method: {other}"), + )), + } +} + +/// Sign the client's unsigned event with the active profile's key and return the +/// signed event JSON. +fn sign_event(keys: &Keys, request: &RawRequest) -> Result { + let json_str = request + .params + .first() + .ok_or_else(|| "sign_event requires an event".to_string())?; + + let mut value: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| format!("The event to sign could not be read: {e}"))?; + if value.get("pubkey").and_then(|v| v.as_str()).is_none() { + value["pubkey"] = serde_json::Value::String(keys.public_key().to_hex()); + } + + let unsigned: UnsignedEvent = + serde_json::from_value(value).map_err(|e| format!("Invalid event: {e}"))?; + let event = unsigned + .sign_with_keys(keys) + .map_err(|e| format!("The event could not be signed: {e}"))?; + Ok(event.as_json()) +} + +/// NIP-44 encrypt/decrypt against a third-party public key, as requested. +fn nip44(keys: &Keys, request: &RawRequest) -> Result { + if request.params.len() != 2 { + return Err("Expected two parameters: and ".to_string()); + } + let peer = + PublicKey::from_hex(&request.params[0]).map_err(|e| format!("Invalid public key: {e}"))?; + let conversation = ConversationKey::derive(keys.secret_key(), &peer) + .map_err(|e| format!("Could not derive a session key: {e}"))?; + + match request.method.as_str() { + "nip44_encrypt" => { + nip44_encrypt(&conversation, &request.params[1]).map_err(|e| e.message().to_string()) + } + _ => nip44_decrypt(&conversation, &request.params[1]).map_err(|e| e.message().to_string()), + } +} + +/// The background loop: connect to the client's relays, announce ourselves, +/// subscribe to kind 24133 events, and answer requests until stopped. +async fn run_sign_task(signer: Signer, app: Arc>, uri: ConnectUri) { + // 1. Resolve the active profile's key under the current vault lock. + let keys = { + let guard = match app.lock() { + Ok(guard) => guard, + Err(_) => { + signer.fail("The vault could not be read."); + return; + } + }; + let hex = match profiles::resolve_active_secret_key(&guard.vault, guard.vault_key()) { + Ok(hex) => hex, + Err(err) => { + signer.fail(err.message()); + return; + } + }; + let secret = match profiles::parse_secret_key(&hex) { + Ok(secret) => secret, + Err(err) => { + signer.fail(err.message()); + return; + } + }; + Keys::new(secret) + }; + + // 2. Build the NIP-44 conversation key shared with the client. + let conversation = match ConversationKey::derive(keys.secret_key(), &uri.peer) { + Ok(key) => key, + Err(err) => { + signer.fail(format!("Could not derive the session key: {err}")); + return; + } + }; + + // 3. Connect to the client's relays. + let client = Client::new(keys.clone()); + for url in &uri.relays { + if let Err(err) = client.add_relay(url.to_string()).await { + signer.fail(format!("Could not add relay {url}: {err}")); + return; + } + } + client.connect().await; + client.wait_for_connection(CONNECT_TIMEOUT).await; + + // 4. Subscribe to the client's kind 24133 events so we hear its requests. + let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer); + if let Err(err) = client.subscribe(filter, None).await { + signer.fail(format!("Could not subscribe for messages: {err}")); + return; + } + + // 5. Announce ourselves: send the connect request with the optional secret. + if let Err(err) = send_connect(&client, &keys, &conversation, &uri).await { + signer.fail(err); + return; + } + + // 6. Answer requests until the connection goes away or we are stopped. + let mut notifications = client.notifications(); + loop { + let notification = match notifications.recv().await { + Ok(notification) => notification, + Err(_) => { + signer.fail("The signer connection was closed."); + return; + } + }; + let RelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + if event.kind != Kind::NostrConnect || event.pubkey != uri.peer { + continue; + } + let plaintext = match nip44_decrypt(&conversation, &event.content) { + Ok(plaintext) => plaintext, + Err(_) => continue, + }; + let request: RawRequest = match serde_json::from_str(&plaintext) { + Ok(request) => request, + Err(_) => continue, + }; + let response = handle_request(&signer, &keys, &uri, &request); + if let Some(response) = response { + if let Err(err) = + publish_payload(&client, &keys, &conversation, &uri.peer, &response).await + { + signer.fail(format!("Could not send a reply: {err}")); + return; + } + } + } +} + +/// Publish the NIP-46 `connect` request proving we control the active profile. +async fn send_connect( + client: &Client, + keys: &Keys, + conversation: &ConversationKey, + uri: &ConnectUri, +) -> Result<(), String> { + let mut params = vec![keys.public_key().to_hex()]; + if let Some(secret) = &uri.secret { + params.push(secret.clone()); + } + let payload = json!({ + "id": uuid::Uuid::new_v4().to_string(), + "method": "connect", + "params": params, + }) + .to_string(); + publish_payload(client, keys, conversation, &uri.peer, &payload).await +} + +/// Encrypt a payload and publish a signed kind 24133 event addressed to `peer`. +async fn publish_payload( + client: &Client, + keys: &Keys, + conversation: &ConversationKey, + peer: &PublicKey, + payload: &str, +) -> Result<(), String> { + let content = nip44_encrypt(conversation, payload).map_err(|e| e.message().to_string())?; + let tag = Tag::parse(["p", peer.to_hex().as_str()]).map_err(|e| format!("{e}"))?; + let event = EventBuilder::new(Kind::NostrConnect, content) + .tags([tag]) + .sign(keys) + .await + .map_err(|e| format!("Could not sign a message: {e}"))?; + client + .send_event(&event) + .await + .map_err(|e| format!("{e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_uri_with_relays_and_secret() { + let uri = parse_connect_uri( + "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5\ + ?relay=wss%3A%2F%2Frelay1.example.com&relay=wss://relay2.example.com&secret=abc", + ) + .unwrap(); + assert_eq!( + uri.peer.to_hex(), + "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5" + ); + assert_eq!(uri.relays.len(), 2); + assert_eq!(uri.relays[0].to_string(), "wss://relay1.example.com"); + assert_eq!(uri.secret.as_deref(), Some("abc")); + } + + #[test] + fn bunker_link_is_rejected() { + let signer = Signer::new(); + assert!(signer + .connect(Arc::new(Mutex::new(App::load().unwrap())), "bunker://abc") + .is_err()); + } + + #[test] + fn nip44_roundtrip() { + let signer = Keys::generate(); + let client = Keys::generate(); + let conversation = + ConversationKey::derive(signer.secret_key(), &client.public_key()).unwrap(); + let encrypted = nip44_encrypt(&conversation, "hello").unwrap(); + assert_ne!(encrypted, "hello"); + assert_eq!(nip44_decrypt(&conversation, &encrypted).unwrap(), "hello"); + } + + #[test] + fn get_public_key_reports_active_profile() { + let signer = Signer::new(); + let keys = Keys::generate(); + let uri = ConnectUri { + peer: Keys::generate().public_key(), + relays: vec![RelayUrl::parse("wss://relay.example.com").unwrap()], + secret: None, + }; + let request = RawRequest { + id: "1".into(), + method: "get_public_key".into(), + params: vec![], + }; + let response = handle_request(&signer, &keys, &uri, &request).unwrap(); + assert_eq!(signer.status().phase, SignerPhase::Connected); + assert!(response.contains(&keys.public_key().to_hex())); + } + + #[test] + fn sign_event_returns_a_signed_event() { + let signer = Signer::new(); + let keys = Keys::generate(); + let uri = ConnectUri { + peer: Keys::generate().public_key(), + relays: vec![RelayUrl::parse("wss://relay.example.com").unwrap()], + secret: None, + }; + let unsigned = + r#"{"kind":1,"created_at":1714078911,"tags":[],"content":"Hello from afar"}"# + .to_string(); + let request = RawRequest { + id: "2".into(), + method: "sign_event".into(), + params: vec![unsigned], + }; + let response = handle_request(&signer, &keys, &uri, &request).unwrap(); + let signed: Event = Event::from_json(response_value(&response)).unwrap(); + assert_eq!(signed.pubkey, keys.public_key()); + assert_eq!(signed.kind, Kind::TextNote); + assert_eq!(signed.content, "Hello from afar"); + assert!(signed.verify_id()); + assert!(signed.verify_signature()); + } + + #[test] + fn unknown_method_gets_an_error() { + let signer = Signer::new(); + let keys = Keys::generate(); + let uri = ConnectUri { + peer: Keys::generate().public_key(), + relays: vec![RelayUrl::parse("wss://relay.example.com").unwrap()], + secret: None, + }; + let request = RawRequest { + id: "3".into(), + method: "make_friends".into(), + params: vec![], + }; + let response = handle_request(&signer, &keys, &uri, &request).unwrap(); + assert!(response.contains("Unsupported method")); + } + + /// Pull the `result` string out of a response JSON object. + fn response_value(response: &str) -> String { + serde_json::from_str::(response).unwrap()["result"] + .as_str() + .unwrap() + .to_string() + } +}