How to sign an XML document with Python?

I am doing a project in Django (full Python) specifically a sales system. Now, I want to incorporate electronic invoicing to the system, focusing only on the electronic sales receipt for now. In my country, the regulatory entity requires that to send the invoice, it must be done in an XML file (with a specific format), then, it must be signed with the digital certificate of the company, packed in a .zip file and sent to the testing web service of the entity. But, I don't know how to do the signature using only Python.

I was researching and I noticed that most of them choose to use languages like Java or C#, mainly. I also found the lxml and cryptography libraries, with which I could make the signature, but when I send it to the web service, I get the error:

Error of the web service: The entered electronic document has been altered - Detail: Incorrect reference digest value

I was trying to find out how to fix this error, I don't know if it has to do with the way I'm signing the XML document or the method I'm using.

I share with you the code to give you an idea of what I used:

from lxml import etree
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_pem_private_key
import base64
import os
import re

def sign_xml(xml_path, private_key_path, certificate_path, signed_xml_path):
    """Signs an XML document with a digital certificate."""

    # Load the XML document
    tree = etree. parse(xml_path)
    root = tree. getroot()

    # Load the private key
    with open(private_key_path, "rb") as key_file:
        private_key = load_pem_private_key(
            key_file. read(), password=None, backend=default_backend()
        )

    # Create the Signature element
    ns = {
        "cac": "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
        "ds": "http://www.w3.org/2000/09/xmldsig#",
        "ext": "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
        "sac": "urn:sunat.gob.pe:billdownload:2",
        "cbc":"urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
    }

    # Create the UBLExtensions element if it does not exist
    extensions = root.find("ext:UBLExtensions", ns)
    if extensions is None:
        extensions = etree.SubElement(root, "{%s}UBLExtensions" % ns['ext'])

    # Create the UBLExtension element
    extension = extensions.find("ext:UBLExtension", ns)
    if extension is None:
        extension = etree.SubElement(extensions, "{%s}UBLExtension" % ns['ext'])

    # Create the ExtensionContent element
    extension_content = extension.find("ext:ExtensionContent", ns)
    if extension_content is None:
        extension_content = etree.SubElement(extension, "{%s}ExtensionContent" % ns['ext'])

    # Clear any existing content in ExtensionContent
    extension_content.clear()

    signature = etree.SubElement(extension_content, "{%s}Signature" % ns['ds'], Id="SignatureSP")
    signed_info = etree.SubElement(signature, "{%s}SignedInfo" % ns['ds'])
    canonical_method = etree.SubElement(signed_info, "{%s}CanonicalizationMethod" % ns['ds'], Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315")
    signature_method = etree.SubElement(signed_info, "{%s}SignatureMethod" % ns['ds'], Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256")
    reference = etree.SubElement(signed_info, "{%s}Reference" % ns['ds'], URI="")
    transformations = etree.SubElement(reference, "{%s}Transforms" % ns['ds'])
    transformation = etree.SubElement(transformations, "{%s}Transform" % ns['ds'], Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature")
    digest_method = etree.SubElement(reference, "{%s}DigestMethod" % ns['ds'], Algorithm="http://www.w3.org/2001/04/xmlenc#sha256")

    # Calculate the DigestValue
    copy_tree = etree.fromstring(etree.tostring(tree))

    reference_to_sign = copy_tree.find(".//ds:Reference", ns)
    if reference_to_sign is not None:
        reference_to_sign.getparent().remove(reference_to_sign)

    xml_serialized = etree.tostring(copy_tree, exclusive=1, inclusive_ns_prefixes=None)
    xml_serialized_without_spaces = re.sub(b'>\s*<', b'><', xml_serialized)

    print("Serialized XML (without spaces):", xml_serialized_without_spaces)

    digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
    digest.update(xml_serialized_without_spaces)
    digest_value = base64.b64encode(digest.finalize()).decode()

    print("DigestValue calculated:", digest_value) # Print the calculated digest value

    etree.SubElement(reference, "{%s}DigestValue" % ns['ds']).text = digest_value

    # Calculate the SignatureValue
    info_firmada_serializado = etree.tostring(signed_info, encoding='UTF-8', method='xml')
    signature = private_key.sign(
        info_firmada_serializado,
        padding.PKCS1v15(),
        hashes.SHA256()
        )
    signature_value = base64.b64encode(signature).decode()
    etree.SubElement(signature, "{%s}SignatureValue" % ns['ds']).text = signature_value

    # Add the certificate information
    key_info = etree.SubElement(signature, "{%s}KeyInfo" % ns['ds']) 
    x509_data = etree.SubElement(key_info, "{%s}X509Data" % ns['ds'])

    with open(certificate_path, "rb") as cert_file:
        certificate_content = cert_file.read()
    
    etree.SubElement(x509_data, "{%s}X509Certificate" % ns['ds']).text = base64.b64encode(certificate_content).decode()

    # Save the signed XML
    tree.write(signed_xml_path, encoding="UTF-8", xml_declaration=True, pretty_print=True)

And this is the test XML document:

<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
   xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
   xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
   xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
   xmlns:ext="urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2">
   <ext:UBLExtensions>
      <ext:UBLExtension>
         <ext:ExtensionContent />
      </ext:UBLExtension>
   </ext:UBLExtensions>
   <cbc:UBLVersionID>2.1</cbc:UBLVersionID>
   <cbc:CustomizationID>2.0</cbc:CustomizationID>
   <cbc:ID>B001-1</cbc:ID>
   <cbc:IssueDate>2024-02-25</cbc:IssueDate>
   <cbc:IssueTime>11:16:38</cbc:IssueTime>
   <cbc:InvoiceTypeCode listID="0101">03</cbc:InvoiceTypeCode>
   <cbc:Note languageLocaleID="1000"><![CDATA[SON CIENTO DIECIOCHO CON 00/100 SOLES]]></cbc:Note>
   <cbc:DocumentCurrencyCode>PEN</cbc:DocumentCurrencyCode>
   <cac:Signature>
      <cbc:ID>21422312421</cbc:ID>
      <cac:SignatoryParty>
         <cac:PartyIdentification>
            <cbc:ID>21422312421</cbc:ID>
         </cac:PartyIdentification>
         <cac:PartyName>
            <cbc:Name><![CDATA[INVERSION A Y B S.A.C.]]></cbc:Name>
         </cac:PartyName>
      </cac:SignatoryParty>
      <cac:DigitalSignatureAttachment>
         <cac:ExternalReference>
            <cbc:URI>#LIMENA CAE-SIGN</cbc:URI>
         </cac:ExternalReference>
      </cac:DigitalSignatureAttachment>
   </cac:Signature>
   <cac:AccountingSupplierParty>
      <cac:Party>
         <cac:PartyIdentification>
            <cbc:ID schemeID="6">21422312421</cbc:ID>
         </cac:PartyIdentification>
         <cac:PartyName>
            <cbc:Name><![CDATA[LIMENA CAE]]></cbc:Name>
         </cac:PartyName>
         <cac:PartyLegalEntity>
            <cbc:RegistrationName><![CDATA[INVERSION A Y B S.A.C.]]></cbc:RegistrationName>
            <cac:RegistrationAddress>
               <cbc:ID>123123</cbc:ID>
               <cbc:AddressTypeCode>0000</cbc:AddressTypeCode>
               <cbc:CitySubdivisionName>CASUARINAS</cbc:CitySubdivisionName>
               <cbc:CityName>LIMA</cbc:CityName>
               <cbc:CountrySubentity>LIMA</cbc:CountrySubentity>
               <cbc:District>LIMA</cbc:District>
               <cac:AddressLine>
                  <cbc:Line><![CDATA[CAL.LOMA SOCOSA NRO. 29 (PISO 5) LIMA - LIMA - SANTIAGO DE SURQUILLO]]></cbc:Line>
               </cac:AddressLine>
               <cac:Country>
                  <cbc:IdentificationCode>PE</cbc:IdentificationCode>
               </cac:Country>
            </cac:RegistrationAddress>
         </cac:PartyLegalEntity>
         <cac:Contact>
            <cbc:Telephone>01-1231231</cbc:Telephone>
            <cbc:ElectronicMail>bginoza7@gmail.com</cbc:ElectronicMail>
         </cac:Contact>
      </cac:Party>
   </cac:AccountingSupplierParty>
   <cac:AccountingCustomerParty>
      <cac:Party>
         <cac:PartyIdentification>
            <cbc:ID schemeID="1">24516534</cbc:ID>
         </cac:PartyIdentification>
         <cac:PartyLegalEntity>
            <cbc:RegistrationName><![CDATA[PERSON 1]]></cbc:RegistrationName>
         </cac:PartyLegalEntity>
      </cac:Party>
   </cac:AccountingCustomerParty>
   <cac:TaxTotal>
      <cbc:TaxAmount currencyID="PEN">18.00</cbc:TaxAmount>
      <cac:TaxSubtotal>
         <cbc:TaxableAmount currencyID="PEN">100.00</cbc:TaxableAmount>
         <cbc:TaxAmount currencyID="PEN">18.00</cbc:TaxAmount>
         <cac:TaxCategory>
            <cac:TaxScheme>
               <cbc:ID>1000</cbc:ID>
               <cbc:Name>IGV</cbc:Name>
               <cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
            </cac:TaxScheme>
         </cac:TaxCategory>
      </cac:TaxSubtotal>
   </cac:TaxTotal>
   <cac:LegalMonetaryTotal>
      <cbc:LineExtensionAmount currencyID="PEN">100.00</cbc:LineExtensionAmount>
      <cbc:TaxInclusiveAmount currencyID="PEN">118.00</cbc:TaxInclusiveAmount>
      <cbc:PayableAmount currencyID="PEN">118.00</cbc:PayableAmount>
   </cac:LegalMonetaryTotal>
   <cac:InvoiceLine>
      <cbc:ID>1</cbc:ID>
      <cbc:InvoicedQuantity unitCode="NIU">2</cbc:InvoicedQuantity>
      <cbc:LineExtensionAmount currencyID="PEN">100.00</cbc:LineExtensionAmount>
      <cac:PricingReference>
         <cac:AlternativeConditionPrice>
            <cbc:PriceAmount currencyID="PEN">59</cbc:PriceAmount>
            <cbc:PriceTypeCode>01</cbc:PriceTypeCode>
         </cac:AlternativeConditionPrice>
      </cac:PricingReference>
      <cac:TaxTotal>
         <cbc:TaxAmount currencyID="PEN">18.00</cbc:TaxAmount>
         <cac:TaxSubtotal>
            <cbc:TaxableAmount currencyID="PEN">100.00</cbc:TaxableAmount>
            <cbc:TaxAmount currencyID="PEN">18.00</cbc:TaxAmount>
            <cac:TaxCategory>
               <cbc:Percent>18</cbc:Percent>
               <cbc:TaxExemptionReasonCode>10</cbc:TaxExemptionReasonCode>
               <cac:TaxScheme>
                  <cbc:ID>1000</cbc:ID>
                  <cbc:Name>IGV</cbc:Name>
                  <cbc:TaxTypeCode>VAT</cbc:TaxTypeCode>
               </cac:TaxScheme>
            </cac:TaxCategory>
         </cac:TaxSubtotal>
      </cac:TaxTotal>
      <cac:Item>
         <cbc:Description><![CDATA[PROD 1]]></cbc:Description>
         <cac:SellersItemIdentification>
            <cbc:ID>C023</cbc:ID>
         </cac:SellersItemIdentification>
      </cac:Item>
      <cac:Price>
         <cbc:PriceAmount currencyID="PEN">50</cbc:PriceAmount>
      </cac:Price>
   </cac:InvoiceLine>
</Invoice>

You'll need to insert a templated signature node into the xml document. The template includes details about how to sign this document.

Then you just tell xmlsec to sign it. It will output the correct xml.

Here is the xmlsec documentation

python implementation from pip install xmlsec
python examples

Here is how you fill in the signature:

    from lxml import etree
import xmlsec

def sign_xml(xml_path, private_key_path, signed_xml_path):
    with open(xml_path) as fp:
        template = etree.parse(fp).getroot()
    
    signature_node = xmlsec.tree.find_node(template, xmlsec.constants.NodeSignature)
    assert signature_node
    
    ctx = xmlsec.SignatureContext()
    key = xmlsec.Key.from_file(private_key_path, xmlsec.constants.KeyDataFormatPem)
    ctx.key = key
    ctx.sign(signature_node)
    
    with open(signed_xml_path, 'wb') as out:
        out.write(etree.tostring(template))

I'll leave it up to you to set up the signature template and add it to your xml document, but I just copied the one from their documentation (see above, section 2.1)

Вернуться на верх