Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/Application/Crypt/Services/MasterPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ public function checkMasterPassword(string $masterPassword): bool
}

/**
* Re-encrypts everything under a new master password, or leaves it all as it was.
*
* The hash belongs inside the transaction with the secrets it describes. The three
* re-encryption passes were already rolled back together, but the hash was stored afterwards,
* so a failure in those two writes — or a process that stopped between them — left every
* account, history row and custom field 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 between which half to save. `transactionAware()` runs on the
* shared `Database`, so these writes join the same transaction as the re-encryption, and the
* rotation either happens or does not.
*
* @throws Exception
*/
public function changeMasterPassword(UpdateMasterPassRequest $request): void
Expand All @@ -102,11 +116,11 @@ function () use ($request) {
$this->accountMasterPasswordService->updateMasterPassword($request);
$this->accountMasterPasswordService->updateHistoryMasterPassword($request);
$this->customFieldCryptService->updateMasterPassword($request);

$this->updateConfig($request->getHash());
},
$this
);

$this->updateConfig($request->getHash());
}

/**
Expand Down
31 changes: 31 additions & 0 deletions tests/Unit/Application/Crypt/Services/MasterPassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,37 @@ public function testChangeMasterPassword()
$this->masterPass->changeMasterPassword($request);
}

/**
* The stored hash is written inside the transaction, with the secrets it describes.
*
* The three re-encryption passes were already rolled back together, but the hash was saved
* after the commit — so a failure in those two writes, or a process that stopped between them,
* left every account, history row and custom field 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.
*
* Asserted by giving `transactionAware()` a double that never runs its closure: nothing inside
* the transaction happens, so nothing at all should be written. With the hash saved afterwards
* it is written regardless, which is exactly the state that outlives a rollback.
*
* @throws Exception
*/
public function testTheStoredHashIsWrittenInsideTheTransaction(): void
{
$request = new UpdateMasterPassRequest('123', '456', self::$faker->sha1());

// No withResolveCallableCallback(): the closure is handed over and never invoked.
$this->repository
->expects(self::once())
->method('transactionAware');

$this->configService
->expects(self::never())
->method('save');

$this->masterPass->changeMasterPassword($request);
}

/**
* Regression: when one of the re-key sub-services throws (simulating a partial
* re-key failure), changeMasterPassword must propagate the exception and must NOT
Expand Down