feat: add embedded signer and NIP-46 client signer modes

- Add Signer trait with common interface for both signing modes
- Implement EmbeddedSigner: keys stored in encrypted vault (Argon2id + AES-256-GCM)
- Implement Nip46ClientSigner: connects to remote signer via nostrconnect:// URI
- Support both local and remote NIP-46 signers
- Add signer mode selection UI (SignerModeScreen)
- Add IPC endpoints for signer mode management, embedded signer, and NIP-46 client
- Update frontend types, API, and AppProvider
- All tests pass (119 Rust + 110 frontend)
This commit is contained in:
Avi 2026-09-01 20:16:14 -05:00
commit b484bdeb08
24 changed files with 3163 additions and 78 deletions

966
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,3 +17,5 @@ base64 = "0.22"
getrandom = "0.2" getrandom = "0.2"
zeroize = "1" zeroize = "1"
rpassword = "7" rpassword = "7"
async-trait = "0.1"
keyring = "4.2"

View file

@ -11,6 +11,7 @@ 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 { SignerScreen } from './screens/SignerScreen';
import { SignerModeScreen } from './screens/SignerModeScreen';
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';
@ -74,6 +75,7 @@ function Shell() {
{screen === 'compose' && <ComposeScreen />} {screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />} {screen === 'relays' && <RelaysScreen />}
{screen === 'signer' && <SignerScreen />} {screen === 'signer' && <SignerScreen />}
{screen === 'signer-mode' && <SignerModeScreen />}
{screen === 'settings' && <SettingsScreen />} {screen === 'settings' && <SettingsScreen />}
</main> </main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />

View file

@ -16,7 +16,8 @@ export type IconName =
| 'shield' | 'shield'
| 'publish' | 'publish'
| 'external' | 'external'
| 'key'; | 'key'
| 'server';
const PATHS: Record<IconName, ReactNode> = { const PATHS: Record<IconName, ReactNode> = {
home: ( home: (
@ -101,6 +102,12 @@ const PATHS: Record<IconName, ReactNode> = {
<path d="M18 6l2 2" /> <path d="M18 6l2 2" />
</> </>
), ),
server: (
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 9h6M9 12h6M9 15h6" />
</>
),
}; };
interface IconProps { interface IconProps {

View file

@ -11,7 +11,8 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'feed', label: 'Feed', icon: 'list' }, { id: 'feed', label: 'Feed', icon: 'list' },
{ 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: 'signer-mode', label: 'Signer Mode', icon: 'key' },
{ id: 'signer', label: 'Signer (Bunker)', icon: 'server' },
{ id: 'settings', label: 'Settings', icon: 'settings' }, { id: 'settings', label: 'Settings', icon: 'settings' },
]; ];

View file

@ -1,15 +1,18 @@
import type { import type {
AppState, AppState,
BackendResponse, BackendResponse,
EmbeddedSignerStatus,
FeedItem, FeedItem,
LinkPreview, LinkPreview,
MetadataPublishReport, MetadataPublishReport,
Nip46SignerStatus,
PickedImage, PickedImage,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerMode,
SignerStatus, SignerStatus,
UpdateApplyReport, UpdateApplyReport,
UpdateCheckReport, UpdateCheckReport,
@ -102,6 +105,24 @@ export const api = {
pickImages: () => call<PickedImage[]>('pick_image'), pickImages: () => call<PickedImage[]>('pick_image'),
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }), uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }), linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
// Signer mode management
signerModeGet: () => call<{ mode: SignerMode }>('signer_mode_get'),
signerModeSet: (mode: SignerMode) => call<AppState>('signer_mode_set', { mode }),
// Embedded signer
embeddedSignerStatus: () => call<EmbeddedSignerStatus>('embedded_signer_status'),
embeddedSignerApprove: (index: number, approved: boolean) =>
call<EmbeddedSignerStatus>('embedded_signer_approve', { index, approved }),
// NIP-46 client signer
nip46Connect: (uri: string, label: string) =>
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }), signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'), signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'), signerStatus: () => call<SignerStatus>('signer_status'),

View file

@ -1,4 +1,5 @@
export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings'; export type Screen =
'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'signer-mode' | 'settings';
export const SCREEN_TITLES: Record<Screen, string> = { export const SCREEN_TITLES: Record<Screen, string> = {
home: 'Home', home: 'Home',
@ -6,6 +7,7 @@ export const SCREEN_TITLES: Record<Screen, string> = {
profiles: 'Profiles', profiles: 'Profiles',
compose: 'Compose', compose: 'Compose',
relays: 'Relays', relays: 'Relays',
signer: 'Signer', signer: 'Signer (Bunker)',
'signer-mode': 'Signer Mode',
settings: 'Settings', settings: 'Settings',
}; };

View file

@ -1,9 +1,22 @@
export type Theme = export type Theme =
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
/** Active signer mode. */
export type SignerMode = 'embedded' | 'nip46';
/** Lifecycle of the NIP-46 remote signer. */ /** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected'; export type SignerPhase = 'stopped' | 'connecting' | 'connected';
/** Approval request details for user confirmation. */
export interface ApprovalDetails {
method: string;
summary: string;
event_kind?: number;
destination_relays: string[];
content_preview: string;
is_sensitive: boolean;
}
/** A NIP-46 request waiting for the user to approve or reject it. */ /** A NIP-46 request waiting for the user to approve or reject it. */
export interface PendingApproval { export interface PendingApproval {
/** Internal id used to answer this request. */ /** Internal id used to answer this request. */
@ -12,6 +25,8 @@ export interface PendingApproval {
method: string; method: string;
/** A short human-readable description of what will be done. */ /** A short human-readable description of what will be done. */
summary: string; summary: string;
/** Detailed approval information. */
details?: ApprovalDetails;
} }
/** Non-secret snapshot of the NIP-46 remote signer for display. */ /** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -21,12 +36,38 @@ export interface SignerStatus {
peer: string | null; peer: string | null;
/** Relays used for the connection. */ /** Relays used for the connection. */
relays: string[]; relays: string[];
/** The relays in `relays` that are actually connected right now. */
connectedRelays: string[];
/** A user-facing error if the signer stopped because of one. */ /** A user-facing error if the signer stopped because of one. */
error: string | null; error: string | null;
/** Requests currently waiting for the user's approval. */ /** Requests currently waiting for the user's approval. */
pending: PendingApproval[]; pending: PendingApproval[];
} }
/** Embedded signer status. */
export interface EmbeddedSignerStatus {
type: 'embedded';
available: boolean;
active_npub?: string;
pending_count: number;
pending: PendingApproval[];
error?: string;
}
/** NIP-46 client signer status. */
export interface Nip46SignerStatus {
type: 'nip46';
connected: boolean;
signer_pubkey?: string;
relays: string[];
connected_relays: string[];
error?: string;
pending_approvals: PendingApproval[];
}
/** Union of all signer statuses. */
export type AnySignerStatus = EmbeddedSignerStatus | Nip46SignerStatus;
/** 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;
@ -152,6 +193,8 @@ export interface AppState {
failed: RelayFailure[]; failed: RelayFailure[];
content: string; content: string;
} | null; } | null;
/** Active signer mode. */
signer_mode: SignerMode;
} }
/** A secret key revealed after the vault is unlocked. */ /** A secret key revealed after the vault is unlocked. */

View file

