Test Failed
Push — master ( 92f3f7...7e3711 )
by Daniel
02:12
created

XmlSignatureValidator::checkDigest()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 7.456

Importance

Changes 0
Metric Value
eloc 9
c 0
b 0
f 0
dl 0
loc 20
ccs 4
cts 10
cp 0.4
rs 9.9666
cc 4
nc 4
nop 3
crap 7.456
1
<?php
2
3
namespace Selective\XmlDSig;
4
5
use DOMDocument;
6
use DOMElement;
7
use DOMXPath;
8
use Selective\XmlDSig\Exception\XmlSignatureValidatorException;
9
10
/**
11
 * Verify the Digital Signatures of XML Documents.
12
 */
13
final class XmlSignatureValidator
14
{
15
    //
16
    // RSA (PKCS#1 v1.5) Identifier
17
    // https://www.w3.org/TR/xmldsig-core/#sec-PKCS1
18
    //
19
    private const SHA1_URL = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1';
20
    private const SHA224_URL = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha224';
21
    private const SHA256_URL = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
22
    private const SHA384_URL = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384';
23
    private const SHA512_URL = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512';
24
25
    /**
26
     * @var resource|false
27
     */
28
    private $publicKeyId;
29
30
    /**
31
     * @var XmlReader
32
     */
33
    private $xmlReader;
34
35
    /**
36
     * The constructor.
37
     */
38 2
    public function __construct()
39
    {
40 2
        $this->xmlReader = new XmlReader();
41 2
    }
42
43
    /**
44
     * Read and load the pfx file.
45
     *
46
     * @param string $filename PFX filename
47
     * @param string $password PFX password
48
     *
49
     * @throws XmlSignatureValidatorException
50
     *
51
     * @return void
52
     */
53 1
    public function loadPfxFile(string $filename, string $password)
54
    {
55 1
        if (!file_exists($filename)) {
56
            throw new XmlSignatureValidatorException(sprintf('File not found: %s', $filename));
57
        }
58
59 1
        $pkcs12 = file_get_contents($filename);
60
61 1
        if (!$pkcs12) {
62
            throw new XmlSignatureValidatorException(sprintf('File could not be read: %s', $filename));
63
        }
64
65 1
        $this->loadPfx($pkcs12, $password);
66 1
    }
67
68
    /**
69
     * Read and load the pfx file.
70
     *
71
     * @param string $pkcs12 The certificate store data
72
     * @param string $password encryption password for unlocking the PKCS12 file
73
     *
74
     * @throws XmlSignatureValidatorException
75
     *
76
     * @return void
77
     */
78 1
    public function loadPfx(string $pkcs12, string $password): void
79
    {
80 1
        $status = openssl_pkcs12_read($pkcs12, $certificates, $password);
81
82 1
        if (!$status) {
83
            throw new XmlSignatureValidatorException('Invalid PFX password');
84
        }
85
86 1
        $this->publicKeyId = openssl_get_publickey($certificates['cert']);
0 ignored issues
show
Documentation Bug introduced by
It seems like openssl_get_publickey($certificates['cert']) of type OpenSSLAsymmetricKey is incompatible with the declared type false|resource of property $publicKeyId.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
87
88 1
        if (!$this->publicKeyId) {
89
            throw new XmlSignatureValidatorException('Invalid public key');
90
        }
91 1
    }
92
93
    /**
94
     * Read and load the public key file.
95
     *
96
     * @param string $filename The public key file
97
     *
98
     * @throws XmlSignatureValidatorException
99
     *
100
     * @return void
101
     */
102 1
    public function loadPublicKeyFile(string $filename): void
103
    {
104 1
        if (!file_exists($filename)) {
105
            throw new XmlSignatureValidatorException(sprintf('File not found: %s', $filename));
106
        }
107
108 1
        $publicKey = file_get_contents($filename);
109
110 1
        if (!$publicKey) {
111
            throw new XmlSignatureValidatorException(sprintf('File could not be read: %s', $filename));
112
        }
113
114 1
        $this->loadPublicKey($publicKey);
115 1
    }
116
117
    /**
118
     * Load the public key content.
119
     *
120
     * @param string $publicKey The public key data
121
     *
122
     * @throws XmlSignatureValidatorException
123
     *
124
     * @return void
125
     */
126 1
    public function loadPublicKey(string $publicKey): void
127
    {
128 1
        $this->publicKeyId = openssl_get_publickey($publicKey);
0 ignored issues
show
Documentation Bug introduced by
It seems like openssl_get_publickey($publicKey) of type OpenSSLAsymmetricKey is incompatible with the declared type false|resource of property $publicKeyId.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
129
130 1
        if (!$this->publicKeyId) {
131
            throw new XmlSignatureValidatorException('Invalid public key');
132
        }
133 1
    }
134
135
    /**
136
     * Sign an XML file and save the signature in a new file.
137
     * This method does not save the public key within the XML file.
138
     *
139
     * https://www.xml.com/pub/a/2001/08/08/xmldsig.html#verify
140
     *
141
     * @param string $filename Input file
142
     *
143
     * @throws XmlSignatureValidatorException
144
     *
145
     * @return bool Success
146
     */
147 2
    public function verifyXmlFile(string $filename): bool
148
    {
149 2
        if (!file_exists($filename)) {
150
            throw new XmlSignatureValidatorException(sprintf('File not found: %s', $filename));
151
        }
152
153 2
        if (!$this->publicKeyId) {
154
            throw new XmlSignatureValidatorException('No public key provided');
155
        }
156
157 2
        $xmlContent = file_get_contents($filename);
158
159 2
        if (!$xmlContent) {
160
            throw new XmlSignatureValidatorException(sprintf('File could not be read: %s', $filename));
161
        }
162
163 2
        return $this->verifyXml($xmlContent);
164
    }
165
166
    /**
167
     * Verify an XML string.
168
     *
169
     * https://www.xml.com/pub/a/2001/08/08/xmldsig.html#verify
170
     *
171
     * @param string $xmlContent The xml content
172
     *
173
     * @throws XmlSignatureValidatorException
174
     *
175
     * @return bool Success
176
     */
177 2
    public function verifyXml(string $xmlContent): bool
178
    {
179 2
        if (!$this->publicKeyId) {
180
            throw new XmlSignatureValidatorException('No public key provided');
181
        }
182
183
        // Read the xml file content
184 2
        $xml = new DOMDocument();
185 2
        $xml->preserveWhiteSpace = true;
186 2
        $xml->formatOutput = false;
187 2
        $isValid = $xml->loadXML($xmlContent);
188
189 2
        if (!$isValid || !$xml->documentElement) {
190
            throw new XmlSignatureValidatorException('Invalid XML content');
191
        }
192
193 2
        $digestAlgorithm = $this->getDigestAlgorithm($xml);
194 2
        $signatureValue = $this->getSignatureValue($xml);
195 2
        $xpath = new DOMXPath($xml);
196 2
        $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
197
198
        /** @var DOMElement $signedInfoNode */
199 2
        foreach ($xpath->evaluate('//xmlns:Signature/xmlns:SignedInfo') as $signedInfoNode) {
200
            // Remove SignatureValue value
201 2
            $signatureValueElement = $this->xmlReader->queryDomNode($xpath, '//xmlns:SignatureValue', $signedInfoNode);
202 2
            $signatureValueElement->nodeValue = '';
203
204 2
            $canonicalData = $signedInfoNode->C14N(true, false);
205
206 2
            $xml2 = new DOMDocument();
207 2
            $xml2->preserveWhiteSpace = true;
208 2
            $xml2->formatOutput = true;
209 2
            $xml2->loadXML($canonicalData);
210 2
            $canonicalData = $xml2->C14N(true, false);
211
212 2
            $status = openssl_verify($canonicalData, $signatureValue, $this->publicKeyId, $digestAlgorithm);
213
214 2
            if ($status !== 1) {
215
                // The XML signature is not valid
216
                return false;
217
            }
218
        }
219
220 2
        return $this->checkDigest($xml, $xpath, $digestAlgorithm);
221
    }
222
223
    /**
224
     * Check digest value.
225
     *
226
     * @param DOMDocument $xml The xml document
227
     * @param DOMXPath $xpath The xpath
228
     * @param int $digestAlgorithm The digest algorithm
229
     *
230
     * @return bool The status
231
     */
232 2
    private function checkDigest(DOMDocument $xml, DOMXPath $xpath, int $digestAlgorithm): bool
233
    {
234 2
        $digestValue = $this->getDigestValue($xml);
235
236
        // Remove signature elements
237
        /** @var DOMElement $signatureNode */
238 2
        foreach ($xpath->query('//xmlns:Signature') ?: [] as $signatureNode) {
239 2
            $signatureNode->remove();
240
        }
241
242
        // Canonicalize the content, exclusive and without comments
243
        $canonicalData = $xml->C14N(true, false);
244
245
        $opensslDigestAlgorithm = $this->getOpenSslDigestAlgo($digestAlgorithm);
246
        $digestValue2 = openssl_digest($canonicalData, $opensslDigestAlgorithm, true);
247
        if ($digestValue2 === false) {
248
            throw new XmlSignatureValidatorException('Invalid digest value');
249
        }
250
251
        return hash_equals($digestValue, $digestValue2);
252
    }
253
254
    /**
255
     * Detect digest algorithm.
256
     *
257
     * @param DOMDocument $xml The xml document
258
     *
259
     * @throws XmlSignatureValidatorException
260
     *
261
     * @return int The algorithm code
262
     */
263 2
    private function getDigestAlgorithm(DOMDocument $xml): int
264
    {
265 2
        $xpath = new DOMXPath($xml);
266 2
        $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
267 2
        $xpath->registerNamespace('Algorithm', 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315');
268
269 2
        $signatureMethodNodes = $xpath->query('//xmlns:Signature/xmlns:SignedInfo/xmlns:SignatureMethod');
270
271
        // Throw an exception if no signature was found.
272 2
        if (!$signatureMethodNodes || $signatureMethodNodes->length < 1) {
0 ignored issues
show
introduced by
$signatureMethodNodes is of type DOMNodeList, thus it always evaluated to true.
Loading history...
273
            throw new XmlSignatureValidatorException('Verification failed: No Signature was found in the document.');
274
        }
275
276
        // We only support one signature for the entire XML document.
277
        // Throw an exception if more than one signature was found.
278 2
        if ($signatureMethodNodes->length > 1) {
279
            throw new XmlSignatureValidatorException(
280
                'Verification failed: More that one signature was found for the document.'
281
            );
282
        }
283
284
        /** @var DOMElement $element */
285 2
        $element = $signatureMethodNodes->item(0);
286 2
        if (!$element instanceof DOMElement) {
0 ignored issues
show
introduced by
$element is always a sub-type of DOMElement.
Loading history...
287
            throw new XmlSignatureValidatorException(
288
                'Verification failed: Signature algorithm was found for the document.'
289
            );
290
        }
291
292 2
        $algorithm = $element->getAttribute('Algorithm');
293
294
        switch ($algorithm) {
295 2
            case self::SHA1_URL:
296 2
                return OPENSSL_ALGO_SHA1;
297
            case self::SHA224_URL:
298
                return OPENSSL_ALGO_SHA224;
299
            case self::SHA256_URL:
300
                return OPENSSL_ALGO_SHA256;
301
            case self::SHA384_URL:
302
                return OPENSSL_ALGO_SHA384;
303
            case self::SHA512_URL:
304
                return OPENSSL_ALGO_SHA512;
305
            default:
306
                throw new XmlSignatureValidatorException("Cannot verify: Unsupported Algorithm <$algorithm>");
307
        }
308
    }
309
310
    /**
311
     * Map algo to OpenSSL method name.
312
     *
313
     * @param int $algo The algo
314
     *
315
     * @return string The name of the OpenSSL algorithm
316
     */
317
    private function getOpenSslDigestAlgo(int $algo): string
318
    {
319
        switch ($algo) {
320
            case OPENSSL_ALGO_SHA1:
321
                return 'sha1';
322
            case OPENSSL_ALGO_SHA224:
323
                return 'sha224';
324
            case OPENSSL_ALGO_SHA256:
325
                return 'sha256';
326
            case OPENSSL_ALGO_SHA384:
327
                return 'sha384';
328
            case OPENSSL_ALGO_SHA512:
329
                return 'sha512';
330
            default:
331
                throw new XmlSignatureValidatorException(
332
                    "Cannot verify: Unsupported Algorithm <$algo>"
333
                );
334
        }
335
    }
336
337
    /**
338
     * Get signature value.
339
     *
340
     * @param DOMDocument $xml The xml document
341
     *
342
     * @throws XmlSignatureValidatorException
343
     *
344
     * @return string The signature value
345
     */
346 2
    private function getSignatureValue(DOMDocument $xml): string
347
    {
348 2
        $xpath = new DOMXPath($xml);
349 2
        $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
350
351
        // Find the SignatureValue node
352 2
        $signatureNodes = $xpath->query('//xmlns:Signature/xmlns:SignatureValue');
353
354
        // Throw an exception if no signature was found.
355 2
        if (!$signatureNodes || $signatureNodes->length < 1) {
0 ignored issues
show
introduced by
$signatureNodes is of type DOMNodeList, thus it always evaluated to true.
Loading history...
356
            throw new XmlSignatureValidatorException('Verification failed: No Signature was found in the document.');
357
        }
358
359
        // We only support one signature for the entire XML document.
360
        // Throw an exception if more than one signature was found.
361 2
        if ($signatureNodes->length > 1) {
362
            throw new XmlSignatureValidatorException(
363
                'Verification failed: More that one signature was found for the document.'
364
            );
365
        }
366
367 2
        $domNode = $signatureNodes->item(0);
368 2
        if (!$domNode) {
369
            throw new XmlSignatureValidatorException(
370
                'Verification failed: No Signature item was found in the document.'
371
            );
372
        }
373
374 2
        $result = base64_decode($domNode->nodeValue, true);
375
376 2
        if ($result === false) {
377
            throw new XmlSignatureValidatorException('Verification failed: Invalid base64 data.');
378
        }
379
380 2
        return (string)$result;
381
    }
382
383
    /**
384
     * Destructor.
385
     */
386 2
    public function __destruct()
387
    {
388
        // Free the key from memory
389
        // PHP 8 deprecates openssl_free_key and automatically destroys the key instance when it goes out of scope.
390 2
        if ($this->publicKeyId && version_compare(PHP_VERSION, '8.0.0', '<')) {
391 2
            openssl_free_key($this->publicKeyId);
0 ignored issues
show
Bug introduced by
It seems like $this->publicKeyId can also be of type true; however, parameter $key of openssl_free_key() does only seem to accept OpenSSLAsymmetricKey|resource, 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

391
            openssl_free_key(/** @scrutinizer ignore-type */ $this->publicKeyId);
Loading history...
392
        }
393 2
    }
394
395
    /**
396
     * Get the digest value.
397
     *
398
     * @param DOMDocument $xml The xml document
399
     *
400
     * @throws XmlSignatureValidatorException
401
     *
402
     * @return string The signature value
403
     */
404 2
    private function getDigestValue(DOMDocument $xml): string
405
    {
406 2
        $xpath = new DOMXPath($xml);
407 2
        $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
408
409
        // Find the DigestValue node
410 2
        $signatureNodes = $xpath->query('//xmlns:Signature/xmlns:SignedInfo/xmlns:Reference/xmlns:DigestValue');
411
412
        // Throw an exception if no signature was found.
413 2
        if (!$signatureNodes || $signatureNodes->length < 1) {
0 ignored issues
show
introduced by
$signatureNodes is of type DOMNodeList, thus it always evaluated to true.
Loading history...
414
            throw new XmlSignatureValidatorException('Verification failed: No Signature was found in the document.');
415
        }
416
417
        // We only support one signature for the entire XML document.
418
        // Throw an exception if more than one signature was found.
419 2
        if ($signatureNodes->length > 1) {
420
            throw new XmlSignatureValidatorException(
421
                'Verification failed: More that one signature was found for the document.'
422
            );
423
        }
424
425 2
        $domNode = $signatureNodes->item(0);
426 2
        if (!$domNode) {
427
            throw new XmlSignatureValidatorException(
428
                'Verification failed: No Signature item was found in the document.'
429
            );
430
        }
431
432 2
        $result = base64_decode($domNode->nodeValue, true);
433
434 2
        if ($result === false) {
435
            throw new XmlSignatureValidatorException('Verification failed: Invalid base64 data.');
436
        }
437
438 2
        return $result;
439
    }
440
}
441