feat(security): verify argon2id password hashing via occ selftest (NSW-957) - #38
feat(security): verify argon2id password hashing via occ selftest (NSW-957)#38printminion-co wants to merge 8 commits into
Conversation
50f6471 to
ccb90de
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The hash-version prefix parsing is too permissive (can misclassify invalid prefixed values), and the JSON artifact output path can still short-circuit without emitting an artifact on encoding failure.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an occ ncw_tools:security:selftest command plus supporting service logic, documentation, and tests to verify that password hashing is argon2id and to emit a structured evidence artifact for C5 control PSS-07 within the real Nextcloud runtime.
Changes:
- Introduces
SecuritySelfTestservice +SecuritySelfTestocc command that surveys stored hashes, verifies hardening config, and optionally performs a round-trip probe user check. - Adds
HashAlgorithmclassifier to correctly interpret Nextcloud’s version-prefixed stored hashes and extract effective cost parameters. - Adds comprehensive unit + integration test coverage, documentation, and psalm stubs/suppressions needed for static analysis.
File summaries
| File | Description |
|---|---|
| lib/Command/SecuritySelfTest.php | New occ command wrapper that validates options, emits artifact to stdout, and logs structured evidence. |
| lib/Security/SecuritySelfTest.php | New service that generates the evidence artifact: hasher probe, stored distribution survey, hardening checks, optional round-trip. |
| lib/Security/HashAlgorithm.php | New classifier for Nextcloud’s stored password hash formats (version-prefixed + legacy) and parameter extraction. |
| tests/unit/Security/SecuritySelfTestTest.php | Unit tests for artifact shape, pass/fail conditions, distribution counting, and round-trip behavior/invariants. |
| tests/unit/Security/HashAlgorithmTest.php | Unit tests for hash classification and parameter extraction, including regression cases for version prefixes. |
| tests/integration/SecuritySelfTestIntegrationTest.php | Integration tests exercising the real DB/user pipeline, including round-trip creation/deletion and “no hash material” invariant. |
| docs/security-selftest.md | User/operator documentation for the command, artifact schema, failure modes, and Kibana caveats. |
| docs/README.md | Documentation index updated to include the new security self-test command docs. |
| appinfo/info.xml | Registers the new occ command with Nextcloud. |
| psalm.xml | Adds psalm stub directory and suppressions for DI-registered classes/constructors. |
| tests/psalm-stubs/OC/Core/Command/Base.php | Psalm stub for Nextcloud private OC\Core\Command\Base used by the command. |
| tests/psalm-stubs/Symfony/Component/Console/Input/InputInterface.php | Psalm stub for Symfony Console input interface used by the command. |
| tests/psalm-stubs/Symfony/Component/Console/Input/InputOption.php | Psalm stub for Symfony Console InputOption constants used by the command. |
| tests/psalm-stubs/Symfony/Component/Console/Output/OutputInterface.php | Psalm stub for Symfony Console output interface used by the command. |
| tests/psalm-stubs/Symfony/Component/Console/Output/ConsoleOutputInterface.php | Psalm stub for ConsoleOutputInterface used to separate stderr from stdout. |
| REUSE.toml | Adds the new documentation file to REUSE coverage. |
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| $flags = JSON_UNESCAPED_SLASHES | ($format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0); | ||
| $json = json_encode($report, $flags); | ||
| if ($json === false) { | ||
| $errors->writeln('<error>Could not encode the evidence artifact: ' . json_last_error_msg() . '</error>'); | ||
| return 1; | ||
| } |
There was a problem hiding this comment.
Good catch — fixed in 563d951, though one level deeper than suggested.
JSON_INVALID_UTF8_SUBSTITUTE alone would have fixed stdout while leaving the other evidence channel exposed: the same report goes to LoggerInterface, and the log writer encodes that context as JSON too, so invalid UTF-8 would drop the Kibana line — which is the channel NSW-957 actually requires. So the sanitising now happens where the untrusted bytes are read (SecuritySelfTest::fromEnvironment(), the source of INSTANCE_NAME / NAMESPACE / ENVIRONMENT), which protects both channels and keeps them consistent. The flag stays on the command as a backstop.
Regression test: testInvalidUtf8InTheEnvironmentStillYieldsAnEncodableArtifact. Confirmed it fails without the fix (Failed asserting that false is true on mb_check_encoding) and passes with it.
| private static function stripVersionPrefix(string $stored): ?string { | ||
| $parts = explode('|', $stored, 2); | ||
| if (count($parts) !== 2) { | ||
| return null; | ||
| } | ||
|
|
||
| if ((int)$parts[0] <= 0) { | ||
| return null; | ||
| } | ||
|
|
||
| return $parts[1]; | ||
| } |
There was a problem hiding this comment.
Deliberate, and I am keeping the behaviour — but you are right that it was undocumented and untested, so 563d951 fixes that.
The loose (int) cast mirrors upstream exactly. OC\Security\Hasher::splitHash():
$explodedString = explode("|", $prefixedHash, 2);
if (\count($explodedString) === 2) {
if ((int)$explodedString[0] > 0) {So Nextcloud itself reads 3foo|<hash> as version 3 and verifies the remainder as argon2id — validate() accepts it too. This command produces audit evidence about what the instance actually does, so classifying that value as argon2id is the truthful answer. Tightening to digits-only would report unknown for a value Nextcloud verifies happily, i.e. invent an anomaly rather than report one.
On "as described in the docs/tests": the doc says the classifier splits the prefix "mirroring the private Hasher::splitHash()", which is what it does — it does not claim a digits-only rule. No contradiction, though the intent was too implicit.
A malformed prefix still does not launder a malformed hash: the remainder goes through password_get_info(), so 3foo|nonsense is unknown.
Added: an explanatory comment on stripVersionPrefix(), and four cases pinning the behaviour — 3foo|<argon2id> and " 3|"<argon2id> classify as argon2id, 3foo|nonsense and -1|<argon2id> as unknown.
Copilot review on #38: the JSON path could return exit 1 with empty stdout when json_encode() failed, which contradicts the invariant the surrounding comment states and that nc-manager/bin/selftest.sh relies on — an empty stdout there degrades the run to a synthetic ERROR artifact and the real evidence is lost. The realistic trigger is invalid UTF-8 in INSTANCE_NAME, NAMESPACE or ENVIRONMENT, which are arbitrary bytes from the environment. Sanitise them where they are read rather than at the encode boundary, because the Kibana log line encodes the same context and would drop it the same way — fixing only stdout would have left the channel the ticket actually requires exposed. JSON_INVALID_UTF8_SUBSTITUTE stays as a backstop on the command. Also document that the loose (int) cast in stripVersionPrefix() is deliberate, with tests. Upstream Hasher::splitHash() splits on `(int)$parts[0] > 0`, so Nextcloud reads '3foo|<hash>' as version 3 and verifies the remainder as argon2id. Tightening to digits-only would report `unknown` for a value the instance verifies happily — inventing an anomaly instead of reporting one. A garbage prefix still does not rescue a garbage hash: the remainder goes through password_get_info().
There was a problem hiding this comment.
🟡 Changes recommended
The new HashAlgorithmTest assumes Argon2 constants are always available and can fatally error on PHP builds without Argon2 support, even though the feature explicitly needs to handle that scenario.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
tests/unit/Security/HashAlgorithmTest.php:120
- This test uses
PASSWORD_ARGON2IDdirectly; if argon2id support is not compiled into PHP, the constant is undefined and the test fatals. Consider skipping the test when argon2id is unavailable and assertingpassword_hash()produced a string before building the stored value.
public function testBareArgon2idPrefixIsNotHowNextcloudStoresHashes(): void {
// Regression guard for the defect this class replaces: a shell check that
// compared the stored value against the literal '$argon2id$' prefix.
$stored = '3|' . password_hash('probe', PASSWORD_ARGON2ID);
$this->assertStringStartsNotWith('$argon2id$', $stored);
$this->assertStringStartsWith('3|$argon2id$', $stored);
$this->assertSame(HashAlgorithm::ARGON2ID, HashAlgorithm::fromStoredHash($stored));
tests/unit/Security/HashAlgorithmTest.php:129
- This test uses
PASSWORD_ARGON2IDdirectly; on PHP builds without Argon2 support it will fatal. Skip when argon2id is unavailable and assertpassword_hash()returns a string before concatenation.
public function testParametersFromArgon2idHash(): void {
$stored = '3|' . password_hash('probe', PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1,
]);
tests/unit/Security/HashAlgorithmTest.php:162
- This test uses
PASSWORD_ARGON2IDdirectly; on PHP builds without Argon2 support it will fatal. Skip when argon2id is unavailable and assertpassword_hash()returns a string before passing it intoparametersFromStoredHash().
public function testParametersNeverCarryHashMaterial(): void {
$stored = '3|' . password_hash('probe', PASSWORD_ARGON2ID);
foreach (HashAlgorithm::parametersFromStoredHash($stored) as $name => $value) {
$this->assertIsString($name);
$this->assertIsInt($value);
}
}
- Files reviewed: 16/16 changed files
- Comments generated: 1
- Review effort level: Lite
| // Freshly generated hashes, so the classifier is exercised against what | ||
| // the current PHP build actually produces rather than only fixtures. | ||
| $cases['freshly hashed argon2id with version 3 prefix'] = [ | ||
| '3|' . password_hash('probe', PASSWORD_ARGON2ID), | ||
| HashAlgorithm::ARGON2ID, | ||
| ]; | ||
| $cases['freshly hashed argon2i with version 2 prefix'] = [ | ||
| '2|' . password_hash('probe', PASSWORD_ARGON2I), | ||
| HashAlgorithm::ARGON2I, | ||
| ]; | ||
| $cases['freshly hashed bcrypt with version 1 prefix'] = [ | ||
| '1|' . password_hash('probe', PASSWORD_BCRYPT), | ||
| HashAlgorithm::BCRYPT, | ||
| ]; | ||
| $cases['freshly hashed bcrypt without prefix'] = [ | ||
| password_hash('probe', PASSWORD_BCRYPT), | ||
| HashAlgorithm::LEGACY_BCRYPT, | ||
| ]; |
There was a problem hiding this comment.
Valid, and it led somewhere bigger than the test file — fixed in 2180b7b.
Guarding the four password_hash() call sites alone would not have been enough. PHP registers the argon2 password_get_info() handlers under the same condition that defines the constants (HAVE_ARGON2LIB || HAVE_LIBSODIUM), so on a build without argon2 support password_get_info() reports unknown for a perfectly good $argon2id$ hash. fromStoredHash() classified purely from that call — so on such a build every stored argon2id row would have landed in the unknown bucket of stored_distribution, blurring the finding on exactly the build this self-test exists to catch. The captured fixtures (REAL_STORED_ARGON2ID and friends) would have started failing there too, and those are not among the lines you flagged.
So the classifier now matches on the algorithm marker instead. That is what upstream's own handlers do — argon2 accepts any $argon2id$… string, bcrypt any 60 character $2y… one — and it is identical for every case the suite already pinned. Two deliberate changes where nothing was pinned before, both now covered by tests:
- a prefixed
$2a/$2bbcrypt hash classifies asbcryptrather thanunknown, which is what the unprefixed branch has always said about those revisions - the length check stays, so
1|$2y$10$tooshortis stillunknown
parametersFromStoredHash() keeps password_get_info(), since only the registered handler can read the cost fields. It degrades to an empty array without argon2 support, which is sound: the artifact reports parameters as evidence and never asserts on them, and the configured-algorithm check is what turns such a build into a FAIL. Documented on the method and in the doc's failure-mode table.
On the tests themselves:
- the B1 regression guard now uses the captured fixture rather than
password_hash(), so the one test that must never be skipped never is - only the freshly-hashed argon2 provider cases and the argon2 parameters test are conditional, behind a single
defined('PASSWORD_ARGON2ID')helper - verified by forcing that helper to
false: 35 tests, 1 skipped, 0 failures, argon2 classification still covered by the fixtures
One more thing this turned up: testParametersFromArgon2idHash asserted the PHP defaults 65536/4/1. password_get_info() seeds its result with those very defaults and only overwrites them when it can parse the hash ($argon2id$garbage reports 65536/4/1 too), so that assertion held even if parsing were broken. It now uses 32768/3/1.
The second half of the note does not apply: password_hash() cannot return a non-string on PHP 8 — it throws — and composer requires ^8.1.
Copilot review on #38 flagged that provideStoredHashes() references PASSWORD_ARGON2ID unconditionally, which is a fatal Error on a PHP build without argon2 support. It is right, but guarding the four password_hash() call sites alone would not have been enough — and chasing it down turned up a defect in the classifier itself. PHP registers the argon2 password handlers under the same condition that defines the constants (HAVE_ARGON2LIB || HAVE_LIBSODIUM), so on a build without argon2 password_get_info() reports `unknown` for a perfectly good $argon2id$ hash. fromStoredHash() classified purely from that call, so on such a build every stored argon2id row would land in the `unknown` bucket of stored_distribution — blurring the finding on exactly the build this self-test exists to catch. The captured fixtures would have started failing there too, and those Copilot did not flag. Classify by algorithm marker instead. This is what upstream's own handlers do (argon2 accepts any $argon2id$… string; bcrypt any 60 character $2y… one) and it is verifiably identical for every case the suite already pinned. Two deliberate changes where nothing was pinned before, both now covered: - a prefixed $2a/$2b bcrypt hash classifies as bcrypt rather than unknown, which is what the unprefixed branch has always said about those revisions - the length check stays, so '1|$2y$10$tooshort' is still unknown parametersFromStoredHash() keeps using password_get_info(), because only the registered handler can read the cost fields. It degrades to an empty array on a build without argon2, which is sound: the artifact reports parameters as evidence and never asserts on them, and the configured-algorithm check is what turns such a build into a FAIL. On the tests: - the B1 regression guard now uses the captured fixture rather than password_hash(), so the one test that must never be skipped never is - only the freshly-hashed argon2 provider cases and the argon2 parameters test are conditional; forcing hasArgon2Support() false leaves 35 tests with 1 skip and no failures, argon2 classification still covered - testParametersFromArgon2idHash asserted the PHP defaults 65536/4/1. password_get_info() seeds its result with those very defaults and only overwrites them when it can parse the hash, so the assertion held even if parsing were broken. It now uses 32768/3/1. The second half of the review note does not apply: password_hash() cannot return a non-string on PHP 8, it throws, and composer requires ^8.1. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
There was a problem hiding this comment.
🔵 Needs a closer look
The integration test registers a global event listener in setUp() without unregistering it, which can accumulate across tests and create avoidable flakiness/memory leaks in the integration suite.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
tests/integration/SecuritySelfTestIntegrationTest.php:52
- The integration test registers a UserCreatedEvent listener in setUp() but never unregisters it. Because the event dispatcher is shared, this can accumulate listeners across tests and keep old test instances alive, causing memory leaks and potentially surprising side effects in longer integration runs.
tests/psalm-stubs/OC/Core/Command/Base.php:33 - The psalm stub for OC\Core\Command\Base declares configure() without a return type, but subclasses in this repo override it as configure(): void. Even though this is only a stub, keeping signatures consistent avoids confusing static analysis and mirrors the real Symfony/Nextcloud contract more accurately.
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
Copilot review on #38: the JSON path could return exit 1 with empty stdout when json_encode() failed, which contradicts the invariant the surrounding comment states and that nc-manager/bin/selftest.sh relies on — an empty stdout there degrades the run to a synthetic ERROR artifact and the real evidence is lost. The realistic trigger is invalid UTF-8 in INSTANCE_NAME, NAMESPACE or ENVIRONMENT, which are arbitrary bytes from the environment. Sanitise them where they are read rather than at the encode boundary, because the Kibana log line encodes the same context and would drop it the same way — fixing only stdout would have left the channel the ticket actually requires exposed. JSON_INVALID_UTF8_SUBSTITUTE stays as a backstop on the command. Also document that the loose (int) cast in stripVersionPrefix() is deliberate, with tests. Upstream Hasher::splitHash() splits on `(int)$parts[0] > 0`, so Nextcloud reads '3foo|<hash>' as version 3 and verifies the remainder as argon2id. Tightening to digits-only would report `unknown` for a value the instance verifies happily — inventing an anomaly instead of reporting one. A garbage prefix still does not rescue a garbage hash: the remainder goes through password_get_info(). Signed-off-by: Misha M.-Kupriyanov <[email protected]>
2180b7b to
e056785
Compare
Copilot review on #38 flagged that provideStoredHashes() references PASSWORD_ARGON2ID unconditionally, which is a fatal Error on a PHP build without argon2 support. It is right, but guarding the four password_hash() call sites alone would not have been enough — and chasing it down turned up a defect in the classifier itself. PHP registers the argon2 password handlers under the same condition that defines the constants (HAVE_ARGON2LIB || HAVE_LIBSODIUM), so on a build without argon2 password_get_info() reports `unknown` for a perfectly good $argon2id$ hash. fromStoredHash() classified purely from that call, so on such a build every stored argon2id row would land in the `unknown` bucket of stored_distribution — blurring the finding on exactly the build this self-test exists to catch. The captured fixtures would have started failing there too, and those Copilot did not flag. Classify by algorithm marker instead. This is what upstream's own handlers do (argon2 accepts any $argon2id$… string; bcrypt any 60 character $2y… one) and it is verifiably identical for every case the suite already pinned. Two deliberate changes where nothing was pinned before, both now covered: - a prefixed $2a/$2b bcrypt hash classifies as bcrypt rather than unknown, which is what the unprefixed branch has always said about those revisions - the length check stays, so '1|$2y$10$tooshort' is still unknown parametersFromStoredHash() keeps using password_get_info(), because only the registered handler can read the cost fields. It degrades to an empty array on a build without argon2, which is sound: the artifact reports parameters as evidence and never asserts on them, and the configured-algorithm check is what turns such a build into a FAIL. On the tests: - the B1 regression guard now uses the captured fixture rather than password_hash(), so the one test that must never be skipped never is - only the freshly-hashed argon2 provider cases and the argon2 parameters test are conditional; forcing hasArgon2Support() false leaves 35 tests with 1 skip and no failures, argon2 classification still covered - testParametersFromArgon2idHash asserted the PHP defaults 65536/4/1. password_get_info() seeds its result with those very defaults and only overwrites them when it can parse the hash, so the assertion held even if parsing were broken. It now uses 32768/3/1. The second half of the review note does not apply: password_hash() cannot return a non-string on PHP 8, it throws, and composer requires ^8.1. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
The upcoming security self-test command extends OC\Core\Command\Base to get the --output handling and the OUTPUT_FORMAT_* constants. Base is a private server class and is therefore not part of the nextcloud/ocp package this app depends on, and symfony/console is only provided by the server at runtime, not by this app's vendor tree. Psalm runs at errorLevel 1 and would report UndefinedClass for all of them. Add scan-only stubs declaring just the members this app uses, and point psalm's extraFiles at the directory holding them. They deliberately live in tests/psalm-stubs/ rather than beside the existing stubs in tests/stubs/, because nothing autoloads the new directory. composer.json maps tests/stubs/ into the autoload-dev classmap and lib/AppInfo/Application.php requires vendor/autoload.php at runtime, so a dev-mode `composer install` would bake the Symfony Console stubs into the live classmap, where they shadow the real classes in 3rdparty/ and fatal every occ command on the instance: PHP Fatal error: Declaration of Symfony\...\Output::writeln(...) must be compatible with Symfony\...\OutputInterface::writeln($messages, int $options = 0): void Release builds use `composer install --no-dev -o` (IONOS/Makefile), so production would never have loaded them — but any developer running a plain composer install in this app would get a dead occ, for every command, not just ours. No test references the stubs: at test time the real server classes are available through the bootstrap. At runtime the real classes win in any case, because the server registers its autoloaders in lib/base.php long before an app's vendor autoloader — the same arrangement the existing OCA\Settings\Mailer\NewUserMailHelper stub relies on. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
C5 control PSS-07 requires evidence that this instance hashes passwords
with argon2id. Add an occ command that collects that evidence and emits
it as a structured artifact, plus the two lib classes behind it.
occ ncw_tools:security:selftest [--round-trip] [--sample-size=N]
[--output=plain|json|json_pretty]
exit 0 = PASS, 1 = FAIL, 2 = usage error
HashAlgorithm classifies a stored hash. This is the part an earlier
proposal got wrong: Nextcloud does not store a bare password_hash()
string. OC\Security\Hasher::hash() prepends a hasher version and a pipe,
so a stored value reads
3|$argon2id$v=19$m=65536,t=4,p=1$<salt>$<hash>
where version 3 is argon2id, 2 is argon2i and 1 is bcrypt. Comparing a
stored value against the literal prefix $argon2id$ can therefore never
match, no matter how the instance is configured. HashAlgorithm splits the
version prefix off first, mirroring the private Hasher::splitHash(), and
then classifies the remainder by its algorithm marker. Unprefixed legacy
hashes (60 char bcrypt, 40 char sha1 hex) and empty passwords get their
own classes, so a dormant account stays distinguishable from a downgraded
configuration.
Classifying by marker rather than by password_get_info() is deliberate.
PHP registers the argon2 password handlers under the same condition that
defines the constants (HAVE_ARGON2LIB || HAVE_LIBSODIUM), so on a build
without argon2 support password_get_info() reports `unknown` for a
perfectly good $argon2id$ hash. That is exactly the build this self-test
exists to catch, and it is the build on which stored_distribution has to
stay truthful: those rows really are argon2id, and reporting them as
`unknown` would blur the finding instead of sharpening it. Marker
matching is what upstream's own handlers do in any case — argon2 accepts
any $argon2id$... string, and bcrypt any 60 character $2y... one. The
length check is kept, so 1|$2y$10$tooshort stays unknown, while the
$2a/$2b revisions classify as bcrypt, which is what the unprefixed
branch has always said about them.
The loose (int) cast in stripVersionPrefix() is deliberate too. Upstream
Hasher::splitHash() splits on (int)$parts[0] > 0, so Nextcloud itself
reads 3foo|<hash> as version 3 and verifies the remainder as argon2id.
Tightening it to digits-only would report `unknown` for a value the
instance verifies happily — inventing an anomaly instead of reporting
one.
SecuritySelfTest is the collector. It returns a plain array and writes no
output of its own, so the command and the tests share one code path. It
runs three checks plus an optional round trip:
- configured_algorithm: hash a random probe through IHasher and classify
the result. This is the algorithm every new password gets.
- stored_distribution: count the surveyed rows of the users table per
algorithm, honouring the sample size (0 = all). Rows with no local
password are tolerated (SSO-only accounts); anything that is neither
argon2id nor empty fails the survey.
- security_config: read the hardening switches through IConfig, so the
effective merged configuration is asserted rather than a single file.
hashing_default_password is the real downgrade switch --
Hasher::getPrefferedAlgorithm() returns PASSWORD_DEFAULT as soon as it
is true. passwordsalt and secret are asserted as presence only.
- round_trip (opt-in): create ncw-selftest-<random> with a password
covering all four character classes so password_policy accepts it,
never set an email address, read the stored hash back through the query
builder, and delete the user in a finally. cleaned_up is re-checked by
resolving the uid again, and a surviving probe user fails the check.
Security invariant, enforced by review: no field of the artifact may
carry hash material, a salt or a secret. Only algorithm names, counts,
booleans and cost parameters are reported. The cost parameters come from
password_get_info() on the probe hash rather than from the hashing*
config keys, because the hasher clamps those to the algorithm minimums --
the probe reports the effective values, which is the stronger evidence.
parametersFromStoredHash() is the one place that still needs the
registered handler, since only it can read the cost fields; it degrades
to an empty array on a build without argon2 support, which is sound
because the parameters are reported as evidence and never asserted.
The service takes a LoggerInterface alongside the five collaborators
because the artifact schema is fixed and has no field for a reason
string: a round trip that fails has to explain itself in the log.
The command keeps stdout free of everything but the artifact, because the
deployment wrapper pipes it straight into jq. Diagnostics go to the
console's error output, and the artifact is logged once at info level
with itself as structured context, which is what reaches Kibana under
log_type=errorlog. A FAIL writes the complete artifact before exiting 1 --
the failure case is exactly what the evidence needs to capture -- while a
usage error writes no artifact at all.
INSTANCE_NAME, NAMESPACE and ENVIRONMENT are arbitrary bytes from the
environment, so they are sanitised where they are read rather than at the
encode boundary. The logger context is encoded as JSON too, so invalid
UTF-8 would drop the Kibana line as well as stdout; fixing only
json_encode() in the command would have left the channel this ticket
actually requires exposed. An unencodable label must not cost the
artifact. JSON_INVALID_UTF8_SUBSTITUTE stays on the command as a
backstop.
The classifier, the service and the command land together because psalm
runs with findUnusedCode and the only entry point is registered in
appinfo/info.xml, which psalm cannot see; splitting them would leave an
intermediate commit failing static analysis.
Signed-off-by: Misha M.-Kupriyanov <[email protected]>
Table-driven coverage for HashAlgorithm over real fixtures, because the version prefix is exactly what an earlier shell-based check missed. Captured fixtures pin the three prefixed forms (3| argon2id, 2| argon2i, 1| bcrypt) as they are actually written to oc_users.password. The provider additionally generates fresh hashes with password_hash(), so the classifier is exercised against what the current PHP build produces rather than only against strings committed a year ago. The freshly hashed argon2 pair is conditional on argon2 support, because PHP defines PASSWORD_ARGON2ID only when it was built with it and referencing the constant otherwise is a fatal Error. The captured fixtures keep argon2 classification covered on such a build, which is the point of classifying by marker rather than by password_get_info(): forcing the support helper to false leaves 35 tests with one skip and no failures. The rest of the table covers the unprefixed legacy forms ($2y and $2a bcrypt, sha1 in both letter cases), the empty string, and the edge cases that must not be mistaken for a hash: garbage, an empty hash behind a valid prefix, a non-numeric or zero version prefix, an md5 digest, and 40 non-hex or 60 non-bcrypt characters. A future version prefix still classifies by the inner hash, which is the intended behaviour. The marker rules are pinned as well: an argon2id marker with an unparseable body still classifies as argon2id, exactly as upstream's own handler treats it; a prefixed $2a or $2b hash is bcrypt; and a prefixed bcrypt marker that is not 60 characters long is unknown. One dedicated test spells out the defect as a regression guard: a stored argon2id hash does not start with $argon2id$, it starts with 3|$argon2id$. It uses the captured fixture rather than password_hash(), so the one test that must never be skipped never is. parametersFromStoredHash is covered for argon2id (memory_cost, time_cost, threads), bcrypt (cost) and the forms that carry no parameters, plus an assertion that it only ever yields string keys with integer values -- it must never become a route for hash material to reach the artifact. The argon2 case deliberately avoids the PHP defaults 65536/4/1: password_get_info() seeds its result with those very defaults and overwrites them only when it can parse the hash, so asserting them would hold even if the parsing were broken. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
Unit coverage for SecuritySelfTest with every collaborator mocked, so the verdict logic is pinned without touching a database or creating users. The artifact shape itself is asserted key by key, in order, because another stream parses it with jq and the schema is frozen: the six top-level keys, the instance fields, the password_hashing and round_trip fields, the four always-present distribution buckets, and the check entry shape. Verdicts covered: - PASS on argon2id with a hardened configuration. - FAIL when the configured algorithm is bcrypt or argon2i, with the hardening result left untouched, so the two halves of the artifact are shown to be independent. - FAIL for each of the seven security_config assertions in turn -- hashing_default_password enabled, brute-force or rate-limit protection disabled, plain http, an unset protocol, and a missing passwordsalt or secret -- each asserting that exactly the expected key failed. - The distribution counting every algorithm, including the buckets that only appear when observed (argon2i, legacy-bcrypt, legacy-sha1), that a stored bcrypt hash fails the survey, and that rows without a local password do not. - The sample size reaching setMaxResults, and 0 leaving it unset. - The round trip: skipped by default and unable to drag the result down; passing and deleting the probe user; failing when the stored hash is not argon2id, when the probe user survives deletion, when createUser throws, and when it returns false. The failure paths assert the logged message and that the exception object itself is never in the context. - The probe user never getting an email address, and the generated probe password carrying all four character classes. Two tests enforce the security invariant directly: the encoded artifact is searched for the real stored hashes (prefixed and unprefixed) and for the configured passwordsalt and secret, none of which may appear. A third guards the encodability of the artifact: testInvalidUtf8InTheEnvironmentStillYieldsAnEncodableArtifact feeds invalid UTF-8 through the instance labels that come from the environment and asserts the report still encodes. Without the sanitising it fails on mb_check_encoding, which is the shape the defect took -- an empty stdout and a dropped Kibana line rather than a wrong value. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
The unit suite mocks the query builder, so it proves the counting logic but not that the query works or that the round trip really writes and removes a row. Add integration coverage against the real database. The service is resolved through the app container, so the constructor's autowiring is covered as well. Covered: - The survey sums to the actual row count of the users table, and always reports the four frozen buckets as integers. - A sample size of 1 really limits the query to one row. - The configured algorithm on a real instance is argon2id, and the reported parameters are the argon2 triple rather than bcrypt's cost. - The full round trip: the stored algorithm is argon2id, cleaned_up is true, the probe user is gone from both IUserManager and the users table, and the uid it used starts with ncw-selftest-. - The probe user has no email address. It only exists between createUser() and delete(), so a UserCreatedEvent listener captures the address at the one moment it can be inspected. tearDown deletes any probe account the listener saw, so a failing assertion can never leave one behind. - The security invariant against real data: every stored hash is read straight from the database and, together with the configured passwordsalt and secret, asserted absent from the encoded artifact. Runs on sqlite locally; the phpunit-mysql workflow covers MySQL. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
Whoever reads a PSS-07 artifact in six months will not have this branch in front of them, so write down what each field means, what makes the command pass, and how to find the log line. Contents: - Usage, the three exit codes, and the stdout contract the deployment wrapper depends on: only the artifact on stdout, diagnostics on stderr, and a complete artifact even on FAIL. - Why a stored hash cannot be matched against $argon2id$, with the 3|$argon2id$... shape spelled out, so the defect this replaces cannot be reintroduced from the documentation either. Also why the classifier reads the algorithm marker rather than calling password_get_info(): PHP knows argon2 there only when it was built with argon2 support, and the stored distribution has to stay truthful on precisely the build that lacks it. - The security invariant, and the fact that both test suites enforce it. - Field-by-field meaning, including why stored_distribution has four guaranteed buckets and additional ones only when observed, why empty rows are tolerated, and why the cost parameters come from the probe hash rather than from the hashing* config keys -- and that they are empty for argon2 on a build without argon2 support. - The Kibana queries, plus a caveat that matters in practice: Nextcloud's log writer serialises nested context arrays into JSON strings, so data.result is directly queryable but data.password_hashing arrives as a string that needs a parse. Consumers that want structured nested fields should use the stdout artifact. - A note that the round trip dispatches user events and therefore causes one extra user-count report on the next cron tick. - A mermaid flow in the style of the existing docs/events pages, and a failure-mode table that maps each symptom to its interpretation -- notably that a bcrypt configured_algorithm with hashing_default_password passing means the PHP build lacks argon2 support, which is an image problem rather than a configuration one. That row also records what the rest of the artifact looks like on such a build, and that no existing account can authenticate there at all. docs/README.md grows a Commands section, since it previously only indexed event flows, and REUSE.toml lists the new page. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
e056785 to
d03ed1d
Compare
There was a problem hiding this comment.
🟢 Approval recommended
The changes are cohesive, well-tested (unit + integration), and the security/evidence invariants described in the PR are concretely enforced by automated tests.
Review details
- Files reviewed: 16/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
security_config.parameters is a map, but an empty PHP array encodes as
`[]` rather than `{}`. A consumer reading
jq '.security_config.parameters.memory_cost'
then does not get null, it errors out with "Cannot index array with
memory_cost" -- the field changes type depending on its contents.
The empty case is reachable: password_get_info() only reports cost fields
for an algorithm whose handler PHP has registered, so an argon2 hash on a
build without argon2 support yields nothing, and so do the $2a/$2b bcrypt
revisions that the classifier now recognises but upstream's bcrypt handler
rejects. IHasher does not produce either today, which is exactly why this
would have surfaced in production rather than in CI.
Cast the map in the service rather than at an encode boundary. Both
evidence channels serialise the artifact -- stdout and the logger context
that reaches Kibana -- so a cast in the command's JSON branch would have
fixed stdout and left the Kibana document with `[]`, which is the mistake
the environment-label sanitising already had to correct once. The service
is also the only layer the tests can reach, since symfony/console is a
psalm-only stub here and the command cannot be instantiated in a unit test.
The plain formatter reads the map with get_object_vars(). No schema_version
bump: this narrows the field to the object the documentation always claimed.
Signed-off-by: Misha M.-Kupriyanov <[email protected]>
The artifact shape is a contract across four repos -- nc-manager/bin/ send-report.sh reads it with jq and forwards it to the report endpoint -- but it existed only as a prose table and an example block, so nothing stopped the producer and its consumers drifting apart. Publish it as docs/security-selftest.schema.json (JSON Schema 2020-12) and validate real artifacts against it in both suites, so the schema cannot describe something the command no longer emits. A consumer can now check an artifact it receives with any standard tool instead of trusting the sender. The schema pins more than field types. It encodes the verdict invariants, which is where the evidence value is: - a top-level PASS requires both sections to have passed - a passing password_hashing requires argon2id, no round-trip failure, and not one surveyed row outside the tolerated argon2id/empty buckets - a SKIPPED round trip must report nothing, and one that passed must show argon2id with cleaned_up true - stored_distribution keys are restricted to the algorithms HashAlgorithm can report, so a typo cannot invent a bucket - additionalProperties is false throughout, so an accidental new field is a schema violation rather than a silent addition to a frozen contract Checked against the artifact a real instance produces, and against fifteen mutations of it -- a PASS claimed over a failing section, a PASS alongside bcrypt rows, a SKIPPED round trip carrying a cleaned_up flag, parameters as `[]`, a bogus distribution bucket, a timestamp without its Z -- each of which the schema rejects. opis/json-schema joins require-dev for the validation, so it is absent from release builds (composer install --no-dev -o). One unit test asserts the schema's pinned schema_version still matches SecuritySelfTest::SCHEMA_VERSION, so the two cannot silently diverge. Signed-off-by: Misha M.-Kupriyanov <[email protected]>
8a12c74 to
25ae4e1
Compare
Adds
occ ncw_tools:security:selftest, which verifies that this instance hashes passwords with argon2id and emits a structured evidence artifact for C5 control PSS-07 (Credential Storage Security).Ticket: NSW-957. Supersedes the shell-based approach in
nextcloud-workspace/images!114.Why it lives here rather than in a shell script
The original proposal verified the algorithm from a standalone PHP script in the
nc-managercontainer, outside the Nextcloud runtime. That is what caused its central defect: it comparedoc_users.passwordagainst the literal prefix$argon2id$, butHasher::hash()returns3 . '|' . password_hash(...)andDatabase::createUser()stores exactly that — so the column holds3|$argon2id$v=19$…and the comparison could never match. The self-test reported a permanent FAIL on a correctly configured instance.Inside the app we get
IHasher(the authority on the algorithm),IConfig(the effective merged config, not one file),IDBConnection(correct driver, table prefix and replica handling instead of a raw PDO DSN hardcoded to MySQL) — and unit tests, which is what actually prevents a recurrence.What it checks
IHasherand classifies the result. This is the algorithm every new password receives.oc_users, so the evidence attests the real user population rather than a fixture. Passes when every row isargon2idor empty (empty = SSO-only accounts, not a downgrade).hashing_default_passwordis false (the one system value that silently downgradesHashertoPASSWORD_DEFAULT), bruteforce and rate-limit protection enabled,overwriteprotocolhttps, andpasswordsalt/secretpresent. Reports the effective argon2 cost parameters as evidence.--round-trip(opt-in) — the ticket's literal scenario: create a disposable user, read its stored hash back from the database, classify it, delete it. Off by default so the command is safe against a production instance; enabled via Helm only on the dedicated self-test instance, which has no live users. The test user never receives an email address.Output
--output=jsonwrites the artifact to stdout; the same artifact is logged throughLoggerInterface, which reaches Kibana vialog_type=errorlog. Exit 0 = PASS, 1 = FAIL, 2 = usage error.stdout is JSON-only — all diagnostics go to stderr — because
nc-manager/bin/selftest.shpipes it intojq. A FAIL still prints the complete artifact before exiting 1, since that is exactly the case the evidence needs to capture.No field ever carries hash material, a salt, or a secret value. Only algorithm names, counts, booleans and cost parameters. The secret checks are keyed
passwordsalt_present/secret_presentsoactual: truecannot be misread as a value. There is a unit test for this invariant.The artifact is a published contract
The shape is consumed across four repos —
nc-manager/bin/send-report.shreads it withjqand forwards it to the report endpoint — so it ships as a JSON Schema,docs/security-selftest.schema.json(draft 2020-12), and both test suites validate real artifacts against it. The schema cannot describe something the command no longer emits, and a consumer can validate what it receives instead of trusting the sender.It pins more than field types. The verdict invariants are where the evidence value is:
PASSrequires both sections to have passedpassword_hashingrequiresargon2id, no round-trip failure, and not one surveyed row outside the toleratedargon2id/emptybucketsSKIPPEDround trip must report nothing, and one that passed must showargon2idwithcleaned_up: truestored_distributionkeys are restricted to the algorithmsHashAlgorithmcan report, so a typo cannot invent a bucketadditionalPropertiesis false throughout, so an accidental new field is a violation rather than a silent addition to a frozen contractChecked against the artifact a real instance produces and against fifteen mutations of it — a
PASSclaimed over a failing section, aPASSalongside bcrypt rows, aSKIPPEDround trip carrying acleaned_upflag,parametersas[], a bogus distribution bucket, a timestamp without itsZ— each of which the schema rejects. A unit test asserts the schema's pinnedschema_versionstill matchesSecuritySelfTest::SCHEMA_VERSION.Two decisions worth a reviewer's attention
Hashes are classified by algorithm marker, not by
password_get_info(). PHP registers the argon2 handlers under the same condition that defines the constants (HAVE_ARGON2LIB || HAVE_LIBSODIUM), so on a build without argon2 supportpassword_get_info()reportsunknownfor a perfectly good$argon2id$hash. That is precisely the build this self-test exists to catch, and it is the build on whichstored_distributionhas to stay truthful: those rows really are argon2id, and reporting them asunknownwould blur the finding instead of sharpening it. Marker matching is what upstream's own handlers do anyway.parametersFromStoredHash()is the one place that still needs the registered handler and degrades to an empty map without it, which is sound because the cost parameters are evidence and never asserted.security_config.parametersis a JSON object even when empty. An empty PHP array encodes as[], not{}, sojq '.security_config.parameters.memory_cost'would error with "Cannot index array with memory_cost" rather than return null — the field would change type with its contents. The cast happens in the service rather than at an encode boundary, because both evidence channels serialise the artifact and a cast in the command's JSON branch would have fixed stdout while leaving the Kibana document with[]. Noschema_versionbump: it narrows the field to the object the documentation always claimed.Verified
test:unittest:integrationpsalmcs:checkreuse lintEvery commit was verified against each gate individually — lint,
cs:check,psalm,test:unitandtest:integration— not just the tip, so a bisect never lands on a red commit.Against a real instance,
configured_algorithmreportsargon2idfrom a3|$argon2id$…stored hash — the case the shell version got wrong — and--round-tripreturnsstored_algorithm: argon2id,cleaned_up: truewith no leftover user. Flippinghashing_default_passwordto true is caught three ways:configured_algorithm: bcrypt,round_trip.stored_algorithm: bcrypt, andparametersswitching from the argon2 triple to{"cost":10}.Notes for review
OC\Core\Command\Baseand four Symfony Console interfaces are stubbed for psalm undertests/psalm-stubs/, deliberately not undertests/stubs/: that directory is in theautoload-devclassmap, andlib/AppInfo/Application.phprequiresvendor/autoload.phpat runtime, so a dev-modecomposer installput the Symfony stubs into the live classmap where they shadowed the real classes in3rdparty/and fataled everyocccommand. Release builds (composer install --no-dev -o) were never affected.composer.json/composer.lockaddopis/json-schematorequire-devfor the artifact validation. Dev-only, so it is absent from release builds.psalm.xmlsuppressions were added for the command class and constructors, mirroring the existing entries forApplication—findUnusedCodecannot see registrations inappinfo/info.xml.security_config.parametersis read frompassword_get_info()on the probe hash rather than fromIConfig, becauseHasherclamps the configured values to the algorithm minimums. The reported values are therefore the effective ones.data.result,data.schema_versionanddata.timestampare directly queryable;data.instance,data.password_hashinganddata.security_configarrive as strings needing a parse. Consumers wanting structured nested fields should use the stdout artifact. Documented indocs/security-selftest.md.pdo_sqlite/pdo_pgsqlonly); thephpunit-mysqlworkflow covers it. The survey usesIQueryBuilderwith the unprefixed table nameusers, so no dialect-specific SQL is involved.Follow-ups in other repos
nextcloud-workspace/images!117—selftest.shinvokes this command and pipes the artifact intosend-report.sh. Supersedes !114.nextcloud-workspace/helm!150— a dedicatednextcloud-selftestJob (post-install/post-upgradeat hook weight 10, afternextcloud-manager), its values and gating, and a concept doc indoc/selftest.md. Supersedes !146.ncw-server— submodule pointer bump, after this merges.