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_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',
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
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
|
gated_response(&signer, &keys, &request).await
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
handle_request(&signer, &keys, &uri, &request)
|
handle_request(&signer, &keys, &uri, &request)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
85
src/ipc.rs
85
src/ipc.rs
|
|
@ -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 } => {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
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 mut inner = self.inner.lock().await;
|
||||||
let Some(entry) = inner.pending.remove(id) else {
|
let entry = inner
|
||||||
return Err(AppError::config("Request no longer pending"));
|
.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 {
|
||||||
|
|
|
||||||
76
src/vault.rs
76
src/vault.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue