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
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue