mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 18:03:23 +00:00
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:
@@ -11,7 +11,7 @@ import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import type { MediaMetadata } from '../bindings/MediaMetadata';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||
import { isActiveDownloadStatus, redactDownloadForPersistence } from '../utils/downloads';
|
||||
|
||||
export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
@@ -628,13 +628,10 @@ useDownloadStore.subscribe(async (state, prevState) => {
|
||||
}
|
||||
|
||||
if (state.downloads !== prevState.downloads) {
|
||||
const staticDownloads = state.downloads.map(d => {
|
||||
const copy = { ...d };
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
return copy;
|
||||
});
|
||||
// Strip secret fields (password/cookies/headers) and volatile progress
|
||||
// before writing to disk. Secrets remain on the in-memory item for the
|
||||
// active session only.
|
||||
const staticDownloads = state.downloads.map(redactDownloadForPersistence);
|
||||
|
||||
const currentSerialized = JSON.stringify(staticDownloads);
|
||||
if (currentSerialized !== lastSavedDownloads) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user