Count every attempt against the temporary master password - #814
Merged
blaipr merged 1 commit intoAug 18, 2026
Merged
Conversation
The fifty-attempt cap could be defeated by guessing in parallel:
$attempts = (int)$this->configService->getByParam(self::PARAM_ATTEMPTS);
...
$this->configService->save(self::PARAM_ATTEMPTS, (string)($attempts + 1));
The new value is worked out in PHP from a number read a moment earlier and
written back absolute, so guesses arriving together all read the same count and
all write the same count back. Fifty of them move the counter by one.
This is the credential an administrator issues so somebody can re-key their
vault, and it is reachable through login. A per-address tracker also applies,
but it keys on REMOTE_ADDR — this global cap is the one meant to hold when the
guessing comes from many places at once, and it was the one that did not.
`ConfigService::incrementIfBelow()` does the arithmetic and the comparison in
one statement and reports whether there was an attempt left; `checkKey()`
expires the password when there was not. It was the only persisted counter
doing its arithmetic in PHP — `Account.countView`, `countDecrypt` and
`PublicLink.countViews` all increment in SQL.
Two details in that statement, both of which bit:
`CAST(… AS UNSIGNED)` cannot be used. Aura's quoter takes whatever follows AS,
so the expression is emitted as CAST(… AS `UNSIGNED) + 1` and will not parse —
while the same SQL run by hand is fine, which makes it read as a database
problem rather than a builder one. COALESCE(`value`, '0') + 1 does the same
arithmetic without the keyword.
`+ 0` on the column side keeps the comparison numeric. `Config.value` is a
varchar, and two strings compare as text: '10' < '3' is true, so a counter would
pass a limit of 3 forever once it reached 10. The limit binds as PDO::PARAM_INT
today, which settles it on its own; this makes it not depend on that, and the
test says so — either mechanism alone leaves it passing, and it fails when both
go.
The existing unit test expected save('tempmaster_attempts', $attempts + 1), the
absolute write that was the defect. It now expects the atomic call, with
expects(never()) on save.
CLAUDE.md gains the two SQL gotchas above; that editing src while a suite runs
invalidates the run and it comes back green; and ConfigBackup::configToJson()'s
unrestricted Serde::deserialize() under "audited, do NOT fix", with the reason
restricting it was reverted.
blaipr
deleted the
fix/an-attempt-on-the-temporary-password-always-counts
branch
August 18, 2026 20:04
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.
The fifty-attempt cap on the temporary master password could be defeated by guessing in parallel.
The new value is worked out in PHP from a number read a moment earlier and written back absolute, so guesses arriving together all read the same count and all write the same count back. Fifty of them move the counter by one.
This is the credential an administrator issues so somebody can re-key their vault, and it is reachable through login (
LoginMasterPass::loadTemporary). A per-addressTracklimiter also applies — but that keys onREMOTE_ADDR, so this global cap is precisely the one meant to hold when the guessing comes from many places at once, and it was the one that did not.The counting moves to the server
ConfigService::incrementIfBelow()does the arithmetic and the comparison in one statement, and reports whether there was an attempt left to take.checkKey()expires the password when there was not.The earlier check is left where it is. It costs nothing and it is no longer what enforces anything.
Two details in the SQL, both of which bit
No
CAST(… AS UNSIGNED). Aura's identifier quoter takes whatever followsAS, so the raw expression is emitted asCAST(COALESCE(`value`, '0') AS `UNSIGNED) + 1`and the statement will not parse. The same SQL run by hand is fine, which makes it read as a database problem rather than a builder one.COALESCE(value, '0') + 1does the same arithmetic without the keyword.+ 0on the column side of the comparison.Config.valueis a varchar, and if both sides arrive as strings the server compares them as text —'10' < '3'is true, so a counter would pass a limit of 3 forever once it reached 10.Being accurate about that second one: the limit binds as
PDO::PARAM_INTtoday (Databasetypes binds byis_int), which settles the comparison on its own.+ 0makes it independent of that rather than being the thing that makes it work. The test says so too — removing either mechanism alone leaves it passing, and it fails when both go, which is the state the behaviour actually depends on. I found that out by running the mutation check on a claim I had already written down the other way.Tests
AttemptCountingTestruns against a real database, because the property is that the server does the arithmetic — a mocked repository counts however the test tells it to. It covers each attempt advancing the counter by exactly one, refusal at the limit leaving it untouched, aNULLvalue counting from zero rather than stopping, a missing parameter not being conjured into existence, and the double-figures case above.The existing unit test expected
save('tempmaster_attempts', $attempts + 1)— the absolute write that was the defect. It now expects the atomic call, withexpects(never())onsave, so the old shape cannot come back quietly.Also in this change
Four
CLAUDE.mdnotes, all from this work: the two SQL gotchas above; that editingsrcwhile a suite is running invalidates the run and it comes back green, with thegit stash push -- <files>recipe for verifying one change of two; andConfigBackup::configToJson()'s unrestrictedSerde::deserialize()recorded under "audited, do NOT fix", with the reason restricting it was reverted and the sweep showing it is the only such site left.3966 unit tests pass; PHPStan level 6 on
srcand PHPCS clean.