<?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_vagaro_client_secret',
            'yatoon_deposit_stripe_secret',
            'yatoon_stripe_webhook_secret',
            'yatoon_square_webhook_signature_key',
            'yatoon_twilio_auth_token',
            'yatoon_whatsapp_access_token',
            'yatoon_webhook_secret',
            'yatoon_zoom_client_secret',
            'yatoon_google_cal_client_secret',
            'yatoon_google_cal_refresh_token',
            'yatoon_apple_private_key',
            'yatoon_ms_graph_client_secret',
            'yatoon_ms_graph_refresh_token',
            'yatoon_turnstile_secret_key',
            'yatoon_webhook_secret',
        );
    }

    /**
     * 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, falling back to v1 CBC on older PHP
     * builds without GCM support.
     *
     * 4.6.1: this used to `return $plain` when no cipher was usable --
     * "fail open rather than lose the credential". On a shared host with a
     * trimmed OpenSSL build, or one where disable_functions covers
     * openssl_encrypt, that wrote live Square access tokens, Stripe
     * sk_live_ keys and Twilio auth tokens into wp_options in plain text
     * while the settings screen still said "saved". Silent downgrade to no
     * encryption is worse than a visible failure, so this now returns false
     * and the caller refuses to store anything.
     *
     * @return string|false Encrypted value, or false if this server cannot
     *                      encrypt at all.
     */
    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 );
            }
        }

        // No usable cipher. Fail closed: the caller must not persist this.
        return false;
    }

    /**
     * True when this server can actually encrypt credentials.
     *
     * Used by the settings screens and the admin notice below so the failure
     * is visible before an administrator pastes a live API key into a field
     * that cannot protect it.
     */
    public static function encryption_available() {
        return self::cipher_available( self::CIPHER_V2 ) || self::cipher_available( self::CIPHER_V1 );
    }

    /**
     * True when the site defines its own salts in wp-config.php.
     *
     * The encryption key is derived from wp_salt(). When AUTH_KEY and friends
     * are not defined as constants, WordPress generates them and stores them
     * in wp_options -- which puts the key and the ciphertext in the same SQL
     * dump and defeats the database-only threat model this class documents.
     * Two sites cloned from one wp-config also share a key and can decrypt
     * each other's credentials.
     */
    public static function salts_are_configured() {
        foreach ( array( 'AUTH_KEY', 'AUTH_SALT', 'SECURE_AUTH_KEY', 'SECURE_AUTH_SALT' ) as $constant ) {
            if ( ! defined( $constant ) || '' === (string) constant( $constant )
                || false !== strpos( (string) constant( $constant ), 'put your unique phrase here' ) ) {
                return false;
            }
        }
        return true;
    }

    /**
     * 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, '' );
        }
        $encrypted = self::encrypt( $value );
        if ( false === $encrypted ) {
            // Record it so the admin notice can explain why the credential
            // did not stick, and leave whatever was stored before untouched.
            update_option( 'yatoon_crypto_unavailable', '1', true );
            if ( function_exists( 'yatoon_safe_log' ) ) {
                yatoon_safe_log( 'Yatoon: refused to store credential "' . $option . '" -- this server has no usable OpenSSL cipher, and storing it unencrypted is not acceptable.' );
            }
            return false;
        }
        if ( '1' === (string) get_option( 'yatoon_crypto_unavailable', '0' ) ) {
            delete_option( 'yatoon_crypto_unavailable' );
        }
        return update_option( $option, $encrypted );
    }

    /**
     * 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 );
    }

    /** Encrypt credentials saved by older releases without changing their value. */
    public static function migrate_plaintext_options() {
        foreach ( self::sensitive_keys() as $option ) {
            $stored = (string) get_option( $option, '' );
            if ( $stored !== '' && strpos( $stored, self::PREFIX_V1 ) !== 0 && strpos( $stored, self::PREFIX_V2 ) !== 0 ) {
                self::update_option( $option, $stored );
            }
        }
    }
}

add_action( 'admin_init', array( 'YATOON_Crypto', 'migrate_plaintext_options' ), 5 );

/**
 * 4.6.1: make credential-storage failures visible.
 *
 * Two separate problems, two separate notices. Both used to be silent, which
 * is how a site could run for months believing its API keys were encrypted.
 */
add_action( 'admin_notices', function () {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }

    if ( ! YATOON_Crypto::encryption_available() || '1' === (string) get_option( 'yatoon_crypto_unavailable', '0' ) ) {
        echo '<div class="notice notice-error"><p><strong>' .
            esc_html__( 'Yatoon: API credentials cannot be stored on this server.', 'yatoon-booking-system' ) .
            '</strong> ' .
            esc_html__( 'PHP has no usable OpenSSL cipher here, so payment and messaging credentials would have to be saved as plain text. Yatoon refuses to do that, which means Square, Stripe, Twilio and Google settings will not save. Ask your host to enable the OpenSSL extension (and to stop disabling openssl_encrypt / openssl_decrypt).', 'yatoon-booking-system' ) .
            '</p></div>';
        return;
    }

    if ( ! YATOON_Crypto::salts_are_configured() ) {
        $dismissed = '1' === (string) get_user_meta( get_current_user_id(), 'yatoon_salt_notice_dismissed', true );
        if ( $dismissed ) {
            return;
        }
        echo '<div class="notice notice-warning"><p><strong>' .
            esc_html__( 'Yatoon: your encryption keys live in the database.', 'yatoon-booking-system' ) .
            '</strong> ' .
            esc_html__( 'wp-config.php does not define AUTH_KEY / SECURE_AUTH_KEY, so WordPress generated them and stored them in wp_options. Yatoon derives its credential-encryption key from those salts, which means a database dump would contain both the encrypted API keys and the key that unlocks them. Generate salts at api.wordpress.org/secret-key/1.1/salt/ and paste them into wp-config.php.', 'yatoon-booking-system' ) .
            '</p></div>';
    }
} );
