Skip to content

Commit dac0b4b

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

2 files changed

Lines changed: 178 additions & 13 deletions

File tree

src/Binding/HTTPArtifact.php

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
use SimpleSAML\Utils\HTTP;
2727
use SimpleSAML\XMLSecurity\Alg\Signature\SignatureAlgorithmFactory;
2828
use SimpleSAML\XMLSecurity\Key\PublicKey;
29-
use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock;
3029

3130
use function array_key_exists;
3231
use function base64_decode;
@@ -208,15 +207,7 @@ public function receive(ServerRequestInterface $request): AbstractMessage
208207
return $samlResponse;
209208
}
210209

211-
$container = ContainerSingleton::getInstance();
212-
$blacklist = $container->getBlacklistedEncryptionAlgorithms();
213-
$verifier = (new SignatureAlgorithmFactory($blacklist))->getAlgorithm(
214-
$samlResponse->getSignature()->getSignedInfo()->getSignatureMethod()->getAlgorithm(),
215-
// TODO: Need to use the key from the metadata
216-
PEMCertificatesMock::getPublicKey(PEMCertificatesMock::SELFSIGNED_PUBLIC_KEY),
217-
);
218-
219-
return $samlResponse->verify($verifier);
210+
return $this->verifyMessageSignature($samlResponse, $idpMetadata);
220211
}
221212

222213

@@ -229,6 +220,72 @@ public function setSPMetadata(Configuration $sp): void
229220
}
230221

231222

