├── .cs.php
├── .github
└── workflows
│ └── build.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── composer.json
├── phpcs.xml
├── phpstan.neon
└── src
├── Algorithm.php
├── CryptoSigner.php
├── CryptoSignerInterface.php
├── CryptoVerifier.php
├── CryptoVerifierInterface.php
├── Exception
├── CertificateException.php
├── XmlSignatureValidatorException.php
└── XmlSignerException.php
├── PrivateKeyStore.php
├── PublicKeyStore.php
├── X509Reader.php
├── XmlReader.php
├── XmlSignatureVerifier.php
└── XmlSigner.php
/.cs.php:
--------------------------------------------------------------------------------
1 | setUsingCache(false)
7 | ->setRiskyAllowed(true)
8 | ->setRules(
9 | [
10 | '@PSR1' => true,
11 | '@PSR2' => true,
12 | '@Symfony' => true,
13 | 'psr_autoloading' => true,
14 | // custom rules
15 | 'align_multiline_comment' => ['comment_type' => 'phpdocs_only'], // psr-5
16 | 'phpdoc_to_comment' => false,
17 | 'no_superfluous_phpdoc_tags' => false,
18 | 'array_indentation' => true,
19 | 'array_syntax' => ['syntax' => 'short'],
20 | 'cast_spaces' => ['space' => 'none'],
21 | 'concat_space' => ['spacing' => 'one'],
22 | 'compact_nullable_typehint' => true,
23 | 'declare_equal_normalize' => ['space' => 'single'],
24 | 'general_phpdoc_annotation_remove' => [
25 | 'annotations' => [
26 | 'author',
27 | 'package',
28 | ],
29 | ],
30 | 'increment_style' => ['style' => 'post'],
31 | 'list_syntax' => ['syntax' => 'short'],
32 | 'echo_tag_syntax' => ['format' => 'long'],
33 | 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false],
34 | 'phpdoc_align' => false,
35 | 'phpdoc_no_empty_return' => false,
36 | 'phpdoc_order' => true, // psr-5
37 | 'phpdoc_no_useless_inheritdoc' => false,
38 | 'protected_to_private' => false,
39 | 'yoda_style' => false,
40 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'],
41 | 'ordered_imports' => [
42 | 'sort_algorithm' => 'alpha',
43 | 'imports_order' => ['class', 'const', 'function'],
44 | ],
45 | 'single_line_throw' => false,
46 | 'declare_strict_types' => false,
47 | 'blank_line_between_import_groups' => true,
48 | 'fully_qualified_strict_types' => true,
49 | 'no_null_property_initialization' => false,
50 | 'operator_linebreak' => [
51 | 'only_booleans' => true,
52 | 'position' => 'beginning',
53 | ],
54 | 'global_namespace_import' => [
55 | 'import_classes' => true,
56 | 'import_constants' => null,
57 | 'import_functions' => null
58 | ]
59 | ]
60 | )
61 | ->setFinder(
62 | PhpCsFixer\Finder::create()
63 | ->in(__DIR__ . '/src')
64 | ->in(__DIR__ . '/tests')
65 | ->name('*.php')
66 | ->ignoreDotFiles(true)
67 | ->ignoreVCS(true)
68 | );
69 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on: [ push, pull_request ]
4 |
5 | jobs:
6 | run:
7 | runs-on: ${{ matrix.operating-system }}
8 | strategy:
9 | matrix:
10 | operating-system: [ ubuntu-latest ]
11 | php-versions: [ '8.1', '8.2' ]
12 | name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }}
13 |
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v1
17 |
18 | - name: Setup PHP
19 | uses: shivammathur/setup-php@v2
20 | with:
21 | php-version: ${{ matrix.php-versions }}
22 | extensions: mbstring, intl, zip, gmp
23 | coverage: none
24 |
25 | - name: Check PHP Version
26 | run: php -v
27 |
28 | - name: Check Composer Version
29 | run: composer -V
30 |
31 | - name: Check PHP Extensions
32 | run: php -m
33 |
34 | - name: Validate composer.json and composer.lock
35 | run: composer validate
36 |
37 | - name: Install dependencies
38 | run: composer install --prefer-dist --no-progress --no-suggest
39 |
40 | - name: Run test suite
41 | run: composer test:all
42 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [Unreleased]
4 |
5 | ## 3.0.0
6 |
7 | ### Added
8 |
9 | - Add support for Elliptic Curve Digital Signature Algorithm ECDSA SHA256
10 | - Add support for x509 certificate
11 | - Add possibility to sign specific parts of an XML document
12 |
13 | ### Changed
14 |
15 | - Require PHP 8
16 |
17 | ### Breaking Changes
18 |
19 | - The package has been completely redesigned to meet the new requirements
20 |
21 | ## 2.0.0
22 |
23 | ### Added
24 |
25 | * A new method: `XmlSigner::loadPrivateKeyFile`
26 | * A `KeyInfo` element with `Modulus` and `Exponent`
27 | * A base64 decoding check
28 | * Tests
29 | * Changelog file
30 |
31 | ### Changed
32 |
33 | * Rename `XmlSigner::setDigestAlgorithm` to `XmlSigner::setAlgorithm`
34 | * Renamed `XmlSignatureValidator::loadPfx` to `XmlSignatureValidator::loadPfxFile`
35 | * Fixed enveloped signature
36 | * Fixed digest method algorithm url
37 | * Tested against:
38 | * https://tools.chilkat.io/xmlDsigVerify.cshtml
39 | * https://www.aleksey.com/xmlsec/xmldsig-verifier.html
40 |
41 | ## 1.0.0
42 |
43 | * First release
44 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 odan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # XMLDSIG for PHP
2 |
3 | [](https://packagist.org/packages/selective/xmldsig)
4 | [](LICENSE)
5 | [](https://github.com/selective-php/xmldsig/actions)
6 | [](https://scrutinizer-ci.com/g/selective-php/xmldsig/code-structure)
7 | [](https://scrutinizer-ci.com/g/selective-php/xmldsig/?branch=master)
8 | [](https://packagist.org/packages/selective/xmldsig/stats)
9 |
10 | ## Features
11 |
12 | * Sign XML Documents with Digital Signatures ([XMLDSIG](https://www.w3.org/TR/xmldsig-core/))
13 | * Verify the Digital Signatures of XML Documents
14 | * ECDSA (SHA256) signature
15 |
16 | ## Requirements
17 |
18 | * PHP 8.1+
19 | * The openssl extension
20 | * A X.509 digital certificate
21 |
22 | ## Installation
23 |
24 | ```
25 | composer require selective/xmldsig
26 | ```
27 |
28 | ## Usage
29 |
30 | ### Signing an XML Document with a digital signature
31 |
32 | Input file: example.xml
33 |
34 | ```xml
35 |
36 |
37 |
38 | 19834209
39 | 02/02/2025
40 |
41 |
42 | ```
43 |
44 | Load and add the private key to the `PrivateKeyStore`:
45 |
46 | ```php
47 | use Selective\XmlDSig\PrivateKeyStore;
48 | // ...
49 |
50 | $privateKeyStore = new PrivateKeyStore();
51 |
52 | // load a private key from a string
53 | $privateKeyStore->loadFromPem('private key content', 'password');
54 |
55 | // or load a private key from a PEM file
56 | $privateKeyStore->loadFromPem(file_get_contents('filename.pem'), 'password');
57 |
58 | // load pfx PKCS#12 certificate from a string
59 | $privateKeyStore->loadFromPkcs12('pfx content', 'password');
60 |
61 | // or load PKCS#12 certificate from a file
62 | $privateKeyStore->loadFromPkcs12(file_get_contents('filename.p12'), 'password');
63 | ```
64 |
65 | Define the digest method: sha1, sha224, sha256, sha384, sha512
66 |
67 | ```php
68 | use Selective\XmlDSig\Algorithm;
69 |
70 | $algorithm = new Algorithm(Algorithm::METHOD_SHA1);
71 | ```
72 |
73 | Create a `CryptoSigner` instance:
74 |
75 | ```php
76 | use Selective\XmlDSig\CryptoSigner;
77 |
78 | $cryptoSigner = new CryptoSigner($privateKeyStore, $algorithm);
79 | ```
80 |
81 | Signing:
82 |
83 | ```php
84 | use Selective\XmlDSig\XmlSigner;
85 |
86 | // Create a XmlSigner and pass the crypto signer
87 | $xmlSigner = new XmlSigner($cryptoSigner);
88 |
89 | // Optional: Set reference URI
90 | $xmlSigner->setReferenceUri('');
91 |
92 | // Create a signed XML string
93 | $signedXml = $xmlSigner->signXml('signXml(file_get_contents($filename));
97 |
98 | // or sign an DOMDocument
99 | $xml = new DOMDocument();
100 | $xml->preserveWhiteSpace = true;
101 | $xml->formatOutput = false;
102 | $xml->loadXML($data);
103 |
104 | $signedXml = $xmlSigner->signDocument($xml);
105 | ```
106 |
107 | Output:
108 |
109 | ```xml
110 |
111 |
112 |
113 | 19834209
114 | 02/02/2025
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 | Base64EncodedValue==
126 |
127 |
128 | AnotherBase64EncodedValue===
129 |
130 |
131 | ```
132 |
133 | #### Signing only specific part of an XML document
134 |
135 | Example:
136 |
137 | ```php
138 | use Selective\XmlDSig\Algorithm;
139 | use Selective\XmlDSig\CryptoSigner;
140 | use Selective\XmlDSig\PrivateKeyStore;
141 | use Selective\XmlDSig\XmlSigner;
142 | use DOMDocument;
143 | use DOMXPath;
144 | // ...
145 |
146 | // Load the XML content you want to sign
147 | $xml = new DOMDocument();
148 | $xml->preserveWhiteSpace = true;
149 | $xml->formatOutput = false;
150 | $xml->loadXML($data);
151 |
152 | // Create a XPATH query to select the element you want to sign
153 | $xpath = new DOMXPath($xml);
154 |
155 | // Change this query according to your requirements
156 | $referenceUri = '#1';
157 | $elementToSign = $xpath->query( '//*[@Id="'. $referenceUri .'"]' )->item(0);
158 |
159 | // Add private key
160 | $privateKeyStore = new PrivateKeyStore();
161 | $privateKeyStore->loadPrivateKey('private key content', 'password');
162 |
163 | $cryptoSigner = new CryptoSigner($privateKeyStore, new Algorithm(Algorithm::METHOD_SHA1));
164 |
165 | // Sign the element
166 | $xmlSigner = new XmlSigner($cryptoSigner);
167 | $signedXml = $xmlSigner->signDocument($xml, $elementToSign);
168 | ```
169 |
170 | ### Signing an XML Document with ECDSA SHA256
171 |
172 | The Elliptic Curve Digital Signature Algorithm (ECDSA) is the elliptic curve
173 | analogue of the Digital Signature Algorithm (DSA).
174 |
175 | It is compatible with OpenSSL and uses elegant math such as Jacobian Coordinates
176 | to speed up the ECDSA on pure PHP.
177 |
178 | **Requirements**
179 |
180 | * The [GMP extension](https://www.php.net/manual/en/book.gmp.php) must be installed and enabled.
181 |
182 | To install the package with Composer, run:
183 |
184 | ```
185 | composer require starkbank/ecdsa
186 | ```
187 |
188 | **Example**
189 |
190 | Note, you can sign an XML **signature** using ECDSA.
191 | It's not supported to use ECDSA for the **digest**.
192 |
193 | You can find a fully working example in the [XmlEcdsaTest](tests/XmlEcdsaTest.php) test class.
194 |
195 | ### Verify the Digital Signatures of XML Documents
196 |
197 | Load the public key(s):
198 |
199 | ```php
200 | use Selective\XmlDSig\PublicKeyStore;
201 | use Selective\XmlDSig\CryptoVerifier;
202 | use Selective\XmlDSig\XmlSignatureVerifier;
203 |
204 | $publicKeyStore = new PublicKeyStore();
205 |
206 | // load a public key from a string
207 | $publicKeyStore->loadFromPem('public key content');
208 |
209 | // or load a public key file
210 | $publicKeyStore->loadFromPem(file_get_contents('cacert.pem'));
211 |
212 | // or load a public key from a PKCS#12 certificate string
213 | $publicKeyStore->loadFromPkcs12('public key content', 'password');
214 |
215 | // or load a public key from a PKCS#12 certificate file
216 | $publicKeyStore->loadFromPkcs12(file_get_contents('filename.pfx'), 'password');
217 |
218 | // Load public keys from DOMDocument X509Certificate nodes
219 | $publicKeyStore->loadFromDocument($xml);
220 |
221 | // Load public key from existing OpenSSLCertificate resource
222 | $publicKeyStore->loadFromCertificate($certificate);
223 | ```
224 |
225 | Create a `CryptoVerifier` instance:
226 |
227 | ```php
228 | use Selective\XmlDSig\CryptoVerifier;
229 |
230 | $cryptoVerifier = new CryptoVerifier($publicKeyStore);
231 | ```
232 |
233 | Verifying:
234 |
235 | ```php
236 | use Selective\XmlDSig\XmlSignatureVerifier;
237 |
238 | // Create a verifier instance and pass the crypto decoder
239 | $xmlSignatureVerifier = new XmlSignatureVerifier($cryptoVerifier);
240 |
241 | // Verify XML from a string
242 | $isValid = $xmlSignatureVerifier->verifyXml($signedXml);
243 |
244 | // or verify a XML file
245 | $isValid = $xmlSignatureVerifier->verifyXml(file_get_contents('signed.xml'));
246 |
247 | // or verifying an DOMDocument instance
248 | $xml = new DOMDocument();
249 | $xml->preserveWhiteSpace = true;
250 | $xml->formatOutput = false;
251 | $xml->loadXML($data);
252 |
253 | $isValid = $xmlSignatureVerifier->verifyDocument($xml);
254 |
255 | if ($isValid === true) {
256 | echo 'The XML signature is valid.';
257 | } else {
258 | echo 'The XML signature is not valid.';
259 | }
260 | ```
261 |
262 | ### Online XML Digital Signature Verifier
263 |
264 | Try these excellent online tools to verify XML signatures:
265 |
266 | *
267 | *
268 |
269 | ## Similar libraries
270 |
271 | *
272 |
273 | ## License
274 |
275 | The MIT License (MIT). Please see [License File](LICENSE) for more information.
276 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "selective/xmldsig",
3 | "description": "Sign XML Documents with Digital Signatures",
4 | "license": "MIT",
5 | "type": "library",
6 | "keywords": [
7 | "xmldsig",
8 | "xml",
9 | "signatures",
10 | "verify"
11 | ],
12 | "homepage": "https://github.com/selective-php/xmldsig",
13 | "require": {
14 | "php": "~8.1 || ~8.2",
15 | "ext-dom": "*",
16 | "ext-openssl": "*"
17 | },
18 | "require-dev": {
19 | "friendsofphp/php-cs-fixer": "^3",
20 | "phpstan/phpstan": "^1",
21 | "phpunit/phpunit": "^10",
22 | "squizlabs/php_codesniffer": "^3",
23 | "starkbank/ecdsa": "^2.0"
24 | },
25 | "autoload": {
26 | "psr-4": {
27 | "Selective\\XmlDSig\\": "src/"
28 | }
29 | },
30 | "autoload-dev": {
31 | "psr-4": {
32 | "Selective\\XmlDSig\\Test\\": "tests/"
33 | }
34 | },
35 | "config": {
36 | "process-timeout": 0,
37 | "sort-packages": true
38 | },
39 | "scripts": {
40 | "cs:check": [
41 | "@putenv PHP_CS_FIXER_IGNORE_ENV=1",
42 | "php-cs-fixer fix --dry-run --format=txt --verbose --diff --config=.cs.php --ansi"
43 | ],
44 | "cs:fix": [
45 | "@putenv PHP_CS_FIXER_IGNORE_ENV=1",
46 | "php-cs-fixer fix --config=.cs.php --ansi --verbose"
47 | ],
48 | "sniffer:check": "phpcs --standard=phpcs.xml",
49 | "sniffer:fix": "phpcbf --standard=phpcs.xml",
50 | "stan": "phpstan analyse -c phpstan.neon --no-progress --ansi",
51 | "test": "phpunit --configuration phpunit.xml --do-not-cache-result --colors=always",
52 | "test:all": [
53 | "@cs:check",
54 | "@sniffer:check",
55 | "@stan",
56 | "@test"
57 | ],
58 | "test:coverage": "php -d xdebug.mode=coverage -r \"require 'vendor/bin/phpunit';\" -- --configuration phpunit.xml --do-not-cache-result --colors=always --coverage-clover build/logs/clover.xml --coverage-html build/coverage"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/phpcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | ./src
10 | ./tests
11 |
12 |
13 |
14 |
15 | warning
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 8
3 | checkGenericClassInNonGenericObjectType: false
4 | reportUnmatchedIgnoredErrors: false
5 | paths:
6 | - src
7 | ignoreErrors:
8 | - '#has unknown class OpenSSLAsymmetricKey as its type#'
9 | - '#(.*)OpenSSLAsymmetricKey(.*)null(.*) does not accept resource#'
10 | - '#openssl_free_key expects resource(.*) OpenSSLAsymmetricKey given#'
11 | - '#openssl_sign expects resource(.*)string(.*) OpenSSLAsymmetricKey given#'
12 | - '#function openssl_verify expects resource\|string\, OpenSSLAsymmetricKey given#'
13 | - '#function openssl_pkey_get_details expects resource\, OpenSSLAsymmetricKey given#'
14 | - '#function openssl_pkey_get_details expects OpenSSLAsymmetricKey\, OpenSSLAsymmetricKey\|resource given#'
15 | - '#function openssl_sign expects array\|OpenSSLAsymmetricKey\|OpenSSLCertificate\|string\, OpenSSLAsymmetricKey\|resource given#'
16 | - '#function openssl_free_key expects OpenSSLAsymmetricKey\, OpenSSLAsymmetricKey\|resource given#'
17 | - '#function openssl_pkey_get_details expects resource\, OpenSSLAsymmetricKey\|resource given#'
18 | - '#function openssl_sign expects resource\|string\, OpenSSLAsymmetricKey\|resource given#'
19 | - '#function openssl_free_key expects resource\, OpenSSLAsymmetricKey\|resource given#'
20 | - '#function openssl_verify expects array\|OpenSSLAsymmetricKey\|OpenSSLCertificate\|string\, OpenSSLAsymmetricKey\|resource given#'
21 | - '#function openssl_verify expects resource\|string\, OpenSSLAsymmetricKey\|resource given#'
--------------------------------------------------------------------------------
/src/Algorithm.php:
--------------------------------------------------------------------------------
1 | setSignatureMethodAlgorithm($signatureMethodAlgorithm);
62 | $this->setDigestMethodAlgorithm($digestMethodAlgorithm ?? $signatureMethodAlgorithm);
63 | }
64 |
65 | /**
66 | * Set signature and digest algorithm.
67 | *
68 | * @param string $algorithm For example: sha1, sha224, sha256, sha384, sha512
69 | */
70 | private function setSignatureMethodAlgorithm(string $algorithm): void
71 | {
72 | switch ($algorithm) {
73 | case self::METHOD_SHA1:
74 | $this->signatureAlgorithmUrl = self::SIGNATURE_SHA1_URL;
75 | $this->signatureSslAlgorithm = OPENSSL_ALGO_SHA1;
76 | break;
77 | case self::METHOD_SHA224:
78 | $this->signatureAlgorithmUrl = self::SIGNATURE_SHA224_URL;
79 | $this->signatureSslAlgorithm = OPENSSL_ALGO_SHA224;
80 | break;
81 | case self::METHOD_SHA256:
82 | $this->signatureAlgorithmUrl = self::SIGNATURE_SHA256_URL;
83 | $this->signatureSslAlgorithm = OPENSSL_ALGO_SHA256;
84 | break;
85 | case self::METHOD_SHA384:
86 | $this->signatureAlgorithmUrl = self::SIGNATURE_SHA384_URL;
87 | $this->signatureSslAlgorithm = OPENSSL_ALGO_SHA384;
88 | break;
89 | case self::METHOD_SHA512:
90 | $this->signatureAlgorithmUrl = self::SIGNATURE_SHA512_URL;
91 | $this->signatureSslAlgorithm = OPENSSL_ALGO_SHA512;
92 | break;
93 | case self::METHOD_ECDSA_SHA256:
94 | $this->signatureAlgorithmUrl = self::SIGNATURE_ECDSA_SHA256_URL;
95 | $this->signatureSslAlgorithm = 0;
96 | break;
97 | default:
98 | throw new UnexpectedValueException(sprintf('Unsupported algorithm: %s>', $algorithm));
99 | }
100 |
101 | $this->signatureAlgorithmName = $algorithm;
102 | }
103 |
104 | /**
105 | * Set signature and digest algorithm.
106 | *
107 | * @param string $algorithm For example: sha1, sha224, sha256, sha384, sha512
108 | */
109 | private function setDigestMethodAlgorithm(string $algorithm): void
110 | {
111 | switch ($algorithm) {
112 | case self::METHOD_SHA1:
113 | $this->digestAlgorithmUrl = self::DIGEST_SHA1_URL;
114 | break;
115 | case self::METHOD_SHA224:
116 | $this->digestAlgorithmUrl = self::DIGEST_SHA224_URL;
117 | break;
118 | case self::METHOD_SHA256:
119 | $this->digestAlgorithmUrl = self::DIGEST_SHA256_URL;
120 | break;
121 | case self::METHOD_SHA384:
122 | $this->digestAlgorithmUrl = self::DIGEST_SHA384_URL;
123 | break;
124 | case self::METHOD_SHA512:
125 | $this->digestAlgorithmUrl = self::DIGEST_SHA512_URL;
126 | break;
127 | case self::METHOD_ECDSA_SHA256:
128 | $this->digestAlgorithmUrl = self::DIGEST_ECDSA_SHA256_URL;
129 | break;
130 | default:
131 | throw new XmlSignerException("Cannot validate digest: Unsupported algorithm <$algorithm>");
132 | }
133 |
134 | $this->digestAlgorithmName = $algorithm;
135 | }
136 |
137 | public function getSignatureAlgorithmUrl(): string
138 | {
139 | return $this->signatureAlgorithmUrl;
140 | }
141 |
142 | public function getDigestAlgorithmUrl(): string
143 | {
144 | return $this->digestAlgorithmUrl;
145 | }
146 |
147 | public function getSignatureSslAlgorithm(): int
148 | {
149 | return $this->signatureSslAlgorithm;
150 | }
151 |
152 | public function getSignatureAlgorithmName(): string
153 | {
154 | return $this->signatureAlgorithmName;
155 | }
156 |
157 | public function getDigestAlgorithmName(): string
158 | {
159 | return $this->digestAlgorithmName;
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/src/CryptoSigner.php:
--------------------------------------------------------------------------------
1 | privateKeyStore = $privateKeyStore;
26 | $this->algorithm = $algorithm;
27 | }
28 |
29 | public function computeSignature(string $data): string
30 | {
31 | // ECDSA
32 | if ($this->algorithm->getSignatureAlgorithmName() === Algorithm::METHOD_ECDSA_SHA256) {
33 | return $this->computeSignatureWithEcdsa($data);
34 | }
35 |
36 | // Default
37 | $privateKey = $this->privateKeyStore->getPrivateKey();
38 |
39 | if (!$privateKey) {
40 | throw new CertificateException('Undefined private key');
41 | }
42 |
43 | // Calculate and encode digest value
44 | $status = openssl_sign($data, $signatureValue, $privateKey, $this->algorithm->getSignatureSslAlgorithm());
45 |
46 | if (!$status) {
47 | throw new XmlSignerException('Computing of the signature failed');
48 | }
49 |
50 | return $signatureValue;
51 | }
52 |
53 | private function computeSignatureWithEcdsa(string $data): string
54 | {
55 | $privateKeyPem = $this->privateKeyStore->getPrivateKeyAsPem();
56 |
57 | if (!$privateKeyPem) {
58 | throw new CertificateException('Undefined private key');
59 | }
60 |
61 | // Generate privateKey from PEM string
62 | $privateKey = PrivateKey::fromPem($privateKeyPem);
63 | $signature = Ecdsa::sign($data, $privateKey);
64 |
65 | return (string)base64_decode($signature->toBase64());
66 | }
67 |
68 | public function computeDigest(string $data): string
69 | {
70 | // Calculate and encode digest value
71 | $digestValue = openssl_digest($data, $this->algorithm->getDigestAlgorithmName(), true);
72 |
73 | if ($digestValue === false) {
74 | throw new UnexpectedValueException('Invalid digest value');
75 | }
76 |
77 | return $digestValue;
78 | }
79 |
80 | public function getPrivateKeyStore(): PrivateKeyStore
81 | {
82 | return $this->privateKeyStore;
83 | }
84 |
85 | public function getAlgorithm(): Algorithm
86 | {
87 | return $this->algorithm;
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/CryptoSignerInterface.php:
--------------------------------------------------------------------------------
1 | publicKeyStore = $publicKeyStore;
24 | }
25 |
26 | public function verify(string $data, string $signature, string $algorithm): bool
27 | {
28 | $publicKeys = $this->publicKeyStore->getPublicKeys();
29 | if (!$publicKeys) {
30 | throw new CertificateException('No public key provided');
31 | }
32 |
33 | if (str_contains($algorithm, 'ecdsa')) {
34 | return $this->verifyEcdsa($publicKeys, $signature, $data);
35 | }
36 |
37 | $algo = $this->mapUrlToOpenSslAlgoCode($algorithm);
38 |
39 | foreach ($publicKeys as $publicKey) {
40 | $status = openssl_verify($data, $signature, $publicKey, $algo);
41 |
42 | if ($status === 1) {
43 | return true;
44 | }
45 | }
46 |
47 | // The XML signature is not valid
48 | return false;
49 | }
50 |
51 | private function mapUrlToOpenSslAlgoCode(string $algorithm): int
52 | {
53 | $algorithm = strtolower($algorithm);
54 |
55 | $hashes = [
56 | Algorithm::METHOD_SHA1 => OPENSSL_ALGO_SHA1,
57 | Algorithm::METHOD_SHA224 => OPENSSL_ALGO_SHA224,
58 | Algorithm::METHOD_SHA256 => OPENSSL_ALGO_SHA256,
59 | Algorithm::METHOD_SHA384 => OPENSSL_ALGO_SHA384,
60 | Algorithm::METHOD_SHA512 => OPENSSL_ALGO_SHA512,
61 | ];
62 |
63 | foreach ($hashes as $hash => $ssl) {
64 | if (str_contains($algorithm, $hash)) {
65 | return $ssl;
66 | }
67 | }
68 |
69 | throw new XmlSignatureValidatorException(
70 | sprintf('Cannot verify: Unsupported Algorithm: %s', $algorithm)
71 | );
72 | }
73 |
74 | /**
75 | * Map algo to OpenSSL method name.
76 | *
77 | * @param string $algorithm The url
78 | *
79 | * @return string The name of the OpenSSL algorithm
80 | */
81 | private function mapUrlToOpenSslDigestAlgo(string $algorithm): string
82 | {
83 | $algorithm = strtolower($algorithm);
84 |
85 | $hashes = [
86 | Algorithm::METHOD_SHA1,
87 | Algorithm::METHOD_SHA224,
88 | Algorithm::METHOD_SHA256,
89 | Algorithm::METHOD_SHA384,
90 | Algorithm::METHOD_SHA512,
91 | Algorithm::METHOD_ECDSA_SHA256,
92 | ];
93 |
94 | foreach ($hashes as $hash) {
95 | if (str_contains($algorithm, $hash)) {
96 | return $hash;
97 | }
98 | }
99 |
100 | throw new XmlSignatureValidatorException(sprintf('Unsupported algorithm: %s', $algorithm));
101 | }
102 |
103 | public function computeDigest(string $data, string $algorithm): string
104 | {
105 | $digestAlgo = $this->mapUrlToOpenSslDigestAlgo($algorithm);
106 | $digest = openssl_digest($data, $digestAlgo, true);
107 |
108 | if ($digest === false) {
109 | throw new XmlSignatureValidatorException('Invalid digest value');
110 | }
111 |
112 | return $digest;
113 | }
114 |
115 | /**
116 | * Verify using the Elliptic Curve Digital Signature Algorithm (ECDSA).
117 | *
118 | * @param OpenSSLAsymmetricKey[] $publicKeys The public keys
119 | * @param string $signature The signature from the xml element
120 | * @param string $data The data
121 | *
122 | * @return bool The status
123 | */
124 | private function verifyEcdsa(array $publicKeys, string $signature, string $data): bool
125 | {
126 | foreach ($publicKeys as $publicKey) {
127 | $signature = Signature::fromDer($signature);
128 |
129 | // Convert OpenSSLAsymmetricKey to PEM string
130 | $details = openssl_pkey_get_details($publicKey);
131 | $publicKey2 = PublicKey::fromPem($details['key'] ?? '');
132 |
133 | $status = Ecdsa::verify($data, $signature, $publicKey2);
134 | if ($status) {
135 | return true;
136 | }
137 | }
138 |
139 | return false;
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/CryptoVerifierInterface.php:
--------------------------------------------------------------------------------
1 | certificates[] = $certificate;
36 | }
37 |
38 | /**
39 | * Get X509 certificates.
40 | *
41 | * @return OpenSSLCertificate[]
42 | */
43 | public function getCertificates(): array
44 | {
45 | return $this->certificates;
46 | }
47 |
48 | /**
49 | * Load X509 certificates from PEM.
50 | * PEM is a base64 format for certificates.
51 | *
52 | * @param string $certificate The certificate bundle
53 | *
54 | * @return void
55 | */
56 | public function addCertificatesFromX509Pem(string $certificate): void
57 | {
58 | $x509Reader = new X509Reader();
59 | foreach ($x509Reader->fromPem($certificate) as $certificate) {
60 | $this->addCertificate($certificate);
61 | }
62 | }
63 |
64 | /**
65 | * Read and load a private key.
66 | *
67 | * @param string $pem The PEM formatted private key
68 | * @param string $password The PEM password
69 | *
70 | * @throws XmlSignerException
71 | *
72 | * @return void
73 | */
74 | public function loadFromPem(string $pem, string $password): void
75 | {
76 | // Read the private key
77 | $privateKey = openssl_pkey_get_private($pem, $password);
78 |
79 | if (!$privateKey) {
80 | throw new XmlSignerException('Invalid password or private key');
81 | }
82 |
83 | $this->privateKey = $privateKey;
84 | $this->privateKeyPem = $pem;
85 |
86 | $this->loadPrivateKeyDetails();
87 | }
88 |
89 | /**
90 | * Load the PKCS12 (PFX) content.
91 | *
92 | * PKCS12 is an encrypted container that contains the public key and private key combined in binary format.
93 | *
94 | * @param string $pkcs12 The content
95 | * @param string $password The password
96 | *
97 | * @throws CertificateException
98 | *
99 | * @return void
100 | */
101 | public function loadFromPkcs12(string $pkcs12, string $password): void
102 | {
103 | if (!$pkcs12) {
104 | throw new CertificateException('The PKCS12 certificate must not be empty.');
105 | }
106 |
107 | $status = openssl_pkcs12_read($pkcs12, $certInfo, $password);
108 |
109 | if (!$status) {
110 | throw new CertificateException(
111 | 'Invalid certificate. Could not read private key from PKCS12 certificate. ' .
112 | openssl_error_string() .
113 | $pkcs12
114 | );
115 | }
116 |
117 | // Read the private key
118 | $this->privateKeyPem = (string)$certInfo['pkey'];
119 |
120 | if (!$this->privateKeyPem) {
121 | throw new CertificateException('Invalid or missing private key');
122 | }
123 |
124 | $privateKey = openssl_pkey_get_private($this->privateKeyPem);
125 |
126 | if (!$privateKey) {
127 | throw new CertificateException('Invalid private key');
128 | }
129 |
130 | $this->privateKey = $privateKey;
131 |
132 | $this->loadPrivateKeyDetails();
133 | }
134 |
135 | /**
136 | * Load private key details.
137 | *
138 | * @throws UnexpectedValueException
139 | *
140 | * @return void
141 | */
142 | private function loadPrivateKeyDetails(): void
143 | {
144 | if (!$this->privateKey) {
145 | throw new UnexpectedValueException('Private key is not defined');
146 | }
147 |
148 | $details = openssl_pkey_get_details($this->privateKey);
149 |
150 | if ($details === false) {
151 | throw new UnexpectedValueException('Invalid private key');
152 | }
153 |
154 | $key = $this->getPrivateKeyDetailKey($details['type']);
155 |
156 | if (isset($details[$key]['n'])) {
157 | $this->modulus = base64_encode($details[$key]['n']);
158 | }
159 | if (isset($details[$key]['e'])) {
160 | $this->publicExponent = base64_encode($details[$key]['e']);
161 | }
162 | }
163 |
164 | /**
165 | * Get private key details key type.
166 | *
167 | * @param int $type The type
168 | *
169 | * @return string The array key
170 | */
171 | private function getPrivateKeyDetailKey(int $type): string
172 | {
173 | $key = '';
174 | $key = $type === OPENSSL_KEYTYPE_RSA ? 'rsa' : $key;
175 | $key = $type === OPENSSL_KEYTYPE_DSA ? 'dsa' : $key;
176 | $key = $type === OPENSSL_KEYTYPE_DH ? 'dh' : $key;
177 | $key = $type === OPENSSL_KEYTYPE_EC ? 'ec' : $key;
178 |
179 | return $key;
180 | }
181 |
182 | public function getPrivateKey(): ?OpenSSLAsymmetricKey
183 | {
184 | return $this->privateKey;
185 | }
186 |
187 | public function getPrivateKeyAsPem(): ?string
188 | {
189 | return $this->privateKeyPem;
190 | }
191 |
192 | public function getModulus(): ?string
193 | {
194 | return $this->modulus;
195 | }
196 |
197 | public function getPublicExponent(): ?string
198 | {
199 | return $this->publicExponent;
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/src/PublicKeyStore.php:
--------------------------------------------------------------------------------
1 | publicKeys[] = $publicKey;
31 | }
32 |
33 | /**
34 | * Return public keys.
35 | *
36 | * @return OpenSSLAsymmetricKey[] The public keys
37 | */
38 | public function getPublicKeys(): array
39 | {
40 | return $this->publicKeys;
41 | }
42 |
43 | /**
44 | * Load public key from a PKCS#12 certificate (PFX) certificate.
45 | *
46 | * @param string $pkcs12 The certificate data
47 | * @param string $password The encryption password for unlocking the PKCS12 certificate
48 | *
49 | * @throws CertificateException
50 | *
51 | * @return void
52 | */
53 | public function loadFromPkcs12(string $pkcs12, string $password): void
54 | {
55 | $status = openssl_pkcs12_read($pkcs12, $certificates, $password);
56 |
57 | if (!$status) {
58 | throw new CertificateException('Invalid certificate. Could not read public key from PKCS12 certificate.');
59 | }
60 |
61 | $publicKey = openssl_get_publickey($certificates['cert']);
62 |
63 | if ($publicKey === false) {
64 | throw new CertificateException('Invalid public key');
65 | }
66 |
67 | $this->addPublicKey($publicKey);
68 | }
69 |
70 | /**
71 | * Load the public key content.
72 | *
73 | * @param OpenSSLCertificate $publicKey The public key data
74 | *
75 | * @throws CertificateException
76 | *
77 | * @return void
78 | */
79 | public function loadFromCertificate(OpenSSLCertificate $publicKey): void
80 | {
81 | $publicKeyIdentifier = openssl_pkey_get_public($publicKey);
82 |
83 | if (!$publicKeyIdentifier) {
84 | throw new CertificateException('Invalid public key');
85 | }
86 |
87 | $this->addPublicKey($publicKeyIdentifier);
88 | }
89 |
90 | /**
91 | * Load the public key content.
92 | *
93 | * @param string $pem A PEM formatted public key
94 | *
95 | * @throws CertificateException
96 | *
97 | * @return void
98 | */
99 | public function loadFromPem(string $pem): void
100 | {
101 | $publicKey = openssl_pkey_get_public($pem);
102 |
103 | if (!$publicKey) {
104 | throw new CertificateException('Invalid public key');
105 | }
106 |
107 | $this->addPublicKey($publicKey);
108 | }
109 |
110 | /**
111 | * Load the public key content from XML document.
112 | *
113 | * @param DOMDocument $xml The document
114 | *
115 | * @return void
116 | */
117 | public function loadFromDocument(DOMDocument $xml): void
118 | {
119 | $xpath = new DOMXPath($xml);
120 | $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
121 |
122 | // Find the X509Certificate nodes
123 | $x509CertificateNodes = $xpath->query('//xmlns:Signature/xmlns:KeyInfo/xmlns:X509Data/xmlns:X509Certificate');
124 |
125 | // Throw an exception if no signature was found.
126 | if (!$x509CertificateNodes || $x509CertificateNodes->length < 1) {
127 | // No X509Certificate item was found in the document
128 | return;
129 | }
130 |
131 | $x509Reader = new X509Reader();
132 | foreach ($x509CertificateNodes as $domNode) {
133 | $base64 = $domNode->nodeValue;
134 | if (!$base64) {
135 | continue;
136 | }
137 |
138 | $this->loadFromCertificate($x509Reader->fromRawBase64($base64));
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/X509Reader.php:
--------------------------------------------------------------------------------
1 | query($expression, $contextNode);
28 |
29 | if (!$nodeList) {
30 | throw new UnexpectedValueException('Signature value not found');
31 | }
32 |
33 | $item = $nodeList->item(0);
34 | if ($item === null) {
35 | throw new UnexpectedValueException('Signature value not found');
36 | }
37 |
38 | return $item;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/XmlSignatureVerifier.php:
--------------------------------------------------------------------------------
1 | cryptoVerifier = $cryptoVerifier;
31 | $this->preserveWhiteSpace = $preserveWhiteSpace;
32 | $this->xmlReader = new XmlReader();
33 | }
34 |
35 | /**
36 | * Verify an XML string.
37 | *
38 | * https://www.xml.com/pub/a/2001/08/08/xmldsig.html#verify
39 | *
40 | * @param string $data The xml content
41 | *
42 | * @throws XmlSignatureValidatorException
43 | *
44 | * @return bool Success
45 | */
46 | public function verifyXml(string $data): bool
47 | {
48 | // Read the xml file content
49 | $xml = new DOMDocument();
50 | $xml->preserveWhiteSpace = $this->preserveWhiteSpace;
51 | $xml->formatOutput = false;
52 | $isValidSignature = $xml->loadXML($data);
53 |
54 | if (!$isValidSignature || !$xml->documentElement) {
55 | throw new XmlSignatureValidatorException('Invalid XML content');
56 | }
57 |
58 | return $this->verifyDocument($xml);
59 | }
60 |
61 | /**
62 | * Verify XML document.
63 | *
64 | * @param DOMDocument $xml The document
65 | *
66 | * @return bool The status
67 | */
68 | public function verifyDocument(DOMDocument $xml): bool
69 | {
70 | $signatureAlgorithm = $this->getDocumentAlgorithm($xml, '//xmlns:SignedInfo/xmlns:SignatureMethod');
71 | $digestAlgorithm = $this->getDocumentAlgorithm($xml, '//xmlns:DigestMethod');
72 | $signatureValue = $this->getSignatureValue($xml);
73 | $xpath = new DOMXPath($xml);
74 | $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
75 |
76 | /** @var DOMNodeList $nodes */
77 | $nodes = $xpath->evaluate('//xmlns:Signature/xmlns:SignedInfo');
78 |
79 | /** @var DOMElement $signedInfoNode */
80 | foreach ($nodes as $signedInfoNode) {
81 | // Remove SignatureValue value
82 | $signatureValueElement = $this->xmlReader->queryDomNode($xpath, '//xmlns:SignatureValue', $signedInfoNode);
83 | $signatureValueElement->nodeValue = '';
84 |
85 | $canonicalData = $signedInfoNode->C14N(true, false);
86 |
87 | $xml2 = new DOMDocument();
88 | $xml2->preserveWhiteSpace = true;
89 | $xml2->formatOutput = true;
90 | $xml2->loadXML($canonicalData);
91 | $canonicalData = $xml2->C14N(true, false);
92 |
93 | $isValidSignature = $this->cryptoVerifier->verify($canonicalData, $signatureValue, $signatureAlgorithm);
94 |
95 | if (!$isValidSignature) {
96 | // The XML signature is not valid
97 | return false;
98 | }
99 | }
100 |
101 | return $this->checkDigest($xml, $xpath, $digestAlgorithm);
102 | }
103 |
104 | /**
105 | * Check digest value.
106 | *
107 | * @param DOMDocument $xml The xml document
108 | * @param DOMXPath $xpath The xpath
109 | * @param string $algorithm The digest algorithm url
110 | *
111 | * @return bool The status
112 | */
113 | private function checkDigest(DOMDocument $xml, DOMXPath $xpath, string $algorithm): bool
114 | {
115 | $digestValue = $this->getDigestValue($xml);
116 |
117 | // Remove signature elements
118 | /** @var DOMElement $signatureNode */
119 | foreach ($xpath->query('//xmlns:Signature') ?: [] as $signatureNode) {
120 | if (!$signatureNode->parentNode) {
121 | continue;
122 | }
123 |
124 | $signatureNode->parentNode->removeChild($signatureNode);
125 | }
126 |
127 | // Canonicalize the content, exclusive and without comments
128 | $canonicalData = $xml->C14N(true, false);
129 |
130 | $digestValue2 = $this->cryptoVerifier->computeDigest($canonicalData, $algorithm);
131 |
132 | return hash_equals($digestValue, $digestValue2);
133 | }
134 |
135 | /**
136 | * Detect digest algorithm.
137 | *
138 | * @param DOMDocument $xml The xml document
139 | * @param string $expression
140 | *
141 | * @throws XmlSignatureValidatorException
142 | *
143 | * @return string The algorithm url
144 | */
145 | private function getDocumentAlgorithm(DOMDocument $xml, string $expression): string
146 | {
147 | $xpath = new DOMXPath($xml);
148 | $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
149 | $xpath->registerNamespace('Algorithm', 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315');
150 |
151 | $signatureMethodNodes = $xpath->query($expression);
152 |
153 | // Throw an exception if no signature was found.
154 | if (!$signatureMethodNodes || $signatureMethodNodes->length < 1) {
155 | throw new XmlSignatureValidatorException('Verification failed: No Signature was found in the document.');
156 | }
157 |
158 | // We only support one signature for the entire XML document.
159 | // Throw an exception if more than one signature was found.
160 | if ($signatureMethodNodes->length > 1) {
161 | throw new XmlSignatureValidatorException(
162 | 'Verification failed: More that one signature was found for the document.'
163 | );
164 | }
165 |
166 | /** @var DOMElement $element */
167 | $element = $signatureMethodNodes->item(0);
168 | if (!$element instanceof DOMElement) {
169 | throw new XmlSignatureValidatorException(
170 | 'Verification failed: Signature algorithm was found for the document.'
171 | );
172 | }
173 |
174 | return $element->getAttribute('Algorithm');
175 | }
176 |
177 | /**
178 | * Get signature value.
179 | *
180 | * @param DOMDocument $xml The xml document
181 | *
182 | * @throws XmlSignatureValidatorException
183 | *
184 | * @return string The signature value
185 | */
186 | private function getSignatureValue(DOMDocument $xml): string
187 | {
188 | $xpath = new DOMXPath($xml);
189 | $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
190 |
191 | // Find the SignatureValue node
192 | $signatureNodes = $xpath->query('//xmlns:Signature/xmlns:SignatureValue');
193 |
194 | // Throw an exception if no signature was found.
195 | if (!$signatureNodes || $signatureNodes->length < 1) {
196 | throw new XmlSignatureValidatorException('Verification failed: No Signature was found in the document.');
197 | }
198 |
199 | // We only support one signature for the entire XML document.
200 | // Throw an exception if more than one signature was found.
201 | if ($signatureNodes->length > 1) {
202 | throw new XmlSignatureValidatorException(
203 | 'Verification failed: More that one signature was found for the document.'
204 | );
205 | }
206 |
207 | $domNode = $signatureNodes->item(0);
208 | if (!$domNode) {
209 | throw new XmlSignatureValidatorException(
210 | 'Verification failed: No Signature item was found in the document.'
211 | );
212 | }
213 |
214 | $result = base64_decode((string)$domNode->nodeValue, true);
215 |
216 | if ($result === false) {
217 | throw new XmlSignatureValidatorException('Verification failed: Invalid base64 data.');
218 | }
219 |
220 | return (string)$result;
221 | }
222 |
223 | /**
224 | * Get the digest value.
225 | *
226 | * @param DOMDocument $xml The xml document
227 | *
228 | * @throws XmlSignatureValidatorException
229 | *
230 | * @return string The signature value
231 | */
232 | private function getDigestValue(DOMDocument $xml): string
233 | {
234 | $xpath = new DOMXPath($xml);
235 | $xpath->registerNamespace('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
236 |
237 | // Find the DigestValue node
238 | $signatureNodes = $xpath->query('//xmlns:Signature/xmlns:SignedInfo/xmlns:Reference/xmlns:DigestValue');
239 |
240 | // Throw an exception if no signature was found.
241 | if (!$signatureNodes || $signatureNodes->length < 1) {
242 | throw new XmlSignatureValidatorException('Verification failed: No Signature was found in the document.');
243 | }
244 |
245 | // We only support one signature for the entire XML document.
246 | // Throw an exception if more than one signature was found.
247 | if ($signatureNodes->length > 1) {
248 | throw new XmlSignatureValidatorException(
249 | 'Verification failed: More that one signature was found for the document.'
250 | );
251 | }
252 |
253 | $domNode = $signatureNodes->item(0);
254 | if (!$domNode) {
255 | throw new XmlSignatureValidatorException(
256 | 'Verification failed: No Signature item was found in the document.'
257 | );
258 | }
259 |
260 | $result = base64_decode((string)$domNode->nodeValue, true);
261 |
262 | if ($result === false) {
263 | throw new XmlSignatureValidatorException('Verification failed: Invalid base64 data.');
264 | }
265 |
266 | return $result;
267 | }
268 | }
269 |
--------------------------------------------------------------------------------
/src/XmlSigner.php:
--------------------------------------------------------------------------------
1 | xmlReader = new XmlReader();
26 | $this->cryptoSigner = $cryptoSigner;
27 | }
28 |
29 | /**
30 | * Sign an XML file and save the signature in a new file.
31 | * This method does not save the public key within the XML file.
32 | *
33 | * @param string $data The XML content to sign
34 | *
35 | * @throws XmlSignerException
36 | *
37 | * @return string The signed XML content
38 | */
39 | public function signXml(string $data): string
40 | {
41 | // Read the xml file content
42 | $xml = new DOMDocument();
43 |
44 | // Whitespaces must be preserved
45 | $xml->preserveWhiteSpace = true;
46 | $xml->formatOutput = false;
47 |
48 | $xml->loadXML($data);
49 |
50 | // Canonicalize the content, exclusive and without comments
51 | if (!$xml->documentElement) {
52 | throw new XmlSignerException('Undefined document element');
53 | }
54 |
55 | return $this->signDocument($xml);
56 | }
57 |
58 | /**
59 | * Sign DOM document.
60 | *
61 | * @param DOMDocument $document The document
62 | * @param DOMElement|null $element The element of the document to sign
63 | *
64 | * @return string The signed XML as string
65 | */
66 | public function signDocument(DOMDocument $document, DOMElement $element = null): string
67 | {
68 | $element = $element ?? $document->documentElement;
69 |
70 | if ($element === null) {
71 | throw new XmlSignerException('Invalid XML document element');
72 | }
73 |
74 | $canonicalData = $element->C14N(true, false);
75 |
76 | // Calculate and encode digest value
77 | $digestValue = $this->cryptoSigner->computeDigest($canonicalData);
78 |
79 | $digestValue = base64_encode($digestValue);
80 | $this->appendSignature($document, $digestValue);
81 |
82 | $result = $document->saveXML();
83 |
84 | if ($result === false) {
85 | throw new XmlSignerException('Signing failed. Invalid XML.');
86 | }
87 |
88 | return $result;
89 | }
90 |
91 | /**
92 | * Create the XML representation of the signature.
93 | *
94 | * @param DOMDocument $xml The xml document
95 | * @param string $digestValue The digest value
96 | *
97 | * @throws UnexpectedValueException
98 | *
99 | * @return void The DOM document
100 | */
101 | private function appendSignature(DOMDocument $xml, string $digestValue): void
102 | {
103 | $signatureElement = $xml->createElement('Signature');
104 | $signatureElement->setAttribute('xmlns', 'http://www.w3.org/2000/09/xmldsig#');
105 |
106 | // Append the element to the XML document.
107 | // We insert the new element as root (child of the document)
108 |
109 | if (!$xml->documentElement) {
110 | throw new UnexpectedValueException('Undefined document element');
111 | }
112 |
113 | $xml->documentElement->appendChild($signatureElement);
114 |
115 | $signedInfoElement = $xml->createElement('SignedInfo');
116 | $signatureElement->appendChild($signedInfoElement);
117 |
118 | $canonicalizationMethodElement = $xml->createElement('CanonicalizationMethod');
119 | $canonicalizationMethodElement->setAttribute('Algorithm', 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315');
120 | $signedInfoElement->appendChild($canonicalizationMethodElement);
121 |
122 | $signatureMethodElement = $xml->createElement('SignatureMethod');
123 | $signatureMethodElement->setAttribute(
124 | 'Algorithm',
125 | $this->cryptoSigner->getAlgorithm()->getSignatureAlgorithmUrl()
126 | );
127 | $signedInfoElement->appendChild($signatureMethodElement);
128 |
129 | $referenceElement = $xml->createElement('Reference');
130 | $referenceElement->setAttribute('URI', $this->referenceUri);
131 | $signedInfoElement->appendChild($referenceElement);
132 |
133 | $transformsElement = $xml->createElement('Transforms');
134 | $referenceElement->appendChild($transformsElement);
135 |
136 | // Enveloped: the node is inside the XML we want to sign
137 | $transformElement = $xml->createElement('Transform');
138 | $transformElement->setAttribute('Algorithm', 'http://www.w3.org/2000/09/xmldsig#enveloped-signature');
139 | $transformsElement->appendChild($transformElement);
140 |
141 | $digestMethodElement = $xml->createElement('DigestMethod');
142 | $digestMethodElement->setAttribute('Algorithm', $this->cryptoSigner->getAlgorithm()->getDigestAlgorithmUrl());
143 | $referenceElement->appendChild($digestMethodElement);
144 |
145 | $digestValueElement = $xml->createElement('DigestValue', $digestValue);
146 | $referenceElement->appendChild($digestValueElement);
147 |
148 | $signatureValueElement = $xml->createElement('SignatureValue', '');
149 | $signatureElement->appendChild($signatureValueElement);
150 |
151 | $keyInfoElement = $xml->createElement('KeyInfo');
152 | $signatureElement->appendChild($keyInfoElement);
153 |
154 | $keyValueElement = $xml->createElement('KeyValue');
155 | $keyInfoElement->appendChild($keyValueElement);
156 |
157 | $rsaKeyValueElement = $xml->createElement('RSAKeyValue');
158 | $keyValueElement->appendChild($rsaKeyValueElement);
159 |
160 | $modulus = $this->cryptoSigner->getPrivateKeyStore()->getModulus();
161 | if ($modulus) {
162 | $modulusElement = $xml->createElement('Modulus', $modulus);
163 | $rsaKeyValueElement->appendChild($modulusElement);
164 | }
165 |
166 | $publicExponent = $this->cryptoSigner->getPrivateKeyStore()->getPublicExponent();
167 | if ($publicExponent) {
168 | $exponentElement = $xml->createElement('Exponent', $publicExponent);
169 | $rsaKeyValueElement->appendChild($exponentElement);
170 | }
171 |
172 | // If certificates are loaded attach them to the KeyInfo element
173 | $certificates = $this->cryptoSigner->getPrivateKeyStore()->getCertificates();
174 | if ($certificates) {
175 | $this->appendX509Certificates($xml, $keyInfoElement, $certificates);
176 | }
177 |
178 | // http://www.soapclient.com/XMLCanon.html
179 | $c14nSignedInfo = $signedInfoElement->C14N(true, false);
180 |
181 | $signatureValue = $this->cryptoSigner->computeSignature($c14nSignedInfo);
182 |
183 | $xpath = new DOMXpath($xml);
184 | $signatureValueElement = $this->xmlReader->queryDomNode($xpath, '//SignatureValue', $signatureElement);
185 | $signatureValueElement->nodeValue = base64_encode($signatureValue);
186 | }
187 |
188 | /**
189 | * Create and append an X509Data element containing certificates in base64 format.
190 | *
191 | * @param DOMDocument $xml
192 | * @param DOMElement $keyInfoElement
193 | * @param OpenSSLCertificate[] $certificates
194 | *
195 | * @return void
196 | */
197 | private function appendX509Certificates(DOMDocument $xml, DOMElement $keyInfoElement, array $certificates): void
198 | {
199 | $x509DataElement = $xml->createElement('X509Data');
200 | $keyInfoElement->appendChild($x509DataElement);
201 |
202 | $x509Reader = new X509Reader();
203 | foreach ($certificates as $certificateId) {
204 | $certificate = $x509Reader->toRawBase64($certificateId);
205 |
206 | $x509CertificateElement = $xml->createElement('X509Certificate', $certificate);
207 | $x509DataElement->appendChild($x509CertificateElement);
208 | }
209 | }
210 |
211 | /**
212 | * Set reference URI.
213 | *
214 | * @param string $referenceUri The reference URI
215 | *
216 | * @return void
217 | */
218 | public function setReferenceUri(string $referenceUri): void
219 | {
220 | $this->referenceUri = $referenceUri;
221 | }
222 | }
223 |
--------------------------------------------------------------------------------