Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
797 changes: 786 additions & 11 deletions distros/safari/api-keys/api-keys.build.js

Large diffs are not rendered by default.

10,720 changes: 10,716 additions & 4 deletions distros/safari/background.build.js

Large diffs are not rendered by default.

71 changes: 64 additions & 7 deletions distros/safari/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,21 @@ const permissionRateMap = new Map(); // host → { count, resetAt }
await storage.set({ isEncrypted: true });
data.isEncrypted = true;
}
// Lockout recovery: if isEncrypted=true but there is NO password verifier AND
// no actually-encrypted key blobs, encryption is bogus (e.g. a stale flag
// received from an older buggy sync). Clearing it prevents a permanent lockout
// where the user can never unlock because checkPassword() always fails.
// We ONLY clear when no encrypted blobs exist — never when real ciphertext is
// present (that would corrupt encrypted keys into "plaintext").
if (data.isEncrypted && !data.passwordHash) {
const { profiles = [] } = await storage.get({ profiles: [] });
const hasEncryptedBlob = profiles.some(p => isEncryptedBlob(p.privKey));
if (!hasEncryptedBlob) {
log('[STARTUP] Lockout recovery: isEncrypted=true with no passwordHash and no encrypted blobs → clearing bogus encryption flag');
await storage.set({ isEncrypted: false });
data.isEncrypted = false;
}
}
encryptionEnabled = data.isEncrypted;
nostrAccessWhileLocked = !!data.nostrAccessWhileLocked;
blockCrossOriginFrames = data.blockCrossOriginFrames !== false;
Expand Down Expand Up @@ -359,6 +374,11 @@ const SENSITIVE_KINDS = new Set([
'setPassword', 'changePassword', 'removePassword', 'resetAllData',
'setAutoLockTimeout', 'setNostrAccessWhileLocked', 'setBlockCrossOriginFrames',
'backup.export', 'backup.import', 'unlock',
// Starting/stopping the NIP-46 bunker exposes the user's key for remote
// signing and returns the secret-bearing connection string. It must be a
// deliberate in-extension action — never triggerable by a web page via
// window.nostr.nip46.startBunker().
'bunkerServer.start', 'bunkerServer.stop',
]);

function isExtensionSender(sender) {
Expand Down Expand Up @@ -419,7 +439,20 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return true; // Keep message channel open for async sendResponse
case 'savePrivateKey':
resetAutoLock();
return savePrivateKey(message.payload);
// Must use sendResponse + return true (not a Promise return): Chrome MV3
// does not deliver Promise-return values to sendMessage callers, so the
// caller could not tell whether the key was actually saved (or whether it
// threw). That made imported keys silently fail while the UI showed success.
(async () => {
try {
await savePrivateKey(message.payload);
sendResponse({ success: true });
} catch (e) {
console.error('savePrivateKey error:', e);
sendResponse({ success: false, error: e.message || 'Failed to save key' });
}
})();
return true;
case 'getNpub':
(async () => {
try {
Expand Down Expand Up @@ -1118,8 +1151,13 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
// --- Encrypted vault backup / restore ---
case 'backup.export':
reply(sendResponse, async () => {
if (!sessionCryptoKey) {
return { success: false, error: 'Extension must be unlocked to create a backup' };
// Backups are encrypted with a dedicated backup password supplied at
// export time — NOT the in-memory session key. This lets users with no
// master password create backups, and works even while locked (the
// stored key blobs stay encrypted and get wrapped again here).
const password = message.payload?.password;
if (typeof password !== 'string' || password.length < 8) {
return { success: false, error: 'A backup password of at least 8 characters is required' };
}
const data = await storage.get({
profiles: [],
Expand All @@ -1135,7 +1173,7 @@ api.runtime.onMessage.addListener((message, _sender, sendResponse) => {
version: null,
});
const plaintext = JSON.stringify(data);
const encrypted = await encryptWithKey(plaintext, sessionCryptoKey, sessionKeySalt);
const encrypted = await encryptBlob(plaintext, password);
const version = api.runtime.getManifest?.()?.version || 'unknown';
return {
success: true,
Expand Down Expand Up @@ -1568,9 +1606,16 @@ async function savePrivateKey([index, privKey]) {
const pubKey = getPublicKeySync(hexKey);
profiles[index].pubKey = pubKey;

// If encryption is active, re-encrypt the new key using the session key
// If encryption is active, re-encrypt the new key using the session key.
const encrypted = await isEncrypted();
if (encrypted && sessionCryptoKey) {
if (encrypted) {
// Encryption is on but there's no live session key (locked, or the MV3
// worker was evicted and lost it). Refuse rather than fall through and
// persist the key as PLAINTEXT into a vault the user believes is
// encrypted. The caller surfaces this as a save error.
if (!sessionCryptoKey) {
throw new Error('Extension is locked — unlock before saving a key');
}
profiles[index].privKey = await encryptWithKey(hexKey, sessionCryptoKey, sessionKeySalt);
sessionKeys.set(index, hexKey);
} else {
Expand Down Expand Up @@ -1629,7 +1674,19 @@ async function getPlaintextPrivKey(index, profile) {
if (isEncryptedBlob(profile.privKey)) {
// Key is encrypted — must use session cache
if (sessionKeys.has(index)) {
return sessionKeys.get(index);
const cached = sessionKeys.get(index);
// Guard against a stale cache entry. sessionKeys is keyed by profile
// index, but deleting a profile shifts every later index down by one
// without updating this in-memory map — so sessionKeys.get(index)
// could be a DIFFERENT identity's key. Verify the cached key actually
// derives to this profile's pubkey before returning it; otherwise we
// would sign with the wrong key. If the profile has no cached pubkey
// we can't validate, so fall back to the legacy behaviour.
if (!profile.pubKey || getPublicKeySync(cached) === profile.pubKey) {
return cached;
}
// Stale entry — drop it and treat this profile as locked.
sessionKeys.delete(index);
}
throw new Error('Extension is locked — cannot access private key');
}
Expand Down
Loading
Loading