Skip to content

Commit ba04439

Browse files
ioigoumetvdijen
authored andcommitted
HTTP-Artifact: verify signatures using IdP metadata signing keys (fix TODO in verifier setup) (#419)
1 parent a2c1ac5 commit ba04439

6 files changed

Lines changed: 473 additions & 12 deletions

File tree

.github/workflows/php.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ jobs:
7474
- name: Get composer cache directory
7575
run: echo COMPOSER_CACHE="$(composer config cache-files-dir)" >> "$GITHUB_ENV"
7676

77+
- name: Get COMPOSER_ROOT_VERSION from composer.json branch alias
78+
run: |
79+
ROOT_VERSION=$(composer config extra.branch-alias.dev-master)
80+
if [ -z "$ROOT_VERSION" ]; then
81+
echo "Could not read extra.branch-alias.dev-master from composer.json" >&2
82+
exit 1
83+
fi
84+
echo "COMPOSER_ROOT_VERSION=$ROOT_VERSION" >> "$GITHUB_ENV"
85+
7786
- name: Cache composer dependencies
7887
uses: actions/cache@v4
7988
with:
@@ -136,6 +145,15 @@ jobs:
136145
- name: Get composer cache directory
137146
run: echo COMPOSER_CACHE="$(composer config cache-files-dir)" >> "$env:GITHUB_ENV"
138147

148+
- name: Get COMPOSER_ROOT_VERSION from composer.json branch alias
149+
run: |
150+
ROOT_VERSION=$(composer config extra.branch-alias.dev-master)
151+
if [ -z "$ROOT_VERSION" ]; then
152+
echo "Could not read extra.branch-alias.dev-master from composer.json" >&2
153+
exit 1
154+
fi
155+
echo "COMPOSER_ROOT_VERSION=$ROOT_VERSION" >> "$GITHUB_ENV"
156+
139157
- name: Cache composer dependencies
140158
uses: actions/cache@v4
141159
with:
@@ -173,6 +191,15 @@ jobs:
173191
- name: Get composer cache directory
174192
run: echo COMPOSER_CACHE="$(composer config cache-files-dir)" >> "$GITHUB_ENV"
175193

194+
- name: Get COMPOSER_ROOT_VERSION from composer.json branch alias
195+
run: |
196+
ROOT_VERSION=$(composer config extra.branch-alias.dev-master)
197+
if [ -z "$ROOT_VERSION" ]; then
198+
echo "Could not read extra.branch-alias.dev-master from composer.json" >&2
199+
exit 1
200+
fi
201+
echo "COMPOSER_ROOT_VERSION=$ROOT_VERSION" >> "$GITHUB_ENV"
202+
176203
- name: Cache composer dependencies
177204
uses: actions/cache@v4
178205
with:
@@ -226,6 +253,15 @@ jobs:
226253
- name: Get composer cache directory
227254
run: echo COMPOSER_CACHE="$(composer config cache-files-dir)" >> "$GITHUB_ENV"
228255

256+
- name: Get COMPOSER_ROOT_VERSION from composer.json branch alias
257+
run: |
258+
ROOT_VERSION=$(composer config extra.branch-alias.dev-master)
259+
if [ -z "$ROOT_VERSION" ]; then
260+
echo "Could not read extra.branch-alias.dev-master from composer.json" >&2
261+
exit 1
262+
fi
263+
echo "COMPOSER_ROOT_VERSION=$ROOT_VERSION" >> "$GITHUB_ENV"
264+
229265
- name: Cache composer dependencies
230266
uses: actions/cache@v4
231267
with:

composer.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"beste/clock": "~3.0.0",
3434
"mockery/mockery": "~1.6.12",
3535
"simplesamlphp/composer-xmlprovider-installer": "~1.0.2",
36+
"simplesamlphp/simplesamlphp": "~2.3.0 | ~2.4.0",
3637
"simplesamlphp/simplesamlphp-test-framework": "~1.9.2"
3738
},
3839
"suggest": {
@@ -50,14 +51,15 @@
5051
},
5152
"extra": {
5253
"branch-alias": {
53-
"dev-master": "v5.0.x-dev"
54+
"dev-master": "v5.1.x-dev"
5455
}
5556
},
5657
"config": {
5758
"allow-plugins": {
5859
"composer/package-versions-deprecated": true,
5960
"dealerdirect/phpcodesniffer-composer-installer": true,
6061
"phpstan/extension-installer": true,
62+
"simplesamlphp/composer-module-installer": true,
6163
"simplesamlphp/composer-xmlprovider-installer": true
6264
}
6365
}

