Skip to content

Encrypt TOTP secrets at rest using AES-256-GCM#819

Open
ericmann wants to merge 5 commits intoWordPress:masterfrom
ericmann:feature/455-encrypt-totp-secrets
Open

Encrypt TOTP secrets at rest using AES-256-GCM#819
ericmann wants to merge 5 commits intoWordPress:masterfrom
ericmann:feature/455-encrypt-totp-secrets

Conversation

@ericmann
Copy link
Collaborator

@ericmann ericmann commented Mar 3, 2026

Summary

Closes #455. Adds encryption-at-rest for TOTP secrets stored in wp_usermeta (_two_factor_totp_key) using AES-256-GCM via WordPress core's sodium_compat.

  • Fully backward-compatible: without constants defined, behavior is completely unchanged
  • Opt-in via two wp-config.php constants: TWO_FACTOR_TOTP_ENCRYPTION_KEY and TWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS
  • Transparent key rotation: move current key to _PREVIOUS, set a new _KEY, and secrets are re-encrypted on next read
  • Opportunistic encryption: existing plaintext secrets are automatically encrypted when read (if a key is configured)
  • AES-NI hardware guard: sodium_crypto_aead_aes256gcm_is_available() ensures graceful fallback to plaintext on systems without hardware support

Files

New: providers/class-two-factor-totp-secret.php

Static utility class handling all encryption/decryption logic:

  • resolve($stored_value, $user_id) — main read path: decrypts, opportunistically encrypts plaintext, handles key rotation
  • prepare_for_storage($plaintext, $user_id) — main write path: encrypts if key available
  • encrypt() / decrypt() — AES-256-GCM with $user_id as additional authenticated data (prevents usermeta row-swapping attacks)
  • is_encryption_available() / is_encrypted() — detection helpers
  • Encrypted format: 1::<hex_nonce>:<hex_ciphertext>

New: tests/providers/class-two-factor-totp-secret.php

13 test cases covering:

  1. Plaintext passthrough without encryption key (backward compat)
  2. Opportunistic encryption on read
  3. Encrypt/decrypt roundtrip
  4. Key rotation with re-encryption
  5. Decryption failure returns empty string
  6. is_encrypted() format detection
  7. Encrypted output format validation
  8. Unique nonces per encryption
  9. Ciphertext bound to user ID (AD mismatch fails)
  10. Full set/get roundtrip with encryption
  11. TOTP code validation with encrypted secret
  12. Availability check with encrypted secret
  13. Availability check with decryption failure

Modified: providers/class-two-factor-totp.php

  • get_user_totp_key() routes through apply_filters( 'two_factor_totp_secret_resolve', ... )
  • set_user_totp_key() routes through apply_filters( 'two_factor_totp_secret_prepare', ... )
  • Default filter callbacks registered in the constructor

WordPress Hooks

Filters (in class-two-factor-totp.php)

  • two_factor_totp_secret_resolve — filters the TOTP secret after reading from DB (default: Two_Factor_Totp_Secret::resolve)
  • two_factor_totp_secret_prepare — filters the TOTP secret before writing to DB (default: Two_Factor_Totp_Secret::prepare_for_storage)

Actions (in class-two-factor-totp-secret.php)

  • two_factor_totp_secret_encrypted ($user_id) — after a secret is encrypted (on read or write)
  • two_factor_totp_secret_decrypted ($user_id, $needs_reencrypt) — after successful decryption
  • two_factor_totp_secret_rotated ($user_id) — after re-encryption during key rotation
  • two_factor_totp_secret_decrypt_failed ($user_id) — when decryption fails (useful for security monitoring)

Setup

Add to wp-config.php:

// Generate with: php -r "echo bin2hex(random_bytes(32));"
define( 'TWO_FACTOR_TOTP_ENCRYPTION_KEY', '<64 hex chars>' );

// Only needed during key rotation — set to the old key:
// define( 'TWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS', '<64 hex chars>' );

