2: e.g. smfw2:… (the prefix is the plugin's hook prefix) * * Legacy format (AES-256-CBC, no MAC, hex-string key) is still READ so values written before the * change keep working; every write produces v2. Callers that persist secrets should re-encrypt a * legacy value when they meet one (`isCurrent()` tells them apart). Nothing new is ever written in * the legacy format. */ class Encryption { /** * The hook prefix is rewritten per plugin by the prefixer, so the on-disk marker is * plugin-specific ("smpl2:", "icr2:"…) and a value never decrypts under the wrong plugin. */ private const PREFIX_BASE = 'softminal_cache_'; private const LEGACY_CIPHER = 'aes-256-cbc'; /** Marker that starts every value written in the current format. */ public static function prefix(): string { return rtrim(self::PREFIX_BASE, '_') . '2:'; } private static function keyMaterial(): string { if (!defined('AUTH_KEY') || !defined('SECURE_AUTH_KEY')) { throw new \Exception('WordPress security keys not defined'); } return AUTH_KEY . SECURE_AUTH_KEY; } /** 32 raw bytes for secretbox. */ private static function key(): string { return hash('sha256', self::keyMaterial(), true); } public static function encrypt(string $data): string { if (!function_exists('sodium_crypto_secretbox')) { throw new \Exception('libsodium is not available.'); } $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $box = sodium_crypto_secretbox($data, $nonce, self::key()); return self::prefix() . base64_encode($nonce . $box); } /** * @throws \Exception when the value is not a valid ciphertext for this site's keys — including * any bit flipped in a v2 value. */ public static function decrypt(string $data): string { if (self::isCurrent($data)) { return self::decryptCurrent($data); } return self::decryptLegacy($data); } /** Written in the current (authenticated) format. */ public static function isCurrent(string $data): bool { $prefix = self::prefix(); return strncmp($data, $prefix, strlen($prefix)) === 0; } /** * Looks like something encrypt() produced (either format) — as opposed to a plaintext value * stored before encryption existed. Cheap shape check only; decrypt() is the real test. */ public static function looksEncrypted(string $data): bool { if ($data === '') { return false; } if (self::isCurrent($data)) { return true; } $decoded = base64_decode($data, true); if ($decoded === false || strpos($decoded, '::') === false) { return false; } $iv = substr($decoded, strrpos($decoded, '::') + 2); return $iv !== '' && ctype_xdigit($iv) && strlen($iv) === 32; } private static function decryptCurrent(string $data): string { if (!function_exists('sodium_crypto_secretbox_open')) { throw new \Exception('libsodium is not available.'); } $raw = base64_decode(substr($data, strlen(self::prefix())), true); if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES) { throw new \Exception('Failed to decode encrypted data'); } $nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $box = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $plain = sodium_crypto_secretbox_open($box, $nonce, self::key()); if ($plain === false) { throw new \Exception('Decryption failed'); } return $plain; } /** AES-256-CBC values written by framework versions before 1.3.0. */ private static function decryptLegacy(string $data): string { if (!function_exists('openssl_decrypt')) { throw new \Exception('OpenSSL extension is not available.'); } $decoded = base64_decode($data, true); if ($decoded === false || strpos($decoded, '::') === false) { throw new \Exception('Failed to decode encrypted data'); } [$encrypted, $ivHex] = explode('::', $decoded, 2); $iv = ctype_xdigit($ivHex) ? hex2bin($ivHex) : false; if ($iv === false) { throw new \Exception('Failed to decode IV'); } // The legacy key was the hex STRING of the hash (OpenSSL used its first 32 characters). $decrypted = openssl_decrypt($encrypted, self::LEGACY_CIPHER, hash('sha256', self::keyMaterial()), 0, $iv); if ($decrypted === false) { throw new \Exception('Decryption failed'); } return $decrypted; } }