Require user approval for NIP-46 sign/encrypt requests

This commit is contained in:
Avi 2026-08-06 10:03:50 -05:00
commit ca3203cfc4
10 changed files with 506 additions and 18 deletions

View file

@ -74,5 +74,7 @@ export const api = {
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean) =>
call<SignerStatus>('signer_approve', { id, approved }),
copyText: (text: string) => window.backend.copyText(text),
};

View file

@ -3,6 +3,16 @@ export type Theme = 'light' | 'dark' | 'system';
/** Lifecycle of the NIP-46 remote signer. */
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
/** A NIP-46 request waiting for the user to approve or reject it. */
export interface PendingApproval {
/** Internal id used to answer this request. */
id: string;
/** The requested NIP-46 method, e.g. `sign_event`. */
method: string;
/** A short human-readable description of what will be done. */
summary: string;
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
export interface SignerStatus {
phase: SignerPhase;
@ -12,6 +22,8 @@ export interface SignerStatus {
relays: 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[];
}
/** A safe view of a profile with no secret key material. */

View file

@ -7,7 +7,13 @@ import { Icon } from '../components/Icon';
import type { SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = { phase: 'stopped', peer: null, relays: [], error: null };
const EMPTY_STATUS: SignerStatus = {
phase: 'stopped',
peer: null,
relays: [],
error: null,
pending: [],
};
/** Shorten a 64-char hex key for display. */
function shortHex(value: string): string {
@ -15,7 +21,7 @@ function shortHex(value: string): string {
}
export function SignerScreen() {
const { state, signerConnect, signerDisconnect, signerStatus } = useApp();
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(null);
@ -37,8 +43,20 @@ export function SignerScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Poll so approval requests appear without needing a manual refresh, and so
// approvals/rejections made elsewhere are reflected here.
useEffect(() => {
const timer = window.setInterval(() => {
void refresh();
}, 1000);
return () => window.clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const vaultLocked = state?.vault_locked ?? false;
const isActive = status.phase === 'connected' || status.phase === 'connecting';
const onConnect = async (event: FormEvent) => {
event.preventDefault();
const trimmed = uri.trim();
@ -67,6 +85,15 @@ export function SignerScreen() {
}
};
const onApprove = async (id: string, approved: boolean) => {
setError(null);
try {
setStatus(await signerApprove(id, approved));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const badge = () => {
switch (status.phase) {
case 'connected':
@ -78,8 +105,6 @@ export function SignerScreen() {
}
};
const isActive = status.phase === 'connected' || status.phase === 'connecting';
return (
<div className="screen">
<div className="screen-inner">
@ -87,8 +112,8 @@ export function SignerScreen() {
<div>
<h1>Signer</h1>
<p className="page-subtitle">
Securely sign for another Nostr app. Paste its nostrconnect:// link to let this app
approve its requests with the active profile&apos;s keys.
Securely sign for another Nostr app. Paste its nostrconnect:// link, then approve each
signing or decryption request here.
</p>
</div>
</header>
@ -142,6 +167,39 @@ export function SignerScreen() {
</div>
</section>
{status.pending.length > 0 && (
<section className="card">
<header className="card-header">
<h2>Requests waiting for approval</h2>
<Badge tone="warning">{status.pending.length}</Badge>
</header>
<div className="card-body signer-pending">
<p className="hint">
The connected app wants to do the following with the active profile&apos;s keys.
Review each one before approving it.
</p>
{status.pending.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>
</div>
<div className="settings-inline">
<Button variant="primary" onClick={() => void onApprove(request.id, true)}>
<Icon name="check" size={16} />
Approve
</Button>
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} />
Reject
</Button>
</div>
</div>
))}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Connect a Nostr app</h2>
@ -150,8 +208,8 @@ export function SignerScreen() {
{isActive ? (
<div className="signer-actions">
<p className="hint">
The signer is listening. Requests from the connected app are approved
automatically.
The signer is listening. Requests from the connected app appear above and are only
run after you approve them.
</p>
<Button variant="danger" onClick={() => void onDisconnect()}>
<Icon name="trash" size={16} />

View file

@ -59,6 +59,7 @@ interface AppContextValue {
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
copyText: (text: string) => Promise<void>;
}
@ -175,6 +176,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
const signerDisconnect = useCallback(() => api.signerDisconnect(), []);
const signerStatus = useCallback(() => api.signerStatus(), []);
const signerApprove = useCallback((id: string, approved: boolean) => {
return api.signerApprove(id, approved);
}, []);
const copyText = useCallback((text: string) => api.copyText(text), []);
@ -209,6 +213,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerConnect,
signerDisconnect,
signerStatus,
signerApprove,
copyText,
}),
[
@ -239,6 +244,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerConnect,
signerDisconnect,
signerStatus,
signerApprove,
copyText,
],
);

View file

@ -68,4 +68,69 @@ describe('SignerScreen', () => {
});
expect(backend.signer.phase).toBe('stopped');
});
it('lists a pending request and approves it', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SignerScreen />);
// Act as if the client asked to sign an event and is waiting.
backend.setSigner({
phase: 'connected',
peer: 'ab12',
relays: ['wss://relay.damus.io'],
error: null,
pending: [
{ id: 'req-1', method: 'sign_event', summary: 'Sign event kind 1: “Hello from afar”' },
],
});
// The connected signer polls status, so the pending request appears on its own.
expect(await screen.findByText(/Hello from afar/, {}, { timeout: 3000 })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Approve' }));
await waitFor(() => {
expect(
backend.requests.some(
(r) =>
r.method === 'signer_approve' &&
r.params?.id === 'req-1' &&
r.params?.approved === true,
),
).toBe(true);
});
await waitFor(() => {
expect(screen.queryByText('Hello from afar')).not.toBeInTheDocument();
});
});
it('rejects a pending request', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SignerScreen />);
backend.setSigner({
phase: 'connected',
peer: '79ab',
relays: ['wss://relay.damus.io'],
error: null,
pending: [{ id: 'req-2', method: 'nip44_decrypt', summary: 'Decrypt a message' }],
});
expect(await screen.findByText('Decrypt a message', {}, { timeout: 3000 })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reject' }));
await waitFor(() => {
expect(
backend.requests.some(
(r) =>
r.method === 'signer_approve' &&
r.params?.id === 'req-2' &&
r.params?.approved === false,
),
).toBe(true);
});
});
});

View file

@ -71,7 +71,7 @@ export function makeRelayTest(url: string, overrides?: Partial<RelayTestResult>)
}
export function makeSignerStatus(overrides?: Partial<SignerStatus>): SignerStatus {
return { phase: 'stopped', peer: null, relays: [], error: null, ...overrides };
return { phase: 'stopped', peer: null, relays: [], error: null, pending: [], ...overrides };
}
/**
@ -102,6 +102,7 @@ export interface ApiMock {
signerConnect: ReturnType<typeof vi.fn>;
signerDisconnect: ReturnType<typeof vi.fn>;
signerStatus: ReturnType<typeof vi.fn>;
signerApprove: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>;
};
/** Current state object backing init/getState. */
@ -225,10 +226,17 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'],
error: null,
pending: [],
}),
),
signerDisconnect: vi.fn(async () => applySigner(makeSignerStatus())),
signerStatus: vi.fn(async () => signer),
signerApprove: vi.fn(async (id: string) => {
return applySigner({
...signer,
pending: signer.pending.filter((request) => request.id !== id),
});
}),
copyText: vi.fn(async () => undefined),
};

View file

@ -177,6 +177,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
relays: ['wss://relay.damus.io'],
error: null,
pending: [],
};
backend.setSigner(next);
return next;
@ -192,6 +193,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'signer_status':
return backend.signer;
case 'signer_approve': {
const id = String(params.id ?? '');
const next: SignerStatus = {
...backend.signer,
pending: backend.signer.pending.filter((request) => request.id !== id),
};
backend.setSigner(next);
return next;
}
case 'relay_add': {
const url = String(params.url);
const nextSettings: Settings = {