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/crypto/encryption.py
CHANGED
|
@@ -9,6 +9,7 @@ Supported algorithms:
|
|
|
9
9
|
- ChaCha20-Poly1305 (excellent software performance)
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import base64
|
|
12
13
|
import json
|
|
13
14
|
from dataclasses import dataclass, asdict
|
|
14
15
|
from enum import Enum
|
|
@@ -37,7 +38,12 @@ DEFAULT_ALGORITHM = EncryptionAlgorithm.AES_256_GCM
|
|
|
37
38
|
|
|
38
39
|
# File format magic bytes
|
|
39
40
|
FILANTI_MAGIC = b"FLNT"
|
|
40
|
-
FORMAT_VERSION = 1
|
|
41
|
+
FORMAT_VERSION = 1 # Legacy v1 format version
|
|
42
|
+
FORMAT_VERSION_V2 = 2 # New v2 format with encrypted metadata
|
|
43
|
+
|
|
44
|
+
# Product identifier for header
|
|
45
|
+
PRODUCT_NAME = "FLNT"
|
|
46
|
+
HEADER_VERSION = "1.1.0"
|
|
41
47
|
|
|
42
48
|
# Chunk size for streaming encryption (64 KB)
|
|
43
49
|
CHUNK_SIZE = 65536
|
|
@@ -151,6 +157,37 @@ class EncryptionMetadata:
|
|
|
151
157
|
return cls(**parsed)
|
|
152
158
|
|
|
153
159
|
|
|
160
|
+
@dataclass
|
|
161
|
+
class FileHeader:
|
|
162
|
+
"""Minimal public header for encrypted files - base64 encoded.
|
|
163
|
+
|
|
164
|
+
Only exposes product name and version, no cryptographic details.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
product: str = PRODUCT_NAME
|
|
168
|
+
version: str = HEADER_VERSION
|
|
169
|
+
|
|
170
|
+
def to_base64(self) -> bytes:
|
|
171
|
+
"""Encode header as base64 bytes."""
|
|
172
|
+
data = {"p": self.product, "v": self.version}
|
|
173
|
+
json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
174
|
+
return base64.b64encode(json_bytes)
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_base64(cls, data: bytes) -> "FileHeader":
|
|
178
|
+
"""Decode header from base64 bytes."""
|
|
179
|
+
try:
|
|
180
|
+
json_bytes = base64.b64decode(data)
|
|
181
|
+
parsed = json.loads(json_bytes.decode("utf-8"))
|
|
182
|
+
return cls(product=parsed.get("p", "FLNT"), version=parsed.get("v", "1.0.0"))
|
|
183
|
+
except Exception as e:
|
|
184
|
+
raise EncryptionError(f"Invalid file header: {e}") from e
|
|
185
|
+
|
|
186
|
+
def validate(self) -> bool:
|
|
187
|
+
"""Validate the header is from Filanti."""
|
|
188
|
+
return self.product == PRODUCT_NAME
|
|
189
|
+
|
|
190
|
+
|
|
154
191
|
def _get_cipher(algorithm: EncryptionAlgorithm, key: bytes):
|
|
155
192
|
"""Get the appropriate cipher for the algorithm."""
|
|
156
193
|
if algorithm == EncryptionAlgorithm.AES_256_GCM:
|
|
@@ -270,6 +307,8 @@ def encrypt_file(
|
|
|
270
307
|
key: bytes,
|
|
271
308
|
algorithm: EncryptionAlgorithm = DEFAULT_ALGORITHM,
|
|
272
309
|
file_manager: FileManager | None = None,
|
|
310
|
+
remove_source: bool = False,
|
|
311
|
+
secure_delete: bool = True,
|
|
273
312
|
) -> EncryptionMetadata:
|
|
274
313
|
"""Encrypt a file using authenticated encryption.
|
|
275
314
|
|
|
@@ -279,6 +318,9 @@ def encrypt_file(
|
|
|
279
318
|
key: Encryption key.
|
|
280
319
|
algorithm: Encryption algorithm to use.
|
|
281
320
|
file_manager: Optional FileManager instance.
|
|
321
|
+
remove_source: If True, delete original file after successful encryption.
|
|
322
|
+
secure_delete: If True and remove_source is True, securely overwrite
|
|
323
|
+
the original file before deletion (defense in depth).
|
|
282
324
|
|
|
283
325
|
Returns:
|
|
284
326
|
EncryptionMetadata for the encrypted file.
|
|
@@ -309,6 +351,13 @@ def encrypt_file(
|
|
|
309
351
|
output = _build_encrypted_file(result.ciphertext, metadata)
|
|
310
352
|
fm.write_bytes(output_path, output)
|
|
311
353
|
|
|
354
|
+
# Remove source file if requested
|
|
355
|
+
if remove_source:
|
|
356
|
+
if secure_delete:
|
|
357
|
+
fm.secure_delete(input_path)
|
|
358
|
+
else:
|
|
359
|
+
fm.delete(input_path)
|
|
360
|
+
|
|
312
361
|
return metadata
|
|
313
362
|
|
|
314
363
|
except (EncryptionError, FileOperationError):
|
|
@@ -328,6 +377,8 @@ def encrypt_file_with_password(
|
|
|
328
377
|
algorithm: EncryptionAlgorithm = DEFAULT_ALGORITHM,
|
|
329
378
|
kdf_params: KDFParams | None = None,
|
|
330
379
|
file_manager: FileManager | None = None,
|
|
380
|
+
remove_source: bool = False,
|
|
381
|
+
secure_delete: bool = True,
|
|
331
382
|
) -> EncryptionMetadata:
|
|
332
383
|
"""Encrypt a file using a password.
|
|
333
384
|
|
|
@@ -338,6 +389,9 @@ def encrypt_file_with_password(
|
|
|
338
389
|
algorithm: Encryption algorithm to use.
|
|
339
390
|
kdf_params: Optional KDF parameters.
|
|
340
391
|
file_manager: Optional FileManager instance.
|
|
392
|
+
remove_source: If True, delete original file after successful encryption.
|
|
393
|
+
secure_delete: If True and remove_source is True, securely overwrite
|
|
394
|
+
the original file before deletion (defense in depth).
|
|
341
395
|
|
|
342
396
|
Returns:
|
|
343
397
|
EncryptionMetadata for the encrypted file.
|
|
@@ -373,6 +427,13 @@ def encrypt_file_with_password(
|
|
|
373
427
|
output = _build_encrypted_file(result.ciphertext, metadata)
|
|
374
428
|
fm.write_bytes(output_path, output)
|
|
375
429
|
|
|
430
|
+
# Remove source file if requested
|
|
431
|
+
if remove_source:
|
|
432
|
+
if secure_delete:
|
|
433
|
+
fm.secure_delete(input_path)
|
|
434
|
+
else:
|
|
435
|
+
fm.delete(input_path)
|
|
436
|
+
|
|
376
437
|
return metadata
|
|
377
438
|
|
|
378
439
|
except (EncryptionError, FileOperationError):
|
|
@@ -386,12 +447,56 @@ def encrypt_file_with_password(
|
|
|
386
447
|
|
|
387
448
|
|
|
388
449
|
def _build_encrypted_file(ciphertext: bytes, metadata: EncryptionMetadata) -> bytes:
|
|
389
|
-
"""Build encrypted file with
|
|
450
|
+
"""Build encrypted file with v2 format (encrypted metadata).
|
|
451
|
+
|
|
452
|
+
File format v2:
|
|
453
|
+
- N bytes: Base64 header ({"p":"FLNT","v":"1.1.0"})
|
|
454
|
+
- 2 bytes: Header length (big-endian uint16)
|
|
455
|
+
- 12 bytes: Metadata nonce (for deriving metadata key and encryption)
|
|
456
|
+
- 4 bytes: Encrypted metadata length (big-endian uint32)
|
|
457
|
+
- M bytes: Encrypted metadata ciphertext
|
|
458
|
+
- K bytes: File ciphertext
|
|
459
|
+
|
|
460
|
+
The metadata is encrypted using AES-GCM with a key derived from:
|
|
461
|
+
SHA-256(metadata_nonce || "filanti-meta-v2")
|
|
462
|
+
"""
|
|
463
|
+
from hashlib import sha256
|
|
464
|
+
|
|
465
|
+
# Create minimal public header
|
|
466
|
+
header = FileHeader()
|
|
467
|
+
header_b64 = header.to_base64()
|
|
468
|
+
|
|
469
|
+
# Generate a random nonce for metadata encryption
|
|
470
|
+
meta_nonce = generate_nonce(NONCE_SIZE_GCM)
|
|
471
|
+
|
|
472
|
+
# Derive metadata encryption key from the nonce
|
|
473
|
+
meta_key = sha256(meta_nonce + b"filanti-meta-v2").digest()
|
|
474
|
+
|
|
475
|
+
# Encrypt metadata using AES-GCM
|
|
476
|
+
meta_plaintext = metadata.to_bytes()
|
|
477
|
+
meta_cipher = AESGCM(meta_key)
|
|
478
|
+
meta_ciphertext = meta_cipher.encrypt(meta_nonce, meta_plaintext, None)
|
|
390
479
|
|
|
391
|
-
|
|
480
|
+
# Assemble v2 file
|
|
481
|
+
parts = [
|
|
482
|
+
header_b64, # Base64 header
|
|
483
|
+
len(header_b64).to_bytes(2, "big"), # Header length (2 bytes)
|
|
484
|
+
meta_nonce, # Metadata nonce (12 bytes)
|
|
485
|
+
len(meta_ciphertext).to_bytes(4, "big"), # Encrypted metadata length (4 bytes)
|
|
486
|
+
meta_ciphertext, # Encrypted metadata
|
|
487
|
+
ciphertext, # File ciphertext
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
return b"".join(parts)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _build_encrypted_file_v1(ciphertext: bytes, metadata: EncryptionMetadata) -> bytes:
|
|
494
|
+
"""Build encrypted file with legacy v1 format (plaintext metadata).
|
|
495
|
+
|
|
496
|
+
File format v1 (legacy):
|
|
392
497
|
- 4 bytes: Magic ("FLNT")
|
|
393
498
|
- 4 bytes: Metadata length (big-endian uint32)
|
|
394
|
-
- N bytes: Metadata (JSON)
|
|
499
|
+
- N bytes: Metadata (JSON plaintext)
|
|
395
500
|
- M bytes: Ciphertext
|
|
396
501
|
"""
|
|
397
502
|
meta_bytes = metadata.to_bytes()
|
|
@@ -403,6 +508,8 @@ def _build_encrypted_file(ciphertext: bytes, metadata: EncryptionMetadata) -> by
|
|
|
403
508
|
def parse_encrypted_file(data: bytes) -> tuple[EncryptionMetadata, bytes]:
|
|
404
509
|
"""Parse encrypted file header and extract ciphertext.
|
|
405
510
|
|
|
511
|
+
Supports both v1 (legacy) and v2 formats.
|
|
512
|
+
|
|
406
513
|
Args:
|
|
407
514
|
data: Encrypted file bytes.
|
|
408
515
|
|
|
@@ -415,6 +522,16 @@ def parse_encrypted_file(data: bytes) -> tuple[EncryptionMetadata, bytes]:
|
|
|
415
522
|
if len(data) < 8:
|
|
416
523
|
raise EncryptionError("Invalid encrypted file: too short")
|
|
417
524
|
|
|
525
|
+
# Check for v1 format (starts with raw FLNT magic bytes)
|
|
526
|
+
if data[:4] == FILANTI_MAGIC:
|
|
527
|
+
return _parse_encrypted_file_v1(data)
|
|
528
|
+
|
|
529
|
+
# Try v2 format (starts with base64-encoded header)
|
|
530
|
+
return _parse_encrypted_file_v2(data)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _parse_encrypted_file_v1(data: bytes) -> tuple[EncryptionMetadata, bytes]:
|
|
534
|
+
"""Parse legacy v1 format encrypted file."""
|
|
418
535
|
if data[:4] != FILANTI_MAGIC:
|
|
419
536
|
raise EncryptionError("Invalid encrypted file: bad magic bytes")
|
|
420
537
|
|
|
@@ -433,3 +550,92 @@ def parse_encrypted_file(data: bytes) -> tuple[EncryptionMetadata, bytes]:
|
|
|
433
550
|
|
|
434
551
|
return metadata, ciphertext
|
|
435
552
|
|
|
553
|
+
|
|
554
|
+
def _parse_encrypted_file_v2(data: bytes) -> tuple[EncryptionMetadata, bytes]:
|
|
555
|
+
"""Parse v2 format encrypted file with encrypted metadata.
|
|
556
|
+
|
|
557
|
+
File format v2:
|
|
558
|
+
- N bytes: Base64 header
|
|
559
|
+
- 2 bytes: Header length
|
|
560
|
+
- 12 bytes: Metadata nonce
|
|
561
|
+
- 4 bytes: Encrypted metadata length
|
|
562
|
+
- M bytes: Encrypted metadata
|
|
563
|
+
- K bytes: File ciphertext
|
|
564
|
+
"""
|
|
565
|
+
from hashlib import sha256
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
# Find header boundary by scanning for valid base64 header
|
|
569
|
+
header_b64 = None
|
|
570
|
+
header_len_pos = None
|
|
571
|
+
|
|
572
|
+
for try_len in range(20, 64):
|
|
573
|
+
if try_len + 2 > len(data):
|
|
574
|
+
break
|
|
575
|
+
potential_header = data[:try_len]
|
|
576
|
+
potential_len = int.from_bytes(data[try_len:try_len+2], "big")
|
|
577
|
+
if potential_len == try_len:
|
|
578
|
+
# Found matching header length
|
|
579
|
+
try:
|
|
580
|
+
FileHeader.from_base64(potential_header)
|
|
581
|
+
header_b64 = potential_header
|
|
582
|
+
header_len_pos = try_len
|
|
583
|
+
break
|
|
584
|
+
except Exception:
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
if header_b64 is None:
|
|
588
|
+
raise EncryptionError("Invalid encrypted file: cannot parse v2 header")
|
|
589
|
+
|
|
590
|
+
# Parse and validate header
|
|
591
|
+
header = FileHeader.from_base64(header_b64)
|
|
592
|
+
if not header.validate():
|
|
593
|
+
raise EncryptionError("Invalid encrypted file: wrong product identifier")
|
|
594
|
+
|
|
595
|
+
offset = header_len_pos + 2 # Skip header + header length bytes
|
|
596
|
+
|
|
597
|
+
# Read metadata nonce (12 bytes)
|
|
598
|
+
if len(data) < offset + NONCE_SIZE_GCM:
|
|
599
|
+
raise EncryptionError("Invalid encrypted file: truncated")
|
|
600
|
+
|
|
601
|
+
meta_nonce = data[offset:offset + NONCE_SIZE_GCM]
|
|
602
|
+
offset += NONCE_SIZE_GCM
|
|
603
|
+
|
|
604
|
+
# Read encrypted metadata length
|
|
605
|
+
if len(data) < offset + 4:
|
|
606
|
+
raise EncryptionError("Invalid encrypted file: truncated")
|
|
607
|
+
|
|
608
|
+
encrypted_meta_len = int.from_bytes(data[offset:offset+4], "big")
|
|
609
|
+
offset += 4
|
|
610
|
+
|
|
611
|
+
# Read encrypted metadata
|
|
612
|
+
if len(data) < offset + encrypted_meta_len:
|
|
613
|
+
raise EncryptionError("Invalid encrypted file: truncated metadata")
|
|
614
|
+
|
|
615
|
+
meta_ciphertext = data[offset:offset + encrypted_meta_len]
|
|
616
|
+
offset += encrypted_meta_len
|
|
617
|
+
|
|
618
|
+
# Extract file ciphertext
|
|
619
|
+
ciphertext = data[offset:]
|
|
620
|
+
|
|
621
|
+
# Derive metadata decryption key
|
|
622
|
+
meta_key = sha256(meta_nonce + b"filanti-meta-v2").digest()
|
|
623
|
+
|
|
624
|
+
# Decrypt metadata
|
|
625
|
+
meta_cipher = AESGCM(meta_key)
|
|
626
|
+
try:
|
|
627
|
+
meta_plaintext = meta_cipher.decrypt(meta_nonce, meta_ciphertext, None)
|
|
628
|
+
except InvalidTag:
|
|
629
|
+
raise EncryptionError("Invalid encrypted file: metadata authentication failed")
|
|
630
|
+
|
|
631
|
+
# Parse metadata
|
|
632
|
+
metadata = EncryptionMetadata.from_bytes(meta_plaintext)
|
|
633
|
+
|
|
634
|
+
return metadata, ciphertext
|
|
635
|
+
|
|
636
|
+
except EncryptionError:
|
|
637
|
+
raise
|
|
638
|
+
except Exception as e:
|
|
639
|
+
raise EncryptionError(f"Invalid encrypted file format: {e}") from e
|
|
640
|
+
|
|
641
|
+
|
filanti/integrity/mac.py
CHANGED
|
@@ -99,16 +99,69 @@ class IntegrityMetadata:
|
|
|
99
99
|
"""Serialize to JSON string."""
|
|
100
100
|
return json.dumps(asdict(self), indent=indent, sort_keys=True)
|
|
101
101
|
|
|
102
|
+
def to_json_minimal(self) -> str:
|
|
103
|
+
"""Serialize to minimal JSON (reduced information leakage)."""
|
|
104
|
+
# Only include essential fields: version, mac, algorithm
|
|
105
|
+
# Omit: filename, filesize, created_at
|
|
106
|
+
minimal = {
|
|
107
|
+
"v": "2", # Minimal format version
|
|
108
|
+
"m": self.mac,
|
|
109
|
+
"a": self._short_algorithm(self.algorithm) if self.algorithm else None,
|
|
110
|
+
}
|
|
111
|
+
return json.dumps(minimal, separators=(",", ":"))
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _short_algorithm(algorithm: str) -> str:
|
|
115
|
+
"""Convert algorithm to short form."""
|
|
116
|
+
short_map = {
|
|
117
|
+
"hmac-sha256": "hs256",
|
|
118
|
+
"hmac-sha384": "hs384",
|
|
119
|
+
"hmac-sha512": "hs512",
|
|
120
|
+
"hmac-sha3-256": "hs3256",
|
|
121
|
+
"hmac-blake2b": "hb2b",
|
|
122
|
+
}
|
|
123
|
+
return short_map.get(algorithm, algorithm)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _expand_algorithm(short: str) -> str:
|
|
127
|
+
"""Convert short algorithm to full form."""
|
|
128
|
+
expand_map = {
|
|
129
|
+
"hs256": "hmac-sha256",
|
|
130
|
+
"hs384": "hmac-sha384",
|
|
131
|
+
"hs512": "hmac-sha512",
|
|
132
|
+
"hs3256": "hmac-sha3-256",
|
|
133
|
+
"hb2b": "hmac-blake2b",
|
|
134
|
+
}
|
|
135
|
+
return expand_map.get(short, short)
|
|
136
|
+
|
|
102
137
|
@classmethod
|
|
103
138
|
def from_json(cls, json_str: str) -> "IntegrityMetadata":
|
|
104
|
-
"""Deserialize from JSON string."""
|
|
139
|
+
"""Deserialize from JSON string (supports v1 and v2/minimal formats)."""
|
|
105
140
|
data = json.loads(json_str)
|
|
141
|
+
|
|
142
|
+
# Check for minimal v2 format
|
|
143
|
+
if "v" in data and data.get("v") == "2":
|
|
144
|
+
return cls(
|
|
145
|
+
version="2.0",
|
|
146
|
+
mac=data.get("m"),
|
|
147
|
+
algorithm=cls._expand_algorithm(data.get("a", "")),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Standard v1 format
|
|
106
151
|
return cls(**data)
|
|
107
152
|
|
|
108
|
-
def save(self, path: str | Path) -> None:
|
|
109
|
-
"""Save metadata to file.
|
|
153
|
+
def save(self, path: str | Path, minimal: bool = False) -> None:
|
|
154
|
+
"""Save metadata to file.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
path: Output path.
|
|
158
|
+
minimal: If True, use minimal format (reduced information leakage).
|
|
159
|
+
"""
|
|
110
160
|
path = Path(path)
|
|
111
|
-
|
|
161
|
+
if minimal:
|
|
162
|
+
path.write_text(self.to_json_minimal(), encoding="utf-8")
|
|
163
|
+
else:
|
|
164
|
+
path.write_text(self.to_json(), encoding="utf-8")
|
|
112
165
|
|
|
113
166
|
@classmethod
|
|
114
167
|
def load(cls, path: str | Path) -> "IntegrityMetadata":
|
|
@@ -384,6 +437,7 @@ def create_integrity_file(
|
|
|
384
437
|
key: bytes,
|
|
385
438
|
algorithm: MACAlgorithm | str = DEFAULT_ALGORITHM,
|
|
386
439
|
output_path: str | Path | None = None,
|
|
440
|
+
minimal: bool = False,
|
|
387
441
|
) -> Path:
|
|
388
442
|
"""Create detached integrity metadata file.
|
|
389
443
|
|
|
@@ -392,6 +446,8 @@ def create_integrity_file(
|
|
|
392
446
|
key: Secret key for HMAC.
|
|
393
447
|
algorithm: MAC algorithm to use.
|
|
394
448
|
output_path: Optional output path (default: file_path + '.mac')
|
|
449
|
+
minimal: If True, use minimal format (reduced information leakage).
|
|
450
|
+
Only includes version, MAC, and algorithm (no filename, filesize, timestamp).
|
|
395
451
|
|
|
396
452
|
Returns:
|
|
397
453
|
Path to the created integrity file.
|
|
@@ -408,13 +464,13 @@ def create_integrity_file(
|
|
|
408
464
|
metadata = IntegrityMetadata(
|
|
409
465
|
mac=result.to_hex(),
|
|
410
466
|
algorithm=result.algorithm,
|
|
411
|
-
filename=file_path.name,
|
|
412
|
-
filesize=file_path.stat().st_size,
|
|
413
|
-
created_at=result.created_at,
|
|
467
|
+
filename=file_path.name if not minimal else None,
|
|
468
|
+
filesize=file_path.stat().st_size if not minimal else None,
|
|
469
|
+
created_at=result.created_at if not minimal else None,
|
|
414
470
|
)
|
|
415
471
|
|
|
416
472
|
try:
|
|
417
|
-
metadata.save(output_path)
|
|
473
|
+
metadata.save(output_path, minimal=minimal)
|
|
418
474
|
return output_path
|
|
419
475
|
except Exception as e:
|
|
420
476
|
raise FileOperationError(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: filanti
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: A modular, security-focused file framework for encryption, hashing, and integrity verification
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -48,7 +48,7 @@ Description-Content-Type: text/markdown
|
|
|
48
48
|
|
|
49
49
|
## Overview
|
|
50
50
|
|
|
51
|
-
**Filanti** is a
|
|
51
|
+
**Filanti** is a Python framework providing secure-by-default primitives for:
|
|
52
52
|
|
|
53
53
|
- **File Encryption** - AES-256-GCM, ChaCha20-Poly1305 with password-based encryption
|
|
54
54
|
- **Asymmetric Encryption** - Hybrid encryption with X25519, RSA-OAEP for multi-recipient file exchange
|
|
@@ -359,6 +359,15 @@ with SecureString("my-password") as pwd:
|
|
|
359
359
|
|
|
360
360
|
Secure secret injection for automation and CI/CD workflows. Avoid hardcoding passwords in scripts or command lines.
|
|
361
361
|
|
|
362
|
+
**Supported Patterns:**
|
|
363
|
+
|
|
364
|
+
| Pattern | Description | Example |
|
|
365
|
+
|---------|-------------|---------|
|
|
366
|
+
| `ENV:VAR` | Unix-style (original) | `ENV:MY_PASSWORD` |
|
|
367
|
+
| `$env:VAR` | PowerShell-style | `$env:MY_PASSWORD` |
|
|
368
|
+
| `${VAR}` | Shell variable expansion | `${MY_PASSWORD}` |
|
|
369
|
+
| `env.VAR` | Dot notation (cross-platform) | `env.MY_PASSWORD` |
|
|
370
|
+
|
|
362
371
|
```python
|
|
363
372
|
import os
|
|
364
373
|
from filanti.api import Filanti
|
|
@@ -366,12 +375,23 @@ from filanti.api import Filanti
|
|
|
366
375
|
# Set secret in environment (done by CI/CD, Docker, etc.)
|
|
367
376
|
os.environ["ENCRYPT_PASSWORD"] = "my-secure-password"
|
|
368
377
|
|
|
369
|
-
#
|
|
370
|
-
Filanti.encrypt("secret.txt", password="ENV:ENCRYPT_PASSWORD")
|
|
371
|
-
Filanti.
|
|
378
|
+
# All patterns are supported
|
|
379
|
+
Filanti.encrypt("secret.txt", password="ENV:ENCRYPT_PASSWORD") # Unix-style
|
|
380
|
+
Filanti.encrypt("secret.txt", password="$env:ENCRYPT_PASSWORD") # PowerShell
|
|
381
|
+
Filanti.encrypt("secret.txt", password="${ENCRYPT_PASSWORD}") # Shell-style
|
|
382
|
+
Filanti.encrypt("secret.txt", password="env.ENCRYPT_PASSWORD") # Dot notation
|
|
383
|
+
|
|
384
|
+
# Load secrets from .env file
|
|
385
|
+
Filanti.load_dotenv(".env")
|
|
386
|
+
Filanti.encrypt("secret.txt", password="ENV:SECRET_FROM_DOTENV")
|
|
387
|
+
|
|
388
|
+
# Or load during encryption
|
|
389
|
+
Filanti.encrypt("secret.txt", password="ENV:MY_KEY", dotenv_path=".env")
|
|
372
390
|
|
|
373
391
|
# Check if value is an ENV reference
|
|
374
|
-
Filanti.is_env_reference("ENV:MY_SECRET")
|
|
392
|
+
Filanti.is_env_reference("ENV:MY_SECRET") # True
|
|
393
|
+
Filanti.is_env_reference("$env:MY_SECRET") # True
|
|
394
|
+
Filanti.is_env_reference("${MY_SECRET}") # True
|
|
375
395
|
|
|
376
396
|
# Resolve secret manually
|
|
377
397
|
password = Filanti.resolve_secret("ENV:ENCRYPT_PASSWORD")
|
|
@@ -392,11 +412,25 @@ safe = Filanti.safe_json_output(data, secret_keys=["password"])
|
|
|
392
412
|
# Set environment variable
|
|
393
413
|
export ENCRYPT_PASSWORD="my-secure-password"
|
|
394
414
|
|
|
395
|
-
#
|
|
415
|
+
# All pattern formats work in --password
|
|
396
416
|
filanti encrypt secret.txt --password ENV:ENCRYPT_PASSWORD
|
|
397
|
-
filanti
|
|
398
|
-
filanti
|
|
399
|
-
filanti
|
|
417
|
+
filanti encrypt secret.txt --password '$env:ENCRYPT_PASSWORD' # PowerShell
|
|
418
|
+
filanti encrypt secret.txt --password '${ENCRYPT_PASSWORD}' # Shell-style
|
|
419
|
+
filanti encrypt secret.txt --password env.ENCRYPT_PASSWORD # Dot notation
|
|
420
|
+
|
|
421
|
+
# PowerShell-friendly --env option (no special characters)
|
|
422
|
+
filanti encrypt secret.txt --env ENCRYPT_PASSWORD
|
|
423
|
+
filanti decrypt secret.txt.enc --env ENCRYPT_PASSWORD
|
|
424
|
+
|
|
425
|
+
# Load from .env file
|
|
426
|
+
filanti encrypt secret.txt --dotenv .env --env-key MY_PASSWORD
|
|
427
|
+
filanti mac file.txt --dotenv secrets.env --env-key HMAC_KEY
|
|
428
|
+
|
|
429
|
+
# All secret-accepting commands support these options:
|
|
430
|
+
# --password Literal or ENV pattern
|
|
431
|
+
# --env Variable name (PowerShell-friendly)
|
|
432
|
+
# --dotenv Path to .env file
|
|
433
|
+
# --env-key Variable name from .env file
|
|
400
434
|
```
|
|
401
435
|
|
|
402
436
|
**Benefits:**
|
|
@@ -404,6 +438,43 @@ filanti sign document.pdf --key mykey --password ENV:KEY_PASSWORD
|
|
|
404
438
|
- Works with CI/CD (GitHub Actions, GitLab CI, Jenkins)
|
|
405
439
|
- Compatible with Docker/Kubernetes secrets
|
|
406
440
|
- 12-factor app compliance
|
|
441
|
+
- PowerShell-native syntax support
|
|
442
|
+
- Cross-platform .env file loading
|
|
443
|
+
|
|
444
|
+
### Secure File Deletion
|
|
445
|
+
|
|
446
|
+
Delete original files securely after encryption/decryption operations.
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
from filanti.api import Filanti
|
|
450
|
+
|
|
451
|
+
# Encrypt and securely delete original
|
|
452
|
+
Filanti.encrypt("secret.txt", password="my-pass", remove_source=True)
|
|
453
|
+
# Original file is securely overwritten before deletion
|
|
454
|
+
|
|
455
|
+
# Decrypt and remove encrypted file
|
|
456
|
+
Filanti.decrypt("secret.txt.enc", password="my-pass", remove_source=True)
|
|
457
|
+
# Encrypted file is securely deleted after decryption
|
|
458
|
+
|
|
459
|
+
# Use faster (non-secure) deletion
|
|
460
|
+
Filanti.encrypt("secret.txt", password="my-pass",
|
|
461
|
+
remove_source=True, secure_delete=False)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
**CLI Support:**
|
|
465
|
+
|
|
466
|
+
```bash
|
|
467
|
+
# Encrypt and securely delete original
|
|
468
|
+
filanti encrypt secret.txt --password mypass --remove-source
|
|
469
|
+
|
|
470
|
+
# Decrypt and remove encrypted file
|
|
471
|
+
filanti decrypt secret.txt.enc --password mypass --remove-source
|
|
472
|
+
|
|
473
|
+
# Use faster (non-secure) deletion
|
|
474
|
+
filanti encrypt secret.txt --password mypass --remove-source --no-secure-delete
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
> ⚠️ **Note:** Secure deletion provides defense-in-depth but has limitations on SSDs with wear-leveling, journaling filesystems, and cloud-synced folders. For maximum security, use full-disk encryption.
|
|
407
478
|
|
|
408
479
|
### Asymmetric / Hybrid Encryption
|
|
409
480
|
|
|
@@ -834,40 +905,72 @@ safe_output = redact_secret("Password is secret123", "secret123")
|
|
|
834
905
|
|
|
835
906
|
---
|
|
836
907
|
|
|
837
|
-
## Architecture
|
|
908
|
+
[//]: # (## Architecture)
|
|
838
909
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
│ ├──
|
|
847
|
-
|
|
848
|
-
│
|
|
849
|
-
|
|
850
|
-
│ ├──
|
|
851
|
-
|
|
852
|
-
│ ├──
|
|
853
|
-
|
|
854
|
-
│ ├──
|
|
855
|
-
|
|
856
|
-
│
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
├──
|
|
861
|
-
|
|
862
|
-
│ ├──
|
|
863
|
-
|
|
864
|
-
│
|
|
865
|
-
|
|
866
|
-
│
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
910
|
+
[//]: # ()
|
|
911
|
+
[//]: # (```)
|
|
912
|
+
|
|
913
|
+
[//]: # (filanti/)
|
|
914
|
+
|
|
915
|
+
[//]: # (├── core/ )
|
|
916
|
+
|
|
917
|
+
[//]: # (│ ├── errors.py )
|
|
918
|
+
|
|
919
|
+
[//]: # (│ ├── file_manager.py )
|
|
920
|
+
|
|
921
|
+
[//]: # (│ ├── metadata.py )
|
|
922
|
+
|
|
923
|
+
[//]: # (│ ├── plugins.py )
|
|
924
|
+
|
|
925
|
+
[//]: # (│ ├── secrets.py )
|
|
926
|
+
|
|
927
|
+
[//]: # (│ └── secure_memory.py )
|
|
928
|
+
|
|
929
|
+
[//]: # (│)
|
|
930
|
+
|
|
931
|
+
[//]: # (├── crypto/ )
|
|
932
|
+
|
|
933
|
+
[//]: # (│ ├── encryption.py )
|
|
934
|
+
|
|
935
|
+
[//]: # (│ ├── decryption.py )
|
|
936
|
+
|
|
937
|
+
[//]: # (│ ├── key_management.py )
|
|
938
|
+
|
|
939
|
+
[//]: # (│ ├── kdf.py )
|
|
940
|
+
|
|
941
|
+
[//]: # (│ ├── streaming.py )
|
|
942
|
+
|
|
943
|
+
[//]: # (│ └── asymmetric.py )
|
|
944
|
+
|
|
945
|
+
[//]: # (│)
|
|
946
|
+
|
|
947
|
+
[//]: # (├── hashing/ )
|
|
948
|
+
|
|
949
|
+
[//]: # (│ └── crypto_hash.py )
|
|
950
|
+
|
|
951
|
+
[//]: # (│)
|
|
952
|
+
|
|
953
|
+
[//]: # (├── integrity/ )
|
|
954
|
+
|
|
955
|
+
[//]: # (│ ├── checksum.py )
|
|
956
|
+
|
|
957
|
+
[//]: # (│ ├── mac.py )
|
|
958
|
+
|
|
959
|
+
[//]: # (│ └── signature.py )
|
|
960
|
+
|
|
961
|
+
[//]: # (│)
|
|
962
|
+
|
|
963
|
+
[//]: # (├── cli/ )
|
|
964
|
+
|
|
965
|
+
[//]: # (│ └── main.py )
|
|
966
|
+
|
|
967
|
+
[//]: # (│)
|
|
968
|
+
|
|
969
|
+
[//]: # (└── api/ )
|
|
970
|
+
|
|
971
|
+
[//]: # ( └── sdk.py )
|
|
972
|
+
|
|
973
|
+
[//]: # (```)
|
|
871
974
|
|
|
872
975
|
### Module Dependencies
|
|
873
976
|
|
|
@@ -1128,7 +1231,8 @@ encrypt_stream_file(input_path, output_path, key, chunk_size=16*1024) # 16 KB
|
|
|
1128
1231
|
|
|
1129
1232
|
---
|
|
1130
1233
|
|
|
1131
|
-
##
|
|
1234
|
+
## Contributors || Acknowledgements
|
|
1235
|
+
[@stephenlb](https://github.com/stephenlb) Thanks for the inspiration and guidance on encryption and security best practices.
|
|
1132
1236
|
|
|
1133
1237
|
### Development Setup
|
|
1134
1238
|
|
|
@@ -1159,15 +1263,21 @@ pip install -e ".[dev]"
|
|
|
1159
1263
|
|
|
1160
1264
|
[//]: # (```)
|
|
1161
1265
|
|
|
1162
|
-
### Pull Request Guidelines
|
|
1266
|
+
[//]: # (### Pull Request Guidelines)
|
|
1163
1267
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
3. Follow existing code style
|
|
1167
|
-
4. Add type hints
|
|
1168
|
-
5. Run full test suite before submitting
|
|
1268
|
+
[//]: # ()
|
|
1269
|
+
[//]: # (1. Write tests for new features)
|
|
1169
1270
|
|
|
1170
|
-
|
|
1271
|
+
[//]: # (2. Update documentation)
|
|
1272
|
+
|
|
1273
|
+
[//]: # (3. Follow existing code style)
|
|
1274
|
+
|
|
1275
|
+
[//]: # (4. Add type hints)
|
|
1276
|
+
|
|
1277
|
+
[//]: # (5. Run full test suite before submitting)
|
|
1278
|
+
|
|
1279
|
+
[//]: # ()
|
|
1280
|
+
[//]: # (---)
|
|
1171
1281
|
|
|
1172
1282
|
## Changelog
|
|
1173
1283
|
|