Skip to content

feat(security): verify argon2id password hashing via occ selftest (NSW-957) - #38

Open
printminion-co wants to merge 8 commits into
mainfrom
mk/dev/NSW-957-password-hashing-selftest
Open

feat(security): verify argon2id password hashing via occ selftest (NSW-957)#38
printminion-co wants to merge 8 commits into
mainfrom
mk/dev/NSW-957-password-hashing-selftest

Conversation

@printminion-co

@printminion-co printminion-co commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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-manager container, outside the Nextcloud runtime. That is what caused its central defect: it compared oc_users.password against the literal prefix $argon2id$, but Hasher::hash() returns 3 . '|' . password_hash(...) and Database::createUser() stores exactly that — so the column holds 3|$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

  1. Configured algorithm — hashes a random probe through IHasher and classifies the result. This is the algorithm every new password receives.
  2. Stored distribution — counts the algorithms actually present in oc_users, so the evidence attests the real user population rather than a fixture. Passes when every row is argon2id or empty (empty = SSO-only accounts, not a downgrade).
  3. Hardening config — asserts hashing_default_password is false (the one system value that silently downgrades Hasher to PASSWORD_DEFAULT), bruteforce and rate-limit protection enabled, overwriteprotocol https, and passwordsalt/secret present. Reports the effective argon2 cost parameters as evidence.
  4. --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=json writes the artifact to stdout; the same artifact is logged through LoggerInterface, which reaches Kibana via log_type=errorlog. Exit 0 = PASS, 1 = FAIL, 2 = usage error.

stdout is JSON-only — all diagnostics go to stderr — because nc-manager/bin/selftest.sh pipes it into jq. 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_present so actual: true cannot 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.sh reads it with jq and 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:

  • 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 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. A unit test asserts the schema's pinned schema_version still matches SecuritySelfTest::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 support password_get_info() reports unknown for a perfectly good $argon2id$ hash. That is precisely 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 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.parameters is a JSON object even when empty. An empty PHP array encodes as [], not {}, so jq '.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 []. No schema_version bump: it narrows the field to the object the documentation always claimed.

Verified

Gate Before After
test:unit 36 tests / 90 assertions 106 / 260
test:integration 3 / 4 9 / 31 (sqlite)
psalm clean clean, 100% inference
cs:check 0 of 31 0 of 37
reuse lint compliant compliant

Every commit was verified against each gate individually — lint, cs:check, psalm, test:unit and test:integration — not just the tip, so a bisect never lands on a red commit.

Against a real instance, configured_algorithm reports argon2id from a 3|$argon2id$… stored hash — the case the shell version got wrong — and --round-trip returns stored_algorithm: argon2id, cleaned_up: true with no leftover user. Flipping hashing_default_password to true is caught three ways: configured_algorithm: bcrypt, round_trip.stored_algorithm: bcrypt, and parameters switching from the argon2 triple to {"cost":10}.

Notes for review

  • OC\Core\Command\Base and four Symfony Console interfaces are stubbed for psalm under tests/psalm-stubs/, deliberately not under tests/stubs/: that directory is in the autoload-dev classmap, and lib/AppInfo/Application.php requires vendor/autoload.php at runtime, so a dev-mode composer install put the Symfony stubs into the live classmap where they shadowed the real classes in 3rdparty/ and fataled every occ command. Release builds (composer install --no-dev -o) were never affected.
  • composer.json / composer.lock add opis/json-schema to require-dev for the artifact validation. Dev-only, so it is absent from release builds.
  • Two psalm.xml suppressions were added for the command class and constructors, mirroring the existing entries for ApplicationfindUnusedCode cannot see registrations in appinfo/info.xml.
  • security_config.parameters is read from password_get_info() on the probe hash rather than from IConfig, because Hasher clamps the configured values to the algorithm minimums. The reported values are therefore the effective ones.
  • Kibana caveat: Nextcloud's log writer serialises nested context arrays into JSON strings. data.result, data.schema_version and data.timestamp are directly queryable; data.instance, data.password_hashing and data.security_config arrive as strings needing a parse. Consumers wanting structured nested fields should use the stdout artifact. Documented in docs/security-selftest.md.
  • MySQL was not exercised locally (the dev container has pdo_sqlite / pdo_pgsql only); the phpunit-mysql workflow covers it. The survey uses IQueryBuilder with the unprefixed table name users, so no dialect-specific SQL is involved.

Follow-ups in other repos

  • nextcloud-workspace/images!117selftest.sh invokes this command and pipes the artifact into send-report.sh. Supersedes !114.
  • nextcloud-workspace/helm!150 — a dedicated nextcloud-selftest Job (post-install/post-upgrade at hook weight 10, after nextcloud-manager), its values and gating, and a concept doc in doc/selftest.md. Supersedes !146.
  • ncw-server — submodule pointer bump, after this merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 SecuritySelfTest service + SecuritySelfTest occ command that surveys stored hashes, verifies hardening config, and optionally performs a round-trip probe user check.
  • Adds HashAlgorithm classifier 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.

Comment thread lib/Command/SecuritySelfTest.php Outdated
Comment on lines +94 to +99
$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;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +102 to +113
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];
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

printminion-co added a commit that referenced this pull request Sep 2, 2026
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().
@printminion-co
printminion-co requested a lite review from Copilot September 3, 2026 08:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_ARGON2ID directly; 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 asserting password_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_ARGON2ID directly; on PHP builds without Argon2 support it will fatal. Skip when argon2id is unavailable and assert password_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_ARGON2ID directly; on PHP builds without Argon2 support it will fatal. Skip when argon2id is unavailable and assert password_hash() returns a string before passing it into parametersFromStoredHash().
	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

Comment on lines +86 to +103
// 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,
];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/$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 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.

printminion-co added a commit that referenced this pull request Sep 4, 2026
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]>
@printminion-co
printminion-co requested a lite review from Copilot September 4, 2026 16:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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

printminion-co added a commit that referenced this pull request Sep 4, 2026
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]>
@printminion-co
printminion-co force-pushed the mk/dev/NSW-957-password-hashing-selftest branch from 2180b7b to e056785 Compare September 4, 2026 16:54
printminion-co added a commit that referenced this pull request Sep 4, 2026
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]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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]>
@printminion-co
printminion-co force-pushed the mk/dev/NSW-957-password-hashing-selftest branch from 8a12c74 to 25ae4e1 Compare September 4, 2026 21:23
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.

2 participants