Passed
Pull Request — master (#2)
by Tim
02:21
created

Security::castKey()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 39
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 22
c 0
b 0
f 0
nc 5
nop 3
dl 0
loc 39
rs 8.9457
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\XMLSecurity\Utils;
6
7
use DOMDocument;
8
use DOMElement;
9
use DOMNode;
10
use Exception;
11
use RuntimeException;
12
use SimpleSAML\Assert\Assert;
13
use SimpleSAML\XML\DOMDocumentFactory;
14
use SimpleSAML\XML\Utils as XMLUtils;
15
use SimpleSAML\XMLSecurity\Constants;
16
use SimpleSAML\XMLSecurity\Exception\InvalidArgumentException;
17
use SimpleSAML\XMLSecurity\XMLSecEnc;
18
use SimpleSAML\XMLSecurity\XMLSecurityDSig;
19
use SimpleSAML\XMLSecurity\XMLSecurityKey;
20
21
/**
22
 * A collection of security-related functions.
23
 *
24
 * @package simplesamlphp/xml-security
25
 */
26
class Security
27
{
28
    /**
29
     * Compare two strings in constant time.
30
     *
31
     * This function allows us to compare two given strings without any timing side channels
32
     * leaking information about them.
33
     *
34
     * @param string $known The reference string.
35
     * @param string $user The user-provided string to test.
36
     *
37
     * @return bool True if both strings are equal, false otherwise.
38
     */
39
    public static function compareStrings(string $known, string $user): bool
40
    {
41
        return hash_equals($known, $user);
42
    }
43
44
45
    /**
46
     * Compute the hash for some data with a given algorithm.
47
     *
48
     * @param string $alg The identifier of the algorithm to use.
49
     * @param string $data The data to digest.
50
     * @param bool $encode Whether to bas64-encode the result or not. Defaults to true.
51
     *
52
     * @return string The (binary or base64-encoded) digest corresponding to the given data.
53
     *
54
     * @throws \SimpleSAML\XMLSecurity\Exception\InvalidArgumentException If $alg is not a valid
55
     *   identifier of a supported digest algorithm.
56
     */
57
    public static function hash(string $alg, string $data, bool $encode = true): string
58
    {
59
        if (!array_key_exists($alg, Constants::$DIGEST_ALGORITHMS)) {
60
            throw new InvalidArgumentException('Unsupported digest method "' . $alg . '"');
61
        }
62
63
        $digest = hash(Constants::$DIGEST_ALGORITHMS[$alg], $data, true);
64
        if ($encode) {
65
            $digest = base64_encode($digest);
66
        }
67
        return $digest;
68
    }
69
70
71
72
    /**
73
     * Check the Signature in a XML element.
74
     *
75
     * This function expects the XML element to contain a Signature element
76
     * which contains a reference to the XML-element. This is common for both
77
     * messages and assertions.
78
     *
79
     * Note that this function only validates the element itself. It does not
80
     * check this against any local keys.
81
     *
82
     * If no Signature-element is located, this function will return false. All
83
     * other validation errors result in an exception. On successful validation
84
     * an array will be returned. This array contains the information required to
85
     * check the signature against a public key.
86
     *
87
     * @param \DOMElement $root The element which should be validated.
88
     * @throws \Exception
89
     * @return array|false An array with information about the Signature element.
90
     */
91
    public static function validateElement(DOMElement $root)
92
    {
93
        /* Create an XML security object. */
94
        $objXMLSecDSig = new XMLSecurityDSig();
95
96
        /* Both SAML messages and SAML assertions use the 'ID' attribute. */
97
        $objXMLSecDSig->idKeys[] = 'ID';
98
99
        /* Locate the XMLDSig Signature element to be used. */
100
        /** @var \DOMElement[] $signatureElement */
101
        $signatureElement = XMLUtils::xpQuery($root, './ds:Signature');
0 ignored issues
show
Bug introduced by
The method xpQuery() does not exist on SimpleSAML\XML\Utils. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

101
        /** @scrutinizer ignore-call */ 
102
        $signatureElement = XMLUtils::xpQuery($root, './ds:Signature');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
102
        if (empty($signatureElement)) {
103
            /* We don't have a signature element ot validate. */
104
105
            return false;
106
        } elseif (count($signatureElement) > 1) {
107
            throw new Exception('XMLSec: more than one signature element in root.');
108
        }
109
        $signatureElement = $signatureElement[0];
110
        $objXMLSecDSig->sigNode = $signatureElement;
111
112
        /* Canonicalize the XMLDSig SignedInfo element in the message. */
113
        $objXMLSecDSig->canonicalizeSignedInfo();
114
115
        /* Validate referenced xml nodes. */
116
        if (!$objXMLSecDSig->validateReference()) {
117
            throw new Exception('XMLsec: digest validation failed');
118
        }
119
120
        /* Check that $root is one of the signed nodes. */
121
        $rootSigned = false;
122
        /** @var \DOMNode $signedNode */
123
        foreach ($objXMLSecDSig->getValidatedNodes() as $signedNode) {
124
            if ($signedNode->isSameNode($root)) {
125
                $rootSigned = true;
126
                break;
127
            } elseif ($root->parentNode instanceof DOMDocument && $signedNode->isSameNode($root->ownerDocument)) {
128
                /* $root is the root element of a signed document. */
129
                $rootSigned = true;
130
                break;
131
            }
132
        }
133
        if (!$rootSigned) {
134
            throw new Exception('XMLSec: The root element is not signed.');
135
        }
136
137
        /* Now we extract all available X509 certificates in the signature element. */
138
        $certificates = [];
139
        foreach (XMLUtils::xpQuery($signatureElement, './ds:KeyInfo/ds:X509Data/ds:X509Certificate') as $certNode) {
140
            $certData = trim($certNode->textContent);
141
            $certData = str_replace(["\r", "\n", "\t", ' '], '', $certData);
142
            $certificates[] = $certData;
143
        }
144
145
        $ret = [
146
            'Signature' => $objXMLSecDSig,
147
            'Certificates' => $certificates,
148
        ];
149
150
        return $ret;
151
    }
152
153
154
    /**
155
     * Helper function to convert a XMLSecurityKey to the correct algorithm.
156
     *
157
     * @param \SimpleSAML\XMLSecurity\XMLSecurityKey $key The key.
158
     * @param string $algorithm The desired algorithm.
159
     * @param string $type Public or private key, defaults to public.
160
     * @return \SimpleSAML\XMLSecurity\XMLSecurityKey The new key.
161
     *
162
     * @throws \SimpleSAML\Assert\AssertionFailedException if assertions are false
163
     */
164
    public static function castKey(XMLSecurityKey $key, string $algorithm, string $type = null): XMLSecurityKey
165
    {
166
        $type = $type ?: 'public';
167
        Assert::oneOf($type, ["private", "public"]);
168
169
        // do nothing if algorithm is already the type of the key
170
        if ($key->type === $algorithm) {
171
            return $key;
172
        }
173
174
        if (
175
            !in_array(
176
                $algorithm,
177
                [
178
                    XMLSecurityKey::RSA_1_5,
179
                    XMLSecurityKey::RSA_SHA1,
180
                    XMLSecurityKey::RSA_SHA256,
181
                    XMLSecurityKey::RSA_SHA384,
182
                    XMLSecurityKey::RSA_SHA512
183
                ],
184
                true
185
            )
186
        ) {
187
            throw new Exception('Unsupported signing algorithm.');
188
        }
189
190
        /** @psalm-suppress PossiblyNullArgument */
191
        $keyInfo = openssl_pkey_get_details($key->key);
192
        if ($keyInfo === false) {
193
            throw new Exception('Unable to get key details from XMLSecurityKey.');
194
        }
195
        if (!isset($keyInfo['key'])) {
196
            throw new Exception('Missing key in public key details.');
197
        }
198
199
        $newKey = new XMLSecurityKey($algorithm, ['type' => $type]);
200
        $newKey->loadKey($keyInfo['key']);
201
202
        return $newKey;
203
    }
204
205
206
    /**
207
     * Check a signature against a key.
208
     *
209
     * An exception is thrown if we are unable to validate the signature.
210
     *
211
     * @param array $info The information returned by the validateElement() function.
212
     * @param \SimpleSAML\XMLSecurity\XMLSecurityKey $key The publickey that should validate the Signature object.
213
     * @throws \Exception
214
     *
215
     * @throws \SimpleSAML\Assert\AssertionFailedException if assertions are false
216
     */
217
    public static function validateSignature(array $info, XMLSecurityKey $key): void
218
    {
219
        Assert::keyExists($info, "Signature");
220
221
        /** @var XMLSecurityDSig $objXMLSecDSig */
222
        $objXMLSecDSig = $info['Signature'];
223
224
        /**
225
         * @var \DOMElement[] $sigMethod
226
         * @var \DOMElement $objXMLSecDSig->sigNode
227
         */
228
        $sigMethod = XMLUtils::xpQuery($objXMLSecDSig->sigNode, './ds:SignedInfo/ds:SignatureMethod');
229
        if (empty($sigMethod)) {
230
            throw new Exception('Missing SignatureMethod element.');
231
        }
232
        $sigMethod = $sigMethod[0];
233
        if (!$sigMethod->hasAttribute('Algorithm')) {
234
            throw new Exception('Missing Algorithm-attribute on SignatureMethod element.');
235
        }
236
        $algo = $sigMethod->getAttribute('Algorithm');
237
238
        if ($key->type === XMLSecurityKey::RSA_SHA256 && $algo !== $key->type) {
239
            $key = self::castKey($key, $algo);
240
        }
241
242
        /* Check the signature. */
243
        if ($objXMLSecDSig->verify($key) !== 1) {
244
            throw new Exception("Unable to validate Signature;  " . openssl_error_string());
245
        }
246
    }
247
248
249
    /**
250
     * Insert a Signature node.
251
     *
252
     * @param \SimpleSAML\XMLSecurity\XMLSecurityKey $key The key we should use to sign the message.
253
     * @param array $certificates The certificates we should add to the signature node.
254
     * @param \DOMElement $root The XML node we should sign.
255
     * @param \DOMNode $insertBefore  The XML element we should insert the signature element before.
256
     */
257
    public static function insertSignature(
258
        XMLSecurityKey $key,
259
        array $certificates,
260
        DOMElement $root,
261
        DOMNode $insertBefore = null
262
    ): void {
263
        $objXMLSecDSig = new XMLSecurityDSig();
264
        $objXMLSecDSig->setCanonicalMethod(XMLSecurityDSig::EXC_C14N);
265
266
        switch ($key->type) {
267
            case XMLSecurityKey::RSA_SHA256:
268
                $type = XMLSecurityDSig::SHA256;
269
                break;
270
            case XMLSecurityKey::RSA_SHA384:
271
                $type = XMLSecurityDSig::SHA384;
272
                break;
273
            case XMLSecurityKey::RSA_SHA512:
274
                $type = XMLSecurityDSig::SHA512;
275
                break;
276
            default:
277
                $type = XMLSecurityDSig::SHA1;
278
        }
279
280
        $objXMLSecDSig->addReferenceList(
281
            [$root],
282
            $type,
283
            ['http://www.w3.org/2000/09/xmldsig#enveloped-signature', XMLSecurityDSig::EXC_C14N],
284
            ['id_name' => 'ID', 'overwrite' => false]
285
        );
286
287
        $objXMLSecDSig->sign($key);
288
289
        foreach ($certificates as $certificate) {
290
            $objXMLSecDSig->add509Cert($certificate, true);
291
        }
292
293
        $objXMLSecDSig->insertSignature($root, $insertBefore);
294
    }
295
296
297
    /**
298
     * Decrypt an encrypted element.
299
     *
300
     * This is an internal helper function.
301
     *
302
     * @param \DOMElement $encryptedData The encrypted data.
303
     * @param \SimpleSAML\XMLSecurity\XMLSecurityKey $inputKey The decryption key.
304
     * @param array &$blacklist Blacklisted decryption algorithms.
305
     * @throws \Exception
306
     * @return \DOMElement The decrypted element.
307
     */
308
    private static function doDecryptElement(
309
        DOMElement $encryptedData,
310
        XMLSecurityKey $inputKey,
311
        array &$blacklist
312
    ): DOMElement {
313
        $enc = new XMLSecEnc();
314
315
        $enc->setNode($encryptedData);
316
        $enc->type = $encryptedData->getAttribute("Type");
317
318
        $symmetricKey = $enc->locateKey($encryptedData);
319
        if (!$symmetricKey) {
320
            throw new Exception('Could not locate key algorithm in encrypted data.');
321
        }
322
323
        $symmetricKeyInfo = $enc->locateKeyInfo($symmetricKey);
324
        if (!$symmetricKeyInfo) {
325
            throw new Exception('Could not locate <dsig:KeyInfo> for the encrypted key.');
326
        }
327
328
        $inputKeyAlgo = $inputKey->getAlgorithm();
329
        if ($symmetricKeyInfo->isEncrypted) {
330
            $symKeyInfoAlgo = $symmetricKeyInfo->getAlgorithm();
331
332
            if (in_array($symKeyInfoAlgo, $blacklist, true)) {
333
                throw new Exception('Algorithm disabled: ' . var_export($symKeyInfoAlgo, true));
334
            }
335
336
            if ($symKeyInfoAlgo === XMLSecurityKey::RSA_OAEP_MGF1P && $inputKeyAlgo === XMLSecurityKey::RSA_1_5) {
337
                /*
338
                 * The RSA key formats are equal, so loading an RSA_1_5 key
339
                 * into an RSA_OAEP_MGF1P key can be done without problems.
340
                 * We therefore pretend that the input key is an
341
                 * RSA_OAEP_MGF1P key.
342
                 */
343
                $inputKeyAlgo = XMLSecurityKey::RSA_OAEP_MGF1P;
344
            }
345
346
            /* Make sure that the input key format is the same as the one used to encrypt the key. */
347
            if ($inputKeyAlgo !== $symKeyInfoAlgo) {
348
                throw new Exception(
349
                    'Algorithm mismatch between input key and key used to encrypt ' .
350
                    ' the symmetric key for the message. Key was: ' .
351
                    var_export($inputKeyAlgo, true) . '; message was: ' .
352
                    var_export($symKeyInfoAlgo, true)
353
                );
354
            }
355
356
            /** @var XMLSecEnc $encKey */
357
            $encKey = $symmetricKeyInfo->encryptedCtx;
358
            $symmetricKeyInfo->key = $inputKey->key;
359
360
            $keySize = $symmetricKey->getSymmetricKeySize();
361
            if ($keySize === null) {
362
                /* To protect against "key oracle" attacks, we need to be able to create a
363
                 * symmetric key, and for that we need to know the key size.
364
                 */
365
                throw new Exception(
366
                    'Unknown key size for encryption algorithm: ' . var_export($symmetricKey->type, true)
367
                );
368
            }
369
370
            try {
371
                /**
372
                 * @var string $key
373
                 * @psalm-suppress UndefinedClass
374
                 */
375
                $key = $encKey->decryptKey($symmetricKeyInfo);
376
                if (strlen($key) !== $keySize) {
377
                    throw new Exception(
378
                        'Unexpected key size (' . strval(strlen($key) * 8) . 'bits) for encryption algorithm: ' .
379
                        var_export($symmetricKey->type, true)
380
                    );
381
                }
382
            } catch (Exception $e) {
383
                /* We failed to decrypt this key. Log it, and substitute a "random" key. */
384
//                Utils::getContainer()->getLogger()->error('Failed to decrypt symmetric key: ' . $e->getMessage());
385
                /* Create a replacement key, so that it looks like we fail in the same way as if the key was correctly
386
                 * padded. */
387
388
                /* We base the symmetric key on the encrypted key and private key, so that we always behave the
389
                 * same way for a given input key.
390
                 */
391
                $encryptedKey = $encKey->getCipherValue();
392
                if ($encryptedKey === null) {
393
                    throw new Exception('No CipherValue available in the encrypted element.');
394
                }
395
396
                /** @psalm-suppress PossiblyNullArgument */
397
                $pkey = openssl_pkey_get_details($symmetricKeyInfo->key);
398
                $pkey = sha1(serialize($pkey), true);
399
                $key = sha1($encryptedKey . $pkey, true);
400
401
                /* Make sure that the key has the correct length. */
402
                if (strlen($key) > $keySize) {
403
                    $key = substr($key, 0, $keySize);
404
                } elseif (strlen($key) < $keySize) {
405
                    $key = str_pad($key, $keySize);
406
                }
407
            }
408
            $symmetricKey->loadkey($key);
409
        } else {
410
            $symKeyAlgo = $symmetricKey->getAlgorithm();
411
            /* Make sure that the input key has the correct format. */
412
            if ($inputKeyAlgo !== $symKeyAlgo) {
413
                throw new Exception(
414
                    'Algorithm mismatch between input key and key in message. ' .
415
                    'Key was: ' . var_export($inputKeyAlgo, true) . '; message was: ' .
416
                    var_export($symKeyAlgo, true)
417
                );
418
            }
419
            $symmetricKey = $inputKey;
420
        }
421
422
        $algorithm = $symmetricKey->getAlgorithm();
423
        if (in_array($algorithm, $blacklist, true)) {
424
            throw new Exception('Algorithm disabled: ' . var_export($algorithm, true));
425
        }
426
427
        /**
428
         * @var string $decrypted
429
         * @psalm-suppress UndefinedClass
430
         */
431
        $decrypted = $enc->decryptNode($symmetricKey, false);
432
433
        /*
434
         * This is a workaround for the case where only a subset of the XML
435
         * tree was serialized for encryption. In that case, we may miss the
436
         * namespaces needed to parse the XML.
437
         */
438
        $xml = '<root xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ' .
439
                        'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' .
440
            $decrypted . '</root>';
441
442
        try {
443
            $newDoc = DOMDocumentFactory::fromString($xml);
444
        } catch (RuntimeException $e) {
445
            throw new Exception('Failed to parse decrypted XML. Maybe the wrong sharedkey was used?', 0, $e);
446
        }
447
448
        /** @psalm-suppress PossiblyNullPropertyFetch */
449
        $decryptedElement = $newDoc->firstChild->firstChild;
450
        if (!($decryptedElement instanceof DOMElement)) {
451
            throw new Exception('Missing decrypted element or it was not actually a DOMElement.');
452
        }
453
454
        return $decryptedElement;
455
    }
456
457
458
    /**
459
     * Decrypt an encrypted element.
460
     *
461
     * @param \DOMElement $encryptedData The encrypted data.
462
     * @param \SimpleSAML\XMLSecurity\XMLSecurityKey $inputKey The decryption key.
463
     * @param array $blacklist Blacklisted decryption algorithms.
464
     * @throws \Exception
465
     * @return \DOMElement The decrypted element.
466
     */
467
    public static function decryptElement(
468
        DOMElement $encryptedData,
469
        XMLSecurityKey $inputKey,
470
        array $blacklist = []
471
    ): DOMElement {
472
        try {
473
            return self::doDecryptElement($encryptedData, $inputKey, $blacklist);
474
        } catch (Exception $e) {
475
            /*
476
             * Something went wrong during decryption, but for security
477
             * reasons we cannot tell the user what failed.
478
             */
479
//            Utils::getContainer()->getLogger()->error('Decryption failed: ' . $e->getMessage());
480
            throw new Exception('Failed to decrypt XML element.', 0, $e);
481
        }
482
    }
483
}
484