Skip to content

Commit eae86c9

Browse files
TheStreamCodeMichael Gasperini
andauthored
fix(security): serialise vault writes and make KDF upgrades crash-safe (#54)
KS-2: route every whole-collection read-modify-write in StorageService through a per-storage-key FIFO mutex, acquiring multiple keys in a fixed order. Two interleaved mutations (import while a save is in flight, autosave plus manual save, a save during a rekey) can no longer overwrite each other from the same in-memory snapshot. A queued mutation rebuilds from the committed cache and a re-encryption excludes concurrent writes on the collections it rewrites. KS-1: record the new KDF metadata as a pending recovery record before re-encrypting the vault and commit the active metadata with a single SecureStore write. The next master-password login derives both candidates, inspects which key decrypts the stored collections, and either promotes the pending metadata or discards the record and keeps the previous password working. A process kill between the vault write and the metadata commit no longer leaves ciphertext whose salt was never stored. Biometric unlock defers to the master password while a recovery record exists, and the login messages state whether the new or the previous password is required. Crypto parameters and error checks are unchanged. Tests: interleaved saves with a delayed AsyncStorage mock; simulated crash between the vault write and the metadata commit for both the legacy KDF upgrade and the master-password change, covering the new-password, old-then-new password and pre-vault-write rollback states. Docs and CHANGELOG updated. Co-authored-by: Michael Gasperini <michael@mikesoft.it>
1 parent 02a74ae commit eae86c9

12 files changed

Lines changed: 1410 additions & 232 deletions

‎CHANGELOG.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,22 @@ All notable project changes are documented here.
44

55
## [Unreleased]
66

7-
No unreleased changes.
7+
### Security
8+
9+
- Made transparent KDF upgrades and master-password changes crash-safe. The vault is
10+
re-encrypted only after a pending recovery record describing the new KDF metadata is
11+
persisted alongside the still-active previous metadata, and the new metadata commit is a
12+
single SecureStore write. A process kill between the vault write and the metadata commit
13+
no longer leaves ciphertext encrypted under a salt that was never stored: the next
14+
password login inspects the vault records and either promotes the pending metadata or
15+
discards it, keeping the previous password usable when the vault was not re-encrypted
16+
yet. Biometric unlock defers to the master password while a recovery record exists, and
17+
the login screen reports whether the new or the previous password is required.
18+
- Serialised whole-vault writes with a per-storage-key async mutex. Two mutations that
19+
start from the same in-memory snapshot (for example an import while a save is in flight,
20+
or autosave plus a manual save) can no longer overwrite each other; a queued mutation
21+
always rebuilds from the committed cache and re-encryptions run before any concurrent
22+
write on the same collection.
823

924
## [3.3.1] - 2026-08-09
1025

‎docs/architecture.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,16 @@ A top-level error boundary (`src/components/ErrorBoundary.tsx`) wraps the naviga
109109
3. Biometric login asks SecureStore for the stored biometric vault key with device authentication, verifies it against the same master-key verifier, and then loads it into memory.
110110
4. `StorageService` initializes the encrypted vault cache with the active key.
111111
5. `AuthContext` completes the shared post-login pipeline: authentication state, login notification, post-auth migration, notification settings, clipboard timeout, and auto-lock timeout.
112-
6. PIN setup, PIN rotation, and transparent KDF upgrades treat ciphertext and verifier
113-
metadata as one authenticated transaction. If rollback also fails, the session and
114-
in-memory key are cleared instead of continuing against uncertain storage.
115-
7. Password and note writes are serialized, encrypted with KS1, persisted to AsyncStorage,
116-
and only then committed to the decrypted in-memory cache.
112+
6. PIN setup, PIN rotation, and transparent KDF upgrades write a pending recovery record
113+
before touching the vault, re-encrypt the vault in one batched write, and commit the new
114+
verifier metadata with a single SecureStore write. A process kill between the vault write
115+
and the metadata commit is reconciled on the next master-password login; biometric unlock
116+
waits for that login. If rollback also fails, the session and in-memory key are cleared
117+
instead of continuing against uncertain storage, and the recovery record is kept so the
118+
next login can complete the promotion.
119+
7. Whole-collection writes are queued per storage key, encrypted with KS1, persisted to
120+
AsyncStorage, and only then committed to the decrypted in-memory cache, so two concurrent
121+
mutations cannot overwrite each other from the same snapshot.
117122
8. Logout and auto-lock clear authentication state and the in-memory key.
118123

119124
## Storage Boundaries

‎docs/security.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,18 @@ Derived keys are normalized to 64-character lowercase hex strings. Invalid deriv
4242

4343
### Transparent KDF upgrade on login
4444

45-
Vaults created with legacy parameters — either PBKDF2 (`memory = 0`) or the old heavy Argon2id (`64 MiB / t = 3`) — are transparently upgraded to the current Argon2id parameters on the next successful password login, while the vault is decrypted in memory. The upgrade re-derives a new key (new salt and verifier), precomputes both encrypted collections, persists them in one batched storage call, and uses explicit rollback if the following verifier update fails. Recoverable failures restore the previous working key and retry on the next login. If both verifier persistence and rollback fail, Keysoft closes the authenticated session and clears the in-memory key rather than allowing further writes against uncertain storage. Biometric logins do not trigger the upgrade (no password is available); the next password login performs it.
45+
Vaults created with legacy parameters — either PBKDF2 (`memory = 0`) or the old heavy Argon2id (`64 MiB / t = 3`) — are transparently upgraded to the current Argon2id parameters on the next successful password login, while the vault is decrypted in memory. The same crash-safe sequence is used when the master password changes:
46+
47+
1. A pending recovery record containing the new KDF metadata (salt, iterations, memory, verifier) is written to SecureStore while the active metadata still describes the previous key.
48+
2. Both encrypted collections are re-encrypted and persisted in one batched AsyncStorage write.
49+
3. The new metadata is committed with a single SecureStore write, which is the atomic switch point.
50+
4. The recovery record is removed.
51+
52+
A process kill between the vault write and the metadata commit would previously leave ciphertext encrypted with a key whose salt was never stored — permanent vault loss. With the recovery record, the next master-password login derives both candidates, inspects which key decrypts the stored collections, and either promotes the pending metadata (vault already re-encrypted) or discards the record and keeps the previous password working (vault not re-encrypted yet). If the password only matches metadata whose key no longer decrypts the vault, login fails with a diagnostic naming the password that is required and keeps the recovery record untouched. A crash after the metadata commit leaves only a stale record, which the next login removes. Biometric unlock cannot derive the pending key: it refuses to run while a record exists and does not disable biometrics, and the following password login refreshes the biometric key best-effort.
53+
54+
Recoverable failures during the regular flow restore the previous working key and retry on a later login. If both the metadata commit and the rollback fail, Keysoft closes the authenticated session and clears the in-memory key rather than allowing further writes against uncertain storage; the recovery record is kept so the next password login can complete the promotion instead of losing the vault. The recovery record holds no key material, only the same class of KDF metadata already stored in SecureStore.
55+
56+
This safety relies on two platform guarantees: a single SecureStore key write is atomic, and `AsyncStorage.multiSet` persists both collections in one transaction on Android (the supported release platform). The residual risk is the same as any password change: once the vault has been re-encrypted, only the new password can open it, so a user who forgets the new password after a crash cannot fall back to the previous one.
4657

4758
When creating or changing the PIN, the verifier metadata and the vault key are produced from the same KDF result. This avoids a duplicate KDF pass for the same new PIN while preserving the configured KDF cost and the 64-character derived-key requirement.
4859

@@ -71,6 +82,8 @@ Vault data uses the KS1 format:
7182

7283
Decryption verifies the MAC before trusting plaintext output.
7384

85+
Each collection is stored as one whole encrypted record. Mutations rebuild that record from the decrypted in-memory cache, so writes are queued per storage key: two concurrent mutations cannot both start from the same snapshot and silently drop each other's record, and a re-encryption of the whole vault excludes concurrent saves on the collections it rewrites.
86+
7487
### Key separation
7588

7689
KS1 derives a single 256-bit key and uses that same value both as the AES-256-CBC

‎src/__tests__/services/AuthService.test.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ describe('AuthService', () => {
6060

6161
(StorageService.saveMasterKeyInfo as jest.Mock).mockResolvedValue(undefined);
6262
(StorageService.deleteMasterKeyInfo as jest.Mock).mockResolvedValue(undefined);
63+
(StorageService.savePendingMasterKeyMigration as jest.Mock).mockResolvedValue(undefined);
64+
(StorageService.getPendingMasterKeyMigration as jest.Mock).mockResolvedValue(null);
65+
(StorageService.deletePendingMasterKeyMigration as jest.Mock).mockResolvedValue(undefined);
66+
(StorageService.canDecryptVaultWithKey as jest.Mock).mockResolvedValue(true);
6367
(StorageService.setEncryptionKey as jest.Mock).mockImplementation(() => {});
6468
(StorageService.initDatabase as jest.Mock).mockResolvedValue(undefined);
6569
(StorageService.getMasterKeyInfo as jest.Mock).mockResolvedValue(mockMasterKeyInfo);
@@ -589,6 +593,115 @@ describe('AuthService', () => {
589593
});
590594
});
591595

596+
describe('Crash-safe KDF transaction', () => {
597+
const newInfo: UserMasterKey = {
598+
salt: 'new-salt',
599+
iterations: 2,
600+
memory: 19456,
601+
verifier: 'new-verifier',
602+
};
603+
const newKey = 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899';
604+
605+
beforeEach(() => {
606+
(CryptoService.createMasterKeyInfoWithDerivedKey as jest.Mock).mockResolvedValue({
607+
masterKeyInfo: newInfo,
608+
derivedKey: newKey,
609+
});
610+
(StorageService.getMasterKeyInfo as jest.Mock).mockResolvedValue(mockMasterKeyInfo);
611+
(StorageService.getEncryptionKey as jest.Mock).mockReturnValue(mockEncryptionKey);
612+
});
613+
614+
it('writes the recovery record before the vault and commits metadata last', async () => {
615+
(CryptoService.isNativeKdfAvailable as jest.Mock).mockReturnValue(true);
616+
(CryptoService.isLegacyKdf as jest.Mock).mockReturnValue(true);
617+
618+
const callOrder: string[] = [];
619+
(StorageService.savePendingMasterKeyMigration as jest.Mock).mockImplementation(async () => {
620+
callOrder.push('write-recovery-record');
621+
});
622+
(StorageService.reEncryptAllData as jest.Mock).mockImplementation(async () => {
623+
callOrder.push('vault-write');
624+
});
625+
(StorageService.saveMasterKeyInfo as jest.Mock).mockImplementation(async () => {
626+
callOrder.push('metadata-commit');
627+
});
628+
(StorageService.deletePendingMasterKeyMigration as jest.Mock).mockImplementation(async () => {
629+
callOrder.push('clear-recovery-record');
630+
});
631+
632+
await expect(AuthService.loginWithMasterPassword(mockMasterPassword)).resolves.toBe(true);
633+
634+
expect(callOrder).toEqual([
635+
'write-recovery-record',
636+
'vault-write',
637+
'metadata-commit',
638+
'clear-recovery-record',
639+
]);
640+
expect(StorageService.savePendingMasterKeyMigration).toHaveBeenCalledWith(
641+
expect.objectContaining({ masterKeyInfo: newInfo }),
642+
);
643+
});
644+
645+
it('keeps the recovery record when the metadata commit and the rollback both fail', async () => {
646+
(CryptoService.isNativeKdfAvailable as jest.Mock).mockReturnValue(true);
647+
(CryptoService.isLegacyKdf as jest.Mock).mockReturnValue(true);
648+
(StorageService.reEncryptAllData as jest.Mock)
649+
.mockResolvedValueOnce(undefined)
650+
.mockRejectedValueOnce(new Error('rollback failed'));
651+
(StorageService.saveMasterKeyInfo as jest.Mock).mockRejectedValueOnce(
652+
new Error('SecureStore unavailable'),
653+
);
654+
655+
await expect(AuthService.loginWithMasterPassword(mockMasterPassword)).resolves.toBe(false);
656+
657+
expect(StorageService.savePendingMasterKeyMigration).toHaveBeenCalled();
658+
expect(StorageService.deletePendingMasterKeyMigration).not.toHaveBeenCalled();
659+
expect(AuthService.getLastAuthFailure()).toEqual(
660+
expect.objectContaining({ reason: 'kdf_upgrade_rollback_failed' }),
661+
);
662+
});
663+
664+
it('writes the recovery record when rotating the master password', async () => {
665+
await AuthService.loginWithMasterPassword(mockMasterPassword);
666+
(CryptoService.isLegacyKdf as jest.Mock).mockReturnValue(false);
667+
668+
await expect(AuthService.updateMasterPassword('new-password')).resolves.toBe(true);
669+
670+
expect(StorageService.savePendingMasterKeyMigration).toHaveBeenCalledWith(
671+
expect.objectContaining({ masterKeyInfo: newInfo }),
672+
);
673+
const pendingWriteOrder = (StorageService.savePendingMasterKeyMigration as jest.Mock).mock
674+
.invocationCallOrder[0];
675+
const vaultWriteOrder = (StorageService.reEncryptAllData as jest.Mock).mock
676+
.invocationCallOrder[0];
677+
const metadataOrder = (StorageService.saveMasterKeyInfo as jest.Mock).mock
678+
.invocationCallOrder[0];
679+
const pendingClearOrders = (StorageService.deletePendingMasterKeyMigration as jest.Mock).mock
680+
.invocationCallOrder;
681+
expect(pendingWriteOrder).toBeLessThan(vaultWriteOrder);
682+
expect(vaultWriteOrder).toBeLessThan(metadataOrder);
683+
expect(Math.max(...pendingClearOrders)).toBeGreaterThan(metadataOrder);
684+
});
685+
686+
it('refuses biometric login while a KDF recovery record is pending', async () => {
687+
(StorageService.getPendingMasterKeyMigration as jest.Mock).mockResolvedValue({
688+
masterKeyInfo: newInfo,
689+
startedAt: 1,
690+
});
691+
(StorageService.getUserPreferences as jest.Mock).mockResolvedValue({
692+
biometricsEnabled: true,
693+
});
694+
695+
await expect(AuthService.loginWithBiometrics()).resolves.toBe(false);
696+
697+
expect(StorageService.getBiometricKey).not.toHaveBeenCalled();
698+
expect(StorageService.deleteBiometricKey).not.toHaveBeenCalled();
699+
expect(AuthService.getLastAuthFailure()).toEqual(
700+
expect.objectContaining({ reason: 'kdf_recovery_pending' }),
701+
);
702+
});
703+
});
704+
592705
describe('verifyMasterPassword', () => {
593706
it('should return true for correct password without modifying auth state', async () => {
594707
// beforeEach -> logout calls setEncryptionKey(''). Reset mocks so

0 commit comments

Comments
 (0)