19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

View file

@ -1,4 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import importlib
import io
import re
@ -10,20 +11,11 @@ from logging import getLogger
from zlib import compress, decompress, decompressobj
from PIL import Image, PdfImagePlugin
from reportlab.lib import colors
from reportlab.lib.units import cm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from odoo import modules
from odoo.tools.arabic_reshaper import reshape
from odoo.tools.parse_version import parse_version
from odoo.tools.misc import file_open
try:
import fontTools
from fontTools.ttLib import TTFont
except ImportError:
TTFont = None
from odoo.tools.misc import file_open, SENTINEL
# ----------------------------------------------------------
# PyPDF2 hack
@ -227,20 +219,43 @@ def to_pdf_stream(attachment) -> io.BytesIO:
_logger.warning("mimetype (%s) not recognized for %s", attachment.mimetype, attachment)
def add_banner(pdf_stream, text=None, logo=False, thickness=2 * cm):
def extract_page(attachment, num_page=0) -> io.BytesIO | None:
"""Exctract a specific page form an attachement pdf"""
pdf_stream = to_pdf_stream(attachment)
if not pdf_stream:
return
pdf = PdfFileReader(pdf_stream)
page = pdf.getPage(num_page)
pdf_writer = PdfFileWriter()
pdf_writer.addPage(page)
stream = io.BytesIO()
pdf_writer.write(stream)
return stream
def add_banner(pdf_stream, text=None, logo=False, thickness=SENTINEL):
""" Add a banner on a PDF in the upper right corner, with Odoo's logo (optionally).
:param pdf_stream (BytesIO): The PDF stream where the banner will be applied.
:param text (str): The text to be displayed.
:param logo (bool): Whether to display Odoo's logo in the banner.
:param thickness (float): The thickness of the banner in pixels.
:param thickness (float): The thickness of the banner in pixels (default: 2cm).
:return (BytesIO): The modified PDF stream.
"""
from reportlab.lib import colors # noqa: PLC0415
from reportlab.lib.utils import ImageReader # noqa: PLC0415
from reportlab.pdfgen import canvas # noqa: PLC0415
if thickness is SENTINEL:
from reportlab.lib.units import cm # noqa: PLC0415
thickness = 2 * cm
old_pdf = PdfFileReader(pdf_stream, strict=False, overwriteWarnings=False)
packet = io.BytesIO()
can = canvas.Canvas(packet)
odoo_logo = Image.open(file_open('base/static/img/main_partner-image.png', mode='rb'))
with file_open('base/static/img/main_partner-image.png', mode='rb') as f:
odoo_logo_file = io.BytesIO(f.read())
odoo_logo = Image.open(odoo_logo_file)
odoo_color = colors.Color(113 / 255, 75 / 255, 103 / 255, 0.8)
for p in range(old_pdf.getNumPages()):
@ -521,7 +536,11 @@ class OdooPdfFileWriter(PdfFileWriter):
# PDF/A needs the glyphs width array embedded in the pdf to be consistent with the ones from the font file.
# But it seems like it is not the case when exporting from wkhtmltopdf.
if TTFont:
try:
import fontTools.ttLib # noqa: PLC0415
except ImportError:
_logger.warning('The fonttools package is not installed. Generated PDF may not be PDF/A compliant.')
else:
fonts = {}
# First browse through all the pages of the pdf file, to get a reference to all the fonts used in the PDF.
for page in pages:
@ -535,7 +554,7 @@ class OdooPdfFileWriter(PdfFileWriter):
for font in fonts.values():
font_file = font['/FontDescriptor']['/FontFile2']
stream = io.BytesIO(decompress(font_file._data))
ttfont = TTFont(stream)
ttfont = fontTools.ttLib.TTFont(stream)
font_upm = ttfont['head'].unitsPerEm
if parse_version(fontTools.__version__) < parse_version('4.37.2'):
glyphs = ttfont.getGlyphSet()._hmtx.metrics
@ -548,8 +567,6 @@ class OdooPdfFileWriter(PdfFileWriter):
font[NameObject('/W')] = ArrayObject([NumberObject(1), ArrayObject(glyph_widths)])
stream.close()
else:
_logger.warning('The fonttools package is not installed. Generated PDF may not be PDF/A compliant.')
outlines = self._root_object['/Outlines'].getObject()
outlines[NameObject('/Count')] = NumberObject(1)

