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);
}
}