Test Plan

  • Run existing tests with no encryption constants → all pass (backward compat verified)
  • Run new @group encryption tests → all 13 pass
  • Manual: define TWO_FACTOR_TOTP_ENCRYPTION_KEY in wp-config.php, set up TOTP, verify DB value starts with 1::
  • Manual: log in with TOTP using encrypted secret, verify success
  • Manual: rotate key (move current to _PREVIOUS, set new _KEY), log in, verify DB value re-encrypted
  • Manual: remove all key constants with encrypted DB values, verify TOTP becomes unavailable for affected users

🤖 Generated with Claude Code

ericmann and others added 2 commits March 3, 2026 11:30
…#455)

Add encryption-at-rest for TOTP secrets stored in wp_usermeta using
AES-256-GCM via sodium_compat, controlled by TWO_FACTOR_TOTP_ENCRYPTION_KEY
and TWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS constants. Fully backward-
compatible: without constants defined, behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace direct static method calls with apply_filters for secret
resolve and prepare_for_storage, registered as default callbacks in
the constructor. Add do_action calls for encryption lifecycle events:
encrypted, decrypted, rotated, and decrypt_failed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 3, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @calvinalkan.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: calvinalkan.

Co-authored-by: ericmann <ericmann@git.wordpress.org>
Co-authored-by: masteradhoc <masteradhoc@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@masteradhoc masteradhoc added this to the 0.16.0 milestone Mar 3, 2026
@masteradhoc
Copy link
Collaborator

@ericmann Thank you very much for the PR!

Is there a reason we intentionally don’t migrate existing _two_factor_totp_key values as well? As i understand now without a constant defined we don't support an encryption. As well if existing users who don’t log in after enabling the constant will continue to have plaintext secrets indefinitely. Last point does make sense to me though.

Could we consider at least encrypting newly created secrets immediately (even without a constant set) and maybe offering an optional migration command for existing ones? Else the handling of logging in and upgrading it at this time would also be fine for me.

Curious about your insight here.

@ericmann
Copy link
Collaborator Author

ericmann commented Mar 3, 2026

Thanks for the review! A few clarifications:

On encrypting without a constant defined — the constant is the encryption key. Without TWO_FACTOR_TOTP_ENCRYPTION_KEY defined, there's no key material to encrypt with. We can't auto-generate one because it needs to live in wp-config.php, outside the database — if the key were stored alongside the encrypted data, encryption would provide no meaningful protection.

On the upgrade path for existing users — this is actually already handled. When a user with a plaintext secret logs in (or any code path calls get_user_totp_key()), resolve() detects the plaintext value, encrypts it, and updates the DB row transparently. So existing secrets are migrated opportunistically on read once a constant is configured.

On users who never log in — this is a fair point. We'll add a WP-CLI command (e.g. wp two-factor totp encrypt-secrets) to bulk-encrypt all remaining plaintext secrets for sites that want to close that gap immediately rather than waiting for organic logins.

ericmann and others added 2 commits March 3, 2026 12:53
Add `wp two-factor totp encrypt-secrets` command that encrypts all
plaintext TOTP secrets in the database. Supports --dry-run flag to
preview changes. Addresses the gap where users who never log in
would retain plaintext secrets indefinitely after enabling encryption.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add stub file for WP_CLI class and WP_CLI\Utils\get_flag_value so
PHPStan can analyse the CLI command file without errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@masteradhoc
Copy link
Collaborator

Old PR as Reference #389
WPORG Implementation see WordPress/wporg-mu-plugins#390

@masteradhoc masteradhoc requested a review from dd32 March 3, 2026 21:10
Document the new filters (two_factor_totp_secret_resolve,
two_factor_totp_secret_prepare), actions (encrypted, decrypted,
rotated, decrypt_failed), and the wp two-factor totp encrypt-secrets
WP-CLI command including key rotation instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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

2 participants