diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e35438c3..3211e6f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: #get dependencies - name: Install dependencies + env: + COMPOSER_NO_BLOCKING: 1 run: composer install --prefer-dist --no-progress --no-suggest #get static analysis tool @@ -72,6 +74,8 @@ jobs: run: composer validate - name: Install dependencies + env: + COMPOSER_NO_BLOCKING: 1 run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite @@ -102,6 +106,8 @@ jobs: run: composer validate - name: Install dependencies + env: + COMPOSER_NO_BLOCKING: 1 run: composer install --prefer-dist --no-progress --no-suggest - name: Run test suite diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c1cc54..91e59e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ChangeLog ========= +2.2.7 +------------------- +- Security fix: JWE decryption now validates the `alg`/`enc` header fields against the client's configured algorithm before decrypting, preventing an algorithm-confusion/downgrade attack where a tampered response header could force use of the legacy, Bleichenbacher-vulnerable RSA1_5 key-management algorithm. + 2.2.6 ------------------- - Added a new notes field to the Payment object, allowing users to include supplementary information or comments related to a payment. diff --git a/src/Hyperwallet/Util/ApiClient.php b/src/Hyperwallet/Util/ApiClient.php index f51327dd..286b3f7e 100644 --- a/src/Hyperwallet/Util/ApiClient.php +++ b/src/Hyperwallet/Util/ApiClient.php @@ -21,7 +21,7 @@ class ApiClient { * * @var string */ - const VERSION = '2.2.3'; + const VERSION = '2.2.7'; /** * The Guzzle http client diff --git a/src/Hyperwallet/Util/HyperwalletEncryption.php b/src/Hyperwallet/Util/HyperwalletEncryption.php index 6f6896fe..73cb6529 100644 --- a/src/Hyperwallet/Util/HyperwalletEncryption.php +++ b/src/Hyperwallet/Util/HyperwalletEncryption.php @@ -136,6 +136,7 @@ public function encrypt($body) { public function decrypt($body) { $privateJweKey = $this->getPrivateJweKey(); $jwe = JOSE_JWT::decode($body); + $this->checkJweHeaderAlgorithm($jwe->header); $decryptedBody = $jwe->decrypt($privateJweKey); $publicJwsKey = $this->getPublicJwsKey(); @@ -351,6 +352,25 @@ public function checkJwsExpiration($header) { } } + /** + * Checks that the JWE header advertises the key-management algorithm and content-encryption method + * this client expects, before the header-controlled algorithm is ever used to decrypt with the + * private key. Prevents an attacker from forcing algorithm downgrade (e.g. to legacy RSA1_5) by + * tampering with the untrusted alg/enc header fields of an intercepted response. + * + * @param array $header JWE header array + * + * @throws HyperwalletException + */ + public function checkJweHeaderAlgorithm($header) { + if (!isset($header['alg']) || $header['alg'] !== $this->encryptionAlgorithm) { + throw new HyperwalletException('While trying to decrypt JWE, unexpected [alg] header found'); + } + if (!isset($header['enc']) || $header['enc'] !== $this->encryptionMethod) { + throw new HyperwalletException('While trying to decrypt JWE, unexpected [enc] header found'); + } + } + /** * Finds the path of composer vendor directory * diff --git a/tests/Hyperwallet/Tests/Util/HyperwalletEncryptionTest.php b/tests/Hyperwallet/Tests/Util/HyperwalletEncryptionTest.php index b3f96304..8ae705fe 100644 --- a/tests/Hyperwallet/Tests/Util/HyperwalletEncryptionTest.php +++ b/tests/Hyperwallet/Tests/Util/HyperwalletEncryptionTest.php @@ -137,6 +137,68 @@ public function testShouldThrowExceptionWhenJwsSignatureExpHeaderParamIsNotInteg } } + public function testShouldThrowExceptionWhenJweAlgHeaderDoesNotMatchExpectedAlgorithm() { + // Setup data + $header = array( + "alg" => "RSA1_5", + "enc" => "A256CBC-HS512" + ); + $clientPath = __DIR__ . "/../../../resources/private-jwkset1"; + $hyperwalletPath = __DIR__ . "/../../../resources/public-jwkset1"; + $encryption = new HyperwalletEncryption($clientPath, $hyperwalletPath); + + // Execute test + try { + $encryption->checkJweHeaderAlgorithm($header); + $this->fail('HyperwalletException expected'); + } catch (HyperwalletException $e) { + $this->assertEquals('While trying to decrypt JWE, unexpected [alg] header found', $e->getMessage()); + } + } + + public function testShouldThrowExceptionWhenJweEncHeaderDoesNotMatchExpectedEncryptionMethod() { + // Setup data + $header = array( + "alg" => "RSA-OAEP-256", + "enc" => "A128CBC-HS256" + ); + $clientPath = __DIR__ . "/../../../resources/private-jwkset1"; + $hyperwalletPath = __DIR__ . "/../../../resources/public-jwkset1"; + $encryption = new HyperwalletEncryption($clientPath, $hyperwalletPath); + + // Execute test + try { + $encryption->checkJweHeaderAlgorithm($header); + $this->fail('HyperwalletException expected'); + } catch (HyperwalletException $e) { + $this->assertEquals('While trying to decrypt JWE, unexpected [enc] header found', $e->getMessage()); + } + } + + public function testShouldRejectTamperedJweAlgHeaderBeforeDecryption() { + // Setup data: an attacker downgrades the alg header on an intercepted, otherwise valid JWE + $clientPath = __DIR__ . "/../../../resources/private-jwkset1"; + $hyperwalletPath = __DIR__ . "/../../../resources/public-jwkset1"; + $originalMessage = "Test message"; + $encryption = new HyperwalletEncryption($clientPath, $hyperwalletPath); + $encryptedMessage = $encryption->encrypt($originalMessage); + + $parts = explode('.', $encryptedMessage); + $header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true); + $header['alg'] = 'RSA1_5'; + $tamperedHeader = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '='); + $parts[0] = $tamperedHeader; + $tamperedMessage = implode('.', $parts); + + // Execute test + try { + $encryption->decrypt($tamperedMessage); + $this->fail('HyperwalletException expected'); + } catch (HyperwalletException $e) { + $this->assertEquals('While trying to decrypt JWE, unexpected [alg] header found', $e->getMessage()); + } + } + public function testShouldThrowExceptionWhenJwsSignatureHasExpired() { // Setup data $header = array(