fix: store the master password's hash inside the rotation - #817
Merged
Conversation
Rotating the master password re-encrypts every account, every history row and
every custom field, and those three passes already ran in one transaction so a
failure part-way rolled them all back. The hash describing the new password was
written afterwards:
$this->repository->transactionAware(function () use ($request) { ... }, $this);
$this->updateConfig($request->getHash());
A failure in those two config writes — or a process that stopped between them —
left every secret re-keyed to the new password while the application went on
believing the old one. `checkMasterPassword()` compares against that hash, so
the new password is refused and the old one opens nothing: an instance nobody
can unlock, and no way back without editing the database by hand.
The ordering was not a choice about which half to save. `transactionAware()`
runs on the shared Database, so the hash joins the same transaction as the
secrets it describes and the rotation either happens or does not.
Asserting a rollback through mocks is awkward, so the test hands
`transactionAware()` a double that never runs its closure: nothing inside the
transaction happens, therefore nothing should be written at all. With the hash
saved afterwards it is written regardless — which is exactly the state that
outlives a rollback. The existing test covers the other direction, a re-key
failure leaving the hash alone, so this closes the pair.
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.
Rotating the master password re-encrypts every account, every history row and every custom field. Those three passes already ran inside one transaction, so a failure part-way rolled them all back — that half was right. The hash describing the new password was written after the commit:
A failure in those two config writes — or a process that stopped between them — left every secret re-keyed to the new password while the application went on believing the old one.
checkMasterPassword()compares against that hash, so the new password is refused and the old one opens nothing. The instance cannot be unlocked, and there is no way back without editing the database by hand.transactionAware()runs on the sharedDatabase, so the hash joins the same transaction as the secrets it describes. One line moved; the rotation either happens or it does not.Why this is not a deliberate ordering
It is the kind of sequencing that is sometimes on purpose — write the config only once the data is safely committed. But that reasoning only picks between two bad outcomes. Inside one transaction there is no half to choose, which beats both.
The test
Asserting "it rolls back" through mocks is awkward, so this asserts the property that makes rollback meaningful: the write has to be inside the transaction.
transactionAware()is given a double that never runs its closure, so nothing inside the transaction happens and nothing at all should be written. With the hash saved afterwards it is written regardless — exactly the state that outlives a rollback.Reverting the change produces:
testChangeMasterPasswordAbortedOnErroralready covers the other direction — a re-key failure must not advance the hash — so this closes the pair rather than repeating it.Context that made this worth checking
Both entry points reach the same service (
ConfigEncryption\SaveControllerand the CLIUpdateMasterPasswordCommand), and both refuse to start unless maintenance mode is on.Initblocks web and API requests while it is, and the CLI additionally takes a lock — so nothing can create an account mid-rotation and be left behind by the re-encryption. That part of the design is sound; the hash was the gap.3967 unit tests + 969 integration pass; PHPStan level 6 on
srcand PHPCS clean.