Fix race condition: vault:unlocked message lost when service worker responds before content.js loads

Two related races caused substitution to silently fail with at-rest encryption:

1. vault:request-unlock was sent at the top of init(), but the runtime.onMessage
   listener that handles the vault:unlocked response was registered only after
   await-ing the document-scanner script. A warm service worker (e.g. freshly
   woken by a sync operation) could respond before the listener existed, dropping
   the message permanently.

2. Even if the listener was registered in time, it posted to window immediately,
   but content.js hadn't been injected yet, so its window.addEventListener('message')
   handler wasn't live and the message went nowhere.

Fix: register api.runtime.onMessage before injecting content.js, and move the
vault:request-unlock send into script.onload — by that point content.js has fully
executed and its message listener is live.

Also stop passing the encrypted settings blob as initial config. When isLocked,
ss_settings is { _ssLocalEncrypted: true, data: '...' }; passing it to content.js
as the initial settings object clutters the settings with encrypted garbage.
Now falls back to { enabled: true } like mappings/identity already did.

https://claude.ai/code/session_01CwcZK8nqL8pyBH9AxDs9qo
This commit is contained in:
Claude
2026-04-01 14:48:19 +00:00
parent 1f869f2ff0
commit 1c4b8b0cdd
+40 -31
View File
@@ -58,20 +58,14 @@
// Load mappings and settings, then inject into page
async function init() {
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 — pass empty config and request decryption
// The background will send decrypted data via vault:unlocked when ready
// Check if data is encrypted — pass empty config; decrypted data arrives via vault:unlocked
const isLocked = result.ss_mappings?._ssLocalEncrypted ||
result.ss_identity?._ssLocalEncrypted;
result.ss_identity?._ssLocalEncrypted ||
result.ss_settings?._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(() => {});
}
const settings = isLocked ? { enabled: true } : (result.ss_settings || { enabled: true });
// Merge active profiles into a flat identity object for the content script
const identity = mergeProfiles(identityData);
@@ -84,12 +78,47 @@
await new Promise(resolve => { docScannerScript.onload = resolve; docScannerScript.onerror = resolve; });
docScannerScript.remove();
// Register the runtime message listener BEFORE injecting content.js and BEFORE
// sending vault:request-unlock. This prevents two races:
// 1. The background could respond to vault:request-unlock before the listener
// is registered (if the service worker is already warm), dropping the message.
// 2. window.postMessage from the listener must reach content.js's message handler,
// which is only registered after content.js finishes executing.
// Solution: register the listener here, but send vault:request-unlock only inside
// script.onload (after content.js has fully executed).
api.runtime.onMessage.addListener(async (message) => {
if (message.type === 'settings:updated') {
window.postMessage({
type: 'ss:config-updated',
settings: message.settings,
}, '*');
}
// Vault unlocked — background sends pre-decrypted data
if (message.type === 'vault:unlocked') {
window.postMessage({
type: 'ss:config-updated',
mappings: message.mappings || [],
identity: message.identity || {},
settings: message.settings || {},
}, '*');
}
});
// Inject the main interception script into the page's world
const script = document.createElement('script');
script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings }));
script.src = api.runtime.getURL('src/content/content.js');
(document.head || document.documentElement).appendChild(script);
script.onload = () => script.remove();
script.onload = () => {
script.remove();
// content.js has fully executed — its window.message listener is now live.
// Safe to request decrypted data; the vault:unlocked response will be
// delivered to content.js without a race.
if (isLocked) {
api.runtime.sendMessage({ type: 'vault:request-unlock' }).catch(() => {});
}
};
// Listen for substitution events from the page script
window.addEventListener('message', async (event) => {
@@ -148,26 +177,6 @@
}
});
// Listen for settings updates and vault unlock from background
api.runtime.onMessage.addListener(async (message) => {
if (message.type === 'settings:updated') {
window.postMessage({
type: 'ss:config-updated',
settings: message.settings,
}, '*');
}
// Vault unlocked — background sends pre-decrypted data
if (message.type === 'vault:unlocked') {
window.postMessage({
type: 'ss:config-updated',
mappings: message.mappings || [],
identity: message.identity || {},
settings: message.settings || {},
}, '*');
}
});
// Storage bridge — lets page world script read/write storage
window.addEventListener('message', async (event) => {
if (event.source !== window) return;