fix: resolve TOCTOU race in vault encryption key auto-generation#405
Merged
fix: resolve TOCTOU race in vault encryption key auto-generation#405
Conversation
Replace the try/catch read-then-write pattern in loadKeyMaterial() with exclusive file creation using Deno.writeTextFile with createNew: true. This uses O_CREAT | O_EXCL at the OS level, guaranteeing only one process can create the key file. The race loser catches AlreadyExists and reads the winner's key instead of overwriting it. Also narrows the initial read catch from bare catch to Deno.errors.NotFound so real errors (permission denied, etc.) propagate instead of being silently swallowed. Co-Authored-By: Magistr <2269529+umag@users.noreply.github.com>
There was a problem hiding this comment.
Review: Approved ✅
This PR correctly fixes a TOCTOU race condition in vault encryption key auto-generation. The fix is well-designed and follows best practices.
Code Quality
- TypeScript strict mode: ✅ No
anytypes, proper typed error handling - Code style (CLAUDE.md): ✅ Named exports, test file next to source, license headers present
- Security: ✅ Correctly uses
createNew: true(maps toO_CREAT | O_EXCL) for atomic exclusive creation - Test coverage: ✅ New concurrency test verifies both vaults share the same key and only one
.keyfile exists - DDD compliance: ✅ Changes are appropriately scoped to infrastructure layer
Implementation Details
The fix correctly:
- Catches
Deno.errors.NotFoundspecifically (not barecatch) so permission errors propagate - Uses
createNew: truefor atomic exclusive file creation - Catches
Deno.errors.AlreadyExistsand reads back the winner's key on race loss - Maintains file permissions at
0o600
No Blocking Issues
The implementation matches the plan and addresses a real security concern where concurrent processes could generate different keys, causing secrets to become permanently unrecoverable.
The PR author correctly notes in the description that the concurrency test exercises the race through async interleaving, but the real safety comes from the OS-level O_EXCL guarantee - which is the correct observation.
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.
Closes #391
Summary
loadKeyMaterial()inLocalEncryptionVaultProviderhad a time-of-check-time-of-use (TOCTOU) race condition. Two concurrent processes could both fail thereadTextFile()check, generate different encryption keys, and the secondatomicWriteTextFile()(which uses rename internally) would silently overwrite the first. Secrets encrypted by the losing process would become permanently unrecoverable.Fix
Replace the read-then-write pattern with exclusive file creation:
Deno.writeTextFile(keyFile, generatedKey, { createNew: true, mode: 0o600 })—createNew: truemaps toO_CREAT | O_EXCLat the OS level, guaranteeing exactly one process wins the creation. No application-level lock needed.Catch
Deno.errors.AlreadyExistson the write — the losing process reads back the winner's key instead of overwriting it.Catch
Deno.errors.NotFoundspecifically (not barecatch) on the initial read — real errors like permission denied now propagate instead of being silently swallowed.Plan vs Implementation
The implementation matches the plan exactly:
atomicWriteTextFilewithDeno.writeTextFile+createNew: trueDeno.errors.NotFoundspecifically on initial readDeno.errors.AlreadyExistson writeAlreadyExistsensureVaultDirectory()before writePromise.all()onput().keyfile assertionNo deviations from the plan. The only additions are the
crypto.subtle.importKeycalls in each branch, which the plan's pseudocode intentionally omitted for brevity.Test caveat
The concurrency test runs two vault instances in the same Deno process. Async interleaving at
awaitpoints can exercise the race, but doesn't guarantee it every run. The real safety comes from the OS-levelO_EXCLguarantee, which holds regardless of test coverage.User impact
.keyfile already exists, the code reads it on the first try and never hits the changed pathTest plan
deno check— type checking cleandeno lint— no lint errorsdeno fmt— formatting clean