Skip to content

Commit c6fea42

Browse files
ioigoumetvdijen
andauthored
refactor/soap-ssl-validator-openssl (#414)
* Remove xmlseclibs coupling by switching SOAP/Artifact validators to OpenSSL-based key extraction and updating tests * Refactor signature verification to fail fast by removing redundant algorithm type guard * Remove string-casts * Fix syntax --------- Co-authored-by: Tim van Dijen <tvdijen@gmail.com>
1 parent 9246bb0 commit c6fea42

5 files changed

Lines changed: 461 additions & 29 deletions

File tree

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@
3939
"ext-intl": "*",
4040

4141
"beste/clock": "~3.0",
42+
"icanhazstring/composer-unused": "^0.9.6",
43+
"maglnet/composer-require-checker": "^4.20",
4244
"mockery/mockery": "~1.6",
4345
"simplesamlphp/simplesamlphp": "^2.5",
44-
"simplesamlphp/simplesamlphp-test-framework": "~2.0",
45-
"icanhazstring/composer-unused": "^0.9.6"
46+
"simplesamlphp/simplesamlphp-test-framework": "~2.0"
4647
},
4748
"suggest": {
4849
"ext-soap": "*"

src/Binding/HTTPArtifact.php

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,18 @@
2525
use SimpleSAML\Store\StoreFactory;
2626
use SimpleSAML\Utils\HTTP;
2727
use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory;
28+
use SimpleSAML\XMLSecurity\Key\PublicKey;
2829
use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock;
2930

3031
use function array_key_exists;
3132
use function base64_decode;
3233
use function base64_encode;
3334
use function bin2hex;
35+
use function chunk_split;
36+
use function file_exists;
3437
use function hexdec;
38+
use function openssl_pkey_get_details;
39+
use function openssl_pkey_get_public;
3540
use function openssl_random_pseudo_bytes;
3641
use function pack;
3742
use function sha1;
@@ -76,7 +81,9 @@ public function getRedirectURL(AbstractMessage $message): string
7681
if ($issuer === null) {
7782
throw new Exception('Cannot get redirect URL, no Issuer set in the message.');
7883
}
79-
$artifact = base64_encode("\x00\x04\x00\x00" . sha1($issuer->getContent(), true) . $generatedId);
84+
$artifact = base64_encode(
85+
"\x00\x04\x00\x00" . sha1($issuer->getContent()->getValue(), true) . $generatedId,
86+
);
8087
$artifactData = $message->toXML();
8188
$artifactDataString = $artifactData->ownerDocument?->saveXML($artifactData);
8289

@@ -96,7 +103,7 @@ public function getRedirectURL(AbstractMessage $message): string
96103
}
97104

98105
$httpUtils = new HTTP();
99-
return $httpUtils->addURLparameters($destination, $params);
106+
return $httpUtils->addURLparameters($destination->getValue(), $params);
100107
}
101108

102109

@@ -184,6 +191,8 @@ public function receive(ServerRequestInterface $request): AbstractMessage
184191
throw new Exception('Received error from ArtifactResolutionService.');
185192
}
186193