@ -0,0 +1,466 @@
import { useCallback, 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 { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SignerModeScreen() {
const {
state,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
nip46Status,
nip46Connect,
nip46Disconnect,
nip46Approve,
embeddedSignerApprove,
refresh,
} = useApp();
const [mode, setMode] = useState<SignerMode>('embedded');
const [embeddedStatus, setEmbeddedStatus] = useState<EmbeddedSignerStatus | null>(null);
const [nip46StatusState, setNip46StatusState] = useState<Nip46SignerStatus | null>(null);
const [uri, setUri] = useState('');
const [label, setLabel] = useState('Remote Signer');
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [loading, setLoading] = useState(true);
const isNip46Active = mode === 'nip46' && nip46StatusState?.connected;
const isEmbeddedActive = mode === 'embedded' && embeddedStatus?.available;
const refreshStatus = useCallback(async () => {
try {
const modeResult = await signerModeGet();
setMode(modeResult.mode);
if (modeResult.mode === 'embedded') {
const status = await embeddedSignerStatus();
setEmbeddedStatus(status);
} else {
const status = await nip46Status();
setNip46StatusState(status);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [signerModeGet, embeddedSignerStatus, nip46Status]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
// Poll for pending approvals
useEffect(() => {
const timer = window.setInterval(() => {
void refreshStatus();
}, 2000);
return () => window.clearInterval(timer);
}, [refreshStatus]);
const vaultLocked = state?.vault_locked ?? false;
const onModeChange = async (newMode: SignerMode) => {
setError(null);
try {
await signerModeSet(newMode);
setMode(newMode);
await refreshStatus();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onNip46Connect = async (event: FormEvent) => {
event.preventDefault();
const trimmed = uri.trim();
if (!trimmed.startsWith('nostrconnect://')) {
setError('Paste the nostrconnect:// link from your Nostr app.');
return;
}
setError(null);
setConnecting(true);
try {
const status = await nip46Connect(trimmed, label.trim() || 'Remote Signer');
setNip46StatusState(status);
setUri('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setConnecting(false);
}
};
const onNip46Disconnect = async () => {
setError(null);
try {
const status = await nip46Disconnect();
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onEmbeddedApprove = async (index: number, approved: boolean) => {
setError(null);
try {
const status = await embeddedSignerApprove(index, approved);
setEmbeddedStatus(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const modeBadge = () => {
if (mode === 'embedded') {
return isEmbeddedActive ? (
<Badge tone="success">Embedded (Active)</Badge>
) : (
<Badge tone={vaultLocked ? 'warning' : 'neutral'}>
Embedded {vaultLocked ? '(Vault Locked)' : '(Ready)'}
</Badge>
);
}
return isNip46Active ? (
<Badge tone="success">NIP-46 (Connected)</Badge>
) : (
<Badge tone={nip46StatusState?.error ? 'danger' : 'neutral'}>
NIP-46 {nip46StatusState?.error ? '(Error)' : '(Disconnected)'}
</Badge>
);
};
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Signer Mode</h1>
<p className="page-subtitle">
Choose how your keys are managed and where signing happens.
</p>
</div>
</header>
<section className="card">
<header className="card-header">
<h2>Current Mode</h2>
<div className="signer-badge">{modeBadge()}</div>
</header>
<div className="card-body">
<div className="mode-options">
<label className={`mode-option${mode === 'embedded' ? ' active' : ''}`}>
<input
type="radio"
name="signer-mode"
value="embedded"
checked={mode === 'embedded'}
onChange={() => void onModeChange('embedded')}
disabled={loading}
/>
<div className="mode-option-content">
<h3>Embedded Signer</h3>
<p>
Keys are stored locally in your encrypted vault. Signing happens on this device.
<br />
<span className="muted">Best for: Simplicity, offline use, full control.</span>
</p>
<ul className="mode-features">
<li>✓ Keys never leave this device</li>
<li>✓ Works offline</li>
<li>✓ Encrypted vault with password</li>
<li>⚠ If vault unlocked, malware could sign</li>
</ul>
</div>
</label>
<label className={`mode-option${mode === 'nip46' ? ' active' : ''}`}>
<input
type="radio"
name="signer-mode"
value="nip46"
checked={mode === 'nip46'}
onChange={() => void onModeChange('nip46')}
disabled={loading}
/>
<div className="mode-option-content">
<h3>NIP-46 Remote Signer</h3>
<p>
Connect to an external signer (bunker) like Nostr Connect, Amber, or a
self-hosted bunker.
<br />
<span className="muted">
Best for: Hardware wallets, mobile signers, key isolation.
</span>
</p>
<ul className="mode-features">
<li>✓ Private key never on this device</li>
<li>✓ Use hardware wallet or mobile app</li>
<li>✓ Approve each request on signer device</li>
<li>⚠ Requires signer to be online</li>
</ul>
</div>
</label>
</div>
{vaultLocked && mode === 'embedded' && (
<Alert tone="warning" title="Vault is locked">
The embedded signer needs an unlocked vault to sign.{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
void refresh();
}}
>
Unlock vault
</a>
before using embedded signing.
</Alert>
)}
{error && <ErrorText>{error}</ErrorText>}
</div>
</section>
{mode === 'embedded' && embeddedStatus && (
<section className="card">
<header className="card-header">
<h2>Embedded Signer Status</h2>
</header>
<div className="card-body">
<dl className="info-list">
<div>
<dt>Active Profile</dt>
<dd>
{embeddedStatus.active_npub ? (
<code className="mono">{embeddedStatus.active_npub}</code>
) : (
<span className="muted">None selected</span>
)}
</dd>
</div>
<div>
<dt>Pending Approvals</dt>
<dd>{embeddedStatus.pending_count}</dd>
</div>
</dl>
{embeddedStatus.pending.length > 0 && (
<div className="signer-pending">
<p className="hint">
The active profile needs approval for the following operations:
</p>
{embeddedStatus.pending.map((request, index) => (
<div key={request.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code>
<p>{request.summary}</p>
{request.details?.content_preview && (
<p className="content-preview">"{request.details.content_preview}"</p>
)}
{request.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive operation</span>
)}
</div>
<div className="settings-inline">
<Button
variant="primary"
size="sm"
onClick={() => void onEmbeddedApprove(index, true)}
>
<Icon name="check" size={14} />
Approve
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void onEmbeddedApprove(index, false)}
>
<Icon name="trash" size={14} />
Reject
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
{mode === 'nip46' && (
<section className="card">
<header className="card-header">
<h2>NIP-46 Connection</h2>
</header>
<div className="card-body">
{isNip46Active && nip46StatusState ? (
<div className="signer-actions">
<p className="hint">
Connected to{' '}
<code className="mono">{nip46StatusState.signer_pubkey?.slice(0, 16)}…</code>
via {nip46StatusState.connected_relays.length} of{' '}
{nip46StatusState.relays.length} relays.
</p>
<dl className="info-list">
<div>
<dt>Signer</dt>
<dd>
<code className="mono">{nip46StatusState.signer_pubkey}</code>
</dd>
</div>
<div>
<dt>Relays</dt>
<dd>
{nip46StatusState.relays.map((relay, i) => {
const connected = nip46StatusState!.connected_relays.includes(relay);
return (
<span
key={i}
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
>
{relay}
</span>
);
})}
</dd>
</div>
</dl>
{nip46StatusState.error && (
<Alert tone="error" title="Connection error">
{nip46StatusState.error}
</Alert>
)}
<Button variant="danger" onClick={() => void onNip46Disconnect()}>
<Icon name="trash" size={16} />
Disconnect
</Button>
</div>
) : (
<form onSubmit={onNip46Connect} noValidate>
<div className="field">
<label htmlFor="nip46-uri" className="visually-hidden">
nostrconnect:// link
</label>
<input
id="nip46-uri"
type="text"
placeholder="nostrconnect://…"
value={uri}
onChange={(e) => setUri(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<p className="hint">
In your Nostr app, choose "use a remote signer" and copy the link here.
</p>
</div>
<div className="field">
<label htmlFor="nip46-label">Connection Label</label>
<input
id="nip46-label"
type="text"
placeholder="e.g. Amber, Hardware Wallet, Self-hosted Bunker"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</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 refreshStatus()} disabled={loading}>
<Icon name="refresh" size={16} />
Refresh
</Button>
</div>
</form>
)}
{nip46StatusState?.pending_approvals.length &&
nip46StatusState.pending_approvals.length > 0 && (
<div className="signer-pending">
<h3>Pending Approvals ({nip46StatusState.pending_approvals.length})</h3>
{nip46StatusState.pending_approvals.map((request) => (
<div key={request.id} className="signer-pending-item">
<div className="signer-pending-info">
<code className="mono signer-pending-method">{request.method}</code>
<p>{request.summary}</p>
{request.details?.content_preview && (
<p className="content-preview">"{request.details.content_preview}"</p>
)}
{request.details?.is_sensitive && (
<span className="sensitive-badge">Sensitive operation</span>
)}
</div>
<div className="settings-inline">
<Button
variant="primary"
size="sm"
onClick={() => void nip46Approve(request.id, true)}
>
<Icon name="check" size={14} />
Approve
</Button>
<Button
variant="danger"
size="sm"
onClick={() => void nip46Approve(request.id, false)}
>
<Icon name="trash" size={14} />
Reject
</Button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Security Notes</h2>
</header>
<div className="card-body">
<ul className="security-notes">
<li>
<strong>Embedded mode:</strong> Your keys are encrypted at rest with Argon2id +
AES-256-GCM. When unlocked, they exist in memory. A compromised OS or malware could
extract them.
</li>
<li>
<strong>NIP-46 mode:</strong> Your private key never touches this device. The signer
(Amber, Nostr Connect, bunker) holds the key and you approve each operation there.
</li>
<li>
<strong>Switching modes:</strong> You can switch modes anytime without changing your
public key. In NIP-46 mode, you'll need to import your key into the external signer
first.
</li>
<li>
<strong>Revocation:</strong> In NIP-46 mode, disconnect revokes the connection. The
signer will reject future requests from this app.
</li>
</ul>
</div>
</section>
</div>
</div>
);
}

View file

@ -12,6 +12,7 @@ const EMPTY_STATUS: SignerStatus = {
phase: 'stopped', phase: 'stopped',
peer: null, peer: null,
relays: [], relays: [],
connectedRelays: [],
error: null, error: null,
pending: [], pending: [],
}; };
@ -144,11 +145,25 @@ export function SignerScreen() {
<dt>Relays</dt> <dt>Relays</dt>
<dd> <dd>
{status.relays.length > 0 ? ( {status.relays.length > 0 ? (
status.relays.map((relay) => ( <>
<span key={relay} className="mono signer-relay"> {status.relays.map((relay) => {
{relay} const connected = status.connectedRelays.includes(relay);
</span> return (
)) <span
key={relay}
className={`mono signer-relay${connected ? ' is-connected' : ''}`}
title={connected ? 'Connected' : 'No connection yet'}
>
{relay}
</span>
);
})}
{status.phase === 'connecting' && status.connectedRelays.length === 0 && (
<span className="muted signer-relay-hint">
Waiting for a relay to answer…
</span>
)}
</>
) : ( ) : (
<span className="muted">None</span> <span className="muted">None</span>
)} )}

View file

@ -10,15 +10,18 @@ import {
import { api, BackendError } from '../lib/api'; import { api, BackendError } from '../lib/api';
import type { import type {
AppState, AppState,
EmbeddedSignerStatus,
FeedItem, FeedItem,
LinkPreview, LinkPreview,
MetadataPublishReport, MetadataPublishReport,
Nip46SignerStatus,
PickedImage, PickedImage,
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerMode,
SignerStatus, SignerStatus,
Theme, Theme,
UpdateApplyReport, UpdateApplyReport,
@ -68,6 +71,18 @@ interface AppContextValue {
pickImages: () => Promise<PickedImage[]>; pickImages: () => Promise<PickedImage[]>;
uploadImage: (token: string) => Promise<UploadedImage>; uploadImage: (token: string) => Promise<UploadedImage>;
linkPreview: (url: string) => Promise<LinkPreview | null>; linkPreview: (url: string) => Promise<LinkPreview | null>;
// Signer mode management
signerModeGet: () => Promise<{ mode: SignerMode }>;
signerModeSet: (mode: SignerMode) => Promise<AppState>;
// Embedded signer
embeddedSignerStatus: () => Promise<EmbeddedSignerStatus>;
embeddedSignerApprove: (index: number, approved: boolean) => Promise<EmbeddedSignerStatus>;
// NIP-46 client signer
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>; signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>; signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>; signerStatus: () => Promise<SignerStatus>;
@ -228,6 +243,32 @@ export function AppProvider({ children }: { children: ReactNode }) {
return next; return next;
}, []); }, []);
// Signer mode management
const signerModeGet = useCallback(() => api.signerModeGet(), []);
const signerModeSet = useCallback(
(mode: SignerMode) => applyState(api.signerModeSet(mode)),
[applyState],
);
// Embedded signer
const embeddedSignerStatus = useCallback(() => api.embeddedSignerStatus(), []);
const embeddedSignerApprove = useCallback(
(index: number, approved: boolean) => api.embeddedSignerApprove(index, approved),
[],
);
// NIP-46 client signer
const nip46Connect = useCallback(
(uri: string, label: string) => api.nip46Connect(uri, label),
[],
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46Approve = useCallback(
(id: string, approved: boolean) => api.nip46Approve(id, approved),
[],
);
const setVaultPassword = useCallback( const setVaultPassword = useCallback(
(currentPassword: string | null, newPassword: string) => (currentPassword: string | null, newPassword: string) =>
applyState(api.setVaultPassword(currentPassword, newPassword)), applyState(api.setVaultPassword(currentPassword, newPassword)),
@ -301,6 +342,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
pickImages, pickImages,
uploadImage, uploadImage,
linkPreview, linkPreview,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46Disconnect,
nip46Status,
nip46Approve,
signerConnect, signerConnect,
signerDisconnect, signerDisconnect,
signerStatus, signerStatus,
@ -350,6 +399,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
pickImages, pickImages,
uploadImage, uploadImage,
linkPreview, linkPreview,
signerModeGet,
signerModeSet,
embeddedSignerStatus,
embeddedSignerApprove,
nip46Connect,
nip46Disconnect,
nip46Status,
nip46Approve,
signerConnect, signerConnect,
signerDisconnect, signerDisconnect,
signerStatus, signerStatus,

View file

@ -2213,6 +2213,16 @@ select {
font-size: 12px; font-size: 12px;
} }
.signer-relay.is-connected {
border-color: var(--success);
color: var(--success);
}
.signer-relay-hint {
margin-left: 4px;
font-size: 12px;
}
.signer-actions { .signer-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -48,6 +48,47 @@ describe('SignerScreen', () => {
expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true); expect(backend.requests.some((r) => r.method === 'signer_connect')).toBe(true);
}); });
it('marks connected relays while the handshake is still in progress', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SignerScreen />);
// The signer is dialling the link's relays: one has answered, one has not.
backend.setSigner({
phase: 'connecting',
peer: 'ab12',
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
connectedRelays: ['wss://relay.damus.io'],
error: null,
pending: [],
});
expect(await screen.findByText('Connecting…')).toBeInTheDocument();
const connected = screen.getByTitle('Connected');
expect(connected).toHaveTextContent('wss://relay.damus.io');
const pending = screen.getByTitle('No connection yet');
expect(pending).toHaveTextContent('wss://relay.nostr.band');
// No "waiting" hint while at least one relay is already up.
expect(screen.queryByText('Waiting for a relay to answer…')).not.toBeInTheDocument();
});
it('shows a waiting hint when no relay has answered yet', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SignerScreen />);
backend.setSigner({
phase: 'connecting',
peer: 'ab12',
relays: ['wss://relay.nostr.band'],
connectedRelays: [],
error: null,
pending: [],
});
expect(await screen.findByText('Waiting for a relay to answer…')).toBeInTheDocument();
});
it('disconnects an active connection', async () => { it('disconnects an active connection', async () => {
const backend = createFakeBackend(); const backend = createFakeBackend();
installFakeBackend(backend); installFakeBackend(backend);
@ -80,6 +121,7 @@ describe('SignerScreen', () => {
phase: 'connected', phase: 'connected',
peer: 'ab12', peer: 'ab12',
relays: ['wss://relay.damus.io'], relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null, error: null,
pending: [ pending: [
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' }, { id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
@ -115,6 +157,7 @@ describe('SignerScreen', () => {
phase: 'connected', phase: 'connected',
peer: '79ab', peer: '79ab',
relays: ['wss://relay.damus.io'], relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null, error: null,
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }], pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
}); });

View file

@ -4,6 +4,7 @@ import type {
ProfileSummary, ProfileSummary,
RelayTestResult, RelayTestResult,
Settings, Settings,
SignerMode,
SignerStatus, SignerStatus,
} from '../lib/types'; } from '../lib/types';
@ -44,6 +45,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
profiles: [alice, bob], profiles: [alice, bob],
settings, settings,
last_publish: null, last_publish: null,
signer_mode: 'embedded' as SignerMode,
...overrides, ...overrides,
}; };
} }
@ -72,7 +74,15 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
} }
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus { export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides }; return {
phase: 'stopped',
peer: null,
relays: [],
connectedRelays: [],
error: null,
pending: [],
...overrides,
};
} }
/** /**
@ -226,6 +236,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
phase: 'connected', phase: 'connected',
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'], relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null, error: null,
pending: [], pending: [],
}), }),

View file

@ -324,6 +324,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
phase: 'connected', phase: 'connected',
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'], relays: ['wss://relay.damus.io'],
connectedRelays: ['wss://relay.damus.io'],
error: null, error: null,
pending: [], pending: [],
}; };

View file

@ -1,6 +1,7 @@
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine; use base64::Engine;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::sync::Arc;
use zeroize::{Zeroize, Zeroizing}; use zeroize::{Zeroize, Zeroizing};
use crate::crypto::{self, VaultKey}; use crate::crypto::{self, VaultKey};
@ -22,8 +23,27 @@ pub struct App {
pub undo_history: Vec<ProfileSummary>, pub undo_history: Vec<ProfileSummary>,
/// The most recent publish report, persisted across restarts. /// The most recent publish report, persisted across restarts.
pub last_publish: Option<StoredPublishReport>, pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
pub signer_mode: SignerMode,
/// Embedded signer instance.
pub embedded_signer: Option<EmbeddedSignerHandle>,
/// NIP-46 client signer instance.
pub nip46_signer: Option<Nip46ClientSignerHandle>,
} }
/// Active signer mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerMode {
Embedded,
Nip46,
}
/// Handle for the embedded signer (type-erased for App storage).
pub type EmbeddedSignerHandle = Arc<crate::signer::embedded::EmbeddedSigner>;
/// Handle for the NIP-46 client signer (type-erased for App storage).
pub type Nip46ClientSignerHandle = Arc<crate::signer::nip46_client::Nip46ClientSigner>;
/// Snapshot of everything the UI needs, containing no secret keys. /// Snapshot of everything the UI needs, containing no secret keys.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct AppStateView { pub struct AppStateView {
@ -43,6 +63,8 @@ pub struct AppStateView {
/// The most recent publish report, persisted across restarts. /// The most recent publish report, persisted across restarts.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub last_publish: Option<StoredPublishReport>, pub last_publish: Option<StoredPublishReport>,
/// Active signer mode.
pub signer_mode: SignerMode,
} }
impl App { impl App {
@ -54,6 +76,9 @@ impl App {
unlock_key: None, unlock_key: None,
undo_history: Vec::new(), undo_history: Vec::new(),
last_publish: vault::load_last_publish(), last_publish: vault::load_last_publish(),
signer_mode: SignerMode::Embedded,
embedded_signer: None,
nip46_signer: None,
}) })
} }
@ -247,6 +272,7 @@ impl App {
settings: self.settings.clone(), settings: self.settings.clone(),
undo_history: self.undo_history.clone(), undo_history: self.undo_history.clone(),
last_publish: self.last_publish.clone(), last_publish: self.last_publish.clone(),
signer_mode: self.signer_mode,
} }
} }
} }
@ -310,6 +336,9 @@ mod tests {
unlock_key: None, unlock_key: None,
undo_history: Vec::new(), undo_history: Vec::new(),
last_publish: None, last_publish: None,
signer_mode: SignerMode::Embedded,
embedded_signer: None,
nip46_signer: None,
} }
} }

View file

@ -73,6 +73,10 @@ pub struct SignerStatus {
pub peer: Option<String>, pub peer: Option<String>,
/// Relays used for the connection. /// Relays used for the connection.
pub relays: Vec<String>, pub relays: Vec<String>,
/// The subset of `relays` that is actually connected right now. Empty
/// while the pool is still connecting; used by the UI to show which of
/// the link's relays answered and which did not.
pub connected_relays: Vec<String>,
/// A user-facing error if the signer stopped because of one. /// A user-facing error if the signer stopped because of one.
pub error: Option<String>, pub error: Option<String>,
/// Requests currently waiting for the user to approve or reject them. /// Requests currently waiting for the user to approve or reject them.
@ -89,6 +93,7 @@ struct SignerInner {
phase: SignerPhase, phase: SignerPhase,
peer: Option<PublicKey>, peer: Option<PublicKey>,
relays: Vec<String>, relays: Vec<String>,
connected_relays: Vec<String>,
error: Option<String>, error: Option<String>,
task: Option<tokio::task::JoinHandle<()>>, task: Option<tokio::task::JoinHandle<()>>,
/// Requests waiting for the user to approve or reject, keyed by an /// Requests waiting for the user to approve or reject, keyed by an
@ -118,6 +123,7 @@ impl Signer {
phase: SignerPhase::Stopped, phase: SignerPhase::Stopped,
peer: None, peer: None,
relays: Vec::new(), relays: Vec::new(),
connected_relays: Vec::new(),
error: None, error: None,
task: None, task: None,
pending: HashMap::new(), pending: HashMap::new(),
@ -142,6 +148,7 @@ impl Signer {
phase: inner.phase, phase: inner.phase,
peer: inner.peer.map(|pk| pk.to_hex()), peer: inner.peer.map(|pk| pk.to_hex()),
relays: inner.relays.clone(), relays: inner.relays.clone(),
connected_relays: inner.connected_relays.clone(),
error: inner.error.clone(), error: inner.error.clone(),
pending, pending,
} }
@ -157,6 +164,7 @@ impl Signer {
inner.phase = SignerPhase::Stopped; inner.phase = SignerPhase::Stopped;
inner.peer = None; inner.peer = None;
inner.relays.clear(); inner.relays.clear();
inner.connected_relays.clear();
inner.error = None; inner.error = None;
inner.pending.clear(); inner.pending.clear();
} }
@ -259,6 +267,7 @@ impl Signer {
inner.phase = SignerPhase::Connecting; inner.phase = SignerPhase::Connecting;
inner.peer = Some(parsed.peer); inner.peer = Some(parsed.peer);
inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect(); inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect();
inner.connected_relays.clear();
inner.error = None; inner.error = None;
} }
@ -272,6 +281,7 @@ impl Signer {
inner.phase = SignerPhase::Stopped; inner.phase = SignerPhase::Stopped;
inner.error = Some(message.into()); inner.error = Some(message.into());
inner.task = None; inner.task = None;
inner.connected_relays.clear();
inner.pending.clear(); inner.pending.clear();
} }
@ -598,6 +608,26 @@ fn nip44(keys: &Keys, request: &RawRequest) -> Result<String, String> {
} }
} }
/// URLs of the pool's relays that are connected right now, polling until at
/// least one answers or `deadline` passes. `and_wait` can return while relays
/// are still dialling, so a single status check would undercount slow relays.
async fn connected_relay_urls(client: &Client, deadline: tokio::time::Instant) -> Vec<String> {
let mut urls: Vec<String> = loop {
let map = client.relays().all().await;
let urls: Vec<String> = map
.into_iter()
.filter(|(_, relay)| relay.status().is_connected())
.map(|(url, _)| url.to_string())
.collect();
if !urls.is_empty() || tokio::time::Instant::now() >= deadline {
break urls;
}
tokio::time::sleep(Duration::from_millis(250)).await;
};
urls.sort();
urls
}
/// The background loop: connect to the client's relays, announce ourselves, /// The background loop: connect to the client's relays, announce ourselves,
/// subscribe to kind 24133 events, and answer requests until stopped. /// subscribe to kind 24133 events, and answer requests until stopped.
async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: ConnectUri) { async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: ConnectUri) {
@ -646,6 +676,33 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
} }
client.connect().and_wait(CONNECT_TIMEOUT).await; client.connect().and_wait(CONNECT_TIMEOUT).await;
// `and_wait` returns when the pool has settled or the timeout elapsed,
// but individual relays may still be dialling. Poll for a short while so
// slow-but-alive relays are counted, and record which relays actually
// connected — the UI shows this so a partially dead link is visible
// instead of a silent "Connecting…".
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
let connected = connected_relay_urls(&client, deadline).await;
signer
.inner
.lock()
.expect("signer mutex poisoned")
.connected_relays = connected.clone();
if connected.is_empty() {
let list = uri
.relays
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", ");
signer.fail(format!(
"None of the relays in the link answered: {list}. The link's relays are unreachable \
from this machine — check your internet connection or have the app use a different \
relay, then try again."
));
return;
}
// 4. Subscribe to the client's kind 24133 events so we hear its requests. // 4. Subscribe to the client's kind 24133 events so we hear its requests.
// Do not use `stream_events` here: it is an auto-closing historical-event // Do not use `stream_events` here: it is an auto-closing historical-event
// helper and ends at EOSE. NIP-46 needs a long-lived subscription because // helper and ends at EOSE. NIP-46 needs a long-lived subscription because
@ -1063,6 +1120,63 @@ mod tests {
assert!(signer.approve("no-such-id", true).is_err()); assert!(signer.approve("no-such-id", true).is_err());
} }
#[tokio::test]
async fn connected_relay_urls_is_empty_when_no_relay_answers() {
// 192.0.2.1 is TEST-NET-1: guaranteed to be unroutable, so the pool
// can never connect to it. The helper must report "nothing" and stop
// at the deadline rather than hang.
let client = Client::new();
client
.add_relay("wss://192.0.2.1")
.await
.expect("add relay");
let deadline = tokio::time::Instant::now() + Duration::from_millis(100);
let urls = connected_relay_urls(&client, deadline).await;
assert!(urls.is_empty());
}
#[tokio::test]
async fn status_reports_connected_relays_and_fail_clears_them() {
let signer = Signer::new();
{
let mut inner = signer.inner.lock().unwrap();
inner.phase = SignerPhase::Connecting;
inner.relays = vec![
"wss://relay.damus.io".to_string(),
"wss://relay.nostr.band".to_string(),
];
inner.connected_relays = vec!["wss://relay.damus.io".to_string()];
}
let status = signer.status();
assert_eq!(status.phase, SignerPhase::Connecting);
assert_eq!(status.relays.len(), 2);
assert_eq!(status.connected_relays, vec!["wss://relay.damus.io"]);
signer.fail("None of the relays answered");
let status = signer.status();
assert_eq!(status.phase, SignerPhase::Stopped);
assert!(status.connected_relays.is_empty());
assert_eq!(status.error.as_deref(), Some("None of the relays answered"));
}
#[test]
fn disconnect_clears_connected_relays() {
let signer = Signer::new();
{
let mut inner = signer.inner.lock().unwrap();
inner.phase = SignerPhase::Connected;
inner.relays = vec!["wss://relay.damus.io".to_string()];
inner.connected_relays = vec!["wss://relay.damus.io".to_string()];
}
signer.disconnect();
let status = signer.status();
assert_eq!(status.phase, SignerPhase::Stopped);
assert!(status.connected_relays.is_empty());
assert!(status.relays.is_empty());
}
#[tokio::test] #[tokio::test]
async fn pending_approvals_are_capped() { async fn pending_approvals_are_capped() {
let signer = Signer::new(); let signer = Signer::new();

View file

@ -5,14 +5,16 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::app::App; use crate::app::{App, SignerMode};
use crate::errors::AppError; use crate::errors::AppError;
use crate::feed; use crate::feed;
use crate::profiles; 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; use crate::signer::embedded::EmbeddedSigner;
use crate::signer::nip46_client::Nip46ClientSigner;
use crate::signer::Signer as SignerTrait;
use crate::updates; use crate::updates;
/// How long to wait for a relay connection test. /// How long to wait for a relay connection test.
@ -134,15 +136,46 @@ pub enum Request {
url: String, url: String,
http_method: String, http_method: String,
}, },
/// Start the NIP-46 remote signer for a `nostrconnect://` link. /// ===== SIGNER MODE MANAGEMENT =====
/// Get the current signer mode.
SignerModeGet,
/// Set the signer mode (embedded or nip46).
SignerModeSet {
mode: SignerMode,
},
/// ===== EMBEDDED SIGNER =====
/// Get embedded signer status.
EmbeddedSignerStatus,
/// Approve/reject a pending embedded signer request.
EmbeddedSignerApprove {
index: usize,
approved: bool,
},
/// ===== NIP-46 CLIENT SIGNER =====
/// Connect to a NIP-46 signer using a nostrconnect:// URI.
Nip46Connect {
uri: String,
label: String,
},
/// Disconnect from the NIP-46 signer.
Nip46Disconnect,
/// Get NIP-46 connection status.
Nip46Status,
/// Approve/reject a pending NIP-46 request.
Nip46Approve {
id: String,
approved: bool,
},
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
SignerConnect { SignerConnect {
uri: String, uri: String,
}, },
/// Stop the NIP-46 remote signer. /// Stop the NIP-46 remote signer (bunker mode).
SignerDisconnect, SignerDisconnect,
/// Report the remote signer's current status. /// Report the remote signer's current status (bunker mode).
SignerStatus, SignerStatus,
/// Approve or reject a NIP-46 request that is waiting for a decision. /// Approve or reject a NIP-46 request that is waiting for a decision (bunker mode).
SignerApprove { SignerApprove {
/// The internal id of the pending request, as reported by /// The internal id of the pending request, as reported by
/// `SignerStatus.pending`. /// `SignerStatus.pending`.
@ -196,7 +229,6 @@ pub async fn serve() -> Result<(), AppError> {
// Shared state, so the NIP-46 signer's background task and the request loop // 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. // both see the same vault (including its unlock key) without racing writes.
let app = Arc::new(Mutex::new(App::load()?)); let app = Arc::new(Mutex::new(App::load()?));
let signer = Arc::new(Signer::new());
let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); let stdout = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout()));
let stdin = tokio::io::stdin(); let stdin = tokio::io::stdin();
@ -226,10 +258,9 @@ pub async fn serve() -> Result<(), AppError> {
}; };
let task_app = app.clone(); let task_app = app.clone();
let task_signer = signer.clone();
let task_stdout = stdout.clone(); let task_stdout = stdout.clone();
tasks.spawn(async move { tasks.spawn(async move {
let reply = handle(task_app, task_signer, envelope.request).await; let reply = handle(task_app, envelope.request).await;
let _ = write_line( let _ = write_line(
&task_stdout, &task_stdout,
ReplyEnvelope { ReplyEnvelope {
@ -268,12 +299,8 @@ async fn write_line(
Ok(()) Ok(())
} }
async fn handle( async fn handle(app: Arc<Mutex<App>>, request: Request) -> Reply<serde_json::Value> {
app: Arc<Mutex<App>>, let result = run(&app, request).await;
signer: Arc<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 {
@ -296,43 +323,164 @@ fn error_code(err: &AppError) -> String {
.unwrap_or_else(|_| "error".to_string()) .unwrap_or_else(|_| "error".to_string())
} }
/// Signer control commands never touch the vault directly, so they take the /// Main request dispatcher.
/// shared handle (a clone) rather than locking the state. Read-only network async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
/// requests (relay tests, feed reads) grab what they need under a short lock
/// and then run without it, so slow relays cannot delay interactive requests.
/// Everything else locks the state for the duration of the call, so mutations
/// remain serialized and never interleave.
async fn run(
app: &Arc<Mutex<App>>,
signer: &Signer,
request: Request,
) -> Result<serde_json::Value, AppError> {
match request { match request {
// Signer mode management
Request::SignerModeGet => {
let guard = app.lock().await;
Ok(json!({ "mode": guard.signer_mode }))
}
Request::SignerModeSet { mode } => {
let mut guard = app.lock().await;
// Initialize the appropriate signer if needed
match mode {
SignerMode::Embedded => {
if guard.embedded_signer.is_none() {
let signer = Arc::new(EmbeddedSigner::new(app.clone()));
// Set active profile
if let Some(npub) = &guard.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
guard.embedded_signer = Some(signer);
}
guard.nip46_signer = None; // Drop NIP-46 signer
}
SignerMode::Nip46 => {
if guard.nip46_signer.is_none() {
let signer = Arc::new(Nip46ClientSigner::new(app.clone()));
guard.nip46_signer = Some(signer);
}
guard.embedded_signer = None; // Drop embedded signer
}
}
guard.signer_mode = mode;
guard.save_vault()?;
Ok(json!(guard.state_view()))
}
// Embedded signer
Request::EmbeddedSignerStatus => {
let guard = app.lock().await;
if let Some(signer) = &guard.embedded_signer {
let status = signer.detailed_status().await;
Ok(json!(status))
} else {
Ok(json!({ "type": "embedded", "available": false, "error": "Not initialized" }))
}
}
Request::EmbeddedSignerApprove { index, approved } => {
let guard = app.lock().await;
if let Some(signer) = &guard.embedded_signer {
signer.respond_to_approval(index, approved).await?;
let status = signer.detailed_status().await;
Ok(json!(status))
} else {
Err(AppError::config("Embedded signer not initialized"))
}
}
// NIP-46 client signer
Request::Nip46Connect { uri, label } => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, label).await?;
Ok(json!(status))
} else {
Err(AppError::config(
"NIP-46 signer not initialized. Set signer mode to nip46 first.",
))
}
}
Request::Nip46Disconnect => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?;
let status = signer.status().await;
Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
}
Request::Nip46Status => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await;
Ok(json!(status))
} else {
Ok(json!({ "connected": false, "error": "Not initialized" }))
}
}
Request::Nip46Approve { id, approved } => {
let guard = app.lock().await;
if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await;
Ok(json!(status))
} else {
Err(AppError::config("NIP-46 signer not initialized"))
}
}
// Legacy NIP-46 bunker (server mode)
Request::SignerConnect { uri } => { Request::SignerConnect { uri } => {
signer.connect(app.clone(), &uri)?; let guard = app.lock().await;
Ok(json!(signer.status())) // Initialize legacy signer if needed
// Note: This uses the old bunker-style signer
// For now, delegate to the new NIP-46 client if in that mode
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
return Ok(json!(status));
}
}
Err(AppError::config(
"Legacy bunker mode not supported. Use NIP-46 client mode.",
))
} }
Request::SignerDisconnect => { Request::SignerDisconnect => {
signer.disconnect(); let guard = app.lock().await;
Ok(json!(signer.status())) if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?;
let status = signer.status().await;
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
}
Request::SignerStatus => {
let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await;
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
} }
Request::SignerStatus => Ok(json!(signer.status())),
Request::SignerApprove { id, approved } => { Request::SignerApprove { id, approved } => {
signer.approve(&id, approved)?; let guard = app.lock().await;
Ok(json!(signer.status())) if guard.signer_mode == SignerMode::Nip46 && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?;
let status = signer.status().await;
return Ok(json!(status));
}
}
Err(AppError::config("Not in NIP-46 client mode"))
} }
// Network-only requests (no shared state lock)
Request::RelayTest { url } => { Request::RelayTest { url } => {
// Pure network probe against the given URL; no shared state.
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?; let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
Ok(json!(result)) Ok(json!(result))
} }
Request::UpdateCheck => { Request::UpdateCheck => {
// Long-running package-manager scan; never touches shared state.
let report = updates::check().await?; let report = updates::check().await?;
Ok(json!(report)) Ok(json!(report))
} }
Request::UpdateApply => { Request::UpdateApply => {
// Installs updates on disk; a rebuild + restart picks them up.
let report = updates::apply().await?; let report = updates::apply().await?;
Ok(json!(report)) Ok(json!(report))
} }
@ -343,14 +491,10 @@ async fn run(
} => { } => {
let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); let limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
let contacts_only = contacts_only.unwrap_or(false); let contacts_only = contacts_only.unwrap_or(false);
// Resolve the requested author outside any lock: parsing a key is
// pure and must not queue behind vault mutations.
let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) { let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()), Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
None => None, None => None,
}; };
// Copy the inputs out of shared state under a short lock so the
// multi-second relay fetches below never block a Select or save.
let (settings, owner_hex) = { let (settings, owner_hex) = {
let guard = app.lock().await; let guard = app.lock().await;
let owner_hex = if author_hex.is_some() { let owner_hex = if author_hex.is_some() {
@ -376,6 +520,8 @@ async fn run(
}; };
Ok(json!(items)) Ok(json!(items))
} }
// Vault state requests (require lock)
other => { other => {
let mut guard = app.lock().await; let mut guard = app.lock().await;
run_with_app(&mut guard, other).await run_with_app(&mut guard, other).await
@ -395,6 +541,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
let key = app.vault_key().copied(); let key = app.vault_key().copied();
let summary = let summary =
profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?; profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?;
// Update embedded signer if active
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
}
app.save_vault()?; app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() })) Ok(json!({ "profile": summary, "state": app.state_view() }))
} }
@ -409,12 +561,22 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
key.as_ref(), key.as_ref(),
&app.settings, &app.settings,
)?; )?;
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(summary.npub.clone())).await;
}
}
app.save_vault()?; app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() })) Ok(json!({ "profile": summary, "state": app.state_view() }))
} }
Request::SelectProfile { npub } => { Request::SelectProfile { npub } => {
profiles::set_active(&mut app.vault, &npub)?; profiles::set_active(&mut app.vault, &npub)?;
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(Some(npub)).await;
}
}
app.save_vault()?; app.save_vault()?;
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }
@ -513,11 +675,23 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Request::UnlockVault { password } => { Request::UnlockVault { password } => {
app.unlock(&password)?; app.unlock(&password)?;
// Re-initialize signers with unlocked vault
if app.signer_mode == SignerMode::Embedded {
if let Some(signer) = &app.embedded_signer {
if let Some(npub) = &app.vault.active_profile {
signer.set_active_profile(Some(npub.clone())).await;
}
}
}
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }
Request::LockVault => { Request::LockVault => {
app.lock(); app.lock();
// Clear signers' active profiles
if let Some(signer) = &app.embedded_signer {
signer.set_active_profile(None).await;
}
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }
@ -571,9 +745,7 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
app.save_vault()?; app.save_vault()?;
Ok(json!(app.state_view())) Ok(json!(app.state_view()))
} }
// Signer control requests are handled by `run` before this function is _ => Err(AppError::internal("Unexpected request.")),
// reached; keeping a wildcard arm keeps the match exhaustive here.
_ => Err(AppError::internal("Unexpected signer request.")),
} }
} }

View file

@ -1,4 +1,5 @@
pub mod app; pub mod app;
pub mod bunker;
pub mod crypto; pub mod crypto;
pub mod errors; pub mod errors;
pub mod feed; pub mod feed;

View file

@ -2,13 +2,13 @@ use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use keynectr::app::App; use keynectr::app::App;
use keynectr::bunker::Signer;
use keynectr::errors::{AppError, ErrorKind}; use keynectr::errors::{AppError, ErrorKind};
use keynectr::ipc; use keynectr::ipc;
use keynectr::profiles::{self, ProfileSummary}; use keynectr::profiles::{self, ProfileSummary};
use keynectr::publish; use keynectr::publish;
use keynectr::relays; use keynectr::relays;
use keynectr::settings::Theme; use keynectr::settings::Theme;
use keynectr::signer::Signer;
use keynectr::vault::{self, StoredProfile, Vault}; use keynectr::vault::{self, StoredProfile, Vault};
const USAGE: &str = "\ const USAGE: &str = "\

258
src/signer/embedded.rs Normal file
View file

@ -0,0 +1,258 @@
//! Embedded signer - keys stored locally in the encrypted vault.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use nostr_sdk::prelude::*;
use tokio::sync::{oneshot, Mutex};
use crate::app::App;
use crate::errors::AppError;
use crate::profiles;
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
use crate::signer::Signer;
/// Maximum time to wait for user approval.
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
/// Maximum pending approvals queue size.
const MAX_PENDING_APPROVALS: usize = 20;
/// A request waiting for user approval.
struct PendingApproval {
method: String,
details: ApprovalDetails,
sender: oneshot::Sender<ApprovalResult>,
}
/// Embedded signer using keys from the local vault.
pub struct EmbeddedSigner {
app: Arc<Mutex<App>>,
active_npub: Arc<Mutex<Option<String>>>,
pending: Arc<Mutex<Vec<PendingApproval>>>,
}
impl EmbeddedSigner {
/// Create a new embedded signer bound to the app state.
pub fn new(app: Arc<Mutex<App>>) -> Self {
Self {
app,
active_npub: Arc::new(Mutex::new(None)),
pending: Arc::new(Mutex::new(Vec::new())),
}
}
/// Set the active profile by npub.
pub async fn set_active_profile(&self, npub: Option<String>) {
let mut guard = self.active_npub.lock().await;
*guard = npub;
}
/// Get the current active npub.
pub async fn active_npub(&self) -> Option<String> {
let guard = self.active_npub.lock().await;
guard.clone()
}
/// Resolve the active profile's Keys, checking vault lock state.
async fn resolve_keys(&self) -> Result<Keys, AppError> {
let app = self.app.lock().await;
let npub_guard = self.active_npub.lock().await;
let npub = npub_guard.as_ref().ok_or_else(|| {
AppError::config("No active profile selected. Choose a profile first.")
})?;
if app.is_locked() {
return Err(AppError::vault_locked());
}
let vault_key = app.vault_key().copied();
let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref())?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
Ok(Keys::new(secret_key))
}
/// Queue an approval request and wait for user decision.
async fn await_approval(&self, details: ApprovalDetails) -> ApprovalResult {
let (sender, receiver) = oneshot::channel();
// Check queue capacity
{
let mut pending = self.pending.lock().await;
if pending.len() >= MAX_PENDING_APPROVALS {
return ApprovalResult::Timeout;
}
pending.push(PendingApproval {
method: details.method.clone(),
details: details.clone(),
sender,
});
}
// Wait for approval with timeout
let result = match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await {
Ok(Ok(approved)) => approved,
Ok(Err(_)) => ApprovalResult::Timeout, // Channel closed (signer dropped)
Err(_) => ApprovalResult::Timeout,
};
// Clean up
self.pending.lock().await.retain(|p| {
p.details.method != details.method
|| p.details.content_preview != details.content_preview
});
result
}
/// Get pending approvals for UI display.
pub async fn pending_approvals(&self) -> Vec<crate::signer::types::PendingApproval> {
let pending = self.pending.lock().await;
pending
.iter()
.map(|p| crate::signer::types::PendingApproval {
id: uuid::Uuid::new_v4().to_string(), // Generate display ID
method: p.method.clone(),
summary: p.details.summary.clone(),
details: p.details.clone(),
})
.collect()
}
/// Approve or reject a pending request by index.
pub async fn respond_to_approval(&self, index: usize, approved: bool) -> Result<(), AppError> {
let mut pending = self.pending.lock().await;
if index >= pending.len() {
return Err(AppError::config("No pending request at that index"));
}
let entry = pending.remove(index);
let _ = entry.sender.send(if approved {
ApprovalResult::Approved
} else {
ApprovalResult::Rejected
});
Ok(())
}
fn describe_sign_event(event: &UnsignedEvent) -> ApprovalDetails {
let content_preview = event.content.chars().take(80).collect::<String>();
let is_sensitive = matches!(
event.kind.as_u16(),
0 | 3
| 5
| 6
| 10000
| 10001
| 10002
| 30000
| 30001
| 30002
| 30003
| 30004
| 30005
| 30006
| 30007
| 30008
| 30009
| 30010
| 30011
| 30012
| 30013
| 30014
| 30015
);
ApprovalDetails {
method: "sign_event".to_string(),
summary: format!("Sign event kind {}", event.kind.as_u16()),
event_kind: Some(event.kind.as_u16()),
destination_relays: Vec::new(), // Filled by caller if known
content_preview,
is_sensitive,
}
}
}
#[async_trait]
impl Signer for EmbeddedSigner {
async fn get_public_key(&self) -> Result<PublicKey, AppError> {
let keys = self.resolve_keys().await?;
Ok(keys.public_key())
}
async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, AppError> {
let keys = self.resolve_keys().await?;
// Request approval for sensitive operations
let details = Self::describe_sign_event(&event);
let approval = self.request_approval(details).await;
match approval {
ApprovalResult::Approved => keys
.sign_event(event)
.map_err(|e| AppError::internal(format!("Failed to sign event: {e}"))),
ApprovalResult::Rejected => Err(AppError::config("Signing request rejected by user")),
ApprovalResult::Timeout => Err(AppError::config("Signing request timed out")),
}
}
fn get_signer_type(&self) -> SignerType {
SignerType::Embedded
}
async fn is_available(&self) -> bool {
let app = self.app.lock().await;
let npub_guard = self.active_npub.lock().await;
npub_guard.is_some() && !app.is_locked()
}
async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult {
self.await_approval(details).await
}
async fn disconnect(&self) -> Result<(), AppError> {
let mut npub_guard = self.active_npub.lock().await;
*npub_guard = None;
self.pending.lock().await.clear();
Ok(())
}
async fn revoke(&self) -> Result<(), AppError> {
let npub = {
let mut npub_guard = self.active_npub.lock().await;
npub_guard.take()
};
if let Some(npub) = npub {
let mut app = self.app.lock().await;
let _ = profiles::delete_profile(&mut app.vault, &npub);
app.save_vault()?;
}
self.pending.lock().await.clear();
Ok(())
}
async fn status_string(&self) -> String {
let available = self.is_available().await;
let npub_guard = self.active_npub.lock().await;
if available {
"Embedded signer: Ready".to_string()
} else if npub_guard.is_none() {
"Embedded signer: No profile selected".to_string()
} else {
"Embedded signer: Vault locked".to_string()
}
}
async fn detailed_status(&self) -> serde_json::Value {
let available = self.is_available().await;
let pending = self.pending_approvals().await;
let npub_guard = self.active_npub.lock().await;
serde_json::json!({
"type": "embedded",
"available": available,
"active_npub": *npub_guard,
"pending_count": pending.len(),
"pending": pending,
})
}
}

43
src/signer/mod.rs Normal file
View file

@ -0,0 +1,43 @@
//! The Signer trait - common interface for all signing modes.
pub mod embedded;
pub mod nip46_client;
pub mod types;
use async_trait::async_trait;
use nostr_sdk::prelude::*;
use crate::errors::AppError;
use crate::signer::types::{ApprovalDetails, ApprovalResult, SignerType};
/// Common interface for all signer implementations.
#[async_trait]
pub trait Signer: Send + Sync {
/// Get the public key of the active signing identity.
async fn get_public_key(&self) -> Result<PublicKey, AppError>;
/// Sign an event with the active key.
async fn sign_event(&self, event: UnsignedEvent) -> Result<Event, AppError>;
/// Get the type of this signer.
fn get_signer_type(&self) -> SignerType;
/// Check if the signer is currently available (unlocked, connected, etc.).
async fn is_available(&self) -> bool;
/// Request user approval for a sensitive operation.
/// Returns the user's decision.
async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult;
/// Disconnect/stop the signer (for NIP-46, closes connection).
async fn disconnect(&self) -> Result<(), AppError>;
/// Revoke the signer authorization (for NIP-46, revokes the connection).
async fn revoke(&self) -> Result<(), AppError>;
/// Get a human-readable status string for UI display.
async fn status_string(&self) -> String;
/// Get detailed status for UI (connection state, pending requests, etc.).
async fn detailed_status(&self) -> serde_json::Value;
}

793
src/signer/nip46_client.rs Normal file
View file

@ -0,0 +1,793 @@
//! NIP-46 client signer - connects to a remote signer (bunker) via nostrconnect://.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use getrandom::getrandom;
use nostr::nips::nip44::v2;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::{oneshot, Mutex};
use crate::app::App;
use crate::errors::AppError;
use crate::profiles;
use crate::signer::types::{
ApprovalDetails, ApprovalResult, Nip46Connection, Nip46Status, PendingApproval, SignerType,
};
use crate::signer::Signer;
/// How long to wait for relays to accept a connection attempt.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How long a request may wait for the user to approve it before it expires.
const APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
/// Maximum number of requests kept waiting for approval at once.
const MAX_PENDING_APPROVALS: usize = 20;
/// Internal state for a pending approval.
struct PendingApprovalInner {
method: String,
details: ApprovalDetails,
sender: oneshot::Sender<ApprovalResult>,
}
/// Parsed nostrconnect:// URI.
struct ConnectUri {
peer: PublicKey,
relays: Vec<RelayUrl>,
secret: Option<String>,
}
/// The NIP-46 client signer.
pub struct Nip46ClientSigner {
inner: Arc<Mutex<Nip46Inner>>,
app: Arc<Mutex<App>>,
}
struct Nip46Inner {
connection: Option<Nip46Connection>,
phase: Nip46Phase,
task: Option<tokio::task::JoinHandle<()>>,
conversation_key: Option<ConversationKey>,
client: Option<Client>,
pending: HashMap<String, PendingApprovalInner>,
keys: Option<Keys>,
connect_secret: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Nip46Phase {
Stopped,
Connecting,
Connected,
Error(String),
}
impl Nip46ClientSigner {
/// Create a new NIP-46 client signer.
pub fn new(app: Arc<Mutex<App>>) -> Self {
Self {
inner: Arc::new(Mutex::new(Nip46Inner {
connection: None,
phase: Nip46Phase::Stopped,
task: None,
conversation_key: None,
client: None,
pending: HashMap::new(),
keys: None,
connect_secret: None,
})),
app,
}
}
/// Parse a nostrconnect:// URI.
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 from your Nostr app.")
})?;
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.",
));
}
Ok(ConnectUri {
peer,
relays,
secret,
})
}
/// Connect to a NIP-46 signer using a nostrconnect:// URI.
pub async fn connect(&self, uri: &str, label: String) -> Result<Nip46Status, AppError> {
let parsed = Self::parse_connect_uri(uri)?;
// Resolve our active profile's keys for NIP-44 encryption
let keys = {
let app = self.app.lock().await;
let npub =
app.vault.active_profile.as_ref().ok_or_else(|| {
AppError::config("No active profile. Select a profile first.")
})?;
if app.is_locked() {
return Err(AppError::vault_locked());
}
let vault_key = app.vault_key().copied();
let secret_hex = profiles::resolve_secret_key(&app.vault, npub, vault_key.as_ref())?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
Keys::new(secret_key)
};
// Derive conversation key with the signer
let conversation = ConversationKey::derive(keys.secret_key(), &parsed.peer)
.map_err(|e| AppError::internal(format!("Could not derive session key: {e}")))?;
// Build connection config
let connection = Nip46Connection {
signer_pubkey: parsed.peer.to_hex(),
relays: parsed.relays.iter().map(|r| r.to_string()).collect(),
secret: parsed.secret.clone(),
label,
created_at: crate::vault::unix_timestamp()?,
};
// Update state to connecting
{
let mut inner = self.inner.lock().await;
if inner.task.is_some() {
return Err(AppError::config(
"Already connected to a signer. Disconnect first.",
));
}
inner.phase = Nip46Phase::Connecting;
inner.connection = Some(connection.clone());
inner.conversation_key = Some(conversation);
inner.keys = Some(keys.clone());
inner.connect_secret = parsed.secret.clone();
inner.pending.clear();
}
// Spawn the connection task
let signer = self.clone();
let task = tokio::spawn(async move {
if let Err(e) = signer.clone().run_sign_task(parsed).await {
signer.fail(e);
}
});
self.inner.lock().await.task = Some(task);
Ok(self.status().await)
}
/// Disconnect from the signer.
pub async fn disconnect(&self) -> Result<(), AppError> {
let mut inner = self.inner.lock().await;
if let Some(task) = inner.task.take() {
task.abort();
}
if let Some(client) = inner.client.take() {
let _ = client.disconnect().await;
}
inner.phase = Nip46Phase::Stopped;
inner.connection = None;
inner.conversation_key = None;
inner.keys = None;
inner.connect_secret = None;
inner.pending.clear();
Ok(())
}
/// Revoke the connection (same as disconnect for now, could send logout).
pub async fn revoke(&self) -> Result<(), AppError> {
self.disconnect().await
}
/// Get current connection status.
pub async fn status(&self) -> Nip46Status {
let inner = self.inner.lock().await;
let connection = inner.connection.clone();
let pending: Vec<PendingApproval> = inner
.pending
.iter()
.map(|(id, entry)| PendingApproval {
id: id.clone(),
method: entry.method.clone(),
summary: entry.details.summary.clone(),
details: entry.details.clone(),
})
.collect();
let connected_relays = if let Some(client) = &inner.client {
let map = client.relays().all().await;
map.into_iter()
.filter(|(_, relay)| relay.status().is_connected())
.map(|(url, _)| url.to_string())
.collect()
} else {
Vec::new()
};
Nip46Status {
connected: matches!(inner.phase, Nip46Phase::Connected),
signer_pubkey: connection.as_ref().map(|c| c.signer_pubkey.clone()),
relays: connection
.as_ref()
.map(|c| c.relays.clone())
.unwrap_or_default(),
connected_relays,
error: match &inner.phase {
Nip46Phase::Error(e) => Some(e.clone()),
_ => None,
},
pending_approvals: pending,
}
}
/// Get pending approvals for UI.
pub async fn pending_approvals(&self) -> Vec<PendingApproval> {
let inner = self.inner.lock().await;
inner
.pending
.iter()
.map(|(id, entry)| PendingApproval {
id: id.clone(),
method: entry.method.clone(),
summary: entry.details.summary.clone(),
details: entry.details.clone(),
})
.collect()
}
/// Approve or reject a pending request.
pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> {
let mut inner = self.inner.lock().await;
let Some(entry) = inner.pending.remove(id) else {
return Err(AppError::config("Request no longer pending"));
};
let _ = entry.sender.send(if approved {
ApprovalResult::Approved
} else {
ApprovalResult::Rejected
});
Ok(())
}
fn fail(&self, message: impl Into<String>) {
if let Ok(mut inner) = self.inner.try_lock() {
inner.phase = Nip46Phase::Error(message.into());
inner.task = None;
inner.client = None;
inner.conversation_key = None;
inner.keys = None;
inner.connect_secret = None;
inner.pending.clear();
}
}
async fn await_approval(&self, details: ApprovalDetails) -> ApprovalResult {
let id = uuid::Uuid::new_v4().to_string();
let (sender, receiver) = oneshot::channel();
{
let mut inner = self.inner.lock().await;
if inner.pending.len() >= MAX_PENDING_APPROVALS {
return ApprovalResult::Timeout;
}
inner.pending.insert(
id.clone(),
PendingApprovalInner {
method: details.method.clone(),
details: details.clone(),
sender,
},
);
}
match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await {
Ok(Ok(approved)) => approved,
Ok(Err(_)) => {
self.inner.lock().await.pending.remove(&id);
ApprovalResult::Timeout
}
Err(_) => {
self.inner.lock().await.pending.remove(&id);
ApprovalResult::Timeout
}
}
}
/// Main background task: connect to relays, subscribe, handle requests.
async fn run_sign_task(self, uri: ConnectUri) -> Result<(), String> {
let (conversation, keys, connect_secret) = {
let inner = self.inner.lock().await;
let conversation = inner
.conversation_key
.as_ref()
.cloned()
.ok_or("No conversation key")?;
let keys = inner.keys.as_ref().cloned().ok_or("No keys")?;
let connect_secret = inner.connect_secret.clone();
(conversation, keys, connect_secret)
};
// Connect to relays
let client = Client::builder()
.authenticator(SignerAuthenticator::new(keys.clone()))
.build();
for url in &uri.relays {
client
.add_relay(url.to_string())
.await
.map_err(|e| format!("Could not add relay {url}: {e}"))?;
}
client.connect().and_wait(CONNECT_TIMEOUT).await;
// Wait for relays to connect
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
let connected = loop {
let map = client.relays().all().await;
let urls: Vec<String> = map
.into_iter()
.filter(|(_, relay)| relay.status().is_connected())
.map(|(url, _)| url.to_string())
.collect();
if !urls.is_empty() || tokio::time::Instant::now() >= deadline {
break urls;
}
tokio::time::sleep(Duration::from_millis(250)).await
};
if connected.is_empty() {
return Err("None of the relays answered".to_string());
}
// Update connected relays
self.inner.lock().await.client = Some(client.clone());
// Subscribe to kind 24133 from signer
let filter = Filter::new().kind(Kind::NostrConnect).author(uri.peer);
let mut notifications = client.notifications();
let subscription = client
.subscribe(filter)
.await
.map_err(|e| format!("Could not subscribe: {e}"))?;
// Send connect request
self.send_connect(&client, &keys, &conversation, &uri, &connect_secret)
.await?;
// Mark as connected
self.inner.lock().await.phase = Nip46Phase::Connected;
// Handle incoming requests
loop {
let incoming = match notifications.next().await {
Some(nostr_sdk::client::ClientNotification::Event {
subscription_id,
event,
..
}) if subscription_id == *subscription.id() => event,
Some(nostr_sdk::client::ClientNotification::Shutdown) | None => {
return Err("Connection closed".to_string());
}
Some(_) => continue,
};
let event = *incoming;
if event.kind != Kind::NostrConnect || event.pubkey != uri.peer {
continue;
}
let plaintext = match nip44_decrypt(&conversation, &event.content) {
Ok(p) => p,
Err(_) => continue,
};
let request: RawRequest = match serde_json::from_str(&plaintext) {
Ok(r) => r,
Err(_) => continue,
};
let response = if self.requires_approval(&request.method) {
self.gated_response(&keys, &request).await
} else {
self.handle_request(&keys, &uri, &request)
};
if let Some(response) = response {
self.publish_payload(&client, &keys, &conversation, &uri.peer, &response)
.await?;
}
}
}
fn requires_approval(&self, method: &str) -> bool {
matches!(method, "sign_event" | "nip44_encrypt" | "nip44_decrypt")
}
async fn gated_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
self.inner.lock().await.phase = Nip46Phase::Connected;
let details = self.describe_request(request);
match self.await_approval(details).await {
ApprovalResult::Approved => self.approved_response(keys, request),
ApprovalResult::Rejected => Some(response_ok_rejected(&request.id)),
ApprovalResult::Timeout => Some(response_ok_timeout(&request.id)),
}
}
fn handle_request(
&self,
keys: &Keys,
uri: &ConnectUri,
request: &RawRequest,
) -> Option<String> {
// Note: we can't await here, so phase update is best-effort
// The phase is updated in gated_response for key-using methods
match request.method.as_str() {
"connect" => {
if let Some(expected) = &uri.secret {
if !request.params.iter().any(|p| p == expected) {
return Some(response_err(
&request.id,
"Connect acknowledgement missing expected secret".to_string(),
));
}
}
Some(response_ok(&request.id, "ack".to_string()))
}
"get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())),
"get_relays" => Some(response_ok(&request.id, json!(uri.relays).to_string())),
"ping" => Some(response_ok(&request.id, "pong".to_string())),
"logout" => Some(response_ok(&request.id, "ack".to_string())),
other => Some(response_err(&request.id, format!("Unsupported: {other}"))),
}
}
fn approved_response(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
match request.method.as_str() {
"sign_event" => self.sign_event(keys, request),
"nip44_encrypt" | "nip44_decrypt" => self.nip44(keys, request),
_ => None,
}
}
fn sign_event(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
let json_str = request.params.first()?;
let mut value: serde_json::Value = serde_json::from_str(json_str).ok()?;
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).ok()?;
let event = keys.sign_event(unsigned).ok()?;
Some(response_ok(&request.id, event.as_json()))
}
fn nip44(&self, keys: &Keys, request: &RawRequest) -> Option<String> {
if request.params.len() != 2 {
return None;
}
let peer = PublicKey::from_hex(&request.params[0]).ok()?;
let conversation = ConversationKey::derive(keys.secret_key(), &peer).ok()?;
let result = match request.method.as_str() {
"nip44_encrypt" => nip44_encrypt(&conversation, &request.params[1]),
_ => nip44_decrypt(&conversation, &request.params[1]),
};
result.map(|v| response_ok(&request.id, v)).ok()
}
fn describe_request(&self, request: &RawRequest) -> ApprovalDetails {
match request.method.as_str() {
"sign_event" => {
let preview = request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.map(|value| {
let kind = value.get("kind").and_then(|k| k.as_u64()).unwrap_or(0);
let content = value
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("")
.chars()
.take(80)
.collect::<String>();
format!("event kind {kind}: \"{content}\"")
})
.unwrap_or_else(|| "an event".to_string());
let is_sensitive = matches!(
request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64())),
Some(0 | 3 | 5 | 6 | 10000 | 10001 | 10002 | 30000..=30015)
);
ApprovalDetails {
method: "sign_event".to_string(),
summary: format!("Sign {preview}"),
event_kind: request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.map(|k| k as u16),
destination_relays: Vec::new(),
content_preview: preview,
is_sensitive,
}
}
"nip44_encrypt" => {
let target = request
.params
.first()
.and_then(|hex| {
if hex.len() == 64 {
Some(format!("{}…{}", &hex[..8], &hex[56..]))
} else {
None
}
})
.unwrap_or_else(|| "a third party".to_string());
ApprovalDetails {
method: "nip44_encrypt".to_string(),
summary: format!("Encrypt a message for {target}"),
event_kind: None,
destination_relays: Vec::new(),
content_preview: String::new(),
is_sensitive: true,
}
}
"nip44_decrypt" => {
let target = request
.params
.first()
.and_then(|hex| {
if hex.len() == 64 {
Some(format!("{}…{}", &hex[..8], &hex[56..]))
} else {
None
}
})
.unwrap_or_else(|| "a third party".to_string());
ApprovalDetails {
method: "nip44_decrypt".to_string(),
summary: format!("Decrypt a message from {target}"),
event_kind: None,
destination_relays: Vec::new(),
content_preview: String::new(),
is_sensitive: true,
}
}
other => ApprovalDetails {
method: other.to_string(),
summary: other.to_string(),
event_kind: None,
destination_relays: Vec::new(),
content_preview: String::new(),
is_sensitive: false,
},
}
}
async fn send_connect(
&self,
client: &Client,
keys: &Keys,
conversation: &ConversationKey,
uri: &ConnectUri,
secret: &Option<String>,
) -> Result<(), String> {
let mut params = vec![keys.public_key().to_hex()];
if let Some(secret) = secret {
params.push(secret.clone());
}
let payload = json!({
"id": uuid::Uuid::new_v4().to_string(),
"method": "connect",
"params": params,
})
.to_string();
self.publish_payload(client, keys, conversation, &uri.peer, &payload)
.await
}
async fn publish_payload(
&self,
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])
.finalize_async(keys)
.await
.map_err(|e| format!("Could not sign: {e}"))?;
client
.send_event(&event)
.await
.map_err(|e| format!("{e}"))?;
Ok(())
}
}
impl Clone for Nip46ClientSigner {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
app: self.app.clone(),
}
}
}
#[async_trait]
impl Signer for Nip46ClientSigner {
async fn get_public_key(&self) -> Result<PublicKey, AppError> {
let inner = self.inner.lock().await;
let connection = inner
.connection
.as_ref()
.ok_or_else(|| AppError::config("Not connected to a signer"))?;
PublicKey::from_hex(&connection.signer_pubkey)
.map_err(|_| AppError::config("Invalid signer public key"))
}
async fn sign_event(&self, _event: UnsignedEvent) -> Result<Event, AppError> {
// For NIP-46 client, signing happens via the NIP-46 channel with user approval
// The actual flow uses request_approval + respond_to_approval
Err(AppError::config(
"NIP-46 signing uses async approval flow. Use request_approval.",
))
}
fn get_signer_type(&self) -> SignerType {
SignerType::Nip46
}
async fn is_available(&self) -> bool {
let inner = self.inner.lock().await;
matches!(inner.phase, Nip46Phase::Connected) && inner.client.is_some()
}
async fn request_approval(&self, details: ApprovalDetails) -> ApprovalResult {
self.await_approval(details).await
}
async fn disconnect(&self) -> Result<(), AppError> {
Nip46ClientSigner::disconnect(self).await
}
async fn revoke(&self) -> Result<(), AppError> {
Nip46ClientSigner::revoke(self).await
}
async fn status_string(&self) -> String {
let inner = self.inner.lock().await;
match inner.phase {
Nip46Phase::Stopped => "NIP-46: Not connected".to_string(),
Nip46Phase::Connecting => "NIP-46: Connecting…".to_string(),
Nip46Phase::Connected => "NIP-46: Connected".to_string(),
Nip46Phase::Error(ref e) => format!("NIP-46: Error - {e}"),
}
}
async fn detailed_status(&self) -> serde_json::Value {
let status = self.status().await;
serde_json::to_value(status).unwrap_or(serde_json::json!({}))
}
}
/// Minimal decrypted NIP-46 request.
#[derive(Debug, Deserialize)]
struct RawRequest {
id: String,
method: String,
#[serde(default)]
params: Vec<String>,
}
fn response_ok(id: &str, result: String) -> String {
json!({ "id": id, "result": result, "error": null }).to_string()
}
fn response_err(id: &str, error: String) -> String {
json!({ "id": id, "result": null, "error": error }).to_string()
}
fn response_ok_rejected(id: &str) -> String {
response_err(id, "The request was rejected by the user.".to_string())
}
fn response_ok_timeout(id: &str) -> String {
response_err(
id,
"The user did not approve this request in time; try again.".to_string(),
)
}
fn nip44_encrypt(conversation: &ConversationKey, plaintext: &str) -> Result<String, AppError> {
let mut nonce = [0u8; 32];
getrandom(&mut nonce).map_err(|e| AppError::internal(format!("Entropy error: {e}")))?;
let payload = v2::encrypt_to_bytes_with_nonce(conversation, plaintext.as_bytes(), nonce)
.map_err(|e| AppError::internal(format!("Encryption failed: {e}")))?;
Ok(B64.encode(payload))
}
fn nip44_decrypt(conversation: &ConversationKey, content: &str) -> Result<String, AppError> {
let bytes = B64
.decode(content)
.map_err(|e| AppError::internal(format!("Decode failed: {e}")))?;
let plaintext = v2::decrypt_to_bytes(conversation, &bytes)
.map_err(|e| AppError::internal(format!("Decryption failed: {e}")))?;
String::from_utf8(plaintext)
.map_err(|_| AppError::internal("Decrypted payload not valid 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()
}

74
src/signer/types.rs Normal file
View file

@ -0,0 +1,74 @@
//! Common types for the Signer abstraction.
use serde::{Deserialize, Serialize};
/// The type of signer being used.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignerType {
/// Keys stored locally in the encrypted vault.
Embedded,
/// Keys held by a remote NIP-46 signer (bunker).
Nip46,
}
/// Details about a signing request, for user approval.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalDetails {
/// The NIP-46 method being requested.
pub method: String,
/// Human-readable summary of what will be done.
pub summary: String,
/// Event kind for `sign_event` requests.
pub event_kind: Option<u16>,
/// Destination relays for the signed event.
pub destination_relays: Vec<String>,
/// Truncated preview of event content.
pub content_preview: String,
/// Whether this is a sensitive operation requiring extra confirmation.
pub is_sensitive: bool,
}
/// Result of a user approval prompt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalResult {
Approved,
Rejected,
Timeout,
}
/// Configuration for a NIP-46 connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip46Connection {
/// The signer's public key (hex).
pub signer_pubkey: String,
/// Relays to use for the connection.
pub relays: Vec<String>,
/// Optional secret from the nostrconnect URI.
pub secret: Option<String>,
/// Human-readable label for this connection.
pub label: String,
/// When this connection was created.
pub created_at: u64,
}
/// Status of a NIP-46 connection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nip46Status {
pub connected: bool,
pub signer_pubkey: Option<String>,
pub relays: Vec<String>,
pub connected_relays: Vec<String>,
pub error: Option<String>,
pub pending_approvals: Vec<PendingApproval>,
}
/// A pending approval request from the signer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingApproval {
pub id: String,
pub method: String,
pub summary: String,
pub details: ApprovalDetails,
}