Search by

beziworld / laravel-xades

kamcw

XAdES-BES and XAdES-B-B signing and verification of XML documents in Laravel with PKCS#12 or PEM certificates

Package info

github.com/Beziworld/laravel-xades

Homepage

Issues

pkg:composer/beziworld/laravel-xades

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.0 2026-11-10 18:49 UTC

This package is auto-updated.

Last update: 2026-11-10 18:50:42 UTC


README

tests Latest Version License

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, SigningCertificate with CertDigest and IssuerSerial) 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||s encoding), SHA-256/384/512, Exclusive or inclusive Canonical XML 1.0
  • optional CommitmentTypeIndication, SignerRole (claimed roles), DataObjectFormat description, intermediate certificates in KeyInfo
  • strict verification: signature value, every reference digest, the SignedProperties reference, the certificate digest, IssuerSerial / IssuerSerialV2, validity at SigningTime, 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.php with passwords from .env, runtime credentials with using()
  • ValidXadesSignature validation rule, xades:sign, xades:verify and xades:certificate Artisan commands, DocumentSigned event 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.

Polska wersja dokumentacji

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:

  1. The document is parsed without network access; any DOCTYPE or ENTITY declaration is rejected (no XXE, no entity expansion, no DTD-declared IDs).
  2. Exactly one top-level ds:Signature (or the one named by signature_id) with a unique Id.
  3. Canonicalization and signature methods from the allow list; SHA-1 only with allow_weak_algorithms.
  4. One xades:QualifyingProperties in a ds:Object of this signature, with Target="#<signature Id>".
  5. Every reference: URI="" (the document), #id (exactly one element with that Id/ID/id, so a duplicated identifier is rejected as a wrapping attempt) or an external URI resolved from detached; transforms limited to enveloped-signature and one Canonical XML 1.0 / Exclusive C14N 1.0; digest compared in constant time.
  6. Exactly one reference covers the SignedProperties of this signature; a reference typed as SignedProperties must point at them.
  7. Enveloped and enveloping signatures must cover the whole document (every non-property ds:Object for enveloping), unless require_whole_document is false; then read data only from signedContents.
  8. The certificate whose digest equals SigningCertificate(V2)/Cert/CertDigest is the signing certificate; X509SerialNumber and IssuerSerialV2 must match it; X509IssuerName is compared leniently (other implementations write names differently) and a difference is reported as a warning.
  9. The signature value over the canonical SignedInfo with that certificate; RSA keys below 2048 bits rejected.
  10. The certificate must be valid at SigningTime (check_validity_period), and trusted when require_trusted is set: pinned in trusted or directly issued by a certificate in trusted.

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 #id and 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.

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.