<?php

/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */

namespace Crypt\GPG;

use Crypt\GPG;
use Crypt\GPG\Exceptions;
use Crypt\GPG\Key;
use Crypt\GPG\SubKey;
use Crypt\GPG\UserId;

/**
 * GnuPG key generator
 *
 * This class provides an object oriented interface for generating keys with
 * the GNU Privacy Guard (GPG).
 *
 * Secure key generation requires true random numbers, and as such can be slow.
 * If the operating system runs out of entropy, key generation will block until
 * more entropy is available.
 *
 * If quick key generation is important, a hardware entropy generator, or an
 * entropy gathering daemon may be installed. For example, administrators of
 * Debian systems may want to install the 'randomsound' package.
 *
 * This class uses the experimental automated key generation support available
 * in GnuPG. See <kbd>doc/DETAILS</kbd> in the
 * {@link https://www.gnupg.org/download/ GPG distribution} for detailed
 * information on the key generation format.
 *
 * @category  Encryption
 * @package   Crypt_GPG
 * @author    Nathan Fredrickson <nathan@silverorange.com>
 * @author    Michael Gauthier <mike@silverorange.com>
 * @copyright 2005-2013 silverorange
 * @license   https://www.gnu.org/copyleft/lesser.html LGPL License 2.1
 * @link      https://github.com/pear/Crypt_GPG
 */
class KeyGenerator extends GPG
{
    /**
     * The expiration date of generated keys
     *
     * @var int
     *
     * @see KeyGenerator::setExpirationDate()
     */
    protected $expirationDate = 0;

    /**
     * The passphrase of generated keys
     *
     * @var string
     *
     * @see KeyGenerator::setPassphrase()
     */
    protected $passphrase = '';

    /**
     * The algorithm for generated primary keys
     *
     * @var int
     *
     * @see KeyGenerator::setKeyParams()
     */
    protected $keyAlgorithm = SubKey::ALGORITHM_DSA;

    /**
     * The size of generated primary keys
     *
     * @var int
     *
     * @see KeyGenerator::setKeyParams()
     */
    protected $keySize = 1024;

    /**
     * The usages of generated primary keys
     *
     * This is a bitwise combination of the usage constants in
     * {@link Crypt\GPG\SubKey}.
     *
     * @var int
     *
     * @see KeyGenerator::setKeyParams()
     */
    protected $keyUsage = 6; // USAGE_SIGN | USAGE_CERTIFY

    /**
     * The algorithm for generated sub-keys
     *
     * @var int
     *
     * @see KeyGenerator::setSubKeyParams()
     */
    protected $subKeyAlgorithm = SubKey::ALGORITHM_ELGAMAL_ENC;

    /**
     * The size of generated sub-keys
     *
     * @var int
     *
     * @see KeyGenerator::setSubKeyParams()
     */
    protected $subKeySize = 2048;

    /**
     * The usages of generated sub-keys
     *
     * This is a bitwise combination of the usage constants in
     * {@link \Crypt\GPG\SubKey}.
     *
     * @var int
     *
     * @see KeyGenerator::setSubKeyParams()
     */
    protected $subKeyUsage = \Crypt\GPG\SubKey::USAGE_ENCRYPT;

