Completed
Pull Request — master (#1132)
by Tim
15:36
created

lib/SimpleSAML/XML/Validator.php (2 issues)

1
<?php
2
3
/**
4
 * This class implements helper functions for XML validation.
5
 *
6
 * @author Olav Morken, UNINETT AS.
7
 * @package SimpleSAMLphp
8
 */
9
10
declare(strict_types=1);
11
12
namespace SimpleSAML\XML;
13
14
use RobRichards\XMLSecLibs\XMLSecEnc;
15
use RobRichards\XMLSecLibs\XMLSecurityDSig;
16
use SimpleSAML\Logger;
17
18
class Validator
19
{
20
    /**
21
     * @var string|null This variable contains the X509 certificate the XML document
22
     *             was signed with, or NULL if it wasn't signed with an X509 certificate.
23
     */
24
    private $x509Certificate = null;
25
26
    /**
27
     * @var array|null This variable contains the nodes which are signed.
28
     */
29
    private $validNodes = null;
30
31
32
    /**
33
     * This function initializes the validator.
34
     *
35
     * This function accepts an optional parameter $publickey, which is the public key
36
     * or certificate which should be used to validate the signature. This parameter can
37
     * take the following values:
38
     * - NULL/FALSE: No validation will be performed. This is the default.
39
     * - A string: Assumed to be a PEM-encoded certificate / public key.
40
     * - An array: Assumed to be an array returned by \SimpleSAML\Utils\Crypto::loadPublicKey.
41
     *
42
     * @param \DOMDocument $xmlNode The XML node which contains the Signature element.
43
     * @param string|array $idAttribute The ID attribute which is used in node references. If
44
     *          this attribute is NULL (the default), then we will use whatever is the default
45
     *          ID. Can be eigther a string with one value, or an array with multiple ID
46
     *          attrbute names.
47
     * @param array|bool $publickey The public key / certificate which should be used to validate the XML node.
48
     * @throws \Exception
49
     */
50
    public function __construct($xmlNode, $idAttribute = null, $publickey = false)
51
    {
52
        assert($xmlNode instanceof \DOMDocument);
53
54
        if ($publickey === null) {
0 ignored issues
show
The condition $publickey === null is always false.
Loading history...
55
            $publickey = false;
56
        } elseif (is_string($publickey)) {
57
            $publickey = [
58
                'PEM' => $publickey,
59
            ];
60
        } else {
61
            assert($publickey === false || is_array($publickey));
62
        }
63
64
        // Create an XML security object
65
        $objXMLSecDSig = new XMLSecurityDSig();
66
67
        // Add the id attribute if the user passed in an id attribute
68
        if ($idAttribute !== null) {
69
            if (is_string($idAttribute)) {
70
                $objXMLSecDSig->idKeys[] = $idAttribute;
71
            } elseif (is_array($idAttribute)) {
72
                foreach ($idAttribute as $ida) {
73
                    $objXMLSecDSig->idKeys[] = $ida;
74
                }
75
            }
76
        }
77
78
        // Locate the XMLDSig Signature element to be used
79
        $signatureElement = $objXMLSecDSig->locateSignature($xmlNode);
80
        if (!$signatureElement) {
81
            throw new \Exception('Could not locate XML Signature element.');
82
        }
83
84
        // Canonicalize the XMLDSig SignedInfo element in the message
85
        $objXMLSecDSig->canonicalizeSignedInfo();
86
87
        // Validate referenced xml nodes
88
        if (!$objXMLSecDSig->validateReference()) {
89
            throw new \Exception('XMLsec: digest validation failed');
90
        }
91
92
93
        // Find the key used to sign the document
94
        $objKey = $objXMLSecDSig->locateKey();
95
        if (empty($objKey)) {
96
            throw new \Exception('Error loading key to handle XML signature');
97
        }
98
99
        // Load the key data
100
        if ($publickey !== false && array_key_exists('PEM', $publickey)) {
0 ignored issues
show
It seems like $publickey can also be of type true; however, parameter $search of array_key_exists() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

100
        if ($publickey !== false && array_key_exists('PEM', /** @scrutinizer ignore-type */ $publickey)) {
Loading history...
101
            // We have PEM data for the public key / certificate
102
            $objKey->loadKey($publickey['PEM']);
103
        } else {
104
            // No PEM data. Search for key in signature
105
106
            if (!XMLSecEnc::staticLocateKeyInfo($objKey, $signatureElement)) {
107
                throw new \Exception('Error finding key data for XML signature validation.');
108
            }
109
110
            if ($publickey !== false) {
111
                /* $publickey is set, and should therefore contain one or more fingerprints.
112
                 * Check that the response contains a certificate with a matching
113
                 * fingerprint.
114
                 */
115
                assert(is_array($publickey['certFingerprint']));
116
117
                $certificate = $objKey->getX509Certificate();
118
                if ($certificate === null) {
119
                    // Wasn't signed with an X509 certificate
120
                    throw new \Exception('Message wasn\'t signed with an X509 certificate,' .
121
                        ' and no public key was provided in the metadata.');
122
                }
123
124
                self::validateCertificateFingerprint($certificate, $publickey['certFingerprint']);
125
                // Key OK
126
            }
127
        }
128
129
        // Check the signature
130
        if ($objXMLSecDSig->verify($objKey) !== 1) {
131
            throw new \Exception("Unable to validate Signature");
132
        }
133
134
        // Extract the certificate
135
        $this->x509Certificate = $objKey->getX509Certificate();
136
137
        // Find the list of validated nodes
138
        $this->validNodes = $objXMLSecDSig->getValidatedNodes();
139
    }
140
141
142
    /**
143
     * Retrieve the X509 certificate which was used to sign the XML.
144
     *
145
     * This function will return the certificate as a PEM-encoded string. If the XML
146
     * wasn't signed by an X509 certificate, NULL will be returned.
147
     *
148
     * @return string|null  The certificate as a PEM-encoded string, or NULL if not signed with an X509 certificate.
149
     */
150
    public function getX509Certificate()
151
    {
152
        return $this->x509Certificate;
153
    }
154
155
156
    /**
157
     * Calculates the fingerprint of an X509 certificate.
158
     *
159
     * @param string $x509cert  The certificate as a base64-encoded string. The string may optionally
160
     *                          be framed with '-----BEGIN CERTIFICATE-----' and '-----END CERTIFICATE-----'.
161
     * @return string|null  The fingerprint as a 40-character lowercase hexadecimal number. NULL is returned if the
162
     *                 argument isn't an X509 certificate.
163
     */
164
    private static function calculateX509Fingerprint(string $x509cert)
165
    {
166
        $lines = explode("\n", $x509cert);
167
168
        $data = '';
169
170
        foreach ($lines as $line) {
171
            // Remove '\r' from end of line if present
172
            $line = rtrim($line);
173
            if ($line === '-----BEGIN CERTIFICATE-----') {
174
                // Delete junk from before the certificate
175
                $data = '';
176
            } elseif ($line === '-----END CERTIFICATE-----') {
177
                // Ignore data after the certificate
178
                break;
179
            } elseif ($line === '-----BEGIN PUBLIC KEY-----') {
180
                // This isn't an X509 certificate
181
                return null;
182
            } else {
183
                // Append the current line to the certificate data
184
                $data .= $line;
185
            }
186
        }
187
188
        /* $data now contains the certificate as a base64-encoded string. The fingerprint
189
         * of the certificate is the sha1-hash of the certificate.
190
         */
191
        return strtolower(sha1(base64_decode($data)));
192
    }
193
194
195
    /**
196
     * Helper function for validating the fingerprint.
197
     *
198
     * Checks the fingerprint of a certificate against an array of valid fingerprints.
199
     * Will throw an exception if none of the fingerprints matches.
200
     *
201
     * @param string $certificate The X509 certificate we should validate.
202
     * @param array $fingerprints The valid fingerprints.
203
     * @throws \Exception
204
     * @return void
205
     */
206
    private static function validateCertificateFingerprint(string $certificate, array $fingerprints)
207
    {
208
        $certFingerprint = self::calculateX509Fingerprint($certificate);
209
        if ($certFingerprint === null) {
210
            // Couldn't calculate fingerprint from X509 certificate. Should not happen.
211
            throw new \Exception('Unable to calculate fingerprint from X509' .
212
                ' certificate. Maybe it isn\'t an X509 certificate?');
213
        }
214
215
        foreach ($fingerprints as $fp) {
216
            assert(is_string($fp));
217
218
            if ($fp === $certFingerprint) {
219
                // The fingerprints matched
220
                return;
221
            }
222
        }
223
224
        // None of the fingerprints matched. Throw an exception describing the error.
225
        throw new \Exception('Invalid fingerprint of certificate. Expected one of [' .
226
            implode('], [', $fingerprints) . '], but got [' . $certFingerprint . ']');
227
    }
228
229
230
    /**
231
     * Validate the fingerprint of the certificate which was used to sign this document.
232
     *
233
     * This function accepts either a string, or an array of strings as a parameter. If this
234
     * is an array, then any string (certificate) in the array can match. If this is a string,
235
     * then that string must match,
236
     *
237
     * @param string|array $fingerprints  The fingerprints which should match. This can be a single string,
238
     *                                    or an array of fingerprints.
239
     * @throws \Exception
240
     * @return void
241
     */
242
    public function validateFingerprint($fingerprints)
243
    {
244
        assert(is_string($fingerprints) || is_array($fingerprints));
245
246
        if ($this->x509Certificate === null) {
247
            throw new \Exception('Key used to sign the message was not an X509 certificate.');
248
        }
249
250
        if (!is_array($fingerprints)) {
251
            $fingerprints = [$fingerprints];
252
        }
253
254
        // Normalize the fingerprints
255
        foreach ($fingerprints as &$fp) {
256
            assert(is_string($fp));
257
258
            // Make sure that the fingerprint is in the correct format
259
            $fp = strtolower(str_replace(":", "", $fp));
260
        }
261
262
        self::validateCertificateFingerprint($this->x509Certificate, $fingerprints);
263
    }
264
265
266
    /**
267
     * This function checks if the given XML node was signed.
268
     *
269
     * @param \DOMNode $node  The XML node which we should verify that was signed.
270
     *
271
     * @return bool  TRUE if this node (or a parent node) was signed. FALSE if not.
272
     */
273
    public function isNodeValidated($node)
274
    {
275
        assert($node instanceof \DOMNode);
276
277
        if ($this->validNodes !== null) {
278
            while ($node !== null) {
279
                if (in_array($node, $this->validNodes, true)) {
280
                    return true;
281
                }
282
283
                $node = $node->parentNode;
284
            }
285
        }
286
287
        /* Neither this node nor any of the parent nodes could be found in the list of
288
         * signed nodes.
289
         */
290
        return false;
291
    }
292
293
294
    /**
295
     * Validate the certificate used to sign the XML against a CA file.
296
     *
297
     * This function throws an exception if unable to validate against the given CA file.
298
     *
299
     * @param string $caFile  File with trusted certificates, in PEM-format.
300
     * @throws \Exception
301
     * @return void
302
     */
303
    public function validateCA($caFile)
304
    {
305
        assert(is_string($caFile));
306
307
        if ($this->x509Certificate === null) {
308
            throw new \Exception('Key used to sign the message was not an X509 certificate.');
309
        }
310
311
        self::validateCertificate($this->x509Certificate, $caFile);
312
    }
313
314
    /**
315
     * Validate a certificate against a CA file, by using the builtin
316
     * openssl_x509_checkpurpose function
317
     *
318
     * @param string $certificate  The certificate, in PEM format.
319
     * @param string $caFile  File with trusted certificates, in PEM-format.
320
     * @return boolean|string TRUE on success, or a string with error messages if it failed.
321
     * @deprecated
322
     */
323
    private static function validateCABuiltIn(string $certificate, string $caFile)
324
    {
325
        // Clear openssl errors
326
        while (openssl_error_string() !== false) {
327
        }
328
329
        $res = openssl_x509_checkpurpose($certificate, X509_PURPOSE_ANY, [$caFile]);
330
331
        $errors = '';
332
        // Log errors
333
        while (($error = openssl_error_string()) !== false) {
334
            $errors .= ' [' . $error . ']';
335
        }
336
337
        if ($res !== true) {
338
            return $errors;
339
        }
340
341
        return true;
342
    }
343
344
345
    /**
346
     * Validate the certificate used to sign the XML against a CA file, by using the "openssl verify" command.
347
     *
348
     * This function uses the openssl verify command to verify a certificate, to work around limitations
349
     * on the openssl_x509_checkpurpose function. That function will not work on certificates without a purpose
350
     * set.
351
     *
352
     * @param string $certificate The certificate, in PEM format.
353
     * @param string $caFile File with trusted certificates, in PEM-format.
354
     * @return bool|string TRUE on success, a string with error messages on failure.
355
     * @throws \Exception
356
     * @deprecated
357
     */
358
    private static function validateCAExec(string $certificate, string $caFile)
359
    {
360
        $command = [
361
            'openssl', 'verify',
362
            '-CAfile', $caFile,
363
            '-purpose', 'any',
364
        ];
365
366
        $cmdline = '';
367
        foreach ($command as $c) {
368
            $cmdline .= escapeshellarg($c) . ' ';
369
        }
370
371
        $cmdline .= '2>&1';
372
        $descSpec = [
373
            0 => ['pipe', 'r'],
374
            1 => ['pipe', 'w'],
375
        ];
376
        $process = proc_open($cmdline, $descSpec, $pipes);
377
        if (!is_resource($process)) {
378
            throw new \Exception('Failed to execute verification command: ' . $cmdline);
379
        }
380
381
        if (fwrite($pipes[0], $certificate) === false) {
382
            throw new \Exception('Failed to write certificate for verification.');
383
        }
384
        fclose($pipes[0]);
385
386
        $out = '';
387
        while (!feof($pipes[1])) {
388
            $line = trim(fgets($pipes[1]));
389
            if (strlen($line) > 0) {
390
                $out .= ' [' . $line . ']';
391
            }
392
        }
393
        fclose($pipes[1]);
394
395
        $status = proc_close($process);
396
        if ($status !== 0 || $out !== ' [stdin: OK]') {
397
            return $out;
398
        }
399
400
        return true;
401
    }
402
403
404
    /**
405
     * Validate the certificate used to sign the XML against a CA file.
406
     *
407
     * This function throws an exception if unable to validate against the given CA file.
408
     *
409
     * @param string $certificate The certificate, in PEM format.
410
     * @param string $caFile File with trusted certificates, in PEM-format.
411
     * @throws \Exception
412
     * @return void
413
     * @deprecated
414
     */
415
    public static function validateCertificate($certificate, $caFile)
416
    {
417
        assert(is_string($certificate));
418
        assert(is_string($caFile));
419
420
        if (!file_exists($caFile)) {
421
            throw new \Exception('Could not load CA file: ' . $caFile);
422
        }
423
424
        Logger::debug('Validating certificate against CA file: ' . var_export($caFile, true));
425
426
        $resBuiltin = self::validateCABuiltIn($certificate, $caFile);
427
        if ($resBuiltin !== true) {
428
            Logger::debug('Failed to validate with internal function: ' . var_export($resBuiltin, true));
429
430
            $resExternal = self::validateCAExec($certificate, $caFile);
431
            if ($resExternal !== true) {
432
                Logger::debug('Failed to validate with external function: ' . var_export($resExternal, true));
433
                throw new \Exception('Could not verify certificate against CA file "' .
434
                    $caFile . '". Internal result:' . var_export($resBuiltin, true) .
435
                    ' External result:' . var_export($resExternal, true));
436
            }
437
        }
438
439
        Logger::debug('Successfully validated certificate.');
440
    }
441
}
442