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:
parent
13efee6b4b
commit
73cb08d856
17 changed files with 1125 additions and 7 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
204
frontend/src/screens/SignerScreen.tsx
Normal file
204
frontend/src/screens/SignerScreen.tsx
Normal 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's keys.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{vaultLocked && (
|
||||
<Alert tone="warning" title="Vault is locked">
|
||||
The signer signs with the active profile'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 “use a remote signer” 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
71
frontend/src/test/SignerScreen.test.tsx
Normal file
71
frontend/src/test/SignerScreen.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
65
src/ipc.rs
65
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<serde_json::Value> {
|
||||
let result = run(app, request).await;
|
||||
async fn handle(
|
||||
app: Arc<Mutex<App>>,
|
||||
signer: &Signer,
|
||||
request: Request,
|
||||
) -> Reply<serde_json::Value> {
|
||||
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<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 {
|
||||
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()?;
|
||||
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.")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
51
src/main.rs
51
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 <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
|
||||
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<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> {
|
||||
let app = App::load()?;
|
||||
let mut lines = vec![
|
||||
|
|
|
|||
605
src/signer.rs
Normal file
605
src/signer.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue