Skip to content

fix(security): serialise vault writes and make KDF upgrades crash-safe - #54

Merged
TheStreamCode merged 1 commit into
mainfrom
fix/review-p2-ks
Oct 21, 2026
Merged

TheStreamCode merged 1 commit into
mainfrom
fix/review-p2-ks

Conversation

@TheStreamCode

@TheStreamCode TheStreamCode commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes the two P2 findings from the October 2026 code review for keysoft:

  • KS-2 — every whole-vault mutation was an unserialised read-modify-write from the shared in-memory cache, so two interleaved mutations could both build from the same snapshot and silently drop one of them.
  • KS-1 — the KDF upgrade and updateMasterPassword re-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)

  • Added a per-storage-key FIFO async mutex (AsyncMutex); operations that touch more than one collection acquire their mutexes in a fixed sorted order so nested acquisition cannot deadlock.
  • Routed every mutation path through it: save/delete/clear passwords, save/delete/clear notes, category create/update/delete, preferences save/clear, backup import, whole-vault re-encryption, legacy plaintext migration writes, and the full data reset.
  • A queued mutation rebuilds its record from the committed cache, and a rekey excludes concurrent writes on the collections it rewrites, so a save started during a re-encryption runs after it and uses the new key.
  • Public API unchanged. The cache is still committed only after the persisted write succeeds.
  • The queue is intentionally not exposed to the UI: these mutations are short (one encrypted record write), so there is no long operation to surface.

KS-1 — crash-safe KDF transaction (src/services/auth/authService.ts, src/services/storage/storageService.ts)

Both the transparent legacy-KDF upgrade and updateMasterPassword now use one sequence:

  1. write a pending recovery record (the new KDF metadata) to SecureStore while the previous metadata is still active and usable;
  2. re-encrypt both collections in one batched AsyncStorage write (a single transaction on Android);
  3. commit the new metadata with a single SecureStore write — the atomic switch point;
  4. remove the recovery record.

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.lock untouched
  • bun run typecheck — pass
  • bun run lint — pass
  • bun run format:check — pass
  • bun 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 — real CryptoService (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 reports kdf_recovery_requires_new_password and 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

  • Focused change, one problem per pull request (both findings share the same security boundary and touch the same services)
  • Tests added or updated where behavior changed
  • Documentation and CHANGELOG updated when behavior changed
  • Required CI checks pass (Validate, CodeQL, markdownlint on commit 1b09306)
  • No secrets, keystores, certificates, or local environment files are included
  • User-visible strings are localized in Italian and English
  • Authentication, crypto, storage, biometric, or backup changes include focused tests
  • No Android permissions or EAS/update behavior changes
  • No EAS build, submission, tag, or release command was run

Residual risk / follow-up

  • Crash safety relies on two Android guarantees documented in docs/security.md: a single SecureStore key write is durable/atomic (SharedPreferences.commit()) and AsyncStorage.multiSet persists both collections in one SQLite transaction. Android is the supported release platform; iOS is paused and the web target uses development mocks.
  • After a crash that already re-encrypted the vault during a password change, only the new password can open it. The login screen reports that, but a user who forgets the new password cannot fall back to the previous one — the same residual risk as any completed password change.
  • Recovery probes decrypt the wrong-key candidate once; CryptoService.decrypt logs 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.
  • No version bump, tag, or release, per the task.

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.
@TheStreamCode
TheStreamCode merged commit eae86c9 into main Oct 21, 2026
7 checks passed
@TheStreamCode
TheStreamCode deleted the fix/review-p2-ks branch October 21, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant