Skip to content

Instantly share code, notes, and snippets.

@ericmann
Last active September 20, 2023 04:13
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ericmann/1e54625d81a35b243eec0d67ae7b868f to your computer and use it in GitHub Desktop.
Save ericmann/1e54625d81a35b243eec0d67ae7b868f to your computer and use it in GitHub Desktop.
Encrypted Options

Overview

Structure

These two files define a mu-plugin that will allow you to encrypt and decrypt specific options in your WordPress database. The base file (encrypted-options.php) defines the actions and filters needed to wire everything up such that get_option()/add_option()/update_option() all flow through the system.

The second file (Fortress.Options.php) must be renamed Options.php and placed in mu-plugins/Fortress/ in your WordPress installation.

Usage

You'll need to add whatever keys you want encrypted to the closure on line 15 otherwise the system will ignore everything. Any code that wants to get/add/uption options should be bound to init or a later action hook in WordPress' lifecycle.

Encryption Key

You will also need to define a FORTRESS_ENCRYPTION_KEY constant in your wp-config.php file for the system to work. This must be a random, hex-encoded, 32-byte string. The following PHP code will generate such a string for you:

<?php
$key = random_bytes(32);
echo bin2hex($key) . PHP_EOL;

Run this from the command line to get a random key:

$ php key.php
245cac1127afcc79a7cd4e94e61fb35ea5cd57afb752d374891dc8383461a618
<?php
/**
* Plugin Name: Encrypted Options
* Plugin URI:
* Description: Encrypt the values of specific options in the WordPress database.
* Author: Eric Mann
* Author URI: https://eamann.com
* Version: 1.0.0
*
* @package Fortress
*/
require_once dirname(__FILE__) . '/Fortress/Options.php';
add_filter('fortress_encrypted_options', function(array $options): array {
// Add whatever keys you want to encrypt to the $options array
return $options;
});
add_action('init', function() {
// Do something with your options here. Either get them or set them.
});
add_action('plugins_loaded', function() {
$options = apply_filters('fortress_encrypted_options', []);
foreach($options as $option) {
add_filter("option_{$option}", '\DisplaceTech\Fortress\Options\decrypt', 10, 2);
add_filter("pre_update_option_{$option}", '\DisplaceTech\Fortress\Options\upCrypt', 10, 3);
add_action("add_option_{$option}", '\DisplaceTech\Fortress\Options\addCrypt', 10, 2);
}
}, 999);
<?php
/**
* Encryption operations for working with WordPress options to store data
* in the options table. Not all options will be encrypted. You will need
* to wire up selection of options to be protected separately.
*
* @package Fortress
*/
namespace DisplaceTech\Fortress\Options;
/**
* Attempt to get the encryption key for the system. Will return an error
* on any encountered failure.
*
* @return string|\WP_Error
*/
function getCryptoKey()
{
if (!defined('FORTRESS_ENCRYPTION_KEY')) {
return new \WP_Error('KEYERROR', 'Invalid site configuration. Missing encryption key.');
}
$encoded = FORTRESS_ENCRYPTION_KEY;
$raw = hex2bin($encoded);
if (strlen($raw) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
return new \WP_Error('KEYERROR', 'Invalid site configuration. Encryption key is invalid.');
}
if ($raw === false) {
return new \WP_Error('KEYERROR', 'Invalid site configuration. Encryption key is invalid.');
}
return $raw;
}
/**
* Decrypt a single specific option based on the installation's encryption key.
*
* @param string $value
* @param string $option
*
* @return mixed
*/
function decrypt(string $value, string $option)
{
if (!is_string($value) || substr($value, 0, 8) !== 'fortress') {
return $value;
}
$value = substr($value, 8);
$key = getCryptoKey();
if (is_wp_error($key)) return $key;
$encrypted = hex2bin($value);
$nonce = substr($encrypted, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($encrypted, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plaintext = sodium_crypto_secretbox_open($cipher, $nonce, $key);
if (false === $plaintext) {
return new \WP_Error('CRYPTOERROR', sprintf('Invalid message authentication tag on %s option.', $option));
}
return maybe_unserialize($plaintext);
}
/**
* Encrypt the value of an option before we update it in the database.
*
* @param mixed $value
* @param mixed $old_value
* @param string $option
* @return string
*/
function upCrypt($value, $old_value, string $option): string
{
// Short circuit and return if nothing's changed
if ( $value === $old_value || maybe_serialize( $value ) === maybe_serialize( $old_value ) ) {
return $value;
}
$key = getCryptoKey();
if (is_wp_error($key)) return $key;
try {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
} catch (\Exception $e) {
return new \WP_Error('CRYPTOERROR', 'Unable to source enough entropy to create a random nonce.');
}
$plaintext = maybe_serialize($value);
try {
$cipher = sodium_crypto_secretbox($plaintext, $nonce, $key);
} catch (\SodiumException $e) {
return new \WP_Error('CRYPTOERROR', 'Error while encrypting the option payload');
}
$encrypted = $nonce . $cipher;
return 'fortress' . bin2hex($encrypted);
}
/**
* When we add an option, there are no default WordPress hooks that allow us to encrypt it.
* So we need to immediately update the option to the same value as we are adding by first
* changing it to some other value, then updating it _back_ to the value we actually want to
* store.
*
* @param string $option
* @param $value
*/
function addCrypt(string $option, $value)
{
delete_option($option);
update_option($option, $value, false);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment