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<Mutex<App>>; 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.
This commit is contained in:
Avi 2026-08-05 19:28:26 -05:00
commit 73cb08d856
17 changed files with 1125 additions and 7 deletions

2
Cargo.lock generated
View file

@ -751,6 +751,7 @@ version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f900ddcdc28395759fcd44b18a03255e7deee8858551bfe5d5d5a07311d82ea" checksum = "2f900ddcdc28395759fcd44b18a03255e7deee8858551bfe5d5d5a07311d82ea"
dependencies = [ dependencies = [
"aes",
"base64", "base64",
"bech32", "bech32",
"bip39", "bip39",
@ -789,6 +790,7 @@ dependencies = [
"base64", "base64",
"getrandom 0.2.17", "getrandom 0.2.17",
"hex", "hex",
"nostr",
"nostr-sdk", "nostr-sdk",
"rpassword", "rpassword",
"serde", "serde",

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
nostr = { version = "0.40", features = ["nip44", "nip46"] }
nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] } nostr-sdk = { version = "0.40", features = ["nip44", "nip98"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -9,6 +9,7 @@ import { HomeScreen } from './screens/HomeScreen';
import { ProfilesScreen } from './screens/ProfilesScreen'; import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen'; import { ComposeScreen } from './screens/ComposeScreen';
import { RelaysScreen } from './screens/RelaysScreen'; import { RelaysScreen } from './screens/RelaysScreen';
import { SignerScreen } from './screens/SignerScreen';
import { SettingsScreen } from './screens/SettingsScreen'; import { SettingsScreen } from './screens/SettingsScreen';
import { CreateProfileModal } from './screens/CreateProfileModal'; import { CreateProfileModal } from './screens/CreateProfileModal';
import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
@ -70,6 +71,7 @@ function Shell() {
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />} {screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
{screen === 'compose' && <ComposeScreen />} {screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />} {screen === 'relays' && <RelaysScreen />}
{screen === 'signer' && <SignerScreen />}
{screen === 'settings' && <SettingsScreen />} {screen === 'settings' && <SettingsScreen />}
</main> </main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />

View file

@ -10,6 +10,7 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'profiles', label: 'Profiles', icon: 'users' }, { id: 'profiles', label: 'Profiles', icon: 'users' },
{ id: 'compose', label: 'Compose', icon: 'edit' }, { id: 'compose', label: 'Compose', icon: 'edit' },
{ id: 'relays', label: 'Relays', icon: 'relay' }, { id: 'relays', label: 'Relays', icon: 'relay' },
{ id: 'signer', label: 'Signer', icon: 'key' },
{ id: 'settings', label: 'Settings', icon: 'settings' }, { id: 'settings', label: 'Settings', icon: 'settings' },
]; ];

View file

@ -8,6 +8,7 @@ import type {
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerStatus,
UploadedImage, UploadedImage,
} from './types'; } from './types';
@ -70,5 +71,8 @@ export const api = {
pickImages: () => call<PickedImage[]>('pick_image'), pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }), uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }), linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),
copyText: (text: string) => window.backend.copyText(text), copyText: (text: string) => window.backend.copyText(text),
}; };

View file

@ -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<Screen, string> = { export const SCREEN_TITLES: Record<Screen, string> = {
home: 'Home', home: 'Home',
profiles: 'Profiles', profiles: 'Profiles',
compose: 'Compose', compose: 'Compose',
relays: 'Relays', relays: 'Relays',
signer: 'Signer',
settings: 'Settings', settings: 'Settings',
}; };

View file

