diff --git a/composer.json b/composer.json index 5e69871b..24b39a5e 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "ext-openssl": "*", "ext-sodium": "*", + "phpseclib/phpseclib": "^3.0.36", "psr/clock": "^1.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 906763ca..8c97b4b1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,235 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f6da93713398752c6e06805659ca09e4", + "content-hash": "d0c70f2f7595443761936bc2ce91d5df", "packages": [ + { + "name": "paragonie/constant_time_encoding", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/df1e7fde177501eee2037dd159cf04f5f301a512", + "reference": "df1e7fde177501eee2037dd159cf04f5f301a512", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4|^5" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2024-05-08T12:36:18+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.42", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.42" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2024-09-16T03:06:04+00:00" + }, { "name": "psr/clock", "version": "1.0.0", diff --git a/src/Signer/RsaPss.php b/src/Signer/RsaPss.php new file mode 100644 index 00000000..59594269 --- /dev/null +++ b/src/Signer/RsaPss.php @@ -0,0 +1,72 @@ +contents(), $key->passphrase()); + } catch (NoKeyLoadedException $e) { + throw new InvalidKeyProvided('It was not possible to parse your key, reason: ' . $e->getMessage()); + } + + if (! $private instanceof PrivateKey) { + throw InvalidKeyProvided::incompatibleKeyType('RSA', $private::class); + } + + if ($private->getLength() < self::MINIMUM_KEY_LENGTH) { + throw InvalidKeyProvided::tooShort(self::MINIMUM_KEY_LENGTH, $private->getLength()); + } + + $signature = $private + ->withPadding(RSA::SIGNATURE_PSS) + ->withHash($this->algorithm()) + ->withMGFHash($this->algorithm()) + ->sign($payload); + + assert(is_string($signature) && $signature !== ''); + + return $signature; + } + + final public function verify(string $expected, string $payload, Key $key): bool + { + try { + $public = PublicKeyLoader::loadPublicKey($key->contents()); + } catch (NoKeyLoadedException $e) { + throw new InvalidKeyProvided('It was not possible to parse your key, reason: ' . $e->getMessage()); + } + + if (! $public instanceof PublicKey) { + throw InvalidKeyProvided::incompatibleKeyType('RSA', $public::class); + } + + return $public + ->withPadding(RSA::SIGNATURE_PSS) + ->withHash($this->algorithm()) + ->withMGFHash($this->algorithm()) + ->verify($payload, $expected); + } + + /** + * Returns which algorithm to be used to create/verify the signature (using phpseclib hash identifiers) + * + * @internal + */ + abstract public function algorithm(): string; +} diff --git a/src/Signer/RsaPss/Sha256.php b/src/Signer/RsaPss/Sha256.php new file mode 100644 index 00000000..f7cc5e36 --- /dev/null +++ b/src/Signer/RsaPss/Sha256.php @@ -0,0 +1,19 @@ + ['ES256', 'ES384', 'ES512'], 'eddsa' => ['EdDSA'], 'blake2b' => ['BLAKE2B'], + 'rsapss' => ['PS256', 'PS384', 'PS512'], ]; protected const PAYLOAD = "It\xe2\x80\x99s a dangerous business, Frodo, going out your door. You step onto the road" @@ -96,6 +97,20 @@ public function blake2bAlgorithms(): iterable yield from $this->iterateAlgorithms('blake2b'); } + #[Bench\Subject] + #[Bench\ParamProviders('rsaPssAlgorithms')] + #[Bench\Groups(['rsapss', 'asymmetric'])] + public function rsaPss(): void + { + $this->runBenchmark(); + } + + /** @return iterable */ + public function rsaPssAlgorithms(): iterable + { + yield from $this->iterateAlgorithms('rsapss'); + } + abstract protected function runBenchmark(): void; protected function resolveAlgorithm(string $name): Signer @@ -112,6 +127,9 @@ protected function resolveAlgorithm(string $name): Signer 'ES512' => new Signer\Ecdsa\Sha512(), 'EdDSA' => new Signer\Eddsa(), 'BLAKE2B' => new Signer\Blake2b(), + 'PS256' => new Signer\RsaPss\Sha256(), + 'PS384' => new Signer\RsaPss\Sha384(), + 'PS512' => new Signer\RsaPss\Sha512(), default => throw new RuntimeException('Unknown algorithm'), }; } @@ -132,6 +150,7 @@ protected function resolveSigningKey(string $name): Key 'K3NWT0XqaH+4jgi42gQmHnFE+HTPVhFYi3u4DFJ3OpRHRMt/aGRBoKD/Pt5H/iYgGCla7Q04CdjOUpLSrjZhtg==', ), 'BLAKE2B' => InMemory::base64Encoded('b6DNRcX2SFapbICe6lXWYoOZA+JXL/dvkfWiv2hJv3Y='), + 'PS256', 'PS384', 'PS512' => InMemory::file(__DIR__ . '/Rsa/private.key'), default => throw new RuntimeException('Unknown algorithm'), }; } @@ -145,6 +164,7 @@ protected function resolveVerificationKey(string $name): Key 'ES384' => InMemory::file(__DIR__ . '/Ecdsa/public-384.key'), 'ES512' => InMemory::file(__DIR__ . '/Ecdsa/public-521.key'), 'EdDSA' => InMemory::base64Encoded('R0TLf2hkQaCg/z7eR/4mIBgpWu0NOAnYzlKS0q42YbY='), + 'PS256', 'PS384', 'PS512' => InMemory::file(__DIR__ . '/Rsa/public.key'), default => throw new RuntimeException('Unknown algorithm'), }; } diff --git a/tests/Signer/RsaPss/RsaPssTestCase.php b/tests/Signer/RsaPss/RsaPssTestCase.php new file mode 100644 index 00000000..bcadb6f7 --- /dev/null +++ b/tests/Signer/RsaPss/RsaPssTestCase.php @@ -0,0 +1,128 @@ +algorithmId(), $this->algorithm()->algorithmId()); + } + + #[PHPUnit\Test] + final public function signatureAlgorithmMustBeCorrect(): void + { + self::assertSame($this->signatureAlgorithm(), $this->algorithm()->algorithm()); + } + + #[PHPUnit\Test] + public function signShouldReturnAValidOpensslSignature(): void + { + $payload = 'testing'; + $signature = $this->algorithm()->sign($payload, self::$rsaKeys['private']); + + $publicKey = PublicKeyLoader::loadPublicKey(self::$rsaKeys['public']->contents()); + assert($publicKey instanceof RSA\PublicKey); + + self::assertTrue( + $publicKey + ->withHash($this->signatureAlgorithm()) + ->withMGFHash($this->signatureAlgorithm()) + ->verify($payload, $signature), + ); + } + + #[PHPUnit\Test] + public function signShouldRaiseAnExceptionWhenKeyIsNotParseable(): void + { + $this->expectException(InvalidKeyProvided::class); + $this->expectExceptionMessage('It was not possible to parse your key, reason: '); + + $this->algorithm()->sign('testing', InMemory::plainText('blablabla')); + } + + #[PHPUnit\Test] + public function signShouldRaiseAnExceptionWhenKeyTypeIsNotRsa(): void + { + $this->expectException(InvalidKeyProvided::class); + $this->expectExceptionMessage( + 'The type of the provided key is not "RSA", "phpseclib3\Crypt\EC\PrivateKey" provided', + ); + + $this->algorithm()->sign('testing', self::$ecdsaKeys['private']); + } + + #[PHPUnit\Test] + public function signShouldRaiseAnExceptionWhenKeyLengthIsBelowMinimum(): void + { + $this->expectException(InvalidKeyProvided::class); + $this->expectExceptionMessage('Key provided is shorter than 2048 bits, only 512 bits provided'); + + $this->algorithm()->sign('testing', self::$rsaKeys['private_short']); + } + + #[PHPUnit\Test] + public function verifyShouldReturnTrueWhenSignatureIsValid(): void + { + $payload = 'testing'; + $privateKey = PublicKeyLoader::loadPrivateKey(self::$rsaKeys['private']->contents()); + assert($privateKey instanceof RSA\PrivateKey); + + $signature = $privateKey + ->withHash($this->signatureAlgorithm()) + ->withMGFHash($this->signatureAlgorithm()) + ->sign($payload); + + self::assertTrue($this->algorithm()->verify($signature, $payload, self::$rsaKeys['public'])); + } + + #[PHPUnit\Test] + public function verifyShouldRaiseAnExceptionWhenKeyIsNotParseable(): void + { + $this->expectException(InvalidKeyProvided::class); + $this->expectExceptionMessage('It was not possible to parse your key, reason:'); + + $this->algorithm()->verify('testing', 'testing', InMemory::plainText('blablabla')); + } + + #[PHPUnit\Test] + public function verifyShouldRaiseAnExceptionWhenKeyTypeIsNotRsa(): void + { + $this->expectException(InvalidKeyProvided::class); + $this->expectExceptionMessage( + 'The type of the provided key is not "RSA", "phpseclib3\Crypt\EC\PublicKey" provided', + ); + + $this->algorithm()->verify('testing', 'testing', self::$ecdsaKeys['public1']); + } +} diff --git a/tests/Signer/RsaPss/Sha256Test.php b/tests/Signer/RsaPss/Sha256Test.php new file mode 100644 index 00000000..e4e49677 --- /dev/null +++ b/tests/Signer/RsaPss/Sha256Test.php @@ -0,0 +1,32 @@ +