<?php
/**
 * Truebe crypto helper: encrypt/decrypt secrets at rest using AES-256-GCM,
 * keyed from WordPress's own salts.
 *
 * Security stance: NO plaintext fallback, ever. If the environment cannot
 * encrypt, encrypt() throws — the caller must refuse to store the secret.
 * If a stored value cannot be decrypted (e.g. salts were rotated), decrypt()
 * signals failure distinctly from "empty" so the caller can prompt re-entry
 * instead of silently serving a broken state.
 */

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

if ( class_exists( 'Truebe_Crypto' ) ) {
	return;
}

class Truebe_Crypto {

	const CIPHER  = 'aes-256-gcm';
	const PREFIX  = 'tbenc1:'; // Encrypted-value marker + format version.
	const TAG_LEN = 16;        // GCM auth tag length in bytes.

	/** Sentinel returned by decrypt() when a token exists but cannot be decrypted. */
	const DECRYPT_FAILED = false;

	/**
	 * Can this environment encrypt? Requires the GCM cipher and at least one salt.
	 *
	 * @return bool
	 */
	public static function available() {
		if ( ! function_exists( 'openssl_encrypt' ) || ! function_exists( 'openssl_get_cipher_methods' ) ) {
			return false;
		}
		if ( ! in_array( self::CIPHER, openssl_get_cipher_methods(), true ) ) {
			return false;
		}
		return defined( 'SECURE_AUTH_KEY' ) || defined( 'AUTH_KEY' );
	}

	/**
	 * Derive a stable 256-bit key from WordPress salts.
	 *
	 * @return string Raw 32-byte key.
	 * @throws RuntimeException If no salt material is defined.
	 */
	private static function key() {
		$material = '';
		if ( defined( 'SECURE_AUTH_KEY' ) ) {
			$material .= SECURE_AUTH_KEY;
		}
		if ( defined( 'SECURE_AUTH_SALT' ) ) {
			$material .= SECURE_AUTH_SALT;
		}
		if ( '' === $material && defined( 'AUTH_KEY' ) ) {
			$material .= AUTH_KEY;
		}
		if ( '' === $material ) {
			throw new RuntimeException( 'Truebe: no WordPress salt material available for encryption.' );
		}
		return hash( 'sha256', 'truebe|' . $material, true );
	}

	/**
	 * Encrypt a plaintext string. Returns a prefixed base64 token.
	 * Empty input returns empty. NO plaintext fallback — throws if it cannot encrypt.
	 *
	 * @param string $plaintext
	 * @return string
	 * @throws RuntimeException If encryption is unavailable or fails.
	 */
	public static function encrypt( $plaintext ) {
		if ( '' === $plaintext ) {
			return '';
		}
		if ( ! self::available() ) {
			throw new RuntimeException( 'Truebe: encryption is not available on this host.' );
		}

		$iv  = random_bytes( openssl_cipher_iv_length( self::CIPHER ) );
		$tag = '';

		$ciphertext = openssl_encrypt(
			$plaintext,
			self::CIPHER,
			self::key(),
			OPENSSL_RAW_DATA,
			$iv,
			$tag,
			'',
			self::TAG_LEN
		);

		if ( false === $ciphertext ) {
			throw new RuntimeException( 'Truebe: encryption failed.' );
		}

		return self::PREFIX . base64_encode( $iv . $tag . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encoding ciphertext bytes for safe storage in an option, not obfuscation.
	}

	/**
	 * Decrypt a token from encrypt().
	 *
	 * @param string $stored
	 * @return string|false Plaintext on success; '' if the value was empty/never set;
	 *                      self::DECRYPT_FAILED (false) if a token exists but cannot be decrypted.
	 */
	public static function decrypt( $stored ) {
		if ( ! is_string( $stored ) || '' === $stored ) {
			return '';
		}
		if ( 0 !== strpos( $stored, self::PREFIX ) ) {
			// Not a Truebe token. Treat as failure rather than trusting unknown plaintext.
			return self::DECRYPT_FAILED;
		}
		if ( ! self::available() ) {
			return self::DECRYPT_FAILED;
		}

		$raw = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own stored ciphertext, not obfuscation.
		if ( false === $raw ) {
			return self::DECRYPT_FAILED;
		}

		$iv_len = openssl_cipher_iv_length( self::CIPHER );
		if ( strlen( $raw ) <= $iv_len + self::TAG_LEN ) {
			return self::DECRYPT_FAILED;
		}

		$iv         = substr( $raw, 0, $iv_len );
		$tag        = substr( $raw, $iv_len, self::TAG_LEN );
		$ciphertext = substr( $raw, $iv_len + self::TAG_LEN );

		try {
			$plaintext = openssl_decrypt(
				$ciphertext,
				self::CIPHER,
				self::key(),
				OPENSSL_RAW_DATA,
				$iv,
				$tag
			);
		} catch ( RuntimeException $e ) {
			return self::DECRYPT_FAILED;
		}

		return ( false === $plaintext ) ? self::DECRYPT_FAILED : $plaintext;
	}
}