Skip to content

Commit

Permalink
Improve ergonomics around permissions
Browse files Browse the repository at this point in the history
Instead of passing around numbers, use a subclass of Flag. This is a
(relatively minor) API-breaking change.
  • Loading branch information
MatthiasValvekens committed Apr 26, 2024
1 parent 296dd3b commit 2006ea8
Show file tree
Hide file tree
Showing 12 changed files with 264 additions and 64 deletions.
12 changes: 9 additions & 3 deletions pyhanko/cli/commands/crypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."
Expand Down
1 change: 0 additions & 1 deletion pyhanko/pdf_utils/crypt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@
"""

from .api import (
ALL_PERMS,
IDENTITY,
AuthResult,
AuthStatus,
Expand Down
2 changes: 1 addition & 1 deletion pyhanko/pdf_utils/crypt/_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', p_entry)
p_entry = struct.pack('<I', p_entry.as_uint32())
m.update(p_entry)
# 5. Pass the first element of the file's file identifier array to the MD5
# hash function.
Expand Down
5 changes: 0 additions & 5 deletions pyhanko/pdf_utils/crypt/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes


def as_signed(val: int):
# converts an integer to a signed int
return struct.unpack('<i', struct.pack('<I', val & 0xFFFFFFFF))[0]


def aes_cbc_decrypt(key, data, iv, use_padding=True):
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
Expand Down
10 changes: 2 additions & 8 deletions pyhanko/pdf_utils/crypt/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from pyhanko.pdf_utils import generic, misc
from pyhanko.pdf_utils.crypt.cred_ser import SerialisableCredential
from pyhanko.pdf_utils.crypt.permissions import PdfPermissions


class PdfKeyNotAvailableError(misc.PdfReadError):
Expand Down Expand Up @@ -31,7 +32,7 @@ class AuthResult:
Authentication status after the authentication attempt.
"""

permission_flags: Optional[int] = None
permission_flags: Optional[PdfPermissions] = None
"""
Granular permission flags. The precise meaning depends on the security
handler.
Expand Down Expand Up @@ -855,10 +856,3 @@ def build_crypt_filter(
except KeyError:
raise NotImplementedError("No such crypt filter method: " + cfm)
return factory(cfdict, acts_as_default)


ALL_PERMS = -4
"""
Dummy value that translates to "everything is allowed" in an
encrypted PDF document.
"""
72 changes: 72 additions & 0 deletions pyhanko/pdf_utils/crypt/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import operator
import struct
from enum import Flag
from functools import reduce


class PdfPermissions(Flag):

@classmethod
def allow_everything(cls):
return reduce(operator.or_, cls.__members__.values())

@classmethod
def from_uint(cls, uint_flags: int):
result = cls(0)
for flag in cls:
if uint_flags & flag.value:
result |= flag
return result

@classmethod
def from_bytes(cls, flags: bytes):
uint_flags = struct.unpack('>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
36 changes: 19 additions & 17 deletions pyhanko/pdf_utils/crypt/pubkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -59,6 +58,7 @@
)
from .cred_ser import SerialisableCredential, SerialisedCredential
from .filter_mixins import AESCryptFilterMixin, RC4CryptFilterMixin
from .permissions import PubKeyPermissions

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 2006ea8

Please sign in to comment.