Ask for a token password whenever a vault will be built - #833
Merged
blaipr merged 1 commit intoAug 20, 2026
Conversation
A token's vault is the master password, sealed with the token's own password and the token itself. Two rules decided when a token has one, and they were not the same rule: AuthTokenForm::checkCommon() demanded a password for isSecuredAction(), while AuthToken::injectSecureData() builds a vault for isSecuredAction() || canUseSecureTokenAction() — the three view actions, which need the master password when a caller asks for custom fields. So a token for ACCOUNT_VIEW, CATEGORY_VIEW or CLIENT_VIEW could be created with the password field left blank, and its vault was then sealed with the empty string. Nothing can open it: Api::getMasterPassFromVault() reads tokenPass as a required parameter, and required refuses the empty string, so the one password that would work cannot be presented. Against the real dispatch such a token answers 400 for an empty tokenPass and 401 for any other, while the same token created with a password answers 200. It was issued, reported as created, and permanently unable to do the thing that needed the vault. One predicate instead of two: needsSecureToken() is what "this token carries a vault" means, injectSecureData() uses it, and the form asks for the password exactly when it is true, or on a refresh, which re-seals the vault whatever the action. There is no second copy left to drift from. AuthTokenFormTest is new — the form had no test. Every vault-carrying action is refused without a password and accepted with one, with three controls so the rule cannot collapse into "always demand a password": an action carrying no vault still needs none, a refresh still always does, and the user and action checks that run first are pinned so a refusal cannot be the form rejecting the fixture for another reason. Restoring isSecuredAction() in the form fails exactly the three CAN_USE_SECURE_TOKEN_ACTIONS cases. UserFormTest kept its own copy of the demo admin id with a comment saying the real one was unreachable; it is public now, so the test uses it.
blaipr
deleted the
fix/ask-for-a-token-password-whenever-a-vault-is-built
branch
August 20, 2026 22:26
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 defect
A token's vault is the master password, sealed with the token's own password and the token
itself. Two rules decided when one exists, and they were not the same rule:
ACCOUNT_VIEW_PASS,ACCOUNT_EDIT_PASS,ACCOUNT_CREATE,PUBLICLINK_CREATE,PUBLICLINK_REFRESHACCOUNT_VIEW,CATEGORY_VIEW,CLIENT_VIEWAuthTokenForm::checkCommon()demanded a password forisSecuredAction(), whileAuthToken::injectSecureData()builds a vault forisSecuredAction() || canUseSecureTokenAction()— the three view actions, which need the master password when a caller asks for custom fields.
So an administrator could create an
ACCOUNT_VIEWtoken with the password field left blank, and itsvault was sealed with the empty string. Nothing can open it:
Api::getMasterPassFromVault()readstokenPassas a required parameter, and required refusesthe empty string, so the one password that would work cannot be presented. Verified end to end
against the real dispatch:
The token is issued, reported as created, and permanently unable to do the one thing that needed
the vault. It fails closed, so this is a broken credential rather than an open one.
The fix
One predicate instead of two.
AuthToken::needsSecureToken()is what "this token carries a vault"means,
injectSecureData()uses it, and the form asks for the password exactly when it is true (oron a refresh, which re-seals the vault whatever the action). The two cannot drift again, because
there is no longer a second copy to drift from.
Test
AuthTokenFormTest— new; the form had no test at all. 13 cases: every vault-carrying actionrefused without a password and accepted with one, plus three controls that stop the rule collapsing
into "always demand a password" — an action that carries no vault still needs none, a refresh still
always does, and the user/action checks that run first are pinned so a refusal cannot be the form
rejecting the fixture for another reason.
Mutation-checked: restoring
isSecuredAction()in the form fails exactly the threeCAN_USE_SECURE_TOKEN_ACTIONScases and leaves the other ten passing.PHPStan level 6 and PHPCS clean.
Also
UserFormTestkept its ownprivate const DEMO_ADMIN_USER_ID = 2with a comment explaining thatthe real one was unreachable. #831 made it
User::DEMO_ADMIN_ID, so the copy and the comment areboth stale; the test now uses the real constant.