phpstan-baseline.neon

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ parameters:
2626
path: src/Binding/HTTPArtifact.php
2727

2828
-
29-
message: "#^Parameter \\$key of method SimpleSAML\\\\SAML2\\\\Binding\\\\HTTPArtifact\\:\\:validateSignature\\(\\) has invalid type SimpleSAML\\\\XMLSecurity\\\\XMLSecurityKey\\.$#"
29+
message: "#^Parameter \\$idpMetadata of method SimpleSAML\\\\SAML2\\\\Binding\\\\HTTPArtifact\\:\\:verifyArtifactResponseSignature\\(\\) has invalid type SimpleSAML\\\\Configuration\\.$#"
30+
count: 1
31+
path: src/Binding/HTTPArtifact.php
32+
33+
-
34+
message: "#^Parameter \\$idpMetadata of method SimpleSAML\\\\SAML2\\\\Binding\\\\HTTPArtifact\\:\\:verifyMessageSignature\\(\\) has invalid type SimpleSAML\\\\Configuration\\.$#"
3035
count: 1
3136
path: src/Binding/HTTPArtifact.php
3237

src/Binding/HTTPArtifact.php

Lines changed: 138 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use SimpleSAML\Module\saml\Message as MSG;
1616
use SimpleSAML\SAML2\Binding;
1717
use SimpleSAML\SAML2\Binding\RelayStateTrait;
18+
use SimpleSAML\SAML2\Compat\ContainerSingleton;
1819
use SimpleSAML\SAML2\SOAPClient;
1920
use SimpleSAML\SAML2\Utils;
2021
use SimpleSAML\SAML2\XML\saml\Issuer;
@@ -24,7 +25,8 @@
2425
use SimpleSAML\SAML2\XML\samlp\ArtifactResponse;
2526
use SimpleSAML\Store\StoreFactory;
2627
use SimpleSAML\Utils\HTTP;
27-
use SimpleSAML\XMLSecurity\XMLSecurityKey;
28+
use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory;
29+
use SimpleSAML\XMLSecurity\Key\PublicKey;
2830

2931
use function array_key_exists;
3032
use function base64_decode;
@@ -204,7 +206,11 @@ public function receive(ServerRequestInterface $request): AbstractMessage
204206
$this->setRelayState($query['RelayState']);
205207
}
206208

207-
return $samlResponse;
209+
if (!$samlResponse->isSigned()) {
210+
return $samlResponse;
211+
}
212+
213+
return $this->verifyMessageSignature($samlResponse, $idpMetadata);
208214
}
209215

210216

@@ -220,15 +226,139 @@ public function setSPMetadata(Configuration $sp): void
220226

221227