@ -1,5 +1,19 @@
export type Theme = 'light' | 'dark' | 'system'; 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. */ /** A safe view of a profile with no secret key material. */
export interface ProfileSummary { export interface ProfileSummary {
label: string; label: string;

View file

@ -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<SignerStatus>(EMPTY_STATUS);
const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(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 <Badge tone="success">Connected</Badge>;
case 'connecting':
return <Badge tone="info">Connecting</Badge>;
default:
return <Badge tone="neutral">Not connected</Badge>;
}
};
const isActive = status.phase === 'connected' || status.phase === 'connecting';
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Signer</h1>
<p className="page-subtitle">
Securely sign for another Nostr app. Paste its nostrconnect:// link to let this app
approve its requests with the active profile&apos;s keys.
</p>
</div>
</header>
{vaultLocked && (
<Alert tone="warning" title="Vault is locked">
The signer signs with the active profile&apos;s key, which is locked. Unlock the vault
before connecting.
</Alert>
)}
<section className="card">
<header className="card-header">
<h2>Status</h2>
<div className="signer-badge">{badge()}</div>
</header>
<div className="card-body signer-status">
<dl className="info-list">
<div>
<dt>Client</dt>
<dd>
{status.peer ? (
<code className="mono" title={status.peer}>
{shortHex(status.peer)}
</code>
) : (
<span className="muted">None yet</span>
)}
</dd>
</div>
<div>
<dt>Relays</dt>
<dd>
{status.relays.length > 0 ? (
status.relays.map((relay) => (
<span key={relay} className="mono signer-relay">
{relay}
</span>
))
) : (
<span className="muted">None</span>
)}
</dd>
</div>
</dl>
{status.error && (
<Alert tone="error" title="Signer error">
{status.error}
</Alert>
)}
</div>
</section>
<section className="card">
<header className="card-header">
<h2>Connect a Nostr app</h2>
</header>
<div className="card-body signer-connect">
{isActive ? (
<div className="signer-actions">
<p className="hint">
The signer is listening. Requests from the connected app are approved
automatically.
</p>
<Button variant="danger" onClick={() => void onDisconnect()}>
<Icon name="trash" size={16} />
Disconnect
</Button>
</div>
) : (
<form onSubmit={onConnect} noValidate>
<div className="field">
<label htmlFor="signer-uri" className="visually-hidden">
nostrconnect:// link
</label>
<input
id="signer-uri"
type="text"
placeholder="nostrconnect://…"
value={uri}
onChange={(event) => setUri(event.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p className="hint">
In the Nostr app, choose &ldquo;use a remote signer&rdquo; and copy the link it
generates here.
</p>
</div>
{error && <ErrorText>{error}</ErrorText>}
<div className="settings-inline">
<Button
variant="primary"
type="submit"
loading={connecting}
disabled={uri.trim().length === 0 || vaultLocked}
>
<Icon name="key" size={16} />
Connect
</Button>
<Button variant="ghost" onClick={() => void refresh()} disabled={loading}>
<Icon name="refresh" size={16} />
Refresh
</Button>
</div>
</form>
)}
</div>
</section>
</div>
</div>
);
}

View file

@ -17,6 +17,7 @@ import type {
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerStatus,
Theme, Theme,
UploadedImage, UploadedImage,
} from '../lib/types'; } from '../lib/types';
@ -55,6 +56,9 @@ interface AppContextValue {
pickImages: () => Promise<PickedImage[]>; pickImages: () => Promise<PickedImage[]>;
uploadImage: (path: string) => Promise<UploadedImage>; uploadImage: (path: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>; linkPreview: (url: string) => Promise<LinkPreview | null>;
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
copyText: (text: string) => Promise<void>; copyText: (text: string) => Promise<void>;
} }
@ -168,6 +172,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
const pickImages = useCallback(() => api.pickImages(), []); const pickImages = useCallback(() => api.pickImages(), []);
const uploadImage = useCallback((path: string) => api.uploadImage(path), []); const uploadImage = useCallback((path: string) => api.uploadImage(path), []);
const linkPreview = useCallback((url: string) => api.linkPreview(url), []); 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), []); const copyText = useCallback((text: string) => api.copyText(text), []);
@ -199,6 +206,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
pickImages, pickImages,
uploadImage, uploadImage,
linkPreview, linkPreview,
signerConnect,
signerDisconnect,
signerStatus,
copyText, copyText,
}), }),
[ [
@ -226,6 +236,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
pickImages, pickImages,
uploadImage, uploadImage,
linkPreview, linkPreview,
signerConnect,
signerDisconnect,
signerStatus,
copyText, copyText,
], ],
); );

View file

@ -1481,3 +1481,31 @@ select {
font-size: 13px; font-size: 13px;
word-break: break-word; 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;
}

View file

@ -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(<SignerScreen />);
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(<SignerScreen />);
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(<SignerScreen />);
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(<SignerScreen />);
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');
});
});

View file

