ExoCrypt 1.2.0__py3-none-any.whl
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.
- exocrypt/__init__.py +18 -0
- exocrypt/__main__.py +4 -0
- exocrypt/cli.py +86 -0
- exocrypt/core.py +372 -0
- exocrypt/project.py +96 -0
- exocrypt-1.2.0.dist-info/METADATA +91 -0
- exocrypt-1.2.0.dist-info/RECORD +11 -0
- exocrypt-1.2.0.dist-info/WHEEL +5 -0
- exocrypt-1.2.0.dist-info/entry_points.txt +2 -0
- exocrypt-1.2.0.dist-info/licenses/LICENSE +17 -0
- exocrypt-1.2.0.dist-info/top_level.txt +1 -0
exocrypt/__init__.py
ADDED
|
@@ -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
|
+
]
|
exocrypt/__main__.py
ADDED
exocrypt/cli.py
ADDED
|
@@ -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())
|
exocrypt/core.py
ADDED
|
@@ -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
|
exocrypt/project.py
ADDED
|
@@ -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,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,11 @@
|
|
|
1
|
+
exocrypt/__init__.py,sha256=Bz6Tk9TkGWMujirsm9YF0SfSdEA-8TL85s8SWUAfcvA,463
|
|
2
|
+
exocrypt/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
|
|
3
|
+
exocrypt/cli.py,sha256=VmSRMfEdviUusgRMSk8SRexs2bBooUXlQmjeENc8pNw,3348
|
|
4
|
+
exocrypt/core.py,sha256=yTQ4jok8b5DZlKpsAGkoMpVtQaJ5Puhz5UjyhRFcEpE,15854
|
|
5
|
+
exocrypt/project.py,sha256=xN41z3vLdtJLQJL2K_KINX16YzCQUzGN6wtAS6dPTcA,4152
|
|
6
|
+
exocrypt-1.2.0.dist-info/licenses/LICENSE,sha256=lxrhWRk9Q6-xYXVte8mYG8upkEUIi-1j839kvj0iYLA,819
|
|
7
|
+
exocrypt-1.2.0.dist-info/METADATA,sha256=qpj6PwY063_R0r9ZLQ_2NU413wh8woaNYxPGQAUi4Rs,3273
|
|
8
|
+
exocrypt-1.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
exocrypt-1.2.0.dist-info/entry_points.txt,sha256=lobtteWCw49S4fM9VSMGs6bgf8FXYbJhdXa3zCyjn74,47
|
|
10
|
+
exocrypt-1.2.0.dist-info/top_level.txt,sha256=KlXhPPGIopQrMa-se3cjqMMExpVBA3UsJsytakM2ZBY,9
|
|
11
|
+
exocrypt-1.2.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
exocrypt
|