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 }), 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),
}; };

View file

@ -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. */

View file

@ -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&apos;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&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"> <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} />

View file

@ -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,
], ],
); );

View file

@ -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);
});
});
}); });

View file

@ -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),
}; };

View file

@ -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 = {

View file

@ -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

View file

@ -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"))
} }

View file

@ -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"]