From adbc7c2d7587a27d98ee7d99ef15f2c139151b4a Mon Sep 17 00:00:00 2001 From: Avi Date: Sun, 27 Sep 2026 22:37:54 -0500 Subject: [PATCH] =?UTF-8?q?feat(signer):=20permissions=20UI=20=E2=80=94=20?= =?UTF-8?q?declared=20grants=20surfaced,=20always-allow=20kind-scoped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 groundwork, the enforcement half that was invisible or too broad: - Nip46Status now carries the connection's declared perms= grant list and its expiry; Signer Mode shows a Permissions panel on a live session (explicit grant rows, or a plain statement that the signer app approves each request when no list was declared). - 'Always allow' grants are kind-scoped: a sign_event grant records the kind of the request the user actually approved and never covers other kinds. Legacy kind-less grants keep their all-kinds meaning so existing vaults keep working. Enforced in both bunker.rs and nip46_client.rs. - Grants list on the Signer screen renders human labels with kind scope. Tests: vault kind-scoping unit tests, frontend permission-label unit tests + two SignerModeScreen tests (declared list, signer-side note). --- frontend/src/lib/permissions.ts | 52 ++++++++++++ frontend/src/lib/types.ts | 20 +++++ frontend/src/screens/SignerModeScreen.tsx | 35 ++++++++ frontend/src/screens/SignerScreen.tsx | 3 +- frontend/src/styles.css | 20 +++++ frontend/src/test/SignerModeScreen.test.tsx | 36 ++++++++ frontend/src/test/permissions.test.ts | 53 ++++++++++++ src/bunker.rs | 20 ++++- src/signer/nip46_client.rs | 41 ++++++---- src/signer/types.rs | 10 +++ src/vault.rs | 91 +++++++++++++++++---- 11 files changed, 350 insertions(+), 31 deletions(-) create mode 100644 frontend/src/lib/permissions.ts create mode 100644 frontend/src/test/permissions.test.ts diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts new file mode 100644 index 0000000..aed1d04 --- /dev/null +++ b/frontend/src/lib/permissions.ts @@ -0,0 +1,52 @@ +import type { Nip46Permissions, SignerGrant } from './types'; + +/** Human label for a NIP-46 method name. */ +export function methodLabel(method: string): string { + switch (method) { + case 'sign_event': + return 'Sign events'; + case 'nip44_encrypt': + return 'Encrypt messages (NIP-44)'; + case 'nip44_decrypt': + return 'Decrypt messages (NIP-44)'; + case 'get_public_key': + return 'Read your public key'; + case 'get_relays': + return 'Read your relay list'; + default: + return method; + } +} + +/** One-line description of a permission grant, e.g. + * "Sign events (kinds 1, 30023)" or "Sign events (all kinds)". */ +export function permissionLabel(method: string, allowedKinds?: number[]): string { + const base = methodLabel(method); + if (method === 'sign_event') { + if (!allowedKinds || allowedKinds.length === 0) return `${base} — all kinds`; + return `${base} — kinds ${allowedKinds.join(', ')}`; + } + return base; +} + +/** Grant rows for the "always allow" list. */ +export function grantLabel(grant: SignerGrant): string { + return permissionLabel(grant.method, grant.allowed_kinds); +} + +/** Rows for the declared per-connection permission set. An absent set means + * there is no local grant list — the signer app approves each request. */ +export function declaredPermissionRows(permissions?: Nip46Permissions): string[] | null { + if (!permissions) return null; + const granted = permissions.granted ?? []; + if (granted.length === 0) return []; + return granted.map((p) => permissionLabel(p.method, p.allowed_kinds)); +} + +/** Format a connection expiry for display. */ +export function formatExpiry(expiresAt?: number): string | null { + if (!expiresAt) return null; + const date = new Date(expiresAt * 1000); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleString(); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d0f98ab..a255dd9 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -29,6 +29,19 @@ export interface PendingApproval { details?: ApprovalDetails; } +/** A single granted NIP-46 permission (mirrors the Rust Nip46Permission). */ +export interface Nip46Permission { + /** The NIP-46 method this covers, e.g. `sign_event`. */ + method: string; + /** Event-kind restrictions for `sign_event`; empty = all kinds. */ + allowed_kinds?: number[]; +} + +/** Declared per-connection permission set (from a `perms=` connect URI). */ +export interface Nip46Permissions { + granted?: Nip46Permission[]; +} + /** 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. */ @@ -37,6 +50,8 @@ export interface SignerGrant { app_pubkey: string; /** NIP-46 method that runs without prompting (e.g. "sign_event"). */ method: string; + /** Event kinds covered for `sign_event`; empty = all kinds (legacy). */ + allowed_kinds?: number[]; } /** Non-secret snapshot of the NIP-46 remote signer for display. */ @@ -75,6 +90,11 @@ export interface Nip46SignerStatus { pending_approvals: PendingApproval[]; /** nostrconnect:// pairing token while a QR pairing is in flight. */ pairing_uri?: string; + /** Declared per-connection permissions, when the connect URI carried a + * `perms=` grant list. Absent = the signer app enforces via its prompts. */ + permissions?: Nip46Permissions; + /** Unix timestamp when the connection expires, if it has a deadline. */ + expires_at?: number; } /** Union of all signer statuses. */ diff --git a/frontend/src/screens/SignerModeScreen.tsx b/frontend/src/screens/SignerModeScreen.tsx index 83dd1c7..51a46c8 100644 --- a/frontend/src/screens/SignerModeScreen.tsx +++ b/frontend/src/screens/SignerModeScreen.tsx @@ -5,6 +5,7 @@ import { Badge } from '../components/Badge'; import { Button } from '../components/Button'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; +import { declaredPermissionRows, formatExpiry } from '../lib/permissions'; import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; import { useApp } from '../state/AppProvider'; @@ -528,6 +529,40 @@ export function SignerModeScreen() { via {nip46StatusState.connected_relays?.length ?? 0} of{' '} {nip46StatusState.relays?.length ?? 0} relays

