<?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\Util\Ecc\NistCurve;
use Jose\Component\Core\Util\Ecc\Point;
use Jose\Component\Core\Util\Ecc\PrivateKey;
use Jose\Component\Core\Util\Ecc\PublicKey;

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

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

        if ($contentEncoding === "aesgcm") {
            return pack('n*', $padLen).str_pad($payload, $padLen + $payloadLen, chr(0), STR_PAD_LEFT);
        } else if ($contentEncoding === "aes128gcm") {
            return str_pad($payload.chr(2), $padLen + $payloadLen, chr(0), STR_PAD_RIGHT);
        } else {
            throw new \ErrorException("This content encoding is not supported");
        }
    }

    /**
     * @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)
     * @param string $contentEncoding
     * @return array
     *
     * @throws \ErrorException
     */
    public static function encrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding): array
    {
        return self::deterministicEncrypt(
            $payload,
            $userPublicKey,
            $userAuthToken,
            $contentEncoding,
            self::createLocalKeyObject(),
            random_bytes(16)
        );
    }

    /**
     * @param string $payload
     * @param string $userPublicKey
     * @param string $userAuthToken
     * @param string $contentEncoding
     * @param array $localKeyObject
     * @param string $salt
     * @return array
     *
     * @throws \ErrorException
     */
    public static function deterministicEncrypt(string $payload, string $userPublicKey, string $userAuthToken, string $contentEncoding, array $localKeyObject, string $salt): array
    {
        $userPublicKey = Base64Url::decode($userPublicKey);
        $userAuthToken = Base64Url::decode($userAuthToken);

        $curve = NistCurve::curve256();

        // get local key pair
        list($localPublicKeyObject, $localPrivateKeyObject) = $localKeyObject;
        $localPublicKey = hex2bin(Utils::serializePublicKey($localPublicKeyObject));

        // get user public key object
        [$userPublicKeyObjectX, $userPublicKeyObjectY] = Utils::unserializePublicKey($userPublicKey);
        $userPublicKeyObject = $curve->getPublicKeyFrom(
            gmp_init(bin2hex($userPublicKeyObjectX), 16),
            gmp_init(bin2hex($userPublicKeyObjectY), 16)
        );

        // get shared secret from user public key and local private key
        $sharedSecret = $curve->mul($userPublicKeyObject->getPoint(), $localPrivateKeyObject->getSecret())->getX();
        $sharedSecret = hex2bin(str_pad(gmp_strval($sharedSecret, 16), 64, '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, $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."
        $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($salt, $localPublicKey, $contentEncoding): string
    {
        if ($contentEncoding === "aes128gcm") {
            return $salt
                .pack('N*', 4096)
                .pack('C*', Utils::safeStrlen($localPublicKey))
                .$localPublicKey;
        }

        return "";
    }

    /**
     * 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
     *
     * @return string
     */
    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
     *
     * @return null|string
     *
     * @throws \ErrorException
     */
    private static function createContext(string $clientPublicKey, string $serverPublicKey, $contentEncoding): ?string
    {
        if ($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
     * @param string $contentEncoding
     * @return string
     *
     * @throws \ErrorException
     */
    private static function createInfo(string $type, ?string $context, string $contentEncoding): string
    {
        if ($contentEncoding === "aesgcm") {
            if (!$context) {
                throw new \ErrorException('Context must exist');
            }

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

            return 'Content-Encoding: '.$type.chr(0).'P-256'.$context;
        } else if ($contentEncoding === "aes128gcm") {
            return 'Content-Encoding: '.$type.chr(0);
        }

        throw new \ErrorException('This content encoding is not supported.');
    }

    /**
     * @return array
     */
    private static function createLocalKeyObject(): array
    {
        try {
            return self::createLocalKeyObjectUsingOpenSSL();
        } catch (\Exception $e) {
            return self::createLocalKeyObjectUsingPurePhpMethod();
        }
    }

    /**
     * @return array
     */
    private static function createLocalKeyObjectUsingPurePhpMethod(): array
    {
        $curve = NistCurve::curve256();
        $privateKey = $curve->createPrivateKey();

        return [
            $curve->createPublicKey($privateKey),
            $privateKey,
        ];
    }

    /**
     * @return array
     */
    private static function createLocalKeyObjectUsingOpenSSL(): array
    {
        $keyResource = openssl_pkey_new([
            'curve_name'       => 'prime256v1',
            'private_key_type' => OPENSSL_KEYTYPE_EC,
        ]);

        if (!$keyResource) {
            throw new \RuntimeException('Unable to create the key');
        }

        $details = openssl_pkey_get_details($keyResource);
        openssl_pkey_free($keyResource);

        if (!$details) {
            throw new \RuntimeException('Unable to get the key details');
        }

        return [
            PublicKey::create(Point::create(
                gmp_init(bin2hex($details['ec']['x']), 16),
                gmp_init(bin2hex($details['ec']['y']), 16)
            )),
            PrivateKey::create(gmp_init(bin2hex($details['ec']['d']), 16))
        ];
    }

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

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

        return $sharedSecret;
    }
}
