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:
parent
286bbcaa04
commit
81b082f238
11 changed files with 348 additions and 28 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -750,10 +750,21 @@ async fn run_sign_task(signer: Signer, app: Arc<tokio::sync::Mutex<App>>, uri: C
|
|||
Ok(request) => request,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Key-using methods wait for an explicit user approval before they run;
|
||||
// everything else is answered immediately.
|
||||
// Key-using methods wait for an explicit user approval before they
|
||||
// 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 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 {
|
||||
handle_request(&signer, &keys, &uri, &request)
|
||||
};
|
||||
|
|
|
|||
85
src/ipc.rs
85
src/ipc.rs
|
|
@ -175,10 +175,14 @@ pub enum Request {
|
|||
Nip46Disconnect,
|
||||
/// Get NIP-46 connection status.
|
||||
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 {
|
||||
id: String,
|
||||
approved: bool,
|
||||
#[serde(default)]
|
||||
always: bool,
|
||||
},
|
||||
/// ===== LEGACY NIP-46 BUNKER (server mode) =====
|
||||
/// Start the NIP-46 remote signer for a `nostrconnect://` link (acting as bunker).
|
||||
|
|
@ -196,6 +200,18 @@ pub enum Request {
|
|||
id: String,
|
||||
/// `true` to run the request, `false` to reject it.
|
||||
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 {
|
||||
npub: String,
|
||||
|
|
@ -353,6 +369,30 @@ async fn ensure_nip46_signer(app: &Arc<Mutex<App>>) -> Option<Nip46ClientSignerH
|
|||
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.
|
||||
async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Value, AppError> {
|
||||
match request {
|
||||
|
|
@ -457,11 +497,17 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
|||
let status = signer.status().await;
|
||||
Ok(json!(status))
|
||||
}
|
||||
Request::Nip46Approve { id, approved } => {
|
||||
Request::Nip46Approve {
|
||||
id,
|
||||
approved,
|
||||
always,
|
||||
} => {
|
||||
let Some(signer) = ensure_nip46_signer(app).await else {
|
||||
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;
|
||||
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 let Some(signer) = &guard.nip46_signer {
|
||||
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(
|
||||
|
|
@ -485,7 +531,7 @@ async fn run(app: &Arc<Mutex<App>>, request: Request) -> Result<serde_json::Valu
|
|||
if let Some(signer) = &guard.nip46_signer {
|
||||
signer.disconnect().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"))
|
||||
|
|
@ -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 let Some(signer) = &guard.nip46_signer {
|
||||
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"))
|
||||
}
|
||||
Request::SignerApprove { id, approved } => {
|
||||
Request::SignerApprove {
|
||||
id,
|
||||
approved,
|
||||
always,
|
||||
} => {
|
||||
let guard = app.lock().await;
|
||||
if guard.signer_mode == SignerMode::Nip46Client && guard.nip46_signer.is_some() {
|
||||
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;
|
||||
return Ok(json!(status));
|
||||
return Ok(nip46_status_as_bunker_json(&status));
|
||||
}
|
||||
}
|
||||
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)
|
||||
Request::RelayTest { url } => {
|
||||
|
|
|
|||
|
|
@ -777,10 +777,39 @@ impl Nip46ClientSigner {
|
|||
|
||||
/// Approve or reject a pending request.
|
||||
pub async fn respond_to_approval(&self, id: &str, approved: bool) -> Result<(), AppError> {
|
||||
self.respond_to_approval_with_always(id, approved, false)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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 Some(entry) = inner.pending.remove(id) else {
|
||||
return Err(AppError::config("Request no longer pending"));
|
||||
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 {
|
||||
ApprovalResult::Approved
|
||||
} 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;
|
||||
let details = self.describe_request(request);
|
||||
match self.await_approval(details).await {
|
||||
|
|
|
|||
76
src/vault.rs
76
src/vault.rs
|
|
@ -120,6 +120,26 @@ pub struct Vault {
|
|||
/// handled; a password-protected vault encrypts them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
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
|
||||
|
|
@ -152,9 +172,41 @@ impl Vault {
|
|||
profiles: Vec::new(),
|
||||
nip46_connections: 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 {
|
||||
!self.profiles.is_empty()
|
||||
}
|
||||
|
|
@ -344,6 +396,7 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
|
|||
profiles,
|
||||
nip46_connections: Vec::new(),
|
||||
connection_secrets: Vec::new(),
|
||||
signer_grants: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -640,6 +693,29 @@ mod tests {
|
|||
use crate::errors::ErrorKind;
|
||||
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);
|
||||
|
||||
fn temp_vault_path() -> PathBuf {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue