Skip to content

Commit c35c0c6

Browse files
OtavioStasiakclaude
andcommitted
feat(biometric-trust): silent-bind migration for existing biometry users on upgrade
Existing installs that had biometry enabled before the trust store existed have BIOMETRY_ENABLED_KEY=true but no keychain sentinel, which would force them through the passcode-only modal on first launch after upgrade. Run a one-shot migration on app init that grandfathers them with a silent enrol(). The marker BIOMETRIC_TRUST_MIGRATION_V1_DONE makes this idempotent and lets the helper distinguish two superficially identical states: !migrated && flag && !sentinel → silent enrol(), set marker. upgrade path migrated && flag && !sentinel → clear flag, no enrol(). reconciliation Without the marker, post-invalidation state (flag=true && !sentinel after a crash between disenrol() and the flag-clear in the enrollmentChanged handler) would silently re-bind and undo the enrollment-change protection. With the marker, that state instead clears the flag — the user re-enables biometry from Settings, which runs a fresh enrol() that observes the new enrolment set. enrol() failure leaves the marker unset so the next boot retries, and leaves the flag alone so the next unlock falls into the unavailable branch and asks for the passcode. probeExists() rejection is swallowed and logged. The trade-off (silent bind vs. theoretical pre-fix compromise) follows the product decision in DECISIONS.md / ADR 0006. Part of VLN-216. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d384c0c commit c35c0c6

4 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import UserPreferences from '../methods/userPreferences';
2+
import log from '../methods/helpers/log';
3+
import { BIOMETRIC_TRUST_MIGRATION_V1_DONE, BIOMETRY_ENABLED_KEY } from '../constants/localAuthentication';
4+
import { biometricTrustStore } from './index';
5+
import { runBiometricTrustMigration } from './migration';
6+
7+
jest.mock('../methods/userPreferences', () => ({
8+
__esModule: true,
9+
default: {
10+
getBool: jest.fn(),
11+
setBool: jest.fn(),
12+
getString: jest.fn(),
13+
setString: jest.fn()
14+
}
15+
}));
16+
17+
jest.mock('../methods/helpers/log', () => ({ __esModule: true, default: jest.fn() }));
18+
19+
jest.mock('./index', () => ({
20+
biometricTrustStore: {
21+
enrol: jest.fn(),
22+
disenrol: jest.fn(),
23+
verify: jest.fn(),
24+
probeExists: jest.fn()
25+
}
26+
}));
27+
28+
const mockedGetBool = UserPreferences.getBool as jest.Mock;
29+
const mockedSetBool = UserPreferences.setBool as jest.Mock;
30+
const mockedEnrol = biometricTrustStore.enrol as jest.Mock;
31+
const mockedProbeExists = biometricTrustStore.probeExists as jest.Mock;
32+
const mockedLog = log as unknown as jest.Mock;
33+
34+
// Drives the mocked UserPreferences with the values the migration needs to see for the
35+
// branch under test, so each test reads like a state machine input row.
36+
const setPrefs = ({ biometryEnabled, migrated }: { biometryEnabled: boolean; migrated: boolean }) => {
37+
mockedGetBool.mockImplementation((key: string) => {
38+
if (key === BIOMETRY_ENABLED_KEY) return biometryEnabled;
39+
if (key === BIOMETRIC_TRUST_MIGRATION_V1_DONE) return migrated;
40+
return undefined;
41+
});
42+
};
43+
44+
describe('runBiometricTrustMigration', () => {
45+
beforeEach(() => {
46+
jest.clearAllMocks();
47+
});
48+
49+
it('upgrade path: !migrated && flag && !sentinel → enrol() once and mark migrated', async () => {
50+
setPrefs({ biometryEnabled: true, migrated: false });
51+
mockedProbeExists.mockResolvedValueOnce(false);
52+
mockedEnrol.mockResolvedValueOnce({ kind: 'success' });
53+
54+
await runBiometricTrustMigration();
55+
56+
expect(mockedEnrol).toHaveBeenCalledTimes(1);
57+
expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true);
58+
expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRY_ENABLED_KEY, false);
59+
});
60+
61+
it('reconciliation path: migrated && flag && !sentinel → clear flag, no enrol()', async () => {
62+
setPrefs({ biometryEnabled: true, migrated: true });
63+
mockedProbeExists.mockResolvedValueOnce(false);
64+
65+
await runBiometricTrustMigration();
66+
67+
expect(mockedEnrol).not.toHaveBeenCalled();
68+
expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRY_ENABLED_KEY, false);
69+
expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, expect.anything());
70+
});
71+
72+
it('flag=false → no-op (no probe, no enrol, no setBool)', async () => {
73+
setPrefs({ biometryEnabled: false, migrated: false });
74+
75+
await runBiometricTrustMigration();
76+
77+
expect(mockedProbeExists).not.toHaveBeenCalled();
78+
expect(mockedEnrol).not.toHaveBeenCalled();
79+
expect(mockedSetBool).not.toHaveBeenCalled();
80+
});
81+
82+
it('flag=true && sentinel exists → no-op (no enrol, no flag clear)', async () => {
83+
setPrefs({ biometryEnabled: true, migrated: false });
84+
mockedProbeExists.mockResolvedValueOnce(true);
85+
86+
await runBiometricTrustMigration();
87+
88+
expect(mockedEnrol).not.toHaveBeenCalled();
89+
expect(mockedSetBool).not.toHaveBeenCalled();
90+
});
91+
92+
it('idempotent: after successful migration, second run is a no-op', async () => {
93+
// first run: upgrade path
94+
setPrefs({ biometryEnabled: true, migrated: false });
95+
mockedProbeExists.mockResolvedValueOnce(false);
96+
mockedEnrol.mockResolvedValueOnce({ kind: 'success' });
97+
await runBiometricTrustMigration();
98+
expect(mockedEnrol).toHaveBeenCalledTimes(1);
99+
100+
// second run: sentinel now exists AND marker is set
101+
jest.clearAllMocks();
102+
setPrefs({ biometryEnabled: true, migrated: true });
103+
mockedProbeExists.mockResolvedValueOnce(true);
104+
105+
await runBiometricTrustMigration();
106+
107+
expect(mockedEnrol).not.toHaveBeenCalled();
108+
expect(mockedSetBool).not.toHaveBeenCalled();
109+
});
110+
111+
it('enrol() error → logged, flag untouched, marker NOT set so next boot retries', async () => {
112+
setPrefs({ biometryEnabled: true, migrated: false });
113+
mockedProbeExists.mockResolvedValueOnce(false);
114+
const cause = new Error('keychain unavailable');
115+
mockedEnrol.mockResolvedValueOnce({ kind: 'error', cause });
116+
117+
await runBiometricTrustMigration();
118+
119+
expect(mockedLog).toHaveBeenCalledWith(cause);
120+
expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRIC_TRUST_MIGRATION_V1_DONE, expect.anything());
121+
expect(mockedSetBool).not.toHaveBeenCalledWith(BIOMETRY_ENABLED_KEY, false);
122+
});
123+
124+
it('probeExists throws → swallowed, logged, no enrol(), no flag mutation', async () => {
125+
setPrefs({ biometryEnabled: true, migrated: false });
126+
const boom = new Error('probe failed');
127+
mockedProbeExists.mockRejectedValueOnce(boom);
128+
129+
await runBiometricTrustMigration();
130+
131+
expect(mockedLog).toHaveBeenCalledWith(boom);
132+
expect(mockedEnrol).not.toHaveBeenCalled();
133+
expect(mockedSetBool).not.toHaveBeenCalled();
134+
});
135+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import UserPreferences from '../methods/userPreferences';
2+
import log from '../methods/helpers/log';
3+
import { BIOMETRIC_TRUST_MIGRATION_V1_DONE, BIOMETRY_ENABLED_KEY } from '../constants/localAuthentication';
4+
import { biometricTrustStore } from './index';
5+
6+
// One-shot upgrade migration for users who had biometry enabled before the trust-store sentinel
7+
// existed. Runs at app init.
8+
//
9+
// State machine:
10+
// !migrated && flag && !sentinel → silent enrol(), mark migrated. (grandfather upgrade path)
11+
// migrated && flag && !sentinel → clear flag, do NOT enrol(). (reconciliation, e.g. crash
12+
// between disenrol() and the
13+
// flag-clear during slice 02
14+
// invalidation)
15+
// flag && sentinel → no-op.
16+
// !flag → no-op.
17+
//
18+
// On enrol() failure the marker is intentionally left unset so the next boot retries; the flag is
19+
// left as-is so the next unlock falls into the `unavailable` branch and asks for the passcode.
20+
export const runBiometricTrustMigration = async (): Promise<void> => {
21+
try {
22+
const biometryEnabled = UserPreferences.getBool(BIOMETRY_ENABLED_KEY) ?? false;
23+
if (!biometryEnabled) {
24+
return;
25+
}
26+
27+
const sentinelExists = await biometricTrustStore.probeExists();
28+
if (sentinelExists) {
29+
return;
30+
}
31+
32+
const migrated = UserPreferences.getBool(BIOMETRIC_TRUST_MIGRATION_V1_DONE) ?? false;
33+
34+
if (!migrated) {
35+
const result = await biometricTrustStore.enrol();
36+
if (result.kind === 'success') {
37+
UserPreferences.setBool(BIOMETRIC_TRUST_MIGRATION_V1_DONE, true);
38+
} else if (result.kind === 'error') {
39+
log(result.cause);
40+
}
41+
return;
42+
}
43+
44+
UserPreferences.setBool(BIOMETRY_ENABLED_KEY, false);
45+
} catch (e) {
46+
log(e);
47+
}
48+
};

app/lib/constants/localAuthentication.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export const PASSCODE_KEY = 'kPasscode';
22
export const LOCKED_OUT_TIMER_KEY = 'kLockedOutTimer';
33
export const ATTEMPTS_KEY = 'kAttempts';
44
export const BIOMETRY_ENABLED_KEY = 'kBiometryEnabled';
5+
export const BIOMETRIC_TRUST_MIGRATION_V1_DONE = 'kBiometricTrustMigrationV1Done';
56

67
export const LOCAL_AUTHENTICATE_EMITTER = 'LOCAL_AUTHENTICATE';
78
export const CHANGE_PASSCODE_EMITTER = 'CHANGE_PASSCODE';

app/sagas/init.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { APP } from '../actions/actionsTypes';
1010
import log from '../lib/methods/helpers/log';
1111
import database from '../lib/database';
1212
import { localAuthenticate } from '../lib/methods/helpers/localAuthentication';
13+
import { runBiometricTrustMigration } from '../lib/biometricTrustStore/migration';
1314
import { appReady, appStart } from '../actions/app';
1415
import { RootEnum } from '../definitions';
1516
import { getSortPreferences } from '../lib/methods/userPreferencesMethods';
@@ -23,6 +24,7 @@ export const initLocalSettings = function* initLocalSettings() {
2324

2425
const restore = function* restore() {
2526
try {
27+
yield call(runBiometricTrustMigration);
2628
const server = UserPreferences.getString(CURRENT_SERVER);
2729
let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
2830

0 commit comments

Comments
 (0)