Skip to content

Commit cabeaa0

Browse files
vveerrggclaude
andcommitted
feat: encrypted vault backup & restore (v1.5.8)
Add automatic backup prompt and restore flow so users can recover their profiles, keys, relays, and vault data after reinstall or data loss. Backup file uses existing PBKDF2+AES-256-GCM encryption. - background.js: backup.export/import handlers, backupNeeded broadcast - sidepanel.html: backup prompt sheet, lock screen restore, first-run restore, Settings > Download Backup button - sidepanel.js: showBackupPrompt (once per session, 30s auto-dismiss), doBackupExport (Blob+anchor with delayed revoke for popup compat), doBackupImport (file picker + password), triggers after profile/relay changes - Cross-browser: Chrome side panel, Safari/Firefox popup, iOS Safari - Version bump 1.5.7 → 1.5.8 (Safari skips 1.5.7, was 1.5.6 in review) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e432aea commit cabeaa0

7 files changed

Lines changed: 310 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "nostrkey",
3-
"version": "1.5.7",
3+
"version": "1.5.8",
44
"description": "Nostr key manager and signer for web apps. Securely store your private keys and sign events without exposing them to websites.",
55
"source": [
66
"background.js",

src/background.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
getDecryptedPrivKey,
2727
isEncryptedBlob,
2828
} from './utilities/utils';
29-
import { encrypt as encryptBlob } from './utilities/crypto';
29+
import { encrypt as encryptBlob, decrypt as decryptBlob } from './utilities/crypto';
3030
import { saveEvent } from './utilities/db';
3131
import { api } from './utilities/browser-polyfill';
3232
import { initSync, scheduleSyncPush } from './utilities/sync-manager';
@@ -440,6 +440,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
440440
const result = await unlockSession(message.payload);
441441
// Broadcast password state change to all views
442442
api.runtime.sendMessage({ kind: 'passwordStateChanged', hasPassword: true }).catch(() => {});
443+
api.runtime.sendMessage({ kind: 'backupNeeded' }).catch(() => {});
443444
sendResponse(result);
444445
} catch (e) {
445446
sendResponse({ success: false, error: e.message });
@@ -459,6 +460,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
459460
const result = await unlockSession(newPassword);
460461
// Broadcast password state change to all views
461462
api.runtime.sendMessage({ kind: 'passwordStateChanged', hasPassword: true }).catch(() => {});
463+
api.runtime.sendMessage({ kind: 'backupNeeded' }).catch(() => {});
462464
sendResponse(result);
463465
} catch (e) {
464466
sendResponse({ success: false, error: e.message });
@@ -1003,6 +1005,95 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
10031005
});
10041006
return true;
10051007

