From 384646df40c9ff36df07b6cb4c9e608045f7b576 Mon Sep 17 00:00:00 2001 From: "R. Eric Wheeler" Date: Tue, 28 Mar 2017 17:57:40 -0700 Subject: [PATCH] Initial commit --- .gitignore | 12 + .php_cs | 60 +++ .../Command/Tests/SignCertCommandTest.php | 28 ++ composer.json | 40 ++ sign.php | 31 ++ .../Console/Command/SignCertCommand.php | 385 ++++++++++++++++++ .../Exception/PhpSecLibErrorToException.php | 32 ++ 7 files changed, 588 insertions(+) create mode 100644 .gitignore create mode 100644 .php_cs create mode 100644 Tests/Sikofitt/Console/Command/Tests/SignCertCommandTest.php create mode 100644 composer.json create mode 100644 sign.php create mode 100644 src/Sikofitt/Console/Command/SignCertCommand.php create mode 100644 src/Sikofitt/PHPSecLib/Exception/PhpSecLibErrorToException.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd9e12f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +vendor/ +composer.lock +*~ +.php_cs.cache +data/ +*.crt +*.csr +*.key +*.pub +.idea + + diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..b264007 --- /dev/null +++ b/.php_cs @@ -0,0 +1,60 @@ +. +EOF; + + +return PhpCsFixer\Config::create() + ->setRiskyAllowed(true) + ->setRules( + [ + '@Symfony' => true, + 'header_comment' => ['header' => $header], + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'no_mixed_echo_print' => ['use' => 'print'], + 'strict_param' => true, + 'strict_comparison' => true, + 'single_import_per_statement' => false, + 'phpdoc_order' => true, + 'array_syntax' => ['syntax' => 'short'], + 'phpdoc_add_missing_param_annotation' => true, + 'psr4' => true, + 'phpdoc_var_without_name' => false, + 'no_extra_consecutive_blank_lines' => [ + 'break', + 'continue', + 'extra', + 'return', + 'throw', + 'parenthesis_brace_block', + 'square_brace_block', + 'curly_brace_block' + ], + ] + )->setFinder( + PhpCsFixer\Finder::create() + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ->name('*.php') + ->in([ + __DIR__, + 'src', + 'bin', + ]) + ); \ No newline at end of file diff --git a/Tests/Sikofitt/Console/Command/Tests/SignCertCommandTest.php b/Tests/Sikofitt/Console/Command/Tests/SignCertCommandTest.php new file mode 100644 index 0000000..bdf63bd --- /dev/null +++ b/Tests/Sikofitt/Console/Command/Tests/SignCertCommandTest.php @@ -0,0 +1,28 @@ +. + */ + +namespace Sikofitt\Console\Command\Tests; + +use PHPUnit\Framework\TestCase; + +class SignCertCommandTest extends TestCase +{ + // TODO: Add tests +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c83633c --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "sikofitt/self-sign-cert", + "description": "Easy self signed certs", + "type": "project", + "require": { + "php":">=7.1", + "roave/security-advisories": "dev-master", + "egulias/email-validator": "^2.1", + "mledoze/countries": "^1.8", + "phpseclib/phpseclib": "^2.0", + "respect/validation": "^1.1", + "symfony/console": "^3.2", + "webmozart/json": "^1.2" + }, + "license": "GPL-3.0", + "authors": [ + { + "name": "R. Eric Wheeler", + "email": "sikofitt@gmail.com" + } + ], + "autoload": { + "psr-4": { + "Sikofitt\\":"src/Sikofitt/" + } + }, + "autoload-dev": { + "psr-4": { + "Sikofitt\\Tests\\":"Tests/" + } + }, + "config": { + "sort-packages": true + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.1", + "phpunit/phpunit": "^6.0", + "symfony/var-dumper": "^3.2" + } +} diff --git a/sign.php b/sign.php new file mode 100644 index 0000000..d8dc4d9 --- /dev/null +++ b/sign.php @@ -0,0 +1,31 @@ +. + */ + +require __DIR__.'/vendor/autoload.php'; + +$application = new \Symfony\Component\Console\Application(); + +$dataDir = dirname(__DIR__.'/vendor/mledoze/countries/dist/countries.json'); + +$application->add(new Sikofitt\Console\Command\SignCertCommand(__DIR__)); +$application->add(new MLD\Console\Command\ExportCommand($dataDir.'/countries.json', __DIR__.'/data')); +$application->run(); diff --git a/src/Sikofitt/Console/Command/SignCertCommand.php b/src/Sikofitt/Console/Command/SignCertCommand.php new file mode 100644 index 0000000..33f8d8d --- /dev/null +++ b/src/Sikofitt/Console/Command/SignCertCommand.php @@ -0,0 +1,385 @@ +. + */ + +namespace Sikofitt\Console\Command; + +use Egulias\EmailValidator\EmailValidator; +use Egulias\EmailValidator\Validation\DNSCheckValidation; +use Egulias\EmailValidator\Validation\MultipleValidationWithAnd; +use Egulias\EmailValidator\Validation\RFCValidation; +use Egulias\EmailValidator\Validation\SpoofCheckValidation; +use phpseclib\Crypt\RSA; +use phpseclib\File\X509; +use Sikofitt\PHPSecLib\Exception\PhpSecLibErrorToException; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ChoiceQuestion; +use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Style\SymfonyStyle; +use Webmozart\Json\JsonDecoder; + +/** + * Class SignCertCommand. + */ +class SignCertCommand extends Command +{ + private $distinguishedName; + private $rootDir; + private $digestAlgo; + private $privateKeyBits; + private $domains = []; + + /** + * SignCertCommand constructor. + * + * @param string $rootDir + * + * @throws LogicException + */ + public function __construct(string $rootDir) + { + parent::__construct(null); + $this->rootDir = $rootDir; + set_error_handler([$this, 'errorHandler']); + } + + /** + * @throws PhpSecLibErrorToException + */ + public static function errorHandler() + { + [$errorNumber, $errorString, $errorFile, $errorLine] = func_get_args(); + + $message = sprintf( + "%s\nError # %s\nFile : %s\nLine : %s", + $errorString, + $errorNumber, + $errorFile, + $errorLine + ); + throw new PhpSecLibErrorToException($message, $errorNumber); + } + + /** + * @throws InvalidArgumentException + */ + public function configure(): void + { + $hostname = gethostname(); + $this->setName('sign') + ->setAliases(['cert', 'sign:cert']) + ->setDescription('Self sign certificates easily.') + ->addOption( + 'years', + 'y', + InputOption::VALUE_OPTIONAL, + 'Number of years for the certificate', + 10 + ) + ->addOption( + 'csrname', + null, + InputOption::VALUE_OPTIONAL, + 'Signing request filename.', + $hostname.'.csr' + ) + ->addOption( + 'keyname', + null, + InputOption::VALUE_OPTIONAL, + 'Private key filename.', + $hostname.'.key' + ) + ->addOption( + 'pkeyname', + null, + InputOption::VALUE_OPTIONAL, + 'Public key filename.', + $hostname.'.pub' + ) + ->addOption( + 'certname', + null, + InputOption::VALUE_OPTIONAL, + 'Certificate filename.', + $hostname.'.crt' + ); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @throws InvalidOptionException + * @throws InvalidArgumentException + * @throws LogicException + */ + public function interact( + InputInterface $input, + OutputInterface $output + ): void { + $jsonDecoder = new JsonDecoder(); + $io = new SymfonyStyle($input, $output); + try { + $countries = (array) $jsonDecoder->decodeFile($this->rootDir.'/data/countries.json'); + } catch (\Exception $e) { + throw new LogicException($e->getMessage(), $e->getCode(), $e); + } + $choices = []; + + foreach ($countries as $country) { + /** @var array $altSpellings */ + $altSpellings = (array) $country->altSpellings; + foreach ($altSpellings as $altSpelling) { + if (preg_match('/\s/', $altSpelling)) { + continue; + } + $twoLetterCode = $altSpelling; //substr($altSpelling, 0, 3); + $choices[mb_strtolower($twoLetterCode)] = mb_strtoupper($twoLetterCode); + } + } + + $countryQuestion = new Question('Country Name'); + $countryQuestion->setAutocompleterValues($choices); + $countryQuestion->setNormalizer(function ($value) { + return mb_strtoupper(trim($value)); + }); + + $countryQuestion->setValidator(function ($value) use ($choices) { + if (false === in_array(mb_strtoupper($value), $choices, true)) { + throw new InvalidOptionException($value.' is not a valid country choice.'); + } + + return $value; + }); + $countryName = $io->askQuestion($countryQuestion); + $stateOrProvinceName = $io->ask('State or Province Name'); + $localityName = $io->ask('Locality Name'); + $organizationName = $io->ask('Organization Name'); + $organizationalUnitName = $io->ask('Organizational Unit Name'); + $commonName = $io->ask('Common Name', gethostname(), function ($value) { + if (null === $value) { + return gethostname(); + } + + return $value; + }); + $emailAddress = $io->ask('Email address (Optional)', null, + function ($value) use ($io) { + if (null === $value) { + return $value; + } + $emailValidator = new EmailValidator(); + $validators = new MultipleValidationWithAnd([ + new RFCValidation(), + new SpoofCheckValidation(), + new DNSCheckValidation(), + ]); + + if (true === $emailValidator->isValid($value, $validators)) { + if ($emailValidator->hasWarnings()) { + $io->warning($emailValidator->getWarnings()); + } + + return $value; + } + + throw new InvalidOptionException($emailValidator->getError()->getMessage()); + }); + + $signingAlgos = [ + 'md2' => 'md2', + 'md5' => 'md5', + 'sha1' => 'sha1', + 'sha256' => 'sha256', + 'sha384' => 'sha384', + 'sha512' => 'sha512', + ]; + $this->domains = [$commonName]; + $domainQuestion = new Question('Domains (hit enter on a blank line to stop adding', + false); + $domainQuestion->setValidator(function ($value) { + if (false !== $value) { + return $value; + } + + return false; + }); + while (false !== $domain = $io->askQuestion($domainQuestion)) { + $this->domains[] = $domain; + } + + $digestAlgoQuestion = new ChoiceQuestion('Digest Method', $signingAlgos, + 'sha512'); + + $digestAlgoQuestion->setValidator(function ($value) use ($signingAlgos) { + if (false === in_array($value, $signingAlgos, + true) + ) { + throw new InvalidOptionException('Invalid digest algorithm : '.$value.'.'); + } + + return $value; + }); + $digestAlgoQuestion->setAutocompleterValues($signingAlgos); + $privateKeyBitsQuestion = new Question('Private key bits', 2048); + $privateKeyBitsQuestion->setNormalizer(function ($value) { + return (int) $value; + }); + $privateKeyBitsQuestion->setValidator(function ($value) { + if ($value < 384) { + throw new InvalidOptionException('Private key bytes should be greater than 384.'); + } + if (false === $this->validateBits($value, $this->digestAlgo)) { + throw new InvalidOptionException('Not enough bits for algorithm '.$this->digestAlgo); + } + + return $value; + }); + $this->digestAlgo = $io->askQuestion($digestAlgoQuestion); + $this->privateKeyBits = $io->askQuestion($privateKeyBitsQuestion); + + $this->distinguishedName = [ + 'countryName' => $countryName, + 'stateOrProvinceName' => $stateOrProvinceName, + 'localityName' => $localityName, + 'organizationName' => $organizationName, + 'organizationalUnitName' => $organizationalUnitName, + 'commonName' => $commonName, + ]; + if (null !== $emailAddress) { + $this->distinguishedName['emailAddress'] = $emailAddress; + } + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * + * @throws InvalidArgumentException + */ + public function execute( + InputInterface $input, + OutputInterface $output + ): void { + $io = new SymfonyStyle($input, $output); + if ($this->privateKeyBits > 4096) { + $io->warning('Please wait, this could take awhile with '.$this->privateKeyBits.' bits.'); + } + + $years = $input->getOption('years'); + $privateKeyObject = new RSA(); + $privateKeyObject->setEncryptionMode(RSA::ENCRYPTION_OAEP); + $privateKeyObject->setHash($this->digestAlgo); + $privateKeyObject->setMGFHash($this->digestAlgo); + $privateKeyObject->setSignatureMode(RSA::SIGNATURE_PKCS1); + $privateKeyParts = $privateKeyObject->createKey($this->privateKeyBits); + $privateKey = $privateKeyParts['privatekey']; + $publicKey = $privateKeyParts['publickey']; + $privateKeyObject->loadKey($privateKey); + + $pubKey = new RSA(); + $pubKey->loadKey($publicKey); + $pubKey->setPublicKey(); + $subject = new X509(); + $subject->setStartDate('now'); + $subject->setEndDate('now +'.$years.' years'); + $subject->setDN($this->distinguishedName, true); + if (count($this->domains) > 1) { + call_user_func_array([$subject, 'setDomain'], $this->domains); + } + $subject->setPublicKey($pubKey); + + $issuer = new X509(); + $issuer->setStartDate('now'); + $issuer->setEndDate('now +'.$years.' years'); + $issuer->setPrivateKey($privateKeyObject); + $issuer->setDN($subject->getDN()); + + $x509 = new X509(); + $x509->setStartDate('now'); + $x509->setEndDate('now +'.$years.' years'); + $csr = $issuer->signCSR($this->prefixHash($this->digestAlgo)); + + $result = $x509->sign($issuer, $subject, + $this->prefixHash($this->digestAlgo)); + + file_put_contents($input->getOption('keyname'), $privateKeyObject->getPrivateKey()); + file_put_contents($input->getOption('pkeyname'), $privateKeyObject->getPublicKey()); + file_put_contents($input->getOption('csrname'), $x509->saveCSR($csr)); + file_put_contents($input->getOption('certname'), $x509->saveX509($result)); + } + + /** + * @param int $bits + * @param string $algo + * + * @return bool + */ + private function validateBits(int $bits, string $algo): bool + { + // see http://tools.ietf.org/html/rfc3447#page-43 + switch ($algo) { + case 'sha256': + if ($bits < 496) { + return false; + } + + return true; + break; + case 'sha384': + if ($bits < 624) { + return false; + } + + return true; + break; + case 'sha512': + if ($bits < 752) { + return false; + } + + return true; + break; + default: + return true; + break; + } + } + + /** + * @param string $hash + * The hash to prefix + * + * @return string + * The hash prefixed with 'WithRSAEncryption' + */ + private function prefixHash(string $hash): string + { + return sprintf('%sWithRSAEncryption', $hash); + } +} diff --git a/src/Sikofitt/PHPSecLib/Exception/PhpSecLibErrorToException.php b/src/Sikofitt/PHPSecLib/Exception/PhpSecLibErrorToException.php new file mode 100644 index 0000000..94d2238 --- /dev/null +++ b/src/Sikofitt/PHPSecLib/Exception/PhpSecLibErrorToException.php @@ -0,0 +1,32 @@ +. + */ + +namespace Sikofitt\PHPSecLib\Exception; + +use Symfony\Component\Console\Exception\ExceptionInterface; + +/** + * Class PhpSecLibErrorToException. + */ +class PhpSecLibErrorToException extends \Exception implements ExceptionInterface +{ +}