1862 lines
59 KiB
Python
1862 lines
59 KiB
Python
"""
|
|
Utilities to deal with signature form fields and their properties in PDF files.
|
|
"""
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from enum import Enum, Flag, unique
|
|
from typing import List, Optional, Set, Tuple, Union
|
|
|
|
from asn1crypto import x509
|
|
from asn1crypto.x509 import KeyUsage
|
|
from pyhanko_certvalidator.authority import AuthorityWithCert
|
|
from pyhanko_certvalidator.errors import InvalidCertificateError
|
|
from pyhanko_certvalidator.path import ValidationPath
|
|
|
|
from pyhanko.pdf_utils import generic
|
|
from pyhanko.pdf_utils.content import RawContent
|
|
from pyhanko.pdf_utils.generic import pdf_name, pdf_string
|
|
from pyhanko.pdf_utils.layout import BoxConstraints
|
|
from pyhanko.pdf_utils.misc import (
|
|
OrderedEnum,
|
|
PdfError,
|
|
PdfReadError,
|
|
PdfWriteError,
|
|
get_and_apply,
|
|
rd,
|
|
)
|
|
from pyhanko.pdf_utils.rw_common import PdfHandler
|
|
from pyhanko.pdf_utils.writer import BasePdfFileWriter
|
|
from pyhanko.sign.general import SigningError, UnacceptableSignerError
|
|
|
|
__all__ = [
|
|
'SigFieldSpec',
|
|
'SigSeedValFlags',
|
|
'SigCertConstraints',
|
|
'SigSeedValueSpec',
|
|
'SigCertConstraintFlags',
|
|
'SigSeedSubFilter',
|
|
'SeedValueDictVersion',
|
|
'SeedLockDocument',
|
|
'SigCertKeyUsage',
|
|
'MDPPerm',
|
|
'FieldMDPAction',
|
|
'FieldMDPSpec',
|
|
'SignatureFormField',
|
|
'InvisSigSettings',
|
|
'VisibleSigSettings',
|
|
'enumerate_sig_fields',
|
|
'append_signature_field',
|
|
'ensure_sig_flags',
|
|
'prepare_sig_field',
|
|
'apply_sig_field_spec_properties',
|
|
'annot_width_height',
|
|
'get_sig_field_annot',
|
|
]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# TODO support other seed value dict entries
|
|
# TODO add more customisability appearance-wise
|
|
|
|
|
|
class MDPPerm(OrderedEnum):
|
|
"""
|
|
Indicates a ``/DocMDP`` level.
|
|
|
|
Cf. Table 254 in ISO 32000-1.
|
|
"""
|
|
|
|
NO_CHANGES = 1
|
|
"""
|
|
No changes to the document are allowed.
|
|
|
|
.. warning::
|
|
This does not apply to DSS updates and the addition of document time
|
|
stamps.
|
|
"""
|
|
FILL_FORMS = 2
|
|
"""
|
|
Form filling & signing is allowed.
|
|
"""
|
|
|
|
ANNOTATE = 3
|
|
"""
|
|
Form filling, signing and commenting are allowed.
|
|
|
|
.. warning::
|
|
Validating this ``/DocMDP`` level is not currently supported,
|
|
but included in the list for completeness.
|
|
"""
|
|
|
|
|
|
class SeedSignatureType:
|
|
"""
|
|
Signature type indicator to be embedded into the seed value dictionary
|
|
attached to a signature field.
|
|
|
|
:param mdp_perm:
|
|
If not ``None``, indicates that the signature field is intended for
|
|
a certification signature. The :class:`MDPPerm` value passed as the
|
|
``mdp_perm`` parameter indicates the modification policy that the
|
|
certification signature should use.
|
|
|
|
A value of ``None`` indicates that the signature field is intended for
|
|
an approval signature (i.e. a non-certification signature).
|
|
"""
|
|
|
|
def __init__(self, mdp_perm: Optional[MDPPerm] = None):
|
|
self.mdp_perm = mdp_perm
|
|
|
|
def __eq__(self, other):
|
|
return (
|
|
isinstance(other, SeedSignatureType)
|
|
and other.mdp_perm == self.mdp_perm
|
|
)
|
|
|
|
def certification_signature(self) -> bool:
|
|
return self.mdp_perm is not None
|
|
|
|
|
|
class SigSeedValFlags(Flag):
|
|
"""
|
|
Flags for the ``/Ff`` entry in the seed value dictionary for a signature
|
|
field. These mark which of the constraints are to be strictly enforced,
|
|
as opposed to optional ones.
|
|
|
|
.. warning::
|
|
The flags :attr:`LEGAL_ATTESTATION` and :attr:`APPEARANCE_FILTER` are
|
|
processed in accordance with the specification when creating a
|
|
signature, but support is nevertheless limited.
|
|
|
|
* PyHanko does not support legal attestations at all, so given that
|
|
the :attr:`LEGAL_ATTESTATION` requirement flag only restricts the
|
|
legal attestations that can be used by the signer, pyHanko can safely
|
|
ignore it when signing.
|
|
|
|
On the other hand, since the validator is not aware of
|
|
legal attestations either, it cannot validate signatures that
|
|
make :attr:`~.SigSeedValueSpec.legal_attestations` a mandatory
|
|
constraint.
|
|
* Since pyHanko does not define any named appearances, setting
|
|
the :attr:`APPEARANCE_FILTER` flag and the
|
|
:attr:`~.SigSeedValueSpec.appearance` entry in the seed value
|
|
dictionary will make pyHanko refuse to sign the document.
|
|
|
|
When validating, the situation is different: since pyHanko has no
|
|
way of knowing whether the signer used the named appearance imposed
|
|
by the seed value dictionary, it will simply emit a warning and
|
|
continue validating the signature.
|
|
"""
|
|
|
|
FILTER = 1
|
|
"""
|
|
Makes the signature handler setting mandatory. PyHanko only supports
|
|
``/Adobe.PPKLite``.
|
|
"""
|
|
|
|
SUBFILTER = 2
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.subfilters`.
|
|
"""
|
|
|
|
V = 4
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.sv_dict_version`.
|
|
"""
|
|
|
|
REASONS = 8
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.reasons`.
|
|
"""
|
|
|
|
LEGAL_ATTESTATION = 16
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.legal_attestations`.
|
|
"""
|
|
|
|
ADD_REV_INFO = 32
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.add_rev_info`.
|
|
"""
|
|
|
|
DIGEST_METHOD = 64
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.digest_method`.
|
|
"""
|
|
|
|
LOCK_DOCUMENT = 128
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.lock_document`.
|
|
"""
|
|
|
|
APPEARANCE_FILTER = 256
|
|
"""
|
|
See :attr:`~.SigSeedValueSpec.appearance`.
|
|
"""
|
|
|
|
|
|
class SigCertConstraintFlags(Flag):
|
|
"""
|
|
Flags for the ``/Ff`` entry in the certificate seed value dictionary for
|
|
a dictionary field. These mark which of the constraints are to be
|
|
strictly enforced, as opposed to optional ones.
|
|
|
|
.. warning::
|
|
While this enum records values for all flags, not all corresponding
|
|
constraint types have been implemented yet.
|
|
"""
|
|
|
|
SUBJECT = 1
|
|
"""
|
|
See :attr:`SigCertConstraints.subjects`.
|
|
"""
|
|
|
|
ISSUER = 2
|
|
"""
|
|
See :attr:`SigCertConstraints.issuers`.
|
|
"""
|
|
|
|
OID = 4
|
|
"""
|
|
Currently not supported.
|
|
"""
|
|
|
|
SUBJECT_DN = 8
|
|
"""
|
|
See :attr:`SigCertConstraints.subject_dn`.
|
|
"""
|
|
|
|
RESERVED = 16
|
|
"""
|
|
Currently not supported (reserved).
|
|
"""
|
|
|
|
KEY_USAGE = 32
|
|
"""
|
|
See :attr:`SigCertConstraints.key_usage`.
|
|
"""
|
|
|
|
URL = 64
|
|
"""
|
|
See :attr:`SigCertConstraints.info_url`.
|
|
|
|
.. note::
|
|
As specified in the standard, this enforcement bit is supposed to be
|
|
ignored by default. We include it for compatibility reasons.
|
|
"""
|
|
|
|
UNSUPPORTED = RESERVED | OID
|
|
"""
|
|
Flags for which the corresponding constraint is unsupported.
|
|
"""
|
|
|
|
|
|
class SigCertKeyUsage:
|
|
"""
|
|
Encodes the key usage bits that must (resp. must not) be active on the
|
|
signer's certificate.
|
|
|
|
.. note::
|
|
See § 4.2.1.3 in :rfc:`5280` and :class:`.KeyUsage` for more
|
|
information on key usage extensions.
|
|
|
|
.. note::
|
|
The human-readable names of the key usage extensions are recorded
|
|
in ``camelCase`` in :rfc:`5280`, but this class uses
|
|
the naming convention of :class:`.KeyUsage` in ``asn1crypto``.
|
|
The conversion is done by replacing ``camelCase`` with ``snake_case``.
|
|
For example, ``nonRepudiation`` becomes ``non_repudiation``, and
|
|
``digitalSignature`` turns into ``digital_signature``.
|
|
|
|
.. note::
|
|
This class is intended to closely replicate the definition of the
|
|
KeyUsage entry Table 235 in ISO 32000-1.
|
|
In particular, it does *not* provide a mechanism to deal
|
|
with extended key usage extensions (cf. § 4.2.1.12 in :rfc:`5280`).
|
|
|
|
:param must_have:
|
|
The :class:`.KeyUsage` object encoding the key usage extensions
|
|
that must be present on the signer's certificate.
|
|
:param forbidden:
|
|
The :class:`.KeyUsage` object encoding the key usage extensions
|
|
that must *not* be present on the signer's certificate.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
must_have: Optional[KeyUsage] = None,
|
|
forbidden: Optional[KeyUsage] = None,
|
|
):
|
|
self.must_have = must_have if must_have is not None else KeyUsage(set())
|
|
self.forbidden = forbidden if forbidden is not None else KeyUsage(set())
|
|
|
|
def encode_to_sv_string(self):
|
|
"""
|
|
Encode the key usage requirements in the format specified in the PDF
|
|
specification.
|
|
|
|
:return:
|
|
A string.
|
|
"""
|
|
|
|
def fmt_bit(bit: int):
|
|
if self.must_have[bit]:
|
|
return '1'
|
|
elif self.forbidden[bit]:
|
|
return '0'
|
|
else:
|
|
return 'X'
|
|
|
|
return ''.join(fmt_bit(bit) for bit in range(9))
|
|
|
|
@classmethod
|
|
def read_from_sv_string(cls, ku_str):
|
|
"""
|
|
Parse a PDF KeyUsage string into an instance of
|
|
:class:`.SigCertKeyUsage`. See Table 235 in ISO 32000-1.
|
|
|
|
:param ku_str:
|
|
A PDF KeyUsage string.
|
|
:return:
|
|
An instance of :class:`.SigCertKeyUsage`.
|
|
"""
|
|
ku_str = ku_str[:9]
|
|
|
|
def _as_tuple(with_val):
|
|
return tuple(1 if val == with_val else 0 for val in ku_str)
|
|
|
|
return SigCertKeyUsage(
|
|
must_have=KeyUsage(_as_tuple('1')),
|
|
forbidden=KeyUsage(_as_tuple('0')),
|
|
)
|
|
|
|
@classmethod
|
|
def from_sets(
|
|
cls,
|
|
must_have: Optional[Set[str]] = None,
|
|
forbidden: Optional[Set[str]] = None,
|
|
):
|
|
"""
|
|
Initialise a :class:`.SigCertKeyUsage` object from two sets.
|
|
|
|
:param must_have:
|
|
The key usage extensions that must be present on the signer's
|
|
certificate.
|
|
:param forbidden:
|
|
The key usage extensions that must *not* be present on the signer's
|
|
certificate.
|
|
:return:
|
|
A :class:`.SigCertKeyUsage` object encoding these.
|
|
"""
|
|
return SigCertKeyUsage(
|
|
must_have=KeyUsage(set() if must_have is None else must_have),
|
|
forbidden=KeyUsage(set() if forbidden is None else forbidden),
|
|
)
|
|
|
|
def must_have_set(self) -> Set[str]:
|
|
"""
|
|
Return the set of key usage extensions that must be present
|
|
on the signer's certificate.
|
|
"""
|
|
return self.must_have.native
|
|
|
|
def forbidden_set(self) -> Set[str]:
|
|
"""
|
|
Return the set of key usage extensions that must not be present
|
|
on the signer's certificate.
|
|
"""
|
|
return self.forbidden.native
|
|
|
|
def __eq__(self, other):
|
|
return (
|
|
isinstance(other, SigCertKeyUsage)
|
|
and self.must_have_set() == other.must_have_set()
|
|
and self.forbidden_set() == other.forbidden_set()
|
|
)
|
|
|
|
|
|
name_type_abbrevs = {
|
|
'2.5.4.3': 'CN',
|
|
'2.5.4.5': 'SerialNumber',
|
|
'2.5.4.6': 'C',
|
|
'2.5.4.7': 'L',
|
|
'2.5.4.8': 'ST',
|
|
'2.5.4.10': 'O',
|
|
'2.5.4.11': 'OU',
|
|
}
|
|
|
|
name_type_abbrevs_rev = {v: k for k, v in name_type_abbrevs.items()}
|
|
|
|
|
|
def x509_name_keyval_pairs(name: x509.Name, abbreviate_oids=False):
|
|
rdns: x509.RDNSequence = name.chosen
|
|
for rdn in rdns:
|
|
for type_and_value in rdn:
|
|
oid: x509.NameType = type_and_value['type']
|
|
# these are all some kind of string, and the PDF
|
|
# standard says that the value should be a text string object,
|
|
# so we just have asn1crypto convert everything to strings
|
|
value = type_and_value['value']
|
|
key = oid.dotted
|
|
if abbreviate_oids:
|
|
key = name_type_abbrevs.get(key, key)
|
|
|
|
yield key, value.native
|
|
# these should be strings
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
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: Optional[List[x509.Certificate]] = None
|
|
"""
|
|
Explicit list of certificates that can be used to sign a signature field.
|
|
"""
|
|
|
|
subject_dn: Optional[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: Optional[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: Optional[str] = None
|
|
"""
|
|
Informational URL that should be opened when an appropriate certificate
|
|
cannot be found (if :attr:`url_type` is ``/Browser``, that is).
|
|
|
|
.. note::
|
|
PyHanko ignores this value, but we include it for compatibility.
|
|
"""
|
|
|
|
url_type: generic.NameObject = pdf_name('/Browser')
|
|
"""
|
|
Handler that should be used to open :attr:`info_url`.
|
|
``/Browser`` is the only implementation-independent value.
|
|
"""
|
|
|
|
key_usage: Optional[List[SigCertKeyUsage]] = None
|
|
"""
|
|
Specify the key usage extensions that should (or should not) be present
|
|
on the signer's certificate.
|
|
"""
|
|
|
|
# TODO support OID constraints (certificate policies) and signature policy
|
|
# 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 PdfReadError('Object /Type entry is not /SVCert')
|
|
except KeyError: # pragma: nocover
|
|
pass
|
|
flags = SigCertConstraintFlags(pdf_dict.get('/Ff', 0))
|
|
subjects = [
|
|
x509.Certificate.load(cert.original_bytes)
|
|
for cert in pdf_dict.get('/Subject', ())
|
|
]
|
|
issuers = [
|
|
x509.Certificate.load(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()
|
|
}
|
|
)
|
|
|
|
def parse_key_usage(val):
|
|
return [SigCertKeyUsage.read_from_sv_string(ku) for ku in val]
|
|
|
|
key_usage = get_and_apply(pdf_dict, '/KeyUsage', parse_key_usage)
|
|
|
|
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,
|
|
'key_usage': key_usage,
|
|
}
|
|
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
|
|
|
|
if self.key_usage is not None:
|
|
result[pdf_name('/KeyUsage')] = generic.ArrayObject(
|
|
pdf_string(ku.encode_to_sv_string()) for ku in self.key_usage
|
|
)
|
|
|
|
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.UNSUPPORTED:
|
|
raise NotImplementedError(
|
|
"Certificate constraint flags include mandatory constraints "
|
|
"that are not supported."
|
|
)
|
|
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 = {
|
|
authority.certificate.issuer_serial
|
|
for authority in validation_path.iter_authorities()
|
|
if isinstance(authority, AuthorityWithCert)
|
|
}
|
|
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
|
|
)
|
|
if (
|
|
flags & SigCertConstraintFlags.KEY_USAGE
|
|
) and self.key_usage is not None:
|
|
from .validation.settings import KeyUsageConstraints
|
|
|
|
for ku in self.key_usage:
|
|
try:
|
|
KeyUsageConstraints(
|
|
key_usage=ku.must_have_set(),
|
|
key_usage_forbidden=ku.forbidden_set(),
|
|
# This is the way ISO 32k does things
|
|
match_all_key_usages=True,
|
|
).validate(signer)
|
|
break
|
|
except InvalidCertificateError:
|
|
continue
|
|
else:
|
|
raise UnacceptableSignerError(
|
|
"The signer satisfies none of the key usage "
|
|
"extension profiles specified in the seed value dictionary."
|
|
)
|
|
|
|
|
|
@unique
|
|
class SigSeedSubFilter(Enum):
|
|
"""
|
|
Enum declaring all supported ``/SubFilter`` values.
|
|
"""
|
|
|
|
ADOBE_PKCS7_DETACHED = pdf_name("/adbe.pkcs7.detached")
|
|
PADES = pdf_name("/ETSI.CAdES.detached")
|
|
ETSI_RFC3161 = pdf_name("/ETSI.RFC3161")
|
|
|
|
|
|
@unique
|
|
class SigAuthType(Enum):
|
|
"""
|
|
Enum declaring all supported ``/Prop_AuthType`` values.
|
|
"""
|
|
|
|
PIN = pdf_string("PIN")
|
|
PASSWORD = pdf_string("Password")
|
|
FINGERPRINT = pdf_string("Fingerprint")
|
|
|
|
|
|
@unique
|
|
class SeedValueDictVersion(OrderedEnum):
|
|
"""
|
|
Specify the minimal compliance level for a seed value dictionary processor.
|
|
"""
|
|
|
|
PDF_1_5 = 1
|
|
"""
|
|
Require the reader to understand all keys defined in PDF 1.5.
|
|
"""
|
|
|
|
PDF_1_7 = 2
|
|
"""
|
|
Require the reader to understand all keys defined in PDF 1.7.
|
|
"""
|
|
|
|
PDF_2_0 = 3
|
|
"""
|
|
Require the reader to understand all keys defined in PDF 2.0.
|
|
"""
|
|
|
|
|
|
@unique
|
|
class SeedLockDocument(Enum):
|
|
"""
|
|
Provides a recommendation to the signer as to whether the document should
|
|
be locked after signing.
|
|
The corresponding flag in :attr:`.SigSeedValueSpec.flags` determines whether
|
|
this constraint is a required constraint.
|
|
"""
|
|
|
|
LOCK = pdf_name('/true')
|
|
"""
|
|
Lock the document after signing.
|
|
"""
|
|
|
|
DO_NOT_LOCK = pdf_name('/false')
|
|
"""
|
|
Lock the document after signing.
|
|
"""
|
|
|
|
SIGNER_DISCRETION = pdf_name('/auto')
|
|
"""
|
|
Leave the decision up to the signer.
|
|
|
|
.. note::
|
|
This is functionally equivalent to not specifying any value.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SigSeedValueSpec:
|
|
"""
|
|
Python representation of a PDF seed value dictionary.
|
|
"""
|
|
|
|
flags: SigSeedValFlags = SigSeedValFlags(0)
|
|
"""
|
|
Enforcement flags. By default, all entries are optional.
|
|
"""
|
|
|
|
reasons: Optional[List[str]] = None
|
|
"""
|
|
Acceptable reasons for signing.
|
|
"""
|
|
|
|
timestamp_server_url: Optional[str] = None
|
|
"""
|
|
RFC 3161 timestamp server endpoint suggestion.
|
|
"""
|
|
|
|
timestamp_required: bool = False
|
|
"""
|
|
Flags whether a timestamp is required.
|
|
This flag is only meaningful if :attr:`timestamp_server_url` is specified.
|
|
"""
|
|
|
|
cert: Optional[SigCertConstraints] = None
|
|
"""
|
|
Constraints on the signer's certificate.
|
|
"""
|
|
|
|
subfilters: Optional[List[SigSeedSubFilter]] = None
|
|
"""
|
|
Acceptable ``/SubFilter`` values.
|
|
"""
|
|
|
|
digest_methods: Optional[List[str]] = None
|
|
"""
|
|
Acceptable digest methods.
|
|
"""
|
|
|
|
add_rev_info: Optional[bool] = None
|
|
"""
|
|
Indicates whether revocation information should be embedded.
|
|
|
|
.. warning::
|
|
This flag exclusively refers to the Adobe-style revocation information
|
|
embedded within the CMS object that is written to the signature field.
|
|
PAdES-style revocation information that is saved to the document
|
|
security store (DSS) does *not* satisfy the requirement.
|
|
Additionally, the standard mandates that ``/SubFilter`` be equal to
|
|
``/adbe.pkcs7.detached`` if this flag is ``True``.
|
|
"""
|
|
|
|
seed_signature_type: Optional[SeedSignatureType] = None
|
|
"""
|
|
Specifies the type of signature that should occupy a signature field;
|
|
this represents the ``/MDP`` entry in the seed value dictionary.
|
|
See :class:`.SeedSignatureType` for details.
|
|
|
|
.. caution::
|
|
Since a certification-type signature is by definition the first
|
|
signature applied to a document, compliance with this requirement
|
|
cannot be cryptographically enforced.
|
|
"""
|
|
|
|
sv_dict_version: Union[SeedValueDictVersion, int, None] = None
|
|
"""
|
|
Specifies the compliance level required of a seed value dictionary
|
|
processor. If ``None``, pyHanko will compute an appropriate value.
|
|
|
|
.. note::
|
|
You may also specify this value directly as an integer.
|
|
This covers potential future versions of the standard that pyHanko
|
|
does not support out of the box.
|
|
"""
|
|
|
|
legal_attestations: Optional[List[str]] = None
|
|
"""
|
|
Specifies the possible legal attestations that a certification signature
|
|
occupying this signature field can supply.
|
|
The corresponding flag in :attr:`flags` indicates whether this is a
|
|
mandatory constraint.
|
|
|
|
.. caution::
|
|
Since :attr:`legal_attestations` is only relevant for certification
|
|
signatures, compliance with this requirement cannot be reliably
|
|
enforced.
|
|
Regardless, since pyHanko's validator is also unaware of legal
|
|
attestation settings, it will refuse to validate signatures
|
|
where this seed value constitutes a mandatory constraint.
|
|
|
|
Additionally, since pyHanko does not support legal attestation
|
|
specifications at all, it vacuously satisfies the requirements of this
|
|
entry no matter what, and will therefore ignore it when signing.
|
|
"""
|
|
|
|
lock_document: Optional[SeedLockDocument] = None
|
|
"""
|
|
Tell the signer whether or not the document should be locked after signing
|
|
this field; see :class:`.SeedLockDocument` for details.
|
|
|
|
The corresponding flag in :attr:`flags` indicates whether this constraint
|
|
is mandatory.
|
|
"""
|
|
|
|
# TODO handle this value by reading named appearances from the user's
|
|
# settings
|
|
|
|
appearance: Optional[str] = None
|
|
"""
|
|
Specify a named appearance to use when generating the signature.
|
|
The corresponding flag in :attr:`flags` indicates whether this constraint
|
|
is mandatory.
|
|
|
|
.. caution::
|
|
There is no standard registry of named appearances, so these constraints
|
|
are not portable, and cannot be validated.
|
|
|
|
PyHanko currently does not define any named appearances.
|
|
"""
|
|
|
|
def as_pdf_object(self):
|
|
"""
|
|
Render this :class:`.SigSeedValueSpec` object to a PDF dictionary.
|
|
|
|
:return:
|
|
A :class:`~.generic.DictionaryObject`.
|
|
"""
|
|
min_version = SeedValueDictVersion.PDF_1_5
|
|
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:
|
|
min_version = SeedValueDictVersion.PDF_1_7
|
|
result[pdf_name('/AddRevInfo')] = generic.BooleanObject(
|
|
self.add_rev_info
|
|
)
|
|
if self.digest_methods is not None:
|
|
min_version = SeedValueDictVersion.PDF_1_7
|
|
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:
|
|
min_version = SeedValueDictVersion.PDF_1_7
|
|
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()
|
|
if self.seed_signature_type is not None:
|
|
mdp_perm = self.seed_signature_type.mdp_perm
|
|
result[pdf_name('/MDP')] = generic.DictionaryObject(
|
|
{
|
|
pdf_name('/P'): generic.NumberObject(
|
|
mdp_perm.value if mdp_perm is not None else 0
|
|
)
|
|
}
|
|
)
|
|
if self.legal_attestations is not None:
|
|
result[pdf_name('/LegalAttestation')] = generic.ArrayObject(
|
|
pdf_string(att) for att in self.legal_attestations
|
|
)
|
|
if self.lock_document is not None:
|
|
min_version = SeedValueDictVersion.PDF_2_0
|
|
result[pdf_name('/LockDocument')] = self.lock_document.value
|
|
if self.appearance is not None:
|
|
result[pdf_name('/AppearanceFilter')] = pdf_string(self.appearance)
|
|
|
|
specified_version = self.sv_dict_version
|
|
if specified_version is not None:
|
|
result[pdf_name('/V')] = generic.NumberObject(
|
|
specified_version.value
|
|
if isinstance(specified_version, SeedValueDictVersion)
|
|
else specified_version
|
|
)
|
|
else:
|
|
result[pdf_name('/V')] = generic.NumberObject(min_version.value)
|
|
return result
|
|
|
|
@classmethod
|
|
def from_pdf_object(cls, pdf_dict):
|
|
"""
|
|
Read from a seed value dictionary.
|
|
|
|
:param pdf_dict:
|
|
A :class:`~.generic.DictionaryObject`.
|
|
:return:
|
|
A :class:`.SigSeedValueSpec` object.
|
|
"""
|
|
if isinstance(pdf_dict, generic.IndirectObject):
|
|
pdf_dict = pdf_dict.get_object()
|
|
try:
|
|
if pdf_dict['/Type'] != '/SV': # pragma: nocover
|
|
raise PdfReadError('Object /Type entry is not /SV')
|
|
except KeyError: # pragma: nocover
|
|
pass
|
|
|
|
flags = SigSeedValFlags(pdf_dict.get('/Ff', 0))
|
|
try:
|
|
sig_filter = pdf_dict['/Filter']
|
|
if (flags & SigSeedValFlags.FILTER) and (
|
|
sig_filter != '/Adobe.PPKLite'
|
|
):
|
|
raise SigningError(
|
|
"Signature handler '%s' is not available, only the "
|
|
"default /Adobe.PPKLite is supported." % sig_filter
|
|
)
|
|
except KeyError:
|
|
pass
|
|
|
|
try:
|
|
min_version = pdf_dict['/V']
|
|
supported = SeedValueDictVersion.PDF_2_0.value
|
|
if flags & SigSeedValFlags.V and min_version > supported:
|
|
raise SigningError(
|
|
"Seed value dictionary version %s not supported."
|
|
% min_version
|
|
)
|
|
min_version = SeedValueDictVersion(min_version)
|
|
except KeyError:
|
|
min_version = None
|
|
|
|
try:
|
|
add_rev_info = bool(pdf_dict['/AddRevInfo'])
|
|
except KeyError:
|
|
add_rev_info = None
|
|
|
|
subfilter_reqs = pdf_dict.get('/SubFilter', None)
|
|
subfilters = None
|
|
if subfilter_reqs is not None:
|
|
|
|
def _subfilters():
|
|
for s in subfilter_reqs:
|
|
try:
|
|
yield SigSeedSubFilter(s)
|
|
except ValueError:
|
|
pass
|
|
|
|
subfilters = list(_subfilters())
|
|
|
|
try:
|
|
digest_methods = [s.lower() for s in pdf_dict['/DigestMethod']]
|
|
except KeyError:
|
|
digest_methods = None
|
|
|
|
reasons = get_and_apply(pdf_dict, '/Reasons', list)
|
|
legal_attestations = get_and_apply(pdf_dict, '/LegalAttestation', list)
|
|
|
|
def read_mdp_dict(mdp):
|
|
try:
|
|
val = mdp['/P']
|
|
return SeedSignatureType(None if val == 0 else MDPPerm(val))
|
|
except (KeyError, TypeError, ValueError):
|
|
raise SigningError(
|
|
f"/MDP entry {mdp} in seed value dictionary is not "
|
|
"correctly formatted."
|
|
)
|
|
|
|
signature_type = get_and_apply(pdf_dict, '/MDP', read_mdp_dict)
|
|
|
|
def read_lock_document(val):
|
|
try:
|
|
return SeedLockDocument(val)
|
|
except ValueError:
|
|
raise SigningError(f"/LockDocument entry '{val}' is invalid.")
|
|
|
|
lock_document = get_and_apply(
|
|
pdf_dict, '/LockDocument', read_lock_document
|
|
)
|
|
appearance_filter = pdf_dict.get('/AppearanceFilter', None)
|
|
timestamp_dict = pdf_dict.get('/TimeStamp', {})
|
|
timestamp_server_url = timestamp_dict.get('/URL', None)
|
|
timestamp_required = bool(timestamp_dict.get('/Ff', 0))
|
|
cert_constraints = pdf_dict.get('/Cert', None)
|
|
if cert_constraints is not None:
|
|
cert_constraints = SigCertConstraints.from_pdf_object(
|
|
cert_constraints
|
|
)
|
|
return cls(
|
|
flags=flags,
|
|
reasons=reasons,
|
|
timestamp_server_url=timestamp_server_url,
|
|
cert=cert_constraints,
|
|
subfilters=subfilters,
|
|
digest_methods=digest_methods,
|
|
add_rev_info=add_rev_info,
|
|
timestamp_required=timestamp_required,
|
|
legal_attestations=legal_attestations,
|
|
seed_signature_type=signature_type,
|
|
sv_dict_version=min_version,
|
|
lock_document=lock_document,
|
|
appearance=appearance_filter,
|
|
)
|
|
|
|
def build_timestamper(self):
|
|
"""
|
|
Return a timestamper object based on the :attr:`timestamp_server_url`
|
|
attribute of this :class:`.SigSeedValueSpec` object.
|
|
|
|
:return:
|
|
A :class:`~.pyhanko.sign.timestamps.HTTPTimeStamper`.
|
|
"""
|
|
from pyhanko.sign.timestamps import HTTPTimeStamper
|
|
|
|
if self.timestamp_server_url:
|
|
return HTTPTimeStamper(self.timestamp_server_url)
|
|
|
|
|
|
class FieldMDPAction(Enum):
|
|
"""
|
|
Marker for the scope of a ``/FieldMDP`` policy.
|
|
"""
|
|
|
|
ALL = pdf_name('/All')
|
|
"""
|
|
The policy locks all form fields.
|
|
"""
|
|
|
|
INCLUDE = pdf_name('/Include')
|
|
"""
|
|
The policy locks all fields in the list (see :attr:`.FieldMDPSpec.fields`).
|
|
"""
|
|
|
|
EXCLUDE = pdf_name('/Exclude')
|
|
"""
|
|
The policy locks all fields except those specified in the list
|
|
(see :attr:`.FieldMDPSpec.fields`).
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FieldMDPSpec:
|
|
"""``/FieldMDP`` policy description.
|
|
|
|
This class models both field lock dictionaries and ``/FieldMDP``
|
|
transformation parameters.
|
|
"""
|
|
|
|
action: FieldMDPAction
|
|
"""
|
|
Indicates the scope of the policy.
|
|
"""
|
|
|
|
fields: Optional[List[str]] = None
|
|
"""
|
|
Indicates the fields subject to the policy,
|
|
unless :attr:`action` is :attr:`.FieldMDPAction.ALL`.
|
|
"""
|
|
|
|
def as_pdf_object(self) -> generic.DictionaryObject:
|
|
"""
|
|
Render this ``/FieldMDP`` policy description as a PDF dictionary.
|
|
|
|
:return:
|
|
A :class:`~.generic.DictionaryObject`.
|
|
"""
|
|
result = generic.DictionaryObject(
|
|
{
|
|
pdf_name('/Action'): self.action.value,
|
|
}
|
|
)
|
|
if self.action != FieldMDPAction.ALL:
|
|
result['/Fields'] = generic.ArrayObject(
|
|
map(pdf_string, self.fields or ())
|
|
)
|
|
return result
|
|
|
|
def as_transform_params(self) -> generic.DictionaryObject:
|
|
"""
|
|
Render this ``/FieldMDP`` policy description as a PDF dictionary,
|
|
ready for inclusion into the ``/TransformParams`` entry of a
|
|
``/FieldMDP`` dictionary associated with a signature object.
|
|
|
|
:return:
|
|
A :class:`~.generic.DictionaryObject`.
|
|
"""
|
|
|
|
result = self.as_pdf_object()
|
|
result['/Type'] = pdf_name('/TransformParams')
|
|
result['/V'] = pdf_name('/1.2')
|
|
return result
|
|
|
|
def as_sig_field_lock(self) -> generic.DictionaryObject:
|
|
"""
|
|
Render this ``/FieldMDP`` policy description as a PDF dictionary,
|
|
ready for inclusion into the ``/Lock`` dictionary of a signature field.
|
|
|
|
:return:
|
|
A :class:`~.generic.DictionaryObject`.
|
|
"""
|
|
|
|
result = self.as_pdf_object()
|
|
result['/Type'] = pdf_name('/SigFieldLock')
|
|
return result
|
|
|
|
@classmethod
|
|
def from_pdf_object(cls, pdf_dict) -> 'FieldMDPSpec':
|
|
"""
|
|
Read a PDF dictionary into a :class:`.FieldMDPSpec` object.
|
|
|
|
:param pdf_dict:
|
|
A :class:`~.generic.DictionaryObject`.
|
|
:return:
|
|
A :class:`.FieldMDPSpec` object.
|
|
"""
|
|
try:
|
|
action = FieldMDPAction(pdf_dict['/Action'])
|
|
except KeyError: # pragma: nocover
|
|
raise PdfReadError("/Action is required.")
|
|
|
|
if action != FieldMDPAction.ALL:
|
|
try:
|
|
fields = pdf_dict['/Fields']
|
|
except KeyError: # pragma: nocover
|
|
raise PdfReadError(
|
|
"/Fields is required when /Action is not /All"
|
|
)
|
|
else:
|
|
fields = None
|
|
return cls(action=action, fields=fields)
|
|
|
|
def is_locked(self, field_name: str) -> bool:
|
|
"""
|
|
Adjudicate whether a field should be locked by the policy described by
|
|
this :class:`.FieldMDPSpec` object.
|
|
|
|
:param field_name:
|
|
The name of a form field.
|
|
:return:
|
|
``True`` if the field should be locked, ``False`` otherwise.
|
|
"""
|
|
if self.action == FieldMDPAction.ALL:
|
|
return True
|
|
|
|
lock_result = self.action == FieldMDPAction.INCLUDE
|
|
for scoped_field_name in self.fields or ():
|
|
# treat non-terminal field in/exclusions as including the whole
|
|
# tree beneath them
|
|
if field_name.startswith(scoped_field_name):
|
|
return lock_result
|
|
return not lock_result
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InvisSigSettings:
|
|
"""
|
|
Invisible signature widget generation settings.
|
|
|
|
These settings exist because there is no real way of including an untagged
|
|
invisible signature in a document that complies with the requirements
|
|
of both PDF/A-2 (or -3) and PDF/UA-1.
|
|
|
|
Compatibility with PDF/A (the default) requires the print flag to be set.
|
|
Compatibility with PDF/UA requires the hidden flag to be set (which is
|
|
banned in PDF/A) or the box to be outside the crop box.
|
|
"""
|
|
|
|
set_print_flag: bool = True
|
|
"""
|
|
Set the print flag. Required in PDF/A.
|
|
"""
|
|
|
|
set_hidden_flag: bool = False
|
|
"""
|
|
Set the hidden flag. Required in PDF/UA.
|
|
"""
|
|
|
|
box_out_of_bounds: bool = False
|
|
"""
|
|
Put the box out of bounds (technically, this just makes the box
|
|
zero-sized with large negative coordinates).
|
|
|
|
This is a hack to get around the fact that PDF/UA requires the hidden
|
|
flag to be set on all in-bounds untagged annotations, and some validators
|
|
consider [0, 0, 0, 0] to be an in-bounds rectangle if (0, 0) is a point
|
|
that falls within the crop box.
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VisibleSigSettings:
|
|
"""
|
|
.. versionadded:: 0.14.0
|
|
|
|
Additional flags used when setting up visible signature widgets.
|
|
"""
|
|
|
|
rotate_with_page: bool = True
|
|
"""
|
|
Allow the signature widget to rotate with the page if rotation is applied
|
|
(e.g. by way of the page's ``/Rotate`` entry). Default is ``True``.
|
|
|
|
.. note::
|
|
If ``False``, this will cause the ``NoRotate`` flag to be set.
|
|
"""
|
|
|
|
scale_with_page_zoom: bool = True
|
|
"""
|
|
Allow the signature widget to scale with the page's zoom level.
|
|
Default is ``True``.
|
|
|
|
.. note::
|
|
If ``False``, this will cause the ``NoZoom`` flag to be set.
|
|
"""
|
|
|
|
print_signature: bool = True
|
|
"""
|
|
Render the signature when the document is printed. Default ``True``.
|
|
"""
|
|
|
|
|
|
# TODO deal with fully qualified field names for the signature field
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SigFieldSpec:
|
|
"""Description of a signature field to be created."""
|
|
|
|
sig_field_name: str
|
|
"""
|
|
Name of the signature field.
|
|
"""
|
|
|
|
on_page: int = 0
|
|
"""
|
|
Index of the page on which the signature field should be included (starting
|
|
at `0`).
|
|
A negative number counts pages from the back of the document,
|
|
with index ``-1`` referring to the last page.
|
|
|
|
.. note::
|
|
This is essentially only relevant for visible signature fields, i.e.
|
|
those that have a widget associated with them.
|
|
"""
|
|
|
|
box: Optional[Tuple[int, int, int, int]] = None
|
|
"""
|
|
Bounding box of the signature field, if applicable.
|
|
|
|
Typically specified in ``ll_x``, ``ll_y``, ``ur_x``, ``ur_y`` format,
|
|
where ``ll_*`` refers to the lower left and ``ur_*`` to the upper right
|
|
corner.
|
|
"""
|
|
|
|
seed_value_dict: Optional[SigSeedValueSpec] = None
|
|
"""
|
|
Specification for the seed value dictionary, if applicable.
|
|
"""
|
|
|
|
field_mdp_spec: Optional[FieldMDPSpec] = None
|
|
"""
|
|
Specification for the field lock dictionary, if applicable.
|
|
"""
|
|
|
|
doc_mdp_update_value: Optional[MDPPerm] = None
|
|
"""
|
|
Value to use for the document modification policy associated with the
|
|
signature in this field.
|
|
|
|
This value will be embedded into the field lock dictionary if specified, and
|
|
is meaningless if :attr:`field_mdp_spec` is not specified.
|
|
|
|
.. warning::
|
|
DocMDP entries for approval signatures are a PDF 2.0 feature.
|
|
Older PDF software will likely ignore this part of the field lock
|
|
dictionary.
|
|
"""
|
|
# TODO add a reference to the docs on certification once those are written.
|
|
|
|
combine_annotation: bool = True
|
|
"""
|
|
Flag controlling whether the field should be combined with its
|
|
annotation dictionary; ``True`` by default.
|
|
"""
|
|
|
|
empty_field_appearance: bool = False
|
|
"""
|
|
Generate a neutral appearance stream for empty, visible signature fields.
|
|
If ``False``, an empty appearance stream will be put in.
|
|
|
|
.. note::
|
|
We use an empty appearance stream to satisfy the appearance requirements
|
|
for widget annotations in ISO 32000-2. However, even when a nontrivial
|
|
appearance stream is present on an empty signature field, many viewers
|
|
will not use it to render the appearance of the empty field on-screen.
|
|
|
|
Instead, these viewers typically substitute their own native widget.
|
|
"""
|
|
|
|
invis_sig_settings: InvisSigSettings = InvisSigSettings()
|
|
"""
|
|
Advanced settings to control invisible signature field generation.
|
|
"""
|
|
|
|
readable_field_name: Optional[str] = None
|
|
"""
|
|
Human-readable field name (``/TU`` entry).
|
|
|
|
.. note::
|
|
This value is commonly rendered as a tooltip in viewers, but also
|
|
serves an accessibility purpose.
|
|
"""
|
|
|
|
visible_sig_settings: VisibleSigSettings = VisibleSigSettings()
|
|
"""
|
|
Advanced settings to control the generation of visible signature fields.
|
|
"""
|
|
|
|
def format_lock_dictionary(self) -> Optional[generic.DictionaryObject]:
|
|
if self.field_mdp_spec is None:
|
|
return None
|
|
result = self.field_mdp_spec.as_sig_field_lock()
|
|
# this requires PDF 2.0 in principle, but meh, noncompliant
|
|
# readers will ignore it anyway
|
|
if self.doc_mdp_update_value is not None:
|
|
result['/P'] = generic.NumberObject(self.doc_mdp_update_value.value)
|
|
return result
|
|
|
|
|
|
def _insert_or_get_field_at(
|
|
writer: BasePdfFileWriter,
|
|
fields,
|
|
path,
|
|
parent_ref=None,
|
|
modified=False,
|
|
field_obj=None,
|
|
):
|
|
current_partial, tail = path[0], path[1:]
|
|
|
|
for field_ref in fields:
|
|
assert isinstance(field_ref, generic.IndirectObject)
|
|
field = field_ref.get_object()
|
|
if field.get('/T', None) == current_partial:
|
|
break
|
|
else:
|
|
# have to insert a new element into the fields array
|
|
if field_obj is not None and not tail:
|
|
field = field_obj
|
|
else:
|
|
# create a generic field
|
|
field = generic.DictionaryObject()
|
|
field['/T'] = pdf_string(current_partial)
|
|
if parent_ref is not None:
|
|
field['/Parent'] = parent_ref
|
|
field_ref = writer.add_object(field)
|
|
fields.append(field_ref)
|
|
writer.update_container(fields)
|
|
modified = True
|
|
|
|
if not tail:
|
|
return modified, field_ref
|
|
# check for /Kids, and create it if necessary
|
|
try:
|
|
kids = field['/Kids']
|
|
except KeyError:
|
|
kids = field['/Kids'] = generic.ArrayObject()
|
|
writer.update_container(field)
|
|
modified = True
|
|
|
|
# recurse in to /Kids array
|
|
return _insert_or_get_field_at(
|
|
writer,
|
|
kids,
|
|
tail,
|
|
parent_ref=field_ref,
|
|
modified=modified,
|
|
field_obj=field_obj,
|
|
)
|
|
|
|
|
|
def ensure_sig_flags(writer: BasePdfFileWriter, lock_sig_flags: bool = True):
|
|
"""
|
|
Ensure the SigFlags setting is present in the AcroForm dictionary.
|
|
|
|
:param writer:
|
|
A PDF writer.
|
|
:param lock_sig_flags:
|
|
Whether to flag the document as append-only.
|
|
"""
|
|
# make sure /SigFlags is present. If not, create it
|
|
# 3 = use append-only mode
|
|
|
|
form = writer.root['/AcroForm']
|
|
|
|
if lock_sig_flags:
|
|
orig_sig_flags = form.get('/SigFlags', None)
|
|
form['/SigFlags'] = generic.NumberObject(3)
|
|
if orig_sig_flags != 3:
|
|
writer.update_container(form)
|
|
else:
|
|
form.setdefault(pdf_name('/SigFlags'), generic.NumberObject(1))
|
|
|
|
|
|
def prepare_sig_field(
|
|
sig_field_name,
|
|
root,
|
|
update_writer: BasePdfFileWriter,
|
|
existing_fields_only=False,
|
|
**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.
|
|
|
|
.. danger::
|
|
This function is internal API.
|
|
"""
|
|
|
|
try:
|
|
form = root['/AcroForm']
|
|
|
|
try:
|
|
fields = form['/Fields']
|
|
except KeyError:
|
|
raise PdfError('/AcroForm has no /Fields')
|
|
|
|
candidates = enumerate_sig_fields_in(
|
|
fields, with_name=sig_field_name, refs_seen=set()
|
|
)
|
|
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
|
|
|
|
# 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, **sig_form_kwargs)
|
|
created, sig_field_ref = _insert_or_get_field_at(
|
|
update_writer,
|
|
fields,
|
|
path=sig_field_name.split('.'),
|
|
field_obj=sig_field,
|
|
)
|
|
sig_field.register_widget_annotation(update_writer, sig_field_ref)
|
|
|
|
# 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 get_sig_field_annot(
|
|
sig_field: generic.DictionaryObject,
|
|
) -> generic.DictionaryObject:
|
|
"""
|
|
Internal function to get the annotation of a signature field.
|
|
|
|
:param sig_field:
|
|
A signature field dictionary.
|
|
:return:
|
|
The dictionary of the corresponding annotation.
|
|
"""
|
|
try:
|
|
(sig_annot,) = sig_field['/Kids']
|
|
sig_annot = sig_annot.get_object()
|
|
except (ValueError, TypeError):
|
|
raise SigningError(
|
|
"Failed to access signature field's annotation. "
|
|
"Signature field must have exactly one child annotation, "
|
|
"or it must be combined with its annotation."
|
|
)
|
|
except KeyError:
|
|
sig_annot = sig_field
|
|
return sig_annot
|
|
|
|
|
|
def annot_width_height(
|
|
annot_dict: generic.DictionaryObject,
|
|
) -> Tuple[float, float]:
|
|
"""
|
|
Internal function to compute the width and height of an annotation.
|
|
|
|
:param annot_dict:
|
|
Annotation dictionary.
|
|
:return:
|
|
a (width, height) tuple
|
|
"""
|
|
try:
|
|
x1, y1, x2, y2 = annot_dict['/Rect']
|
|
except KeyError:
|
|
return 0, 0
|
|
w = abs(x1 - x2)
|
|
h = abs(y1 - y2)
|
|
return w, h
|
|
|
|
|
|
def enumerate_sig_fields(
|
|
handler: PdfHandler,
|
|
filled_status: Optional[bool] = None,
|
|
with_name: Optional[str] = None,
|
|
):
|
|
"""
|
|
Enumerate signature fields.
|
|
|
|
:param handler:
|
|
The :class:`~.rw_common.PdfHandler` to operate on.
|
|
:param filled_status:
|
|
Optional boolean. If ``True`` (resp. ``False``) then all filled
|
|
(resp. empty) fields are returned. If left ``None`` (the default), then
|
|
all fields are returned.
|
|
:param with_name:
|
|
If not ``None``, only look for fields with the specified name.
|
|
:return:
|
|
A generator producing signature fields.
|
|
"""
|
|
|
|
try:
|
|
fields = handler.root['/AcroForm']['/Fields']
|
|
except KeyError:
|
|
return
|
|
|
|
yield from enumerate_sig_fields_in(
|
|
fields,
|
|
filled_status=filled_status,
|
|
with_name=with_name,
|
|
refs_seen=set(),
|
|
)
|
|
|
|
|
|
def enumerate_sig_fields_in(
|
|
field_list,
|
|
filled_status=None,
|
|
with_name=None,
|
|
parent_name="",
|
|
parents=None,
|
|
*,
|
|
refs_seen,
|
|
):
|
|
if not isinstance(field_list, generic.ArrayObject):
|
|
logger.warning(
|
|
f"Values of type {type(field_list)} are not valid as field "
|
|
f"lists, must be array objects -- skipping."
|
|
)
|
|
return
|
|
|
|
parents = parents or ()
|
|
for field_ref in field_list:
|
|
if not isinstance(field_ref, generic.IndirectObject):
|
|
logger.warning(
|
|
"Entries in field list must be indirect references -- skipping."
|
|
)
|
|
continue
|
|
if field_ref.reference in refs_seen:
|
|
raise PdfReadError("Circular reference in form tree")
|
|
|
|
field = field_ref.get_object()
|
|
if not isinstance(field, generic.DictionaryObject):
|
|
logger.warning(
|
|
"Entries in field list must be dictionary objects, not "
|
|
f"{type(field)} -- skipping."
|
|
)
|
|
continue
|
|
# /T is the field name. If not specified, we're dealing with a bare
|
|
# widget, so skip it. (these should never occur in /Fields, but hey)
|
|
try:
|
|
field_name = field['/T']
|
|
except KeyError:
|
|
continue
|
|
fq_name = (
|
|
field_name
|
|
if not parent_name
|
|
else ("%s.%s" % (parent_name, field_name))
|
|
)
|
|
explicitly_requested = with_name is not None and fq_name == with_name
|
|
child_requested = explicitly_requested or (
|
|
with_name is not None and with_name.startswith(fq_name)
|
|
)
|
|
# /FT is inheritable, so go up the chain
|
|
current_path = (field,) + parents
|
|
for parent_field in current_path:
|
|
try:
|
|
field_type = parent_field['/FT']
|
|
break
|
|
except KeyError:
|
|
continue
|
|
else:
|
|
field_type = None
|
|
|
|
if field_type == '/Sig':
|
|
field_value = field.get('/V')
|
|
# "cast" to a regular string object
|
|
filled = field_value is not None
|
|
status_check = filled_status is None or filled == filled_status
|
|
name_check = with_name is None or explicitly_requested
|
|
if status_check and name_check:
|
|
yield fq_name, field_value, field_ref
|
|
elif explicitly_requested:
|
|
raise SigningError(
|
|
'Field with name %s exists but is not a signature field'
|
|
% fq_name
|
|
)
|
|
|
|
# if necessary, descend into the field hierarchy
|
|
if with_name is None or (child_requested and not explicitly_requested):
|
|
try:
|
|
yield from enumerate_sig_fields_in(
|
|
field['/Kids'],
|
|
parent_name=fq_name,
|
|
parents=current_path,
|
|
with_name=with_name,
|
|
filled_status=filled_status,
|
|
refs_seen=refs_seen | {field_ref.reference},
|
|
)
|
|
except KeyError:
|
|
continue
|
|
|
|
|
|
def append_signature_field(
|
|
pdf_out: BasePdfFileWriter, sig_field_spec: SigFieldSpec
|
|
):
|
|
"""
|
|
Append signature fields to a PDF file.
|
|
|
|
:param pdf_out:
|
|
Incremental writer to house the objects.
|
|
:param sig_field_spec:
|
|
A :class:`.SigFieldSpec` object describing the signature field
|
|
to add.
|
|
"""
|
|
root = pdf_out.root
|
|
|
|
page_ref = pdf_out.find_page_for_modification(sig_field_spec.on_page)[0]
|
|
field_created, sig_field_ref = prepare_sig_field(
|
|
sig_field_spec.sig_field_name,
|
|
root,
|
|
update_writer=pdf_out,
|
|
existing_fields_only=False,
|
|
box=sig_field_spec.box,
|
|
include_on_page=page_ref,
|
|
combine_annotation=sig_field_spec.combine_annotation,
|
|
invis_settings=sig_field_spec.invis_sig_settings,
|
|
visible_settings=sig_field_spec.visible_sig_settings,
|
|
)
|
|
ensure_sig_flags(writer=pdf_out, lock_sig_flags=False)
|
|
if not field_created:
|
|
raise PdfWriteError(
|
|
'Signature field with name %s already exists.'
|
|
% sig_field_spec.sig_field_name
|
|
)
|
|
|
|
sig_field = sig_field_ref.get_object()
|
|
apply_sig_field_spec_properties(
|
|
pdf_out, sig_field=sig_field, sig_field_spec=sig_field_spec
|
|
)
|
|
|
|
if sig_field_spec.box is not None:
|
|
llx, lly, urx, ury = sig_field_spec.box
|
|
w = abs(urx - llx)
|
|
h = abs(ury - lly)
|
|
if w and h:
|
|
sig_field[pdf_name('/AP')] = ap_dict = generic.DictionaryObject()
|
|
if sig_field_spec.empty_field_appearance:
|
|
# draw a simple rectangle
|
|
appearance_cmds = [
|
|
b'q',
|
|
# background
|
|
b'q 0.95 0.95 0.95 rg 0 0 %g %g re f Q' % (w, h),
|
|
# border
|
|
b'0.5 w 0 0 %g %g re S' % (w, h),
|
|
b'Q',
|
|
]
|
|
ap_stream = RawContent(
|
|
b' '.join(appearance_cmds),
|
|
box=BoxConstraints(width=w, height=h),
|
|
).as_form_xobject()
|
|
else:
|
|
ap_stream = RawContent(
|
|
b'', box=BoxConstraints(width=w, height=h)
|
|
).as_form_xobject()
|
|
ap_dict[pdf_name('/N')] = pdf_out.add_object(ap_stream)
|
|
|
|
|
|
def apply_sig_field_spec_properties(
|
|
pdf_out: BasePdfFileWriter,
|
|
sig_field: generic.DictionaryObject,
|
|
sig_field_spec: SigFieldSpec,
|
|
):
|
|
"""
|
|
Internal function to apply field spec properties to a newly created field.
|
|
"""
|
|
|
|
if sig_field_spec.readable_field_name is not None:
|
|
sig_field[pdf_name('/TU')] = generic.TextStringObject(
|
|
sig_field_spec.readable_field_name
|
|
)
|
|
if sig_field_spec.seed_value_dict is not None:
|
|
# /SV must be an indirect reference as per the spec
|
|
sv_ref = pdf_out.add_object(
|
|
sig_field_spec.seed_value_dict.as_pdf_object()
|
|
)
|
|
sig_field[pdf_name('/SV')] = sv_ref
|
|
|
|
lock = sig_field_spec.format_lock_dictionary()
|
|
if lock is not None:
|
|
sig_field[pdf_name('/Lock')] = pdf_out.add_object(lock)
|
|
|
|
|
|
class SignatureFormField(generic.DictionaryObject):
|
|
def __init__(
|
|
self,
|
|
field_name,
|
|
*,
|
|
box=None,
|
|
include_on_page=None,
|
|
combine_annotation=True,
|
|
invis_settings: InvisSigSettings = InvisSigSettings(),
|
|
visible_settings: VisibleSigSettings = VisibleSigSettings(),
|
|
annot_flags=None,
|
|
):
|
|
if box is not None:
|
|
rect = [generic.FloatObject(rd(x)) for x in box]
|
|
invisible = not (abs(box[0] - box[2]) and abs(box[1] - box[3]))
|
|
else:
|
|
coord = -9999 if invis_settings.box_out_of_bounds else 0
|
|
rect = [generic.FloatObject(coord)] * 4
|
|
invisible = True
|
|
|
|
super().__init__(
|
|
{
|
|
# Signature field properties
|
|
pdf_name('/FT'): pdf_name('/Sig'),
|
|
pdf_name('/T'): pdf_string(field_name),
|
|
}
|
|
)
|
|
|
|
self.combine_annotation = combine_annotation
|
|
annot_dict: generic.DictionaryObject
|
|
if combine_annotation:
|
|
annot_dict = self
|
|
else:
|
|
annot_dict = generic.DictionaryObject()
|
|
|
|
# Annotation properties: bare minimum
|
|
annot_dict['/Type'] = pdf_name('/Annot')
|
|
annot_dict['/Subtype'] = pdf_name('/Widget')
|
|
|
|
if annot_flags is None:
|
|
# this sets the "lock" bit
|
|
annot_flags = 0b10000000
|
|
if invisible:
|
|
if invis_settings.set_hidden_flag:
|
|
annot_flags |= 0b10
|
|
if invis_settings.set_print_flag:
|
|
annot_flags |= 0b100
|
|
else:
|
|
if visible_settings.print_signature:
|
|
annot_flags |= 0b100
|
|
if not visible_settings.scale_with_page_zoom:
|
|
annot_flags |= 0b1000
|
|
if not visible_settings.rotate_with_page:
|
|
annot_flags |= 0b10000
|
|
|
|
annot_dict['/F'] = generic.NumberObject(annot_flags)
|
|
annot_dict['/Rect'] = generic.ArrayObject(rect)
|
|
|
|
self.page_ref = include_on_page
|
|
if include_on_page is not None:
|
|
annot_dict['/P'] = include_on_page
|
|
|
|
self.annot_dict = annot_dict
|
|
|
|
def register_widget_annotation(
|
|
self, writer: BasePdfFileWriter, sig_field_ref
|
|
):
|
|
annot_dict = self.annot_dict
|
|
if not self.combine_annotation:
|
|
annot_ref = writer.add_object(annot_dict)
|
|
self['/Kids'] = generic.ArrayObject([annot_ref])
|
|
else:
|
|
annot_ref = sig_field_ref
|
|
writer.register_annotation(self.page_ref, annot_ref)
|