Coverage for adhoc-cicd-odoo-odoo / odoo / tools / pdf / signature.py: 17%
142 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1import base64
2import datetime
3import hashlib
4import io
5from typing import Optional
6from asn1crypto import cms, algos, core, x509
7import logging
9try:
10 from cryptography.hazmat.primitives import hashes
11 from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
12 from cryptography.hazmat.primitives.serialization import Encoding, load_pem_private_key
13 from cryptography.hazmat.primitives.asymmetric import padding
14 from cryptography.x509 import Certificate, load_pem_x509_certificate
15except ImportError:
16 # cryptography 41.0.7 and above is supported
17 hashes = None
18 PrivateKeyTypes = None
19 Encoding = None
20 load_pem_private_key = None
21 padding = None
22 Certificate = None
23 load_pem_x509_certificate = None
25from odoo import _
26from odoo.addons.base.models.res_company import ResCompany
27from odoo.addons.base.models.res_users import ResUsers
28from odoo.tools.pdf import PdfReader, PdfWriter, ArrayObject, ByteStringObject, DictionaryObject, NameObject, NumberObject, create_string_object, DecodedStreamObject as StreamObject
30_logger = logging.getLogger(__name__)
32class PdfSigner:
33 """Class that defines methods uses in the signing process of pdf documents
35 The PdfSigner will perform the following operations on a PDF document:
36 - Modifiying the document by adding a signature field via a form,
37 - Performing a cryptographic signature of the document.
39 This implementation follows the Adobe PDF Reference (v1.7) (https://ia601001.us.archive.org/1/items/pdf1.7/pdf_reference_1-7.pdf)
40 for the structure of the PDF document,
41 and Digital Signatures in a PDF (https://www.adobe.com/devnet-docs/acrobatetk/tools/DigSig/Acrobat_DigitalSignatures_in_PDF.pdf),
42 for the structure of the signature in a PDF.
43 """
45 def __init__(self, stream: io.BytesIO, company: Optional[ResCompany] = None, signing_time=None) -> None:
46 self.signing_time = signing_time
47 self.company = company
48 if not 'clone_document_from_reader' in dir(PdfWriter):
49 _logger.info("PDF signature is supported by Python 3.12 and above")
50 return
51 reader = PdfReader(stream)
52 self.writer = PdfWriter()
53 self.writer.clone_document_from_reader(reader)
57 def sign_pdf(self, visible_signature: bool = False, field_name: str = "Odoo Signature", signer: Optional[ResUsers] = None) -> Optional[io.BytesIO]:
58 """Signs the pdf document using a PdfWriter object
60 Returns:
61 Optional[io.BytesIO]: the resulting output stream after the signature has been performed, or None in case of error
62 """
63 if not self.company or not load_pem_x509_certificate:
64 return
66 dummy, sig_field_value = self._setup_form(visible_signature, field_name, signer)
68 if not self._perform_signature(sig_field_value):
69 return
71 out_stream = io.BytesIO()
72 self.writer.write_stream(out_stream)
73 return out_stream
75 def _load_key_and_certificate(self) -> tuple[Optional[PrivateKeyTypes], Optional[Certificate]]:
76 """Loads the private key
78 Returns:
79 Optional[PrivateKeyTypes]: a private key object, or None if the key couldn't be loaded.
80 """
81 if "signing_certificate_id" not in self.company._fields \
82 or not self.company.signing_certificate_id.pem_certificate:
83 return None, None
85 certificate = self.company.signing_certificate_id
86 cert_bytes = base64.decodebytes(certificate.pem_certificate)
87 private_key_bytes = base64.decodebytes(certificate.private_key_id.content)
88 return load_pem_private_key(private_key_bytes, None), load_pem_x509_certificate(cert_bytes)
90 def _setup_form(self, visible_signature: bool, field_name: str, signer: Optional[ResUsers] = None) -> tuple[DictionaryObject, DictionaryObject] | None:
91 """Creates the /AcroForm and populates it with the appropriate field for the signature
93 Args:
94 visible_signature (bool): boolean value that determines if the signature should be visible on the document
95 field_name (str): the name of the signature field
96 signer (Optional[ResUsers]): user that will be used in the visuals of the signature field
98 Returns:
99 tuple[DictionaryObject, DictionaryObject]: a tuple containing the signature field and the signature content
100 """
101 if "/AcroForm" not in self.writer._root_object:
102 form = DictionaryObject()
103 form.update({
104 NameObject("/SigFlags"): NumberObject(3)
105 })
106 form_ref = self.writer._add_object(form)
108 self.writer._root_object.update({
109 NameObject("/AcroForm"): form_ref
110 })
111 else:
112 form = self.writer._root_object["/AcroForm"].get_object()
115 # SigFlags(3) = SignatureExists = true && AppendOnly = true.
116 # The document contains signed signature and must be modified in incremental mode (see https://github.com/pdf-association/pdf-issues/issues/457)
117 form.update({
118 NameObject("/SigFlags"): NumberObject(3)
119 })
121 # Assigning the newly created field to a page
122 page = self.writer.pages[0]
124 # Setting up the signature field properties
125 signature_field = DictionaryObject()
127 # Metadata of the signature field
128 # /FT = Field Type, here set to /Sig the signature type
129 # /T = name of the field
130 # /Type = type of object, in this case annotation (/Annot)
131 # /Subtype = type of annotation
132 # /F = annotation flags, represented as a 32 bit unsigned integer. 132 corresponds to the Print and Locked flags
133 # Print : corresponds to printing the signature when the page is printed
134 # Locked : preventing the annotation properties to be modfied or the annotation to be deletd by the user
135 # (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),
136 # /P = page reference, reference to the page where the signature field is located
137 signature_field.update({
138 NameObject("/FT"): NameObject("/Sig"),
139 NameObject("/T"): create_string_object(field_name),
140 NameObject("/Type"): NameObject("/Annot"),
141 NameObject("/Subtype"): NameObject("/Widget"),
142 NameObject("/F"): NumberObject(132),
143 NameObject("/P"): page.indirect_reference,
144 })
147 # Creating the appearance (visible elements of the signature)
148 if visible_signature:
149 origin = page.mediabox.upper_right # retrieves the top-right coordinates of the page
150 rect_size = (200, 20) # dimensions of the box (width, height)
151 padding = 5
153 # Box that will contain the signature, defined as [x1, y1, x2, y2]
154 # where (x1, y1) is the bottom left coordinates of the box,
155 # and (x2, y2) the top-right coordinates.
156 rect = [
157 origin[0] - rect_size[0] - padding,
158 origin[1] - rect_size[1] - padding,
159 origin[0] - padding,
160 origin[1] - padding
161 ]
163 # Here is defined the StreamObject that contains the information about the visible
164 # parts of the signature
165 #
166 # Dictionary contents:
167 # /BBox = coordinates of the 'visible' box, relative to the /Rect definition of the signature field
168 # /Resources = resources needed to properly render the signature,
169 # /Font = dictionary containing the information about the font used by the signature
170 # /F1 = font resource, used to define a font that will be usable in the signature
171 stream = StreamObject()
172 stream.update({
173 NameObject("/BBox"): self._create_number_array_object([0, 0, rect_size[0], rect_size[1]]),
174 NameObject("/Resources"): DictionaryObject({
175 NameObject("/Font"): DictionaryObject({
176 NameObject("/F1"): DictionaryObject({
177 NameObject("/Type"): NameObject("/Font"),
178 NameObject("/Subtype"): NameObject("/Type1"),
179 NameObject("/BaseFont"): NameObject("/Helvetica")
180 })
181 })
182 }),
183 NameObject("/Type"): NameObject("/XObject"),
184 NameObject("/Subtype"): NameObject("/Form")
185 })
187 #
188 content = "Digitally signed"
189 content = create_string_object(f'{content} by {signer.name} <{signer.email}>') if signer is not None else create_string_object(content)
191 # Setting the parameters used to display the text object of the signature
192 # More details on this subject can be found in the sections 4.3 and 5.3
193 # of the Adobe PDF Reference (v1.7) https://ia601001.us.archive.org/1/items/pdf1.7/pdf_reference_1-7.pdf
194 #
195 # Parameters:
196 # q = saves the the current graphics state on the graphics state stack
197 # 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
198 # BT = begin text object
199 # /F1 = reference to the font resource named F1
200 # 12 Tf = set the font size to 12
201 # 0 TL = defines text leading, the space between lines, here set to 0
202 # 0 10 Td = moves the text to the start of the next line, expressed in text space units. Here (x, y) = (0, 10)
203 # (text_content) Tj = renders a text string
204 # ET = end text object
205 # Q = Restore the graphics state by removing the most recently saved state from the stack and making it the current state
206 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()
207 signature_appearence = DictionaryObject()
208 signature_appearence.update({
209 NameObject("/N"): stream
210 })
211 signature_field.update({
212 NameObject("/AP"): signature_appearence,
213 })
214 else:
215 rect = [0,0,0,0]
217 signature_field.update({
218 NameObject("/Rect"): self._create_number_array_object(rect)
219 })
221 # Setting up the actual signature contents with placeholders for /Contents and /ByteRange
222 #
223 # Dictionary contents:
224 # /Contents = content of the signature field. The content is a byte string of an object that follows
225 # the Cryptographic Message Syntax (CMS). The object is converted in hexadecimal and stored as bytes.
226 # The /Contents are pre-filled with placeholder values of an arbitrary size (i.e. 8KB) to ensure that
227 # the signature will fit in the "<>" bounds of the field
228 # /ByteRange = an array represented as [offset, length, offset, length, ...] which defines the bytes that
229 # are used when computing the digest of the document. Similarly to the /Contents, the /ByteRange is set to
230 # a placeholder as we aren't yet able to compute the range at this point.
231 # /Type = the type of form field. Here /Sig, the signature field
232 # /Filter
233 # /SubFilter
234 # /M = the timestamp of the signature. Indicates when the document was signed.
235 signature_field_value = DictionaryObject()
236 signature_field_value.update({
237 NameObject("/Contents"): ByteStringObject(b"\0" * 8192),
238 NameObject("/ByteRange"): self._create_number_array_object([0, 0, 0, 0]),
239 NameObject("/Type"): NameObject("/Sig"),
240 NameObject("/Filter"): NameObject("/Adobe.PPKLite"),
241 NameObject("/SubFilter"): NameObject("/adbe.pkcs7.detached"),
242 NameObject("/M"): create_string_object(datetime.datetime.now(datetime.timezone.utc).strftime("D:%Y%m%d%H%M%S")),
243 })
245 # Here we add the reference to be written in a specific order. This is needed
246 # by Adobe Acrobat to consider the signature valid.
247 signature_field_ref = self.writer._add_object(signature_field)
248 signature_field_value_ref = self.writer._add_object(signature_field_value)
250 # /V = the actual value of the signature field. Used to store the dictionary of the field
251 signature_field.update({
252 NameObject("/V"): signature_field_value_ref
253 })
255 # Definition of the fields array linked to the form (/AcroForm)
256 if "/Fields" not in self.writer._root_object:
257 fields = ArrayObject()
258 else:
259 fields = self.writer._root_object["/Fields"].get_object()
260 fields.append(signature_field_ref)
261 form.update({
262 NameObject("/Fields"): fields
263 })
265 # The signature field reference is added to the annotations array
266 if "/Annots" not in page:
267 page[NameObject("/Annots")] = ArrayObject()
268 page[NameObject("/Annots")].append(signature_field_ref)
270 return signature_field, signature_field_value
272 def _get_cms_object(self, digest: bytes) -> Optional[cms.ContentInfo]:
273 """Creates an object that follows the Cryptographic Message Syntax(CMS)
275 RFC: https://datatracker.ietf.org/doc/html/rfc5652
277 Args:
278 digest (bytes): the digest of the document in bytes
280 Returns:
281 cms.ContentInfo: a CMS object containing the information of the signature
282 """
283 private_key, certificate = self._load_key_and_certificate()
284 if private_key == None or certificate == None:
285 return None
286 cert = x509.Certificate.load(
287 certificate.public_bytes(encoding=Encoding.DER))
288 encap_content_info = {
289 'content_type': 'data',
290 'content': None
291 }
293 attrs = cms.CMSAttributes([
294 cms.CMSAttribute({
295 'type': 'content_type',
296 'values': ['data']
297 }),
298 cms.CMSAttribute({
299 'type': 'signing_time',
300 'values': [cms.Time({'utc_time': core.UTCTime(self.signing_time or datetime.datetime.now(datetime.timezone.utc))})]
301 }),
302 cms.CMSAttribute({
303 'type': 'cms_algorithm_protection',
304 'values': [
305 cms.CMSAlgorithmProtection(
306 {
307 'mac_algorithm': None,
308 'digest_algorithm': cms.DigestAlgorithm(
309 {'algorithm': 'sha256', 'parameters': None}
310 ),
311 'signature_algorithm': cms.SignedDigestAlgorithm({
312 'algorithm': 'sha256_rsa',
313 'parameters': None
314 })
315 }
316 )
317 ]
318 }),
319 cms.CMSAttribute({
320 'type': 'message_digest',
321 'values': [digest],
322 }),
323 ])
325 signed_attrs = private_key.sign(
326 attrs.dump(),
327 padding.PKCS1v15(),
328 hashes.SHA256()
329 )
331 signer_info = cms.SignerInfo({
332 'version': "v1",
333 'digest_algorithm': algos.DigestAlgorithm({'algorithm': 'sha256'}),
334 'signature_algorithm': algos.SignedDigestAlgorithm({'algorithm': 'sha256_rsa'}),
335 'signature': signed_attrs,
336 'sid': cms.SignerIdentifier({
337 'issuer_and_serial_number': cms.IssuerAndSerialNumber({
338 'issuer': cert.issuer,
339 'serial_number': cert.serial_number
340 })
341 }),
342 'signed_attrs': attrs})
344 signed_data = {
345 'version': 'v1',
346 'digest_algorithms': [algos.DigestAlgorithm({'algorithm': 'sha256'})],
347 'encap_content_info': encap_content_info,
348 'certificates': [cert],
349 'signer_infos': [signer_info]
350 }
352 return cms.ContentInfo({
353 'content_type': 'signed_data',
354 'content': cms.SignedData(signed_data)
355 })
357 def _perform_signature(self, sig_field_value: DictionaryObject) -> bool:
358 """Creates the actual signature content and populate /ByteRange and /Contents properties with meaningful content.
360 Args:
361 sig_field_value (DictionaryObject): the value (/V) of the signature field which needs to be modified
362 """
363 pdf_data = self._get_document_data()
365 # Computation of the location of the last inserted contents for the signature field
366 signature_field_pos = pdf_data.rfind(b"/FT /Sig")
367 contents_field_pos = pdf_data.find(b"Contents", signature_field_pos)
369 # Computing the start and end position of the /Contents <signature> field
370 # to exclude the content of <> (aka the actual signature) from the byte range
371 placeholder_start = contents_field_pos + 9
372 placeholder_end = placeholder_start + len(b"\0" * 8192) * 2 + 2
374 # Replacing the placeholder byte range with the actual range
375 # that will be used to compute the document digest
376 placeholder_byte_range = sig_field_value.get("/ByteRange")
378 # Here the byte range represents an array [index, length, index, length, ...]
379 # where 'index' represents the index of a byte, and length the number of bytes to take
380 # This array indicates the bytes that are used when computing the digest of the document
381 byte_range = [0, placeholder_start,
382 placeholder_end, abs(len(pdf_data) - placeholder_end)]
384 byte_range = self._correct_byte_range(
385 placeholder_byte_range, byte_range, len(pdf_data))
387 sig_field_value.update({
388 NameObject("/ByteRange"): self._create_number_array_object(byte_range)
389 })
391 pdf_data = self._get_document_data()
393 digest = self._compute_digest_from_byte_range(pdf_data, byte_range)
395 cms_content_info = self._get_cms_object(digest)
397 if cms_content_info == None:
398 return False
400 signature_hex = cms_content_info.dump().hex()
401 signature_hex = signature_hex.ljust(8192 * 2, "0")
403 sig_field_value.update({
404 NameObject("/Contents"): ByteStringObject(bytes.fromhex(signature_hex))
405 })
406 return True
408 def _get_document_data(self):
409 """Retrieves the bytes of the document from the writer"""
410 output_stream = io.BytesIO()
411 self.writer.write_stream(output_stream)
412 return output_stream.getvalue()
415 def _correct_byte_range(self, old_range: list[int], new_range: list[int], base_pdf_len: int) -> list[int]:
416 """Corrects the last value of the new byte range
418 This function corrects the initial byte range (old_range) which was computed for document containing
419 the placeholder values for the /ByteRange and /Contents fields. This is needed because when updating
420 /ByteRange, the length of the document will change as the byte range will take more bytes of the
421 document, resulting in an invalid byte range.
423 Args:
424 old_range (list[int]): the previous byte range
425 new_range (list[int]): the new byte range
426 base_pdf_len (int): the base length of the pdf, before insertion of the actual byte range
428 Returns:
429 list[int]: the corrected byte range
430 """
431 # Computing the difference of length of the strings of the old and new byte ranges.
432 # Used to determine if a re-computation of the range is needed or not
433 current_len = len(str(old_range))
434 corrected_len = len(str(new_range))
435 diff = corrected_len - current_len
437 if diff == 0:
438 return new_range
440 corrected_range = new_range.copy()
441 corrected_range[-1] = abs((base_pdf_len + diff) - new_range[-2])
442 return self._correct_byte_range(new_range, corrected_range, base_pdf_len)
445 def _compute_digest_from_byte_range(self, data: bytes, byte_range: list[int]) -> bytes:
446 """Computes the digest of the data from a byte range. Uses SHA256 algorithm to compute the hash.
448 The byte range is defined as an array [offset, length, offset, length, ...] which corresponds to the bytes from the document
449 that will be used in the computation of the hash.
451 i.e. for document = b'example' and byte_range = [0, 1, 6, 1],
452 the hash will be computed from b'ee'
454 Args:
455 document (bytes): the data in bytes
456 byte_range (list[int]): the byte range used to compute the digest.
458 Returns:
459 bytes: the computed digest
460 """
461 hashed = hashlib.sha256()
462 for i in range(0, len(byte_range), 2):
463 hashed.update(data[byte_range[i]:byte_range[i] + byte_range[i+1]])
464 return hashed.digest()
466 def _create_number_array_object(self, array: list[int]) -> ArrayObject:
467 return ArrayObject([NumberObject(item) for item in array])