diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index a389a92..e6c79eb 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -524,6 +524,8 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'signer_disconnect', 'signer_status', 'signer_approve', + 'signer_grants_list', + 'signer_grant_revoke', // New signer modes (default: nip46_client most secure) 'signer_mode_get', 'signer_mode_set', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5e326bf..7d6fdb5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -12,6 +12,7 @@ import type { RelayTestResult, RevealedKey, Settings, + SignerGrant, SignerMode, SignerStatus, UpdateApplyReport, @@ -121,15 +122,23 @@ export const api = { nip46PairStart: (label: string) => call('nip46_pair_start', { label }), nip46Disconnect: () => call('nip46_disconnect'), nip46Status: () => call('nip46_status'), - nip46Approve: (id: string, approved: boolean) => - call('nip46_approve', { id, approved }), + nip46Approve: (id: string, approved: boolean, always = false) => + call('nip46_approve', { id, approved, always }), // Legacy NIP-46 bunker (deprecated, kept for compatibility) signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), signerStatus: () => call('signer_status'), - signerApprove: (id: string, approved: boolean) => - call('signer_approve', { id, approved }), + signerApprove: (id: string, approved: boolean, always = false) => + call('signer_approve', { id, approved, always }), + + // Standing "always allow" grants for apps using us as their signer. + signerGrantsList: () => call('signer_grants_list'), + signerGrantRevoke: (appPubkey: string, grantMethod: string) => + call<{ removed: boolean }>('signer_grant_revoke', { + app_pubkey: appPubkey, + grant_method: grantMethod, + }), deleteProfile: (npub: string) => call('delete_profile', { npub }), undoDelete: () => call('undo_delete'), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 308b44f..2faffd4 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -29,6 +29,16 @@ export interface PendingApproval { details?: ApprovalDetails; } +/** A standing "always allow" grant: one app may use one method without a + * prompt. Created by choosing "Always allow" on an approval; revoked from + * the Signer screen. */ +export interface SignerGrant { + /** App's hex pubkey this grant applies to. */ + app_pubkey: string; + /** NIP-46 method that runs without prompting (e.g. "sign_event"). */ + method: string; +} + /** Non-secret snapshot of the NIP-46 remote signer for display. */ export interface SignerStatus { phase: SignerPhase; diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx index 9595693..1385bf0 100644 --- a/frontend/src/screens/SignerModeScreen.tsx +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -228,10 +228,10 @@ export function SignerModeScreen() { ); const handleNip46Approve = useCallback( - async (id: string, approved: boolean) => { + async (id: string, approved: boolean, always = false) => { setError(null); try { - const status = await nip46Approve(id, approved); + const status = await nip46Approve(id, approved, always); setNip46StatusState(status); } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -549,6 +549,12 @@ export function SignerModeScreen() { > Approve + + + + + ))} + + + )} +

Connect a Nostr app

diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 70c9fca..0755b0a 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -21,6 +21,7 @@ import type { RelayTestResult, RevealedKey, Settings, + SignerGrant, SignerMode, SignerStatus, Theme, @@ -82,12 +83,14 @@ interface AppContextValue { nip46PairStart: (label: string) => Promise; nip46Disconnect: () => Promise; nip46Status: () => Promise; - nip46Approve: (id: string, approved: boolean) => Promise; + nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise; // Legacy NIP-46 bunker (deprecated) signerConnect: (uri: string) => Promise; signerDisconnect: () => Promise; signerStatus: () => Promise; - signerApprove: (id: string, approved: boolean) => Promise; + signerApprove: (id: string, approved: boolean, always?: boolean) => Promise; + signerGrantsList: () => Promise; + signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>; deleteProfile: (npub: string) => Promise; undoDelete: () => Promise; clearLastDeleted: () => void; @@ -267,7 +270,13 @@ export function AppProvider({ children }: { children: ReactNode }) { const nip46Status = useCallback(() => api.nip46Status(), []); const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []); const nip46Approve = useCallback( - (id: string, approved: boolean) => api.nip46Approve(id, approved), + (id: string, approved: boolean, always = false) => api.nip46Approve(id, approved, always), + [], + ); + + const signerGrantsList = useCallback(() => api.signerGrantsList(), []); + const signerGrantRevoke = useCallback( + (appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod), [], ); @@ -360,6 +369,8 @@ export function AppProvider({ children }: { children: ReactNode }) { signerDisconnect, signerStatus, signerApprove, + signerGrantsList, + signerGrantRevoke, deleteProfile, undoDelete, publishProfileMetadata, @@ -418,6 +429,8 @@ export function AppProvider({ children }: { children: ReactNode }) { signerDisconnect, signerStatus, signerApprove, + signerGrantsList, + signerGrantRevoke, copyText, ], ); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 72a197c..d0ee3cc 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -6,6 +6,7 @@ import type { PublishReport, RelayTestResult, Settings, + SignerGrant, SignerStatus, UpdateApplyReport, UpdateCheckReport, @@ -41,6 +42,8 @@ export interface FakeBackend { /** Current NIP-46 signer status. */ signer: SignerStatus; setSigner: (next: SignerStatus) => void; + /** Standing "always allow" grants returned by signer_grants_list. */ + signerGrants: SignerGrant[]; /** Notes returned by `feed_get`. */ feedItems: FeedItem[]; /** Notes returned by `feed_get` with `contacts_only: true`. */ @@ -106,6 +109,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend { setSigner(next) { backend.signer = next; }, + signerGrants: [], feedItems: [ { id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', @@ -344,14 +348,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend { case 'signer_approve': { const id = String(params.id ?? ''); + const entry = backend.signer.pending.find((request) => request.id === id); const next: SignerStatus = { ...backend.signer, pending: backend.signer.pending.filter((request) => request.id !== id), }; backend.setSigner(next); + if (params.approved === true && params.always === true && entry) { + if (!backend.signerGrants.some((g) => g.method === entry.method)) { + backend.signerGrants = [ + ...backend.signerGrants, + { app_pubkey: backend.signer.peer ?? '', method: entry.method }, + ]; + } + } return next; } + case 'signer_grants_list': + return backend.signerGrants; + + case 'signer_grant_revoke': { + const app = String(params.app_pubkey ?? ''); + const method = String(params.grant_method ?? ''); + const before = backend.signerGrants.length; + backend.signerGrants = backend.signerGrants.filter( + (g) => !(g.app_pubkey === app && g.method === method), + ); + return { removed: backend.signerGrants.length < before }; + } + case 'relay_add': { const url = String(params.url); const nextSettings: Settings = { diff --git a/src/bunker.rs b/src/bunker.rs index 307f69a..3f2b47b 100644 --- a/src/bunker.rs +++ b/src/bunker.rs @@ -750,10 +750,21 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C Ok(request) => request, Err(_) => continue, }; - // Key-using methods wait for an explicit user approval before they run; - // everything else is answered immediately. + // Key-using methods wait for an explicit user approval before they + // run — unless the user granted this app standing "always allow" + // permission for that method. Everything else is answered immediately. let response = if requires_approval(&request.method) { - gated_response(&signer, &keys, &request).await + let granted = { + let guard = app.lock().await; + guard + .vault + .has_signer_grant(&uri.peer.to_hex(), &request.method) + }; + if granted { + approved_response(&keys, &request) + } else { + gated_response(&signer, &keys, &request).await + } } else { handle_request(&signer, &keys, &uri, &request) }; diff --git a/src/ipc.rs b/src/ipc.rs index 2241dd1..af851c6 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -175,10 +175,14 @@ pub enum Request { Nip46Disconnect, /// Get NIP-46 connection status. Nip46Status, - /// Approve/reject a pending NIP-46 request. + /// Approve/reject a pending NIP-46 request. `always = true` additionally + /// records a standing grant so this peer's future requests of the same + /// method run without prompting. Nip46Approve { id: String, approved: bool, + #[serde(default)] + always: bool, }, /// ===== LEGACY NIP-46 BUNKER (server mode) ===== /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). @@ -196,6 +200,18 @@ pub enum Request { id: String, /// `true` to run the request, `false` to reject it. approved: bool, + /// `true` alongside `approved` records a standing "always allow" + /// grant for this peer + method. + #[serde(default)] + always: bool, + }, + /// List standing "always allow" grants for apps using us as signer. + SignerGrantsList, + /// Revoke one standing grant (app pubkey + method). The field avoids the + /// name `method` because the request enum is internally tagged on it. + SignerGrantRevoke { + app_pubkey: String, + grant_method: String, }, DeleteProfile { npub: String, @@ -353,6 +369,30 @@ async fn ensure_nip46_signer(app: &Arc>) -> Option serde_json::Value { + let phase = if status.connected { + "connected" + } else if status.pairing_uri.is_some() { + "connecting" + } else { + "stopped" + }; + json!({ + "phase": phase, + "peer": status.signer_pubkey, + "relays": status.relays, + "connectedRelays": status.connected_relays, + "error": status.error, + "pending": status.pending_approvals.iter().map(|p| json!({ + "id": p.id, + "method": p.method, + "summary": p.summary, + })).collect::>(), + }) +} + /// Main request dispatcher. async fn run(app: &Arc>, request: Request) -> Result { match request { @@ -457,11 +497,17 @@ async fn run(app: &Arc>, request: Request) -> Result { + Request::Nip46Approve { + id, + approved, + always, + } => { let Some(signer) = ensure_nip46_signer(app).await else { return Err(AppError::config("NIP-46 signer not initialized")); }; - signer.respond_to_approval(&id, approved).await?; + signer + .respond_to_approval_with_always(&id, approved, always) + .await?; let status = signer.status().await; Ok(json!(status)) } @@ -472,7 +518,7 @@ async fn run(app: &Arc>, request: Request) -> Result>, request: Request) -> Result>, request: Request) -> Result { + Request::SignerApprove { + id, + approved, + always, + } => { let guard = app.lock().await; if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if let Some(signer) = &guard.nip46_signer { - signer.respond_to_approval(&id, approved).await?; + signer + .respond_to_approval_with_always(&id, approved, always) + .await?; let status = signer.status().await; - return Ok(json!(status)); + return Ok(nip46_status_as_bunker_json(&status)); } } Err(AppError::config("Not in NIP-46 client mode")) } + Request::SignerGrantsList => { + let guard = app.lock().await; + Ok(json!(guard.vault.signer_grants)) + } + Request::SignerGrantRevoke { + app_pubkey, + grant_method, + } => { + let mut guard = app.lock().await; + let removed = guard.vault.revoke_signer_grant(&app_pubkey, &grant_method); + if removed { + guard.save_vault()?; + } + Ok(json!({ "removed": removed })) + } // Network-only requests (no shared state lock) Request::RelayTest { url } => { diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index 18b48fc..ae64c33 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -777,10 +777,39 @@ impl Nip46ClientSigner { /// Approve or reject a pending request. pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { - let mut inner = self.inner.lock().await; - let Some(entry) = inner.pending.remove(id) else { - return Err(AppError::config("Request no longer pending")); + self.respond_to_approval_with_always(id, approved, false) + .await + } + + /// Answer a pending request, optionally recording a standing grant so + /// this peer's future requests of the same method skip the prompt + /// ("always allow", like Amber and other signer apps offer). + pub async fn respond_to_approval_with_always( + &self, + id: &str, + approved: bool, + always: bool, + ) -> Result<(), AppError> { + let (entry, peer_hex) = { + let mut inner = self.inner.lock().await; + let entry = inner + .pending + .remove(id) + .ok_or_else(|| AppError::config("Request no longer pending"))?; + // The grant is scoped to whoever SENT the request — the + // connection's remote pubkey. + let peer = inner + .connection + .as_ref() + .map(|c| c.signer_pubkey.clone()) + .unwrap_or_default(); + (entry, peer) }; + if approved && always && !peer_hex.is_empty() { + let mut app = self.app.lock().await; + app.vault.grant_signer_method(&peer_hex, &entry.method)?; + app.save_vault()?; + } let _ = entry.sender.send(if approved { ApprovalResult::Approved } else { @@ -1138,6 +1167,28 @@ impl Nip46ClientSigner { )); } + // Standing grant ("always allow") for this peer + method: skip the + // prompt and run. Grants are per (peer pubkey, method) and revocable + // from the Signer screen. + { + let peer_hex = self + .inner + .lock() + .await + .connection + .as_ref() + .map(|c| c.signer_pubkey.clone()) + .unwrap_or_default(); + if !peer_hex.is_empty() { + let app = self.app.lock().await; + if app.vault.has_signer_grant(&peer_hex, &request.method) { + drop(app); + self.inner.lock().await.phase = Nip46Phase::Connected; + return self.approved_response(keys, request); + } + } + } + self.inner.lock().await.phase = Nip46Phase::Connected; let details = self.describe_request(request); match self.await_approval(details).await { diff --git a/src/vault.rs b/src/vault.rs index d46ce04..d1ddb7c 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -120,6 +120,26 @@ pub struct Vault { /// handled; a password-protected vault encrypts them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub connection_secrets: Vec, + /// Standing "always allow" grants for apps that use this machine as + /// their NIP-46 signer (bunker mode). Keyed by the *app's* pubkey and + /// the gated method it was allowed to run; a matching request skips the + /// approval prompt until revoked. Revoke by deleting the grant. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signer_grants: Vec, +} + +/// A standing permission for one connected NIP-46 app: "always allow" a +/// gated method instead of asking on every request (like Amber and other +/// signer apps do). Covers exactly one (app, method) pair. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SignerGrant { + /// The app's public key (hex) whose requests may skip the prompt. + pub app_pubkey: String, + /// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`, + /// or `nip44_decrypt`. + pub method: String, + /// Unix timestamp of when the user granted it. + pub created_at: u64, } /// An encrypted NIP-46 connection secret, keyed by its @@ -152,9 +172,41 @@ impl Vault { profiles: Vec::new(), nip46_connections: Vec::new(), connection_secrets: Vec::new(), + signer_grants: Vec::new(), } } + /// Whether `app_pubkey` may run gated `method` without a prompt. + pub fn has_signer_grant(&self, app_pubkey: &str, method: &str) -> bool { + self.signer_grants + .iter() + .any(|g| g.app_pubkey == app_pubkey && g.method == method) + } + + /// Record an "always allow" grant (idempotent). + pub fn grant_signer_method( + &mut self, + app_pubkey: &str, + method: &str, + ) -> Result<(), crate::errors::AppError> { + if !self.has_signer_grant(app_pubkey, method) { + self.signer_grants.push(SignerGrant { + app_pubkey: app_pubkey.to_string(), + method: method.to_string(), + created_at: crate::vault::unix_timestamp()?, + }); + } + Ok(()) + } + + /// Drop a standing grant; returns whether one was removed. + pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool { + let before = self.signer_grants.len(); + self.signer_grants + .retain(|g| !(g.app_pubkey == app_pubkey && g.method == method)); + self.signer_grants.len() != before + } + pub fn has_profiles(&self) -> bool { !self.profiles.is_empty() } @@ -344,6 +396,7 @@ pub fn parse_vault(content: &str) -> Result { profiles, nip46_connections: Vec::new(), connection_secrets: Vec::new(), + signer_grants: Vec::new(), }); } @@ -640,6 +693,29 @@ mod tests { use crate::errors::ErrorKind; use std::sync::atomic::{AtomicU32, Ordering}; + #[test] + fn signer_grants_roundtrip_and_revoke() { + let mut vault = Vault::empty(); + assert!(!vault.has_signer_grant("aa", "sign_event")); + + vault.grant_signer_method("aa", "sign_event").unwrap(); + vault.grant_signer_method("aa", "sign_event").unwrap(); // idempotent + vault.grant_signer_method("bb", "sign_event").unwrap(); + assert_eq!(vault.signer_grants.len(), 2); + assert!(vault.has_signer_grant("aa", "sign_event")); + assert!(!vault.has_signer_grant("aa", "nip04_decrypt")); + + let json = serde_json::to_string(&vault).unwrap(); + let mut loaded: Vault = serde_json::from_str(&json).unwrap(); + assert!(loaded.has_signer_grant("aa", "sign_event")); + assert!(loaded.has_signer_grant("bb", "sign_event")); + + assert!(loaded.revoke_signer_grant("aa", "sign_event")); + assert!(!loaded.revoke_signer_grant("aa", "sign_event")); + assert!(!loaded.has_signer_grant("aa", "sign_event")); + assert!(loaded.has_signer_grant("bb", "sign_event")); + } + static COUNTER: AtomicU32 = AtomicU32::new(0); fn temp_vault_path() -> PathBuf {