<?php
/**
 * File Path: includes/api/class-mbs-encryption.php
 *
 * My Book Showroom — Encryption Helper
 *
 * AES-256-GCM authenticated encryption/decryption for sensitive data (license
 * keys, api_secret, PA-API credentials, ESP keys, CAPTCHA secrets). Uses
 * WordPress AUTH_KEY as key material. Random IV per encryption. GCM auth tag
 * prevents ciphertext tampering (unlike the legacy AES-256-CBC scheme).
 *
 * Backward compatibility: values encrypted before the SA sprint carry no "v2:"
 * prefix (CBC format). decrypt() handles both formats transparently.
 * maybe_migrate_to_gcm() re-encrypts all known options to GCM on first
 * admin_init after upgrade — this is a one-time, zero-downtime migration.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class MBS_Encryption {

	/**
	 * GCM IV length: 12 bytes (96-bit). NIST SP 800-38D recommended length.
	 * Shorter than CBC's 16 bytes; GCM is defined for 12-byte IVs natively.
	 */
	private const IV_LENGTH_GCM = 12;

	/**
	 * Legacy CBC IV length: 16 bytes (128-bit AES block size).
	 * Retained to decrypt values stored before the SA sprint upgrade.
	 */
	private const IV_LENGTH_CBC = 16;

	/**
	 * GCM authentication tag length: 16 bytes (128-bit). Maximum security.
	 */
	private const TAG_LENGTH = 16;

	/**
	 * Version prefix prepended to all GCM-encrypted values.
	 * Values without this prefix were encrypted with the legacy CBC scheme.
	 */
	private const GCM_PREFIX = 'v2:';

	/**
	 * wp_options key tracking whether the one-time CBC→GCM migration is done.
	 */
	private const MIGRATION_FLAG = 'mbs_enc_format';

	/**
	 * CRITICAL OPERATIONAL LIMITATION — AUTH_KEY ROTATION
	 *
	 * This class derives its encryption key material directly from wp_salt('auth') / AUTH_KEY.
	 * If AUTH_KEY in wp-config.php is ever rotated (security hardening, hosting migration,
	 * breach recovery, etc.), ALL previously encrypted values become permanently undecryptable:
	 *
	 *   - mbs_license_key_encrypted
	 *   - mbs_api_secret_encrypted
	 *   - mbs_setting_import_amazon_api_key_encrypted
	 *   - mbs_setting_import_amazon_api_secret_encrypted
	 *
	 * Consequences:
	 *   - License appears invalid → daily ping fails → PRO features disabled
	 *   - Amazon importer stops working silently
	 *   - No automatic recovery or key-versioning path currently exists
	 *
	 * Recommendation:
	 *   - Document this risk prominently to users
	 *   - Do NOT rotate AUTH_KEY casually
	 *   - Consider adding proper key versioning + re-encryption routine in a future sprint
	 *
	 * This is a known design limitation, not a bug in the current implementation.
	 */

	/**
	 * Encrypt a string using AES-256-GCM (authenticated encryption).
	 *
	 * Output format: "v2:" + base64( IV[12] || TAG[16] || ciphertext )
	 *
	 * The GCM auth tag detects any ciphertext tampering at decrypt time,
	 * closing the CBC bit-flipping vulnerability (PLG-SA-H5).
	 *
	 * @param string $data Plaintext to encrypt. Empty strings are rejected.
	 * @return string "v2:"-prefixed base64-encoded payload, or empty string on failure.
	 */
	public static function encrypt( string $data ): string {
		if ( empty( $data ) ) {
			return '';
		}

		// Explicit 32-byte key derivation (matches legacy — key material unchanged).
		$key_material = wp_salt( 'auth' );
		$key          = substr( hash( 'sha256', $key_material, true ), 0, 32 );
		$iv           = random_bytes( self::IV_LENGTH_GCM );
		$tag          = ''; // Populated by reference from openssl_encrypt().

		$ciphertext = openssl_encrypt(
			$data,
			'aes-256-gcm',
			$key,
			OPENSSL_RAW_DATA,
			$iv,
			$tag,      // @phpstan-ignore argument.type (out-param; string set by OpenSSL)
			'',        // Additional authenticated data (AAD) — not used.
			self::TAG_LENGTH
		);

		if ( false === $ciphertext ) {
			return '';
		}

		// Pack: IV (12) + TAG (16) + ciphertext, prefix with version marker.
		return self::GCM_PREFIX . base64_encode( $iv . $tag . $ciphertext );
	}

	/**
	 * Decrypt a string previously encrypted with encrypt().
	 *
	 * Supports both GCM ("v2:" prefix, current) and legacy CBC (no prefix) formats.
	 * CBC values should be migrated via maybe_migrate_to_gcm() at admin_init.
	 *
	 * B-009: Return value semantics — callers must use `!== false` to detect
	 * failure, not `empty()` or `strlen() > 0`. All current callers use
	 * `!== false` or `?: ''` correctly.
	 *
	 * @param string $data Encrypted value ("v2:"-prefixed GCM or legacy CBC base64).
	 * @return string|false Decrypted plaintext on success; false on any failure
	 *                      (bad tag, truncated IV, base64 error, wrong key).
	 */
	public static function decrypt( string $data ): string|false {
		if ( empty( $data ) ) {
			return false;
		}

		// Explicit 32-byte key derivation (must match encrypt()).
		$key_material = wp_salt( 'auth' );
		$key          = substr( hash( 'sha256', $key_material, true ), 0, 32 );

		// ── GCM path (current format) ──────────────────────────────────────────
		if ( str_starts_with( $data, self::GCM_PREFIX ) ) {
			$raw = base64_decode( substr( $data, strlen( self::GCM_PREFIX ) ), true );
			if ( false === $raw ) {
				return false;
			}

			$min_len = self::IV_LENGTH_GCM + self::TAG_LENGTH; // 28 bytes minimum.
			if ( strlen( $raw ) < $min_len ) {
				return false;
			}

			$iv         = substr( $raw, 0, self::IV_LENGTH_GCM );
			$tag        = substr( $raw, self::IV_LENGTH_GCM, self::TAG_LENGTH );
			$ciphertext = substr( $raw, $min_len );

			// openssl_decrypt() returns false if the tag does not match — tamper detected.
			return openssl_decrypt( $ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
		}

		// ── Legacy CBC path (backward compatibility) ───────────────────────────
		// Values stored before the SA sprint have no version prefix.
		// maybe_migrate_to_gcm() re-encrypts these on first admin_init after upgrade.
		$raw = base64_decode( $data, true );
		if ( false === $raw || strlen( $raw ) < self::IV_LENGTH_CBC ) {
			return false;
		}

		$iv        = substr( $raw, 0, self::IV_LENGTH_CBC );
		$encrypted = substr( $raw, self::IV_LENGTH_CBC );

		return openssl_decrypt( $encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
	}

	/**
	 * One-time migration: re-encrypt all known CBC-encrypted wp_options values
	 * to AES-256-GCM. Runs at admin_init (priority 1). No-op after first run.
	 *
	 * MAINTENANCE NOTE: This list must be updated whenever a new encrypt() call
	 * is added for a wp_options key. Missing an option here means it remains in
	 * the legacy CBC format until manually re-saved by the admin.
	 *
	 * @since 2.1.0 (SA sprint — PLG-SA-H5)
	 */
	public static function maybe_migrate_to_gcm(): void {
		if ( 'gcm' === get_option( self::MIGRATION_FLAG, 'cbc' ) ) {
			return; // Already migrated — fast path, one DB read.
		}

		$encrypted_options = array(
			// License credentials.
			'mbs_license_key_encrypted',
			'mbs_api_secret_encrypted',
			// Import API keys.
			'mbs_setting_import_amazon_api_key_encrypted',
			'mbs_setting_import_amazon_api_secret_encrypted',
			'mbs_setting_import_google_books_api_key_encrypted',
			// Payment webhook secrets.
			'mbs_setting_payment_stripe_webhook_secret_encrypted',
			'mbs_setting_payment_paypal_webhook_secret_encrypted',
			// Analytics ID (encrypted; no _encrypted suffix by historical convention).
			'mbs_setting_analytics_id',
			// ESP API keys.
			'mbs_setting_email_mailerlite_api_key',
			'mbs_setting_email_convertkit_api_key',
			'mbs_setting_email_brevo_api_key',
			'mbs_setting_email_sendy_api_key',
			'mbs_setting_email_activecampaign_api_key',
			'mbs_setting_email_getresponse_api_key',
			'mbs_setting_email_campaignmonitor_api_key',
			'mbs_setting_email_moosend_api_key',
			'mbs_setting_email_authoremail_api_key',
			// ESP account URLs (Sendy, ActiveCampaign).
			'mbs_setting_email_sendy_url',
			'mbs_setting_email_activecampaign_url',
			// CAPTCHA keys (6 values: 3 providers × site/secret).
			'mbs_setting_email_captcha_recaptcha_v3_site_key',
			'mbs_setting_email_captcha_recaptcha_v3_secret_key',
			'mbs_setting_email_captcha_hcaptcha_site_key',
			'mbs_setting_email_captcha_hcaptcha_secret_key',
			'mbs_setting_email_captcha_turnstile_site_key',
			'mbs_setting_email_captcha_turnstile_secret_key',
		);

		foreach ( $encrypted_options as $option ) {
			$stored = (string) get_option( $option, '' );

			// Skip: not yet set, or already GCM-encrypted.
			if ( '' === $stored || str_starts_with( $stored, self::GCM_PREFIX ) ) {
				continue;
			}

			// Decrypt via the legacy CBC path (no prefix → CBC branch in decrypt()).
			$plaintext = self::decrypt( $stored ); // R1-authorized: migration of credential storage — value not logged.
			if ( false !== $plaintext && '' !== $plaintext ) {
				update_option( $option, self::encrypt( $plaintext ), false );
			}
		}

		update_option( self::MIGRATION_FLAG, 'gcm', false );
	}
}

// One-time CBC→GCM migration runs at admin_init priority 1 (before most hooks).
// Subsequent requests skip in one get_option() read once the flag is set.
add_action( 'admin_init', array( 'MBS_Encryption', 'maybe_migrate_to_gcm' ), 1 );
