From ed54a048abf57d9e40685ca6dacc81c9fa212fb4 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 21:21:35 +0330 Subject: [PATCH] fix(keychain): version startup consent policy --- src/App.tsx | 11 ++++++----- src/components/KeychainPermissionModal.tsx | 13 +++++++------ src/utils/keychainStartup.test.ts | 20 +++++++++++++++++++- src/utils/keychainStartup.ts | 13 +++++++++++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 29e7fed..b134d44 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; import { openUrl } from '@tauri-apps/plugin-opener'; import { getPlatformInfo, usePlatformInfo } from './utils/platform'; -import { getKeychainStartupDecision } from './utils/keychainStartup'; +import { getKeychainConsentVersion, getKeychainStartupDecision } from './utils/keychainStartup'; import { getVersion } from '@tauri-apps/api/app'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; @@ -97,7 +97,7 @@ function App() { const platform = usePlatformInfo(); const [filter, setFilter] = useState('all'); const [coreReady, setCoreReady] = useState(false); - const [appVersion, setAppVersion] = useState(''); + const [keychainConsentVersion, setKeychainConsentVersion] = useState(''); const [sidebarWidth, setSidebarWidth] = useState(() => { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); @@ -338,13 +338,14 @@ function App() { getPlatformInfo().catch(() => null) ]); if (!active) return; - setAppVersion(currentAppVersion); + const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion); + setKeychainConsentVersion(currentKeychainConsentVersion); try { const settings = useSettingsStore.getState(); const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({ portable: currentPlatform?.portable === true, - appVersion: currentAppVersion, + appVersion: currentKeychainConsentVersion, approvedVersion: settings.keychainAccessVersion, accessGranted: settings.keychainAccessGranted, promptDismissed: settings.keychainPromptDismissed @@ -876,7 +877,7 @@ function App() { - + ); diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx index 92a59c4..4be94cd 100644 --- a/src/components/KeychainPermissionModal.tsx +++ b/src/components/KeychainPermissionModal.tsx @@ -3,13 +3,14 @@ import { useSettingsStore } from '../store/useSettingsStore'; import { invokeCommand as invoke } from '../ipc'; import { KeyRound, ShieldAlert } from 'lucide-react'; import { usePlatformInfo } from '../utils/platform'; +import { getKeychainConsentVersion } from '../utils/keychainStartup'; import { getVersion } from '@tauri-apps/api/app'; type KeychainPermissionModalProps = { - appVersion: string; + consentVersion: string; }; -export const KeychainPermissionModal: React.FC = ({ appVersion }) => { +export const KeychainPermissionModal: React.FC = ({ consentVersion }) => { const showKeychainModal = useSettingsStore(state => state.showKeychainModal); const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt); const platform = usePlatformInfo(); @@ -19,11 +20,11 @@ export const KeychainPermissionModal: React.FC = ( useEffect(() => { if (!showKeychainModal || isGranting) return; const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') dismissKeychainPrompt(appVersion); + if (event.key === 'Escape') dismissKeychainPrompt(consentVersion); }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); - }, [appVersion, dismissKeychainPrompt, isGranting, showKeychainModal]); + }, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]); if (!showKeychainModal) { return null; @@ -56,7 +57,7 @@ export const KeychainPermissionModal: React.FC = ( try { const result = await invoke('grant_keychain_access'); if (result.persistent) { - const grantedVersion = appVersion || await getVersion().catch(() => ''); + const grantedVersion = consentVersion || getKeychainConsentVersion(await getVersion().catch(() => '')); // Keep state in sync with the grant result instead of rehydrating // before Zustand has persisted keychainAccessGranted. useSettingsStore.setState({ @@ -79,7 +80,7 @@ export const KeychainPermissionModal: React.FC = ( }; const handleLater = () => { - dismissKeychainPrompt(appVersion); + dismissKeychainPrompt(consentVersion); }; return ( diff --git a/src/utils/keychainStartup.test.ts b/src/utils/keychainStartup.test.ts index 64fda54..a5df1d8 100644 --- a/src/utils/keychainStartup.test.ts +++ b/src/utils/keychainStartup.test.ts @@ -1,7 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { getKeychainStartupDecision } from './keychainStartup'; +import { getKeychainConsentVersion, getKeychainStartupDecision } from './keychainStartup'; describe('getKeychainStartupDecision', () => { + it('changes the consent identity when the credential-access policy changes', () => { + expect(getKeychainConsentVersion('1.1.0')).toBe('1.1.0|keychain-policy-2'); + expect(getKeychainConsentVersion('')).toBe(''); + }); + + it('re-prompts when the app build keeps the same semantic version', () => { + expect(getKeychainStartupDecision({ + portable: false, + appVersion: getKeychainConsentVersion('1.1.0'), + approvedVersion: '1.1.0', + accessGranted: true, + promptDismissed: true + })).toEqual({ + deferKeychainHydration: true, + showKeychainPrompt: true + }); + }); + it('defers persistent hydration and shows the explanation after an update', () => { expect(getKeychainStartupDecision({ portable: false, diff --git a/src/utils/keychainStartup.ts b/src/utils/keychainStartup.ts index 01ab716..117c5ce 100644 --- a/src/utils/keychainStartup.ts +++ b/src/utils/keychainStartup.ts @@ -11,6 +11,19 @@ export type KeychainStartupDecision = { showKeychainPrompt: boolean; }; +// The semantic app version can remain unchanged across release-candidate and +// packaging rebuilds. Bump this contract version whenever a build can require +// a fresh credential-store authorization, so an updated binary cannot skip +// Firelink's explanation and invoke the OS prompt directly. +const KEYCHAIN_CONSENT_POLICY_VERSION = '2'; + +export const getKeychainConsentVersion = (appVersion: string): string => { + const normalizedVersion = appVersion.trim(); + return normalizedVersion + ? `${normalizedVersion}|keychain-policy-${KEYCHAIN_CONSENT_POLICY_VERSION}` + : ''; +}; + export const getKeychainStartupDecision = ({ portable, appVersion,