From 8799d038249ee4d5ed3d98e3a6aabc381429e138 Mon Sep 17 00:00:00 2001 From: Eric Mann Date: Mon, 28 Sep 2026 12:41:52 -0700 Subject: [PATCH 01/59] plan: derive build plan from SPEC --- CLAUDE.md | 66 +++++++++++ docs/PLAN.md | 276 ++++++++++++++++++++++++++++++++++++++++++++++ docs/PROGRESS.md | 29 +++++ docs/SPEC.md | 221 +++++++++++++++++++++++++++++++++++++ docs/foundry.json | 190 +++++++++++++++++++++++++++++++ 5 files changed, 782 insertions(+) create mode 100644 docs/PLAN.md create mode 100644 docs/PROGRESS.md create mode 100644 docs/SPEC.md create mode 100644 docs/foundry.json diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2d..68ea99f71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,67 @@ @AGENTS.md + + +# Build guidance: TOTP secrets via the WordPress Secrets API + +Everything below is scoped to the build described in `docs/SPEC.md` and `docs/PLAN.md`. Strip this block before upstreaming. + +## Principles +- Fail closed. An unreadable secret (`WP_Error`) never validates, is never treated as "not configured", never re-enrols the user, and never drops a user to single-factor login. Other providers keep working. +- Two-Factor owns no cryptography. All storage goes through `Two_Factor_Secrets`; never call `wp_*_secret()` elsewhere. +- Feature-detect at call time (`function_exists( 'wp_get_network_secret' )`), never at include time. Sites without the API behave exactly as today. +- Three read states are sacred: `null` (absent), `WP_Error` (unreadable), string (value). Never collapse them. +- Plaintext user meta (`_two_factor_totp_key`) beats the marker; marker (`_two_factor_totp_key_network`, holds the network ID) beats nothing; else no secret. +- Migration is lazy, verified by read-back with `hash_equals()`, idempotent, and safe under concurrent requests. `is_available_for_user()` never migrates and never decrypts. +- Revealed values go only to the immediate caller; `Two_Factor_Secrets::memzero()` after use. No hook ever receives plaintext. +- `two_factor_use_secrets_api` (default `true`) gates writes and migration only; migrated users are still read while the API is present. +- Every task ships its tests. Existing tests must pass unchanged with and without the API. +- PHP 7.4 syntax, WPCS + VIP-Go clean, PHPStan level 5 clean via stubs (no baseline ignores), `two-factor` text domain, `@since 0.18.0`. + +## Commands +- `npm run env start` (once; restart after editing `.wp-env.json`) +- `npm run lint` (PHPCS, PHPStan, CSS, JS) and `composer lint-compat` +- `npm test` (single site then multisite; run sequentially, never concurrently) +- `npm run composer -- test -- --group secrets|totp|cli|core` for a subset +- `npm run env run tests-cli wp plugin list` to confirm `secrets-api` is mapped + +## Module map +- `two-factor.php` — loads core, compat, `class-two-factor-secrets.php`, settings; registers CLI and uninstall. +- `class-two-factor-secrets.php` — `Two_Factor_Secrets`, static adapter: `is_api_present`, `is_provider_writable`, `can_write`, `get_secret_name`, `get_marker_meta_key`, `get_user_secret`, `set_user_secret`, `delete_user_secret`, `provider_label`, `memzero`; `@internal` seams `two_factor_secrets_api_present` filter and `$test_overrides`. +- `class-two-factor-core.php` — login orchestration; `get_available_providers_for_user()` forces the fallback provider when every enabled provider is unregistered or enrolled-but-unavailable; `uninstall()` calls each provider's `uninstall_user_data()` before deleting meta. +- `providers/class-two-factor-provider.php` — base class; new `is_enrolled_but_unavailable_for_user()` (default `false`) and static `uninstall_user_data()` (no-op). +- `providers/class-two-factor-totp.php` — `get_user_totp_key_state()`, `get_user_totp_key()`, `set_user_totp_key()`, `delete_user_totp_key()`, `migrate_user_totp_key()`, `export_user_totp_key()`, `get_user_totp_key_storage()`, `count_users_by_storage()`, `has_affected_users()`, `has_plaintext_users()`, admin notice, Site Health test `two_factor_totp_secret_storage`, deletion hooks (`delete_user` single site, `wpmu_delete_user` multisite). +- `CLI/class-two-factor-cli-command.php` — `status` gains `totp_storage`; `secrets status|migrate|export`. +- `includes/` — never modified. +- `tests/class-two-factor-secrets-test-case.php` — `Two_Factor_Secrets_UnitTestCase` (`require_secrets_api()`, `simulate_api_absent()`, override reset). `tests/phpstan/` — stubs, excluded from PHPUnit. + +## Constraints +Rules marked (pattern) are enforced by `docs/foundry.json` `constraints`; the rest the reviewer checks by reading. +- (pattern) No `wp_*_secret()` / `wp_secrets_*()` call outside `class-two-factor-secrets.php`. +- (pattern) No site-scope `wp_get_secret`/`wp_set_secret`/`wp_delete_secret`/`wp_list_secrets` anywhere; network scope only. +- (pattern) The `two-factor/{slug}-{id}` prefix appears only in the adapter. +- (pattern) `_two_factor_totp_key` and `_two_factor_totp_key_network` are referenced via constants outside their definitions. +- (pattern) No `error_log`/`var_dump`/`print_r`/`var_export` in the adapter, the TOTP provider or the CLI. +- (pattern) Every `__()`-family call uses the `two-factor` domain. +- (pattern) `Requires at least: 7.0` is not bumped; no composer/npm dependency on `secrets-api`. +- (pattern, single-line only) `two_factor_secret*` hooks never receive `$plaintext|$secret|$key|$value|$readback|$state`. +- (pattern) CLI batch size and the affected-users transient TTL come from constants. +- (reading) No diff under `includes/`: `git diff --stat feature/secrets-api-totp -- includes/` must be empty. +- (reading) `is_available_for_user()` and `is_enrolled_but_unavailable_for_user()` never call `get_user_secret()`, `migrate_user_totp_key()` or `reveal()`. +- (reading) Every `get_users()`/`WP_User_Query` over TOTP meta passes `'blog_id' => 0`. +- (reading) `WP_Error` messages or codes from the Secrets API are never echoed to end users; only the translated "unavailable" text is. +- (reading) No `gh pr`/`gh issue` command targets `WordPress/two-factor`; always `--repo ericmann/two-factor`. +- (reading) Multi-line `do_action( 'two_factor_secret…' )` calls carry no plaintext argument. + +## Commit template +``` +: + +Goal: <one sentence> +Tests: <test files and names added or changed> +Interpretation: <any reading chosen where SPEC allowed two, or "none"> +Measurement: <tuning tasks only; otherwise omit> +Manual check: <what a human must verify, or "none"> +``` + +SPEC.md wins over PLAN.md, which wins over code comments. +<!-- foundry:end --> diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 000000000..1ecba87f0 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,276 @@ +# Two-Factor Secrets API (TOTP) build plan +Derived from docs/SPEC.md (Status: approved for build, unversioned) on 2026-09-28. SPEC.md wins over this file. + +## Decisions +- Phases → SPEC defines none; derived as scaffold (test environment) → adapter and base hooks → TOTP integration → downgrade detection → WP-CLI → documentation. Each is a separate phase so the reviewer can check the storage contract before it is consumed. +- SPEC 4.6 assumes core's existing fallback covers a TOTP-only user whose provider is registered but unavailable → it does not (see Spec issues #1). P1-02 adds a generic base-class hook `Two_Factor_Provider::is_enrolled_but_unavailable_for_user( $user )` (default `false`) and teaches `Two_Factor_Core::get_available_providers_for_user()` to apply the existing `two_factor_fallback_provider_for_user` resolution when every enabled provider is either unavailable-and-enrolled or not registered. TOTP overrides it to `true` for affected users. No TOTP logic enters core. +- SPEC 4.5 per-user uninstall hook `uninstall_user_data( $user_id )` → implemented as a bulk static `Two_Factor_Provider::uninstall_user_data()` with no argument. Core cannot know which users carry a provider's marker without provider-specific logic, so the provider pages through its own users. Core calls it once per provider class before deleting user meta so the marker is still available for the lookup. +- Test seam for "API absent" (SPEC 5) → an `@internal` filter `two_factor_secrets_api_present` applied inside `Two_Factor_Secrets::is_api_present()`; it can only force `false`, never `true`. Hooks are reset by `WP_UnitTestCase` between tests, which is safer for an unattended run than a static the tests must remember to clear. +- Test seam for write failure and read-back mismatch → the Secrets API caches its provider and store in `static` locals for the whole PHP process (`_wp_secrets_get_provider()`, `_wp_secrets_get_store()`), so tests cannot swap a failing store in. `Two_Factor_Secrets` therefore routes its raw API calls through private wrappers that consult an `@internal` static `Two_Factor_Secrets::$test_overrides` (keys `set`, `get`, `delete`, `writable`, each a callable). It is not a hook and is documented as test-only. `Two_Factor_Secrets_UnitTestCase::tear_down()` clears it. +- Marker present, API present, network matches, but the API returns `null` → `WP_Error` code `two_factor_secret_missing` from `Two_Factor_Secrets::get_user_secret()`, not `null`. The marker asserts the secret exists, and SPEC goal 4 says an unreadable secret is never mistaken for "not configured". The profile UI's reset path clears the stale marker. +- Empty `$key` passed to `Two_Factor_Totp::set_user_totp_key()` → delegates to `delete_user_totp_key()` and returns its boolean. An empty secret is never written to the Secrets API. +- Site Health row not in SPEC's table (API present, `can_write()` false, plaintext remains) → `good`, with wording that migration is disabled by the `two_factor_use_secrets_api` filter or a read-only provider. Not nagging a deliberate opt-out matches the "do not nag" rule for the API-absent row. +- Affected-user detection when the API is present → only runs on multisite and only looks for markers naming a different network (`meta_compare => '!='`). A single site has one network, so no query runs there. Result cached in a site transient for `Two_Factor_Totp::AFFECTED_USERS_CACHE_TTL` (300 s). +- `wp two-factor secrets migrate` paging → ordered by ID, `number => batch`, `offset` advanced only by the number of users in the batch that were not migrated (failed or skipped), because migrated users drop out of the plaintext query. With `--dry-run` the offset advances by the full batch. Same approach for `export` over the marker query. +- `wp two-factor secrets export` when deleting the Secrets API entry fails after plaintext was verified → returns `true` (exported). The marker is always removed so precedence (SPEC 4.2 rule 1) is unambiguous; an orphaned ciphertext row is the same acceptable outcome SPEC 4.5 allows for uninstall. +- readme.md mirror (SPEC 6) → readme.md carries no FAQ, hooks or CLI sections (it defers to readme.txt), so no readme.md change is made; P5-01 states this in its commit body. +- Open questions in SPEC 8 → not resolved here by design; they go verbatim into `docs/PR-DESCRIPTION.md` (P5-03). +- Branch and PR target → SPEC 7 says the PR targets `master` on `ericmann/two-factor`. `docs/foundry.json` sets `baseBranch` to `feature/secrets-api-totp` because that is the branch these documents are committed on; the implementer's build branch is cut from it. The author retargets or merges by hand. + +## Conventions +- Commit title `<ID>: <title>`; body with `Goal:`, `Tests:`, `Interpretation:` (any reading chosen where SPEC allowed two), `Measurement:` (tuning tasks only; none in this plan), `Manual check:`. +- Version placeholder for every new docblock: `@since 0.18.0`. +- Text domain for every string: `two-factor`. PHP 7.4 syntax only (no `match`, no union types, no `readonly`, no named arguments, no `str_contains`). +- Never modify anything under `includes/`. +- Never call `wp_*_secret()` / `wp_secrets_*()` outside `class-two-factor-secrets.php`. Always the `*_network_*` variants inside it. +- Canonical names used across tasks (restated in each task that needs them): + - Adapter: `class-two-factor-secrets.php`, `class Two_Factor_Secrets` (all static). Slug for TOTP: `Two_Factor_Totp::SECRET_SLUG = 'totp'`. Secret name: `two-factor/{$slug}-{$user_id}`. + - Marker: `Two_Factor_Totp::SECRET_NETWORK_META_KEY = '_two_factor_totp_key_network'`, value `(string) get_current_network_id()`. + - Public filter: `two_factor_use_secrets_api( bool $use, int $user_id )`, default `true`. + - Internal test filter: `two_factor_secrets_api_present( bool $present )`. + - Internal test static: `Two_Factor_Secrets::$test_overrides` (array; keys `set`, `get`, `delete`, `writable`). + - Actions: `two_factor_secrets_migrated( int $user_id, string $slug )`, `two_factor_secrets_migration_failed( int $user_id, string $slug, WP_Error $error )`, `two_factor_secret_unavailable( int $user_id, string $slug, WP_Error $error )`. + - Error codes: `two_factor_secrets_api_missing`, `two_factor_secret_wrong_network`, `two_factor_secret_missing`, `two_factor_secrets_not_writable`, `two_factor_secrets_migration_mismatch`, `two_factor_secrets_export_mismatch`. + - Base-class hooks: `Two_Factor_Provider::is_enrolled_but_unavailable_for_user( $user ): bool` (default `false`), `Two_Factor_Provider::uninstall_user_data(): void` (static, no-op). + - Transient: `Two_Factor_Totp::AFFECTED_USERS_TRANSIENT = 'two_factor_totp_affected_users'`, TTL `Two_Factor_Totp::AFFECTED_USERS_CACHE_TTL = 300`. + - Site Health test id: `two_factor_totp_secret_storage`. + - CLI: `wp two-factor secrets <status|migrate|export>`; `Two_Factor_CLI_Command::SECRETS_DEFAULT_BATCH_SIZE = 100`. + - Test base class: `Two_Factor_Secrets_UnitTestCase` in `tests/class-two-factor-secrets-test-case.php`, group `secrets`. + - Deterministic test key: `WP_SECRETS_KEY` = `AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=` (canonical base64 of 32 bytes of `0x01`). +- Every test that needs the real Secrets API calls `$this->require_secrets_api()` (marks skipped when `wp_get_network_secret()` does not exist). Every test that needs "API absent" calls `$this->simulate_api_absent()`. +- On multisite, every `get_users()` / `WP_User_Query` that looks for markers or plaintext keys passes `'blog_id' => 0`, otherwise the default capabilities meta filter hides users of other sites. +- Verification for every task: `npm run lint`, `composer lint-compat`, `npm test` (both suites, needs `npm run env start` once). Run the two suites sequentially, never concurrently. + +## Phase 0 — Test environment + +### P0-01: Create the build branch and load the Secrets API feature plugin in the tests environment +**Goal:** Make the Secrets API feature plugin v0.2.1 available to PHPUnit under wp-env with a deterministic key, so later tasks can test both code paths in one process. +**Files touched:** `.wp-env.json`, `tests/bootstrap.php`, `tests/two-factor.php` +**Design constraints:** SPEC 5 (Testing: test environment). Create branch `build/secrets-api-totp` from `feature/secrets-api-totp` before any change. In `.wp-env.json`, add `env.tests.plugins: [ ".", "ericmann/secrets-api#v0.2.1" ]` (an env-level `plugins` key replaces the root one, so `"."` must be repeated) and add `"WP_SECRETS_KEY": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="` to `env.tests.config`. Do not change the root `plugins` key or the development environment. In `tests/bootstrap.php`, inside the existing `muplugins_loaded` callback and before `two-factor.php` is required: resolve `$secrets_api = getenv( 'TWO_FACTOR_SECRETS_API_FILE' )`, falling back to `WP_PLUGIN_DIR . '/secrets-api/secrets-api.php'`; `require_once` it only if `file_exists()`. The suite must keep running when the file is absent. Do not touch `.github/workflows/test.yml` (wp-env clones the GitHub source itself). +**Acceptance tests:** `tests/two-factor.php`: `Tests_Two_Factor::test_secrets_api_feature_plugin_loaded_for_tests` (asserts `function_exists( 'wp_get_network_secret' )`, `class_exists( 'WP_Secret' )`, `defined( 'WP_SECRETS_KEY' )` and that `WP_SECRETS_KEY` is 44 characters; calls `markTestSkipped()` when `wp_get_network_secret` does not exist) and `Tests_Two_Factor::test_secrets_api_network_round_trip` (skips likewise; `wp_set_network_secret( 'two-factor-test/probe', 'v' )` is `true`, `wp_get_network_secret( 'two-factor-test/probe' )->reveal()` is `'v'`, then `wp_delete_network_secret()` and a second get returns `null`). All existing tests unchanged and passing. +**Out of scope:** Any plugin source change; PHPStan stubs; test base classes. +**Verification:** `git switch -c build/secrets-api-totp`; `npm run env start` (restarts with the new mapping; confirm `secrets-api` appears in `npm run env run tests-cli wp plugin list`); `npm run lint`; `composer lint-compat`; `npm test`. The two new tests must run (not skip) in both suites. +**Depends on:** none + +### P0-02: PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory +**Goal:** Let PHPStan (level 5) resolve `wp_*_network_secret()`, `wp_secrets_*()`, `WP_Secret` and `WP_Secret_Version` without baseline ignores, while keeping the stubs out of the PHPUnit and PHPCS scans. +**Files touched:** `tests/phpstan/secrets-api-stubs.php` (new), `phpstan.dist.neon`, `phpunit.xml.dist`, `phpunit-multisite.xml` +**Design constraints:** SPEC 4.9 (stubs under `scanFiles`, not baseline ignores). Stubs declare, with full docblocks and empty bodies: `wp_set_network_secret( $name, $value )` returning `true|WP_Error`; `wp_get_network_secret( $name, $version = WP_Secret_Version::CURRENT )` returning `WP_Secret|null|WP_Error`; `wp_delete_network_secret( $name )` returning `true|WP_Error`; `wp_secrets_provider_is_writable(): bool`; `wp_secrets_provider_label(): string`; `wp_secrets_memzero( &$value ): void`; `final class WP_Secret` with `reveal(): string|WP_Error`, `get_name(): string`, `fingerprint(): string`; `final class WP_Secret_Version` with `const CURRENT = 'current'; const PREVIOUS = 'previous';`. First line after the opening tag: `// phpcs:ignoreFile -- PHPStan stubs for the WordPress Secrets API; never executed.` Add the file to `scanFiles` in `phpstan.dist.neon`. PHPUnit includes every `.php` under `tests/`, and the stubs would redeclare the real functions when the feature plugin is loaded, so add `<exclude>tests/phpstan</exclude>` inside the `<testsuite>` element of both `phpunit.xml.dist` and `phpunit-multisite.xml` (keep the two files in sync, per TESTS.md). +**Acceptance tests:** No new PHPUnit tests. `npm run lint:phpstan` passes; `npm test` still passes in both suites (proves the exclusion works: with the stubs included the run would fatal on redeclaration). +**Out of scope:** Analysing `tests/` with PHPStan; any adapter code. +**Verification:** `npm run lint` (includes PHPStan and PHPCS); `composer lint-compat`; `npm test`. +**Depends on:** P0-01 + +### P0-03: Push Phase 0 and record the manual environment check +**Goal:** Push the branch and list what a human must confirm about the test environment. +**Files touched:** `docs/PROGRESS.md` (log entry only) +**Design constraints:** SPEC 7 (push to `origin` = `ericmann/two-factor` only; never touch `WordPress/two-factor`). Before pushing run `gh repo set-default --view` and confirm it prints `ericmann/two-factor`; if it prints anything else, do not run any `gh pr`/`gh issue` command in this flight and write that fact in the log entry. +**Acceptance tests:** none (verification commands from P0-02 must still pass). +**Out of scope:** Opening a pull request. +**Verification:** `git push -u origin build/secrets-api-totp`. Write in the progress log: `Manual check: NOT VERIFIED (human)` followed by the list: (a) a fresh clone with `npm install && npm run env start` clones `ericmann/secrets-api` at `v0.2.1` into the tests environment; (b) CI on GitHub Actions passes for the WP `latest`, `7.0-branch` and `7.1-branch` legs with the feature plugin present; (c) `gh repo set-default --view` shows the fork. +**Depends on:** P0-02 + +## Phase 1 — Storage adapter and provider base hooks + +### P1-01: `Two_Factor_Secrets` adapter with tests and the shared secrets test case +**Goal:** Add the static storage adapter that is the only place Two-Factor calls the Secrets API, plus the test base class every later secrets test extends. +**Files touched:** `class-two-factor-secrets.php` (new), `two-factor.php`, `phpstan.dist.neon`, `phpunit.xml.dist`, `phpunit-multisite.xml`, `tests/bootstrap.php`, `tests/class-two-factor-secrets-test-case.php` (new), `tests/class-two-factor-secrets.php` (new) +**Design constraints:** SPEC 4.1 (adapter surface and rules), 4.2 (marker semantics), 4.7 (`two_factor_use_secrets_api` docblock), 5 (seams). `two-factor.php` requires the file right after `class-two-factor-compat.php`. Add `class-two-factor-secrets.php` to `paths` in `phpstan.dist.neon` and to the coverage `<whitelist>` of both PHPUnit configs. Class `Two_Factor_Secrets`, every method `public static` unless stated: +- `is_api_present(): bool` — `function_exists()` for all six functions (`wp_get_network_secret`, `wp_set_network_secret`, `wp_delete_network_secret`, `wp_secrets_provider_is_writable`, `wp_secrets_provider_label`, `wp_secrets_memzero`) and `class_exists( 'WP_Secret' )`, evaluated at call time; result ANDed with `(bool) apply_filters( 'two_factor_secrets_api_present', true )`. Docblock marks the filter `@internal Test seam only; not a public extension point. Can only force false.` +- `is_provider_writable(): bool` — `false` when `! is_api_present()`; else `self::$test_overrides['writable']` when set, else `wp_secrets_provider_is_writable()`. +- `can_write( $user_id = 0 ): bool` — `is_api_present()` && `(bool) apply_filters( 'two_factor_use_secrets_api', true, (int) $user_id )` && `is_provider_writable()`. The filter docblock states that it controls writes and migration only, that already-migrated users are still read from the Secrets API while it is present, and that `$user_id` may be `0`. +- `get_secret_name( $user_id, $slug ): string` — `"two-factor/{$slug}-{$user_id}"`; `$slug` is validated with `/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/`, throwing `InvalidArgumentException` otherwise. +- `get_marker_meta_key( $slug ): string` — `"_two_factor_{$slug}_key_network"` (for `totp` this equals `_two_factor_totp_key_network`; P2-01 asserts `Two_Factor_Totp::SECRET_NETWORK_META_KEY` matches). +- `get_user_secret( $user_id, $slug ): string|null|WP_Error` — read marker meta; `''`/`false` → `null` (no API call). Marker present: if `! is_api_present()` → `WP_Error( 'two_factor_secrets_api_missing' )`; if `(int) $marker !== get_current_network_id()` → `WP_Error( 'two_factor_secret_wrong_network' )`; else raw get: `WP_Error` → return it; `null` → `WP_Error( 'two_factor_secret_missing' )`; `WP_Secret` → `reveal()`; a `WP_Error` from `reveal()` is returned as is. +- `set_user_secret( $user_id, $slug, $value ): true|WP_Error` — requires `is_api_present()` (else `two_factor_secrets_api_missing`); raw set; on `WP_Error` return it without touching the marker; on success `update_user_meta( $user_id, marker, (string) get_current_network_id() )` and return `true`. +- `delete_user_secret( $user_id, $slug ): true|WP_Error` — if `is_api_present()` call raw delete and remember a `WP_Error`; always `delete_user_meta( $user_id, marker )`; return the remembered `WP_Error` or `true`. A missing secret is success. +- `provider_label(): string` — `wp_secrets_provider_label()` or `''` when absent. +- `memzero( &$value ): void` — `wp_secrets_memzero()` when it exists, else `$value = ''`. +- Private `api_set( $name, $value )`, `api_get( $name )`, `api_delete( $name )` — call `self::$test_overrides[ 'set'|'get'|'delete' ]` when set (`get` override returns `string|null|WP_Error` and is used in place of get-plus-reveal), else the `wp_*_network_secret()` function. `public static $test_overrides = array();` documented `@internal Test seam only; keys set, get, delete, writable.` No method logs, echoes or stores a revealed value anywhere except the return value. +The test base class `Two_Factor_Secrets_UnitTestCase extends WP_UnitTestCase` (abstract) provides: `require_secrets_api()` (markTestSkipped unless `function_exists( 'wp_get_network_secret' )`), `simulate_api_absent()` (`add_filter( 'two_factor_secrets_api_present', '__return_false' )`), `tear_down()` that sets `Two_Factor_Secrets::$test_overrides = array()`, deletes site transient `two_factor_totp_affected_users` and calls `parent::tear_down()`. `tests/bootstrap.php` requires it after the WP test library bootstrap (`WP_UnitTestCase` does not exist before that line). +**Acceptance tests:** `tests/class-two-factor-secrets.php`, class `Tests_Two_Factor_Secrets extends Two_Factor_Secrets_UnitTestCase`, `@group secrets`: `test_get_secret_name_format` (`two-factor/totp-42`, passes `wp_secrets_validate_name()` when available), `test_get_secret_name_rejects_invalid_slug`, `test_is_api_present_reflects_functions`, `test_is_api_present_internal_filter_forces_absent`, `test_can_write_true_with_api_present`, `test_can_write_false_when_api_absent`, `test_can_write_false_when_filter_opts_out` (asserts the filter receives the user ID), `test_can_write_false_when_provider_read_only` (`$test_overrides['writable']` returns `false`; `is_api_present()` stays true), `test_get_user_secret_null_without_marker`, `test_get_user_secret_round_trip_uses_network_scope` (after `set_user_secret`, `wp_get_network_secret( name )` finds it and `wp_get_secret( name )` returns `null`; marker equals current network ID), `test_get_user_secret_error_when_api_absent_with_marker` (code `two_factor_secrets_api_missing`), `test_get_user_secret_error_when_marker_names_other_network` (marker set to `get_current_network_id() + 1`, code `two_factor_secret_wrong_network`), `test_get_user_secret_error_when_marker_present_but_secret_gone` (delete via `wp_delete_network_secret` directly; code `two_factor_secret_missing`), `test_get_user_secret_passes_through_api_error` (`$test_overrides['get']` returns `WP_Error( 'secret_decryption_failed' )`), `test_set_user_secret_error_leaves_marker_absent` (`$test_overrides['set']` returns `WP_Error`), `test_delete_user_secret_removes_secret_and_marker`, `test_delete_user_secret_removes_marker_when_api_absent`, `test_delete_user_secret_missing_is_success`. +**Out of scope:** Any change to `Two_Factor_Totp`; the base-class hooks; readme or AGENTS.md text. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`; `npm run composer -- test -- --group secrets` runs the new class in both suites. +**Depends on:** P0-02 + +### P1-02: Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core +**Goal:** Give providers a way to say "enrolled but currently unusable" and "clean per-user external data on uninstall", and make core fail closed for the former. +**Files touched:** `providers/class-two-factor-provider.php`, `class-two-factor-core.php`, `tests/class-two-factor-dummy-unavailable.php` (new), `tests/bootstrap.php`, `tests/providers/class-two-factor-provider.php`, `tests/class-two-factor-core.php` +**Design constraints:** SPEC 4.5 (uninstall hook, no TOTP logic in core), 4.6 (a TOTP-only affected user must not be let in with one factor), goal 4 (fail closed). Base class additions, both `@since 0.18.0`: `public function is_enrolled_but_unavailable_for_user( $user )` returning `false` (docblock: "true when the user has enrolled this provider but its stored credential cannot currently be used, for example because an external store is unavailable; core then forces the fallback provider instead of dropping to single factor"), and `public static function uninstall_user_data()` no-op (docblock: "delete per-user data held outside user meta; called once during uninstall before user meta is deleted so implementations can still use their own meta to find affected users"). In `Two_Factor_Core::get_available_providers_for_user()`: extract the existing fallback resolution into `private static function resolve_fallback_provider_for_user( WP_User $user, array $providers, array $stored_providers ): ?string` (applies `two_factor_fallback_provider_for_user` with the same three arguments and returns the key only if it is a string, registered, and `is_available_for_user()`), keep the existing branch's WP_Error message and data byte-for-byte; after the `foreach` that builds `$configured_providers`, if it is empty and `$enabled_providers` is not, collect enabled providers whose `is_enrolled_but_unavailable_for_user( $user )` is true; if any, resolve the fallback with that list as `$stored_providers`; on success add it to `$configured_providers`; otherwise return `new WP_Error( 'no_available_2fa_methods', __( 'Error: Your two-factor method is currently unavailable and no fallback method could be used. Please contact a site administrator for assistance.', 'two-factor' ), array( 'user_providers_raw' => $stored_providers, 'available_providers' => array_keys( $providers ), 'fallback_provider' => <filtered value>, 'unavailable_providers' => <list> ) )`. In `Two_Factor_Core::uninstall()`, inside the existing provider-class loop and before options and meta are deleted, call `call_user_func( array( $provider_class, 'uninstall_user_data' ) )` when `method_exists`, wrapped in the same `try/catch` style as its neighbours. Test double `Two_Factor_Dummy_Unavailable extends Two_Factor_Dummy` (file required from `tests/bootstrap.php` next to the dummy-secure fixture): `is_available_for_user()` returns `false`, `is_enrolled_but_unavailable_for_user()` returns `true`, `uninstall_user_data()` sets `public static $uninstall_user_data_calls` (int) up by one. Tests register it via the `two_factor_providers` filter with its file path and `remove_all_filters( 'two_factor_providers' )` afterwards, following `test_uninstall_removes_disabled_provider_user_meta`. +**Acceptance tests:** `tests/providers/class-two-factor-provider.php`: `test_is_enrolled_but_unavailable_for_user_base_returns_false`, `test_uninstall_user_data_base_is_noop`. `tests/class-two-factor-core.php`: `test_get_available_providers_for_user_forces_fallback_when_enrolled_provider_is_unavailable` (enabled `['Two_Factor_Dummy_Unavailable']` → result keys exactly `['Two_Factor_Email']`; `is_user_using_two_factor()` true), `test_get_available_providers_for_user_fails_closed_when_enrolled_provider_is_unavailable_and_fallback_invalid` (fallback filter returns `'Two_Factor_Nonexistent'` → `WP_Error` code `no_available_2fa_methods` with `unavailable_providers` data; `is_user_using_two_factor()` still true), `test_get_available_providers_for_user_keeps_available_provider_when_another_is_unavailable` (enabled `['Two_Factor_Dummy_Unavailable', 'Two_Factor_Dummy']` → keys exactly `['Two_Factor_Dummy']`, no email forced), `test_get_available_providers_for_user_does_not_force_fallback_for_unconfigured_provider` (enabled `['Two_Factor_Backup_Codes']` with no codes → empty array, unchanged behaviour), `test_uninstall_calls_provider_uninstall_user_data`. All existing core tests unchanged. +**Out of scope:** TOTP changes; any admin UI. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`. +**Depends on:** P1-01 + +### P1-03: Push Phase 1 and record the manual check +**Goal:** Push the adapter and core hooks for review. +**Files touched:** `docs/PROGRESS.md` (log entry only) +**Design constraints:** SPEC 7. +**Acceptance tests:** none (P1-02 verification must still pass). +**Out of scope:** Opening a pull request. +**Verification:** `git push`. Progress log: `Manual check: NOT VERIFIED (human)` — (a) on the dev site with WP_DEBUG on, log in as a user with only the Dummy provider enabled and confirm the login flow is unchanged; (b) read `class-two-factor-secrets.php` for any code path that could echo or log a revealed value. +**Depends on:** P1-02 + +## Phase 2 — TOTP integration + +### P2-01: TOTP tri-state read, Secrets API writes and lazy migration +**Goal:** Route `Two_Factor_Totp` key storage through the adapter with the 4.2 precedence, verified writes, and idempotent lazy migration, keeping every existing public signature. +**Files touched:** `providers/class-two-factor-totp.php`, `tests/providers/class-two-factor-totp-secrets.php` (new) +**Design constraints:** SPEC 4.2 (precedence), 4.3 (`get_user_totp_key_state`, `get_user_totp_key`, `set_user_totp_key`, `delete_user_totp_key`, `is_available_for_user`, `uninstall_user_meta_keys`), 4.4 (migration), 4.1 (never log/echo revealed values; memzero after use), 4.7 (actions never receive plaintext). Constants: `SECRET_SLUG = 'totp'`, `SECRET_NETWORK_META_KEY = '_two_factor_totp_key_network'`. New/changed methods, all `@since 0.18.0` where new: +- `public function get_user_totp_key_state( $user_id, $migrate = true ): string|null|WP_Error` — `$plaintext = (string) get_user_meta( $user_id, self::SECRET_META_KEY, true )`; if non-empty: when `$migrate && Two_Factor_Secrets::can_write( $user_id )` call `$this->migrate_user_totp_key( $user_id )` and ignore its result; return `$plaintext` (login must not break). Else if marker meta is empty → `null`. Else return `Two_Factor_Secrets::get_user_secret( $user_id, self::SECRET_SLUG )`. +- `public function get_user_totp_key( $user_id ): string` — `$state = get_user_totp_key_state( $user_id )`; return `is_string( $state ) ? $state : ''`. Docblock points to the tri-state method. +- `public function migrate_user_totp_key( $user_id ): true|null|WP_Error` — plaintext empty → `null`; `! can_write( $user_id )` → `WP_Error( 'two_factor_secrets_not_writable' )` (no action fired); `set_user_secret()` `WP_Error` → fire `two_factor_secrets_migration_failed( $user_id, 'totp', $error )`, return it; read back with `get_user_secret()`; not a string or `! hash_equals( $plaintext, $readback )` → `delete_user_secret()` (ignore result), fire failure action with the `WP_Error` (the read-back error, or `two_factor_secrets_migration_mismatch`), return it; success → `delete_user_meta( $user_id, self::SECRET_META_KEY )`, `Two_Factor_Secrets::memzero( $readback )`, fire `two_factor_secrets_migrated( $user_id, 'totp' )`, return `true`. Idempotent: a second call finds no plaintext and returns `null`. +- `public function set_user_totp_key( $user_id, $key )` — `'' === (string) $key` → return `$this->delete_user_totp_key( $user_id )`. If `can_write( $user_id )`: `set_user_secret()`; `WP_Error` → return `false`; read back and `hash_equals`; mismatch/error → `delete_user_secret()` best effort, return `false`; success → `delete_user_meta( SECRET_META_KEY )`, return `true`. Never fall back to plaintext on a Secrets API failure. Else: `$result = update_user_meta( SECRET_META_KEY, $key )`; `Two_Factor_Secrets::delete_user_secret()` best effort (clears any stale marker/secret so precedence rule 1 stays unambiguous); return `$result`. +- `public function delete_user_totp_key( $user_id ): bool` — `delete_user_meta( LAST_SUCCESSFUL_LOGIN_META_KEY )`; `$meta = delete_user_meta( SECRET_META_KEY )`; `$secret = Two_Factor_Secrets::delete_user_secret()` (removes the marker even when the API is absent); return `true` when plaintext, marker and (if API present) secret are all gone afterwards, else `false`. +- `public function is_available_for_user( $user ): bool` — plaintext meta non-empty, OR (marker non-empty AND `Two_Factor_Secrets::is_api_present()` AND `(int) $marker === get_current_network_id()`). No decrypt, no migration. +- `public function is_enrolled_but_unavailable_for_user( $user ): bool` — marker non-empty AND (`! is_api_present()` OR marker network differs). No decrypt. +- `public function get_user_totp_key_storage( $user_id ): string` — one of `'plaintext'` (plaintext non-empty), `'secrets-api'` (marker present and `get_user_secret()` returns a string; memzero it), `'unavailable'` (marker present and `get_user_secret()` returns `WP_Error`), `'none'`. Never migrates (`$migrate = false` semantics: reads meta directly). +- `uninstall_user_meta_keys()` adds `SECRET_NETWORK_META_KEY`. +`validate_code_for_user()` and `authentication_page()` are NOT changed here (P2-02). Existing tests `Tests_Two_Factor_Totp` and `Tests_Two_Factor_Totp_REST_API` must pass unchanged with the API present (secret lands in the Secrets API) and with it absent. +**Acceptance tests:** `tests/providers/class-two-factor-totp-secrets.php`, class `Tests_Two_Factor_Totp_Secrets extends Two_Factor_Secrets_UnitTestCase`, `@group providers`, `@group totp`, `@group secrets`: `test_marker_key_matches_adapter` (`SECRET_NETWORK_META_KEY === Two_Factor_Secrets::get_marker_meta_key( 'totp' )`), `test_set_key_with_api_stores_in_secrets_api` (plaintext meta empty, marker equals network ID, `get_user_totp_key()` returns the key, `wp_get_network_secret( 'two-factor/totp-{id}' )` exists), `test_set_key_without_api_stores_plaintext` (simulate absent), `test_set_key_returns_false_when_secrets_write_fails` (`$test_overrides['set']` → plaintext meta still empty, marker absent, return `false`), `test_set_key_returns_false_when_readback_mismatches` (`$test_overrides['get']` returns another string → `false`, secret deleted), `test_set_empty_key_deletes` , `test_delete_key_removes_secret_marker_and_plaintext`, `test_delete_key_removes_marker_when_api_absent`, `test_lazy_migration_moves_plaintext_to_secrets_api` (seed plaintext meta via `update_user_meta`, call `get_user_totp_key()` → value returned, plaintext gone, marker set, `two_factor_secrets_migrated` fired once with `( $user_id, 'totp' )`), `test_lazy_migration_is_idempotent` (second read returns the same value, no second action, `migrate_user_totp_key()` returns `null`), `test_lazy_migration_write_failure_keeps_plaintext_and_fires_failed_action`, `test_lazy_migration_readback_mismatch_keeps_plaintext_and_cleans_up` (marker absent afterwards), `test_lazy_migration_skipped_when_filter_opts_out` (plaintext stays, no actions), `test_plaintext_beats_marker` (seed both; with the filter opted out, `get_user_totp_key_state()` returns the plaintext), `test_key_state_null_without_secret`, `test_key_state_error_when_api_absent_with_marker` (code `two_factor_secrets_api_missing`; `get_user_totp_key()` returns `''`), `test_key_state_error_when_marker_names_other_network`, `test_migrated_user_still_readable_when_filter_opts_out`, `test_is_available_for_user_true_with_marker_and_api`, `test_is_available_for_user_false_with_marker_and_api_absent`, `test_is_available_for_user_false_with_marker_for_other_network`, `test_is_available_for_user_does_not_migrate` (plaintext seeded, `is_available_for_user()` true, plaintext still present, no action), `test_is_enrolled_but_unavailable_for_user_matrix`, `test_get_user_totp_key_storage_values` (all four values), `test_uninstall_user_meta_keys_include_marker`. Multisite only (`if ( ! is_multisite() ) markTestSkipped`): `test_secret_set_on_blog_one_validates_on_blog_two` (create a second blog, set key on blog 1, `switch_to_blog( 2 )`, `get_user_totp_key()` returns it and `Two_Factor_Totp::is_valid_authcode( $key, Two_Factor_Totp::calc_totp( $key ) )` is true, `restore_current_blog()`). +**Out of scope:** Changes to `validate_code_for_user`, `authentication_page`, `user_two_factor_options`; deletion hooks; admin notices; CLI. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`; also `npm run composer -- test -- --group totp` in both suites. +**Depends on:** P1-02 + +### P2-02: Fail-closed validation, login prompt, profile UI and the login lockout regression +**Goal:** Make every login and profile read of the TOTP secret honour the tri-state, show the "unavailable" paths, fire `two_factor_secret_unavailable`, and prove an affected TOTP-only user is never let in with one factor. +**Files touched:** `providers/class-two-factor-totp.php`, `tests/providers/class-two-factor-totp-secrets.php` +**Design constraints:** SPEC 4.3 (validate, authentication page, profile UI), 4.6 (login), 4.7 (`two_factor_secret_unavailable`; no hook receives plaintext), 4.1 (memzero). Add `private function report_unavailable( $user_id, WP_Error $error )` that fires `do_action( 'two_factor_secret_unavailable', (int) $user_id, self::SECRET_SLUG, $error )`. `validate_code_for_user( $user, $code )`: `$key = get_user_totp_key_state( $user->ID )`; `WP_Error` → `report_unavailable()`, return `false`; `null`/`''` → `false`; else compute the tick as today, then `Two_Factor_Secrets::memzero( $key )` before returning. `authentication_page( $user )`: read the state; on `WP_Error` call `report_unavailable()`, render the two prompt actions as today, print `<p class="two-factor-prompt two-factor-totp-unavailable">` with `esc_html_e( 'Your authenticator app secret is currently unavailable on this site. Please use another method, such as a recovery code, or contact a site administrator.', 'two-factor' )`, fire `two_factor_after_authentication_input`, and return without the code input, `two-factor-login` script or submit button. Never print the `WP_Error` message or code. Otherwise unchanged. `user_two_factor_options( $user )`: read the state; on `WP_Error` call `report_unavailable()` and render, inside `#two-factor-totp-options`, `wp_admin_notice( esc_html__( 'The stored authenticator app secret cannot be read. Reset the authenticator app to set it up again.', 'two-factor' ), array( 'type' => 'error', 'additional_classes' => array( 'inline' ) ) )` followed by the existing "Reset authenticator app" button block; do not render the setup steps or generate a key. `null`/`''` keeps today's setup flow; a string keeps today's "configured" block. +**Acceptance tests:** In `Tests_Two_Factor_Totp_Secrets`: `test_validate_code_fails_when_secret_unreadable` (seed via API, `$test_overrides['get']` returns `WP_Error( 'secret_decryption_failed' )`, a code computed from the real key is rejected, `two_factor_secret_unavailable` fired once with `( $user_id, 'totp', WP_Error )`), `test_validate_code_fails_when_secret_absent`, `test_validate_code_succeeds_from_secrets_api` (real API), `test_validate_authentication_fails_when_api_absent_with_marker`, `test_authentication_page_shows_unavailable_message` (output contains the message, not `authcode` input, not the error code string; action fired), `test_authentication_page_normal_when_readable`, `test_user_options_shows_reset_not_setup_when_unreadable` (contains 'Reset authenticator app' and the notice text; does not contain 'Authentication Code:' nor `two-factor-totp-key`; action fired), `test_user_options_shows_setup_when_null`, `test_login_fails_closed_for_affected_totp_only_user` (real API: set key, `Two_Factor_Core::enable_provider_for_user( $id, 'Two_Factor_Totp' )` as the only provider, `simulate_api_absent()`; assert `is_available_for_user()` false, `Two_Factor_Core::get_available_providers_for_user( $id )` has exactly key `Two_Factor_Email`, `Two_Factor_Core::is_user_using_two_factor( $id )` true, `Two_Factor_Core::filter_authenticate( $user )` returns the `WP_User` and `has_filter( 'send_auth_cookies', '__return_false' )` is truthy; remove that filter in the test), `test_login_fails_closed_for_affected_totp_only_user_without_fallback` (same, plus fallback filter returning `'Two_Factor_Nonexistent'` → `get_available_providers_for_user` is `WP_Error`, `is_user_using_two_factor` true), `test_affected_user_keeps_backup_codes` (TOTP + backup codes enabled, codes generated, API absent → available providers are exactly `Two_Factor_Backup_Codes`). +**Out of scope:** Deletion hooks, uninstall, notices, Site Health, CLI, docs. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`. +**Depends on:** P2-01 + +### P2-03: User deletion hooks and uninstall cleanup of Secrets API entries +**Goal:** Remove a user's TOTP secret and marker when the account is deleted, and remove every user's secret on plugin uninstall. +**Files touched:** `providers/class-two-factor-totp.php`, `tests/providers/class-two-factor-totp-secrets.php`, `tests/class-two-factor-core.php` +**Design constraints:** SPEC 4.5. In `Two_Factor_Totp::__construct()`: `if ( is_multisite() ) { add_action( 'wpmu_delete_user', array( $this, 'delete_user_totp_key' ) ); } else { add_action( 'delete_user', array( $this, 'delete_user_totp_key' ) ); }` (both actions pass the user ID as the first argument; `delete_user_totp_key` already accepts it). Add `public static function uninstall_user_data()`: if `! Two_Factor_Secrets::is_api_present()` return (docblock: secrets are orphaned in the store when the API is absent at uninstall; acceptable per SPEC 4.5); otherwise page through users with `WP_User_Query( array( 'blog_id' => 0, 'fields' => 'ID', 'number' => 100, 'offset' => $offset, 'orderby' => 'ID', 'meta_key' => self::SECRET_NETWORK_META_KEY, 'meta_compare' => 'EXISTS', 'count_total' => false ) )` and call `Two_Factor_Secrets::delete_user_secret( $id, self::SECRET_SLUG )` for each; because the marker is removed by that call, keep `offset` at `0` and stop when a page returns no IDs or contains only IDs already seen (guard against a stuck row). Core already calls this hook (P1-02) before deleting the marker meta. Also update the `uninstall()` docblock in `Two_Factor_Core` with one sentence about orphaned secrets when the API is absent. +**Acceptance tests:** `Tests_Two_Factor_Totp_Secrets`: `test_delete_user_removes_secret_on_single_site` (skip on multisite; `wp_delete_user( $id )` after setting a key → `wp_get_network_secret( name )` is `null`), `test_delete_user_keeps_secret_on_multisite` (skip unless multisite; `remove_user_from_blog()` then `wp_delete_user( $id )` on a blog → secret still present, marker still present), `test_wpmu_delete_user_removes_secret_on_multisite` (skip unless multisite; `wpmu_delete_user( $id )` → secret and marker gone), `test_constructor_registers_correct_deletion_hook` (`has_action( is_multisite() ? 'wpmu_delete_user' : 'delete_user', array( Two_Factor_Totp::get_instance(), 'delete_user_totp_key' ) )` truthy and the other hook absent), `test_uninstall_user_data_removes_secrets_for_all_marked_users` (three users, real API), `test_uninstall_user_data_noop_when_api_absent`. `tests/class-two-factor-core.php`: `test_uninstall_removes_totp_secrets_and_markers` (real API; after `Two_Factor_Core::uninstall()` the secret is `null`, marker meta empty, `get_user_totp_key()` is `''`). +**Out of scope:** Admin notices, Site Health, CLI, docs. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`. +**Depends on:** P2-02 + +### P2-04: Push Phase 2 and record the manual check +**Goal:** Push the TOTP integration for review. +**Files touched:** `docs/PROGRESS.md` (log entry only) +**Design constraints:** SPEC 7. +**Acceptance tests:** none (P2-03 verification must still pass). +**Out of scope:** Opening a pull request. +**Verification:** `git push`. Progress log: `Manual check: NOT VERIFIED (human)` — with the feature plugin active on the dev site: (a) enrol an authenticator app on a profile, confirm `wp option get _wp_network_secret_two-factor/totp-<id>` exists and `_two_factor_totp_key` user meta is empty; (b) log in with a code from the app; (c) deactivate the feature plugin, confirm the login screen offers only the other methods and that the profile shows the reset notice after re-activation with `WP_SECRETS_KEY` changed; (d) delete the user and confirm the option row is gone. +**Depends on:** P2-03 + +## Phase 3 — Downgrade detection + +### P3-01: Affected-user detection and the administrator notice +**Goal:** Warn administrators when migrated users exist but the Secrets API is missing (or their marker names another network), cheaply and non-dismissibly. +**Files touched:** `providers/class-two-factor-totp.php`, `tests/providers/class-two-factor-totp-secrets.php` +**Design constraints:** SPEC 4.6 (admin notice, cheap detection, capabilities). Constants `AFFECTED_USERS_TRANSIENT = 'two_factor_totp_affected_users'`, `AFFECTED_USERS_CACHE_TTL = 300`. `public static function has_affected_users(): bool` — `$cached = get_site_transient( self::AFFECTED_USERS_TRANSIENT )`; `'yes'`/`'no'` → return; else compute: if `! Two_Factor_Secrets::is_api_present()` → `get_users( array( 'blog_id' => 0, 'meta_key' => self::SECRET_NETWORK_META_KEY, 'meta_compare' => 'EXISTS', 'number' => 1, 'fields' => 'ID', 'count_total' => false ) )`; elseif `is_multisite()` → same with `'meta_value' => (string) get_current_network_id(), 'meta_compare' => '!='`; else `false`; store `'yes'`/`'no'` with `set_site_transient( ..., self::AFFECTED_USERS_CACHE_TTL )`. `public static function clear_affected_users_cache()` deletes the transient; call it from `set_user_totp_key()`, `delete_user_totp_key()` and `migrate_user_totp_key()` success paths. Constructor registers `admin_notices` and `network_admin_notices` to `public function admin_notice_secrets_api_missing()`: `$cap = is_network_admin() ? 'manage_network_options' : 'manage_options'`; return unless `current_user_can( $cap )` and `self::has_affected_users()`; then `wp_admin_notice()` with `type => 'error'`, `dismissible => false`, message (escaped, `two-factor` domain) that authenticator-app secrets for one or more users were stored with the WordPress Secrets API which is no longer available on this site, those users cannot use their authenticator app until it is restored, and that administrators should re-activate the Secrets API or run `wp two-factor secrets export` before removing it and `wp two-factor secrets migrate` after restoring it. Both literal command strings must appear in the output. +**Acceptance tests:** `Tests_Two_Factor_Totp_Secrets`: `test_has_affected_users_false_without_markers`, `test_has_affected_users_true_when_api_absent_with_marker`, `test_has_affected_users_true_for_other_network_marker` (skip unless multisite), `test_has_affected_users_is_cached` (second call after seeding a marker without clearing the cache still returns the cached `no`; `clear_affected_users_cache()` then flips it), `test_admin_notice_shown_to_manage_options_user` (output contains both command strings and `notice-error`, no `is-dismissible`), `test_admin_notice_hidden_without_capability` (subscriber → empty output), `test_admin_notice_hidden_when_no_affected_users`, `test_admin_notice_network_admin_requires_manage_network_options` (skip unless multisite; set `$GLOBALS['current_screen']` to a network screen via `set_current_screen( 'dashboard-network' )`; a site administrator without super admin sees nothing, a super admin sees the notice). +**Out of scope:** Site Health; CLI; docs. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`. +**Depends on:** P2-03 + +### P3-02: Site Health test `two_factor_totp_secret_storage` +**Goal:** Report TOTP secret storage state in Site Health with the four statuses SPEC defines. +**Files touched:** `providers/class-two-factor-totp.php`, `tests/providers/class-two-factor-totp-secrets.php` +**Design constraints:** SPEC 4.6 (Site Health table) and the Decision on the extra row. Constructor adds `add_filter( 'site_status_tests', array( $this, 'register_site_health_test' ) )`. `register_site_health_test( $tests )` adds `$tests['direct']['two_factor_totp_secret_storage'] = array( 'label' => __( 'Authenticator app secret storage', 'two-factor' ), 'test' => array( $this, 'site_health_secret_storage' ) )`. `public static function has_plaintext_users(): bool` — `get_users()` with `blog_id => 0`, `meta_key => SECRET_META_KEY`, `meta_value => ''`, `meta_compare => '!='`, `number => 1`, `fields => 'ID'`, `count_total => false` (not cached). `site_health_secret_storage()` returns the standard array (`label`, `status`, `badge` => `array( 'label' => __( 'Security', 'two-factor' ), 'color' => ... )`, `description` (wrapped in `<p>`), `actions` (may be `''`), `test` => `'two_factor_totp_secret_storage'`) with, in this order: (1) `has_affected_users()` → `critical`, colour `red`, description explains those users cannot use their authenticator app and names both remedies: re-activate the Secrets API, or run `wp two-factor secrets export` before deactivating it; (2) API present and `Two_Factor_Secrets::can_write()` and `has_plaintext_users()` → `recommended`, colour `orange`, description suggests `wp two-factor secrets migrate`; (3) API present and no plaintext users → `good`, colour `blue`, description names `Two_Factor_Secrets::provider_label()`; (4) API present, `can_write()` false, plaintext users remain → `good`, colour `blue`, description says migration is disabled by the `two_factor_use_secrets_api` filter or a read-only provider and secrets remain in user meta; (5) API absent, no affected users → `good`, colour `blue`, description exactly `__( 'TOTP secrets are stored in user meta; the WordPress Secrets API, when available, will be used automatically.', 'two-factor' )` (neutral, no nag). +**Acceptance tests:** `Tests_Two_Factor_Totp_Secrets`: `test_site_health_test_is_registered` (filter adds the `direct` entry with the id), `test_site_health_critical_when_affected_users`, `test_site_health_recommended_when_plaintext_remains` (real API; seed plaintext meta directly; description contains `wp two-factor secrets migrate`), `test_site_health_good_when_fully_migrated` (description contains the provider label), `test_site_health_good_when_filter_opts_out_with_plaintext`, `test_site_health_good_neutral_when_api_absent` (exact neutral sentence, status `good`). +**Out of scope:** CLI; docs. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`. +**Depends on:** P3-01 + +### P3-03: Push Phase 3 and record the manual check +**Goal:** Push the downgrade-detection work for review. +**Files touched:** `docs/PROGRESS.md` (log entry only) +**Design constraints:** SPEC 7. +**Acceptance tests:** none (P3-02 verification must still pass). +**Out of scope:** Opening a pull request. +**Verification:** `git push`. Progress log: `Manual check: NOT VERIFIED (human)` — in a browser on the dev site: (a) with a migrated user and the feature plugin deactivated, the red non-dismissible notice appears on Dashboard for an administrator and not for an editor; (b) Tools → Site Health shows the critical result, then `recommended` after re-activating with a second user still in plaintext, then `good` after migrating; (c) on a multisite dev network the notice also appears in Network Admin for a super admin. +**Depends on:** P3-02 + +## Phase 4 — WP-CLI + +### P4-01: `wp two-factor status` storage field and `wp two-factor secrets status` +**Goal:** Expose per-user TOTP storage and site-wide Secrets API status from the CLI. +**Files touched:** `CLI/class-two-factor-cli-command.php`, `tests/cli/class-two-factor-cli-command.php` +**Design constraints:** SPEC 4.8 (`status` field; `secrets status`), existing `backup-codes <action>` pattern (docblock `## OPTIONS`, `@subcommand secrets`, `resolve_user()`, `WP_CLI\Utils\format_items`). In `status()`, append field `totp_storage` = `Two_Factor_Totp::get_instance()->get_user_totp_key_storage( $user->ID )` when `class_exists( 'Two_Factor_Totp' )`, else `'none'`; add it last in the `format_items` field list. New `public function secrets( $args, $assoc_args )` dispatching on `$args[0]` to private `secrets_status()`, `secrets_migrate()`, `secrets_export()`; unknown or missing action → `WP_CLI::error( 'Unknown action "%s". Use: wp two-factor secrets <status|migrate|export>' )`. `secrets_status( $assoc_args )` emits one row via `format_items` with fields, in order: `api_present` (`true`/`false`), `provider` (`Two_Factor_Secrets::provider_label()` or `''`), `writable` (`true`/`false` from `Two_Factor_Secrets::is_provider_writable()`, added in P1-01), `filter_enabled` (`(bool) apply_filters( 'two_factor_use_secrets_api', true, 0 )`), `plaintext_users`, `migrated_users`, `affected_users` (integers). Counts come from a new `public static function Two_Factor_Totp::count_users_by_storage(): array` returning `array( 'plaintext' => int, 'migrated' => int, 'affected' => int )` using `WP_User_Query` with `blog_id => 0`, `fields => 'ID'`, `number => 1`, `count_total => true` and `get_total()`: plaintext = `SECRET_META_KEY != ''`; migrated = marker `EXISTS`; affected = marker `EXISTS` when the API is absent, marker `!=` current network ID when present. `--format` passthrough like `status`. +**Acceptance tests:** `Tests_Two_Factor_CLI_Command`: `test_status_reports_totp_storage_none`, `test_status_reports_totp_storage_plaintext` (API opted out via filter), `test_status_reports_totp_storage_secrets_api` (skip without API), `test_status_reports_totp_storage_unavailable` (marker + API absent filter), `test_secrets_status_reports_present_api_and_counts` (skip without API; one plaintext user via opted-out write, one migrated user; counts `1`, `1`, `0`), `test_secrets_status_reports_absent_api` (`api_present` `false`, `provider` `''`, `affected_users` `1` with a marker), `test_secrets_status_format_passthrough`, `test_secrets_unknown_action_errors`, `test_secrets_missing_action_errors`. Existing CLI tests unchanged (they assert individual keys, not the field list). +**Out of scope:** `migrate` and `export` bodies (stub them to `WP_CLI::error( 'Not implemented' )` only if needed to compile; prefer leaving the dispatcher cases for P4-02/P4-03 to add). +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`; `npm run composer -- test -- --group cli`. +**Depends on:** P3-02 + +### P4-02: `wp two-factor secrets migrate` +**Goal:** Bulk-migrate plaintext TOTP secrets through the same code path as lazy migration, in bounded batches, with dry-run and per-user modes. +**Files touched:** `CLI/class-two-factor-cli-command.php`, `tests/cli/class-two-factor-cli-command.php` +**Design constraints:** SPEC 4.8 (`migrate`), 4.4 (same code path: `Two_Factor_Totp::migrate_user_totp_key()`), Decision on paging. Constant `SECRETS_DEFAULT_BATCH_SIZE = 100`; `--batch-size` parsed like `--count` in `backup_codes()` (decimal integer ≥ 1, else `WP_CLI::error`), default from the constant, never a literal elsewhere. Before any work: `if ( ! Two_Factor_Secrets::can_write() ) WP_CLI::error( 'The Secrets API is not available for writing (missing, read-only, or disabled by the two_factor_use_secrets_api filter). Nothing was migrated.' )`. `--user=<id|login|email>` → `resolve_user()` (error "User not found: %s") and migrate that one user. Otherwise page: `WP_User_Query( array( 'blog_id' => 0, 'fields' => 'ID', 'number' => $batch, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'meta_key' => Two_Factor_Totp::SECRET_META_KEY, 'meta_value' => '', 'meta_compare' => '!=', 'count_total' => false ) )`; for each ID: dry run → log "Would migrate user %d" and count as `skipped`; else `$result = migrate_user_totp_key( $id )`: `true` → `migrated++`; `null` → `skipped++`; `WP_Error` → `failed++` and `WP_CLI::warning( 'User %d: %s' )` with the error message. After each batch `$offset += ( $dry_run ? count( $ids ) : $skipped_in_batch + $failed_in_batch )`; stop when a batch is empty. Finish with `WP_CLI::success( 'Migrated %1$d, failed %2$d, skipped %3$d.' )` (dry run prefixes "Dry run: "). Call `Two_Factor_Totp::clear_affected_users_cache()` at the end. +**Acceptance tests:** `Tests_Two_Factor_CLI_Command` (all skip without the API unless noted): `test_secrets_migrate_single_user` (plaintext seeded directly; after the command plaintext meta empty, marker set, success message counts `1, 0, 0`), `test_secrets_migrate_dry_run_changes_nothing` (plaintext still present, no marker, message starts with "Dry run"), `test_secrets_migrate_pages_through_users_in_batches` (three seeded users, `--batch-size=2`, all three migrated, `migrated` count `3`), `test_secrets_migrate_reports_failed_users_and_continues` (`Two_Factor_Secrets::$test_overrides['set']` fails only for one user ID → counts `2, 1, 0`, a warning names the failed user, its plaintext is intact), `test_secrets_migrate_errors_when_api_absent` (no API needed; simulate absent → aborts with the not-writable message), `test_secrets_migrate_errors_when_filter_opts_out`, `test_secrets_migrate_invalid_batch_size_errors`, `test_secrets_migrate_user_not_found`. +**Out of scope:** `export`; docs. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`; `npm run composer -- test -- --group cli`. +**Depends on:** P4-01 + +### P4-03: `wp two-factor secrets export` +**Goal:** Reverse migration for decommissioning the Secrets API, with confirmation and verification. +**Files touched:** `providers/class-two-factor-totp.php`, `CLI/class-two-factor-cli-command.php`, `tests/providers/class-two-factor-totp-secrets.php`, `tests/cli/class-two-factor-cli-command.php` +**Design constraints:** SPEC 4.8 (`export`), Decision on export return value. Add `public function Two_Factor_Totp::export_user_totp_key( $user_id ): true|null|WP_Error` — plaintext non-empty or marker empty → `null`; `$secret = Two_Factor_Secrets::get_user_secret()`; `WP_Error` → return it (user untouched); `update_user_meta( SECRET_META_KEY, $secret )`; `$readback = (string) get_user_meta( ..., true )`; `! hash_equals( $secret, $readback )` → `delete_user_meta( SECRET_META_KEY )`, `memzero` both, return `WP_Error( 'two_factor_secrets_export_mismatch' )`; else `Two_Factor_Secrets::delete_user_secret()` (ignore its result), `memzero` both, `clear_affected_users_cache()`, return `true`. In the CLI `secrets_export( $assoc_args )`: requires `Two_Factor_Secrets::is_api_present()` else `WP_CLI::error( 'The Secrets API is not available; there is nothing to read the secrets from.' )`; `WP_CLI::confirm( 'This will store authenticator app (TOTP) secrets unencrypted in user meta. Continue?', $assoc_args )` (skipped by `--yes`); `--user` resolves one user, otherwise page the marker query (`meta_key => SECRET_NETWORK_META_KEY`, `meta_compare => 'EXISTS'`, same ordering and `blog_id => 0`) with the P4-02 batching rules, `SECRETS_DEFAULT_BATCH_SIZE` per page, advancing `offset` by the number of users left untouched (`null` or `WP_Error`); `WP_Error` → `WP_CLI::warning( 'User %d: secret is unreadable and was left untouched (%s).' )` with the error code only; finish with `WP_CLI::success( 'Exported %1$d, unreadable %2$d, skipped %3$d.' )`. +**Acceptance tests:** `Tests_Two_Factor_Totp_Secrets`: `test_export_user_totp_key_round_trip` (real API; after export plaintext equals the key, marker empty, `wp_get_network_secret( name )` `null`), `test_export_user_totp_key_null_when_plaintext_or_absent`, `test_export_user_totp_key_error_when_unreadable` (`$test_overrides['get']` → `WP_Error`; plaintext still empty, marker intact), `test_export_user_totp_key_mismatch_cleans_up` (filter `update_user_metadata` short-circuit for `SECRET_META_KEY` returning `true` without writing → `two_factor_secrets_export_mismatch`, marker intact). `Tests_Two_Factor_CLI_Command`: `test_secrets_export_requires_confirmation` (without `--yes` aborts with "confirmation declined", nothing changed), `test_secrets_export_single_user_round_trip` (`--yes`; plaintext restored, marker gone, then `wp two-factor status` reports `plaintext`), `test_secrets_export_all_users_in_batches` (three users, export with `--yes`; all plaintext), `test_secrets_export_skips_unreadable_secret` (one user's read fails → warning names the user, its marker stays, others exported, counts `2, 1, 0`), `test_secrets_export_errors_when_api_absent`. +**Out of scope:** Docs. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`; `npm run composer -- test -- --group cli`. +**Depends on:** P4-02 + +### P4-04: Push Phase 4 and record the manual check +**Goal:** Push the CLI work for review. +**Files touched:** `docs/PROGRESS.md` (log entry only) +**Design constraints:** SPEC 7. +**Acceptance tests:** none (P4-03 verification must still pass). +**Out of scope:** Opening a pull request. +**Verification:** `git push`. Progress log: `Manual check: NOT VERIFIED (human)` — in the dev container (`npm run env run cli wp ...`) with the feature plugin active: (a) `wp two-factor secrets status` and `wp two-factor secrets status --format=json` render; (b) `wp two-factor secrets migrate --dry-run`, then `migrate`, then `wp two-factor status <user>` shows `secrets-api`; (c) `wp two-factor secrets export` prompts, `--yes` completes and `status` shows `plaintext`; (d) `wp help two-factor secrets` shows the OPTIONS block. +**Depends on:** P4-03 + +## Phase 5 — Documentation + +### P5-01: readme.txt, readme.md and CHANGELOG.md +**Goal:** Document the feature for site owners and in the changelog. +**Files touched:** `readme.txt`, `CHANGELOG.md` +**Design constraints:** SPEC 6, SPEC 7 (product documentation stays out of `docs/`). In `readme.txt`: add to `= Actions & Filters =` entries for `two_factor_use_secrets_api` (writes and migration only; migrated users still read), `two_factor_secrets_migrated`, `two_factor_secrets_migration_failed`, `two_factor_secret_unavailable`, each one line in the existing style; add to `== WP-CLI Commands ==` three bullets for `wp two-factor secrets status|migrate|export` and mention the new `totp_storage` field of `status`; add FAQ entry `= How are authenticator (TOTP) secrets stored? =` covering: plaintext user meta by default on WP < 7.2 without the feature plugin; automatic encrypted storage through the WordPress Secrets API when present (link `https://github.com/ericmann/secrets-api`); lazy migration and `wp two-factor secrets migrate`; the `two_factor_use_secrets_api` opt-out filter; the recommendation to define `WP_SECRETS_KEY` (canonical base64 of 32 bytes) and the warning that rotating `LOGGED_IN_KEY`/`LOGGED_IN_SALT` without it makes stored secrets unreadable; the deactivation warning and `wp two-factor secrets export`; the fact that secrets left in the store when the API is absent at uninstall are not removed. `readme.md` has no equivalent sections and is left unchanged (say so in the commit body). `CHANGELOG.md`: insert `## [Unreleased]` above `## [0.17.0]` with `### New Features` and one entry: "Store authenticator app (TOTP) secrets encrypted with the WordPress Secrets API when it is available, with lazy migration, `wp two-factor secrets` commands and a Site Health check by @ericmann in [#TBD](https://github.com/WordPress/two-factor/pull/TBD)". +**Acceptance tests:** none (documentation). `npm run lint` must still pass (readme is not linted, but run it anyway). +**Out of scope:** AGENTS.md, TESTS.md, PR description. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`; visually check `readme.txt` renders sections in the same style as neighbours. +**Depends on:** P4-03 + +### P5-02: AGENTS.md, TESTS.md and docblock audit +**Goal:** Bring the contributor docs and inline docs in line with the shipped code. +**Files touched:** `AGENTS.md`, `TESTS.md`, and only docblocks in `class-two-factor-secrets.php`, `providers/class-two-factor-totp.php`, `providers/class-two-factor-provider.php`, `class-two-factor-core.php`, `CLI/class-two-factor-cli-command.php` +**Design constraints:** SPEC 6 and 4.3 (meta table). `AGENTS.md`: Core Files bullet for `class-two-factor-secrets.php` (static adapter, only caller of the Secrets API, network scope, `two_factor_use_secrets_api`); add `SECRET_NETWORK_META_KEY` / `_two_factor_totp_key_network` row (note it is a constant on `Two_Factor_Totp`, not `Two_Factor_Core`, and holds the network ID); a `### Secrets API hooks` subsection listing the filter, the internal test filter (marked test-only) and the three actions; a `### WP-CLI` subsection naming `CLI/class-two-factor-cli-command.php`, the `secrets` subcommand and the `totp_storage` field; the base-class bullet gains `is_enrolled_but_unavailable_for_user()` and `uninstall_user_data()`; correct the stale "PHPStan (level 2)" mention to level 5. `TESTS.md`: document `tests/class-two-factor-secrets.php`, `tests/providers/class-two-factor-totp-secrets.php`, the `secrets` group filter, the `Two_Factor_Secrets_UnitTestCase` helpers, `tests/class-two-factor-dummy-unavailable.php`, `tests/phpstan/` and why PHPUnit excludes it, and how the feature plugin reaches the tests (`.wp-env.json` `env.tests.plugins`, `WP_SECRETS_KEY`, `TWO_FACTOR_SECRETS_API_FILE` override, skip behaviour when absent); add the new CLI test bullets. Docblock audit: every method, constant, hook and class added in this build carries `@since 0.18.0`; `two_factor_use_secrets_api` docblock states the read-still-works rule; `Two_Factor_Core::uninstall()` mentions orphaned secrets. No code changes. +**Acceptance tests:** none. `git diff --stat` for the PHP files must show docblock-only hunks (reviewer checks). +**Out of scope:** Behaviour changes; readme. +**Verification:** `npm run lint`; `composer lint-compat`; `npm test`. +**Depends on:** P5-01 + +### P5-03: Upstream PR description +**Goal:** Write the ready-to-paste PR body the author will use by hand. +**Files touched:** `docs/PR-DESCRIPTION.md` (new) +**Design constraints:** SPEC 6 (contents), SPEC 7 (never open the PR; the file is under `docs/` and the author strips Foundry files, so keep it self-contained), SPEC 8 (open questions verbatim). Sections, in order: Motivation (plaintext `_two_factor_totp_key`, PR #389 XChaCha20 history and why it stalled, Trac #66187 / wordpress-develop#13759 / feature plugin v0.2.1); Design summary (adapter, marker, precedence, network scope, fail-closed rules, hooks); Migration and downgrade story (lazy + CLI migrate, admin notice, Site Health, export, uninstall orphan note); Compatibility (no `Requires at least` bump, no new dependency, opt-out filter, behaviour identical without the API); Testing notes (how the feature plugin is loaded in wp-env, `WP_SECRETS_KEY`, both suites, groups); Open questions for maintainers (the three from SPEC 8, plus the generic `is_enrolled_but_unavailable_for_user()` core hook and the bulk `uninstall_user_data()` shape as things maintainers may want to rename). +**Acceptance tests:** none. +**Out of scope:** Running `gh pr create` against any repository. +**Verification:** Markdown renders (`cat docs/PR-DESCRIPTION.md`); `npm run lint` unaffected. +**Depends on:** P5-02 + +### P5-04: Push Phase 5 and record the final manual check +**Goal:** Push the completed branch and list the final human steps. +**Files touched:** `docs/PROGRESS.md` (log entry only) +**Design constraints:** SPEC 7 (PR to `ericmann/two-factor` only; base `master`; never `WordPress/two-factor`). +**Acceptance tests:** none (P5-03 verification must still pass). +**Out of scope:** Opening the upstream PR. +**Verification:** `git push`. Progress log: `Manual check: NOT VERIFIED (human)` — (a) read `docs/PR-DESCRIPTION.md` and `readme.txt` FAQ for tone; (b) confirm CI is green on the pushed branch; (c) open the fork PR with base `master` on `ericmann/two-factor` (`gh pr create --repo ericmann/two-factor --base master --draft`) only if the pipeline's own PR step did not; (d) run the P2-04 and P3-03 browser checks once more on the final commit. +**Depends on:** P5-03 + +## Spec issues +1. **SPEC 4.6 relies on a fallback that does not exist.** `Two_Factor_Core::get_available_providers_for_user()` only forces the `two_factor_fallback_provider_for_user` provider when none of the user's stored providers is *registered*. A TOTP-only user whose provider is registered but reports `is_available_for_user() === false` currently yields an empty list, `get_primary_provider_for_user()` returns `null`, `is_user_using_two_factor()` returns `false`, and the password alone logs them in. Resolution: P1-02 adds the generic `is_enrolled_but_unavailable_for_user()` hook and the matching core branch; P2-02's regression tests prove the fail-closed behaviour SPEC asks for. +2. **`uninstall_user_data( $user_id )` cannot be driven by core without TOTP knowledge.** Resolved as a bulk static hook (see Decisions). +3. **Marker present but secret absent is unspecified.** Resolved as `WP_Error( 'two_factor_secret_missing' )` (see Decisions). +4. **Site Health table lacks the "API present, cannot write, plaintext remains" row.** Resolved as `good` with explanatory text (see Decisions). +5. **"Read-only provider" in the `can_write()` matrix has no Secrets API seam.** `wp_secrets_provider_is_writable()` reflects a provider that is cached per process; without a drop-in there is no way to flip it in-process. P1-01 covers it with the `writable` key of the adapter's `@internal` `$test_overrides`. +6. **SPEC 4.9 says PHPStan is level 5; AGENTS.md says level 2.** `phpstan.dist.neon` is level 5. P5-02 corrects AGENTS.md. +7. **Branch base.** SPEC 3 names base `master`; the plan documents are committed on `feature/secrets-api-totp` and `docs/foundry.json` uses that as `baseBranch`. The author retargets the final PR to `master` by hand. +8. **`readme.md` mirror.** No equivalent sections exist; nothing to mirror (Decisions). +9. **Test-only `WP_SECRETS_KEY` is public in `.wp-env.json`.** It only protects the throwaway test database and is deliberately deterministic per SPEC 5. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md new file mode 100644 index 000000000..77a281321 --- /dev/null +++ b/docs/PROGRESS.md @@ -0,0 +1,29 @@ +# Two-Factor Secrets API (TOTP) build progress +Branch: (set by implement) +Started: (set by implement) + +## Tasks +- [ ] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment +- [ ] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory +- [ ] P0-03 Push Phase 0 and record the manual environment check +- [ ] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case +- [ ] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core +- [ ] P1-03 Push Phase 1 and record the manual check +- [ ] P2-01 TOTP tri-state read, Secrets API writes and lazy migration +- [ ] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression +- [ ] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries +- [ ] P2-04 Push Phase 2 and record the manual check +- [ ] P3-01 Affected-user detection and the administrator notice +- [ ] P3-02 Site Health test `two_factor_totp_secret_storage` +- [ ] P3-03 Push Phase 3 and record the manual check +- [ ] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` +- [ ] P4-02 `wp two-factor secrets migrate` +- [ ] P4-03 `wp two-factor secrets export` +- [ ] P4-04 Push Phase 4 and record the manual check +- [ ] P5-01 readme.txt, readme.md and CHANGELOG.md +- [ ] P5-02 AGENTS.md, TESTS.md and docblock audit +- [ ] P5-03 Upstream PR description +- [ ] P5-04 Push Phase 5 and record the final manual check + +## Log +(one entry per task, appended by implement) diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 000000000..d6493803f --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,221 @@ +# SPEC: Store TOTP secrets with the WordPress Secrets API + +Status: approved for build · Branch: `feature/secrets-api-totp` · Base: `master` +Target: a PR to `ericmann/two-factor` (the author's fork) for review. It is **not** a PR to `WordPress/two-factor`; the author opens that one by hand later. + +## 1. Background + +Two-Factor keeps each user's TOTP shared secret **in plaintext** in user meta (`_two_factor_totp_key`, `Two_Factor_Totp::SECRET_META_KEY`). Anyone who can read the database, a backup, or a SQL-injection dump can mint valid codes for every TOTP user. An earlier upstream attempt (`upstream/pr-389-recrypt`, PR #389) added homegrown XChaCha20 encryption keyed off `SECURE_AUTH_SALT`. It stalled because salt rotation needs a bespoke "recrypt" process. + +The **WordPress Secrets API** removes the need for Two-Factor to own any cryptography: + +- Core Trac ticket: #66187, milestone 7.2, accepted, not yet committed. +- Core PR: WordPress/wordpress-develop#13759. +- Feature plugin: `github.com/ericmann/secrets-api`, currently v0.2.1. +- Proposal: make.wordpress.org/core/2026/08/25/proposal-a-secrets-api-for-wordpress-7-2/ + +The API's own ADR 0003 names Two-Factor integration as the Two-Factor maintainers' call. This change is that integration. + +### Secrets API facts this spec relies on (confirmed from feature-plugin source v0.2.1) + +- The plugin and core use identical function names. Feature-detect with `function_exists( 'wp_get_network_secret' )` at **call time**, never when the plugin file is included. +- `wp_set_network_secret( $name, $value ): true|WP_Error` +- `wp_get_network_secret( $name ): WP_Secret|null|WP_Error`. The three return states must never be collapsed: + - `null` means the secret does not exist. + - `WP_Error` means it exists but cannot be read (for example `secret_key_unavailable` or `secret_decryption_failed`). + - `WP_Secret` means success. +- `wp_delete_network_secret( $name ): true|WP_Error` +- `WP_Secret::reveal(): string|WP_Error`. The object masks itself when printed or serialized. +- `wp_secrets_provider_is_writable(): bool` and `wp_secrets_provider_label(): string`. +- `wp_secrets_memzero( &$value )` +- Names take the form `namespace/name`. Each segment matches `/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/` and the whole name is at most 172 characters. +- There is **no per-user scope**, only site and network. Site-scope secrets are bound to the blog ID, so a site-scope secret written on blog 1 cannot be read on blog 2. +- The API does no capability checks. Namespaces are not access control. +- If `WP_SECRETS_KEY` is undefined, the key is derived from `LOGGED_IN_KEY`/`LOGGED_IN_SALT`. Rotating those salts without `WP_SECRETS_KEY` set makes every secret return `WP_Error`. +- A broken drop-in fails closed: every call returns `WP_Error`. +- There is no user-meta import helper and no per-user lifecycle hooks. The owning plugin handles migration and cleanup. + +## 2. Goals + +1. When the Secrets API is present, new and existing TOTP secrets are stored encrypted in the Secrets API instead of plaintext user meta. This happens automatically, with a filter to opt out. +2. Existing plaintext secrets are migrated **lazily**, the first time they are read in the login or profile flow. A **WP-CLI bulk command** migrates everyone in one pass. +3. Sites without the Secrets API (WP < 7.2 without the feature plugin) behave exactly as today. Nothing changes and nothing breaks. +4. Failures **fail closed**: + - An unreadable secret (`WP_Error`) never passes TOTP authentication. + - It is never mistaken for "not configured." + - It never silently re-enrolls the user. + - Other providers (backup codes, email fallback) keep working. +5. If the Secrets API disappears after migration (the feature plugin is deactivated), affected users fall back to their other providers. Administrators get a clear admin notice and a critical Site Health result. A WP-CLI export command moves secrets back to user meta before deactivation. +6. Deleting a user and uninstalling the plugin both clean up the stored secrets. + +## 3. Non-goals + +- Backup codes and email tokens. They are already stored as one-way hashes and don't need reversible storage. +- A settings-page UI toggle. The opt-out is a filter only. +- A cron-based background migration sweep. +- Owning any cryptography or key management in Two-Factor. +- Bumping `Requires at least` or making the Secrets API a hard dependency. +- Per-user access control on secrets. The Secrets API doesn't offer it, and Two-Factor doesn't need it. +- Changes to files in `includes/`. + +## 4. Design + +### 4.1 Storage adapter: `Two_Factor_Secrets` + +Add a new core file, `class-two-factor-secrets.php`, in the plugin root next to `class-two-factor-compat.php`. `two-factor.php` loads it. The class is a thin static adapter so that providers never call `wp_*_secret` directly. It is written generically (keyed by a provider "slug") so a future provider could reuse it. TOTP is the only consumer in this change. + +Required surface (names are normative; parameter lists may gain optional args): + +| Method | Returns | Behavior | +|---|---|---| +| `is_api_present()` | `bool` | `function_exists( 'wp_get_network_secret' )`, plus the other functions used. Checked at call time. This is the single seam tests use to simulate absence (see 4.8). | +| `can_write()` | `bool` | `is_api_present()`, the `two_factor_use_secrets_api` filter (default `true`) passes, and `wp_secrets_provider_is_writable()` is true. | +| `get_secret_name( $user_id, $slug )` | `string` | `two-factor/{$slug}-{$user_id}`, for example `two-factor/totp-42`. Must satisfy the Secrets API name rules. | +| `get_user_secret( $user_id, $slug )` | `string\|null\|WP_Error` | Revealed plaintext, `null` if absent, `WP_Error` if unreadable or if the API is absent while the marker says a secret exists. | +| `set_user_secret( $user_id, $slug, $value )` | `true\|WP_Error` | Writes the network secret and records the marker (4.2). | +| `delete_user_secret( $user_id, $slug )` | `true\|WP_Error` | Deletes the network secret if present and removes the marker. A missing secret is success. | + +Rules: + +- **Always use the network-scope functions** (`wp_*_network_secret`), on both single site and multisite. Two-Factor's user meta applies across the network, so the secret must too. On a single site the network functions store in `wp_options` via `*_site_option`, which works. +- The adapter never logs, echoes, or returns a revealed value anywhere except to its immediate caller. After a revealed value has been used for validation, zero it with `wp_secrets_memzero()` where practical. +- `two_factor_use_secrets_api` (bool, default `true`) controls **writes and migration only**. When it returns `false`, new secrets go to plaintext user meta and nothing is migrated. Users who already have a stored secret are **still read** from the Secrets API as long as it is present, so opting out never locks anyone out. Document this in the filter's docblock. + +### 4.2 Location marker (user meta) + +Add a user meta key `_two_factor_totp_key_network` (constant `Two_Factor_Totp::SECRET_NETWORK_META_KEY`). It holds the **network ID** (`get_current_network_id()`) whose Secrets API store holds that user's TOTP secret. + +- The marker lets `is_available_for_user()` answer cheaply, without decrypting. It also lets downgrade detection find affected users. +- If a read happens on a different network than the marker names (multi-network installs share `wp_users`), the result is `WP_Error` (`two_factor_secret_wrong_network`). It is never `null`. +- If a marker exists but `is_api_present()` is false, the result is `WP_Error` (`two_factor_secrets_api_missing`). + +Where the authoritative TOTP secret lives: + +1. **Plaintext meta (`_two_factor_totp_key`) non-empty:** plaintext is authoritative. This covers legacy data and data written by an older plugin version after a downgrade followed by an upgrade. If `can_write()` is true, migrate it (4.4). +2. **Else, marker present:** the Secrets API is authoritative. +3. **Else:** no TOTP secret. + +### 4.3 `Two_Factor_Totp` changes + +Existing public methods keep their signatures and backward-compatible return semantics, because tests and third parties call them. + +- **New `get_user_totp_key_state( $user_id ): string|null|WP_Error`.** The tri-state read that follows 4.2's precedence. Every internal caller that must tell "absent" apart from "unreadable" uses this. +- **`get_user_totp_key( $user_id ): string`.** Backward-compatible wrapper that returns `''` for both `null` and `WP_Error`. Update its docblock to point to the new method. It triggers lazy migration like any other read. +- **`set_user_totp_key( $user_id, $key )`:** + - When `can_write()` is true, it writes via the adapter, then deletes the plaintext meta **only after** verifying (4.4). + - Otherwise it writes plaintext meta exactly as today, and also deletes any stale marker and secret (best effort) so that step 1 of 4.2 stays unambiguous. + - It returns a truthy value on success and `false` on failure, as today. A Secrets API write error **must not** silently fall back to plaintext when `can_write()` was true. It returns `false`, and the REST setup endpoint surfaces its existing `db_error` message. +- **`delete_user_totp_key( $user_id )`:** deletes plaintext meta, the last-login meta, the Secrets API secret and the marker. When the API is absent, it removes the marker anyway, so a reset is always possible. +- **`is_available_for_user( $user )`:** true if plaintext meta is non-empty, **or** the marker is present **and** `is_api_present()` **and** the marker matches the current network. It must not decrypt. When it returns false for a migrated user because the API is missing, core's existing provider fallback applies (see 4.6). +- **`validate_code_for_user` / `validate_authentication`:** read via the tri-state. `null` and `WP_Error` both fail validation. No code path treats `WP_Error` as success, as empty-and-allowed, or as a reason to skip 2FA. +- **Authentication page:** when the read is a `WP_Error`, show a translated message that the authenticator secret is currently unavailable and the user should use another method, such as a recovery code. Do not show the `WP_Error` message or code to the user. Expose the error code only through an action (4.7). +- **Profile UI (`user_two_factor_options`):** when the tri-state read is a `WP_Error`, do **not** fall into the "generate a new key / setup" branch silently. Show a notice that the stored secret can't be read, plus the existing "Reset authenticator app" control. Resetting deletes the secret and re-enrollment proceeds normally. `null` keeps today's setup flow. +- **`uninstall_user_meta_keys()`:** add the marker key. +- Add `SECRET_NETWORK_META_KEY` to the AGENTS.md meta table. + +### 4.4 Lazy migration + +Migration triggers whenever `get_user_totp_key_state()` finds non-empty plaintext meta **and** `can_write()` is true. Steps: + +1. `set_user_secret()`. On `WP_Error`, keep the plaintext, return the plaintext value (login must not break), and fire `two_factor_secrets_migration_failed`. +2. Read it back with `get_user_secret()` and compare to the plaintext with `hash_equals()`. On mismatch or error, delete the new secret and marker (best effort), keep the plaintext, fire the failure action, and return the plaintext. +3. On success, delete the plaintext `_two_factor_totp_key` meta, fire `two_factor_secrets_migrated`, and return the value. + +Migration must be **idempotent** and safe under concurrent requests. Two simultaneous logins migrating the same value must both succeed, and must never leave a user with neither plaintext nor a readable secret. `is_available_for_user()` never migrates, because it runs on list screens and must stay a cheap read. + +### 4.5 User deletion and uninstall + +- **Single site:** hook `delete_user` and delete that user's TOTP secret and marker. +- **Multisite:** do **not** delete on `delete_user`. In multisite, `delete_user` fires when a user is removed from one blog, but the account remains network-wide. Delete on `wpmu_delete_user` only. Add tests for both cases. +- Register the hooks in `Two_Factor_Totp`'s constructor, following the provider self-registration pattern in AGENTS.md. +- **Uninstall (`Two_Factor_Core::uninstall()`):** for every user with the marker, delete the Secrets API secret if the API is present, then the meta. A provider may declare additional per-user uninstall work through a new optional static hook on the base class, for example `uninstall_user_data( $user_id )`, which is a no-op by default. Don't add TOTP-specific logic to core. If the API is absent at uninstall, the secrets are orphaned in the store. Document this in the uninstall docblock and the readme FAQ. It is acceptable. + +### 4.6 Downgrade / missing-API behavior + +"Affected users" means users with the marker while `is_api_present()` is false, or while the marker names another network. + +- **Login:** TOTP reports unavailable for those users, so core's existing logic picks another enabled provider or the `two_factor_fallback_provider_for_user` / email fail-safe. The build must **verify with tests** that a user whose only enabled provider is TOTP, and who is affected, is **not** let in without a second factor. This is the existing fail-closed email fallback; add a regression test that proves it. +- **Admin notice:** shown on admin screens to users with `manage_options`, or `manage_network_options` in the network admin on multisite, when at least one affected user exists. The notice is not dismissible and names the WP-CLI export and migrate commands. Keep detection cheap and run it only when the API is absent: `get_users()` with `meta_key`, `number => 1`, `fields => 'ID'` and `count_total => false`, cached in a short-lived transient. +- **Site Health:** add a direct test `two_factor_totp_secret_storage` through the `site_status_tests` filter. + + | Situation | Status | Content | + |---|---|---| + | Affected users exist | `critical` | Explains the lockout risk and remedies: re-activate the Secrets API, or run the export command before deactivating. | + | API present, `can_write()` true, plaintext secrets remain | `recommended` | Suggests `wp two-factor secrets migrate`. | + | API present and nothing left in plaintext | `good` | Names `wp_secrets_provider_label()`. | + | API absent and no affected users | `good` | Neutral wording ("TOTP secrets are stored in user meta; the WordPress Secrets API, when available, will be used automatically"). **Do not** nag sites that predate 7.2. | + +### 4.7 Hooks (new, documented) + +- `apply_filters( 'two_factor_use_secrets_api', bool $use, int $user_id )`. Opts out of writes and migration (4.1). `$user_id` may be 0 for non-user contexts. +- `do_action( 'two_factor_secrets_migrated', int $user_id, string $slug )` +- `do_action( 'two_factor_secrets_migration_failed', int $user_id, string $slug, WP_Error $error )` +- `do_action( 'two_factor_secret_unavailable', int $user_id, string $slug, WP_Error $error )`. Fires when a login or profile read hits `WP_Error`. + +No hook ever receives a plaintext secret. + +### 4.8 WP-CLI: `wp two-factor secrets <action>` + +Follow the existing `backup-codes <action>` subcommand pattern in `CLI/class-two-factor-cli-command.php`, including `resolve_user()` and the confirmation conventions. + +| Command | Purpose | +|---|---| +| `wp two-factor secrets status [--format=<format>]` | Whether the API is present, the provider label, whether it is writable, and the effect of the `two_factor_use_secrets_api` filter. Counts of users with plaintext TOTP secrets, users migrated, and affected users. | +| `wp two-factor secrets migrate [--user=<id\|login\|email>] [--batch-size=<n>] [--dry-run]` | Migrates plaintext secrets using the same code path as lazy migration (4.4). Pages through users in batches (default 100) to bound memory, and reports migrated, failed and skipped counts. Errors out clearly if `can_write()` is false. | +| `wp two-factor secrets export [--user=<id\|login\|email>] [--yes]` | Reverse migration for decommissioning the Secrets API. Reads each stored secret, writes plaintext meta, verifies it, then deletes the secret and marker. Requires confirmation without `--yes` and warns that secrets will be stored unencrypted. Users whose secrets are unreadable are reported and left untouched. | + +Also extend the existing `wp two-factor status <user>` output with a TOTP storage field (`plaintext`, `secrets-api`, `unavailable` or `none`). + +### 4.9 Other constraints + +- PHP 7.4 compatible (PHPCompatibilityWP). WPCS / VIP-Go PHPCS clean. PHPStan (the config is level 5) clean. +- PHPStan doesn't know `wp_*_secret` or `WP_Secret`. Add stubs, for example `tests/phpstan/secrets-api-stubs.php` referenced under `scanFiles`, rather than baseline ignores. +- No new runtime dependencies. No composer package for the Secrets API in production. +- Don't modify anything under `includes/`. +- Strings are translatable with the `two-factor` text domain. + +## 5. Testing + +- **Test environment:** make the Secrets API feature plugin available to the `tests` wp-env environment, for example a `.wp-env.json` `env.tests.mappings` / `plugins` entry pinned to `ericmann/secrets-api#v0.2.1`. + - Load it from `tests/bootstrap.php` on `muplugins_loaded` before Two-Factor, **only if the file exists**, so the suite still runs without it. + - Define a deterministic `WP_SECRETS_KEY` (canonical base64 of 32 bytes) in the tests config. + - Update `.github/workflows/test.yml` only if the wp-env change alone isn't enough. +- **Both code paths in one process:** tests need a way to simulate "API absent" while the functions exist. Use one internal seam on `Two_Factor_Secrets::is_api_present()`, for example a filter prefixed and documented as `@internal` for tests, or an overridable static. It must not be advertised as a public extension point. Tests that need the real API `markTestSkipped()` when the feature plugin isn't loaded. +- **Existing tests** that call `set_user_totp_key` / `get_user_totp_key` must pass unchanged, with and without the API. +- **New tests** (`@group secrets` plus existing groups as appropriate), at minimum: + - The adapter: name format, all three read states, network-scope usage, and the `can_write()` matrix (absent, filtered off, read-only provider). + - Set, get and delete round-trips with the API: plaintext meta is empty afterward and the marker is set. + - Lazy migration: success deletes the plaintext; write failure and read-back mismatch keep the plaintext and fire the failure action; the operation is idempotent. + - Precedence: plaintext beats marker (4.2). + - `WP_Error` read: validation fails, the auth page shows the unavailable message, the profile UI shows the reset path and not the setup flow, and `two_factor_secret_unavailable` fires. + - The filter off: new writes go to plaintext, and existing migrated users are still readable. + - API absent with a marker: `is_available_for_user()` is false, and the login lockout/fallback regression from 4.6 holds. + - Wrong network marker (4.2). + - User deletion: the single-site `delete_user` path; on multisite, `delete_user` keeps the secret and `wpmu_delete_user` removes it. + - Uninstall removes the secrets and the marker. + - Site Health result for each state, and admin notice visibility by capability. + - CLI `secrets status`, `migrate` (dry run, per user, batching) and `export` (confirmation, round trip); the `status` storage field. + - Multisite suite: a secret set on blog 1 validates while on blog 2. +- Everything runs through `npm test`, both single site and multisite, and lint (`npm run lint`, `npm run lint:phpstan`) must pass. + +## 6. Documentation + +- `readme.txt`: a FAQ entry, "How are authenticator (TOTP) secrets stored?" It covers the Secrets API, the opt-out filter, the `WP_SECRETS_KEY` recommendation and the salt-rotation warning, the migrate and export CLI commands, and the deactivation warning. +- `readme.md`: mirror the readme.txt content where readme.md carries equivalent sections. +- `CHANGELOG.md`: an `## [Unreleased]` section with a "New Features" entry. Don't invent a PR number; use a placeholder the author will fill in. +- `AGENTS.md`: document `class-two-factor-secrets.php` in Core Files, the new meta key, the hooks, and the CLI subcommand. +- `TESTS.md`: document the new test file(s) and groups, and how the Secrets API plugin is provided to tests. +- Inline docblocks with `@since` using the next version placeholder `0.18.0`. +- `docs/PR-DESCRIPTION.md`: a ready-to-paste upstream PR body covering motivation (plaintext secrets, PR #389's history), design summary, the migration and downgrade story, testing notes, and open questions for maintainers. The author will open the upstream PR by hand. + +## 7. Process constraints for the build + +- The PR produced by this build targets **`ericmann/two-factor`, base `master`** (gh's default repo is pinned to the fork). **Never** open a PR, issue or comment against `WordPress/two-factor`. +- The root `CLAUDE.md` is a checked-in file whose only content is `@AGENTS.md`. Do **not** replace it. If the pipeline needs to add build guidance, append it inside a clearly delimited `<!-- foundry:begin -->` / `<!-- foundry:end -->` block so it can be stripped before upstreaming. +- Foundry's working documents (`docs/SPEC.md`, `docs/PLAN.md`, `docs/PROGRESS.md`, `docs/foundry.json`, review and summary files) live under `docs/`. The author strips them before upstreaming, so keep product documentation out of those files. + +## 8. Open questions for upstream maintainers (to go into the PR description, not resolved here) + +1. Should the adapter be public API for third-party providers, or `@internal` for now? +2. Is the `LOGGED_IN_*` salt-rotation hazard acceptable without `WP_SECRETS_KEY`, or should Two-Factor surface a Site Health recommendation to define it? +3. Naming the network-scope secrets `two-factor/totp-{ID}` puts one row per TOTP user in the options table. Is that acceptable on large networks, versus waiting for a user-scoped store in the Secrets API? diff --git a/docs/foundry.json b/docs/foundry.json new file mode 100644 index 000000000..e23827e5e --- /dev/null +++ b/docs/foundry.json @@ -0,0 +1,190 @@ +{ + "verify": [ + "npm run lint", + "composer lint-compat", + { "cmd": "npm test", "timeoutMs": 1800000 } + ], + "extraVerify": { + ".wp-env.json": [{ "cmd": "npm run env start", "timeoutMs": 900000 }], + "tests/phpstan/": ["npm run lint:phpstan"] + }, + "build": [], + "baseBranch": "feature/secrets-api-totp", + "branchPrefix": "build/", + "maxRounds": 3, + "maxRoundsHard": 6, + "constraints": [ + { + "id": "secrets-api-only-in-adapter", + "description": "Only class-two-factor-secrets.php may call the WordPress Secrets API functions; every other file goes through Two_Factor_Secrets.", + "paths": ["two-factor.php", "class-two-factor-core.php", "class-two-factor-compat.php", "providers/", "settings/", "CLI/"], + "pattern": "\\bwp_(?:get|set|delete|list|retire)_(?:network_)?secrets?(?:_version)?\\s*\\(|\\bwp_secrets_(?:provider_is_writable|provider_label|memzero|validate_name)\\s*\\(|\\bwp_import_option_as_secret\\s*\\(", + "shouldMatch": [ + "$secret = wp_get_network_secret( $name );", + "wp_set_network_secret($name, $value);", + "if ( wp_secrets_provider_is_writable() ) {", + "wp_secrets_memzero( $key );", + "$r = wp_delete_secret( $name );", + "$names = wp_list_network_secrets( 'two-factor' );" + ], + "shouldNotMatch": [ + "Two_Factor_Secrets::get_user_secret( $user_id, 'totp' );", + "function_exists( 'wp_get_network_secret' )", + "$label = Two_Factor_Secrets::provider_label();" + ] + }, + { + "id": "network-scope-only", + "description": "Never use the site-scope Secrets API functions anywhere, including the adapter; TOTP secrets are network scope on single site and multisite.", + "paths": ["class-two-factor-secrets.php", "two-factor.php", "class-two-factor-core.php", "providers/", "settings/", "CLI/"], + "pattern": "\\bwp_(?:get|set|delete|list|retire)_secrets?(?:_version)?\\s*\\(|\\bwp_import_option_as_secret\\s*\\(", + "shouldMatch": [ + "wp_get_secret( $name )", + "wp_set_secret($n, $v);", + "wp_list_secrets( 'two-factor' )", + "wp_delete_secret( $name );" + ], + "shouldNotMatch": [ + "wp_get_network_secret( $name )", + "wp_set_network_secret( $name, $value )", + "wp_delete_network_secret( $name );" + ] + }, + { + "id": "secret-name-prefix-only-in-adapter", + "description": "The two-factor/{slug}-{id} secret name is built only by Two_Factor_Secrets::get_secret_name(); nothing else spells the prefix (the REST namespace two-factor/1.0 is unrelated).", + "paths": ["two-factor.php", "class-two-factor-core.php", "providers/", "settings/", "CLI/"], + "pattern": "['\"]two-factor\\/(?!1\\.0\\b)", + "shouldMatch": [ + "$name = 'two-factor/' . $slug . '-' . $user_id;", + "\"two-factor/{$slug}-{$user_id}\"", + "'two-factor/totp-' . $user_id" + ], + "shouldNotMatch": [ + "const REST_NAMESPACE = 'two-factor/1.0';", + "Two_Factor_Secrets::get_secret_name( $user_id, 'totp' )", + "'/two-factor/1.0/totp'" + ] + }, + { + "id": "totp-meta-keys-via-constants", + "description": "The TOTP secret and marker meta keys are referenced through Two_Factor_Totp::SECRET_META_KEY / SECRET_NETWORK_META_KEY (or Two_Factor_Secrets::get_marker_meta_key()), never as string literals outside their definitions.", + "paths": ["class-two-factor-secrets.php", "class-two-factor-core.php", "two-factor.php", "settings/", "CLI/", "providers/class-two-factor-backup-codes.php", "providers/class-two-factor-email.php", "providers/class-two-factor-provider.php"], + "pattern": "['\"]_two_factor_totp_key(?:_network)?['\"]", + "shouldMatch": [ + "get_user_meta( $id, '_two_factor_totp_key_network', true )", + "'meta_key' => '_two_factor_totp_key',", + "delete_user_meta( $id, \"_two_factor_totp_key\" );" + ], + "shouldNotMatch": [ + "get_user_meta( $id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true )", + "'meta_key' => Two_Factor_Totp::SECRET_META_KEY,", + "return \"_two_factor_{$slug}_key_network\";" + ] + }, + { + "id": "no-debug-output-in-secret-handling-files", + "description": "No error_log/var_dump/print_r/var_export in files that hold revealed secrets; a revealed value must never reach a log.", + "paths": ["class-two-factor-secrets.php", "providers/class-two-factor-totp.php", "CLI/"], + "pattern": "\\b(?:error_log|var_dump|print_r|var_export|debug_print_backtrace|wp_debug_log)\\s*\\(", + "shouldMatch": [ + "error_log( $secret );", + "var_dump($key);", + "print_r( $state, true )" + ], + "shouldNotMatch": [ + "WP_CLI::log( 'Migrated user' );", + "// never error_log a secret", + "WP_CLI::warning( sprintf( 'User %d: %s', $id, $code ) );" + ] + }, + { + "id": "i18n-text-domain", + "description": "Every translatable string uses the two-factor text domain and never omits it.", + "paths": ["class-two-factor-secrets.php", "class-two-factor-core.php", "two-factor.php", "providers/", "settings/", "CLI/"], + "pattern": "\\b(?:__|_e|esc_html__|esc_html_e|esc_attr__|esc_attr_e)\\(\\s*'(?:[^'\\\\]|\\\\.)*'\\s*,\\s*'(?!two-factor')|\\b(?:_x|_ex|esc_html_x|esc_attr_x)\\(\\s*'(?:[^'\\\\]|\\\\.)*'\\s*,\\s*'(?:[^'\\\\]|\\\\.)*'\\s*,\\s*'(?!two-factor')|\\b(?:__|_e|esc_html__|esc_html_e|esc_attr__|esc_attr_e)\\(\\s*'(?:[^'\\\\]|\\\\.)*'\\s*\\)", + "shouldMatch": [ + "__( 'Hello', 'default' )", + "esc_html_e( 'Hi' );", + "_x( 'Label', 'context', 'secrets-api' )", + "return __( 'Missing domain' );" + ], + "shouldNotMatch": [ + "__( 'Hello', 'two-factor' )", + "_x( 'Authenticator App', 'Provider Label', 'two-factor' )", + "esc_html_e( 'It\\'s fine', 'two-factor' );", + "esc_html__( 'Use %s', 'two-factor' )," + ] + }, + { + "id": "no-requires-bump", + "description": "Requires at least stays at 7.0; the Secrets API is never a hard dependency.", + "paths": ["two-factor.php"], + "pattern": "Requires at least:\\s*(?!7\\.0\\b)\\S", + "shouldMatch": [ + " * Requires at least: 7.2", + "Requires at least: 7.1" + ], + "shouldNotMatch": [ + " * Requires at least: 7.0" + ] + }, + { + "id": "no-secrets-api-package-dependency", + "description": "No composer or npm dependency on the Secrets API feature plugin (it is only mapped into the wp-env tests environment).", + "paths": ["composer.json", "package.json"], + "pattern": "secrets-api", + "shouldMatch": [ + "\"ericmann/secrets-api\": \"^0.2\"" + ], + "shouldNotMatch": [ + "\"php-stubs/wp-cli-stubs\": \"dev-master\"" + ] + }, + { + "id": "no-plaintext-in-secret-hooks", + "description": "The two_factor_secret* actions and filters never receive a plaintext or revealed value (line-based; the reviewer also reads multi-line calls).", + "paths": ["class-two-factor-secrets.php", "providers/class-two-factor-totp.php", "CLI/"], + "pattern": "(?:do_action|apply_filters)\\(\\s*'two_factor_secret[^']*'[^;]*\\$(?:plaintext|secret|key|value|revealed|readback|state)\\b", + "shouldMatch": [ + "do_action( 'two_factor_secrets_migrated', $user_id, $slug, $plaintext );", + "do_action( 'two_factor_secret_unavailable', $user_id, $slug, $error, $key );", + "apply_filters( 'two_factor_secrets_api_present', $present, $secret );" + ], + "shouldNotMatch": [ + "do_action( 'two_factor_secrets_migrated', $user_id, $slug );", + "do_action( 'two_factor_secret_unavailable', (int) $user_id, self::SECRET_SLUG, $error );", + "apply_filters( 'two_factor_use_secrets_api', true, (int) $user_id )" + ] + }, + { + "id": "cli-batch-size-via-constant", + "description": "The migrate/export batch size default comes from Two_Factor_CLI_Command::SECRETS_DEFAULT_BATCH_SIZE, never a literal.", + "paths": ["CLI/"], + "pattern": "get_flag_value\\([^)]*'batch-size'\\s*,\\s*['\"]?\\d+", + "shouldMatch": [ + "get_flag_value( $assoc_args, 'batch-size', 100 )", + "get_flag_value( $assoc_args, 'batch-size', '100' )" + ], + "shouldNotMatch": [ + "get_flag_value( $assoc_args, 'batch-size', self::SECRETS_DEFAULT_BATCH_SIZE )", + "get_flag_value( $assoc_args, 'batch-size', (string) self::SECRETS_DEFAULT_BATCH_SIZE )" + ] + }, + { + "id": "affected-users-cache-ttl-via-constant", + "description": "The affected-users transient TTL comes from Two_Factor_Totp::AFFECTED_USERS_CACHE_TTL, never a literal or inline MINUTE_IN_SECONDS expression.", + "paths": ["providers/"], + "pattern": "set_(?:site_)?transient\\(.*,\\s*(?:\\d+|\\d+\\s*\\*\\s*[A-Z_]+_IN_SECONDS|[A-Z_]+_IN_SECONDS(?:\\s*\\*\\s*\\d+)?)\\s*\\)", + "shouldMatch": [ + "set_site_transient( self::AFFECTED_USERS_TRANSIENT, 'yes', 300 );", + "set_site_transient( $k, $v, 5 * MINUTE_IN_SECONDS );", + "set_transient( $k, $v, MINUTE_IN_SECONDS );" + ], + "shouldNotMatch": [ + "set_site_transient( self::AFFECTED_USERS_TRANSIENT, 'yes', self::AFFECTED_USERS_CACHE_TTL );", + "delete_site_transient( self::AFFECTED_USERS_TRANSIENT );" + ] + } + ] +} From 9a4a1c30c9a712df90e9475cb2bdeefedb66ae4d Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:43:32 -0700 Subject: [PATCH 02/59] chore: start implementation run --- .foundry/state.json | 17 +++++++++++++++++ .gitignore | 1 + docs/PROGRESS.md | 4 ++-- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .foundry/state.json diff --git a/.foundry/state.json b/.foundry/state.json new file mode 100644 index 000000000..b2adc2c7e --- /dev/null +++ b/.foundry/state.json @@ -0,0 +1,17 @@ +{ + "round": 0, + "implemented": false, + "reviewed": false, + "verdict": null, + "summarized": false, + "halted": null, + "preexistingUntracked": [], + "policies": { + "signing": "auto", + "push": true, + "pr": "draft", + "feedback": true + }, + "signing": "on", + "rounds": [] +} diff --git a/.gitignore b/.gitignore index 41825c059..cc8d15a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ /npm-debug.log .phpunit.result.cache* phpstan.neon +.foundry/implement.lock diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 77a281321..16f6801ba 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Two-Factor Secrets API (TOTP) build progress -Branch: (set by implement) -Started: (set by implement) +Branch: build/2026-09-28 +Started: 2026-09-28T19:43:32.427Z ## Tasks - [ ] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment From adb3bdf532809d96a5d6fe8fed0b65433572de08 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:44:52 -0700 Subject: [PATCH 03/59] chore: run halted --- .foundry/feedback.jsonl | 1 + .foundry/state.json | 2 +- docs/PROGRESS.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .foundry/feedback.jsonl diff --git a/.foundry/feedback.jsonl b/.foundry/feedback.jsonl new file mode 100644 index 000000000..28305ca17 --- /dev/null +++ b/.foundry/feedback.jsonl @@ -0,0 +1 @@ +{"at":"2026-09-28T19:44:52.482Z","stage":"controller","round":0,"category":"halt","message":"Run halted: P0-01 blocked at operator level. (1) The permission classifier denied twice (Untrusted Code Integration) the commands that add the third-party GitHub plugin ericmann/secrets-api#v0.2.1 to .wp-env.json and start wp-env. (2) Verify commands cannot start: node_modules is missing (npm-run-all, wp-env, phpcs not found; exit 127), so nothing can be tested. Needed: allow this dependency (or add a Bash permission rule), and run npm install and composer install. No source changes were made.","source":"auto"} diff --git a/.foundry/state.json b/.foundry/state.json index b2adc2c7e..72c6326ba 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -4,7 +4,7 @@ "reviewed": false, "verdict": null, "summarized": false, - "halted": null, + "halted": "P0-01 blocked at operator level. (1) The permission classifier denied twice (Untrusted Code Integration) the commands that add the third-party GitHub plugin ericmann/secrets-api#v0.2.1 to .wp-env.json and start wp-env. (2) Verify commands cannot start: node_modules is missing (npm-run-all, wp-env, phpcs not found; exit 127), so nothing can be tested. Needed: allow this dependency (or add a Bash permission rule), and run npm install and composer install. No source changes were made.", "preexistingUntracked": [], "policies": { "signing": "auto", diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 16f6801ba..94490ed78 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,7 @@ Branch: build/2026-09-28 Started: 2026-09-28T19:43:32.427Z ## Tasks -- [ ] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment +- [~] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment - [ ] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory - [ ] P0-03 Push Phase 0 and record the manual environment check - [ ] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case From e9e18d7c50188bad9c8ad21d35e1e736d65ea3e1 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:48:10 -0700 Subject: [PATCH 04/59] P0-01 (partial): load Secrets API feature plugin in tests env Maps ericmann/secrets-api#v0.2.1 into the wp-env tests environment with a deterministic test-only WP_SECRETS_KEY, and loads it from the PHPUnit bootstrap when present. Applied by the operator after the dependency was approved; acceptance tests remain for the implementer. --- .wp-env.json | 9 +++++++-- tests/bootstrap.php | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.wp-env.json b/.wp-env.json index 8e06478aa..f1214e33b 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -8,8 +8,13 @@ "WP_TESTS_EMAIL": "admin@example.org", "WP_TESTS_DOMAIN": "example.org", "WP_SITEURL": "https://example.org", - "WP_HOME": "https://example.org" - } + "WP_HOME": "https://example.org", + "WP_SECRETS_KEY": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" + }, + "plugins": [ + ".", + "ericmann/secrets-api#v0.2.1" + ] } } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index badfc1a85..7b55579f5 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -35,6 +35,15 @@ tests_add_filter( 'muplugins_loaded', function () { + // Load the Secrets API feature plugin when the tests environment provides it. + $secrets_api = getenv( 'TWO_FACTOR_SECRETS_API_FILE' ); + if ( ! $secrets_api ) { + $secrets_api = WP_PLUGIN_DIR . '/secrets-api/secrets-api.php'; + } + if ( file_exists( $secrets_api ) ) { + require_once $secrets_api; + } + require_once dirname( __DIR__ ) . '/two-factor.php'; } ); From ba6e745c27a23c6f006ccfb41bb373045c6a3e64 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:49:39 -0700 Subject: [PATCH 05/59] foundry: clear operator halt --- .foundry/state.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.foundry/state.json b/.foundry/state.json index 72c6326ba..b2adc2c7e 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -4,7 +4,7 @@ "reviewed": false, "verdict": null, "summarized": false, - "halted": "P0-01 blocked at operator level. (1) The permission classifier denied twice (Untrusted Code Integration) the commands that add the third-party GitHub plugin ericmann/secrets-api#v0.2.1 to .wp-env.json and start wp-env. (2) Verify commands cannot start: node_modules is missing (npm-run-all, wp-env, phpcs not found; exit 127), so nothing can be tested. Needed: allow this dependency (or add a Bash permission rule), and run npm install and composer install. No source changes were made.", + "halted": null, "preexistingUntracked": [], "policies": { "signing": "auto", From f54ffa6762c55596218d1ab9d2da794ace1fa8bf Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:50:40 -0700 Subject: [PATCH 06/59] P0-01: load Secrets API feature plugin in tests env Goal: acceptance tests for Secrets API availability under wp-env. Tests: test_secrets_api_feature_plugin_loaded_for_tests, test_secrets_api_network_round_trip (run, not skipped, both suites). Interpretation: worked on build/2026-09-28 (created by run_start) instead of build/secrets-api-totp. Manual check: none. --- tests/two-factor.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/two-factor.php b/tests/two-factor.php index 39a9bd1ed..a70dcc4a1 100644 --- a/tests/two-factor.php +++ b/tests/two-factor.php @@ -29,4 +29,32 @@ public function test_classes_exist() { $this->assertTrue( class_exists( 'Two_Factor_Provider' ) ); $this->assertTrue( class_exists( 'Two_Factor_Core' ) ); } + + /** + * Check that the Secrets API feature plugin is loaded with a test key. + */ + public function test_secrets_api_feature_plugin_loaded_for_tests() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + + $this->assertTrue( function_exists( 'wp_get_network_secret' ) ); + $this->assertTrue( class_exists( 'WP_Secret' ) ); + $this->assertTrue( defined( 'WP_SECRETS_KEY' ) ); + $this->assertSame( 44, strlen( WP_SECRETS_KEY ) ); + } + + /** + * Check that a network secret can be stored, read and deleted. + */ + public function test_secrets_api_network_round_trip() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + + $this->assertTrue( wp_set_network_secret( 'two-factor-test/probe', 'v' ) ); + $this->assertSame( 'v', wp_get_network_secret( 'two-factor-test/probe' )->reveal() ); + wp_delete_network_secret( 'two-factor-test/probe' ); + $this->assertNull( wp_get_network_secret( 'two-factor-test/probe' ) ); + } } From 8a204c8f3a5054495b9a9a1f0d734ea57f13e8ab Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:50:42 -0700 Subject: [PATCH 07/59] progress: P0-01 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 94490ed78..cfb34ba24 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,7 @@ Branch: build/2026-09-28 Started: 2026-09-28T19:43:32.427Z ## Tasks -- [~] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment +- [x] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment - [ ] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory - [ ] P0-03 Push Phase 0 and record the manual environment check - [ ] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case @@ -27,3 +27,6 @@ Started: 2026-09-28T19:43:32.427Z ## Log (one entry per task, appended by implement) + +### P0-01 — f54ffa6 +Config/bootstrap done earlier by operator (e9e18d7); added two acceptance tests in tests/two-factor.php. Both run (not skipped) in single and multisite, 305 tests each. Interpretation: stayed on build/2026-09-28 rather than creating build/secrets-api-totp. From 51fff020e8fe04389755e4caaf973e3c90017deb Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:51:18 -0700 Subject: [PATCH 08/59] P0-02: PHPStan stubs for the Secrets API Goal: resolve Secrets API symbols in PHPStan; exclude stubs from PHPUnit. Tests: lint and both suites pass. --- phpstan.dist.neon | 1 + phpunit-multisite.xml | 1 + phpunit.xml.dist | 1 + tests/phpstan/secrets-api-stubs.php | 89 +++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 tests/phpstan/secrets-api-stubs.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 4b2d77f17..e54e13165 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -13,3 +13,4 @@ parameters: - two-factor.php scanFiles: - vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php + - tests/phpstan/secrets-api-stubs.php diff --git a/phpunit-multisite.xml b/phpunit-multisite.xml index 46bf4685a..fa643097d 100644 --- a/phpunit-multisite.xml +++ b/phpunit-multisite.xml @@ -15,6 +15,7 @@ <testsuites> <testsuite name="multisite"> <directory suffix=".php">tests</directory> + <exclude>tests/phpstan</exclude> </testsuite> </testsuites> diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5276f35c1..7790fac4f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,7 @@ <testsuites> <testsuite name="All Tests"> <directory suffix=".php">tests</directory> + <exclude>tests/phpstan</exclude> </testsuite> </testsuites> diff --git a/tests/phpstan/secrets-api-stubs.php b/tests/phpstan/secrets-api-stubs.php new file mode 100644 index 000000000..84410bfb9 --- /dev/null +++ b/tests/phpstan/secrets-api-stubs.php @@ -0,0 +1,89 @@ +<?php +// phpcs:ignoreFile -- PHPStan stubs for the WordPress Secrets API; never executed. +/** + * PHPStan stubs for the Secrets API feature plugin. + * + * @package Two_Factor + */ + +/** + * Store a network-scoped secret. + * + * @param string $name Secret name. + * @param string $value Secret value. + * @return true|WP_Error + */ +function wp_set_network_secret( $name, $value ) {} + +/** + * Retrieve a network-scoped secret. + * + * @param string $name Secret name. + * @param string $version Version to read. + * @return WP_Secret|null|WP_Error + */ +function wp_get_network_secret( $name, $version = WP_Secret_Version::CURRENT ) {} + +/** + * Delete a network-scoped secret. + * + * @param string $name Secret name. + * @return true|WP_Error + */ +function wp_delete_network_secret( $name ) {} + +/** + * Whether the active secrets provider accepts writes. + * + * @return bool + */ +function wp_secrets_provider_is_writable() {} + +/** + * Human-readable label of the active secrets provider. + * + * @return string + */ +function wp_secrets_provider_label() {} + +/** + * Overwrite a value in memory. + * + * @param string $value Value to wipe. + * @return void + */ +function wp_secrets_memzero( &$value ) {} + +/** + * A retrieved secret. + */ +final class WP_Secret { + /** + * Reveal the plaintext value. + * + * @return string|WP_Error + */ + public function reveal() {} + + /** + * Secret name. + * + * @return string + */ + public function get_name() {} + + /** + * Fingerprint of the value. + * + * @return string + */ + public function fingerprint() {} +} + +/** + * Secret version identifiers. + */ +final class WP_Secret_Version { + const CURRENT = 'current'; + const PREVIOUS = 'previous'; +} From 158fc6ab6c9d4060fe9e16d27aa90c9586d6dc7d Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:51:20 -0700 Subject: [PATCH 09/59] progress: P0-02 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index cfb34ba24..9d464f46e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -4,7 +4,7 @@ Started: 2026-09-28T19:43:32.427Z ## Tasks - [x] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment -- [ ] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory +- [x] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory - [ ] P0-03 Push Phase 0 and record the manual environment check - [ ] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case - [ ] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core @@ -30,3 +30,6 @@ Started: 2026-09-28T19:43:32.427Z ### P0-01 — f54ffa6 Config/bootstrap done earlier by operator (e9e18d7); added two acceptance tests in tests/two-factor.php. Both run (not skipped) in single and multisite, 305 tests each. Interpretation: stayed on build/2026-09-28 rather than creating build/secrets-api-totp. + +### P0-02 — 51fff02 +Added tests/phpstan/secrets-api-stubs.php, scanFiles entry, and tests/phpstan exclude in both phpunit configs. Lint and both suites green (305 tests). From 913635567bec2f03001ff984b0717e66beac4ba8 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:51:34 -0700 Subject: [PATCH 10/59] P0-03: push Phase 0 and record manual environment check From 0abe3adb9dbda4549c7852cc45c263686e64cfc5 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:51:36 -0700 Subject: [PATCH 11/59] progress: P0-03 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9d464f46e..ed5a8d718 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -5,7 +5,7 @@ Started: 2026-09-28T19:43:32.427Z ## Tasks - [x] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment - [x] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory -- [ ] P0-03 Push Phase 0 and record the manual environment check +- [x] P0-03 Push Phase 0 and record the manual environment check - [ ] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case - [ ] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core - [ ] P1-03 Push Phase 1 and record the manual check @@ -33,3 +33,6 @@ Config/bootstrap done earlier by operator (e9e18d7); added two acceptance tests ### P0-02 — 51fff02 Added tests/phpstan/secrets-api-stubs.php, scanFiles entry, and tests/phpstan exclude in both phpunit configs. Lint and both suites green (305 tests). + +### P0-03 — 9136355 +Pushed build/2026-09-28 to origin (ericmann/two-factor). gh default repo is ericmann/two-factor. Empty commit used as task commit. Manual check: NOT VERIFIED (human): (a) fresh clone env start clones ericmann/secrets-api v0.2.1; (b) CI passes on WP latest, 7.0-branch, 7.1-branch legs; (c) gh repo set-default --view shows the fork. From 07e94fb5509e6dc1a7b4b891a9406f9c93309eee Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:54:50 -0700 Subject: [PATCH 12/59] P1-01: Two_Factor_Secrets adapter and shared secrets test case Goal: sole Secrets API adapter plus test base class. Tests: 18 adapter tests in group secrets, both suites. Interpretation: test class/file named Two_Factor_Secrets_Tests in tests/class-two-factor-secrets-tests.php; PHPCS demands file name match the class for classes not extending a core test case, and a name sorting before class-two-factor-core.php breaks core tests (they register set_auth_cookie hooks in set_up_before_class, which a preceding test class wipes). --- class-two-factor-secrets.php | 311 +++++++++++++++++++ phpstan.dist.neon | 1 + phpunit-multisite.xml | 1 + phpunit.xml.dist | 1 + tests/bootstrap.php | 3 + tests/class-two-factor-secrets-test-case.php | 43 +++ tests/class-two-factor-secrets-tests.php | 228 ++++++++++++++ two-factor.php | 5 + 8 files changed, 593 insertions(+) create mode 100644 class-two-factor-secrets.php create mode 100644 tests/class-two-factor-secrets-test-case.php create mode 100644 tests/class-two-factor-secrets-tests.php diff --git a/class-two-factor-secrets.php b/class-two-factor-secrets.php new file mode 100644 index 000000000..62fa04427 --- /dev/null +++ b/class-two-factor-secrets.php @@ -0,0 +1,311 @@ +<?php +/** + * Storage adapter for the WordPress Secrets API. + * + * @package Two_Factor + */ + +/** + * The only place Two-Factor talks to the Secrets API. + * + * Secrets are always network scoped, so the same code path serves single site + * and multisite installs. A per-user marker in user meta records which network + * holds the secret; the marker is what makes "not yet migrated" distinguishable + * from "migrated but unreadable". + * + * @since 0.16.0 + */ +class Two_Factor_Secrets { + + /** + * Test seam overrides. + * + * @internal Test seam only; keys set, get, delete, writable. + * + * @var array<string, callable> + */ + public static $test_overrides = array(); + + /** + * Whether the Secrets API is available right now. + * + * @return bool + */ + public static function is_api_present() { + $present = function_exists( 'wp_get_network_secret' ) + && function_exists( 'wp_set_network_secret' ) + && function_exists( 'wp_delete_network_secret' ) + && function_exists( 'wp_secrets_provider_is_writable' ) + && function_exists( 'wp_secrets_provider_label' ) + && function_exists( 'wp_secrets_memzero' ) + && class_exists( 'WP_Secret' ); + + /** + * Filters whether the Secrets API is treated as present. + * + * @internal Test seam only; not a public extension point. Can only force false. + * + * @param bool $present Whether the API is present. + */ + $filtered = (bool) apply_filters( 'two_factor_secrets_api_present', true ); + + return $present && $filtered; + } + + /** + * Whether the active secrets provider accepts writes. + * + * @return bool + */ + public static function is_provider_writable() { + if ( ! self::is_api_present() ) { + return false; + } + + if ( isset( self::$test_overrides['writable'] ) ) { + return (bool) call_user_func( self::$test_overrides['writable'] ); + } + + return (bool) wp_secrets_provider_is_writable(); + } + + /** + * Whether new secrets may be written to the Secrets API. + * + * @param int $user_id User ID; may be 0 when no user is in context. + * @return bool + */ + public static function can_write( $user_id = 0 ) { + if ( ! self::is_api_present() ) { + return false; + } + + /** + * Filters whether Two-Factor writes secrets to the Secrets API. + * + * This controls writes and migration only. Users whose secret was already + * migrated are still read from the Secrets API while it is present. + * + * @since 0.16.0 + * + * @param bool $enabled Whether to use the Secrets API. Default true. + * @param int $user_id User ID, which may be 0 when no user is in context. + */ + if ( ! (bool) apply_filters( 'two_factor_use_secrets_api', true, (int) $user_id ) ) { + return false; + } + + return self::is_provider_writable(); + } + + /** + * Build the secret name for a user. + * + * @param int $user_id User ID. + * @param string $slug Secret slug, such as "totp". + * @return string + * + * @throws InvalidArgumentException When the slug is invalid. + */ + public static function get_secret_name( $user_id, $slug ) { + self::assert_slug( $slug ); + + return "two-factor/{$slug}-{$user_id}"; + } + + /** + * Get the user meta key holding the network marker. + * + * @param string $slug Secret slug. + * @return string + * + * @throws InvalidArgumentException When the slug is invalid. + */ + public static function get_marker_meta_key( $slug ) { + self::assert_slug( $slug ); + + return "_two_factor_{$slug}_key_network"; + } + + /** + * Read a user's secret. + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @return string|null|WP_Error Null when no secret has been migrated for the user. + */ + public static function get_user_secret( $user_id, $slug ) { + $marker = get_user_meta( $user_id, self::get_marker_meta_key( $slug ), true ); + + if ( '' === $marker || false === $marker || null === $marker ) { + return null; + } + + if ( ! self::is_api_present() ) { + return new WP_Error( + 'two_factor_secrets_api_missing', + __( 'The Secrets API is not available, so this secret cannot be read.', 'two-factor' ) + ); + } + + if ( (int) get_current_network_id() !== (int) $marker ) { + return new WP_Error( + 'two_factor_secret_wrong_network', + __( 'This secret is stored for a different network.', 'two-factor' ) + ); + } + + $value = self::api_get( self::get_secret_name( $user_id, $slug ) ); + + if ( null === $value ) { + return new WP_Error( + 'two_factor_secret_missing', + __( 'The stored secret could not be found.', 'two-factor' ) + ); + } + + return $value; + } + + /** + * Store a user's secret. + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @param string $value Secret value. + * @return true|WP_Error + */ + public static function set_user_secret( $user_id, $slug, $value ) { + if ( ! self::is_api_present() ) { + return new WP_Error( + 'two_factor_secrets_api_missing', + __( 'The Secrets API is not available, so this secret cannot be stored.', 'two-factor' ) + ); + } + + $result = self::api_set( self::get_secret_name( $user_id, $slug ), $value ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + update_user_meta( $user_id, self::get_marker_meta_key( $slug ), (string) get_current_network_id() ); + + return true; + } + + /** + * Delete a user's secret and its marker. + * + * A missing secret counts as success. + * + * @param int $user_id User ID. + * @param string $slug Secret slug. + * @return true|WP_Error + */ + public static function delete_user_secret( $user_id, $slug ) { + $error = null; + + if ( self::is_api_present() ) { + $result = self::api_delete( self::get_secret_name( $user_id, $slug ) ); + if ( is_wp_error( $result ) ) { + $error = $result; + } + } + + delete_user_meta( $user_id, self::get_marker_meta_key( $slug ) ); + + return $error ? $error : true; + } + + /** + * Label of the active secrets provider. + * + * @return string + */ + public static function provider_label() { + if ( function_exists( 'wp_secrets_provider_label' ) ) { + return (string) wp_secrets_provider_label(); + } + + return ''; + } + + /** + * Wipe a value from memory where possible. + * + * @param string $value Value to wipe. + * @return void + */ + public static function memzero( &$value ) { + if ( function_exists( 'wp_secrets_memzero' ) ) { + wp_secrets_memzero( $value ); + return; + } + + $value = ''; + } + + /** + * Validate a slug. + * + * @param string $slug Secret slug. + * @return void + * + * @throws InvalidArgumentException When the slug is invalid. + */ + private static function assert_slug( $slug ) { + if ( ! is_string( $slug ) || 1 !== preg_match( '/^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/', $slug ) ) { + throw new InvalidArgumentException( 'Invalid secret slug.' ); + } + } + + /** + * Write to the Secrets API. + * + * @param string $name Secret name. + * @param string $value Secret value. + * @return true|WP_Error + */ + private static function api_set( $name, $value ) { + if ( isset( self::$test_overrides['set'] ) ) { + return call_user_func( self::$test_overrides['set'], $name, $value ); + } + + return wp_set_network_secret( $name, $value ); + } + + /** + * Read and reveal from the Secrets API. + * + * @param string $name Secret name. + * @return string|null|WP_Error + */ + private static function api_get( $name ) { + if ( isset( self::$test_overrides['get'] ) ) { + return call_user_func( self::$test_overrides['get'], $name ); + } + + $secret = wp_get_network_secret( $name ); + + if ( is_wp_error( $secret ) || null === $secret ) { + return $secret; + } + + return $secret->reveal(); + } + + /** + * Delete from the Secrets API. + * + * @param string $name Secret name. + * @return true|WP_Error + */ + private static function api_delete( $name ) { + if ( isset( self::$test_overrides['delete'] ) ) { + return call_user_func( self::$test_overrides['delete'], $name ); + } + + return wp_delete_network_secret( $name ); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon index e54e13165..ef80279c2 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -9,6 +9,7 @@ parameters: - providers - settings - class-two-factor-compat.php + - class-two-factor-secrets.php - class-two-factor-core.php - two-factor.php scanFiles: diff --git a/phpunit-multisite.xml b/phpunit-multisite.xml index fa643097d..668e27841 100644 --- a/phpunit-multisite.xml +++ b/phpunit-multisite.xml @@ -27,6 +27,7 @@ <directory suffix=".php">settings</directory> <file>class-two-factor-compat.php</file> <file>class-two-factor-core.php</file> + <file>class-two-factor-secrets.php</file> <file>two-factor.php</file> <exclude> <file>includes/function.login-footer.php</file> diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7790fac4f..aac5ac863 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -22,6 +22,7 @@ <directory suffix=".php">settings</directory> <file>class-two-factor-compat.php</file> <file>class-two-factor-core.php</file> + <file>class-two-factor-secrets.php</file> <file>two-factor.php</file> <exclude> <file>includes/function.login-footer.php</file> diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 7b55579f5..d8b3cf34f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -50,3 +50,6 @@ function () { // Start up the WP testing environment. require_once $_tests_dir . '/includes/bootstrap.php'; + +// The shared secrets test case extends WP_UnitTestCase, which exists only after the line above. +require_once __DIR__ . '/class-two-factor-secrets-test-case.php'; diff --git a/tests/class-two-factor-secrets-test-case.php b/tests/class-two-factor-secrets-test-case.php new file mode 100644 index 000000000..865c271cc --- /dev/null +++ b/tests/class-two-factor-secrets-test-case.php @@ -0,0 +1,43 @@ +<?php +/** + * Shared test case for tests touching the Secrets API adapter. + * + * @package Two_Factor + */ + +/** + * Base class for Secrets API related tests. + */ +abstract class Two_Factor_Secrets_UnitTestCase extends WP_UnitTestCase { + + /** + * Skip the test unless the Secrets API feature plugin is loaded. + * + * @return void + */ + protected function require_secrets_api() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + } + + /** + * Make the adapter behave as though the Secrets API were absent. + * + * @return void + */ + protected function simulate_api_absent() { + add_filter( 'two_factor_secrets_api_present', '__return_false' ); + } + + /** + * Reset shared state. + * + * @return void + */ + public function tear_down() { + Two_Factor_Secrets::$test_overrides = array(); + delete_site_transient( 'two_factor_totp_affected_users' ); + parent::tear_down(); + } +} diff --git a/tests/class-two-factor-secrets-tests.php b/tests/class-two-factor-secrets-tests.php new file mode 100644 index 000000000..4270b5530 --- /dev/null +++ b/tests/class-two-factor-secrets-tests.php @@ -0,0 +1,228 @@ +<?php +/** + * Test the Secrets API storage adapter. + * + * @package Two_Factor + */ + +/** + * Class Two_Factor_Secrets_Tests + * + * @package Two_Factor + * @group secrets + */ +class Two_Factor_Secrets_Tests extends Two_Factor_Secrets_UnitTestCase { + + /** + * Secret name format. + */ + public function test_get_secret_name_format() { + $this->assertSame( 'two-factor/totp-42', Two_Factor_Secrets::get_secret_name( 42, 'totp' ) ); + + if ( function_exists( 'wp_secrets_validate_name' ) ) { + $this->assertNotWPError( wp_secrets_validate_name( 'two-factor/totp-42' ) ); + } + } + + /** + * Invalid slugs are rejected. + */ + public function test_get_secret_name_rejects_invalid_slug() { + $this->expectException( InvalidArgumentException::class ); + Two_Factor_Secrets::get_secret_name( 1, 'Bad Slug' ); + } + + /** + * Presence reflects loaded functions. + */ + public function test_is_api_present_reflects_functions() { + $this->assertSame( function_exists( 'wp_get_network_secret' ) && class_exists( 'WP_Secret' ), Two_Factor_Secrets::is_api_present() ); + } + + /** + * The internal filter can force absence. + */ + public function test_is_api_present_internal_filter_forces_absent() { + $this->simulate_api_absent(); + $this->assertFalse( Two_Factor_Secrets::is_api_present() ); + } + + /** + * Writes are allowed with the API present. + */ + public function test_can_write_true_with_api_present() { + $this->require_secrets_api(); + $this->assertTrue( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * Writes are refused without the API. + */ + public function test_can_write_false_when_api_absent() { + $this->simulate_api_absent(); + $this->assertFalse( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * The opt-out filter disables writes and receives the user ID. + */ + public function test_can_write_false_when_filter_opts_out() { + $this->require_secrets_api(); + $seen = null; + add_filter( + 'two_factor_use_secrets_api', + function ( $enabled, $user_id ) use ( &$seen ) { + $seen = $user_id; + return false; + }, + 10, + 2 + ); + + $this->assertFalse( Two_Factor_Secrets::can_write( 77 ) ); + $this->assertSame( 77, $seen ); + } + + /** + * A read-only provider disables writes. + */ + public function test_can_write_false_when_provider_read_only() { + $this->require_secrets_api(); + Two_Factor_Secrets::$test_overrides['writable'] = '__return_false'; + + $this->assertTrue( Two_Factor_Secrets::is_api_present() ); + $this->assertFalse( Two_Factor_Secrets::can_write( 1 ) ); + } + + /** + * No marker means null. + */ + public function test_get_user_secret_null_without_marker() { + $user_id = self::factory()->user->create(); + $this->assertNull( Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ) ); + } + + /** + * Secrets are stored in network scope. + */ + public function test_get_user_secret_round_trip_uses_network_scope() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + + $this->assertTrue( Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ) ); + $this->assertSame( 'ABCDEF', Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ) ); + $this->assertNotNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertNull( wp_get_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( + (string) get_current_network_id(), + get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) + ); + + Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ); + } + + /** + * Marker with no API is an error. + */ + public function test_get_user_secret_error_when_api_absent_with_marker() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secrets_api_missing', $result->get_error_code() ); + } + + /** + * Marker naming another network is an error. + */ + public function test_get_user_secret_error_when_marker_names_other_network() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) ( get_current_network_id() + 1 ) ); + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secret_wrong_network', $result->get_error_code() ); + } + + /** + * Marker with no stored secret is an error. + */ + public function test_get_user_secret_error_when_marker_present_but_secret_gone() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ); + wp_delete_network_secret( "two-factor/totp-{$user_id}" ); + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secret_missing', $result->get_error_code() ); + } + + /** + * API errors pass through. + */ + public function test_get_user_secret_passes_through_api_error() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) get_current_network_id() ); + Two_Factor_Secrets::$test_overrides['get'] = function () { + return new WP_Error( 'secret_decryption_failed' ); + }; + + $result = Two_Factor_Secrets::get_user_secret( $user_id, 'totp' ); + $this->assertWPError( $result ); + $this->assertSame( 'secret_decryption_failed', $result->get_error_code() ); + } + + /** + * A failed write leaves no marker. + */ + public function test_set_user_secret_error_leaves_marker_absent() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + Two_Factor_Secrets::$test_overrides['set'] = function () { + return new WP_Error( 'write_failed' ); + }; + + $this->assertWPError( Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ) ); + $this->assertSame( '', get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) ); + } + + /** + * Delete removes secret and marker. + */ + public function test_delete_user_secret_removes_secret_and_marker() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + Two_Factor_Secrets::set_user_secret( $user_id, 'totp', 'ABCDEF' ); + + $this->assertTrue( Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ) ); + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) ); + } + + /** + * Delete removes the marker when the API is absent. + */ + public function test_delete_user_secret_removes_marker_when_api_absent() { + $user_id = self::factory()->user->create(); + update_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $this->assertTrue( Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ) ); + $this->assertSame( '', get_user_meta( $user_id, Two_Factor_Secrets::get_marker_meta_key( 'totp' ), true ) ); + } + + /** + * Deleting a missing secret succeeds. + */ + public function test_delete_user_secret_missing_is_success() { + $this->require_secrets_api(); + $user_id = self::factory()->user->create(); + + $this->assertTrue( Two_Factor_Secrets::delete_user_secret( $user_id, 'totp' ) ); + } +} diff --git a/two-factor.php b/two-factor.php index 6c3909fb5..d23ca97c5 100644 --- a/two-factor.php +++ b/two-factor.php @@ -49,6 +49,11 @@ */ require_once TWO_FACTOR_DIR . 'class-two-factor-compat.php'; +/** + * Storage adapter for the WordPress Secrets API. + */ +require_once TWO_FACTOR_DIR . 'class-two-factor-secrets.php'; + // Load settings UI class so the settings page can be rendered. require_once TWO_FACTOR_DIR . 'settings/class-two-factor-settings.php'; From ae8d03cd247884793466289746f23b953214b1a7 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:54:53 -0700 Subject: [PATCH 13/59] progress: P1-01 done --- docs/PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ed5a8d718..e4871e0dd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -6,7 +6,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P0-01 Create the build branch and load the Secrets API feature plugin in the tests environment - [x] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory - [x] P0-03 Push Phase 0 and record the manual environment check -- [ ] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case +- [x] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case - [ ] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core - [ ] P1-03 Push Phase 1 and record the manual check - [ ] P2-01 TOTP tri-state read, Secrets API writes and lazy migration @@ -36,3 +36,7 @@ Added tests/phpstan/secrets-api-stubs.php, scanFiles entry, and tests/phpstan ex ### P0-03 — 9136355 Pushed build/2026-09-28 to origin (ericmann/two-factor). gh default repo is ericmann/two-factor. Empty commit used as task commit. Manual check: NOT VERIFIED (human): (a) fresh clone env start clones ericmann/secrets-api v0.2.1; (b) CI passes on WP latest, 7.0-branch, 7.1-branch legs; (c) gh repo set-default --view shows the fork. + +### P1-01 — 07e94fb +Added class-two-factor-secrets.php (Two_Factor_Secrets), required in two-factor.php, wired into phpstan paths and both phpunit coverage whitelists, bootstrap requires tests/class-two-factor-secrets-test-case.php. 18 tests in group secrets, both suites green (323 tests). +Interpretation: test class is Two_Factor_Secrets_Tests in tests/class-two-factor-secrets-tests.php (not Tests_Two_Factor_Secrets): PHPCS requires file name = class name for non-WP test-case parents, and a file sorting before class-two-factor-core.php breaks core tests (core registers set_auth_cookie hooks in set_up_before_class; an earlier test class wipes them). Later test files must sort after class-two-factor-core.php or extend nothing that runs first. Also the internal filter param is $enabled (PHPCS reserved keyword). From 1d74d907105515ba78c02a1daadb6153f07b1e61 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:56:46 -0700 Subject: [PATCH 14/59] P1-02: provider unavailable/uninstall hooks and fail-closed core handling Goal: enrolled-but-unavailable providers force fallback or fail closed. Tests: 2 provider base tests, 5 core tests (fallback, fail-closed, keep available, unconfigured, uninstall hook). Interpretation: resolve_fallback_provider_for_user has an optional 4th by-ref arg exposing the raw filter value for error data; fixture loaded via two_factor_providers path instead of bootstrap (Two_Factor_Dummy not loaded at bootstrap). --- class-two-factor-core.php | 135 +++++++++++++----- providers/class-two-factor-provider.php | 28 ++++ tests/class-two-factor-core.php | 113 +++++++++++++++ tests/class-two-factor-dummy-unavailable.php | 50 +++++++ tests/providers/class-two-factor-provider.php | 21 +++ 5 files changed, 312 insertions(+), 35 deletions(-) create mode 100644 tests/class-two-factor-dummy-unavailable.php diff --git a/class-two-factor-core.php b/class-two-factor-core.php index 42ef1a85b..d657ac9fb 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -237,6 +237,14 @@ public static function uninstall() { } } + // Let the provider delete per-user data held outside user meta, while its own meta still exists. + if ( method_exists( $provider_class, 'uninstall_user_data' ) ) { + try { + call_user_func( array( $provider_class, 'uninstall_user_data' ) ); + } catch ( Exception $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Intentionally empty, provider may not implement this method. + } + } + // Merge with provider-specific option keys. if ( method_exists( $provider_class, 'uninstall_options' ) ) { try { @@ -755,41 +763,10 @@ public static function get_available_providers_for_user( $user = null ) { $still_registered = array_intersect( $stored_providers, array_keys( $providers ) ); if ( empty( $still_registered ) ) { - /** - * Filter the provider forced on when none of a user's stored providers are still registered. - * - * Returning a key that is not registered, or that the provider itself reports as unavailable - * for this user, is treated as "no fallback": the method returns a `no_available_2fa_methods` - * WP_Error rather than allowing the user through with one factor. - * - * The returned provider must be usable without any prior per-user setup (like the email - * provider is), since the user has no working provider left to configure it through: - * - * add_filter( 'two_factor_fallback_provider_for_user', function() { - * return 'Two_Factor_Backup_Codes'; // Wrong: requires codes to already be generated. - * } ); - * - * A fallback that is not already available for the user resolves to the WP_Error branch, - * not to a silent single-factor login. - * - * @since 0.17.0 - * - * @param string $fallback_provider Provider key to force on. Default 'Two_Factor_Email'. - * @param int $user_id The user ID. - * @param string[] $stored_providers Provider keys stored for the user, none of which are registered. - */ - $fallback_provider = apply_filters( - 'two_factor_fallback_provider_for_user', - 'Two_Factor_Email', - $user->ID, - $stored_providers - ); + $fallback_filtered = null; + $fallback_provider = self::resolve_fallback_provider_for_user( $user, $providers, $stored_providers, $fallback_filtered ); - if ( - is_string( $fallback_provider ) - && isset( $providers[ $fallback_provider ] ) - && $providers[ $fallback_provider ]->is_available_for_user( $user ) - ) { + if ( null !== $fallback_provider ) { // Force the fallback provider to 'on'. $enabled_providers[] = $fallback_provider; } else { @@ -801,7 +778,7 @@ public static function get_available_providers_for_user( $user = null ) { array( 'user_providers_raw' => $stored_providers, 'available_providers' => array_keys( $providers ), - 'fallback_provider' => $fallback_provider, + 'fallback_provider' => $fallback_filtered, ) ); } @@ -814,9 +791,97 @@ public static function get_available_providers_for_user( $user = null ) { } } + /** + * A provider the user enrolled can be unusable right now (for example when its secret store is + * unavailable). Dropping it would leave the user with no second factor, so force the fallback + * provider on, or fail closed when there is none. + */ + if ( empty( $configured_providers ) && ! empty( $enabled_providers ) ) { + $unavailable_providers = array(); + + foreach ( $enabled_providers as $provider_key ) { + if ( isset( $providers[ $provider_key ] ) && $providers[ $provider_key ]->is_enrolled_but_unavailable_for_user( $user ) ) { + $unavailable_providers[] = $provider_key; + } + } + + if ( ! empty( $unavailable_providers ) ) { + $fallback_filtered = null; + $fallback_provider = self::resolve_fallback_provider_for_user( $user, $providers, $unavailable_providers, $fallback_filtered ); + + if ( null === $fallback_provider ) { + return new WP_Error( + 'no_available_2fa_methods', + __( 'Error: Your two-factor method is currently unavailable and no fallback method could be used. Please contact a site administrator for assistance.', 'two-factor' ), + array( + 'user_providers_raw' => $stored_providers, + 'available_providers' => array_keys( $providers ), + 'fallback_provider' => $fallback_filtered, + 'unavailable_providers' => $unavailable_providers, + ) + ); + } + + $configured_providers[ $fallback_provider ] = $providers[ $fallback_provider ]; + } + } + return $configured_providers; } + /** + * Resolve the fallback provider to force on for a user. + * + * @since 0.18.0 + * + * @param WP_User $user The user. + * @param array $providers Registered provider instances indexed by key. + * @param string[] $stored_providers Provider keys stored for the user. + * @param mixed $filtered Optional. Receives the raw value returned by the fallback filter. + * @return string|null The fallback provider key, or null when it is not a registered, available provider. + */ + private static function resolve_fallback_provider_for_user( WP_User $user, array $providers, array $stored_providers, &$filtered = null ) { + /** + * Filter the provider forced on when none of a user's stored providers are still registered. + * + * Returning a key that is not registered, or that the provider itself reports as unavailable + * for this user, is treated as "no fallback": the method returns a `no_available_2fa_methods` + * WP_Error rather than allowing the user through with one factor. + * + * The returned provider must be usable without any prior per-user setup (like the email + * provider is), since the user has no working provider left to configure it through: + * + * add_filter( 'two_factor_fallback_provider_for_user', function() { + * return 'Two_Factor_Backup_Codes'; // Wrong: requires codes to already be generated. + * } ); + * + * A fallback that is not already available for the user resolves to the WP_Error branch, + * not to a silent single-factor login. + * + * @since 0.17.0 + * + * @param string $fallback_provider Provider key to force on. Default 'Two_Factor_Email'. + * @param int $user_id The user ID. + * @param string[] $stored_providers Provider keys stored for the user, none of which are registered. + */ + $filtered = apply_filters( + 'two_factor_fallback_provider_for_user', + 'Two_Factor_Email', + $user->ID, + $stored_providers + ); + + if ( + is_string( $filtered ) + && isset( $providers[ $filtered ] ) + && $providers[ $filtered ]->is_available_for_user( $user ) + ) { + return $filtered; + } + + return null; + } + /** * Fetch the provider for the request based on the user preferences. * diff --git a/providers/class-two-factor-provider.php b/providers/class-two-factor-provider.php index 5659309b4..706270013 100644 --- a/providers/class-two-factor-provider.php +++ b/providers/class-two-factor-provider.php @@ -211,4 +211,32 @@ public static function uninstall_user_meta_keys() { public static function uninstall_options() { return array(); } + + /** + * Whether the user has enrolled this provider but its stored credential cannot currently be used. + * + * True when the user has enrolled this provider but its stored credential cannot currently be used, + * for example because an external store is unavailable; core then forces the fallback provider + * instead of dropping to single factor. + * + * @since 0.18.0 + * + * @param WP_User $user WP_User object of the user. + * @return bool + */ + public function is_enrolled_but_unavailable_for_user( $user ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Base implementation keeps the provider interface signature but does not use the user. + return false; + } + + /** + * Delete per-user data held outside user meta. + * + * Called once during uninstall before user meta is deleted so implementations can still use + * their own meta to find affected users. + * + * @since 0.18.0 + * + * @return void + */ + public static function uninstall_user_data() {} } diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index 7545a543d..d5735fe4b 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -3680,6 +3680,119 @@ public function test_get_available_providers_for_user_fails_closed_on_invalid_fa } } + /** + * Register the unavailable provider fixture. + * + * @return void + */ + private function register_unavailable_provider() { + add_filter( + 'two_factor_providers', + function ( $providers ) { + $providers['Two_Factor_Dummy_Unavailable'] = __DIR__ . '/class-two-factor-dummy-unavailable.php'; + return $providers; + } + ); + } + + /** + * An enrolled but unavailable provider forces the fallback. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_forces_fallback_when_enrolled_provider_is_unavailable() { + $this->register_unavailable_provider(); + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy_Unavailable' ) ); + + try { + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertIsArray( $available ); + $this->assertSame( array( 'Two_Factor_Email' ), array_keys( $available ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ) ); + } finally { + remove_all_filters( 'two_factor_providers' ); + } + } + + /** + * An invalid fallback fails closed for an unavailable provider. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_fails_closed_when_enrolled_provider_is_unavailable_and_fallback_invalid() { + $this->register_unavailable_provider(); + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy_Unavailable' ) ); + + $filter = function () { + return 'Two_Factor_Nonexistent'; + }; + add_filter( 'two_factor_fallback_provider_for_user', $filter ); + + try { + $result = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertWPError( $result ); + $this->assertSame( 'no_available_2fa_methods', $result->get_error_code() ); + $this->assertSame( array( 'Two_Factor_Dummy_Unavailable' ), $result->get_error_data()['unavailable_providers'] ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user->ID ) ); + } finally { + remove_filter( 'two_factor_fallback_provider_for_user', $filter ); + remove_all_filters( 'two_factor_providers' ); + } + } + + /** + * An available provider means no fallback is forced. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_keeps_available_provider_when_another_is_unavailable() { + $this->register_unavailable_provider(); + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Dummy_Unavailable', 'Two_Factor_Dummy' ) ); + + try { + $available = Two_Factor_Core::get_available_providers_for_user( $user->ID ); + + $this->assertSame( array( 'Two_Factor_Dummy' ), array_keys( $available ) ); + } finally { + remove_all_filters( 'two_factor_providers' ); + } + } + + /** + * A merely unconfigured provider does not force the fallback. + * + * @covers Two_Factor_Core::get_available_providers_for_user + */ + public function test_get_available_providers_for_user_does_not_force_fallback_for_unconfigured_provider() { + $user = self::factory()->user->create_and_get(); + update_user_meta( $user->ID, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Backup_Codes' ) ); + + $this->assertSame( array(), Two_Factor_Core::get_available_providers_for_user( $user->ID ) ); + } + + /** + * Uninstall calls each provider's uninstall_user_data(). + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_calls_provider_uninstall_user_data() { + $this->register_unavailable_provider(); + Two_Factor_Dummy_Unavailable::$uninstall_user_data_calls = 0; + + try { + Two_Factor_Core::uninstall(); + + $this->assertSame( 1, Two_Factor_Dummy_Unavailable::$uninstall_user_data_calls ); + } finally { + remove_all_filters( 'two_factor_providers' ); + } + } + /** * Verify process_provider() returns WP_Error when no provider is given. * diff --git a/tests/class-two-factor-dummy-unavailable.php b/tests/class-two-factor-dummy-unavailable.php new file mode 100644 index 000000000..c406a1afb --- /dev/null +++ b/tests/class-two-factor-dummy-unavailable.php @@ -0,0 +1,50 @@ +<?php +/** + * Test Two Factor Dummy Unavailable. + * + * @package Two_Factor + */ + +/** + * Provider fixture that is enrolled but never usable. + * + * @package Two_Factor + */ +class Two_Factor_Dummy_Unavailable extends Two_Factor_Dummy { + + /** + * Number of times uninstall_user_data() was called. + * + * @var int + */ + public static $uninstall_user_data_calls = 0; + + /** + * Never available. + * + * @param WP_User $user WP_User object of the user. + * @return boolean + */ + public function is_available_for_user( $user ) { + return false; + } + + /** + * Always enrolled but unavailable. + * + * @param WP_User $user WP_User object of the user. + * @return boolean + */ + public function is_enrolled_but_unavailable_for_user( $user ) { + return true; + } + + /** + * Count uninstall calls. + * + * @return void + */ + public static function uninstall_user_data() { + ++self::$uninstall_user_data_calls; + } +} diff --git a/tests/providers/class-two-factor-provider.php b/tests/providers/class-two-factor-provider.php index 86a4bee35..7ad03d553 100644 --- a/tests/providers/class-two-factor-provider.php +++ b/tests/providers/class-two-factor-provider.php @@ -175,4 +175,25 @@ public function test_uninstall_user_meta_keys_base_returns_empty() { public function test_uninstall_options_base_returns_empty() { $this->assertSame( array(), Two_Factor_Dummy::uninstall_options() ); } + + /** + * The base class does not report enrolled-but-unavailable. + * + * @covers Two_Factor_Provider::is_enrolled_but_unavailable_for_user + */ + public function test_is_enrolled_but_unavailable_for_user_base_returns_false() { + $user = self::factory()->user->create_and_get(); + $provider = Two_Factor_Dummy::get_instance(); + + $this->assertFalse( $provider->is_enrolled_but_unavailable_for_user( $user ) ); + } + + /** + * The base uninstall_user_data hook is a no-op. + * + * @covers Two_Factor_Provider::uninstall_user_data + */ + public function test_uninstall_user_data_base_is_noop() { + $this->assertNull( Two_Factor_Dummy::uninstall_user_data() ); + } } From 8d47283ba2edd6ac37171c7ce1c6045e6a487b22 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:56:49 -0700 Subject: [PATCH 15/59] progress: P1-02 done --- docs/PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index e4871e0dd..65c676aef 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -7,7 +7,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P0-02 PHPStan stubs for the Secrets API and PHPUnit exclusion of the stub directory - [x] P0-03 Push Phase 0 and record the manual environment check - [x] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case -- [ ] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core +- [x] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core - [ ] P1-03 Push Phase 1 and record the manual check - [ ] P2-01 TOTP tri-state read, Secrets API writes and lazy migration - [ ] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression @@ -40,3 +40,7 @@ Pushed build/2026-09-28 to origin (ericmann/two-factor). gh default repo is eric ### P1-01 — 07e94fb Added class-two-factor-secrets.php (Two_Factor_Secrets), required in two-factor.php, wired into phpstan paths and both phpunit coverage whitelists, bootstrap requires tests/class-two-factor-secrets-test-case.php. 18 tests in group secrets, both suites green (323 tests). Interpretation: test class is Two_Factor_Secrets_Tests in tests/class-two-factor-secrets-tests.php (not Tests_Two_Factor_Secrets): PHPCS requires file name = class name for non-WP test-case parents, and a file sorting before class-two-factor-core.php breaks core tests (core registers set_auth_cookie hooks in set_up_before_class; an earlier test class wipes them). Later test files must sort after class-two-factor-core.php or extend nothing that runs first. Also the internal filter param is $enabled (PHPCS reserved keyword). + +### P1-02 — 1d74d90 +Base Two_Factor_Provider::is_enrolled_but_unavailable_for_user() (false) and static uninstall_user_data() (no-op); core get_available_providers_for_user extracts resolve_fallback_provider_for_user and adds the enrolled-but-unavailable branch (fallback or no_available_2fa_methods WP_Error with unavailable_providers); uninstall() calls uninstall_user_data before options/meta deletion. Fixture tests/class-two-factor-dummy-unavailable.php. 7 new tests; 330 in both suites. +Interpretation: helper has optional 4th by-ref $filtered param to preserve raw filtered value in error data; fixture is loaded through the two_factor_providers path (not tests/bootstrap.php) because Two_Factor_Dummy is not loaded at bootstrap time. phpcs:ignore on unused $user in base method mirrors pre_process_authentication precedent. From 9046c966c73b83fa6c927bb9dd399d6e4a0cd143 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:56:56 -0700 Subject: [PATCH 16/59] P1-03: push Phase 1 and record manual check From a96cfc7d82349dbc695ff57e67c69e4de6c774bf Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:56:58 -0700 Subject: [PATCH 17/59] progress: P1-03 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 65c676aef..3c13108d0 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -8,7 +8,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P0-03 Push Phase 0 and record the manual environment check - [x] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case - [x] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core -- [ ] P1-03 Push Phase 1 and record the manual check +- [x] P1-03 Push Phase 1 and record the manual check - [ ] P2-01 TOTP tri-state read, Secrets API writes and lazy migration - [ ] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression - [ ] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries @@ -44,3 +44,6 @@ Interpretation: test class is Two_Factor_Secrets_Tests in tests/class-two-factor ### P1-02 — 1d74d90 Base Two_Factor_Provider::is_enrolled_but_unavailable_for_user() (false) and static uninstall_user_data() (no-op); core get_available_providers_for_user extracts resolve_fallback_provider_for_user and adds the enrolled-but-unavailable branch (fallback or no_available_2fa_methods WP_Error with unavailable_providers); uninstall() calls uninstall_user_data before options/meta deletion. Fixture tests/class-two-factor-dummy-unavailable.php. 7 new tests; 330 in both suites. Interpretation: helper has optional 4th by-ref $filtered param to preserve raw filtered value in error data; fixture is loaded through the two_factor_providers path (not tests/bootstrap.php) because Two_Factor_Dummy is not loaded at bootstrap time. phpcs:ignore on unused $user in base method mirrors pre_process_authentication precedent. + +### P1-03 — 9046c96 +Pushed to origin. Empty commit used as task commit. Manual check: NOT VERIFIED (human): (a) dev site, WP_DEBUG on, user with only Dummy provider logs in unchanged; (b) read class-two-factor-secrets.php for any path that could echo or log a revealed value. From 234dc052d06b5c63e49dd491817c9d40d4a261fb Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:58:37 -0700 Subject: [PATCH 18/59] P2-01: TOTP tri-state read, Secrets API writes and lazy migration Goal: route TOTP key storage through Two_Factor_Secrets with verified writes and lazy migration. Tests: 27 tests in Two_Factor_Totp_Secrets_Tests (multisite cross-blog test skipped on single site). Interpretation: test class named Two_Factor_Totp_Secrets_Tests / file class-two-factor-totp-secrets-tests.php to satisfy PHPCS file naming, as in P1-01. --- providers/class-two-factor-totp.php | 257 +++++++++- .../class-two-factor-totp-secrets-tests.php | 475 ++++++++++++++++++ 2 files changed, 725 insertions(+), 7 deletions(-) create mode 100644 tests/providers/class-two-factor-totp-secrets-tests.php diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 6160dbae9..4424fad12 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -19,6 +19,24 @@ class Two_Factor_Totp extends Two_Factor_Provider { */ const SECRET_META_KEY = '_two_factor_totp_key'; + /** + * The user meta key marking which network holds the user's secret in the Secrets API. + * + * @since 0.18.0 + * + * @var string + */ + const SECRET_NETWORK_META_KEY = '_two_factor_totp_key_network'; + + /** + * The slug used for this provider's entries in the Secrets API. + * + * @since 0.18.0 + * + * @var string + */ + const SECRET_SLUG = 'totp'; + /** * The user meta key for the last successful TOTP token timestamp logged in with. * @@ -451,6 +469,9 @@ public function user_two_factor_options( $user ) { /** * Get the TOTP secret key for a user. * + * Returns an empty string when there is no usable key. Use get_user_totp_key_state() to tell + * "no key" apart from "a key exists but cannot be read". + * * @since 0.2.0 * * @param int $user_id User ID. @@ -458,21 +479,174 @@ public function user_two_factor_options( $user ) { * @return string */ public function get_user_totp_key( $user_id ) { - return (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + $state = $this->get_user_totp_key_state( $user_id ); + + return is_string( $state ) ? $state : ''; + } + + /** + * Get the TOTP secret key for a user, distinguishing "none" from "unreadable". + * + * Precedence: a plaintext user meta value wins (and is lazily migrated when the Secrets API can + * be written to); otherwise the Secrets API is consulted when the user has a location marker. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param bool $migrate Optional. Whether to lazily migrate a plaintext key. Default true. + * + * @return string|null|WP_Error The key, null when the user has none, or a WP_Error when a key exists but cannot be read. + */ + public function get_user_totp_key_state( $user_id, $migrate = true ) { + $plaintext = (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + + if ( '' !== $plaintext ) { + if ( $migrate && Two_Factor_Secrets::can_write( $user_id ) ) { + $this->migrate_user_totp_key( $user_id ); + } + + return $plaintext; + } + + if ( '' === (string) get_user_meta( $user_id, self::SECRET_NETWORK_META_KEY, true ) ) { + return null; + } + + return Two_Factor_Secrets::get_user_secret( $user_id, self::SECRET_SLUG ); + } + + /** + * Move a plaintext TOTP key into the Secrets API. + * + * The plaintext is removed only after the stored secret has been read back and matches. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * + * @return true|null|WP_Error True when migrated, null when there is nothing to migrate, WP_Error on failure. + */ + public function migrate_user_totp_key( $user_id ) { + $plaintext = (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + + if ( '' === $plaintext ) { + return null; + } + + if ( ! Two_Factor_Secrets::can_write( $user_id ) ) { + return new WP_Error( + 'two_factor_secrets_not_writable', + __( 'The Secrets API cannot be written to, so the secret was not migrated.', 'two-factor' ) + ); + } + + $result = Two_Factor_Secrets::set_user_secret( $user_id, self::SECRET_SLUG, $plaintext ); + + if ( is_wp_error( $result ) ) { + $this->fire_migration_failed( $user_id, $result ); + return $result; + } + + $readback = Two_Factor_Secrets::get_user_secret( $user_id, self::SECRET_SLUG ); + + if ( ! is_string( $readback ) || ! hash_equals( $plaintext, $readback ) ) { + $error = is_wp_error( $readback ) + ? $readback + : new WP_Error( + 'two_factor_secrets_migration_mismatch', + __( 'The stored secret did not match the original, so the migration was rolled back.', 'two-factor' ) + ); + + Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + $this->fire_migration_failed( $user_id, $error ); + + return $error; + } + + delete_user_meta( $user_id, self::SECRET_META_KEY ); + Two_Factor_Secrets::memzero( $readback ); + + /** + * Fires after a user's TOTP secret was moved into the Secrets API. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, "totp". + */ + do_action( 'two_factor_secrets_migrated', $user_id, self::SECRET_SLUG ); + + return true; + } + + /** + * Fire the migration failure action. + * + * @param int $user_id User ID. + * @param WP_Error $error The failure. + * + * @return void + */ + private function fire_migration_failed( $user_id, $error ) { + /** + * Fires when moving a user's TOTP secret into the Secrets API failed. + * + * The plaintext copy is kept, so the user can still log in. The error never contains the secret. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, "totp". + * @param WP_Error $error The failure. + */ + do_action( 'two_factor_secrets_migration_failed', $user_id, self::SECRET_SLUG, $error ); } /** * Set the TOTP secret key for a user. * + * Stores into the Secrets API when it is available and writable, verifying by read-back; falls + * back to user meta only when the Secrets API is not usable. A Secrets API failure never + * results in a plaintext write. + * * @since 0.2.0 * * @param int $user_id User ID. * @param string $key TOTP secret key. * - * @return int|bool Meta ID if the key did not exist, true on update, false on failure. + * @return int|bool Meta ID if the key did not exist, true on update or Secrets API write, false on failure. */ public function set_user_totp_key( $user_id, $key ) { - return update_user_meta( $user_id, self::SECRET_META_KEY, $key ); + if ( '' === (string) $key ) { + return $this->delete_user_totp_key( $user_id ); + } + + if ( Two_Factor_Secrets::can_write( $user_id ) ) { + $result = Two_Factor_Secrets::set_user_secret( $user_id, self::SECRET_SLUG, $key ); + + if ( is_wp_error( $result ) ) { + return false; + } + + $readback = Two_Factor_Secrets::get_user_secret( $user_id, self::SECRET_SLUG ); + + if ( ! is_string( $readback ) || ! hash_equals( (string) $key, $readback ) ) { + Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + return false; + } + + Two_Factor_Secrets::memzero( $readback ); + delete_user_meta( $user_id, self::SECRET_META_KEY ); + + return true; + } + + $result = update_user_meta( $user_id, self::SECRET_META_KEY, $key ); + + // Clear any stale marker or secret so the plaintext value is unambiguous. + Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + + return $result; } /** @@ -486,7 +660,43 @@ public function set_user_totp_key( $user_id, $key ) { */ public function delete_user_totp_key( $user_id ) { delete_user_meta( $user_id, self::LAST_SUCCESSFUL_LOGIN_META_KEY ); - return delete_user_meta( $user_id, self::SECRET_META_KEY ); + delete_user_meta( $user_id, self::SECRET_META_KEY ); + + $secret = Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + + return true === $secret + && '' === (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ) + && '' === (string) get_user_meta( $user_id, self::SECRET_NETWORK_META_KEY, true ); + } + + /** + * Get where a user's TOTP key is stored. + * + * Never migrates. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * + * @return string One of 'plaintext', 'secrets-api', 'unavailable' or 'none'. + */ + public function get_user_totp_key_storage( $user_id ) { + if ( '' !== (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ) ) { + return 'plaintext'; + } + + if ( '' === (string) get_user_meta( $user_id, self::SECRET_NETWORK_META_KEY, true ) ) { + return 'none'; + } + + $secret = Two_Factor_Secrets::get_user_secret( $user_id, self::SECRET_SLUG ); + + if ( is_string( $secret ) ) { + Two_Factor_Secrets::memzero( $secret ); + return 'secrets-api'; + } + + return 'unavailable'; } /** @@ -758,10 +968,42 @@ public static function calc_totp( $key, $step_count = false, $digits = self::DEF * @return boolean */ public function is_available_for_user( $user ) { - // Only available if the secret key has been saved for the user. - $key = $this->get_user_totp_key( $user->ID ); + // Available if a plaintext key is saved, or the key lives in a Secrets API network we can reach. + if ( '' !== (string) get_user_meta( $user->ID, self::SECRET_META_KEY, true ) ) { + return true; + } + + $marker = (string) get_user_meta( $user->ID, self::SECRET_NETWORK_META_KEY, true ); + + return '' !== $marker + && Two_Factor_Secrets::is_api_present() + && get_current_network_id() === (int) $marker; + } + + /** + * Whether the user enrolled this provider but the stored key cannot currently be used. + * + * True when the key lives in the Secrets API and the API is missing, or the key belongs to a + * different network. Does not decrypt anything. + * + * @since 0.18.0 + * + * @param WP_User $user WP_User object of the user. + * + * @return boolean + */ + public function is_enrolled_but_unavailable_for_user( $user ) { + if ( '' !== (string) get_user_meta( $user->ID, self::SECRET_META_KEY, true ) ) { + return false; + } + + $marker = (string) get_user_meta( $user->ID, self::SECRET_NETWORK_META_KEY, true ); + + if ( '' === $marker ) { + return false; + } - return ! empty( $key ); + return ! Two_Factor_Secrets::is_api_present() || get_current_network_id() !== (int) $marker; } /** @@ -899,6 +1141,7 @@ private static function abssort( $a, $b ) { public static function uninstall_user_meta_keys() { return array( self::SECRET_META_KEY, + self::SECRET_NETWORK_META_KEY, self::LAST_SUCCESSFUL_LOGIN_META_KEY, ); } diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php new file mode 100644 index 000000000..e4ef846eb --- /dev/null +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -0,0 +1,475 @@ +<?php +/** + * Test the TOTP provider's Secrets API storage. + * + * @package Two_Factor + */ + +/** + * Class Two_Factor_Totp_Secrets_Tests + * + * @package Two_Factor + * @group providers + * @group totp + * @group secrets + */ +class Two_Factor_Totp_Secrets_Tests extends Two_Factor_Secrets_UnitTestCase { + + /** + * Provider under test. + * + * @var Two_Factor_Totp + */ + private $provider; + + /** + * Recorded action calls. + * + * @var array + */ + private $calls = array(); + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + $this->provider = Two_Factor_Totp::get_instance(); + $this->calls = array(); + + $record = function ( $name ) { + return function () use ( $name ) { + $this->calls[] = array_merge( array( $name ), func_get_args() ); + }; + }; + + add_action( 'two_factor_secrets_migrated', $record( 'migrated' ), 10, 2 ); + add_action( 'two_factor_secrets_migration_failed', $record( 'failed' ), 10, 3 ); + } + + /** + * Create a user. + * + * @return int + */ + private function user() { + return self::factory()->user->create(); + } + + /** + * Count recorded calls of a kind. + * + * @param string $name Action alias. + * @return int + */ + private function count_calls( $name ) { + return count( + array_filter( + $this->calls, + function ( $call ) use ( $name ) { + return $call[0] === $name; + } + ) + ); + } + + /** + * Read the marker meta. + * + * @param int $user_id User ID. + * @return string + */ + private function marker( $user_id ) { + return (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ); + } + + /** + * Read the plaintext meta. + * + * @param int $user_id User ID. + * @return string + */ + private function plaintext( $user_id ) { + return (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, true ); + } + + /** + * Marker constant matches the adapter. + */ + public function test_marker_key_matches_adapter() { + $this->assertSame( Two_Factor_Secrets::get_marker_meta_key( 'totp' ), Two_Factor_Totp::SECRET_NETWORK_META_KEY ); + } + + /** + * Keys are stored in the Secrets API. + */ + public function test_set_key_with_api_stores_in_secrets_api() { + $this->require_secrets_api(); + $user_id = $this->user(); + + $this->assertTrue( $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertNotNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Keys fall back to plaintext without the API. + */ + public function test_set_key_without_api_stores_plaintext() { + $this->simulate_api_absent(); + $user_id = $this->user(); + + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + } + + /** + * A failed Secrets API write never falls back to plaintext. + */ + public function test_set_key_returns_false_when_secrets_write_fails() { + $this->require_secrets_api(); + $user_id = $this->user(); + Two_Factor_Secrets::$test_overrides['set'] = function () { + return new WP_Error( 'write_failed' ); + }; + + $this->assertFalse( $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * A read-back mismatch fails the write and cleans up. + */ + public function test_set_key_returns_false_when_readback_mismatches() { + $this->require_secrets_api(); + $user_id = $this->user(); + Two_Factor_Secrets::$test_overrides['get'] = function () { + return 'SOMETHINGELSE'; + }; + + $this->assertFalse( $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ) ); + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * Setting an empty key deletes. + */ + public function test_set_empty_key_deletes() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->provider->set_user_totp_key( $user_id, '' ); + + $this->assertSame( '', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + } + + /** + * Delete clears everything. + */ + public function test_delete_key_removes_secret_marker_and_plaintext() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'LEFTOVER' ); + + $this->assertTrue( $this->provider->delete_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Delete clears the marker without the API. + */ + public function test_delete_key_removes_marker_when_api_absent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $this->assertTrue( $this->provider->delete_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * Reading a plaintext key migrates it. + */ + public function test_lazy_migration_moves_plaintext_to_secrets_api() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + $this->assertSame( 1, $this->count_calls( 'migrated' ) ); + $this->assertSame( array( 'migrated', $user_id, 'totp' ), $this->calls[0] ); + } + + /** + * Migration runs once. + */ + public function test_lazy_migration_is_idempotent() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->provider->get_user_totp_key( $user_id ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 1, $this->count_calls( 'migrated' ) ); + $this->assertNull( $this->provider->migrate_user_totp_key( $user_id ) ); + } + + /** + * A failed write keeps plaintext. + */ + public function test_lazy_migration_write_failure_keeps_plaintext_and_fires_failed_action() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + Two_Factor_Secrets::$test_overrides['set'] = function () { + return new WP_Error( 'write_failed' ); + }; + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( 1, $this->count_calls( 'failed' ) ); + $this->assertSame( 0, $this->count_calls( 'migrated' ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * A read-back mismatch keeps plaintext and cleans up. + */ + public function test_lazy_migration_readback_mismatch_keeps_plaintext_and_cleans_up() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + Two_Factor_Secrets::$test_overrides['get'] = function () { + return 'SOMETHINGELSE'; + }; + + $result = $this->provider->migrate_user_totp_key( $user_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secrets_migration_mismatch', $result->get_error_code() ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( 1, $this->count_calls( 'failed' ) ); + } + + /** + * Opting out skips migration. + */ + public function test_lazy_migration_skipped_when_filter_opts_out() { + $this->require_secrets_api(); + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( array(), $this->calls ); + } + + /** + * Plaintext wins over a marker. + */ + public function test_plaintext_beats_marker() { + $this->require_secrets_api(); + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'PLAINTEXT' ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + $this->assertSame( 'PLAINTEXT', $this->provider->get_user_totp_key_state( $user_id ) ); + } + + /** + * No key means null. + */ + public function test_key_state_null_without_secret() { + $this->assertNull( $this->provider->get_user_totp_key_state( $this->user() ) ); + } + + /** + * A marker without the API is an error. + */ + public function test_key_state_error_when_api_absent_with_marker() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $state = $this->provider->get_user_totp_key_state( $user_id ); + + $this->assertWPError( $state ); + $this->assertSame( 'two_factor_secrets_api_missing', $state->get_error_code() ); + $this->assertSame( '', $this->provider->get_user_totp_key( $user_id ) ); + } + + /** + * A marker for another network is an error. + */ + public function test_key_state_error_when_marker_names_other_network() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) ( get_current_network_id() + 1 ) ); + + $state = $this->provider->get_user_totp_key_state( $user_id ); + + $this->assertWPError( $state ); + $this->assertSame( 'two_factor_secret_wrong_network', $state->get_error_code() ); + } + + /** + * Opting out does not stop reads of migrated users. + */ + public function test_migrated_user_still_readable_when_filter_opts_out() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + } + + /** + * Available with marker and API. + */ + public function test_is_available_for_user_true_with_marker_and_api() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertTrue( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + } + + /** + * Unavailable with marker but no API. + */ + public function test_is_available_for_user_false_with_marker_and_api_absent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + $this->assertFalse( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + } + + /** + * Unavailable with a marker for another network. + */ + public function test_is_available_for_user_false_with_marker_for_other_network() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) ( get_current_network_id() + 1 ) ); + + $this->assertFalse( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + } + + /** + * Availability checks never migrate. + */ + public function test_is_available_for_user_does_not_migrate() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $this->assertTrue( $this->provider->is_available_for_user( get_userdata( $user_id ) ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( array(), $this->calls ); + } + + /** + * Enrolled-but-unavailable matrix. + */ + public function test_is_enrolled_but_unavailable_for_user_matrix() { + $user_id = $this->user(); + $user = get_userdata( $user_id ); + + // Nothing stored. + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + + // Plaintext. + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + delete_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY ); + + // Marker for this network, API present. + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + if ( function_exists( 'wp_get_network_secret' ) ) { + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + + // Marker for another network. + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) ( get_current_network_id() + 1 ) ); + $this->assertTrue( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + } + + // Marker with the API absent. + $this->simulate_api_absent(); + $this->assertTrue( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + } + + /** + * Storage descriptions. + */ + public function test_get_user_totp_key_storage_values() { + $this->require_secrets_api(); + $user_id = $this->user(); + + $this->assertSame( 'none', $this->provider->get_user_totp_key_storage( $user_id ) ); + + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + $this->assertSame( 'plaintext', $this->provider->get_user_totp_key_storage( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ), 'Reading storage does not migrate.' ); + + $this->provider->delete_user_totp_key( $user_id ); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + $this->assertSame( 'secrets-api', $this->provider->get_user_totp_key_storage( $user_id ) ); + + $this->simulate_api_absent(); + $this->assertSame( 'unavailable', $this->provider->get_user_totp_key_storage( $user_id ) ); + } + + /** + * Uninstall removes the marker. + */ + public function test_uninstall_user_meta_keys_include_marker() { + $this->assertContains( Two_Factor_Totp::SECRET_NETWORK_META_KEY, Two_Factor_Totp::uninstall_user_meta_keys() ); + } + + /** + * A secret set on one site validates on another. + */ + public function test_secret_set_on_blog_one_validates_on_blog_two() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->require_secrets_api(); + + $user_id = $this->user(); + $blog_id = self::factory()->blog->create(); + $key = Two_Factor_Totp::generate_key(); + + $this->provider->set_user_totp_key( $user_id, $key ); + + switch_to_blog( $blog_id ); + try { + $this->assertSame( $key, $this->provider->get_user_totp_key( $user_id ) ); + $this->assertTrue( Two_Factor_Totp::is_valid_authcode( $key, Two_Factor_Totp::calc_totp( $key ) ) ); + } finally { + restore_current_blog(); + } + } +} From ea66afe7052645163cb46dfb447b1bd5aba82f95 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:58:41 -0700 Subject: [PATCH 19/59] progress: P2-01 done --- docs/PROGRESS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3c13108d0..116feb3b3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -9,7 +9,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P1-01 `Two_Factor_Secrets` adapter with tests and the shared secrets test case - [x] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core - [x] P1-03 Push Phase 1 and record the manual check -- [ ] P2-01 TOTP tri-state read, Secrets API writes and lazy migration +- [x] P2-01 TOTP tri-state read, Secrets API writes and lazy migration - [ ] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression - [ ] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries - [ ] P2-04 Push Phase 2 and record the manual check @@ -47,3 +47,8 @@ Interpretation: helper has optional 4th by-ref $filtered param to preserve raw f ### P1-03 — 9046c96 Pushed to origin. Empty commit used as task commit. Manual check: NOT VERIFIED (human): (a) dev site, WP_DEBUG on, user with only Dummy provider logs in unchanged; (b) read class-two-factor-secrets.php for any path that could echo or log a revealed value. + +### P2-01 — 234dc05 +Two_Factor_Totp: SECRET_NETWORK_META_KEY/SECRET_SLUG, get_user_totp_key_state, migrate_user_totp_key (verified, idempotent, fires two_factor_secrets_migrated / _migration_failed), Secrets-API-first set/delete, is_available_for_user and is_enrolled_but_unavailable_for_user by marker, get_user_totp_key_storage, marker added to uninstall meta keys. Tests: tests/providers/class-two-factor-totp-secrets-tests.php (27 tests; multisite blog test skipped on single). Suites: 356 tests, single has 1 expected skip, multisite runs it. +Interpretation: class/file named Two_Factor_Totp_Secrets_Tests (PHPCS file naming, as P1-01). +Note: delete_user_totp_key now returns true only when plaintext, marker and secret are gone (also true when nothing existed). From f346ba43d1fae648282ab1bda1642e7d8d82c280 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:59:56 -0700 Subject: [PATCH 20/59] P2-02: fail-closed TOTP validation, prompt, profile UI and lockout regression Goal: honour the tri-state secret read everywhere and fire two_factor_secret_unavailable. Tests: 11 tests appended to Two_Factor_Totp_Secrets_Tests including affected-user login regression. Interpretation: unavailable login prompt omits the generic 'Enter the code' line. --- providers/class-two-factor-totp.php | 88 ++++++- .../class-two-factor-totp-secrets-tests.php | 241 ++++++++++++++++++ 2 files changed, 323 insertions(+), 6 deletions(-) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 4424fad12..d722dcbb2 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -364,7 +364,13 @@ public function user_two_factor_options( $user ) { return; } - $key = $this->get_user_totp_key( $user->ID ); + $state = $this->get_user_totp_key_state( $user->ID ); + + if ( is_wp_error( $state ) ) { + $this->report_unavailable( $user->ID, $state ); + } + + $key = is_string( $state ) ? $state : ''; wp_localize_script( 'two-factor-totp-admin', @@ -380,7 +386,22 @@ public function user_two_factor_options( $user ) { ?> <div id="two-factor-totp-options"> <?php - if ( empty( $key ) ) : + if ( is_wp_error( $state ) ) : + wp_admin_notice( + esc_html__( 'The stored authenticator app secret cannot be read. Reset the authenticator app to set it up again.', 'two-factor' ), + array( + 'type' => 'error', + 'additional_classes' => array( 'inline' ), + ) + ); + ?> + <p> + <button type="button" class="button button-secondary reset-totp-key hide-if-no-js"> + <?php esc_html_e( 'Reset authenticator app', 'two-factor' ); ?> + </button> + </p> + <?php + elseif ( empty( $key ) ) : $key = $this->generate_key(); $totp_url = $this->generate_qr_code_url( $user, $key ); ?> @@ -453,6 +474,7 @@ public function user_two_factor_options( $user ) { wp_enqueue_script( 'two-factor-totp-qrcode' ); ?> <?php else : ?> + <?php Two_Factor_Secrets::memzero( $key ); ?> <p class="description success"> <?php esc_html_e( 'An authenticator app is currently configured. You will need to re-scan the QR code on all devices if reset.', 'two-factor' ); ?> </p> @@ -747,10 +769,20 @@ public function validate_authentication( $user ) { * @return bool Whether the code is valid for the user and a newer code has not been used. */ public function validate_code_for_user( $user, $code ) { - $valid_timestamp = $this->get_authcode_valid_ticktime( - $this->get_user_totp_key( $user->ID ), - $code - ); + $key = $this->get_user_totp_key_state( $user->ID ); + + if ( is_wp_error( $key ) ) { + $this->report_unavailable( $user->ID, $key ); + return false; + } + + if ( null === $key || '' === $key ) { + return false; + } + + $valid_timestamp = $this->get_authcode_valid_ticktime( $key, $code ); + + Two_Factor_Secrets::memzero( $key ); if ( ! $valid_timestamp ) { return false; @@ -1006,6 +1038,29 @@ public function is_enrolled_but_unavailable_for_user( $user ) { return ! Two_Factor_Secrets::is_api_present() || get_current_network_id() !== (int) $marker; } + /** + * Announce that a user's stored secret cannot be used. + * + * @param int $user_id User ID. + * @param WP_Error $error Why the secret is unavailable. + * + * @return void + */ + private function report_unavailable( $user_id, WP_Error $error ) { + /** + * Fires when a user's TOTP secret exists but cannot be read. + * + * Neither the secret nor its plaintext is passed to listeners. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * @param string $slug Secret slug, "totp". + * @param WP_Error $error Why the secret is unavailable. + */ + do_action( 'two_factor_secret_unavailable', (int) $user_id, self::SECRET_SLUG, $error ); + } + /** * Prints the form that prompts the user to authenticate. * @@ -1017,10 +1072,31 @@ public function is_enrolled_but_unavailable_for_user( $user ) { */ public function authentication_page( $user ) { require_once ABSPATH . '/wp-admin/includes/template.php'; + + $state = $this->get_user_totp_key_state( $user->ID ); + + if ( is_wp_error( $state ) ) { + $this->report_unavailable( $user->ID, $state ); + } elseif ( is_string( $state ) ) { + Two_Factor_Secrets::memzero( $state ); + } ?> <?php /** This action is documented in providers/class-two-factor-backup-codes.php */ do_action( 'two_factor_before_authentication_prompt', $this ); + + if ( is_wp_error( $state ) ) { + /** This action is documented in providers/class-two-factor-backup-codes.php */ + do_action( 'two_factor_after_authentication_prompt', $this ); + ?> + <p class="two-factor-prompt two-factor-totp-unavailable"> + <?php esc_html_e( 'Your authenticator app secret is currently unavailable on this site. Please use another method, such as a recovery code, or contact a site administrator.', 'two-factor' ); ?> + </p> + <?php + /** This action is documented in providers/class-two-factor-backup-codes.php */ + do_action( 'two_factor_after_authentication_input', $this ); + return; + } ?> <p class="two-factor-prompt"> <?php esc_html_e( 'Enter the code generated by your authenticator app.', 'two-factor' ); ?> diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index e4ef846eb..8a42609da 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -472,4 +472,245 @@ public function test_secret_set_on_blog_one_validates_on_blog_two() { restore_current_blog(); } } + + /** + * Record calls to the unavailable action. + * + * @return void + */ + private function record_unavailable() { + add_action( + 'two_factor_secret_unavailable', + function () { + $this->calls[] = array_merge( array( 'unavailable' ), func_get_args() ); + }, + 10, + 3 + ); + } + + /** + * Make the stored secret unreadable with a decryption error. + * + * @return void + */ + private function make_unreadable() { + Two_Factor_Secrets::$test_overrides['get'] = function () { + return new WP_Error( 'secret_decryption_failed', 'sensitive detail' ); + }; + } + + /** + * Capture output of a callback. + * + * @param callable $callback Callback. + * @return string + */ + private function capture( $callback ) { + ob_start(); + $callback(); + return (string) ob_get_clean(); + } + + /** + * Unreadable secrets fail validation. + */ + public function test_validate_code_fails_when_secret_unreadable() { + $this->require_secrets_api(); + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + $this->make_unreadable(); + $this->record_unavailable(); + + $this->assertFalse( $this->provider->validate_code_for_user( get_userdata( $user_id ), Two_Factor_Totp::calc_totp( $key ) ) ); + $this->assertSame( 1, $this->count_calls( 'unavailable' ) ); + $this->assertSame( $user_id, $this->calls[0][1] ); + $this->assertSame( 'totp', $this->calls[0][2] ); + $this->assertInstanceOf( WP_Error::class, $this->calls[0][3] ); + } + + /** + * No secret fails validation. + */ + public function test_validate_code_fails_when_secret_absent() { + $user_id = $this->user(); + + $this->assertFalse( $this->provider->validate_code_for_user( get_userdata( $user_id ), '123456' ) ); + } + + /** + * A Secrets API key validates. + */ + public function test_validate_code_succeeds_from_secrets_api() { + $this->require_secrets_api(); + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + + $this->assertTrue( $this->provider->validate_code_for_user( get_userdata( $user_id ), Two_Factor_Totp::calc_totp( $key ) ) ); + } + + /** + * A marker without the API fails validation. + */ + public function test_validate_authentication_fails_when_api_absent_with_marker() { + $this->require_secrets_api(); + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + $this->simulate_api_absent(); + $_POST['authcode'] = Two_Factor_Totp::calc_totp( $key ); + + try { + $this->assertFalse( $this->provider->validate_authentication( get_userdata( $user_id ) ) ); + } finally { + unset( $_POST['authcode'] ); + } + } + + /** + * The login prompt explains the problem. + */ + public function test_authentication_page_shows_unavailable_message() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->make_unreadable(); + $this->record_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->authentication_page( $user ); + } + ); + + $this->assertStringContainsString( 'currently unavailable on this site', $html ); + $this->assertStringNotContainsString( 'name="authcode"', $html ); + $this->assertStringNotContainsString( 'secret_decryption_failed', $html ); + $this->assertStringNotContainsString( 'sensitive detail', $html ); + $this->assertSame( 1, $this->count_calls( 'unavailable' ) ); + } + + /** + * The login prompt is normal when the secret is readable. + */ + public function test_authentication_page_normal_when_readable() { + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->record_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->authentication_page( $user ); + } + ); + + $this->assertStringContainsString( 'name="authcode"', $html ); + $this->assertStringNotContainsString( 'currently unavailable', $html ); + $this->assertSame( 0, $this->count_calls( 'unavailable' ) ); + } + + /** + * The profile offers a reset, not setup, for an unreadable secret. + */ + public function test_user_options_shows_reset_not_setup_when_unreadable() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + $this->make_unreadable(); + $this->record_unavailable(); + $user = get_userdata( $user_id ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->user_two_factor_options( $user ); + } + ); + + $this->assertStringContainsString( 'Reset authenticator app', $html ); + $this->assertStringContainsString( 'cannot be read', $html ); + $this->assertStringNotContainsString( 'Authentication Code:', $html ); + $this->assertStringNotContainsString( 'two-factor-totp-key', $html ); + $this->assertSame( 1, $this->count_calls( 'unavailable' ) ); + } + + /** + * The profile offers setup when there is no secret. + */ + public function test_user_options_shows_setup_when_null() { + $user = get_userdata( $this->user() ); + + $html = $this->capture( + function () use ( $user ) { + $this->provider->user_two_factor_options( $user ); + } + ); + + $this->assertStringContainsString( 'Authentication Code:', $html ); + $this->assertStringContainsString( 'two-factor-totp-key', $html ); + } + + /** + * An affected TOTP-only user is forced onto the fallback, never single factor. + */ + public function test_login_fails_closed_for_affected_totp_only_user() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp' ) ); + $this->simulate_api_absent(); + $user = get_userdata( $user_id ); + + $this->assertFalse( $this->provider->is_available_for_user( $user ) ); + + try { + $available = Two_Factor_Core::get_available_providers_for_user( $user_id ); + + $this->assertIsArray( $available ); + $this->assertSame( array( 'Two_Factor_Email' ), array_keys( $available ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) ); + $this->assertSame( $user, Two_Factor_Core::filter_authenticate( $user ) ); + $this->assertNotFalse( has_filter( 'send_auth_cookies', '__return_false' ) ); + } finally { + remove_filter( 'send_auth_cookies', '__return_false', PHP_INT_MAX ); + } + } + + /** + * Without a valid fallback the user is locked out, not let in. + */ + public function test_login_fails_closed_for_affected_totp_only_user_without_fallback() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp' ) ); + $this->simulate_api_absent(); + add_filter( + 'two_factor_fallback_provider_for_user', + function () { + return 'Two_Factor_Nonexistent'; + } + ); + + $this->assertWPError( Two_Factor_Core::get_available_providers_for_user( $user_id ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) ); + } + + /** + * Backup codes remain usable for an affected user. + */ + public function test_affected_user_keeps_backup_codes() { + $this->require_secrets_api(); + $user_id = $this->user(); + $user = get_userdata( $user_id ); + $this->provider->set_user_totp_key( $user_id, Two_Factor_Totp::generate_key() ); + Two_Factor_Backup_Codes::get_instance()->generate_codes( $user ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp', 'Two_Factor_Backup_Codes' ) ); + $this->simulate_api_absent(); + + $this->assertSame( array( 'Two_Factor_Backup_Codes' ), array_keys( Two_Factor_Core::get_available_providers_for_user( $user_id ) ) ); + } } From 5c2a24c5e44da83a850dffee9ce47bf03ade55ef Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 12:59:59 -0700 Subject: [PATCH 21/59] progress: P2-02 done --- docs/PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 116feb3b3..945484f83 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -10,7 +10,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P1-02 Provider base hooks and fail-closed handling of enrolled-but-unavailable providers in core - [x] P1-03 Push Phase 1 and record the manual check - [x] P2-01 TOTP tri-state read, Secrets API writes and lazy migration -- [ ] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression +- [x] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression - [ ] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries - [ ] P2-04 Push Phase 2 and record the manual check - [ ] P3-01 Affected-user detection and the administrator notice @@ -52,3 +52,7 @@ Pushed to origin. Empty commit used as task commit. Manual check: NOT VERIFIED ( Two_Factor_Totp: SECRET_NETWORK_META_KEY/SECRET_SLUG, get_user_totp_key_state, migrate_user_totp_key (verified, idempotent, fires two_factor_secrets_migrated / _migration_failed), Secrets-API-first set/delete, is_available_for_user and is_enrolled_but_unavailable_for_user by marker, get_user_totp_key_storage, marker added to uninstall meta keys. Tests: tests/providers/class-two-factor-totp-secrets-tests.php (27 tests; multisite blog test skipped on single). Suites: 356 tests, single has 1 expected skip, multisite runs it. Interpretation: class/file named Two_Factor_Totp_Secrets_Tests (PHPCS file naming, as P1-01). Note: delete_user_totp_key now returns true only when plaintext, marker and secret are gone (also true when nothing existed). + +### P2-02 — f346ba4 +validate_code_for_user, authentication_page and user_two_factor_options now use get_user_totp_key_state; private report_unavailable fires two_factor_secret_unavailable(user_id,'totp',WP_Error). 11 tests appended to tests/providers/class-two-factor-totp-secrets-tests.php incl. login lockout regression (affected TOTP-only user gets Email fallback / WP_Error, send_auth_cookies still blocked; backup codes retained). 367 tests both suites. +Interpretation: unavailable prompt renders the two prompt actions and the unavailable message but omits the generic 'Enter the code' line; test class named Two_Factor_Totp_Secrets_Tests. From ec6277dd8a79050336055f08ed25ac413eda6747 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:01:17 -0700 Subject: [PATCH 22/59] P2-03: user deletion hooks and uninstall cleanup of Secrets API entries Goal: remove TOTP secrets and markers on user deletion and uninstall. Tests: 6 in Two_Factor_Totp_Secrets_Tests, 1 in core uninstall. Interpretation: hooks call wrapper delete_user_secrets_on_user_deletion() (void) since PHPStan rejects bool-returning action callbacks. --- class-two-factor-core.php | 3 + providers/class-two-factor-totp.php | 63 ++++++++++++ tests/class-two-factor-core.php | 23 +++++ .../class-two-factor-totp-secrets-tests.php | 99 +++++++++++++++++++ 4 files changed, 188 insertions(+) diff --git a/class-two-factor-core.php b/class-two-factor-core.php index d657ac9fb..1209ff5df 100644 --- a/class-two-factor-core.php +++ b/class-two-factor-core.php @@ -195,6 +195,9 @@ public static function login_enqueue_scripts() { /** * Delete all plugin data on uninstall. * + * Secrets held in the Secrets API are removed by the providers, but are orphaned in the store + * if the Secrets API is absent when the plugin is uninstalled. + * * @since 0.10.0 * * @return void diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index d722dcbb2..9e899288e 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -70,6 +70,13 @@ protected function __construct() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) ); + // On multisite, `delete_user` also fires when a user is only removed from one site, so wait for the network-level deletion. + if ( is_multisite() ) { + add_action( 'wpmu_delete_user', array( $this, 'delete_user_secrets_on_user_deletion' ) ); + } else { + add_action( 'delete_user', array( $this, 'delete_user_secrets_on_user_deletion' ) ); + } + parent::__construct(); } @@ -691,6 +698,21 @@ public function delete_user_totp_key( $user_id ) { && '' === (string) get_user_meta( $user_id, self::SECRET_NETWORK_META_KEY, true ); } + /** + * Delete a user's TOTP data when the user account is deleted. + * + * Wrapper around delete_user_totp_key() so the deletion actions get a callback that returns nothing. + * + * @since 0.18.0 + * + * @param int $user_id ID of the user being deleted. + * + * @return void + */ + public function delete_user_secrets_on_user_deletion( $user_id ) { + $this->delete_user_totp_key( $user_id ); + } + /** * Get where a user's TOTP key is stored. * @@ -1207,6 +1229,47 @@ private static function abssort( $a, $b ) { return ( $a < $b ) ? -1 : 1; } + /** + * Delete every user's TOTP secret held in the Secrets API during plugin uninstall. + * + * When the Secrets API is absent at uninstall time the secrets are orphaned in the store, which + * is acceptable because they cannot be reached from here. + * + * @since 0.18.0 + * + * @return void + */ + public static function uninstall_user_data() { + if ( ! Two_Factor_Secrets::is_api_present() ) { + return; + } + + $seen = array(); + + do { + // The marker is removed with each secret, so the same offset always yields the next page. + $user_ids = ( new WP_User_Query( + array( + 'blog_id' => 0, + 'fields' => 'ID', + 'number' => 100, + 'offset' => 0, + 'orderby' => 'ID', + 'meta_key' => self::SECRET_NETWORK_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Uninstall only. + 'meta_compare' => 'EXISTS', + 'count_total' => false, + ) + ) )->get_results(); + + $new_ids = array_diff( array_map( 'intval', $user_ids ), $seen ); + + foreach ( $new_ids as $user_id ) { + $seen[] = $user_id; + Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + } + } while ( ! empty( $new_ids ) ); + } + /** * Return user meta keys to delete during plugin uninstall. * diff --git a/tests/class-two-factor-core.php b/tests/class-two-factor-core.php index d5735fe4b..f3c24aea8 100644 --- a/tests/class-two-factor-core.php +++ b/tests/class-two-factor-core.php @@ -3079,6 +3079,29 @@ function ( $providers ) { remove_all_filters( 'two_factor_providers' ); } + /** + * Plugin uninstall removes TOTP secrets held in the Secrets API and their markers. + * + * @covers Two_Factor_Core::uninstall + */ + public function test_uninstall_removes_totp_secrets_and_markers() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + + $user_id = self::factory()->user->create(); + $totp = Two_Factor_Totp::get_instance(); + $totp->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertNotNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + + Two_Factor_Core::uninstall(); + + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', (string) get_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertSame( '', $totp->get_user_totp_key( $user_id ) ); + } + /** * Plugin uninstall removes the site-wide enabled providers option. * diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index 8a42609da..6759f727b 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -713,4 +713,103 @@ public function test_affected_user_keeps_backup_codes() { $this->assertSame( array( 'Two_Factor_Backup_Codes' ), array_keys( Two_Factor_Core::get_available_providers_for_user( $user_id ) ) ); } + + /** + * Deleting a user removes the secret on single site. + */ + public function test_delete_user_removes_secret_on_single_site() { + if ( is_multisite() ) { + $this->markTestSkipped( 'Single site only.' ); + } + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + wp_delete_user( $user_id ); + + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Removing a user from one site keeps the network secret. + */ + public function test_delete_user_keeps_secret_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->require_secrets_api(); + $user_id = $this->user(); + $blog_id = self::factory()->blog->create(); + add_user_to_blog( $blog_id, $user_id, 'subscriber' ); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + switch_to_blog( $blog_id ); + try { + wp_delete_user( $user_id ); + } finally { + restore_current_blog(); + } + + $this->assertNotNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + } + + /** + * Network deletion removes the secret. + */ + public function test_wpmu_delete_user_removes_secret_on_multisite() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->require_secrets_api(); + require_once ABSPATH . 'wp-admin/includes/ms.php'; + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + wpmu_delete_user( $user_id ); + + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + + /** + * The right deletion hook is registered. + */ + public function test_constructor_registers_correct_deletion_hook() { + $callback = array( Two_Factor_Totp::get_instance(), 'delete_user_secrets_on_user_deletion' ); + + $this->assertNotFalse( has_action( is_multisite() ? 'wpmu_delete_user' : 'delete_user', $callback ) ); + $this->assertFalse( has_action( is_multisite() ? 'delete_user' : 'wpmu_delete_user', $callback ) ); + } + + /** + * Uninstall removes all secrets. + */ + public function test_uninstall_user_data_removes_secrets_for_all_marked_users() { + $this->require_secrets_api(); + $user_ids = array( $this->user(), $this->user(), $this->user() ); + foreach ( $user_ids as $user_id ) { + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + } + + Two_Factor_Totp::uninstall_user_data(); + + foreach ( $user_ids as $user_id ) { + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + } + + /** + * Uninstall does nothing without the API. + */ + public function test_uninstall_user_data_noop_when_api_absent() { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->simulate_api_absent(); + + Two_Factor_Totp::uninstall_user_data(); + + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + } } From f67fb21d261f32fd75ab8defcada336fdda36112 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:01:20 -0700 Subject: [PATCH 23/59] progress: P2-03 done --- docs/PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 945484f83..a7dea0f51 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -11,7 +11,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P1-03 Push Phase 1 and record the manual check - [x] P2-01 TOTP tri-state read, Secrets API writes and lazy migration - [x] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression -- [ ] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries +- [x] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries - [ ] P2-04 Push Phase 2 and record the manual check - [ ] P3-01 Affected-user detection and the administrator notice - [ ] P3-02 Site Health test `two_factor_totp_secret_storage` @@ -56,3 +56,7 @@ Note: delete_user_totp_key now returns true only when plaintext, marker and secr ### P2-02 — f346ba4 validate_code_for_user, authentication_page and user_two_factor_options now use get_user_totp_key_state; private report_unavailable fires two_factor_secret_unavailable(user_id,'totp',WP_Error). 11 tests appended to tests/providers/class-two-factor-totp-secrets-tests.php incl. login lockout regression (affected TOTP-only user gets Email fallback / WP_Error, send_auth_cookies still blocked; backup codes retained). 367 tests both suites. Interpretation: unavailable prompt renders the two prompt actions and the unavailable message but omits the generic 'Enter the code' line; test class named Two_Factor_Totp_Secrets_Tests. + +### P2-03 — ec6277d +Deletion hook (wpmu_delete_user on multisite, delete_user otherwise) and Two_Factor_Totp::uninstall_user_data (paged WP_User_Query on the marker meta, offset 0, seen-ID guard); uninstall() docblock notes orphaned secrets. 7 tests (6 in Two_Factor_Totp_Secrets_Tests, 1 in core). 374 tests both suites; skips are the single-site/multisite-only tests. +Interpretation: hooks call new void wrapper delete_user_secrets_on_user_deletion() instead of delete_user_totp_key() directly because PHPStan level 5 rejects bool-returning action callbacks; the constructor test asserts the wrapper. From 04e60534dd5b080e7840270b20aaa64c11022dd3 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:01:27 -0700 Subject: [PATCH 24/59] P2-04: push Phase 2 and record manual check From f0852304bee0d898b72c3b8d184cc2f5ff73ff29 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:01:30 -0700 Subject: [PATCH 25/59] progress: P2-04 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a7dea0f51..3d3d2f169 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -12,7 +12,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P2-01 TOTP tri-state read, Secrets API writes and lazy migration - [x] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression - [x] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries -- [ ] P2-04 Push Phase 2 and record the manual check +- [x] P2-04 Push Phase 2 and record the manual check - [ ] P3-01 Affected-user detection and the administrator notice - [ ] P3-02 Site Health test `two_factor_totp_secret_storage` - [ ] P3-03 Push Phase 3 and record the manual check @@ -60,3 +60,6 @@ Interpretation: unavailable prompt renders the two prompt actions and the unavai ### P2-03 — ec6277d Deletion hook (wpmu_delete_user on multisite, delete_user otherwise) and Two_Factor_Totp::uninstall_user_data (paged WP_User_Query on the marker meta, offset 0, seen-ID guard); uninstall() docblock notes orphaned secrets. 7 tests (6 in Two_Factor_Totp_Secrets_Tests, 1 in core). 374 tests both suites; skips are the single-site/multisite-only tests. Interpretation: hooks call new void wrapper delete_user_secrets_on_user_deletion() instead of delete_user_totp_key() directly because PHPStan level 5 rejects bool-returning action callbacks; the constructor test asserts the wrapper. + +### P2-04 — 04e6053 +Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): with feature plugin on dev site (a) enrol app, confirm _wp_network_secret_two-factor/totp-<id> option exists and _two_factor_totp_key meta empty; (b) log in with an app code; (c) deactivate plugin, login offers only other methods; after re-activation with WP_SECRETS_KEY changed profile shows reset notice; (d) delete user, option row gone. From 3613cd1660fc9260eacc94ad76dd6c6091864d4c Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:02:26 -0700 Subject: [PATCH 26/59] P3-01: affected-user detection and administrator notice Goal: warn admins when migrated secrets are unreachable. Tests: 8 tests in Two_Factor_Totp_Secrets_Tests. --- providers/class-two-factor-totp.php | 106 ++++++++++++ .../class-two-factor-totp-secrets-tests.php | 159 ++++++++++++++++++ 2 files changed, 265 insertions(+) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 9e899288e..bb1fcba3f 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -37,6 +37,24 @@ class Two_Factor_Totp extends Two_Factor_Provider { */ const SECRET_SLUG = 'totp'; + /** + * The site transient caching whether any user has a secret that is currently unreachable. + * + * @since 0.18.0 + * + * @var string + */ + const AFFECTED_USERS_TRANSIENT = 'two_factor_totp_affected_users'; + + /** + * How long, in seconds, the affected-users result is cached. + * + * @since 0.18.0 + * + * @var int + */ + const AFFECTED_USERS_CACHE_TTL = 300; + /** * The user meta key for the last successful TOTP token timestamp logged in with. * @@ -70,6 +88,9 @@ protected function __construct() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) ); + add_action( 'admin_notices', array( $this, 'admin_notice_secrets_api_missing' ) ); + add_action( 'network_admin_notices', array( $this, 'admin_notice_secrets_api_missing' ) ); + // On multisite, `delete_user` also fires when a user is only removed from one site, so wait for the network-level deletion. if ( is_multisite() ) { add_action( 'wpmu_delete_user', array( $this, 'delete_user_secrets_on_user_deletion' ) ); @@ -594,6 +615,7 @@ public function migrate_user_totp_key( $user_id ) { delete_user_meta( $user_id, self::SECRET_META_KEY ); Two_Factor_Secrets::memzero( $readback ); + self::clear_affected_users_cache(); /** * Fires after a user's TOTP secret was moved into the Secrets API. @@ -666,6 +688,7 @@ public function set_user_totp_key( $user_id, $key ) { Two_Factor_Secrets::memzero( $readback ); delete_user_meta( $user_id, self::SECRET_META_KEY ); + self::clear_affected_users_cache(); return true; } @@ -674,6 +697,7 @@ public function set_user_totp_key( $user_id, $key ) { // Clear any stale marker or secret so the plaintext value is unambiguous. Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + self::clear_affected_users_cache(); return $result; } @@ -692,6 +716,7 @@ public function delete_user_totp_key( $user_id ) { delete_user_meta( $user_id, self::SECRET_META_KEY ); $secret = Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + self::clear_affected_users_cache(); return true === $secret && '' === (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ) @@ -713,6 +738,87 @@ public function delete_user_secrets_on_user_deletion( $user_id ) { $this->delete_user_totp_key( $user_id ); } + /** + * Whether any user has a secret in the Secrets API that this site cannot currently reach. + * + * The result is cached briefly in a site transient. + * + * @since 0.18.0 + * + * @return bool + */ + public static function has_affected_users() { + $cached = get_site_transient( self::AFFECTED_USERS_TRANSIENT ); + + if ( 'yes' === $cached || 'no' === $cached ) { + return 'yes' === $cached; + } + + $args = array( + 'blog_id' => 0, + 'meta_key' => self::SECRET_NETWORK_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Single-row lookup, result cached. + 'meta_compare' => 'EXISTS', + 'number' => 1, + 'fields' => 'ID', + 'count_total' => false, + ); + + if ( Two_Factor_Secrets::is_api_present() ) { + if ( is_multisite() ) { + $args['meta_value'] = (string) get_current_network_id(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Single-row lookup, result cached. + $args['meta_compare'] = '!='; + $affected = ! empty( get_users( $args ) ); + } else { + $affected = false; + } + } else { + $affected = ! empty( get_users( $args ) ); + } + + set_site_transient( self::AFFECTED_USERS_TRANSIENT, $affected ? 'yes' : 'no', self::AFFECTED_USERS_CACHE_TTL ); + + return $affected; + } + + /** + * Forget the cached affected-users result. + * + * @since 0.18.0 + * + * @return void + */ + public static function clear_affected_users_cache() { + delete_site_transient( self::AFFECTED_USERS_TRANSIENT ); + } + + /** + * Warn administrators that some users' authenticator secrets are unreachable. + * + * @since 0.18.0 + * + * @return void + */ + public function admin_notice_secrets_api_missing() { + $capability = is_network_admin() ? 'manage_network_options' : 'manage_options'; + + if ( ! current_user_can( $capability ) || ! self::has_affected_users() ) { + return; + } + + wp_admin_notice( + sprintf( + /* translators: 1: WP-CLI export command, 2: WP-CLI migrate command. */ + esc_html__( 'Authenticator app secrets for one or more users were stored with the WordPress Secrets API, which is no longer available on this site. Those users cannot use their authenticator app until it is restored. Re-activate the Secrets API, or run %1$s before removing it and %2$s after restoring it.', 'two-factor' ), + '<code>wp two-factor secrets export</code>', + '<code>wp two-factor secrets migrate</code>' + ), + array( + 'type' => 'error', + 'dismissible' => false, + ) + ); + } + /** * Get where a user's TOTP key is stored. * diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index 6759f727b..ef3b6623d 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -812,4 +812,163 @@ public function test_uninstall_user_data_noop_when_api_absent() { $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); } + + /** + * Seed a marker for a fresh user. + * + * @param string|null $network Marker value; defaults to the current network. + * @return int User ID. + */ + private function seed_marker( $network = null ) { + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, null === $network ? (string) get_current_network_id() : $network ); + Two_Factor_Totp::clear_affected_users_cache(); + + return $user_id; + } + + /** + * No markers means no affected users. + */ + public function test_has_affected_users_false_without_markers() { + $this->assertFalse( Two_Factor_Totp::has_affected_users() ); + } + + /** + * A marker with no API affects users. + */ + public function test_has_affected_users_true_when_api_absent_with_marker() { + $this->seed_marker(); + $this->simulate_api_absent(); + + $this->assertTrue( Two_Factor_Totp::has_affected_users() ); + } + + /** + * A marker for another network affects users. + */ + public function test_has_affected_users_true_for_other_network_marker() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->require_secrets_api(); + $this->seed_marker( (string) ( get_current_network_id() + 1 ) ); + + $this->assertTrue( Two_Factor_Totp::has_affected_users() ); + } + + /** + * The result is cached. + */ + public function test_has_affected_users_is_cached() { + $this->simulate_api_absent(); + Two_Factor_Totp::clear_affected_users_cache(); + + $this->assertFalse( Two_Factor_Totp::has_affected_users() ); + + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + $this->assertFalse( Two_Factor_Totp::has_affected_users() ); + + Two_Factor_Totp::clear_affected_users_cache(); + + $this->assertTrue( Two_Factor_Totp::has_affected_users() ); + } + + /** + * Administrators see the notice. + */ + public function test_admin_notice_shown_to_manage_options_user() { + $this->seed_marker(); + $this->simulate_api_absent(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + if ( is_multisite() ) { + grant_super_admin( get_current_user_id() ); + } + + $html = $this->capture( + function () { + $this->provider->admin_notice_secrets_api_missing(); + } + ); + + $this->assertStringContainsString( 'wp two-factor secrets export', $html ); + $this->assertStringContainsString( 'wp two-factor secrets migrate', $html ); + $this->assertStringContainsString( 'notice-error', $html ); + $this->assertStringNotContainsString( 'is-dismissible', $html ); + } + + /** + * Others do not. + */ + public function test_admin_notice_hidden_without_capability() { + $this->seed_marker(); + $this->simulate_api_absent(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $this->assertSame( + '', + $this->capture( + function () { + $this->provider->admin_notice_secrets_api_missing(); + } + ) + ); + } + + /** + * No notice without affected users. + */ + public function test_admin_notice_hidden_when_no_affected_users() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + if ( is_multisite() ) { + grant_super_admin( get_current_user_id() ); + } + + $this->assertSame( + '', + $this->capture( + function () { + $this->provider->admin_notice_secrets_api_missing(); + } + ) + ); + } + + /** + * The network admin notice needs manage_network_options. + */ + public function test_admin_notice_network_admin_requires_manage_network_options() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Multisite only.' ); + } + $this->seed_marker(); + $this->simulate_api_absent(); + set_current_screen( 'dashboard-network' ); + + try { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->assertSame( + '', + $this->capture( + function () { + $this->provider->admin_notice_secrets_api_missing(); + } + ) + ); + + grant_super_admin( get_current_user_id() ); + $this->assertStringContainsString( + 'wp two-factor secrets export', + $this->capture( + function () { + $this->provider->admin_notice_secrets_api_missing(); + } + ) + ); + } finally { + set_current_screen( 'front' ); + } + } } From fdb1c5f5718044adc9bc1057f235903134f9f065 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:02:29 -0700 Subject: [PATCH 27/59] progress: P3-01 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3d3d2f169..4c9c63d30 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -13,7 +13,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P2-02 Fail-closed validation, login prompt, profile UI and the login lockout regression - [x] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries - [x] P2-04 Push Phase 2 and record the manual check -- [ ] P3-01 Affected-user detection and the administrator notice +- [x] P3-01 Affected-user detection and the administrator notice - [ ] P3-02 Site Health test `two_factor_totp_secret_storage` - [ ] P3-03 Push Phase 3 and record the manual check - [ ] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` @@ -63,3 +63,6 @@ Interpretation: hooks call new void wrapper delete_user_secrets_on_user_deletion ### P2-04 — 04e6053 Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): with feature plugin on dev site (a) enrol app, confirm _wp_network_secret_two-factor/totp-<id> option exists and _two_factor_totp_key meta empty; (b) log in with an app code; (c) deactivate plugin, login offers only other methods; after re-activation with WP_SECRETS_KEY changed profile shows reset notice; (d) delete user, option row gone. + +### P3-01 — 3613cd1 +Added AFFECTED_USERS_TRANSIENT/AFFECTED_USERS_CACHE_TTL (300), has_affected_users(), clear_affected_users_cache() (called from set/delete/migrate success paths), admin_notice_secrets_api_missing hooked to admin_notices and network_admin_notices (non-dismissible error, contains both wp two-factor secrets export/migrate command strings). 8 tests; 382 tests both suites (skips are env-specific). From 866b1ca53d1f4d199333d92357d2805b567ea7c0 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:03:20 -0700 Subject: [PATCH 28/59] P3-02: Site Health test two_factor_totp_secret_storage Goal: report TOTP secret storage state in Site Health. Tests: 6 tests in Two_Factor_Totp_Secrets_Tests. Interpretation: API present with no plaintext users is 'good' (provider label) regardless of writability. --- providers/class-two-factor-totp.php | 129 ++++++++++++++++++ .../class-two-factor-totp-secrets-tests.php | 80 +++++++++++ 2 files changed, 209 insertions(+) diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index bb1fcba3f..ad76d88f1 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -88,6 +88,7 @@ protected function __construct() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) ); + add_filter( 'site_status_tests', array( $this, 'register_site_health_test' ) ); add_action( 'admin_notices', array( $this, 'admin_notice_secrets_api_missing' ) ); add_action( 'network_admin_notices', array( $this, 'admin_notice_secrets_api_missing' ) ); @@ -819,6 +820,134 @@ public function admin_notice_secrets_api_missing() { ); } + /** + * Register the Site Health test for TOTP secret storage. + * + * @since 0.18.0 + * + * @param array $tests Site Health tests. + * + * @return array + */ + public function register_site_health_test( $tests ) { + $tests['direct']['two_factor_totp_secret_storage'] = array( + 'label' => __( 'Authenticator app secret storage', 'two-factor' ), + 'test' => array( $this, 'site_health_secret_storage' ), + ); + + return $tests; + } + + /** + * Whether any user still has a plaintext TOTP secret in user meta. + * + * Not cached. + * + * @since 0.18.0 + * + * @return bool + */ + public static function has_plaintext_users() { + $users = get_users( + array( + 'blog_id' => 0, + 'meta_key' => self::SECRET_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Single-row lookup for Site Health. + 'meta_value' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Single-row lookup for Site Health. + 'meta_compare' => '!=', + 'number' => 1, + 'fields' => 'ID', + 'count_total' => false, + ) + ); + + return ! empty( $users ); + } + + /** + * Site Health test result for TOTP secret storage. + * + * @since 0.18.0 + * + * @return array + */ + public function site_health_secret_storage() { + $result = array( + 'label' => '', + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security', 'two-factor' ), + 'color' => 'blue', + ), + 'description' => '', + 'actions' => '', + 'test' => 'two_factor_totp_secret_storage', + ); + + if ( self::has_affected_users() ) { + $result['label'] = __( 'Some authenticator app secrets are unavailable', 'two-factor' ); + $result['status'] = 'critical'; + $result['badge']['color'] = 'red'; + $result['description'] = sprintf( + '<p>%s</p>', + sprintf( + /* translators: %s: WP-CLI export command. */ + esc_html__( 'Some users\' authenticator app secrets were stored with the WordPress Secrets API, which this site cannot currently reach. Those users cannot use their authenticator app. Re-activate the Secrets API, or run %s before deactivating it.', 'two-factor' ), + '<code>wp two-factor secrets export</code>' + ) + ); + + return $result; + } + + if ( ! Two_Factor_Secrets::is_api_present() ) { + $result['label'] = __( 'Authenticator app secrets are stored in user meta', 'two-factor' ); + $result['description'] = sprintf( + '<p>%s</p>', + esc_html__( 'TOTP secrets are stored in user meta; the WordPress Secrets API, when available, will be used automatically.', 'two-factor' ) + ); + + return $result; + } + + if ( ! self::has_plaintext_users() ) { + $result['label'] = __( 'Authenticator app secrets are stored securely', 'two-factor' ); + $result['description'] = sprintf( + '<p>%s</p>', + sprintf( + /* translators: %s: name of the secrets storage provider. */ + esc_html__( 'Authenticator app secrets are stored with the WordPress Secrets API (%s).', 'two-factor' ), + esc_html( Two_Factor_Secrets::provider_label() ) + ) + ); + + return $result; + } + + if ( Two_Factor_Secrets::can_write() ) { + $result['label'] = __( 'Some authenticator app secrets are not yet migrated', 'two-factor' ); + $result['status'] = 'recommended'; + $result['badge']['color'] = 'orange'; + $result['description'] = sprintf( + '<p>%s</p>', + sprintf( + /* translators: %s: WP-CLI migrate command. */ + esc_html__( 'Some authenticator app secrets are still stored in user meta. They move to the WordPress Secrets API when those users next log in, or you can run %s to migrate them all now.', 'two-factor' ), + '<code>wp two-factor secrets migrate</code>' + ) + ); + + return $result; + } + + $result['label'] = __( 'Authenticator app secrets remain in user meta', 'two-factor' ); + $result['description'] = sprintf( + '<p>%s</p>', + esc_html__( 'Migration to the WordPress Secrets API is disabled by the two_factor_use_secrets_api filter or a read-only secrets provider, so authenticator app secrets remain in user meta.', 'two-factor' ) + ); + + return $result; + } + /** * Get where a user's TOTP key is stored. * diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index ef3b6623d..525c5adc5 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -971,4 +971,84 @@ function () { set_current_screen( 'front' ); } } + + /** + * The Site Health test is registered. + */ + public function test_site_health_test_is_registered() { + $tests = apply_filters( 'site_status_tests', array( 'direct' => array() ) ); + + $this->assertArrayHasKey( 'two_factor_totp_secret_storage', $tests['direct'] ); + } + + /** + * Affected users are critical. + */ + public function test_site_health_critical_when_affected_users() { + $this->seed_marker(); + $this->simulate_api_absent(); + + $result = $this->provider->site_health_secret_storage(); + + $this->assertSame( 'critical', $result['status'] ); + $this->assertSame( 'red', $result['badge']['color'] ); + $this->assertStringContainsString( 'wp two-factor secrets export', $result['description'] ); + $this->assertSame( 'two_factor_totp_secret_storage', $result['test'] ); + } + + /** + * Remaining plaintext is recommended to migrate. + */ + public function test_site_health_recommended_when_plaintext_remains() { + $this->require_secrets_api(); + $user_id = $this->user(); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $result = $this->provider->site_health_secret_storage(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertSame( 'orange', $result['badge']['color'] ); + $this->assertStringContainsString( 'wp two-factor secrets migrate', $result['description'] ); + } + + /** + * Fully migrated is good and names the provider. + */ + public function test_site_health_good_when_fully_migrated() { + $this->require_secrets_api(); + + $result = $this->provider->site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( esc_html( Two_Factor_Secrets::provider_label() ), $result['description'] ); + } + + /** + * Opting out with plaintext remaining is good. + */ + public function test_site_health_good_when_filter_opts_out_with_plaintext() { + $this->require_secrets_api(); + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + update_user_meta( $this->user(), Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + + $result = $this->provider->site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( 'two_factor_use_secrets_api', $result['description'] ); + } + + /** + * Without the API the message is neutral. + */ + public function test_site_health_good_neutral_when_api_absent() { + $this->simulate_api_absent(); + + $result = $this->provider->site_health_secret_storage(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertSame( + '<p>TOTP secrets are stored in user meta; the WordPress Secrets API, when available, will be used automatically.</p>', + $result['description'] + ); + } } From eca12c484f75e9e345ffa98d823fff1ef321cfa6 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:03:22 -0700 Subject: [PATCH 29/59] progress: P3-02 done --- docs/PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4c9c63d30..a766efcd6 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -14,7 +14,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P2-03 User deletion hooks and uninstall cleanup of Secrets API entries - [x] P2-04 Push Phase 2 and record the manual check - [x] P3-01 Affected-user detection and the administrator notice -- [ ] P3-02 Site Health test `two_factor_totp_secret_storage` +- [x] P3-02 Site Health test `two_factor_totp_secret_storage` - [ ] P3-03 Push Phase 3 and record the manual check - [ ] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` - [ ] P4-02 `wp two-factor secrets migrate` @@ -66,3 +66,7 @@ Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human ### P3-01 — 3613cd1 Added AFFECTED_USERS_TRANSIENT/AFFECTED_USERS_CACHE_TTL (300), has_affected_users(), clear_affected_users_cache() (called from set/delete/migrate success paths), admin_notice_secrets_api_missing hooked to admin_notices and network_admin_notices (non-dismissible error, contains both wp two-factor secrets export/migrate command strings). 8 tests; 382 tests both suites (skips are env-specific). + +### P3-02 — 866b1ca +register_site_health_test, has_plaintext_users(), site_health_secret_storage() with the five status paths in SPEC order. 6 tests; 388 tests both suites. +Interpretation: API present + no plaintext users is 'good' naming the provider label even when writes are disabled; case (4) only when plaintext remains and can_write() is false. From 8eea02d0f22ca2b0d528246a5396cbeb88a50e59 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:03:28 -0700 Subject: [PATCH 30/59] P3-03: push Phase 3 and record manual check From 583689af8262402e3b9dd36c34b27c115984722d Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:03:31 -0700 Subject: [PATCH 31/59] progress: P3-03 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a766efcd6..ac2a75dc3 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -15,7 +15,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P2-04 Push Phase 2 and record the manual check - [x] P3-01 Affected-user detection and the administrator notice - [x] P3-02 Site Health test `two_factor_totp_secret_storage` -- [ ] P3-03 Push Phase 3 and record the manual check +- [x] P3-03 Push Phase 3 and record the manual check - [ ] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` - [ ] P4-02 `wp two-factor secrets migrate` - [ ] P4-03 `wp two-factor secrets export` @@ -70,3 +70,6 @@ Added AFFECTED_USERS_TRANSIENT/AFFECTED_USERS_CACHE_TTL (300), has_affected_user ### P3-02 — 866b1ca register_site_health_test, has_plaintext_users(), site_health_secret_storage() with the five status paths in SPEC order. 6 tests; 388 tests both suites. Interpretation: API present + no plaintext users is 'good' naming the provider label even when writes are disabled; case (4) only when plaintext remains and can_write() is false. + +### P3-03 — 8eea02d +Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): (a) migrated user + feature plugin deactivated: red non-dismissible notice on Dashboard for admin, not for editor; (b) Site Health critical, then recommended (re-activated, second user plaintext), then good after migrating; (c) multisite: notice in Network Admin for super admin. From 60c5017df73823ff15bea68bd0e192140c27305e Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:04:46 -0700 Subject: [PATCH 32/59] P4-01: wp two-factor status storage field and secrets status Goal: expose TOTP storage and Secrets API status from the CLI. Tests: 9 tests in Tests_Two_Factor_CLI_Command. Interpretation: Two_Factor_Secrets::provider_label() now returns '' whenever is_api_present() is false (including the test seam). --- CLI/class-two-factor-cli-command.php | 86 ++++++++++++- class-two-factor-secrets.php | 2 +- providers/class-two-factor-totp.php | 38 ++++++ tests/cli/class-two-factor-cli-command.php | 142 +++++++++++++++++++++ 4 files changed, 266 insertions(+), 2 deletions(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 7aba2a50c..04fda0b8e 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -125,6 +125,11 @@ public function status( $args, $assoc_args ) { $backup_codes_remaining = Two_Factor_Backup_Codes::codes_remaining_for_user( $user ); } + $totp_storage = 'none'; + if ( class_exists( 'Two_Factor_Totp' ) ) { + $totp_storage = Two_Factor_Totp::get_instance()->get_user_totp_key_storage( $user->ID ); + } + $items = array( array( 'user_id' => $user->ID, @@ -133,6 +138,7 @@ public function status( $args, $assoc_args ) { 'primary_provider' => $primary ? $primary->get_key() : '', 'enabled_providers' => implode( ', ', $enabled_providers ), 'backup_codes_remaining' => $backup_codes_remaining, + 'totp_storage' => $totp_storage, ), ); @@ -140,7 +146,7 @@ public function status( $args, $assoc_args ) { WP_CLI\Utils\format_items( $format, $items, - array( 'user_id', 'user_login', 'using_2fa', 'primary_provider', 'enabled_providers', 'backup_codes_remaining' ) + array( 'user_id', 'user_login', 'using_2fa', 'primary_provider', 'enabled_providers', 'backup_codes_remaining', 'totp_storage' ) ); } @@ -709,4 +715,82 @@ public function unlock( $args, $assoc_args ) { ); } } + + /** + * Inspect and manage where authenticator app secrets are stored. + * + * ## OPTIONS + * + * <action> + * : Action to perform. Supported: status. + * + * [--format=<format>] + * : Output format for status. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * # Show Secrets API availability and user counts + * $ wp two-factor secrets status + * + * @since 0.18.0 + * + * @param array $args Positional arguments: action. + * @param array $assoc_args Associative arguments. + */ + public function secrets( $args, $assoc_args ) { + $action = isset( $args[0] ) ? $args[0] : ''; + + switch ( $action ) { + case 'status': + $this->secrets_status( $assoc_args ); + break; + + default: + WP_CLI::error( + sprintf( + /* translators: %s: provided action */ + __( 'Unknown action "%s". Use: wp two-factor secrets <status|migrate|export>', 'two-factor' ), + (string) $action + ) + ); + } + } + + /** + * Report Secrets API availability and how many users are in each storage state. + * + * @since 0.18.0 + * + * @param array $assoc_args Associative arguments. + */ + private function secrets_status( $assoc_args ) { + $counts = Two_Factor_Totp::count_users_by_storage(); + + $items = array( + array( + 'api_present' => Two_Factor_Secrets::is_api_present() ? 'true' : 'false', + 'provider' => Two_Factor_Secrets::provider_label(), + 'writable' => Two_Factor_Secrets::is_provider_writable() ? 'true' : 'false', + 'filter_enabled' => (bool) apply_filters( 'two_factor_use_secrets_api', true, 0 ) ? 'true' : 'false', + 'plaintext_users' => $counts['plaintext'], + 'migrated_users' => $counts['migrated'], + 'affected_users' => $counts['affected'], + ), + ); + + $format = WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); + WP_CLI\Utils\format_items( + $format, + $items, + array( 'api_present', 'provider', 'writable', 'filter_enabled', 'plaintext_users', 'migrated_users', 'affected_users' ) + ); + } } diff --git a/class-two-factor-secrets.php b/class-two-factor-secrets.php index 62fa04427..878e8fb8c 100644 --- a/class-two-factor-secrets.php +++ b/class-two-factor-secrets.php @@ -224,7 +224,7 @@ public static function delete_user_secret( $user_id, $slug ) { * @return string */ public static function provider_label() { - if ( function_exists( 'wp_secrets_provider_label' ) ) { + if ( self::is_api_present() ) { return (string) wp_secrets_provider_label(); } diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index ad76d88f1..5557b3dbe 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -948,6 +948,44 @@ public function site_health_secret_storage() { return $result; } + /** + * Count users by where their TOTP key is stored. + * + * @since 0.18.0 + * + * @return array{plaintext: int, migrated: int, affected: int} + */ + public static function count_users_by_storage() { + $count = function ( $meta_key, $compare, $value = null ) { + $args = array( + 'blog_id' => 0, + 'fields' => 'ID', + 'number' => 1, + 'count_total' => true, + 'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- CLI reporting. + 'meta_compare' => $compare, + ); + + if ( null !== $value ) { + $args['meta_value'] = $value; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- CLI reporting. + } + + $query = new WP_User_Query( $args ); + + return (int) $query->get_total(); + }; + + $affected = Two_Factor_Secrets::is_api_present() + ? $count( self::SECRET_NETWORK_META_KEY, '!=', (string) get_current_network_id() ) + : $count( self::SECRET_NETWORK_META_KEY, 'EXISTS' ); + + return array( + 'plaintext' => $count( self::SECRET_META_KEY, '!=', '' ), + 'migrated' => $count( self::SECRET_NETWORK_META_KEY, 'EXISTS' ), + 'affected' => $affected, + ); + } + /** * Get where a user's TOTP key is stored. * diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index 2885e13c7..1f0a8d7eb 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -269,6 +269,148 @@ public function test_status_reports_backup_code_count() { $this->assertSame( 4, $this->last_format()['items'][0]['backup_codes_remaining'] ); } + /** + * A user without a TOTP key reports storage none. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_none() { + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'none', $this->last_format()['items'][0]['totp_storage'] ); + $this->assertContains( 'totp_storage', $this->last_format()['fields'] ); + } + + /** + * A plaintext TOTP key reports storage plaintext. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_plaintext() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'plaintext', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * A Secrets API TOTP key reports storage secrets-api. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_secrets_api() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'secrets-api', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * An unreachable key reports storage unavailable. + * + * @covers Two_Factor_CLI_Command::status + */ + public function test_status_reports_totp_storage_unavailable() { + update_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + add_filter( 'two_factor_secrets_api_present', '__return_false' ); + + $this->command->status( array( 'cli_test_user' ), array() ); + + $this->assertSame( 'unavailable', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * Secrets status reports a present API and user counts. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_reports_present_api_and_counts() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + $plaintext_user = self::factory()->user->create(); + $migrated_user = self::factory()->user->create(); + $totp = Two_Factor_Totp::get_instance(); + + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $totp->set_user_totp_key( $plaintext_user, 'ABCDEFGH' ); + remove_filter( 'two_factor_use_secrets_api', '__return_false' ); + $totp->set_user_totp_key( $migrated_user, 'ABCDEFGH' ); + + $this->command->secrets( array( 'status' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'true', $item['api_present'] ); + $this->assertSame( 1, $item['plaintext_users'] ); + $this->assertSame( 1, $item['migrated_users'] ); + $this->assertSame( 0, $item['affected_users'] ); + $this->assertSame( array( 'api_present', 'provider', 'writable', 'filter_enabled', 'plaintext_users', 'migrated_users', 'affected_users' ), $this->last_format()['fields'] ); + } + + /** + * Secrets status reports an absent API. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_reports_absent_api() { + add_filter( 'two_factor_secrets_api_present', '__return_false' ); + update_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + + $this->command->secrets( array( 'status' ), array() ); + + $item = $this->last_format()['items'][0]; + $this->assertSame( 'false', $item['api_present'] ); + $this->assertSame( '', $item['provider'] ); + $this->assertSame( 1, $item['affected_users'] ); + } + + /** + * The --format flag is passed through for secrets status. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_status_format_passthrough() { + $this->command->secrets( array( 'status' ), array( 'format' => 'json' ) ); + + $this->assertSame( 'json', $this->last_format()['format'] ); + } + + /** + * An unknown secrets action errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_unknown_action_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'bogus' ), array() ); + } + ); + + $this->assertStringContainsString( 'Unknown action "bogus"', $message ); + } + + /** + * A missing secrets action errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_missing_action_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array(), array() ); + } + ); + + $this->assertStringContainsString( 'Unknown action', $message ); + } + /* * --------------------------------------------------------------------- * list-providers From c047f13c12d169f440898e36c1f3d4d75e70d93d Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:04:49 -0700 Subject: [PATCH 33/59] progress: P4-01 done --- docs/PROGRESS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ac2a75dc3..75de1f1fd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -16,7 +16,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P3-01 Affected-user detection and the administrator notice - [x] P3-02 Site Health test `two_factor_totp_secret_storage` - [x] P3-03 Push Phase 3 and record the manual check -- [ ] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` +- [x] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` - [ ] P4-02 `wp two-factor secrets migrate` - [ ] P4-03 `wp two-factor secrets export` - [ ] P4-04 Push Phase 4 and record the manual check @@ -73,3 +73,7 @@ Interpretation: API present + no plaintext users is 'good' naming the provider l ### P3-03 — 8eea02d Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): (a) migrated user + feature plugin deactivated: red non-dismissible notice on Dashboard for admin, not for editor; (b) Site Health critical, then recommended (re-activated, second user plaintext), then good after migrating; (c) multisite: notice in Network Admin for super admin. + +### P4-01 — 60c5017 +status now has totp_storage (last field); new secrets() dispatcher with status action (migrate/export cases left for P4-02/P4-03; default branch errors 'Unknown action ... <status|migrate|export>'); Two_Factor_Totp::count_users_by_storage(). 9 CLI tests; 397 tests both suites. +Interpretation: Two_Factor_Secrets::provider_label() returns '' whenever is_api_present() is false (so the test seam is honoured). secrets_status booleans are emitted as 'true'/'false' strings; counts as ints. From f5d6fbfdb6efdc4602ce3bb2c62a4864c9898621 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:06:02 -0700 Subject: [PATCH 34/59] P4-02: wp two-factor secrets migrate Goal: bulk-migrate plaintext TOTP secrets in bounded batches. Tests: 8 tests in Tests_Two_Factor_CLI_Command. --- CLI/class-two-factor-cli-command.php | 158 ++++++++++++++++++- tests/cli/class-two-factor-cli-command.php | 170 +++++++++++++++++++++ 2 files changed, 327 insertions(+), 1 deletion(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 04fda0b8e..839e3a0f5 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -27,6 +27,15 @@ class Two_Factor_CLI_Command extends WP_CLI_Command { */ const BACKUP_CODES_MAX_GENERATE_COUNT = 100; + /** + * Default number of users processed per batch by the secrets migrate and export commands. + * + * @since 0.18.0 + * + * @var int + */ + const SECRETS_DEFAULT_BATCH_SIZE = 100; + /** * Resolve a user from an ID, login, or email address. * @@ -722,7 +731,16 @@ public function unlock( $args, $assoc_args ) { * ## OPTIONS * * <action> - * : Action to perform. Supported: status. + * : Action to perform. Supported: status, migrate. + * + * [--user=<user>] + * : For migrate, only migrate this user (ID, login, or email). + * + * [--batch-size=<n>] + * : For migrate, number of users to process per batch. Defaults to 100. + * + * [--dry-run] + * : For migrate, report what would be migrated without changing anything. * * [--format=<format>] * : Output format for status. @@ -740,6 +758,12 @@ public function unlock( $args, $assoc_args ) { * # Show Secrets API availability and user counts * $ wp two-factor secrets status * + * # Move all plaintext authenticator app secrets into the Secrets API + * $ wp two-factor secrets migrate + * + * # Preview the migration + * $ wp two-factor secrets migrate --dry-run + * * @since 0.18.0 * * @param array $args Positional arguments: action. @@ -753,6 +777,10 @@ public function secrets( $args, $assoc_args ) { $this->secrets_status( $assoc_args ); break; + case 'migrate': + $this->secrets_migrate( $assoc_args ); + break; + default: WP_CLI::error( sprintf( @@ -793,4 +821,132 @@ private function secrets_status( $assoc_args ) { array( 'api_present', 'provider', 'writable', 'filter_enabled', 'plaintext_users', 'migrated_users', 'affected_users' ) ); } + + /** + * Parse the --batch-size flag. + * + * @since 0.18.0 + * + * @param array $assoc_args Associative arguments. + * @return int + */ + private function get_secrets_batch_size( $assoc_args ) { + $raw = WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', (string) self::SECRETS_DEFAULT_BATCH_SIZE ); + + if ( ! is_scalar( $raw ) || ! preg_match( '/^\d+$/', trim( (string) $raw ) ) || (int) trim( (string) $raw ) < 1 ) { + WP_CLI::error( + sprintf( + /* translators: %s: provided batch size */ + __( 'Invalid value for --batch-size: %s. It must be a decimal integer of at least 1.', 'two-factor' ), + wp_json_encode( $raw ) + ) + ); + } + + return (int) trim( (string) $raw ); + } + + /** + * Migrate plaintext TOTP secrets into the Secrets API. + * + * @since 0.18.0 + * + * @param array $assoc_args Associative arguments. + */ + private function secrets_migrate( $assoc_args ) { + $batch = $this->get_secrets_batch_size( $assoc_args ); + $dry_run = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false ); + + if ( ! Two_Factor_Secrets::can_write() ) { + WP_CLI::error( __( 'The Secrets API is not available for writing (missing, read-only, or disabled by the two_factor_use_secrets_api filter). Nothing was migrated.', 'two-factor' ) ); + } + + $totp = Two_Factor_Totp::get_instance(); + $migrated = 0; + $failed = 0; + $skipped = 0; + + $migrate_one = function ( $user_id ) use ( $totp, $dry_run, &$migrated, &$failed, &$skipped ) { + if ( $dry_run ) { + WP_CLI::log( sprintf( 'Would migrate user %d', $user_id ) ); + ++$skipped; + return 'skipped'; + } + + $result = $totp->migrate_user_totp_key( $user_id ); + + if ( true === $result ) { + ++$migrated; + return 'migrated'; + } + + if ( is_wp_error( $result ) ) { + ++$failed; + WP_CLI::warning( sprintf( 'User %d: %s', $user_id, $result->get_error_message() ) ); + return 'failed'; + } + + ++$skipped; + return 'skipped'; + }; + + $user_identifier = WP_CLI\Utils\get_flag_value( $assoc_args, 'user', null ); + + if ( null !== $user_identifier ) { + $user = $this->resolve_user( (string) $user_identifier ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $user_identifier + ) + ); + } + + $migrate_one( $user->ID ); + } else { + $offset = 0; + + do { + $query = new WP_User_Query( + array( + 'blog_id' => 0, + 'fields' => 'ID', + 'number' => $batch, + 'offset' => $offset, + 'orderby' => 'ID', + 'order' => 'ASC', + 'meta_key' => Two_Factor_Totp::SECRET_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One-off CLI migration. + 'meta_value' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- One-off CLI migration. + 'meta_compare' => '!=', + 'count_total' => false, + ) + ); + $ids = array_map( 'intval', $query->get_results() ); + + // Migrated users drop out of the result set; skipped and failed ones stay, so step past them. + $stayed = 0; + foreach ( $ids as $user_id ) { + if ( 'migrated' !== $migrate_one( $user_id ) ) { + ++$stayed; + } + } + + $offset += $stayed; + } while ( ! empty( $ids ) ); + } + + Two_Factor_Totp::clear_affected_users_cache(); + + $message = sprintf( + /* translators: 1: number migrated, 2: number failed, 3: number skipped */ + __( 'Migrated %1$d, failed %2$d, skipped %3$d.', 'two-factor' ), + $migrated, + $failed, + $skipped + ); + + WP_CLI::success( $dry_run ? __( 'Dry run: ', 'two-factor' ) . $message : $message ); + } } diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index 1f0a8d7eb..c028af4c7 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -411,6 +411,176 @@ function () { $this->assertStringContainsString( 'Unknown action', $message ); } + /** + * Seed a plaintext TOTP key directly in user meta. + * + * @param int $user_id User ID. + */ + protected function seed_plaintext_key( $user_id ) { + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + } + + /** + * Skip unless the Secrets API is loaded. + */ + protected function require_secrets_api() { + if ( ! function_exists( 'wp_get_network_secret' ) ) { + $this->markTestSkipped( 'Secrets API feature plugin is not loaded.' ); + } + } + + /** + * Reset adapter test seams. + */ + public function tear_down() { + Two_Factor_Secrets::$test_overrides = array(); + parent::tear_down(); + } + + /** + * Migrating a single user moves the secret. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_single_user() { + $this->require_secrets_api(); + $this->seed_plaintext_key( $this->user->ID ); + + $this->command->secrets( array( 'migrate' ), array( 'user' => 'cli_test_user' ) ); + + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( (string) get_current_network_id(), get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertSame( 'Migrated 1, failed 0, skipped 0.', $this->last_message( 'success' ) ); + } + + /** + * A dry run changes nothing. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_dry_run_changes_nothing() { + $this->require_secrets_api(); + $this->seed_plaintext_key( $this->user->ID ); + + $this->command->secrets( array( 'migrate' ), array( 'dry-run' => true ) ); + + $this->assertSame( 'ABCDEFGH', get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + $this->assertStringStartsWith( 'Dry run', $this->last_message( 'success' ) ); + } + + /** + * Migration pages through users in batches. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_pages_through_users_in_batches() { + $this->require_secrets_api(); + $ids = array( $this->user->ID, self::factory()->user->create(), self::factory()->user->create() ); + foreach ( $ids as $id ) { + $this->seed_plaintext_key( $id ); + } + + $this->command->secrets( array( 'migrate' ), array( 'batch-size' => '2' ) ); + + foreach ( $ids as $id ) { + $this->assertSame( '', (string) get_user_meta( $id, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + $this->assertSame( 'Migrated 3, failed 0, skipped 0.', $this->last_message( 'success' ) ); + } + + /** + * A failure for one user does not stop the run. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_reports_failed_users_and_continues() { + $this->require_secrets_api(); + $bad = self::factory()->user->create(); + $ids = array( $this->user->ID, $bad, self::factory()->user->create() ); + foreach ( $ids as $id ) { + $this->seed_plaintext_key( $id ); + } + Two_Factor_Secrets::$test_overrides['set'] = function ( $name, $value ) use ( $bad ) { + if ( "two-factor/totp-{$bad}" === $name ) { + return new WP_Error( 'write_failed', 'write failed' ); + } + return wp_set_network_secret( $name, $value ); + }; + + $this->command->secrets( array( 'migrate' ), array( 'batch-size' => '2' ) ); + + $this->assertSame( 'Migrated 2, failed 1, skipped 0.', $this->last_message( 'success' ) ); + $this->assertStringContainsString( "User {$bad}:", $this->last_message( 'warning' ) ); + $this->assertSame( 'ABCDEFGH', get_user_meta( $bad, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + + /** + * Migrating without the API errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_errors_when_api_absent() { + add_filter( 'two_factor_secrets_api_present', '__return_false' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array() ); + } + ); + + $this->assertStringContainsString( 'Nothing was migrated', $message ); + } + + /** + * Migrating with the filter opted out errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_errors_when_filter_opts_out() { + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array() ); + } + ); + + $this->assertStringContainsString( 'Nothing was migrated', $message ); + } + + /** + * An invalid batch size errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_invalid_batch_size_errors() { + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array( 'batch-size' => '0' ) ); + } + ); + + $this->assertStringContainsString( 'Invalid value for --batch-size', $message ); + } + + /** + * An unknown user errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_migrate_user_not_found() { + $this->require_secrets_api(); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'migrate' ), array( 'user' => 'nobody-here' ) ); + } + ); + + $this->assertStringContainsString( 'User not found: nobody-here', $message ); + } + /* * --------------------------------------------------------------------- * list-providers From a3cdb74899f0ca9c520b797f03ae9137af804e01 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:06:04 -0700 Subject: [PATCH 35/59] progress: P4-02 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 75de1f1fd..3e23e251f 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -17,7 +17,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P3-02 Site Health test `two_factor_totp_secret_storage` - [x] P3-03 Push Phase 3 and record the manual check - [x] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` -- [ ] P4-02 `wp two-factor secrets migrate` +- [x] P4-02 `wp two-factor secrets migrate` - [ ] P4-03 `wp two-factor secrets export` - [ ] P4-04 Push Phase 4 and record the manual check - [ ] P5-01 readme.txt, readme.md and CHANGELOG.md @@ -77,3 +77,6 @@ Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human ### P4-01 — 60c5017 status now has totp_storage (last field); new secrets() dispatcher with status action (migrate/export cases left for P4-02/P4-03; default branch errors 'Unknown action ... <status|migrate|export>'); Two_Factor_Totp::count_users_by_storage(). 9 CLI tests; 397 tests both suites. Interpretation: Two_Factor_Secrets::provider_label() returns '' whenever is_api_present() is false (so the test seam is honoured). secrets_status booleans are emitted as 'true'/'false' strings; counts as ints. + +### P4-02 — f5d6fbf +secrets migrate: SECRETS_DEFAULT_BATCH_SIZE, get_secrets_batch_size() helper (reusable by export), --user, --dry-run, paged WP_User_Query using Two_Factor_Totp::migrate_user_totp_key (offset advances only past skipped/failed users). 8 tests; 405 tests both suites. Added tear_down in CLI test class to reset Two_Factor_Secrets::$test_overrides. From 89affc9555ecd95c0fa9aed57c497304ce61f127 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:07:09 -0700 Subject: [PATCH 36/59] P4-03: wp two-factor secrets export Goal: reverse migration with confirmation and verification. Tests: 4 provider tests, 5 CLI tests. --- CLI/class-two-factor-cli-command.php | 120 ++++++++++++++++- providers/class-two-factor-totp.php | 53 ++++++++ tests/cli/class-two-factor-cli-command.php | 121 ++++++++++++++++++ .../class-two-factor-totp-secrets-tests.php | 63 +++++++++ 4 files changed, 354 insertions(+), 3 deletions(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 839e3a0f5..455581859 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -731,17 +731,20 @@ public function unlock( $args, $assoc_args ) { * ## OPTIONS * * <action> - * : Action to perform. Supported: status, migrate. + * : Action to perform. Supported: status, migrate, export. * * [--user=<user>] - * : For migrate, only migrate this user (ID, login, or email). + * : For migrate and export, only handle this user (ID, login, or email). * * [--batch-size=<n>] - * : For migrate, number of users to process per batch. Defaults to 100. + * : For migrate and export, number of users to process per batch. Defaults to 100. * * [--dry-run] * : For migrate, report what would be migrated without changing anything. * + * [--yes] + * : For export, skip the confirmation prompt. + * * [--format=<format>] * : Output format for status. * --- @@ -764,6 +767,9 @@ public function unlock( $args, $assoc_args ) { * # Preview the migration * $ wp two-factor secrets migrate --dry-run * + * # Move all secrets back into user meta before removing the Secrets API + * $ wp two-factor secrets export --yes + * * @since 0.18.0 * * @param array $args Positional arguments: action. @@ -781,6 +787,10 @@ public function secrets( $args, $assoc_args ) { $this->secrets_migrate( $assoc_args ); break; + case 'export': + $this->secrets_export( $assoc_args ); + break; + default: WP_CLI::error( sprintf( @@ -949,4 +959,108 @@ private function secrets_migrate( $assoc_args ) { WP_CLI::success( $dry_run ? __( 'Dry run: ', 'two-factor' ) . $message : $message ); } + + /** + * Export TOTP secrets from the Secrets API back into user meta. + * + * @since 0.18.0 + * + * @param array $assoc_args Associative arguments. + */ + private function secrets_export( $assoc_args ) { + $batch = $this->get_secrets_batch_size( $assoc_args ); + + if ( ! Two_Factor_Secrets::is_api_present() ) { + WP_CLI::error( __( 'The Secrets API is not available; there is nothing to read the secrets from.', 'two-factor' ) ); + } + + WP_CLI::confirm( __( 'This will store authenticator app (TOTP) secrets unencrypted in user meta. Continue?', 'two-factor' ), $assoc_args ); + + $totp = Two_Factor_Totp::get_instance(); + $exported = 0; + $errors = 0; + $skipped = 0; + + $export_one = function ( $user_id ) use ( $totp, &$exported, &$errors, &$skipped ) { + $result = $totp->export_user_totp_key( $user_id ); + + if ( true === $result ) { + ++$exported; + return 'exported'; + } + + if ( is_wp_error( $result ) ) { + ++$errors; + WP_CLI::warning( + sprintf( + 'User %d: secret is unreadable and was left untouched (%s).', + $user_id, + $result->get_error_code() + ) + ); + return 'error'; + } + + ++$skipped; + return 'skipped'; + }; + + $user_identifier = WP_CLI\Utils\get_flag_value( $assoc_args, 'user', null ); + + if ( null !== $user_identifier ) { + $user = $this->resolve_user( (string) $user_identifier ); + if ( ! $user ) { + WP_CLI::error( + sprintf( + /* translators: %s: user identifier */ + __( 'User not found: %s', 'two-factor' ), + $user_identifier + ) + ); + } + + $export_one( $user->ID ); + } else { + $offset = 0; + + do { + $query = new WP_User_Query( + array( + 'blog_id' => 0, + 'fields' => 'ID', + 'number' => $batch, + 'offset' => $offset, + 'orderby' => 'ID', + 'order' => 'ASC', + 'meta_key' => Two_Factor_Totp::SECRET_NETWORK_META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One-off CLI export. + 'meta_compare' => 'EXISTS', + 'count_total' => false, + ) + ); + $ids = array_map( 'intval', $query->get_results() ); + + // Exported users lose their marker and drop out; step past the ones that stayed. + $stayed = 0; + foreach ( $ids as $user_id ) { + if ( 'exported' !== $export_one( $user_id ) ) { + ++$stayed; + } + } + + $offset += $stayed; + } while ( ! empty( $ids ) ); + } + + Two_Factor_Totp::clear_affected_users_cache(); + + WP_CLI::success( + sprintf( + /* translators: 1: number exported, 2: number unreadable, 3: number skipped */ + __( 'Exported %1$d, unreadable %2$d, skipped %3$d.', 'two-factor' ), + $exported, + $errors, + $skipped + ) + ); + } } diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 5557b3dbe..6585bf512 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -631,6 +631,59 @@ public function migrate_user_totp_key( $user_id ) { return true; } + /** + * Move a TOTP key from the Secrets API back into user meta. + * + * Used when decommissioning the Secrets API. The Secrets API copy is removed only after the + * plaintext copy has been read back and matches. + * + * @since 0.18.0 + * + * @param int $user_id User ID. + * + * @return true|null|WP_Error True when exported, null when there is nothing to export, WP_Error on failure. + */ + public function export_user_totp_key( $user_id ) { + if ( '' !== (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ) ) { + return null; + } + + if ( '' === (string) get_user_meta( $user_id, self::SECRET_NETWORK_META_KEY, true ) ) { + return null; + } + + $secret = Two_Factor_Secrets::get_user_secret( $user_id, self::SECRET_SLUG ); + + if ( is_wp_error( $secret ) ) { + return $secret; + } + + if ( ! is_string( $secret ) ) { + return null; + } + + update_user_meta( $user_id, self::SECRET_META_KEY, $secret ); + $readback = (string) get_user_meta( $user_id, self::SECRET_META_KEY, true ); + + if ( ! hash_equals( $secret, $readback ) ) { + delete_user_meta( $user_id, self::SECRET_META_KEY ); + Two_Factor_Secrets::memzero( $secret ); + Two_Factor_Secrets::memzero( $readback ); + + return new WP_Error( + 'two_factor_secrets_export_mismatch', + __( 'The exported secret did not match the original, so the export was rolled back.', 'two-factor' ) + ); + } + + Two_Factor_Secrets::delete_user_secret( $user_id, self::SECRET_SLUG ); + Two_Factor_Secrets::memzero( $secret ); + Two_Factor_Secrets::memzero( $readback ); + self::clear_affected_users_cache(); + + return true; + } + /** * Fire the migration failure action. * diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index c028af4c7..703494dca 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -581,6 +581,127 @@ function () { $this->assertStringContainsString( 'User not found: nobody-here', $message ); } + /** + * Export needs confirmation. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_export_requires_confirmation() { + $this->require_secrets_api(); + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'export' ), array() ); + } + ); + + $this->assertStringContainsString( 'confirmation declined', $message ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( (string) get_current_network_id(), get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + } + + /** + * Exporting a single user round-trips. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_export_single_user_round_trip() { + $this->require_secrets_api(); + Two_Factor_Totp::get_instance()->set_user_totp_key( $this->user->ID, 'ABCDEFGH' ); + + $this->command->secrets( + array( 'export' ), + array( + 'user' => 'cli_test_user', + 'yes' => true, + ) + ); + + $this->assertSame( 'ABCDEFGH', get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_META_KEY, true ) ); + $this->assertSame( '', (string) get_user_meta( $this->user->ID, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $this->command->status( array( 'cli_test_user' ), array() ); + $this->assertSame( 'plaintext', $this->last_format()['items'][0]['totp_storage'] ); + } + + /** + * Exporting all users pages through batches. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_export_all_users_in_batches() { + $this->require_secrets_api(); + $ids = array( $this->user->ID, self::factory()->user->create(), self::factory()->user->create() ); + foreach ( $ids as $id ) { + Two_Factor_Totp::get_instance()->set_user_totp_key( $id, 'ABCDEFGH' ); + } + + $this->command->secrets( + array( 'export' ), + array( + 'yes' => true, + 'batch-size' => '2', + ) + ); + + foreach ( $ids as $id ) { + $this->assertSame( 'ABCDEFGH', get_user_meta( $id, Two_Factor_Totp::SECRET_META_KEY, true ) ); + } + $this->assertSame( 'Exported 3, unreadable 0, skipped 0.', $this->last_message( 'success' ) ); + } + + /** + * An unreadable secret is skipped with a warning. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_export_skips_unreadable_secret() { + $this->require_secrets_api(); + $bad = self::factory()->user->create(); + $ids = array( $this->user->ID, $bad, self::factory()->user->create() ); + foreach ( $ids as $id ) { + Two_Factor_Totp::get_instance()->set_user_totp_key( $id, 'ABCDEFGH' ); + } + Two_Factor_Secrets::$test_overrides['get'] = function ( $name ) use ( $bad ) { + if ( "two-factor/totp-{$bad}" === $name ) { + return new WP_Error( 'secret_decryption_failed' ); + } + $secret = wp_get_network_secret( $name ); + return $secret ? $secret->reveal() : null; + }; + + $this->command->secrets( + array( 'export' ), + array( + 'yes' => true, + 'batch-size' => '2', + ) + ); + + $this->assertSame( 'Exported 2, unreadable 1, skipped 0.', $this->last_message( 'success' ) ); + $this->assertStringContainsString( "User {$bad}:", $this->last_message( 'warning' ) ); + $this->assertSame( (string) get_current_network_id(), get_user_meta( $bad, Two_Factor_Totp::SECRET_NETWORK_META_KEY, true ) ); + } + + /** + * Exporting without the API errors. + * + * @covers Two_Factor_CLI_Command::secrets + */ + public function test_secrets_export_errors_when_api_absent() { + add_filter( 'two_factor_secrets_api_present', '__return_false' ); + + $message = $this->assert_command_aborts( + function () { + $this->command->secrets( array( 'export' ), array( 'yes' => true ) ); + } + ); + + $this->assertStringContainsString( 'nothing to read the secrets from', $message ); + } + /* * --------------------------------------------------------------------- * list-providers diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index 525c5adc5..809b36145 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -1051,4 +1051,67 @@ public function test_site_health_good_neutral_when_api_absent() { $result['description'] ); } + + /** + * Export moves a key back to user meta. + */ + public function test_export_user_totp_key_round_trip() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertTrue( $this->provider->export_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + } + + /** + * Nothing to export returns null. + */ + public function test_export_user_totp_key_null_when_plaintext_or_absent() { + $user_id = $this->user(); + $this->assertNull( $this->provider->export_user_totp_key( $user_id ) ); + + update_user_meta( $user_id, Two_Factor_Totp::SECRET_META_KEY, 'ABCDEFGH' ); + update_user_meta( $user_id, Two_Factor_Totp::SECRET_NETWORK_META_KEY, (string) get_current_network_id() ); + $this->assertNull( $this->provider->export_user_totp_key( $user_id ) ); + } + + /** + * An unreadable secret is left untouched. + */ + public function test_export_user_totp_key_error_when_unreadable() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + $this->make_unreadable(); + + $this->assertWPError( $this->provider->export_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + } + + /** + * A plaintext write mismatch rolls back. + */ + public function test_export_user_totp_key_mismatch_cleans_up() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + add_filter( + 'update_user_metadata', + function ( $check, $object_id, $meta_key ) { + return Two_Factor_Totp::SECRET_META_KEY === $meta_key ? true : $check; + }, + 10, + 3 + ); + + $result = $this->provider->export_user_totp_key( $user_id ); + + $this->assertWPError( $result ); + $this->assertSame( 'two_factor_secrets_export_mismatch', $result->get_error_code() ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + } } From f691be8fdc5289a2a4d09a8336dc37544635ed3a Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:07:12 -0700 Subject: [PATCH 37/59] progress: P4-03 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3e23e251f..198904629 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -18,7 +18,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P3-03 Push Phase 3 and record the manual check - [x] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` - [x] P4-02 `wp two-factor secrets migrate` -- [ ] P4-03 `wp two-factor secrets export` +- [x] P4-03 `wp two-factor secrets export` - [ ] P4-04 Push Phase 4 and record the manual check - [ ] P5-01 readme.txt, readme.md and CHANGELOG.md - [ ] P5-02 AGENTS.md, TESTS.md and docblock audit @@ -80,3 +80,6 @@ Interpretation: Two_Factor_Secrets::provider_label() returns '' whenever is_api_ ### P4-02 — f5d6fbf secrets migrate: SECRETS_DEFAULT_BATCH_SIZE, get_secrets_batch_size() helper (reusable by export), --user, --dry-run, paged WP_User_Query using Two_Factor_Totp::migrate_user_totp_key (offset advances only past skipped/failed users). 8 tests; 405 tests both suites. Added tear_down in CLI test class to reset Two_Factor_Secrets::$test_overrides. + +### P4-03 — 89affc9 +Two_Factor_Totp::export_user_totp_key (verified, rolls back on mismatch) and CLI secrets export (confirm unless --yes, --user, --batch-size paging, warnings carry error code only). 9 tests; 414 tests both suites. From 3594837eaf39b7eccf97c1dd55a846493128e539 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:07:19 -0700 Subject: [PATCH 38/59] P4-04: push Phase 4 and record manual check From 986385b7462be322e5c59ab8e78308cb18ca9397 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:07:21 -0700 Subject: [PATCH 39/59] progress: P4-04 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 198904629..d2b74bcc8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -19,7 +19,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P4-01 `wp two-factor status` storage field and `wp two-factor secrets status` - [x] P4-02 `wp two-factor secrets migrate` - [x] P4-03 `wp two-factor secrets export` -- [ ] P4-04 Push Phase 4 and record the manual check +- [x] P4-04 Push Phase 4 and record the manual check - [ ] P5-01 readme.txt, readme.md and CHANGELOG.md - [ ] P5-02 AGENTS.md, TESTS.md and docblock audit - [ ] P5-03 Upstream PR description @@ -83,3 +83,6 @@ secrets migrate: SECRETS_DEFAULT_BATCH_SIZE, get_secrets_batch_size() helper (re ### P4-03 — 89affc9 Two_Factor_Totp::export_user_totp_key (verified, rolls back on mismatch) and CLI secrets export (confirm unless --yes, --user, --batch-size paging, warnings carry error code only). 9 tests; 414 tests both suites. + +### P4-04 — 3594837 +Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): in dev container with feature plugin active (a) wp two-factor secrets status and --format=json render; (b) migrate --dry-run, migrate, then status <user> shows secrets-api; (c) export prompts, --yes completes, status shows plaintext; (d) wp help two-factor secrets shows OPTIONS. From 814fcf8b179894f49816cd3e6a4e6a05013cb214 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:08:03 -0700 Subject: [PATCH 40/59] P5-01: readme.txt and CHANGELOG.md Goal: document Secrets API storage, hooks and CLI. Tests: none (docs); lint and both suites green. Interpretation: readme.md has no equivalent sections and is left unchanged; readme.txt keeps its CRLF line endings. --- CHANGELOG.md | 6 ++++++ readme.txt | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 612346b40..8c9ac25f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file, per [the Keep a Changelog standard](http://keepachangelog.com/), and will adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### New Features + +- Store authenticator app (TOTP) secrets encrypted with the WordPress Secrets API when it is available, with lazy migration, `wp two-factor secrets` commands and a Site Health check by @ericmann in [#TBD](https://github.com/WordPress/two-factor/pull/TBD) + ## [0.17.0] - 2026-09-25 ### Security Fixes diff --git a/readme.txt b/readme.txt index 504c6689f..1e672baa9 100644 --- a/readme.txt +++ b/readme.txt @@ -94,6 +94,10 @@ Here is a list of action and filter hooks provided by the plugin: - `two_factor_enabled_providers_for_user` filter overrides the list of two-factor providers enabled for a user. First argument is an array of enabled provider classnames as values, the second argument is the user ID. - `two_factor_is_required_for_user` filter controls whether two-factor authentication is required for a user. Return `false` to bypass the two-factor flow (e.g. for trusted IP addresses). First argument is a boolean (whether the user has a primary provider configured), the second argument is the `WP_User` object. - `two_factor_fallback_provider_for_user` filter overrides the provider forced on when none of a user's stored two-factor providers are still registered (e.g. after a provider plugin is deactivated). Defaults to `Two_Factor_Email`. First argument is the provider classname, the second is the user ID, the third is the array of provider classnames that were stored for the user but are no longer registered. The returned provider must be registered and available to the user (`is_available_for_user()`), or the user is shown an error instead of being let through with a fallback. +- `two_factor_use_secrets_api` filter controls whether new authenticator app (TOTP) secrets are written to the WordPress Secrets API and whether existing plaintext secrets are migrated. It controls writes and migration only: users whose secret was already migrated are still read from the Secrets API while it is present. First argument is a boolean (default `true`), the second is the user ID, which can be `0` when no user is in context. +- `two_factor_secrets_migrated` action fires after a user's TOTP secret was moved into the Secrets API. Receives the user ID and the secret slug (`totp`), never the secret. +- `two_factor_secrets_migration_failed` action fires when moving a user's TOTP secret into the Secrets API failed and the plaintext copy was kept. Receives the user ID, the secret slug, and a `WP_Error`. +- `two_factor_secret_unavailable` action fires when a user's stored TOTP secret cannot be read (for example because the Secrets API is missing or the key changed). Receives the user ID, the secret slug, and a `WP_Error`. - `two_factor_user_authenticated` action which receives the logged in `WP_User` object as the first argument for determining the logged in user right after the authentication workflow. - `two_factor_user_api_login_enable` filter restricts authentication for REST API and XML-RPC to application passwords only. Provides the user ID as the second argument. - `two_factor_email_token_ttl` filter overrides the time interval in seconds that an email token is considered after generation. Accepts the time in seconds as the first argument and the ID of the `WP_User` object being authenticated. @@ -112,11 +116,14 @@ Here is a list of action and filter hooks provided by the plugin: The plugin includes a `wp two-factor` WP-CLI namespace for managing two-factor authentication from the command line. All commands accept a user by ID, login, or email. -* `wp two-factor status <user>` — Shows a user's current 2FA status (read-only). Supports `--format=json`. +* `wp two-factor status <user>` — Shows a user's current 2FA status (read-only), including where their authenticator app secret is stored (`totp_storage`). Supports `--format=json`. * `wp two-factor list-providers` — Lists all registered two-factor providers. * `wp two-factor enable <user> <provider>` — Enables a provider for a user. Providers that require a shared secret (like TOTP) can't be enabled this way and will point you to the profile page instead. * `wp two-factor disable <user> [<provider>]` — Disables a single provider, or performs a full reset of all 2FA for the user when no provider is given. Both forms prompt for confirmation unless `--yes` is passed. * `wp two-factor backup-codes generate <user> [--count=<n>]` — Generates a fresh set of backup codes for a user, replacing any existing ones. Defaults to 10 codes. +* `wp two-factor secrets status` — Shows whether the WordPress Secrets API is available and how many users have authenticator app secrets in user meta, in the Secrets API, or unreachable. Supports `--format=json`. +* `wp two-factor secrets migrate [--user=<user>] [--batch-size=<n>] [--dry-run]` — Moves plaintext authenticator app secrets into the Secrets API. +* `wp two-factor secrets export [--user=<user>] [--batch-size=<n>] [--yes]` — Moves authenticator app secrets from the Secrets API back into user meta, for example before removing the Secrets API. * `wp two-factor unlock <user>` — Clears a user's login rate-limit/throttle without changing their 2FA configuration. Run `wp help two-factor` for the full list, or `wp help two-factor <command>` for options and examples for a specific command. @@ -171,6 +178,14 @@ Yes. The Two-Factor plugin is compatible with WordPress Multisite. Each user con As an administrator, go to **Users → All Users** in the WordPress admin, click **Edit** on the affected user's profile, scroll down to the **Two-Factor Options** section, and uncheck all enabled methods, then click **Update User**. This will remove 2FA for that user, allowing them to log in with their password alone. You can also do this via WP-CLI with wp two-factor disable <user_id> --yes, which performs a full reset (see the WP-CLI Commands section above). Once they're back in, encourage them to re-enable 2FA and generate fresh backup codes. += How are authenticator (TOTP) secrets stored? = + +By default, and on WordPress versions without the Secrets API, they are stored in plaintext user meta. When the WordPress [Secrets API](https://github.com/ericmann/secrets-api) is available, new secrets are stored encrypted through it automatically, and existing plaintext secrets are migrated the next time each user logs in. To migrate everyone at once run `wp two-factor secrets migrate`. To opt out of writing to the Secrets API, return `false` from the `two_factor_use_secrets_api` filter; users who were already migrated are still read from it. + +Define `WP_SECRETS_KEY` (the base64 encoding of 32 random bytes) in `wp-config.php` so encryption does not depend on your salts. Without it, rotating `LOGGED_IN_KEY` or `LOGGED_IN_SALT` makes stored secrets unreadable. Affected users can't use their authenticator app until they reset it, and if it was their only method they are sent to the fallback method instead of being let in with a single factor. + +Do not deactivate the Secrets API while users have migrated secrets: those users can't use their authenticator app until it is restored, and an administrator notice and Site Health check will warn you. Run `wp two-factor secrets export` before removing it to move secrets back into user meta, or `wp two-factor secrets migrate` after restoring it. Secrets left in the store when the Secrets API is absent at the time the plugin is uninstalled are not removed. + = Can I require 2FA for all users or specific roles? = Not through the plugin's interface — there are no built-in enforcement settings. However, developers can use the `two_factor_providers_for_user` filter to control which providers are available per user or role, and combine it with custom logic to redirect users who haven't set up 2FA. Native enforcement support is a known and tracked feature request — follow the discussion at [GitHub issue #255](https://github.com/WordPress/two-factor/issues/255). From 8ba2240216a7f9c1ca54d37d7d21c98a329a436d Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:08:05 -0700 Subject: [PATCH 41/59] progress: P5-01 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d2b74bcc8..3c8c03236 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -20,7 +20,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P4-02 `wp two-factor secrets migrate` - [x] P4-03 `wp two-factor secrets export` - [x] P4-04 Push Phase 4 and record the manual check -- [ ] P5-01 readme.txt, readme.md and CHANGELOG.md +- [x] P5-01 readme.txt, readme.md and CHANGELOG.md - [ ] P5-02 AGENTS.md, TESTS.md and docblock audit - [ ] P5-03 Upstream PR description - [ ] P5-04 Push Phase 5 and record the final manual check @@ -86,3 +86,6 @@ Two_Factor_Totp::export_user_totp_key (verified, rolls back on mismatch) and CLI ### P4-04 — 3594837 Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): in dev container with feature plugin active (a) wp two-factor secrets status and --format=json render; (b) migrate --dry-run, migrate, then status <user> shows secrets-api; (c) export prompts, --yes completes, status shows plaintext; (d) wp help two-factor secrets shows OPTIONS. + +### P5-01 — 814fcf8 +readme.txt: four hooks under Actions & Filters, three secrets CLI bullets plus totp_storage note, TOTP storage FAQ; CHANGELOG.md [Unreleased] entry. readme.md unchanged (no equivalent sections). readme.txt is CRLF; preserved. From 0f5d369eab4d3c13d56ff144c116868c4e309b36 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:09:04 -0700 Subject: [PATCH 42/59] P5-02: AGENTS.md, TESTS.md and docblock audit Goal: contributor docs and inline docs match the shipped code. Tests: none (docs); docblock-only PHP hunks (@since 0.18.0). --- AGENTS.md | 20 +++++++++++++++-- TESTS.md | 24 ++++++++++++++++++++ class-two-factor-secrets.php | 34 +++++++++++++++++++++++++++-- providers/class-two-factor-totp.php | 4 ++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d978c0a30..023a1db15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ For code coverage support: `npm run env start -- --xdebug=coverage` ```bash npm run lint # all linters (PHP, CSS, JS) npm run lint:php # PHPCS with WordPress + VIP-Go standards -npm run lint:phpstan # PHPStan static analysis (level 2) +npm run lint:phpstan # PHPStan static analysis (level 5) npm run lint:css # wp-scripts lint-style npm run lint:js # wp-scripts lint-js npm run format # auto-fix PHPCS and JS/CSS issues @@ -50,8 +50,9 @@ The plugin follows a provider pattern. `Two_Factor_Core` owns the login intercep - **`two-factor.php`** — Entry point. Defines `TWO_FACTOR_DIR` and `TWO_FACTOR_VERSION`, loads all core files (including the settings class), instantiates `Two_Factor_Compat`, and calls `Two_Factor_Core::add_hooks()`. Also registers the wp-admin settings page (Settings → Two-Factor) and the filter callbacks that enforce the site-wide enabled-providers option (`two_factor_filter_enabled_providers`, `two_factor_filter_enabled_providers_for_user`). - **`class-two-factor-core.php`** — Central class. Owns the login flow, user meta, nonce management, rate limiting, session tracking, REST API endpoints, and the user profile settings UI. - **`class-two-factor-compat.php`** — Compatibility shims for third-party plugins (currently: Jetpack SSO). New integrations go here; the goal is to avoid any plugin-specific logic outside this file. +- **`class-two-factor-secrets.php`** — `Two_Factor_Secrets`, a static adapter and the only place Two-Factor calls the WordPress Secrets API (`wp_get_network_secret()` and friends). Secrets are always network scope, on single site and multisite alike. A per-user marker in user meta records which network holds the secret. New writes and migration are gated by the `two_factor_use_secrets_api` filter; reads of already-migrated users are not. - **`settings/class-two-factor-settings.php`** — `Two_Factor_Settings`, renders the site-wide settings screen (Settings → Two-Factor, requires `manage_options`) where admins choose which providers are available on the site. Saving writes the `two_factor_enabled_providers` option; enforcement happens via the filters registered in `two-factor.php`, not in this class. -- **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup. +- **`providers/class-two-factor-provider.php`** — Abstract base class all providers extend. Defines the required interface: `get_label()`, `is_available_for_user()`, `authentication_page()`, `validate_authentication()`, and optional hooks for REST routes, settings UI, and uninstall cleanup. It also defines `is_enrolled_but_unavailable_for_user()` (a provider the user enrolled whose credential cannot currently be used; core then forces the fallback provider instead of dropping to single factor) and `uninstall_user_data()` (delete per-user data held outside user meta during uninstall). - **`providers/`** — Concrete providers: `class-two-factor-totp.php`, `class-two-factor-email.php`, `class-two-factor-backup-codes.php`, `class-two-factor-dummy.php`. - **`includes/`** — Custom `login_header()` and `login_footer()` template functions that replace the WordPress core versions with additional filter hooks. Excluded from PHPCS because they intentionally deviate from core function signatures. Do not modify files in includes/ directly. They are intentionally kept close to WordPress core function signatures to ease future merging into Core. Any functional changes should go through the filter hooks they expose instead. - **`tests/`** — PHPUnit tests. See [TESTS.md](TESTS.md). @@ -97,6 +98,7 @@ New providers should follow this pattern rather than registering hooks from outs | `USER_RATE_LIMIT_KEY` | `_two_factor_last_login_failure` | Rate limiting timestamp | | `USER_FAILED_LOGIN_ATTEMPTS_KEY` | `_two_factor_failed_login_attempts` | Failed attempt count | | `USER_PASSWORD_WAS_RESET_KEY` | `_two_factor_password_was_reset` | Flags compromised-password reset | +| `Two_Factor_Totp::SECRET_NETWORK_META_KEY` | `_two_factor_totp_key_network` | Marker that the TOTP secret lives in the Secrets API; holds the network ID. A constant on `Two_Factor_Totp`, not `Two_Factor_Core`. | ### Key Site Options (constants on `Two_Factor_Core`) @@ -104,6 +106,20 @@ New providers should follow this pattern rather than registering hooks from outs |---|---|---| | `ENABLED_PROVIDERS_OPTION_KEY` | `two_factor_enabled_providers` | Provider class names enabled site-wide via the settings page; never saved means all providers are allowed. | +### Secrets API hooks + +- `two_factor_use_secrets_api` filter (`$enabled`, `$user_id`) controls writes and migration only; already-migrated users are still read from the Secrets API while it is present. `$user_id` may be `0`. +- `two_factor_secrets_api_present` filter is an internal test seam. It can only force "absent" and is not a public extension point. +- `two_factor_secrets_migrated` action (`$user_id`, `$slug`) after a secret was moved into the Secrets API. +- `two_factor_secrets_migration_failed` action (`$user_id`, `$slug`, `$error`) when a migration failed and the plaintext copy was kept. +- `two_factor_secret_unavailable` action (`$user_id`, `$slug`, `$error`) when a stored secret cannot be read. + +No hook ever receives a plaintext secret. + +### WP-CLI + +`CLI/class-two-factor-cli-command.php` registers the `wp two-factor` commands. `status` includes a `totp_storage` field (`plaintext`, `secrets-api`, `unavailable` or `none`). The `secrets` subcommand has `status`, `migrate` and `export` actions. + ### REST API Namespace: `two-factor/1.0` (constant `Two_Factor_Core::REST_NAMESPACE`). Each provider that exposes REST endpoints registers its own routes in `register_rest_routes()` called from its constructor. diff --git a/TESTS.md b/TESTS.md index d2d3b0746..c2520d1e2 100644 --- a/TESTS.md +++ b/TESTS.md @@ -176,6 +176,18 @@ Extends `WP_Test_REST_TestCase`. Tests the backup codes REST endpoints: - User cannot generate codes for a different user - Admin can generate codes for other users +### Secrets API Adapter — `tests/class-two-factor-secrets-tests.php` + +**Class:** `Two_Factor_Secrets_Tests` · **Group:** `secrets` + +Tests `Two_Factor_Secrets`: secret names, marker meta, API presence, `can_write()` and its filter, the read/write/delete round trip in network scope, and the error codes for a missing API, another network's marker, a vanished secret and API errors. The file is named after the class, and sorts after `class-two-factor-core.php` on purpose: the core tests register hooks in `set_up_before_class()`, which an earlier test class would wipe. + +### TOTP Secrets Storage — `tests/providers/class-two-factor-totp-secrets-tests.php` + +**Class:** `Two_Factor_Totp_Secrets_Tests` · **Groups:** `providers`, `totp`, `secrets` + +Tests how `Two_Factor_Totp` uses the adapter: storage precedence, verified writes, lazy migration and export, fail-closed validation, the login prompt and profile UI when a secret is unreadable, the affected-user login regression, user deletion, uninstall, the administrator notice, the Site Health test and multisite cross-site reads. + ### Dummy Provider — `tests/providers/class-two-factor-dummy.php` **Class:** `Tests_Two_Factor_Dummy` · **Groups:** `providers`, `dummy` @@ -209,9 +221,21 @@ Tests the `Two_Factor_CLI_Command` WP-CLI command class. The WP-CLI runtime is n - `disable` (all) — full reset clears providers/throttle/nonce state and destroys sessions, preserves the compromised-password-reset flag, idempotent no-op, stale-meta cleanup guarding the fail-closed email fallback, confirmation required without `--yes` - `backup-codes generate` — default and `--count` code counts, regeneration replaces the set, enables the provider so codes are usable at login, session destruction when first enabled, unknown-action and missing-argument errors - `unlock` — clears the login throttle for a rate-limited user; no-op message otherwise +- `status` `totp_storage` field for each storage state +- `secrets status` — API availability, provider label and per-state user counts, `--format` passthrough, unknown/missing action errors +- `secrets migrate` — single user, `--dry-run`, `--batch-size` paging, per-user failures, error when the API is absent or opted out +- `secrets export` — confirmation, single user, batches, unreadable secrets skipped with a warning, error when the API is absent ## Test Helpers +- **`tests/class-two-factor-secrets-test-case.php`** — `Two_Factor_Secrets_UnitTestCase`, the base class for secrets tests. `require_secrets_api()` skips the test when the feature plugin is not loaded, `simulate_api_absent()` makes the adapter report the API as missing, and `tear_down()` resets `Two_Factor_Secrets::$test_overrides` and the affected-users transient. `tests/bootstrap.php` loads it after the WordPress test library. +- **`tests/class-two-factor-dummy-unavailable.php`** — `Two_Factor_Dummy_Unavailable`, a provider that is enrolled but never available, and counts `uninstall_user_data()` calls. +- **`tests/phpstan/`** — Stubs for the Secrets API functions and classes so PHPStan can analyse the adapter (`scanFiles` in `phpstan.dist.neon`). PHPUnit excludes the directory in both configs because the stubs would redeclare the real functions once the feature plugin is loaded. + +### Secrets API feature plugin + +The tests need the Secrets API feature plugin. `.wp-env.json` maps `ericmann/secrets-api#v0.2.1` into the tests environment (`env.tests.plugins`, which must repeat `"."`) and sets a deterministic `WP_SECRETS_KEY`. `tests/bootstrap.php` loads `secrets-api/secrets-api.php` from the plugins directory, or the file named by the `TWO_FACTOR_SECRETS_API_FILE` environment variable. When the file is absent the suite still runs and tests that need the API are skipped; tests that simulate an absent API run either way. Restart the environment with `npm run env start` after changing the mapping. + - **`tests/bootstrap.php`** — Locates the WordPress test library (via `WP_TESTS_DIR` env var, relative path, or `/tmp/wordpress-tests-lib`), loads the plugin via `muplugins_loaded`, then boots the WP test environment. - **`tests/class-two-factor-dummy-secure.php`** — Defines `Two_Factor_Dummy_Secure`, a test-only provider class that spoofs the key of `Two_Factor_Dummy` but always fails `validate_authentication`. Used by `Tests_Two_Factor_Dummy_Secure` and some core tests. - **`tests/cli/`** — WP-CLI test doubles loaded by `Tests_Two_Factor_CLI_Command`: `class-wp-cli-command.php` (empty base-class stub), `class-wp-cli.php` (captures output into `WP_CLI::$logger`, throws on `error()`/`confirm()`), `class-wp-cli-mock-exit-exception.php` (stands in for a process exit), and `wp-cli-utils.php` (`WP_CLI\Utils\get_flag_value()` and `format_items()`). diff --git a/class-two-factor-secrets.php b/class-two-factor-secrets.php index 878e8fb8c..21036c289 100644 --- a/class-two-factor-secrets.php +++ b/class-two-factor-secrets.php @@ -13,7 +13,7 @@ * holds the secret; the marker is what makes "not yet migrated" distinguishable * from "migrated but unreadable". * - * @since 0.16.0 + * @since 0.18.0 */ class Two_Factor_Secrets { @@ -22,6 +22,8 @@ class Two_Factor_Secrets { * * @internal Test seam only; keys set, get, delete, writable. * + * @since 0.18.0 + * * @var array<string, callable> */ public static $test_overrides = array(); @@ -29,6 +31,8 @@ class Two_Factor_Secrets { /** * Whether the Secrets API is available right now. * + * @since 0.18.0 + * * @return bool */ public static function is_api_present() { @@ -55,6 +59,8 @@ public static function is_api_present() { /** * Whether the active secrets provider accepts writes. * + * @since 0.18.0 + * * @return bool */ public static function is_provider_writable() { @@ -72,6 +78,8 @@ public static function is_provider_writable() { /** * Whether new secrets may be written to the Secrets API. * + * @since 0.18.0 + * * @param int $user_id User ID; may be 0 when no user is in context. * @return bool */ @@ -86,7 +94,7 @@ public static function can_write( $user_id = 0 ) { * This controls writes and migration only. Users whose secret was already * migrated are still read from the Secrets API while it is present. * - * @since 0.16.0 + * @since 0.18.0 * * @param bool $enabled Whether to use the Secrets API. Default true. * @param int $user_id User ID, which may be 0 when no user is in context. @@ -101,6 +109,8 @@ public static function can_write( $user_id = 0 ) { /** * Build the secret name for a user. * + * @since 0.18.0 + * * @param int $user_id User ID. * @param string $slug Secret slug, such as "totp". * @return string @@ -116,6 +126,8 @@ public static function get_secret_name( $user_id, $slug ) { /** * Get the user meta key holding the network marker. * + * @since 0.18.0 + * * @param string $slug Secret slug. * @return string * @@ -130,6 +142,8 @@ public static function get_marker_meta_key( $slug ) { /** * Read a user's secret. * + * @since 0.18.0 + * * @param int $user_id User ID. * @param string $slug Secret slug. * @return string|null|WP_Error Null when no secret has been migrated for the user. @@ -170,6 +184,8 @@ public static function get_user_secret( $user_id, $slug ) { /** * Store a user's secret. * + * @since 0.18.0 + * * @param int $user_id User ID. * @param string $slug Secret slug. * @param string $value Secret value. @@ -199,6 +215,8 @@ public static function set_user_secret( $user_id, $slug, $value ) { * * A missing secret counts as success. * + * @since 0.18.0 + * * @param int $user_id User ID. * @param string $slug Secret slug. * @return true|WP_Error @@ -221,6 +239,8 @@ public static function delete_user_secret( $user_id, $slug ) { /** * Label of the active secrets provider. * + * @since 0.18.0 + * * @return string */ public static function provider_label() { @@ -234,6 +254,8 @@ public static function provider_label() { /** * Wipe a value from memory where possible. * + * @since 0.18.0 + * * @param string $value Value to wipe. * @return void */ @@ -249,6 +271,8 @@ public static function memzero( &$value ) { /** * Validate a slug. * + * @since 0.18.0 + * * @param string $slug Secret slug. * @return void * @@ -263,6 +287,8 @@ private static function assert_slug( $slug ) { /** * Write to the Secrets API. * + * @since 0.18.0 + * * @param string $name Secret name. * @param string $value Secret value. * @return true|WP_Error @@ -278,6 +304,8 @@ private static function api_set( $name, $value ) { /** * Read and reveal from the Secrets API. * + * @since 0.18.0 + * * @param string $name Secret name. * @return string|null|WP_Error */ @@ -298,6 +326,8 @@ private static function api_get( $name ) { /** * Delete from the Secrets API. * + * @since 0.18.0 + * * @param string $name Secret name. * @return true|WP_Error */ diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 6585bf512..2bd7e583a 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -687,6 +687,8 @@ public function export_user_totp_key( $user_id ) { /** * Fire the migration failure action. * + * @since 0.18.0 + * * @param int $user_id User ID. * @param WP_Error $error The failure. * @@ -1389,6 +1391,8 @@ public function is_enrolled_but_unavailable_for_user( $user ) { /** * Announce that a user's stored secret cannot be used. * + * @since 0.18.0 + * * @param int $user_id User ID. * @param WP_Error $error Why the secret is unavailable. * From 5e73d725ef87891c6e0dffb60669058f8c1606cc Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:09:06 -0700 Subject: [PATCH 43/59] progress: P5-02 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3c8c03236..d842ff4bf 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -21,7 +21,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P4-03 `wp two-factor secrets export` - [x] P4-04 Push Phase 4 and record the manual check - [x] P5-01 readme.txt, readme.md and CHANGELOG.md -- [ ] P5-02 AGENTS.md, TESTS.md and docblock audit +- [x] P5-02 AGENTS.md, TESTS.md and docblock audit - [ ] P5-03 Upstream PR description - [ ] P5-04 Push Phase 5 and record the final manual check @@ -89,3 +89,6 @@ Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human ### P5-01 — 814fcf8 readme.txt: four hooks under Actions & Filters, three secrets CLI bullets plus totp_storage note, TOTP storage FAQ; CHANGELOG.md [Unreleased] entry. readme.md unchanged (no equivalent sections). readme.txt is CRLF; preserved. + +### P5-02 — 0f5d369 +AGENTS.md (adapter bullet, marker row, Secrets API hooks, WP-CLI, base-class hooks, PHPStan level 5) and TESTS.md (new test files, helpers, phpstan stubs, feature-plugin wiring, CLI bullets). Docblock audit: adapter @since corrected 0.16.0 -> 0.18.0, added @since to private helpers; PHP hunks are docblock-only. TESTS.md documents the actual class/file names Two_Factor_Secrets_Tests and Two_Factor_Totp_Secrets_Tests. From b8695108e6283b42357e48d253cfca0023234da7 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:09:32 -0700 Subject: [PATCH 44/59] P5-03: upstream PR description Goal: ready-to-paste PR body. Tests: none (docs). --- docs/PR-DESCRIPTION.md | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/PR-DESCRIPTION.md diff --git a/docs/PR-DESCRIPTION.md b/docs/PR-DESCRIPTION.md new file mode 100644 index 000000000..3af9ad4ff --- /dev/null +++ b/docs/PR-DESCRIPTION.md @@ -0,0 +1,61 @@ +# Store TOTP secrets with the WordPress Secrets API + +## Motivation + +Two-Factor keeps every user's TOTP shared secret in plaintext user meta (`_two_factor_totp_key`, `Two_Factor_Totp::SECRET_META_KEY`). Anyone who can read the database, a backup, or a SQL injection dump can mint valid codes for every TOTP user. + +An earlier attempt (PR #389, "recrypt") added homegrown XChaCha20 encryption keyed off `SECURE_AUTH_SALT`. It stalled because rotating the salt needed a bespoke re-encryption process, and it made Two-Factor responsible for cryptography and key management. + +The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (milestone 7.2), core PR WordPress/wordpress-develop#13759, and is available today as a feature plugin (`ericmann/secrets-api`, v0.2.1). This change uses it when present and changes nothing when it is not. + +## Design summary + +- **Adapter.** `Two_Factor_Secrets` (`class-two-factor-secrets.php`) is a static class and the only code that calls the Secrets API. Everything else goes through it. Presence is detected at call time, never when files load. +- **Network scope only.** Secrets are named `two-factor/totp-{user_id}` and always stored with the network functions, on single site and multisite alike. The API has no per-user scope, and site-scope secrets are bound to the blog ID, which would break a user moving between sites. +- **Location marker.** User meta `_two_factor_totp_key_network` (`Two_Factor_Totp::SECRET_NETWORK_META_KEY`) holds the network ID that owns the secret. It is what tells "not migrated" apart from "migrated but unreadable". +- **Precedence.** A non-empty plaintext meta value wins and is migrated lazily. Otherwise no marker means no key. Otherwise the secret is read from the Secrets API. +- **Tri-state reads.** `get_user_totp_key_state()` returns a string, `null` (no key) or `WP_Error` (a key exists but cannot be read). `get_user_totp_key()` keeps its signature and returns `''` for both non-string states. +- **Verified writes.** Every write and migration is read back and compared with `hash_equals()`. The plaintext copy is deleted only after that succeeds. A Secrets API failure never falls back to writing plaintext. +- **Fail closed.** + - An unreadable secret never authenticates and is never treated as "not configured" or re-enrolled silently. + - The login prompt explains that the authenticator app is unavailable and offers no code field. + - A user whose only enrolled method is the unavailable TOTP is forced onto the fallback provider (email by default, filterable), or locked out with a `no_available_2fa_methods` error if no valid fallback exists. They are never let in with one factor. + - Users with backup codes keep using them. +- **Hooks.** + - `two_factor_use_secrets_api` filter: opt out of writes and migration. Already-migrated users are still read from the Secrets API while it is present. + - `two_factor_secrets_migrated`, `two_factor_secrets_migration_failed` and `two_factor_secret_unavailable` actions. No hook ever receives a plaintext secret. + - `two_factor_secrets_api_present` is an internal test seam that can only force "absent". +- **Provider base class.** Two new generic hooks: `is_enrolled_but_unavailable_for_user()` and the static `uninstall_user_data()`. Core stays free of TOTP logic. + +## Migration and downgrade story + +- **Lazy migration.** The first time a plaintext secret is read in the login or profile flow it is moved into the Secrets API, verified, and the plaintext is removed. +- **Bulk.** `wp two-factor secrets migrate [--user=<user>] [--batch-size=<n>] [--dry-run]` uses the same code path in bounded batches. +- **Status.** `wp two-factor secrets status` and a `totp_storage` field on `wp two-factor status <user>` (`plaintext`, `secrets-api`, `unavailable`, `none`). +- **If the Secrets API goes away.** Affected users cannot use their authenticator app. Administrators see a non-dismissible notice (Dashboard and Network Admin, `manage_options` / `manage_network_options` only) and a critical Site Health result, `two_factor_totp_secret_storage`. Detection is a cheap single-row query cached in a site transient. +- **Export.** `wp two-factor secrets export [--user=<user>] [--batch-size=<n>] [--yes]` moves secrets back into user meta (with confirmation) before the API is removed. +- **Cleanup.** Deleting a user removes their secret and marker (`wpmu_delete_user` on multisite, so removal from a single site does not delete a network secret). Uninstall removes every secret while the API is available. Secrets left in the store when the API is absent at uninstall are orphaned; there is nothing to reach them with. + +## Compatibility + +- No `Requires at least` bump, and no new package dependency. The Secrets API is optional. +- Without the Secrets API (WP earlier than 7.2 and no feature plugin) behaviour is identical to today: secrets stay in user meta and nothing is migrated. +- The opt-out is the `two_factor_use_secrets_api` filter only. There is no settings UI. +- Existing public method signatures on `Two_Factor_Totp` are unchanged. +- `includes/` is untouched. + +## Testing notes + +- The feature plugin is loaded into the wp-env tests environment through `env.tests.plugins` in `.wp-env.json` (`ericmann/secrets-api#v0.2.1`, with `"."` repeated because an env-level `plugins` key replaces the root one). CI needs no change because wp-env clones the source itself. +- `WP_SECRETS_KEY` is set to a fixed, test-only value so encryption is deterministic. `tests/bootstrap.php` loads `secrets-api.php` if present (override with `TWO_FACTOR_SECRETS_API_FILE`); when it is absent the suite still runs and API-dependent tests skip. +- Tests run in both suites (single site and multisite). Multisite-only cases cover reading a secret from a second blog, deletion semantics, and Network Admin notice capabilities. +- Group filter: `npm run composer -- test -- --group secrets`. Shared helpers live in `Two_Factor_Secrets_UnitTestCase`. +- PHPStan (level 5) uses stubs in `tests/phpstan/`, excluded from PHPUnit because they would redeclare the real functions. + +## Open questions for maintainers + +1. Should the adapter be public API for third-party providers, or `@internal` for now? +2. Is the `LOGGED_IN_*` salt-rotation hazard acceptable without `WP_SECRETS_KEY`, or should Two-Factor surface a Site Health recommendation to define it? +3. Naming the network-scope secrets `two-factor/totp-{ID}` puts one row per TOTP user in the options table. Is that acceptable on large networks, versus waiting for a user-scoped store in the Secrets API? +4. `Two_Factor_Provider::is_enrolled_but_unavailable_for_user()` is a generic core hook that lets any provider say "enrolled, but currently unusable", so core forces the fallback instead of failing open. Is that the right name and shape for a core-level concept? +5. `Two_Factor_Provider::uninstall_user_data()` is a bulk, static hook called once during uninstall for data held outside user meta. Would maintainers prefer a different name or a per-user shape? From 051e1f9530d9fa750710c78e3a504736dc6a684b Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:09:34 -0700 Subject: [PATCH 45/59] progress: P5-03 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d842ff4bf..2ffc4060b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -22,7 +22,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P4-04 Push Phase 4 and record the manual check - [x] P5-01 readme.txt, readme.md and CHANGELOG.md - [x] P5-02 AGENTS.md, TESTS.md and docblock audit -- [ ] P5-03 Upstream PR description +- [x] P5-03 Upstream PR description - [ ] P5-04 Push Phase 5 and record the final manual check ## Log @@ -92,3 +92,6 @@ readme.txt: four hooks under Actions & Filters, three secrets CLI bullets plus t ### P5-02 — 0f5d369 AGENTS.md (adapter bullet, marker row, Secrets API hooks, WP-CLI, base-class hooks, PHPStan level 5) and TESTS.md (new test files, helpers, phpstan stubs, feature-plugin wiring, CLI bullets). Docblock audit: adapter @since corrected 0.16.0 -> 0.18.0, added @since to private helpers; PHP hunks are docblock-only. TESTS.md documents the actual class/file names Two_Factor_Secrets_Tests and Two_Factor_Totp_Secrets_Tests. + +### P5-03 — b869510 +Wrote docs/PR-DESCRIPTION.md with the required sections in order; SPEC 8 questions verbatim plus the two extra core-hook naming questions. No PR opened. From b3d571f8b118a881119092575e4dd63a1087f79b Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:09:41 -0700 Subject: [PATCH 46/59] P5-04: push Phase 5 and record final manual check From 58260284160e787f555e911ff53e47a81f8cf218 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:09:43 -0700 Subject: [PATCH 47/59] progress: P5-04 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2ffc4060b..fe05b5b89 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -23,7 +23,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P5-01 readme.txt, readme.md and CHANGELOG.md - [x] P5-02 AGENTS.md, TESTS.md and docblock audit - [x] P5-03 Upstream PR description -- [ ] P5-04 Push Phase 5 and record the final manual check +- [x] P5-04 Push Phase 5 and record the final manual check ## Log (one entry per task, appended by implement) @@ -95,3 +95,6 @@ AGENTS.md (adapter bullet, marker row, Secrets API hooks, WP-CLI, base-class hoo ### P5-03 — b869510 Wrote docs/PR-DESCRIPTION.md with the required sections in order; SPEC 8 questions verbatim plus the two extra core-hook naming questions. No PR opened. + +### P5-04 — b3d571f +Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): (a) read docs/PR-DESCRIPTION.md and readme.txt FAQ for tone; (b) confirm CI green on the pushed branch; (c) open the fork PR (base master, ericmann/two-factor, draft) only if the pipeline PR step did not; (d) rerun the P2-04 and P3-03 browser checks on the final commit. From f7618bb8fcd9433707ee947749b8d8e8879853ee Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:10:17 -0700 Subject: [PATCH 48/59] chore: handoff for review --- docs/HANDOFF.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/HANDOFF.md diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 000000000..43fd53a89 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,45 @@ +# Handoff: TOTP secrets in the WordPress Secrets API + +- Branch: `build/2026-09-28` (the spec named `build/secrets-api-totp`; `foundry_run_start` created this one and the run stayed on it) +- Base commit: `8799d03` (plan) on `feature/secrets-api-totp` +- Head commit at handoff: `5826028` (plus this file) +- Tasks: 21 done, 0 blocked, 0 skipped +- Final verify: lint, PHPStan (level 5), lint-compat, and both PHPUnit suites green (414 tests each; single site has 5 skips, multisite has 1, all environment-specific). + +## Blocked / skipped tasks + +None. + +## Interpretation choices + +- P0-01: stayed on the `build/2026-09-28` branch instead of creating `build/secrets-api-totp`. The `.wp-env.json` and bootstrap changes had already been applied by the operator. +- P1-01: the test classes are named `Two_Factor_Secrets_Tests` (file `tests/class-two-factor-secrets-tests.php`) and, in P2-01, `Two_Factor_Totp_Secrets_Tests` (file `tests/providers/class-two-factor-totp-secrets-tests.php`), not `Tests_Two_Factor_*` as the plan said. PHPCS requires the file name to match the class for classes that don't extend a recognised WP test case, and a file sorting before `tests/class-two-factor-core.php` breaks the core tests (they register `set_auth_cookie` hooks in `set_up_before_class()`, which an earlier test class wipes). Filter parameter is `$enabled` because PHPCS flags `$use`. +- P1-02: `resolve_fallback_provider_for_user()` has an optional fourth by-reference argument that exposes the raw filter value so the existing error data (`fallback_provider`) stays byte-for-byte. The `Two_Factor_Dummy_Unavailable` fixture is loaded through the `two_factor_providers` filter path, not `tests/bootstrap.php`, because `Two_Factor_Dummy` is not loaded at bootstrap time. +- P2-01: `delete_user_totp_key()` now returns `true` only when plaintext, marker and secret are all gone (including when there was nothing to delete). +- P2-02: the unavailable login prompt renders the two prompt actions and the unavailable message but omits the generic "Enter the code" line. +- P2-03: deletion hooks call a new void wrapper `delete_user_secrets_on_user_deletion()` instead of `delete_user_totp_key()`, because PHPStan level 5 rejects bool-returning action callbacks. The constructor test asserts the wrapper. +- P3-02: with the API present and no plaintext users the Site Health result is `good` naming the provider label, even when writes are disabled; the "migration disabled" message is used only when plaintext remains and `can_write()` is false. +- P4-01: `Two_Factor_Secrets::provider_label()` returns `''` whenever `is_api_present()` is false (so the test seam is honoured). Booleans in `secrets status` are the strings `'true'`/`'false'`. +- P5-01: `readme.md` has no equivalent sections and was left unchanged. `readme.txt` uses CRLF line endings, preserved. + +## `⚠️ ASSUMPTION` config keys + +None introduced. Constants: `SECRET_SLUG = 'totp'`, `AFFECTED_USERS_CACHE_TTL = 300`, `Two_Factor_CLI_Command::SECRETS_DEFAULT_BATCH_SIZE = 100` (none tuned). + +## Manual checks (all NOT VERIFIED (human)) + +- Phase 0: fresh clone with `npm install && npm run env start` clones `ericmann/secrets-api` v0.2.1 into the tests env; CI passes on the WP `latest`, `7.0-branch`, `7.1-branch` legs; `gh repo set-default --view` shows the fork. +- Phase 1: Dummy-only user logs in unchanged on the dev site; read `class-two-factor-secrets.php` for any path that could echo or log a revealed value. +- Phase 2: enrol an authenticator app and confirm the `_wp_network_secret_two-factor/totp-<id>` option exists with empty `_two_factor_totp_key` meta; log in with an app code; deactivate the feature plugin and confirm only other methods are offered, and that the profile shows the reset notice after re-activating with `WP_SECRETS_KEY` changed; deleting the user removes the option row. +- Phase 3: red non-dismissible notice for an admin (not an editor) with a migrated user and the feature plugin off; Site Health goes critical, then recommended, then good; on multisite the notice shows in Network Admin for a super admin. +- Phase 4: `wp two-factor secrets status` (and `--format=json`), `migrate --dry-run` then `migrate`, `status <user>` shows `secrets-api`, `export` prompts and `--yes` completes, `wp help two-factor secrets` shows the OPTIONS block. +- Phase 5: read `docs/PR-DESCRIPTION.md` and the readme FAQ for tone; confirm CI is green; open the fork PR (base `master`, `ericmann/two-factor`) if the pipeline did not; rerun the Phase 2 and 3 browser checks on the final commit. + +## For a reviewer who has not seen this code + +- `Two_Factor_Secrets` is the only file that may call the Secrets API (enforced by constraints in `docs/foundry.json`). Secrets are network scope only; a user-meta marker `_two_factor_totp_key_network` records the owning network. +- Read precedence: non-empty plaintext meta, then no marker means none, then the Secrets API (tri-state string / null / `WP_Error`). +- Security-critical path: `Two_Factor_Core::get_available_providers_for_user()` now forces the fallback provider (or a `no_available_2fa_methods` error) when the only enrolled providers report `is_enrolled_but_unavailable_for_user()`. The regression test is `test_login_fails_closed_for_affected_totp_only_user` (and its no-fallback variant). +- Tests that need the API skip when it is absent; tests simulate absence through the internal `two_factor_secrets_api_present` filter. +- `docs/PR-DESCRIPTION.md` is the ready-to-paste upstream PR body; nothing was opened against `WordPress/two-factor`. +- Feedback logged: none (no Foundry tooling friction). From 9dac25f4871e1ad55834318b50eec1da485358f0 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:10:17 -0700 Subject: [PATCH 49/59] chore: round 0 implemented --- .foundry/state.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.foundry/state.json b/.foundry/state.json index b2adc2c7e..994332d5a 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -1,6 +1,6 @@ { "round": 0, - "implemented": false, + "implemented": true, "reviewed": false, "verdict": null, "summarized": false, From c1e4a90a75ab43bddf38a8e4c5cc5874414da972 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:18:21 -0700 Subject: [PATCH 50/59] review: round 1 --- .foundry/state.json | 19 +++++-- docs/PLAN.md | 11 ++++ docs/PROGRESS.md | 1 + docs/REVIEW.md | 119 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 docs/REVIEW.md diff --git a/.foundry/state.json b/.foundry/state.json index 994332d5a..6e69c8cee 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -1,8 +1,8 @@ { - "round": 0, - "implemented": true, - "reviewed": false, - "verdict": null, + "round": 1, + "implemented": false, + "reviewed": true, + "verdict": "CHANGES REQUESTED", "summarized": false, "halted": null, "preexistingUntracked": [], @@ -13,5 +13,14 @@ "feedback": true }, "signing": "on", - "rounds": [] + "rounds": [ + { + "round": 1, + "fixTasks": 1, + "unblocked": 0, + "verdict": "CHANGES REQUESTED", + "nonConverging": false, + "at": "2026-09-28T20:18:21.733Z" + } + ] } diff --git a/docs/PLAN.md b/docs/PLAN.md index 1ecba87f0..ba5b9aab6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -274,3 +274,14 @@ The test base class `Two_Factor_Secrets_UnitTestCase extends WP_UnitTestCase` (a 7. **Branch base.** SPEC 3 names base `master`; the plan documents are committed on `feature/secrets-api-totp` and `docs/foundry.json` uses that as `baseBranch`. The author retargets the final PR to `master` by hand. 8. **`readme.md` mirror.** No equivalent sections exist; nothing to mirror (Decisions). 9. **Test-only `WP_SECRETS_KEY` is public in `.wp-env.json`.** It only protects the throwaway test database and is deliberately deterministic per SPEC 5. + +## Review fixes (round 1) + +### R1-01: Correct the lockout and decommissioning documentation for Secrets API storage, with behaviour-pinning tests +**Goal:** Make the readme FAQ, the `secrets` command docblock and docs/PR-DESCRIPTION.md describe what the code actually does: (F1) a TOTP-only user whose secret cannot be decrypted while the Secrets API is present (salt rotation without WP_SECRETS_KEY, or a missing secret row) is locked out until an administrator resets them, and is NOT sent to the email fallback. The forced fallback applies only when the Secrets API is missing or the marker names another network. (F2) `wp two-factor secrets export` is undone by lazy migration while the API is active and `two_factor_use_secrets_api` allows writes, so the decommission procedure is: return false from `two_factor_use_secrets_api`, run export, then deactivate the Secrets API. +**Files touched:** readme.txt, docs/PR-DESCRIPTION.md, CLI/class-two-factor-cli-command.php, tests/providers/class-two-factor-totp-secrets-tests.php +**Design constraints:** SPEC 6 (FAQ content: WP_SECRETS_KEY recommendation, salt-rotation warning, migrate/export commands, deactivation warning).,SPEC 4.1 (the filter gates writes and migration only; reads continue).,SPEC 4.3/4.6 (is_available_for_user never decrypts; forced fallback only for affected users).,No behaviour change in PHP source. The CLI change is docblock-only: the `secrets()` EXAMPLES/description gains the filter-first step. Do not change the confirmation prompt string, which existing tests assert.,readme.txt uses CRLF line endings; preserve them.,Keep SPEC 8 open questions verbatim in PR-DESCRIPTION.md. You may add one more open question about forcing a fallback for undecryptable secrets (see REVIEW.md spec issue 1).,Text domain two-factor and @since 0.18.0 for any new docblock text. +**Acceptance tests:** Add to Two_Factor_Totp_Secrets_Tests (both require_secrets_api()): (1) `test_unreadable_secret_with_api_present_keeps_totp_and_does_not_force_fallback`: set a key via the API, enable only Two_Factor_Totp, set `Two_Factor_Secrets::$test_overrides['get']` to return WP_Error('secret_decryption_failed'); assert is_available_for_user() is true, is_enrolled_but_unavailable_for_user() is false, array_keys( Two_Factor_Core::get_available_providers_for_user( $id ) ) === array( 'Two_Factor_Totp' ), is_user_using_two_factor() is true, and validate_code_for_user() with a code from the real key is false. This pins the behaviour F1's corrected FAQ describes; the old FAQ text would contradict it. (2) `test_exported_secret_is_remigrated_on_read_unless_filter_opts_out`: set a key via the API, export_user_totp_key() returns true, then get_user_totp_key() migrates it back (plaintext empty, marker set). Repeat with add_filter( 'two_factor_use_secrets_api', '__return_false' ) before export and assert that after get_user_totp_key() the plaintext remains and the marker is absent. This pins the procedure F2's corrected docs describe. +**Out of scope:** Changing core fallback logic, is_available_for_user(), export_user_totp_key() or CLI behaviour; loading the TOTP class when it is disabled site-wide (REVIEW.md spec issue 2); readme.md; AGENTS.md; TESTS.md, except adding the two new test names if TESTS.md lists TOTP secrets tests individually. +**Verification:** npm run lint; composer lint-compat; npm test (both suites). `grep -n "sent to the fallback" readme.txt` returns nothing, and the FAQ names returning false from two_factor_use_secrets_api before running export. The PR-DESCRIPTION fail-closed bullet scopes the forced fallback to a missing API or another network. +**Depends on:** none diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fe05b5b89..9d9ec759c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -24,6 +24,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P5-02 AGENTS.md, TESTS.md and docblock audit - [x] P5-03 Upstream PR description - [x] P5-04 Push Phase 5 and record the final manual check +- [ ] R1-01 Correct the lockout and decommissioning documentation for Secrets API storage, with behaviour-pinning tests ## Log (one entry per task, appended by implement) diff --git a/docs/REVIEW.md b/docs/REVIEW.md new file mode 100644 index 000000000..61a9ae92c --- /dev/null +++ b/docs/REVIEW.md @@ -0,0 +1,119 @@ +# Review: TOTP secrets in the WordPress Secrets API +Round: 1 + +Branch `build/2026-09-28`, base `8799d03`, head `9dac25f`. 21 tasks done, 0 blocked, 0 skipped. + +## Verdict + +CHANGES REQUESTED + +Categories 1 to 3 (constraints, boundaries, tests) are clean across the branch. There are two category-5 findings. Both are in the site-owner documentation that ships with the plugin, and both are about lockout and decommissioning. The first states a security behaviour the code does not have. They are grouped into one fix task. + +## What I verified myself + +- `foundry_verify`: all 11 `docs/foundry.json` constraints pass with no fixture failures and zero hits. `npm run lint` (PHPCS plus PHPStan level 5), `composer lint-compat` and `npm test` are green: 414 tests per suite, with 5 skips on single site and 1 on multisite, all environment-specific. +- Existing tests with the API **absent**, per SPEC 5 ("with and without the API"). I ran both suites inside `tests-cli` with `TWO_FACTOR_SECRETS_API_FILE=/nonexistent`. Single site: 414 tests OK, 61 skipped. Multisite: 414 tests OK, 60 skipped. The skips are the tests that need the API. +- Reading-only constraints from CLAUDE.md: + - `git diff --stat 8799d03..HEAD -- includes/` is empty. + - `is_available_for_user()` and `is_enrolled_but_unavailable_for_user()` read meta only. Neither calls `get_user_secret()`, `migrate_user_totp_key()` or `reveal()`. + - Every `get_users()` / `WP_User_Query` over TOTP meta passes `'blog_id' => 0`: `has_affected_users`, `has_plaintext_users`, `count_users_by_storage`, `uninstall_user_data`, CLI migrate and CLI export. + - The login prompt and profile UI print only the translated "unavailable" text, never an error message or code. + - The multi-line `do_action( 'two_factor_secret…' )` calls pass only `$user_id`, the slug and a `WP_Error`. +- Boundaries: + - Only `class-two-factor-secrets.php` calls the Secrets API. + - Core has no TOTP-specific logic. It uses the generic `is_enrolled_but_unavailable_for_user()` and `uninstall_user_data()` hooks. + - The one `.gitignore` change (`.foundry/implement.lock`) was made by Foundry's own `chore: start implementation run` commit, not by the implementer. + - The `.wp-env.json` and bootstrap edits come from the operator commit `e9e18d7` and match P0-01. +- Mutation sampling. Every mutation was **killed**: + - `class-two-factor-core.php`: disabling the enrolled-but-unavailable fallback branch (4 failures, including the login lockout regression tests). + - `class-two-factor-core.php`: dropping the `uninstall_user_data` call (2 failures). + - `class-two-factor-secrets.php`: removing the wrong-network check (2 failures). + - `providers/class-two-factor-totp.php`: dropping `hash_equals` from migration read-back (1 failure). + - `providers/class-two-factor-totp.php`: falling back to plaintext on a Secrets API write error (1 failure). + - `providers/class-two-factor-totp.php`: registering `delete_user` instead of `wpmu_delete_user` on multisite (3 multisite failures). + - `providers/class-two-factor-totp.php`: using `manage_options` for the network-admin notice (1 multisite failure). + - `CLI/class-two-factor-cli-command.php`: migrate paging offset (2 failures). + - `CLI/class-two-factor-cli-command.php`: export paging offset (1 failure). + +## Findings + +### F1: category 5 (spec drift). The readme FAQ misstates what happens to TOTP-only users after salt rotation. Task P5-01, with the same claim in the P5-03 PR description. + +- **Where:** + - `readme.txt`, FAQ "How are authenticator (TOTP) secrets stored?", second paragraph: "Affected users can't use their authenticator app until they reset it, and if it was their only method they are sent to the fallback method instead of being let in with a single factor." + - `docs/PR-DESCRIPTION.md`, "Fail closed" bullets: "A user whose only enrolled method is the unavailable TOTP is forced onto the fallback provider", which follows directly from "An unreadable secret never authenticates". +- **What is wrong:** rotating `LOGGED_IN_KEY`/`LOGGED_IN_SALT` without `WP_SECRETS_KEY` leaves the Secrets API present and the marker on the current network, and every read returns `WP_Error( 'secret_decryption_failed' )`. In that state: + - `Two_Factor_Totp::is_available_for_user()` returns **true**, correctly per SPEC 4.3: marker present, API present, same network, no decrypt. + - `is_enrolled_but_unavailable_for_user()` returns **false**. + - So `Two_Factor_Core::get_available_providers_for_user()` returns exactly `Two_Factor_Totp`, and no fallback is forced. A TOTP-only user gets the "currently unavailable" prompt with no code field and no alternative method. + + The user is locked out until an administrator resets them (for example with `wp two-factor disable <user> --yes`). They are not sent to the email fallback. The code fails closed, which is correct. The documentation promises a recovery path that does not exist. +- **What would break:** an administrator who trusts the FAQ may rotate salts without `WP_SECRETS_KEY`, expecting TOTP-only users to receive email codes. In fact every TOTP-only user is locked out. +- **Minimal fix:** + - Correct the readme FAQ sentence: TOTP-only users are locked out until an administrator resets their authenticator app, users with backup codes can still use them, and this is why `WP_SECRETS_KEY` is recommended. + - Scope the PR-description bullet to the cases that do force the fallback: the Secrets API is missing, or the marker names another network. State that an unreadable secret with the API present fails closed without a forced fallback. + - Add a behaviour-pinning test so the documented behaviour cannot drift again. + +### F2: category 5 (spec drift / incomplete procedure). "Export before removing the Secrets API" is undone by lazy migration. Task P4-03, and P5-01 for the FAQ. + +- **Where:** + - `readme.txt` FAQ, third paragraph: "Run `wp two-factor secrets export` before removing it…". + - `CLI/class-two-factor-cli-command.php` `secrets()` docblock example "Move all secrets back into user meta before removing the Secrets API". + - `docs/PR-DESCRIPTION.md` "Export" bullet. +- **What is wrong:** `export_user_totp_key()` writes plaintext meta and removes the marker. While the Secrets API stays active and `two_factor_use_secrets_api` returns true, the next login or profile view of that user runs `get_user_totp_key_state()`. That call sees non-empty plaintext with `can_write()` true and migrates the secret straight back (SPEC 4.4). Anyone who logs in between `export` and deactivation is re-migrated. After deactivation they are an affected user again, which is exactly what export was meant to prevent. SPEC 4.8 describes export as "reverse migration for decommissioning the Secrets API". The documented procedure does not achieve that on a live site. +- **What would break:** on a busy site, users who log in during the window lose their authenticator app when the Secrets API is removed. The admin notice and the critical Site Health result then fire, contradicting the admin's belief that export succeeded. +- **Minimal fix:** document the working order in the readme FAQ, the `secrets` command docblock and the PR description: + 1. Return `false` from `two_factor_use_secrets_api`, which stops migration while reads still work (SPEC 4.1). + 2. Run `wp two-factor secrets export`. + 3. Deactivate the Secrets API. + + Add a test pinning both halves: an exported user is re-migrated on the next read when the filter allows writes, and stays in plaintext when the filter returns `false`. + +## Spec issues + +1. **SPEC goal 4 vs SPEC 4.3 for undecryptable secrets.** Goal 4 says "other providers (backup codes, email fallback) keep working". SPEC 4.3, however, requires `is_available_for_user()` to be true whenever the marker is present, the API is present and the network matches, without decrypting. The consequence is that a TOTP-only user whose secret fails to decrypt (salt rotation without `WP_SECRETS_KEY`, a broken drop-in on the same network, or a missing row: `two_factor_secret_missing`) has no forced email fallback and is locked out pending an admin reset. The implementation follows SPEC and fails closed, which is safe. Whether maintainers want a forced fallback here is worth adding to the PR's open questions. It would need a decrypt on the login path only, not on list screens. +2. **Provider self-registration versus the site-wide provider setting.** SPEC 4.5 requires the deletion hooks in `Two_Factor_Totp`'s constructor. When an administrator removes TOTP in Settings → Two-Factor, `two_factor_filter_enabled_providers` drops it from `two_factor_providers`, so the class is never loaded or instantiated. As a result: + - Deleting a migrated user leaves their ciphertext row orphaned. Uninstall still cleans it up, because `uninstall()` merges the default providers. + - The admin notice and Site Health test are not registered. + - `wp two-factor secrets <action>` fatals on `Two_Factor_Totp::count_users_by_storage()` or `Two_Factor_Totp::get_instance()` ("class not found"). The existing `status` command guards with `class_exists()`; the new subcommand does not. + + This is an edge configuration and SPEC's own hook-placement rule produces it, so I have not raised it as a finding. It is worth a decision: either load the TOTP class for these paths regardless of the site-wide setting, or have the `secrets` subcommand exit with a clear `WP_CLI::error()`. + +## Manual checks still owed (from HANDOFF.md, all NOT VERIFIED (human)) + +- Phase 0: + - A fresh clone with `npm install && npm run env start` clones `ericmann/secrets-api` v0.2.1 into the tests env. + - CI passes on the WP `latest`, `7.0-branch` and `7.1-branch` legs. + - `gh repo set-default --view` shows the fork. +- Phase 1: + - A Dummy-only user logs in unchanged on the dev site. + - Read `class-two-factor-secrets.php` for any path that could echo or log a revealed value. I read it during this review and found none; a human check is still owed. +- Phase 2: + - Enrol an authenticator app and confirm the `_wp_network_secret_two-factor/totp-<id>` option exists and the `_two_factor_totp_key` meta is empty. + - Log in with an app code. + - Deactivate the feature plugin and confirm only other methods are offered. + - Re-activate with `WP_SECRETS_KEY` changed and confirm the profile shows the reset notice. + - Delete the user and confirm the option row is removed. +- Phase 3: + - A red, non-dismissible notice appears for an admin (not an editor) when a migrated user exists and the feature plugin is off. + - Site Health goes critical, then recommended, then good. + - On multisite, the notice shows in Network Admin for a super admin. +- Phase 4: + - `wp two-factor secrets status` works, including with `--format=json`. + - Run `migrate --dry-run`, then `migrate`. + - `status <user>` shows `secrets-api`. + - `export` prompts, and `--yes` completes. + - `wp help two-factor secrets` shows the OPTIONS block. +- Phase 5: + - Read `docs/PR-DESCRIPTION.md` and the readme FAQ for tone. + - Confirm CI is green. + - Open the fork PR (base `master`, `ericmann/two-factor`) if the pipeline did not. + - Rerun the Phase 2 and 3 browser checks on the final commit. + +## Notes + +- Concurrency (SPEC 4.4): two logins migrating the same plaintext both succeed, because they write the same value and both read-backs match. There is one theoretical window. If request A's read-back returns a transient `WP_Error` just after request B completed a successful migration and deleted the plaintext, A's rollback (`delete_user_secret()`) removes B's good secret and the marker, leaving neither copy. This needs a transient read failure on an otherwise healthy store, so I am noting it rather than raising a task. A rollback that re-checks whether the plaintext still exists before deleting would close it. +- `delete_user_totp_key()` now returns `true` when there was nothing to delete, where it used to return `false` (listed in HANDOFF). No caller in the plugin uses the return value, and existing tests pass unchanged. +- CLI progress lines (`Would migrate user %d`, `User %d: %s`, the export warning) are not translated, while the errors and success messages are. That matches how some existing CLI output is handled, but it is inconsistent within the new subcommand. +- The admin notice says the Secrets API "is no longer available on this site". That is also shown for the wrong-network case on multi-network installs, where the wording is slightly inaccurate. +- The test classes were renamed to `Two_Factor_Secrets_Tests` and `Two_Factor_Totp_Secrets_Tests`, and the reason in HANDOFF (PHPCS file naming, and ordering relative to the core tests' `set_up_before_class`) holds up. TESTS.md documents the real names. From c99d5cff73398176c26a9c40c47d530aa6be00d3 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:18:44 -0700 Subject: [PATCH 51/59] chore: start review-fix round 1 --- .foundry/state.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.foundry/state.json b/.foundry/state.json index 6e69c8cee..8f9de36ef 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -1,8 +1,8 @@ { "round": 1, "implemented": false, - "reviewed": true, - "verdict": "CHANGES REQUESTED", + "reviewed": false, + "verdict": null, "summarized": false, "halted": null, "preexistingUntracked": [], From b6e6c61dc3123209cc21f8ca8810a98c31f3d922 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:19:52 -0700 Subject: [PATCH 52/59] R1-01: Correct lockout and decommissioning docs for Secrets API storage Goal: docs match behaviour (undecryptable secret with API present locks out; export needs filter opt-out first). Tests: two behaviour-pinning tests in Two_Factor_Totp_Secrets_Tests. Interpretation: added open question 6 to PR description. Manual check: NOT VERIFIED (human) --- CLI/class-two-factor-cli-command.php | 5 ++- docs/PR-DESCRIPTION.md | 6 ++- readme.txt | 4 +- .../class-two-factor-totp-secrets-tests.php | 40 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CLI/class-two-factor-cli-command.php b/CLI/class-two-factor-cli-command.php index 455581859..3cfc69e39 100644 --- a/CLI/class-two-factor-cli-command.php +++ b/CLI/class-two-factor-cli-command.php @@ -767,7 +767,10 @@ public function unlock( $args, $assoc_args ) { * # Preview the migration * $ wp two-factor secrets migrate --dry-run * - * # Move all secrets back into user meta before removing the Secrets API + * # Move all secrets back into user meta before removing the Secrets API. + * # Opt out of the Secrets API first (return false from the + * # two_factor_use_secrets_api filter), otherwise reads migrate the + * # secrets straight back. Deactivate the Secrets API afterwards. * $ wp two-factor secrets export --yes * * @since 0.18.0 diff --git a/docs/PR-DESCRIPTION.md b/docs/PR-DESCRIPTION.md index 3af9ad4ff..3629880f5 100644 --- a/docs/PR-DESCRIPTION.md +++ b/docs/PR-DESCRIPTION.md @@ -19,7 +19,8 @@ The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (mil - **Fail closed.** - An unreadable secret never authenticates and is never treated as "not configured" or re-enrolled silently. - The login prompt explains that the authenticator app is unavailable and offers no code field. - - A user whose only enrolled method is the unavailable TOTP is forced onto the fallback provider (email by default, filterable), or locked out with a `no_available_2fa_methods` error if no valid fallback exists. They are never let in with one factor. + - When the Secrets API is missing, or the marker names another network, a user whose only enrolled method is the unavailable TOTP is forced onto the fallback provider (email by default, filterable), or locked out with a `no_available_2fa_methods` error if no valid fallback exists. They are never let in with one factor. + - When the Secrets API is present but the secret cannot be decrypted (salt rotation without `WP_SECRETS_KEY`, or a missing secret row), TOTP stays available and is not replaced by the fallback. The user cannot authenticate and is locked out until an administrator resets them. - Users with backup codes keep using them. - **Hooks.** - `two_factor_use_secrets_api` filter: opt out of writes and migration. Already-migrated users are still read from the Secrets API while it is present. @@ -33,7 +34,7 @@ The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (mil - **Bulk.** `wp two-factor secrets migrate [--user=<user>] [--batch-size=<n>] [--dry-run]` uses the same code path in bounded batches. - **Status.** `wp two-factor secrets status` and a `totp_storage` field on `wp two-factor status <user>` (`plaintext`, `secrets-api`, `unavailable`, `none`). - **If the Secrets API goes away.** Affected users cannot use their authenticator app. Administrators see a non-dismissible notice (Dashboard and Network Admin, `manage_options` / `manage_network_options` only) and a critical Site Health result, `two_factor_totp_secret_storage`. Detection is a cheap single-row query cached in a site transient. -- **Export.** `wp two-factor secrets export [--user=<user>] [--batch-size=<n>] [--yes]` moves secrets back into user meta (with confirmation) before the API is removed. +- **Export.** `wp two-factor secrets export [--user=<user>] [--batch-size=<n>] [--yes]` moves secrets back into user meta (with confirmation) before the API is removed. While the API is active and `two_factor_use_secrets_api` allows writes, lazy migration moves exported secrets back on the next read. To decommission: return false from `two_factor_use_secrets_api`, run export, then deactivate the Secrets API. - **Cleanup.** Deleting a user removes their secret and marker (`wpmu_delete_user` on multisite, so removal from a single site does not delete a network secret). Uninstall removes every secret while the API is available. Secrets left in the store when the API is absent at uninstall are orphaned; there is nothing to reach them with. ## Compatibility @@ -59,3 +60,4 @@ The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (mil 3. Naming the network-scope secrets `two-factor/totp-{ID}` puts one row per TOTP user in the options table. Is that acceptable on large networks, versus waiting for a user-scoped store in the Secrets API? 4. `Two_Factor_Provider::is_enrolled_but_unavailable_for_user()` is a generic core hook that lets any provider say "enrolled, but currently unusable", so core forces the fallback instead of failing open. Is that the right name and shape for a core-level concept? 5. `Two_Factor_Provider::uninstall_user_data()` is a bulk, static hook called once during uninstall for data held outside user meta. Would maintainers prefer a different name or a per-user shape? +6. Should a secret that is unreadable while the Secrets API is present also force the fallback provider, as a missing API does? Today the user is locked out until an administrator resets them. diff --git a/readme.txt b/readme.txt index 1e672baa9..cdf59a677 100644 --- a/readme.txt +++ b/readme.txt @@ -182,9 +182,9 @@ As an administrator, go to **Users → All Users** in the WordPress admin, click By default, and on WordPress versions without the Secrets API, they are stored in plaintext user meta. When the WordPress [Secrets API](https://github.com/ericmann/secrets-api) is available, new secrets are stored encrypted through it automatically, and existing plaintext secrets are migrated the next time each user logs in. To migrate everyone at once run `wp two-factor secrets migrate`. To opt out of writing to the Secrets API, return `false` from the `two_factor_use_secrets_api` filter; users who were already migrated are still read from it. -Define `WP_SECRETS_KEY` (the base64 encoding of 32 random bytes) in `wp-config.php` so encryption does not depend on your salts. Without it, rotating `LOGGED_IN_KEY` or `LOGGED_IN_SALT` makes stored secrets unreadable. Affected users can't use their authenticator app until they reset it, and if it was their only method they are sent to the fallback method instead of being let in with a single factor. +Define `WP_SECRETS_KEY` (the base64 encoding of 32 random bytes) in `wp-config.php` so encryption does not depend on your salts. Without it, rotating `LOGGED_IN_KEY` or `LOGGED_IN_SALT` makes stored secrets unreadable. Affected users can't use their authenticator app, and if it was their only method they are locked out until an administrator resets their two-factor settings. The fallback method is only forced when the Secrets API is missing or the secret belongs to another network. -Do not deactivate the Secrets API while users have migrated secrets: those users can't use their authenticator app until it is restored, and an administrator notice and Site Health check will warn you. Run `wp two-factor secrets export` before removing it to move secrets back into user meta, or `wp two-factor secrets migrate` after restoring it. Secrets left in the store when the Secrets API is absent at the time the plugin is uninstalled are not removed. +Do not deactivate the Secrets API while users have migrated secrets: those users can't use their authenticator app until it is restored, and an administrator notice and Site Health check will warn you. To move secrets back into user meta before removing it, first return false from the `two_factor_use_secrets_api` filter (otherwise secrets are migrated straight back on the next read), run `wp two-factor secrets export`, then deactivate the Secrets API. If the Secrets API is already gone, run `wp two-factor secrets migrate` after restoring it. Secrets left in the store when the Secrets API is absent at the time the plugin is uninstalled are not removed. = Can I require 2FA for all users or specific roles? = diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index 809b36145..30ce0628f 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -283,6 +283,46 @@ public function test_lazy_migration_skipped_when_filter_opts_out() { $this->assertSame( array(), $this->calls ); } + /** + * An unreadable secret with the API present keeps TOTP enrolled and does not force the fallback. + */ + public function test_unreadable_secret_with_api_present_keeps_totp_and_does_not_force_fallback() { + $this->require_secrets_api(); + $user_id = $this->user(); + $key = Two_Factor_Totp::generate_key(); + $this->provider->set_user_totp_key( $user_id, $key ); + update_user_meta( $user_id, Two_Factor_Core::ENABLED_PROVIDERS_USER_META_KEY, array( 'Two_Factor_Totp' ) ); + $this->make_unreadable(); + $user = get_userdata( $user_id ); + + $this->assertTrue( $this->provider->is_available_for_user( $user ) ); + $this->assertFalse( $this->provider->is_enrolled_but_unavailable_for_user( $user ) ); + $this->assertSame( array( 'Two_Factor_Totp' ), array_keys( Two_Factor_Core::get_available_providers_for_user( $user_id ) ) ); + $this->assertTrue( Two_Factor_Core::is_user_using_two_factor( $user_id ) ); + $this->assertFalse( $this->provider->validate_code_for_user( $user, Two_Factor_Totp::calc_totp( $key ) ) ); + } + + /** + * Export is undone by lazy migration unless the filter opts out first. + */ + public function test_exported_secret_is_remigrated_on_read_unless_filter_opts_out() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + $this->assertTrue( $this->provider->export_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( '', $this->plaintext( $user_id ) ); + $this->assertSame( (string) get_current_network_id(), $this->marker( $user_id ) ); + + add_filter( 'two_factor_use_secrets_api', '__return_false' ); + $this->assertTrue( $this->provider->export_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->provider->get_user_totp_key( $user_id ) ); + $this->assertSame( 'ABCDEFGH', $this->plaintext( $user_id ) ); + $this->assertSame( '', $this->marker( $user_id ) ); + } + /** * Plaintext wins over a marker. */ From b8704faee174bac7e26d601cc243c07aaf05daa5 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:19:55 -0700 Subject: [PATCH 53/59] progress: R1-01 done --- docs/PROGRESS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9d9ec759c..b96dfe617 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -24,7 +24,7 @@ Started: 2026-09-28T19:43:32.427Z - [x] P5-02 AGENTS.md, TESTS.md and docblock audit - [x] P5-03 Upstream PR description - [x] P5-04 Push Phase 5 and record the final manual check -- [ ] R1-01 Correct the lockout and decommissioning documentation for Secrets API storage, with behaviour-pinning tests +- [x] R1-01 Correct the lockout and decommissioning documentation for Secrets API storage, with behaviour-pinning tests ## Log (one entry per task, appended by implement) @@ -99,3 +99,6 @@ Wrote docs/PR-DESCRIPTION.md with the required sections in order; SPEC 8 questio ### P5-04 — b3d571f Pushed to origin. Empty commit as task commit. Manual check: NOT VERIFIED (human): (a) read docs/PR-DESCRIPTION.md and readme.txt FAQ for tone; (b) confirm CI green on the pushed branch; (c) open the fork PR (base master, ericmann/two-factor, draft) only if the pipeline PR step did not; (d) rerun the P2-04 and P3-03 browser checks on the final commit. + +### R1-01 — b6e6c61 +Docs-only fix plus 2 tests (test_unreadable_secret_with_api_present_keeps_totp_and_does_not_force_fallback, test_exported_secret_is_remigrated_on_read_unless_filter_opts_out). readme FAQ, PR-DESCRIPTION fail-closed bullet/export bullet, CLI secrets() docblock updated. Interpretation: added open question 6 to PR-DESCRIPTION; TESTS.md not changed (lists class only). CRLF preserved in readme.txt. Verify green (lint, compat, both suites). From a006744256dba8aaf48f3bf8755604163e5745e4 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:20:05 -0700 Subject: [PATCH 54/59] chore: handoff for review --- docs/HANDOFF.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 43fd53a89..6617a8235 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -43,3 +43,10 @@ None introduced. Constants: `SECRET_SLUG = 'totp'`, `AFFECTED_USERS_CACHE_TTL = - Tests that need the API skip when it is absent; tests simulate absence through the internal `two_factor_secrets_api_present` filter. - `docs/PR-DESCRIPTION.md` is the ready-to-paste upstream PR body; nothing was opened against `WordPress/two-factor`. - Feedback logged: none (no Foundry tooling friction). + +## Round 1 + +- Branch: build/2026-09-28. Tasks: 22 done, 0 blocked, 0 skipped. +- R1-01 (done, b6e6c61): corrected readme FAQ, docs/PR-DESCRIPTION.md and the `secrets` CLI docblock. An undecryptable secret with the Secrets API present locks the user out (no forced fallback); decommissioning requires returning false from `two_factor_use_secrets_api` before `secrets export`. Two behaviour-pinning tests added. No PHP behaviour change. +- Interpretation: added open question 6 to PR-DESCRIPTION.md (forcing a fallback for undecryptable secrets). TESTS.md unchanged. +- Manual check: NOT VERIFIED (human) - read the corrected FAQ text. From 70d620954523896bd6479281d4b6e7ac7b991706 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:20:05 -0700 Subject: [PATCH 55/59] chore: round 1 implemented --- .foundry/state.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.foundry/state.json b/.foundry/state.json index 8f9de36ef..bc88bd904 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -1,6 +1,6 @@ { "round": 1, - "implemented": false, + "implemented": true, "reviewed": false, "verdict": null, "summarized": false, From fe547f2bed79b823edbc56f165371890027ba554 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:23:47 -0700 Subject: [PATCH 56/59] review: round 2 approved --- .foundry/state.json | 11 +++- docs/REVIEW.md | 121 +++++++++++++++++++------------------------- 2 files changed, 62 insertions(+), 70 deletions(-) diff --git a/.foundry/state.json b/.foundry/state.json index bc88bd904..4fd01fc23 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -1,8 +1,8 @@ { "round": 1, "implemented": true, - "reviewed": false, - "verdict": null, + "reviewed": true, + "verdict": "APPROVED", "summarized": false, "halted": null, "preexistingUntracked": [], @@ -21,6 +21,13 @@ "verdict": "CHANGES REQUESTED", "nonConverging": false, "at": "2026-09-28T20:18:21.733Z" + }, + { + "round": 2, + "fixTasks": 0, + "unblocked": 0, + "verdict": "APPROVED", + "at": "2026-09-28T20:23:47.438Z" } ] } diff --git a/docs/REVIEW.md b/docs/REVIEW.md index 61a9ae92c..5d20a92db 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -1,83 +1,68 @@ # Review: TOTP secrets in the WordPress Secrets API -Round: 1 +Round: 2 -Branch `build/2026-09-28`, base `8799d03`, head `9dac25f`. 21 tasks done, 0 blocked, 0 skipped. +Branch `build/2026-09-28`, base `8799d03`, head `70d6209`. 22 tasks done (21 plan tasks plus R1-01), 0 blocked, 0 skipped. ## Verdict -CHANGES REQUESTED +APPROVED -Categories 1 to 3 (constraints, boundaries, tests) are clean across the branch. There are two category-5 findings. Both are in the site-owner documentation that ships with the plugin, and both are about lockout and decommissioning. The first states a security behaviour the code does not have. They are grouped into one fix task. +Categories 1 to 3 (constraints, boundaries, tests) are clean across the whole branch. R1-01 resolves both round-1 findings, and I found no new findings. One edge case that round 1 recorded as a spec issue (the `secrets` CLI subcommand fatals when TOTP is disabled site-wide) is still open. It is carried forward below for a human decision. ## What I verified myself -- `foundry_verify`: all 11 `docs/foundry.json` constraints pass with no fixture failures and zero hits. `npm run lint` (PHPCS plus PHPStan level 5), `composer lint-compat` and `npm test` are green: 414 tests per suite, with 5 skips on single site and 1 on multisite, all environment-specific. -- Existing tests with the API **absent**, per SPEC 5 ("with and without the API"). I ran both suites inside `tests-cli` with `TWO_FACTOR_SECRETS_API_FILE=/nonexistent`. Single site: 414 tests OK, 61 skipped. Multisite: 414 tests OK, 60 skipped. The skips are the tests that need the API. -- Reading-only constraints from CLAUDE.md: +- **`foundry_verify`:** all 11 `docs/foundry.json` constraints pass, with no fixture failures and zero hits. `npm run lint` (PHPCS, PHPStan level 5, CSS, JS), `composer lint-compat` and `npm test` are green. Each suite runs 416 tests: single site skips 5 and multisite skips 1. All skips are environment-specific. +- **R1-01 (`b6e6c61`) against its plan entry:** + - F1 (salt-rotation lockout): + - The readme FAQ now says that a TOTP-only user whose secret cannot be decrypted is locked out until an administrator resets them. + - The FAQ also says the fallback is forced only when the Secrets API is missing or the marker names another network. That matches `is_available_for_user()` / `is_enrolled_but_unavailable_for_user()` and `Two_Factor_Core::get_available_providers_for_user()`. + - The PR description's fail-closed bullet is scoped the same way, and open question 6 was added. + - `grep "sent to the fallback" readme.txt` returns nothing. + - F2 (decommissioning order): the readme FAQ, the `secrets()` docblock EXAMPLES and the PR description's Export bullet all give the same order: + 1. Return false from `two_factor_use_secrets_api`. + 2. Run `wp two-factor secrets export`. + 3. Deactivate the Secrets API. + + `export_user_totp_key()` does not check `can_write()`, and `delete_user_secret()` works while writes are filtered off, so this procedure does work. + - The fix changes no PHP behaviour. The CLI edit is docblock-only, and the confirmation prompt string is unchanged. + - `readme.txt` still has CRLF line endings. + - The SPEC 8 open questions are still verbatim. +- **R1-01 tests.** Both new tests exist in `Two_Factor_Totp_Secrets_Tests` and assert the behaviour the corrected docs describe. When I mutated `is_enrolled_but_unavailable_for_user()` to return true for any marker (a forced fallback on decrypt failure), `test_unreadable_secret_with_api_present_keeps_totp_and_does_not_force_fallback` failed, together with the existing matrix test. +- **Reading-only constraints from CLAUDE.md, re-checked on the whole branch:** - `git diff --stat 8799d03..HEAD -- includes/` is empty. - - `is_available_for_user()` and `is_enrolled_but_unavailable_for_user()` read meta only. Neither calls `get_user_secret()`, `migrate_user_totp_key()` or `reveal()`. - - Every `get_users()` / `WP_User_Query` over TOTP meta passes `'blog_id' => 0`: `has_affected_users`, `has_plaintext_users`, `count_users_by_storage`, `uninstall_user_data`, CLI migrate and CLI export. - - The login prompt and profile UI print only the translated "unavailable" text, never an error message or code. - - The multi-line `do_action( 'two_factor_secret…' )` calls pass only `$user_id`, the slug and a `WP_Error`. -- Boundaries: + - `is_available_for_user()` and `is_enrolled_but_unavailable_for_user()` read meta only. + - Every `get_users()` / `WP_User_Query` over TOTP meta passes `'blog_id' => 0`. + - Only translated "unavailable" text reaches end users. + - The multi-line `two_factor_secret*` actions carry only the user ID, the slug and a `WP_Error`. + - The CLI export warning prints the error code, never a value. +- **Boundaries:** - Only `class-two-factor-secrets.php` calls the Secrets API. - - Core has no TOTP-specific logic. It uses the generic `is_enrolled_but_unavailable_for_user()` and `uninstall_user_data()` hooks. - - The one `.gitignore` change (`.foundry/implement.lock`) was made by Foundry's own `chore: start implementation run` commit, not by the implementer. - - The `.wp-env.json` and bootstrap edits come from the operator commit `e9e18d7` and match P0-01. -- Mutation sampling. Every mutation was **killed**: - - `class-two-factor-core.php`: disabling the enrolled-but-unavailable fallback branch (4 failures, including the login lockout regression tests). - - `class-two-factor-core.php`: dropping the `uninstall_user_data` call (2 failures). - - `class-two-factor-secrets.php`: removing the wrong-network check (2 failures). - - `providers/class-two-factor-totp.php`: dropping `hash_equals` from migration read-back (1 failure). - - `providers/class-two-factor-totp.php`: falling back to plaintext on a Secrets API write error (1 failure). - - `providers/class-two-factor-totp.php`: registering `delete_user` instead of `wpmu_delete_user` on multisite (3 multisite failures). - - `providers/class-two-factor-totp.php`: using `manage_options` for the network-admin notice (1 multisite failure). - - `CLI/class-two-factor-cli-command.php`: migrate paging offset (2 failures). - - `CLI/class-two-factor-cli-command.php`: export paging offset (1 failure). + - Core has no TOTP-specific logic. + - The only `.gitignore` change is Foundry's own `.foundry/implement.lock` line. + - No CI, `.gitattributes` or editor-config changes. +- **Mutation sampling this round** (new mutants, different from round 1's). All were **killed**: + - `class-two-factor-secrets.php`: collapsing "marker present, secret missing" to `null` (`if ( null === $value )` becomes `if ( false )`). 1 failure. + - `providers/class-two-factor-totp.php`: `is_enrolled_but_unavailable_for_user()` returns true whenever a marker exists. 2 failures, including the new R1-01 test. + - `providers/class-two-factor-totp.php`: dropping the per-user `delete_user_secret()` call from `uninstall_user_data()`. 2 failures. + - `class-two-factor-core.php`: dropping the forced-fallback assignment in the enrolled-but-unavailable branch, which is a fail-open. 2 failures, including `test_login_fails_closed_for_affected_totp_only_user`. + - `CLI/class-two-factor-cli-command.php`: removing the `can_write()` guard in `secrets migrate`. 2 failures. ## Findings -### F1: category 5 (spec drift). The readme FAQ misstates what happens to TOTP-only users after salt rotation. Task P5-01, with the same claim in the P5-03 PR description. - -- **Where:** - - `readme.txt`, FAQ "How are authenticator (TOTP) secrets stored?", second paragraph: "Affected users can't use their authenticator app until they reset it, and if it was their only method they are sent to the fallback method instead of being let in with a single factor." - - `docs/PR-DESCRIPTION.md`, "Fail closed" bullets: "A user whose only enrolled method is the unavailable TOTP is forced onto the fallback provider", which follows directly from "An unreadable secret never authenticates". -- **What is wrong:** rotating `LOGGED_IN_KEY`/`LOGGED_IN_SALT` without `WP_SECRETS_KEY` leaves the Secrets API present and the marker on the current network, and every read returns `WP_Error( 'secret_decryption_failed' )`. In that state: - - `Two_Factor_Totp::is_available_for_user()` returns **true**, correctly per SPEC 4.3: marker present, API present, same network, no decrypt. - - `is_enrolled_but_unavailable_for_user()` returns **false**. - - So `Two_Factor_Core::get_available_providers_for_user()` returns exactly `Two_Factor_Totp`, and no fallback is forced. A TOTP-only user gets the "currently unavailable" prompt with no code field and no alternative method. - - The user is locked out until an administrator resets them (for example with `wp two-factor disable <user> --yes`). They are not sent to the email fallback. The code fails closed, which is correct. The documentation promises a recovery path that does not exist. -- **What would break:** an administrator who trusts the FAQ may rotate salts without `WP_SECRETS_KEY`, expecting TOTP-only users to receive email codes. In fact every TOTP-only user is locked out. -- **Minimal fix:** - - Correct the readme FAQ sentence: TOTP-only users are locked out until an administrator resets their authenticator app, users with backup codes can still use them, and this is why `WP_SECRETS_KEY` is recommended. - - Scope the PR-description bullet to the cases that do force the fallback: the Secrets API is missing, or the marker names another network. State that an unreadable secret with the API present fails closed without a forced fallback. - - Add a behaviour-pinning test so the documented behaviour cannot drift again. - -### F2: category 5 (spec drift / incomplete procedure). "Export before removing the Secrets API" is undone by lazy migration. Task P4-03, and P5-01 for the FAQ. - -- **Where:** - - `readme.txt` FAQ, third paragraph: "Run `wp two-factor secrets export` before removing it…". - - `CLI/class-two-factor-cli-command.php` `secrets()` docblock example "Move all secrets back into user meta before removing the Secrets API". - - `docs/PR-DESCRIPTION.md` "Export" bullet. -- **What is wrong:** `export_user_totp_key()` writes plaintext meta and removes the marker. While the Secrets API stays active and `two_factor_use_secrets_api` returns true, the next login or profile view of that user runs `get_user_totp_key_state()`. That call sees non-empty plaintext with `can_write()` true and migrates the secret straight back (SPEC 4.4). Anyone who logs in between `export` and deactivation is re-migrated. After deactivation they are an affected user again, which is exactly what export was meant to prevent. SPEC 4.8 describes export as "reverse migration for decommissioning the Secrets API". The documented procedure does not achieve that on a live site. -- **What would break:** on a busy site, users who log in during the window lose their authenticator app when the Secrets API is removed. The admin notice and the critical Site Health result then fire, contradicting the admin's belief that export succeeded. -- **Minimal fix:** document the working order in the readme FAQ, the `secrets` command docblock and the PR description: - 1. Return `false` from `two_factor_use_secrets_api`, which stops migration while reads still work (SPEC 4.1). - 2. Run `wp two-factor secrets export`. - 3. Deactivate the Secrets API. - - Add a test pinning both halves: an exported user is re-migrated on the next read when the filter allows writes, and stays in plaintext when the filter returns `false`. +None. ## Spec issues -1. **SPEC goal 4 vs SPEC 4.3 for undecryptable secrets.** Goal 4 says "other providers (backup codes, email fallback) keep working". SPEC 4.3, however, requires `is_available_for_user()` to be true whenever the marker is present, the API is present and the network matches, without decrypting. The consequence is that a TOTP-only user whose secret fails to decrypt (salt rotation without `WP_SECRETS_KEY`, a broken drop-in on the same network, or a missing row: `two_factor_secret_missing`) has no forced email fallback and is locked out pending an admin reset. The implementation follows SPEC and fails closed, which is safe. Whether maintainers want a forced fallback here is worth adding to the PR's open questions. It would need a decrypt on the login path only, not on list screens. -2. **Provider self-registration versus the site-wide provider setting.** SPEC 4.5 requires the deletion hooks in `Two_Factor_Totp`'s constructor. When an administrator removes TOTP in Settings → Two-Factor, `two_factor_filter_enabled_providers` drops it from `two_factor_providers`, so the class is never loaded or instantiated. As a result: - - Deleting a migrated user leaves their ciphertext row orphaned. Uninstall still cleans it up, because `uninstall()` merges the default providers. - - The admin notice and Site Health test are not registered. - - `wp two-factor secrets <action>` fatals on `Two_Factor_Totp::count_users_by_storage()` or `Two_Factor_Totp::get_instance()` ("class not found"). The existing `status` command guards with `class_exists()`; the new subcommand does not. +1. **Undecryptable secrets with the API present (carried from round 1, now documented).** SPEC 4.3 requires `is_available_for_user()` to be true whenever the marker is present, the API is present and the network matches. So a TOTP-only user whose secret fails to decrypt is locked out pending an admin reset, rather than being forced onto the email fallback that SPEC goal 4 implies. The code fails closed, the docs now say so, and `docs/PR-DESCRIPTION.md` open question 6 asks maintainers whether to force a fallback here. +2. **TOTP disabled site-wide (carried from round 1, still unresolved).** If an administrator removes TOTP in Settings → Two-Factor, `two_factor_filter_enabled_providers` drops it from `two_factor_providers`, so `Two_Factor_Totp` is never loaded. Consequences: + - `wp two-factor secrets status|migrate|export` fatals on `Two_Factor_Totp::count_users_by_storage()` or `Two_Factor_Totp::get_instance()`. The existing `backup-codes generate` path (`CLI/class-two-factor-cli-command.php:578`) and `status` guard with `class_exists()`; the new subcommand does not. + - The deletion hooks, admin notice and Site Health test are not registered, so deleting a migrated user orphans that user's secret row. - This is an edge configuration and SPEC's own hook-placement rule produces it, so I have not raised it as a finding. It is worth a decision: either load the TOTP class for these paths regardless of the site-wide setting, or have the `secrets` subcommand exit with a clear `WP_CLI::error()`. + A human needs to choose between two fixes: + - Load the TOTP class for these data-management paths regardless of the site-wide setting. This is the better behaviour for an admin who wants to export before decommissioning. + - Add a `class_exists()` guard with a clear `WP_CLI::error()`. + + Either way, a test needs a seam, because the class cannot be unloaded in-process. I have not raised this as a fix task: SPEC's own self-registration rule produces the configuration, and the choice of remedy is not mine to make. ## Manual checks still owed (from HANDOFF.md, all NOT VERIFIED (human)) @@ -87,7 +72,7 @@ Categories 1 to 3 (constraints, boundaries, tests) are clean across the branch. - `gh repo set-default --view` shows the fork. - Phase 1: - A Dummy-only user logs in unchanged on the dev site. - - Read `class-two-factor-secrets.php` for any path that could echo or log a revealed value. I read it during this review and found none; a human check is still owed. + - Read `class-two-factor-secrets.php` for any path that could echo or log a revealed value. - Phase 2: - Enrol an authenticator app and confirm the `_wp_network_secret_two-factor/totp-<id>` option exists and the `_two_factor_totp_key` meta is empty. - Log in with an app code. @@ -109,11 +94,11 @@ Categories 1 to 3 (constraints, boundaries, tests) are clean across the branch. - Confirm CI is green. - Open the fork PR (base `master`, `ericmann/two-factor`) if the pipeline did not. - Rerun the Phase 2 and 3 browser checks on the final commit. +- Round 1 (R1-01): read the corrected FAQ text, and check that `wp help two-factor secrets` renders the new multi-line example comment cleanly. ## Notes -- Concurrency (SPEC 4.4): two logins migrating the same plaintext both succeed, because they write the same value and both read-backs match. There is one theoretical window. If request A's read-back returns a transient `WP_Error` just after request B completed a successful migration and deleted the plaintext, A's rollback (`delete_user_secret()`) removes B's good secret and the marker, leaving neither copy. This needs a transient read failure on an otherwise healthy store, so I am noting it rather than raising a task. A rollback that re-checks whether the plaintext still exists before deleting would close it. -- `delete_user_totp_key()` now returns `true` when there was nothing to delete, where it used to return `false` (listed in HANDOFF). No caller in the plugin uses the return value, and existing tests pass unchanged. -- CLI progress lines (`Would migrate user %d`, `User %d: %s`, the export warning) are not translated, while the errors and success messages are. That matches how some existing CLI output is handled, but it is inconsistent within the new subcommand. -- The admin notice says the Secrets API "is no longer available on this site". That is also shown for the wrong-network case on multi-network installs, where the wording is slightly inaccurate. -- The test classes were renamed to `Two_Factor_Secrets_Tests` and `Two_Factor_Totp_Secrets_Tests`, and the reason in HANDOFF (PHPCS file naming, and ordering relative to the core tests' `set_up_before_class`) holds up. TESTS.md documents the real names. +- The admin notice (`providers/class-two-factor-totp.php`, `admin_notice_secrets_api_missing()`) and the critical Site Health text still say "run `wp two-factor secrets export` before removing it" without the filter-first step R1-01 added to the FAQ. Both are shown only once the API is already unreachable, when export cannot run anyway (`secrets export` errors without the API). So the advice is moot in context, but it is less complete than the FAQ. +- The FAQ's "If the Secrets API is already gone, run `wp two-factor secrets migrate` after restoring it" is harmless. Restoring the API alone makes migrated users readable again; `migrate` only helps users who were enrolled in plaintext meanwhile. +- The `readme.txt` CLI bullet for `secrets export` ("for example before removing the Secrets API") does not repeat the filter-first step. The FAQ a few sections below does. +- The concurrency rollback window noted in round 1 (a transient read-back error after another request finished migrating) is unchanged and still theoretical. From f985a3a7a747095d2101348fc7e9989535f2de58 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:25:50 -0700 Subject: [PATCH 57/59] chore: build summary --- .foundry/state.json | 2 +- docs/SUMMARY.md | 131 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 docs/SUMMARY.md diff --git a/.foundry/state.json b/.foundry/state.json index 4fd01fc23..a06071d2c 100644 --- a/.foundry/state.json +++ b/.foundry/state.json @@ -3,7 +3,7 @@ "implemented": true, "reviewed": true, "verdict": "APPROVED", - "summarized": false, + "summarized": true, "halted": null, "preexistingUntracked": [], "policies": { diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..0e610df9c --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,131 @@ +# Build summary: TOTP secrets in the WordPress Secrets API + +**Merge line:** `build/2026-09-28`, base `8799d03` (on `feature/secrets-api-totp`) → head `fe547f2`, 55 commits (plus this summary). 22 tasks done (21 plan + 1 review fix), 0 blocked, 0 skipped. 2 review rounds; final verdict **APPROVED**. Not merged. The upstream-facing PR targets `master` on `ericmann/two-factor` and must be retargeted by hand (see Spec issues). + +## What was built + +**Phase 0: test environment (P0-01 to P0-03).** The wp-env tests environment now loads the `ericmann/secrets-api` v0.2.1 feature plugin with a fixed test-only `WP_SECRETS_KEY`. PHPStan gets stubs for the Secrets API functions, and the stub directory is excluded from PHPUnit. + +**Phase 1: storage adapter and provider base hooks (P1-01 to P1-03).** `Two_Factor_Secrets` is the only class that talks to the Secrets API. It stores secrets at network scope, and a user-meta marker (`_two_factor_totp_key_network`) records which network owns each secret. Reads are tri-state: a string, `null` for none, or a `WP_Error`. The provider base class gains `is_enrolled_but_unavailable_for_user()` and a bulk static `uninstall_user_data()`. Core now fails closed. If a user's only enrolled providers are enrolled but unavailable, core forces the fallback provider, or returns a `no_available_2fa_methods` error when there is no fallback. Before this change, the user would have logged in with only a password. + +**Phase 2: TOTP integration (P2-01 to P2-04).** TOTP keys are written to the Secrets API when it can write. Existing plaintext keys migrate lazily on read. An unreadable secret never authenticates: the login prompt shows an "unavailable" message instead of a code field, and the profile offers a reset. Deleting a user removes their secret row, and so does plugin uninstall. + +**Phase 3: downgrade detection (P3-01 to P3-03).** Administrators see a red, non-dismissible notice when migrated users exist but the Secrets API is missing (or, on multisite, when markers name another network). The affected-user lookup is cached for 300 s. A new Site Health test, `two_factor_totp_secret_storage`, reports critical, recommended or good. + +**Phase 4: WP-CLI (P4-01 to P4-04).** `wp two-factor status` now shows a storage field. A new `wp two-factor secrets` command adds three subcommands: +- `status`: storage counts, with `--format` support. +- `migrate`: moves plaintext keys into the Secrets API, with `--dry-run` and batching. +- `export`: moves secrets back into plaintext meta for decommissioning. It asks for confirmation unless `--yes` is given. + +**Phase 5: documentation (P5-01 to P5-04).** Updated readme.txt (FAQ, hooks, CLI) and CHANGELOG.md. AGENTS.md now says PHPStan level 5, and TESTS.md was updated. `docs/PR-DESCRIPTION.md` holds the upstream PR body, with SPEC 8's open questions copied verbatim plus open question 6. + +**Review fix (R1-01).** Corrected two claims in the readme FAQ, the `secrets` CLI docblock and the PR description: +- An undecryptable secret while the API is present locks a TOTP-only user out until an admin resets them. It does not force the fallback provider. +- Decommissioning must return false from `two_factor_use_secrets_api` *before* running `export`. Otherwise lazy migration moves exported users straight back into the Secrets API. + +Two tests now pin both behaviours. No PHP behaviour changed. + +## Decisions that shaped it + +- **Plan:** The phases are derived, because SPEC defines none: scaffold → adapter/base hooks → TOTP → downgrade detection → CLI → docs. +- **P1-02:** SPEC 4.6 assumed core already falls back for a registered-but-unavailable provider. It does not, so a generic base-class hook `is_enrolled_but_unavailable_for_user()` (default `false`) was added and core applies `two_factor_fallback_provider_for_user`. No TOTP logic lives in core. +- **P1-02:** SPEC 4.5's per-user `uninstall_user_data( $user_id )` became a bulk static `uninstall_user_data()` with no argument. The provider pages through its own users, and core calls it before deleting user meta. +- **P1-01:** The "API absent" test seam is an `@internal` filter, `two_factor_secrets_api_present`. It can only force `false`. +- **P1-01:** The seam for write failure and read-back mismatch is an `@internal` static, `Two_Factor_Secrets::$test_overrides` (keys `set`, `get`, `delete`, `writable`). It is needed because the Secrets API caches its provider and store per process. The shared test case clears it in `tear_down()`. +- **P1-01:** When the marker is present, the API is present and the network matches, but the API returns `null`, the adapter returns `WP_Error( 'two_factor_secret_missing' )` rather than `null`. The profile reset clears the stale marker. +- **P2-01:** `set_user_totp_key()` with an empty key calls `delete_user_totp_key()` instead, so an empty secret is never written. +- **P2-01:** `delete_user_totp_key()` returns `true` only when plaintext, marker and secret are all gone, including when there was nothing to delete. +- **P2-02:** The "unavailable" login prompt omits the generic "Enter the code" line. +- **P2-03:** The deletion hooks call a void wrapper, `delete_user_secrets_on_user_deletion()`, because PHPStan level 5 rejects bool-returning action callbacks. +- **P3-01:** When the API is present, affected-user detection runs only on multisite, and only looks for markers that name a different network. The result is cached in a site transient for 300 s. +- **P3-02:** When the API is present, `can_write()` is false and plaintext remains, Site Health reports `good` with "migration disabled" wording. This row is not in SPEC's table. With the API present and no plaintext users, the result is `good` and names the provider label, even when writes are disabled. +- **P4-01:** `Two_Factor_Secrets::provider_label()` returns `''` whenever the API is absent. Booleans in `secrets status` are the strings `'true'`/`'false'`. +- **P4-02:** `migrate` pages by ID. The offset advances only by the number of users in a batch that were not migrated, because migrated users drop out of the query. With `--dry-run` it advances by the full batch. `export` pages the same way. +- **P4-03:** If `export` verifies the plaintext but then fails to delete the Secrets API row, it still returns `true` and still removes the marker. The orphaned ciphertext row is accepted. +- **P5-01:** readme.md has no FAQ, hooks or CLI sections, so it was left unchanged. readme.txt keeps its CRLF line endings. +- **P5-03:** SPEC 8's open questions are not resolved in this build. They go verbatim into `docs/PR-DESCRIPTION.md`. +- **P0-01 / plan:** The run used branch `build/2026-09-28`, not SPEC's `build/secrets-api-totp`. The base is `feature/secrets-api-totp`, not `master`, and the author retargets by hand. +- **P1-01:** The test classes are `Two_Factor_Secrets_Tests` and `Two_Factor_Totp_Secrets_Tests`, not `Tests_Two_Factor_*`. PHPCS requires the file name to match the class, and the file sort order must not break the core tests' `set_up_before_class()` hooks. The filter parameter is named `$enabled` because PHPCS flags `$use`. +- **P1-02:** `resolve_fallback_provider_for_user()` takes an optional fourth by-reference argument, which keeps the existing `fallback_provider` error data byte-identical. The `Two_Factor_Dummy_Unavailable` fixture loads through the `two_factor_providers` filter. +- **R1-01:** Added open question 6 to the PR description: should an undecryptable secret force the fallback provider? + +## Assumptions still in play + +No `⚠️ ASSUMPTION` config keys were introduced. These constants were chosen without tuning and remain guesses: +- `Two_Factor_Totp::SECRET_SLUG = 'totp'` +- `Two_Factor_Totp::AFFECTED_USERS_CACHE_TTL = 300`: the notice and Site Health can lag by up to 5 minutes. +- `Two_Factor_CLI_Command::SECRETS_DEFAULT_BATCH_SIZE = 100` + +## Spec issues (edits to make to SPEC.md) + +1. **SPEC 4.6 relies on a fallback that does not exist** for registered-but-unavailable providers. Resolved in P1-02 with the new hook and core branch. SPEC should describe this mechanism. +2. **SPEC 4.5 `uninstall_user_data( $user_id )`** cannot be driven by core without TOTP knowledge. Change the SPEC to the bulk static form. +3. **Marker present but secret absent** is unspecified. Specify `WP_Error( 'two_factor_secret_missing' )`. +4. **The Site Health table** is missing the row "API present, cannot write, plaintext remains". Add it as `good` with migration-disabled wording. +5. **The "read-only provider" case in the `can_write()` matrix** has no Secrets API seam. Document the `$test_overrides['writable']` seam. +6. **PHPStan level:** SPEC 4.9 says 5, AGENTS.md said 2. AGENTS.md is now corrected to 5. +7. **Branch base:** SPEC 3 names `master`, but the build was cut from `feature/secrets-api-totp`. Align the SPEC or retarget by hand. +8. **readme.md mirror (SPEC 6):** readme.md has no matching sections. Drop the requirement. +9. **The test-only `WP_SECRETS_KEY` is public in `.wp-env.json`.** This is deliberate per SPEC 5. Note it explicitly. +10. **SPEC goal 4 conflicts with SPEC 4.3 for undecryptable secrets** (review rounds 1 and 2). SPEC 4.3 makes TOTP available whenever the marker, the API and the network all match, so a TOTP-only user whose secret fails to decrypt is locked out pending an admin reset instead of getting the email fallback. The code fails closed and the docs now say so. Decide whether to force a fallback (PR open question 6) and make the two SPEC sections consistent. +11. **TOTP disabled site-wide** (review rounds 1 and 2, **unresolved**). Removing TOTP in Settings → Two-Factor means `Two_Factor_Totp` is never loaded. As a result: + - `wp two-factor secrets status|migrate|export` **fatals** (class not found). There is no `class_exists()` guard, unlike `status` and `backup-codes generate`. + - The deletion hooks, admin notice and Site Health test are not registered. + - Deleting a migrated user orphans that user's secret row. + + Choose between two fixes: load the TOTP class for these data-management paths regardless of the setting, or add a `class_exists()` guard with a clear `WP_CLI::error()`. Either one needs a test seam. + +## Manual checks owed (all NOT VERIFIED, human) + +- **Phase 0:** + - A fresh clone with `npm install && npm run env start` clones `ericmann/secrets-api` v0.2.1 into the tests env. + - CI is green on the WP `latest`, `7.0-branch` and `7.1-branch` legs. + - `gh repo set-default --view` shows the fork. +- **Phase 1:** + - A Dummy-only user logs in unchanged on the dev site. + - Read `class-two-factor-secrets.php` for any path that could echo or log a revealed value. +- **Phase 2:** + - Enrol an authenticator app. The `_wp_network_secret_two-factor/totp-<id>` option should exist and the `_two_factor_totp_key` meta should be empty. + - Log in with an app code. + - Deactivate the feature plugin. Only the other methods should be offered. + - Re-activate with `WP_SECRETS_KEY` changed. The profile should show the reset notice. + - Delete the user. The option row should be removed. +- **Phase 3:** + - With a migrated user and the feature plugin off, an admin (not an editor) sees a red, non-dismissible notice. + - Site Health moves from critical to recommended to good. + - On multisite, a super admin sees the notice in Network Admin. +- **Phase 4:** + - `wp two-factor secrets status` works, including with `--format=json`. + - Run `migrate --dry-run`, then `migrate`. + - `status <user>` shows `secrets-api`. + - `export` prompts for confirmation, and `--yes` completes it. + - `wp help two-factor secrets` shows the OPTIONS block. +- **Phase 5:** + - Read `docs/PR-DESCRIPTION.md` and the readme FAQ for tone. + - Confirm CI is green. + - Open the fork PR (base `master`, `ericmann/two-factor`). + - Rerun the Phase 2 and 3 browser checks on the final commit. +- **R1-01:** + - Read the corrected FAQ text. + - Check that `wp help two-factor secrets` renders the multi-line example comment cleanly. + +## Review history + +- **Round 1:** CHANGES REQUESTED. 2 findings: + - F1: the salt-rotation lockout was misdocumented. + - F2: the export-before-decommission procedure was undone by lazy migration. + + Both led to 1 fix task (R1-01). 2 spec issues were raised. Not non-converging. +- **Round 2:** APPROVED. 0 findings, 0 fix tasks. No finding recurred: R1-01 does not reappear, and both round-1 findings are resolved. Mutation sampling killed all 5 mutants. **This was a notes-only approval.** `## Notes` flagged four things that were not queued as work: + - The admin notice and the critical Site Health text still say "run export before removing" without the filter-first step. This is moot in context, because export cannot run once the API is gone. + - The FAQ line "run `migrate` after restoring" is harmless but unnecessary. + - The readme.txt CLI bullet for `secrets export` omits the filter-first step, which the FAQ does include. + - A theoretical concurrency rollback window during migration read-back is unchanged. + +## Pipeline friction + +- controller / halt: The run halted because P0-01 was blocked at operator level: + 1. The permission classifier twice denied (as "Untrusted Code Integration") the commands that add the third-party GitHub plugin `ericmann/secrets-api#v0.2.1` to `.wp-env.json` and start wp-env. + 2. Verify commands could not start: `node_modules` was missing (npm-run-all, wp-env and phpcs not found; exit 127), so nothing could be tested. + + The operator needed to allow this dependency (or add a Bash permission rule) and run `npm install` and `composer install`. No source changes were made. From 0d52b80e872f65035c0a19937a698c93c553e15f Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:31:47 -0700 Subject: [PATCH 58/59] Keep TOTP secret lifecycle hooks when TOTP is disabled site-wide When TOTP was turned off in Settings -> Two-Factor the provider class was never loaded, so user deletion left stored secrets behind, the admin notice and Site Health test never registered, and wp two-factor secrets crashed with a missing class. Always load the class and register those hooks statically, without instantiating the provider (which would expose its REST routes while disabled). --- AGENTS.md | 2 + providers/class-two-factor-totp.php | 41 +++++++++++++------ tests/cli/class-two-factor-cli-command.php | 15 +++++++ .../class-two-factor-totp-secrets-tests.php | 35 +++++++++++++++- two-factor.php | 7 ++++ 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 023a1db15..590951230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ Each concrete provider registers its own hooks in its constructor: New providers should follow this pattern rather than registering hooks from outside the class. +The one exception is data that outlives the provider being enabled. `Two_Factor_Totp::register_secret_lifecycle_hooks()` registers user-deletion cleanup, the unreachable-secrets admin notice and the Site Health test statically, and `two-factor.php` always loads the TOTP class and calls it, so these keep working when an admin turns TOTP off in Settings → Two-Factor. + ### Key User Meta (constants on `Two_Factor_Core`) | Constant | Meta Key | Purpose | diff --git a/providers/class-two-factor-totp.php b/providers/class-two-factor-totp.php index 2bd7e583a..c24e73e4a 100644 --- a/providers/class-two-factor-totp.php +++ b/providers/class-two-factor-totp.php @@ -88,18 +88,33 @@ protected function __construct() { add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_two_factor_options' ) ); - add_filter( 'site_status_tests', array( $this, 'register_site_health_test' ) ); - add_action( 'admin_notices', array( $this, 'admin_notice_secrets_api_missing' ) ); - add_action( 'network_admin_notices', array( $this, 'admin_notice_secrets_api_missing' ) ); + parent::__construct(); + } + + /** + * Register the hooks that manage stored secrets for the lifetime of a user. + * + * These run whether or not TOTP is enabled on the site: a site that turns TOTP off + * after users enrolled still holds their secrets, and must still delete them with + * the user, warn about unreachable ones and report their storage in Site Health. + * They are static so that registering them does not instantiate the provider, + * which would expose its REST routes while it is disabled. + * + * @since 0.18.0 + * + * @return void + */ + public static function register_secret_lifecycle_hooks() { + add_filter( 'site_status_tests', array( __CLASS__, 'register_site_health_test' ) ); + add_action( 'admin_notices', array( __CLASS__, 'admin_notice_secrets_api_missing' ) ); + add_action( 'network_admin_notices', array( __CLASS__, 'admin_notice_secrets_api_missing' ) ); // On multisite, `delete_user` also fires when a user is only removed from one site, so wait for the network-level deletion. if ( is_multisite() ) { - add_action( 'wpmu_delete_user', array( $this, 'delete_user_secrets_on_user_deletion' ) ); + add_action( 'wpmu_delete_user', array( __CLASS__, 'delete_user_secrets_on_user_deletion' ) ); } else { - add_action( 'delete_user', array( $this, 'delete_user_secrets_on_user_deletion' ) ); + add_action( 'delete_user', array( __CLASS__, 'delete_user_secrets_on_user_deletion' ) ); } - - parent::__construct(); } /** @@ -790,8 +805,8 @@ public function delete_user_totp_key( $user_id ) { * * @return void */ - public function delete_user_secrets_on_user_deletion( $user_id ) { - $this->delete_user_totp_key( $user_id ); + public static function delete_user_secrets_on_user_deletion( $user_id ) { + self::get_instance()->delete_user_totp_key( $user_id ); } /** @@ -854,7 +869,7 @@ public static function clear_affected_users_cache() { * * @return void */ - public function admin_notice_secrets_api_missing() { + public static function admin_notice_secrets_api_missing() { $capability = is_network_admin() ? 'manage_network_options' : 'manage_options'; if ( ! current_user_can( $capability ) || ! self::has_affected_users() ) { @@ -884,10 +899,10 @@ public function admin_notice_secrets_api_missing() { * * @return array */ - public function register_site_health_test( $tests ) { + public static function register_site_health_test( $tests ) { $tests['direct']['two_factor_totp_secret_storage'] = array( 'label' => __( 'Authenticator app secret storage', 'two-factor' ), - 'test' => array( $this, 'site_health_secret_storage' ), + 'test' => array( __CLASS__, 'site_health_secret_storage' ), ); return $tests; @@ -925,7 +940,7 @@ public static function has_plaintext_users() { * * @return array */ - public function site_health_secret_storage() { + public static function site_health_secret_storage() { $result = array( 'label' => '', 'status' => 'good', diff --git a/tests/cli/class-two-factor-cli-command.php b/tests/cli/class-two-factor-cli-command.php index 703494dca..6fc5dd570 100644 --- a/tests/cli/class-two-factor-cli-command.php +++ b/tests/cli/class-two-factor-cli-command.php @@ -353,6 +353,21 @@ public function test_secrets_status_reports_present_api_and_counts() { $this->assertSame( array( 'api_present', 'provider', 'writable', 'filter_enabled', 'plaintext_users', 'migrated_users', 'affected_users' ), $this->last_format()['fields'] ); } + /** + * Secrets status works when TOTP is disabled site-wide. + */ + public function test_secrets_status_works_when_totp_disabled_site_wide() { + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + try { + $this->command->secrets( array( 'status' ), array() ); + } finally { + delete_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY ); + } + + $this->assertArrayHasKey( 'plaintext_users', $this->last_format()['items'][0] ); + } + /** * Secrets status reports an absent API. * diff --git a/tests/providers/class-two-factor-totp-secrets-tests.php b/tests/providers/class-two-factor-totp-secrets-tests.php index 30ce0628f..9c16bd3aa 100644 --- a/tests/providers/class-two-factor-totp-secrets-tests.php +++ b/tests/providers/class-two-factor-totp-secrets-tests.php @@ -815,13 +815,44 @@ public function test_wpmu_delete_user_removes_secret_on_multisite() { /** * The right deletion hook is registered. */ - public function test_constructor_registers_correct_deletion_hook() { - $callback = array( Two_Factor_Totp::get_instance(), 'delete_user_secrets_on_user_deletion' ); + public function test_registers_correct_deletion_hook() { + $callback = array( 'Two_Factor_Totp', 'delete_user_secrets_on_user_deletion' ); $this->assertNotFalse( has_action( is_multisite() ? 'wpmu_delete_user' : 'delete_user', $callback ) ); $this->assertFalse( has_action( is_multisite() ? 'delete_user' : 'wpmu_delete_user', $callback ) ); } + /** + * Lifecycle hooks stay registered, and still delete secrets, when TOTP is disabled site-wide. + */ + public function test_lifecycle_hooks_work_when_totp_disabled_site_wide() { + $this->require_secrets_api(); + $user_id = $this->user(); + $this->provider->set_user_totp_key( $user_id, 'ABCDEFGH' ); + + update_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY, array( 'Two_Factor_Email' ) ); + + try { + $this->assertArrayNotHasKey( 'Two_Factor_Totp', Two_Factor_Core::get_providers() ); + + $this->assertNotFalse( has_filter( 'site_status_tests', array( 'Two_Factor_Totp', 'register_site_health_test' ) ) ); + $this->assertNotFalse( has_action( 'admin_notices', array( 'Two_Factor_Totp', 'admin_notice_secrets_api_missing' ) ) ); + $this->assertNotFalse( has_action( 'network_admin_notices', array( 'Two_Factor_Totp', 'admin_notice_secrets_api_missing' ) ) ); + + if ( is_multisite() ) { + require_once ABSPATH . 'wp-admin/includes/ms.php'; + wpmu_delete_user( $user_id ); + } else { + require_once ABSPATH . 'wp-admin/includes/user.php'; + wp_delete_user( $user_id ); + } + + $this->assertNull( wp_get_network_secret( "two-factor/totp-{$user_id}" ) ); + } finally { + delete_option( Two_Factor_Core::ENABLED_PROVIDERS_OPTION_KEY ); + } + } + /** * Uninstall removes all secrets. */ diff --git a/two-factor.php b/two-factor.php index d23ca97c5..f83d3410e 100644 --- a/two-factor.php +++ b/two-factor.php @@ -54,6 +54,13 @@ */ require_once TWO_FACTOR_DIR . 'class-two-factor-secrets.php'; +/** + * The TOTP provider owns secrets that outlive its being enabled, so its lifecycle + * hooks are registered even when the site has turned TOTP off. + */ +require_once TWO_FACTOR_DIR . 'providers/class-two-factor-totp.php'; +Two_Factor_Totp::register_secret_lifecycle_hooks(); + // Load settings UI class so the settings page can be rendered. require_once TWO_FACTOR_DIR . 'settings/class-two-factor-settings.php'; From 4118f8d3bab2dd6e4dc873767ff8a6f51bf1c119 Mon Sep 17 00:00:00 2001 From: Eric Mann <eric.mann@automattic.com> Date: Mon, 28 Sep 2026 13:32:11 -0700 Subject: [PATCH 59/59] Update PR description: history, fail-closed rationale, disabled-TOTP fix --- docs/PR-DESCRIPTION.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/PR-DESCRIPTION.md b/docs/PR-DESCRIPTION.md index 3629880f5..f0d334cd4 100644 --- a/docs/PR-DESCRIPTION.md +++ b/docs/PR-DESCRIPTION.md @@ -1,13 +1,28 @@ # Store TOTP secrets with the WordPress Secrets API +Closes #455. Supersedes #819. + ## Motivation Two-Factor keeps every user's TOTP shared secret in plaintext user meta (`_two_factor_totp_key`, `Two_Factor_Totp::SECRET_META_KEY`). Anyone who can read the database, a backup, or a SQL injection dump can mint valid codes for every TOTP user. -An earlier attempt (PR #389, "recrypt") added homegrown XChaCha20 encryption keyed off `SECURE_AUTH_SALT`. It stalled because rotating the salt needed a bespoke re-encryption process, and it made Two-Factor responsible for cryptography and key management. +Two earlier attempts encrypted the secret inside Two-Factor itself: + +- #389 ("recrypt") used XChaCha20 keyed off `SECURE_AUTH_SALT`. It stalled because rotating the salt needed a bespoke re-encryption process. +- #819 used AES-256-GCM with opt-in `wp-config.php` keys. In review, @georgestephanis preferred an independent architecture that both Two-Factor and other plugins could share over each plugin building its own. #819 was closed so this integration could replace it. + +Both made Two-Factor responsible for cryptography and key management. The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (milestone 7.2), core PR WordPress/wordpress-develop#13759, and is available today as a feature plugin (`ericmann/secrets-api`, v0.2.1). This change uses it when present and changes nothing when it is not. +## Answering the fail-open question from #819 + +On #819, @georgestephanis asked whether a decryption failure after a bad or rotated key could fail open, leaving TOTP unenforced instead of blocking the login. Here it cannot, and that is deliberate: + +- **An unreadable secret never authenticates.** It is never treated as "TOTP not configured" and never silently re-enrolls the user. +- **Core had a separate fail-open path, which this PR closes.** Core forced the fallback provider only when a user's stored providers were *unregistered*. A provider that was registered but unusable produced an empty provider list, and the user then logged in with a password alone. The new generic hook `Two_Factor_Provider::is_enrolled_but_unavailable_for_user()` lets core force the fallback in that case, or refuse the login outright with `no_available_2fa_methods`. Regression tests cover a user whose only method is TOTP. +- **If the secret can't be decrypted while the Secrets API is present, the user is locked out, and that is intentional.** For example, salts were rotated without `WP_SECRETS_KEY`. The user stays locked out until an administrator resets their authenticator app (backup codes still work). We deliberately do not quietly move them to email, because a changed or broken key should be noticed, not papered over. + ## Design summary - **Adapter.** `Two_Factor_Secrets` (`class-two-factor-secrets.php`) is a static class and the only code that calls the Secrets API. Everything else goes through it. Presence is detected at call time, never when files load. @@ -27,6 +42,7 @@ The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (mil - `two_factor_secrets_migrated`, `two_factor_secrets_migration_failed` and `two_factor_secret_unavailable` actions. No hook ever receives a plaintext secret. - `two_factor_secrets_api_present` is an internal test seam that can only force "absent". - **Provider base class.** Two new generic hooks: `is_enrolled_but_unavailable_for_user()` and the static `uninstall_user_data()`. Core stays free of TOTP logic. +- **Works while TOTP is disabled.** Secrets outlive the provider being enabled, so `Two_Factor_Totp::register_secret_lifecycle_hooks()` registers user-deletion cleanup, the admin notice and the Site Health test statically. `two-factor.php` always loads the TOTP class and calls it. The provider itself is not instantiated, so its REST routes stay off while it is disabled. ## Migration and downgrade story @@ -60,4 +76,3 @@ The WordPress Secrets API removes that burden. It is tracked in Trac #66187 (mil 3. Naming the network-scope secrets `two-factor/totp-{ID}` puts one row per TOTP user in the options table. Is that acceptable on large networks, versus waiting for a user-scoped store in the Secrets API? 4. `Two_Factor_Provider::is_enrolled_but_unavailable_for_user()` is a generic core hook that lets any provider say "enrolled, but currently unusable", so core forces the fallback instead of failing open. Is that the right name and shape for a core-level concept? 5. `Two_Factor_Provider::uninstall_user_data()` is a bulk, static hook called once during uninstall for data held outside user meta. Would maintainers prefer a different name or a per-user shape? -6. Should a secret that is unreadable while the Secrets API is present also force the fallback provider, as a missing API does? Today the user is locked out until an administrator resets them.