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_disconnect',
'signer_status', 'signer_status',
'signer_approve', 'signer_approve',
'signer_grants_list',
'signer_grant_revoke',
// New signer modes (default: nip46_client most secure) // New signer modes (default: nip46_client most secure)
'signer_mode_get', 'signer_mode_get',
'signer_mode_set', 'signer_mode_set',

View file

@ -12,6 +12,7 @@ import type {
RelayTestResult, RelayTestResult,
RevealedKey, RevealedKey,
Settings, Settings,
SignerGrant,
SignerMode, SignerMode,
SignerStatus, SignerStatus,
UpdateApplyReport, UpdateApplyReport,
@ -121,15 +122,23 @@ export const api = {
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }), nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'), nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'), nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean) => nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved }), call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),
// Legacy NIP-46 bunker (deprecated, kept for compatibility) // Legacy NIP-46 bunker (deprecated, kept for compatibility)
signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }), signerConnect: (uri: string) => call<SignerStatus>('signer_connect', { uri }),
signerDisconnect: () => call<SignerStatus>('signer_disconnect'), signerDisconnect: () => call<SignerStatus>('signer_disconnect'),
signerStatus: () => call<SignerStatus>('signer_status'), signerStatus: () => call<SignerStatus>('signer_status'),
signerApprove: (id: string, approved: boolean) => signerApprove: (id: string, approved: boolean, always = false) =>
call<SignerStatus>('signer_approve', { id, approved }), 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 }), deleteProfile: (npub: string) => call<AppState>('delete_profile', { npub }),
undoDelete: () => call<AppState>('undo_delete'), undoDelete: () => call<AppState>('undo_delete'),

View file

