Skip to content

Store TOTP secrets with the WordPress Secrets API - #2

Draft
ericmann wants to merge 16 commits into
masterfrom
secrets-api-totp
Draft

ericmann wants to merge 16 commits into
masterfrom
secrets-api-totp

Conversation

@ericmann

@ericmann ericmann commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Store TOTP secrets with the WordPress Secrets API

Closes WordPress#455. Supersedes WordPress#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.

Two earlier attempts encrypted the secret inside Two-Factor itself:

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 WordPress#819

On WordPress#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.
  • 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.
    • 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.
    • 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

  • 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>] [--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>] [--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

  • 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?
Open WordPress Playground Preview

When the WordPress Secrets API is available (WordPress 7.2+, or the
secrets-api feature plugin), store each user's TOTP shared secret in it,
encrypted, instead of in plaintext user meta. Sites without the API
behave exactly as before.

- Two_Factor_Secrets adapter: the only caller of the Secrets API. Always
  uses network scope; detects the API at call time.
- Lazy, verified migration of existing plaintext secrets, plus
  `wp two-factor secrets status|migrate|export`.
- Fail closed: an unreadable secret never authenticates. Core now forces
  the fallback provider (or refuses login) when every enrolled provider
  is registered but unusable, instead of allowing a password-only login.
- Admin notice and Site Health test when migrated secrets become
  unreachable; lifecycle hooks keep working when TOTP is disabled.
- Opt out of writes and migration with the two_factor_use_secrets_api
  filter.

Closes WordPress#455. Supersedes WordPress#819.
--user is a WP-CLI global parameter: WP-CLI consumed it to switch the
current user and the command never saw it, so 'secrets export --user=X
--yes' exported every user's secret to plaintext instead of just X's.
Accept the user positionally, matching 'status <user>' and
'backup-codes generate <user>'.
@ericmann ericmann mentioned this pull request Sep 28, 2026
ericmann and others added 14 commits September 29, 2026 08:34
Two-Factor should never pull a secret back out of the Secrets API into
plaintext user meta. Remove Two_Factor_Totp::export_user_totp_key() and
the 'wp two-factor secrets export' action. The worst case after removing
the Secrets API is that affected users re-enrol their authenticator app:
an administrator resets it from the profile, which works while the API
is missing.

The admin notice and Site Health result now point to restoring the
Secrets API or resetting affected users instead of to export.
The dry run ended with "Dry run: Migrated 0, failed 0, skipped N", which
is accurate but reads as if nothing would happen. Report "Dry run: would
migrate N, skipped M. Nothing was changed." instead, and only list users
who actually hold a plaintext secret, so a dry run for a single user
without one is reported as a skip.
The Secrets API wraps one root key with the site key (WP_SECRETS_KEY).
If that key changes without the API's own rotation, the root key can't
be unwrapped and every read and write fails, including writes of brand
new secrets. Re-enrolling after an admin reset then returned a generic
500 "re-scan the QR code" db_error, and the reset itself had already
left the user with no second factor.

- TOTP setup now returns a 503 two_factor_secrets_key_unavailable that
  says to restore WP_SECRETS_KEY, or two_factor_secrets_write_failed for
  other Secrets API write failures. Nothing is stored, and there is
  still no plaintext fallback while the API is present.
- While the key is unusable, the profile says to restore the key and
  offers no reset, and the REST reset is refused, so the user stays
  enrolled and kept out instead of being left with a password alone.
- Two_Factor_Secrets::is_key_unavailable_error() keeps the Secrets API
  error code inside the adapter.
Storing TOTP secrets with the Secrets API was automatic whenever the API
was present. Because migration is one-directional and a changed secrets
key makes every stored secret unreadable, an administrator now has to
turn it on under Settings > Two-Factor and acknowledge that first.

The choice is a network option, since secrets are network scoped; on
multisite only a network administrator can change it. Until it is on,
nothing is written or migrated, Site Health recommends it with a link to
the settings screen, and `wp two-factor secrets migrate` refuses.
Turning it off stops new writes and migration only.

The two_factor_use_secrets_api filter now receives the administrator's
choice as its default, so it can turn storage off or force it on.
`wp two-factor secrets status` reports opted_in and enabled in place of
filter_enabled.
Changelogs are generated at release time.
The plugin requires PHP 7.4, so the adapter's methods now declare scalar
parameter and return types. Methods returning string|null|WP_Error or
true|WP_Error keep an untyped return, as 7.4 has no union types.

Two_Factor_Totp casts the user ID (and the key) where it calls the
adapter, so its long-standing loosely typed public methods keep accepting
what they did before.
The feature plugin was mapped into the tests environment by .wp-env.json.
It is now wordpress/secrets-api 0.2.1 in require-dev, and the test
bootstrap loads it from vendor/, so the tests no longer depend on the
wp-env plugin mapping.

The package is not on Packagist, so composer.json declares it as a
package repository pointing at the tagged zip. A vcs repository is not
usable here: the wp-env container has no git, and the GitHub API it
would fall back to is rate limited for unauthenticated CI runs.
The filter now receives whether the Secrets API is actually loaded as a
second argument. It still cannot force a missing API to count as present.
Two_Factor_Secrets was all static, so tests reached into it through a
public $test_overrides array and the two_factor_secrets_api_present
filter, and most of them needed the real Secrets API loaded.

The logic now lives in Two_Factor_Secrets_Manager, which is constructed
with a Two_Factor_Secrets_Store. Two_Factor_Secrets_Api_Store is the only
code that calls the Secrets API. Two_Factor_Secrets keeps every public
static method, forwarding to a shared manager, so callers are unchanged.

Tests install an in-memory store instead, which removes $test_overrides
and the filter and lets the secrets tests run without the feature plugin.
A small set of tests still exercises the real store.
All Secrets API handling for TOTP lived in Two_Factor_Totp, and the CLI
called that class by name, so a custom provider got none of it.

Providers now declare the user meta keys that hold secrets through the
static Two_Factor_Provider::user_secret_meta_keys(). The storage logic
(verified writes, lazy one-way migration, deletion, storage state) moves
into Two_Factor_Secrets_Manager, generalised over a slug and a meta key.
Two_Factor_Secrets_Lifecycle handles deletion with the user and on
uninstall, the unreachable-secrets notice, Site Health and the counts for
every declared secret, registered from Two_Factor_Core::add_hooks().

Two_Factor_Totp's public methods keep their signatures and become
wrappers. Its slug, marker key and secret name are unchanged, so nothing
already stored moves. Two_Factor_Totp::register_secret_lifecycle_hooks()
is removed.

Two_Factor_Core::get_provider_secrets() lists secrets from every provider
class, including ones turned off site-wide, without instantiating any.
A duplicate slug is refused.

wp two-factor secrets migrate covers every declared secret, and secrets
status prints one row per secret.
Let providers declare their secrets; move secrets logic behind an injectable store
Resolve the composer.lock conflict: keep master's phpstan 2.3.0 bump and
this branch's wordpress/secrets-api entry, and regenerate the content hash.
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for encrypting TOTP secrets

1 participant