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

View file

@ -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' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
{screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />}
{screen === 'signer' && <SignerScreen />}
{screen === 'settings' && <SettingsScreen />}
</main>
<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: 'compose', label: 'Compose', icon: 'edit' },
{ id: 'relays', label: 'Relays', icon: 'relay' },
{ id: 'signer', label: 'Signer', icon: 'key' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
];

View file

@ -8,6 +8,7 @@ import type {
RelayTestResult,
RevealedKey,
Settings,
SignerStatus,
UploadedImage,
} from './types';
@ -70,5 +71,8 @@ export const api = {
pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (path: string) => call<UploadedImage>('upload_image', { path }),
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),
};

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> = {
home: 'Home',
profiles: 'Profiles',
compose: 'Compose',
relays: 'Relays',
signer: 'Signer',
settings: 'Settings',
};

View file

@ -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;

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,
RevealedKey,
Settings,
SignerStatus,
Theme,
UploadedImage,
} from '../lib/types';
@ -55,6 +56,9 @@ interface AppContextValue {
pickImages: () => Promise<PickedImage[]>;
uploadImage: (path: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>;
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
copyText: (text: string) => Promise<void>;
}
@ -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,
],
);

View file

@ -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;
}

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,
RelayTestResult,
Settings,
SignerStatus,
} from '../lib/types';
export const ALICE = 'npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
@ -69,6 +70,10 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
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,
* registers it with `vi.mock`, and can inspect/override behaviour.
@ -94,15 +99,31 @@ export interface ApiMock {
pickImages: ReturnType<typeof vi.fn>;
uploadImage: 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>;
};
/** 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;
},
};
}

View file

@ -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<string, unknown>): Promise<unknown> {
@ -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 = {