<?php

/**
 * Copyright 2024-2025 Simply.IN Sp. z o.o.
 *
 * NOTICE OF LICENSE
 *
 * Licensed under the EUPL-1.2 or later.
 * You may not use this work except in compliance with the Licence.
 *
 * Copy of the Licence is available at:
 * https://joinup.ec.europa.eu/software/page/eupl
 * It is bundled with this package in the file LICENSE.txt
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the Licence is distributed on an as is basis,
 * without warranties or conditions of any kind, either express or implied.
 * Check the Licence for the specific language governing permissions
 * and limitations under the License.
 *
 * @author   Simply.IN Sp. z o.o.
 * @copyright 2024-2025 Simply.IN Sp. z o.o.
 * @license   https://joinup.ec.europa.eu/software/page/eupl
 */

declare(strict_types=1);

namespace SimplyIn\Auth;

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

/**
 * Authenticated at-rest cipher for secrets the plugin must read back (the RSA private key, the
 * inbound webhook HMAC secret), keyed off a per-installation secret injected by the caller. Any
 * tampering (or a wrong key) fails decryption and returns '' rather than yielding corrupted key
 * material.
 *
 * Pure and framework-free (the key is injected) so it is unit-testable in isolation.
 */
final class SecretCipher
{
    private const CIPHER = 'aes-256-gcm';

    /** GCM nonce length in bytes (the standard 96-bit IV for AES-GCM). */
    private const IV_LENGTH = 12;

    /** GCM authentication tag length in bytes. */
    private const TAG_LENGTH = 16;

    /** Envelope tag: {@see self::PREFIX} . base64(iv . tag . ciphertext). */
    private const PREFIX = 'sinenc:v1:';

    /** 32-byte AES-256 key derived from the injected installation secret. */
    private readonly string $key;

    public function __construct(string $key)
    {
        $this->key = hash('sha256', $key, true);
    }

    /**
     * Encrypt a plaintext into the self-describing envelope. Returns '' when encryption is not
     * possible so callers never persist a value they cannot read back.
     */
    public function encrypt(string $plaintext): string
    {
        try {
            $iv = random_bytes(self::IV_LENGTH);
        } catch (\Throwable) {
            return '';
        }

        $tag = '';
        $ciphertext = openssl_encrypt($plaintext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag, '', self::TAG_LENGTH);
        if ($ciphertext === false) {
            return '';
        }

        return self::PREFIX . base64_encode($iv . $tag . $ciphertext);
    }

    /**
     * Decrypt a value produced by {@see self::encrypt()}. Returns '' for anything that is not a
     * well-formed, authentic envelope (unset config, legacy/plaintext value, tampered ciphertext,
     * or wrong key).
     */
    public function decrypt(string $envelope): string
    {
        if (!str_starts_with($envelope, self::PREFIX)) {
            return '';
        }
        $raw = base64_decode(substr($envelope, strlen(self::PREFIX)), true);
        if ($raw === false) {
            return '';
        }

        if (strlen($raw) <= self::IV_LENGTH + self::TAG_LENGTH) {
            return '';
        }

        $iv = substr($raw, 0, self::IV_LENGTH);
        $tag = substr($raw, self::IV_LENGTH, self::TAG_LENGTH);
        $ciphertext = substr($raw, self::IV_LENGTH + self::TAG_LENGTH);
        $plaintext = openssl_decrypt($ciphertext, self::CIPHER, $this->key, OPENSSL_RAW_DATA, $iv, $tag);

        return $plaintext === false ? '' : $plaintext;
    }
}
