Encrypt TOTP secrets at rest using AES-256-GCM#819
Encrypt TOTP secrets at rest using AES-256-GCM#819ericmann wants to merge 5 commits intoWordPress:masterfrom
Conversation
…#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>
|
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 Unlinked AccountsThe 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. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
@ericmann Thank you very much for the PR! Is there a reason we intentionally don’t migrate existing 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. |
|
Thanks for the review! A few clarifications: On encrypting without a constant defined — the constant is the encryption key. Without 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 On users who never log in — this is a fair point. We'll add a WP-CLI command (e.g. |
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>
|
Old PR as Reference #389 |
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>
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'ssodium_compat.wp-config.phpconstants:TWO_FACTOR_TOTP_ENCRYPTION_KEYandTWO_FACTOR_TOTP_ENCRYPTION_KEY_PREVIOUS_PREVIOUS, set a new_KEY, and secrets are re-encrypted on next readsodium_crypto_aead_aes256gcm_is_available()ensures graceful fallback to plaintext on systems without hardware supportFiles
New:
providers/class-two-factor-totp-secret.phpStatic utility class handling all encryption/decryption logic:
resolve($stored_value, $user_id)— main read path: decrypts, opportunistically encrypts plaintext, handles key rotationprepare_for_storage($plaintext, $user_id)— main write path: encrypts if key availableencrypt()/decrypt()— AES-256-GCM with$user_idas additional authenticated data (prevents usermeta row-swapping attacks)is_encryption_available()/is_encrypted()— detection helpers1::<hex_nonce>:<hex_ciphertext>New:
tests/providers/class-two-factor-totp-secret.php13 test cases covering:
is_encrypted()format detectionModified:
providers/class-two-factor-totp.phpget_user_totp_key()routes throughapply_filters( 'two_factor_totp_secret_resolve', ... )set_user_totp_key()routes throughapply_filters( 'two_factor_totp_secret_prepare', ... )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 decryptiontwo_factor_totp_secret_rotated($user_id)— after re-encryption during key rotationtwo_factor_totp_secret_decrypt_failed($user_id)— when decryption fails (useful for security monitoring)Setup
Add to
wp-config.php:Test Plan
@group encryptiontests → all 13 passTWO_FACTOR_TOTP_ENCRYPTION_KEYinwp-config.php, set up TOTP, verify DB value starts with1::_PREVIOUS, set new_KEY), log in, verify DB value re-encrypted🤖 Generated with Claude Code