Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Secure Key Sharing via Steganography

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.

Running

pip install -r requirements.txt
uvicorn backend:app --reload
# → http://localhost:8000

Cryptography

Fernet Encryption

The 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.


Key Splitting

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.


Steganography

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.

Image — LSB via Stegano

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.

Audio — Manual WAV LSB

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()

Video — Frame LSB via OpenCV

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 (implemented from scratch)

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) & 0xFFFFFFFF

Then 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 + maj

After all chunks are processed, the 8 accumulated 32-bit values are concatenated to form the 256-bit digest.


HMAC-SHA256 (implemented from scratch)

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.


Secure Envelope

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.


Full Encrypt / Decrypt Flow

Encrypt

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

Decrypt

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

Security Properties

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).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages