Skip to content

Commit 3bf2040

Browse files
committed
Add persistent NIP-07 permission manager and profile controls
1 parent 8126e1d commit 3bf2040

4 files changed

Lines changed: 224 additions & 3 deletions

File tree

src/main/db.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ function init() {
8282
active INTEGER NOT NULL DEFAULT 1,
8383
created_at INTEGER NOT NULL
8484
);
85+
86+
CREATE TABLE IF NOT EXISTS nostr_permissions (
87+
id INTEGER PRIMARY KEY AUTOINCREMENT,
88+
origin TEXT NOT NULL,
89+
action TEXT NOT NULL,
90+
decision TEXT NOT NULL,
91+
created_at INTEGER NOT NULL,
92+
updated_at INTEGER NOT NULL,
93+
UNIQUE(origin, action)
94+
);
95+
8596
8697
CREATE TABLE IF NOT EXISTS privacy_settings (
8798
id INTEGER PRIMARY KEY,
@@ -176,12 +187,47 @@ function clearHistory() {
176187
return { ok: true }
177188
}
178189

190+
// ── Nostr permissions ─────────────────────────────────────────────────────────
191+
function getNostrPermission(origin, action) {
192+
return db
193+
.prepare('SELECT decision FROM nostr_permissions WHERE origin=? AND action=?')
194+
.get(origin, action) || null
195+
}
196+
197+
function setNostrPermission(origin, action, decision) {
198+
const ts = now()
199+
200+
db.prepare(`
201+
INSERT INTO nostr_permissions(origin, action, decision, created_at, updated_at)
202+
VALUES (?, ?, ?, ?, ?)
203+
ON CONFLICT(origin, action)
204+
DO UPDATE SET decision=excluded.decision, updated_at=excluded.updated_at
205+
`).run(origin, action, decision, ts, ts)
206+
207+
return { origin, action, decision }
208+
}
209+
210+
function listNostrPermissions() {
211+
return db
212+
.prepare('SELECT origin, action, decision, updated_at FROM nostr_permissions ORDER BY updated_at DESC')
213+
.all()
214+
}
215+
216+
function removeNostrPermission(origin, action) {
217+
db
218+
.prepare('DELETE FROM nostr_permissions WHERE origin=? AND action=?')
219+
.run(origin, action)
220+
221+
return { ok: true }
222+
}
223+
179224
module.exports = {
180225
init,
181226
getSetting, setSetting,
182227
getPrivacy, setPrivacy,
183228
getFavorites, addFavorite, removeFavorite,
184229
addHistory, getHistory, clearHistory,
185230
cashuGetBalance, cashuListMints, cashuAddMint, cashuRemoveMint,
231+
getNostrPermission, setNostrPermission, listNostrPermissions, removeNostrPermission,
186232
_db: () => db,
187233
}

src/main/index.js

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use strict'
2-
const { app, BrowserWindow, BrowserView, ipcMain, session } = require('electron')
2+
const { app, BrowserWindow, BrowserView, ipcMain, session, dialog } = require('electron')
33
const path = require('path')
44
const DB = require('./db')
55
const wallet = require('./wallet')
@@ -28,6 +28,80 @@ const UA_POOL = [
2828
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
2929
]
3030
let currentUA = UA_POOL[Math.floor(Math.random() * UA_POOL.length)]
31+
const nostrPermissions = new Map()
32+
33+
function getIpcOrigin(ipcEvent) {
34+
const url =
35+
ipcEvent.senderFrame?.url ||
36+
ipcEvent.sender?.getURL?.() ||
37+
''
38+
39+
try {
40+
return new URL(url).origin
41+
} catch (_) {
42+
return 'unknown-origin'
43+
}
44+
}
45+
46+
function summarizeNostrEvent(event) {
47+
if (!event || typeof event !== 'object') {
48+
return 'Unknown event'
49+
}
50+
51+
const kind = event.kind ?? 'unknown'
52+
const content = typeof event.content === 'string' ? event.content : ''
53+
const tags = Array.isArray(event.tags) ? event.tags.length : 0
54+
55+
return [
56+
`Kind: ${kind}`,
57+
`Content length: ${content.length} chars`,
58+
`Tags: ${tags}`,
59+
].join('\n')
60+
}
61+
62+
async function confirmNostrPermission(ipcEvent, action, nostrEvent) {
63+
const origin = getIpcOrigin(ipcEvent)
64+
65+
const stored = DB.getNostrPermission(origin, action)
66+
67+
if (stored?.decision === 'allow') {
68+
return true
69+
}
70+
71+
if (stored?.decision === 'deny') {
72+
return false
73+
}
74+
75+
const result = await dialog.showMessageBox(mainWindow, {
76+
type: 'question',
77+
title: 'Nostr signing request',
78+
message: 'A website is requesting access to your Nostr identity.',
79+
detail: [
80+
`Origin: ${origin}`,
81+
`Action: ${action}`,
82+
'',
83+
summarizeNostrEvent(nostrEvent),
84+
'',
85+
'Only approve this request if you trust this website.',
86+
].join('\n'),
87+
buttons: ['Allow once', 'Always allow', 'Always deny', 'Deny once'],
88+
defaultId: 0,
89+
cancelId: 3,
90+
noLink: true,
91+
})
92+
93+
if (result.response === 1) {
94+
DB.setNostrPermission(origin, action, 'allow')
95+
return true
96+
}
97+
98+
if (result.response === 2) {
99+
DB.setNostrPermission(origin, action, 'deny')
100+
return false
101+
}
102+
103+
return result.response === 0
104+
}
31105

32106
function createMainView() {
33107
const view = new BrowserView({
@@ -358,12 +432,25 @@ ipcMain.handle('nostr-import-nsec', (_, args) => nostr.importNsec(DB, args))
358432
ipcMain.handle('nostr-skip', () => DB.setSetting('nostr_skipped', '1'))
359433
ipcMain.handle('nostr-get-profile', () => nostr.getProfile(DB))
360434
ipcMain.handle('nostr-remove-profile', () => nostr.removeProfile(DB))
435+
ipcMain.handle('nostr-list-permissions', () => DB.listNostrPermissions())
436+
ipcMain.handle('nostr-remove-permission', (_, { origin, action }) => {
437+
if (!origin || !action) throw new Error('Invalid permission')
438+
return DB.removeNostrPermission(origin, action)
439+
})
361440
ipcMain.handle('nostr-get-relays', () => nostr.getRelays())
362441
ipcMain.handle('nostr-sign-event', (_, { event }) => nostr.signEvent(DB, event))
363442
ipcMain.handle('nostr-get-pubkey', () => nostr.getPubkey(DB))
364443
// NIP-07 aliases — same implementation, separate IPC channels for clarity
365444
ipcMain.handle('nostr-get-pubkey-nip07', () => nostr.getPubkey(DB))
366-
ipcMain.handle('nostr-sign-event-nip07', (_, { event: e }) => nostr.signEvent(DB, e))
445+
ipcMain.handle('nostr-sign-event-nip07', async (ipcEvent, { event: e }) => {
446+
const allowed = await confirmNostrPermission(ipcEvent, 'signEvent', e)
447+
448+
if (!allowed) {
449+
throw new Error('Nostr signing request denied by user')
450+
}
451+
452+
return nostr.signEvent(DB, e)
453+
})
367454
ipcMain.handle('nostr-get-relays-nip07', () => nostr.getRelays())
368455
ipcMain.handle('nostr-nip04-encrypt', async (_, { pubkey, text }) => {
369456
const { nip04 } = require('nostr-tools')

src/preload/shell.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@ contextBridge.exposeInMainWorld('zap', {
4040
validateMnemonic: (a) => ipcRenderer.invoke('validate-mnemonic', a),
4141
setupWallet: (a) => ipcRenderer.invoke('setup-wallet', a),
4242

43-
// Nostr
43+
// Nostr
4444
nostrCreateProfile: (a) => ipcRenderer.invoke('nostr-create-profile', a),
4545
nostrImportNsec: (a) => ipcRenderer.invoke('nostr-import-nsec', a),
4646
nostrSkip: () => ipcRenderer.invoke('nostr-skip'),
4747
nostrGetProfile: () => ipcRenderer.invoke('nostr-get-profile'),
4848
nostrRemoveProfile: () => ipcRenderer.invoke('nostr-remove-profile'),
49+
nostrListPermissions: () => ipcRenderer.invoke('nostr-list-permissions'),
50+
nostrRemovePermission: (a) =>
51+
ipcRenderer.invoke('nostr-remove-permission', a),
4952
nostrGetRelays: () => ipcRenderer.invoke('nostr-get-relays'),
5053
nostrSignEvent: (a) => ipcRenderer.invoke('nostr-sign-event', a),
5154
nostrGetPubkey: () => ipcRenderer.invoke('nostr-get-pubkey'),

src/renderer/components/nostr/NostrPanel.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface Profile {
1010
export default function NostrPanel({ onClose }: { onClose:()=>void }) {
1111
const [profile, setProfile] = useState<Profile|null>(null)
1212
const [relays, setRelays] = useState<string[]>([])
13+
const [permissions, setPermissions] = useState<any[]>([])
1314
const [showImport, setShowImport] = useState(false)
1415
const [nsec, setNsec] = useState('')
1516
const [name, setName] = useState('')
@@ -21,6 +22,8 @@ export default function NostrPanel({ onClose }: { onClose:()=>void }) {
2122
setProfile(p || null)
2223
const r = await window.zap?.nostrGetRelays()
2324
setRelays(Object.keys(r || {}))
25+
const perms = await window.zap?.nostrListPermissions()
26+
setPermissions(perms || [])
2427
}
2528

2629
useEffect(() => {
@@ -76,6 +79,20 @@ export default function NostrPanel({ onClose }: { onClose:()=>void }) {
7679
}
7780
}
7881

82+
const revokePermission = async (origin: string, action: string) => {
83+
setBusy(true)
84+
setError('')
85+
86+
try {
87+
await window.zap?.nostrRemovePermission({ origin, action })
88+
await load()
89+
} catch (err: any) {
90+
setError(err?.message || 'Failed to revoke permission.')
91+
} finally {
92+
setBusy(false)
93+
}
94+
}
95+
7996
return (
8097
<>
8198
<div className="panel-hd">
@@ -177,7 +194,75 @@ export default function NostrPanel({ onClose }: { onClose:()=>void }) {
177194
>
178195
📋 Copia pubkey
179196
</button>
197+
<div style={{
198+
marginTop:16,
199+
padding:12,
200+
background:'var(--bg-3)',
201+
border:'1px solid var(--b0)',
202+
borderRadius:'var(--r-md)',
203+
}}>
204+
<div className="sec-title" style={{ marginBottom:10 }}>
205+
NIP-07 Permissions
206+
</div>
207+
208+
{permissions.length === 0 ? (
209+
<div style={{
210+
fontSize:11.5,
211+
color:'var(--t2)',
212+
lineHeight:1.5,
213+
}}>
214+
No stored permissions yet.
215+
</div>
216+
) : (
217+
permissions.map((perm, idx) => (
218+
<div
219+
key={`${perm.origin}-${perm.action}-${idx}`}
220+
style={{
221+
padding:'10px 0',
222+
borderBottom:
223+
idx !== permissions.length - 1
224+
? '1px solid var(--b0)'
225+
: 'none',
226+
}}
227+
>
228+
<div style={{
229+
fontSize:12,
230+
fontWeight:700,
231+
color:'var(--t0)',
232+
marginBottom:3,
233+
wordBreak:'break-all',
234+
}}>
235+
{perm.origin}
236+
</div>
237+
238+
<div style={{
239+
fontSize:11,
240+
color:'var(--t2)',
241+
marginBottom:8,
242+
}}>
243+
{perm.action}{perm.decision}
244+
</div>
180245

246+
<button
247+
style={{
248+
padding:'6px 10px',
249+
border:'1px solid #7f1d1d',
250+
background:'rgba(127,29,29,.15)',
251+
color:'#fca5a5',
252+
borderRadius:'var(--r-sm)',
253+
cursor:'pointer',
254+
fontSize:11,
255+
}}
256+
onClick={() =>
257+
revokePermission(perm.origin, perm.action)
258+
}
259+
>
260+
Revoke
261+
</button>
262+
</div>
263+
))
264+
)}
265+
</div>
181266
<button
182267
style={{
183268
width:'100%',

0 commit comments

Comments
 (0)