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

1import base64 

2import datetime 

3import hashlib 

4import io 

5from typing import Optional 

6from asn1crypto import cms, algos, core, x509 

7import logging 

8 

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 

24 

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 

29 

30_logger = logging.getLogger(__name__) 

31 

32class PdfSigner: 

33 """Class that defines methods uses in the signing process of pdf documents 

34 

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. 

38 

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 """ 

44 

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) 

54 

55 

56 

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 

59 

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 

65 

66 dummy, sig_field_value = self._setup_form(visible_signature, field_name, signer) 

67 

68 if not self._perform_signature(sig_field_value): 

69 return 

70 

71 out_stream = io.BytesIO() 

72 self.writer.write_stream(out_stream) 

73 return out_stream 

74 

75 def _load_key_and_certificate(self) -> tuple[Optional[PrivateKeyTypes], Optional[Certificate]]: 

76 """Loads the private key 

77 

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 

84 

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) 

89 

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 

92 

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 

97 

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) 

107 

108 self.writer._root_object.update({ 

109 NameObject("/AcroForm"): form_ref 

110 }) 

111 else: 

112 form = self.writer._root_object["/AcroForm"].get_object() 

113 

114 

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 }) 

120 

121 # Assigning the newly created field to a page 

122 page = self.writer.pages[0] 

123 

124 # Setting up the signature field properties 

125 signature_field = DictionaryObject() 

126 

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 }) 

145 

146 

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 

152 

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 ] 

162 

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 }) 

186 

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) 

190 

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] 

216 

217 signature_field.update({ 

218 NameObject("/Rect"): self._create_number_array_object(rect) 

219 }) 

220 

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 }) 

244 

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) 

249 

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 }) 

254 

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 }) 

264 

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) 

269 

270 return signature_field, signature_field_value 

271 

272 def _get_cms_object(self, digest: bytes) -> Optional[cms.ContentInfo]: 

273 """Creates an object that follows the Cryptographic Message Syntax(CMS) 

274 

275 RFC: https://datatracker.ietf.org/doc/html/rfc5652 

276 

277 Args: 

278 digest (bytes): the digest of the document in bytes 

279 

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 } 

292 

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 ]) 

324 

325 signed_attrs = private_key.sign( 

326 attrs.dump(), 

327 padding.PKCS1v15(), 

328 hashes.SHA256() 

329 ) 

330 

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}) 

343 

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 } 

351 

352 return cms.ContentInfo({ 

353 'content_type': 'signed_data', 

354 'content': cms.SignedData(signed_data) 

355 }) 

356 

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. 

359 

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() 

364 

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) 

368 

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 

373 

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") 

377 

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)] 

383 

384 byte_range = self._correct_byte_range( 

385 placeholder_byte_range, byte_range, len(pdf_data)) 

386 

387 sig_field_value.update({ 

388 NameObject("/ByteRange"): self._create_number_array_object(byte_range) 

389 }) 

390 

391 pdf_data = self._get_document_data() 

392 

393 digest = self._compute_digest_from_byte_range(pdf_data, byte_range) 

394 

395 cms_content_info = self._get_cms_object(digest) 

396 

397 if cms_content_info == None: 

398 return False 

399 

400 signature_hex = cms_content_info.dump().hex() 

401 signature_hex = signature_hex.ljust(8192 * 2, "0") 

402 

403 sig_field_value.update({ 

404 NameObject("/Contents"): ByteStringObject(bytes.fromhex(signature_hex)) 

405 }) 

406 return True 

407 

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() 

413 

414 

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 

417 

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. 

422 

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 

427 

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 

436 

437 if diff == 0: 

438 return new_range 

439 

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) 

443 

444 

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. 

447 

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. 

450 

451 i.e. for document = b'example' and byte_range = [0, 1, 6, 1], 

452 the hash will be computed from b'ee' 

453 

454 Args: 

455 document (bytes): the data in bytes 

456 byte_range (list[int]): the byte range used to compute the digest. 

457 

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() 

465 

466 def _create_number_array_object(self, array: list[int]) -> ArrayObject: 

467 return ArrayObject([NumberObject(item) for item in array])