Require user approval for NIP-46 sign/encrypt requests
This commit is contained in:
parent
593fc6af8b
commit
ca3203cfc4
10 changed files with 506 additions and 18 deletions
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'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'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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue