beziworld / laravel-xades
XAdES-BES and XAdES-B-B signing and verification of XML documents in Laravel with PKCS#12 or PEM certificates
Requires
- php: ^8.3
- ext-dom: *
- ext-libxml: *
- ext-mbstring: *
- ext-openssl: *
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- phpseclib/phpseclib: ^3.0.43
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.7|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan-strict-rules: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 18:50:42 UTC
README
XAdES signing and verification of XML documents in Laravel, with a PKCS#12 (.p12/.pfx) or PEM certificate.
Built for Polish e-government XML (KSeF AuthTokenRequest, JPK InitUpload, e-Deklaracje and other
documents that require a XAdES signature), usable for any XML.
- XAdES-BES (ETSI TS 101 903 / XAdES 1.3.2:
SigningTime,SigningCertificatewithCertDigestandIssuerSerial) and XAdES-B-B (ETSI EN 319 132-1:SigningCertificateV2,DataObjectFormat) - enveloped, enveloping and detached signatures (detached over any bytes, for example a PDF)
- RSA (PKCS#1 v1.5, 2048 bits and more) and ECDSA (P-256 and larger,
r||sencoding), SHA-256/384/512, Exclusive or inclusive Canonical XML 1.0 - optional
CommitmentTypeIndication,SignerRole(claimed roles),DataObjectFormatdescription, intermediate certificates inKeyInfo - strict verification: signature value, every reference digest, the SignedProperties reference, the
certificate digest,
IssuerSerial/IssuerSerialV2, validity atSigningTime, optional trust list; rejects DOCTYPE/XXE, duplicated identifiers (signature wrapping), XPath/XSLT transforms and SHA-1 - validation of the output against the official XML Signature and XAdES XSD (bundled, no network access)
- named certificates in
config/xades.phpwith passwords from.env, runtime credentials withusing() ValidXadesSignaturevalidation rule,xades:sign,xades:verifyandxades:certificateArtisan commands,DocumentSignedevent for audit logs
Interoperability was checked both ways with the signing code of the official KSeF C# client (.NET SignedXml):
signatures made by this package verify in .NET, and the .NET signature is part of the test suite.
Why this package
| robrichards/xmlseclibs (95M downloads) | lyquidity/xml-signer (107k) | this package | |
|---|---|---|---|
| XML Signature | yes | yes (on xmlseclibs) | yes |
| XAdES qualifying properties | no, build them yourself | yes, including policies, counter-signatures, time-stamps | BES and B-B, no time-stamps |
| Rejects duplicated identifiers (wrapping) | left to the caller | not documented | yes, always |
| Validation of the output against the XAdES XSD | no | not documented | yes, official XSD bundled |
| Laravel config, facade, rule, commands | no | no | yes |
| Last release | active | January 2023 | new |
On Packagist (November 2026) there is no Laravel package for XAdES. xmlseclibs is a solid low-level XML
Signature library, but XAdES needs more than that: the SignedProperties reference with its Type, the
QualifyingProperties bound to the signature Id, the signing certificate digest, and a verifier that knows
which element each reference may point at. lyquidity/xml-signer covers much more of XAdES (time-stamps, LT/LTA,
policies) but is framework-agnostic, needs ext-gmp and ext-curl for its OCSP/TSP client and has not been
released since 2023. This package does the common case (sign and verify XAdES-BES / B-B with a file
certificate) the Laravel way, implementing XML Signature directly on ext-dom canonicalization (the same libxml
C14N that xmlseclibs uses) and phpseclib, so that every reference is resolved under strict rules.
Installation
composer require beziworld/laravel-xades
php artisan vendor:publish --tag=xades-config # optional
Requirements: PHP 8.3+, Laravel 12 or 13, ext-dom, ext-openssl.
# PKCS#12 bundle (path or base64 content) XADES_PKCS12=/var/secure/pieczec.p12 XADES_PKCS12_PASSWORD="secret" # or PEM files (the key may be encrypted) XADES_KEY=/var/secure/key.pem XADES_KEY_PASSPHRASE= XADES_CERTIFICATE=/var/secure/certificate.pem XADES_CHAIN=/var/secure/intermediates.pem XADES_PROFILE=bes # bes or baseline-b XADES_DIGEST=sha256 # sha256, sha384, sha512 XADES_CANONICALIZATION=exc-c14n XADES_TRUSTED_CERTIFICATES=/etc/ssl/ca/issuer.pem,/etc/ssl/ca/partner.pem XADES_REQUIRE_TRUSTED=false
More certificates (for example several company seals) go to certificates in config/xades.php:
'certificates' => [ 'default' => [...], 'acme' => [ 'pkcs12' => env('ACME_PKCS12'), 'password' => env('ACME_PKCS12_PASSWORD'), ], ],
Signing
use Beziworld\Xades\Facades\Xades; $signed = Xades::signEnveloped($xml); // ds:Signature appended to the root element $signed = Xades::signEnveloping($xml); // the document goes into ds:Object $signature = Xades::signDetached($pdf, 'faktura.pdf', 'application/pdf'); $signed = Xades::certificate('acme')->signEnveloped($xml); // named certificate $signed = Xades::signEnveloped($xml, [ 'profile' => 'baseline-b', // XAdES-B-B instead of XAdES-BES 'digest' => 'sha512', 'canonicalization' => 'c14n', // inclusive Canonical XML 1.0 'signing_time' => now(), 'claimed_roles' => ['Prezes Zarządu'], 'commitment_type' => 'http://uri.etsi.org/01903/v1.2.2#ProofOfApproval', 'mime_type' => 'text/xml', 'description' => 'Wniosek', 'id' => 'Signature-1', // otherwise a random xades-... identifier ]);
Credentials stored elsewhere (per tenant, in a vault):
$signer = Xades::using([ 'pkcs12' => $tenant->pkcs12, // path or base64 'password' => decrypt($tenant->pkcs12_password), ]); $signer = Xades::using(['key' => $keyPem, 'certificate' => $certificatePem, 'passphrase' => $passphrase]); $signer->signEnveloped($xml);
The signer refuses RSA keys below 2048 bits, EC keys below 256 bits and a key that does not belong to the
certificate. The produced structure (default bes profile):
<ds:Signature Id="xades-..."> <ds:SignedInfo> <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/> <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/> <ds:Reference Id="xades-...-ref0" URI=""> <!-- enveloped-signature + exc-c14n, SHA-256 --> <ds:Reference Type="http://uri.etsi.org/01903#SignedProperties" URI="#xades-...-signedprops"> </ds:SignedInfo> <ds:SignatureValue/> <ds:KeyInfo><ds:X509Data><ds:X509Certificate/></ds:X509Data></ds:KeyInfo> <ds:Object> <xades:QualifyingProperties Target="#xades-..."> <xades:SignedProperties Id="xades-...-signedprops"> <xades:SignedSignatureProperties> <xades:SigningTime/> <xades:SigningCertificate><xades:Cert><xades:CertDigest/><xades:IssuerSerial/></xades:Cert></xades:SigningCertificate> </xades:SignedSignatureProperties> <xades:SignedDataObjectProperties><xades:DataObjectFormat ObjectReference="#xades-...-ref0"/></xades:SignedDataObjectProperties> </xades:SignedProperties> </xades:QualifyingProperties> </ds:Object> </ds:Signature>
KSeF 2.0 authentication example
$request = <<<XML <?xml version="1.0" encoding="utf-8"?> <AuthTokenRequest xmlns="http://ksef.mf.gov.pl/auth/token/2.0"> <Challenge>{$challenge}</Challenge> <ContextIdentifier><Nip>5265877635</Nip></ContextIdentifier> <SubjectIdentifierType>certificateSubject</SubjectIdentifierType> </AuthTokenRequest> XML; $signed = Xades::certificate('ksef')->signEnveloped($request); // POST $signed to /auth/xades-signature (see beziworld/laravel-ksef)
KSeF accepts enveloped and enveloping signatures only (not detached). Which certificates an authority accepts is decided by the authority: see Limits.
Verification
$result = Xades::verify($signedXml); // throws InvalidSignature $result->packaging; // Packaging::Enveloped, Enveloping or Detached $result->profile; // Profile::Bes or Profile::BaselineB $result->certificate; // CertificateInfo: subject, issuer, serialNumber, fingerprint, validFrom, validTo $result->certificate->nip(); // NIP from organizationIdentifier VATPL-... or serialNumber TINPL-... $result->certificate->pesel();// PESEL from serialNumber PNOPL-... $result->signingTime; // CarbonImmutable (UTC) or null $result->trusted; // true/false against the trust list, null when no list is configured $result->coversWholeDocument; // true when the whole document (minus the signature) is signed $result->signedContents; // canonical XML of what the signature covers $result->claimedRoles; $result->commitmentType; $result->warnings; // findings that do not invalidate the signature Xades::isValid($signedXml); // bool Xades::verify($signatureXml, ['detached' => ['faktura.pdf' => $pdfBytes]]); Xades::verify($signedXml, ['trusted' => [storage_path('ca/issuer.pem')], 'require_trusted' => true]); Xades::verify($signedXml, ['signature_id' => 'second']); // documents with several signatures
Options: trusted, require_trusted, allow_weak_algorithms, check_validity_period,
require_whole_document, detached, signature_id, certificate (used when KeyInfo has no certificate),
max_references, clock_skew. Defaults come from xades.verification.
What is checked, and in which order:
- The document is parsed without network access; any
DOCTYPEorENTITYdeclaration is rejected (no XXE, no entity expansion, no DTD-declared IDs). - Exactly one top-level
ds:Signature(or the one named bysignature_id) with a uniqueId. - Canonicalization and signature methods from the allow list; SHA-1 only with
allow_weak_algorithms. - One
xades:QualifyingPropertiesin ads:Objectof this signature, withTarget="#<signature Id>". - Every reference:
URI=""(the document),#id(exactly one element with thatId/ID/id, so a duplicated identifier is rejected as a wrapping attempt) or an external URI resolved fromdetached; transforms limited to enveloped-signature and one Canonical XML 1.0 / Exclusive C14N 1.0; digest compared in constant time. - Exactly one reference covers the SignedProperties of this signature; a reference typed as SignedProperties must point at them.
- Enveloped and enveloping signatures must cover the whole document (every non-property
ds:Objectfor enveloping), unlessrequire_whole_documentis false; then read data only fromsignedContents. - The certificate whose digest equals
SigningCertificate(V2)/Cert/CertDigestis the signing certificate;X509SerialNumberandIssuerSerialV2must match it;X509IssuerNameis compared leniently (other implementations write names differently) and a difference is reported as a warning. - The signature value over the canonical
SignedInfowith that certificate; RSA keys below 2048 bits rejected. - The certificate must be valid at
SigningTime(check_validity_period), and trusted whenrequire_trustedis set: pinned intrustedor directly issued by a certificate intrusted.
Validation rule
use Beziworld\Xades\Rules\ValidXadesSignature; $request->validate([ 'document' => ['required', 'file', new ValidXadesSignature()], 'declaration' => ['required', ValidXadesSignature::trustedBy([storage_path('ca/issuer.pem')])->withSchema()], ]);
Schema validation
Xades::validateSchema($signedXml); // throws SchemaViolation with errors(), returns the number of signatures
Each ds:Signature is validated against the W3C XML Signature schema and its QualifyingProperties against
XAdES01903v132-201601.xsd of ETSI EN 319 132-1 (which also defines SigningCertificateV2). Both files are
bundled in resources/schema; the import of the XAdES schema points at the local copy and the DOCTYPE of the
W3C file is removed, so validation never reaches the network.
Artisan
php artisan xades:sign request.xml request.signed.xml --certificate=acme --profile=baseline-b
php artisan xades:sign faktura.pdf --packaging=detached --mime=application/pdf
php artisan xades:verify request.signed.xml --schema --trusted=ca.pem --require-trusted
php artisan xades:verify faktura.pdf.xades.xml --detached=faktura.pdf
php artisan xades:certificate acme --days=30 # fails when the certificate expires within 30 days
xades:certificate exits with an error when the certificate expires soon, so it can run in the scheduler.
Events
Beziworld\Xades\Events\DocumentSigned (certificate name, packaging, signature id, certificate SHA-256
fingerprint) is dispatched after each signature; no document or key material is included. Disable with
XADES_EVENTS=false.
Limits
- No qualified signature claims. A key in a PKCS#12 or PEM file is not kept on a qualified signature creation device, so a signature made with it is not a qualified electronic signature (or seal) under eIDAS, even with a qualified certificate. Authorities decide which certificates they accept; for example KSeF accepts its own KSeF certificates and, on the test environment, self-signed certificates, and JPK requires a qualified signature or the Trusted Profile.
- No time-stamps. XAdES-T and higher levels (TSA time-stamps,
SignatureTimeStamp, validation data) are not produced; unsigned properties found during verification are reported as a warning and not validated. - No certificate path validation and no revocation checks (OCSP/CRL). Trust means a pinned certificate or a certificate directly issued by one in the trust list.
- Verification accepts only same-document references
""and#idand detached references whose content you supply; XPath, XPath Filter 2.0, XSLT and base64 transforms, Canonical XML 1.1 and RSA-PSS are rejected. - Enveloping signatures do not keep the XML declaration, processing instructions or comments outside the root element of the source document.
- PKCS#12 files encrypted with legacy algorithms (RC2-40, 3DES) need the OpenSSL legacy provider on OpenSSL 3;
re-export such a file with AES-256 (
openssl pkcs12 -export ...) if it cannot be read.
Errors
| Exception | When |
|---|---|
InvalidConfiguration |
unknown certificate name, missing key/certificate/password, invalid option |
InvalidCertificate |
unreadable certificate, key or PKCS#12, wrong password, key not matching the certificate, weak key |
InvalidDocument |
empty or malformed XML, DOCTYPE/ENTITY declarations, document larger than 50 MB |
InvalidSignature |
verification failed; reason() returns a stable code such as digest_mismatch, signature_mismatch, ambiguous_reference, partial_document, signing_certificate_mismatch, untrusted_certificate, weak_algorithm |
SchemaViolation |
the signature does not conform to the XML Signature or XAdES schema; errors() lists libxml messages |
SigningFailed |
the private key operation or serialization failed |
All exceptions extend Beziworld\Xades\Exceptions\XadesException.
Development
composer install
composer ci # pint --test, phpstan (level 8), pest
Tests generate throwaway RSA and EC keys and certificates (self-signed and issued by a test CA) at runtime with
phpseclib; no private key is stored in the repository. tests/Fixtures/ksef-dotnet-signed.xml is an
AuthTokenRequest signed by the signing code of the official KSeF C# client (public certificate only).
Author
Created and maintained by Kamil Ćwiertnia, beziworld.eu.
- E-mail: biuro@beziworld.eu
- Issues: GitHub issues
Need an integration? Get in touch through beziworld.eu.
Related packages
laravel-ksef · laravel-jpk · laravel-wezel-krajowy
License
MIT. See LICENSE.md. The bundled schemas are published by ETSI (XAdES) and the W3C (XML Signature) under their own terms. This package is not affiliated with or endorsed by ETSI, the W3C, the Polish Ministry of Finance or any trust service provider.