1008+
// --- Encrypted vault backup / restore ---
1009+
case 'backup.export':
1010+
reply(sendResponse, async () => {
1011+
if (!sessionPassword) {
1012+
return { success: false, error: 'Extension must be unlocked to create a backup' };
1013+
}
1014+
const data = await storage.get({
1015+
profiles: [],
1016+
profileIndex: 0,
1017+
isEncrypted: false,
1018+
passwordHash: null,
1019+
passwordSalt: null,
1020+
apiKeyVault: null,
1021+
vaultDocs: null,
1022+
nostrAccessWhileLocked: true,
1023+
blockCrossOriginFrames: true,
1024+
autoLockMinutes: 15,
1025+
version: null,
1026+
});
1027+
const plaintext = JSON.stringify(data);
1028+
const encrypted = await encryptBlob(plaintext, sessionPassword);
1029+
const version = api.runtime.getManifest?.()?.version || 'unknown';
1030+
return {
1031+
success: true,
1032+
envelope: {
1033+
format: 'nostrkey-backup',
1034+
version: 1,
1035+
createdAt: new Date().toISOString(),
1036+
extensionVersion: version,
1037+
profileCount: Array.isArray(data.profiles) ? data.profiles.length : 0,
1038+
payload: JSON.parse(encrypted),
1039+
},
1040+
};
1041+
});
1042+
return true;
1043+
case 'backup.import':
1044+
reply(sendResponse, async () => {
1045+
try {
1046+
const { envelope, password } = message.payload;
1047+
if (!envelope || envelope.format !== 'nostrkey-backup') {
1048+
return { success: false, error: 'Not a valid NostrKey backup file' };
1049+
}
1050+
if (typeof envelope.version !== 'number' || envelope.version > 1) {
1051+
return { success: false, error: 'Backup version not supported. Update NostrKey and try again.' };
1052+
}
1053+
const payloadStr = JSON.stringify(envelope.payload);
1054+
let plaintext;
1055+
try {
1056+
plaintext = await decryptBlob(payloadStr, password);
1057+
} catch (_) {
1058+
return { success: false, error: 'Wrong password — could not decrypt backup' };
1059+
}
1060+
const data = JSON.parse(plaintext);
1061+
// Write all backed-up keys to storage
1062+
await storage.set(data);
1063+
// Update in-memory state
1064+
encryptionEnabled = !!data.isEncrypted;
1065+
locked = false;
1066+
sessionPassword = password;
1067+
nostrAccessWhileLocked = data.nostrAccessWhileLocked !== false;
1068+
blockCrossOriginFrames = data.blockCrossOriginFrames !== false;
1069+
if (typeof data.autoLockMinutes === 'number') {
1070+
autoLockTimeout = data.autoLockMinutes * 60 * 1000;
1071+
}
1072+
// Populate session key cache
1073+
sessionKeys.clear();
1074+
if (Array.isArray(data.profiles)) {
1075+
for (let i = 0; i < data.profiles.length; i++) {
1076+
const p = data.profiles[i];
1077+
if (p.type === 'bunker' || !p.privKey) continue;
1078+
if (isEncryptedBlob(p.privKey)) {
1079+
try {
1080+
const hex = await decryptBlob(p.privKey, password);
1081+
sessionKeys.set(i, hex);
1082+
} catch (_) {}
1083+
} else {
1084+
sessionKeys.set(i, p.privKey);
1085+
}
1086+
}
1087+
}
1088+
resetAutoLock();
1089+
const profileCount = Array.isArray(data.profiles) ? data.profiles.length : 0;
1090+
return { success: true, profileCount };
1091+
} catch (e) {
1092+
return { success: false, error: e.message || 'Restore failed' };
1093+
}
1094+
});
1095+
return true;
1096+
10061097
// nostr: protocol URL handler — no key access needed, no permission prompt
10071098
case 'replaceURL':
10081099
reply(sendResponse, async () => {

src/chrome-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"default_locale": "en",
44
"name": "__MSG_extension_name__",
55
"description": "__MSG_extension_description__",
6-
"version": "1.5.7",
6+
"version": "1.5.8",
77
"short_name": "NostrKey",
88
"author": "Humanjava Enterprises Inc",
99
"homepage_url": "https://nostrkey.com",

src/firefox-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"default_locale": "en",
44
"name": "__MSG_extension_name__",
55
"description": "__MSG_extension_description__",
6-
"version": "1.5.7",
6+
"version": "1.5.8",
77
"short_name": "NostrKey",
88
"author": "Humanjava Enterprises Inc",
99
"homepage_url": "https://nostrkey.com",

src/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"default_locale": "en",
44
"name": "__MSG_extension_name__",
55
"description": "__MSG_extension_description__",
6-
"version": "1.5.7",
6+
"version": "1.5.8",
77
"author": "Humanjava Enterprises Inc",
88
"homepage_url": "https://nostrkey.com",
99
"icons": {

src/sidepanel.html

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,17 @@
170170
background-color: #a6e22e;
171171
}
172172
.hidden { display: none !important; }
173+
@keyframes slideUp {
174+
from { transform: translateY(100%); }
175+
to { transform: translateY(0); }
176+
}
177+
.backup-prompt {
178+
background-color: #3e3d32;
179+
border-top: 3px solid #a6e22e;
180+
padding: 16px;
181+
flex-shrink: 0;
182+
animation: slideUp 0.3s ease-out;
183+
}
173184
/* iOS Safari extension popup sizing.
174185
iPad: min-height added via JS for popover auto-sizer.
175186
iPhone: native half-sheet / full-sheet behavior.
@@ -228,6 +239,27 @@
228239
<button id="cancel-reset-btn" class="button flex-1" type="button">Cancel</button>
229240
</div>
230241
</div>
242+
<!-- Recover from backup (lock screen) -->
243+
<div style="margin-top:8px;text-align:center;width:100%;max-width:320px;">
244+
<button id="restore-backup-btn" type="button" style="background:none;border:none;color:#b0b0a8;font-size:0.85rem;cursor:pointer;text-decoration:underline;">
245+
Recover from backup file
246+
</button>
247+
</div>
248+
<div id="restore-backup-panel" class="hidden" style="width:100%;max-width:320px;margin-top:12px;padding:16px;background:#3e3d32;border-radius:8px;border:1px solid #a6e22e;box-sizing:border-box;">
249+
<p style="color:#f8f8f2;font-size:0.9rem;font-weight:bold;margin-bottom:8px;">Restore from Backup</p>
250+
<p style="color:#b0b0a8;font-size:0.85rem;margin-bottom:12px;">Select your backup file and enter the master password that was active when the backup was created.</p>
251+
<label for="restore-password" style="display:block;font-size:0.8rem;color:#b0b0a8;margin-bottom:4px;">Master password</label>
252+
<input id="restore-password" type="password" class="input w-full" placeholder="Master password from backup" autocomplete="off" style="margin-bottom:8px;" />
253+
<label for="restore-file" style="display:block;font-size:0.8rem;color:#b0b0a8;margin-bottom:4px;">Backup file</label>
254+
<input id="restore-file" type="file" accept=".json" style="color:#b0b0a8;font-size:0.8rem;margin-bottom:12px;width:100%;" />
255+
<div id="restore-error" class="hidden" style="color:#f92672;font-size:0.8rem;margin-bottom:8px;" aria-live="assertive"></div>
256+
<div id="restore-success" class="hidden" style="color:#a6e22e;font-size:0.8rem;margin-bottom:8px;" aria-live="polite"></div>
257+
<div class="flex gap-2">
258+
<button id="do-restore-btn" class="button flex-1" type="button">Restore</button>
259+
<button id="cancel-restore-btn" class="button flex-1" type="button" style="border-color:#b0b0a8;">Cancel</button>
260+
</div>
261+
</div>
262+
231263
<!-- Active profile + Nostr access toggle -->
232264
<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;">
233265
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
@@ -284,6 +316,17 @@
284316
<div class="flex flex-col gap-2" style="width:100%;margin-top:4px;">
285317
<button id="check-vault-btn" class="button w-full" type="button" style="font-size:0.8rem;padding:8px 12px;min-height:36px;">Check for Existing Vault</button>
286318
<button id="setup-encryption-btn" class="button w-full" type="button" style="font-size:0.8rem;padding:8px 12px;min-height:36px;">Create New Vault &amp; Password</button>
319+
<button id="firstrun-restore-btn" class="button w-full" type="button" style="font-size:0.8rem;padding:8px 12px;min-height:36px;border-color:#b0b0a8;">Recover from Backup File</button>
320+
</div>
321+
<!-- First-run restore panel -->
322+
<div id="firstrun-restore-panel" class="hidden" style="width:100%;margin-top:12px;padding:12px;background:#272822;border-radius:8px;border:1px solid #a6e22e;">
323+
<label for="firstrun-restore-password" style="display:block;font-size:0.8rem;color:#b0b0a8;margin-bottom:4px;">Master password</label>
324+
<input id="firstrun-restore-password" type="password" class="input w-full" placeholder="Password from backup" autocomplete="off" style="margin-bottom:8px;" />
325+
<label for="firstrun-restore-file" style="display:block;font-size:0.8rem;color:#b0b0a8;margin-bottom:4px;">Backup file</label>
326+
<input id="firstrun-restore-file" type="file" accept=".json" style="color:#b0b0a8;font-size:0.8rem;margin-bottom:8px;width:100%;" />
327+
<div id="firstrun-restore-error" class="hidden" style="color:#f92672;font-size:0.8rem;margin-bottom:8px;" aria-live="assertive"></div>
328+
<div id="firstrun-restore-success" class="hidden" style="color:#a6e22e;font-size:0.8rem;margin-bottom:8px;" aria-live="polite"></div>
329+
<button id="firstrun-do-restore-btn" class="button w-full" type="button" style="font-size:0.8rem;min-height:36px;">Restore</button>
287330
</div>
288331
</div>
289332
</div>
@@ -587,6 +630,7 @@ <h2 class="section-title">Sync</h2>
587630
<h2 class="section-title">Advanced</h2>
588631
<button id="open-settings-btn" class="button w-full mb-2">Full Settings</button>
589632
<button id="open-history-btn" class="button w-full mb-2">Event History</button>
633+
<button id="download-backup-btn" class="button w-full mb-2">Download Backup</button>
590634
<button id="open-experimental-btn" class="button w-full" style="display:none">Experimental</button>
591635
</div>
592636
<p style="font-size:0.8rem;color:#b0b0a8;text-align:center;margin-top:24px;">
@@ -596,6 +640,21 @@ <h2 class="section-title">Advanced</h2>
596640
</div>
597641
</div>
598642

643+
<!-- Backup prompt (slide-up bottom sheet) -->
644+
<div id="backup-prompt" class="backup-prompt hidden">
645+
<div class="flex items-center gap-3" style="margin-bottom:8px;">
646+
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#a6e22e" stroke-width="1.5">
647+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
648+
</svg>
649+
<span style="color:#f8f8f2;font-size:0.95rem;font-weight:600;">Back up your vault</span>
650+
</div>
651+
<p style="color:#b0b0a8;font-size:0.8rem;margin-bottom:12px;line-height:1.4;">Save an encrypted backup file. You'll need your master password to restore it.</p>
652+
<div class="flex gap-2">
653+
<button id="backup-save-btn" class="button flex-1" type="button">Save Backup</button>
654+
<button id="backup-dismiss-btn" class="button flex-1" type="button" style="border-color:#b0b0a8;">Dismiss</button>
655+
</div>
656+
</div>
657+
599658
<!-- Bottom Tab Navigation -->
600659
<nav class="sidepanel-tabs" role="tablist" aria-label="Main navigation">
601660
<button class="tab-btn active" data-view="home" role="tab" aria-selected="true" aria-controls="view-home" type="button">

0 commit comments

Comments
 (0)