feat(signer): permissions UI — declared grants surfaced, always-allow kind-scoped

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).
This commit is contained in:
Avi 2026-09-27 22:37:54 -05:00
commit adbc7c2d75
11 changed files with 350 additions and 31 deletions

View file

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

View file

@ -29,6 +29,19 @@ export interface PendingApproval {
details?: ApprovalDetails; 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 /** A standing "always allow" grant: one app may use one method without a
* prompt. Created by choosing "Always allow" on an approval; revoked from * prompt. Created by choosing "Always allow" on an approval; revoked from
* the Signer screen. */ * the Signer screen. */
@ -37,6 +50,8 @@ export interface SignerGrant {
app_pubkey: string; app_pubkey: string;
/** NIP-46 method that runs without prompting (e.g. "sign_event"). */ /** NIP-46 method that runs without prompting (e.g. "sign_event"). */
method: string; 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. */ /** Non-secret snapshot of the NIP-46 remote signer for display. */
@ -75,6 +90,11 @@ export interface Nip46SignerStatus {
pending_approvals: PendingApproval[]; pending_approvals: PendingApproval[];
/** nostrconnect:// pairing token while a QR pairing is in flight. */ /** nostrconnect:// pairing token while a QR pairing is in flight. */
pairing_uri?: string; 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. */ /** Union of all signer statuses. */

View file

@ -5,6 +5,7 @@ import { Badge } from '../components/Badge';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { declaredPermissionRows, formatExpiry } from '../lib/permissions';
import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types'; import type { SignerMode, EmbeddedSignerStatus, Nip46SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
@ -528,6 +529,40 @@ export function SignerModeScreen() {
via {nip46StatusState.connected_relays?.length ?? 0} of{' '} via {nip46StatusState.connected_relays?.length ?? 0} of{' '}
{nip46StatusState.relays?.length ?? 0} relays {nip46StatusState.relays?.length ?? 0} relays
</p> </p>
<div className="signer-permissions">
<h3>Permissions</h3>
{(() => {
const rows = declaredPermissionRows(nip46StatusState.permissions);
const expiry = formatExpiry(nip46StatusState.expires_at);
return (
<>
{rows === null ? (
<p className="hint">
This signer approves every request on your phone — Keynctr holds no
standing permission list for this connection.
</p>
) : rows.length === 0 ? (
<p className="hint">
No operations were granted by the connect request.
</p>
) : (
<ul className="signer-permission-list">
{rows.map((row) => (
<li key={row} className="mono">
{row}
</li>
))}
</ul>
)}
{expiry && (
<p className="hint">
<Icon name="shield" size={14} /> This connection expires {expiry}.
</p>
)}
</>
);
})()}
</div>
{nip46StatusState.error && ( {nip46StatusState.error && (
<Alert tone="error" title="Connection error"> <Alert tone="error" title="Connection error">
{nip46StatusState.error} {nip46StatusState.error}

View file

@ -5,6 +5,7 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format'; import { shortHexId } from '../lib/format';
import { grantLabel } from '../lib/permissions';
import type { SignerGrant, SignerStatus } from '../lib/types'; import type { SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
@ -252,7 +253,7 @@ export function SignerScreen() {
{grants.map((grant) => ( {grants.map((grant) => (
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item"> <div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
<div className="signer-pending-info"> <div className="signer-pending-info">
<code className="mono signer-pending-method">{grant.method}</code> <code className="mono signer-pending-method">{grantLabel(grant)}</code>
<p>for {shortHexId(grant.app_pubkey)}</p> <p>for {shortHexId(grant.app_pubkey)}</p>
</div> </div>
<div className="settings-inline"> <div className="settings-inline">

View file

@ -2242,6 +2242,26 @@ select {
gap: 12px; 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 Motion system
Purposeful motion for feedback, state, and continuity. Purposeful motion for feedback, state, and continuity.

View file

@ -69,4 +69,40 @@ describe('SignerModeScreen handshake states', () => {
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true), 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(<SignerModeScreen />);
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(<SignerModeScreen />);
expect(await screen.findByText('Permissions')).toBeInTheDocument();
expect(await screen.findByText(/approves every request on your phone/i)).toBeInTheDocument();
});
}); });

View file

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

View file

@ -402,6 +402,23 @@ struct RawRequest {
params: Vec<String>, params: Vec<String>,
} }
/// 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<u16> {
if request.method != "sign_event" {
return None;
}
request
.params
.first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.map(|k| k as u16)
}
/// `{"id":..,"result":<s>,"error":null}` /// `{"id":..,"result":<s>,"error":null}`
fn response_ok(id: &str, result: String) -> String { fn response_ok(id: &str, result: String) -> String {
json!({ "id": id, "result": result, "error": null }).to_string() json!({ "id": id, "result": result, "error": null }).to_string()
@ -754,11 +771,12 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
// run — unless the user granted this app standing "always allow" // run — unless the user granted this app standing "always allow"
// permission for that method. Everything else is answered immediately. // permission for that method. Everything else is answered immediately.
let response = if requires_approval(&request.method) { let response = if requires_approval(&request.method) {
let kind = request_kind(&request);
let granted = { let granted = {
let guard = app.lock().await; let guard = app.lock().await;
guard guard
.vault .vault
.has_signer_grant(&uri.peer.to_hex(), &request.method) .has_signer_grant(&uri.peer.to_hex(), &request.method, kind)
}; };
if granted { if granted {
approved_response(&keys, &request) approved_response(&keys, &request)

View file

@ -1382,6 +1382,8 @@ impl Nip46ClientSigner {
} else { } else {
None 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) (entry, peer)
}; };
if approved && always && !peer_hex.is_empty() { 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<u16> = if entry.method == "sign_event" {
entry.details.event_kind.into_iter().collect()
} else {
Vec::new()
};
let mut app = self.app.lock().await; 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()?; app.save_vault()?;
} }
let _ = entry.sender.send(if approved { let _ = entry.sender.send(if approved {
@ -1906,16 +1917,14 @@ impl Nip46ClientSigner {
} }
// Check method permissions // Check method permissions
let allowed = match request.method.as_str() { let event_kind = request
"sign_event" => {
let kind = request
.params .params
.first() .first()
.and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok()) .and_then(|json| serde_json::from_str::<serde_json::Value>(json).ok())
.and_then(|v| v.get("kind").and_then(|k| k.as_u64())) .and_then(|v| v.get("kind").and_then(|k| k.as_u64()))
.unwrap_or(0) as u16; .map(|k| k as u16);
self.can_sign_event(kind).await let allowed = match request.method.as_str() {
} "sign_event" => self.can_sign_event(event_kind.unwrap_or(0)).await,
"nip44_encrypt" => self.can_encrypt().await, "nip44_encrypt" => self.can_encrypt().await,
"nip44_decrypt" => self.can_decrypt().await, "nip44_decrypt" => self.can_decrypt().await,
_ => false, _ => false,
@ -1929,9 +1938,10 @@ impl Nip46ClientSigner {
)); ));
} }
// Standing grant ("always allow") for this peer + method: skip the // Standing grant ("always allow") for this peer + method + kind:
// prompt and run. Grants are per (peer pubkey, method) and revocable // skip the prompt and run. Grants are per (peer pubkey, method,
// from the Signer screen. // kind-set) and revocable from the Signer screen — a kind-1 grant
// never covers a kind-3 request.
{ {
let peer_hex = self let peer_hex = self
.inner .inner
@ -1943,7 +1953,10 @@ impl Nip46ClientSigner {
.unwrap_or_default(); .unwrap_or_default();
if !peer_hex.is_empty() { if !peer_hex.is_empty() {
let app = self.app.lock().await; 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); drop(app);
self.inner.lock().await.phase = Nip46Phase::Connected; self.inner.lock().await.phase = Nip46Phase::Connected;
return self.approved_response(keys, request); return self.approved_response(keys, request);

View file

@ -98,6 +98,16 @@ pub struct Nip46Status {
/// `None` once paired or when not pairing. /// `None` once paired or when not pairing.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pairing_uri: Option<String>, pub pairing_uri: Option<String>,
/// 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<super::permissions::Nip46Permissions>,
/// 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<u64>,
} }
/// A pending approval request from the signer. /// A pending approval request from the signer.

View file

@ -149,6 +149,16 @@ pub struct SignerGrant {
/// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`, /// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`,
/// or `nip44_decrypt`. /// or `nip44_decrypt`.
pub method: String, 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<u16>,
/// Unix timestamp of when the user granted it. /// Unix timestamp of when the user granted it.
pub created_at: u64, pub created_at: u64,
} }
@ -206,22 +216,44 @@ impl Vault {
} }
/// Whether `app_pubkey` may run gated `method` without a prompt. /// 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 /// For `sign_event` the grant is kind-scoped: a grant recorded for
.iter() /// specific kinds does NOT cover other kinds — an uncovered kind falls
.any(|g| g.app_pubkey == app_pubkey && g.method == method) /// 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<u16>,
) -> 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). /// 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( pub fn grant_signer_method(
&mut self, &mut self,
app_pubkey: &str, app_pubkey: &str,
method: &str, method: &str,
allowed_kinds: &[u16],
) -> Result<(), crate::errors::AppError> { ) -> 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 { self.signer_grants.push(SignerGrant {
app_pubkey: app_pubkey.to_string(), app_pubkey: app_pubkey.to_string(),
method: method.to_string(), method: method.to_string(),
allowed_kinds: allowed_kinds.to_vec(),
created_at: crate::vault::unix_timestamp()?, created_at: crate::vault::unix_timestamp()?,
}); });
} }
@ -796,24 +828,53 @@ mod tests {
#[test] #[test]
fn signer_grants_roundtrip_and_revoke() { fn signer_grants_roundtrip_and_revoke() {
let mut vault = Vault::empty(); 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", &[1]).unwrap();
vault.grant_signer_method("aa", "sign_event").unwrap(); // idempotent vault.grant_signer_method("aa", "sign_event", &[1]).unwrap(); // idempotent
vault.grant_signer_method("bb", "sign_event").unwrap(); vault.grant_signer_method("bb", "sign_event", &[1]).unwrap();
assert_eq!(vault.signer_grants.len(), 2); assert_eq!(vault.signer_grants.len(), 2);
assert!(vault.has_signer_grant("aa", "sign_event")); assert!(vault.has_signer_grant("aa", "sign_event", Some(1)));
assert!(!vault.has_signer_grant("aa", "nip04_decrypt")); assert!(!vault.has_signer_grant("aa", "nip04_decrypt", None));
let json = serde_json::to_string(&vault).unwrap(); let json = serde_json::to_string(&vault).unwrap();
let mut loaded: Vault = serde_json::from_str(&json).unwrap(); let mut loaded: Vault = serde_json::from_str(&json).unwrap();
assert!(loaded.has_signer_grant("aa", "sign_event")); assert!(loaded.has_signer_grant("aa", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("bb", "sign_event")); 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.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("aa", "sign_event", Some(1)));
assert!(loaded.has_signer_grant("bb", "sign_event")); 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); static COUNTER: AtomicU32 = AtomicU32::new(0);