<?php
/**
 * BaseCloud Boost — Secret Encryption
 *
 * Authenticated encryption for sensitive option values (API tokens, keys, and
 * webhook secrets) so they are never stored in plaintext in the database.
 *
 * The encryption key is derived from this site's WordPress salts, so the
 * ciphertext is meaningless without the site's wp-config.php. libsodium is used
 * when available (bundled with PHP 7.2+), with an OpenSSL AES-256-GCM fallback.
 * If neither is available the value is stored as-is — never worse than before.
 *
 * @package BaseCloud\Boost\Core
 */

declare( strict_types=1 );

namespace BaseCloud\Boost\Core;

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

/**
 * Transparent encrypt/decrypt for stored secrets.
 */
class Crypto {

	/** Prefix marking a value as encrypted; the next token is the cipher method. */
	private const MARKER = 'bcb_enc::';

	/**
	 * Is this value one of our encrypted blobs?
	 *
	 * @param string $value Stored value.
	 * @return bool
	 */
	public static function is_encrypted( string $value ): bool {
		return strncmp( $value, self::MARKER, strlen( self::MARKER ) ) === 0;
	}

	/**
	 * Encrypt a plaintext secret. Returns the original string unchanged if no
	 * crypto extension is available, or if it is already encrypted/empty.
	 *
	 * @param string $plain Plaintext.
	 * @return string
	 */
	public static function encrypt( string $plain ): string {
		if ( $plain === '' || self::is_encrypted( $plain ) ) {
			return $plain;
		}

		$key = self::key();

		if ( function_exists( 'sodium_crypto_secretbox' ) ) {
			try {
				$nonce  = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
				$cipher = sodium_crypto_secretbox( $plain, $nonce, $key );
				return self::MARKER . 's::' . base64_encode( $nonce . $cipher );
			} catch ( \Throwable $e ) {
				// Fall through to OpenSSL.
			}
		}

		if ( function_exists( 'openssl_encrypt' ) ) {
			$iv     = random_bytes( 12 );
			$tag    = '';
			$cipher = openssl_encrypt( $plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
			if ( $cipher !== false ) {
				return self::MARKER . 'o::' . base64_encode( $iv . $tag . $cipher );
			}
		}

		// No crypto available — store plaintext (same exposure as pre-1.0.1).
		return $plain;
	}

	/**
	 * Decrypt a value produced by {@see encrypt()}. Returns an empty string if
	 * the value cannot be decrypted (e.g. the site salts have changed), so a bad
	 * secret is treated as "not set" rather than leaking ciphertext.
	 *
	 * @param string $value Stored value.
	 * @return string
	 */
	public static function decrypt( string $value ): string {
		if ( ! self::is_encrypted( $value ) ) {
			return $value;
		}

		$rest   = substr( $value, strlen( self::MARKER ) );
		$method = substr( $rest, 0, 1 );
		$blob   = base64_decode( substr( $rest, 3 ), true );
		if ( $blob === false ) {
			return '';
		}

		$key = self::key();

		if ( $method === 's' && function_exists( 'sodium_crypto_secretbox_open' ) ) {
			$nonce_len = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
			if ( strlen( $blob ) <= $nonce_len ) {
				return '';
			}
			$nonce  = substr( $blob, 0, $nonce_len );
			$cipher = substr( $blob, $nonce_len );
			try {
				$plain = sodium_crypto_secretbox_open( $cipher, $nonce, $key );
				return $plain === false ? '' : $plain;
			} catch ( \Throwable $e ) {
				return '';
			}
		}

		if ( $method === 'o' && function_exists( 'openssl_decrypt' ) ) {
			if ( strlen( $blob ) <= 28 ) { // 12-byte IV + 16-byte tag.
				return '';
			}
			$iv     = substr( $blob, 0, 12 );
			$tag    = substr( $blob, 12, 16 );
			$cipher = substr( $blob, 28 );
			$plain  = openssl_decrypt( $cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
			return $plain === false ? '' : $plain;
		}

		return '';
	}

	/**
	 * Mask a secret for display — only ever reveals that one is set, never its value.
	 *
	 * @param string $stored Raw stored (possibly encrypted) value.
	 * @return bool True when a non-empty secret is stored.
	 */
	public static function has_secret( string $stored ): bool {
		if ( $stored === '' ) {
			return false;
		}
		return self::is_encrypted( $stored ) ? self::decrypt( $stored ) !== '' : true;
	}

	/**
	 * Derive a 32-byte key from the site's WordPress salts.
	 *
	 * @return string Raw 32-byte key.
	 */
	private static function key(): string {
		return hash( 'sha256', wp_salt( 'auth' ) . '|bcboost', true );
	}
}
