Skip to content

Commit b50c066

Browse files
committed
Add AES-GCM file encryption (encrypt_file / decrypt_file)
1 parent 28720f1 commit b50c066

6 files changed

Lines changed: 329 additions & 1 deletion

File tree

automation_file/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
)
3030
from automation_file.core.config import AutomationConfig, ConfigException
3131
from automation_file.core.config_watcher import ConfigWatcher
32+
from automation_file.core.crypto import (
33+
CryptoException,
34+
decrypt_file,
35+
encrypt_file,
36+
generate_key,
37+
key_from_password,
38+
)
3239
from automation_file.core.dag_executor import execute_action_dag
3340
from automation_file.core.fim import IntegrityMonitor
3441
from automation_file.core.json_store import read_action_json, write_action_json
@@ -325,6 +332,11 @@ def __getattr__(name: str) -> Any:
325332
"AuditException",
326333
"AuditLog",
327334
"IntegrityMonitor",
335+
"CryptoException",
336+
"encrypt_file",
337+
"decrypt_file",
338+
"generate_key",
339+
"key_from_password",
328340
# Triggers
329341
"FileWatcher",
330342
"TriggerManager",

automation_file/core/action_registry.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def _http_commands() -> dict[str, Command]:
153153

154154

155155
def _utils_commands() -> dict[str, Command]:
156-
from automation_file.core import checksum, manifest
156+
from automation_file.core import checksum, crypto, manifest
157157
from automation_file.remote import cross_backend
158158
from automation_file.utils import deduplicate, fast_find, grep, rotate
159159

@@ -168,6 +168,8 @@ def _utils_commands() -> dict[str, Command]:
168168
"FA_grep": grep.grep_files,
169169
"FA_rotate_backups": rotate.rotate_backups,
170170
"FA_copy_between": cross_backend.copy_between,
171+
"FA_encrypt_file": crypto.encrypt_file,
172+
"FA_decrypt_file": crypto.decrypt_file,
171173
}
172174

173175