194+
$artifactResponse = $this->verifyArtifactResponseSignature($artifactResponse, $idpMetadata);
195+
187196
$samlResponse = $artifactResponse->getMessage();
188197
if ($samlResponse === null) {
189198
/* Empty ArtifactResponse - possibly because of Artifact replay? */
@@ -218,4 +227,74 @@ public function setSPMetadata(Configuration $sp): void
218227
{
219228
$this->spMetadata = $sp;
220229
}
230+
231+
232+
/**
233+
* Verify the ArtifactResponse signature using IdP metadata keys.
234+
*
235+
* Returns the verified ArtifactResponse instance.
236+
*
237+
* @throws \Exception When unsigned, when metadata has no signing keys, or when verification fails.
238+
*/
239+
private function verifyArtifactResponseSignature(
240+
ArtifactResponse $artifactResponse,
241+
Configuration $idpMetadata,
242+
): ArtifactResponse {
243+
if ($artifactResponse->isSigned() !== true) {
244+
throw new Exception('ArtifactResponse must be signed.');
245+
}
246+
247+
$keys = $idpMetadata->getPublicKeys('signing', true);
248+
if (empty($keys)) {
249+
throw new Exception('No signing keys found in IdP metadata.');
250+
}
251+
252+
$signatureMethod = $artifactResponse
253+
->getSignature()
254+
->getSignedInfo()
255+
->getSignatureMethod()
256+
->getAlgorithm()
257+
->getValue();
258+
259+
$factory = new SignatureAlgorithmFactory();
260+
261+
$lastException = null;
262+
foreach ($keys as $k) {
263+
if (($k['type'] ?? null) !== 'X509Certificate') {
264+
continue;
265+
}
266+
267+
$pemCert = "-----BEGIN CERTIFICATE-----\n" .
268+
chunk_split($k['X509Certificate'], 64) .
269+
"-----END CERTIFICATE-----\n";
270+
271+
$opensslKey = openssl_pkey_get_public($pemCert);
272+
if ($opensslKey === false) {
273+
$lastException = new Exception('Unable to extract public key from X509 certificate.');
274+
continue;
275+
}
276+
277+
$keyInfo = openssl_pkey_get_details($opensslKey);
278+
if ($keyInfo === false || !isset($keyInfo['key']) || !is_string($keyInfo['key'])) {
279+
$lastException = new Exception('Unable to get public key details from X509 certificate.');
280+
continue;
281+
}
282+
283+
$pemPublicKey = $keyInfo['key'];
284+
285+
$file = Utils::getContainer()->getTempDir() . '/' . sha1($pemPublicKey) . '.pem';
286+
if (!file_exists($file)) {
287+
Utils::getContainer()->writeFile($file, $pemPublicKey);
288+
}
289+
290+
try {
291+
$verifier = $factory->getAlgorithm($signatureMethod, PublicKey::fromFile($file));
292+
return $artifactResponse->verify($verifier);
293+
} catch (Exception $e) {
294+
$lastException = $e;
295+
}
296+
}
297+
298+
throw $lastException ?? new Exception('Unable to verify ArtifactResponse signature.');
299+
}
221300
}

src/SOAPClient.php

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use DOMDocument;
88
use Exception;
9-
use RobRichards\XMLSecLibs\XMLSecurityKey;
9+
use OpenSSLAsymmetricKey;
1010
use SimpleSAML\Configuration;
1111
use SimpleSAML\SAML2\Compat\ContainerSingleton;
1212
use SimpleSAML\SAML2\XML\samlp\AbstractMessage;
@@ -24,12 +24,17 @@
2424

2525
use function chunk_split;
2626
use function file_exists;
27+
use function is_object;
28+
use function is_string;
29+
use function method_exists;
2730
use function openssl_pkey_get_details;
2831
use function openssl_pkey_get_public;
32+
use function property_exists;
2933
use function sha1;
3034
use function sprintf;
3135
use function stream_context_create;
3236
use function stream_context_get_options;
37+
use function trim;
3338

