Skip to content

Ask for a token password whenever a vault will be built - #833

Merged
blaipr merged 1 commit into
mainfrom
fix/ask-for-a-token-password-whenever-a-vault-is-built
Aug 20, 2026
Merged

Ask for a token password whenever a vault will be built#833
blaipr merged 1 commit into
mainfrom
fix/ask-for-a-token-password-whenever-a-vault-is-built

Conversation

@blaipr

@blaipr blaipr commented Aug 20, 2026

Copy link
Copy Markdown
Member

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:

asks for a password builds a vault
ACCOUNT_VIEW_PASS, ACCOUNT_EDIT_PASS, ACCOUNT_CREATE, PUBLICLINK_CREATE, PUBLICLINK_REFRESH yes yes
ACCOUNT_VIEW, CATEGORY_VIEW, CLIENT_VIEW no yes

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 an administrator could create an ACCOUNT_VIEW token with the password field left blank, and its
vault was 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. Verified end to end
against the real dispatch:

no-password token + customFields, tokenPass=""        -> 400 Wrong parameters
no-password token + customFields, tokenPass="…"       -> 401 Unauthorized access
same token created WITH a password, right tokenPass   -> 200

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 (or
on 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 action
refused 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 three
CAN_USE_SECURE_TOKEN_ACTIONS cases and leaves the other ten passing.

OK (4007 tests, 36822 assertions)   unit
OK (994 tests, 2979 assertions)     integration

PHPStan level 6 and PHPCS clean.

Also

UserFormTest kept its own private const DEMO_ADMIN_USER_ID = 2 with a comment explaining that
the real one was unreachable. #831 made it User::DEMO_ADMIN_ID, so the copy and the comment are
both stale; the test now uses the real constant.

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
blaipr merged commit a545dbe into main Aug 20, 2026
8 checks passed
@blaipr
blaipr deleted the fix/ask-for-a-token-password-whenever-a-vault-is-built branch August 20, 2026 22:26
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