@ -29,6 +29,16 @@ export interface PendingApproval {
details?: ApprovalDetails; 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. */ /** Non-secret snapshot of the NIP-46 remote signer for display. */
export interface SignerStatus { export interface SignerStatus {
phase: SignerPhase; phase: SignerPhase;

View file

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

View file

@ -5,7 +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 type { SignerStatus } from '../lib/types'; import type { SignerGrant, SignerStatus } from '../lib/types';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
const EMPTY_STATUS: SignerStatus = { const EMPTY_STATUS: SignerStatus = {
@ -18,8 +18,10 @@ const EMPTY_STATUS: SignerStatus = {
}; };
export function SignerScreen() { 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 [status, setStatus] = useState<SignerStatus>(EMPTY_STATUS);
const [grants, setGrants] = useState<SignerGrant[]>([]);
const [uri, setUri] = useState(''); const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
@ -28,6 +30,7 @@ export function SignerScreen() {
const refresh = async () => { const refresh = async () => {
try { try {
setStatus(await signerStatus()); setStatus(await signerStatus());
setGrants(await signerGrantsList());
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } 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); setError(null);
try { 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) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} }
@ -200,6 +214,13 @@ export function SignerScreen() {
<Icon name="check" size={16} /> <Icon name="check" size={16} />
Approve Approve
</Button> </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)}> <Button variant="danger" onClick={() => void onApprove(request.id, false)}>
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />
Reject Reject
@ -211,6 +232,34 @@ export function SignerScreen() {
</section> </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"> <section className="card">
<header className="card-header"> <header className="card-header">
<h2>Connect a Nostr app</h2> <h2>Connect a Nostr app</h2>

View file

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

View file

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

View file

@ -750,10 +750,21 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
Ok(request) => request, Ok(request) => request,
Err(_) => continue, Err(_) => continue,
}; };
// Key-using methods wait for an explicit user approval before they run; // Key-using methods wait for an explicit user approval before they
// everything else is answered immediately. // 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 response = if requires_approval(&request.method) {
gated_response(&signer, &keys, &request).await let granted = {
let guard = app.lock().await;
guard
.vault
.has_signer_grant(&uri.peer.to_hex(), &request.method)
};
if granted {
approved_response(&keys, &request)
} else {
gated_response(&signer, &keys, &request).await
}
} else { } else {
handle_request(&signer, &keys, &uri, &request) handle_request(&signer, &keys, &uri, &request)
}; };

View file

@ -175,10 +175,14 @@ pub enum Request {
Nip46Disconnect, Nip46Disconnect,
/// Get NIP-46 connection status. /// Get NIP-46 connection status.
Nip46Status, Nip46Status,
/// Approve/reject a pending NIP-46 request. /// Approve/reject a pending NIP-46 request. `always = true` additionally
/// records a standing grant so this peer's future requests of the same
/// method run without prompting.
Nip46Approve { Nip46Approve {
id: String, id: String,
approved: bool, approved: bool,
#[serde(default)]
always: bool,
}, },
/// ===== LEGACY NIP-46 BUNKER (server mode) ===== /// ===== LEGACY NIP-46 BUNKER (server mode) =====
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker). /// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
@ -196,6 +200,18 @@ pub enum Request {
id: String, id: String,
/// `true` to run the request, `false` to reject it. /// `true` to run the request, `false` to reject it.
approved: bool, approved: bool,
/// `true` alongside `approved` records a standing "always allow"
/// grant for this peer + method.
#[serde(default)]
always: bool,
},
/// List standing "always allow" grants for apps using us as signer.
SignerGrantsList,
/// Revoke one standing grant (app pubkey + method). The field avoids the
/// name `method` because the request enum is internally tagged on it.
SignerGrantRevoke {
app_pubkey: String,
grant_method: String,
}, },
DeleteProfile { DeleteProfile {
npub: String, npub: String,
@ -353,6 +369,30 @@ async fn ensure_nip46_signer(app: &Arc<Mutex<App>>) -> Option<Nip46ClientSignerH
guard.nip46_signer.clone() guard.nip46_signer.clone()
} }
/// Translate the NIP-46 client signer's status into the shape the Signer
/// (bunker) screen consumes, so one live session serves both UIs.
fn nip46_status_as_bunker_json(status: &crate::signer::types::Nip46Status) -> serde_json::Value {
let phase = if status.connected {
"connected"
} else if status.pairing_uri.is_some() {
"connecting"
} else {
"stopped"
};
json!({
"phase": phase,
"peer": status.signer_pubkey,
"relays": status.relays,
"connectedRelays": status.connected_relays,
"error": status.error,
"pending": status.pending_approvals.iter().map(|p| json!({
"id": p.id,
"method": p.method,
"summary": p.summary,
})).collect::<Vec<_>>(),
})
}
/// Main request dispatcher. /// Main request dispatcher.
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> { async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
match request { match request {
@ -457,11 +497,17 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
let status = signer.status().await; let status = signer.status().await;
Ok(json!(status)) Ok(json!(status))
} }
Request::Nip46Approve { id, approved } => { Request::Nip46Approve {
id,
approved,
always,
} => {
let Some(signer) = ensure_nip46_signer(app).await else { let Some(signer) = ensure_nip46_signer(app).await else {
return Err(AppError::config("NIP-46 signer not initialized")); return Err(AppError::config("NIP-46 signer not initialized"));
}; };
signer.respond_to_approval(&id, approved).await?; signer
.respond_to_approval_with_always(&id, approved, always)
.await?;
let status = signer.status().await; let status = signer.status().await;
Ok(json!(status)) Ok(json!(status))
} }
@ -472,7 +518,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?; let status = signer.connect(&uri, "Legacy Bunker".to_string()).await?;
return Ok(json!(status)); return Ok(nip46_status_as_bunker_json(&status));
} }
} }
Err(AppError::config( Err(AppError::config(
@ -485,7 +531,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
signer.disconnect().await?; signer.disconnect().await?;
let status = signer.status().await; let status = signer.status().await;
return Ok(json!(status)); return Ok(nip46_status_as_bunker_json(&status));
} }
} }
Err(AppError::config("Not in NIP-46 client mode")) Err(AppError::config("Not in NIP-46 client mode"))
@ -495,22 +541,43 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
let status = signer.status().await; let status = signer.status().await;
return Ok(json!(status)); return Ok(nip46_status_as_bunker_json(&status));
} }
} }
Err(AppError::config("Not in NIP-46 client mode")) Err(AppError::config("Not in NIP-46 client mode"))
} }
Request::SignerApprove { id, approved } => { Request::SignerApprove {
id,
approved,
always,
} => {
let guard = app.lock().await; let guard = app.lock().await;
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() { if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
if let Some(signer) = &guard.nip46_signer { if let Some(signer) = &guard.nip46_signer {
signer.respond_to_approval(&id, approved).await?; signer
.respond_to_approval_with_always(&id, approved, always)
.await?;
let status = signer.status().await; let status = signer.status().await;
return Ok(json!(status)); return Ok(nip46_status_as_bunker_json(&status));
} }
} }
Err(AppError::config("Not in NIP-46 client mode")) Err(AppError::config("Not in NIP-46 client mode"))
} }
Request::SignerGrantsList => {
let guard = app.lock().await;
Ok(json!(guard.vault.signer_grants))
}
Request::SignerGrantRevoke {
app_pubkey,
grant_method,
} => {
let mut guard = app.lock().await;
let removed = guard.vault.revoke_signer_grant(&app_pubkey, &grant_method);
if removed {
guard.save_vault()?;
}
Ok(json!({ "removed": removed }))
}
// Network-only requests (no shared state lock) // Network-only requests (no shared state lock)
Request::RelayTest { url } => { Request::RelayTest { url } => {

View file

@ -777,10 +777,39 @@ impl Nip46ClientSigner {
/// Approve or reject a pending request. /// Approve or reject a pending request.
pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> { pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> {
let mut inner = self.inner.lock().await; self.respond_to_approval_with_always(id, approved, false)
let Some(entry) = inner.pending.remove(id) else { .await
return Err(AppError::config("Request no longer pending")); }
/// Answer a pending request, optionally recording a standing grant so
/// this peer's future requests of the same method skip the prompt
/// ("always allow", like Amber and other signer apps offer).
pub async fn respond_to_approval_with_always(
&self,
id: &str,
approved: bool,
always: bool,
) -> Result<(), AppError> {
let (entry, peer_hex) = {
let mut inner = self.inner.lock().await;
let entry = inner
.pending
.remove(id)
.ok_or_else(|| AppError::config("Request no longer pending"))?;
// The grant is scoped to whoever SENT the request — the
// connection's remote pubkey.
let peer = inner
.connection
.as_ref()
.map(|c| c.signer_pubkey.clone())
.unwrap_or_default();
(entry, peer)
}; };
if approved && always && !peer_hex.is_empty() {
let mut app = self.app.lock().await;
app.vault.grant_signer_method(&peer_hex, &entry.method)?;
app.save_vault()?;
}
let _ = entry.sender.send(if approved { let _ = entry.sender.send(if approved {
ApprovalResult::Approved ApprovalResult::Approved
} else { } else {
@ -1138,6 +1167,28 @@ 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.
{
let peer_hex = self
.inner
.lock()
.await
.connection
.as_ref()
.map(|c| c.signer_pubkey.clone())
.unwrap_or_default();
if !peer_hex.is_empty() {
let app = self.app.lock().await;
if app.vault.has_signer_grant(&peer_hex, &request.method) {
drop(app);
self.inner.lock().await.phase = Nip46Phase::Connected;
return self.approved_response(keys, request);
}
}
}
self.inner.lock().await.phase = Nip46Phase::Connected; self.inner.lock().await.phase = Nip46Phase::Connected;
let details = self.describe_request(request); let details = self.describe_request(request);
match self.await_approval(details).await { match self.await_approval(details).await {

View file

@ -120,6 +120,26 @@ pub struct Vault {
/// handled; a password-protected vault encrypts them. /// handled; a password-protected vault encrypts them.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connection_secrets: Vec<ConnectionSecret>, pub connection_secrets: Vec<ConnectionSecret>,
/// Standing "always allow" grants for apps that use this machine as
/// their NIP-46 signer (bunker mode). Keyed by the *app's* pubkey and
/// the gated method it was allowed to run; a matching request skips the
/// approval prompt until revoked. Revoke by deleting the grant.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signer_grants: Vec<SignerGrant>,
}
/// A standing permission for one connected NIP-46 app: "always allow" a
/// gated method instead of asking on every request (like Amber and other
/// signer apps do). Covers exactly one (app, method) pair.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignerGrant {
/// The app's public key (hex) whose requests may skip the prompt.
pub app_pubkey: String,
/// The gated NIP-46 method covered: `sign_event`, `nip44_encrypt`,
/// or `nip44_decrypt`.
pub method: String,
/// Unix timestamp of when the user granted it.
pub created_at: u64,
} }
/// An encrypted NIP-46 connection secret, keyed by its /// An encrypted NIP-46 connection secret, keyed by its
@ -152,9 +172,41 @@ impl Vault {
profiles: Vec::new(), profiles: Vec::new(),
nip46_connections: Vec::new(), nip46_connections: Vec::new(),
connection_secrets: Vec::new(), connection_secrets: Vec::new(),
signer_grants: Vec::new(),
} }
} }
/// 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)
}
/// Record an "always allow" grant (idempotent).
pub fn grant_signer_method(
&mut self,
app_pubkey: &str,
method: &str,
) -> Result<(), crate::errors::AppError> {
if !self.has_signer_grant(app_pubkey, method) {
self.signer_grants.push(SignerGrant {
app_pubkey: app_pubkey.to_string(),
method: method.to_string(),
created_at: crate::vault::unix_timestamp()?,
});
}
Ok(())
}
/// Drop a standing grant; returns whether one was removed.
pub fn revoke_signer_grant(&mut self, app_pubkey: &str, method: &str) -> bool {
let before = self.signer_grants.len();
self.signer_grants
.retain(|g| !(g.app_pubkey == app_pubkey && g.method == method));
self.signer_grants.len() != before
}
pub fn has_profiles(&self) -> bool { pub fn has_profiles(&self) -> bool {
!self.profiles.is_empty() !self.profiles.is_empty()
} }
@ -344,6 +396,7 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
profiles, profiles,
nip46_connections: Vec::new(), nip46_connections: Vec::new(),
connection_secrets: Vec::new(), connection_secrets: Vec::new(),
signer_grants: Vec::new(),
}); });
} }
@ -640,6 +693,29 @@ mod tests {
use crate::errors::ErrorKind; use crate::errors::ErrorKind;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
#[test]
fn signer_grants_roundtrip_and_revoke() {
let mut vault = Vault::empty();
assert!(!vault.has_signer_grant("aa", "sign_event"));
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();
assert_eq!(vault.signer_grants.len(), 2);
assert!(vault.has_signer_grant("aa", "sign_event"));
assert!(!vault.has_signer_grant("aa", "nip04_decrypt"));
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.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"));
}
static COUNTER: AtomicU32 = AtomicU32::new(0); static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_vault_path() -> PathBuf { fn temp_vault_path() -> PathBuf {