Skip to content

Commit

Permalink
ENH: Use cryptography for encryption/decryption as a fallback for P…
Browse files Browse the repository at this point in the history
…yCryptodome (#2000)

Use the [`cryptography` package](https://pypi.org/project/cryptography/) (maintained by the Python Cryptographic Authority, pyca) as a fallback if [PyCryptodome](https://pypi.org/project/pycryptodome/) (maintained by Legrandin / Helder Eijs) is not installed.

Closes #1998
  • Loading branch information
exiledkingcc authored Jul 27, 2023
1 parent 0a9055d commit 277643f
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 136 deletions.
14 changes: 10 additions & 4 deletions .github/workflows/github-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ jobs:
strategy:
matrix:
python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11"]
use-cryptodome: [""]
use-crypto-lib: ["pycryptodome"]
include:
- python-version: "3.9"
use-crypto-lib: "cryptography"
- python-version: "3.10"
use-cryptodome: "false"
use-crypto-lib: ""
steps:
- name: Checkout Code
uses: actions/checkout@v3
Expand Down Expand Up @@ -62,10 +64,14 @@ jobs:
run: |
pip install -r requirements/ci-3.11.txt
if: matrix.python-version == '3.11'
- name: Remove cryptodome
- name: Remove pycryptodome
run: |
pip uninstall pycryptodome -y
if: matrix.use-cryptodome == 'false'
if: matrix.use-crypto-lib != 'pycryptodome'
- name: Install cryptography
run: |
pip install cryptography
if: matrix.use-crypto-lib == 'cryptography'
- name: Install pypdf
run: |
pip install .
Expand Down
76 changes: 76 additions & 0 deletions pypdf/_crypt_providers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright (c) 2023, exiledkingcc
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

from pypdf._crypt_providers._base import CryptBase, CryptIdentity

try:
from pypdf._crypt_providers._pycryptodome import (
CryptAES,
CryptRC4,
aes_cbc_decrypt,
aes_cbc_encrypt,
aes_ecb_decrypt,
aes_ecb_encrypt,
rc4_decrypt,
rc4_encrypt,
)
except ImportError:
try:
from pypdf._crypt_providers._cryptography import ( # type: ignore
CryptAES,
CryptRC4,
aes_cbc_decrypt,
aes_cbc_encrypt,
aes_ecb_decrypt,
aes_ecb_encrypt,
rc4_decrypt,
rc4_encrypt,
)
except ImportError:
from pypdf._crypt_providers._fallback import ( # type: ignore
CryptAES,
CryptRC4,
aes_cbc_decrypt,
aes_cbc_encrypt,
aes_ecb_decrypt,
aes_ecb_encrypt,
rc4_decrypt,
rc4_encrypt,
)

__all__ = [
"CryptBase",
"CryptIdentity",
"CryptRC4",
"CryptAES",
"rc4_encrypt",
"rc4_decrypt",
"aes_ecb_encrypt",
"aes_ecb_decrypt",
"aes_cbc_encrypt",
"aes_cbc_decrypt",
]
38 changes: 38 additions & 0 deletions pypdf/_crypt_providers/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Copyright (c) 2023, exiledkingcc
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.


class CryptBase:
def encrypt(self, data: bytes) -> bytes: # pragma: no cover
return data

def decrypt(self, data: bytes) -> bytes: # pragma: no cover
return data


class CryptIdentity(CryptBase):
pass
109 changes: 109 additions & 0 deletions pypdf/_crypt_providers/_cryptography.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Copyright (c) 2023, exiledkingcc
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

import secrets

from cryptography.hazmat.primitives import padding # type: ignore[import]
from cryptography.hazmat.primitives.ciphers.algorithms import AES, ARC4 # type: ignore[import]
from cryptography.hazmat.primitives.ciphers.base import Cipher # type: ignore[import]
from cryptography.hazmat.primitives.ciphers.modes import CBC, ECB # type: ignore[import]

from pypdf._crypt_providers._base import CryptBase


class CryptRC4(CryptBase):
def __init__(self, key: bytes) -> None:
self.cipher = Cipher(ARC4(key), mode=None)

def encrypt(self, data: bytes) -> bytes:
encryptor = self.cipher.encryptor()
return encryptor.update(data) + encryptor.finalize()

def decrypt(self, data: bytes) -> bytes:
decryptor = self.cipher.decryptor()
return decryptor.update(data) + decryptor.finalize()


class CryptAES(CryptBase):
def __init__(self, key: bytes) -> None:
self.alg = AES(key)

def encrypt(self, data: bytes) -> bytes:
iv = secrets.token_bytes(16)
pad = padding.PKCS7(128).padder()
data = pad.update(data) + pad.finalize()

cipher = Cipher(self.alg, CBC(iv))
encryptor = cipher.encryptor()
return iv + encryptor.update(data) + encryptor.finalize()

def decrypt(self, data: bytes) -> bytes:
iv = data[:16]
data = data[16:]
# for empty encrypted data
if not data:
return data

# just for robustness, it does not happen under normal circumstances
if len(data) % 16 != 0:
pad = padding.PKCS7(128).padder()
data = pad.update(data) + pad.finalize()

cipher = Cipher(self.alg, CBC(iv))
decryptor = cipher.decryptor()
d = decryptor.update(data) + decryptor.finalize()
return d[: -d[-1]]


def rc4_encrypt(key: bytes, data: bytes) -> bytes:
encryptor = Cipher(ARC4(key), mode=None).encryptor()
return encryptor.update(data) + encryptor.finalize()


def rc4_decrypt(key: bytes, data: bytes) -> bytes:
decryptor = Cipher(ARC4(key), mode=None).decryptor()
return decryptor.update(data) + decryptor.finalize()


def aes_ecb_encrypt(key: bytes, data: bytes) -> bytes:
encryptor = Cipher(AES(key), mode=ECB()).encryptor()
return encryptor.update(data) + encryptor.finalize()


def aes_ecb_decrypt(key: bytes, data: bytes) -> bytes:
decryptor = Cipher(AES(key), mode=ECB()).decryptor()
return decryptor.update(data) + decryptor.finalize()


def aes_cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
encryptor = Cipher(AES(key), mode=CBC(iv)).encryptor()
return encryptor.update(data) + encryptor.finalize()


def aes_cbc_decrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
decryptor = Cipher(AES(key), mode=CBC(iv)).decryptor()
return decryptor.update(data) + decryptor.finalize()
90 changes: 90 additions & 0 deletions pypdf/_crypt_providers/_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright (c) 2023, exiledkingcc
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# * The name of the author may not be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

from pypdf._crypt_providers._base import CryptBase
from pypdf.errors import DependencyError

_DEPENDENCY_ERROR_STR = "PyCryptodome is required for AES algorithm"


class CryptRC4(CryptBase): # type: ignore
def __init__(self, key: bytes) -> None:
self.S = bytearray(range(256))
j = 0
for i in range(256):
j = (j + self.S[i] + key[i % len(key)]) % 256
self.S[i], self.S[j] = self.S[j], self.S[i]

def encrypt(self, data: bytes) -> bytes:
S = bytearray(self.S)
out = [0 for _ in range(len(data))]
i, j = 0, 0
for k in range(len(data)):
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
x = S[(S[i] + S[j]) % 256]
out[k] = data[k] ^ x
return bytes(bytearray(out))

def decrypt(self, data: bytes) -> bytes:
return self.encrypt(data)


class CryptAES(CryptBase): # type: ignore
def __init__(self, key: bytes) -> None:
pass

def encrypt(self, data: bytes) -> bytes:
raise DependencyError(_DEPENDENCY_ERROR_STR)

def decrypt(self, data: bytes) -> bytes:
raise DependencyError(_DEPENDENCY_ERROR_STR)


def rc4_encrypt(key: bytes, data: bytes) -> bytes:
return CryptRC4(key).encrypt(data)


def rc4_decrypt(key: bytes, data: bytes) -> bytes:
return CryptRC4(key).decrypt(data)


def aes_ecb_encrypt(key: bytes, data: bytes) -> bytes:
raise DependencyError(_DEPENDENCY_ERROR_STR)


def aes_ecb_decrypt(key: bytes, data: bytes) -> bytes:
raise DependencyError(_DEPENDENCY_ERROR_STR)


def aes_cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
raise DependencyError(_DEPENDENCY_ERROR_STR)


def aes_cbc_decrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
raise DependencyError(_DEPENDENCY_ERROR_STR)
Loading

0 comments on commit 277643f

Please sign in to comment.