Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
8799d03
plan: derive build plan from SPEC
ericmann Sep 28, 2026
9a4a1c3
chore: start implementation run
ericmann Sep 28, 2026
adb3bdf
chore: run halted
ericmann Sep 28, 2026
e9e18d7
P0-01 (partial): load Secrets API feature plugin in tests env
ericmann Sep 28, 2026
ba6e745
foundry: clear operator halt
ericmann Sep 28, 2026
f54ffa6
P0-01: load Secrets API feature plugin in tests env
ericmann Sep 28, 2026
8a204c8
progress: P0-01 done
ericmann Sep 28, 2026
51fff02
P0-02: PHPStan stubs for the Secrets API
ericmann Sep 28, 2026
158fc6a
progress: P0-02 done
ericmann Sep 28, 2026
9136355
P0-03: push Phase 0 and record manual environment check
ericmann Sep 28, 2026
0abe3ad
progress: P0-03 done
ericmann Sep 28, 2026
07e94fb
P1-01: Two_Factor_Secrets adapter and shared secrets test case
ericmann Sep 28, 2026
ae8d03c
progress: P1-01 done
ericmann Sep 28, 2026
1d74d90
P1-02: provider unavailable/uninstall hooks and fail-closed core hand…
ericmann Sep 28, 2026
8d47283
progress: P1-02 done
ericmann Sep 28, 2026
9046c96
P1-03: push Phase 1 and record manual check
ericmann Sep 28, 2026
a96cfc7
progress: P1-03 done
ericmann Sep 28, 2026
234dc05
P2-01: TOTP tri-state read, Secrets API writes and lazy migration
ericmann Sep 28, 2026
ea66afe
progress: P2-01 done
ericmann Sep 28, 2026
f346ba4
P2-02: fail-closed TOTP validation, prompt, profile UI and lockout re…
ericmann Sep 28, 2026
5c2a24c
progress: P2-02 done
ericmann Sep 28, 2026
ec6277d
P2-03: user deletion hooks and uninstall cleanup of Secrets API entries
ericmann Sep 28, 2026
f67fb21
progress: P2-03 done
ericmann Sep 28, 2026
04e6053
P2-04: push Phase 2 and record manual check
ericmann Sep 28, 2026
f085230
progress: P2-04 done
ericmann Sep 28, 2026
3613cd1
P3-01: affected-user detection and administrator notice
ericmann Sep 28, 2026
fdb1c5f
progress: P3-01 done
ericmann Sep 28, 2026
866b1ca
P3-02: Site Health test two_factor_totp_secret_storage
ericmann Sep 28, 2026
eca12c4
progress: P3-02 done
ericmann Sep 28, 2026
8eea02d
P3-03: push Phase 3 and record manual check
ericmann Sep 28, 2026
583689a
progress: P3-03 done
ericmann Sep 28, 2026
60c5017
P4-01: wp two-factor status storage field and secrets status
ericmann Sep 28, 2026
c047f13
progress: P4-01 done
ericmann Sep 28, 2026
f5d6fbf
P4-02: wp two-factor secrets migrate
ericmann Sep 28, 2026
a3cdb74
progress: P4-02 done
ericmann Sep 28, 2026
89affc9
P4-03: wp two-factor secrets export
ericmann Sep 28, 2026
f691be8
progress: P4-03 done
ericmann Sep 28, 2026
3594837
P4-04: push Phase 4 and record manual check
ericmann Sep 28, 2026
986385b
progress: P4-04 done
ericmann Sep 28, 2026
814fcf8
P5-01: readme.txt and CHANGELOG.md
ericmann Sep 28, 2026
8ba2240
progress: P5-01 done
ericmann Sep 28, 2026
0f5d369
P5-02: AGENTS.md, TESTS.md and docblock audit
ericmann Sep 28, 2026
5e73d72
progress: P5-02 done
ericmann Sep 28, 2026
b869510
P5-03: upstream PR description
ericmann Sep 28, 2026
051e1f9
progress: P5-03 done
ericmann Sep 28, 2026
b3d571f
P5-04: push Phase 5 and record final manual check
ericmann Sep 28, 2026
5826028
progress: P5-04 done
ericmann Sep 28, 2026
f7618bb
chore: handoff for review
ericmann Sep 28, 2026
9dac25f
chore: round 0 implemented
ericmann Sep 28, 2026
c1e4a90
review: round 1
ericmann Sep 28, 2026
c99d5cf
chore: start review-fix round 1
ericmann Sep 28, 2026
b6e6c61
R1-01: Correct lockout and decommissioning docs for Secrets API storage
ericmann Sep 28, 2026
b8704fa
progress: R1-01 done
ericmann Sep 28, 2026
a006744
chore: handoff for review
ericmann Sep 28, 2026
70d6209
chore: round 1 implemented
ericmann Sep 28, 2026
fe547f2
review: round 2 approved
ericmann Sep 28, 2026
f985a3a
chore: build summary
ericmann Sep 28, 2026
0d52b80
Keep TOTP secret lifecycle hooks when TOTP is disabled site-wide
ericmann Sep 28, 2026
4118f8d
Update PR description: history, fail-closed rationale, disabled-TOTP fix
ericmann Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .foundry/feedback.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
33 changes: 33 additions & 0 deletions .foundry/state.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"round": 1,
"implemented": true,
"reviewed": true,
"verdict": "APPROVED",
"summarized": true,
"halted": null,
"preexistingUntracked": [],
"policies": {
"signing": "auto",
"push": true,
"pr": "draft",
"feedback": true
},
"signing": "on",
"rounds": [
{
"round": 1,
"fixTasks": 1,
"unblocked": 0,
"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"
}
]
}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
/npm-debug.log
.phpunit.result.cache*
phpstan.neon
.foundry/implement.lock
9 changes: 7 additions & 2 deletions .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
}
22 changes: 20 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -87,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 |
Expand All @@ -97,13 +100,28 @@ 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`)

| Constant | Option Key | Purpose |
|---|---|---|
| `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.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1 +1,67 @@
@AGENTS.md

<!-- foundry:begin -->
# 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
```
<ID>: <title>

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 -->
Loading