<?php
/**
 * Symmetric encryption helper.
 *
 * Used for license keys, AI API keys and any other secret we persist in
 * wp_options or wp_sitemeta. The cipher key is derived from WP_CONSTANTs
 * (AUTH_KEY etc.) so a stolen DB dump alone never leaks plaintext keys.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Crypto {

    /**
     * Encrypt a plaintext string with authenticated AES-256-GCM.
     * Returns "gcm:<b64(iv|tag|ct)>" — or "b64:<b64>" if OpenSSL is unavailable.
     * Legacy "enc:" (AES-256-CBC) payloads remain decryptable for back-compat.
     */
    public static function encrypt( $plain ) {
        $plain = (string) $plain;
        if ( '' === $plain ) {
            return '';
        }
        if ( ! function_exists( 'openssl_encrypt' ) ) {
            return 'b64:' . base64_encode( $plain );
        }
        $key = hash( 'sha256', self::secret(), true );
        $iv  = openssl_random_pseudo_bytes( 12 ); // 96-bit nonce, GCM standard
        $tag = '';
        $ct  = openssl_encrypt( $plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
        if ( false === $ct ) {
            return 'b64:' . base64_encode( $plain );
        }
        return 'gcm:' . base64_encode( $iv . $tag . $ct );
    }

    /**
     * Decrypt a previously encrypted payload.
     *
     * Returns the plaintext string on success, '' for empty input (nothing to
     * do), and **false** on any genuine decryption failure — e.g. a GCM
     * auth-tag mismatch after the WordPress salts were rotated, a corrupt/
     * truncated payload, or OpenSSL being unavailable for a cipher payload.
     * Callers can therefore distinguish "no secret stored" ('') from "a secret
     * is stored but unreadable" (false). See the note above self::secret().
     *
     * @param string $payload Stored ciphertext ("gcm:"/"enc:"/"b64:" prefixed).
     * @return string|false Plaintext, '' for empty input, or false on failure.
     */
    public static function decrypt( $payload ) {
        $payload = (string) $payload;
        if ( '' === $payload ) {
            return '';
        }
        if ( 0 === strpos( $payload, 'b64:' ) ) {
            return base64_decode( substr( $payload, 4 ), true ); // false on malformed input
        }
        if ( ! function_exists( 'openssl_decrypt' ) ) {
            return false;
        }
        $key = hash( 'sha256', self::secret(), true );

        // AES-256-GCM (current).
        if ( 0 === strpos( $payload, 'gcm:' ) ) {
            $raw = base64_decode( substr( $payload, 4 ), true );
            if ( false === $raw || strlen( $raw ) < 29 ) { // 12 iv + 16 tag + >=1 ct
                return false;
            }
            $iv  = substr( $raw, 0, 12 );
            $tag = substr( $raw, 12, 16 );
            $ct  = substr( $raw, 28 );
            $pt  = openssl_decrypt( $ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag );
            return false === $pt ? false : (string) $pt;
        }

        // AES-256-CBC (legacy payloads written before the GCM upgrade).
        if ( 0 === strpos( $payload, 'enc:' ) ) {
            $raw = base64_decode( substr( $payload, 4 ), true );
            if ( false === $raw || strlen( $raw ) < 17 ) {
                return false;
            }
            $iv = substr( $raw, 0, 16 );
            $ct = substr( $raw, 16 );
            $pt = openssl_decrypt( $ct, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
            return false === $pt ? false : (string) $pt;
        }

        return false;
    }

    /** Mask a secret for display: keep last 4 chars, the rest as bullets. */
    public static function mask( $secret ) {
        $secret = (string) $secret;
        $len    = mb_strlen( $secret );
        if ( $len <= 4 ) {
            return str_repeat( '•', $len );
        }
        return str_repeat( '•', max( 4, $len - 4 ) ) . mb_substr( $secret, -4 );
    }

    /**
     * Derive the cipher key from the site's WordPress salts (AUTH_KEY etc.).
     *
     * INTENTIONAL, DOCUMENTED TRADE-OFF: because the key is derived from the
     * salts in wp-config.php and is never stored, rotating those salts — or
     * restoring a database dump onto a site with different salts — makes every
     * previously stored secret (currently the AI API key) undecryptable. That
     * is by design: it means a stolen DB dump alone can never reveal the keys.
     * decrypt() reports this as `false` (not a fatal error); the affected
     * settings screen then asks the admin to re-enter the secret. This is not
     * a bug — see docs/nis2-checkliste.md ("Bekannte Einschränkung").
     */
    private static function secret() {
        $parts = array();
        foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'NONCE_KEY', 'AUTH_SALT' ) as $c ) {
            if ( defined( $c ) ) {
                $parts[] = constant( $c );
            }
        }
        if ( empty( $parts ) ) {
            $parts[] = wp_salt( 'auth' );
        }
        return implode( '|', $parts ) . '|seoforge-v1';
    }
}
