Skip to content

Commit 8470675

Browse files
committed
Remove xmlseclibs coupling by switching SOAP/Artifact validators to OpenSSL-based key extraction and updating tests
1 parent 21c2c99 commit 8470675

5 files changed

Lines changed: 457 additions & 28 deletions

File tree

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"mockery/mockery": "~1.6",
4343
"simplesamlphp/simplesamlphp": "^2.5",
4444
"simplesamlphp/simplesamlphp-test-framework": "~1.11",
45-
"icanhazstring/composer-unused": "^0.9.6"
45+
"icanhazstring/composer-unused": "^0.9.6",
46+
"maglnet/composer-require-checker": "^4.20"
4647
},
4748
"suggest": {
4849
"ext-soap": "*"

src/Binding/HTTPArtifact.php

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,20 @@
2424
use SimpleSAML\SAML2\XML\samlp\ArtifactResponse;
2525
use SimpleSAML\Store\StoreFactory;
2626
use SimpleSAML\Utils\HTTP;
27+
use SimpleSAML\XMLSchema\Type\AnyURIValue;
2728
use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory;
29+
use SimpleSAML\XMLSecurity\Key\PublicKey;
2830
use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock;
2931

3032
use function array_key_exists;
3133
use function base64_decode;
3234
use function base64_encode;
3335
use function bin2hex;
36+
use function chunk_split;
37+
use function file_exists;
3438
use function hexdec;
39+
use function openssl_pkey_get_details;
40+
use function openssl_pkey_get_public;
3541
use function openssl_random_pseudo_bytes;
3642
use function pack;
3743
use function sha1;
@@ -76,7 +82,9 @@ public function getRedirectURL(AbstractMessage $message): string
7682
if ($issuer === null) {
7783
throw new Exception('Cannot get redirect URL, no Issuer set in the message.');
7884
}
79-
$artifact = base64_encode("\x00\x04\x00\x00" . sha1($issuer->getContent(), true) . $generatedId);
85+
$artifact = base64_encode(
86+
"\x00\x04\x00\x00" . sha1((string)$issuer->getContent(), true) . $generatedId,
87+
);
8088
$artifactData = $message->toXML();
8189
$artifactDataString = $artifactData->ownerDocument?->saveXML($artifactData);
8290

@@ -96,7 +104,7 @@ public function getRedirectURL(AbstractMessage $message): string
96104
}
97105

98106
$httpUtils = new HTTP();
99-
return $httpUtils->addURLparameters($destination, $params);
107+
return $httpUtils->addURLparameters((string)$destination, $params);
100108
}
101109

102110

@@ -184,6 +192,8 @@ public function receive(ServerRequestInterface $request): AbstractMessage
184192
throw new Exception('Received error from ArtifactResolutionService.');
185193
}
186194

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

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)