diff --git a/composer.json b/composer.json index 2f9ba64..cdb9db0 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "vimeo/psalm": "^5.8", "phpstan/phpstan": "^1.10", "squizlabs/php_codesniffer": "^3.7", - "slevomat/coding-standard": "^8.8" + "slevomat/coding-standard": "^8.8", + "ext-openssl": "*" }, "scripts": { "test": [ diff --git a/src/OpenIDConnectServiceProvider.php b/src/OpenIDConnectServiceProvider.php index e196582..f49e081 100644 --- a/src/OpenIDConnectServiceProvider.php +++ b/src/OpenIDConnectServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Jose\Component\KeyManagement\JWKFactory; use MinVWS\OpenIDConnectLaravel\Http\Responses\LoginResponse; use MinVWS\OpenIDConnectLaravel\Http\Responses\LoginResponseInterface; use MinVWS\OpenIDConnectLaravel\OpenIDConfiguration\OpenIDConfigurationLoader; @@ -94,8 +95,16 @@ protected function registerClient(): void protected function registerJweDecryptInterface(): void { + if (empty(config('oidc.decryption_key_path'))) { + $this->app->singleton(JweDecryptInterface::class, function () { + return null; + }); + return; + } + $this->app->singleton(JweDecryptInterface::class, function (Application $app) { - return new JweDecryptService(decryptionKeyPath: $app['config']->get('oidc.decryption_key_path')); + $jwk = JWKFactory::createFromKeyFile($app['config']->get('oidc.decryption_key_path')); + return new JweDecryptService(decryptionKey: $jwk); }); } diff --git a/src/Services/JWE/JweDecryptService.php b/src/Services/JWE/JweDecryptService.php index 57168dc..aaf557c 100644 --- a/src/Services/JWE/JweDecryptService.php +++ b/src/Services/JWE/JweDecryptService.php @@ -13,27 +13,36 @@ use Jose\Component\Encryption\JWEDecrypter; use Jose\Component\Encryption\Serializer\CompactSerializer; use Jose\Component\Encryption\Serializer\JWESerializerManager; -use Jose\Component\KeyManagement\JWKFactory; class JweDecryptService implements JweDecryptInterface { + /** + * @param JWK $decryptionKey + * @param JWESerializerManager $serializerManager + * @param JWEDecrypter $jweDecrypter + * phpcs:disable Squiz.Functions.MultiLineFunctionDeclaration.Indent -- waiting for phpcs 3.8.0 + */ public function __construct( - protected string $decryptionKeyPath, + protected JWK $decryptionKey, + protected JWESerializerManager $serializerManager = new JWESerializerManager([new CompactSerializer()]), + protected JWEDecrypter $jweDecrypter = new JWEDecrypter( + new AlgorithmManager([new RSAOAEP()]), + new AlgorithmManager([new A128CBCHS256()]), + new CompressionMethodManager([new Deflate()]) + ), ) { } /** + * phpcs:enable * @throws JweDecryptException */ public function decrypt(string $jweString): string { - $jweDecrypter = $this->getDecrypter(); - - $serializerManager = new JWESerializerManager([new CompactSerializer()]); - $jwe = $serializerManager->unserialize($jweString); + $jwe = $this->serializerManager->unserialize($jweString); // Success of decryption, $jwe is now decrypted - $success = $jweDecrypter->decryptUsingKey($jwe, $this->getDecryptionKey(), 0); + $success = $this->jweDecrypter->decryptUsingKey($jwe, $this->decryptionKey, 0); if (!$success) { throw new JweDecryptException('Failed to decrypt JWE'); } @@ -45,22 +54,4 @@ public function decrypt(string $jweString): string return $payload; } - - protected function getDecrypter(): JWEDecrypter - { - $keyEncryptionAlgorithmManager = new AlgorithmManager([new RSAOAEP()]); - $contentEncryptionAlgorithmManager = new AlgorithmManager([new A128CBCHS256()]); - $compressionMethodManager = new CompressionMethodManager([new Deflate()]); - - return new JWEDecrypter( - $keyEncryptionAlgorithmManager, - $contentEncryptionAlgorithmManager, - $compressionMethodManager - ); - } - - protected function getDecryptionKey(): JWK - { - return JWKFactory::createFromKeyFile($this->decryptionKeyPath); - } } diff --git a/tests/Unit/Services/JWE/JweDecryptServiceTest.php b/tests/Unit/Services/JWE/JweDecryptServiceTest.php new file mode 100644 index 0000000..f3c56c4 --- /dev/null +++ b/tests/Unit/Services/JWE/JweDecryptServiceTest.php @@ -0,0 +1,226 @@ +generateOpenSSLKey(); + $this->decryptionKeyResource = $keyResource; + $this->decryptionKey = JWKFactory::createFromKeyFile(stream_get_meta_data($keyResource)['uri']); + $this->x509Certificate = $this->generateX509Certificate($key); + } + + protected function tearDown(): void + { + if (is_resource($this->decryptionKeyResource)) { + fclose($this->decryptionKeyResource); + } + + parent::tearDown(); + } + + public function testServiceCanBeCreated(): void + { + $jweDecryptService = new JweDecryptService($this->decryptionKey); + + $this->assertInstanceOf(JweDecryptService::class, $jweDecryptService); + } + + /** + * @throws JweDecryptException + * @throws JsonException + */ + public function testJweDecryption(): void + { + $payload = $this->buildExamplePayload(); + + $jwe = $this->buildJweString( + payload: $payload, + recipient: JWKFactory::createFromX509Resource($this->x509Certificate) + ); + + $jweDecryptService = new JweDecryptService($this->decryptionKey); + $decryptedPayload = $jweDecryptService->decrypt($jwe); + + $this->assertEquals($payload, $decryptedPayload); + } + + /** + * @throws JweDecryptException + * @throws JsonException + */ + public function testJweDecryptionThrowsExceptionWhenKeyIsNotCorrect(): void + { + $this->expectException(JweDecryptException::class); + $this->expectExceptionMessage('Failed to decrypt JWE'); + + // Create different key + [$key, $keyResource] = $this->generateOpenSSLKey(); + $jwk = JWKFactory::createFromKeyFile(stream_get_meta_data($keyResource)['uri']); + + // Build JWE for default certificate + $payload = $this->buildExamplePayload(); + $jwe = $this->buildJweString( + payload: $payload, + recipient: JWKFactory::createFromX509Resource($this->x509Certificate) + ); + + // Try to decrypt with different key + $jweDecryptService = new JweDecryptService($jwk); + $jweDecryptService->decrypt($jwe); + } + + /** + * @throws JweDecryptException + * @throws JsonException + */ + public function testJweDecryptionThrowsExceptionWhenPayloadIsNull(): void + { + $this->expectException(JweDecryptException::class); + $this->expectExceptionMessage('Payload of JWE is null'); + + $jweMock = Mockery::mock(JWE::class); + $jweMock + ->shouldReceive('getPayload') + ->andReturn(null); + + $decryptionKey = Mockery::mock(JWK::class); + $serializerManager = Mockery::mock(JWESerializerManager::class); + $serializerManager + ->shouldReceive('unserialize') + ->with('something') + ->andReturn($jweMock); + + $jweDecrypter = Mockery::mock(JWEDecrypter::class); + $jweDecrypter + ->shouldReceive('decryptUsingKey') + ->andReturn(true); + + $decryptService = new JweDecryptService( + $decryptionKey, + $serializerManager, + $jweDecrypter, + ); + + $decryptService->decrypt('something'); + } + + protected function buildJweString(string $payload, JWK $recipient): string + { + // Create the JWE builder object + $jweBuilder = new JWEBuilder( + new AlgorithmManager([new RSAOAEP()]), + new AlgorithmManager([new A128CBCHS256()]), + new CompressionMethodManager([new Deflate()]) + ); + + // Build the JWE + $jwe = $jweBuilder + ->create() + ->withPayload($payload) + ->withSharedProtectedHeader([ + 'alg' => 'RSA-OAEP', + 'enc' => 'A128CBC-HS256', + 'zip' => 'DEF', + ]) + ->addRecipient($recipient) + ->build(); + + // Get the compact serialization of the JWE + return (new CompactSerializer())->serialize($jwe, 0); + } + + /** + * @throws JsonException + */ + protected function buildExamplePayload(): string + { + return json_encode([ + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 3600, + 'iss' => 'My service', + 'aud' => 'Your application', + ], JSON_THROW_ON_ERROR); + } + + /** + * Generate OpenSSL Key and return the tempfile resource + * @return array{OpenSSLAsymmetricKey, resource} + */ + protected function generateOpenSSLKey(): array + { + $file = tmpfile(); + if (!is_resource($file)) { + throw new RuntimeException('Could not create temporary file'); + } + + $key = openssl_pkey_new([ + 'private_key_bits' => 512, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + if (!$key instanceof OpenSSLAsymmetricKey) { + throw new RuntimeException('Could not generate private key'); + } + + openssl_pkey_export($key, $privateKey); + fwrite($file, $privateKey); + + return [$key, $file]; + } + + /** + * Generate X509 certificate + * @param OpenSSLAsymmetricKey $key + * @return OpenSSLCertificate + */ + protected function generateX509Certificate(OpenSSLAsymmetricKey $key): OpenSSLCertificate + { + $csr = openssl_csr_new([], $key); + if (!$csr instanceof OpenSSLCertificateSigningRequest) { + throw new RuntimeException('Could not generate CSR'); + } + + $certificate = openssl_csr_sign($csr, null, $key, 365); + if (!$certificate instanceof OpenSSLCertificate) { + throw new RuntimeException('Could not generate X509 certificate'); + } + + return $certificate; + } +}