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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue