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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue