feat(signer): always-allow grants for external signer requests

Apps asking Keynctr to sign (NIP-46) can now be granted standing
permission per (peer pubkey, method). Approvals gained an 'Always
allow' option; existing grants are listed with a Revoke button on the
Signer screen and persist in the encrypted vault.
This commit is contained in:
Avi 2026-09-12 17:00:41 -05:00
commit 81b082f238
11 changed files with 348 additions and 28 deletions

View file

@ -524,6 +524,8 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'signer_disconnect',
'signer_status',
'signer_approve',
'signer_grants_list',
'signer_grant_revoke',
// New signer modes (default: nip46_client most secure)
'signer_mode_get',
'signer_mode_set',

View file

@ -12,6 +12,7 @@ import type {
RelayTestResult,
RevealedKey,
Settings,
SignerGrant,
SignerMode,
SignerStatus,
UpdateApplyReport,
@ -121,15 +122,23 @@ export const api = {
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }),
nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean) =>
call<SignerStatus>('signer_approve', { id, approved }),
signerApprove: (id: string, approved: boolean, always = false) =>
call<SignerStatus>('signer_approve', { id, approved, always }),
// Standing "always allow" grants for apps using us as their signer.
signerGrantsList: () => call<SignerGrant[]>('signer_grants_list'),
signerGrantRevoke: (appPubkey: string, grantMethod: string) =>
call<{ removed: boolean }>('signer_grant_revoke', {
app_pubkey: appPubkey,
grant_method: grantMethod,
}),
deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
undoDelete: () => call<AppState>('undo_delete'),

View file

