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 { 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<SidebarFilter>('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() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
<KeychainPermissionModal appVersion={appVersion} />
<KeychainPermissionModal consentVersion={keychainConsentVersion} />
</div>
);
+7 -6
View File
@@ -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<KeychainPermissionModalProps> = ({ appVersion }) => {
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ 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<KeychainPermissionModalProps> = (
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<KeychainPermissionModalProps> = (
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<KeychainPermissionModalProps> = (
};
const handleLater = () => {
dismissKeychainPrompt(appVersion);
dismissKeychainPrompt(consentVersion);
};
return (
+19 -1
View File
@@ -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,
+13
View File
@@ -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,