3439
/**
3540
* Implementation of the SAML 2.0 SOAP binding.
@@ -251,7 +256,7 @@ private static function addSSLValidator(AbstractMessage $msg, $context): void
251256
return;
252257
}
253258

254-
if (!isset($keyInfo['key'])) {
259+
if (!isset($keyInfo['key']) || !is_string($keyInfo['key'])) {
255260
$container->getLogger()->warning('Missing key in public key details.');
256261
return;
257262
}
@@ -263,29 +268,88 @@ private static function addSSLValidator(AbstractMessage $msg, $context): void
263268
/**
264269
* Validate a SOAP message against the certificate on the SSL connection.
265270
*
266-
* @param string $data The public key that was used on the connection.
267-
* @param \RobRichards\XMLSecLibs\XMLSecurityKey $key The key we should validate the certificate against.
271+
* @param string $data The public key (PEM) that was used on the connection.
272+
* @param mixed $key The key we should validate the certificate against.
268273
* @throws \Exception
269274
*/
270-
public static function validateSSL(string $data, XMLSecurityKey $key): void
275+
public static function validateSSL(string $data, mixed $key): void
271276
{
272277
$container = ContainerSingleton::getInstance();
273278

274-
$keyInfo = openssl_pkey_get_details($key->key);
279+
$pem = self::extractPublicKeyPem($key);
275280

276-
if ($keyInfo === false) {
277-
throw new Exception('Unable to get key details from XMLSecurityKey.');
281+
if (trim($pem) !== trim($data)) {
282+
throw new Exception('Key on SSL connection did not match key we validated against.');
278283
}
279284

280-
if (!isset($keyInfo['key'])) {
281-
throw new Exception('Missing key in public key details.');
285+
$container->getLogger()->debug('Message validated based on SSL certificate.');
286+
}
287+
288+
289+
/**
290+
* Extract a PEM-encoded public key from different key representations.
291+
*
292+
* This avoids coupling to a specific XML security backend.
293+
*
294+
* @param mixed $key
295+
* @throws \Exception
296+
*/
297+
private static function extractPublicKeyPem(mixed $key): string
298+
{
299+
// If the validating key is already PEM, normalize it by re-loading through OpenSSL.
300+
if (is_string($key)) {
301+
$opensslKey = openssl_pkey_get_public($key);
302+
if ($opensslKey === false) {
303+
throw new Exception('Unable to load validating public key from PEM string.');
304+
}
305+
306+
$keyInfo = openssl_pkey_get_details($opensslKey);
307+
if ($keyInfo === false || !isset($keyInfo['key']) || !is_string($keyInfo['key'])) {
308+
throw new Exception('Unable to get key details from validating PEM key.');
309+
}
310+
311+
return $keyInfo['key'];
282312
}
283313

284-
if (trim($keyInfo['key']) !== trim($data)) {
285-
throw new Exception('Key on SSL connection did not match key we validated against.');
314+
// Some key implementations may expose PEM via a method.
315+
if (is_object($key)) {
316+
foreach (['getPublicKeyPem', 'getPem', 'toPEM', 'toPem'] as $method) {
317+
if (method_exists($key, $method)) {
318+
/** @var mixed $pem */
319+
$pem = $key->{$method}();
320+
if (is_string($pem) && $pem !== '') {
321+
return self::extractPublicKeyPem($pem);
322+
}
323+
}
324+
}
325+
326+
// Common compatibility case: an object wraps an OpenSSL key or PEM in a public "key" property.
327+
if (property_exists($key, 'key')) {
328+
/** @var mixed $inner */
329+
$inner = $key->key;
330+
331+
if (is_string($inner)) {
332+
return self::extractPublicKeyPem($inner);
333+
}
334+
335+
if ($inner instanceof OpenSSLAsymmetricKey) {
336+
$keyInfo = openssl_pkey_get_details($inner);
337+
if ($keyInfo !== false && isset($keyInfo['key']) && is_string($keyInfo['key'])) {
338+
return $keyInfo['key'];
339+
}
340+
}
341+
}
286342
}
287343

288-
$container->getLogger()->debug('Message validated based on SSL certificate.');
344+
// Last attempt: OpenSSL might accept the value directly (OpenSSLAsymmetricKey).
345+
if ($key instanceof OpenSSLAsymmetricKey) {
346+
$keyInfo = openssl_pkey_get_details($key);
347+
if ($keyInfo !== false && isset($keyInfo['key']) && is_string($keyInfo['key'])) {
348+
return $keyInfo['key'];
349+
}
350+
}
351+
352+
throw new Exception('Unable to extract public key PEM from validating key.');
289353
}
290354

291355

0 commit comments

Comments
 (0)