@ -4,6 +4,7 @@ import type {
ProfileSummary, ProfileSummary,
RelayTestResult, RelayTestResult,
Settings, Settings,
SignerStatus,
} from '../lib/types'; } from '../lib/types';
export const ALICE = 'npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; export const ALICE = 'npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
@ -69,6 +70,10 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
return { url, connected: true, latency_ms: 42, ...overrides }; return { url, connected: true, latency_ms: 42, ...overrides };
} }
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
return { phase: 'stopped', peer: null, relays: [], error: null, ...overrides };
}
/** /**
* A configurable mock of the `../lib/api` module. Each test creates one, * A configurable mock of the `../lib/api` module. Each test creates one,
* registers it with `vi.mock`, and can inspect/override behaviour. * registers it with `vi.mock`, and can inspect/override behaviour.
@ -94,15 +99,31 @@ export interface ApiMock {
pickImages: ReturnType<typeof vi.fn>; pickImages: ReturnType<typeof vi.fn>;
uploadImage: ReturnType<typeof vi.fn>; uploadImage: ReturnType<typeof vi.fn>;
linkPreview: ReturnType<typeof vi.fn>; linkPreview: ReturnType<typeof vi.fn>;
signerConnect: ReturnType<typeof vi.fn>;
signerDisconnect: ReturnType<typeof vi.fn>;
signerStatus: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>; copyText: ReturnType<typeof vi.fn>;
}; };
/** Current state object backing init/getState. */ /** Current state object backing init/getState. */
state: AppState; state: AppState;
setState: (next: AppState) => void; 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 { export function createApiMock(initial: AppState = makeState()): ApiMock {
let state: AppState = initial; 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 = { const api = {
init: vi.fn(async () => state), init: vi.fn(async () => state),
@ -198,6 +219,16 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
image: 'https://example.com/cover.jpg', image: 'https://example.com/cover.jpg',
site_name: 'Example', 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), copyText: vi.fn(async () => undefined),
}; };
@ -207,5 +238,9 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
setState: (next: AppState) => { setState: (next: AppState) => {
state = next; state = next;
}, },
signer,
setSigner: (next: SignerStatus) => {
signer = next;
},
}; };
} }

View file

