fix(keychain): version startup consent policy

This commit is contained in:
NimBold
2026-07-15 21:21:35 +03:30
parent d2479f52be
commit ed54a048ab
4 changed files with 45 additions and 12 deletions
+6 -5
View File
@@ -23,7 +23,7 @@ import { useToast } from "./contexts/ToastContext";
import { setLogStreamActive } from './utils/logger'; import { setLogStreamActive } from './utils/logger';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, usePlatformInfo } from './utils/platform'; 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 { getVersion } from '@tauri-apps/api/app';
import type { PostQueueAction } from './bindings/PostQueueAction'; import type { PostQueueAction } from './bindings/PostQueueAction';
import { PanelLeft } from 'lucide-react'; import { PanelLeft } from 'lucide-react';
@@ -97,7 +97,7 @@ function App() {
const platform = usePlatformInfo(); const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all'); const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false); const [coreReady, setCoreReady] = useState(false);
const [appVersion, setAppVersion] = useState(''); const [keychainConsentVersion, setKeychainConsentVersion] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
@@ -338,13 +338,14 @@ function App() {
getPlatformInfo().catch(() => null) getPlatformInfo().catch(() => null)
]); ]);
if (!active) return; if (!active) return;
setAppVersion(currentAppVersion); const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
setKeychainConsentVersion(currentKeychainConsentVersion);
try { try {
const settings = useSettingsStore.getState(); const settings = useSettingsStore.getState();
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({ const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true, portable: currentPlatform?.portable === true,
appVersion: currentAppVersion, appVersion: currentKeychainConsentVersion,
approvedVersion: settings.keychainAccessVersion, approvedVersion: settings.keychainAccessVersion,
accessGranted: settings.keychainAccessGranted, accessGranted: settings.keychainAccessGranted,
promptDismissed: settings.keychainPromptDismissed promptDismissed: settings.keychainPromptDismissed
@@ -876,7 +877,7 @@ function App() {
<AddDownloadsModal /> <AddDownloadsModal />
<PropertiesModal /> <PropertiesModal />
<DeleteConfirmationModal /> <DeleteConfirmationModal />
<KeychainPermissionModal appVersion={appVersion} /> <KeychainPermissionModal consentVersion={keychainConsentVersion} />
</div> </div>
); );
+7 -6
View File
@@ -3,13 +3,14 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { invokeCommand as invoke } from '../ipc'; import { invokeCommand as invoke } from '../ipc';
import { KeyRound, ShieldAlert } from 'lucide-react'; import { KeyRound, ShieldAlert } from 'lucide-react';
import { usePlatformInfo } from '../utils/platform'; import { usePlatformInfo } from '../utils/platform';
import { getKeychainConsentVersion } from '../utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
type KeychainPermissionModalProps = { type KeychainPermissionModalProps = {
appVersion: string; consentVersion: string;
}; };
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ appVersion }) => { export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ consentVersion }) => {
const showKeychainModal = useSettingsStore(state => state.showKeychainModal); const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt); const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
const platform = usePlatformInfo(); const platform = usePlatformInfo();
@@ -19,11 +20,11 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
useEffect(() => { useEffect(() => {
if (!showKeychainModal || isGranting) return; if (!showKeychainModal || isGranting) return;
const handleEscape = (event: KeyboardEvent) => { const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissKeychainPrompt(appVersion); if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
}; };
window.addEventListener('keydown', handleEscape); window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape);
}, [appVersion, dismissKeychainPrompt, isGranting, showKeychainModal]); }, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
if (!showKeychainModal) { if (!showKeychainModal) {
return null; return null;
@@ -56,7 +57,7 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
try { try {
const result = await invoke('grant_keychain_access'); const result = await invoke('grant_keychain_access');
if (result.persistent) { 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 // Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted. // before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({ useSettingsStore.setState({
@@ -79,7 +80,7 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
}; };
const handleLater = () => { const handleLater = () => {
dismissKeychainPrompt(appVersion); dismissKeychainPrompt(consentVersion);
}; };
return ( return (
+19 -1
View File
@@ -1,7 +1,25 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { getKeychainStartupDecision } from './keychainStartup'; import { getKeychainConsentVersion, getKeychainStartupDecision } from './keychainStartup';
describe('getKeychainStartupDecision', () => { 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', () => { it('defers persistent hydration and shows the explanation after an update', () => {
expect(getKeychainStartupDecision({ expect(getKeychainStartupDecision({
portable: false, portable: false,
+13
View File
@@ -11,6 +11,19 @@ export type KeychainStartupDecision = {
showKeychainPrompt: boolean; 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 = ({ export const getKeychainStartupDecision = ({
portable, portable,
appVersion, appVersion,