diff --git a/.github/workflows/netlicensing-php-demo.yml b/.github/workflows/netlicensing-php-demo.yml index 119a881..5d80f17 100644 --- a/.github/workflows/netlicensing-php-demo.yml +++ b/.github/workflows/netlicensing-php-demo.yml @@ -16,11 +16,11 @@ permissions: jobs: build: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - php-version: ['8.2', '8.3', '8.4'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 @@ -30,6 +30,7 @@ jobs: with: php-version: ${{ matrix.php-version }} coverage: none + extensions: curl, dom, libxml, mbstring, openssl tools: composer:v2 - name: Validate composer manifests @@ -47,11 +48,14 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress --no-interaction + - name: Audit dependencies for known vulnerabilities + run: composer audit + - name: Run PHPUnit run: vendor/bin/phpunit --testsuite "Application Test Suite" - name: Run demo scenario (latest PHP) - if: matrix.php-version == '8.4' + if: matrix.php-version == '8.5' run: | cd demo php NetLicensingDemo.php diff --git a/.github/workflows/netlicensing-php-dependency-test.yml b/.github/workflows/netlicensing-php-dependency-test.yml index cf52230..5926808 100644 --- a/.github/workflows/netlicensing-php-dependency-test.yml +++ b/.github/workflows/netlicensing-php-dependency-test.yml @@ -10,11 +10,11 @@ permissions: jobs: dependency-check: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - php-version: ['8.4'] + php-version: ['8.2', '8.5'] steps: - name: Setup PHP ${{ matrix.php-version }} @@ -32,3 +32,4 @@ jobs: run: | composer require --no-interaction labs64/netlicensingclient-php composer show labs64/netlicensingclient-php + composer audit diff --git a/README.md b/README.md index 0453e28..9d4279f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ PHP wrapper for Labs64 NetLicensing [RESTful API](https://netlicensing.io/wiki/r Visit Labs64 NetLicensing at https://netlicensing.io +## Requirements + +- PHP 8.2, 8.3, 8.4 or 8.5 with the `curl`, `dom`, `json`, `libxml`, `mbstring` and `openssl` extensions +- Run a patched PHP runtime: at least 8.2.35, 8.3.36, 8.4.27 or 8.5.12 (fixes for CVE-2026-109618 and CVE-2026-109619) + ## Installation ``` diff --git a/composer.json b/composer.json index 58dcf18..2f7cdb3 100644 --- a/composer.json +++ b/composer.json @@ -39,13 +39,15 @@ "require": { "php": ">=8.2", "php-curl-class/php-curl-class": "^11.0.0 || ^12.0.0 || ^13.0.0", - "fr3d/xmldsig": "^3.0", + "robrichards/xmlseclibs": "^3.1.5", "ext-mbstring": "*", "ext-json": "*", - "ext-dom": "*" + "ext-dom": "*", + "ext-openssl": "*", + "ext-libxml": "*" }, "require-dev": { - "phpunit/phpunit": "^10.5", + "phpunit/phpunit": "^11.5.50", "fakerphp/faker": "^1.23", "wp-cli/php-cli-tools": "^0.12 || ^0.13" }, diff --git a/examples/OfflineValidation.php b/examples/OfflineValidation.php index 8f22fca..8c5e8a7 100644 --- a/examples/OfflineValidation.php +++ b/examples/OfflineValidation.php @@ -10,12 +10,12 @@ public function testOfflineValidation() { try { // 1. Create context, for offline validation you only need to provide the public key. - $publicKey = file_get_contents(__DIR__ . '../resources/rsa_public.pem'); + $publicKey = file_get_contents(__DIR__ . '/resources/rsa_public.pem'); $context = new Context(); $context->setPublicKey($publicKey); // 2. Read the validation file. - $offlineValidation = file_get_contents(__DIR__ . '../resources/Isb-DEMO.xml'); + $offlineValidation = file_get_contents(__DIR__ . '/resources/Isb-DEMO.xml'); // 3. Validate. ValidationResult is same as if validation would be executed against the // NetLicensing service online. diff --git a/examples/OfflineValidationTest.php b/examples/OfflineValidationTest.php index c7e9579..a95270d 100644 --- a/examples/OfflineValidationTest.php +++ b/examples/OfflineValidationTest.php @@ -10,12 +10,12 @@ public function testOfflineValidation() { try { // 1. Create context, for offline validation you only need to provide the public key. - $publicKey = file_get_contents(__DIR__ . '../resources/rsa_public.pem'); + $publicKey = file_get_contents(__DIR__ . '/resources/rsa_public.pem'); $context = new Context(); $context->setPublicKey($publicKey); // 2. Read the validation file. - $offlineValidation = file_get_contents(__DIR__ . '../resources/Isb-DEMO.xml'); + $offlineValidation = file_get_contents(__DIR__ . '/resources/Isb-DEMO.xml'); // 3. Validate. ValidationResult is same as if validation would be executed against the // NetLicensing service online. diff --git a/phpunit.xml b/phpunit.xml index be31f49..661f1d5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,15 +1,32 @@ + failOnWarning="true" + failOnNotice="true" + failOnDeprecation="true"> ./tests + + + ./common + ./converter + ./entity + ./exception + ./service + ./util + ./vo + netlicensing.php + + diff --git a/service/NetLicensingService.php b/service/NetLicensingService.php index 2252a98..08a3be8 100644 --- a/service/NetLicensingService.php +++ b/service/NetLicensingService.php @@ -233,6 +233,8 @@ private function configure(Context $context): void $this->curl->setHeader('Authorization', 'Basic ' . base64_encode("apiKey:" . $context->getApiKey())); break; case Constants::ANONYMOUS_IDENTIFICATION: + // the curl instance is shared, don't send credentials left over from a previous context + $this->curl->removeHeader('Authorization'); break; default: throw new RestException("Unknown security mode"); diff --git a/service/ValidationService.php b/service/ValidationService.php index 3fe300f..2ab1252 100644 --- a/service/ValidationService.php +++ b/service/ValidationService.php @@ -10,6 +10,7 @@ use DateTime; use DOMDocument; +use DOMElement; use ErrorException; use Exception; @@ -75,15 +76,110 @@ static public function retrieveValidationFile(Context $context, string $number, * * @return ValidationResults|null result of the validation * @throws BadSignatureException + * @throws MalformedArgumentsException * @throws Exception */ static public function validateOffline(Context $context, string $validationFile, array &$meta = []): ?ValidationResults { - $validationDoc = new DOMDocument(); - $validationDoc->loadXML($validationFile); + $validationDoc = self::loadValidationFile($validationFile); SignatureUtils::check($context, $validationDoc); - return self::convertValidationResult($validationDoc); + return self::convertValidationResult(self::convertValidationDoc($validationDoc), $meta); + } + + /** + * @param string $validationFile + * @return DOMDocument + * @throws MalformedArgumentsException + */ + static private function loadValidationFile(string $validationFile): DOMDocument + { + if (trim($validationFile) === '') { + throw new MalformedArgumentsException('Validation file is empty'); + } + + $useInternalErrors = libxml_use_internal_errors(true); + + try { + $validationDoc = new DOMDocument(); + $loaded = $validationDoc->loadXML($validationFile, LIBXML_NONET); + $error = libxml_get_last_error(); + libxml_clear_errors(); + } finally { + libxml_use_internal_errors($useInternalErrors); + } + + if (!$loaded || !$validationDoc->documentElement) { + throw new MalformedArgumentsException('Validation file is not a valid XML document' + . ($error ? ': ' . trim($error->message) : '')); + } + + return $validationDoc; + } + + /** + * Converts the XML validation file into the same structure as the JSON response of the online validation. + * + * @param DOMDocument $validationDoc + * @return object + */ + static private function convertValidationDoc(DOMDocument $validationDoc): object + { + $root = $validationDoc->documentElement; + $result = (object)['infos' => (object)['info' => []], 'items' => (object)['item' => []]]; + + if ($root->hasAttribute('ttl')) { + $result->ttl = $root->getAttribute('ttl'); + } + + foreach (self::childElements($root, 'infos') as $infos) { + foreach (self::childElements($infos, 'info') as $info) { + $result->infos->info[] = (object)[ + 'id' => $info->getAttribute('id'), + 'type' => $info->getAttribute('type'), + 'value' => $info->textContent, + ]; + } + } + + foreach (self::childElements($root, 'items') as $items) { + foreach (self::childElements($items, 'item') as $item) { + $result->items->item[] = self::convertXmlItem($item, ['type' => $item->getAttribute('type')]); + } + } + + return $result; + } + + static private function convertXmlItem(DOMElement $element, array $attributes): object + { + $item = (object)array_merge($attributes, ['property' => [], 'list' => []]); + + foreach (self::childElements($element, 'property') as $property) { + $item->property[] = (object)['name' => $property->getAttribute('name'), 'value' => $property->textContent]; + } + + foreach (self::childElements($element, 'list') as $list) { + $item->list[] = self::convertXmlItem($list, ['name' => $list->getAttribute('name')]); + } + + return $item; + } + + /** + * @return DOMElement[] + */ + static private function childElements(DOMElement $parent, string $localName): array + { + $elements = []; + + foreach ($parent->childNodes as $child) { + if ($child instanceof DOMElement && $child->localName === $localName) { + $elements[] = $child; + } + } + + return $elements; } /** @@ -131,6 +227,9 @@ static private function convertValidationResult($validationFile, array &$meta = $validationResults->setProductModuleValidation($array[Constants::PRODUCT_MODULE_NUMBER], $array); } + } + + if (!empty($validationFile->ttl)) { $validationResults->setTtl(new DateTime($validationFile->ttl)); } diff --git a/tests/DummyTest.php b/tests/DummyTest.php deleted file mode 100644 index 6d70bae..0000000 --- a/tests/DummyTest.php +++ /dev/null @@ -1,11 +0,0 @@ -assertEquals(true, true); - } -} \ No newline at end of file diff --git a/tests/NetLicensingServiceTest.php b/tests/NetLicensingServiceTest.php new file mode 100644 index 0000000..9d98cd0 --- /dev/null +++ b/tests/NetLicensingServiceTest.php @@ -0,0 +1,49 @@ +invoke($service, $context); + + return $service->curl()->getOpt(CURLOPT_HTTPHEADER) ?? []; + } + + private function authorizationHeaders(array $headers): array + { + // curl doesn't send headers with an empty value + return array_values(array_filter($headers, + fn($header) => stripos($header, 'Authorization:') === 0 && trim(substr($header, 14)) !== '')); + } + + public function testApiKeyAuthorizationHeader() + { + $context = (new Context())->setSecurityMode(Constants::APIKEY_IDENTIFICATION)->setApiKey('secret'); + + $this->assertSame(['Authorization: Basic ' . base64_encode('apiKey:secret')], + $this->authorizationHeaders($this->configure($context))); + } + + public function testAnonymousContextDoesNotReusePreviousCredentials() + { + $this->configure((new Context())->setUsername('demo')->setPassword('demo')); + + $anonymous = (new Context())->setSecurityMode(Constants::ANONYMOUS_IDENTIFICATION); + + $this->assertSame([], $this->authorizationHeaders($this->configure($anonymous))); + } + + public function testBuildPostDataFlattensArrayValues() + { + $query = (new NetLicensingCurl())->buildPostData(['a' => ['1', '2'], 'b' => 'x', 'c.d' => ['3', '4']]); + + $this->assertSame('a=1&a=2&b=x&c.d=3&c.d=4', $query); + } +} diff --git a/tests/OfflineValidationTest.php b/tests/OfflineValidationTest.php new file mode 100644 index 0000000..81d0cd5 --- /dev/null +++ b/tests/OfflineValidationTest.php @@ -0,0 +1,112 @@ +setPublicKey(file_get_contents(self::RESOURCES . $publicKeyFile)); + } + + return $context; + } + + private function validationFile(): string + { + return file_get_contents(self::RESOURCES . 'Isb-DEMO.xml'); + } + + public function testValidSignatureReturnsValidationResults() + { + $meta = []; + $result = ValidationService::validateOffline($this->context(), $this->validationFile(), $meta); + + $validation = $result->getProductModuleValidation('Msb-DEMO'); + $this->assertSame('true', $validation['valid']); + $this->assertSame('Subscription', $validation['licensingModel']); + $this->assertSame('2021-04-21T20:15:45.694Z', $validation['expires']); + $this->assertEquals(new DateTime('2020-12-28T22:53:06.681Z'), $result->getTtl()); + $this->assertSame([], $meta); + } + + public function testWrongPublicKeyIsRejected() + { + $this->expectException(BadSignatureException::class); + ValidationService::validateOffline($this->context('rsa_public_wrong.pem'), $this->validationFile()); + } + + public function testTamperedContentIsRejected() + { + $tampered = str_replace('"valid">true', '"valid">false', $this->validationFile()); + + $this->expectException(BadSignatureException::class); + ValidationService::validateOffline($this->context(), $tampered); + } + + public function testTamperedSignatureValueIsRejected() + { + $tampered = preg_replace('#j#', 'k', $this->validationFile()); + + $this->expectException(BadSignatureException::class); + ValidationService::validateOffline($this->context(), $tampered); + } + + public function testMissingSignatureIsRejected() + { + $unsigned = preg_replace('#.*#s', '', $this->validationFile()); + + $this->expectException(BadSignatureException::class); + ValidationService::validateOffline($this->context(), $unsigned); + } + + public function testCommentInjectionDoesNotTruncateValues() + { + // comments are excluded from the signed data (Reference URI=""), so they must not alter the parsed values + $injected = str_replace('>Msb-DEMO<', '>Msb-DEMO<', $this->validationFile()); + + $result = ValidationService::validateOffline($this->context(), $injected); + + $this->assertSame(['Msb-DEMO'], array_keys($result->getValidations())); + } + + public function testMalformedXmlIsRejected() + { + $this->expectException(MalformedArgumentsException::class); + ValidationService::validateOffline($this->context(), 'expectException(MalformedArgumentsException::class); + ValidationService::validateOffline($this->context(), ''); + } + + public function testExternalEntitiesAreNotExpanded() + { + $xxe = ']>' + . '' + . 'M1&x;' + . ''; + + $result = ValidationService::validateOffline($this->context(null), $xxe); + + $this->assertSame('', $result->getProductModuleValidation('M1')['valid']); + } + + public function testWithoutPublicKeySignatureIsNotChecked() + { + $result = ValidationService::validateOffline($this->context(null), $this->validationFile()); + + $this->assertSame('true', $result->getProductModuleValidation('Msb-DEMO')['valid']); + } +} diff --git a/util/SignatureUtils.php b/util/SignatureUtils.php index 10631dd..216fcac 100644 --- a/util/SignatureUtils.php +++ b/util/SignatureUtils.php @@ -10,30 +10,45 @@ use DOMDocument; use Exception; -use FR3D\XmlDSig\Adapter\XmlseclibsAdapter; +use RobRichards\XMLSecLibs\XMLSecurityDSig; +use RobRichards\XMLSecLibs\XMLSecurityKey; class SignatureUtils { /** + * Verifies the XML digital signature of a NetLicensing response against the public key set in the context. + * Verification is skipped when the context has no public key. + * * @param Context $context * @param DOMDocument $response * @throws BadSignatureException */ - static public function check(Context $context, DOMDocument $response) + static public function check(Context $context, DOMDocument $response): void { - if ($context->getPublicKey()) { - try { - $xmlDsig = new XmlseclibsAdapter(); - $xmlDsig->setPublicKey($context->getPublicKey()); - $xmlDsig->setDigestAlgorithm(XmlseclibsAdapter::RSA_SHA1); - $valid = $xmlDsig->verify($response); - - if (!$valid) { - throw new BadSignatureException("Response signature verification failure"); - } - } catch (Exception $e) { - throw new BadSignatureException($e->getMessage()); + if (!$context->getPublicKey()) { + return; + } + + try { + $dSig = new XMLSecurityDSig(); + + if (!$dSig->locateSignature($response)) { + throw new BadSignatureException('Signature DOM element not found.'); + } + + $dSig->canonicalizeSignedInfo(); + + $key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA1, ['type' => 'public']); + $key->loadKey($context->getPublicKey()); + + // check signature value, then the digest of the referenced (signed) data + if ($dSig->verify($key) !== 1 || !$dSig->validateReference()) { + throw new BadSignatureException('Response signature verification failure'); } + } catch (BadSignatureException $e) { + throw $e; + } catch (Exception $e) { + throw new BadSignatureException($e->getMessage(), 0, $e); } } } diff --git a/vo/NetLicensingCurl.php b/vo/NetLicensingCurl.php index f92941c..805ebfa 100644 --- a/vo/NetLicensingCurl.php +++ b/vo/NetLicensingCurl.php @@ -29,7 +29,9 @@ public function buildPostData($data) foreach ($data as $key => $value) { if (is_array($value)) { - $query = preg_replace('/&' . $key . '%5B%5D=/simU', '&' . $key . '=', $query); + // send array values as repeated keys (key=1&key=2) instead of key[0]=1&key[1]=2 + $encodedKey = urlencode($key); + $query = preg_replace('/(^|&)' . preg_quote($encodedKey, '/') . '%5B\\d*%5D=/i', '${1}' . $encodedKey . '=', $query); } }