<?php
/**
 * @see       https://github.com/zendframework/zend-crypt for the canonical source repository
 * @copyright Copyright (c) 2005-2018 Zend Technologies USA Inc. (https://www.zend.com)
 * @license   https://github.com/zendframework/zend-crypt/blob/master/LICENSE.md New BSD License
 */

namespace Zend\Crypt\PublicKey;

use Zend\Crypt\Exception;
use Zend\Math;

use const OPENSSL_KEYTYPE_DH;
use const PHP_VERSION_ID;

use function function_exists;
use function mb_strlen;
use function openssl_dh_compute_key;
use function openssl_error_string;
use function openssl_pkey_get_details;
use function openssl_pkey_new;
use function preg_match;

/**
 * PHP implementation of the Diffie-Hellman public key encryption algorithm.
 * Allows two unassociated parties to establish a joint shared secret key
 * to be used in encrypting subsequent communications.
 */
class DiffieHellman
{
    const DEFAULT_KEY_SIZE = 2048;

    /**
     * Key formats
     */
    const FORMAT_BINARY = 'binary';
    const FORMAT_NUMBER = 'number';
    const FORMAT_BTWOC  = 'btwoc';

    /**
     * Static flag to select whether to use PHP5.3's openssl extension
     * if available.
     *
     * @var bool
     */
    public static $useOpenssl = true;

    /**
     * Default large prime number; required by the algorithm.
     *
     * @var string
     */
    private $prime = null;

    /**
     * The default generator number. This number must be greater than 0 but
     * less than the prime number set.
     *
     * @var string
     */
    private $generator = null;

    /**
     * A private number set by the local user. It's optional and will
     * be generated if not set.
     *
     * @var string
     */
    private $privateKey = null;

    /**
     * BigInteger support object courtesy of Zend\Math
     *
     * @var \Zend\Math\BigInteger\Adapter\AdapterInterface
     */
    private $math = null;

    /**
     * The public key generated by this instance after calling generateKeys().
     *
     * @var string
     */
    private $publicKey = null;

    /**
     * The shared secret key resulting from a completed Diffie Hellman
     * exchange
     *
     * @var string
     */
    private $secretKey = null;

    /**
     * @var resource
     */
    protected $opensslKeyResource = null;

    /**
     * Constructor; if set construct the object using the parameter array to
     * set values for Prime, Generator and Private.
     * If a Private Key is not set, one will be generated at random.
     *
     * @param string $prime
     * @param string $generator
     * @param string $privateKey
     * @param string $privateKeyFormat
     */
    public function __construct($prime, $generator, $privateKey = null, $privateKeyFormat = self::FORMAT_NUMBER)
    {
        // set up BigInteger adapter
        $this->math = Math\BigInteger\BigInteger::factory();

        $this->setPrime($prime);
        $this->setGenerator($generator);
        if ($privateKey !== null) {
            $this->setPrivateKey($privateKey, $privateKeyFormat);
        }
    }

    /**
     * Set whether to use openssl extension
     *
     * @static
     * @param bool $flag
     */
    public static function useOpensslExtension($flag = true)
    {
        static::$useOpenssl = (bool) $flag;
    }

    /**
     * Generate own public key. If a private number has not already been set,
     * one will be generated at this stage.
     *
     * @return DiffieHellman Provides a fluent interface
     * @throws \Zend\Crypt\Exception\RuntimeException
     */
    public function generateKeys()
    {
        if (function_exists('openssl_dh_compute_key') && static::$useOpenssl !== false) {
            $details = [
                'p' => $this->convert($this->getPrime(), self::FORMAT_NUMBER, self::FORMAT_BINARY),
                'g' => $this->convert($this->getGenerator(), self::FORMAT_NUMBER, self::FORMAT_BINARY)
            ];
            // the priv_key parameter is allowed only for PHP < 7.1
            // @see https://bugs.php.net/bug.php?id=73478
            if ($this->hasPrivateKey() && PHP_VERSION_ID < 70100) {
                $details['priv_key'] = $this->convert(
                    $this->privateKey,
                    self::FORMAT_NUMBER,
                    self::FORMAT_BINARY
                );
                $opensslKeyResource = openssl_pkey_new(['dh' => $details]);
            } else {
                $opensslKeyResource = openssl_pkey_new([
                    'dh'               => $details,
                    'private_key_bits' => self::DEFAULT_KEY_SIZE,
                    'private_key_type' => OPENSSL_KEYTYPE_DH
                ]);
            }

            if (false === $opensslKeyResource) {
                throw new Exception\RuntimeException(
                    'Can not generate new key; openssl ' . openssl_error_string()
                );
            }

            $data = openssl_pkey_get_details($opensslKeyResource);

            $this->setPrivateKey($data['dh']['priv_key'], self::FORMAT_BINARY);
            $this->setPublicKey($data['dh']['pub_key'], self::FORMAT_BINARY);

            $this->opensslKeyResource = $opensslKeyResource;
        } else {
            // Private key is lazy generated in the absence of ext/openssl
            $publicKey = $this->math->powmod($this->getGenerator(), $this->getPrivateKey(), $this->getPrime());
            $this->setPublicKey($publicKey);
        }

        return $this;
    }

    /**
     * Setter for the value of the public number
     *
     * @param string $number
     * @param string $format
     * @return DiffieHellman Provides a fluent interface
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function setPublicKey($number, $format = self::FORMAT_NUMBER)
    {
        $number = $this->convert($number, $format, self::FORMAT_NUMBER);
        if (! preg_match('/^\d+$/', $number)) {
            throw new Exception\InvalidArgumentException('Invalid parameter; not a positive natural number');
        }
        $this->publicKey = (string) $number;

        return $this;
    }

    /**
     * Returns own public key for communication to the second party to this transaction
     *
     * @param string $format
     * @return string
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function getPublicKey($format = self::FORMAT_NUMBER)
    {
        if ($this->publicKey === null) {
            throw new Exception\InvalidArgumentException(
                'A public key has not yet been generated using a prior call to generateKeys()'
            );
        }

        return $this->convert($this->publicKey, self::FORMAT_NUMBER, $format);
    }

    /**
     * Compute the shared secret key based on the public key received from the
     * the second party to this transaction. This should agree to the secret
     * key the second party computes on our own public key.
     * Once in agreement, the key is known to only to both parties.
     * By default, the function expects the public key to be in binary form
     * which is the typical format when being transmitted.
     *
     * If you need the binary form of the shared secret key, call
     * getSharedSecretKey() with the optional parameter for Binary output.
     *
     * @param string $publicKey
     * @param string $publicKeyFormat
     * @param string $secretKeyFormat
     * @return string
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     * @throws \Zend\Crypt\Exception\RuntimeException
     */
    public function computeSecretKey(
        $publicKey,
        $publicKeyFormat = self::FORMAT_NUMBER,
        $secretKeyFormat = self::FORMAT_NUMBER
    ) {
        if (function_exists('openssl_dh_compute_key') && static::$useOpenssl !== false) {
            $publicKey = $this->convert($publicKey, $publicKeyFormat, self::FORMAT_BINARY);
            $secretKey = openssl_dh_compute_key($publicKey, $this->opensslKeyResource);
            if (false === $secretKey) {
                throw new Exception\RuntimeException(
                    'Can not compute key; openssl ' . openssl_error_string()
                );
            }
            $this->secretKey = $this->convert($secretKey, self::FORMAT_BINARY, self::FORMAT_NUMBER);
        } else {
            $publicKey = $this->convert($publicKey, $publicKeyFormat, self::FORMAT_NUMBER);
            if (! preg_match('/^\d+$/', $publicKey)) {
                throw new Exception\InvalidArgumentException(
                    'Invalid parameter; not a positive natural number'
                );
            }
            $this->secretKey = $this->math->powmod($publicKey, $this->getPrivateKey(), $this->getPrime());
        }

        return $this->getSharedSecretKey($secretKeyFormat);
    }

