Odoo18-Base/addons/l10n_es_edi_tbai/models/xml_utils.py
2025-03-10 11:12:23 +07:00

119 lines
4.7 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import re
from base64 import b64encode, encodebytes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from lxml import etree
from odoo.tools.xml_utils import cleanup_xml_node
# Utility Methods for Basque Country's TicketBAI XML-related stuff.
NS_MAP = {'': 'http://www.w3.org/2000/09/xmldsig#'} # default namespace matches signature's `ds:``
def canonicalize_node(node):
"""
Returns the canonical (C14N 1.0, without comments, non exclusive) representation of node.
Speficied in: https://www.w3.org/TR/2001/REC-xml-c14n-20010315
Required for computing digests and signatures.
Returns an UTF-8 encoded bytes string.
"""
node = etree.fromstring(node) if isinstance(node, str) else node
return etree.tostring(node, method='c14n', with_comments=False, exclusive=False)
def cleanup_xml_signature(xml_sig):
"""
Cleanups the content of the provided string representation of an XML signature.
In addition, removes all line feeds for the ds:Object element.
Turns self-closing tags into regular tags (with an empty string content)
as the former may not be supported by some signature validation implementations.
Returns an etree._Element
"""
sig_elem = cleanup_xml_node(xml_sig, remove_blank_nodes=False, indent_level=-1)
etree.indent(sig_elem, space='') # removes indentation
for elem in sig_elem.find('Object', namespaces=NS_MAP).iter():
if elem.text == '\n':
elem.text = '' # keeps the signature in one line, prevents self-closing tags
elem.tail = '' # removes line feed and whitespace after the tag
return sig_elem
def get_uri(uri, reference, base_uri):
"""
Returns the content within `reference` that is identified by `uri`.
Canonicalization is used to convert node reference to an octet stream.
- The base_uri points to the whole document tree, without the signature
https://www.w3.org/TR/xmldsig-core/#sec-EnvelopedSignature
- URIs starting with # are same-document references
https://www.w3.org/TR/xmldsig-core/#sec-URI
Returns an UTF-8 encoded bytes string.
"""
node = reference.getroottree()
if uri == base_uri:
# Empty URI: whole document, without signature
return canonicalize_node(
re.sub(
r'^[^\n]*<ds:Signature.*<\/ds:Signature>', r'',
etree.tostring(node, encoding='unicode'),
flags=re.DOTALL | re.MULTILINE)
)
if uri.startswith('#'):
query = '//*[@*[local-name() = "Id" ]=$uri]' # case-sensitive 'Id'
results = node.xpath(query, uri=uri.lstrip('#'))
if len(results) == 1:
return canonicalize_node(results[0])
if len(results) > 1:
raise Exception("Ambiguous reference URI {} resolved to {} nodes".format(
uri, len(results)))
raise Exception(f"URI {uri!r} not found")
def calculate_references_digests(node, base_uri=''):
"""
Processes the references from node and computes their digest values as specified in
https://www.w3.org/TR/xmldsig-core/#sec-DigestMethod
https://www.w3.org/TR/xmldsig-core/#sec-DigestValue
"""
for reference in node.findall('Reference', namespaces=NS_MAP):
ref_node = get_uri(reference.get('URI', ''), reference, base_uri)
hash_digest = hashlib.new('sha256', ref_node).digest()
reference.find('DigestValue', namespaces=NS_MAP).text = b64encode(hash_digest)
def fill_signature(node, private_key):
"""
Uses private_key to sign the SignedInfo sub-node of `node`, as specified in:
https://www.w3.org/TR/xmldsig-core/#sec-SignatureValue
https://www.w3.org/TR/xmldsig-core/#sec-SignedInfo
"""
signed_info_xml = node.find('SignedInfo', namespaces=NS_MAP)
# During signature generation, the digest is computed over the canonical form of the document
signature = private_key.sign(
canonicalize_node(signed_info_xml),
padding.PKCS1v15(),
hashes.SHA256()
)
node.find('SignatureValue', namespaces=NS_MAP).text =\
bytes_as_block(signature)
def int_as_bytes(number):
"""
Converts an integer to an ASCII/UTF-8 byte string (with no leading zeroes).
"""
return number.to_bytes((number.bit_length() + 7) // 8, byteorder='big')
def bytes_as_block(string):
"""
Returns the passed string modified to include a line feed every `length` characters.
It may be recommended to keep length under 76:
https://www.w3.org/TR/2004/REC-xmlschema-2-20041028/#rf-maxLength
https://www.ietf.org/rfc/rfc2045.txt
"""
return encodebytes(string).decode()