ExoCrypt 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: ExoCrypt
3
+ Version: 1.2.0
4
+ Summary: Chunked authenticated encryption for files and Python project bundles
5
+ License: MIT
6
+ Keywords: encryption,python,AES-GCM,Argon2id,file-protection,CLI
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: cryptography>=42.0.0
11
+ Requires-Dist: argon2-cffi>=23.1.0
12
+ Dynamic: license-file
13
+
14
+ # ExoCrypt 1.2.0
15
+
16
+ ExoCrypt protects files and Python project bundles using **Argon2id + AES-256-GCM**.
17
+
18
+ ## What's new in 1.2.0
19
+
20
+ - New authenticated chunked format v3 for file encryption/decryption.
21
+ - Large files are processed in 1 MiB chunks without loading the whole file into memory.
22
+ - Each chunk is authenticated separately; reordered, altered, missing, or appended chunks are rejected.
23
+ - Argon2id key derivation (64 MiB memory, 3 passes, parallelism 4).
24
+ - Fresh random 16-byte salt and 8-byte nonce prefix for every encryption; a unique per-chunk nonce is derived from the prefix and chunk counter.
25
+ - Atomic output files; partial plaintext is removed if authentication fails.
26
+ - Still reads legacy v1 (ExoCrypt 1.0.x) and v2 (ExoCrypt 1.1.x) files.
27
+ - Project bundles are encrypted using the v3 format.
28
+
29
+ ## Install
30
+
31
+ Python 3.10+:
32
+
33
+ ```bash
34
+ python -m pip install -e .
35
+ ```
36
+
37
+ Dependencies: `cryptography>=42.0.0` and `argon2-cffi>=23.1.0`.
38
+
39
+ ## CLI
40
+
41
+ ```bash
42
+ exocrypt protect main.py
43
+ exocrypt unprotect main.py.exc -o restored.py
44
+ exocrypt verify main.py.exc
45
+ exocrypt project ./my_project -o my_project.exc
46
+ exocrypt unproject my_project.exc -o ./restored_project
47
+ exocrypt --version
48
+ ```
49
+
50
+ The CLI prompts for passwords instead of asking you to put them in shell history.
51
+
52
+ ## Python API
53
+
54
+ ```python
55
+ from exocrypt import encrypt_file, decrypt_file, verify_file
56
+
57
+ encrypt_file("main.py", "main.py.exc", "a long unique password")
58
+ assert verify_file("main.py.exc", "a long unique password")
59
+ decrypt_file("main.py.exc", "restored.py", "a long unique password")
60
+ ```
61
+
62
+ For in-memory bytes:
63
+
64
+ ```python
65
+ from exocrypt import encrypt_bytes, decrypt_bytes
66
+
67
+ encrypted = encrypt_bytes(b"secret content", "a long unique password")
68
+ assert decrypt_bytes(encrypted, "a long unique password") == b"secret content"
69
+ ```
70
+
71
+ ## Security notes
72
+
73
+ - No encryption can be guaranteed impossible to decrypt forever.
74
+ - AES-256-GCM and Argon2id are established algorithms. ExoCrypt does not depend on keeping its algorithm secret.
75
+ - Use a long, unique password. Weak passwords may be guessed offline.
76
+ - A different implementation can decrypt files if it implements this documented format and has the correct password.
77
+ - If code is decrypted and executed on a device controlled by someone else, the plaintext may be inspected while it runs. Encryption at rest does not make running code permanently unreadable.
78
+ - Keep a secure backup of both your original files and the password. There is no password recovery backdoor.
79
+ - The chunked file API limits memory usage; the in-memory `encrypt_bytes` / `decrypt_bytes` functions naturally hold the byte payload in memory.
80
+ - Project bundling currently builds a ZIP in memory before encrypting it, so very large directory trees can still use more memory during `project` operations.
81
+
82
+ ## Tests
83
+
84
+ ```bash
85
+ python -m pip install -e .
86
+ python -m unittest discover -s tests -v
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ExoCrypt.egg-info/PKG-INFO
5
+ ExoCrypt.egg-info/SOURCES.txt
6
+ ExoCrypt.egg-info/dependency_links.txt
7
+ ExoCrypt.egg-info/entry_points.txt
8
+ ExoCrypt.egg-info/requires.txt
9
+ ExoCrypt.egg-info/top_level.txt
10
+ exocrypt/__init__.py
11
+ exocrypt/__main__.py
12
+ exocrypt/cli.py
13
+ exocrypt/core.py
14
+ exocrypt/project.py
15
+ tests/test_core.py
16
+ tests/test_project.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ exocrypt = exocrypt.cli:main
@@ -0,0 +1,2 @@
1
+ cryptography>=42.0.0
2
+ argon2-cffi>=23.1.0
@@ -0,0 +1 @@
1
+ exocrypt
exocrypt-1.2.0/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ExoCrypt contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: ExoCrypt
3
+ Version: 1.2.0
4
+ Summary: Chunked authenticated encryption for files and Python project bundles
5
+ License: MIT
6
+ Keywords: encryption,python,AES-GCM,Argon2id,file-protection,CLI
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: cryptography>=42.0.0
11
+ Requires-Dist: argon2-cffi>=23.1.0
12
+ Dynamic: license-file
13
+
14
+ # ExoCrypt 1.2.0
15
+
16
+ ExoCrypt protects files and Python project bundles using **Argon2id + AES-256-GCM**.
17
+
18
+ ## What's new in 1.2.0
19
+
20
+ - New authenticated chunked format v3 for file encryption/decryption.
21
+ - Large files are processed in 1 MiB chunks without loading the whole file into memory.
22
+ - Each chunk is authenticated separately; reordered, altered, missing, or appended chunks are rejected.
23
+ - Argon2id key derivation (64 MiB memory, 3 passes, parallelism 4).
24
+ - Fresh random 16-byte salt and 8-byte nonce prefix for every encryption; a unique per-chunk nonce is derived from the prefix and chunk counter.
25
+ - Atomic output files; partial plaintext is removed if authentication fails.
26
+ - Still reads legacy v1 (ExoCrypt 1.0.x) and v2 (ExoCrypt 1.1.x) files.
27
+ - Project bundles are encrypted using the v3 format.
28
+
29
+ ## Install
30
+
31
+ Python 3.10+:
32
+
33
+ ```bash
34
+ python -m pip install -e .
35
+ ```
36
+
37
+ Dependencies: `cryptography>=42.0.0` and `argon2-cffi>=23.1.0`.
38
+
39
+ ## CLI
40
+
41
+ ```bash
42
+ exocrypt protect main.py
43
+ exocrypt unprotect main.py.exc -o restored.py
44
+ exocrypt verify main.py.exc
45
+ exocrypt project ./my_project -o my_project.exc
46
+ exocrypt unproject my_project.exc -o ./restored_project
47
+ exocrypt --version
48
+ ```
49
+
50
+ The CLI prompts for passwords instead of asking you to put them in shell history.
51
+
52
+ ## Python API
53
+
54
+ ```python
55
+ from exocrypt import encrypt_file, decrypt_file, verify_file
56
+
57
+ encrypt_file("main.py", "main.py.exc", "a long unique password")
58
+ assert verify_file("main.py.exc", "a long unique password")
59
+ decrypt_file("main.py.exc", "restored.py", "a long unique password")
60
+ ```
61
+
62
+ For in-memory bytes:
63
+
64
+ ```python
65
+ from exocrypt import encrypt_bytes, decrypt_bytes
66
+
67
+ encrypted = encrypt_bytes(b"secret content", "a long unique password")
68
+ assert decrypt_bytes(encrypted, "a long unique password") == b"secret content"
69
+ ```
70
+
71
+ ## Security notes
72
+
73
+ - No encryption can be guaranteed impossible to decrypt forever.
74
+ - AES-256-GCM and Argon2id are established algorithms. ExoCrypt does not depend on keeping its algorithm secret.
75
+ - Use a long, unique password. Weak passwords may be guessed offline.
76
+ - A different implementation can decrypt files if it implements this documented format and has the correct password.
77
+ - If code is decrypted and executed on a device controlled by someone else, the plaintext may be inspected while it runs. Encryption at rest does not make running code permanently unreadable.
78
+ - Keep a secure backup of both your original files and the password. There is no password recovery backdoor.
79
+ - The chunked file API limits memory usage; the in-memory `encrypt_bytes` / `decrypt_bytes` functions naturally hold the byte payload in memory.
80
+ - Project bundling currently builds a ZIP in memory before encrypting it, so very large directory trees can still use more memory during `project` operations.
81
+
82
+ ## Tests
83
+
84
+ ```bash
85
+ python -m pip install -e .
86
+ python -m unittest discover -s tests -v
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,78 @@
1
+ # ExoCrypt 1.2.0
2
+
3
+ ExoCrypt protects files and Python project bundles using **Argon2id + AES-256-GCM**.
4
+
5
+ ## What's new in 1.2.0
6
+
7
+ - New authenticated chunked format v3 for file encryption/decryption.
8
+ - Large files are processed in 1 MiB chunks without loading the whole file into memory.
9
+ - Each chunk is authenticated separately; reordered, altered, missing, or appended chunks are rejected.
10
+ - Argon2id key derivation (64 MiB memory, 3 passes, parallelism 4).
11
+ - Fresh random 16-byte salt and 8-byte nonce prefix for every encryption; a unique per-chunk nonce is derived from the prefix and chunk counter.
12
+ - Atomic output files; partial plaintext is removed if authentication fails.
13
+ - Still reads legacy v1 (ExoCrypt 1.0.x) and v2 (ExoCrypt 1.1.x) files.
14
+ - Project bundles are encrypted using the v3 format.
15
+
16
+ ## Install
17
+
18
+ Python 3.10+:
19
+
20
+ ```bash
21
+ python -m pip install -e .
22
+ ```
23
+
24
+ Dependencies: `cryptography>=42.0.0` and `argon2-cffi>=23.1.0`.
25
+
26
+ ## CLI
27
+
28
+ ```bash
29
+ exocrypt protect main.py
30
+ exocrypt unprotect main.py.exc -o restored.py
31
+ exocrypt verify main.py.exc
32
+ exocrypt project ./my_project -o my_project.exc
33
+ exocrypt unproject my_project.exc -o ./restored_project
34
+ exocrypt --version
35
+ ```
36
+
37
+ The CLI prompts for passwords instead of asking you to put them in shell history.
38
+
39
+ ## Python API
40
+
41
+ ```python
42
+ from exocrypt import encrypt_file, decrypt_file, verify_file
43
+
44
+ encrypt_file("main.py", "main.py.exc", "a long unique password")
45
+ assert verify_file("main.py.exc", "a long unique password")
46
+ decrypt_file("main.py.exc", "restored.py", "a long unique password")
47
+ ```
48
+
49
+ For in-memory bytes:
50
+
51
+ ```python
52
+ from exocrypt import encrypt_bytes, decrypt_bytes
53
+
54
+ encrypted = encrypt_bytes(b"secret content", "a long unique password")
55
+ assert decrypt_bytes(encrypted, "a long unique password") == b"secret content"
56
+ ```
57
+
58
+ ## Security notes
59
+
60
+ - No encryption can be guaranteed impossible to decrypt forever.
61
+ - AES-256-GCM and Argon2id are established algorithms. ExoCrypt does not depend on keeping its algorithm secret.
62
+ - Use a long, unique password. Weak passwords may be guessed offline.
63
+ - A different implementation can decrypt files if it implements this documented format and has the correct password.
64
+ - If code is decrypted and executed on a device controlled by someone else, the plaintext may be inspected while it runs. Encryption at rest does not make running code permanently unreadable.
65
+ - Keep a secure backup of both your original files and the password. There is no password recovery backdoor.
66
+ - The chunked file API limits memory usage; the in-memory `encrypt_bytes` / `decrypt_bytes` functions naturally hold the byte payload in memory.
67
+ - Project bundling currently builds a ZIP in memory before encrypting it, so very large directory trees can still use more memory during `project` operations.
68
+
69
+ ## Tests
70
+
71
+ ```bash
72
+ python -m pip install -e .
73
+ python -m unittest discover -s tests -v
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,18 @@
1
+ """ExoCrypt: authenticated encryption for files and project bundles."""
2
+ from .core import (
3
+ ExoCryptError,
4
+ encrypt_bytes,
5
+ decrypt_bytes,
6
+ encrypt_file,
7
+ decrypt_file,
8
+ verify_file,
9
+ )
10
+ from .project import protect_project, unprotect_project
11
+
12
+ __version__ = "1.2.0"
13
+
14
+ __all__ = [
15
+ "__version__", "ExoCryptError", "encrypt_bytes", "decrypt_bytes",
16
+ "encrypt_file", "decrypt_file", "verify_file",
17
+ "protect_project", "unprotect_project",
18
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,86 @@
1
+ """Command-line interface for ExoCrypt."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import getpass
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .core import encrypt_file, decrypt_file, verify_file, ExoCryptError
11
+ from .project import protect_project, unprotect_project
12
+
13
+
14
+ def _password(confirm=False):
15
+ first = getpass.getpass("Password: ")
16
+ if confirm:
17
+ second = getpass.getpass("Confirm password: ")
18
+ if first != second:
19
+ raise ValueError("passwords do not match")
20
+ if not first:
21
+ raise ValueError("password cannot be empty")
22
+ return first
23
+
24
+
25
+ def build_parser():
26
+ parser = argparse.ArgumentParser(
27
+ prog="exocrypt",
28
+ description="ExoCrypt 1.2.0 — chunked Argon2id + AES-256-GCM authenticated encryption."
29
+ )
30
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
31
+ sub = parser.add_subparsers(dest="command", required=True)
32
+
33
+ p = sub.add_parser("protect", help="encrypt one file")
34
+ p.add_argument("source")
35
+ p.add_argument("-o", "--output")
36
+ p.add_argument("--overwrite", action="store_true")
37
+
38
+ p = sub.add_parser("unprotect", help="decrypt one file")
39
+ p.add_argument("source")
40
+ p.add_argument("-o", "--output", required=True)
41
+ p.add_argument("--overwrite", action="store_true")
42
+
43
+ p = sub.add_parser("verify", help="verify password and file integrity")
44
+ p.add_argument("source")
45
+
46
+ p = sub.add_parser("project", help="encrypt a whole project into one .exc file")
47
+ p.add_argument("source_dir")
48
+ p.add_argument("-o", "--output", required=True)
49
+ p.add_argument("--overwrite", action="store_true")
50
+
51
+ p = sub.add_parser("unproject", help="decrypt a project bundle into a directory")
52
+ p.add_argument("source")
53
+ p.add_argument("-o", "--output", required=True)
54
+ p.add_argument("--overwrite", action="store_true")
55
+ return parser
56
+
57
+
58
+ def main(argv=None):
59
+ parser = build_parser()
60
+ args = parser.parse_args(argv)
61
+ try:
62
+ if args.command == "protect":
63
+ source = Path(args.source)
64
+ output = Path(args.output) if args.output else Path(str(source) + ".exc")
65
+ result = encrypt_file(source, output, _password(confirm=True), overwrite=args.overwrite)
66
+ print(f"Encrypted successfully (format v2): {result}")
67
+ elif args.command == "unprotect":
68
+ result = decrypt_file(args.source, args.output, _password(), overwrite=args.overwrite)
69
+ print(f"Decrypted successfully: {result}")
70
+ elif args.command == "verify":
71
+ verify_file(args.source, _password())
72
+ print("OK: password accepted and file authentication is valid.")
73
+ elif args.command == "project":
74
+ result = protect_project(args.source_dir, args.output, _password(confirm=True), overwrite=args.overwrite)
75
+ print(f"Project encrypted successfully (format v2): {result}")
76
+ elif args.command == "unproject":
77
+ result = unprotect_project(args.source, args.output, _password(), overwrite=args.overwrite)
78
+ print(f"Project extracted successfully: {result}")
79
+ return 0
80
+ except (OSError, ValueError, ExoCryptError) as exc:
81
+ print(f"exocrypt: error: {exc}", file=sys.stderr)
82
+ return 2
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
@@ -0,0 +1,372 @@
1
+ """Versioned authenticated encryption: Argon2id + AES-256-GCM.
2
+
3
+ Format v3 encrypts data in independently authenticated chunks, allowing large files
4
+ to be processed without loading the entire file into memory. Format v1/v2 remain
5
+ readable for backward compatibility.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import struct
11
+ from pathlib import Path
12
+ from typing import Union
13
+
14
+ from argon2.low_level import Type, hash_secret_raw
15
+ from cryptography.exceptions import InvalidTag
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives import hashes
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
+
20
+ PathLike = Union[str, os.PathLike]
21
+ MAGIC = b"EXOCRYPT"
22
+ FORMAT_VERSION = 3
23
+ LEGACY_VERSION = 1
24
+ PREVIOUS_VERSION = 2
25
+ SALT_SIZE = 16
26
+ NONCE_SIZE = 12
27
+ NONCE_PREFIX_SIZE = 8
28
+ KEY_SIZE = 32
29
+ CHUNK_SIZE = 1024 * 1024
30
+ MAX_COUNTER = 0xFFFFFFFF
31
+
32
+ ARGON2_TIME_COST = 3
33
+ ARGON2_MEMORY_COST = 65_536
34
+ ARGON2_PARALLELISM = 4
35
+
36
+ LEGACY_ITERATIONS_MIN = 100_000
37
+ LEGACY_ITERATIONS_MAX = 5_000_000
38
+ V1_HEADER_SIZE = len(MAGIC) + 1 + 4 + SALT_SIZE + NONCE_SIZE
39
+ V2_HEADER_SIZE = len(MAGIC) + 1 + SALT_SIZE + NONCE_SIZE
40
+ V3_HEADER_SIZE = len(MAGIC) + 1 + SALT_SIZE + NONCE_PREFIX_SIZE + 4
41
+ FRAME_HEADER_SIZE = 5 # one flag byte + uint32 ciphertext length
42
+
43
+
44
+ class ExoCryptError(Exception):
45
+ """Base error for ExoCrypt operations."""
46
+
47
+
48
+ def _validate_password(password: str) -> bytes:
49
+ if not isinstance(password, str) or not password:
50
+ raise ValueError("password must be a non-empty string")
51
+ encoded = password.encode("utf-8")
52
+ if len(encoded) > 1024:
53
+ raise ValueError("password is too long (maximum 1024 UTF-8 bytes)")
54
+ return encoded
55
+
56
+
57
+ def _derive_key_v3(password: str, salt: bytes) -> bytes:
58
+ return hash_secret_raw(
59
+ secret=_validate_password(password), salt=salt,
60
+ time_cost=ARGON2_TIME_COST, memory_cost=ARGON2_MEMORY_COST,
61
+ parallelism=ARGON2_PARALLELISM, hash_len=KEY_SIZE,
62
+ type=Type.ID, version=19,
63
+ )
64
+
65
+
66
+ def _derive_key_v2(password: str, salt: bytes) -> bytes:
67
+ return _derive_key_v3(password, salt)
68
+
69
+
70
+ def _derive_key_v1(password: str, salt: bytes, iterations: int) -> bytes:
71
+ raw = _validate_password(password)
72
+ if iterations < LEGACY_ITERATIONS_MIN or iterations > LEGACY_ITERATIONS_MAX:
73
+ raise ExoCryptError("invalid or unsupported legacy KDF iteration count")
74
+ kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=KEY_SIZE,
75
+ salt=salt, iterations=iterations)
76
+ return kdf.derive(raw)
77
+
78
+
79
+ def _v3_header(salt: bytes, nonce_prefix: bytes) -> bytes:
80
+ return (MAGIC + bytes([FORMAT_VERSION]) + salt + nonce_prefix
81
+ + struct.pack(">I", CHUNK_SIZE))
82
+
83
+
84
+ def _nonce(prefix: bytes, counter: int) -> bytes:
85
+ if counter > MAX_COUNTER:
86
+ raise ExoCryptError("file exceeds the supported chunk count")
87
+ return prefix + struct.pack(">I", counter)
88
+
89
+
90
+ def _frame_aad(header: bytes, counter: int, final: bool, ciphertext_len: int) -> bytes:
91
+ return header + struct.pack(">I", counter) + bytes([1 if final else 0]) + struct.pack(">I", ciphertext_len)
92
+
93
+
94
+ def _encrypt_v3_bytes(data: bytes, password: str) -> bytes:
95
+ salt, prefix = os.urandom(SALT_SIZE), os.urandom(NONCE_PREFIX_SIZE)
96
+ header = _v3_header(salt, prefix)
97
+ key = _derive_key_v3(password, salt)
98
+ aes = AESGCM(key)
99
+ output = bytearray(header)
100
+ counter = 0
101
+ for offset in range(0, len(data), CHUNK_SIZE):
102
+ chunk = data[offset:offset + CHUNK_SIZE]
103
+ final = offset + len(chunk) == len(data)
104
+ # Empty input uses an authenticated empty final frame.
105
+ ct_len = len(chunk) + 16
106
+ nonce = _nonce(prefix, counter)
107
+ aad = _frame_aad(header, counter, final, ct_len)
108
+ encrypted = aes.encrypt(nonce, chunk, aad)
109
+ output.extend(struct.pack(">BI", 1 if final else 0, len(encrypted)))
110
+ output.extend(encrypted)
111
+ counter += 1
112
+ if len(data) == 0:
113
+ ct_len = 16
114
+ encrypted = aes.encrypt(_nonce(prefix, 0), b"", _frame_aad(header, 0, True, ct_len))
115
+ output.extend(struct.pack(">BI", 1, len(encrypted)))
116
+ output.extend(encrypted)
117
+ return bytes(output)
118
+
119
+
120
+ def _decrypt_v3_bytes(blob: bytes, password: str) -> bytes:
121
+ if len(blob) < V3_HEADER_SIZE:
122
+ raise ExoCryptError("file is too short or damaged")
123
+ offset = len(MAGIC) + 1
124
+ salt = blob[offset:offset + SALT_SIZE]; offset += SALT_SIZE
125
+ prefix = blob[offset:offset + NONCE_PREFIX_SIZE]; offset += NONCE_PREFIX_SIZE
126
+ chunk_size = struct.unpack(">I", blob[offset:offset + 4])[0]; offset += 4
127
+ if chunk_size != CHUNK_SIZE:
128
+ raise ExoCryptError("unsupported or invalid chunk size")
129
+ header = blob[:offset]
130
+ aes = AESGCM(_derive_key_v3(password, salt))
131
+ output = bytearray()
132
+ counter = 0
133
+ saw_final = False
134
+ while offset < len(blob):
135
+ if len(blob) - offset < FRAME_HEADER_SIZE:
136
+ raise ExoCryptError("truncated encrypted chunk header")
137
+ flag, ct_len = struct.unpack(">BI", blob[offset:offset + FRAME_HEADER_SIZE])
138
+ offset += FRAME_HEADER_SIZE
139
+ if flag not in (0, 1):
140
+ raise ExoCryptError("invalid encrypted chunk flag")
141
+ if ct_len < 16 or ct_len > CHUNK_SIZE + 16:
142
+ raise ExoCryptError("invalid encrypted chunk length")
143
+ if len(blob) - offset < ct_len:
144
+ raise ExoCryptError("truncated encrypted chunk")
145
+ ciphertext = blob[offset:offset + ct_len]; offset += ct_len
146
+ plain = aes.decrypt(_nonce(prefix, counter),
147
+ ciphertext,
148
+ _frame_aad(header, counter, flag == 1, ct_len))
149
+ if flag == 1:
150
+ if len(plain) > CHUNK_SIZE:
151
+ raise ExoCryptError("invalid final chunk size")
152
+ output.extend(plain)
153
+ saw_final = True
154
+ if offset != len(blob):
155
+ raise ExoCryptError("unexpected data after final chunk")
156
+ break
157
+ if len(plain) != CHUNK_SIZE:
158
+ raise ExoCryptError("invalid non-final chunk size")
159
+ output.extend(plain)
160
+ counter += 1
161
+ if counter > MAX_COUNTER:
162
+ raise ExoCryptError("file exceeds the supported chunk count")
163
+ if not saw_final:
164
+ raise ExoCryptError("encrypted file is missing its final authentication chunk")
165
+ return bytes(output)
166
+
167
+
168
+ def encrypt_bytes(data: bytes, password: str) -> bytes:
169
+ """Encrypt bytes with the v3 chunked format (Argon2id + AES-256-GCM)."""
170
+ if not isinstance(data, bytes):
171
+ raise TypeError("data must be bytes")
172
+ return _encrypt_v3_bytes(data, password)
173
+
174
+
175
+ def decrypt_bytes(blob: bytes, password: str) -> bytes:
176
+ """Decrypt v1, v2 and v3 ExoCrypt files."""
177
+ if not isinstance(blob, bytes):
178
+ raise TypeError("blob must be bytes")
179
+ if len(blob) < len(MAGIC) + 1 or blob[:len(MAGIC)] != MAGIC:
180
+ raise ExoCryptError("not a valid ExoCrypt file or file is too short")
181
+ version = blob[len(MAGIC)]
182
+ if version == FORMAT_VERSION:
183
+ try:
184
+ return _decrypt_v3_bytes(blob, password)
185
+ except InvalidTag as exc:
186
+ raise ExoCryptError("authentication failed: wrong password or file was modified") from exc
187
+ if version == PREVIOUS_VERSION:
188
+ if len(blob) < V2_HEADER_SIZE + 16:
189
+ raise ExoCryptError("file is too short or damaged")
190
+ offset = len(MAGIC) + 1
191
+ salt = blob[offset:offset + SALT_SIZE]; offset += SALT_SIZE
192
+ nonce = blob[offset:offset + NONCE_SIZE]; offset += NONCE_SIZE
193
+ header = blob[:offset]
194
+ try:
195
+ return AESGCM(_derive_key_v2(password, salt)).decrypt(nonce, blob[offset:], header)
196
+ except InvalidTag as exc:
197
+ raise ExoCryptError("authentication failed: wrong password or file was modified") from exc
198
+ if version == LEGACY_VERSION:
199
+ if len(blob) < V1_HEADER_SIZE + 16:
200
+ raise ExoCryptError("legacy file is too short or damaged")
201
+ offset = len(MAGIC) + 1
202
+ iterations = struct.unpack(">I", blob[offset:offset + 4])[0]; offset += 4
203
+ salt = blob[offset:offset + SALT_SIZE]; offset += SALT_SIZE
204
+ nonce = blob[offset:offset + NONCE_SIZE]; offset += NONCE_SIZE
205
+ header = blob[:offset]
206
+ try:
207
+ return AESGCM(_derive_key_v1(password, salt, iterations)).decrypt(nonce, blob[offset:], header)
208
+ except InvalidTag as exc:
209
+ raise ExoCryptError("authentication failed: wrong password or file was modified") from exc
210
+ raise ExoCryptError(f"unsupported ExoCrypt format version: {version}")
211
+
212
+
213
+ def encrypt_file(source: PathLike, destination: PathLike, password: str, *, overwrite: bool = False) -> Path:
214
+ """Encrypt a file incrementally, keeping memory use bounded for large files."""
215
+ src, dst = Path(source), Path(destination)
216
+ if not src.is_file():
217
+ raise FileNotFoundError(f"source file not found: {src}")
218
+ if src.resolve() == dst.resolve():
219
+ raise ExoCryptError("source and destination must be different files")
220
+ if dst.exists() and not overwrite:
221
+ raise FileExistsError(f"destination already exists: {dst} (use overwrite=True to replace it)")
222
+ salt, prefix = os.urandom(SALT_SIZE), os.urandom(NONCE_PREFIX_SIZE)
223
+ header = _v3_header(salt, prefix)
224
+ aes = AESGCM(_derive_key_v3(password, salt))
225
+ temp = _temp_path(dst)
226
+ counter = 0
227
+ try:
228
+ dst.parent.mkdir(parents=True, exist_ok=True)
229
+ with src.open("rb") as inp, temp.open("xb") as out:
230
+ out.write(header)
231
+ current = inp.read(CHUNK_SIZE)
232
+ if not current:
233
+ current = b""
234
+ encrypted = aes.encrypt(_nonce(prefix, 0), current, _frame_aad(header, 0, True, 16))
235
+ out.write(struct.pack(">BI", 1, len(encrypted)))
236
+ out.write(encrypted)
237
+ else:
238
+ while True:
239
+ following = inp.read(CHUNK_SIZE)
240
+ final = not following
241
+ ct_len = len(current) + 16
242
+ encrypted = aes.encrypt(_nonce(prefix, counter), current,
243
+ _frame_aad(header, counter, final, ct_len))
244
+ out.write(struct.pack(">BI", 1 if final else 0, len(encrypted)))
245
+ out.write(encrypted)
246
+ counter += 1
247
+ if final:
248
+ break
249
+ current = following
250
+ out.flush()
251
+ os.fsync(out.fileno())
252
+ os.replace(temp, dst)
253
+ finally:
254
+ try:
255
+ temp.unlink()
256
+ except FileNotFoundError:
257
+ pass
258
+ return dst
259
+
260
+
261
+ def decrypt_file(source: PathLike, destination: PathLike, password: str, *, overwrite: bool = False) -> Path:
262
+ """Decrypt a v3 file incrementally; legacy v1/v2 files use the compatibility path."""
263
+ src, dst = Path(source), Path(destination)
264
+ if not src.is_file():
265
+ raise FileNotFoundError(f"encrypted file not found: {src}")
266
+ if src.resolve() == dst.resolve():
267
+ raise ExoCryptError("source and destination must be different files")
268
+ if dst.exists() and not overwrite:
269
+ raise FileExistsError(f"destination already exists: {dst} (use overwrite=True to replace it)")
270
+ with src.open("rb") as inp:
271
+ prefix_header = inp.read(len(MAGIC) + 1)
272
+ if prefix_header[:len(MAGIC)] != MAGIC:
273
+ raise ExoCryptError("not a valid ExoCrypt file")
274
+ version = prefix_header[-1]
275
+ if version != FORMAT_VERSION:
276
+ plain = decrypt_bytes(src.read_bytes(), password)
277
+ _atomic_write(dst, plain)
278
+ return dst
279
+ rest = inp.read(SALT_SIZE + NONCE_PREFIX_SIZE + 4)
280
+ if len(rest) != SALT_SIZE + NONCE_PREFIX_SIZE + 4:
281
+ raise ExoCryptError("file is too short or damaged")
282
+ header = prefix_header + rest
283
+ offset = 0
284
+ salt = rest[offset:offset + SALT_SIZE]; offset += SALT_SIZE
285
+ nonce_prefix = rest[offset:offset + NONCE_PREFIX_SIZE]; offset += NONCE_PREFIX_SIZE
286
+ chunk_size = struct.unpack(">I", rest[offset:offset + 4])[0]
287
+ if chunk_size != CHUNK_SIZE:
288
+ raise ExoCryptError("unsupported or invalid chunk size")
289
+ aes = AESGCM(_derive_key_v3(password, salt))
290
+ temp = _temp_path(dst)
291
+ counter = 0
292
+ saw_final = False
293
+ try:
294
+ dst.parent.mkdir(parents=True, exist_ok=True)
295
+ with temp.open("xb") as out:
296
+ while True:
297
+ frame_header = inp.read(FRAME_HEADER_SIZE)
298
+ if not frame_header:
299
+ break
300
+ if len(frame_header) != FRAME_HEADER_SIZE:
301
+ raise ExoCryptError("truncated encrypted chunk header")
302
+ flag, ct_len = struct.unpack(">BI", frame_header)
303
+ if flag not in (0, 1) or ct_len < 16 or ct_len > CHUNK_SIZE + 16:
304
+ raise ExoCryptError("invalid encrypted chunk header")
305
+ ciphertext = inp.read(ct_len)
306
+ if len(ciphertext) != ct_len:
307
+ raise ExoCryptError("truncated encrypted chunk")
308
+ aad = _frame_aad(header, counter, flag == 1, ct_len)
309
+ plain = aes.decrypt(_nonce(nonce_prefix, counter), ciphertext, aad)
310
+ if flag == 1:
311
+ if len(plain) > CHUNK_SIZE:
312
+ raise ExoCryptError("invalid final chunk size")
313
+ out.write(plain)
314
+ saw_final = True
315
+ if inp.read(1):
316
+ raise ExoCryptError("unexpected data after final chunk")
317
+ break
318
+ if len(plain) != CHUNK_SIZE:
319
+ raise ExoCryptError("invalid non-final chunk size")
320
+ out.write(plain)
321
+ counter += 1
322
+ if counter > MAX_COUNTER:
323
+ raise ExoCryptError("file exceeds the supported chunk count")
324
+ if not saw_final:
325
+ raise ExoCryptError("encrypted file is missing its final authentication chunk")
326
+ out.flush()
327
+ os.fsync(out.fileno())
328
+ os.replace(temp, dst)
329
+ except InvalidTag as exc:
330
+ raise ExoCryptError("authentication failed: wrong password or file was modified") from exc
331
+ finally:
332
+ try:
333
+ temp.unlink()
334
+ except FileNotFoundError:
335
+ pass
336
+ return dst
337
+
338
+
339
+ def verify_file(source: PathLike, password: str) -> bool:
340
+ src = Path(source)
341
+ if not src.is_file():
342
+ raise FileNotFoundError(f"file not found: {src}")
343
+ # Full verification is performed, without leaving plaintext on disk.
344
+ with src.open("rb") as inp:
345
+ prefix = inp.read(len(MAGIC) + 1)
346
+ if len(prefix) == len(MAGIC) + 1 and prefix[:len(MAGIC)] == MAGIC and prefix[-1] == FORMAT_VERSION:
347
+ # Stream to a temporary destination and delete it immediately after successful authentication.
348
+ with __import__("tempfile").TemporaryDirectory() as td:
349
+ decrypt_file(src, Path(td) / "verified.bin", password, overwrite=True)
350
+ else:
351
+ decrypt_bytes(src.read_bytes(), password)
352
+ return True
353
+
354
+
355
+ def _temp_path(destination: Path) -> Path:
356
+ return destination.with_name(destination.name + ".tmp-" + os.urandom(6).hex())
357
+
358
+
359
+ def _atomic_write(destination: Path, data: bytes) -> None:
360
+ destination.parent.mkdir(parents=True, exist_ok=True)
361
+ temp = _temp_path(destination)
362
+ try:
363
+ with temp.open("xb") as handle:
364
+ handle.write(data)
365
+ handle.flush()
366
+ os.fsync(handle.fileno())
367
+ os.replace(temp, destination)
368
+ finally:
369
+ try:
370
+ temp.unlink()
371
+ except FileNotFoundError:
372
+ pass
@@ -0,0 +1,96 @@
1
+ """Encrypt a directory into an authenticated encrypted ZIP bundle."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import os
6
+ import tempfile
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ from .core import encrypt_bytes, decrypt_bytes, _atomic_write, ExoCryptError
11
+
12
+
13
+ def _safe_members(root: Path):
14
+ for path in sorted(root.rglob("*")):
15
+ if path.is_symlink():
16
+ raise ExoCryptError(f"symbolic links are not supported: {path}")
17
+ if path.is_file():
18
+ yield path
19
+
20
+
21
+ def protect_project(source_dir, destination_file, password: str, *, overwrite: bool = False) -> Path:
22
+ source = Path(source_dir).resolve()
23
+ destination = Path(destination_file)
24
+ if not source.is_dir():
25
+ raise NotADirectoryError(f"project directory not found: {source}")
26
+ if destination.exists() and not overwrite:
27
+ raise FileExistsError(f"destination already exists: {destination}")
28
+ if destination.resolve().is_relative_to(source):
29
+ raise ExoCryptError("destination archive must be outside the source directory")
30
+
31
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as temp:
32
+ temp_name = temp.name
33
+ try:
34
+ with zipfile.ZipFile(temp_name, "w", compression=zipfile.ZIP_DEFLATED) as archive:
35
+ for path in _safe_members(source):
36
+ archive.write(path, path.relative_to(source).as_posix())
37
+ zip_bytes = Path(temp_name).read_bytes()
38
+ _atomic_write(destination, encrypt_bytes(zip_bytes, password))
39
+ finally:
40
+ try:
41
+ os.unlink(temp_name)
42
+ except FileNotFoundError:
43
+ pass
44
+ return destination
45
+
46
+
47
+ def unprotect_project(source_file, destination_dir, password: str, *, overwrite: bool = False) -> Path:
48
+ source = Path(source_file)
49
+ destination = Path(destination_dir)
50
+ if not source.is_file():
51
+ raise FileNotFoundError(f"encrypted project not found: {source}")
52
+ if destination.exists() and any(destination.iterdir()) and not overwrite:
53
+ raise FileExistsError(f"destination directory is not empty: {destination}")
54
+
55
+ plain_zip = decrypt_bytes(source.read_bytes(), password)
56
+
57
+ # Validate every ZIP entry before writing anything, preventing partial extraction
58
+ # when a later entry contains a traversal path or symbolic link.
59
+ with zipfile.ZipFile(io.BytesIO(plain_zip), "r") as archive:
60
+ infos = archive.infolist()
61
+ seen = set()
62
+ for member in infos:
63
+ name = member.filename
64
+ if not name or "\\x00" in name:
65
+ raise ExoCryptError("archive contains an invalid path")
66
+ target = (destination / name).resolve()
67
+ base = destination.resolve()
68
+ if target != base and base not in target.parents:
69
+ raise ExoCryptError("archive contains an unsafe path")
70
+ normalized = target.as_posix().casefold()
71
+ if normalized in seen:
72
+ raise ExoCryptError("archive contains duplicate paths")
73
+ seen.add(normalized)
74
+ mode = (member.external_attr >> 16) & 0o170000
75
+ if mode == 0o120000:
76
+ raise ExoCryptError("archive contains an unsupported symbolic link")
77
+ if member.flag_bits & 0x1:
78
+ raise ExoCryptError("encrypted ZIP entries are not supported")
79
+
80
+ destination.mkdir(parents=True, exist_ok=True)
81
+ for member in infos:
82
+ target = (destination / member.filename).resolve()
83
+ if member.is_dir():
84
+ target.mkdir(parents=True, exist_ok=True)
85
+ else:
86
+ target.parent.mkdir(parents=True, exist_ok=True)
87
+ # Open with exclusive creation unless overwrite was explicitly allowed.
88
+ if target.exists() and not overwrite:
89
+ raise FileExistsError(f"destination file already exists: {target}")
90
+ with archive.open(member, "r") as src, target.open("wb") as dst:
91
+ while True:
92
+ chunk = src.read(1024 * 1024)
93
+ if not chunk:
94
+ break
95
+ dst.write(chunk)
96
+ return destination
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ExoCrypt"
7
+ version = "1.2.0"
8
+ description = "Chunked authenticated encryption for files and Python project bundles"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "cryptography>=42.0.0",
14
+ "argon2-cffi>=23.1.0"
15
+ ]
16
+ keywords = ["encryption", "python", "AES-GCM", "Argon2id", "file-protection", "CLI"]
17
+
18
+ [project.scripts]
19
+ exocrypt = "exocrypt.cli:main"
20
+
21
+ [tool.setuptools]
22
+ packages = ["exocrypt"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,74 @@
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from exocrypt.core import encrypt_bytes, decrypt_bytes, ExoCryptError, encrypt_file, decrypt_file, verify_file, CHUNK_SIZE
7
+
8
+
9
+ class EncryptionTests(unittest.TestCase):
10
+ def test_round_trip(self):
11
+ original = b"print('hello exocrypt')\n"
12
+ encrypted = encrypt_bytes(original, "a long test password 123!")
13
+ self.assertNotEqual(encrypted, original)
14
+ self.assertEqual(decrypt_bytes(encrypted, "a long test password 123!"), original)
15
+
16
+ def test_wrong_password_rejected(self):
17
+ encrypted = encrypt_bytes(b"secret", "correct password")
18
+ with self.assertRaises(ExoCryptError):
19
+ decrypt_bytes(encrypted, "incorrect password")
20
+
21
+ def test_tampering_detected(self):
22
+ encrypted = bytearray(encrypt_bytes(b"secret", "correct password"))
23
+ encrypted[-1] ^= 1
24
+ with self.assertRaises(ExoCryptError):
25
+ decrypt_bytes(bytes(encrypted), "correct password")
26
+
27
+ def test_file_helpers(self):
28
+ with tempfile.TemporaryDirectory() as temp:
29
+ root = Path(temp)
30
+ src = root / "sample.py"
31
+ enc = root / "sample.py.exc"
32
+ out = root / "restored.py"
33
+ src.write_text("print('ok')", encoding="utf-8")
34
+ encrypt_file(src, enc, "long enough password 123")
35
+ self.assertTrue(verify_file(enc, "long enough password 123"))
36
+ decrypt_file(enc, out, "long enough password 123")
37
+ self.assertEqual(out.read_text(encoding="utf-8"), "print('ok')")
38
+
39
+
40
+ def test_header_tampering_detected(self):
41
+ encrypted = bytearray(encrypt_bytes(b"secret", "correct password"))
42
+ encrypted[9] ^= 1
43
+ with self.assertRaises(ExoCryptError):
44
+ decrypt_bytes(bytes(encrypted), "correct password")
45
+
46
+ def test_empty_bytes_round_trip(self):
47
+ encrypted = encrypt_bytes(b"", "empty file password")
48
+ self.assertEqual(decrypt_bytes(encrypted, "empty file password"), b"")
49
+
50
+ def test_multiple_chunks_round_trip(self):
51
+ original = b"A" * (CHUNK_SIZE * 2 + 17)
52
+ encrypted = encrypt_bytes(original, "multi chunk password")
53
+ self.assertEqual(decrypt_bytes(encrypted, "multi chunk password"), original)
54
+
55
+ def test_streaming_file_multiple_chunks(self):
56
+ with tempfile.TemporaryDirectory() as temp:
57
+ root = Path(temp)
58
+ src = root / "large.bin"
59
+ enc = root / "large.bin.exc"
60
+ out = root / "restored.bin"
61
+ original = os.urandom(CHUNK_SIZE * 2 + 123)
62
+ src.write_bytes(original)
63
+ encrypt_file(src, enc, "stream password 123")
64
+ self.assertTrue(verify_file(enc, "stream password 123"))
65
+ decrypt_file(enc, out, "stream password 123")
66
+ self.assertEqual(out.read_bytes(), original)
67
+
68
+ def test_truncation_rejected(self):
69
+ encrypted = encrypt_bytes(b"some data", "truncation password")
70
+ with self.assertRaises(ExoCryptError):
71
+ decrypt_bytes(encrypted[:-3], "truncation password")
72
+
73
+ if __name__ == "__main__":
74
+ unittest.main()
@@ -0,0 +1,27 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from exocrypt.project import protect_project, unprotect_project
6
+ from exocrypt.core import ExoCryptError
7
+
8
+
9
+ class ProjectTests(unittest.TestCase):
10
+ def test_project_round_trip(self):
11
+ with tempfile.TemporaryDirectory() as temp:
12
+ root = Path(temp)
13
+ src = root / "source"
14
+ src.mkdir()
15
+ (src / "main.py").write_text("print('hello')", encoding="utf-8")
16
+ (src / "pkg").mkdir()
17
+ (src / "pkg" / "__init__.py").write_text("", encoding="utf-8")
18
+ encrypted = root / "project.exc"
19
+ restored = root / "restored"
20
+ protect_project(src, encrypted, "a long unique password 123")
21
+ unprotect_project(encrypted, restored, "a long unique password 123")
22
+ self.assertEqual((restored / "main.py").read_text(encoding="utf-8"), "print('hello')")
23
+ self.assertEqual((restored / "pkg" / "__init__.py").read_text(encoding="utf-8"), "")
24
+
25
+
26
+ if __name__ == "__main__":
27
+ unittest.main()