Files
peptide-news-plugin/includes/class-peptide-news-encryption.php
github-actions[bot] 102104f4dc Deploy b92dc4b from main
2026-04-12 21:22:34 +00:00

175 lines
5.3 KiB
PHP
Executable File

<?php
declare( strict_types=1 );
/**
* AES-256-CBC encryption for sensitive option values (API keys).
*
* Encrypts data at rest in wp_options using AES-256-CBC with WordPress
* auth salt as the key material. Transparent encrypt/decrypt API so
* callers don't need to know the cipher details.
*
* Triggered by: Admin settings save (class-peptide-news-admin.php),
* and read by any class that retrieves API keys.
*
* Dependencies: OpenSSL PHP extension (standard on all modern hosts).
*
* @since 2.3.0
* @see class-peptide-news-admin.php — Encrypts keys on save via sanitize callback.
* @see class-peptide-news-llm.php — Decrypts OpenRouter key before API calls.
* @see class-peptide-news-fetcher.php — Decrypts NewsAPI key before API calls.
* @see ARCHITECTURE.md — Security section documents encryption strategy.
*/
class Peptide_News_Encryption {
/**
* Cipher algorithm used for encryption.
*
* @var string
*/
const CIPHER = 'aes-256-cbc';
/**
* Encrypt a plaintext string.
*
* Returns a base64-encoded string containing the IV prepended to the
* ciphertext. Returns the original value if OpenSSL is unavailable or
* encryption fails (fail-open so the plugin still works on hosts
* without OpenSSL, just without at-rest encryption).
*
* @param string $plaintext The value to encrypt.
* @return string Encrypted value (base64) or original plaintext on failure.
*/
public static function encrypt( string $plaintext ): string {
if ( empty( $plaintext ) ) {
return '';
}
if ( ! self::is_available() ) {
return $plaintext;
}
$key = self::get_key();
$iv_length = openssl_cipher_iv_length( self::CIPHER );
$iv = openssl_random_pseudo_bytes( $iv_length );
$ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
if ( false === $ciphertext ) {
return $plaintext;
}
// Prepend IV to ciphertext and base64-encode for safe storage.
return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
}
/**
* Decrypt a previously encrypted string.
*
* Handles both encrypted (base64) and legacy plaintext values
* gracefully, so existing unencrypted keys continue to work
* during the migration period.
*
* @param string $encrypted The encrypted value (or legacy plaintext).
* @return string Decrypted plaintext.
*/
public static function decrypt( string $encrypted ): string {
if ( empty( $encrypted ) ) {
return '';
}
if ( ! self::is_available() ) {
return $encrypted;
}
// Detect legacy plaintext: if it doesn't look like base64, return as-is.
// Valid encrypted values are always base64 and at least 24 chars
// (16-byte IV + minimum 1-byte ciphertext, base64-encoded).
if ( ! self::looks_encrypted( $encrypted ) ) {
return $encrypted;
}
$raw = base64_decode( $encrypted, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
if ( false === $raw ) {
return $encrypted;
}
$key = self::get_key();
$iv_length = openssl_cipher_iv_length( self::CIPHER );
if ( strlen( $raw ) <= $iv_length ) {
// Too short to contain IV + ciphertext — treat as plaintext.
return $encrypted;
}
$iv = substr( $raw, 0, $iv_length );
$ciphertext = substr( $raw, $iv_length );
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
if ( false === $plaintext ) {
// Decryption failed — likely a legacy plaintext value that
// happened to look like base64. Return the original.
return $encrypted;
}
return $plaintext;
}
/**
* Check whether a stored value looks like it was encrypted by this class.
*
* Used to distinguish encrypted values from legacy plaintext API keys
* during the migration period. Encrypted values are base64-encoded
* and at least 24 characters long (16-byte IV + ciphertext).
*
* @param string $value The stored option value.
* @return bool True if the value appears to be encrypted.
*/
public static function looks_encrypted( string $value ): bool {
if ( strlen( $value ) < 24 ) {
return false;
}
// Must be valid base64.
if ( ! preg_match( '/^[A-Za-z0-9+\/]+=*$/', $value ) ) {
return false;
}
// API keys typically start with "sk-" or similar prefixes
// and contain characters that aren't valid base64.
// If it starts with a known API key prefix, it's plaintext.
$plaintext_prefixes = array( 'sk-', 'pk-', 'key-', 'Bearer ' );
foreach ( $plaintext_prefixes as $prefix ) {
if ( strpos( $value, $prefix ) === 0 ) {
return false;
}
}
return true;
}
/**
* Check whether the OpenSSL extension is available with the required cipher.
*
* @return bool
*/
public static function is_available(): bool {
return function_exists( 'openssl_encrypt' )
&& in_array( self::CIPHER, openssl_get_cipher_methods(), true );
}
/**
* Derive the encryption key from WordPress auth salt.
*
* Uses SHA-256 hash of the auth salt to ensure a consistent
* 32-byte key regardless of the salt's actual length.
*
* @return string 32-byte binary key.
*/
private static function get_key(): string {
// wp_salt('auth') returns the AUTH_KEY + AUTH_SALT concatenation.
// Hash it to get a consistent 32-byte key for AES-256.
return hash( 'sha256', wp_salt( 'auth' ), true );
}
}