diff --git a/.banditrc.yml b/.banditrc.yml index b9f6df50d..411a418a1 100644 --- a/.banditrc.yml +++ b/.banditrc.yml @@ -2,3 +2,6 @@ skips: # Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. # => OK, we don't care though - B101 + # B305:blacklist - Use of insecure cipher mode cryptography.hazmat.primitives.ciphers.modes.ECB. + # Need to bypass this check because the PDF specification demands the use of ECB mode on one of the encryption algorithms + - B305 diff --git a/docs/Encryption.md b/docs/Encryption.md index a2f94173a..61a3c770a 100644 --- a/docs/Encryption.md +++ b/docs/Encryption.md @@ -81,7 +81,7 @@ If no permission is specified it will default to `all()`. ## Encryption method ## -There are 3 available encryption methods: +There are 4 available encryption methods: * `NO_ENCRYPTION` Data is not encrypted, only add the access permission flags. @@ -90,7 +90,10 @@ There are 3 available encryption methods: Default PDF encryption algorithm. * `AES_128` - Encrypts the data with AES algorithm. Requires the `cryptography` package. + Encrypts the data with 128 bit key AES algorithm. Requires the `cryptography` package. + + * `AES_256` + Encrypts the data with 256 bit key AES algorithm. Requires the `cryptography` package. ```python from fpdf import FPDF diff --git a/fpdf/annotations.py b/fpdf/annotations.py index 687cae8b2..b67a1e17c 100644 --- a/fpdf/annotations.py +++ b/fpdf/annotations.py @@ -52,12 +52,12 @@ def __init__( self.f_t = Name(field_type) if field_type else None self.v = value self.f = sum(flags) - self.contents = PDFString(contents) if contents else None + self.contents = PDFString(contents, encrypt=True) if contents else None self.a = action self.dest = dest self.c = f"[{color[0]} {color[1]} {color[2]}]" if color else None - self.t = PDFString(title) if title else None - self.m = PDFDate(modification_time) if modification_time else None + self.t = PDFString(title, encrypt=True) if title else None + self.m = PDFDate(modification_time, encrypt=True) if modification_time else None self.quad_points = ( pdf_list(f"{quad_point:.2f}" for quad_point in quad_points) if quad_points diff --git a/fpdf/encryption.py b/fpdf/encryption.py index 6e6624c81..5f3bf77d9 100644 --- a/fpdf/encryption.py +++ b/fpdf/encryption.py @@ -1,16 +1,20 @@ import hashlib import logging import math +import stringprep +import unicodedata from os import urandom +from typing import Callable, Iterable, Type, Union -from .enums import EncryptionMethod -from .syntax import Name, PDFObject, PDFString -from .syntax import create_dictionary_string as pdf_dict, build_obj_dict +from .enums import AccessPermission, EncryptionMethod +from .errors import FPDFException +from .syntax import Name, PDFObject, PDFString, build_obj_dict +from .syntax import create_dictionary_string as pdf_dict # try to use cryptography for AES encryption try: from cryptography.hazmat.primitives.ciphers import Cipher, modes - from cryptography.hazmat.primitives.ciphers.algorithms import AES128 + from cryptography.hazmat.primitives.ciphers.algorithms import AES128, AES256 from cryptography.hazmat.primitives.padding import PKCS7 import_error = None @@ -35,7 +39,7 @@ class ARC4: MOD = 256 - def KSA(self, key): + def KSA(self, key: bytes) -> list: key_length = len(key) S = list(range(self.MOD)) j = 0 @@ -44,7 +48,7 @@ def KSA(self, key): S[i], S[j] = S[j], S[i] return S - def PRGA(self, S): + def PRGA(self, S: list) -> Iterable[int]: i = 0 j = 0 while True: @@ -54,7 +58,7 @@ def PRGA(self, S): K = S[(S[i] + S[j]) % self.MOD] yield K - def encrypt(self, key, text): + def encrypt(self, key: bytes, text: Union[bytes, bytearray]) -> list: keystream = self.PRGA(self.KSA(key)) res = [] for c in text: @@ -65,13 +69,13 @@ def encrypt(self, key, text): class CryptFilter: """Represents one crypt filter, listed under CF inside the encryption dictionary""" - def __init__(self, mode, length): + def __init__(self, mode: str, length: int) -> None: super().__init__() self.type = Name("CryptFilter") self.c_f_m = Name(mode) self.length = int(length / 8) - def serialize(self): + def serialize(self) -> str: obj_dict = build_obj_dict({key: getattr(self, key) for key in dir(self)}) return pdf_dict(obj_dict) @@ -83,14 +87,18 @@ class EncryptionDictionary(PDFObject): The PDF trailer must reference this object (/Encrypt) """ - def __init__(self, security_handler): + def __init__(self, security_handler: "StandardSecurityHandler") -> None: super().__init__() self.filter = Name("Standard") self.length = security_handler.key_length - self.r = security_handler.r + self.r = security_handler.revision self.o = f"<{security_handler.o.upper()}>" self.u = f"<{security_handler.u.upper()}>" - self.v = security_handler.v + if security_handler.revision == 6: + self.o_e = f"<{security_handler.oe.upper()}>" + self.u_e = f"<{security_handler.ue.upper()}>" + self.perms = f"<{security_handler.perms.upper()}>" + self.v = security_handler.version self.p = int32(security_handler.access_permission) if not security_handler.encrypt_metadata: self.encrypt_metadata = "false" @@ -119,11 +127,11 @@ class StandardSecurityHandler: def __init__( self, fpdf, - owner_password, - user_password=None, - permission=None, - encryption_method=None, - encrypt_metadata=False, + owner_password: str, + user_password: Union[str, None] = None, + permission: AccessPermission = AccessPermission.all(), + encryption_method: EncryptionMethod = EncryptionMethod.RC4, + encrypt_metadata: bool = False, ): self.fpdf = fpdf self.access_permission = ( @@ -137,66 +145,87 @@ def __init__( self.cf = None self.key_length = 128 + if import_error and self.encryption_method in ( + EncryptionMethod.AES_128, + EncryptionMethod.AES_256, + ): + raise EnvironmentError( + "cryptography module not available" + " - Try: 'pip install cryptography' or use RC4 encryption method" + f" - Import error was: {import_error}" + ) if self.encryption_method == EncryptionMethod.AES_128: - if import_error: - raise EnvironmentError( - "cryptography module not available" - " - Try: 'pip install cryptography' or use RC4 encryption method" - f" - Import error was: {import_error}" - ) - self.v = 4 - self.r = 4 + self.version = 4 + self.revision = 4 fpdf._set_min_pdf_version("1.6") self.cf = CryptFilter(mode="AESV2", length=self.key_length) + elif self.encryption_method == EncryptionMethod.AES_256: + self.version = 5 + self.revision = 6 + fpdf._set_min_pdf_version("2.0") + self.key_length = 256 + self.cf = CryptFilter(mode="AESV3", length=self.key_length) elif self.encryption_method == EncryptionMethod.NO_ENCRYPTION: - self.v = 4 - self.r = 4 + self.version = 4 + self.revision = 4 fpdf._set_min_pdf_version("1.6") self.cf = CryptFilter(mode="V2", length=self.key_length) else: - self.v = 2 - self.r = 3 + self.version = 2 + self.revision = 3 fpdf._set_min_pdf_version("1.5") # not including crypt filter because it's only required on V=4 # if needed, it would be CryptFilter(mode=V2) self.encrypt_metadata = encrypt_metadata - def generate_passwords(self, file_id): - """Return the first hash of the PDF file id""" + def generate_passwords(self, file_id: str) -> None: + """File_id is the first hash of the PDF file id""" self.file_id = file_id self.info_id = file_id[1:33] - self.o = self.generate_owner_password() - self.k = self.generate_encryption_key() - self.u = self.generate_user_password() + if self.revision == 6: + self.k = self.get_random_bytes(32) + self.generate_user_password_rev6() + self.generate_owner_password_rev6() + self.generate_perms_rev6() + else: + self.o = self.generate_owner_password() + self.k = self.generate_encryption_key() + self.u = self.generate_user_password() - def get_encryption_obj(self): + def get_encryption_obj(self) -> EncryptionDictionary: """Return an encryption dictionary""" return EncryptionDictionary(self) - def encrypt(self, text, obj_id): + def encrypt( + self, text: Union[str, bytearray, bytes], obj_id: int + ) -> Union[str, bytes]: """Method invoked by PDFObject and PDFContentStream to encrypt strings and streams""" + LOGGER.debug("Encrypting %s", text) return ( self.encrypt_stream(text, obj_id) if isinstance(text, (bytearray, bytes)) else self.encrypt_string(text, obj_id) ) - def encrypt_string(self, string, obj_id): + def encrypt_string(self, string: str, obj_id: int) -> str: if self.encryption_method == EncryptionMethod.NO_ENCRYPTION: - return PDFString(string).serialize() + return PDFString(string, encrypt=False).serialize() LOGGER.debug("Encrypting string: %s", string) return f"<{bytes(self.encrypt_bytes(string.encode('latin-1'), obj_id)).hex().upper()}>" - def encrypt_stream(self, stream, obj_id): + def encrypt_stream(self, stream: bytes, obj_id: int) -> bytes: if self.encryption_method == EncryptionMethod.NO_ENCRYPTION: return stream return bytes(self.encrypt_bytes(stream, obj_id)) - def is_aes_algorithm(self): - return self.encryption_method == EncryptionMethod.AES_128 + def is_aes_algorithm(self) -> bool: + return self.encryption_method in ( + EncryptionMethod.AES_128, + EncryptionMethod.AES_256, + ) - def encrypt_bytes(self, data, obj_id): + def encrypt_bytes(self, data: bytes, obj_id: int): """ PDF32000 reference - Algorithm 1: Encryption of data using the RC4 or AES algorithms Append object ID and generation ID to the key and encrypt the data @@ -218,22 +247,114 @@ def encrypt_bytes(self, data, obj_id): return self.encrypt_AES_cryptography(key, data) return ARC4().encrypt(key, data) - def encrypt_AES_cryptography(self, key, data): - iv = self.get_initialization_vector(16) - padder = PKCS7(self.key_length).padder() + def encrypt_AES_cryptography(self, key: bytes, data: bytes) -> bytes: + """Encrypts an array of bytes using AES algorithms (AES 128 or AES 256)""" + iv = bytearray(self.get_random_bytes(16)) + padder = PKCS7(128).padder() padded_data = padder.update(data) padded_data += padder.finalize() - cipher = Cipher(AES128(key), modes.CBC(iv)) + cipher = ( + Cipher(AES128(key), modes.CBC(iv)) + if self.encryption_method == EncryptionMethod.AES_128 + else Cipher(AES256(self.k), modes.CBC(iv)) + ) encryptor = cipher.encryptor() data = encryptor.update(padded_data) + encryptor.finalize() iv.extend(data) return iv @classmethod - def get_initialization_vector(cls, size): - return bytearray(urandom(size)) + def get_random_bytes(cls: Type["StandardSecurityHandler"], size: int) -> bytes: + """ + https://docs.python.org/3/library/os.html#os.urandom + os.urandom will use OS-specific sources to generate random bytes + suitable for cryptographic use + """ + return urandom(size) - def padded_password(self, password): + @classmethod + def prepare_string(cls: Type["StandardSecurityHandler"], string: str) -> bytes: + """ + PDF2.0 - ISO 32000-2:2020 + All passwords for revision 6 shall be based on Unicode. Preprocessing of a user-provided password + consists first of normalizing its representation by applying the "SASLPrep" profile (Internet RFC 4013) + of the "stringprep" algorithm (Internet RFC 3454) to the supplied password using the Normalize and BiDi + options. Next, the password string shall be converted to UTF-8 encoding, and then truncated to the + first 127 bytes if the string is longer than 127 bytes + + Python offers a stringprep module with the tables mapped in methods + """ + + # Mapping + def char_map(char: str) -> str: + if not char: + return "" + # Commonly mapped to nothing + if stringprep.in_table_b1(char): + return "" + # Map non-ascii space characters to space + if stringprep.in_table_c12(char): + return "\u0020" + return char + + if len(string) < 1: + return bytes() + + prepared_string = "".join(char_map(c) for c in string) + + # Normalization - applies Unicode normalization form KC + prepared_string = unicodedata.ucd_3_2_0.normalize("NFKC", prepared_string) + + # Prohibited output - RCF4013 2.3 + def is_prohibited(char: str) -> bool: + return ( + stringprep.in_table_c12(char) # Non-ASCII space characters + or stringprep.in_table_c21_c22(char) # Control characters + or stringprep.in_table_c3(char) # Private use + or stringprep.in_table_c4(char) # Non-character code points + or stringprep.in_table_c5(char) # Surrogate codes + or stringprep.in_table_c6(char) # Inappropriate for plain text + or stringprep.in_table_c7( + char + ) # Inappropriate for canonical representation + or stringprep.in_table_c8( + char + ) # Change display properties or are deprecated + or stringprep.in_table_c9(char) # Tagging characters + ) + + for char in prepared_string: + if is_prohibited(char): + raise FPDFException( + f"The password {string} contains prohibited characters" + ) + + # Bidirectional characters + def has_character(string: str, fun: Callable) -> bool: + return any(fun(char) for char in string) + + if has_character(prepared_string, stringprep.in_table_d1): + # If a string contains any RandALCat character, the string MUST NOT contain any LCat character. + if has_character(prepared_string, stringprep.in_table_d2): + raise FPDFException( + f"The password {string} contains invalid bidirectional characters." + ) + # If a string contains any RandALCat character, a RandALCat character MUST be the first character + # of the string, and a RandALCat character MUST be the last character of the string. + if not ( + stringprep.in_table_d1(prepared_string[0]) + and stringprep.in_table_d1(prepared_string[-1]) + ): + raise FPDFException( + f"The password {string} contains invalid bidirectional characters." + ) + + if len(prepared_string) > 127: + prepared_string = prepared_string[:127] + + return prepared_string.encode("UTF-8") + + def padded_password(self, password: str) -> bytearray: """ PDF32000 reference - Algorithm 2: Computing an encryption key Step (a) - Add the default padding at the end of provided password to make it 32 bit long @@ -244,7 +365,7 @@ def padded_password(self, password): p.extend(self.DEFAULT_PADDING[: (32 - len(p))]) return p - def generate_owner_password(self): + def generate_owner_password(self) -> str: """ PDF32000 reference - Algorithm 3: Computing the encryption dictionary's O (owner password) value The security handler is only using revision 3 or 4, so the legacy r2 version is not implemented here @@ -258,10 +379,10 @@ def generate_owner_password(self): new_key = [] for k in rc4key: new_key.append(k ^ i) - result = ARC4().encrypt(new_key, result) + result = ARC4().encrypt(bytes(new_key), result) return bytes(result).hex() - def generate_user_password(self): + def generate_user_password(self) -> str: """ PDF32000 reference - Algorithm 5: Computing the encryption dictionary's U (user password) value The security handler is only using revision 3 or 4, so the legacy r2 version is not implemented here @@ -269,19 +390,131 @@ def generate_user_password(self): m = hashlib.new("md5", usedforsecurity=False) m.update(bytearray(self.DEFAULT_PADDING)) m.update(bytes.fromhex(self.info_id)) - result = m.digest() + result = bytearray(m.digest()) key = self.k for i in range(20): new_key = [] for k in key: new_key.append(k ^ i) - result = ARC4().encrypt(new_key, result) + result = ARC4().encrypt(bytes(new_key), result) result.extend( (result[x] ^ self.DEFAULT_PADDING[x]) for x in range(16) ) # add 16 bytes of random padding return bytes(result).hex() - def generate_encryption_key(self): + @classmethod + def compute_hash( + cls: Type["StandardSecurityHandler"], + input_password: bytes, + salt: bytes, + user_key: bytes = bytearray(), + ) -> bytes: + """ + Algorithm 2B - section 7.6.4.3.4 of the ISO 32000-2:2020 + Applied on Security handlers revision 6 + """ + k = hashlib.sha256(input_password + salt + user_key).digest() + round_number = 0 + while True: + round_number += 1 + k1 = input_password + k + user_key + # Step (a + b) + cipher = Cipher(AES128(k[:16]), modes.CBC(k[16:32])) + encryptor = cipher.encryptor() + e = encryptor.update(k1 * 64) + encryptor.finalize() + # Step (c) + # remainder = int.from_bytes(e[:16], byteorder="big") % 3 + remainder = sum(e[:16]) % 3 + # Step (d) + if remainder == 0: + k = hashlib.sha256(e).digest() + elif remainder == 1: + k = hashlib.sha384(e).digest() + else: + k = hashlib.sha512(e).digest() + # Step (e) + if round_number >= 64 and e[-1] <= round_number - 32: + break + + return k[:32] + + def generate_user_password_rev6(self) -> None: + """ + Generating the U (user password) and UE (user encryption) + for security handlers of revision 6 + Algorithm 8 - Section 7.6.4.4.7 of the ISO 32000-2:2020 + """ + user_password = self.prepare_string(self.user_password) + if not user_password: + user_password = bytearray() + user_validation_salt = self.get_random_bytes(8) + user_key_salt = self.get_random_bytes(8) + u = ( + self.compute_hash(input_password=user_password, salt=user_validation_salt) + + user_validation_salt + + user_key_salt + ) + self.u = u.hex() + + key = self.compute_hash(input_password=user_password, salt=user_key_salt) + cipher = Cipher(AES256(key), modes.CBC(b"\x00" * 16)) + encryptor = cipher.encryptor() + ue = encryptor.update(self.k) + encryptor.finalize() + self.ue = ue.hex() + + def generate_owner_password_rev6(self) -> None: + """ + Generating the O (owner password) and OE (owner encryption) + for security handlers of revision 6 + Algorithm 9 - Section 7.6.4.4.8 of the ISO 32000-2:2020 + """ + owner_password = self.prepare_string(self.owner_password) + if not owner_password: + raise FPDFException(f"Invalid owner password {self.owner_password}") + owner_validation_salt = self.get_random_bytes(8) + owner_key_salt = self.get_random_bytes(8) + o = ( + self.compute_hash( + input_password=owner_password, + salt=owner_validation_salt, + user_key=bytes.fromhex(self.u), + ) + + owner_validation_salt + + owner_key_salt + ) + self.o = o.hex() + + key = self.compute_hash( + input_password=owner_password, + salt=owner_key_salt, + user_key=bytes.fromhex(self.u), + ) + + cipher = Cipher(AES256(key), modes.CBC(b"\x00" * 16)) + encryptor = cipher.encryptor() + oe = encryptor.update(self.k) + encryptor.finalize() + self.oe = oe.hex() + + def generate_perms_rev6(self) -> None: + """ + 7.6.4.4.9 Algorithm 10: Computing the encryption dictionary’s Perms (permissions) value + (Security handlers of revision 6) of the ISO 32000-2:2020 + """ + perms64b = 0xFFFFFFFF00000000 | self.access_permission + encrypt_metadata = b"T" if self.encrypt_metadata else b"F" + perms_input = ( + perms64b.to_bytes(8, byteorder="little", signed=False) + + encrypt_metadata + + b"adb" + + self.get_random_bytes(4) + ) + # nosemgrep: python.cryptography.security.insecure-cipher-mode-ecb.insecure-cipher-mode-ecb + cipher = Cipher(AES256(self.k), modes.ECB()) + encryptor = cipher.encryptor() + perms = encryptor.update(perms_input) + encryptor.finalize() + self.perms = perms.hex() + + def generate_encryption_key(self) -> bytes: """ PDF32000 reference Algorithm 2: Computing an encryption key @@ -295,7 +528,7 @@ def generate_encryption_key(self): ) ) m.update(bytes.fromhex(self.info_id)) - if self.encrypt_metadata is False and self.v == 4: + if self.encrypt_metadata is False and self.version == 4: m.update(bytes([0xFF, 0xFF, 0xFF, 0xFF])) result = m.digest()[: (math.ceil(self.key_length / 8))] for _ in range(50): @@ -303,13 +536,13 @@ def generate_encryption_key(self): return result -def md5(data): +def md5(data: Union[bytes, bytearray]) -> bytes: h = hashlib.new("md5", usedforsecurity=False) h.update(data) return h.digest() -def int32(n): +def int32(n: int) -> int: """convert long to signed 32 bit integer""" n = n & 0xFFFFFFFF return (n ^ 0x80000000) - 0x80000000 diff --git a/fpdf/enums.py b/fpdf/enums.py index 32b73a9de..68b184a5b 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -845,3 +845,4 @@ class EncryptionMethod(Enum): NO_ENCRYPTION = 0 RC4 = 1 AES_128 = 2 + AES_256 = 3 diff --git a/test/encryption/encryption_aes256.pdf b/test/encryption/encryption_aes256.pdf new file mode 100644 index 000000000..f90f86209 Binary files /dev/null and b/test/encryption/encryption_aes256.pdf differ diff --git a/test/encryption/encryption_aes256_user_password.pdf b/test/encryption/encryption_aes256_user_password.pdf new file mode 100644 index 000000000..a12487e97 Binary files /dev/null and b/test/encryption/encryption_aes256_user_password.pdf differ diff --git a/test/encryption/test_encryption.py b/test/encryption/test_encryption.py index 81eeeab95..5d0fc8fe4 100644 --- a/test/encryption/test_encryption.py +++ b/test/encryption/test_encryption.py @@ -1,11 +1,14 @@ # pylint: disable=protected-access +from os import devnull from pathlib import Path import sys import pytest from fpdf import FPDF +from fpdf.encryption import StandardSecurityHandler as sh from fpdf.enums import AccessPermission, EncryptionMethod +from fpdf.errors import FPDFException from test.conftest import assert_pdf_equal HERE = Path(__file__).resolve().parent @@ -132,7 +135,7 @@ def fixed_iv(size): encryption_method=EncryptionMethod.AES_128, permissions=AccessPermission.none(), ) - pdf._security_handler.get_initialization_vector = fixed_iv + pdf._security_handler.get_random_bytes = fixed_iv assert_pdf_equal(pdf, HERE / "encryption_aes128.pdf", tmp_path) @@ -201,3 +204,92 @@ def test_encrypt_outline(tmp_path): # issue 732 pdf.start_section("Subtitle", level=1) pdf.set_encryption(owner_password="fpdf2") assert_pdf_equal(pdf, HERE / "encrypt_outline.pdf", tmp_path) + + +def test_encryption_aes256(tmp_path): + pdf = FPDF() + + def custom_file_id(): + return pdf._default_file_id(bytearray([0xFF])) + + pdf.file_id = custom_file_id + + def fixed_iv(size): + return bytearray(size) + + pdf.set_author("author") + pdf.set_subject("string to be encrypted") + pdf.add_page() + pdf.set_font("helvetica", size=12) + pdf.cell(txt="hello world") + pdf.text(50, 50, "Some text") + pdf.ink_annotation( + [(40, 50), (70, 25), (100, 50), (70, 75), (40, 50)], + title="Lucas", + contents="Some encrypted annotation", + ) + pdf.set_encryption( + owner_password="fpdf2", + encryption_method=EncryptionMethod.AES_256, + permissions=AccessPermission.none(), + ) + pdf._security_handler.get_random_bytes = fixed_iv + assert_pdf_equal(pdf, HERE / "encryption_aes256.pdf", tmp_path) + + +def test_encryption_aes256_with_user_password(tmp_path): + pdf = FPDF() + + def custom_file_id(): + return pdf._default_file_id(bytearray([0xFF])) + + pdf.file_id = custom_file_id + + def fixed_iv(size): + return bytearray(size) + + pdf.set_author("author") + pdf.set_subject("string to be encrypted") + pdf.add_page() + pdf.set_font("helvetica", size=12) + pdf.cell(txt="hello world") + pdf.set_encryption( + owner_password="fpdf2", + user_password="1" * 1000, + encryption_method=EncryptionMethod.AES_256, + permissions=AccessPermission.all(), + ) + pdf._security_handler.get_random_bytes = fixed_iv + assert_pdf_equal(pdf, HERE / "encryption_aes256_user_password.pdf", tmp_path) + + +def test_blank_owner_password(tmp_path): + pdf = FPDF() + pdf.set_encryption( + owner_password="", + encryption_method=EncryptionMethod.AES_256, + permissions=AccessPermission.none(), + ) + with pytest.raises(FPDFException) as e: + pdf.output(devnull) + assert str(e.value) == "Invalid owner password " + + +def test_password_prep(): + """ + The PDF standard requires the passwords to be prepared using the stringprep algorithm + using the SASLprep as per RFC 4013 + https://datatracker.ietf.org/doc/html/rfc4013 + Those assertions are bases on the examples section of the RFC + """ + assert sh.prepare_string("I\xadX") == b"IX" # SOFT HYPHEN mapped to nothing + assert sh.prepare_string("user") == b"user" # no transformation + assert sh.prepare_string("USER") == b"USER" # case preserved + assert sh.prepare_string("\xaa") == b"a" # output is NFKC, input in ISO 8859-1 + assert sh.prepare_string("\u2168") == b"IX" # output is NFKC, will match #1 + with pytest.raises(FPDFException) as e: + sh.prepare_string("\x07") # Error - prohibited character + assert str(e.value) == "The password  contains prohibited characters" + with pytest.raises(FPDFException) as e: + sh.prepare_string("\u0627\x31") # Error - bidirectional check + assert sh.prepare_string("A" * 300) == b"A" * 127 # test cap 127 chars