Search code examples
pythondjangoxmldigital-signature

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>

Solution

  • You have to install pip install xmlsec and pip install pyOpenSSL. If key and certificate is not available, it will be created first. For the X509data you can set True/False, depends of you have to add it. You have to consider the namespace, too.

    import os
    from lxml import etree
    import xmlsec
    from OpenSSL import crypto
    
    
    PRIVATE_KEY_FILE = "private_key.pem"
    CERTIFICATE_FILE = "certificate.crt"
    XML_FILE_TO_SIGN = "xml_to_sign.xml"
    SIGNED_XML_FILE = "signed_invoice.xml"
    
    def generate_rsa_key_and_certificate():
        # Check if the private key and certificate exist
        if not os.path.exists(PRIVATE_KEY_FILE) or not os.path.exists(CERTIFICATE_FILE):
            # Generate RSA private key using PyOpenSSL
            private_key = crypto.PKey()
            private_key.generate_key(crypto.TYPE_RSA, 2048)
    
            # Save private key to file
            with open(PRIVATE_KEY_FILE, "wb") as f:
                f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, private_key))
    
            # Create a self-signed certificate using PyOpenSSL
            # Set up the X509 certificate
            cert = crypto.X509()
            cert.set_version(2)
            cert.set_serial_number(1000)
    
            # Set subject and issuer (self-signed, so subject == issuer)
            subject = cert.get_subject()
            subject.CN = "example.com"
            subject.O = "Example"
            subject.L = "Los Angeles"
            subject.ST = "California"
            subject.C = "US"
    
            cert.set_issuer(subject)
            cert.set_notBefore(b'20250227000000Z')  # Start time
            cert.set_notAfter(b'20260227000000Z')   # Expiry time
    
            # Set the public key (the one that matches the private key)
            cert.set_pubkey(private_key)
    
            # Sign the certificate with the private key
            cert.sign(private_key, "sha256")
    
            # Save the certificate to file
            with open(CERTIFICATE_FILE, "wb") as f:
                f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert))
    
            print(f"Private key saved to {PRIVATE_KEY_FILE}")
            print(f"Certificate saved to {CERTIFICATE_FILE}")
        else:
            print("Private key and certificate already exist.")
    
    def load_private_key():
        with open(PRIVATE_KEY_FILE, "rb") as f:
            private_key = xmlsec.Key.from_file(f, xmlsec.KeyFormat.PEM)
        return private_key
    
    def load_certificate():
        with open(CERTIFICATE_FILE, "rb") as f:
            return f.read().decode("utf-8").replace("-----BEGIN CERTIFICATE-----", "").replace("-----END CERTIFICATE-----", "").replace("\n", "")
    
    def sign_xml(xml_string, add_x509=False):
        private_key = load_private_key()
        cert_pem = load_certificate() if add_x509 else None
    
        xml_bytes = xml_string.encode("utf-8")
        root = etree.fromstring(xml_bytes)
    
        # Find the <ext:ExtensionContent> where the signature will be appended
        ns = {
            "ext": "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2",
            "ds": "http://www.w3.org/2000/09/xmldsig#"
        }
    
        ext_content = root.find(".//ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent", namespaces=ns)
        if ext_content is None:
            print("Error: <ext:ExtensionContent> not found.")
            return None
    
        # Create the signature template
        signature = xmlsec.template.create(
            root,
            xmlsec.Transform.EXCL_C14N,
            xmlsec.Transform.RSA_SHA256,
            ns="ds"
        )
        ext_content.append(signature)
    
        # Add Reference and KeyInfo for the Signature
        ref = xmlsec.template.add_reference(signature, xmlsec.Transform.SHA256, uri="")
        xmlsec.template.add_transform(ref, xmlsec.Transform.ENVELOPED)
        xmlsec.template.add_transform(ref, xmlsec.Transform.EXCL_C14N)
        key_info = xmlsec.template.ensure_key_info(signature)
    
        if add_x509:
            # Add X509 Data (Certificate)
            x509_data = xmlsec.template.add_x509_data(key_info)
            cert_element = etree.SubElement(x509_data, "{http://www.w3.org/2000/09/xmldsig#}X509Certificate")
            cert_element.text = cert_pem  
    
        # Create signature context and sign
        signature_context = xmlsec.SignatureContext()
        signature_context.key = private_key
    
        try:
            signature_context.sign(signature)
        except xmlsec.Error as e:
            print(f"Signing error: {e}")
            return None
    
        return etree.tostring(root, pretty_print=True, xml_declaration=True, encoding="utf-8").decode()
    
    if __name__ == "__main__":
        generate_rsa_key_and_certificate()
        ADD_X509 = True  # Set to False if you don't need include X509Data
    
        if not os.path.exists(XML_FILE_TO_SIGN):
            print(f"Error: XML file '{XML_FILE_TO_SIGN}' not found.")
            exit(1)
    
        with open(XML_FILE_TO_SIGN, "r", encoding="utf-8") as f:
            xml_to_sign = f.read()
    
        signed_xml = sign_xml(xml_to_sign, ADD_X509)
    
        if signed_xml:
            with open(SIGNED_XML_FILE, "w", encoding="utf-8") as f:
                f.write(signed_xml)
            print(f"Signed XML saved as '{SIGNED_XML_FILE}'")
        else:
            print("Error: Failed to sign XML.")