@ -5,8 +5,9 @@ import type {
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
Settings, Settings,
SignerStatus,
} from '../lib/types'; } 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 * 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 }[]; pickedImages: { path: string; name: string; mime: string }[];
/** URLs returned by `upload_image`, one per call. */ /** URLs returned by `upload_image`, one per call. */
uploadUrls: string[]; uploadUrls: string[];
/** Current NIP-46 signer status. */
signer: SignerStatus;
setSigner: (next: SignerStatus) => void;
} }
export function createFakeBackend(initial?: AppState): FakeBackend { export function createFakeBackend(initial?: AppState): FakeBackend {
@ -85,6 +89,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
requests: [], requests: [],
pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }], pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }],
uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'], uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'],
signer: makeSignerStatus(),
setSigner(next) {
backend.signer = next;
},
}; };
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> { async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
@ -162,6 +170,28 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
site_name: 'Example', 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': { case 'relay_add': {
const url = String(params.url); const url = String(params.url);
const nextSettings: Settings = { const nextSettings: Settings = {

View file

@ -1,3 +1,4 @@
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -9,6 +10,7 @@ use crate::profiles;
use crate::publish; use crate::publish;
use crate::relays; use crate::relays;
use crate::settings::Theme; use crate::settings::Theme;
use crate::signer::Signer;
/// How long to wait for a relay connection test. /// How long to wait for a relay connection test.
const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8); const RELAY_TEST_TIMEOUT: Duration = Duration::from_secs(8);
@ -85,6 +87,14 @@ pub enum Request {
url: String, url: String,
http_method: 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. /// 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> { pub async fn serve() -> Result<(), AppError> {
use tokio::io::AsyncBufReadExt; 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 stdin = tokio::io::stdin();
let mut lines = tokio::io::BufReader::new(stdin).lines(); let mut lines = tokio::io::BufReader::new(stdin).lines();
let mut stdout = tokio::io::stdout(); 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( write_line(
&mut stdout, &mut stdout,
ReplyEnvelope { ReplyEnvelope {
@ -178,8 +192,12 @@ where
Ok(()) Ok(())
} }
async fn handle(app: &mut App, request: Request) -> Reply<serde_json::Value> { async fn handle(
let result = run(app, request).await; app: Arc<Mutex<App>>,
signer: &Signer,
request: Request,
) -> Reply<serde_json::Value> {
let result = run(&app, signer, request).await;
match result { match result {
Ok(value) => Reply::Ok { data: value }, Ok(value) => Reply::Ok { data: value },
Err(err) => Reply::Error { Err(err) => Reply::Error {
@ -202,7 +220,41 @@ fn error_code(err: &AppError) -> String {
.unwrap_or_else(|_| "error".to_string()) .unwrap_or_else(|_| "error".to_string())
} }
async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> { /// 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<Mutex<App>>,
signer: &Signer,
request: Request,
) -> Result<serde_json::Value, AppError> {
// 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<serde_json::Value, AppError> {
match request { match request {
Request::Init | Request::GetState => Ok(json!(app.state_view())), Request::Init | Request::GetState => Ok(json!(app.state_view())),
@ -315,6 +367,9 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
app.save_settings()?; app.save_settings()?;
Ok(json!(app.settings)) Ok(json!(app.settings))
} }
// Signer control requests are handled by `run` before this function is
// reached; keeping a wildcard arm keeps the match exhaustive here.
_ => Err(AppError::internal("Unexpected signer request.")),
} }
} }

View file

@ -6,6 +6,7 @@ pub mod profiles;
pub mod publish; pub mod publish;
pub mod relays; pub mod relays;
pub mod settings; pub mod settings;
pub mod signer;
pub mod uploads; pub mod uploads;
pub mod vault; pub mod vault;

View file

@ -1,4 +1,5 @@
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use nostr_manager_backend::app::App; use nostr_manager_backend::app::App;
use nostr_manager_backend::errors::{AppError, ErrorKind}; 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::publish;
use nostr_manager_backend::relays; use nostr_manager_backend::relays;
use nostr_manager_backend::settings::Theme; use nostr_manager_backend::settings::Theme;
use nostr_manager_backend::signer::Signer;
use nostr_manager_backend::vault; use nostr_manager_backend::vault;
const USAGE: &str = "\ const USAGE: &str = "\
@ -31,6 +33,8 @@ Commands:
remove-password Remove the vault password (keys back to plaintext) remove-password Remove the vault password (keys back to plaintext)
unlock Verify the vault password for this process unlock Verify the vault password for this process
show-secret <npub> Show a profile's secret key (hex + nsec) after unlocking show-secret <npub> Show a profile's secret key (hex + nsec) after unlocking
signer status Show the profile and relays the signer would use
signer connect <uri> Run the NIP-46 signer for a nostrconnect:// link
info Show storage locations and version info Show storage locations and version
serve Run the JSON-lines IPC server serve Run the JSON-lines IPC server
@ -64,6 +68,7 @@ async fn main() -> ExitCode {
"remove-password" => cli_remove_password(), "remove-password" => cli_remove_password(),
"unlock" => cli_unlock(), "unlock" => cli_unlock(),
"show-secret" => cli_show_secret(&args), "show-secret" => cli_show_secret(&args),
"signer" => cli_signer(&args).await,
"info" => cli_info(), "info" => cli_info(),
"help" | "--help" | "-h" => { "help" | "--help" | "-h" => {
println!("{USAGE}"); println!("{USAGE}");
@ -338,6 +343,52 @@ fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
)) ))
} }
async fn cli_signer(args: &[String]) -> Result<String, AppError> {
let sub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend signer <status|connect>"))?;
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 <nostrconnect://…>".to_string(),
]
.join("\n"))
}
"connect" => {
let uri = args
.get(3)
.ok_or_else(|| AppError::config("Usage: signer connect <uri>"))?;
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<String, AppError> { fn cli_info() -> Result<String, AppError> {
let app = App::load()?; let app = App::load()?;
let mut lines = vec![ let mut lines = vec![

605
src/signer.rs Normal file
View file

@ -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<String>,
/// Relays used for the connection.
pub relays: Vec<String>,
/// A user-facing error if the signer stopped because of one.
pub error: Option<String>,
}
/// Shareable control handle for the remote signer.
#[derive(Clone)]
pub struct Signer {
inner: Arc<Mutex<SignerInner>>,
}
struct SignerInner {
phase: SignerPhase,
peer: Option<PublicKey>,
relays: Vec<String>,
error: Option<String>,
task: Option<tokio::task::JoinHandle<()>>,
}
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<Mutex<App>>, 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<String>) {
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<RelayUrl>,
secret: Option<String>,
}
/// Parse a `nostrconnect://<client-pubkey>?relay=...&relay=...&secret=...` link.
fn parse_connect_uri(raw: &str) -> Result<ConnectUri, AppError> {
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<RelayUrl> = Vec::new();
let mut secret: Option<String> = 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<String> {
let mut out: Vec<u8> = 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<String, AppError> {
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<String, AppError> {
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<String>,
}
/// `{"id":..,"result":<s>,"error":null}`
fn response_ok(id: &str, result: String) -> String {
json!({ "id": id, "result": result, "error": null }).to_string()
}
/// `{"id":..,"result":null,"error":<e>}`
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<String> {
// 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<String, String> {
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<String, String> {
if request.params.len() != 2 {
return Err("Expected two parameters: <public key> and <payload>".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<Mutex<App>>, 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::<serde_json::Value>(response).unwrap()["result"]
.as_str()
.unwrap()
.to_string()
}
}