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)