Skip to content

Commit 5996a05

Browse files
vveerrggclaude
andcommitted
feat: allow NIP-07 access while extension is locked
Add a toggle on the lock screen that lets websites use NIP-07 (getPublicKey, signEvent, encrypt/decrypt) while the UI stays locked, as long as keys are still in memory from a previous unlock in the session. Lock still protects the UI — no viewing/exporting keys, changing settings, or vault access. - background.js: conditional sessionKeys retention on lock, ask() bypass when keys available, new message handlers (getNostrAccessWhileLocked, setNostrAccessWhileLocked, getActiveProfileInfo) - sidepanel.html: lock screen card with profile info, material-design toggle switch, copy-npub button, responsive layout, anchored footer with NostrKey.com / Ts&Cs / Donate links - sidepanel.js: locked access state, renderLockedAccessCard() with color-coded status, toggle and copy handlers - docs/support.html: add "Support the Project" donation card with Lightning address and Nostr link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4196910 commit 5996a05

14 files changed

Lines changed: 862 additions & 49 deletions

distros/chrome/background-sw.build.js

Lines changed: 58 additions & 8 deletions
Large diffs are not rendered by default.

distros/chrome/background.build.js

Lines changed: 58 additions & 8 deletions
Large diffs are not rendered by default.

distros/chrome/options.build.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,10 @@ td {
11851185
position: fixed;
11861186
}
11871187

1188+
.absolute {
1189+
position: absolute;
1190+
}
1191+
11881192
.relative {
11891193
position: relative;
11901194
}

distros/chrome/sidepanel.build.js

Lines changed: 91 additions & 1 deletion
Large diffs are not rendered by default.

