import base64 import hashlib from base64 import b64encode from copy import deepcopy from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from lxml import etree from odoo.exceptions import UserError NS_MAP = {'ds': "http://www.w3.org/2000/09/xmldsig#"} 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. """ return etree.tostring(node, method="c14n", with_comments=False, exclusive=False) 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. - URIs starting with # are same-document references https://www.w3.org/TR/xmldsig-core/#sec-URI - Empty URIs point to the whole document tree, without the signature https://www.w3.org/TR/xmldsig-core/#sec-EnvelopedSignature Returns an UTF-8 encoded bytes string. """ node = deepcopy(reference.getroottree().getroot()) if uri == base_uri: # Base URI: whole document, without signature (default is empty URI) for signature in node.xpath('ds:Signature', namespaces=NS_MAP): if signature.tail: # move the tail to the previous node or to the parent if (previous := signature.getprevious()) is not None: previous.tail = "".join([previous.tail or "", signature.tail or ""]) else: signature.getparent().text = "".join([signature.getparent().text or "", signature.tail or ""]) node.remove(signature) return _canonicalize_node(node) if uri.startswith("#"): path = "//*[@*[local-name() = '{}' ]=$uri]" results = node.xpath(path.format("Id"), uri=uri.lstrip("#")) # case-sensitive 'Id' if len(results) == 1: return _canonicalize_node(results[0]) if len(results) > 1: raise UserError(f"Ambiguous reference URI {uri} resolved to {len(results)} nodes") raise UserError(f'URI {uri} not found') def _reference_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("ds:Reference", namespaces=NS_MAP): ref_node = _get_uri(reference.get("URI", ""), reference, base_uri=base_uri) lib = hashlib.new("sha256", ref_node) reference.find("ds:DigestValue", namespaces=NS_MAP).text = b64encode(lib.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("ds: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("ds:SignatureValue", namespaces=NS_MAP).text = base64.encodebytes(signature) def _int_to_bytes(number): """ Converts an integer to a byte string (in smallest big-endian form). """ return number.to_bytes((number.bit_length() + 7) // 8, byteorder='big')