223+
/**
224+
* Verify the signature on a signed SAML message using IdP metadata keys.
225+
*
226+
* Returns the verified message instance.
227+
*
228+
* @throws \Exception When metadata has no signing keys, or when verification fails.
229+
*/
230+
private function verifyMessageSignature(AbstractMessage $message, Configuration $idpMetadata): AbstractMessage
231+
{
232+
$container = ContainerSingleton::getInstance();
233+
$blacklist = $container->getBlacklistedEncryptionAlgorithms();
234+
235+
// getPublicKeys(..., $required = true) throws if no signing cert/key material is present in metadata,
236+
// so $keys is guaranteed non-empty here (no additional empty($keys) guard needed).
237+
$keys = $idpMetadata->getPublicKeys('signing', true);
238+
239+
$signatureMethod = $message
240+
->getSignature()
241+
->getSignedInfo()
242+
->getSignatureMethod()
243+
->getAlgorithm()
244+
->getValue();
245+
246+
$factory = new SignatureAlgorithmFactory($blacklist);
247+
248+
$lastException = null;
249+
foreach ($keys as $k) {
250+
if (($k['type'] ?? null) !== 'X509Certificate') {
251+
continue;
252+
}
253+
254+
$pemCert = "-----BEGIN CERTIFICATE-----\n" .
255+
chunk_split($k['X509Certificate'], 64) .
256+
"-----END CERTIFICATE-----\n";
257+
258+
$opensslKey = openssl_pkey_get_public($pemCert);
259+
if ($opensslKey === false) {
260+
$lastException = new Exception('Unable to extract public key from X509 certificate.');
261+
continue;
262+
}
263+
264+
$keyInfo = openssl_pkey_get_details($opensslKey);
265+
if ($keyInfo === false || !isset($keyInfo['key']) || !is_string($keyInfo['key'])) {
266+
$lastException = new Exception('Unable to get public key details from X509 certificate.');
267+
continue;
268+
}
269+
270+
$pemPublicKey = $keyInfo['key'];
271+
272+
$file = Utils::getContainer()->getTempDir() . '/' . sha1($pemPublicKey) . '.pem';
273+
if (!file_exists($file)) {
274+
Utils::getContainer()->writeFile($file, $pemPublicKey);
275+
}
276+
277+
try {
278+
$verifier = $factory->getAlgorithm($signatureMethod, PublicKey::fromFile($file));
279+
return $message->verify($verifier);
280+
} catch (Exception $e) {
281+
$lastException = $e;
282+
}
283+
}
284+
285+
throw $lastException ?? new Exception('Unable to verify message signature.');
286+
}
287+
288+
232289
/**
233290
* Verify the ArtifactResponse signature using IdP metadata keys.
234291
*
@@ -244,10 +301,9 @@ private function verifyArtifactResponseSignature(
244301
throw new Exception('ArtifactResponse must be signed.');
245302
}
246303

304+
// getPublicKeys(..., $required = true) throws if no signing cert/key material is present in metadata,
305+
// so $keys is guaranteed non-empty here (no additional empty($keys) guard needed).
247306
$keys = $idpMetadata->getPublicKeys('signing', true);
248-
if (empty($keys)) {
249-
throw new Exception('No signing keys found in IdP metadata.');
250-
}
251307

252308
$signatureMethod = $artifactResponse
253309
->getSignature()

tests/SAML2/Binding/HTTPArtifactTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use ReflectionMethod;
1515
use SimpleSAML\Configuration;
1616
use SimpleSAML\SAML2\Binding\HTTPArtifact;
17+
use SimpleSAML\SAML2\XML\samlp\AbstractMessage;
1718
use SimpleSAML\SAML2\XML\samlp\ArtifactResponse;
1819
use SimpleSAML\XMLSecurity\TestUtils\PEMCertificatesMock;
1920
use SimpleSAML\XMLSecurity\XML\ds\Signature;
@@ -155,6 +156,84 @@ public function testVerifyArtifactResponseSignatureBasicScenarios(
155156
}
156157

157158

159+
/**
160+
* @return array<
161+
* string,
162+
* array{
163+
* verifyThrowsMessage: ?string,
164+
* idpMetadata: \SimpleSAML\Configuration,
165+
* expectedExceptionMessage: ?string
166+
* }
167+
* >
168+
*/
169+
public static function provideVerifyMessageSignatureCases(): array
170+
{
171+
$base64Cert = PEMCertificatesMock::getPlainCertificateContents();
172+
173+
$idpMetadataWithSigningKey = Configuration::loadFromArray(
174+
[
175+
'entityid' => 'https://idp.example.test',
176+
'keys' => [
177+
[
178+
'type' => 'X509Certificate',
179+
'signing' => true,
180+
'encryption' => false,
181+
'X509Certificate' => $base64Cert,
182+
],
183+
],
184+
],
185+
'[idp]',
186+
);
187+
188+
$idpMetadataWithoutKeys = Configuration::loadFromArray(
189+
[
190+
'entityid' => 'https://idp.example.test',
191+
],
192+
'[idp]',
193+
);
194+
195+
return [
196+
'signed message but metadata has no keys => throws (metadata required)' => [
197+
'verifyThrowsMessage' => null,
198+
'idpMetadata' => $idpMetadataWithoutKeys,
199+
'expectedExceptionMessage' => 'Missing certificate in metadata.',
200+
],
201+
'signed message but verification fails => throws verify exception' => [
202+
'verifyThrowsMessage' => 'Unable to validate Signature',
203+
'idpMetadata' => $idpMetadataWithSigningKey,
204+
'expectedExceptionMessage' => 'Unable to validate Signature',
205+
],
206+
'signed message and verification ok => returns verified instance' => [
207+
'verifyThrowsMessage' => null,
208+
'idpMetadata' => $idpMetadataWithSigningKey,
209+
'expectedExceptionMessage' => null,
210+
],
211+
];
212+
}
213+
214+
215+
#[DataProvider('provideVerifyMessageSignatureCases')]
216+
public function testVerifyMessageSignatureBasicScenarios(
217+
?string $verifyThrowsMessage,
218+
Configuration $idpMetadata,
219+
?string $expectedExceptionMessage,
220+
): void {
221+
$message = $this->buildSignedMessageStub($verifyThrowsMessage);
222+
223+
if ($expectedExceptionMessage !== null) {
224+
$this->expectException(Exception::class);
225+
$this->expectExceptionMessage($expectedExceptionMessage);
226+
}
227+
228+
$ha = new HTTPArtifact();
229+
$verified = $this->callVerifyMessageSignature($ha, $message, $idpMetadata);
230+
231+
if ($expectedExceptionMessage === null) {
232+
$this->assertSame($message, $verified);
233+
}
234+
}
235+
236+
158237
private function callVerifyArtifactResponseSignature(
159238
HTTPArtifact $ha,
160239
ArtifactResponse $artifactResponse,
@@ -167,6 +246,18 @@ private function callVerifyArtifactResponseSignature(
167246
}
168247

169248

249+
private function callVerifyMessageSignature(
250+
HTTPArtifact $ha,
251+
AbstractMessage $message,
252+
Configuration $idpMetadata,
253+
): AbstractMessage {
254+
$m = new ReflectionMethod(HTTPArtifact::class, 'verifyMessageSignature');
255+
/** @var \SimpleSAML\SAML2\XML\samlp\AbstractMessage $result */
256+
$result = $m->invoke($ha, $message, $idpMetadata);
257+
return $result;
258+
}
259+
260+
170261
private function buildArtifactResponseStub(bool $signed, ?string $verifyThrowsMessage): ArtifactResponse
171262
{
172263
$stub = $this->createStub(ArtifactResponse::class);
@@ -191,6 +282,24 @@ private function buildArtifactResponseStub(bool $signed, ?string $verifyThrowsMe
191282
}
192283

193284

285+
private function buildSignedMessageStub(?string $verifyThrowsMessage): AbstractMessage
286+
{
287+
$stub = $this->createStub(AbstractMessage::class);
288+
289+
$stub->method('getSignature')->willReturn(
290+
self::buildMinimalDsSignature('http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'),
291+
);
292+
293+
if ($verifyThrowsMessage !== null) {
294+
$stub->method('verify')->willThrowException(new Exception($verifyThrowsMessage));
295+
} else {
296+
$stub->method('verify')->willReturn($stub);
297+
}
298+
299+
return $stub;
300+
}
301+
302+
194303
private static function buildMinimalDsSignature(string $signatureAlgorithm): Signature
195304
{
196305
$xml =

0 commit comments

Comments
 (0)