+
+

Permissions

+ {(() => { + const rows = declaredPermissionRows(nip46StatusState.permissions); + const expiry = formatExpiry(nip46StatusState.expires_at); + return ( + <> + {rows === null ? ( +

+ This signer approves every request on your phone — Keynctr holds no + standing permission list for this connection. +

+ ) : rows.length === 0 ? ( +

+ No operations were granted by the connect request. +

+ ) : ( + + )} + {expiry && ( +

+ This connection expires {expiry}. +

+ )} + + ); + })()} +
{nip46StatusState.error && ( {nip46StatusState.error} diff --git a/frontend/src/screens/SignerScreen.tsx b/frontend/src/screens/SignerScreen.tsx index b55d897..94a1fe3 100644 --- a/frontend/src/screens/SignerScreen.tsx +++ b/frontend/src/screens/SignerScreen.tsx @@ -5,6 +5,7 @@ import { Button } from '../components/Button'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; import { shortHexId } from '../lib/format'; +import { grantLabel } from '../lib/permissions'; import type { SignerGrant, SignerStatus } from '../lib/types'; import { useApp } from '../state/AppProvider'; @@ -252,7 +253,7 @@ export function SignerScreen() { {grants.map((grant) => (
- {grant.method} + {grantLabel(grant)}

for {shortHexId(grant.app_pubkey)}

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3d1f373..5c3c1d6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2242,6 +2242,26 @@ select { gap: 12px; } +.signer-permissions { + width: 100%; + padding: 10px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.signer-permissions h3 { + margin: 0 0 6px; + font-size: 13px; +} + +.signer-permission-list { + margin: 0; + padding-left: 18px; + font-size: 12px; + line-height: 1.7; +} + /* ------------------------------------------------------------------------- Motion system Purposeful motion for feedback, state, and continuity. diff --git a/frontend/src/test/SignerModeScreen.test.tsx b/frontend/src/test/SignerModeScreen.test.tsx index a4a94f4..f62b51d 100644 --- a/frontend/src/test/SignerModeScreen.test.tsx +++ b/frontend/src/test/SignerModeScreen.test.tsx @@ -69,4 +69,40 @@ describe('SignerModeScreen handshake states', () => { expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true), ); }); + + it('shows the declared permission list on a connected session', async () => { + const backend = installNip46Backend(); + backend.setNip46({ + type: 'nip46', + connected: true, + signer_pubkey: 'aabbccddeeff0011', + relays: ['wss://relay.test'], + connected_relays: ['wss://relay.test'], + pending_approvals: [], + permissions: { + granted: [{ method: 'sign_event', allowed_kinds: [1, 30023] }, { method: 'nip44_encrypt' }], + }, + }); + renderWithApp(); + + expect(await screen.findByText('Permissions')).toBeInTheDocument(); + expect(screen.getByText('Sign events — kinds 1, 30023')).toBeInTheDocument(); + expect(screen.getByText('Encrypt messages (NIP-44)')).toBeInTheDocument(); + }); + + it('explains signer-side enforcement when no grant list was declared', async () => { + const backend = installNip46Backend(); + backend.setNip46({ + type: 'nip46', + connected: true, + signer_pubkey: 'aabbccddeeff0011', + relays: ['wss://relay.test'], + connected_relays: ['wss://relay.test'], + pending_approvals: [], + }); + renderWithApp(); + + expect(await screen.findByText('Permissions')).toBeInTheDocument(); + expect(await screen.findByText(/approves every request on your phone/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/test/permissions.test.ts b/frontend/src/test/permissions.test.ts new file mode 100644 index 0000000..d5d6f08 --- /dev/null +++ b/frontend/src/test/permissions.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + declaredPermissionRows, + formatExpiry, + grantLabel, + permissionLabel, +} from '../lib/permissions'; + +describe('permission labels', () => { + it('labels a kind-scoped sign_event grant', () => { + expect(permissionLabel('sign_event', [1, 30023])).toBe('Sign events — kinds 1, 30023'); + }); + + it('labels an all-kinds sign_event grant', () => { + expect(permissionLabel('sign_event', [])).toBe('Sign events — all kinds'); + expect(permissionLabel('sign_event')).toBe('Sign events — all kinds'); + }); + + it('labels non-signing methods without kind noise', () => { + expect(permissionLabel('nip44_decrypt')).toBe('Decrypt messages (NIP-44)'); + }); + + it('renders a grant row through grantLabel', () => { + expect(grantLabel({ app_pubkey: 'aa', method: 'sign_event', allowed_kinds: [1] })).toBe( + 'Sign events — kinds 1', + ); + }); +}); + +describe('declared permission rows', () => { + it('returns null when the connection declared no grant list', () => { + expect(declaredPermissionRows(undefined)).toBeNull(); + expect(declaredPermissionRows({})).toEqual([]); + }); + + it('renders each granted method', () => { + expect( + declaredPermissionRows({ + granted: [{ method: 'sign_event', allowed_kinds: [1] }, { method: 'nip44_encrypt' }], + }), + ).toEqual(['Sign events — kinds 1', 'Encrypt messages (NIP-44)']); + }); +}); + +describe('formatExpiry', () => { + it('formats a unix timestamp', () => { + expect(formatExpiry(1760000000)).toBeTruthy(); + }); + + it('returns null when there is no deadline', () => { + expect(formatExpiry(undefined)).toBeNull(); + }); +}); diff --git a/src/bunker.rs b/src/bunker.rs index 3f2b47b..a908b25 100644 --- a/src/bunker.rs +++ b/src/bunker.rs @@ -402,6 +402,23 @@ struct RawRequest { params: Vec, } +/// The event kind a gated request operates on, when it has one. +/// `sign_event` carries the unsigned event JSON in `params[0]`; other +/// gated methods (encrypt/decrypt) have no kind dimension, and an +/// unparseable payload returns `None` so grant checks fail closed toward +/// prompting. +fn request_kind(request: &RawRequest) -> Option { + if request.method != "sign_event" { + return None; + } + request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())) + .map(|k| k as u16) +} + /// `{"id":..,"result":,"error":null}` fn response_ok(id: &str, result: String) -> String { json!({ "id": id, "result": result, "error": null }).to_string() @@ -754,11 +771,12 @@ async fn run_sign_task(signer: Signer, app: Arc>, uri: C // 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) { + let kind = request_kind(&request); let granted = { let guard = app.lock().await; guard .vault - .has_signer_grant(&uri.peer.to_hex(), &request.method) + .has_signer_grant(&uri.peer.to_hex(), &request.method, kind) }; if granted { approved_response(&keys, &request) diff --git a/src/signer/nip46_client.rs b/src/signer/nip46_client.rs index 1248714..cc85cb8 100644 --- a/src/signer/nip46_client.rs +++ b/src/signer/nip46_client.rs @@ -1382,6 +1382,8 @@ impl Nip46ClientSigner { } else { None }, + permissions: connection.as_ref().and_then(|c| c.permissions.clone()), + expires_at: connection.as_ref().and_then(|c| c.expires_at), } } @@ -1431,8 +1433,17 @@ impl Nip46ClientSigner { (entry, peer) }; if approved && always && !peer_hex.is_empty() { + // A "sign_event" always-allow covers only the kinds of the + // request the user actually saw — never other kinds. Other + // gated methods have no kind dimension. + let kinds: Vec = if entry.method == "sign_event" { + entry.details.event_kind.into_iter().collect() + } else { + Vec::new() + }; let mut app = self.app.lock().await; - app.vault.grant_signer_method(&peer_hex, &entry.method)?; + app.vault + .grant_signer_method(&peer_hex, &entry.method, &kinds)?; app.save_vault()?; } let _ = entry.sender.send(if approved { @@ -1906,16 +1917,14 @@ impl Nip46ClientSigner { } // Check method permissions + let event_kind = request + .params + .first() + .and_then(|json| serde_json::from_str::(json).ok()) + .and_then(|v| v.get("kind").and_then(|k| k.as_u64())) + .map(|k| k as u16); let allowed = match request.method.as_str() { - "sign_event" => { - let kind = request - .params - .first() - .and_then(|json| serde_json::from_str::(json).ok()) - .and_then(|v| v.get("kind").and_then(|k| k.as_u64())) - .unwrap_or(0) as u16; - self.can_sign_event(kind).await - } + "sign_event" => self.can_sign_event(event_kind.unwrap_or(0)).await, "nip44_encrypt" => self.can_encrypt().await, "nip44_decrypt" => self.can_decrypt().await, _ => false, @@ -1929,9 +1938,10 @@ 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. + // Standing grant ("always allow") for this peer + method + kind: + // skip the prompt and run. Grants are per (peer pubkey, method, + // kind-set) and revocable from the Signer screen — a kind-1 grant + // never covers a kind-3 request. { let peer_hex = self .inner @@ -1943,7 +1953,10 @@ impl Nip46ClientSigner { .unwrap_or_default(); if !peer_hex.is_empty() { let app = self.app.lock().await; - if app.vault.has_signer_grant(&peer_hex, &request.method) { + if app + .vault + .has_signer_grant(&peer_hex, &request.method, event_kind) + { drop(app); self.inner.lock().await.phase = Nip46Phase::Connected; return self.approved_response(keys, request); diff --git a/src/signer/types.rs b/src/signer/types.rs index 1334e85..876ce83 100644 --- a/src/signer/types.rs +++ b/src/signer/types.rs @@ -98,6 +98,16 @@ pub struct Nip46Status { /// `None` once paired or when not pairing. #[serde(default, skip_serializing_if = "Option::is_none")] pub pairing_uri: Option, + /// The declared per-connection permissions for the live session, when + /// the connect URI carried a `perms=` grant list. `None` means no local + /// grant list — enforcement is the signer app's own approval prompts. + /// Surfaced so the UI can show what the connection was granted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, + /// When the live connection expires (unix timestamp), if it has a + /// deadline. Expired connections are refused at request time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, } /// A pending approval request from the signer. diff --git a/src/vault.rs b/src/vault.rs index 0e4d907..0791f26 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -149,6 +149,16 @@ pub struct SignerGrant { /// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`, /// or `nip44_decrypt`. pub method: String, + /// Event kinds covered when `method` is `sign_event`. + /// + /// A grant is created "always allow" against ONE concrete request, so a + /// new `sign_event` grant carries exactly the kinds of that request. + /// A non-empty list restricts the grant to those kinds; an empty list + /// means all kinds — possible only on legacy grants (written before + /// grants were kind-scoped), never created anew. Grants for other + /// methods always leave this empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_kinds: Vec, /// Unix timestamp of when the user granted it. pub created_at: u64, } @@ -206,22 +216,44 @@ impl Vault { } /// 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) + /// + /// For `sign_event` the grant is kind-scoped: a grant recorded for + /// specific kinds does NOT cover other kinds — an uncovered kind falls + /// back to the approval prompt. `None` for `event_kind` means the + /// request has no kind dimension (encrypt/decrypt) or the kind could + /// not be parsed; a kind-restricted grant never answers `None`, so + /// unparseable kinds fail closed toward prompting. + pub fn has_signer_grant( + &self, + app_pubkey: &str, + method: &str, + event_kind: Option, + ) -> bool { + self.signer_grants.iter().any(|g| { + g.app_pubkey == app_pubkey + && g.method == method + && (g.allowed_kinds.is_empty() + || event_kind.is_some_and(|k| g.allowed_kinds.contains(&k))) + }) } /// Record an "always allow" grant (idempotent). + /// + /// `allowed_kinds` scopes a `sign_event` grant to the kinds of the + /// request the user actually approved. Pass an empty slice for + /// non-signing methods and for kinds the user did not see — a new + /// grant never silently covers more than the request behind it. pub fn grant_signer_method( &mut self, app_pubkey: &str, method: &str, + allowed_kinds: &[u16], ) -> Result<(), crate::errors::AppError> { - if !self.has_signer_grant(app_pubkey, method) { + if !self.has_signer_grant(app_pubkey, method, allowed_kinds.first().copied()) { self.signer_grants.push(SignerGrant { app_pubkey: app_pubkey.to_string(), method: method.to_string(), + allowed_kinds: allowed_kinds.to_vec(), created_at: crate::vault::unix_timestamp()?, }); } @@ -796,24 +828,53 @@ mod tests { #[test] fn signer_grants_roundtrip_and_revoke() { let mut vault = Vault::empty(); - assert!(!vault.has_signer_grant("aa", "sign_event")); + assert!(!vault.has_signer_grant("aa", "sign_event", Some(1))); - 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(); + vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); + vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); // idempotent + vault.grant_signer_method("bb", "sign_event", &[1]).unwrap(); assert_eq!(vault.signer_grants.len(), 2); - assert!(vault.has_signer_grant("aa", "sign_event")); - assert!(!vault.has_signer_grant("aa", "nip04_decrypt")); + assert!(vault.has_signer_grant("aa", "sign_event", Some(1))); + assert!(!vault.has_signer_grant("aa", "nip04_decrypt", None)); 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.has_signer_grant("aa", "sign_event", Some(1))); + assert!(loaded.has_signer_grant("bb", "sign_event", Some(1))); 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")); + assert!(!loaded.has_signer_grant("aa", "sign_event", Some(1))); + assert!(loaded.has_signer_grant("bb", "sign_event", Some(1))); + } + + #[test] + fn signer_grants_are_kind_scoped() { + let mut vault = Vault::empty(); + // A grant made against a kind-1 request must not cover kind-3. + vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); + assert!(vault.has_signer_grant("aa", "sign_event", Some(1))); + assert!(!vault.has_signer_grant("aa", "sign_event", Some(3))); + // An unparseable kind (None) also falls back to the prompt. + assert!(!vault.has_signer_grant("aa", "sign_event", None)); + + // A legacy grant with no kind list (written before grants were + // kind-scoped) still covers every kind — stored vaults keep working. + vault.signer_grants.push(SignerGrant { + app_pubkey: "legacy".to_string(), + method: "sign_event".to_string(), + allowed_kinds: Vec::new(), + created_at: 0, + }); + assert!(vault.has_signer_grant("legacy", "sign_event", Some(1))); + assert!(vault.has_signer_grant("legacy", "sign_event", Some(30023))); + assert!(vault.has_signer_grant("legacy", "sign_event", None)); + + // Non-signing methods carry no kind dimension. + vault + .grant_signer_method("aa", "nip44_decrypt", &[]) + .unwrap(); + assert!(vault.has_signer_grant("aa", "nip44_decrypt", None)); } static COUNTER: AtomicU32 = AtomicU32::new(0);