View file

@ -0,0 +1,467 @@
import base64
import datetime
import hashlib
import io
from typing import Optional
from asn1crypto import cms, algos, core, x509
import logging
try:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.x509 import Certificate, load_pem_x509_certificate
except ImportError:
# cryptography 41.0.7 and above is supported
hashes = None
PrivateKeyTypes = None
Encoding = None
load_pem_private_key = None
padding = None
Certificate = None
load_pem_x509_certificate = None
from odoo import _
from odoo.addons.base.models.res_company import ResCompany
from odoo.addons.base.models.res_users import ResUsers
from odoo.tools.pdf import PdfReader, PdfWriter, ArrayObject, ByteStringObject, DictionaryObject, NameObject, NumberObject, create_string_object, DecodedStreamObject as StreamObject
_logger = logging.getLogger(__name__)
class PdfSigner:
"""Class that defines methods uses in the signing process of pdf documents
The PdfSigner will perform the following operations on a PDF document:
- Modifiying the document by adding a signature field via a form,
- Performing a cryptographic signature of the document.
This implementation follows the Adobe PDF Reference (v1.7) (https://ia601001.us.archive.org/1/items/pdf1.7/pdf_reference_1-7.pdf)
for the structure of the PDF document,
and Digital Signatures in a PDF (https://www.adobe.com/devnet-docs/acrobatetk/tools/DigSig/Acrobat_DigitalSignatures_in_PDF.pdf),
for the structure of the signature in a PDF.
"""
def __init__(self, stream: io.BytesIO, company: Optional[ResCompany] = None, signing_time=None) -> None:
self.signing_time = signing_time
self.company = company
if not 'clone_document_from_reader' in dir(PdfWriter):
_logger.info("PDF signature is supported by Python 3.12 and above")
return
reader = PdfReader(stream)
self.writer = PdfWriter()
self.writer.clone_document_from_reader(reader)
def sign_pdf(self, visible_signature: bool = False, field_name: str = "Odoo Signature", signer: Optional[ResUsers] = None) -> Optional[io.BytesIO]:
"""Signs the pdf document using a PdfWriter object
Returns:
Optional[io.BytesIO]: the resulting output stream after the signature has been performed, or None in case of error
"""
if not self.company or not load_pem_x509_certificate:
return
dummy, sig_field_value = self._setup_form(visible_signature, field_name, signer)
if not self._perform_signature(sig_field_value):
return
out_stream = io.BytesIO()
self.writer.write_stream(out_stream)
return out_stream
def _load_key_and_certificate(self) -> tuple[Optional[PrivateKeyTypes], Optional[Certificate]]:
"""Loads the private key
Returns:
Optional[PrivateKeyTypes]: a private key object, or None if the key couldn't be loaded.
"""
if "signing_certificate_id" not in self.company._fields \
or not self.company.signing_certificate_id.pem_certificate:
return None, None
certificate = self.company.signing_certificate_id
cert_bytes = base64.decodebytes(certificate.pem_certificate)
private_key_bytes = base64.decodebytes(certificate.private_key_id.content)
return load_pem_private_key(private_key_bytes, None), load_pem_x509_certificate(cert_bytes)
def _setup_form(self, visible_signature: bool, field_name: str, signer: Optional[ResUsers] = None) -> tuple[DictionaryObject, DictionaryObject] | None:
"""Creates the /AcroForm and populates it with the appropriate field for the signature
Args:
visible_signature (bool): boolean value that determines if the signature should be visible on the document
field_name (str): the name of the signature field
signer (Optional[ResUsers]): user that will be used in the visuals of the signature field
Returns:
tuple[DictionaryObject, DictionaryObject]: a tuple containing the signature field and the signature content
"""
if "/AcroForm" not in self.writer._root_object:
form = DictionaryObject()
form.update({
NameObject("/SigFlags"): NumberObject(3)
})
form_ref = self.writer._add_object(form)
self.writer._root_object.update({
NameObject("/AcroForm"): form_ref
})
else:
form = self.writer._root_object["/AcroForm"].get_object()
# SigFlags(3) = SignatureExists = true && AppendOnly = true.
# The document contains signed signature and must be modified in incremental mode (see https://github.com/pdf-association/pdf-issues/issues/457)
form.update({
NameObject("/SigFlags"): NumberObject(3)
})
# Assigning the newly created field to a page
page = self.writer.pages[0]
# Setting up the signature field properties
signature_field = DictionaryObject()
# Metadata of the signature field
# /FT = Field Type, here set to /Sig the signature type
# /T = name of the field
# /Type = type of object, in this case annotation (/Annot)
# /Subtype = type of annotation
# /F = annotation flags, represented as a 32 bit unsigned integer. 132 corresponds to the Print and Locked flags
# Print : corresponds to printing the signature when the page is printed
# Locked : preventing the annotation properties to be modfied or the annotation to be deletd by the user
# (see section 8.4.2 of the Adobe PDF Reference (v1.7) https://ia601001.us.archive.org/1/items/pdf1.7/pdf_reference_1-7.pdf),
# /P = page reference, reference to the page where the signature field is located
signature_field.update({
NameObject("/FT"): NameObject("/Sig"),
NameObject("/T"): create_string_object(field_name),
NameObject("/Type"): NameObject("/Annot"),
NameObject("/Subtype"): NameObject("/Widget"),
NameObject("/F"): NumberObject(132),
NameObject("/P"): page.indirect_reference,
})
# Creating the appearance (visible elements of the signature)
if visible_signature:
origin = page.mediabox.upper_right # retrieves the top-right coordinates of the page
rect_size = (200, 20) # dimensions of the box (width, height)
padding = 5
# Box that will contain the signature, defined as [x1, y1, x2, y2]
# where (x1, y1) is the bottom left coordinates of the box,
# and (x2, y2) the top-right coordinates.
rect = [
origin[0] - rect_size[0] - padding,
origin[1] - rect_size[1] - padding,
origin[0] - padding,
origin[1] - padding
]
# Here is defined the StreamObject that contains the information about the visible
# parts of the signature
#
# Dictionary contents:
# /BBox = coordinates of the 'visible' box, relative to the /Rect definition of the signature field
# /Resources = resources needed to properly render the signature,
# /Font = dictionary containing the information about the font used by the signature
# /F1 = font resource, used to define a font that will be usable in the signature
stream = StreamObject()
stream.update({
NameObject("/BBox"): self._create_number_array_object([0, 0, rect_size[0], rect_size[1]]),
NameObject("/Resources"): DictionaryObject({
NameObject("/Font"): DictionaryObject({
NameObject("/F1"): DictionaryObject({
NameObject("/Type"): NameObject("/Font"),
NameObject("/Subtype"): NameObject("/Type1"),
NameObject("/BaseFont"): NameObject("/Helvetica")
})
})
}),
NameObject("/Type"): NameObject("/XObject"),
NameObject("/Subtype"): NameObject("/Form")
})
#
content = "Digitally signed"
content = create_string_object(f'{content} by {signer.name} <{signer.email}>') if signer is not None else create_string_object(content)
# Setting the parameters used to display the text object of the signature
# More details on this subject can be found in the sections 4.3 and 5.3
# of the Adobe PDF Reference (v1.7) https://ia601001.us.archive.org/1/items/pdf1.7/pdf_reference_1-7.pdf
#
# Parameters:
# q = saves the the current graphics state on the graphics state stack
# 0.5 0 0 0.5 0 0 cm = modification of the current transformation matrix. Here used to scale down the text size by 0.5 in x and y
# BT = begin text object
# /F1 = reference to the font resource named F1
# 12 Tf = set the font size to 12
# 0 TL = defines text leading, the space between lines, here set to 0
# 0 10 Td = moves the text to the start of the next line, expressed in text space units. Here (x, y) = (0, 10)
# (text_content) Tj = renders a text string
# ET = end text object
# Q = Restore the graphics state by removing the most recently saved state from the stack and making it the current state
stream._data = f"q 0.5 0 0 0.5 0 0 cm BT /F1 12 Tf 0 TL 0 10 Td ({content}) Tj ET Q".encode()
signature_appearence = DictionaryObject()
signature_appearence.update({
NameObject("/N"): stream
})
signature_field.update({
NameObject("/AP"): signature_appearence,
})
else:
rect = [0,0,0,0]
signature_field.update({
NameObject("/Rect"): self._create_number_array_object(rect)
})
# Setting up the actual signature contents with placeholders for /Contents and /ByteRange
#
# Dictionary contents:
# /Contents = content of the signature field. The content is a byte string of an object that follows
# the Cryptographic Message Syntax (CMS). The object is converted in hexadecimal and stored as bytes.
# The /Contents are pre-filled with placeholder values of an arbitrary size (i.e. 8KB) to ensure that
# the signature will fit in the "<>" bounds of the field
# /ByteRange = an array represented as [offset, length, offset, length, ...] which defines the bytes that
# are used when computing the digest of the document. Similarly to the /Contents, the /ByteRange is set to
# a placeholder as we aren't yet able to compute the range at this point.
# /Type = the type of form field. Here /Sig, the signature field
# /Filter
# /SubFilter
# /M = the timestamp of the signature. Indicates when the document was signed.
signature_field_value = DictionaryObject()
signature_field_value.update({
NameObject("/Contents"): ByteStringObject(b"\0" * 8192),
NameObject("/ByteRange"): self._create_number_array_object([0, 0, 0, 0]),
NameObject("/Type"): NameObject("/Sig"),
NameObject("/Filter"): NameObject("/Adobe.PPKLite"),
NameObject("/SubFilter"): NameObject("/adbe.pkcs7.detached"),
NameObject("/M"): create_string_object(datetime.datetime.now(datetime.timezone.utc).strftime("D:%Y%m%d%H%M%S")),
})
# Here we add the reference to be written in a specific order. This is needed
# by Adobe Acrobat to consider the signature valid.
signature_field_ref = self.writer._add_object(signature_field)
signature_field_value_ref = self.writer._add_object(signature_field_value)
# /V = the actual value of the signature field. Used to store the dictionary of the field
signature_field.update({
NameObject("/V"): signature_field_value_ref
})
# Definition of the fields array linked to the form (/AcroForm)
if "/Fields" not in self.writer._root_object:
fields = ArrayObject()
else:
fields = self.writer._root_object["/Fields"].get_object()
fields.append(signature_field_ref)
form.update({
NameObject("/Fields"): fields
})
# The signature field reference is added to the annotations array
if "/Annots" not in page:
page[NameObject("/Annots")] = ArrayObject()
page[NameObject("/Annots")].append(signature_field_ref)
return signature_field, signature_field_value
def _get_cms_object(self, digest: bytes) -> Optional[cms.ContentInfo]:
"""Creates an object that follows the Cryptographic Message Syntax(CMS)
RFC: https://datatracker.ietf.org/doc/html/rfc5652
Args:
digest (bytes): the digest of the document in bytes
Returns:
cms.ContentInfo: a CMS object containing the information of the signature
"""
private_key, certificate = self._load_key_and_certificate()
if private_key == None or certificate == None:
return None
cert = x509.Certificate.load(
certificate.public_bytes(encoding=Encoding.DER))
encap_content_info = {
'content_type': 'data',
'content': None
}
attrs = cms.CMSAttributes([
cms.CMSAttribute({
'type': 'content_type',
'values': ['data']
}),
cms.CMSAttribute({
'type': 'signing_time',
'values': [cms.Time({'utc_time': core.UTCTime(self.signing_time or datetime.datetime.now(datetime.timezone.utc))})]
}),
cms.CMSAttribute({
'type': 'cms_algorithm_protection',
'values': [
cms.CMSAlgorithmProtection(
{
'mac_algorithm': None,
'digest_algorithm': cms.DigestAlgorithm(
{'algorithm': 'sha256', 'parameters': None}
),
'signature_algorithm': cms.SignedDigestAlgorithm({
'algorithm': 'sha256_rsa',
'parameters': None
})
}
)
]
}),
cms.CMSAttribute({
'type': 'message_digest',
'values': [digest],
}),
])
signed_attrs = private_key.sign(
attrs.dump(),
padding.PKCS1v15(),
hashes.SHA256()
)
signer_info = cms.SignerInfo({
'version': "v1",
'digest_algorithm': algos.DigestAlgorithm({'algorithm': 'sha256'}),
'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': 'sha256_rsa'}),
'signature': signed_attrs,
'sid': cms.SignerIdentifier({
'issuer_and_serial_number': cms.IssuerAndSerialNumber({
'issuer': cert.issuer,
'serial_number': cert.serial_number
})
}),
'signed_attrs': attrs})
signed_data = {
'version': 'v1',
'digest_algorithms': [algos.DigestAlgorithm({'algorithm': 'sha256'})],
'encap_content_info': encap_content_info,
'certificates': [cert],
'signer_infos': [signer_info]
}
return cms.ContentInfo({
'content_type': 'signed_data',
'content': cms.SignedData(signed_data)
})
def _perform_signature(self, sig_field_value: DictionaryObject) -> bool:
"""Creates the actual signature content and populate /ByteRange and /Contents properties with meaningful content.
Args:
sig_field_value (DictionaryObject): the value (/V) of the signature field which needs to be modified
"""
pdf_data = self._get_document_data()
# Computation of the location of the last inserted contents for the signature field
signature_field_pos = pdf_data.rfind(b"/FT /Sig")
contents_field_pos = pdf_data.find(b"Contents", signature_field_pos)
# Computing the start and end position of the /Contents <signature> field
# to exclude the content of <> (aka the actual signature) from the byte range
placeholder_start = contents_field_pos + 9
placeholder_end = placeholder_start + len(b"\0" * 8192) * 2 + 2
# Replacing the placeholder byte range with the actual range
# that will be used to compute the document digest
placeholder_byte_range = sig_field_value.get("/ByteRange")
# Here the byte range represents an array [index, length, index, length, ...]
# where 'index' represents the index of a byte, and length the number of bytes to take
# This array indicates the bytes that are used when computing the digest of the document
byte_range = [0, placeholder_start,
placeholder_end, abs(len(pdf_data) - placeholder_end)]
byte_range = self._correct_byte_range(
placeholder_byte_range, byte_range, len(pdf_data))
sig_field_value.update({
NameObject("/ByteRange"): self._create_number_array_object(byte_range)
})
pdf_data = self._get_document_data()
digest = self._compute_digest_from_byte_range(pdf_data, byte_range)
cms_content_info = self._get_cms_object(digest)
if cms_content_info == None:
return False
signature_hex = cms_content_info.dump().hex()
signature_hex = signature_hex.ljust(8192 * 2, "0")
sig_field_value.update({
NameObject("/Contents"): ByteStringObject(bytes.fromhex(signature_hex))
})
return True
def _get_document_data(self):
"""Retrieves the bytes of the document from the writer"""
output_stream = io.BytesIO()
self.writer.write_stream(output_stream)
return output_stream.getvalue()
def _correct_byte_range(self, old_range: list[int], new_range: list[int], base_pdf_len: int) -> list[int]:
"""Corrects the last value of the new byte range
This function corrects the initial byte range (old_range) which was computed for document containing
the placeholder values for the /ByteRange and /Contents fields. This is needed because when updating
/ByteRange, the length of the document will change as the byte range will take more bytes of the
document, resulting in an invalid byte range.
Args:
old_range (list[int]): the previous byte range
new_range (list[int]): the new byte range
base_pdf_len (int): the base length of the pdf, before insertion of the actual byte range
Returns:
list[int]: the corrected byte range
"""
# Computing the difference of length of the strings of the old and new byte ranges.
# Used to determine if a re-computation of the range is needed or not
current_len = len(str(old_range))
corrected_len = len(str(new_range))
diff = corrected_len - current_len
if diff == 0:
return new_range
corrected_range = new_range.copy()
corrected_range[-1] = abs((base_pdf_len + diff) - new_range[-2])
return self._correct_byte_range(new_range, corrected_range, base_pdf_len)
def _compute_digest_from_byte_range(self, data: bytes, byte_range: list[int]) -> bytes:
"""Computes the digest of the data from a byte range. Uses SHA256 algorithm to compute the hash.
The byte range is defined as an array [offset, length, offset, length, ...] which corresponds to the bytes from the document
that will be used in the computation of the hash.
i.e. for document = b'example' and byte_range = [0, 1, 6, 1],
the hash will be computed from b'ee'
Args:
document (bytes): the data in bytes
byte_range (list[int]): the byte range used to compute the digest.
Returns:
bytes: the computed digest
"""
hashed = hashlib.sha256()
for i in range(0, len(byte_range), 2):
hashed.update(data[byte_range[i]:byte_range[i] + byte_range[i+1]])
return hashed.digest()
def _create_number_array_object(self, array: list[int]) -> ArrayObject:
return ArrayObject([NumberObject(item) for item in array])