# sign_xml_rest.py
# version 1.0.0 (2026-07-08T08:17Z)
# author: David Ireland <https://di-mgt.com.au/contact>
# copyright (c) 2026 DI Management Services Pty Ltd
# SPDX-License-Identifier: Apache-2.0
# Description: Sign a CFDI XML file using CryptoSys PKI and the FirmaSAT REST API and save the signed result.
import requests
import json
import os
import base64
import xml.etree.ElementTree as ET
from cryptosyspki import Sig, X509
# Requires CryptoSys PKI to be installed on your system.
# Available from https://cryptosys.net/pki/
def validate_xml(xml_string):
"""Call the REST API passing only the XML data."""
url = "https://di-mgt.com.au/cryptosys/api/fsa_validation"
api_token = "YOUR_API_TOKEN_HERE" # Replace with your own api-token
payload = {"api-token": api_token,"xmldata": xml_string}
response = requests.post(url, json=payload)
# Return the complete response object
return response
def sign_using_rest(outfile, xmlfile, certfile, keyfile, password):
"""
Sign a CFDI XML file using a REST API and save the signed result.
This function takes an unsigned CFDI (Comprobante Fiscal Digital por Internet) XML file,
validates it, signs it using the provided certificate and key, and saves the signed XML
to an output file. The signing process uses a REST API for validation and digest generation.
Args:
outfile (str): Path to the output file where the signed XML will be saved.
xmlfile (str): Path to the input XML file to be signed.
certfile (str): Path to the X.509 certificate file.
keyfile (str): Path to the private key file.
password (str): Password to decrypt the private key.
Returns:
None
Raises:
SystemExit: If XML validation fails, signature validation fails, or REST API returns non-200 status.
FileNotFoundError: If input files (xmlfile, certfile, keyfile) do not exist.
ET.ParseError: If the XML file is malformed.
Side Effects:
- Modifies the XML tree by adding Certificado, NoCertificado, and Sello attributes.
- Creates output file at the specified outfile path.
- Prints validation and signature status to console.
Example:
sign_using_rest(
outfile="cfdv40-ejemplo-signed.xml",
xmlfile="cfdv40-ejemplo.xml",
certfile="emisor.cer",
keyfile="emisor.key",
password="12345678a"
)
"""
# Register the CFDI namespace so ElementTree can write the XML with the correct prefix.
namespaces = {'cfdi': 'http://www.sat.gob.mx/cfd/4'}
for prefix, uri in namespaces.items():
ET.register_namespace(prefix, uri)
# Parse the unsigned CFDI document into an ElementTree so we can add the signing attributes.
tree = ET.parse(xmlfile)
root = tree.getroot()
print("Cert root is", root.tag) # {http://www.sat.gob.mx/cfd/4}Comprobante
# Read the certificate from disk and extract the certificate content plus the serial number.
# If you already know these values, you can hardcode them into the XML and skip this step.
certdata = X509.read_string_from_file(certfile)
print(f"Certificado={certdata}")
# Get serialNumber as large integer in hex form
serialnumhex = X509.query_cert(certdata, "serialNumber")
print(f"SerialNumber(hex)={serialnumhex}")
# Convert to SAT 20-decimal-digit form
nocertificado = bytes.fromhex(serialnumhex).decode('utf-8')
print(f"NoCertificado={nocertificado}")
# Add the certificate details to the root element.
element = root
element.set('Certificado', certdata)
element.set('NoCertificado', nocertificado)
# Serialize the XML tree to a string so it can be sent to the validation API.
xml_string = ET.tostring(root, encoding='utf-8').decode("utf8")
# Validate the unsigned XML first to confirm that the document is structurally valid and to obtain the original digest.
response = validate_xml(xml_string)
print("REST status_code=", response.status_code)
if response.status_code != 200:
print("REST API Failed:", response.text)
exit()
# The service is expected to return a partial-success status here; we need the XML to be valid and the original digest to be present.
data = json.loads(response.text)
print(data['status'])
print("xml-valid:", data['validation-result']['xml-valid'])
if not data['validation-result']['xml-valid']:
print("Invalid XML:", response.text)
exit()
if 'original-string-digest' not in data['validation-result']:
print("Error: 'original-string-digest' not found in validation result")
print("Response:", response.text)
exit()
digesthex = data['validation-result']['original-string-digest']
print(f"digest={digesthex}")
# 'cfdv40-ejemplo.xml' => "c1492662dbd98ddbb7892d027c10808236e296eded13deec87179c2a8fdc742e"
# Create the digital signature with the private key and the digest returned by the API; the result is base64-encoded for CFDI use.
sello = Sig.sign_digest(bytes.fromhex(digesthex), keyfile, password, Sig.Alg.RSA_SHA256)
print(f"computed signature={sello}")
# Add the computed signature to the root element before the second validation pass.
element.set('Sello', sello)
# Revalidate the signed XML to confirm that the signature is valid.
xml_string = ET.tostring(root, encoding='utf-8').decode("utf8")
response = validate_xml(xml_string)
print("REST status_code=", response.status_code)
if response.status_code != 200:
print("REST API Failed:", response.text)
exit()
# The service should report partial-success again; we specifically need the signature-validation flag to be true.
data = json.loads(response.text)
print(data['status'])
print("signature-valid:", data['validation-result']['signature-valid'])
if not data['validation-result']['signature-valid']:
print("Invalid signature:", response.text)
exit()
# If validation succeeds, write the signed XML to the requested output file.
tree.write(outfile, encoding='utf-8', xml_declaration=True, short_empty_elements=True)
print(f"Created output file '{outfile}'")
def main():
sign_using_rest(
outfile = "cfdv40-ejemplo-signed.xml",
xmlfile = "cfdv40-ejemplo.xml",
certfile = "emisor.cer",
keyfile = "emisor.key",
password = "12345678a"
)
if __name__ == "__main__":
main()