Repository navigation
fix(security): serialise vault writes and make KDF upgrades crash-safe - #54
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the two P2 findings from the October 2026 code review for keysoft:
updateMasterPasswordre-encrypted and committed the vault before persisting the new salt/verifier metadata; a process kill between the two writes left ciphertext encrypted under a salt that was never stored, locking the user out permanently.Change
KS-2 — serialised vault writes (
src/services/storage/storageService.ts)AsyncMutex); operations that touch more than one collection acquire their mutexes in a fixed sorted order so nested acquisition cannot deadlock.KS-1 — crash-safe KDF transaction (
src/services/auth/authService.ts,src/services/storage/storageService.ts)Both the transparent legacy-KDF upgrade and
updateMasterPasswordnow use one sequence:Recovery on the next master-password login derives both candidates, inspects which key actually decrypts the stored ciphertext, and then 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 entered password only matches metadata whose key no longer decrypts the vault, login fails with a diagnostic naming the password that is required and the recovery record is kept untouched. A crash after the metadata commit leaves only a stale record that the next login removes.
Biometric unlock cannot derive the pending key, so it refuses to run while a recovery record exists (without disabling biometrics) and the following password login refreshes the biometric key best-effort. A rekey-in-progress guard prevents two concurrent rekeys in the same process. Rollback-failure handling still clears the authenticated session and the in-memory key, but now keeps the recovery record so the next login can complete the promotion. Crypto parameters and error checks are unchanged.
Three new user-visible failure messages (IT/EN) explain whether the new or the previous password is required.
Verification
bun install— no changes,bun.lockuntouchedbun run typecheck— passbun run lint— passbun run format:check— passbun run test:ci— 32 suites, 222 tests passed (baseline: 30 suites, 212 tests)New tests:
src/__tests__/services/StorageService.test.ts— two interleaved saves with a delayed AsyncStorage mock where the first write is much slower, so an unserialised second save would be overwritten and lost; a save queued during a concurrent re-encryption is encrypted with the new key; the queue stays usable after a failed write.src/__tests__/services/AuthServiceKdfUpgradeCrashRecovery.test.ts— realCryptoService(deterministic native-KDF test double) plus in-memory device storage; simulates a process kill by never resolving the active-metadata SecureStore write after the vault write, then restarts into a login that promotes the pending metadata, opens the vault, and clears the record.src/__tests__/services/AuthServicePasswordChangeCrashRecovery.test.ts— the same crash for a master-password change: old password first reportskdf_recovery_requires_new_passwordand keeps the record, then the new password recovers and opens the vault; separately, a recovery record whose vault write never happened is rolled back so the previous password and vault remain active.src/__tests__/services/AuthService.test.ts— transaction ordering (recovery record → vault write → metadata commit → cleanup), recovery record retention when commit and rollback both fail, and the biometric guard.Checklist
Residual risk / follow-up
docs/security.md: a single SecureStore key write is durable/atomic (SharedPreferences.commit()) andAsyncStorage.multiSetpersists both collections in one SQLite transaction. Android is the supported release platform; iOS is paused and the web target uses development mocks.CryptoService.decryptlogs the expected MAC failure as an error. Logs stay message-only, but a quiet decrypt path could be added later if the noise becomes a problem.