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

View file

@ -95,6 +95,14 @@ pub enum Request {
SignerDisconnect,
/// Report the remote signer's current status.
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.
@ -244,6 +252,10 @@ async fn run(
Ok(json!(signer.status()))
}
Request::SignerStatus => Ok(json!(signer.status())),
Request::SignerApprove { id, approved } => {
signer.approve(&id, approved)?;
Ok(json!(signer.status()))
}
other => {
let mut guard = app.lock().expect("app mutex poisoned");
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:"
.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"))
}

View file

@ -11,6 +11,7 @@
//! answer its requests. A mistaken `bunker://` link (the opposite role) is
//! rejected with a clear message.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@ -21,6 +22,7 @@ use nostr::nips::nip44::v2::ConversationKey;
use nostr::JsonUtil;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::oneshot;
use nostr_sdk::prelude::*;
@ -31,6 +33,9 @@ use crate::profiles;
/// How long to wait for relays to accept a connection attempt.
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
@ -39,10 +44,21 @@ pub enum SignerPhase {
Stopped,
/// Connected to relays, waiting for the client to acknowledge us.
Connecting,
/// A client connected; requests are auto-approved.
/// A client connected; requests are awaiting approval.
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.
#[derive(Debug, Clone, Serialize)]
pub struct SignerStatus {
@ -53,6 +69,8 @@ pub struct SignerStatus {
pub relays: Vec<String>,
/// A user-facing error if the signer stopped because of one.
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.
@ -67,6 +85,17 @@ struct SignerInner {
relays: Vec<String>,
error: Option<String>,
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 {
@ -85,6 +114,7 @@ impl Signer {
relays: Vec::new(),
error: None,
task: None,
pending: HashMap::new(),
})),
}
}
@ -92,15 +122,27 @@ impl Signer {
/// A non-secret snapshot of the current state for a UI.
pub fn status(&self) -> SignerStatus {
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 {
phase: inner.phase,
peer: inner.peer.map(|pk| pk.to_hex()),
relays: inner.relays.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) {
let mut inner = self.inner.lock().expect("signer mutex poisoned");
if let Some(task) = inner.task.take() {
@ -110,6 +152,75 @@ impl Signer {
inner.peer = None;
inner.relays.clear();
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.
@ -150,6 +261,7 @@ impl Signer {
inner.phase = SignerPhase::Stopped;
inner.error = Some(message.into());
inner.task = None;
inner.pending.clear();
}
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.
///
/// 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(
signer: &Signer,
keys: &Keys,
uri: &ConnectUri,
request: &RawRequest,
) -> Option<String> {
// Seen a client request ⇒ the handshake succeeded; auto-approve from here.
// Seen a client request ⇒ the handshake succeeded.
signer.set_connected();
match request.method.as_str() {
"connect" => Some(response_ok(&request.id, "ack".to_string())),
"get_public_key" => Some(response_ok(&request.id, keys.public_key().to_hex())),
"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) {
Ok(event) => Some(response_ok(&request.id, event)),
Err(e) => Some(response_err(&request.id, e)),
@ -301,11 +470,6 @@ fn handle_request(
Ok(value) => Some(response_ok(&request.id, value)),
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(
&request.id,
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
/// signed event JSON.
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,
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 Err(err) =
publish_payload(&client, &keys, &conversation, &uri.peer, &response).await
@ -553,6 +775,7 @@ mod tests {
#[test]
fn sign_event_returns_a_signed_event() {
// The post-approval dispatcher produces a valid signed event.
let signer = Signer::new();
let keys = Keys::generate();
let uri = ConnectUri {
@ -595,6 +818,96 @@ mod tests {
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.
fn response_value(response: &str) -> String {
serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]