No file locking and no atomic writes: concurrent or interrupted operations corrupt the vault #34
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
From the 1.0 security survey. Confirmed still present and entirely unmitigated. This is the largest work unit on the milestone.
Threat
There is no file locking of any kind in the repo. Searching all non-test Go files for
flock,syscall.Flock,O_EXCL,sync.Mutex, andsync.RWMutexreturns zero hits. There are also no atomic write paths — every write is a directafero.WriteFileto its final destination. The combination means two concurrentsecretinvocations, or one invocation interrupted at the wrong moment, can leave the vault in a state that is not merely stale but permanently undecryptable.Five concrete unsynchronized paths in
AddSecret(internal/vault/secrets.go:114-190):1. Version serial allocation is a read-modify-write race.
secret.GenerateVersionName(internal/secret/version.go:79-132) lists the versions directory, finds the maximum serial, and adds one. Two concurrentsecret addcalls on the same secret both compute e.g.20260809.003; the second silently overwrites the first'svalue.age,priv.age, andmetadata.age. The first secret is gone with no error reported to either caller.2.
Version.Savewrites three files non-atomically (internal/secret/version.go:135-195, writing at:371,:391,:441,:482). A crash or a competing writer between those writes leaves a version directory where, for example,value.ageexists butpriv.agedoes not. That version can never be decrypted again — the per-version private key is the only thing that recoversvalue.age, and it is gone. This is unrecoverable data loss from a plain Ctrl-C.3. The current-version pointer is written remove-then-write.
secret.SetCurrentVersion(internal/secret/version.go:544-557):Note this is a plain file, not the symlink the README describes. Between the
Removeand theWriteFilethe secret has no current version at all: a concurrentsecret getfails with "failed to read current version file", and a crash inside that window orphans every version of the secret permanently — the data is intact on disk but nothing points at it.SelectVault(internal/vault/management.go:296-306) uses the identical remove-then-write pattern on the globalcurrentvaultpointer, so the same crash can leave the tool with no selected vault.4. Previous-version metadata is decrypt-modify-reencrypt-write (
updateVersionMetadata,internal/vault/secrets.go:193-242, writing at:236). Concurrent adds interleave here and lose anotAfterstamp or write a torn file.5. Directory preparation is TOCTOU.
prepareSecretDir(:536-568) doesDirExistsand thenMkdirAll, so the existence check that backs--forcecan be invalidated between the two.Rollback is also non-atomic:
createAndSaveVersion:734andcopyVersionsWithRollback:760,769callRemoveAllon failure, which can itself partially fail and leave debris.None of this needs an attacker. A cron job overlapping an interactive session, a CI fan-out, a
maketarget running twosecretcalls in parallel, or a laptop suspending mid-write all reach it. The user-visible symptom of the worst case issecret getfailing forever on a secret that was written successfully.Definition of done
AddSecret,RemoveSecret,MoveSecret,CopySecretAllVersions,SetCurrentVersion,SelectVault, and unlocker create/remove.Version.Savemust not be able to leavevalue.agewithout itspriv.age.writeFileAtomichelper: create a temp file in the destination directory with mode0600, write,fsync, thenRenameover the target. The temp file must be cleaned up on every error path, and must never be created with default permissions and chmod'd afterward — that is a window where key material is world-readable.SetCurrentVersionandSelectVaultno longer remove-then-write.Renameover the existing file is atomic on POSIX; use it.AddSecreton the same secret does not lose a version and does not produce duplicate serials; a simulated failure between the writes inVersion.Saveleaves no half-written version directory; the current pointer is never observably absent.make checkgreen with-raceenabled (see thescript/testissue — land that first so the race detector is actually running).TODO.mdupdated in the same commit.Implementation requirements
afero.Fsabstraction is the hard part and must be handled deliberately.aferohas no locking primitive, andafero.MemMapFsbacks the test suite. The lock needs a real-filesystem implementation with a no-op or in-process-mutex equivalent for the memory filesystem. Do not silently degrade to no locking when the filesystem is not a real one — make the substitution explicit and documented, or the tests will pass while production is unprotected.Rename-based atomic writes must be verified to behave on the afero backend used in tests, not just on the real filesystem.<vaultDir>/.lock) or the state-directory level. Vault-level allows concurrent work in different vaults; state-level is simpler and safer. Pick one, write down the reasoning in the commit message, and be consistent.flockon an open descriptor releases automatically on process death; a hand-rolled lockfile-with-PID does not. Prefer the former.0600from creation, inside the destination directory (not/tmp, which may be a different filesystem and would makeRenamenon-atomic), and removed on failure.Notes
Sequence this after the
script/testfix, so-raceis on while this is being developed, and after the lint branch lands, since it rewritesinternal/vault/secrets.goandinternal/secret/version.goheavily.Also worth noting: the survey found the repo currently creates no temp files anywhere, and file permissions are correct throughout (
DirPerms = 0o700,FilePerms = 0o600ininternal/secret/constants.go). The reason there are no temp files is precisely that writes are not atomic. This change introduces them, so the permission discipline above is a new requirement, not an existing one being preserved.