Skip to content

Commit

Permalink
Replace PyCrypto with tinyaes for encryption support
Browse files Browse the repository at this point in the history
[tinyaes](https://github.com/naufraghi/tinyaes-py) is a minimal AES-only
library that wraps the C library [tiny-AES-c](https://github.com/kokke/tiny-AES-c).

Currently the library wraps only the CTR mode that can be used in
PyInstaller instead of the CFB mode used before.

Reading various sources, CFB is [somewhat similar to CBC](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_Feedback_(CFB)))
and CTR is suggested as a go-to stream cipher (https://crypto.stackexchange.com/questions/6029/aes-cbc-mode-or-aes-ctr-mode-recommended and links)
that can be used instead of CBC.

Closes #2365

PR iterations:

- use tinyaes from pypi (installing from git triggers a key confirmation)
- make lint job happy
- remove some unused imports
- better explain test for issue #1663
  • Loading branch information
naufraghi committed Mar 21, 2020
1 parent 3e6f7dc commit d084d5d
Show file tree
Hide file tree
Showing 6 changed files with 42 additions and 84 deletions.
39 changes: 6 additions & 33 deletions PyInstaller/archive/pyz_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,6 @@
BLOCK_SIZE = 16


def import_aes(module_name):
"""
Tries to import the AES module from PyCrypto.
PyCrypto 2.4 and 2.6 uses different name of the AES extension.
"""
return __import__(module_name, fromlist=[module_name.split('.')[-1]])


def get_crypto_hiddenimports():
"""
These module names are appended to the PyInstaller analysis phase.
:return: Name of the AES module.
"""
try:
# The _AES.so module exists only in PyCrypto 2.6 and later. Try to import
# that first.
modname = 'Crypto.Cipher._AES'
import_aes(modname)
except ImportError:
# Fallback to AES.so, which should be there in PyCrypto 2.4 and earlier.
modname = 'Crypto.Cipher.AES'
import_aes(modname)
return modname


class PyiBlockCipher(object):
"""
This class is used only to encrypt Python modules.
Expand All @@ -52,15 +26,14 @@ def __init__(self, key=None):
self.key = key.zfill(BLOCK_SIZE)
assert len(self.key) == BLOCK_SIZE

# Import the right AES module.
self._aesmod = import_aes(get_crypto_hiddenimports())
import tinyaes
self._aesmod = tinyaes

def encrypt(self, data):
iv = os.urandom(BLOCK_SIZE)
return iv + self.__create_cipher(iv).encrypt(data)
return iv + self.__create_cipher(iv).CTR_xcrypt_buffer(data)

def __create_cipher(self, iv):
# The 'BlockAlgo' class is stateful, this factory method is used to
# re-initialize the block cipher class with each call to encrypt() and
# decrypt().
return self._aesmod.new(self.key.encode(), self._aesmod.MODE_CFB, iv)
# The 'AES' class is stateful, this factory method is used to
# re-initialize the block cipher class with each call to xcrypt().
return self._aesmod.AES(self.key.encode(), iv)
7 changes: 4 additions & 3 deletions PyInstaller/building/build_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
from ..depend.utils import create_py3_base_library, scan_code_for_ctypes
from ..archive import pyz_crypto
from ..utils.misc import get_path_to_toplevel_modules, get_unicode_modules, mtime
from ..configure import get_importhooks_dir

if is_win:
from ..utils.win32 import winmanifest
Expand Down Expand Up @@ -160,6 +159,9 @@ def __init__(self, scripts, pathex=None, binaries=None, datas=None,
runtime_hooks
An optional list of scripts to use as users' runtime hooks. Specified
as file names.
cipher
Add optional instance of the pyz_crypto.PyiBlockCipher class
(with a provided key).
win_no_prefer_redirects
If True, prefers not to follow version redirects when searching for
Windows SxS Assemblies.
Expand Down Expand Up @@ -221,8 +223,7 @@ def __init__(self, scripts, pathex=None, binaries=None, datas=None,
with open_file(pyi_crypto_key_path, 'w', encoding='utf-8') as f:
f.write('# -*- coding: utf-8 -*-\n'
'key = %r\n' % cipher.key)
logger.info('Adding dependencies on pyi_crypto.py module')
self.hiddenimports.append(pyz_crypto.get_crypto_hiddenimports())
self.hiddenimports.append('tinyaes')

self.excludes = excludes or []
self.scripts = TOC()
Expand Down
15 changes: 5 additions & 10 deletions PyInstaller/building/makespec.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import os
import sys
import argparse
from distutils.version import LooseVersion

from .. import HOMEPATH, DEFAULT_SPECPATH
from .. import log as logging
Expand Down Expand Up @@ -393,18 +392,14 @@ def main(scripts, name=None, onefile=None,
scripts = list(map(Path, scripts))

if key:
# Tries to import PyCrypto since we need it for bytecode obfuscation. Also make sure its
# version is >= 2.4.
# Tries to import tinyaes since we need it for bytecode obfuscation.
try:
import Crypto
is_version_acceptable = LooseVersion(Crypto.__version__) >= LooseVersion('2.4')
if not is_version_acceptable:
logger.error('PyCrypto version must be >= 2.4, older versions are not supported.')
sys.exit(1)
import tinyaes # noqa: F401 (test import)
except ImportError:
logger.error('We need PyCrypto >= 2.4 to use byte-code obfuscation but we could not')
logger.error('We need tinyaes to use byte-code obfuscation but we '
'could not')
logger.error('find it. You can install it with pip by running:')
logger.error(' pip install PyCrypto')
logger.error(' pip install tinyaes')
sys.exit(1)
cipher_init = cipher_init_template % {'key': key}
else:
Expand Down
40 changes: 10 additions & 30 deletions PyInstaller/loader/pyimod02_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
PYZ_TYPE_PKG = 1
PYZ_TYPE_DATA = 2


class FilePos(object):
"""
This class keeps track of the file object representing and current position
Expand Down Expand Up @@ -150,7 +151,6 @@ def __init__(self, path=None, start=0):
self.checkmagic()
self.loadtoc()


def loadtoc(self):
"""
Overridable.
Expand Down Expand Up @@ -249,40 +249,20 @@ def __init__(self):
self.key = key.zfill(CRYPT_BLOCK_SIZE)
assert len(self.key) == CRYPT_BLOCK_SIZE

# Import the right AES module.
self._aes = self._import_aesmod()

def _import_aesmod(self):
"""
Tries to import the AES module from PyCrypto.
PyCrypto 2.4 and 2.6 uses different name of the AES extension.
"""
# The _AES.so module exists only in PyCrypto 2.6 and later. Try to import
# that first.
modname = 'Crypto.Cipher._AES'

kwargs = dict(fromlist=['Crypto', 'Cipher'])
try:
mod = __import__(modname, **kwargs)
except ImportError:
modname = 'Crypto.Cipher.AES'
mod = __import__(modname, **kwargs)

import tinyaes
self._aesmod = tinyaes
# Issue #1663: Remove the AES module from sys.modules list. Otherwise
# it interferes with using 'Crypto.Cipher' module in users' code.
if modname in sys.modules:
del sys.modules[modname]
return mod
# it interferes with using 'tinyaes' module in users' code.
del sys.modules['tinyaes']

def __create_cipher(self, iv):
# The 'BlockAlgo' class is stateful, this factory method is used to
# re-initialize the block cipher class with each call to encrypt() and
# decrypt().
return self._aes.new(self.key, self._aes.MODE_CFB, iv)
# The 'AES' class is stateful, this factory method is used to
# re-initialize the block cipher class with each call to xcrypt().
return self._aesmod.AES(self.key.encode(), iv)

def decrypt(self, data):
return self.__create_cipher(data[:CRYPT_BLOCK_SIZE]).decrypt(data[CRYPT_BLOCK_SIZE:])
cipher = self.__create_cipher(data[:CRYPT_BLOCK_SIZE])
return cipher.CTR_xcrypt_buffer(data[CRYPT_BLOCK_SIZE:])


class ZlibArchiveReader(ArchiveReader):
Expand Down
22 changes: 14 additions & 8 deletions tests/functional/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@

# Local imports
# -------------
from PyInstaller.compat import is_darwin, is_win, is_py37
from PyInstaller.compat import is_darwin, is_win
from PyInstaller.utils.tests import importorskip, skipif, skipif_win, \
skipif_winorosx, skipif_notwin, skipif_notosx, skipif_no_compiler, \
skipif_notlinux, xfail
from PyInstaller.utils.hooks import is_module_satisfies


def test_run_from_path_environ(pyi_builder):
Expand Down Expand Up @@ -82,6 +81,7 @@ def MyEXE(*args, **kwargs):

pyi_builder.test_source("print('Hello Python!')")


def test_base_modules_regex(pyi_builder):
"""
Verify that the regex for excluding modules listed in
Expand Down Expand Up @@ -122,6 +122,7 @@ def test_compiled_filenames(pyi_builder):
assert not isabs(pyi_dummy_module.DummyClass.dummyMethod.__code__.co_filename), "pyi_dummy_module.DummyClass.dummyMethod.__code__.co_filename has compiled filename: %s" % (pyi_dummy_module.DummyClass.dummyMethod.__code__.co_filename,)
""")


def test_decoders_ascii(pyi_builder):
pyi_builder.test_source(
"""
Expand Down Expand Up @@ -162,17 +163,16 @@ def test_email(pyi_builder):
""")


@skipif(is_module_satisfies('Crypto >= 3'), reason='Bytecode encryption is not '
'compatible with pycryptodome.')
@importorskip('Crypto')
@importorskip('tinyaes')
def test_feature_crypto(pyi_builder):
pyi_builder.test_source(
"""
from pyimod00_crypto_key import key
from pyimod02_archive import CRYPT_BLOCK_SIZE
# Issue 1663: Crypto feature caused issues when using PyCrypto module.
import Crypto.Cipher.AES
# Test against issue #1663: importing a package in the bootstrap
# phase should not interfere with subsequent imports.
import tinyaes
assert type(key) is str
# The test runner uses 'test_key' as key.
Expand Down Expand Up @@ -334,6 +334,7 @@ def MyEXE(*args, **kwargs):
_, err = capsys.readouterr()
assert "'import warnings' failed" not in err


@skipif_win
def test_python_makefile(pyi_builder):
pyi_builder.test_script('pyi_python_makefile.py')
Expand Down Expand Up @@ -518,13 +519,16 @@ def _find_executables(name):
pyi_builder._find_executables = _find_executables
pyi_builder.test_source("print('Hello Python!')")


def test_spec_with_utf8(pyi_builder_spec):
pyi_builder_spec.test_spec('spec-with-utf8.spec')


@skipif_notosx
def test_osx_override_info_plist(pyi_builder_spec):
pyi_builder_spec.test_spec('pyi_osx_override_info_plist.spec')


def test_hook_collect_submodules(pyi_builder, script_dir):
# This is designed to test the operation of
# PyInstaller.utils.hook.collect_submodules. To do so:
Expand All @@ -543,10 +547,12 @@ def test_hook_collect_submodules(pyi_builder, script_dir):
""",
['--additional-hooks-dir=%s' % script_dir.join('pyi_hooks')])


# Test that PyInstaller can handle a script with an arbitrary extension.
def test_arbitrary_ext(pyi_builder):
pyi_builder.test_script('pyi_arbitrary_ext.foo')


def test_option_runtime_tmpdir(pyi_builder):
"Test to ensure that option `runtime_tmpdir` can be set and has effect."

Expand All @@ -566,7 +572,7 @@ def test_option_runtime_tmpdir(pyi_builder):
' sys._MEIPASS = ' + runtime_tmpdir + ', cwd = ' + cwd)
print('test - done')
""",
['--runtime-tmpdir=.']) # set runtime-tmpdir to current working dir
['--runtime-tmpdir=.']) # set runtime-tmpdir to current working dir


@xfail(reason='Issue #3037 - all scripts share the same global vars')
Expand Down
3 changes: 3 additions & 0 deletions tests/requirements-tools.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ flake8-diff
pywin32; sys_platform == 'win32'

lxml

# crypto support (`--key` option)
tinyaes ~= 1.0

0 comments on commit d084d5d

Please sign in to comment.