<?php
/**
 * Yatoon Booking System - Credential Encryption Helper
 *
 * Wraps get_option()/update_option() for third-party API credentials
 * (Square, Stripe, Twilio, Google, Vagaro) so the values are stored
 * encrypted in wp_options rather than in plain text.
 *
 * This is defense-in-depth, not a replacement for site hardening: the
 * encryption key is derived from WordPress's own salts (AUTH_KEY /
 * SECURE_AUTH_KEY), so anyone with both database AND wp-config.php
 * access can still decrypt. What it protects against is the more
 * common case of database-only exposure (a stray SQL dump, a
 * misconfigured backup, a different plugin reading wp_options).
 *
 * Format history:
 * - "yenc:v2:" (current) - AES-256-GCM. Authenticated: a tampered or
 *   truncated ciphertext fails verification and decrypt() returns ''
 *   instead of silently returning garbage. This is the format used
 *   for every new encrypt() call.
 * - "yenc:v1:" (legacy) - AES-256-CBC, no authentication tag. Still
 *   readable so sites upgrading from 4.2.1 don't need a migration
 *   step; decrypt() detects the prefix and reads it accordingly. Any
 *   v1 value is re-encrypted as v2 automatically the next time it's
 *   saved through update_option().
 * - No prefix (legacy) - plain text from before this class existed.
 *   Returned as-is; also upgraded to v2 on next save.
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class YATOON_Crypto {

    const PREFIX_V2 = 'yenc:v2:';
    const CIPHER_V2 = 'aes-256-gcm';

    const PREFIX_V1 = 'yenc:v1:';
    const CIPHER_V1 = 'aes-256-cbc';

    /**
     * List of wp_options keys that hold third-party credentials and
     * should always be stored encrypted.
     */
    public static function sensitive_keys() {
        return array(
            'yatoon_square_access_token',
            'yatoon_vagaro_api_key',
            'yatoon_deposit_stripe_secret',
            'yatoon_stripe_webhook_secret',
            'yatoon_square_webhook_signature_key',
            'yatoon_twilio_auth_token',
            'yatoon_google_cal_client_secret',
            'yatoon_google_cal_refresh_token',
        );
    }

    /**
     * Derive a 32-byte encryption key from WordPress's own salts.
     * Cached per-request since wp_salt() involves a filter call.
     * Shared by both cipher versions - GCM and CBC both take a
     * 32-byte key for their 256-bit variants, and using the same
     * derivation keeps a single source of truth.
     */
    private static function key() {
        static $key = null;
        if ( $key === null ) {
            $material = wp_salt( 'auth' ) . '|' . wp_salt( 'secure_auth' ) . '|yatoon-credential-store';
            $key = hash( 'sha256', $material, true ); // 32 raw bytes
        }
        return $key;
    }

    private static function cipher_available( $cipher ) {
        return function_exists( 'openssl_encrypt' ) && function_exists( 'openssl_decrypt' )
            && in_array( $cipher, openssl_get_cipher_methods(), true );
    }

    /**
     * Encrypt a plain-text credential for storage using the current
     * (v2, AES-256-GCM) format. Returns the original value unchanged
     * if OpenSSL/GCM isn't available (fails open rather than losing
     * the credential) or falls back to v1 CBC if only that's present
     * (older PHP builds without GCM support).
     */
    public static function encrypt( $plain ) {
        $plain = (string) $plain;
        if ( $plain === '' ) {
            return $plain;
        }

        if ( self::cipher_available( self::CIPHER_V2 ) ) {
            $iv_len = openssl_cipher_iv_length( self::CIPHER_V2 );
            $iv     = openssl_random_pseudo_bytes( $iv_len );
            $tag    = '';
            $cipher = openssl_encrypt( $plain, self::CIPHER_V2, self::key(), OPENSSL_RAW_DATA, $iv, $tag );
            if ( $cipher !== false && $tag !== '' ) {
                return self::PREFIX_V2 . base64_encode( $iv . $tag . $cipher );
            }
        }

        // Fall back to the legacy authenticated-less CBC format only if
        // GCM genuinely isn't available on this server.
        if ( self::cipher_available( self::CIPHER_V1 ) ) {
            $iv_len = openssl_cipher_iv_length( self::CIPHER_V1 );
            $iv     = openssl_random_pseudo_bytes( $iv_len );
            $cipher = openssl_encrypt( $plain, self::CIPHER_V1, self::key(), OPENSSL_RAW_DATA, $iv );
            if ( $cipher !== false ) {
                return self::PREFIX_V1 . base64_encode( $iv . $cipher );
            }
        }

        return $plain; // no usable cipher on this server; fail open
    }

    /**
     * Decrypt a stored value. Understands both the current v2 (GCM)
     * format and the legacy v1 (CBC) format. Values with neither
     * prefix are assumed to be pre-encryption plain text and are
     * returned as-is.
     */
    public static function decrypt( $stored ) {
        $stored = (string) $stored;
        if ( $stored === '' ) {
            return $stored;
        }

        if ( strpos( $stored, self::PREFIX_V2 ) === 0 ) {
            return self::decrypt_v2( substr( $stored, strlen( self::PREFIX_V2 ) ) );
        }

        if ( strpos( $stored, self::PREFIX_V1 ) === 0 ) {
            return self::decrypt_v1( substr( $stored, strlen( self::PREFIX_V1 ) ) );
        }

        return $stored; // legacy plain text
    }

    private static function decrypt_v2( $b64 ) {
        if ( ! self::cipher_available( self::CIPHER_V2 ) ) {
            return ''; // can't decrypt on this server; fail closed for credentials
        }
        $raw = base64_decode( $b64, true );
        if ( $raw === false ) {
            return '';
        }
        $iv_len  = openssl_cipher_iv_length( self::CIPHER_V2 );
        $tag_len = 16; // GCM authentication tag is 16 bytes
        if ( strlen( $raw ) <= $iv_len + $tag_len ) {
            return '';
        }
        $iv     = substr( $raw, 0, $iv_len );
        $tag    = substr( $raw, $iv_len, $tag_len );
        $cipher = substr( $raw, $iv_len + $tag_len );
        $plain  = openssl_decrypt( $cipher, self::CIPHER_V2, self::key(), OPENSSL_RAW_DATA, $iv, $tag );
        // openssl_decrypt returns false if the tag doesn't verify, i.e.
        // the ciphertext was truncated or tampered with.
        return $plain === false ? '' : $plain;
    }

    private static function decrypt_v1( $b64 ) {
        if ( ! self::cipher_available( self::CIPHER_V1 ) ) {
            return '';
        }
        $raw = base64_decode( $b64, true );
        if ( $raw === false ) {
            return '';
        }
        $iv_len = openssl_cipher_iv_length( self::CIPHER_V1 );
        if ( strlen( $raw ) <= $iv_len ) {
            return '';
        }
        $iv     = substr( $raw, 0, $iv_len );
        $cipher = substr( $raw, $iv_len );
        $plain  = openssl_decrypt( $cipher, self::CIPHER_V1, self::key(), OPENSSL_RAW_DATA, $iv );
        return $plain === false ? '' : $plain;
    }

    /**
     * Read a credential option, transparently decrypting it.
     */
    public static function get_option( $option, $default = '' ) {
        $stored = get_option( $option, $default );
        return self::decrypt( $stored );
    }

    /**
     * Save a credential option, transparently encrypting it (always
     * using the current v2/GCM format, regardless of what format the
     * previous value was stored in).
     * Passing an empty string stores an empty string (does not
     * encrypt emptiness) so "is this configured?" checks still work
     * with a simple !== '' comparison.
     */
    public static function update_option( $option, $value ) {
        if ( $value === '' ) {
            return update_option( $option, '' );
        }
        return update_option( $option, self::encrypt( $value ) );
    }

    /**
     * Convenience: true if $option is one of the known sensitive keys.
     */
    public static function is_sensitive( $option ) {
        return in_array( $option, self::sensitive_keys(), true );
    }
}