    /**
     * Sets the expiration date of generated keys
     *
     * @param string|int $date Either a string that may be parsed by
     *                         PHP's strtotime() function, or an integer
     *                         timestamp representing the number of seconds
     *                         since the UNIX epoch. This date must be at
     *                         least one date in the future. Keys that
     *                         expire in the past may not be generated. Use
     *                         an expiration date of 0 for keys that do not
     *                         expire.
     *
     * @throws \InvalidArgumentException If the date is not a valid format, or
     *                                  if the date is not at least one day in
     *                                  the future, or if the date is greater
     *                                  than 2038-01-19T03:14:07.
     *
     * @return $this The current object, for fluent interface.
     */
    public function setExpirationDate($date)
    {
        if (is_int($date) || ctype_digit(strval($date))) {
            $expirationDate = intval($date);
        } else {
            $expirationDate = strtotime($date);
        }

        if ($expirationDate === false) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Invalid expiration date format: "%s". Please use a '
                    . 'format compatible with PHP\'s strtotime().',
                    $date
                )
            );
        }

        if ($expirationDate !== 0 && $expirationDate < time() + 86400) {
            throw new \InvalidArgumentException(
                'Expiration date must be at least a day in the future.'
            );
        }

        // GnuPG suffers from the 2038 bug
        if ($expirationDate > 2147483647) {
            throw new \InvalidArgumentException(
                'Expiration date must not be greater than 2038-01-19T03:14:07.'
            );
        }

        $this->expirationDate = $expirationDate;

        return $this;
    }

    /**
     * Sets the passphrase of generated keys
     *
     * @param string $passphrase the passphrase to use for generated keys. Use
     *                           null or an empty string for no passphrase.
     *
     * @return $this the current object, for fluent interface.
     */
    public function setPassphrase($passphrase)
    {
        $this->passphrase = strval($passphrase);
        return $this;
    }

    /**
     * Sets the parameters for the primary key of generated key-pairs
     *
     * @param int $algorithm the algorithm used by the key. This should be
     *                       one of the \Crypt\GPG\SubKey::ALGORITHM_*
     *                       constants.
     * @param int $size      optional. The size of the key. Different
     *                       algorithms have different size requirements.
     *                       If not specified, the default size for the
     *                       specified algorithm will be used. If an
     *                       invalid key size is used, GnuPG will do its
     *                       best to round it to a valid size.
     * @param int $usage     optional. A bitwise combination of key usages.
     *                       If not specified, the primary key will be used
     *                       only to sign and certify. This is the default
     *                       behavior of GnuPG in interactive mode. Use
     *                       the \Crypt\GPG\SubKey::USAGE_* constants here.
     *                       The primary key may be used to certify even
     *                       if the certify usage is not specified.
     *
     * @return $this the current object, for fluent interface.
     */
    public function setKeyParams($algorithm, $size = 0, $usage = 0)
    {
        $algorithm = intval($algorithm);

        if ($algorithm === SubKey::ALGORITHM_ELGAMAL_ENC) {
            throw new Exceptions\InvalidKeyParamsException(
                'Primary key algorithm must be capable of signing. The '
                . 'Elgamal algorithm can only encrypt.',
                0,
                $algorithm,
                $size,
                $usage
            );
        }

        if ($size != 0) {
            $size = intval($size);
        }

        if ($usage != 0) {
            $usage = intval($usage);
        }

        $usageEncrypt = SubKey::USAGE_ENCRYPT;

        if ($algorithm === SubKey::ALGORITHM_DSA
            && ($usage & $usageEncrypt) === $usageEncrypt
        ) {
            throw new Exceptions\InvalidKeyParamsException(
                'The DSA algorithm is not capable of encrypting. Please '
                . 'specify a different algorithm or do not include encryption '
                . 'as a usage for the primary key.',
                0,
                $algorithm,
                $size,
                $usage
            );
        }

        $this->keyAlgorithm = $algorithm;

        if ($size != 0) {
            $this->keySize = $size;
        }

        if ($usage != 0) {
            $this->keyUsage = $usage;
        }

        return $this;
    }

    /**
     * Sets the parameters for the sub-key of generated key-pairs
     *
     * @param int $algorithm The algorithm used by the key. This should be
     *                       one of the \Crypt\GPG\SubKey::ALGORITHM_*
     *                       constants.
     * @param int $size      Optional size of the key. Different
     *                       algorithms have different size requirements.
     *                       If not specified, the default size for the
     *                       specified algorithm will be used. If an
     *                       invalid key size is used, GnuPG will do its
     *                       best to round it to a valid size.
     * @param int $usage     Optional bitwise combination of key usages.
     *                       If not specified, the sub-key will be used
     *                       only to encrypt. This is the default behavior
     *                       of GnuPG in interactive mode. Use the
     *                       \Crypt\GPG\SubKey::USAGE_* constants here.
     *
     * @return $this The current object, for fluent interface.
     */
    public function setSubKeyParams($algorithm, $size = 0, $usage = 0)
    {
        $algorithm = intval($algorithm);

        if ($size != 0) {
            $size = intval($size);
        }

        if ($usage != 0) {
            $usage = intval($usage);
        }

        $usageSign = SubKey::USAGE_SIGN;

        if ($algorithm === SubKey::ALGORITHM_ELGAMAL_ENC && ($usage & $usageSign) === $usageSign) {
            throw new Exceptions\InvalidKeyParamsException(
                'The Elgamal algorithm is not capable of signing. Please '
                . 'specify a different algorithm or do not include signing '
                . 'as a usage for the sub-key.',
                0,
                $algorithm,
                $size,
                $usage
            );
        }

        $usageEncrypt = SubKey::USAGE_ENCRYPT;

        if ($algorithm === SubKey::ALGORITHM_DSA && ($usage & $usageEncrypt) === $usageEncrypt) {
            throw new Exceptions\InvalidKeyParamsException(
                'The DSA algorithm is not capable of encrypting. Please '
                . 'specify a different algorithm or do not include encryption '
                . 'as a usage for the sub-key.',
                0,
                $algorithm,
                $size,
                $usage
            );
        }

        $this->subKeyAlgorithm = $algorithm;

        if ($size != 0) {
            $this->subKeySize = $size;
        }

        if ($usage != 0) {
            $this->subKeyUsage = $usage;
        }

        return $this;
    }

    /**
     * Generates a new key-pair in the current keyring
     *
     * Secure key generation requires true random numbers, and as such can be
     * solw. If the operating system runs out of entropy, key generation will
     * block until more entropy is available.
     *
     * If quick key generation is important, a hardware entropy generator, or
     * an entropy gathering daemon may be installed. For example,
     * administrators of Debian systems may want to install the 'randomsound'
     * package.
     *
     * @param string|UserId $name    either a {@link \Crypt\GPG\UserId}
     *                               object, or a string containing
     *                               the name of the user id.
     * @param string        $email   optional. If <i>$name</i> is
     *                               specified as a string, this is
     *                               the email address of the user id.
     * @param string        $comment optional. If <i>$name</i> is
     *                               specified as a string, this is
     *                               the comment of the user id.
     *
     * @return Key the newly generated key.
     *
     * @throws Exceptions\KeyNotCreatedException if the key parameters are
     *         incorrect, if an unknown error occurs during key generation, or
     *         if the newly generated key is not found in the keyring.
     *
     * @throws Exceptions\Exception if an unknown or unexpected error occurs.
     *         Use the <kbd>debug</kbd> option and file a bug report if these
     *         exceptions occur.
     */
    public function generateKey($name, $email = '', $comment = ''): Key
    {
        $handle = uniqid('key', true);

        $userId = $this->getUserId($name, $email, $comment);

        $keyParams = [
            'Key-Type'      => $this->keyAlgorithm,
            'Key-Length'    => $this->keySize,
            'Key-Usage'     => $this->getUsage($this->keyUsage),
            'Subkey-Type'   => $this->subKeyAlgorithm,
            'Subkey-Length' => $this->subKeySize,
            'Subkey-Usage'  => $this->getUsage($this->subKeyUsage),
            'Handle'        => $handle,
        ];

        if ($this->expirationDate != 0) {
            // GnuPG only accepts granularity of days
            $expirationDate = date('Y-m-d', $this->expirationDate);
            $keyParams['Expire-Date'] = $expirationDate;
        }

        if (strlen($this->passphrase)) {
            $keyParams['Passphrase'] = $this->passphrase;
        }

        $name    = $userId->getName();
        $email   = $userId->getEmail();
        $comment = $userId->getComment();

        if (strlen($name) > 0) {
            $keyParams['Name-Real'] = $name;
        }

        if (strlen($email) > 0) {
            $keyParams['Name-Email'] = $email;
        }

        if (strlen($comment) > 0) {
            $keyParams['Name-Comment'] = $comment;
        }

        $keyParamsFormatted = [];
        foreach ($keyParams as $name => $value) {
            $keyParamsFormatted[] = $name . ': ' . $value;
        }

        // This is required in GnuPG 2.1
        if (!strlen($this->passphrase)) {
            $keyParamsFormatted[] = '%no-protection';
        }

        $input = implode("\n", $keyParamsFormatted) . "\n%commit\n";

        $this->engine->reset();
        $this->engine->setProcessData('Handle', $handle);
        $this->engine->setInput($input);
        $this->engine->setOutput($output);
        $this->engine->setOperation('--gen-key', ['--batch']);

        try {
            $this->engine->run();
        } catch (Exceptions\InvalidKeyParamsException $e) {
            switch ($this->engine->getProcessData('LineNumber')) {
                case 1:
                    throw new Exceptions\InvalidKeyParamsException(
                        'Invalid primary key algorithm specified.',
                        0,
                        $this->keyAlgorithm,
                        $this->keySize,
                        $this->keyUsage
                    );
                case 4:
                    throw new Exceptions\InvalidKeyParamsException(
                        'Invalid sub-key algorithm specified.',
                        0,
                        $this->subKeyAlgorithm,
                        $this->subKeySize,
                        $this->subKeyUsage
                    );
                default:
                    throw $e;
            }
        }

        $fingerprint = $this->engine->getProcessData('KeyCreated');
        $keys        = $this->_getKeys($fingerprint);

        if (count($keys) === 0) {
            throw new Exceptions\KeyNotCreatedException(
                sprintf('Newly created key "%s" not found in keyring.', $fingerprint)
            );
        }

        return $keys[0];
    }

    /**
     * Builds a GnuPG key usage string suitable for key generation
     *
     * See <kbd>doc/DETAILS</kbd> in the
     * {@link https://www.gnupg.org/download/ GPG distribution} for detailed
     * information on the key usage format.
     *
     * @param int $usage A bitwise combination of the key usages. This is
     *                   a combination of the \Crypt\GPG\SubKey::USAGE_* constants.
     *
     * @return string The key usage string.
     */
    protected function getUsage($usage): string
    {
        $map = [
            SubKey::USAGE_ENCRYPT        => 'encrypt',
            SubKey::USAGE_SIGN           => 'sign',
            SubKey::USAGE_CERTIFY        => 'cert',
            SubKey::USAGE_AUTHENTICATION => 'auth',
        ];

        // cert is always used for primary keys and does not need to be
        // specified
        $usage &= ~SubKey::USAGE_CERTIFY;

        $usageArray = [];

        foreach ($map as $key => $value) {
            if (($usage & $key) === $key) {
                $usageArray[] = $value;
            }
        }

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

    /**
     * Gets a user id object from parameters
     *
     * @param string|UserId $name    either a {@link \Crypt\GPG\UserId}
     *                               object, or a string containing
     *                               the name of the user id.
     * @param string        $email   optional. If <i>$name</i> is
     *                               specified as a string, this is
     *                               the email address of the user id.
     * @param string        $comment optional. If <i>$name</i> is
     *                               specified as a string, this is
     *                               the comment of the user id.
     *
     * @return UserId a user id object for the specified parameters.
     */
    protected function getUserId($name, $email = '', $comment = ''): UserId
    {
        if ($name instanceof UserId) {
            $userId = $name;
        } else {
            $userId = new UserId();
            $userId->setName($name)->setEmail($email)->setComment($comment);
        }

        return $userId;
    }
}
