diff --git a/pyhanko/cli/commands/crypt.py b/pyhanko/cli/commands/crypt.py index db888b3e..d36d5fa4 100644 --- a/pyhanko/cli/commands/crypt.py +++ b/pyhanko/cli/commands/crypt.py @@ -8,6 +8,7 @@ from pyhanko.keys import load_certs_from_pemder from pyhanko.pdf_utils import crypt from pyhanko.pdf_utils.crypt import StandardSecurityHandler +from pyhanko.pdf_utils.crypt.permissions import PubKeyPermissions from pyhanko.pdf_utils.reader import PdfFileReader from pyhanko.pdf_utils.writer import copy_into_new_writer @@ -175,9 +176,14 @@ def _decrypt_pubkey( ) auth_result = r.decrypt_pubkey(sedk) if auth_result.status == crypt.AuthStatus.USER: - # TODO read 2nd bit of perms in CMS enveloped data - # is the one indicating that change of encryption is OK - if not force: + if ( + not force + and auth_result.permission_flags + and not ( + PubKeyPermissions.ALLOW_ENCRYPTION_CHANGE + in auth_result.permission_flags + ) + ): raise click.ClickException( "Change of encryption is typically not allowed with " "user access. Pass --force to decrypt the file anyway." diff --git a/pyhanko/pdf_utils/crypt/__init__.py b/pyhanko/pdf_utils/crypt/__init__.py index e6b05412..c35d98be 100644 --- a/pyhanko/pdf_utils/crypt/__init__.py +++ b/pyhanko/pdf_utils/crypt/__init__.py @@ -57,7 +57,6 @@ """ from .api import ( - ALL_PERMS, IDENTITY, AuthResult, AuthStatus, diff --git a/pyhanko/pdf_utils/crypt/_legacy.py b/pyhanko/pdf_utils/crypt/_legacy.py index 1aa90b58..7ea64097 100644 --- a/pyhanko/pdf_utils/crypt/_legacy.py +++ b/pyhanko/pdf_utils/crypt/_legacy.py @@ -35,7 +35,7 @@ def derive_legacy_file_key( m.update(owner_entry) # 4. Treat the value of the /P entry as an unsigned 4-byte integer and pass # these bytes to the MD5 hash function, low-order byte first. - p_entry = struct.pack('I', flags)[0] + return cls.from_uint(uint_flags) + + @classmethod + def from_sint32(cls, sint32_flags: int): + return cls.from_uint(sint32_flags & 0xFFFFFFFF) + + def as_uint32(self): + raise NotImplementedError + + def as_bytes(self) -> bytes: + return struct.pack('>I', self.as_uint32()) + + def as_sint32(self) -> int: + return struct.unpack('>i', self.as_bytes())[0] + + +class StandardPermissions(PdfPermissions, Flag): + # We purposefully do not inherit from IntFlag since + # PDF uses 32-bit twos complement to treat flags as ints, + # which doesn't jive well with what IntFlag would do, + # so it's hard to detect backwards compatibility issues. + + ALLOW_PRINTING = 4 + ALLOW_MODIFICATION_GENERIC = 8 + ALLOW_CONTENT_EXTRACTION = 16 + ALLOW_ANNOTS_FORM_FILLING = 32 + ALLOW_FORM_FILLING = 256 + ALLOW_ASSISTIVE_TECHNOLOGY = 512 + ALLOW_REASSEMBLY = 1024 + ALLOW_HIGH_QUALITY_PRINTING = 2048 + + def as_uint32(self): + return sum(x.value for x in self.__class__ if x in self) | 0xFFFFF0C0 + + +class PubKeyPermissions(PdfPermissions, Flag): + ALLOW_ENCRYPTION_CHANGE = 2 + ALLOW_PRINTING = 4 + ALLOW_MODIFICATION_GENERIC = 8 + ALLOW_CONTENT_EXTRACTION = 16 + ALLOW_ANNOTS_FORM_FILLING = 32 + ALLOW_FORM_FILLING = 256 + ALLOW_ASSISTIVE_TECHNOLOGY = 512 + ALLOW_REASSEMBLY = 1024 + ALLOW_HIGH_QUALITY_PRINTING = 2048 + + def as_uint32(self): + # ensure the first bit is set for compatibility with Acrobat + return sum(x.value for x in self.__class__ if x in self) | 0xFFFFF0C1 diff --git a/pyhanko/pdf_utils/crypt/pubkey.py b/pyhanko/pdf_utils/crypt/pubkey.py index f42f8f4e..d006b2e9 100644 --- a/pyhanko/pdf_utils/crypt/pubkey.py +++ b/pyhanko/pdf_utils/crypt/pubkey.py @@ -44,9 +44,8 @@ from cryptography.hazmat.primitives.serialization import pkcs12 from .. import generic, misc -from ._util import aes_cbc_decrypt, aes_cbc_encrypt, as_signed, rc4_encrypt +from ._util import aes_cbc_decrypt, aes_cbc_encrypt, rc4_encrypt from .api import ( - ALL_PERMS, AuthResult, AuthStatus, CryptFilter, @@ -59,6 +58,7 @@ ) from .cred_ser import SerialisableCredential, SerialisedCredential from .filter_mixins import AESCryptFilterMixin, RC4CryptFilterMixin +from .permissions import PubKeyPermissions logger = logging.getLogger(__name__) @@ -131,7 +131,7 @@ def add_recipients( self, certs: List[x509.Certificate], policy: RecipientEncryptionPolicy, - perms=ALL_PERMS, + perms: PubKeyPermissions = PubKeyPermissions.allow_everything(), ): """ Add recipients to this crypt filter. @@ -164,7 +164,7 @@ def add_recipients( new_cms = construct_recipient_cms( certs, self._recp_key_seed, - perms & 0xFFFFFFFF, + perms, policy=policy, include_permissions=self.acts_as_default, ) @@ -307,10 +307,10 @@ class PubKeyAdbeSubFilter(enum.Enum): def construct_envelope_content( - seed: bytes, perms: int, include_permissions=True + seed: bytes, perms: PubKeyPermissions, include_permissions=True ): assert len(seed) == 20 - return seed + (struct.pack('>I', perms) if include_permissions else b'') + return seed + (perms.as_bytes() if include_permissions else b'') def _rsaes_pkcs1v15_recipient( @@ -546,7 +546,7 @@ def _recipient_info( def construct_recipient_cms( certificates: List[x509.Certificate], seed: bytes, - perms: int, + perms: PubKeyPermissions, policy: RecipientEncryptionPolicy, include_permissions=True, ) -> cms.ContentInfo: @@ -1055,7 +1055,7 @@ def read_envelope_key( def read_seed_from_recipient_cms( recipient_cms: cms.ContentInfo, decrypter: EnvelopeKeyDecrypter -) -> Tuple[Optional[bytes], Optional[int]]: +) -> Tuple[Optional[bytes], Optional[PubKeyPermissions]]: content_type = recipient_cms['content_type'].native if content_type != 'enveloped_data': raise misc.PdfReadError( @@ -1120,10 +1120,10 @@ def read_seed_from_recipient_cms( ) seed = content[:20] - perms: Optional[int] = None + perms: Optional[PubKeyPermissions] = None if len(content) == 24: # permissions are included - perms = struct.unpack('>I', content[20:])[0] + perms = PubKeyPermissions.from_bytes(content[20:]) return seed, perms @@ -1192,7 +1192,7 @@ def build_from_certs( version=SecurityHandlerVersion.AES256, use_aes=True, use_crypt_filters=True, - perms: int = ALL_PERMS, + perms: PubKeyPermissions = PubKeyPermissions.allow_everything(), encrypt_metadata=True, policy: RecipientEncryptionPolicy = RecipientEncryptionPolicy(), **kwargs, @@ -1220,7 +1220,7 @@ def build_from_certs( handlers of version :attr:`~.SecurityHandlerVersion.RC4_OR_AES128` or higher. :param perms: - Permission flags (as a 4-byte signed integer). + Permission flags. :param encrypt_metadata: Whether to encrypt document metadata. @@ -1449,7 +1449,7 @@ def as_pdf_object(self): def add_recipients( self, certs: List[x509.Certificate], - perms=ALL_PERMS, + perms: PubKeyPermissions = PubKeyPermissions.allow_everything(), policy: RecipientEncryptionPolicy = RecipientEncryptionPolicy(), ): # add recipients to all *default* crypt filters @@ -1492,7 +1492,7 @@ def authenticate( else: actual_credential = credential - perms = 0xFFFFFFFF + perms = PubKeyPermissions.allow_everything() for cf in self.crypt_filter_config.standard_filters(): if not isinstance(cf, PubKeyCryptFilter): continue @@ -1503,11 +1503,13 @@ def authenticate( # these should really be the same for both filters, but hey, # you never know. ANDing them seems to be the most reasonable # course of action - if result.permission_flags is not None: - perms &= result.permission_flags + cf_flags = result.permission_flags + if cf_flags is not None: + assert isinstance(cf_flags, PubKeyPermissions) + perms &= cf_flags if isinstance(actual_credential, SerialisableCredential): self._credential = actual_credential - return AuthResult(AuthStatus.USER, as_signed(perms)) + return AuthResult(AuthStatus.USER, perms) def get_file_encryption_key(self) -> bytes: # just grab the key from the default stream filter diff --git a/pyhanko/pdf_utils/crypt/standard.py b/pyhanko/pdf_utils/crypt/standard.py index 5cbd5f1a..fe4733d1 100644 --- a/pyhanko/pdf_utils/crypt/standard.py +++ b/pyhanko/pdf_utils/crypt/standard.py @@ -18,9 +18,8 @@ compute_u_value_r34, legacy_normalise_pw, ) -from ._util import aes_cbc_decrypt, aes_cbc_encrypt, as_signed, rc4_encrypt +from ._util import aes_cbc_decrypt, aes_cbc_encrypt, rc4_encrypt from .api import ( - ALL_PERMS, AuthResult, AuthStatus, CryptFilter, @@ -33,6 +32,7 @@ ) from .cred_ser import SerialisableCredential, SerialisedCredential from .filter_mixins import AESCryptFilterMixin, RC4CryptFilterMixin +from .permissions import StandardPermissions @dataclass @@ -274,7 +274,7 @@ def build_from_pw_legacy( desired_user_pass=None, keylen_bytes=16, use_aes128=True, - perms: int = ALL_PERMS, + perms: StandardPermissions = StandardPermissions.allow_everything(), crypt_filter_config=None, encrypt_metadata=True, **kwargs, @@ -303,7 +303,7 @@ def build_from_pw_legacy( :param use_aes128: Use AES-128 instead of RC4 (default: ``True``). :param perms: - Permission bits to set (defined as an integer) + Permission bits to set :param crypt_filter_config: Custom crypt filter configuration. PyHanko will supply a reasonable default if none is specified. @@ -331,10 +331,16 @@ def build_from_pw_legacy( ) # force perms to a 4-byte format - perms = as_signed(perms & 0xFFFFFFFC) if rev == StandardSecuritySettingsRevision.RC4_BASIC: # some permissions are not available for these security handlers - perms = as_signed(perms | 0xFFFFFFC0) + # the default is 'allow' + perms = ( + perms + | StandardPermissions.ALLOW_FORM_FILLING + | StandardPermissions.ALLOW_ASSISTIVE_TECHNOLOGY + | StandardPermissions.ALLOW_REASSEMBLY + | StandardPermissions.ALLOW_HIGH_QUALITY_PRINTING + ) u_entry, key = compute_u_value_r2( desired_user_pass, o_entry, perms, id1 ) @@ -387,7 +393,7 @@ def build_from_pw( cls, desired_owner_pass, desired_user_pass=None, - perms=ALL_PERMS, + perms: StandardPermissions = StandardPermissions.allow_everything(), encrypt_metadata=True, **kwargs, ): @@ -437,7 +443,7 @@ def build_from_pw( ) assert len(oe_seed) == 32 - perms_bytes = struct.pack('= StandardSecuritySettingsRevision.AES256: self.__class__._check_r6_values( udata, odata, oeseed, ueseed, encrypted_perms @@ -579,9 +585,21 @@ def _get_bytes(x: generic.PdfObject) -> bytes: raise misc.PdfReadError(f"Expected string, but got {type(x)}") return x.original_bytes + def _parse_permissions(x: generic.PdfObject) -> StandardPermissions: + if isinstance(x, generic.NumberObject): + return StandardPermissions.from_sint32(x) + else: + raise misc.PdfReadError( + f"Cannot parse {x} as a permission indicator" + ) + return dict( legacy_keylen=keylen, - perm_flags=as_signed(encrypt_dict.get('/P', ALL_PERMS)), + perm_flags=encrypt_dict.get_and_apply( + '/P', + _parse_permissions, + default=StandardPermissions.allow_everything(), + ), odata=odata.original_bytes[:48], udata=udata.original_bytes[:48], oeseed=encrypt_dict.get_and_apply('/OE', _get_bytes), @@ -610,7 +628,7 @@ def as_pdf_object(self): result['/Filter'] = generic.NameObject('/Standard') result['/O'] = generic.ByteStringObject(self.odata) result['/U'] = generic.ByteStringObject(self.udata) - result['/P'] = generic.NumberObject(as_signed(self.perms)) + result['/P'] = generic.NumberObject(self.perms.as_sint32()) # this shouldn't be necessary for V5 handlers, but Adobe Reader # requires it anyway ...sigh... if ( @@ -752,7 +770,10 @@ def _authenticate_r6(self, password) -> Tuple[AuthStatus, Optional[bytes]]: # known plaintext mandated in the standard ...sigh... perms_ok = decrypted_p_entry[9:12] == b'adb' - perms_ok &= self.perms == struct.unpack('