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 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.")