@ -29,6 +29,16 @@ export interface PendingApproval {
details?: ApprovalDetails;
}
/** 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. */
export interface SignerGrant {
/** App's hex pubkey this grant applies to. */
app_pubkey: string;
/** NIP-46 method that runs without prompting (e.g. "sign_event"). */
method: string;
}
/** Non-secret snapshot of the NIP-46 remote signer for display. */
export interface SignerStatus {
phase: SignerPhase;

View file

@ -228,10 +228,10 @@ export function SignerModeScreen() {
);
const handleNip46Approve = useCallback(
async (id: string, approved: boolean) => {
async (id: string, approved: boolean, always = false) => {
setError(null);
try {
const status = await nip46Approve(id, approved);
const status = await nip46Approve(id, approved, always);
setNip46StatusState(status);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@ -549,6 +549,12 @@ export function SignerModeScreen() {
>
Approve
</Button>
<Button
variant="secondary"
onClick={() => void handleNip46Approve(r.id, true, true)}
>
Always allow
</Button>
<Button
variant="danger"
onClick={() => void handleNip46Approve(r.id, false)}

View file

@ -5,7 +5,7 @@ import { Button } from '../components/Button';
import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon';
import { shortHexId } from '../lib/format';
import type { SignerStatus } from '../lib/types';
import type { SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = {
@ -18,8 +18,10 @@ const EMPTY_STATUS: SignerStatus = {
};
export function SignerScreen() {
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove } = useApp();
const { state, signerConnect, signerDisconnect, signerStatus, signerApprove, signerGrantsList, signerGrantRevoke } =
useApp();
const [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [grants, setGrants] = useState<SignerGrant[]>([]);
const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
@ -28,6 +30,7 @@ export function SignerScreen() {
const refresh = async () => {
try {
setStatus(await signerStatus());
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@ -82,10 +85,21 @@ export function SignerScreen() {
}
};
const onApprove = async (id: string, approved: boolean) => {
const onApprove = async (id: string, approved: boolean, always = false) => {
setError(null);
try {
setStatus(await signerApprove(id, approved));
setStatus(await signerApprove(id, approved, always));
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onRevokeGrant = async (grant: SignerGrant) => {
setError(null);
try {
await signerGrantRevoke(grant.app_pubkey, grant.method);
setGrants(await signerGrantsList());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
@ -200,6 +214,13 @@ export function SignerScreen() {
<Icon name="check" size={16} />
Approve
</Button>
<Button
variant="secondary"
onClick={() => void onApprove(request.id, true, true)}
>
<Icon name="check" size={16} />
Always allow
</Button>
<Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} />
Reject
@ -211,6 +232,34 @@ export function SignerScreen() {
</section>
)}
{grants.length > 0 && (
<section className="card">
<header className="card-header">
<h2>Always-allow permissions</h2>
<Badge>{grants.length}</Badge>
</header>
<div className="card-body signer-pending">
<p className="hint">
These requests run without asking. Revoke one to go back to approving it every
time.
</p>
{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>
<p>for {shortHexId(grant.app_pubkey)}</p>
</div>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onRevokeGrant(grant)}>
Revoke
</Button>
</div>
</div>
))}
</div>
</section>
)}
<section className="card">
<header className="card-header">
<h2>Connect a Nostr app</h2>

View file

@ -21,6 +21,7 @@ import type {
RelayTestResult,
RevealedKey,
Settings,
SignerGrant,
SignerMode,
SignerStatus,
Theme,
@ -82,12 +83,14 @@ interface AppContextValue {
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean) => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
signerConnect: (uri: string) => Promise<SignerStatus>;
signerDisconnect: () => Promise<SignerStatus>;
signerStatus: () => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
signerApprove: (id: string, approved: boolean, always?: boolean) => Promise<SignerStatus>;
signerGrantsList: () => Promise<SignerGrant[]>;
signerGrantRevoke: (appPubkey: string, grantMethod: string) => Promise<{ removed: boolean }>;
deleteProfile: (npub: string) => Promise<AppState>;
undoDelete: () => Promise<AppState>;
clearLastDeleted: () => void;
@ -267,7 +270,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback(
(id: string, approved: boolean) => api.nip46Approve(id, approved),
(id: string, approved: boolean, always = false) => api.nip46Approve(id, approved, always),
[],
);
const signerGrantsList = useCallback(() => api.signerGrantsList(), []);
const signerGrantRevoke = useCallback(
(appPubkey: string, grantMethod: string) => api.signerGrantRevoke(appPubkey, grantMethod),
[],
);
@ -360,6 +369,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect,
signerStatus,
signerApprove,
signerGrantsList,
signerGrantRevoke,
deleteProfile,
undoDelete,
publishProfileMetadata,
@ -418,6 +429,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
signerDisconnect,
signerStatus,
signerApprove,
signerGrantsList,
signerGrantRevoke,
copyText,
],
);

View file

@ -6,6 +6,7 @@ import type {
PublishReport,
RelayTestResult,
Settings,
SignerGrant,
SignerStatus,
UpdateApplyReport,
UpdateCheckReport,
@ -41,6 +42,8 @@ export interface FakeBackend {
/** Current NIP-46 signer status. */
signer: SignerStatus;
setSigner: (next: SignerStatus) => void;
/** Standing "always allow" grants returned by signer_grants_list. */
signerGrants: SignerGrant[];
/** Notes returned by `feed_get`. */
feedItems: FeedItem[];
/** Notes returned by `feed_get` with `contacts_only: true`. */
@ -106,6 +109,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
setSigner(next) {
backend.signer = next;
},
signerGrants: [],
feedItems: [
{
id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@ -344,14 +348,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'signer_approve': {
const id = String(params.id ?? '');
const entry = backend.signer.pending.find((request) => request.id === id);
const next: SignerStatus = {
...backend.signer,
pending: backend.signer.pending.filter((request) => request.id !== id),
};
backend.setSigner(next);
if (params.approved === true && params.always === true && entry) {
if (!backend.signerGrants.some((g) => g.method === entry.method)) {
backend.signerGrants = [
...backend.signerGrants,
{ app_pubkey: backend.signer.peer ?? '', method: entry.method },
];
}
}
return next;
}
case 'signer_grants_list':
return backend.signerGrants;
case 'signer_grant_revoke': {
const app = String(params.app_pubkey ?? '');
const method = String(params.grant_method ?? '');
const before = backend.signerGrants.length;
backend.signerGrants = backend.signerGrants.filter(
(g) => !(g.app_pubkey === app && g.method === method),
);
return { removed: backend.signerGrants.length < before };
}
case 'relay_add': {
const url = String(params.url);
const nextSettings: Settings = {