    /**
     * Return the computed shared secret key from the DiffieHellman transaction
     *
     * @param string $format
     * @return string
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function getSharedSecretKey($format = self::FORMAT_NUMBER)
    {
        if (! isset($this->secretKey)) {
            throw new Exception\InvalidArgumentException(
                'A secret key has not yet been computed; call computeSecretKey() first'
            );
        }

        return $this->convert($this->secretKey, self::FORMAT_NUMBER, $format);
    }

    /**
     * Setter for the value of the prime number
     *
     * @param string $number
     * @return DiffieHellman Provides a fluent interface
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function setPrime($number)
    {
        if (! preg_match('/^\d+$/', $number) || $number < 11) {
            throw new Exception\InvalidArgumentException(
                'Invalid parameter; not a positive natural number or too small: ' .
                'should be a large natural number prime'
            );
        }
        $this->prime = (string) $number;

        return $this;
    }

    /**
     * Getter for the value of the prime number
     *
     * @param string $format
     * @return string
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function getPrime($format = self::FORMAT_NUMBER)
    {
        if (! isset($this->prime)) {
            throw new Exception\InvalidArgumentException('No prime number has been set');
        }

        return $this->convert($this->prime, self::FORMAT_NUMBER, $format);
    }

    /**
     * Setter for the value of the generator number
     *
     * @param string $number
     * @return DiffieHellman Provides a fluent interface
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function setGenerator($number)
    {
        if (! preg_match('/^\d+$/', $number) || $number < 2) {
            throw new Exception\InvalidArgumentException(
                'Invalid parameter; not a positive natural number greater than 1'
            );
        }
        $this->generator = (string) $number;

        return $this;
    }

    /**
     * Getter for the value of the generator number
     *
     * @param string $format
     * @return string
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function getGenerator($format = self::FORMAT_NUMBER)
    {
        if (! isset($this->generator)) {
            throw new Exception\InvalidArgumentException('No generator number has been set');
        }

        return $this->convert($this->generator, self::FORMAT_NUMBER, $format);
    }

    /**
     * Setter for the value of the private number
     *
     * @param string $number
     * @param string $format
     * @return DiffieHellman Provides a fluent interface
     * @throws \Zend\Crypt\Exception\InvalidArgumentException
     */
    public function setPrivateKey($number, $format = self::FORMAT_NUMBER)
    {
        $number = $this->convert($number, $format, self::FORMAT_NUMBER);
        if (! preg_match('/^\d+$/', $number)) {
            throw new Exception\InvalidArgumentException('Invalid parameter; not a positive natural number');
        }
        $this->privateKey = (string) $number;

        return $this;
    }

    /**
     * Getter for the value of the private number
     *
     * @param string $format
     * @return string
     */
    public function getPrivateKey($format = self::FORMAT_NUMBER)
    {
        if (! $this->hasPrivateKey()) {
            $this->setPrivateKey($this->generatePrivateKey(), self::FORMAT_BINARY);
        }

        return $this->convert($this->privateKey, self::FORMAT_NUMBER, $format);
    }

    /**
     * Check whether a private key currently exists.
     *
     * @return bool
     */
    public function hasPrivateKey()
    {
        return isset($this->privateKey);
    }

    /**
     * Convert number between formats
     *
     * @param string $number
     * @param string $inputFormat
     * @param string $outputFormat
     * @return string
     */
    protected function convert($number, $inputFormat = self::FORMAT_NUMBER, $outputFormat = self::FORMAT_BINARY)
    {
        if ($inputFormat == $outputFormat) {
            return $number;
        }

        // convert to number
        switch ($inputFormat) {
            case self::FORMAT_BINARY:
            case self::FORMAT_BTWOC:
                $number = $this->math->binToInt($number);
                break;
            case self::FORMAT_NUMBER:
            default:
                // do nothing
                break;
        }

        // convert to output format
        switch ($outputFormat) {
            case self::FORMAT_BINARY:
                return $this->math->intToBin($number);
            case self::FORMAT_BTWOC:
                return $this->math->intToBin($number, true);
            case self::FORMAT_NUMBER:
            default:
                return $number;
        }
    }

    /**
     * In the event a private number/key has not been set by the user,
     * or generated by ext/openssl, a best attempt will be made to
     * generate a random key. Having a random number generator installed
     * on linux/bsd is highly recommended! The alternative is not recommended
     * for production unless without any other option.
     *
     * @return string
     */
    protected function generatePrivateKey()
    {
        return Math\Rand::getBytes(mb_strlen($this->getPrime(), '8bit'));
    }
}
