├── .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 | [![Latest Version on Packagist](https://img.shields.io/github/release/selective-php/xmldsig.svg)](https://packagist.org/packages/selective/xmldsig) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](LICENSE) 5 | [![Build Status](https://github.com/selective-php/xmldsig/workflows/build/badge.svg)](https://github.com/selective-php/xmldsig/actions) 6 | [![Coverage Status](https://img.shields.io/scrutinizer/coverage/g/selective-php/xmldsig.svg)](https://scrutinizer-ci.com/g/selective-php/xmldsig/code-structure) 7 | [![Quality Score](https://img.shields.io/scrutinizer/quality/g/selective-php/xmldsig.svg)](https://scrutinizer-ci.com/g/selective-php/xmldsig/?branch=master) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/selective/xmldsig.svg)](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 | --------------------------------------------------------------------------------