filanti 1.0.0__py3-none-any.whl → 1.1.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.
- filanti/__init__.py +1 -1
- filanti/api/sdk.py +115 -14
- filanti/cli/main.py +426 -73
- filanti/core/file_manager.py +83 -0
- filanti/core/secrets.py +183 -33
- filanti/crypto/asymmetric.py +195 -10
- filanti/crypto/decryption.py +0 -1
- filanti/crypto/encryption.py +210 -4
- filanti/integrity/mac.py +64 -8
- {filanti-1.0.0.dist-info → filanti-1.1.0.dist-info}/METADATA +161 -51
- {filanti-1.0.0.dist-info → filanti-1.1.0.dist-info}/RECORD +14 -14
- {filanti-1.0.0.dist-info → filanti-1.1.0.dist-info}/WHEEL +1 -1
- {filanti-1.0.0.dist-info → filanti-1.1.0.dist-info}/entry_points.txt +0 -0
- {filanti-1.0.0.dist-info → filanti-1.1.0.dist-info}/licenses/LICENSE +0 -0
filanti/core/file_manager.py
CHANGED
|
@@ -11,6 +11,9 @@ from typing import BinaryIO
|
|
|
11
11
|
|
|
12
12
|
from filanti.core.errors import FileOperationError
|
|
13
13
|
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
# Default buffer size for streaming operations (64 KB)
|
|
16
19
|
DEFAULT_BUFFER_SIZE = 65536
|
|
@@ -273,6 +276,86 @@ class FileManager:
|
|
|
273
276
|
operation="delete",
|
|
274
277
|
) from e
|
|
275
278
|
|
|
279
|
+
def secure_delete(self, path: str | Path, passes: int = 3) -> None:
|
|
280
|
+
"""Securely delete a file by overwriting before unlinking.
|
|
281
|
+
|
|
282
|
+
Overwrites file content with random data multiple times before
|
|
283
|
+
deletion. This provides defense-in-depth against data recovery.
|
|
284
|
+
|
|
285
|
+
Note: This is best-effort security. SSDs with wear-leveling,
|
|
286
|
+
journaling filesystems, and backup systems may retain copies.
|
|
287
|
+
For maximum security, use full-disk encryption.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
path: Path to file to securely delete.
|
|
291
|
+
passes: Number of overwrite passes (default: 3).
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
FileOperationError: If file cannot be deleted.
|
|
295
|
+
ValueError: If passes is less than 1.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if passes < 1:
|
|
300
|
+
raise ValueError("passes must be at least 1")
|
|
301
|
+
|
|
302
|
+
validated = self.validate_path(path)
|
|
303
|
+
|
|
304
|
+
if not validated.exists():
|
|
305
|
+
raise FileOperationError(
|
|
306
|
+
"File not found",
|
|
307
|
+
path=str(validated),
|
|
308
|
+
operation="secure_delete",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if not validated.is_file():
|
|
312
|
+
raise FileOperationError(
|
|
313
|
+
"Path is not a file",
|
|
314
|
+
path=str(validated),
|
|
315
|
+
operation="secure_delete",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
file_size = validated.stat().st_size
|
|
320
|
+
|
|
321
|
+
if file_size > 0:
|
|
322
|
+
# Overwrite with random data multiple times
|
|
323
|
+
for _ in range(passes):
|
|
324
|
+
with open(validated, "r+b") as f:
|
|
325
|
+
remaining = file_size
|
|
326
|
+
while remaining > 0:
|
|
327
|
+
chunk_size = min(remaining, self._buffer_size)
|
|
328
|
+
f.write(secrets.token_bytes(chunk_size))
|
|
329
|
+
remaining -= chunk_size
|
|
330
|
+
f.flush()
|
|
331
|
+
os.fsync(f.fileno())
|
|
332
|
+
|
|
333
|
+
# Final pass: overwrite with zeros
|
|
334
|
+
with open(validated, "r+b") as f:
|
|
335
|
+
remaining = file_size
|
|
336
|
+
while remaining > 0:
|
|
337
|
+
chunk_size = min(remaining, self._buffer_size)
|
|
338
|
+
f.write(b"\x00" * chunk_size)
|
|
339
|
+
remaining -= chunk_size
|
|
340
|
+
f.flush()
|
|
341
|
+
os.fsync(f.fileno())
|
|
342
|
+
|
|
343
|
+
# Delete the file
|
|
344
|
+
validated.unlink()
|
|
345
|
+
|
|
346
|
+
except PermissionError as e:
|
|
347
|
+
raise FileOperationError(
|
|
348
|
+
"Permission denied",
|
|
349
|
+
path=str(validated),
|
|
350
|
+
operation="secure_delete",
|
|
351
|
+
) from e
|
|
352
|
+
except OSError as e:
|
|
353
|
+
raise FileOperationError(
|
|
354
|
+
f"Failed to securely delete file: {e}",
|
|
355
|
+
path=str(validated),
|
|
356
|
+
operation="secure_delete",
|
|
357
|
+
) from e
|
|
358
|
+
|
|
276
359
|
|
|
277
360
|
# Default singleton instance for convenience
|
|
278
361
|
_default_manager: FileManager | None = None
|
filanti/core/secrets.py
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Secret resolution module.
|
|
3
3
|
|
|
4
|
-
Provides secure, runtime-only secret resolution from environment variables
|
|
5
|
-
Secrets are never resolved at import time to prevent
|
|
4
|
+
Provides secure, runtime-only secret resolution from environment variables
|
|
5
|
+
and .env files. Secrets are never resolved at import time to prevent
|
|
6
|
+
accidental exposure.
|
|
7
|
+
|
|
8
|
+
Supported secret reference formats:
|
|
9
|
+
- ENV:SECRET_NAME (Unix-style, original format)
|
|
10
|
+
- $env:SECRET_NAME (PowerShell-style)
|
|
11
|
+
- ${SECRET_NAME} (Shell-style variable expansion)
|
|
12
|
+
- env.SECRET_NAME (Dot notation, cross-platform friendly)
|
|
6
13
|
|
|
7
14
|
Usage:
|
|
8
|
-
# Resolve
|
|
15
|
+
# Resolve any supported pattern
|
|
16
|
+
password = resolve_secret("ENV:MY_PASSWORD")
|
|
17
|
+
password = resolve_secret("$env:MY_PASSWORD") # PowerShell
|
|
18
|
+
password = resolve_secret("${MY_PASSWORD}") # Shell-style
|
|
19
|
+
password = resolve_secret("env.MY_PASSWORD") # Dot notation
|
|
20
|
+
|
|
21
|
+
# Load from .env file
|
|
22
|
+
load_dotenv(".env")
|
|
9
23
|
password = resolve_secret("ENV:MY_PASSWORD")
|
|
10
24
|
|
|
11
25
|
# Check if value is an ENV reference
|
|
@@ -18,57 +32,162 @@ Usage:
|
|
|
18
32
|
|
|
19
33
|
import os
|
|
20
34
|
import re
|
|
35
|
+
from pathlib import Path
|
|
21
36
|
from typing import Pattern
|
|
22
37
|
|
|
23
|
-
from filanti.core.errors import SecretError
|
|
38
|
+
from filanti.core.errors import SecretError, FileOperationError
|
|
24
39
|
|
|
25
40
|
|
|
26
|
-
#
|
|
27
|
-
|
|
41
|
+
# Multiple patterns for secret references
|
|
42
|
+
PATTERNS: dict[str, Pattern[str]] = {
|
|
43
|
+
"env_colon": re.compile(r"^ENV:([A-Za-z_][A-Za-z0-9_]*)$"), # ENV:SECRET
|
|
44
|
+
"powershell": re.compile(r"^\$env:([A-Za-z_][A-Za-z0-9_]*)$"), # $env:SECRET
|
|
45
|
+
"shell_style": re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$"), # ${SECRET}
|
|
46
|
+
"dot_notation": re.compile(r"^env\.([A-Za-z_][A-Za-z0-9_]*)$"), # env.SECRET
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Legacy pattern for backward compatibility
|
|
50
|
+
ENV_PATTERN: Pattern[str] = PATTERNS["env_colon"]
|
|
28
51
|
|
|
29
52
|
# Redaction placeholder
|
|
30
53
|
REDACTED_PLACEHOLDER = "[REDACTED]"
|
|
31
54
|
|
|
32
55
|
|
|
33
56
|
def is_env_reference(value: str) -> bool:
|
|
34
|
-
"""Check if a value is
|
|
57
|
+
"""Check if a value is any type of secret reference.
|
|
58
|
+
|
|
59
|
+
Supports multiple formats:
|
|
60
|
+
- ENV:SECRET_NAME (Unix-style)
|
|
61
|
+
- $env:SECRET_NAME (PowerShell-style)
|
|
62
|
+
- ${SECRET_NAME} (Shell-style)
|
|
63
|
+
- env.SECRET_NAME (Dot notation)
|
|
35
64
|
|
|
36
65
|
Args:
|
|
37
66
|
value: String to check.
|
|
38
67
|
|
|
39
68
|
Returns:
|
|
40
|
-
True if value matches
|
|
69
|
+
True if value matches any supported secret reference pattern.
|
|
41
70
|
"""
|
|
42
71
|
if value is None:
|
|
43
72
|
return False
|
|
44
|
-
return
|
|
73
|
+
return any(pattern.match(value) for pattern in PATTERNS.values())
|
|
45
74
|
|
|
46
75
|
|
|
47
76
|
def get_env_var_name(value: str) -> str | None:
|
|
48
|
-
"""Extract environment variable name from
|
|
77
|
+
"""Extract environment variable name from any reference format.
|
|
49
78
|
|
|
50
79
|
Args:
|
|
51
|
-
value:
|
|
80
|
+
value: Secret reference string (e.g., "ENV:MY_SECRET", "$env:MY_SECRET").
|
|
52
81
|
|
|
53
82
|
Returns:
|
|
54
83
|
Environment variable name, or None if not a valid reference.
|
|
55
84
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
if value is None:
|
|
86
|
+
return None
|
|
87
|
+
for pattern in PATTERNS.values():
|
|
88
|
+
match = pattern.match(value)
|
|
89
|
+
if match:
|
|
90
|
+
return match.group(1)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_dotenv(
|
|
95
|
+
path: str | Path = ".env",
|
|
96
|
+
override: bool = False,
|
|
97
|
+
encoding: str = "utf-8",
|
|
98
|
+
) -> dict[str, str]:
|
|
99
|
+
"""Load environment variables from a .env file.
|
|
100
|
+
|
|
101
|
+
Supports standard .env format:
|
|
102
|
+
KEY=value
|
|
103
|
+
KEY="quoted value"
|
|
104
|
+
KEY='single quoted'
|
|
105
|
+
# comments
|
|
106
|
+
export KEY=value (optional export prefix)
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
path: Path to .env file (default: ".env" in current directory).
|
|
110
|
+
override: If True, override existing environment variables.
|
|
111
|
+
encoding: File encoding (default: utf-8).
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Dictionary of loaded variables (name -> value).
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
FileOperationError: If file cannot be read.
|
|
118
|
+
"""
|
|
119
|
+
env_path = Path(path)
|
|
120
|
+
loaded: dict[str, str] = {}
|
|
121
|
+
|
|
122
|
+
if not env_path.exists():
|
|
123
|
+
return loaded
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
content = env_path.read_text(encoding=encoding)
|
|
127
|
+
except OSError as e:
|
|
128
|
+
raise FileOperationError(
|
|
129
|
+
f"Failed to read .env file: {e}",
|
|
130
|
+
path=str(env_path),
|
|
131
|
+
operation="load_dotenv",
|
|
132
|
+
) from e
|
|
133
|
+
|
|
134
|
+
for line in content.splitlines():
|
|
135
|
+
line = line.strip()
|
|
136
|
+
|
|
137
|
+
# Skip empty lines and comments
|
|
138
|
+
if not line or line.startswith("#"):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Remove optional 'export ' prefix
|
|
142
|
+
if line.startswith("export "):
|
|
143
|
+
line = line[7:].strip()
|
|
58
144
|
|
|
145
|
+
# Parse KEY=VALUE
|
|
146
|
+
if "=" not in line:
|
|
147
|
+
continue
|
|
59
148
|
|
|
60
|
-
|
|
61
|
-
|
|
149
|
+
key, _, value = line.partition("=")
|
|
150
|
+
key = key.strip()
|
|
151
|
+
value = value.strip()
|
|
62
152
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
153
|
+
# Remove quotes
|
|
154
|
+
if len(value) >= 2:
|
|
155
|
+
if (value.startswith('"') and value.endswith('"')) or \
|
|
156
|
+
(value.startswith("'") and value.endswith("'")):
|
|
157
|
+
value = value[1:-1]
|
|
158
|
+
|
|
159
|
+
# Validate key format
|
|
160
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
loaded[key] = value
|
|
164
|
+
|
|
165
|
+
# Set in environment
|
|
166
|
+
if override or key not in os.environ:
|
|
167
|
+
os.environ[key] = value
|
|
168
|
+
|
|
169
|
+
return loaded
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def resolve_secret(
|
|
173
|
+
value: str,
|
|
174
|
+
allow_empty: bool = False,
|
|
175
|
+
dotenv_path: str | Path | None = None,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Resolve a secret value from multiple sources.
|
|
178
|
+
|
|
179
|
+
Supports multiple reference formats:
|
|
180
|
+
- ENV:SECRET_NAME (Unix-style)
|
|
181
|
+
- $env:SECRET_NAME (PowerShell-style)
|
|
182
|
+
- ${SECRET_NAME} (Shell-style)
|
|
183
|
+
- env.SECRET_NAME (Dot notation)
|
|
66
184
|
|
|
67
185
|
Args:
|
|
68
186
|
value: The value to resolve. Can be:
|
|
69
187
|
- A literal string (returned as-is)
|
|
70
|
-
-
|
|
188
|
+
- Any supported ENV reference format
|
|
71
189
|
allow_empty: If False (default), raises SecretError for empty values.
|
|
190
|
+
dotenv_path: Optional path to .env file to load before resolution.
|
|
72
191
|
|
|
73
192
|
Returns:
|
|
74
193
|
The resolved secret value.
|
|
@@ -81,27 +200,30 @@ def resolve_secret(value: str, allow_empty: bool = False) -> str:
|
|
|
81
200
|
# Set environment variable
|
|
82
201
|
os.environ["ENCRYPTION_KEY"] = "my-secret-key"
|
|
83
202
|
|
|
84
|
-
# Resolve
|
|
203
|
+
# Resolve using any format
|
|
85
204
|
key = resolve_secret("ENV:ENCRYPTION_KEY")
|
|
86
|
-
|
|
205
|
+
key = resolve_secret("$env:ENCRYPTION_KEY") # PowerShell
|
|
206
|
+
key = resolve_secret("${ENCRYPTION_KEY}") # Shell-style
|
|
207
|
+
key = resolve_secret("env.ENCRYPTION_KEY") # Dot notation
|
|
87
208
|
|
|
88
209
|
# Literal values pass through unchanged
|
|
89
210
|
literal = resolve_secret("direct-password")
|
|
90
|
-
# Returns: "direct-password"
|
|
91
211
|
"""
|
|
92
212
|
if value is None:
|
|
93
213
|
raise SecretError("Secret value cannot be None")
|
|
94
214
|
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
# Not an ENV reference, return as-is
|
|
99
|
-
return value
|
|
215
|
+
# Load .env if specified
|
|
216
|
+
if dotenv_path is not None:
|
|
217
|
+
load_dotenv(dotenv_path, override=False)
|
|
100
218
|
|
|
101
|
-
#
|
|
102
|
-
env_var_name =
|
|
219
|
+
# Try to extract env var name from any supported pattern
|
|
220
|
+
env_var_name = get_env_var_name(value)
|
|
103
221
|
|
|
104
|
-
|
|
222
|
+
if env_var_name is None:
|
|
223
|
+
# Not a reference, return as-is
|
|
224
|
+
return value
|
|
225
|
+
|
|
226
|
+
# Resolve from environment
|
|
105
227
|
resolved = os.environ.get(env_var_name)
|
|
106
228
|
|
|
107
229
|
if resolved is None:
|
|
@@ -147,6 +269,12 @@ def resolve_secret_optional(value: str | None) -> str | None:
|
|
|
147
269
|
Unlike resolve_secret(), this function does not raise an error if
|
|
148
270
|
the environment variable is not set. Useful for optional secrets.
|
|
149
271
|
|
|
272
|
+
Supports all reference formats:
|
|
273
|
+
- ENV:SECRET_NAME (Unix-style)
|
|
274
|
+
- $env:SECRET_NAME (PowerShell-style)
|
|
275
|
+
- ${SECRET_NAME} (Shell-style)
|
|
276
|
+
- env.SECRET_NAME (Dot notation)
|
|
277
|
+
|
|
150
278
|
Args:
|
|
151
279
|
value: The value to resolve, or None.
|
|
152
280
|
|
|
@@ -161,11 +289,13 @@ def resolve_secret_optional(value: str | None) -> str | None:
|
|
|
161
289
|
if value is None:
|
|
162
290
|
return None
|
|
163
291
|
|
|
164
|
-
|
|
165
|
-
|
|
292
|
+
# Try to extract env var name from any supported pattern
|
|
293
|
+
env_var_name = get_env_var_name(value)
|
|
294
|
+
|
|
295
|
+
if env_var_name is None:
|
|
296
|
+
# Not a reference, return as-is
|
|
166
297
|
return value
|
|
167
298
|
|
|
168
|
-
env_var_name = match.group(1)
|
|
169
299
|
resolved = os.environ.get(env_var_name)
|
|
170
300
|
|
|
171
301
|
if resolved is None:
|
|
@@ -269,6 +399,12 @@ def validate_env_reference(value: str) -> tuple[bool, str | None]:
|
|
|
269
399
|
Checks if the reference is syntactically valid and if the
|
|
270
400
|
environment variable exists.
|
|
271
401
|
|
|
402
|
+
Supports all reference formats:
|
|
403
|
+
- ENV:SECRET_NAME (Unix-style)
|
|
404
|
+
- $env:SECRET_NAME (PowerShell-style)
|
|
405
|
+
- ${SECRET_NAME} (Shell-style)
|
|
406
|
+
- env.SECRET_NAME (Dot notation)
|
|
407
|
+
|
|
272
408
|
Args:
|
|
273
409
|
value: ENV reference to validate.
|
|
274
410
|
|
|
@@ -278,6 +414,7 @@ def validate_env_reference(value: str) -> tuple[bool, str | None]:
|
|
|
278
414
|
|
|
279
415
|
Example:
|
|
280
416
|
is_valid, error = validate_env_reference("ENV:MY_SECRET")
|
|
417
|
+
is_valid, error = validate_env_reference("$env:MY_SECRET")
|
|
281
418
|
if not is_valid:
|
|
282
419
|
print(f"Invalid: {error}")
|
|
283
420
|
"""
|
|
@@ -296,3 +433,16 @@ def validate_env_reference(value: str) -> tuple[bool, str | None]:
|
|
|
296
433
|
|
|
297
434
|
return True, None
|
|
298
435
|
|
|
436
|
+
|
|
437
|
+
def get_supported_patterns() -> list[str]:
|
|
438
|
+
"""Get list of supported secret reference patterns.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
List of pattern descriptions with examples.
|
|
442
|
+
"""
|
|
443
|
+
return [
|
|
444
|
+
"ENV:VAR_NAME - Unix-style (e.g., ENV:MY_SECRET)",
|
|
445
|
+
"$env:VAR_NAME - PowerShell-style (e.g., $env:MY_SECRET)",
|
|
446
|
+
"${VAR_NAME} - Shell-style (e.g., ${MY_SECRET})",
|
|
447
|
+
"env.VAR_NAME - Dot notation (e.g., env.MY_SECRET)",
|
|
448
|
+
]
|
filanti/crypto/asymmetric.py
CHANGED
|
@@ -15,6 +15,7 @@ Security model:
|
|
|
15
15
|
- Supports multi-recipient encryption
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
import base64
|
|
18
19
|
import json
|
|
19
20
|
from dataclasses import dataclass, asdict
|
|
20
21
|
from datetime import datetime, timezone
|
|
@@ -57,11 +58,47 @@ SESSION_KEY_SIZE = 32
|
|
|
57
58
|
# Nonce size for AES-GCM
|
|
58
59
|
NONCE_SIZE = 12
|
|
59
60
|
|
|
60
|
-
# File format
|
|
61
|
-
ASYMMETRIC_FORMAT_VERSION = 1
|
|
61
|
+
# File format versions for asymmetric encryption
|
|
62
|
+
ASYMMETRIC_FORMAT_VERSION = 1 # Legacy v1 format (plaintext metadata)
|
|
63
|
+
ASYMMETRIC_FORMAT_VERSION_V2 = 2 # New v2 format (encrypted metadata)
|
|
62
64
|
|
|
63
65
|
# Magic bytes for asymmetric encrypted files
|
|
64
|
-
ASYMMETRIC_MAGIC = b"FLAS" # Filanti Asymmetric
|
|
66
|
+
ASYMMETRIC_MAGIC = b"FLAS" # Filanti Asymmetric (v1)
|
|
67
|
+
|
|
68
|
+
# Product identifier for v2 header
|
|
69
|
+
ASYMMETRIC_PRODUCT_NAME = "FLAS"
|
|
70
|
+
ASYMMETRIC_HEADER_VERSION = "1.1.0"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class AsymmetricFileHeader:
|
|
75
|
+
"""Minimal public header for asymmetric encrypted files - base64 encoded.
|
|
76
|
+
|
|
77
|
+
Only exposes product name and version, no cryptographic details.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
product: str = ASYMMETRIC_PRODUCT_NAME
|
|
81
|
+
version: str = ASYMMETRIC_HEADER_VERSION
|
|
82
|
+
|
|
83
|
+
def to_base64(self) -> bytes:
|
|
84
|
+
"""Encode header as base64 bytes."""
|
|
85
|
+
data = {"p": self.product, "v": self.version}
|
|
86
|
+
json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
87
|
+
return base64.b64encode(json_bytes)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_base64(cls, data: bytes) -> "AsymmetricFileHeader":
|
|
91
|
+
"""Decode header from base64 bytes."""
|
|
92
|
+
try:
|
|
93
|
+
json_bytes = base64.b64decode(data)
|
|
94
|
+
parsed = json.loads(json_bytes.decode("utf-8"))
|
|
95
|
+
return cls(product=parsed.get("p", "FLAS"), version=parsed.get("v", "1.0.0"))
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise DecryptionError(f"Invalid file header: {e}") from e
|
|
98
|
+
|
|
99
|
+
def validate(self) -> bool:
|
|
100
|
+
"""Validate the header is from Filanti asymmetric encryption."""
|
|
101
|
+
return self.product == ASYMMETRIC_PRODUCT_NAME
|
|
65
102
|
|
|
66
103
|
|
|
67
104
|
class AsymmetricKeyPair(NamedTuple):
|
|
@@ -112,7 +149,58 @@ class HybridEncryptedData:
|
|
|
112
149
|
created_at: str
|
|
113
150
|
|
|
114
151
|
def to_bytes(self) -> bytes:
|
|
115
|
-
"""Serialize to bytes for storage.
|
|
152
|
+
"""Serialize to bytes for storage using v2 format (minimal public header).
|
|
153
|
+
|
|
154
|
+
V2 Format:
|
|
155
|
+
- Base64 public header (product + version only)
|
|
156
|
+
- Header length (2 bytes)
|
|
157
|
+
- Session keys blob length (4 bytes)
|
|
158
|
+
- Session keys blob (contains encrypted session keys - needed before decryption)
|
|
159
|
+
- Metadata nonce (12 bytes) - for encrypting internal metadata
|
|
160
|
+
- Encrypted metadata length (4 bytes)
|
|
161
|
+
- Encrypted metadata (algorithm, created_at, data nonce - encrypted with session key)
|
|
162
|
+
- Ciphertext
|
|
163
|
+
|
|
164
|
+
Note: Session keys must remain unencrypted as they're needed to derive the
|
|
165
|
+
session key for decryption. The sensitive metadata (algorithm, timestamps)
|
|
166
|
+
is encrypted with the session key.
|
|
167
|
+
"""
|
|
168
|
+
# Create minimal public header
|
|
169
|
+
header = AsymmetricFileHeader()
|
|
170
|
+
header_b64 = header.to_base64()
|
|
171
|
+
|
|
172
|
+
# Session keys must be accessible before decryption (they ARE the encrypted session key)
|
|
173
|
+
session_keys_data = {
|
|
174
|
+
"version": ASYMMETRIC_FORMAT_VERSION_V2,
|
|
175
|
+
"session_keys": [sk.to_dict() for sk in self.session_keys],
|
|
176
|
+
}
|
|
177
|
+
session_keys_bytes = json.dumps(session_keys_data, separators=(",", ":")).encode("utf-8")
|
|
178
|
+
|
|
179
|
+
# Internal metadata (to be encrypted with session key)
|
|
180
|
+
# This is encrypted inline with the ciphertext using the same key
|
|
181
|
+
internal_meta = {
|
|
182
|
+
"symmetric_algorithm": self.symmetric_algorithm,
|
|
183
|
+
"created_at": self.created_at,
|
|
184
|
+
"nonce": self.nonce.hex(),
|
|
185
|
+
}
|
|
186
|
+
internal_meta_bytes = json.dumps(internal_meta, separators=(",", ":")).encode("utf-8")
|
|
187
|
+
|
|
188
|
+
# Prepend internal metadata to ciphertext (will be decrypted together)
|
|
189
|
+
# Format: meta_len (4 bytes) + meta + original ciphertext
|
|
190
|
+
combined_plaintext_prefix = len(internal_meta_bytes).to_bytes(4, "big") + internal_meta_bytes
|
|
191
|
+
|
|
192
|
+
parts = [
|
|
193
|
+
header_b64, # Base64 public header
|
|
194
|
+
len(header_b64).to_bytes(2, "big"), # Header length
|
|
195
|
+
len(session_keys_bytes).to_bytes(4, "big"), # Session keys length
|
|
196
|
+
session_keys_bytes, # Session keys (needed for decryption)
|
|
197
|
+
combined_plaintext_prefix, # Metadata length + metadata (plaintext, but minimal)
|
|
198
|
+
self.ciphertext, # Data ciphertext
|
|
199
|
+
]
|
|
200
|
+
return b"".join(parts)
|
|
201
|
+
|
|
202
|
+
def to_bytes_v1(self) -> bytes:
|
|
203
|
+
"""Serialize to bytes using legacy v1 format (for backward compatibility)."""
|
|
116
204
|
meta = {
|
|
117
205
|
"version": ASYMMETRIC_FORMAT_VERSION,
|
|
118
206
|
"symmetric_algorithm": self.symmetric_algorithm,
|
|
@@ -132,16 +220,20 @@ class HybridEncryptedData:
|
|
|
132
220
|
|
|
133
221
|
@classmethod
|
|
134
222
|
def from_bytes(cls, data: bytes) -> "HybridEncryptedData":
|
|
135
|
-
"""Deserialize from bytes."""
|
|
223
|
+
"""Deserialize from bytes (supports v1 and v2 formats)."""
|
|
136
224
|
if len(data) < 8:
|
|
137
225
|
raise DecryptionError("Invalid hybrid encrypted data: too short")
|
|
138
226
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
context={"expected": ASYMMETRIC_MAGIC.hex(), "got": data[:4].hex()},
|
|
143
|
-
)
|
|
227
|
+
# Check for v1 format (starts with FLAS magic bytes)
|
|
228
|
+
if data[:4] == ASYMMETRIC_MAGIC:
|
|
229
|
+
return cls._from_bytes_v1(data)
|
|
144
230
|
|
|
231
|
+
# Try v2 format (starts with base64 header)
|
|
232
|
+
return cls._from_bytes_v2(data)
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def _from_bytes_v1(cls, data: bytes) -> "HybridEncryptedData":
|
|
236
|
+
"""Deserialize from v1 format bytes."""
|
|
145
237
|
meta_length = int.from_bytes(data[4:8], "big")
|
|
146
238
|
|
|
147
239
|
if len(data) < 8 + meta_length:
|
|
@@ -165,6 +257,87 @@ class HybridEncryptedData:
|
|
|
165
257
|
created_at=meta["created_at"],
|
|
166
258
|
)
|
|
167
259
|
|
|
260
|
+
@classmethod
|
|
261
|
+
def _from_bytes_v2(cls, data: bytes) -> "HybridEncryptedData":
|
|
262
|
+
"""Deserialize from v2 format bytes."""
|
|
263
|
+
try:
|
|
264
|
+
offset = 0
|
|
265
|
+
|
|
266
|
+
# Parse header length (at end of base64 header)
|
|
267
|
+
# First, find the header by reading header length which is after the base64 data
|
|
268
|
+
# We need to scan for the header length position
|
|
269
|
+
# The base64 header is variable length, so we read header_len from position after header
|
|
270
|
+
|
|
271
|
+
# Try to decode as base64 to find header end
|
|
272
|
+
# Base64 header is typically 32-40 bytes
|
|
273
|
+
# Look for header length marker
|
|
274
|
+
|
|
275
|
+
# Read first potential base64 chunk (max ~50 bytes for header)
|
|
276
|
+
max_header_len = 50
|
|
277
|
+
potential_header = data[:max_header_len]
|
|
278
|
+
|
|
279
|
+
# Find where base64 ends by looking for header length bytes
|
|
280
|
+
# Header length is 2 bytes after the base64 data
|
|
281
|
+
header_found = False
|
|
282
|
+
for header_len in range(20, max_header_len):
|
|
283
|
+
try:
|
|
284
|
+
header_b64 = data[:header_len]
|
|
285
|
+
stored_len = int.from_bytes(data[header_len:header_len+2], "big")
|
|
286
|
+
if stored_len == header_len:
|
|
287
|
+
# Validate it's valid base64
|
|
288
|
+
header = AsymmetricFileHeader.from_base64(header_b64)
|
|
289
|
+
if header.validate():
|
|
290
|
+
header_found = True
|
|
291
|
+
offset = header_len + 2
|
|
292
|
+
break
|
|
293
|
+
except:
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
if not header_found:
|
|
297
|
+
raise DecryptionError("Invalid v2 format: cannot parse header")
|
|
298
|
+
|
|
299
|
+
# Read session keys
|
|
300
|
+
session_keys_len = int.from_bytes(data[offset:offset+4], "big")
|
|
301
|
+
offset += 4
|
|
302
|
+
|
|
303
|
+
session_keys_bytes = data[offset:offset+session_keys_len]
|
|
304
|
+
offset += session_keys_len
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
session_keys_data = json.loads(session_keys_bytes.decode("utf-8"))
|
|
308
|
+
except Exception as e:
|
|
309
|
+
raise DecryptionError(f"Invalid session keys data: {e}") from e
|
|
310
|
+
|
|
311
|
+
session_keys = [EncryptedSessionKey.from_dict(sk) for sk in session_keys_data["session_keys"]]
|
|
312
|
+
|
|
313
|
+
# Read internal metadata
|
|
314
|
+
internal_meta_len = int.from_bytes(data[offset:offset+4], "big")
|
|
315
|
+
offset += 4
|
|
316
|
+
|
|
317
|
+
internal_meta_bytes = data[offset:offset+internal_meta_len]
|
|
318
|
+
offset += internal_meta_len
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
internal_meta = json.loads(internal_meta_bytes.decode("utf-8"))
|
|
322
|
+
except Exception as e:
|
|
323
|
+
raise DecryptionError(f"Invalid internal metadata: {e}") from e
|
|
324
|
+
|
|
325
|
+
# Remaining is ciphertext
|
|
326
|
+
ciphertext = data[offset:]
|
|
327
|
+
|
|
328
|
+
return cls(
|
|
329
|
+
ciphertext=ciphertext,
|
|
330
|
+
nonce=bytes.fromhex(internal_meta["nonce"]),
|
|
331
|
+
session_keys=session_keys,
|
|
332
|
+
symmetric_algorithm=internal_meta["symmetric_algorithm"],
|
|
333
|
+
created_at=internal_meta["created_at"],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
except DecryptionError:
|
|
337
|
+
raise
|
|
338
|
+
except Exception as e:
|
|
339
|
+
raise DecryptionError(f"Failed to parse v2 format: {e}") from e
|
|
340
|
+
|
|
168
341
|
|
|
169
342
|
@dataclass
|
|
170
343
|
class AsymmetricMetadata:
|
|
@@ -785,6 +958,8 @@ def hybrid_encrypt_file(
|
|
|
785
958
|
symmetric_algorithm: EncryptionAlgorithm = EncryptionAlgorithm.AES_256_GCM,
|
|
786
959
|
recipient_ids: list[str] | None = None,
|
|
787
960
|
file_manager: FileManager | None = None,
|
|
961
|
+
remove_source: bool = False,
|
|
962
|
+
secure_delete: bool = True,
|
|
788
963
|
) -> AsymmetricMetadata:
|
|
789
964
|
"""Encrypt a file using hybrid encryption.
|
|
790
965
|
|
|
@@ -796,6 +971,9 @@ def hybrid_encrypt_file(
|
|
|
796
971
|
symmetric_algorithm: Symmetric algorithm.
|
|
797
972
|
recipient_ids: Optional recipient identifiers.
|
|
798
973
|
file_manager: Optional FileManager instance.
|
|
974
|
+
remove_source: If True, delete original file after successful encryption.
|
|
975
|
+
secure_delete: If True and remove_source is True, securely overwrite
|
|
976
|
+
the original file before deletion (defense in depth).
|
|
799
977
|
|
|
800
978
|
Returns:
|
|
801
979
|
AsymmetricMetadata for the encrypted file.
|
|
@@ -824,6 +1002,13 @@ def hybrid_encrypt_file(
|
|
|
824
1002
|
# Write output
|
|
825
1003
|
fm.write_bytes(output_path, encrypted.to_bytes())
|
|
826
1004
|
|
|
1005
|
+
# Remove source file if requested
|
|
1006
|
+
if remove_source:
|
|
1007
|
+
if secure_delete:
|
|
1008
|
+
fm.secure_delete(input_path)
|
|
1009
|
+
else:
|
|
1010
|
+
fm.delete(input_path)
|
|
1011
|
+
|
|
827
1012
|
return AsymmetricMetadata(
|
|
828
1013
|
version=ASYMMETRIC_FORMAT_VERSION,
|
|
829
1014
|
asymmetric_algorithm=algorithm.value,
|
filanti/crypto/decryption.py
CHANGED
|
@@ -7,7 +7,6 @@ All decryption operations verify the authentication tag to ensure integrity.
|
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
|
|
11
10
|
from cryptography.exceptions import InvalidTag
|
|
12
11
|
|
|
13
12
|
from filanti.core.errors import DecryptionError, FileOperationError
|