<?php
/**
 * Crypto — chiffrement at-rest des secrets stockés (mots de passe DB,
 * bind LDAP, client_secret OAuth).
 *
 * Algorithme : sodium_crypto_secretbox (XSalsa20 + Poly1305 AEAD)
 *   - 32 octets de clé symétrique stockée hors-DB dans securite/master.key
 *     (mode 0600, auto-générée à la première utilisation).
 *   - Nonce aléatoire de 24 octets par chiffrement.
 *   - Format de stockage : "enc_v1:" + base64(nonce ‖ ciphertext).
 *
 * Modèle de menace :
 *   - Vol du seul fichier database.sqlite : les secrets restent illisibles
 *     car la clé maître est dans un fichier séparé.
 *   - Vol du couple (sqlite + master.key) : compromission complète. Pour
 *     élever ce niveau il faudrait un HSM ou un KMS externe — hors-scope.
 *
 * Conséquence opérationnelle : master.key DOIT être sauvegardé en même temps
 * que database.sqlite. Le perdre rend les mots de passe irrécupérables.
 */
class Crypto
{
    private const PREFIX = 'enc_v1:';

    private static ?string $cachedKey = null;

    /**
     * Chiffre une chaîne. Une chaîne vide reste vide (rien à protéger).
     * Une chaîne déjà chiffrée est renvoyée telle quelle (idempotent).
     */
    public static function encrypt(string $plaintext): string
    {
        if ($plaintext === '')             return '';
        if (self::isEncrypted($plaintext)) return $plaintext;

        self::ensureSodium();
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ct    = sodium_crypto_secretbox($plaintext, $nonce, self::loadKey());
        return self::PREFIX . base64_encode($nonce . $ct);
    }

    /**
     * Déchiffre une chaîne. Renvoie null si le déchiffrement échoue
     * (clé incorrecte, payload corrompu, format invalide).
     * Une chaîne vide reste vide ; une chaîne non-chiffrée est renvoyée
     * telle quelle (rétro-compat avec les valeurs non encore migrées).
     */
    public static function decrypt(?string $stored): ?string
    {
        if ($stored === null || $stored === '') return $stored;
        if (!self::isEncrypted($stored))        return $stored;

        self::ensureSodium();
        $blob = base64_decode(substr($stored, strlen(self::PREFIX)), true);
        if ($blob === false || strlen($blob) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
            return null;
        }
        $nonce = substr($blob, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ct    = substr($blob, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        $pt = @sodium_crypto_secretbox_open($ct, $nonce, self::loadKey());
        return $pt === false ? null : $pt;
    }

    /**
     * Garde-fou : si l'extension PHP sodium n'est pas chargée, on échoue
     * avec un message clair plutôt qu'un cryptique « Undefined constant
     * SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ».
     *
     * Activation côté admin :
     *   - php.ini : décommenter `extension=sodium`
     *   - redémarrer Apache / php-fpm
     * Le DLL `php_sodium.dll` est livré avec PHP 8.x sous Windows
     * (`php/ext/php_sodium.dll`) ; sous Linux il est généralement
     * inclus dans le paquet php-* selon la distribution.
     */
    private static function ensureSodium(): void
    {
        if (function_exists('sodium_crypto_secretbox')) return;
        throw new RuntimeException(
            "PHP sodium extension required for at-rest encryption. "
            . "Enable 'extension=sodium' in php.ini and restart Apache/php-fpm."
        );
    }

    /** True si la chaîne porte le préfixe de version. */
    public static function isEncrypted(?string $value): bool
    {
        return is_string($value) && str_starts_with($value, self::PREFIX);
    }

    // -------------------------------------------------------------------------

    private static function keyPath(): string
    {
        return SECURITE_PATH . '/master.key';
    }

    private static function loadKey(): string
    {
        if (self::$cachedKey !== null) return self::$cachedKey;

        $path = self::keyPath();
        if (!is_file($path)) {
            self::generateKey($path);
        }

        $raw = @file_get_contents($path);
        if ($raw === false || strlen($raw) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
            throw new RuntimeException(
                'master.key missing, unreadable, or wrong length (' . $path . ')'
            );
        }
        self::$cachedKey = $raw;
        return $raw;
    }

    private static function generateKey(string $path): void
    {
        if (!is_dir(SECURITE_PATH)) {
            mkdir(SECURITE_PATH, 0750, true);
        }
        $key   = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
        $bytes = @file_put_contents($path, $key, LOCK_EX);
        if ($bytes === false) {
            throw new RuntimeException('Cannot write master.key at ' . $path);
        }
        @chmod($path, 0600);

        if (function_exists('sodium_memzero')) {
            sodium_memzero($key);
        }
    }
}
