<?php

namespace Blesta\Core\Util\Crypto;

use Configure;
use Exception;

/**
 * Passphrase-based authenticated encryption for client note bodies.
 *
 * Derives a key from a per-note passphrase via PBKDF2-HMAC-SHA256, encrypts with
 * AES-256-CBC, and authenticates with encrypt-then-HMAC (HMAC-SHA256). The passphrase
 * is never stored, so a body encrypted here is unrecoverable without it.
 *
 * The output is a self-describing, versioned blob safe to store in a text column:
 *
 *     BN1$<iterations>$<base64 salt>$<base64 iv>$<base64 hmac>$<base64 ciphertext>
 *
 * The iteration count is embedded so the configured cost can change over time without
 * breaking notes already encrypted under a different value.
 *
 * @package blesta
 * @subpackage core.Util.Crypto
 * @copyright Copyright (c) 2026, Phillips Data, Inc.
 * @license http://www.blesta.com/license/ The Blesta License Agreement
 * @link http://www.blesta.com/ Blesta
 */
class PassphraseCrypto
{
    /**
     * @var string The blob format version/prefix ("Blesta Note v1")
     */
    private $version = 'BN1';

    /**
     * @var int The PBKDF2 iteration count used when none is configured (OWASP 2023)
     */
    private $default_iterations = 600000;

    /**
     * @var int An upper bound on the iteration count accepted from a stored blob, to
     *  prevent a tampered/corrupt value from causing a pathologically expensive derivation
     */
    private $max_iterations = 10000000;

    /**
     * Encrypts the given plaintext under the given passphrase.
     *
     * @param string $plaintext The value to encrypt
     * @param string $passphrase The passphrase to derive the key from
     * @return string The versioned, self-describing cipher blob
     * @throws Exception If the passphrase is empty or encryption fails
     */
    public function encrypt(string $plaintext, string $passphrase): string
    {
        if ($passphrase === '') {
            throw new Exception('A passphrase is required to encrypt.');
        }

        $iterations = $this->iterations();
        $salt = random_bytes(16);
        $iv = random_bytes(16);

        $keys = hash_pbkdf2('sha256', $passphrase, $salt, $iterations, 64, true);
        $enc_key = substr($keys, 0, 32);
        $mac_key = substr($keys, 32, 32);

        $ciphertext = openssl_encrypt($plaintext, 'aes-256-cbc', $enc_key, OPENSSL_RAW_DATA, $iv);
        if ($ciphertext === false) {
            throw new Exception('Unable to encrypt the given value.');
        }

        $mac = hash_hmac('sha256', $this->version . $iterations . $salt . $iv . $ciphertext, $mac_key, true);

        return implode('$', [
            $this->version,
            $iterations,
            base64_encode($salt),
            base64_encode($iv),
            base64_encode($mac),
            base64_encode($ciphertext)
        ]);
    }

    /**
     * Decrypts a blob produced by self::encrypt() using the given passphrase.
     *
     * The iteration count embedded in the blob is used for the derivation, never the
     * currently-configured value, so notes remain readable after the config changes.
     *
     * @param string $blob The cipher blob to decrypt
     * @param string $passphrase The passphrase to derive the key from
     * @return string|false The decrypted plaintext, or false on a wrong passphrase or
     *  a malformed/corrupt/tampered blob
     */
    public function decrypt(string $blob, string $passphrase)
    {
        if (!$this->isEncrypted($blob)) {
            return false;
        }

        $parts = explode('$', $blob);
        if (count($parts) !== 6) {
            return false;
        }

        [$version, $iterations, $salt64, $iv64, $mac64, $ciphertext64] = $parts;

        $iterations = (int) $iterations;
        $salt = base64_decode($salt64, true);
        $iv = base64_decode($iv64, true);
        $mac = base64_decode($mac64, true);
        $ciphertext = base64_decode($ciphertext64, true);

        // Reject anything structurally invalid before doing expensive work
        if (
            $iterations < 1 || $iterations > $this->max_iterations
            || $salt === false || $iv === false || $mac === false || $ciphertext === false
            || strlen($salt) !== 16 || strlen($iv) !== 16
        ) {
            return false;
        }

        $keys = hash_pbkdf2('sha256', $passphrase, $salt, $iterations, 64, true);
        $enc_key = substr($keys, 0, 32);
        $mac_key = substr($keys, 32, 32);

        // Authenticate before decrypting; a wrong passphrase fails here
        $expected_mac = hash_hmac('sha256', $version . $iterations . $salt . $iv . $ciphertext, $mac_key, true);
        if (!hash_equals($expected_mac, $mac)) {
            return false;
        }

        // openssl_decrypt returns false on failure; propagate as false
        return openssl_decrypt($ciphertext, 'aes-256-cbc', $enc_key, OPENSSL_RAW_DATA, $iv);
    }

    /**
     * Determines whether the given value is a blob produced by this class.
     *
     * @param string|null $value The value to test
     * @return bool True if the value carries this class's version prefix
     */
    public function isEncrypted(?string $value): bool
    {
        $prefix = $this->version . '$';

        return $value !== null && strncmp($value, $prefix, strlen($prefix)) === 0;
    }

    /**
     * Returns the PBKDF2 iteration count to use for new encryptions, honoring the
     * Blesta.note_encryption_kdf_iterations config value and falling back to the default
     * when it is absent (e.g. an existing install whose config predates this feature).
     *
     * @return int The iteration count
     */
    private function iterations(): int
    {
        $configured = (int) Configure::get('Blesta.note_encryption_kdf_iterations');

        return $configured > 0 ? $configured : $this->default_iterations;
    }
}
