Fix substitution breaking after sync import with at-rest encryption

When at-rest encryption is enabled, injector.js detects encrypted blobs
in storage, passes empty config to the content script, and waits for a
vault:unlocked broadcast from the background. That broadcast was only
ever triggered when the user explicitly entered their password in the
popup. After a Gist/URL sync import (which writes newly-imported data
in encrypted form), no page ever received the decrypted config, so
substitution silently stopped working.

Two fixes:

1. injector.js: when isLocked is true (encrypted blobs detected), send
   vault:request-unlock to the background. If the key is already cached
   (e.g. the user authenticated during the sync pull), the background
   responds immediately with vault:unlocked containing the decrypted
   data. This fixes every new page load after a sync import.

2. service-worker.js: add vault:request-unlock handler that checks
   Storage.isLocked() and, if the key is available, reads decrypted
   mappings/identity/settings and sends vault:unlocked back to the
   requesting tab.

3. options.js: after a successful Gist or URL pull, send vault:unlocked
   to the background so it broadcasts decrypted data to all currently-
   open tabs immediately, without requiring a page reload.

https://claude.ai/code/session_01QJnEnLfbXKR5FSCQ3Qfs53
This commit is contained in:
Claude
2026-04-01 04:27:26 +00:00
parent 0997f7a8e4
commit 9f763ba0ec
3 changed files with 28 additions and 1 deletions
+17
View File
@@ -184,6 +184,23 @@ const messageHandlers = {
sendResponse({ locked });
},
// Content script requests decrypted data on page load when it sees
// encrypted storage but the key is already cached (e.g. after a sync import).
async 'vault:request-unlock'(_message, sender) {
const locked = await Storage.isLocked();
if (locked) return; // key not available — user must unlock via popup
const mappings = await Storage.getMappings();
const identity = await Storage.getIdentity();
const settings = await Storage.getSettings();
api.tabs.sendMessage(sender.tab.id, {
type: 'vault:unlocked',
mappings,
identity,
settings,
}).catch(() => {});
},
async 'vault:unlocked'() {
// User unlocked the vault — clear the LOCK badge and refresh icon
api.action.setBadgeText({ text: '' });
+7 -1
View File
@@ -60,13 +60,19 @@
const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']);
const settings = result.ss_settings || { enabled: true };
// Check if data is encrypted (locked) — pass empty config
// Check if data is encrypted — pass empty config and request decryption
// The background will send decrypted data via vault:unlocked when ready
const isLocked = result.ss_mappings?._ssLocalEncrypted ||
result.ss_identity?._ssLocalEncrypted;
const mappings = isLocked ? [] : (result.ss_mappings || []);
const identityData = isLocked ? {} : (result.ss_identity || {});
// If data is encrypted but the vault key may already be cached
// (e.g. after a sync import), ask the background to push decrypted data
if (isLocked) {
api.runtime.sendMessage({ type: 'vault:request-unlock' }).catch(() => {});
}
// Merge active profiles into a flat identity object for the content script
const identity = mergeProfiles(identityData);
+4
View File
@@ -201,6 +201,7 @@ document.addEventListener('DOMContentLoaded', async () => {
setGistSyncStatus('Pull failed: ' + r2.reason, 'error');
} else if (r2.imported) {
setGistSyncStatus(`Pulled (${r2.time}). Refreshing…`, 'ok');
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
@@ -215,6 +216,7 @@ document.addEventListener('DOMContentLoaded', async () => {
setGistSyncStatus('Pull failed: ' + r.reason, 'error');
} else if (r.imported) {
setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
@@ -258,6 +260,7 @@ document.addEventListener('DOMContentLoaded', async () => {
setUrlSyncStatus('Pull failed: ' + r2.reason, 'error');
} else if (r2.imported) {
setUrlSyncStatus(`Pulled (${r2.time}). Refreshing…`, 'ok');
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
@@ -272,6 +275,7 @@ document.addEventListener('DOMContentLoaded', async () => {
setUrlSyncStatus('Pull failed: ' + r.reason, 'error');
} else if (r.imported) {
setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();