A FastAPI backend that encrypts a message with Fernet, splits the key into three fragments, and hides each fragment inside a different media file using LSB steganography. Recovering the message requires all three carrier files and the ciphertext envelope.
SHA-256 and HMAC-SHA256 are implemented from scratch in backend.py — no hashlib dependency. Fernet (AES-128-CBC) is used via the cryptography library; steganography for images via stegano, audio and video via custom bit-manipulation code.
pip install -r requirements.txt
uvicorn backend:app --reload
# → http://localhost:8000The message is encrypted with Fernet from the Python cryptography library. Fernet is a high-level symmetric encryption scheme built on top of AES-128-CBC + HMAC-SHA256.
Internals of a Fernet key:
┌──────────────────── 32 bytes total ────────────────────────┐
│ 16 bytes signing key (HMAC-SHA256) │ 16 bytes AES key │
└─────────────────────────────────────────────────────────────┘
The key is generated by Fernet.generate_key(), which calls os.urandom(32) internally and Base64url-encodes the result. Each encryption operation produces a new random IV, so ciphertexts are non-deterministic even for identical plaintexts.
A Fernet token has the structure:
Version (1B) | Timestamp (8B) | IV (16B) | Ciphertext (variable) | HMAC (32B)
The HMAC covers all preceding fields, providing authenticated encryption — decryption fails loudly if the token or key is corrupted.
The raw 32-byte key is first Base64url-encoded (yielding a 44-character string), then split into three roughly equal string fragments:
def _split(key: bytes) -> list[str]:
b64 = base64.urlsafe_b64encode(key).decode() # 44 chars
n, rem = len(b64) // 3, len(b64) % 3 # 14 chars each, 2 extras
parts, i = [], 0
for k in range(3):
size = n + (1 if k < rem else 0) # parts 0,1 get 15; part 2 gets 14
parts.append(b64[i : i + size])
i += size
return parts # e.g. ["AAAAAAAAAAAAAA", "BBBBBBBBBBBBBBB", "CCCCCCCCCCCCCC"]Reconstruction simply concatenates the three parts and Base64url-decodes:
def _join(parts: list[str]) -> bytes:
return base64.urlsafe_b64decode(_fix_b64("".join(parts).strip()))_fix_b64 re-adds any = padding that was stripped during transport. No threshold scheme (e.g. Shamir's) is used — all three parts are required and any subset reveals nothing about the full key.
All three methods use LSB (Least Significant Bit) substitution: the carrier bytes are modified so their lowest bit carries one bit of secret data. One bit per byte means an 8-character secret requires 64 bytes of carrier capacity. The visual/audible distortion is imperceptible because flipping the LSB of an 8-bit sample changes its value by at most 1.
def img_hide(src: str, secret: str, dst: str) -> None:
lsb.hide(src, secret).save(dst)
def img_reveal(path: str) -> str:
return lsb.reveal(path).strip()The stegano library iterates over pixel channel values in row-major order and replaces the LSB of each byte with one bit of the secret. It uses an internal length sentinel so reveal() knows when to stop reading. Output must be a lossless format (PNG) — JPEG compression would destroy the embedded bits.
WAV (PCM) stores raw, uncompressed audio samples with no lossy transform, making it ideal for LSB embedding.
def aud_hide(src: str, secret: str, dst: str) -> None:
with wave.open(src, "rb") as w:
params, frames = w.getparams(), bytearray(w.readframes(w.getnframes()))
bits = format(len(secret), "016b") + "".join(format(ord(c), "08b") for c in secret)
for i, b in enumerate(bits):
frames[i] = (frames[i] & 0xFE) | int(b)
with wave.open(dst, "wb") as w:
w.setparams(params)
w.writeframes(bytes(frames))The first 16 bits encode the character count of the secret (supporting strings up to 65 535 chars). The remaining bits are the secret's ASCII/UTF-8 representation, 8 bits per character. Each sample byte is masked to clear its LSB (& 0xFE) then OR'd with the payload bit.
Extraction mirrors this exactly:
def aud_reveal(path: str) -> str:
with wave.open(path, "rb") as w:
frames = list(w.readframes(w.getnframes()))
n = int("".join(str(frames[i] & 1) for i in range(16)), 2)
bits = "".join(str(frames[i] & 1) for i in range(16, 16 + n * 8))
return "".join(chr(int(bits[i : i + 8], 2)) for i in range(0, len(bits), 8)).strip()Only the first frame is modified. This minimises the number of altered bytes while still providing enough capacity (a 640×480 frame has 921 600 pixel-channel bytes).
def vid_hide(src: str, secret: str, dst: str) -> None:
...
bits = format(len(secret), "032b") + "".join(format(ord(c), "08b") for c in secret)
flat = frame.flatten().copy()
for i, b in enumerate(bits):
flat[i] = (int(flat[i]) & 0xFE) | int(b)
writer = cv2.VideoWriter(dst, cv2.VideoWriter_fourcc(*"png "), fps, (W, H))
...The length header is 32 bits here (vs 16 for audio), supporting up to ~4 billion character secrets. The output codec is Motion PNG ("png ") — a lossless intraframe codec. FFV1 is used as a fallback. Both preserve LSBs exactly. H.264/H.265 would not — their DCT-based compression rounds pixel values, destroying the 1-bit payload.
SHA-256 is used for file integrity verification. The implementation lives entirely in backend.py with no hashlib dependency.
Algorithm structure:
SHA-256 operates on 512-bit (64-byte) blocks. Before processing, the message is padded to a multiple of 512 bits:
original message | 0x80 | zero bytes | 64-bit big-endian length
←────────── total length ≡ 0 mod 512 ──────────────→
Two sets of constants drive the algorithm — both derived from irrational numbers to ensure no hidden structure (the "nothing up my sleeve" principle):
_K = [...] # 64 constants: first 32 bits of cbrt(first 64 primes)
_H0 = [...] # 8 initial hash values: first 32 bits of sqrt(first 8 primes)For each 64-byte chunk, a message schedule of 64 words is built — the first 16 come directly from the chunk, the rest are derived via two sigma functions:
s0 = rotr(w[j-15], 7) ^ rotr(w[j-15], 18) ^ (w[j-15] >> 3)
s1 = rotr(w[j-2], 17) ^ rotr(w[j-2], 19) ^ (w[j-2] >> 10)
w[j] = (w[j-16] + s0 + w[j-7] + s1) & 0xFFFFFFFFThen 64 compression rounds update 8 working variables (a,b,c,d,e,f,g,h) using the schedule words, round constants, and two more non-linear functions:
ch = (e & f) ^ (~e & g) # choice: e selects between f and g
maj = (a & b) ^ (a & c) ^ (b & c) # majority vote of a, b, c
S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
temp1 = h + S1 + ch + K[j] + w[j]
temp2 = S0 + majAfter all chunks are processed, the 8 accumulated 32-bit values are concatenated to form the 256-bit digest.
HMAC provides message authentication — it proves a hash was produced by someone who holds the key, not just anyone who can run SHA-256.
def hmac_sha256(key: bytes, msg: bytes) -> bytes:
block = 64
if len(key) > block:
key = sha256(key) # compress long keys
key = key.ljust(block, b"\x00")
o_key = bytes(b ^ 0x5C for b in key) # outer pad
i_key = bytes(b ^ 0x36 for b in key) # inner pad
return sha256(o_key + sha256(i_key + msg))The two XOR constants (0x5C = opad, 0x36 = ipad) are defined in RFC 2104. The double-hash construction prevents length-extension attacks that would be possible against a naive SHA256(key || msg).
HMAC-SHA256 is exposed in the codebase as a utility function and is what the Fernet token uses internally for its authentication tag.
After encoding, the server builds a tamper-evident envelope using the custom SHA-256 implementation:
envelope = base64.urlsafe_b64encode(json.dumps({
"msg": encrypted, # Fernet token (Base64)
"hashes": {
"img": _hash(img_out), # custom SHA-256 of encoded image
"vid": _hash(vid_out), # custom SHA-256 of encoded video
"aud": _hash(aud_out), # custom SHA-256 of encoded audio
},
}).encode()).decode()_hash reads the full file and passes it to the custom sha256(), returning a 64-character hex digest (256 bits). The envelope is a single Base64url string the user copies and stores. It contains the ciphertext but not the key — the key lives only in the three carrier files.
Plaintext
│
├─ Fernet.generate_key() ──► 32-byte key
│
├─ Fernet(key).encrypt(plaintext) ──► Fernet token
│
├─ _split(key) ──► [part0, part1, part2]
│
├─ img_hide(image, part0) ──► encoded_image.png
├─ vid_hide(video, part1) ──► encoded_video.avi
├─ aud_hide(audio, part2) ──► encoded_audio.wav
│
├─ SHA-256 each encoded file
│
└─ Base64url( JSON{ msg, hashes } ) ──► ciphertext envelope
Ciphertext envelope + 3 encoded files
│
├─ Base64url-decode + JSON parse ──► { msg, hashes }
│
├─ SHA-256 each uploaded file
│ ├─ mismatch ──► HTTP 403 Tampered
│ └─ match ──► continue
│
├─ img_reveal(image) ──► part0
├─ vid_reveal(video) ──► part1
├─ aud_reveal(audio) ──► part2
│
├─ _join([part0, part1, part2]) ──► 32-byte key
│
└─ Fernet(key).decrypt(msg) ──► plaintext
| Property | Mechanism |
|---|---|
| Confidentiality | AES-128-CBC via Fernet |
| Message integrity | HMAC-SHA256 inside Fernet token |
| File integrity | SHA-256 (custom implementation) hash stored in envelope |
| Key distribution | 3-of-3 split across separate media files |
| Steganographic cover | LSB substitution — no visible artefacts |
| Key non-persistence | Key never written to disk in plaintext |
Limitations:
- Loss of any one carrier file makes the message permanently unrecoverable.
- All three files must be acquired by an attacker; if they are, the scheme collapses.
- Fernet uses AES-128, not AES-256 — not quantum-resistant.
- LSB steganography is detectable by statistical steganalysis tools (e.g. chi-square tests on pixel LSB distributions).