-
{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);