Skip to content

Commit

Permalink
feat: AES-2 encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
michalc committed Jan 3, 2024
1 parent e5de07c commit ed5bcdf
Show file tree
Hide file tree
Showing 3 changed files with 73 additions and 9 deletions.
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Topic :: System :: Archiving :: Compression",
]
dependencies = [
"pycryptodome>=3.10.1",
]

[project.optional-dependencies]
dev = [
Expand All @@ -25,6 +28,7 @@ dev = [
"stream-unzip>=0.0.86"
]
ci = [
"pycryptodome==3.10.1",
"coverage==6.2",
"pytest==6.2.5",
"pytest-cov==3.0.0",
Expand Down
31 changes: 22 additions & 9 deletions stream_zip.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from collections import deque
from struct import Struct
import secrets
import zlib

from Crypto.Cipher import AES
from Crypto.Hash import HMAC, SHA1
from Crypto.Util import Counter
from Crypto.Protocol.KDF import PBKDF2

# Private methods

_NO_COMPRESSION_BUFFERED_32 = object()
Expand Down Expand Up @@ -63,7 +69,7 @@ def method_compressobj(offset, default_get_compressobj):
return method_compressobj


def stream_zip(files, chunk_size=65536, get_compressobj=lambda: zlib.compressobj(wbits=-zlib.MAX_WBITS, level=9), extended_timestamps=True):
def stream_zip(files, chunk_size=65536, get_compressobj=lambda: zlib.compressobj(wbits=-zlib.MAX_WBITS, level=9), extended_timestamps=True, password=None):

def evenly_sized(chunks):
chunk = b''
Expand Down Expand Up @@ -119,6 +125,9 @@ def get_zipped_chunks_uneven():
mod_at_unix_extra_signature = b'UT'
mod_at_unix_extra_struct = Struct('<2sH1sl')

aes_extra_signature = b'\x01\x99'
aes_extra_struct = Struct('<2sHH2sBH')

modified_at_struct = Struct('<HH')

central_directory = deque()
Expand Down Expand Up @@ -188,7 +197,7 @@ def _zip_64_local_header_and_data(compression, name_encoded, mod_at_ms_dos, mod_

# (encryption,) data descriptor and utf-8 file names
flags = \
b'\x88\x08' if aes_extra else \
b'\x09\x08' if aes_extra else \
b'\x08\x08'

yield from _(local_header_signature)
Expand Down Expand Up @@ -252,7 +261,7 @@ def _zip_32_local_header_and_data(compression, name_encoded, mod_at_ms_dos, mod_

# (encryption,) data descriptor and utf-8 file names
flags = \
b'\x88\x08' if aes_extra else \
b'\x09\x08' if aes_extra else \
b'\x08\x08'

yield from _(local_header_signature)
Expand Down Expand Up @@ -343,7 +352,7 @@ def _no_compression_64_local_header_and_data(compression, name_encoded, mod_at_m

# (encryption and) utf-8 file names
flags = \
b'\x80\x08' if aes_extra else \
b'\x01\x08' if aes_extra else \
b'\x00\x08'

yield from _(local_header_signature)
Expand Down Expand Up @@ -401,7 +410,7 @@ def _no_compression_32_local_header_and_data(compression, name_encoded, mod_at_m

# (encryption and) utf-8 file names
flags = \
b'\x80\x08' if aes_extra else \
b'\x01\x08' if aes_extra else \
b'\x00\x08'

extra = mod_at_unix_extra + aes_extra
Expand Down Expand Up @@ -476,7 +485,7 @@ def _no_compression_streamed_64_local_header_and_data(compression, name_encoded,

# (encryption and) utf-8 file names
flags = \
b'\x80\x08' if aes_extra else \
b'\x01\x08' if aes_extra else \
b'\x00\x08'

yield from _(local_header_signature)
Expand Down Expand Up @@ -533,7 +542,7 @@ def _no_compression_streamed_32_local_header_and_data(compression, name_encoded,

# (encryption and) utf-8 file names
flags = \
b'\x80\x08' if aes_extra else \
b'\x01\x08' if aes_extra else \
b'\x00\x08'

yield from _(local_header_signature)
Expand Down Expand Up @@ -616,15 +625,19 @@ def _no_compression_streamed_data(chunks, uncompressed_size, crc_32, maximum_siz
(mode << 16) | \
(0x10 if name_encoded[-1:] == b'/' else 0x0) # MS-DOS directory

data_func, compression = \
data_func, raw_compression = \
(_zip_64_local_header_and_data, 8) if _method is _ZIP_64 else \
(_zip_32_local_header_and_data, 8) if _method is _ZIP_32 else \
(_no_compression_64_local_header_and_data, 0) if _method is _NO_COMPRESSION_BUFFERED_64 else \
(_no_compression_32_local_header_and_data, 0) if _method is _NO_COMPRESSION_BUFFERED_32 else \
(_no_compression_streamed_64_local_header_and_data, 0) if _method is _NO_COMPRESSION_STREAMED_64 else \
(_no_compression_streamed_32_local_header_and_data, 0)

aes_extra, encryption_func = (b'', _no_encryption)
compression, aes_extra, encryption_func = \
(99, aes_extra_struct.pack(aes_extra_signature, 7, 2, b'AE', 3, raw_compression), _aes_encrypted) if password is not None else \
(raw_compression, b'', _no_encryption)

print("STRUCT PACK", aes_extra)

central_directory_header_entry, name_encoded, extra = yield from data_func(compression, name_encoded, mod_at_ms_dos, mod_at_unix_extra, aes_extra, external_attr, uncompressed_size, crc_32, _get_compress_obj, encryption_func, evenly_sized(chunks))
central_directory_size += len(central_directory_header_signature) + len(central_directory_header_entry) + len(name_encoded) + len(extra)
Expand Down
47 changes: 47 additions & 0 deletions test_stream_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -1082,3 +1082,50 @@ def test_unzip_modification_time_extended_timestamps_disabled(method, timezone,
subprocess.run(['unzip', f'{d}/test.zip', '-d', d], env={'TZ': timezone})

assert os.path.getmtime('my_file') == expected_modified_at.timestamp()


@pytest.mark.parametrize(
"method",
[
ZIP_32,
ZIP_64,
NO_COMPRESSION_64,
NO_COMPRESSION_32,
],
)
def test_password_unzips_with_stream_unzip(method):
now = datetime.strptime('2021-01-01 21:01:12', '%Y-%m-%d %H:%M:%S')
mode = stat.S_IFREG | 0o600
password = 'my-pass'

files = (
('file-1', now, mode, method, (b'a' * 10000, b'b' * 10000)),
)

assert [
(b'file-1', None, b'a' * 10000 + b'b' * 10000),
] == [
(name, size, b''.join(chunks))
for name, size, chunks in stream_unzip(stream_zip(files, password=password), password=password)
]


@pytest.mark.parametrize(
"method",
[
ZIP_32,
ZIP_64,
NO_COMPRESSION_64,
NO_COMPRESSION_32,
],
)
def test_password_bytes_not_deterministic(method):
now = datetime.strptime('2021-01-01 21:01:12', '%Y-%m-%d %H:%M:%S')
mode = stat.S_IFREG | 0o600
password = 'my-pass'

files = (
('file-1', now, mode, method, (b'a' * 10000, b'b' * 10000)),
)

assert b''.join(stream_zip(files, password=password)) != b''.join(stream_zip(files, password=password))

0 comments on commit ed5bcdf

Please sign in to comment.