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;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</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 && (
|
||||
<Alert tone="error" title="Connection error">
|
||||
{nip46StatusState.error}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<div key={`${grant.app_pubkey}:${grant.method}`} className="signer-pending-item">
|
||||
<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>
|
||||
</div>
|
||||
<div className="settings-inline">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(<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>,
|
||||
}
|
||||
|
||||
/// 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}`
|
||||
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<tokio::sync::Mutex<App>>, 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)
|
||||
|
|
|
|||
|
|
@ -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<u16> = 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 allowed = match request.method.as_str() {
|
||||
"sign_event" => {
|
||||
let kind = request
|
||||
let event_kind = 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()))
|
||||
.unwrap_or(0) as u16;
|
||||
self.can_sign_event(kind).await
|
||||
}
|
||||
.map(|k| k as u16);
|
||||
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_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);
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
|
|
|
|||
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`,
|
||||
/// 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<u16>,
|
||||
/// 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<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).
|
||||
///
|
||||
/// `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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue