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:
parent
98593cb170
commit
adbc7c2d75
11 changed files with 350 additions and 31 deletions
52
frontend/src/lib/permissions.ts
Normal file
52
frontend/src/lib/permissions.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
53
frontend/src/test/permissions.test.ts
Normal file
53
frontend/src/test/permissions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
91
src/vault.rs
91
src/vault.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue