Skip to content

fix: store the master password's hash inside the rotation - #817

Merged
blaipr merged 1 commit into
mainfrom
fix/rotating-the-master-password-is-all-or-nothing
Aug 18, 2026
Merged

fix: store the master password's hash inside the rotation#817
blaipr merged 1 commit into
mainfrom
fix/rotating-the-master-password-is-all-or-nothing

Conversation

@blaipr

@blaipr blaipr commented Aug 18, 2026

Copy link
Copy Markdown
Member

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:

$this->repository->transactionAware(
    function () use ($request) {
        $this->accountMasterPasswordService->updateMasterPassword($request);
        $this->accountMasterPasswordService->updateHistoryMasterPassword($request);
        $this->customFieldCryptService->updateMasterPassword($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. The instance cannot be unlocked, and there is no way back without editing the database by hand.

transactionAware() runs on the shared Database, 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:

ConfigService::save('masterPwd', '$2y$12$...'): bool was not expected to be called, actually called 1 time.

testChangeMasterPasswordAbortedOnError already 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\SaveController and the CLI UpdateMasterPasswordCommand), and both refuse to start unless maintenance mode is on. Init blocks 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 src and PHPCS clean.

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.
@blaipr blaipr changed the title Store the master password's hash inside the rotation fix: store the master password's hash inside the rotation Aug 18, 2026
@blaipr
blaipr merged commit 55c61bb into main Aug 18, 2026
8 checks passed
@blaipr
blaipr deleted the fix/rotating-the-master-password-is-all-or-nothing branch August 18, 2026 20:25
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