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 }),
|
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
|
||||||
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
|
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
|
||||||
signerStatus: () => call<SignerStatus>('signer_status'),
|
signerStatus: () => call<SignerStatus>('signer_status'),
|
||||||
|
signerApprove: (id: string, approved: boolean) =>
|
||||||
|
call<SignerStatus>('signer_approve', { id, approved }),
|
||||||
copyText: (text: string) => window.backend.copyText(text),
|
copyText: (text: string) => window.backend.copyText(text),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@ export type Theme = 'light' | 'dark' | 'system';
|
||||||
/** Lifecycle of the NIP-46 remote signer. */
|
/** Lifecycle of the NIP-46 remote signer. */
|
||||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
||||||
|
|
||||||
|
/** 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. */
|
/** Non-secret snapshot of the NIP-46 remote signer for display. */
|
||||||
export interface SignerStatus {
|
export interface SignerStatus {
|
||||||
phase: SignerPhase;
|
phase: SignerPhase;
|
||||||
|
|
@ -12,6 +22,8 @@ export interface SignerStatus {
|
||||||
relays: string[];
|
relays: string[];
|
||||||
/** A user-facing error if the signer stopped because of one. */
|
/** A user-facing error if the signer stopped because of one. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** Requests currently waiting for the user's approval. */
|
||||||
|
pending: PendingApproval[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A safe view of a profile with no secret key material. */
|
/** 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 type { SignerStatus } from '../lib/types';
|
||||||
import { useApp } from '../state/AppProvider';
|
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. */
|
/** Shorten a 64-char hex key for display. */
|
||||||
function shortHex(value: string): string {
|
function shortHex(value: string): string {
|
||||||
|
|
@ -15,7 +21,7 @@ function shortHex(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignerScreen() {
|
export function SignerScreen() {
|
||||||
const { state, signerConnect, signerDisconnect, signerStatus } = useApp();
|
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
|
||||||
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
|
||||||
const [uri, setUri] = useState('');
|
const [uri, setUri] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -37,8 +43,20 @@ export function SignerScreen() {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 vaultLocked = state?.vault_locked ?? false;
|
||||||
|
|
||||||
|
const isActive = status.phase === 'connected' || status.phase === 'connecting';
|
||||||
|
|
||||||
const onConnect = async (event: FormEvent) => {
|
const onConnect = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = uri.trim();
|
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 = () => {
|
const badge = () => {
|
||||||
switch (status.phase) {
|
switch (status.phase) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
|
|
@ -78,8 +105,6 @@ export function SignerScreen() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isActive = status.phase === 'connected' || status.phase === 'connecting';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<div className="screen">
|
||||||
<div className="screen-inner">
|
<div className="screen-inner">
|
||||||
|
|
@ -87,8 +112,8 @@ export function SignerScreen() {
|
||||||
<div>
|
<div>
|
||||||
<h1>Signer</h1>
|
<h1>Signer</h1>
|
||||||
<p className="page-subtitle">
|
<p className="page-subtitle">
|
||||||
Securely sign for another Nostr app. Paste its nostrconnect:// link to let this app
|
Securely sign for another Nostr app. Paste its nostrconnect:// link, then approve each
|
||||||
approve its requests with the active profile's keys.
|
signing or decryption request here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -142,6 +167,39 @@ export function SignerScreen() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
<h2>Connect a Nostr app</h2>
|
<h2>Connect a Nostr app</h2>
|
||||||
|
|
@ -150,8 +208,8 @@ export function SignerScreen() {
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="signer-actions">
|
<div className="signer-actions">
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
The signer is listening. Requests from the connected app are approved
|
The signer is listening. Requests from the connected app appear above and are only
|
||||||
automatically.
|
run after you approve them.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="danger" onClick={() => void onDisconnect()}>
|
<Button variant="danger" onClick={() => void onDisconnect()}>
|
||||||
<Icon name="trash" size={16} />
|
<Icon name="trash" size={16} />
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ interface AppContextValue {
|
||||||
signerConnect: (uri: string) => Promise<SignerStatus>;
|
signerConnect: (uri: string) => Promise<SignerStatus>;
|
||||||
signerDisconnect: () => Promise<SignerStatus>;
|
signerDisconnect: () => Promise<SignerStatus>;
|
||||||
signerStatus: () => Promise<SignerStatus>;
|
signerStatus: () => Promise<SignerStatus>;
|
||||||
|
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
|
||||||
copyText: (text: string) => Promise<void>;
|
copyText: (text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,6 +176,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
|
const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []);
|
||||||
const signerDisconnect = useCallback(() => api.signerDisconnect(), []);
|
const signerDisconnect = useCallback(() => api.signerDisconnect(), []);
|
||||||
const signerStatus = useCallback(() => api.signerStatus(), []);
|
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), []);
|
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||||
|
|
||||||
|
|
@ -209,6 +213,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
signerConnect,
|
signerConnect,
|
||||||
signerDisconnect,
|
signerDisconnect,
|
||||||
signerStatus,
|
signerStatus,
|
||||||
|
signerApprove,
|
||||||
copyText,
|
copyText,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -239,6 +244,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
signerConnect,
|
signerConnect,
|
||||||
signerDisconnect,
|
signerDisconnect,
|
||||||
signerStatus,
|
signerStatus,
|
||||||
|
signerApprove,
|
||||||
copyText,
|
copyText,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,69 @@ describe('SignerScreen', () => {
|
||||||
});
|
});
|
||||||
expect(backend.signer.phase).toBe('stopped');
|
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 {
|
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>;
|
signerConnect: ReturnType<typeof vi.fn>;
|
||||||
signerDisconnect: ReturnType<typeof vi.fn>;
|
signerDisconnect: ReturnType<typeof vi.fn>;
|
||||||
signerStatus: ReturnType<typeof vi.fn>;
|
signerStatus: ReturnType<typeof vi.fn>;
|
||||||
|
signerApprove: ReturnType<typeof vi.fn>;
|
||||||
copyText: ReturnType<typeof vi.fn>;
|
copyText: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
/** Current state object backing init/getState. */
|
/** Current state object backing init/getState. */
|
||||||
|
|
@ -225,10 +226,17 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||||
relays: ['wss://relay.damus.io'],
|
relays: ['wss://relay.damus.io'],
|
||||||
error: null,
|
error: null,
|
||||||
|
pending: [],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
signerDisconnect: vi.fn(async () => applySigner(makeSignerStatus())),
|
signerDisconnect: vi.fn(async () => applySigner(makeSignerStatus())),
|
||||||
signerStatus: vi.fn(async () => signer),
|
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),
|
copyText: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
peer: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
|
||||||
relays: ['wss://relay.damus.io'],
|
relays: ['wss://relay.damus.io'],
|
||||||
error: null,
|
error: null,
|
||||||
|
pending: [],
|
||||||
};
|
};
|
||||||
backend.setSigner(next);
|
backend.setSigner(next);
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -192,6 +193,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
case 'signer_status':
|
case 'signer_status':
|
||||||
return backend.signer;
|
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': {
|
case 'relay_add': {
|
||||||
const url = String(params.url);
|
const url = String(params.url);
|
||||||
const nextSettings: Settings = {
|
const nextSettings: Settings = {
|
||||||
|
|
|
||||||
12
src/ipc.rs
12
src/ipc.rs
|
|
@ -95,6 +95,14 @@ pub enum Request {
|
||||||
SignerDisconnect,
|
SignerDisconnect,
|
||||||
/// Report the remote signer's current status.
|
/// Report the remote signer's current status.
|
||||||
SignerStatus,
|
SignerStatus,
|
||||||
|
/// Approve or reject a NIP-46 request that is waiting for a decision.
|
||||||
|
SignerApprove {
|
||||||
|
/// The internal id of the pending request, as reported by
|
||||||
|
/// `SignerStatus.pending`.
|
||||||
|
id: String,
|
||||||
|
/// `true` to run the request, `false` to reject it.
|
||||||
|
approved: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reply envelope carrying either data or a safe user-facing error.
|
/// A reply envelope carrying either data or a safe user-facing error.
|
||||||
|
|
@ -244,6 +252,10 @@ async fn run(
|
||||||
Ok(json!(signer.status()))
|
Ok(json!(signer.status()))
|
||||||
}
|
}
|
||||||
Request::SignerStatus => Ok(json!(signer.status())),
|
Request::SignerStatus => Ok(json!(signer.status())),
|
||||||
|
Request::SignerApprove { id, approved } => {
|
||||||
|
signer.approve(&id, approved)?;
|
||||||
|
Ok(json!(signer.status()))
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
let mut guard = app.lock().expect("app mutex poisoned");
|
let mut guard = app.lock().expect("app mutex poisoned");
|
||||||
run_with_app(&mut guard, other).await
|
run_with_app(&mut guard, other).await
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
|
||||||
"In the GUI, the signer listens as long as the app is running. From here, run:"
|
"In the GUI, the signer listens as long as the app is running. From here, run:"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
" nostr-manager-backend signer connect <nostrconnect://…>".to_string(),
|
" nostr-manager-backend signer connect <nostrconnect://…>".to_string(),
|
||||||
|
"Sign/decrypt requests must be approved in the GUI signer screen.".to_string(),
|
||||||
]
|
]
|
||||||
.join("\n"))
|
.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
331
src/signer.rs
331
src/signer.rs
|
|
@ -11,6 +11,7 @@
|
||||||
//! answer its requests. A mistaken `bunker://` link (the opposite role) is
|
//! answer its requests. A mistaken `bunker://` link (the opposite role) is
|
||||||
//! rejected with a clear message.
|
//! rejected with a clear message.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ use nostr::nips::nip44::v2::ConversationKey;
|
||||||
use nostr::JsonUtil;
|
use nostr::JsonUtil;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
|
@ -31,6 +33,9 @@ use crate::profiles;
|
||||||
/// How long to wait for relays to accept a connection attempt.
|
/// How long to wait for relays to accept a connection attempt.
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
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);
|
||||||
|
|
||||||
/// Lifecycle of the remote signer, for display in the GUI.
|
/// Lifecycle of the remote signer, for display in the GUI.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|
@ -39,10 +44,21 @@ pub enum SignerPhase {
|
||||||
Stopped,
|
Stopped,
|
||||||
/// Connected to relays, waiting for the client to acknowledge us.
|
/// Connected to relays, waiting for the client to acknowledge us.
|
||||||
Connecting,
|
Connecting,
|
||||||
/// A client connected; requests are auto-approved.
|
/// A client connected; requests are awaiting approval.
|
||||||
Connected,
|
Connected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A NIP-46 request that is waiting for the user to approve or reject it.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PendingApproval {
|
||||||
|
/// Internal id used by the UI to answer this request.
|
||||||
|
pub id: String,
|
||||||
|
/// The requested NIP-46 method, e.g. `sign_event`.
|
||||||
|
pub method: String,
|
||||||
|
/// A short human-readable description of what will be done.
|
||||||
|
pub summary: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// A non-secret snapshot of the signer for the UI.
|
/// A non-secret snapshot of the signer for the UI.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SignerStatus {
|
pub struct SignerStatus {
|
||||||
|
|
@ -53,6 +69,8 @@ pub struct SignerStatus {
|
||||||
pub relays: Vec<String>,
|
pub relays: Vec<String>,
|
||||||
/// A user-facing error if the signer stopped because of one.
|
/// A user-facing error if the signer stopped because of one.
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
/// Requests currently waiting for the user to approve or reject them.
|
||||||
|
pub pending: Vec<PendingApproval>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shareable control handle for the remote signer.
|
/// Shareable control handle for the remote signer.
|
||||||
|
|
@ -67,6 +85,17 @@ struct SignerInner {
|
||||||
relays: Vec<String>,
|
relays: Vec<String>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
task: Option<tokio::task::JoinHandle<()>>,
|
task: Option<tokio::task::JoinHandle<()>>,
|
||||||
|
/// Requests waiting for the user to approve or reject, keyed by an
|
||||||
|
/// internal id derived from the request's own id.
|
||||||
|
pending: HashMap<String, PendingApprovalInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request kept waiting until the user approves or rejects it. The `sender`
|
||||||
|
/// is how the UI's decision travels back to the running signer task.
|
||||||
|
struct PendingApprovalInner {
|
||||||
|
method: String,
|
||||||
|
summary: String,
|
||||||
|
sender: oneshot::Sender<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Signer {
|
impl Default for Signer {
|
||||||
|
|
@ -85,6 +114,7 @@ impl Signer {
|
||||||
relays: Vec::new(),
|
relays: Vec::new(),
|
||||||
error: None,
|
error: None,
|
||||||
task: None,
|
task: None,
|
||||||
|
pending: HashMap::new(),
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,15 +122,27 @@ impl Signer {
|
||||||
/// A non-secret snapshot of the current state for a UI.
|
/// A non-secret snapshot of the current state for a UI.
|
||||||
pub fn status(&self) -> SignerStatus {
|
pub fn status(&self) -> SignerStatus {
|
||||||
let inner = self.inner.lock().expect("signer mutex poisoned");
|
let inner = self.inner.lock().expect("signer mutex poisoned");
|
||||||
|
let mut pending: Vec<PendingApproval> = inner
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.map(|(id, entry)| PendingApproval {
|
||||||
|
id: id.clone(),
|
||||||
|
method: entry.method.clone(),
|
||||||
|
summary: entry.summary.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
pending.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
SignerStatus {
|
SignerStatus {
|
||||||
phase: inner.phase,
|
phase: inner.phase,
|
||||||
peer: inner.peer.map(|pk| pk.to_hex()),
|
peer: inner.peer.map(|pk| pk.to_hex()),
|
||||||
relays: inner.relays.clone(),
|
relays: inner.relays.clone(),
|
||||||
error: inner.error.clone(),
|
error: inner.error.clone(),
|
||||||
|
pending,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop listening and cancel the running task.
|
/// Stop listening and cancel the running task. Any request waiting for an
|
||||||
|
/// approval decision is aborted.
|
||||||
pub fn disconnect(&self) {
|
pub fn disconnect(&self) {
|
||||||
let mut inner = self.inner.lock().expect("signer mutex poisoned");
|
let mut inner = self.inner.lock().expect("signer mutex poisoned");
|
||||||
if let Some(task) = inner.task.take() {
|
if let Some(task) = inner.task.take() {
|
||||||
|
|
@ -110,6 +152,75 @@ impl Signer {
|
||||||
inner.peer = None;
|
inner.peer = None;
|
||||||
inner.relays.clear();
|
inner.relays.clear();
|
||||||
inner.error = None;
|
inner.error = None;
|
||||||
|
inner.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer a request waiting for approval. Returns an error if the id is not
|
||||||
|
/// (or no longer) pending.
|
||||||
|
pub fn approve(&self, id: &str, approve: bool) -> Result<(), AppError> {
|
||||||
|
let mut inner = self.inner.lock().expect("signer mutex poisoned");
|
||||||
|
let Some(entry) = inner.pending.remove(id) else {
|
||||||
|
return Err(AppError::config(
|
||||||
|
"That request is no longer waiting for approval (it may have timed out).",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let _ = entry.sender.send(approve);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many requests are waiting for approval right now.
|
||||||
|
pub fn pending_count(&self) -> usize {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.expect("signer mutex poisoned")
|
||||||
|
.pending
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Park a key-using request and wait for the user's decision.
|
||||||
|
///
|
||||||
|
/// Inserts the request into the `pending` map (so the UI can list and
|
||||||
|
/// answer it) and awaits the decision channel with a timeout. Returns
|
||||||
|
/// `Some(true)` when approved, `Some(false)` when rejected, and `None`
|
||||||
|
/// when no decision arrived in time or the signer was disconnected.
|
||||||
|
async fn await_approval(&self, method: &str, summary: String) -> Option<bool> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let (sender, receiver) = oneshot::channel();
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().expect("signer mutex poisoned");
|
||||||
|
inner.pending.insert(
|
||||||
|
id.clone(),
|
||||||
|
PendingApprovalInner {
|
||||||
|
method: method.to_string(),
|
||||||
|
summary,
|
||||||
|
sender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(APPROVAL_TIMEOUT, receiver).await {
|
||||||
|
Ok(Ok(approved)) => {
|
||||||
|
// Already removed by `approve`; nothing left to clean up.
|
||||||
|
Some(approved)
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
// Disconnected: the sender was dropped by `disconnect`.
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.expect("signer mutex poisoned")
|
||||||
|
.pending
|
||||||
|
.remove(&id);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.expect("signer mutex poisoned")
|
||||||
|
.pending
|
||||||
|
.remove(&id);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `nostrconnect://` link and listen as the active profile.
|
/// Parse a `nostrconnect://` link and listen as the active profile.
|
||||||
|
|
@ -150,6 +261,7 @@ impl Signer {
|
||||||
inner.phase = SignerPhase::Stopped;
|
inner.phase = SignerPhase::Stopped;
|
||||||
inner.error = Some(message.into());
|
inner.error = Some(message.into());
|
||||||
inner.task = None;
|
inner.task = None;
|
||||||
|
inner.pending.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_connected(&self) {
|
fn set_connected(&self) {
|
||||||
|
|
@ -276,19 +388,76 @@ fn response_err(id: &str, error: String) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route a decrypted request and return the response JSON to publish back.
|
/// Route a decrypted request and return the response JSON to publish back.
|
||||||
|
///
|
||||||
|
/// Methods that use the signer's key (`sign_event`, `nip44_encrypt`,
|
||||||
|
/// `nip44_decrypt`) are handled by `approved_response` once the user has
|
||||||
|
/// approved them (see `gated_response` and `Signer::await_approval`). This
|
||||||
|
/// dispatcher handles everything that is either key-free or part of the
|
||||||
|
/// handshake, and routes gated methods to their approval-aware handler.
|
||||||
fn handle_request(
|
fn handle_request(
|
||||||
signer: &Signer,
|
signer: &Signer,
|
||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
uri: &ConnectUri,
|
uri: &ConnectUri,
|
||||||
request: &RawRequest,
|
request: &RawRequest,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
// Seen a client request ⇒ the handshake succeeded; auto-approve from here.
|
// Seen a client request ⇒ the handshake succeeded.
|
||||||
signer.set_connected();
|
signer.set_connected();
|
||||||
|
|
||||||
match request.method.as_str() {
|
match request.method.as_str() {
|
||||||
"connect" => Some(response_ok(&request.id, "ack".to_string())),
|
"connect" => Some(response_ok(&request.id, "ack".to_string())),
|
||||||
"get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())),
|
"get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())),
|
||||||
"ping" => Some(response_ok(&request.id, "pong".to_string())),
|
"ping" => Some(response_ok(&request.id, "pong".to_string())),
|
||||||
|
"sign_event" => approved_response(keys, request),
|
||||||
|
"nip44_encrypt" => approved_response(keys, request),
|
||||||
|
"nip44_decrypt" => approved_response(keys, request),
|
||||||
|
"get_relays" | "switch_relays" => {
|
||||||
|
let list = serde_json::to_string(&uri.relays).unwrap_or_default();
|
||||||
|
Some(response_ok(&request.id, list))
|
||||||
|
}
|
||||||
|
"logout" => Some(response_ok(&request.id, "ack".to_string())),
|
||||||
|
other => Some(response_err(
|
||||||
|
&request.id,
|
||||||
|
format!("Unsupported method: {other}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a NIP-46 method needs an explicit user approval before running.
|
||||||
|
fn requires_approval(method: &str) -> bool {
|
||||||
|
matches!(method, "sign_event" | "nip44_encrypt" | "nip44_decrypt")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve-or-reject a gated request into a response. This is async because it
|
||||||
|
/// parks the request in the `pending` map and waits (with a timeout) for the
|
||||||
|
/// user to decide before touching the signer's keys.
|
||||||
|
async fn gated_response(signer: &Signer, keys: &Keys, request: &RawRequest) -> Option<String> {
|
||||||
|
signer.set_connected();
|
||||||
|
match signer
|
||||||
|
.await_approval(&request.method, describe_request(request))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Some(true) => approved_response(keys, request),
|
||||||
|
Some(false) => Some(response_ok_rejected(&request.id)),
|
||||||
|
None => Some(response_ok_timeout(&request.id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `error` string NIP-46 uses when the user rejects a request.
|
||||||
|
fn response_ok_rejected(id: &str) -> String {
|
||||||
|
response_err(id, "The request was rejected by the user.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error string NIP-46 uses when an approval decision never arrives.
|
||||||
|
fn response_ok_timeout(id: &str) -> String {
|
||||||
|
response_err(
|
||||||
|
id,
|
||||||
|
"The user did not approve this request in time; try again.".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the actual signed/crypto work once the user has approved a gated method.
|
||||||
|
fn approved_response(keys: &Keys, request: &RawRequest) -> Option<String> {
|
||||||
|
match request.method.as_str() {
|
||||||
"sign_event" => match sign_event(keys, request) {
|
"sign_event" => match sign_event(keys, request) {
|
||||||
Ok(event) => Some(response_ok(&request.id, event)),
|
Ok(event) => Some(response_ok(&request.id, event)),
|
||||||
Err(e) => Some(response_err(&request.id, e)),
|
Err(e) => Some(response_err(&request.id, e)),
|
||||||
|
|
@ -301,11 +470,6 @@ fn handle_request(
|
||||||
Ok(value) => Some(response_ok(&request.id, value)),
|
Ok(value) => Some(response_ok(&request.id, value)),
|
||||||
Err(e) => Some(response_err(&request.id, e)),
|
Err(e) => Some(response_err(&request.id, e)),
|
||||||
},
|
},
|
||||||
"get_relays" | "switch_relays" => {
|
|
||||||
let list = serde_json::to_string(&uri.relays).unwrap_or_default();
|
|
||||||
Some(response_ok(&request.id, list))
|
|
||||||
}
|
|
||||||
"logout" => Some(response_ok(&request.id, "ack".to_string())),
|
|
||||||
other => Some(response_err(
|
other => Some(response_err(
|
||||||
&request.id,
|
&request.id,
|
||||||
format!("Unsupported method: {other}"),
|
format!("Unsupported method: {other}"),
|
||||||
|
|
@ -313,6 +477,58 @@ fn handle_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A short, safe description of a gated request for the approval UI. Never
|
||||||
|
/// includes secrets; for signing it shows the event kind and a truncated
|
||||||
|
/// content preview.
|
||||||
|
fn describe_request(request: &RawRequest) -> String {
|
||||||
|
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(60)
|
||||||
|
.collect::<String>();
|
||||||
|
format!("event kind {kind}: “{content}”")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "an event".to_string());
|
||||||
|
format!("Sign {preview}")
|
||||||
|
}
|
||||||
|
"nip44_encrypt" => {
|
||||||
|
let target = request
|
||||||
|
.params
|
||||||
|
.first()
|
||||||
|
.and_then(|hex| short_pubkey(hex))
|
||||||
|
.unwrap_or_else(|| "a third party".to_string());
|
||||||
|
format!("Encrypt a message for {target}")
|
||||||
|
}
|
||||||
|
"nip44_decrypt" => {
|
||||||
|
let target = request
|
||||||
|
.params
|
||||||
|
.first()
|
||||||
|
.and_then(|hex| short_pubkey(hex))
|
||||||
|
.unwrap_or_else(|| "a third party".to_string());
|
||||||
|
format!("Decrypt a message from {target}")
|
||||||
|
}
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abbreviate a 64-char hex pubkey as `8…8`.
|
||||||
|
fn short_pubkey(hex: &str) -> Option<String> {
|
||||||
|
if hex.len() != 64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!("{}…{}", &hex[..8], &hex[56..]))
|
||||||
|
}
|
||||||
|
|
||||||
/// Sign the client's unsigned event with the active profile's key and return the
|
/// Sign the client's unsigned event with the active profile's key and return the
|
||||||
/// signed event JSON.
|
/// signed event JSON.
|
||||||
fn sign_event(keys: &Keys, request: &RawRequest) -> Result<String, String> {
|
fn sign_event(keys: &Keys, request: &RawRequest) -> Result<String, String> {
|
||||||
|
|
@ -439,7 +655,13 @@ async fn run_sign_task(signer: Signer, app: Arc<Mutex<App>>, uri: ConnectUri) {
|
||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
let response = handle_request(&signer, &keys, &uri, &request);
|
// Key-using methods wait for an explicit user approval before they run;
|
||||||
|
// everything else is answered immediately.
|
||||||
|
let response = if requires_approval(&request.method) {
|
||||||
|
gated_response(&signer, &keys, &request).await
|
||||||
|
} else {
|
||||||
|
handle_request(&signer, &keys, &uri, &request)
|
||||||
|
};
|
||||||
if let Some(response) = response {
|
if let Some(response) = response {
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
publish_payload(&client, &keys, &conversation, &uri.peer, &response).await
|
publish_payload(&client, &keys, &conversation, &uri.peer, &response).await
|
||||||
|
|
@ -553,6 +775,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sign_event_returns_a_signed_event() {
|
fn sign_event_returns_a_signed_event() {
|
||||||
|
// The post-approval dispatcher produces a valid signed event.
|
||||||
let signer = Signer::new();
|
let signer = Signer::new();
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let uri = ConnectUri {
|
let uri = ConnectUri {
|
||||||
|
|
@ -595,6 +818,96 @@ mod tests {
|
||||||
assert!(response.contains("Unsupported method"));
|
assert!(response.contains("Unsupported method"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_using_methods_are_gated() {
|
||||||
|
assert!(requires_approval("sign_event"));
|
||||||
|
assert!(requires_approval("nip44_encrypt"));
|
||||||
|
assert!(requires_approval("nip44_decrypt"));
|
||||||
|
assert!(!requires_approval("connect"));
|
||||||
|
assert!(!requires_approval("get_public_key"));
|
||||||
|
assert!(!requires_approval("ping"));
|
||||||
|
assert!(!requires_approval("logout"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sign_event_waits_for_approval_then_signs() {
|
||||||
|
let signer = Signer::new();
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let unsigned =
|
||||||
|
r#"{"kind":1,"created_at":1714078911,"tags":[],"content":"Hello from afar"}"#
|
||||||
|
.to_string();
|
||||||
|
let request = RawRequest {
|
||||||
|
id: "2".into(),
|
||||||
|
method: "sign_event".into(),
|
||||||
|
params: vec![unsigned],
|
||||||
|
};
|
||||||
|
|
||||||
|
// A gated request parks itself and waits; nothing is signed yet.
|
||||||
|
let s1 = signer.clone();
|
||||||
|
let keys_for_task = keys.clone();
|
||||||
|
let task = tokio::spawn(async move { gated_response(&s1, &keys_for_task, &request).await });
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
assert_eq!(signer.pending_count(), 1);
|
||||||
|
assert_eq!(signer.status().pending.len(), 1);
|
||||||
|
assert_eq!(signer.status().pending[0].method, "sign_event");
|
||||||
|
assert!(signer.status().pending[0].summary.contains("kind 1"));
|
||||||
|
|
||||||
|
// Approve it: the task now signs and returns the signed event.
|
||||||
|
let pending_id = signer.status().pending[0].id.clone();
|
||||||
|
signer.approve(&pending_id, true).unwrap();
|
||||||
|
let response = task.await.unwrap().unwrap();
|
||||||
|
let signed: Event = Event::from_json(response_value(&response)).unwrap();
|
||||||
|
assert_eq!(signed.pubkey, keys.public_key());
|
||||||
|
assert_eq!(signed.kind, Kind::TextNote);
|
||||||
|
assert_eq!(signed.content, "Hello from afar");
|
||||||
|
assert!(signed.verify_id());
|
||||||
|
assert!(signed.verify_signature());
|
||||||
|
assert_eq!(signer.pending_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejected_request_never_uses_the_key() {
|
||||||
|
let signer = Signer::new();
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let request = RawRequest {
|
||||||
|
id: "4".into(),
|
||||||
|
method: "sign_event".into(),
|
||||||
|
params: vec![r#"{"kind":1,"created_at":1714078911,"tags":[],"content":"x"}"#.into()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let s1 = signer.clone();
|
||||||
|
let task = tokio::spawn(async move { gated_response(&s1, &keys, &request).await });
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
let pending_id = signer.status().pending[0].id.clone();
|
||||||
|
signer.approve(&pending_id, false).unwrap();
|
||||||
|
|
||||||
|
let response = task.await.unwrap().unwrap();
|
||||||
|
assert!(response.contains("rejected"));
|
||||||
|
assert_eq!(signer.pending_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approving_an_unknown_id_errors() {
|
||||||
|
let signer = Signer::new();
|
||||||
|
assert!(signer.approve("no-such-id", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_event_summary_previews_the_event() {
|
||||||
|
let request = RawRequest {
|
||||||
|
id: "5".into(),
|
||||||
|
method: "sign_event".into(),
|
||||||
|
params: vec![
|
||||||
|
r#"{"kind":1,"created_at":1714078911,"tags":[],"content":"Hello from afar"}"#
|
||||||
|
.to_string(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let summary = describe_request(&request);
|
||||||
|
assert!(summary.contains("kind 1"));
|
||||||
|
assert!(summary.contains("Hello from afar"));
|
||||||
|
assert!(!summary.contains("secret"));
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the `result` string out of a response JSON object.
|
/// Pull the `result` string out of a response JSON object.
|
||||||
fn response_value(response: &str) -> String {
|
fn response_value(response: &str) -> String {
|
||||||
serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
|
serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue