diff --git a/REUSE.toml b/REUSE.toml
index 34d2636..7ef0f03 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -44,7 +44,8 @@ path = [
"CODE_OF_CONDUCT.md",
"docs/README.md",
"docs/events/post-setup.md",
- "docs/events/user-stats.md"
+ "docs/events/user-stats.md",
+ "docs/security-selftest.md"
]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 STRATO GmbH"
diff --git a/appinfo/info.xml b/appinfo/info.xml
index eb6bb1e..17e6ec1 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -14,4 +14,7 @@
+
+ OCA\NcwTools\Command\SecuritySelfTest
+
diff --git a/docs/README.md b/docs/README.md
index 6535d63..99d1614 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,12 @@
-# Event Flows
+# NCW Tools documentation
+
+## Event flows
NCW Tools reacts to Nextcloud lifecycle events by registering listeners that enqueue background jobs. Each document below walks through one end-to-end flow — the triggering event, the listener's synchronous work, the job's asynchronous behaviour, and the configuration it relies on.
- [Post-setup welcome mail](events/post-setup.md) — sends the initial welcome email to the admin user once the system is reachable after installation.
- [User stats reporting](events/user-stats.md) — reports the current total user count to the PSS Stats API after every user create or delete.
+
+## Commands
+
+- [Security self-test](security-selftest.md) — `occ ncw_tools:security:selftest` verifies that password hashing is argon2id and emits the C5 PSS-07 evidence artifact.
diff --git a/docs/security-selftest.md b/docs/security-selftest.md
new file mode 100644
index 0000000..9f2b6a8
--- /dev/null
+++ b/docs/security-selftest.md
@@ -0,0 +1,203 @@
+# Security self-test
+
+`occ ncw_tools:security:selftest` verifies that this instance hashes passwords with **argon2id** and that the surrounding hardening switches are in place, then emits a structured evidence artifact for C5 control **PSS-07**. The command is the evidence producer: a deployment wrapper runs it, pipes the artifact into `jq`, and archives the result. The same artifact is written to the log as structured context so it also lands in Kibana.
+
+## Usage
+
+```
+occ ncw_tools:security:selftest [--round-trip] [--sample-size=N] [--output=plain|json|json_pretty]
+```
+
+| Option | Default | Purpose |
+| --- | --- | --- |
+| `--round-trip` | off | Create a disposable probe user, read back the algorithm that actually landed in `oc_users.password`, then delete it again. This is the only check that observes the *whole* write path rather than a hasher probe. |
+| `--sample-size=N` | `1000` | Number of rows to survey in the stored hash distribution. `0` surveys all rows. |
+| `--output` | `plain` | `json` and `json_pretty` emit the evidence artifact; `plain` renders it for humans. |
+
+| Exit code | Meaning |
+| --- | --- |
+| `0` | PASS — every executed check passed. |
+| `1` | FAIL — at least one executed check failed. The complete artifact is still written to stdout. |
+| `2` | Usage error (unknown `--output` format, invalid `--sample-size`). No artifact is written. |
+
+**stdout carries nothing but the artifact.** Diagnostics, warnings and the log line go to stderr, so `occ ncw_tools:security:selftest --output=json | jq` is safe. On a FAIL the artifact is written in full *before* the non-zero exit — the failure case is precisely what the evidence needs to capture.
+
+## Why a stored hash is not simply matched against `$argon2id$`
+
+Nextcloud does not store a bare `password_hash()` string. `OC\Security\Hasher::hash()` prepends a hasher version and a pipe:
+
+```
+3|$argon2id$v=19$m=65536,t=4,p=1$$
+```
+
+Version `3` is argon2id, `2` is argon2i, `1` is bcrypt. Hashes written before the version prefix existed are stored unprefixed — a 60 character bcrypt string or a 40 character sha1 hex digest. A check that compares the stored value against the literal prefix `$argon2id$` therefore never matches, no matter how the instance is configured. `OCA\NcwTools\Security\HashAlgorithm` splits the version prefix off first (mirroring the private `Hasher::splitHash()`) and then classifies the remainder with `password_get_info()`.
+
+## The evidence artifact
+
+```json
+{
+ "schema_version": "3",
+ "timestamp": "2026-09-01T12:00:00Z",
+ "result": "PASS",
+ "instance": { "id": "ocb0ycrd5a7h", "url": "https://cloud.example.com", "name": "", "namespace": "", "environment": "" },
+ "password_hashing": {
+ "result": "PASS",
+ "configured_algorithm": "argon2id",
+ "round_trip": { "result": "SKIPPED", "stored_algorithm": null, "cleaned_up": null },
+ "stored_distribution": { "argon2id": 1, "bcrypt": 0, "empty": 0, "unknown": 0 }
+ },
+ "security_config": {
+ "result": "PASS",
+ "checks": [ { "key": "hashing_default_password", "expected": false, "actual": false, "result": "PASS" } ],
+ "parameters": { "memory_cost": 65536, "time_cost": 4, "threads": 1 }
+ }
+}
+```
+
+### Security invariant
+
+**No field ever carries hash material, a salt or a secret value.** Only algorithm names, counts, booleans and cost parameters are reported. `passwordsalt` and `secret` are reported as presence (`true`/`false`) under the keys `passwordsalt_present` and `secret_present` — never as values. Both the unit and the integration suite assert this by encoding the artifact and searching it for the real stored hashes and config secrets.
+
+### Fields
+
+| Field | Meaning |
+| --- | --- |
+| `schema_version` | Artifact schema version. Currently `"3"` (a string). |
+| `timestamp` | Collection time, UTC, `YYYY-MM-DDTHH:MM:SSZ`. |
+| `result` | `PASS` only when both `password_hashing.result` and `security_config.result` are `PASS`. |
+| `instance.id` | `instanceid` from `IConfig`. |
+| `instance.url` | `overwrite.cli.url` from `IConfig`. Used downstream as the fallback identifier. |
+| `instance.name` / `namespace` / `environment` | From the environment variables `INSTANCE_NAME`, `NAMESPACE`, `ENVIRONMENT`; empty string when unset. |
+| `password_hashing.configured_algorithm` | The algorithm a *new* password gets, determined by hashing a random probe through `IHasher` and classifying the result. Must be `argon2id`. |
+| `password_hashing.round_trip` | See below. `SKIPPED` unless `--round-trip` is given, and a skipped round trip never affects the result. |
+| `password_hashing.stored_distribution` | Row counts per algorithm over the surveyed rows of the `users` table. |
+| `security_config.checks` | One entry per asserted config value: `key`, `expected`, `actual`, `result`. |
+| `security_config.parameters` | Evidence, not an assertion: the cost parameters the probe hash was actually produced with, as reported by `password_get_info()`. `memory_cost`/`time_cost`/`threads` for argon2, `cost` for bcrypt. These are the *effective* values — stronger evidence than the `hashing*` config keys, which the hasher clamps to the algorithm minimums. |
+
+### `stored_distribution`
+
+The four buckets `argon2id`, `bcrypt`, `empty` and `unknown` are always present, in that order. `argon2i`, `legacy-bcrypt` and `legacy-sha1` are added only when rows of that kind are actually observed, so their presence in an artifact is itself the finding. Consumers should read named keys (or sum `to_entries`) rather than assume a fixed key set.
+
+The survey passes when every counted row is `argon2id` or `empty`. Rows with no local password at all (SSO-only accounts) are not a hashing downgrade and are tolerated; anything else — `bcrypt`, `argon2i`, either legacy form, or `unknown` — fails the survey, because it means either a legacy account that has never re-authenticated or a configuration that has been downgraded.
+
+### `round_trip`
+
+| Field | Meaning |
+| --- | --- |
+| `result` | `SKIPPED` without `--round-trip`. Otherwise `PASS` when `stored_algorithm` is `argon2id` **and** `cleaned_up` is `true`. |
+| `stored_algorithm` | The algorithm classified from the row the probe user actually wrote, or `null` when the probe could not be created. |
+| `cleaned_up` | Re-checked after deletion by resolving the uid again; `true` means the probe user is gone. |
+
+The probe user is created as `ncw-selftest-` with a password assembled from all four character classes (so the always-enabled `password_policy` app accepts it) and **never** gets an email address, so it cannot receive mail or a password reset link. Deletion happens in a `finally`, and a probe user that survives is both logged at error level and reported as a failure. The reason a round trip failed is never in the artifact (the schema is fixed) — look for the `SecuritySelfTest: round-trip …` error lines in the log.
+
+Creating and deleting the probe user dispatches `UserCreatedEvent` and `UserDeletedEvent`, which this app's own `UserEventListener` reacts to by enqueuing a (deduplicated) `UserStatsJob`. Running the round trip therefore causes one extra user-count report on the next cron tick.
+
+### `security_config.checks`
+
+| `key` | Expected | Why |
+| --- | --- | --- |
+| `hashing_default_password` | `false` | The real downgrade switch. `Hasher::getPrefferedAlgorithm()` returns `PASSWORD_DEFAULT` — bcrypt today — as soon as this is `true`, so every new password stops being argon2id. |
+| `auth.bruteforce.protection.enabled` | `true` | Brute-force throttling on authentication endpoints. |
+| `ratelimit.protection.enabled` | `true` | Rate limiting on annotated controllers. |
+| `overwriteprotocol` | `https` | Credentials must never be submitted over plain http. |
+| `passwordsalt_present` | `true` | Presence of `passwordsalt`; needed to verify legacy hashes. Value never reported. |
+| `secret_present` | `true` | Presence of `secret`. Value never reported. |
+
+All values are read through `IConfig`, so the **effective merged** configuration is asserted — `config/config.php` plus every `config/*.config.php` overlay — not a single file.
+
+## Log line and Kibana
+
+The command logs exactly one line at info level with the artifact as structured context:
+
+- message: `ncw_tools security selftest`
+- app: `ncw_tools`
+
+The deployment sets `log_type=errorlog`, so the log JSON is written to stderr and picked up by the platform's log shipper.
+
+```
+app: "ncw_tools" AND message: "ncw_tools security selftest"
+```
+
+Narrow to failures:
+
+```
+app: "ncw_tools" AND message: "ncw_tools security selftest" AND data.result: "FAIL"
+```
+
+The round-trip diagnostics use the same app and are matched with:
+
+```
+app: "ncw_tools" AND message: "SecuritySelfTest: *"
+```
+
+**Caveat on nested fields.** Nextcloud's log writer serialises nested context arrays into JSON *strings*. The top-level scalars stay directly queryable — `data.schema_version`, `data.timestamp`, `data.result` — but `data.instance`, `data.password_hashing` and `data.security_config` arrive as strings containing JSON and need a parse (a Logstash/ingest `json` filter, or `| fromjson` at query time) before their inner fields can be filtered on. Use the stdout artifact, not the log line, when a consumer needs the nested fields structured.
+
+## Flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+
+ participant OCC as occ ncw_tools:security:selftest
+ participant CMD as Command\SecuritySelfTest
+ participant SVC as Security\SecuritySelfTest
+ participant H as IHasher
+ participant HA as HashAlgorithm
+ participant DB as IDBConnection
+ participant CFG as IConfig
+ participant UM as IUserManager
+ participant RND as ISecureRandom
+ participant LOG as LoggerInterface
+
+ OCC->>CMD: execute(--round-trip?, --sample-size, --output)
+
+ alt invalid --output or --sample-size
+ CMD-->>OCC: stderr message, exit 2
+ else options valid
+ CMD->>SVC: run(roundTrip, sampleSize)
+
+ SVC->>RND: generate(32, alphanumeric)
+ SVC->>H: hash(probe)
+ H-->>SVC: "3|$argon2id$…"
+ SVC->>HA: fromStoredHash(probe hash)
+ HA-->>SVC: configured_algorithm
+
+ SVC->>DB: SELECT password FROM users [LIMIT sampleSize]
+ loop every row
+ SVC->>HA: fromStoredHash(row)
+ HA-->>SVC: algorithm
+ end
+
+ alt --round-trip given
+ SVC->>RND: generate(mixed character classes)
+ SVC->>UM: createUser("ncw-selftest-…", probe password)
+ Note over SVC,UM: no email address is ever set
+ SVC->>DB: SELECT password FROM users WHERE uid = probe
+ SVC->>HA: fromStoredHash(stored)
+ SVC->>UM: delete() in finally
+ SVC->>UM: get(uid) → cleaned_up?
+ else round trip skipped
+ Note over SVC: round_trip.result = SKIPPED
+ end
+
+ SVC->>CFG: hardening switches + secret presence
+ SVC->>HA: parametersFromStoredHash(probe hash)
+ SVC-->>CMD: evidence artifact
+
+ CMD->>LOG: info("ncw_tools security selftest", artifact)
+ CMD-->>OCC: artifact on stdout
+ CMD-->>OCC: exit 0 on PASS, 1 on FAIL
+ end
+```
+
+## Failure modes
+
+| Symptom | Interpretation |
+| --- | --- |
+| `configured_algorithm` is `bcrypt` and `hashing_default_password` fails | The downgrade switch is on. Every password set from now on is bcrypt. |
+| `configured_algorithm` is `bcrypt` but `hashing_default_password` passes | The PHP build has no argon2 support (`PASSWORD_ARGON2ID` undefined). An image problem, not a config problem. |
+| `configured_algorithm` is `argon2id` but `round_trip.stored_algorithm` is not | Something between `IUserManager` and the database is rewriting the hash — a user backend that hashes on its own, for example. |
+| `stored_distribution` shows `bcrypt`, `argon2i` or a `legacy-*` bucket | Accounts that have not authenticated since the algorithm changed. Nextcloud rehashes on the next successful login; a persistent count means those accounts are dormant. |
+| `round_trip.cleaned_up` is `false` | A probe account was left behind. Delete `ncw-selftest-*` manually and investigate the logged error. |
+| `round_trip.result` is `FAIL` with `stored_algorithm: null` | The probe user could not be created — most likely `password_policy` rejected the generated password. See the logged error. |
+| Exit 2 with no artifact | Wrong invocation, not a control failure. |
diff --git a/lib/Command/SecuritySelfTest.php b/lib/Command/SecuritySelfTest.php
new file mode 100644
index 0000000..870fc16
--- /dev/null
+++ b/lib/Command/SecuritySelfTest.php
@@ -0,0 +1,204 @@
+setName(Application::APP_ID . ':security:selftest')
+ ->setDescription('Verify that password hashing is argon2id and emit an evidence artifact')
+ ->addOption(
+ 'round-trip',
+ null,
+ InputOption::VALUE_NONE,
+ 'Create and delete a disposable probe user to observe the algorithm actually written to the database',
+ )
+ ->addOption(
+ 'sample-size',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'Number of stored password hashes to survey (0 = all)',
+ (string)SecuritySelfTestService::DEFAULT_SAMPLE_SIZE,
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $errors = $this->errorOutput($output);
+
+ $format = $this->outputFormat($input);
+ if ($format === null) {
+ $errors->writeln('Invalid --output format, expected one of: ' . implode(', ', self::OUTPUT_FORMATS) . '');
+ return 2;
+ }
+
+ $sampleSize = $this->sampleSize($input);
+ if ($sampleSize === null) {
+ $errors->writeln('Invalid --sample-size, expected a non-negative integer (0 = all)');
+ return 2;
+ }
+
+ $report = $this->selfTest->run($input->getOption('round-trip') === true, $sampleSize);
+
+ // The structured context is what reaches Kibana; the deployment sets
+ // log_type=errorlog, so the context is serialised alongside the message.
+ $this->logger->info(self::LOG_MESSAGE, $report);
+
+ // A FAIL is the case the evidence exists for, so the complete artifact is
+ // written before the non-zero exit — never a short-circuited error path.
+ if ($format === self::OUTPUT_FORMAT_PLAIN) {
+ $this->writePlain($output, $report);
+ } else {
+ // JSON_INVALID_UTF8_SUBSTITUTE backs up the sanitising the service
+ // already does on environment-derived labels: an unencodable byte
+ // must degrade one field, never cost the entire artifact.
+ $flags = JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
+ | ($format === self::OUTPUT_FORMAT_JSON_PRETTY ? JSON_PRETTY_PRINT : 0);
+ $json = json_encode($report, $flags);
+ if ($json === false) {
+ $errors->writeln('Could not encode the evidence artifact: ' . json_last_error_msg() . '');
+ return 1;
+ }
+ $output->writeln($json);
+ }
+
+ return $report['result'] === SecuritySelfTestService::RESULT_PASS ? 0 : 1;
+ }
+
+ /**
+ * Diagnostics must not land on stdout: under `--output=json` the deployment
+ * wrapper pipes stdout straight into `jq`, and a single stray line there
+ * costs the whole evidence artifact.
+ */
+ private function errorOutput(OutputInterface $output): OutputInterface {
+ return $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output;
+ }
+
+ /**
+ * @param array{
+ * schema_version: string,
+ * timestamp: string,
+ * result: string,
+ * instance: array{id: string, url: string, name: string, namespace: string, environment: string},
+ * password_hashing: array{
+ * result: string,
+ * configured_algorithm: string,
+ * round_trip: array{result: string, stored_algorithm: string|null, cleaned_up: bool|null},
+ * stored_distribution: array
+ * },
+ * security_config: array{
+ * result: string,
+ * checks: list,
+ * parameters: array
+ * }
+ * } $report
+ */
+ private function writePlain(OutputInterface $output, array $report): void {
+ $output->writeln('schema_version: ' . $report['schema_version']);
+ $output->writeln('timestamp: ' . $report['timestamp']);
+ $output->writeln('result: ' . $report['result']);
+ $output->writeln('instance:');
+ foreach ($report['instance'] as $key => $value) {
+ $output->writeln(' ' . $key . ': ' . $value);
+ }
+
+ $hashing = $report['password_hashing'];
+ $output->writeln('password_hashing: ' . $hashing['result']);
+ $output->writeln(' configured_algorithm: ' . $hashing['configured_algorithm']);
+ $output->writeln(' round_trip: ' . $hashing['round_trip']['result']);
+ $output->writeln(' stored_algorithm: ' . ($hashing['round_trip']['stored_algorithm'] ?? '-'));
+ $output->writeln(' cleaned_up: ' . $this->formatValue($hashing['round_trip']['cleaned_up'] ?? '-'));
+ $output->writeln(' stored_distribution:');
+ foreach ($hashing['stored_distribution'] as $algorithm => $count) {
+ $output->writeln(' ' . $algorithm . ': ' . $count);
+ }
+
+ $security = $report['security_config'];
+ $output->writeln('security_config: ' . $security['result']);
+ foreach ($security['checks'] as $check) {
+ $output->writeln(sprintf(
+ ' %s: %s (expected %s, actual %s)',
+ $check['key'],
+ $check['result'],
+ $this->formatValue($check['expected']),
+ $this->formatValue($check['actual']),
+ ));
+ }
+ $output->writeln(' parameters:');
+ foreach ($security['parameters'] as $name => $value) {
+ $output->writeln(' ' . $name . ': ' . $value);
+ }
+ }
+
+ private function formatValue(bool|string $value): string {
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+
+ return $value;
+ }
+
+ private function outputFormat(InputInterface $input): ?string {
+ $format = $input->getOption('output');
+ if (!is_string($format) || !in_array($format, self::OUTPUT_FORMATS, true)) {
+ return null;
+ }
+
+ return $format;
+ }
+
+ private function sampleSize(InputInterface $input): ?int {
+ $sampleSize = $input->getOption('sample-size');
+ if (is_int($sampleSize)) {
+ return $sampleSize >= 0 ? $sampleSize : null;
+ }
+
+ if (!is_string($sampleSize) || $sampleSize === '' || !ctype_digit($sampleSize)) {
+ return null;
+ }
+
+ return (int)$sampleSize;
+ }
+}
diff --git a/lib/Security/HashAlgorithm.php b/lib/Security/HashAlgorithm.php
new file mode 100644
index 0000000..8386fd6
--- /dev/null
+++ b/lib/Security/HashAlgorithm.php
@@ -0,0 +1,134 @@
+$
+ *
+ * Version 3 is argon2id, version 2 is argon2i, version 1 is bcrypt. Comparing a
+ * stored value against the literal prefix `$argon2id$` therefore never matches —
+ * the version prefix has to be split off first, exactly as the private
+ * `Hasher::splitHash()` does.
+ *
+ * Hashes written before the version prefix existed are stored unprefixed: a
+ * 60 character bcrypt string or a 40 character sha1 hex digest.
+ *
+ * No method of this class ever returns hash material, a salt or a secret —
+ * only algorithm names and cost parameters.
+ */
+final class HashAlgorithm {
+ public const ARGON2ID = 'argon2id';
+ public const ARGON2I = 'argon2i';
+ public const BCRYPT = 'bcrypt';
+ public const LEGACY_BCRYPT = 'legacy-bcrypt';
+ public const LEGACY_SHA1 = 'legacy-sha1';
+ public const EMPTY = 'empty';
+ public const UNKNOWN = 'unknown';
+
+ /** Length of an unprefixed legacy bcrypt/PHPass hash. */
+ private const LEGACY_BCRYPT_LENGTH = 60;
+
+ /** Length of an unprefixed legacy sha1 hex digest. */
+ private const LEGACY_SHA1_LENGTH = 40;
+
+ /**
+ * @param string $stored The raw value of the `oc_users.password` column
+ * @return self::ARGON2ID|self::ARGON2I|self::BCRYPT|self::LEGACY_BCRYPT|self::LEGACY_SHA1|self::EMPTY|self::UNKNOWN
+ */
+ public static function fromStoredHash(string $stored): string {
+ if ($stored === '') {
+ return self::EMPTY;
+ }
+
+ $hash = self::stripVersionPrefix($stored);
+ if ($hash !== null) {
+ /** @var string $algoName `password_get_info()` always reports a name, 'unknown' included. */
+ $algoName = password_get_info($hash)['algoName'];
+
+ return self::fromAlgoName($algoName);
+ }
+
+ if (strlen($stored) === self::LEGACY_BCRYPT_LENGTH && str_starts_with($stored, '$2')) {
+ return self::LEGACY_BCRYPT;
+ }
+
+ if (strlen($stored) === self::LEGACY_SHA1_LENGTH && ctype_xdigit($stored)) {
+ return self::LEGACY_SHA1;
+ }
+
+ return self::UNKNOWN;
+ }
+
+ /**
+ * The cost parameters the given hash was produced with, as reported by
+ * `password_get_info()` — `memory_cost`/`time_cost`/`threads` for argon2,
+ * `cost` for bcrypt. These are the *effective* values, which is stronger
+ * evidence than the `hashing*` config keys because the hasher clamps those
+ * to the algorithm minimums.
+ *
+ * Returns an empty array for legacy, empty or unrecognised hashes.
+ *
+ * @return array
+ */
+ public static function parametersFromStoredHash(string $stored): array {
+ $hash = self::stripVersionPrefix($stored) ?? $stored;
+
+ // password_get_info() reports argon2's memory_cost/time_cost/threads and
+ // bcrypt's cost as integers, and an empty array for anything it does not
+ // recognise. It never reports hash material.
+ /** @var array $options */
+ $options = password_get_info($hash)['options'];
+
+ return $options;
+ }
+
+ /**
+ * Splits `|` and returns the hash part, mirroring the private
+ * `OC\Security\Hasher::splitHash()`. Returns null when the value carries no
+ * version prefix, i.e. when it is a legacy hash.
+ *
+ * The loose `(int)` cast is deliberate, not an oversight: upstream splits on
+ * `(int)$explodedString[0] > 0`, so Nextcloud itself accepts `3foo|`
+ * as version 3 and verifies the remainder as argon2id. This is evidence
+ * about what the instance actually does, so a stricter digits-only rule
+ * would report `unknown` for a value Nextcloud verifies happily — inventing
+ * an anomaly rather than reporting one. Malformed *hashes* are still caught:
+ * the remainder goes through `password_get_info()`, which yields `unknown`.
+ */
+ 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];
+ }
+
+ /**
+ * @return self::ARGON2ID|self::ARGON2I|self::BCRYPT|self::UNKNOWN
+ */
+ private static function fromAlgoName(string $algoName): string {
+ return match ($algoName) {
+ 'argon2id' => self::ARGON2ID,
+ 'argon2i' => self::ARGON2I,
+ 'bcrypt' => self::BCRYPT,
+ default => self::UNKNOWN,
+ };
+ }
+}
diff --git a/lib/Security/SecuritySelfTest.php b/lib/Security/SecuritySelfTest.php
new file mode 100644
index 0000000..e8a2f11
--- /dev/null
+++ b/lib/Security/SecuritySelfTest.php
@@ -0,0 +1,378 @@
+
+ * },
+ * security_config: array{
+ * result: self::RESULT_PASS|self::RESULT_FAIL,
+ * checks: list,
+ * parameters: array
+ * }
+ * }
+ */
+ public function run(bool $roundTrip = false, int $sampleSize = self::DEFAULT_SAMPLE_SIZE): array {
+ // One probe hash serves both checks: it reveals the algorithm every new
+ // password gets, and the cost parameters it was actually produced with.
+ // The probe hash itself is never reported.
+ $probeHash = $this->hasher->hash($this->probeMessage());
+
+ $passwordHashing = $this->checkPasswordHashing($probeHash, $roundTrip, $sampleSize);
+ $securityConfig = $this->checkSecurityConfig($probeHash);
+
+ return [
+ 'schema_version' => self::SCHEMA_VERSION,
+ 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
+ 'result' => $this->verdict(
+ $passwordHashing['result'] === self::RESULT_PASS
+ && $securityConfig['result'] === self::RESULT_PASS,
+ ),
+ 'instance' => $this->describeInstance(),
+ 'password_hashing' => $passwordHashing,
+ 'security_config' => $securityConfig,
+ ];
+ }
+
+ /**
+ * @return array{
+ * result: self::RESULT_PASS|self::RESULT_FAIL,
+ * configured_algorithm: string,
+ * round_trip: array{result: string, stored_algorithm: string|null, cleaned_up: bool|null},
+ * stored_distribution: array
+ * }
+ */
+ private function checkPasswordHashing(string $probeHash, bool $roundTrip, int $sampleSize): array {
+ $configuredAlgorithm = HashAlgorithm::fromStoredHash($probeHash);
+ $distribution = $this->surveyStoredHashes($sampleSize);
+ $roundTripReport = $roundTrip
+ ? $this->runRoundTrip()
+ : ['result' => self::RESULT_SKIPPED, 'stored_algorithm' => null, 'cleaned_up' => null];
+
+ $passed = $configuredAlgorithm === self::EXPECTED_ALGORITHM
+ && $this->surveyPassed($distribution)
+ && $roundTripReport['result'] !== self::RESULT_FAIL;
+
+ return [
+ 'result' => $this->verdict($passed),
+ 'configured_algorithm' => $configuredAlgorithm,
+ 'round_trip' => $roundTripReport,
+ 'stored_distribution' => $distribution,
+ ];
+ }
+
+ /**
+ * Counts stored hashes per algorithm. Only counts are returned — never a hash.
+ *
+ * @return array
+ */
+ private function surveyStoredHashes(int $sampleSize): array {
+ $counts = array_fill_keys(self::REPORTED_BUCKETS, 0);
+
+ $query = $this->db->getQueryBuilder();
+ // Table name without prefix on purpose: the query builder adds `oc_`.
+ $query->select('password')->from('users');
+ if ($sampleSize > 0) {
+ $query->setMaxResults($sampleSize);
+ }
+
+ $result = $query->executeQuery();
+ try {
+ while (($stored = $this->nextStoredHash($result)) !== null) {
+ $algorithm = HashAlgorithm::fromStoredHash($stored);
+ $counts[$algorithm] = ($counts[$algorithm] ?? 0) + 1;
+ }
+ } finally {
+ $result->closeCursor();
+ }
+
+ return $counts;
+ }
+
+ /**
+ * The next `password` value of the result, or null once it is exhausted.
+ *
+ * `IResult::fetchAssociative()` would be typed, but it only exists since
+ * Nextcloud 33 and this app supports 31.
+ */
+ private function nextStoredHash(IResult $result): ?string {
+ /** @var array|false $row */
+ $row = $result->fetch();
+ if ($row === false) {
+ return null;
+ }
+
+ return $this->asString($row['password'] ?? null);
+ }
+
+ /**
+ * @param array $distribution
+ */
+ private function surveyPassed(array $distribution): bool {
+ foreach ($distribution as $algorithm => $count) {
+ if ($count > 0 && !in_array($algorithm, self::TOLERATED_BUCKETS, true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Hashes a throwaway password through the real user pipeline and reads back
+ * what landed in the database, then removes the probe user again.
+ *
+ * @return array{result: string, stored_algorithm: string|null, cleaned_up: bool|null}
+ */
+ private function runRoundTrip(): array {
+ $uid = self::ROUND_TRIP_UID_PREFIX . $this->random->generate(12, ISecureRandom::CHAR_ALPHANUMERIC);
+ $user = null;
+ $storedAlgorithm = null;
+
+ try {
+ // Deliberately no email address: a disposable probe account must not
+ // be able to receive mail or a password reset link.
+ $user = $this->userManager->createUser($uid, $this->probePassword());
+ if ($user === false) {
+ $this->logger->error('SecuritySelfTest: could not create the round-trip probe user', ['uid' => $uid]);
+ } else {
+ $storedAlgorithm = HashAlgorithm::fromStoredHash($this->readStoredHash($uid));
+ }
+ } catch (\Throwable $e) {
+ // Message only, never the exception: deep frames can carry the
+ // throwaway password in their stack-trace arguments.
+ $this->logger->error('SecuritySelfTest: round-trip probe failed', [
+ 'uid' => $uid,
+ 'exceptionClass' => $e::class,
+ 'message' => $e->getMessage(),
+ ]);
+ } finally {
+ if ($user !== null && $user !== false) {
+ try {
+ $user->delete();
+ } catch (\Throwable $e) {
+ $this->logger->error('SecuritySelfTest: could not delete the round-trip probe user', [
+ 'uid' => $uid,
+ 'exceptionClass' => $e::class,
+ 'message' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ $cleanedUp = $this->userManager->get($uid) === null;
+ if (!$cleanedUp) {
+ $this->logger->error('SecuritySelfTest: round-trip probe user still exists', ['uid' => $uid]);
+ }
+
+ return [
+ 'result' => $this->verdict($storedAlgorithm === self::EXPECTED_ALGORITHM && $cleanedUp),
+ 'stored_algorithm' => $storedAlgorithm,
+ 'cleaned_up' => $cleanedUp,
+ ];
+ }
+
+ private function readStoredHash(string $uid): string {
+ $query = $this->db->getQueryBuilder();
+ $query->select('password')
+ ->from('users')
+ ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)));
+
+ $result = $query->executeQuery();
+ try {
+ return $this->asString($result->fetchOne());
+ } finally {
+ $result->closeCursor();
+ }
+ }
+
+ /**
+ * Reads the hardening switches through IConfig, so the *effective merged*
+ * configuration is asserted rather than a single config file.
+ *
+ * @return array{
+ * result: self::RESULT_PASS|self::RESULT_FAIL,
+ * checks: list,
+ * parameters: array
+ * }
+ */
+ private function checkSecurityConfig(string $probeHash): array {
+ $checks = [
+ // The real downgrade switch: Hasher::getPrefferedAlgorithm() falls
+ // back to PASSWORD_DEFAULT (bcrypt today) when this is true.
+ $this->check('hashing_default_password', false, $this->config->getSystemValueBool('hashing_default_password', false)),
+ $this->check('auth.bruteforce.protection.enabled', true, $this->config->getSystemValueBool('auth.bruteforce.protection.enabled', true)),
+ $this->check('ratelimit.protection.enabled', true, $this->config->getSystemValueBool('ratelimit.protection.enabled', true)),
+ $this->check('overwriteprotocol', 'https', $this->config->getSystemValueString('overwriteprotocol', '')),
+ // Presence only — the values are secrets and must never be reported.
+ $this->check('passwordsalt_present', true, $this->config->getSystemValueString('passwordsalt', '') !== ''),
+ $this->check('secret_present', true, $this->config->getSystemValueString('secret', '') !== ''),
+ ];
+
+ $passed = true;
+ foreach ($checks as $check) {
+ if ($check['result'] !== self::RESULT_PASS) {
+ $passed = false;
+ }
+ }
+
+ return [
+ 'result' => $this->verdict($passed),
+ 'checks' => $checks,
+ // Evidence, not an assertion: the cost parameters new hashes get.
+ 'parameters' => HashAlgorithm::parametersFromStoredHash($probeHash),
+ ];
+ }
+
+ /**
+ * @return array{key: string, expected: bool|string, actual: bool|string, result: string}
+ */
+ private function check(string $key, bool|string $expected, bool|string $actual): array {
+ return [
+ 'key' => $key,
+ 'expected' => $expected,
+ 'actual' => $actual,
+ 'result' => $this->verdict($expected === $actual),
+ ];
+ }
+
+ /**
+ * @return array{id: string, url: string, name: string, namespace: string, environment: string}
+ */
+ private function describeInstance(): array {
+ return [
+ 'id' => $this->config->getSystemValueString('instanceid', ''),
+ 'url' => $this->config->getSystemValueString('overwrite.cli.url', ''),
+ 'name' => $this->fromEnvironment('INSTANCE_NAME'),
+ 'namespace' => $this->fromEnvironment('NAMESPACE'),
+ 'environment' => $this->fromEnvironment('ENVIRONMENT'),
+ ];
+ }
+
+ /**
+ * Environment values are arbitrary bytes, and both evidence channels encode
+ * the artifact as JSON: `json_encode()` returns false on invalid UTF-8, and
+ * the log writer would drop the context the same way. Substituting here
+ * rather than at the encode boundary keeps stdout and the Kibana line
+ * consistent, and keeps a malformed label from costing the whole artifact.
+ */
+ private function fromEnvironment(string $name): string {
+ $value = getenv($name);
+ if (!is_string($value)) {
+ return '';
+ }
+
+ return mb_convert_encoding($value, 'UTF-8', 'UTF-8');
+ }
+
+ /**
+ * @return self::RESULT_PASS|self::RESULT_FAIL
+ */
+ private function verdict(bool $passed): string {
+ return $passed ? self::RESULT_PASS : self::RESULT_FAIL;
+ }
+
+ private function asString(mixed $value): string {
+ return is_string($value) ? $value : '';
+ }
+
+ private function probeMessage(): string {
+ return $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC);
+ }
+
+ /**
+ * A throwaway password with all four character classes present, so the
+ * always-enabled password_policy app accepts it whatever it enforces.
+ */
+ private function probePassword(): string {
+ return $this->random->generate(8, ISecureRandom::CHAR_LOWER)
+ . $this->random->generate(8, ISecureRandom::CHAR_UPPER)
+ . $this->random->generate(8, ISecureRandom::CHAR_DIGITS)
+ . $this->random->generate(8, ISecureRandom::CHAR_SYMBOLS);
+ }
+}
diff --git a/psalm.xml b/psalm.xml
index a2f738c..f57efed 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -19,11 +19,25 @@
+
+
+
+
@@ -31,9 +45,11 @@
+
+
diff --git a/tests/integration/SecuritySelfTestIntegrationTest.php b/tests/integration/SecuritySelfTestIntegrationTest.php
new file mode 100644
index 0000000..14bf7e1
--- /dev/null
+++ b/tests/integration/SecuritySelfTestIntegrationTest.php
@@ -0,0 +1,190 @@
+ */
+ private array $observedProbeUids = [];
+
+ /** @var array */
+ private array $observedProbeEmails = [];
+
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->db = Server::get(IDBConnection::class);
+ $this->userManager = Server::get(IUserManager::class);
+ // Resolved through the app container, so the DI wiring is covered too.
+ $this->selfTest = (new Application())->getContainer()->get(SecuritySelfTest::class);
+
+ // The probe user only exists between createUser() and delete(), so the
+ // creation event is the only place its email address can be inspected.
+ Server::get(IEventDispatcher::class)->addListener(
+ UserCreatedEvent::class,
+ function (UserCreatedEvent $event): void {
+ $uid = $event->getUser()->getUID();
+ if (!str_starts_with($uid, 'ncw-selftest-')) {
+ return;
+ }
+ $this->observedProbeUids[] = $uid;
+ $this->observedProbeEmails[$uid] = $event->getUser()->getEMailAddress();
+ },
+ );
+ }
+
+ protected function tearDown(): void {
+ // Safety net: never leave a probe account behind, whatever failed.
+ foreach ($this->observedProbeUids as $uid) {
+ $this->userManager->get($uid)?->delete();
+ }
+ $this->observedProbeUids = [];
+ $this->observedProbeEmails = [];
+
+ parent::tearDown();
+ }
+
+ public function testSurveyCountsEveryStoredHashInTheUsersTable(): void {
+ $report = $this->selfTest->run();
+
+ $distribution = $report['password_hashing']['stored_distribution'];
+ foreach ([HashAlgorithm::ARGON2ID, HashAlgorithm::BCRYPT, HashAlgorithm::EMPTY, HashAlgorithm::UNKNOWN] as $bucket) {
+ $this->assertArrayHasKey($bucket, $distribution);
+ $this->assertIsInt($distribution[$bucket]);
+ }
+
+ $this->assertSame($this->countUsers(), array_sum($distribution));
+ }
+
+ public function testSurveyRespectsTheSampleSize(): void {
+ $this->assertGreaterThan(0, $this->countUsers(), 'The test instance needs at least one user');
+
+ $report = $this->selfTest->run(false, 1);
+
+ $this->assertSame(1, array_sum($report['password_hashing']['stored_distribution']));
+ }
+
+ public function testConfiguredAlgorithmIsArgon2idOnThisInstance(): void {
+ $report = $this->selfTest->run();
+
+ $this->assertSame(HashAlgorithm::ARGON2ID, $report['password_hashing']['configured_algorithm']);
+ $this->assertSame(
+ ['memory_cost', 'time_cost', 'threads'],
+ array_keys($report['security_config']['parameters']),
+ );
+ }
+
+ public function testRoundTripStoresArgon2idAndRemovesTheProbeUser(): void {
+ $report = $this->selfTest->run(true);
+
+ $roundTrip = $report['password_hashing']['round_trip'];
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $roundTrip['result']);
+ $this->assertSame(HashAlgorithm::ARGON2ID, $roundTrip['stored_algorithm']);
+ $this->assertTrue($roundTrip['cleaned_up']);
+
+ $this->assertCount(1, $this->observedProbeUids, 'Exactly one probe user should have been created');
+ $uid = $this->observedProbeUids[0];
+
+ // Never set an email address on a disposable probe account.
+ $this->assertEmpty($this->observedProbeEmails[$uid], 'The probe user must have no email address');
+
+ // Gone from the user manager and from the database.
+ $this->assertNull($this->userManager->get($uid));
+ $this->assertNull($this->readStoredHash($uid));
+ }
+
+ public function testArtifactCarriesNoHashMaterialFromTheDatabase(): void {
+ $storedHashes = $this->readAllStoredHashes();
+ $this->assertNotEmpty($storedHashes, 'The test instance needs at least one stored hash');
+
+ $report = $this->selfTest->run(true);
+ $encoded = json_encode($report);
+ $this->assertIsString($encoded);
+
+ foreach ($storedHashes as $stored) {
+ if ($stored === '') {
+ continue;
+ }
+ $this->assertStringNotContainsString($stored, $encoded);
+ $this->assertStringNotContainsString(explode('|', $stored, 2)[1] ?? $stored, $encoded);
+ }
+
+ $config = Server::get(IConfig::class);
+ foreach (['passwordsalt', 'secret'] as $key) {
+ $value = $config->getSystemValueString($key, '');
+ if ($value !== '') {
+ $this->assertStringNotContainsString($value, $encoded);
+ }
+ }
+ }
+
+ private function countUsers(): int {
+ $query = $this->db->getQueryBuilder();
+ $query->select($query->func()->count('*', 'total'))->from('users');
+ $result = $query->executeQuery();
+ try {
+ return (int)$result->fetchOne();
+ } finally {
+ $result->closeCursor();
+ }
+ }
+
+ private function readStoredHash(string $uid): ?string {
+ $query = $this->db->getQueryBuilder();
+ $query->select('password')
+ ->from('users')
+ ->where($query->expr()->eq('uid', $query->createNamedParameter($uid)));
+ $result = $query->executeQuery();
+ try {
+ $stored = $result->fetchOne();
+ return $stored === false ? null : (string)$stored;
+ } finally {
+ $result->closeCursor();
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function readAllStoredHashes(): array {
+ $query = $this->db->getQueryBuilder();
+ $query->select('password')->from('users');
+ $result = $query->executeQuery();
+ try {
+ $hashes = [];
+ while (($row = $result->fetch()) !== false) {
+ $hashes[] = (string)($row['password'] ?? '');
+ }
+ return $hashes;
+ } finally {
+ $result->closeCursor();
+ }
+ }
+}
diff --git a/tests/psalm-stubs/OC/Core/Command/Base.php b/tests/psalm-stubs/OC/Core/Command/Base.php
new file mode 100644
index 0000000..6d60b10
--- /dev/null
+++ b/tests/psalm-stubs/OC/Core/Command/Base.php
@@ -0,0 +1,85 @@
+ $messages
+ */
+ public function writeln($messages, int $options = 0): void;
+
+ /**
+ * @param string|iterable $messages
+ */
+ public function write($messages, bool $newline = false, int $options = 0): void;
+}
diff --git a/tests/unit/Security/HashAlgorithmTest.php b/tests/unit/Security/HashAlgorithmTest.php
new file mode 100644
index 0000000..26bd76a
--- /dev/null
+++ b/tests/unit/Security/HashAlgorithmTest.php
@@ -0,0 +1,167 @@
+ [self::REAL_STORED_ARGON2ID, HashAlgorithm::ARGON2ID],
+ 'version 2 prefix, real argon2i hash' => [self::REAL_STORED_ARGON2I, HashAlgorithm::ARGON2I],
+ 'version 1 prefix, real bcrypt hash' => [self::REAL_STORED_BCRYPT, HashAlgorithm::BCRYPT],
+
+ // Legacy, unprefixed hashes.
+ 'bare bcrypt hash (60 chars, no version prefix)' => [
+ '$2y$10$nv5Wx10BmWJnGFj7mhFGPeUaW8wNnFRqP0g09H93X/9yDhxNBjv/m',
+ HashAlgorithm::LEGACY_BCRYPT,
+ ],
+ 'bare bcrypt $2a variant' => [
+ '$2a$10$nv5Wx10BmWJnGFj7mhFGPeUaW8wNnFRqP0g09H93X/9yDhxNBjv/m',
+ HashAlgorithm::LEGACY_BCRYPT,
+ ],
+ 'sha1 hex digest' => [sha1('correct horse battery staple'), HashAlgorithm::LEGACY_SHA1],
+ 'sha1 hex digest, upper case' => [strtoupper(sha1('correct horse battery staple')), HashAlgorithm::LEGACY_SHA1],
+
+ // Nothing stored.
+ 'empty string' => ['', HashAlgorithm::EMPTY],
+
+ // Garbage and edge cases.
+ 'garbage' => ['not-a-hash-at-all', HashAlgorithm::UNKNOWN],
+ 'version prefix with empty hash' => ['3|', HashAlgorithm::UNKNOWN],
+ 'version prefix with garbage hash' => ['3|nonsense', HashAlgorithm::UNKNOWN],
+ 'non-numeric prefix is not a version' => ['foo|$argon2id$v=19$m=65536,t=4,p=1$c2FsdA$aGFzaA', HashAlgorithm::UNKNOWN],
+ 'zero prefix is not a version' => ['0|$argon2id$v=19$m=65536,t=4,p=1$c2FsdA$aGFzaA', HashAlgorithm::UNKNOWN],
+ '40 non-hex characters' => [str_repeat('z', 40), HashAlgorithm::UNKNOWN],
+ '60 characters that are not bcrypt' => [str_repeat('z', 60), HashAlgorithm::UNKNOWN],
+ 'md5 digest' => [md5('correct horse battery staple'), HashAlgorithm::UNKNOWN],
+
+ // An unknown future hasher version still classifies by the inner hash.
+ 'unknown future version prefix' => ['9|' . self::stripPrefix(self::REAL_STORED_ARGON2ID), HashAlgorithm::ARGON2ID],
+
+ // A trailing-garbage version prefix is accepted, deliberately: upstream
+ // splits on `(int)$parts[0] > 0`, so Nextcloud reads these as version 3
+ // and verifies the remainder as argon2id. Reporting `unknown` here would
+ // invent an anomaly the instance does not actually have. See
+ // HashAlgorithm::stripVersionPrefix().
+ 'version prefix with trailing garbage' => [
+ '3foo|' . self::stripPrefix(self::REAL_STORED_ARGON2ID),
+ HashAlgorithm::ARGON2ID,
+ ],
+ 'version prefix with leading whitespace' => [
+ ' 3|' . self::stripPrefix(self::REAL_STORED_ARGON2ID),
+ HashAlgorithm::ARGON2ID,
+ ],
+ // ...but a garbage prefix does not rescue a garbage hash.
+ 'trailing-garbage prefix with garbage hash' => ['3foo|nonsense', HashAlgorithm::UNKNOWN],
+ 'negative prefix is not a version' => [
+ '-1|' . self::stripPrefix(self::REAL_STORED_ARGON2ID),
+ HashAlgorithm::UNKNOWN,
+ ],
+ ];
+
+ // 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,
+ ];
+
+ return $cases;
+ }
+
+ #[DataProvider('provideStoredHashes')]
+ public function testFromStoredHash(string $stored, string $expected): void {
+ $this->assertSame($expected, HashAlgorithm::fromStoredHash($stored));
+ }
+
+ 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));
+ }
+
+ public function testParametersFromArgon2idHash(): void {
+ $stored = '3|' . password_hash('probe', PASSWORD_ARGON2ID, [
+ 'memory_cost' => 65536,
+ 'time_cost' => 4,
+ 'threads' => 1,
+ ]);
+
+ $this->assertSame(
+ ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1],
+ HashAlgorithm::parametersFromStoredHash($stored),
+ );
+ }
+
+ public function testParametersFromBcryptHash(): void {
+ $stored = '1|' . password_hash('probe', PASSWORD_BCRYPT, ['cost' => 11]);
+
+ $this->assertSame(['cost' => 11], HashAlgorithm::parametersFromStoredHash($stored));
+ }
+
+ public static function provideHashesWithoutParameters(): array {
+ return [
+ 'empty' => [''],
+ 'garbage' => ['not-a-hash-at-all'],
+ 'sha1' => [sha1('probe')],
+ ];
+ }
+
+ #[DataProvider('provideHashesWithoutParameters')]
+ public function testParametersAreEmptyForUnrecognisedHashes(string $stored): void {
+ $this->assertSame([], HashAlgorithm::parametersFromStoredHash($stored));
+ }
+
+ public function testParametersNeverCarryHashMaterial(): void {
+ $stored = '3|' . password_hash('probe', PASSWORD_ARGON2ID);
+
+ foreach (HashAlgorithm::parametersFromStoredHash($stored) as $name => $value) {
+ $this->assertIsString($name);
+ $this->assertIsInt($value);
+ }
+ }
+
+ private static function stripPrefix(string $stored): string {
+ return explode('|', $stored, 2)[1];
+ }
+}
diff --git a/tests/unit/Security/SecuritySelfTestTest.php b/tests/unit/Security/SecuritySelfTestTest.php
new file mode 100644
index 0000000..f690d68
--- /dev/null
+++ b/tests/unit/Security/SecuritySelfTestTest.php
@@ -0,0 +1,631 @@
+ */
+ private array $queryBuilders = [];
+
+ protected function setUp(): void {
+ parent::setUp();
+ $this->hasher = $this->createMock(IHasher::class);
+ $this->db = $this->createMock(IDBConnection::class);
+ $this->config = $this->createMock(IConfig::class);
+ $this->userManager = $this->createMock(IUserManager::class);
+ $this->random = $this->createMock(ISecureRandom::class);
+ $this->logger = $this->createMock(LoggerInterface::class);
+ $this->queryBuilders = [];
+
+ // Deterministic randomness: each call returns the head of the requested
+ // character class, so probe passwords stay assertable.
+ $this->random->method('generate')
+ ->willReturnCallback(fn (int $length, string $characters = 'abc'): string => substr(str_repeat($characters, $length), 0, $length));
+ }
+
+ public function testPassWhenHashingIsArgon2idAndTheInstanceIsHardened(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['result']);
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['password_hashing']['result']);
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['security_config']['result']);
+ $this->assertSame(HashAlgorithm::ARGON2ID, $report['password_hashing']['configured_algorithm']);
+ }
+
+ public function testArtifactMatchesTheAgreedShape(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(
+ ['schema_version', 'timestamp', 'result', 'instance', 'password_hashing', 'security_config'],
+ array_keys($report),
+ );
+ $this->assertSame('3', $report['schema_version']);
+ $this->assertSame(
+ ['id', 'url', 'name', 'namespace', 'environment'],
+ array_keys($report['instance']),
+ );
+ $this->assertSame('inst-1', $report['instance']['id']);
+ $this->assertSame('https://cloud.example.com', $report['instance']['url']);
+ $this->assertSame(
+ ['result', 'configured_algorithm', 'round_trip', 'stored_distribution'],
+ array_keys($report['password_hashing']),
+ );
+ $this->assertSame(
+ ['result', 'stored_algorithm', 'cleaned_up'],
+ array_keys($report['password_hashing']['round_trip']),
+ );
+ $this->assertSame(
+ [HashAlgorithm::ARGON2ID, HashAlgorithm::BCRYPT, HashAlgorithm::EMPTY, HashAlgorithm::UNKNOWN],
+ array_keys($report['password_hashing']['stored_distribution']),
+ );
+ $this->assertSame(
+ ['result', 'checks', 'parameters'],
+ array_keys($report['security_config']),
+ );
+ $this->assertSame(
+ ['key', 'expected', 'actual', 'result'],
+ array_keys($report['security_config']['checks'][0]),
+ );
+ $this->assertSame(
+ ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 1],
+ $report['security_config']['parameters'],
+ );
+ $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $report['timestamp']);
+ }
+
+ public function testInstanceMetadataComesFromTheEnvironment(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ putenv('INSTANCE_NAME=customer-42');
+ putenv('NAMESPACE=ncw-prod');
+ putenv('ENVIRONMENT=production');
+ try {
+ $report = $this->selfTest()->run();
+ } finally {
+ putenv('INSTANCE_NAME');
+ putenv('NAMESPACE');
+ putenv('ENVIRONMENT');
+ }
+
+ $this->assertSame('customer-42', $report['instance']['name']);
+ $this->assertSame('ncw-prod', $report['instance']['namespace']);
+ $this->assertSame('production', $report['instance']['environment']);
+ }
+
+ /**
+ * Environment values are arbitrary bytes. Both evidence channels encode the
+ * artifact as JSON, and `json_encode()` returns false on invalid UTF-8 — so
+ * an unencodable label must degrade its own field rather than cost the whole
+ * artifact on stdout and the context of the Kibana line.
+ */
+ public function testInvalidUtf8InTheEnvironmentStillYieldsAnEncodableArtifact(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ // A lone 0x80 continuation byte: valid Latin-1, never valid UTF-8.
+ putenv("INSTANCE_NAME=ncw-\x80-prod");
+ try {
+ $report = $this->selfTest()->run();
+ } finally {
+ putenv('INSTANCE_NAME');
+ }
+
+ $this->assertTrue(
+ mb_check_encoding($report['instance']['name'], 'UTF-8'),
+ 'the instance label must be valid UTF-8 once sanitised',
+ );
+ $this->assertNotFalse(
+ json_encode($report),
+ 'the artifact must survive json_encode() without the substitute flag',
+ );
+ $this->assertStringContainsString('ncw-', $report['instance']['name']);
+ $this->assertStringContainsString('-prod', $report['instance']['name']);
+ }
+
+ public function testInstanceMetadataIsEmptyWithoutEnvironmentVariables(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame('', $report['instance']['name']);
+ $this->assertSame('', $report['instance']['namespace']);
+ $this->assertSame('', $report['instance']['environment']);
+ }
+
+ public function testFailWhenConfiguredAlgorithmIsBcrypt(): void {
+ $this->stubHasher(self::bcrypt());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(HashAlgorithm::BCRYPT, $report['password_hashing']['configured_algorithm']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['password_hashing']['result']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ // The hardening checks are independent of the hasher probe.
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['security_config']['result']);
+ $this->assertSame(['cost' => 10], $report['security_config']['parameters']);
+ }
+
+ public function testFailWhenConfiguredAlgorithmIsArgon2i(): void {
+ $this->stubHasher(self::argon2i());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(HashAlgorithm::ARGON2I, $report['password_hashing']['configured_algorithm']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ }
+
+ /**
+ * @return array}>
+ */
+ public static function provideBrokenSecurityConfig(): array {
+ return [
+ 'hashing_default_password enabled downgrades the hasher' => [
+ 'hashing_default_password',
+ ['hashing_default_password' => true],
+ ],
+ 'brute force protection disabled' => [
+ 'auth.bruteforce.protection.enabled',
+ ['auth.bruteforce.protection.enabled' => false],
+ ],
+ 'rate limit protection disabled' => [
+ 'ratelimit.protection.enabled',
+ ['ratelimit.protection.enabled' => false],
+ ],
+ 'plain http' => [
+ 'overwriteprotocol',
+ ['overwriteprotocol' => 'http'],
+ ],
+ 'protocol not configured' => [
+ 'overwriteprotocol',
+ ['overwriteprotocol' => ''],
+ ],
+ 'password salt missing' => [
+ 'passwordsalt_present',
+ ['passwordsalt' => ''],
+ ],
+ 'secret missing' => [
+ 'secret_present',
+ ['secret' => ''],
+ ],
+ ];
+ }
+
+ /**
+ * @param array $overrides
+ */
+ #[DataProvider('provideBrokenSecurityConfig')]
+ public function testFailForEachSecurityConfigAssertion(string $failingKey, array $overrides): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig($overrides);
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['security_config']['result']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ // Only the broken assertion fails; the password hashing checks are untouched.
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['password_hashing']['result']);
+
+ $failed = [];
+ foreach ($report['security_config']['checks'] as $check) {
+ if ($check['result'] === SecuritySelfTest::RESULT_FAIL) {
+ $failed[] = $check['key'];
+ }
+ }
+ $this->assertSame([$failingKey], $failed);
+ }
+
+ public function testSecurityConfigReportsSecretPresenceOnly(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $report = $this->selfTest()->run();
+
+ $presence = [];
+ foreach ($report['security_config']['checks'] as $check) {
+ $presence[$check['key']] = $check['actual'];
+ }
+ $this->assertTrue($presence['passwordsalt_present']);
+ $this->assertTrue($presence['secret_present']);
+
+ $encoded = json_encode($report);
+ $this->assertIsString($encoded);
+ $this->assertStringNotContainsString(self::PASSWORD_SALT, $encoded);
+ $this->assertStringNotContainsString(self::SECRET, $encoded);
+ }
+
+ public function testArtifactNeverCarriesHashMaterial(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id(), self::bcrypt()], self::argon2id());
+
+ $report = $this->selfTest()->run(true);
+
+ $encoded = json_encode($report);
+ $this->assertIsString($encoded);
+ foreach ([self::argon2id(), self::bcrypt(), self::argon2i()] as $hash) {
+ $this->assertStringNotContainsString($hash, $encoded);
+ // Not even the bare hash without the version prefix.
+ $this->assertStringNotContainsString(explode('|', $hash, 2)[1], $encoded);
+ }
+ }
+
+ public function testStoredDistributionCountsEveryAlgorithm(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([
+ self::argon2id(),
+ self::argon2id(),
+ self::argon2i(),
+ self::bcrypt(),
+ self::legacyBcrypt(),
+ self::legacySha1(),
+ '',
+ 'garbage',
+ ]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame([
+ HashAlgorithm::ARGON2ID => 2,
+ HashAlgorithm::BCRYPT => 1,
+ HashAlgorithm::EMPTY => 1,
+ HashAlgorithm::UNKNOWN => 1,
+ HashAlgorithm::ARGON2I => 1,
+ HashAlgorithm::LEGACY_BCRYPT => 1,
+ HashAlgorithm::LEGACY_SHA1 => 1,
+ ], $report['password_hashing']['stored_distribution']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['password_hashing']['result']);
+ }
+
+ public function testStoredHashesWithoutAPasswordDoNotFailTheSurvey(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id(), '', '', '']);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(
+ [HashAlgorithm::ARGON2ID => 1, HashAlgorithm::BCRYPT => 0, HashAlgorithm::EMPTY => 3, HashAlgorithm::UNKNOWN => 0],
+ $report['password_hashing']['stored_distribution'],
+ );
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['result']);
+ }
+
+ public function testAStoredBcryptHashFailsTheSurvey(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id(), self::bcrypt()]);
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame(1, $report['password_hashing']['stored_distribution'][HashAlgorithm::BCRYPT]);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['password_hashing']['result']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ }
+
+ public function testSampleSizeLimitsTheSurvey(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $this->queryBuilders[0]->expects($this->once())->method('setMaxResults')->with(250);
+
+ $this->selfTest()->run(false, 250);
+ }
+
+ public function testSampleSizeZeroSurveysEveryRow(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $this->queryBuilders[0]->expects($this->never())->method('setMaxResults');
+
+ $this->selfTest()->run(false, 0);
+ }
+
+ public function testRoundTripIsSkippedUnlessRequested(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $this->userManager->expects($this->never())->method('createUser');
+
+ $report = $this->selfTest()->run();
+
+ $this->assertSame([
+ 'result' => SecuritySelfTest::RESULT_SKIPPED,
+ 'stored_algorithm' => null,
+ 'cleaned_up' => null,
+ ], $report['password_hashing']['round_trip']);
+ // A skipped round trip must not drag the overall result down.
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['result']);
+ }
+
+ public function testRoundTripPassesAndRemovesTheProbeUser(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()], self::argon2id());
+
+ $user = $this->createMock(IUser::class);
+ $user->expects($this->once())->method('delete')->willReturn(true);
+ // A disposable probe account must never get an email address.
+ $user->expects($this->never())->method('setEMailAddress');
+
+ $this->userManager->expects($this->once())
+ ->method('createUser')
+ ->with($this->stringStartsWith('ncw-selftest-'), $this->anything())
+ ->willReturn($user);
+ $this->userManager->method('get')->willReturn(null);
+ $this->logger->expects($this->never())->method('error');
+
+ $report = $this->selfTest()->run(true);
+
+ $this->assertSame([
+ 'result' => SecuritySelfTest::RESULT_PASS,
+ 'stored_algorithm' => HashAlgorithm::ARGON2ID,
+ 'cleaned_up' => true,
+ ], $report['password_hashing']['round_trip']);
+ $this->assertSame(SecuritySelfTest::RESULT_PASS, $report['result']);
+ }
+
+ public function testRoundTripProbePasswordUsesAllCharacterClasses(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()], self::argon2id());
+
+ $user = $this->createMock(IUser::class);
+ $password = null;
+ $this->userManager->method('createUser')
+ ->willReturnCallback(function (string $uid, string $given) use ($user, &$password): IUser {
+ $password = $given;
+ return $user;
+ });
+ $this->userManager->method('get')->willReturn(null);
+
+ $this->selfTest()->run(true);
+
+ $this->assertIsString($password);
+ $this->assertGreaterThanOrEqual(12, strlen($password));
+ $this->assertMatchesRegularExpression('/[a-z]/', $password);
+ $this->assertMatchesRegularExpression('/[A-Z]/', $password);
+ $this->assertMatchesRegularExpression('/[0-9]/', $password);
+ $this->assertMatchesRegularExpression('/[^a-zA-Z0-9]/', $password);
+ }
+
+ public function testRoundTripFailsWhenTheStoredHashIsNotArgon2id(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()], self::bcrypt());
+
+ $user = $this->createMock(IUser::class);
+ $user->expects($this->once())->method('delete')->willReturn(true);
+ $this->userManager->method('createUser')->willReturn($user);
+ $this->userManager->method('get')->willReturn(null);
+
+ $report = $this->selfTest()->run(true);
+
+ $this->assertSame([
+ 'result' => SecuritySelfTest::RESULT_FAIL,
+ 'stored_algorithm' => HashAlgorithm::BCRYPT,
+ 'cleaned_up' => true,
+ ], $report['password_hashing']['round_trip']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ }
+
+ public function testRoundTripFailsWhenTheProbeUserSurvives(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()], self::argon2id());
+
+ $user = $this->createMock(IUser::class);
+ $this->userManager->method('createUser')->willReturn($user);
+ $this->userManager->method('get')->willReturn($user);
+
+ $this->logger->expects($this->once())
+ ->method('error')
+ ->with('SecuritySelfTest: round-trip probe user still exists', $this->anything());
+
+ $report = $this->selfTest()->run(true);
+
+ $this->assertSame([
+ 'result' => SecuritySelfTest::RESULT_FAIL,
+ 'stored_algorithm' => HashAlgorithm::ARGON2ID,
+ 'cleaned_up' => false,
+ ], $report['password_hashing']['round_trip']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ }
+
+ public function testRoundTripFailsAndLogsWhenUserCreationThrows(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $this->userManager->method('createUser')
+ ->willThrowException(new \InvalidArgumentException('Password is among the most common ones'));
+ $this->userManager->method('get')->willReturn(null);
+
+ $this->logger->expects($this->once())
+ ->method('error')
+ ->with(
+ 'SecuritySelfTest: round-trip probe failed',
+ $this->callback(fn (array $context): bool => $context['exceptionClass'] === \InvalidArgumentException::class
+ && $context['message'] === 'Password is among the most common ones'
+ && !array_key_exists('exception', $context)),
+ );
+
+ $report = $this->selfTest()->run(true);
+
+ $this->assertSame([
+ 'result' => SecuritySelfTest::RESULT_FAIL,
+ 'stored_algorithm' => null,
+ 'cleaned_up' => true,
+ ], $report['password_hashing']['round_trip']);
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['result']);
+ }
+
+ public function testRoundTripFailsAndLogsWhenUserCreationReturnsFalse(): void {
+ $this->stubHasher(self::argon2id());
+ $this->stubConfig();
+ $this->stubDatabase([self::argon2id()]);
+
+ $this->userManager->method('createUser')->willReturn(false);
+ $this->userManager->method('get')->willReturn(null);
+
+ $this->logger->expects($this->once())
+ ->method('error')
+ ->with('SecuritySelfTest: could not create the round-trip probe user', $this->anything());
+
+ $report = $this->selfTest()->run(true);
+
+ $this->assertSame(SecuritySelfTest::RESULT_FAIL, $report['password_hashing']['round_trip']['result']);
+ $this->assertNull($report['password_hashing']['round_trip']['stored_algorithm']);
+ }
+
+ private function selfTest(): SecuritySelfTest {
+ return new SecuritySelfTest(
+ $this->hasher,
+ $this->db,
+ $this->config,
+ $this->userManager,
+ $this->random,
+ $this->logger,
+ );
+ }
+
+ private function stubHasher(string $hash): void {
+ $this->hasher->method('hash')->willReturn($hash);
+ }
+
+ /**
+ * @param array $overrides
+ */
+ private function stubConfig(array $overrides = []): void {
+ $values = array_merge([
+ 'instanceid' => 'inst-1',
+ 'overwrite.cli.url' => 'https://cloud.example.com',
+ 'overwriteprotocol' => 'https',
+ 'passwordsalt' => self::PASSWORD_SALT,
+ 'secret' => self::SECRET,
+ 'hashing_default_password' => false,
+ 'auth.bruteforce.protection.enabled' => true,
+ 'ratelimit.protection.enabled' => true,
+ ], $overrides);
+
+ $this->config->method('getSystemValueString')
+ ->willReturnCallback(function (string $key, string $default = '') use ($values): string {
+ $value = $values[$key] ?? $default;
+ return is_string($value) ? $value : $default;
+ });
+ $this->config->method('getSystemValueBool')
+ ->willReturnCallback(function (string $key, bool $default = false) use ($values): bool {
+ $value = $values[$key] ?? $default;
+ return is_bool($value) ? $value : $default;
+ });
+ }
+
+ /**
+ * The service asks for one query builder to survey the stored hashes and, for
+ * a round trip, a second one to read the probe user's hash back.
+ *
+ * @param list $storedHashes
+ */
+ private function stubDatabase(array $storedHashes, ?string $roundTripHash = null): void {
+ $surveyResult = $this->createMock(IResult::class);
+ $rows = array_map(static fn (string $hash): array => ['password' => $hash], $storedHashes);
+ $rows[] = false;
+ $surveyResult->method('fetch')->willReturnOnConsecutiveCalls(...$rows);
+ $this->queryBuilders[] = $this->stubQueryBuilder($surveyResult);
+
+ if ($roundTripHash !== null) {
+ $roundTripResult = $this->createMock(IResult::class);
+ $roundTripResult->method('fetchOne')->willReturn($roundTripHash);
+ $this->queryBuilders[] = $this->stubQueryBuilder($roundTripResult);
+ }
+
+ $this->db->method('getQueryBuilder')->willReturnOnConsecutiveCalls(...$this->queryBuilders);
+ }
+
+ private function stubQueryBuilder(IResult&MockObject $result): IQueryBuilder&MockObject {
+ $query = $this->createMock(IQueryBuilder::class);
+ $query->method('select')->willReturnSelf();
+ $query->method('from')->willReturnSelf();
+ $query->method('where')->willReturnSelf();
+ $query->method('setMaxResults')->willReturnSelf();
+ $query->method('expr')->willReturn($this->createMock(IExpressionBuilder::class));
+ $query->method('createNamedParameter')->willReturn(':uid');
+ $query->method('executeQuery')->willReturn($result);
+
+ return $query;
+ }
+
+ private static function argon2id(): string {
+ return '3|$argon2id$v=19$m=65536,t=4,p=1$QWg3b3ptdUY2bTMyLm1VSA$shWk3Zo2H45opssZuQLI/XERpr+n4BOC53D4i24CTRE';
+ }
+
+ private static function argon2i(): string {
+ return '2|$argon2i$v=19$m=65536,t=4,p=1$dFcyWjAyNWI3QTJUS2VVWg$neelG9YBu6q+p8TKXAzqAQjQXraqCRisUflPMxjeHLQ';
+ }
+
+ private static function bcrypt(): string {
+ return '1|$2y$10$nv5Wx10BmWJnGFj7mhFGPeUaW8wNnFRqP0g09H93X/9yDhxNBjv/m';
+ }
+
+ private static function legacyBcrypt(): string {
+ return '$2y$10$nv5Wx10BmWJnGFj7mhFGPeUaW8wNnFRqP0g09H93X/9yDhxNBjv/m';
+ }
+
+ private static function legacySha1(): string {
+ return sha1('legacy');
+ }
+}