fix(security): redact secrets from plaintext persistence

- Strip password, cookies, and headers from download_queue before writing
  to store.bin; secrets remain in-memory for the active session only.
- Move extension pairing token from PersistedSettings to the OS keychain,
  rotating it on upgrade from versions that persisted it as plaintext.
- Add ignores_legacy_extension_pairing_token_field test to confirm serde
  silently drops the old field so existing installs migrate cleanly.
- Document intentional retention of URLs (signed params are the download
  source and cannot be redacted without breaking resume/retry).
This commit is contained in:
NimBold
2026-06-18 08:20:22 +03:30
parent e2dd387a8c
commit 3a76c6f5d7
7 changed files with 105 additions and 13 deletions
+37 -2
View File
@@ -44,6 +44,15 @@ const tauriStorage: StateStorage = {
},
};
/**
* Keychain identifier for the browser-extension pairing token. The token is an
* HMAC shared secret and is therefore persisted via the OS keychain rather
* than the plaintext `store.bin` settings document. A fresh token is minted
* when no prior entry exists (also covering upgrades from versions that
* stored the token as plaintext, effectively rotating it on upgrade).
*/
const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token';
export type {
ActiveView,
AppFontSize,
@@ -126,6 +135,7 @@ export interface SettingsState {
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => void;
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
hydratePairingToken: () => Promise<void>;
}
const defaultDirectories = {
@@ -287,7 +297,33 @@ export const useSettingsStore = create<SettingsState>()(
removeSiteLogin: (id) => set((state) => ({
siteLogins: state.siteLogins.filter((login) => login.id !== id)
})),
regeneratePairingToken: () => set({ extensionPairingToken: generateSecureToken() }),
regeneratePairingToken: () => {
const token = generateSecureToken();
set({ extensionPairingToken: token });
invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token }).catch(e => {
console.error('Failed to persist regenerated extension pairing token to keychain:', e);
});
},
hydratePairingToken: async () => {
const existing = useSettingsStore.getState().extensionPairingToken;
try {
const stored = await invoke('get_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID });
if (stored) {
set({ extensionPairingToken: stored });
return;
}
} catch {
// No prior token in the keychain (fresh install or upgrade from a
// version that stored plaintext). Fall through to mint + store.
}
const token = existing || generateSecureToken();
set({ extensionPairingToken: token });
try {
await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token });
} catch (e) {
console.error('Failed to persist extension pairing token to keychain:', e);
}
},
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
}),
{
@@ -322,7 +358,6 @@ export const useSettingsStore = create<SettingsState>()(
mediaCookieSource: state.mediaCookieSource,
downloadDirectories: state.downloadDirectories,
siteLogins: state.siteLogins,
extensionPairingToken: state.extensionPairingToken,
autoCheckUpdates: state.autoCheckUpdates
}),
merge: (persistedState: unknown, currentState) => {