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.
@@ -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 accidental exposure.
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 ENV:SECRET_NAME pattern
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
- # Pattern for ENV-based secret references: ENV:SECRET_NAME
27
- ENV_PATTERN: Pattern[str] = re.compile(r"^ENV:([A-Za-z_][A-Za-z0-9_]*)$")
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 an ENV-based secret reference.
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 ENV:SECRET_NAME pattern.
69
+ True if value matches any supported secret reference pattern.
41
70
  """
42
71
  if value is None:
43
72
  return False
44
- return bool(ENV_PATTERN.match(value))
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 ENV reference.
77
+ """Extract environment variable name from any reference format.
49
78
 
50
79
  Args:
51
- value: ENV reference string (e.g., "ENV:MY_SECRET").
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
- match = ENV_PATTERN.match(value)
57
- return match.group(1) if match else None
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
- def resolve_secret(value: str, allow_empty: bool = False) -> str:
61
- """Resolve a secret value, handling ENV-based references.
149
+ key, _, value = line.partition("=")
150
+ key = key.strip()
151
+ value = value.strip()
62
152
 
63
- This function supports runtime-only secret resolution. When a value
64
- matches the pattern ENV:SECRET_NAME, the actual secret is read from
65
- the corresponding environment variable at runtime.
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
- - An ENV reference (e.g., "ENV:MY_PASSWORD")
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 it
203
+ # Resolve using any format
85
204
  key = resolve_secret("ENV:ENCRYPTION_KEY")
86
- # Returns: "my-secret-key"
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
- # Check if this is an ENV reference
96
- match = ENV_PATTERN.match(value)
97
- if not match:
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
- # Extract environment variable name
102
- env_var_name = match.group(1)
219
+ # Try to extract env var name from any supported pattern
220
+ env_var_name = get_env_var_name(value)
103
221
 
104
- # Resolve from environment at runtime
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
- match = ENV_PATTERN.match(value)
165
- if not match:
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
+ ]
@@ -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 version for asymmetric encryption
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
- if data[:4] != ASYMMETRIC_MAGIC:
140
- raise DecryptionError(
141
- "Invalid hybrid encrypted file: bad magic bytes",
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,
@@ -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