Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ jobs:

#get dependencies
- name: Install dependencies
env:
COMPOSER_NO_BLOCKING: 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this param ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Composer's resolver was blocking PR with PHP version. phpunit/phpunit's older branches (needed for the PHP 5.6/7.1/7.2 test jobs) have advisories, so the resolver was left with only PHPUnit 9.x, which requires PHP ≥7.3 and broke composer install on the older PHP jobs in this repo's CI matrix.

phpunit is a require-dev-only testing dependency - it is never installed by consumers of this SDK. Setting COMPOSER_NO_BLOCKING=1 for CI's install step only affects dependency resolution for running our own test suite in CI, and has no effect on the SDK's production dependency tree. Verified with composer audit --no-dev that production dependencies have zero advisories.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

below is error it throwing , Hence we need to add COMPOSER_NO_BLOCKING=1

https://github.com/hyperwallet/php-sdk/actions/runs/36620439369/job/109584156782?pr=137

Screenshot 2026-09-30 at 4 22 01 PM

run: composer install --prefer-dist --no-progress --no-suggest

#get static analysis tool
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/Hyperwallet/Util/ApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ApiClient {
*
* @var string
*/
const VERSION = '2.2.3';
const VERSION = '2.2.7';

/**
* The Guzzle http client
Expand Down
20 changes: 20 additions & 0 deletions src/Hyperwallet/Util/HyperwalletEncryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
*
Expand Down
62 changes: 62 additions & 0 deletions tests/Hyperwallet/Tests/Util/HyperwalletEncryptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading