def render(self): command_stream = [b'q'] # text rendering self._init_text_box() _text_params = self.get_default_text_params() if self.text_params is not None: _text_params.update(self.text_params) text = self.style.stamp_text % _text_params self.text_box.content = text stamp_height = self.get_stamp_height() stamp_width = self.get_stamp_width() bg = self.style.background if bg is not None: # TODO this is one of the places where some more clever layout # engine would really help, since all of this is pretty ad hoc and # makes a number of non-obvious choices that would be better off # delegated to somewhere else. bg.set_writer(self.writer) # scale the background bg_height = 0.9 * stamp_height if bg.box.height_defined: sf = bg_height / bg.box.height else: bg.box.height = bg_height sf = 1 bg_y = 0.05 * stamp_height bg_width = bg.box.width * sf bg_x = 0 if bg_width <= stamp_width: bg_x = (stamp_width - bg_width) // 2 # set opacity in graphics state opacity = generic.FloatObject(self.style.background_opacity) self.set_resource(category=ResourceType.EXT_G_STATE, name=pdf_name('/BackgroundGS'), value=generic.DictionaryObject({ pdf_name('/CA'): opacity, pdf_name('/ca'): opacity })) command_stream.append( b'q /BackgroundGS gs %g 0 0 %g %g %g cm %s Q' % (sf, sf, bg_x, bg_y, bg.render())) self.import_resources(bg.resources) tb = self.text_box text_commands = tb.render() text_scale = 1 if self.expected_text_width is not None and tb.box.width_defined: text_scale = self.expected_text_width / tb.box.width command_stream.append( b'q %g 0 0 %g %g %g cm' % (text_scale, text_scale, self.text_box_x(), self.text_box_y())) command_stream.append(text_commands) command_stream.append(b'Q') # append additional drawing commands command_stream.extend(self.extra_commands()) # draw border around stamp command_stream.append( b'%g w 0 0 %g %g re S' % (self.style.border_width, stamp_width, stamp_height)) command_stream.append(b'Q') return b' '.join(command_stream)
def __init__(self, field_name, include_on_page, *, writer, sig_object_ref=None, box=None, appearances: Optional[AnnotAppearances] = None): if box is not None: visible = True rect = list(map(generic.FloatObject, box)) if appearances is not None: ap = appearances.as_pdf_object() else: ap = None else: rect = [generic.FloatObject(0)] * 4 ap = None visible = False # this sets the "Print" bit, and activates "Locked" if the # signature field is ready to be filled flags = 0b100 if sig_object_ref is None else 0b10000100 super().__init__({ # Signature field properties pdf_name('/FT'): pdf_name('/Sig'), pdf_name('/T'): pdf_string(field_name), # Annotation properties: bare minimum pdf_name('/Type'): pdf_name('/Annot'), pdf_name('/Subtype'): pdf_name('/Widget'), pdf_name('/F'): generic.NumberObject(flags), pdf_name('/P'): include_on_page, pdf_name('/Rect'): generic.ArrayObject(rect) }) if sig_object_ref is not None: self[pdf_name('/V')] = sig_object_ref if ap is not None: self[pdf_name('/AP')] = ap # register ourselves self.reference = self_reference = writer.add_object(self) # if we're building an invisible form field, this is all there is to it if visible: writer.register_annotation(include_on_page, self_reference)
w = IncrementalPdfFileWriter(BytesIO(MINIMAL)) meta = signers.PdfSignatureMetadata(field_name='Sig1') out = signers.sign_pdf(w, meta, signer=SELF_SIGN) r = PdfFileReader(out) emb = r.embedded_signatures[0] # Again: # (yes, obviously this also isn't a valid timestamp token, hence the # match=... rule here) with pytest.raises(SignatureValidationError, match='.*must be /DocTimeStamp.*'): validate_pdf_timestamp(emb, validation_context=SIMPLE_V_CONTEXT()) @pytest.mark.parametrize('wrong_subfilter', [ pdf_name('/abcde'), pdf_name("/ETSI.RFC3161"), None, generic.NullObject() ]) @freeze_time('2020-11-01') def test_sig_wrong_subfilter(wrong_subfilter): def tamper(writer, sig_obj): if wrong_subfilter: sig_obj['/SubFilter'] = wrong_subfilter else: del sig_obj['/SubFilter'] out = _tamper_with_sig_obj(tamper) r = PdfFileReader(out) emb = r.embedded_signatures[0] with pytest.raises(SignatureValidationError): val_trusted(emb)
def _prepare_sig_field(sig_field_name, root, update_writer: IncrementalPdfFileWriter, existing_fields_only=False, lock_sig_flags=True, **kwargs): """ Returns a tuple of a boolean and a reference to a signature field. The boolean is ``True`` if the field was created, and ``False`` otherwise. """ if sig_field_name is None: # pragma: nocover raise ValueError try: form = root['/AcroForm'] try: fields = form['/Fields'] except KeyError: raise ValueError('/AcroForm has no /Fields') candidates = enumerate_sig_fields_in(fields, with_name=sig_field_name) sig_field_ref = None try: field_name, value, sig_field_ref = next(candidates) if value is not None: raise SigningError( 'Signature field with name %s appears to be filled already.' % sig_field_name) except StopIteration: if existing_fields_only: raise SigningError( 'No empty signature field with name %s found.' % sig_field_name) form_created = False except KeyError: # we have to create the form if existing_fields_only: raise SigningError('This file does not contain a form.') # no AcroForm present, so create one form = generic.DictionaryObject() root[pdf_name('/AcroForm')] = update_writer.add_object(form) fields = generic.ArrayObject() form[pdf_name('/Fields')] = fields # now we need to mark the root as updated update_writer.update_root() form_created = True sig_field_ref = None if sig_field_ref is not None: return False, sig_field_ref if '.' in sig_field_name: raise NotImplementedError( "Creating fields deep in the form hierarchy is not supported" "right now.") # no signature field exists, so create one # default: grab a reference to the first page page_ref = update_writer.find_page_for_modification(0)[0] sig_form_kwargs = {'include_on_page': page_ref} sig_form_kwargs.update(**kwargs) sig_field = SignatureFormField(sig_field_name, writer=update_writer, **sig_form_kwargs) sig_field_ref = sig_field.reference fields.append(sig_field_ref) # make sure /SigFlags is present. If not, create it sig_flags = 3 if lock_sig_flags else 1 form.setdefault(pdf_name('/SigFlags'), generic.NumberObject(sig_flags)) # if a field was added to an existing form, register an extra update if not form_created: update_writer.update_container(fields) return True, sig_field_ref
def as_pdf_object(self): """ Render this :class:`.SigSeedValueSpec` object to a PDF dictionary. :return: A :class:`~.generic.DictionaryObject`. """ result = generic.DictionaryObject({ pdf_name('/Type'): pdf_name('/SV'), pdf_name('/Ff'): generic.NumberObject(self.flags.value), }) if self.subfilters is not None: result[pdf_name('/SubFilter')] = generic.ArrayObject( sf.value for sf in self.subfilters) if self.add_rev_info is not None: result[pdf_name('/AddRevInfo')] = generic.BooleanObject( self.add_rev_info) if self.digest_methods is not None: result[pdf_name('/DigestMethod')] = generic.ArrayObject( map(pdf_string, self.digest_methods)) if self.reasons is not None: result[pdf_name('/Reasons')] = generic.ArrayObject( pdf_string(reason) for reason in self.reasons) if self.timestamp_server_url is not None: result[pdf_name('/TimeStamp')] = generic.DictionaryObject({ pdf_name('/URL'): pdf_string(self.timestamp_server_url), pdf_name('/Ff'): generic.NumberObject(1 if self.timestamp_required else 0) }) if self.cert is not None: result[pdf_name('/Cert')] = self.cert.as_pdf_object() return result
class SigCertConstraints: """ This part of the seed value dictionary allows the document author to set constraints on the signer's certificate. See Table 235 in ISO 32000-1. """ flags: SigCertConstraintFlags = SigCertConstraintFlags(0) """ Enforcement flags. By default, all entries are optional. """ subjects: List[x509.Certificate] = None """ Explicit list of certificates that can be used to sign a signature field. """ subject_dn: x509.Name = None """ Certificate subject names that can be used to sign a signature field. Subject DN entries that are not mentioned are unconstrained. """ issuers: List[x509.Certificate] = None """ List of issuer certificates that the signer certificate can be issued by. Note that these issuers do not need to be the *direct* issuer of the signer's certificate; any descendant relationship will do. """ info_url: str = None """ Informational URL that should be opened when an appropriate signature cannot be found. """ url_type: generic.NameObject = pdf_name('/Browser') """ Handler that should be used to open :attr:`info_url`. ``/Browser`` is the only implementation-independent value. """ # TODO support key usage and OID constraints @classmethod def from_pdf_object(cls, pdf_dict): """ Read a PDF dictionary into a :class:`.SigCertConstraints` object. :param pdf_dict: A :class:`~.generic.DictionaryObject`. :return: A :class:`.SigCertConstraints` object. """ if isinstance(pdf_dict, generic.IndirectObject): pdf_dict = pdf_dict.get_object() try: if pdf_dict['/Type'] != '/SVCert': # pragma: nocover raise ValueError('Object /Type entry is not /SVCert') except KeyError: # pragma: nocover pass flags = SigCertConstraintFlags(pdf_dict.get('/Ff', 0)) subjects = [ oskeys.parse_certificate(cert.original_bytes) for cert in pdf_dict.get('/Subject', ()) ] issuers = [ oskeys.parse_certificate(cert.original_bytes) for cert in pdf_dict.get('/Issuer', ()) ] def format_attr(attr): # strip initial / attr = attr[1:] # attempt to convert abbreviated attrs to OIDs, since build() # takes OIDs return name_type_abbrevs_rev.get(attr.upper(), attr) subject_dns = x509.Name.build({ format_attr(attr): value for dn_dir in pdf_dict.get('/SubjectDN', ()) for attr, value in dn_dir.items() }) url = pdf_dict.get('/URL') url_type = pdf_dict.get('/URLType') kwargs = { 'flags': flags, 'subjects': subjects or None, 'subject_dn': subject_dns or None, 'issuers': issuers or None, 'info_url': url } if url is not None and url_type is not None: kwargs['url_type'] = url_type return cls(**kwargs) def as_pdf_object(self): """ Render this :class:`.SigCertConstraints` object to a PDF dictionary. :return: A :class:`~.generic.DictionaryObject`. """ result = generic.DictionaryObject({ pdf_name('/Type'): pdf_name('/SVCert'), pdf_name('/Ff'): generic.NumberObject(self.flags.value), }) if self.subjects is not None: result[pdf_name('/Subject')] = generic.ArrayObject( generic.ByteStringObject(cert.dump()) for cert in self.subjects) if self.subject_dn: # FIXME Adobe Reader seems to ignore this for some reason. # Should try to figure out what I'm doing wrong result[pdf_name('/SubjectDN')] = generic.ArrayObject([ generic.DictionaryObject({ pdf_name('/' + key): pdf_string(value) for key, value in x509_name_keyval_pairs( self.subject_dn, abbreviate_oids=True) }) ]) if self.issuers is not None: result[pdf_name('/Issuer')] = generic.ArrayObject( generic.ByteStringObject(cert.dump()) for cert in self.issuers) if self.info_url is not None: result[pdf_name('/URL')] = pdf_string(self.info_url) result[pdf_name('/URLType')] = self.url_type return result def satisfied_by(self, signer: x509.Certificate, validation_path: Optional[ValidationPath]): """ Evaluate whether a signing certificate satisfies the required constraints of this :class:`.SigCertConstraints` object. :param signer: The candidate signer's certificate. :param validation_path: Validation path of the signer's certificate. :raises UnacceptableSignerError: Raised if the conditions are not met. """ # this function assumes that key usage & trust checks have # passed already. flags = self.flags if (flags & SigCertConstraintFlags.SUBJECT) \ and self.subjects is not None: # Explicit whitelist of approved signer certificates # compare using issuer_serial acceptable = (s.issuer_serial for s in self.subjects) if signer.issuer_serial not in acceptable: raise UnacceptableSignerError( "Signer certificate not on SVCert whitelist.") if (flags & SigCertConstraintFlags.ISSUER) \ and self.issuers is not None: if validation_path is None: raise UnacceptableSignerError("Validation path not provided.") # Here, we need to match any issuer in the chain of trust to # any of the issuers on the approved list. # To do so, we collect all issuer_serial identifiers in the chain # for all certificates except the last one (i.e. the current signer) path_iss_serials = { entry.issuer_serial for entry in validation_path.copy().pop() } for issuer in self.issuers: if issuer.issuer_serial in path_iss_serials: break else: # raise error if the loop runs to completion raise UnacceptableSignerError( "Signer certificate cannot be traced back to approved " "issuer.") if (flags & SigCertConstraintFlags.SUBJECT_DN) and self.subject_dn: # I'm not entirely sure whether my reading of the standard is # is correct, but I believe that this is the intention: # A DistinguishedName object is a sequence of # relative distinguished names (RDNs). The contents of the # /SubjectDN specify a list of constraints that might apply to each # of these RDNs. I believe the requirement is that each of the # SubjectDN entries must match one of these RDNs. requirement_list = list(x509_name_keyval_pairs(self.subject_dn)) subject_name = list(x509_name_keyval_pairs(signer.subject)) if not all(attr in subject_name for attr in requirement_list): raise UnacceptableSignerError( "Subject does not have some of the following required " "attributes: " + self.subject_dn.human_friendly)
def _inner_layout_natural_size(self): text_commands, (text_width, text_height) \ = super()._inner_layout_natural_size() qr_ref, natural_qr_size = self._qr_xobject() self.set_resource( category=content.ResourceType.XOBJECT, name=pdf_name('/QR'), value=qr_ref ) style = self.style stamp_box = self.box # To size the QR code, we proceed as follows: # - If qr_inner_size is not None, use it # - If the stamp has a fully defined bbox already, # make sure it fits within the innseps, and it's not too much smaller # than the text box # - Else, scale down by DEFAULT_QR_SCALE and use that value # # Note: if qr_inner_size is defined AND the stamp bbox is available # already, scaling might still take effect depending on the inner layout # rule. innsep = style.innsep if style.qr_inner_size is not None: qr_size = style.qr_inner_size elif stamp_box.width_defined and stamp_box.height_defined: # ensure that the QR code doesn't shrink too much if the text # box is too tall. min_dim = min( max(stamp_box.height, text_height), max(stamp_box.width, text_width) ) qr_size = min_dim - 2 * innsep else: qr_size = int(round(DEFAULT_QR_SCALE * natural_qr_size)) qr_innunits_scale = qr_size / natural_qr_size qr_padded = qr_size + 2 * innsep # Next up: put the QR code and the text box together to get the # inner layout bounding box if style.qr_position.horizontal_flow: inn_width = qr_padded + text_width inn_height = max(qr_padded, text_height) else: inn_width = max(qr_padded, text_width) inn_height = qr_padded + text_height # grab the base layout rule from the QR position setting default_layout: layout.SimpleBoxLayoutRule = style.qr_position.value # Fill in the margins qr_layout_rule = layout.SimpleBoxLayoutRule( x_align=default_layout.x_align, y_align=default_layout.y_align, margins=layout.Margins.uniform(innsep), # There's no point in scaling here, the inner content canvas # is always big enough inner_content_scaling=layout.InnerScaling.NO_SCALING ) inner_box = layout.BoxConstraints(inn_width, inn_height) qr_inn_pos = qr_layout_rule.fit(inner_box, qr_size, qr_size) # we still need to take the axis reversal into account # (which also implies an adjustment in the y displacement) draw_qr_command = b'q %g 0 0 %g %g %g cm /QR Do Q' % ( qr_inn_pos.x_scale * qr_innunits_scale, -qr_inn_pos.y_scale * qr_innunits_scale, qr_inn_pos.x_pos, qr_inn_pos.y_pos + qr_size, ) # Time to put in the text box now if style.qr_position == QRPosition.LEFT_OF_TEXT: tb_margins = layout.Margins( left=qr_padded, right=0, top=0, bottom=0 ) elif style.qr_position == QRPosition.RIGHT_OF_TEXT: tb_margins = layout.Margins( right=qr_padded, left=0, top=0, bottom=0 ) elif style.qr_position == QRPosition.BELOW_TEXT: tb_margins = layout.Margins( bottom=qr_padded, right=0, left=0, top=0 ) else: tb_margins = layout.Margins( top=qr_padded, right=0, left=0, bottom=0 ) tb_layout_rule = layout.SimpleBoxLayoutRule( # flip around the alignment conventions of the default layout # to position the text box on the other side x_align=default_layout.x_align.flipped, y_align=default_layout.y_align.flipped, margins=tb_margins, inner_content_scaling=layout.InnerScaling.NO_SCALING ) # position the text box text_inn_pos = tb_layout_rule.fit(inner_box, text_width, text_height) commands = [draw_qr_command, b'q', text_inn_pos.as_cm()] commands.extend(text_commands) commands.append(b'Q') return commands, (inn_width, inn_height)
def import_page_as_xobject(self, other: PdfHandler, page_ix=0, content_stream=0, inherit_filters=True): """ Import a page content stream from some other :class:`~.rw_common.PdfHandler` into the current one as a form XObject. :param other: A :class:`~.rw_common.PdfHandler` :param page_ix: Index of the page to copy (default: 0) :param content_stream: Index of the page's content stream to copy, if multiple are present (default: 0) :param inherit_filters: Inherit the content stream's filters, if present. :return: An :class:`~.generic.IndirectObject` referring to the page object as added to the current reader. """ page_ref, resources = other.find_page_for_modification(page_ix) page_obj = page_ref.get_object() # find the page's /MediaBox by going up the tree until we encounter it pagetree_obj = page_obj while True: try: mb = pagetree_obj['/MediaBox'] break except KeyError: try: pagetree_obj = pagetree_obj['/Parent'] except KeyError: # pragma: nocover raise PdfReadError( f'Page {page_ix} does not have a /MediaBox') stream_dict = { pdf_name('/BBox'): mb, pdf_name('/Resources'): self.import_object(resources), pdf_name('/Type'): pdf_name('/XObject'), pdf_name('/Subtype'): pdf_name('/Form') } command_stream = page_obj['/Contents'] # if the page /Contents is an array, retrieve the content stream # with the appropriate index if isinstance(command_stream, generic.ArrayObject): command_stream = command_stream[content_stream].get_object() assert isinstance(command_stream, generic.StreamObject) filters = None if inherit_filters: try: # try to inherit filters from the original command stream filters = command_stream['/Filter'] except KeyError: pass if filters is not None: stream_dict[pdf_name('/Filter')] = self.import_object(filters) result = generic.StreamObject( stream_dict, encoded_data=command_stream.encoded_data) else: result = generic.StreamObject(stream_dict, stream_data=command_stream.data) return self.add_object(result)