distros/chrome/sidepanel.html

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@
115115
background-color: #a6e22e;
116116
color: #272822;
117117
}
118+
/* Material-style toggle switch */
119+
.toggle-switch {
120+
position: relative;
121+
display: inline-block;
122+
width: 44px;
123+
height: 24px;
124+
flex-shrink: 0;
125+
}
126+
.toggle-switch input {
127+
opacity: 0;
128+
width: 0;
129+
height: 0;
130+
position: absolute;
131+
}
132+
.toggle-slider {
133+
position: absolute;
134+
cursor: pointer;
135+
top: 0; left: 0; right: 0; bottom: 0;
136+
background-color: #49483e;
137+
border-radius: 24px;
138+
transition: background-color 0.2s ease;
139+
}
140+
.toggle-slider::before {
141+
content: '';
142+
position: absolute;
143+
height: 18px;
144+
width: 18px;
145+
left: 3px;
146+
bottom: 3px;
147+
background-color: #8f908a;
148+
border-radius: 50%;
149+
transition: transform 0.2s ease, background-color 0.2s ease;
150+
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
151+
}
152+
.toggle-switch input:checked + .toggle-slider {
153+
background-color: rgba(166, 226, 46, 0.3);
154+
}
155+
.toggle-switch input:checked + .toggle-slider::before {
156+
transform: translateX(20px);
157+
background-color: #a6e22e;
158+
}
118159
.hidden { display: none !important; }
119160
@media (prefers-reduced-motion: reduce) {
120161
*, *::before, *::after {
@@ -129,7 +170,8 @@
129170
<body>
130171
<div class="sidepanel-layout">
131172
<!-- LOCKED STATE -->
132-
<div id="locked-view" class="sidepanel-content hidden">
173+
<div id="locked-view" class="hidden" style="display:flex;flex-direction:column;height:100%;">
174+
<div class="sidepanel-content" style="flex:1;overflow-y:auto;padding-left:max(8px, calc((100% - 320px) / 2));padding-right:max(8px, calc((100% - 320px) / 2));">
133175
<div class="flex flex-col items-center gap-4 py-8">
134176
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
135177
<path d="M16 12V8a4 4 0 10-8 0v4" stroke="#a6e22e" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
@@ -150,20 +192,52 @@
150192
<div id="unlock-error" class="text-sm font-bold mt-2 hidden" style="color:#fd971f;" aria-live="assertive"></div>
151193
<button class="button w-full mt-3" type="submit">Unlock</button>
152194
</form>
153-
<div style="margin-top:24px;text-align:center;">
195+
<div style="margin-top:8px;text-align:center;width:100%;max-width:320px;">
154196
<button id="forgot-password-btn" type="button" style="background:none;border:none;color:#b0b0a8;font-size:0.85rem;cursor:pointer;text-decoration:underline;">
155197
Forgot password? Start fresh
156198
</button>
157199
</div>
158200
<!-- Reset confirmation (hidden by default) -->
159-
<div id="reset-confirm" class="hidden" style="margin-top:16px;padding:16px;background:#3e3d32;border-radius:8px;border:1px solid #f92672;">
201+
<div id="reset-confirm" class="hidden" style="width:100%;max-width:320px;margin-top:16px;padding:16px;background:#3e3d32;border-radius:8px;border:1px solid #f92672;box-sizing:border-box;">
160202
<p style="color:#f92672;font-size:0.9rem;font-weight:bold;margin-bottom:8px;">Delete all data?</p>
161203
<p style="color:#b0b0a8;font-size:0.85rem;margin-bottom:16px;">This will permanently delete all your profiles, keys, and vault data. This cannot be undone.</p>
162204
<div class="flex gap-2">
163205
<button id="confirm-reset-btn" class="button flex-1" type="button" style="border-color:#f92672;color:#f92672;">Delete Everything</button>
164206
<button id="cancel-reset-btn" class="button flex-1" type="button">Cancel</button>
165207
</div>
166208
</div>
209+
<!-- Active profile + Nostr access toggle -->
210+
<div id="locked-access-card" class="hidden" style="width:100%;max-width:320px;margin-top:24px;padding:16px;background:#3e3d32;border-radius:12px;border:1px solid #49483e;">
211+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
212+
<div style="min-width:0;flex:1;">
213+
<div style="font-size:0.8rem;color:#b0b0a8;">Active Profile</div>
214+
<div id="locked-profile-name" style="font-size:1rem;color:#f8f8f2;font-weight:600;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
215+
<div id="locked-profile-npub" style="font-size:0.75rem;color:#8f908a;margin-top:4px;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
216+
</div>
217+
<button id="copy-locked-npub-btn" class="button" type="button" title="Copy npub" aria-label="Copy public key" style="min-width:auto;padding:8px 12px;margin-left:12px;flex-shrink:0;">
218+
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#a6e22e" stroke-width="1.5">
219+
<path d="M19.4 20H9.6a.6.6 0 01-.6-.6V9.6a.6.6 0 01.6-.6h9.8a.6.6 0 01.6.6v9.8a.6.6 0 01-.6.6z"></path>
220+
<path d="M15 9V4.6a.6.6 0 00-.6-.6H4.6a.6.6 0 00-.6.6v9.8a.6.6 0 00.6.6H9"></path>
221+
</svg>
222+
</button>
223+
</div>
224+
<label class="flex items-center gap-3 cursor-pointer" style="padding:4px 0;">
225+
<span class="toggle-switch">
226+
<input id="nostr-access-toggle" type="checkbox" />
227+
<span class="toggle-slider"></span>
228+
</span>
229+
<span style="color:#f8f8f2;font-size:14px;">Allow Nostr access while locked</span>
230+
</label>
231+
<p id="nostr-access-status" style="color:#b0b0a8;font-size:0.8rem;margin-top:6px;line-height:1.4;"></p>
232+
</div>
233+
</div>
234+
</div>
235+
<div style="flex-shrink:0;padding:12px 16px;border-top:1px solid #49483e;background:#3e3d32;text-align:center;font-size:0.75rem;">
236+
<a href="https://nostrkey.com" target="_blank" rel="noopener" style="color:#b0b0a8;text-decoration:none;">NostrKey.com</a>
237+
<span style="color:#49483e;margin:0 6px;">|</span>
238+
<a href="https://nostrkey.com/terms.html" target="_blank" rel="noopener" style="color:#b0b0a8;text-decoration:none;">Ts &amp; Cs</a>
239+
<span style="color:#49483e;margin:0 6px;">|</span>
240+
<a href="https://nostrkey.com/support.html#donate" target="_blank" rel="noopener" style="color:#a6e22e;text-decoration:none;">Donate</a>
167241
</div>
168242
</div>
169243

distros/safari/background.build.js

Lines changed: 58 additions & 8 deletions
Large diffs are not rendered by default.

distros/safari/background.js

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,12 @@ let locked = true; // start locked; determined on first isLocked check
101101
let encryptionEnabled = false; // cached encryption state for fast lookups
102102
let autoLockTimeout = 15 * 60 * 1000; // 15 minutes default
103103
let autoLockTimer = null;
104+
let nostrAccessWhileLocked = false;
104105

105106
// Load persisted state on startup
106107
(async () => {
107108
log('[STARTUP] Reading persisted state...');
108-
const data = await storage.get({ autoLockMinutes: 15, isEncrypted: false, passwordHash: null });
109+
const data = await storage.get({ autoLockMinutes: 15, isEncrypted: false, passwordHash: null, nostrAccessWhileLocked: false });
109110
log(`[STARTUP] isEncrypted=${data.isEncrypted}, passwordHash=${data.passwordHash ? 'EXISTS' : 'null'}, autoLockMinutes=${data.autoLockMinutes}`);
110111
autoLockTimeout = data.autoLockMinutes * 60 * 1000;
111112
// Defensive: if passwordHash exists but flag is stale, self-heal
@@ -115,6 +116,7 @@ let autoLockTimer = null;
115116
data.isEncrypted = true;
116117
}
117118
encryptionEnabled = data.isEncrypted;
119+
nostrAccessWhileLocked = !!data.nostrAccessWhileLocked;
118120
// If encryption is enabled, we start locked
119121
locked = encryptionEnabled;
120122
log(`[STARTUP] Final state: encryptionEnabled=${encryptionEnabled}, locked=${locked}`);
@@ -144,14 +146,16 @@ function resetAutoLock() {
144146
* Lock the session — clear all decrypted keys from memory.
145147
*/
146148
function lockSession() {
147-
sessionKeys.clear();
149+
if (!nostrAccessWhileLocked) {
150+
sessionKeys.clear();
151+
}
148152
sessionPassword = null;
149153
locked = true;
150154
if (autoLockTimer) {
151155
clearTimeout(autoLockTimer);
152156
autoLockTimer = null;
153157
}
154-
log('Session locked.');
158+
log(`Session locked. Keys retained: ${nostrAccessWhileLocked && sessionKeys.size > 0}`);
155159
}
156160

157161
/**
@@ -410,6 +414,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
410414
sessionPassword = null;
411415
locked = false;
412416
encryptionEnabled = false;
417+
nostrAccessWhileLocked = false;
413418
// Re-initialize with default profile
414419
await storage.set({
415420
profiles: [{ name: 'Default Nostr Profile', privKey: '', pubKey: '' }],
@@ -442,6 +447,50 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
442447
sendResponse(true);
443448
return true;
444449

450+
// --- Nostr access while locked ---
451+
case 'getNostrAccessWhileLocked':
452+
sendResponse(nostrAccessWhileLocked);
453+
return true;
454+
case 'setNostrAccessWhileLocked':
455+
nostrAccessWhileLocked = !!message.payload;
456+
storage.set({ nostrAccessWhileLocked: !!message.payload });
457+
if (!message.payload && locked) {
458+
sessionKeys.clear(); // Turning OFF while locked = clear keys immediately
459+
}
460+
sendResponse(true);
461+
return true;
462+
case 'getActiveProfileInfo':
463+
(async () => {
464+
try {
465+
const pi = await getProfileIndex();
466+
const profiles = await getProfiles();
467+
const profile = profiles[pi];
468+
if (!profile) {
469+
log('[getActiveProfileInfo] No profile found at index ' + pi);
470+
sendResponse({ name: 'Unknown', npub: '', hasKeys: false });
471+
return;
472+
}
473+
let npub = '';
474+
if (profile.type === 'bunker' && profile.remotePubkey) {
475+
npub = nip19.npubEncode(profile.remotePubkey);
476+
} else if (profile.pubKey) {
477+
npub = nip19.npubEncode(profile.pubKey);
478+
}
479+
const result = {
480+
name: profile.name || 'Unnamed Profile',
481+
npub,
482+
hasKeys: sessionKeys.has(pi),
483+
isBunker: profile.type === 'bunker',
484+
};
485+
log('[getActiveProfileInfo] Sending: ' + JSON.stringify(result));
486+
sendResponse(result);
487+
} catch (e) {
488+
log('[getActiveProfileInfo] Error: ' + e.message);
489+
sendResponse({ name: 'Error', npub: '', hasKeys: false });
490+
}
491+
})();
492+
return true;
493+
445494
// --- NIP-49 ncryptsec handlers ---
446495
case 'ncryptsec.decrypt':
447496
reply(sendResponse, async () => {
@@ -920,10 +969,14 @@ async function ask(uuid, { kind, host, payload }) {
920969
if (!isBunker) {
921970
const isLocked = await checkLockState();
922971
if (isLocked) {
923-
const sendResponse = validations[uuid];
924-
delete validations[uuid];
925-
sendResponse?.({ error: 'locked', message: 'Extension is locked. Please unlock with your master password.' });
926-
return;
972+
if (!(nostrAccessWhileLocked && sessionKeys.has(pi))) {
973+
// No keys available — reject as before
974+
const sendResponse = validations[uuid];
975+
delete validations[uuid];
976+
sendResponse?.({ error: 'locked', message: 'Extension is locked. Please unlock with your master password.' });
977+
return;
978+
}
979+
// Keys available despite lock — proceed with permission check
927980
}
928981
}
929982

distros/safari/options.build.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,10 @@ td {
11851185
position: fixed;
11861186
}
11871187

1188+
.absolute {
1189+
position: absolute;
1190+
}
1191+
11881192
.relative {
11891193
position: relative;
11901194
}

distros/safari/sidepanel.build.js

Lines changed: 91 additions & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)