222228
/**
223-
* A validator which returns true if the ArtifactResponse was signed with the given key
229+
* Verify the signature on a signed SAML message using IdP metadata keys.
230+
*
231+
* Returns the verified message instance.
232+
*
233+
* @throws \Exception When metadata has no signing keys, or when verification fails.
234+
*/
235+
private function verifyMessageSignature(AbstractMessage $message, Configuration $idpMetadata): AbstractMessage
236+
{
237+
$container = ContainerSingleton::getInstance();
238+
$blacklist = $container->getBlacklistedEncryptionAlgorithms();
239+
240+
// getPublicKeys(..., $required = true) throws if no signing cert/key material is present in metadata,
241+
// so $keys is guaranteed non-empty here (no additional empty($keys) guard needed).
242+
$keys = $idpMetadata->getPublicKeys('signing', true);
243+
244+
$signatureMethod = $message
245+
->getSignature()
246+
->getSignedInfo()
247+
->getSignatureMethod()
248+
->getAlgorithm()
249+
->getValue();
250+
251+
$factory = new SignatureAlgorithmFactory($blacklist);
252+
253+
$lastException = null;
254+
foreach ($keys as $k) {
255+
if (($k['type'] ?? null) !== 'X509Certificate') {
256+
continue;
257+
}
258+
259+
$pemCert = "-----BEGIN CERTIFICATE-----\n" .
260+
chunk_split($k['X509Certificate'], 64) .
261+
"-----END CERTIFICATE-----\n";
262+
263+
$opensslKey = openssl_pkey_get_public($pemCert);
264+
if ($opensslKey === false) {
265+
$lastException = new Exception('Unable to extract public key from X509 certificate.');
266+
continue;
267+
}
268+
269+
$keyInfo = openssl_pkey_get_details($opensslKey);
270+
if ($keyInfo === false || !isset($keyInfo['key']) || !is_string($keyInfo['key'])) {
271+
$lastException = new Exception('Unable to get public key details from X509 certificate.');
272+
continue;
273+
}
274+
275+
$pemPublicKey = $keyInfo['key'];
276+
277+
$file = Utils::getContainer()->getTempDir() . '/' . sha1($pemPublicKey) . '.pem';
278+
if (!file_exists($file)) {
279+
Utils::getContainer()->writeFile($file, $pemPublicKey);
280+
}
281+
282+
try {
283+
$verifier = $factory->getAlgorithm($signatureMethod, PublicKey::fromFile($file));
284+
return $message->verify($verifier);
285+
} catch (Exception $e) {
286+
$lastException = $e;
287+
}
288+
}
289+
290+
throw $lastException ?? new Exception('Unable to verify message signature.');
291+
}
292+
293+
294+
/**
295+
* Verify the ArtifactResponse signature using IdP metadata keys.
224296
*
225297
* @param \SimpleSAML\SAML2\XML\samlp\ArtifactResponse $message
226298
* @param \SimpleSAML\XMLSecurity\XMLSecurityKey $key
227-
* @return bool
299+
*
300+
* Returns the verified ArtifactResponse instance.
301+
*
302+
* @throws \Exception When unsigned, when metadata has no signing keys, or when verification fails.
228303
*/
229-
public static function validateSignature(ArtifactResponse $message, XMLSecurityKey $key): bool
230-
{
231-
// @todo verify if this works and/or needs to do anything more. Ref. HTTPRedirect binding
232-
return $message->validate($key);
304+
private function verifyArtifactResponseSignature(
305+
ArtifactResponse $artifactResponse,
306+
Configuration $idpMetadata,
307+
): ArtifactResponse {
308+
if ($artifactResponse->isSigned() !== true) {
309+
throw new Exception('ArtifactResponse must be signed.');
310+
}
311+
312+
// getPublicKeys(..., $required = true) throws if no signing cert/key material is present in metadata,
313+
// so $keys is guaranteed non-empty here (no additional empty($keys) guard needed).
314+
$keys = $idpMetadata->getPublicKeys('signing', true);
315+
316+
$signatureMethod = $artifactResponse
317+
->getSignature()
318+
->getSignedInfo()
319+
->getSignatureMethod()
320+
->getAlgorithm()
321+
->getValue();
322+
323+
$factory = new SignatureAlgorithmFactory();
324+
325+
$lastException = null;
326+
foreach ($keys as $k) {
327+
if (($k['type'] ?? null) !== 'X509Certificate') {
328+
continue;
329+
}
330+
331+
$pemCert = "-----BEGIN CERTIFICATE-----\n" .
332+
chunk_split($k['X509Certificate'], 64) .
333+
"-----END CERTIFICATE-----\n";
334+
335+
$opensslKey = openssl_pkey_get_public($pemCert);
336+
if ($opensslKey === false) {
337+
$lastException = new Exception('Unable to extract public key from X509 certificate.');
338+
continue;
339+
}
340+
341+
$keyInfo = openssl_pkey_get_details($opensslKey);
342+
if ($keyInfo === false || !isset($keyInfo['key']) || !is_string($keyInfo['key'])) {
343+
$lastException = new Exception('Unable to get public key details from X509 certificate.');
344+
continue;
345+
}
346+
347+
$pemPublicKey = $keyInfo['key'];
348+
349+
$file = Utils::getContainer()->getTempDir() . '/' . sha1($pemPublicKey) . '.pem';
350+
if (!file_exists($file)) {
351+
Utils::getContainer()->writeFile($file, $pemPublicKey);
352+
}
353+
354+
try {
355+
$verifier = $factory->getAlgorithm($signatureMethod, PublicKey::fromFile($file));
356+
return $artifactResponse->verify($verifier);
357+
} catch (Exception $e) {
358+
$lastException = $e;
359+
}
360+
}
361+
362+
throw $lastException ?? new Exception('Unable to verify ArtifactResponse signature.');
233363
}
234364
}

src/SOAPClient.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,14 +203,14 @@ protected function createSoapClient(array $options): BuiltinSoapClient
203203
*
204204
* @param \SoapClient $client
205205
* @param string|null $request
206-
* @param \SimpleSAML\SAML2\Type\SAMLAnyURIValue $destination
206+
* @param string $destination
207207
* @param string $action
208208
* @return string
209209
*/
210210
protected function doSoapRequest(
211211
BuiltinSoapClient $client,
212212
?string $request,
213-
SAMLAnyURIValue $destination,
213+
string $destination,
214214
string $action,
215215
): string {
216216
return (string) $client->__doRequest(

0 commit comments

Comments
 (0)