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:
parent
aabc22553f
commit
b484bdeb08
24 changed files with 3163 additions and 78 deletions
966
Cargo.lock
generated
966
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,3 +17,5 @@ base64 = "0.22"
|
|||
getrandom = "0.2"
|
||||
zeroize = "1"
|
||||
rpassword = "7"
|
||||
async-trait = "0.1"
|
||||
keyring = "4.2"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ProfilesScreen } from './screens/ProfilesScreen';
|
|||
import { ComposeScreen } from './screens/ComposeScreen';
|
||||
import { RelaysScreen } from './screens/RelaysScreen';
|
||||
import { SignerScreen } from './screens/SignerScreen';
|
||||
import { SignerModeScreen } from './screens/SignerModeScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
import { CreateProfileModal } from './screens/CreateProfileModal';
|
||||
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
|
||||
|
|
@ -74,6 +75,7 @@ function Shell() {
|
|||
{screen === 'compose' && <ComposeScreen />}
|
||||
{screen === 'relays' && <RelaysScreen />}
|
||||
{screen === 'signer' && <SignerScreen />}
|
||||
{screen === 'signer-mode' && <SignerModeScreen />}
|
||||
{screen === 'settings' && <SettingsScreen />}
|
||||
</main>
|
||||
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export type IconName =
|
|||
| 'shield'
|
||||
| 'publish'
|
||||
| 'external'
|
||||
| 'key';
|
||||
| 'key'
|
||||
| 'server';
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
home: (
|
||||
|
|
@ -101,6 +102,12 @@ const PATHS: Record<IconName, ReactNode> = {
|
|||
<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 {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
|
|||
{ id: 'feed', label: 'Feed', icon: 'list' },
|
||||
{ id: 'compose', label: 'Compose', icon: 'edit' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import type {
|
||||
AppState,
|
||||
BackendResponse,
|
||||
EmbeddedSignerStatus,
|
||||
FeedItem,
|
||||
LinkPreview,
|
||||
MetadataPublishReport,
|
||||
Nip46SignerStatus,
|
||||
PickedImage,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
SignerMode,
|
||||
SignerStatus,
|
||||
UpdateApplyReport,
|
||||
UpdateCheckReport,
|
||||
|
|
@ -102,6 +105,24 @@ export const api = {
|
|||
pickImages: () => call<PickedImage[]>('pick_image'),
|
||||
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
|
||||
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 }),
|
||||
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
|
||||
signerStatus: () => call<SignerStatus>('signer_status'),
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
home: 'Home',
|
||||
|
|
@ -6,6 +7,7 @@ export const SCREEN_TITLES: Record<Screen, string> = {
|
|||
profiles: 'Profiles',
|
||||
compose: 'Compose',
|
||||
relays: 'Relays',
|
||||
signer: 'Signer',
|
||||
signer: 'Signer (Bunker)',
|
||||
'signer-mode': 'Signer Mode',
|
||||
settings: 'Settings',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
export type Theme =
|
||||
'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic';
|
||||
|
||||
/** Active signer mode. */
|
||||
export type SignerMode = 'embedded' | 'nip46';
|
||||
|
||||
/** Lifecycle of the NIP-46 remote signer. */
|
||||
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. */
|
||||
export interface PendingApproval {
|
||||
/** Internal id used to answer this request. */
|
||||
|
|
@ -12,6 +25,8 @@ export interface PendingApproval {
|
|||
method: string;
|
||||
/** A short human-readable description of what will be done. */
|
||||
summary: string;
|
||||
/** Detailed approval information. */
|
||||
details?: ApprovalDetails;
|
||||
}
|
||||
|
||||
/** Non-secret snapshot of the NIP-46 remote signer for display. */
|
||||
|
|
@ -21,12 +36,38 @@ export interface SignerStatus {
|
|||
peer: string | null;
|
||||
/** Relays used for the connection. */
|
||||
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. */
|
||||
error: string | null;
|
||||
/** Requests currently waiting for the user's approval. */
|
||||
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. */
|
||||
export interface ProfileSummary {
|
||||
label: string;
|
||||
|
|
@ -152,6 +193,8 @@ export interface AppState {
|
|||
failed: RelayFailure[];
|
||||
content: string;
|
||||
} | null;
|
||||
/** Active signer mode. */
|
||||
signer_mode: SignerMode;
|
||||
}
|
||||
|
||||
/** A secret key revealed after the vault is unlocked. */
|
||||
|
|
|
|||
466
frontend/src/screens/SignerModeScreen.tsx
Normal file
466
frontend/src/screens/SignerModeScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ const EMPTY_STATUS: SignerStatus = {
|
|||
phase: 'stopped',
|
||||
peer: null,
|
||||
relays: [],
|
||||
connectedRelays: [],
|
||||
error: null,
|
||||
pending: [],
|
||||
};
|
||||
|
|
@ -144,11 +145,25 @@ export function SignerScreen() {
|
|||
<dt>Relays</dt>
|
||||
<dd>
|
||||
{status.relays.length > 0 ? (
|
||||
status.relays.map((relay) => (
|
||||
<span key={relay} className="mono signer-relay">
|
||||
{relay}
|
||||
</span>
|
||||
))
|
||||
<>
|
||||
{status.relays.map((relay) => {
|
||||
const connected = status.connectedRelays.includes(relay);
|
||||
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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ import {
|
|||
import { api, BackendError } from '../lib/api';
|
||||
import type {
|
||||
AppState,
|
||||
EmbeddedSignerStatus,
|
||||
FeedItem,
|
||||
LinkPreview,
|
||||
MetadataPublishReport,
|
||||
Nip46SignerStatus,
|
||||
PickedImage,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
SignerMode,
|
||||
SignerStatus,
|
||||
Theme,
|
||||
UpdateApplyReport,
|
||||
|
|
@ -68,6 +71,18 @@ interface AppContextValue {
|
|||
pickImages: () => Promise<PickedImage[]>;
|
||||
uploadImage: (token: string) => Promise<UploadedImage>;
|
||||
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>;
|
||||
signerDisconnect: () => Promise<SignerStatus>;
|
||||
signerStatus: () => Promise<SignerStatus>;
|
||||
|
|
@ -228,6 +243,32 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
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(
|
||||
(currentPassword: string | null, newPassword: string) =>
|
||||
applyState(api.setVaultPassword(currentPassword, newPassword)),
|
||||
|
|
@ -301,6 +342,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
signerModeGet,
|
||||
signerModeSet,
|
||||
embeddedSignerStatus,
|
||||
embeddedSignerApprove,
|
||||
nip46Connect,
|
||||
nip46Disconnect,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
signerConnect,
|
||||
signerDisconnect,
|
||||
signerStatus,
|
||||
|
|
@ -350,6 +399,14 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
signerModeGet,
|
||||
signerModeSet,
|
||||
embeddedSignerStatus,
|
||||
embeddedSignerApprove,
|
||||
nip46Connect,
|
||||
nip46Disconnect,
|
||||
nip46Status,
|
||||
nip46Approve,
|
||||
signerConnect,
|
||||
signerDisconnect,
|
||||
signerStatus,
|
||||
|
|
|
|||
|
|
@ -2213,6 +2213,16 @@ select {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,47 @@ describe('SignerScreen', () => {
|
|||
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 () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
|
|
@ -80,6 +121,7 @@ describe('SignerScreen', () => {
|
|||
phase: 'connected',
|
||||
peer: 'ab12',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
connectedRelays: ['wss://relay.damus.io'],
|
||||
error: null,
|
||||
pending: [
|
||||
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
|
||||
|
|
@ -115,6 +157,7 @@ describe('SignerScreen', () => {
|
|||
phase: 'connected',
|
||||
peer: '79ab',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
connectedRelays: ['wss://relay.damus.io'],
|
||||
error: null,
|
||||
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
ProfileSummary,
|
||||
RelayTestResult,
|
||||
Settings,
|
||||
SignerMode,
|
||||
SignerStatus,
|
||||
} from '../lib/types';
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
|
|||
profiles: [alice, bob],
|
||||
settings,
|
||||
last_publish: null,
|
||||
signer_mode: 'embedded' as SignerMode,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -72,7 +74,15 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
|
|||
}
|
||||
|
||||
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',
|
||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
connectedRelays: ['wss://relay.damus.io'],
|
||||
error: null,
|
||||
pending: [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
phase: 'connected',
|
||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
connectedRelays: ['wss://relay.damus.io'],
|
||||
error: null,
|
||||
pending: [],
|
||||
};
|
||||
|
|
|
|||
31
src/app.rs
31
src/app.rs
|
|
@ -1,6 +1,7 @@
|
|||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use base64::Engine;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::crypto::{self, VaultKey};
|
||||
|
|
@ -22,8 +23,27 @@ pub struct App {
|
|||
pub undo_history: Vec<ProfileSummary>,
|
||||
/// The most recent publish report, persisted across restarts.
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AppStateView {
|
||||
|
|
@ -43,6 +63,8 @@ pub struct AppStateView {
|
|||
/// The most recent publish report, persisted across restarts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_publish: Option<StoredPublishReport>,
|
||||
/// Active signer mode.
|
||||
pub signer_mode: SignerMode,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -54,6 +76,9 @@ impl App {
|
|||
unlock_key: None,
|
||||
undo_history: Vec::new(),
|
||||
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(),
|
||||
undo_history: self.undo_history.clone(),
|
||||
last_publish: self.last_publish.clone(),
|
||||
signer_mode: self.signer_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -310,6 +336,9 @@ mod tests {
|
|||
unlock_key: None,
|
||||
undo_history: Vec::new(),
|
||||
last_publish: None,
|
||||
signer_mode: SignerMode::Embedded,
|
||||
embedded_signer: None,
|
||||
nip46_signer: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ pub struct SignerStatus {
|
|||
pub peer: Option<String>,
|
||||
/// Relays used for the connection.
|
||||
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.
|
||||
pub error: Option<String>,
|
||||
/// Requests currently waiting for the user to approve or reject them.
|
||||
|
|
@ -89,6 +93,7 @@ struct SignerInner {
|
|||
phase: SignerPhase,
|
||||
peer: Option<PublicKey>,
|
||||
relays: Vec<String>,
|
||||
connected_relays: Vec<String>,
|
||||
error: Option<String>,
|
||||
task: Option<tokio::task::JoinHandle<()>>,
|
||||
/// Requests waiting for the user to approve or reject, keyed by an
|
||||
|
|
@ -118,6 +123,7 @@ impl Signer {
|
|||
phase: SignerPhase::Stopped,
|
||||
peer: None,
|
||||
relays: Vec::new(),
|
||||
connected_relays: Vec::new(),
|
||||
error: None,
|
||||
task: None,
|
||||
pending: HashMap::new(),
|
||||
|
|
@ -142,6 +148,7 @@ impl Signer {
|
|||
phase: inner.phase,
|
||||
peer: inner.peer.map(|pk| pk.to_hex()),
|
||||
relays: inner.relays.clone(),
|
||||
connected_relays: inner.connected_relays.clone(),
|
||||
error: inner.error.clone(),
|
||||
pending,
|
||||
}
|
||||
|
|
@ -157,6 +164,7 @@ impl Signer {
|
|||
inner.phase = SignerPhase::Stopped;
|
||||
inner.peer = None;
|
||||
inner.relays.clear();
|
||||
inner.connected_relays.clear();
|
||||
inner.error = None;
|
||||
inner.pending.clear();
|
||||
}
|
||||
|
|
@ -259,6 +267,7 @@ impl Signer {
|
|||
inner.phase = SignerPhase::Connecting;
|
||||
inner.peer = Some(parsed.peer);
|
||||
inner.relays = parsed.relays.iter().map(|r| r.to_string()).collect();
|
||||
inner.connected_relays.clear();
|
||||
inner.error = None;
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +281,7 @@ impl Signer {
|
|||
inner.phase = SignerPhase::Stopped;
|
||||
inner.error = Some(message.into());
|
||||
inner.task = None;
|
||||
inner.connected_relays.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,
|
||||
/// 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) {
|
||||
|
|
@ -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;
|
||||
|
||||
// `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.
|
||||
// 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
|
||||
|
|
@ -1063,6 +1120,63 @@ mod tests {
|
|||
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]
|
||||
async fn pending_approvals_are_capped() {
|
||||
let signer = Signer::new();
|
||||
258
src/ipc.rs
258
src/ipc.rs
|
|
@ -5,14 +5,16 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::{App, SignerMode};
|
||||
use crate::errors::AppError;
|
||||
use crate::feed;
|
||||
use crate::profiles;
|
||||
use crate::publish;
|
||||
use crate::relays;
|
||||
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;
|
||||
|
||||
/// How long to wait for a relay connection test.
|
||||
|
|
@ -134,15 +136,46 @@ pub enum Request {
|
|||
url: 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 {
|
||||
uri: String,
|
||||
},
|
||||
/// Stop the NIP-46 remote signer.
|
||||
/// Stop the NIP-46 remote signer (bunker mode).
|
||||
SignerDisconnect,
|
||||
/// Report the remote signer's current status.
|
||||
/// Report the remote signer's current status (bunker mode).
|
||||
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 {
|
||||
/// The internal id of the pending request, as reported by
|
||||
/// `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
|
||||
// both see the same vault (including its unlock key) without racing writes.
|
||||
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 stdin = tokio::io::stdin();
|
||||
|
|
@ -226,10 +258,9 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
};
|
||||
|
||||
let task_app = app.clone();
|
||||
let task_signer = signer.clone();
|
||||
let task_stdout = stdout.clone();
|
||||
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(
|
||||
&task_stdout,
|
||||
ReplyEnvelope {
|
||||
|
|
@ -268,12 +299,8 @@ async fn write_line(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
app: Arc<Mutex<App>>,
|
||||
signer: Arc<Signer>,
|
||||
request: Request,
|
||||
) -> Reply<serde_json::Value> {
|
||||
let result = run(&app, &signer, request).await;
|
||||
async fn handle(app: Arc<Mutex<App>>, request: Request) -> Reply<serde_json::Value> {
|
||||
let result = run(&app, request).await;
|
||||
match result {
|
||||
Ok(value) => Reply::Ok { data: value },
|
||||
Err(err) => Reply::Error {
|
||||
|
|
@ -296,43 +323,164 @@ fn error_code(err: &AppError) -> String {
|
|||
.unwrap_or_else(|_| "error".to_string())
|
||||
}
|
||||
|
||||
/// Signer control commands never touch the vault directly, so they take the
|
||||
/// shared handle (a clone) rather than locking the state. Read-only network
|
||||
/// 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> {
|
||||
/// Main request dispatcher.
|
||||
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
|
||||
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 } => {
|
||||
signer.connect(app.clone(), &uri)?;
|
||||
Ok(json!(signer.status()))
|
||||
let guard = app.lock().await;
|
||||
// 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 => {
|
||||
signer.disconnect();
|
||||
Ok(json!(signer.status()))
|
||||
let guard = app.lock().await;
|
||||
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 } => {
|
||||
signer.approve(&id, approved)?;
|
||||
Ok(json!(signer.status()))
|
||||
let guard = app.lock().await;
|
||||
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 } => {
|
||||
// Pure network probe against the given URL; no shared state.
|
||||
let result = relays::test_connection(&url, RELAY_TEST_TIMEOUT).await?;
|
||||
Ok(json!(result))
|
||||
}
|
||||
Request::UpdateCheck => {
|
||||
// Long-running package-manager scan; never touches shared state.
|
||||
let report = updates::check().await?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
Request::UpdateApply => {
|
||||
// Installs updates on disk; a rebuild + restart picks them up.
|
||||
let report = updates::apply().await?;
|
||||
Ok(json!(report))
|
||||
}
|
||||
|
|
@ -343,14 +491,10 @@ async fn run(
|
|||
} => {
|
||||
let limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
|
||||
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()) {
|
||||
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
|
||||
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 guard = app.lock().await;
|
||||
let owner_hex = if author_hex.is_some() {
|
||||
|
|
@ -376,6 +520,8 @@ async fn run(
|
|||
};
|
||||
Ok(json!(items))
|
||||
}
|
||||
|
||||
// Vault state requests (require lock)
|
||||
other => {
|
||||
let mut guard = app.lock().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 summary =
|
||||
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()?;
|
||||
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(),
|
||||
&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()?;
|
||||
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
||||
}
|
||||
|
||||
Request::SelectProfile { 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()?;
|
||||
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 } => {
|
||||
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()))
|
||||
}
|
||||
|
||||
Request::LockVault => {
|
||||
app.lock();
|
||||
// Clear signers' active profiles
|
||||
if let Some(signer) = &app.embedded_signer {
|
||||
signer.set_active_profile(None).await;
|
||||
}
|
||||
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()?;
|
||||
Ok(json!(app.state_view()))
|
||||
}
|
||||
// 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.")),
|
||||
_ => Err(AppError::internal("Unexpected request.")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod app;
|
||||
pub mod bunker;
|
||||
pub mod crypto;
|
||||
pub mod errors;
|
||||
pub mod feed;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ use std::process::ExitCode;
|
|||
use std::sync::Arc;
|
||||
|
||||
use keynectr::app::App;
|
||||
use keynectr::bunker::Signer;
|
||||
use keynectr::errors::{AppError, ErrorKind};
|
||||
use keynectr::ipc;
|
||||
use keynectr::profiles::{self, ProfileSummary};
|
||||
use keynectr::publish;
|
||||
use keynectr::relays;
|
||||
use keynectr::settings::Theme;
|
||||
use keynectr::signer::Signer;
|
||||
use keynectr::vault::{self, StoredProfile, Vault};
|
||||
|
||||
const USAGE: &str = "\
|
||||
|
|
|
|||
258
src/signer/embedded.rs
Normal file
258
src/signer/embedded.rs
Normal 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
43
src/signer/mod.rs
Normal 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
793
src/signer/nip46_client.rs
Normal 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
74
src/signer/types.rs
Normal 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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue