<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

namespace Shopware\Plugins\ViisonPickwareCommon\Components\Encryption;

class EncryptionService
{
    /**
     * The version of the 'protocol' used for app payload encryption.
     */
    const APP_ENCRYPTION_VERSION = 'v0';

    /**
     * The cipher used for encryption of the actual payload for apps.
     */
    const APP_ENCRYPTION_PAYLOAD_CIPHER = 'aes-128-cfb';

    /**
     * The encoded public key used for RSA encryption of the actual encryption key as well as fallback for validation of
     * app signatures.
     */
    const APP_ENCRYPTION_PUBLIC_KEY = <<<'PUBLIC_KEY'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EmMYwl4T7oFisJjVyFa
7QPt+natft+VimTt4zIKGOMp2151sBWITIGa6geau2zr5Y5uCLVPAWpKV0QHb/ay
4KjUCkjnEYjaZMmoxd9sI3QswpWwNiQoRXQF9BA9FOKd/V0bTCuJ7uaZSG6cMQix
opfRWWtRAGAv0GGdRuf2x5SgRd9vMJXevXb6kKfsr2qiaUTV4wIoTd27z4wlwtRm
abDz7q9Jq7t7gcAhnaeT0A+CLwR3Ul9RjtA3UnzWTQrLwigeSDKJlMstlZPwyQJg
LYv4XFK4onwu8XA+J7W++Gm/E4ZjiPqydfdrxx1ycuSG9b+TTDZeGwbP4tDDDwiQ
SwIDAQAB
-----END PUBLIC KEY-----
PUBLIC_KEY;

    /**
     * The encoded public key used for validation of app signatures.
     */
    const APP_SIGNATURE_PUBLIC_KEY = <<<'PUBLIC_KEY'
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArkCjpIq9Em7pQwV3y/OS
IoY0jd9HqCXVNXrLkQ0ICyYomoa7WsqMcGA0U9NXx/KEv7QMMQiZWDpkB9Uh1wca
lOw+pxjgynPfH5Ti9RlKk0b6ZCq23sXDMC6ocQMpwCwpdJNrHKOgZayLyRigwIyw
9xv1YBy3B5bQH6xgXRF2m1/yAL6Q5O2aSArwb205s33HCKxkBD3U5bLj7og4wCr1
DDaY2wc8rJKLen7WyP0uh4rHfngeHRtcvA9KhdAUf6FfDvx1WaxToG+XK5ABxBON
JOZBFY0CSKW/G6A+OV2SvtQ7kmI1z5QYBxeeTRaZkx33aELv0De6aQfkLR5TiPZP
HQIDAQAB
-----END PUBLIC KEY-----
PUBLIC_KEY;

    /**
     * Encrypts the passed `$payload` string in a way that the Pickware apps can decrypt the data. We use a combination
     * of RSA and AES encryption. The `$payload` itself is encrypted using AES-128-CFB with a randomly generated
     * initialization vector and key, while that key is then encrypted using RSA public key encryption. The resulting,
     * encrypted message is a string of the format `<c1>.<c2>.<c3>.<c4>`:
     *     1. Protocol version: the version of the encryption format; currently `v0`.
     *     2. AES initialization vector: the randomly generated initialization vector used for AES encrpytion of the
     *        payload, base64 encoded.
     *     3. AES key: the randomly generated key used for AES encryption of the payload, RSA encrypted and twice base64
     *        encoded (both before and after RSA encryption).
     *     4. Payload: the actual data payload, AES encrypted using the randomly generated key and initialization vector
     *        and base64 encoded.
     *
     * @param string $payload
     * @return string
     * @throws EncryptionException
     */
    public function encryptForApp($payload)
    {
        // Encrypt the payload using the default cipher
        $initializationVectorLength = openssl_cipher_iv_length(self::APP_ENCRYPTION_PAYLOAD_CIPHER);
        $initializationVector = $this->getEntropyBytes($initializationVectorLength);
        $payloadEncryptionKey = $this->getEntropyBytes($initializationVectorLength);
        $encryptedPayload = openssl_encrypt(
            $payload,
            self::APP_ENCRYPTION_PAYLOAD_CIPHER,
            $payloadEncryptionKey,
            0,
            $initializationVector
        );
        if ($encryptedPayload === false) {
            throw EncryptionException::payloadEncryptionFailed(self::getOpenSslError());
        }

        // Encrpyt the payload encryption key using RSA
        $encryptedPayloadEncryptionKey = null;
        $keyEncryptionSuccess = openssl_public_encrypt(
            base64_encode($payloadEncryptionKey),
            $encryptedPayloadEncryptionKey,
            $this->loadRsaPublicKey(self::APP_ENCRYPTION_PUBLIC_KEY),
            OPENSSL_PKCS1_OAEP_PADDING
        );
        if ($keyEncryptionSuccess === false) {
            throw EncryptionException::keyEncryptionFailed(self::getOpenSslError());
        }

        // Construct the full, encrypted message
        return sprintf(
            '%1$s.%2$s.%3$s.%4$s',
            self::APP_ENCRYPTION_VERSION,
            base64_encode($initializationVector),
            base64_encode($encryptedPayloadEncryptionKey),
            $encryptedPayload // openssl_encrypt() already base64 encodes the result
        );
    }

    /**
     * Verifies that the given `$signature` is valid for the given `$message`.
     *
     * @param string $signature
     * @param string $message
     * @return boolean True, if the given `$signature` is valid. False otherwise.
     */
    public function isAppSignatureValid($signature, $message)
    {
        // Validate the signature using the dedicated app signature public key (new system) first and only if it fails
        // using the encryption public key (legacy system)
        return (
            $this->isAppSignatureValidWithKey($signature, $message, self::APP_SIGNATURE_PUBLIC_KEY)
            || $this->isAppSignatureValidWithKey($signature, $message, self::APP_ENCRYPTION_PUBLIC_KEY)
        );
    }

    /**
     * @param string $encodedPublicKey
     * @return resource
     * @throws EncryptionException
     */
    private function loadRsaPublicKey($encodedPublicKey)
    {
        $rsaPublicKey = openssl_pkey_get_public($encodedPublicKey);
        if ($rsaPublicKey === false) {
            throw EncryptionException::rsaPublicKeyLoadingFailed(self::getOpenSslError());
        }

        return $rsaPublicKey;
    }

    /**
     * @param $numberOfBytes
     * @return string $numberOfBytes of pseudo-random entropy, generated using cryptographically strong methods
     * @throws EncryptionException if the requested amount of entropy could not be generated using cryptographically
     *         strong methods
     */
    private function getEntropyBytes($numberOfBytes)
    {
        $entropyIsCryptographicallyStrong = false;
        $initializationVector = openssl_random_pseudo_bytes($numberOfBytes, $entropyIsCryptographicallyStrong);
        if ($entropyIsCryptographicallyStrong === false || $initializationVector === false) {
            throw EncryptionException::acquiringEntropyFailed($numberOfBytes, self::getOpenSslError());
        }

        return $initializationVector;
    }

    /**
     * Verifies that the given `$signature` is valid for the given `$message` and `$encodedPublicKey`.
     *
     * @param string $signature
     * @param string $message
     * @param string $encodedPublicKey
     * @return boolean True, if the given `$signature` is valid. False otherwise.
     * @throws EncryptionException If an error prevented signature verification from being performed.
     */
    private function isAppSignatureValidWithKey($signature, $message, $encodedPublicKey)
    {
        $verificationResult = openssl_verify(
            $message,
            base64_decode($signature),
            $this->loadRsaPublicKey($encodedPublicKey),
            OPENSSL_ALGO_SHA256
        );
        if ($verificationResult === 1) {
            return true;
        } elseif ($verificationResult === 0) {
            return false;
        }

        throw EncryptionException::signatureVerificationFailed($signature, self::getOpenSslError());
    }

    /**
     * @return string
     */
    private static function getOpenSslError()
    {
        $errors = [];
        $error = openssl_error_string();
        while ($error) {
            $errors[] = $error;
            $error = openssl_error_string();
        }

        return implode('; ', $errors);
    }
}
