<?php declare(strict_types=1);
/*
 * This file is part of the WebPush library.
 *
 * (c) Louis Lagrange <lagrange.louis@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Minishlink\WebPush;

use Base64Url\Base64Url;
use Jose\Component\Core\JWK;
use Jose\Component\Core\Util\Ecc\PrivateKey;
use Jose\Component\Core\Util\ECKey;

class Encryption
{
    public const MAX_PAYLOAD_LENGTH = 4078;
    public const MAX_COMPATIBILITY_PAYLOAD_LENGTH = 2820;

    /**
     * @return string padded payload (plaintext)
     * @throws \ErrorException
     */
    public static function padPayload(string $payload, int $maxLengthToPad, ContentEncoding $contentEncoding): string
    {
        $payloadLen = Utils::safeStrlen($payload);
        $padLen = $maxLengthToPad ? $maxLengthToPad - $payloadLen : 0;

        if ($contentEncoding === ContentEncoding::aesgcm) {
            return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
        }
        if ($contentEncoding === ContentEncoding::aes128gcm) {
            return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
        }

        // @phpstan-ignore deadCode.unreachable
        throw new \ErrorException('This content encoding is not implemented.');
    }

    /**
     * @param string $payload With padding
     * @param string $userPublicKey Base 64 encoded (MIME or URL-safe)
     * @param string $userAuthToken Base 64 encoded (MIME or URL-safe)
     *
     * @throws \Random\RandomException Thrown on php 8.2 and higher
     */
    public static function encrypt(
        string $payload,
        string $userPublicKey,
        #[\SensitiveParameter]
        string $userAuthToken,
        ContentEncoding $contentEncoding,
    ): array {
        return self::deterministicEncrypt(
            $payload,
            $userPublicKey,
            $userAuthToken,
            $contentEncoding,
            self::createLocalKeyObject(),
            random_bytes(16)
        );
    }

    /**
     * @throws \RuntimeException|\ErrorException
     */
    public static function deterministicEncrypt(
        string $payload,
        string $userPublicKey,
        #[\SensitiveParameter]
        string $userAuthToken,
        ContentEncoding $contentEncoding,
        array $localKeyObject,
        string $salt
    ): array {
        $userPublicKey = Base64Url::decode($userPublicKey);
        $userAuthToken = Base64Url::decode($userAuthToken);

        // get local key pair
        if (count($localKeyObject) === 1) {
            /** @var JWK $localJwk */
            $localJwk = current($localKeyObject);
            $localPublicKey = hex2bin(Utils::serializePublicKeyFromJWK($localJwk));
        } else {
            /** @var PrivateKey $localPrivateKeyObject */
            [$localPublicKeyObject, $localPrivateKeyObject] = $localKeyObject;
            $localPublicKey = hex2bin(Utils::serializePublicKey($localPublicKeyObject));
            $localJwk = new JWK([
                'kty' => 'EC',
                'crv' => 'P-256',
                'd' => Base64Url::encode($localPrivateKeyObject->getSecret()->toBytes(false)),
                'x' => Base64Url::encode($localPublicKeyObject[0]),
                'y' => Base64Url::encode($localPublicKeyObject[1]),
            ]);
        }
        if (!$localPublicKey) {
            throw new \RuntimeException('Failed to convert local public key from hexadecimal to binary.');
        }

        // get user public key object
        [$userPublicKeyObjectX, $userPublicKeyObjectY] = Utils::unserializePublicKey($userPublicKey);
        $userJwk = new JWK([
            'kty' => 'EC',
            'crv' => 'P-256',
            'x' => Base64Url::encode($userPublicKeyObjectX),
            'y' => Base64Url::encode($userPublicKeyObjectY),
        ]);

        // get shared secret from user public key and local private key

        $sharedSecret = self::calculateAgreementKey($localJwk, $userJwk);

        $sharedSecret = str_pad($sharedSecret, 32, chr(0), STR_PAD_LEFT);

        // section 4.3
        $ikm = self::getIKM($userAuthToken, $userPublicKey, $localPublicKey, $sharedSecret, $contentEncoding);

        // section 4.2
        $context = self::createContext($userPublicKey, $localPublicKey, $contentEncoding);

        // derive the Content Encryption Key
        $contentEncryptionKeyInfo = self::createInfo($contentEncoding->value, $context, $contentEncoding);
        $contentEncryptionKey = self::hkdf($salt, $ikm, $contentEncryptionKeyInfo, 16);

        // section 3.3, derive the nonce
        $nonceInfo = self::createInfo('nonce', $context, $contentEncoding);
        $nonce = self::hkdf($salt, $ikm, $nonceInfo, 12);

        // encrypt
        // "The additional data passed to each invocation of AEAD_AES_128_GCM is a zero-length octet sequence."
        $tag = '';
        $encryptedText = openssl_encrypt($payload, 'aes-128-gcm', $contentEncryptionKey, OPENSSL_RAW_DATA, $nonce, $tag);

        // return values in url safe base64
        return [
            'localPublicKey' => $localPublicKey,
            'salt' => $salt,
            'cipherText' => $encryptedText.$tag,
        ];
    }

    public static function getContentCodingHeader(string $salt, string $localPublicKey, ContentEncoding $contentEncoding): string
    {
        if ($contentEncoding === ContentEncoding::aesgcm) {
            return '';
        }
        if ($contentEncoding === ContentEncoding::aes128gcm) {
            return $salt
                .pack('N*', 4096)
                .pack('C*', Utils::safeStrlen($localPublicKey))
                .$localPublicKey;
        }

        // @phpstan-ignore deadCode.unreachable
        throw new \ValueError('This content encoding is not implemented.');
    }

    /**
     * HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
     *
     * This is used to derive a secure encryption key from a mostly-secure shared
     * secret.
     *
     * This is a partial implementation of HKDF tailored to our specific purposes.
     * In particular, for us the value of N will always be 1, and thus T always
     * equals HMAC-Hash(PRK, info | 0x01).
     *
     * See {@link https://www.rfc-editor.org/rfc/rfc5869.txt}
     * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}
     *
     * @param string $salt   A non-secret random value
     * @param string $ikm    Input keying material
     * @param string $info   Application-specific context
     * @param int    $length The length (in bytes) of the required output key
     */
    private static function hkdf(string $salt, string $ikm, string $info, int $length): string
    {
        // extract
        $prk = hash_hmac('sha256', $ikm, $salt, true);

        // expand
        return mb_substr(hash_hmac('sha256', $info.chr(1), $prk, true), 0, $length, '8bit');
    }

    /**
     * Creates a context for deriving encryption parameters.
     * See section 4.2 of
     * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
     * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
     *
     * @param string $clientPublicKey The client's public key
     * @param string $serverPublicKey Our public key
     *
     * @throws \ErrorException
     */
    private static function createContext(string $clientPublicKey, string $serverPublicKey, ContentEncoding $contentEncoding): ?string
    {
        if ($contentEncoding === ContentEncoding::aes128gcm) {
            return null;
        }

        if (Utils::safeStrlen($clientPublicKey) !== 65) {
            throw new \ErrorException('Invalid client public key length.');
        }

        // This one should never happen, because it's our code that generates the key
        if (Utils::safeStrlen($serverPublicKey) !== 65) {
            throw new \ErrorException('Invalid server public key length.');
        }

        $len = chr(0).'A'; // 65 as Uint16BE

        return chr(0).$len.$clientPublicKey.$len.$serverPublicKey;
    }

    /**
     * Returns an info record. See sections 3.2 and 3.3 of
     * {@link https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
     * From {@link https://github.com/GoogleChrome/push-encryption-node/blob/master/src/encrypt.js}.
     *
     * @param string $type The type of the info record
     * @param string|null $context The context for the record
     *
     * @throws \ErrorException
     */
    private static function createInfo(string $type, ?string $context, ContentEncoding $contentEncoding): string
    {
        if ($contentEncoding === ContentEncoding::aesgcm) {
            if (!$context) {
                throw new \ValueError('Context must exist.');
            }

            if (Utils::safeStrlen($context) !== 135) {
                throw new \ValueError('Context argument has invalid size.');
            }

            return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
        }

        if ($contentEncoding === ContentEncoding::aes128gcm) {
            return 'Content-Encoding: '.$type.chr(0);
        }

        // @phpstan-ignore deadCode.unreachable
        throw new \ErrorException('This content encoding is not implemented.');
    }

    private static function createLocalKeyObject(): array
    {
        $keyResource = openssl_pkey_new([
            'ec' => [
                'curve_name'       => 'prime256v1',
                'private_key_type' => OPENSSL_KEYTYPE_EC,
            ],
        ]);
        if (!$keyResource) {
            throw new \RuntimeException('Unable to create the local key.');
        }

        $details = openssl_pkey_get_details($keyResource);
        if (!$details) {
            throw new \RuntimeException('Unable to get the local key details.');
        }

        return [
            new JWK([
                'kty' => 'EC',
                'crv' => 'P-256',
                'x' => Base64Url::encode(self::addNullPadding($details['ec']['x'])),
                'y' => Base64Url::encode(self::addNullPadding($details['ec']['y'])),
                'd' => Base64Url::encode(self::addNullPadding($details['ec']['d'])),
            ]),
        ];
    }

    /**
     * @throws \ValueError
     */
    private static function getIKM(string $userAuthToken, string $userPublicKey, string $localPublicKey, string $sharedSecret, ContentEncoding $contentEncoding): string
    {
        if (empty($userAuthToken)) {
            return $sharedSecret;
        }
        if ($contentEncoding === ContentEncoding::aesgcm) {
            $info = 'Content-Encoding: auth'.chr(0);
        } elseif ($contentEncoding === ContentEncoding::aes128gcm) {
            $info = 'WebPush: info'.chr(0).$userPublicKey.$localPublicKey;
        } else {
            throw new \ValueError('This content encoding is not implemented.');
        }

        return self::hkdf($userAuthToken, $sharedSecret, $info, 32);
    }

    private static function calculateAgreementKey(JWK $private_key, JWK $public_key): string
    {
        $publicPem = ECKey::convertPublicKeyToPEM($public_key);
        $privatePem = ECKey::convertPrivateKeyToPEM($private_key);

        $result = openssl_pkey_derive($publicPem, $privatePem);
        if ($result === false) {
            throw new \RuntimeException('Unable to compute the agreement key.');
        }
        return $result;
    }

    private static function addNullPadding(string $data): string
    {
        return str_pad($data, 32, chr(0), STR_PAD_LEFT);
    }
}