automation_file/core/crypto.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""AES-256-GCM file encryption helpers.
2+
3+
``encrypt_file(source, target, key)`` writes a self-describing envelope::
4+
5+
magic = b"FA-AESG" 7 bytes
6+
version = 0x01 1 byte
7+
flags = 0x00 1 byte (reserved)
8+
aad_len = uint32 BE 4 bytes
9+
nonce = 12 bytes
10+
aad = <aad_len>
11+
ciphertext + tag (rest — GCM tag is the trailing 16 bytes)
12+
13+
``decrypt_file`` reads the same format, verifies the tag, and writes the
14+
plaintext to ``target``. Tampering (bit flips anywhere in the envelope
15+
except ``aad_len``) surfaces as :class:`CryptoException`.
16+
17+
GCM has a hard plaintext limit of roughly 64 GiB per ``(key, nonce)``
18+
pair; since each encrypt generates a fresh nonce, the practical cap is
19+
per-file and is much larger than typical automation payloads. For files
20+
approaching that size, split before calling ``encrypt_file``.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import os
26+
from pathlib import Path
27+
28+
from cryptography.exceptions import InvalidTag
29+
from cryptography.hazmat.primitives import hashes
30+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
31+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
32+
33+
from automation_file.exceptions import FileAutomationException
34+
from automation_file.logging_config import file_automation_logger
35+
36+
_MAGIC = b"FA-AESG"
37+
_VERSION = 0x01
38+
_NONCE_SIZE = 12
39+
_HEADER_FIXED_SIZE = len(_MAGIC) + 2 + 4 # magic + version + flags + aad_len
40+
_VALID_KEY_SIZES = frozenset({16, 24, 32})
41+
_DEFAULT_PBKDF2_ITERATIONS = 200_000
42+
43+
44+
class CryptoException(FileAutomationException):
45+
"""Raised when encryption / decryption fails (including on tamper)."""
46+
47+
48+
def generate_key(*, bits: int = 256) -> bytes:
49+
"""Return cryptographically random bytes suitable for AES-GCM."""
50+
if bits not in (128, 192, 256):
51+
raise CryptoException(f"bits must be 128 / 192 / 256, got {bits}")
52+
return os.urandom(bits // 8)
53+
54+
55+
def key_from_password(
56+
password: str,
57+
salt: bytes,
58+
*,
59+
iterations: int = _DEFAULT_PBKDF2_ITERATIONS,
60+
bits: int = 256,
61+
) -> bytes:
62+
"""Derive a symmetric key from ``password`` via PBKDF2-HMAC-SHA256."""
63+
if not password:
64+
raise CryptoException("password must be non-empty")
65+
if len(salt) < 16:
66+
raise CryptoException("salt must be at least 16 bytes")
67+
if bits not in (128, 192, 256):
68+
raise CryptoException(f"bits must be 128 / 192 / 256, got {bits}")
69+
kdf = PBKDF2HMAC(
70+
algorithm=hashes.SHA256(),
71+
length=bits // 8,
72+
salt=salt,
73+
iterations=iterations,
74+
)
75+
return kdf.derive(password.encode("utf-8"))
76+
77+
78+
def encrypt_file(
79+
source: str | os.PathLike[str],
80+
target: str | os.PathLike[str],
81+
key: bytes,
82+
*,
83+
associated_data: bytes = b"",
84+
) -> dict[str, int]:
85+
"""Encrypt ``source`` to ``target`` under AES-GCM. Returns a size summary."""
86+
_validate_key(key)
87+
if not isinstance(associated_data, (bytes, bytearray)):
88+
raise CryptoException("associated_data must be bytes")
89+
src = Path(source)
90+
if not src.is_file():
91+
raise CryptoException(f"source file not found: {src}")
92+
93+
plaintext = src.read_bytes()
94+
nonce = os.urandom(_NONCE_SIZE)
95+
aesgcm = AESGCM(bytes(key))
96+
ciphertext = aesgcm.encrypt(nonce, plaintext, bytes(associated_data) or None)
97+
98+
envelope = _build_header(associated_data, nonce) + ciphertext
99+
dst = Path(target)
100+
dst.parent.mkdir(parents=True, exist_ok=True)
101+
dst.write_bytes(envelope)
102+
file_automation_logger.info(
103+
"encrypt_file: %s -> %s (%d -> %d bytes)",
104+
src,
105+
dst,
106+
len(plaintext),
107+
len(envelope),
108+
)
109+
return {"plaintext_bytes": len(plaintext), "ciphertext_bytes": len(envelope)}
110+
111+
112+
def decrypt_file(
113+
source: str | os.PathLike[str],
114+
target: str | os.PathLike[str],
115+
key: bytes,
116+
) -> dict[str, int]:
117+
"""Decrypt ``source`` to ``target``. Raises on invalid tag / header."""
118+
_validate_key(key)
119+
src = Path(source)
120+
if not src.is_file():
121+
raise CryptoException(f"source file not found: {src}")
122+
envelope = src.read_bytes()
123+
nonce, aad, ciphertext = _parse_envelope(envelope)
124+
aesgcm = AESGCM(bytes(key))
125+
try:
126+
plaintext = aesgcm.decrypt(nonce, ciphertext, aad or None)
127+
except InvalidTag as err:
128+
raise CryptoException("authentication failed: wrong key or tampered data") from err
129+
130+
dst = Path(target)
131+
dst.parent.mkdir(parents=True, exist_ok=True)
132+
dst.write_bytes(plaintext)
133+
file_automation_logger.info(
134+
"decrypt_file: %s -> %s (%d -> %d bytes)",
135+
src,
136+
dst,
137+
len(envelope),
138+
len(plaintext),
139+
)
140+
return {"ciphertext_bytes": len(envelope), "plaintext_bytes": len(plaintext)}
141+
142+
143+
def _validate_key(key: bytes) -> None:
144+
if not isinstance(key, (bytes, bytearray)):
145+
raise CryptoException("key must be bytes")
146+
if len(key) not in _VALID_KEY_SIZES:
147+
raise CryptoException(
148+
f"key length must be 16 / 24 / 32 bytes, got {len(key)}",
149+
)
150+
151+
152+
def _build_header(aad: bytes, nonce: bytes) -> bytes:
153+
aad_len = len(aad).to_bytes(4, "big")
154+
return _MAGIC + bytes([_VERSION, 0x00]) + aad_len + nonce + bytes(aad)
155+
156+
157+
def _parse_envelope(envelope: bytes) -> tuple[bytes, bytes, bytes]:
158+
if len(envelope) < _HEADER_FIXED_SIZE + _NONCE_SIZE + 16:
159+
raise CryptoException("ciphertext envelope is shorter than the fixed header")
160+
if not envelope.startswith(_MAGIC):
161+
raise CryptoException("not an AES-GCM envelope (bad magic)")
162+
version = envelope[len(_MAGIC)]
163+
if version != _VERSION:
164+
raise CryptoException(f"unsupported envelope version {version}")
165+
aad_len = int.from_bytes(envelope[_HEADER_FIXED_SIZE - 4 : _HEADER_FIXED_SIZE], "big")
166+
nonce_start = _HEADER_FIXED_SIZE
167+
nonce_end = nonce_start + _NONCE_SIZE
168+
aad_end = nonce_end + aad_len
169+
if aad_end > len(envelope):
170+
raise CryptoException("envelope truncated before aad end")
171+
nonce = envelope[nonce_start:nonce_end]
172+
aad = envelope[nonce_end:aad_end]
173+
ciphertext = envelope[aad_end:]
174+
return nonce, aad, ciphertext

dev.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"paramiko>=3.4.0",
2626
"PySide6>=6.6.0",
2727
"watchdog>=4.0.0",
28+
"cryptography>=42.0.0",
2829
"tomli>=2.0.1; python_version<\"3.11\""
2930
]
3031
classifiers = [

stable.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"paramiko>=3.4.0",
2626
"PySide6>=6.6.0",
2727
"watchdog>=4.0.0",
28+
"cryptography>=42.0.0",
2829
"tomli>=2.0.1; python_version<\"3.11\""
2930
]
3031
classifiers = [

tests/test_crypto.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Tests for AES-GCM file encryption helpers."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from automation_file import (
10+
CryptoException,
11+
build_default_registry,
12+
decrypt_file,
13+
encrypt_file,
14+
generate_key,
15+
key_from_password,
16+
)
17+
18+
19+
def test_generate_key_default_length() -> None:
20+
assert len(generate_key()) == 32
21+
22+
23+
def test_generate_key_rejects_bad_size() -> None:
24+
with pytest.raises(CryptoException):
25+
generate_key(bits=111)
26+
27+
28+
def test_round_trip_preserves_plaintext(tmp_path: Path) -> None:
29+
key = generate_key()
30+
source = tmp_path / "plain.bin"
31+
source.write_bytes(b"top-secret payload\n")
32+
enc = tmp_path / "cipher.bin"
33+
dec = tmp_path / "restored.bin"
34+
summary = encrypt_file(source, enc, key)
35+
assert summary["plaintext_bytes"] == source.stat().st_size
36+
decrypt_file(enc, dec, key)
37+
assert dec.read_bytes() == source.read_bytes()
38+
39+
40+
def test_ciphertext_differs_between_calls(tmp_path: Path) -> None:
41+
key = generate_key()
42+
source = tmp_path / "plain.bin"
43+
source.write_bytes(b"same data")
44+
enc_a = tmp_path / "a.bin"
45+
enc_b = tmp_path / "b.bin"
46+
encrypt_file(source, enc_a, key)
47+
encrypt_file(source, enc_b, key)
48+
assert enc_a.read_bytes() != enc_b.read_bytes()
49+
50+
51+
def test_wrong_key_fails(tmp_path: Path) -> None:
52+
good = generate_key()
53+
bad = generate_key()
54+
source = tmp_path / "plain.bin"
55+
source.write_bytes(b"x" * 128)
56+
enc = tmp_path / "cipher.bin"
57+
encrypt_file(source, enc, good)
58+
with pytest.raises(CryptoException, match="authentication"):
59+
decrypt_file(enc, tmp_path / "dec.bin", bad)
60+
61+
62+
def test_tampered_ciphertext_fails(tmp_path: Path) -> None:
63+
key = generate_key()
64+
source = tmp_path / "plain.bin"
65+
source.write_bytes(b"x" * 128)
66+
enc = tmp_path / "cipher.bin"
67+
encrypt_file(source, enc, key)
68+
contents = bytearray(enc.read_bytes())
69+
contents[-1] ^= 0x01
70+
enc.write_bytes(bytes(contents))
71+
with pytest.raises(CryptoException, match="authentication"):
72+
decrypt_file(enc, tmp_path / "dec.bin", key)
73+
74+
75+
def test_associated_data_roundtrip(tmp_path: Path) -> None:
76+
key = generate_key()
77+
source = tmp_path / "plain.bin"
78+
source.write_bytes(b"hello")
79+
enc = tmp_path / "cipher.bin"
80+
encrypt_file(source, enc, key, associated_data=b"file=plain.bin")
81+
dec = tmp_path / "dec.bin"
82+
decrypt_file(enc, dec, key)
83+
assert dec.read_bytes() == b"hello"
84+
85+
86+
def test_invalid_key_size(tmp_path: Path) -> None:
87+
source = tmp_path / "plain.bin"
88+
source.write_bytes(b"x")
89+
with pytest.raises(CryptoException, match="key length"):
90+
encrypt_file(source, tmp_path / "out", b"\x00" * 10)
91+
92+
93+
def test_missing_source_raises(tmp_path: Path) -> None:
94+
with pytest.raises(CryptoException, match="source file"):
95+
encrypt_file(tmp_path / "absent", tmp_path / "out", generate_key())
96+
97+
98+
def test_bad_magic_rejected(tmp_path: Path) -> None:
99+
garbage = tmp_path / "junk.bin"
100+
garbage.write_bytes(b"NOT-A-REAL-ENVELOPE" + b"\x00" * 64)
101+
with pytest.raises(CryptoException, match="magic"):
102+
decrypt_file(garbage, tmp_path / "out.bin", generate_key())
103+
104+
105+
def test_key_from_password_deterministic() -> None:
106+
salt = b"\x00" * 16
107+
key_a = key_from_password("passphrase", salt, iterations=1_000)
108+
key_b = key_from_password("passphrase", salt, iterations=1_000)
109+
assert key_a == key_b
110+
assert len(key_a) == 32
111+
112+
113+
def test_key_from_password_requires_nonempty_password() -> None:
114+
with pytest.raises(CryptoException, match="non-empty"):
115+
key_from_password("", b"\x00" * 16)
116+
117+
118+
def test_key_from_password_rejects_short_salt() -> None:
119+
with pytest.raises(CryptoException, match="salt"):
120+
key_from_password("pw", b"\x00" * 8)
121+
122+
123+
def test_key_from_password_round_trip(tmp_path: Path) -> None:
124+
salt = b"sixteen-byte-saltX"
125+
key = key_from_password("strong-password", salt, iterations=1_000)
126+
source = tmp_path / "plain.txt"
127+
source.write_text("hello", encoding="utf-8")
128+
enc = tmp_path / "cipher.bin"
129+
encrypt_file(source, enc, key)
130+
dec = tmp_path / "dec.txt"
131+
decrypt_file(enc, dec, key)
132+
assert dec.read_text(encoding="utf-8") == "hello"
133+
134+
135+
def test_encrypt_decrypt_actions_registered() -> None:
136+
registry = build_default_registry()
137+
assert "FA_encrypt_file" in registry
138+
assert "FA_decrypt_file" in registry

0 commit comments

Comments
 (0)