<?php
/**
 * SocialPromoter private credential crypto.
 *
 * PLUGIN-PRIVATE ONLY. Encrypts and decrypts SocialPromoter's own service
 * credentials (OAuth access/refresh tokens for X, Facebook, LinkedIn) stored
 * in wp_options and read only by this plugin.
 *
 * Per the PublisherKits Crypto & Shared API Key Standard v1.0, this is a
 * plugin-unique class for private credentials only. AI access now runs through
 * the WordPress core AI Client (Settings > Connectors), so no AI key is stored
 * or encrypted by this plugin.
 *
 * @package PublisherKits_SocialPromoter
 */

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


/**
 * Class PKITS_SP_Crypto
 */
class PKITS_SP_Crypto {

	/**
	 * Marker prefix for the current ("v2") ciphertext format. A ':' is not a
	 * base64 character, so a legacy value can never begin with this, which lets
	 * decrypt() tell the two formats apart unambiguously.
	 *
	 * @var string
	 */
	const V2_PREFIX = 'v2:';

	/**
	 * Encrypt a plaintext value.
	 *
	 * New values use the full-strength 256-bit key and are tagged with the v2
	 * marker. Legacy values (written before this version) remain readable by
	 * decrypt(), so upgrading never forces a reconnect.
	 *
	 * @param string $value Plaintext to encrypt.
	 * @return string Encrypted string, or original value on failure.
	 */
	public static function encrypt( $value ) {
		if ( ! function_exists( 'openssl_encrypt' ) ) {
			return base64_encode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		}

		$iv     = openssl_random_pseudo_bytes( openssl_cipher_iv_length( 'AES-256-CBC' ) );
		$cipher = openssl_encrypt( $value, 'AES-256-CBC', self::get_key(), OPENSSL_RAW_DATA, $iv );

		if ( false === $cipher ) {
			return base64_encode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		}

		return self::V2_PREFIX . base64_encode( $iv . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
	}

	/**
	 * Decrypt an encrypted value.
	 *
	 * Handles both the current v2 format (strong key, fails loud) and the legacy
	 * format written before this version (old key), so existing stored
	 * credentials keep working without a reconnect.
	 *
	 * @param string $value Encrypted string.
	 * @return string Decrypted plaintext, or '' when a v2 value cannot be decrypted.
	 */
	public static function decrypt( $value ) {
		if ( empty( $value ) ) {
			return '';
		}

		// Current format: strong key, and fail LOUD (return '') rather than
		// emitting binary garbage — e.g. after an AUTH_KEY rotation — so callers
		// see "no credential" instead of posting with a corrupt token.
		if ( 0 === strpos( $value, self::V2_PREFIX ) ) {
			if ( ! function_exists( 'openssl_decrypt' ) ) {
				return '';
			}
			$raw = base64_decode( substr( $value, strlen( self::V2_PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
			if ( false === $raw ) {
				return '';
			}
			$iv_length = openssl_cipher_iv_length( 'AES-256-CBC' );
			$iv        = substr( $raw, 0, $iv_length );
			$cipher    = substr( $raw, $iv_length );
			$plain     = openssl_decrypt( $cipher, 'AES-256-CBC', self::get_key(), OPENSSL_RAW_DATA, $iv );
			return ( false === $plain ) ? '' : $plain;
		}

		// Legacy format (pre-1.50): base64( iv . cipher ) with the old key, or
		// plain base64 on openssl-less hosts. Read exactly as before.
		if ( ! function_exists( 'openssl_decrypt' ) ) {
			return base64_decode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		}

		$raw        = base64_decode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		$iv_length  = openssl_cipher_iv_length( 'AES-256-CBC' );
		$iv         = substr( $raw, 0, $iv_length );
		$cipher     = substr( $raw, $iv_length );
		$decrypted  = openssl_decrypt( $cipher, 'AES-256-CBC', self::legacy_key(), 0, $iv );

		if ( false === $decrypted ) {
			// Fallback: plain base64 decode (handles pre-openssl stored values).
			return base64_decode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		}

		return $decrypted;
	}

	/**
	 * Current encryption key: the full 256 bits of the salt digest (raw binary),
	 * matching the AES-256 cipher.
	 *
	 * @return string 32-byte binary key.
	 */
	private static function get_key() {
		return hash( 'sha256', self::key_salt(), true );
	}

	/**
	 * Legacy key for reading values stored before 1.50: the first 32 HEX
	 * characters of the digest (~128 bits effective). Kept solely for
	 * backward-compatible decryption; never used to encrypt.
	 *
	 * @return string 32-char key.
	 */
	private static function legacy_key() {
		return substr( hash( 'sha256', self::key_salt() ), 0, 32 );
	}

	/**
	 * Salt both keys derive from. Unchanged from prior versions so legacy values
	 * remain decryptable (changing it would force every account to reconnect).
	 *
	 * @return string
	 */
	private static function key_salt() {
		return ( defined( 'AUTH_KEY' ) && '' !== AUTH_KEY ) ? AUTH_KEY : 'pkits-sp-fallback-key-' . get_bloginfo( 'url' );
	}
}
