Initial commit

This commit is contained in:
R. Eric Wheeler 2017-03-28 17:57:40 -07:00
commit 384646df40
7 changed files with 588 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
vendor/
composer.lock
*~
.php_cs.cache
data/
*.crt
*.csr
*.key
*.pub
.idea

60
.php_cs Normal file
View File

@ -0,0 +1,60 @@
<?php
$header = <<<EOF
Self-Sign Cert
Copyright (C) 2017 http://blog.rewiv.com eric@rewiv.com
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
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',
])
);

View File

@ -0,0 +1,28 @@
<?php
/*
* Self-Sign Cert
* Copyright (C) 2017 http://blog.rewiv.com eric@rewiv.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Sikofitt\Console\Command\Tests;
use PHPUnit\Framework\TestCase;
class SignCertCommandTest extends TestCase
{
// TODO: Add tests
}

40
composer.json Normal file
View File

@ -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"
}
}

31
sign.php Normal file
View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Self-Sign Cert
* Copyright (C) 2017 http://blog.rewiv.com eric@rewiv.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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();

View File

@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
/*
* Self-Sign Cert
* Copyright (C) 2017 http://blog.rewiv.com eric@rewiv.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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);
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/*
* Self-Sign Cert
* Copyright (C) 2017 http://blog.rewiv.com eric@rewiv.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Sikofitt\PHPSecLib\Exception;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* Class PhpSecLibErrorToException.
*/
class PhpSecLibErrorToException extends \Exception implements ExceptionInterface
{
}