| 1 |
<?php |
| 2 |
/** |
| 3 |
* Encryption Utility. |
| 4 |
* |
| 5 |
* Utility for encrypting & storing sensitive data and retrieving decrypted data. |
| 6 |
* |
| 7 |
* @package StoreEngine\Utils |
| 8 |
* |
| 9 |
* @since 1.0.0-beta-6 |
| 10 |
* @version 1.0.1 |
| 11 |
*/ |
| 12 |
|
| 13 |
namespace StoreEngine\Utils; |
| 14 |
|
| 15 |
use Exception; |
| 16 |
use SodiumException; |
| 17 |
use StoreEngine\Classes\Exceptions\StoreEngineException; |
| 18 |
|
| 19 |
if ( ! defined( 'ABSPATH' ) ) { |
| 20 |
exit; |
| 21 |
} |
| 22 |
|
| 23 |
/** |
| 24 |
* Class Crypto |
| 25 |
* |
| 26 |
* Provides secure encryption and decryption using XChaCha20-Poly1305 AEAD (libsodium). |
| 27 |
* Uses a 256-bit key, with random nonces per message and base64-encoded output. |
| 28 |
*/ |
| 29 |
class Crypto { |
| 30 |
/** |
| 31 |
* Length of nonce for XChaCha20-Poly1305 (24 bytes). |
| 32 |
*/ |
| 33 |
private const NONCE_LEN = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; |
| 34 |
|
| 35 |
/** |
| 36 |
* Cached binary key to avoid repeated conversion from hex. |
| 37 |
* |
| 38 |
* @var string|null |
| 39 |
*/ |
| 40 |
private static ?string $key = null; |
| 41 |
|
| 42 |
/** |
| 43 |
* Generates a new 256-bit key for encryption (hex-encoded). |
| 44 |
* |
| 45 |
* @return string Hexadecimal-encoded encryption key. |
| 46 |
* @throws StoreEngineException |
| 47 |
*/ |
| 48 |
public static function generateKey(): string { |
| 49 |
try { |
| 50 |
return sodium_bin2hex( sodium_crypto_aead_xchacha20poly1305_ietf_keygen() ); |
| 51 |
} catch ( SodiumException |Exception $e ) { |
| 52 |
throw new StoreEngineException( esc_html( $e->getMessage() ), 'failed-to-generate-encryption-key' ); |
| 53 |
} |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* Retrieves and caches the encryption key from environment variables. |
| 58 |
* |
| 59 |
* @return string Binary-safe encryption key. |
| 60 |
* @throws StoreEngineException If the key is missing or invalid. |
| 61 |
*/ |
| 62 |
public static function getKey(): string { |
| 63 |
if ( ! self::$key ) { |
| 64 |
$hex = null; |
| 65 |
if ( function_exists( 'getenv' ) && getenv( 'STOREENGINE_ENCRYPTION_KEY' ) ) { |
| 66 |
try { |
| 67 |
$hex = sodium_hex2bin( getenv( 'STOREENGINE_ENCRYPTION_KEY' ) ); |
| 68 |
} catch ( Exception $e ) { |
| 69 |
throw new StoreEngineException( |
| 70 |
esc_html__( 'Failed to decode encryption key.', 'storeengine' ), |
| 71 |
'failed-to-decode-encryption-key', |
| 72 |
[ |
| 73 |
'error' => esc_html( $e->getMessage() ), |
| 74 |
'via' => 'getenv(STOREENGINE_ENCRYPTION_KEY)', |
| 75 |
], |
| 76 |
0, |
| 77 |
$e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 78 |
); |
| 79 |
} |
| 80 |
} elseif ( Constants::get_constant( 'STOREENGINE_ENCRYPTION_KEY' ) ) { |
| 81 |
try { |
| 82 |
$hex = sodium_hex2bin( Constants::get_constant( 'STOREENGINE_ENCRYPTION_KEY' ) ); |
| 83 |
} catch ( Exception $e ) { |
| 84 |
throw new StoreEngineException( |
| 85 |
esc_html__( 'Failed to decode encryption key.', 'storeengine' ), |
| 86 |
'failed-to-decode-encryption-key', |
| 87 |
[ |
| 88 |
'error' => esc_html( $e->getMessage() ), |
| 89 |
'via' => 'STOREENGINE_ENCRYPTION_KEY', |
| 90 |
], |
| 91 |
0, |
| 92 |
$e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 93 |
); |
| 94 |
} |
| 95 |
} elseif ( Constants::get_constant( 'SECURE_AUTH_KEY' ) ) { |
| 96 |
// Fallback to hashed SECURE_AUTH_KEY if no key is defined |
| 97 |
$hex = hash( 'sha256', Constants::get_constant( 'SECURE_AUTH_KEY' ), true ); |
| 98 |
} |
| 99 |
|
| 100 |
if ( ! $hex ) { |
| 101 |
throw new StoreEngineException( esc_html__( 'Encryption key is missing.', 'storeengine' ), 'encryption-key-missing' ); |
| 102 |
} |
| 103 |
|
| 104 |
self::$key = $hex; |
| 105 |
} |
| 106 |
|
| 107 |
return self::$key; |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Encrypts the given data using XChaCha20-Poly1305. |
| 112 |
* |
| 113 |
* @param string|int|float|bool|array|object $data |
| 114 |
* The data to encrypt. Arrays/objects are JSON-encoded. |
| 115 |
* |
| 116 |
* @return string Base64-encoded ciphertext (nonce + encrypted data). |
| 117 |
* @throws StoreEngineException |
| 118 |
*/ |
| 119 |
public static function encrypt( $data ): string { |
| 120 |
try { |
| 121 |
// Serialize arrays/objects |
| 122 |
$data = maybe_serialize( $data ); |
| 123 |
|
| 124 |
// Generate a unique random nonce per encryption |
| 125 |
$nonce = random_bytes( self::NONCE_LEN ); |
| 126 |
|
| 127 |
// Perform authenticated encryption (AEAD), without associated data |
| 128 |
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( $data, '', $nonce, self::getKey() ); |
| 129 |
|
| 130 |
// Return base64 encoded (nonce + ciphertext) |
| 131 |
return base64_encode( $nonce . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 132 |
} catch ( Exception $e ) { |
| 133 |
throw new StoreEngineException( |
| 134 |
esc_html__( 'Failed to encrypt data.', 'storeengine' ), |
| 135 |
'failed-to-encrypt-data', |
| 136 |
[ 'error' => esc_html( $e->getMessage() ) ], |
| 137 |
0, |
| 138 |
$e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 139 |
); |
| 140 |
} |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* Decrypts data encrypted with encrypt(). |
| 145 |
* |
| 146 |
* @param ?string $data Base64-encoded string (nonce + ciphertext). |
| 147 |
* |
| 148 |
* @return string|array|false The decrypted value, auto-decoded from JSON if applicable. False on failure. |
| 149 |
* @throws StoreEngineException |
| 150 |
*/ |
| 151 |
public static function decrypt( ?string $data ) { |
| 152 |
try { |
| 153 |
if ( ! $data ) { |
| 154 |
return false; |
| 155 |
} |
| 156 |
|
| 157 |
// Decode base64 input |
| 158 |
$decoded = base64_decode( $data, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 159 |
|
| 160 |
if ( false === $decoded ) { |
| 161 |
return $data; // Maybe not encrypted. |
| 162 |
} |
| 163 |
|
| 164 |
if ( strlen( $decoded ) <= self::NONCE_LEN ) { |
| 165 |
return false; // Maybe not encrypted data. |
| 166 |
} |
| 167 |
|
| 168 |
// Extract nonce and ciphertext |
| 169 |
$nonce = substr( $decoded, 0, self::NONCE_LEN ); |
| 170 |
$ciphertext = substr( $decoded, self::NONCE_LEN ); |
| 171 |
|
| 172 |
// Decrypt the ciphertext, without associated data. |
| 173 |
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( $ciphertext, '', $nonce, self::getKey() ); |
| 174 |
|
| 175 |
return maybe_unserialize( $plaintext ); |
| 176 |
} catch ( Exception $e ) { |
| 177 |
throw new StoreEngineException( |
| 178 |
esc_html__( 'Failed to decrypt data.', 'storeengine' ), |
| 179 |
'failed-to-decrypt-data', |
| 180 |
[ 'error' => esc_html( $e->getMessage() ) ], |
| 181 |
0, |
| 182 |
$e // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped |
| 183 |
); |
| 184 |
} |
| 185 |
} |
| 186 |
|
| 187 |
/** |
| 188 |
* Retrieves the value of a transient. |
| 189 |
* |
| 190 |
* If the transient does not exist, does not have a value, or has expired, |
| 191 |
* then the return value will be false. |
| 192 |
* |
| 193 |
* @param string $transient Transient name. Expected to not be SQL-escaped. |
| 194 |
* |
| 195 |
* @return array|false|string Value of transient. |
| 196 |
* @throws StoreEngineException |
| 197 |
*/ |
| 198 |
public static function get_transient( string $transient ) { |
| 199 |
$value = get_transient( $transient ); |
| 200 |
if ( false === $value ) { |
| 201 |
return false; |
| 202 |
} |
| 203 |
|
| 204 |
return self::decrypt( $value ); |
| 205 |
} |
| 206 |
|
| 207 |
/** |
| 208 |
* Sets/updates the value of a transient. |
| 209 |
* |
| 210 |
* You do not need to serialize values. If the value needs to be serialized, |
| 211 |
* then it will be serialized before it is set. |
| 212 |
* |
| 213 |
* @param string $transient Transient name. Expected to not be SQL-escaped. |
| 214 |
* Must be 172 characters or fewer in length. |
| 215 |
* @param mixed $value Transient value. Must be serializable if non-scalar. |
| 216 |
* Expected to not be SQL-escaped. |
| 217 |
* @param int $expiration Optional. Time until expiration in seconds. Default 0 (no expiration). |
| 218 |
* |
| 219 |
* @return bool True if the value was set, false otherwise. |
| 220 |
* @throws StoreEngineException |
| 221 |
*/ |
| 222 |
public static function set_transient( string $transient, $value, int $expiration = 0 ): bool { |
| 223 |
return set_transient( $transient, self::encrypt( $value ), $expiration ); |
| 224 |
} |
| 225 |
} |
| 226 |
|
| 227 |
// End of file crypto.php |
| 228 |
|