-
-
Notifications
You must be signed in to change notification settings - Fork 4
Security and Cryptography
StegX v2.0 implements a layered cryptographic architecture where each layer is mathematically independent. This page provides a complete formal analysis of every cryptographic primitive in the pipeline, with parameters taken directly from the source code.
Before any key derivation occurs, StegX combines all authentication factors into a single deterministic byte stream using a tagged-length-value (TLV) framing protocol. This is implemented in kdf.py → _mix_factors():
mixed = frame(PWD0, password_bytes)
+ frame(KFL0, keyfile_bytes or "")
+ frame(YKR0, yubikey_response or "")
Each frame is: [4-byte tag][4-byte big-endian length][data]. The maximum factor size is 16 MiB (_MAX_FACTOR_LEN = 16 * 1024 * 1024).
The mixed material undergoes an additional HMAC-SHA256 extraction before entering Argon2id:
mixed' = HMAC-SHA256(header_salt, mixed)
This pre-extraction ensures that even if two different images share the same password, their derived master keys are completely unrelated (due to different header salts).
Argon2id is a hybrid variant of Argon2 that combines:
- Argon2d (data-dependent memory access — resistant to GPU/ASIC attacks)
- Argon2i (data-independent memory access — resistant to side-channel attacks)
The first pass uses data-independent addressing (safe against cache-timing), while subsequent passes use data-dependent addressing (safe against TMTO attacks).
From kdf.py:
| Parameter | Symbol | Value | Purpose |
|---|---|---|---|
| Time cost | 3 | Number of passes over memory | |
| Memory cost | 65,536 KiB (64 MB) | Minimum RAM required per hash | |
| Parallelism | 4 | Concurrent threads | |
| Output length | — | 32 bytes (256 bits) | Master key size |
GPU Attack Scenario:
An NVIDIA RTX 4090 has 24 GB of VRAM. Each Argon2id evaluation requires
With
Comparison with legacy tools:
| Tool | KDF | Cracking Rate |
|---|---|---|
| Steghide | MD5 (unsalted) |
|
| OpenStego | PBKDF2 (1,000 iter) |
|
| StegX | Argon2id (64 MB, t=3) | ≤ 3,429 H/s (GPU) |
| StegX | Argon2id (64 MB, t=3) | ≈ 9 H/s (single CPU) |
For a password with 40 bits of entropy (
StegX provides kdf.py → calibrate_argon2_for_target_ms() which tests progressively larger memory costs (32 MB → 64 MB → 128 MB → 256 MB) and selects the first configuration that exceeds the target latency (default 500ms). This allows administrators to tune the TMTO bound to their specific hardware.
Using the same key for encryption, authentication, and PRNG seeding violates the cryptographic separation principle. A vulnerability in one operation (e.g., a nonce reuse in GCM) could leak information about the key used in another.
StegX derives independent sub-keys using HKDF-Expand (RFC 5869) with SHA-256:
From kdf.py, the following info labels are used:
| Sub-Key | HKDF Info Label | Length | Purpose |
|---|---|---|---|
stegx/v2/aes-256-gcm |
32 B | AES-256-GCM encryption key | |
stegx/v2/chacha20-poly1305 |
32 B | ChaCha20-Poly1305 key (dual-cipher only) | |
stegx/v2/pixel-shuffle-seed |
32 B | PRNG seed for pixel permutation | |
stegx/v2/sentinel |
varies | Magic sentinel derivation | |
stegx/v2/decoy-shuffle-seed |
32 B | Decoy region PRNG seed |
HKDF-Expand is a PRF (Pseudorandom Function) under the assumption that HMAC-SHA256 is a PRF. For two distinct info labels
This guarantees cryptographic independence: compromising
AES-256-GCM (Galois/Counter Mode) is always used as the primary encryption layer. The key os.urandom(12).
GCM provides:
- Confidentiality via CTR-mode AES encryption
- Authenticity via GHASH polynomial evaluation over GF(2¹²⁸), producing a 128-bit authentication tag
When --dual-cipher is enabled, the AES-GCM ciphertext is further encrypted with ChaCha20-Poly1305 using an independent key
Both ciphers bind the header as AAD. The AAD is computed by header.py → Header.as_aad(), which serializes the full header with inner_ct_length set to zero. This ensures that any modification to the header (KDF parameters, flags, salt, nonces) will cause authentication to fail, even if the ciphertext itself is untouched.
Each AEAD layer produces a 128-bit tag. The probability of forging a valid tag without the key is:
With dual-cipher, an attacker must forge both tags independently:
The PRNG seed seed_int_from_subkey():
def seed_int_from_subkey(subkey: bytes) -> int:
return int.from_bytes(subkey[:8], "big")This integer seeds Python's Mersenne Twister PRNG (random.Random(seed_int)), which generates a Fisher-Yates shuffle of all embeddable pixel positions. The result is a pseudo-random permutation
Let
For a typical 1920×1080 RGB image with 60% adaptive filtering (
When panic mode is active, the pixel positions are first split into two halves (decoy and real) using a cover-fingerprint-derived PRNG (see Architecture § Decoy Region Splitting). Each half is then independently shuffled using its own HKDF-derived seed, ensuring that the real and decoy PRNG sequences are cryptographically unrelated.
Cryptographic keys stored in userspace memory can be recovered via:
- Cold boot attacks: Freezing RAM modules and reading residual data after power-off
- Swap file leaks: The OS paging key material to disk
StegX implements secure_memory.py → SecureBuffer, which provides:
-
OS-Level Memory Locking:
- Linux/macOS:
mlock()vialibc.so.6/libc.dylib - Windows:
VirtualLock()viakernel32.dll
This prevents the OS from swapping the buffer to disk.
- Linux/macOS:
-
Deterministic Zeroization: After use, all key material is overwritten with zeros using
ctypes.memset():ctypes.memset( (ctypes.c_char * len(buf)).from_buffer(buf), 0, len(buf) )
This bypasses Python's garbage collector, which does not guarantee timely deallocation.
-
Automatic Cleanup:
SecureBufferimplements__enter__/__exit__(context manager) and__del__(destructor), ensuring keys are zeroized even if an exception occurs.
Password entered
→ _mix_factors() → mixed bytes (bytearray)
→ Argon2id → master_key (SecureBuffer)
→ HKDF → K_aes (SecureBuffer)
→ HKDF → K_chacha (SecureBuffer, if dual-cipher)
→ HKDF → K_seed (used, then discarded)
→ Encryption/Decryption
→ master_key.close() → zeroize + munlock
→ K_aes.close() → zeroize + munlock
→ K_chacha.close() → zeroize + munlock
Every SecureBuffer is wrapped in a try/finally block in crypto.py, guaranteeing zeroization regardless of success or failure.
User Guide
Technical Reference
Validation