schattenpost: OTP + LSB-Steganografie
One-Time-Pad-Verschluesselung mit os.urandom-Pad, automatischer Schutz gegen Pad-Wiederverwendung via Offset-Zaehler, LSB-Einbettung in Traegerfoto. CLI mit genkey/hide/reveal, einzige Abhaengigkeit Pillow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
277
schattenpost.py
Normal file
277
schattenpost.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
schattenpost — One-Time-Pad-Verschlüsselung + LSB-Steganografie.
|
||||
|
||||
Verschlüsselt Text mit einem echten One-Time-Pad (XOR) und versteckt den
|
||||
Ciphertext in den niederwertigsten Bits eines Trägerfotos. Das Ergebnis sieht
|
||||
aus wie ein ganz normales Bild, trägt aber die Nachricht bit-genau in sich.
|
||||
|
||||
WICHTIG:
|
||||
* Verschickt das Stego-Bild IMMER als Datei/Dokument, niemals als "Foto" —
|
||||
Messenger jagen Fotos durch verlustbehaftete Kompression und zerstören dabei
|
||||
die versteckten Bits. Als Dokument bleibt das PNG unangetastet.
|
||||
* Das Pad (der Schlüssel) muss auf beiden Seiten byte-identisch sein und darf
|
||||
NIE zweimal für dieselben Bytes benutzt werden. Der Offset-Zähler
|
||||
(pad.key.offset) sorgt automatisch dafür, dass kein Byte doppelt verbraucht
|
||||
wird — solange ihr ihn nicht manuell zurückdreht.
|
||||
|
||||
Subcommands:
|
||||
genkey Erzeugt ein echtes Zufalls-Pad.
|
||||
hide Verschlüsselt + versteckt eine Nachricht in einem Trägerbild.
|
||||
reveal Extrahiert + entschlüsselt eine Nachricht aus einem Stego-Bild.
|
||||
|
||||
Abhängigkeit: Pillow (pip install Pillow)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
MAGIC = b"SPST" # Schattenpost-Container-Kennung
|
||||
VERSION = 1
|
||||
# Header: magic(4) + version(1) + offset(8, uint64 BE) + length(4, uint32 BE)
|
||||
HEADER_FMT = ">4sBQI"
|
||||
HEADER_LEN = struct.calcsize(HEADER_FMT)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# One-Time-Pad
|
||||
# --------------------------------------------------------------------------- #
|
||||
def otp_xor(data: bytes, key: bytes) -> bytes:
|
||||
"""XOR data gegen key. key muss mindestens so lang wie data sein."""
|
||||
if len(key) < len(data):
|
||||
raise ValueError(
|
||||
f"Pad zu kurz: brauche {len(data)} Bytes, habe nur {len(key)}."
|
||||
)
|
||||
return bytes(d ^ k for d, k in zip(data, key))
|
||||
|
||||
|
||||
def load_pad(path: str) -> bytes:
|
||||
with open(path, "rb") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def read_offset(pad_path: str) -> int:
|
||||
"""Nächster unbenutzter Offset im Pad. Fehlt die Datei -> 0."""
|
||||
off_path = pad_path + ".offset"
|
||||
if not os.path.exists(off_path):
|
||||
return 0
|
||||
with open(off_path, "r") as fh:
|
||||
return int(fh.read().strip() or "0")
|
||||
|
||||
|
||||
def write_offset(pad_path: str, value: int) -> None:
|
||||
with open(pad_path + ".offset", "w") as fh:
|
||||
fh.write(str(value))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# LSB-Steganografie
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _bits_from_bytes(data: bytes):
|
||||
"""Liefert die Bits von data, MSB zuerst pro Byte."""
|
||||
for byte in data:
|
||||
for i in range(7, -1, -1):
|
||||
yield (byte >> i) & 1
|
||||
|
||||
|
||||
def embed(carrier_path: str, payload: bytes, out_path: str) -> None:
|
||||
"""Bettet payload in die LSBs von carrier ein und speichert als PNG."""
|
||||
img = Image.open(carrier_path).convert("RGB")
|
||||
width, height = img.size
|
||||
capacity_bits = width * height * 3
|
||||
need_bits = len(payload) * 8
|
||||
if need_bits > capacity_bits:
|
||||
raise ValueError(
|
||||
f"Trägerbild zu klein: brauche {need_bits} Bits, "
|
||||
f"Platz für {capacity_bits} ({need_bits // 8} vs "
|
||||
f"{capacity_bits // 8} Bytes). Nimm ein größeres Foto."
|
||||
)
|
||||
|
||||
px = img.load()
|
||||
bits = _bits_from_bytes(payload)
|
||||
done = False
|
||||
for y in range(height):
|
||||
if done:
|
||||
break
|
||||
for x in range(width):
|
||||
r, g, b = px[x, y]
|
||||
channels = [r, g, b]
|
||||
for c in range(3):
|
||||
bit = next(bits, None)
|
||||
if bit is None:
|
||||
done = True
|
||||
break
|
||||
channels[c] = (channels[c] & ~1) | bit
|
||||
px[x, y] = (channels[0], channels[1], channels[2])
|
||||
if done:
|
||||
break
|
||||
|
||||
img.save(out_path, "PNG")
|
||||
|
||||
|
||||
def _read_bits(px, width, height, n_bits):
|
||||
"""Liest n_bits LSBs linear aus dem Bild und gibt sie als Bytes zurück."""
|
||||
out = bytearray()
|
||||
acc = 0
|
||||
count = 0
|
||||
read = 0
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b = px[x, y]
|
||||
for c in (r, g, b):
|
||||
acc = (acc << 1) | (c & 1)
|
||||
count += 1
|
||||
read += 1
|
||||
if count == 8:
|
||||
out.append(acc)
|
||||
acc = 0
|
||||
count = 0
|
||||
if read >= n_bits:
|
||||
return bytes(out)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def extract(stego_path: str) -> tuple[int, bytes]:
|
||||
"""Liest Header + Ciphertext aus einem Stego-Bild. Gibt (offset, ct)."""
|
||||
img = Image.open(stego_path).convert("RGB")
|
||||
width, height = img.size
|
||||
px = img.load()
|
||||
|
||||
header = _read_bits(px, width, height, HEADER_LEN * 8)
|
||||
magic, version, offset, length = struct.unpack(HEADER_FMT, header)
|
||||
if magic != MAGIC:
|
||||
raise ValueError(
|
||||
"Kein Schattenpost-Container gefunden (Magic passt nicht). "
|
||||
"Wurde das Bild vielleicht doch als Foto komprimiert?"
|
||||
)
|
||||
if version != VERSION:
|
||||
raise ValueError(f"Unbekannte Version {version}.")
|
||||
|
||||
total_bits = (HEADER_LEN + length) * 8
|
||||
blob = _read_bits(px, width, height, total_bits)
|
||||
ciphertext = blob[HEADER_LEN:HEADER_LEN + length]
|
||||
return offset, ciphertext
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Commands
|
||||
# --------------------------------------------------------------------------- #
|
||||
def cmd_genkey(args):
|
||||
size = args.size
|
||||
if os.path.exists(args.output) and not args.force:
|
||||
sys.exit(f"[!] {args.output} existiert schon. --force zum Überschreiben.")
|
||||
with open(args.output, "wb") as fh:
|
||||
fh.write(os.urandom(size))
|
||||
write_offset(args.output, 0)
|
||||
print(f"[+] Pad erzeugt: {args.output} ({size} Bytes, echtes os.urandom)")
|
||||
print(f"[+] Offset-Zähler zurückgesetzt: {args.output}.offset -> 0")
|
||||
print("[i] Gib dieses Pad sicher an deinen Gesprächspartner weiter "
|
||||
"(z.B. USB-Stick, persönlich). Beide brauchen dieselbe Datei.")
|
||||
|
||||
|
||||
def cmd_hide(args):
|
||||
if args.text is not None:
|
||||
plaintext = args.text.encode("utf-8")
|
||||
elif args.infile:
|
||||
with open(args.infile, "rb") as fh:
|
||||
plaintext = fh.read()
|
||||
else:
|
||||
plaintext = sys.stdin.buffer.read()
|
||||
|
||||
if not plaintext:
|
||||
sys.exit("[!] Leere Nachricht.")
|
||||
|
||||
pad = load_pad(args.key)
|
||||
offset = read_offset(args.key)
|
||||
segment = pad[offset:offset + len(plaintext)]
|
||||
if len(segment) < len(plaintext):
|
||||
sys.exit(
|
||||
f"[!] Pad erschöpft: ab Offset {offset} bleiben nur "
|
||||
f"{len(segment)} Bytes, Nachricht braucht {len(plaintext)}. "
|
||||
"Erzeuge/tausche ein neues Pad."
|
||||
)
|
||||
|
||||
ciphertext = otp_xor(plaintext, segment)
|
||||
header = struct.pack(HEADER_FMT, MAGIC, VERSION, offset, len(ciphertext))
|
||||
payload = header + ciphertext
|
||||
|
||||
embed(args.carrier, payload, args.output)
|
||||
write_offset(args.key, offset + len(plaintext))
|
||||
|
||||
print(f"[+] Nachricht verschlüsselt ({len(plaintext)} Bytes) "
|
||||
f"mit Pad-Offset {offset}.")
|
||||
print(f"[+] Versteckt in: {args.output}")
|
||||
print(f"[+] Pad-Offset weitergezählt -> {offset + len(plaintext)}")
|
||||
print("[i] Verschicke das PNG als DATEI/Dokument, nicht als Foto!")
|
||||
|
||||
|
||||
def cmd_reveal(args):
|
||||
offset, ciphertext = extract(args.stego)
|
||||
pad = load_pad(args.key)
|
||||
segment = pad[offset:offset + len(ciphertext)]
|
||||
if len(segment) < len(ciphertext):
|
||||
sys.exit(
|
||||
f"[!] Pad passt nicht: ab Offset {offset} brauche ich "
|
||||
f"{len(ciphertext)} Bytes, habe nur {len(segment)}."
|
||||
)
|
||||
plaintext = otp_xor(ciphertext, segment)
|
||||
|
||||
if args.outfile:
|
||||
with open(args.outfile, "wb") as fh:
|
||||
fh.write(plaintext)
|
||||
print(f"[+] Entschlüsselt ({len(plaintext)} Bytes, Offset {offset}) "
|
||||
f"-> {args.outfile}")
|
||||
else:
|
||||
try:
|
||||
sys.stdout.write(plaintext.decode("utf-8"))
|
||||
if not plaintext.endswith(b"\n"):
|
||||
sys.stdout.write("\n")
|
||||
except UnicodeDecodeError:
|
||||
sys.stdout.buffer.write(plaintext)
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="schattenpost",
|
||||
description="One-Time-Pad + LSB-Steganografie für vertrauliche Nachrichten.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
g = sub.add_parser("genkey", help="Echtes Zufalls-Pad erzeugen.")
|
||||
g.add_argument("-o", "--output", required=True, help="Pad-Datei.")
|
||||
g.add_argument("-s", "--size", type=int, default=1_000_000,
|
||||
help="Größe in Bytes (Default 1 MB).")
|
||||
g.add_argument("--force", action="store_true", help="Überschreiben erlauben.")
|
||||
g.set_defaults(func=cmd_genkey)
|
||||
|
||||
h = sub.add_parser("hide", help="Nachricht verschlüsseln + verstecken.")
|
||||
h.add_argument("-t", "--text", help="Nachrichtentext (sonst --infile/stdin).")
|
||||
h.add_argument("-i", "--infile", help="Datei als Nachricht.")
|
||||
h.add_argument("-k", "--key", required=True, help="Pad-Datei.")
|
||||
h.add_argument("-c", "--carrier", required=True, help="Trägerbild.")
|
||||
h.add_argument("-o", "--output", required=True, help="Stego-PNG-Ausgabe.")
|
||||
h.set_defaults(func=cmd_hide)
|
||||
|
||||
r = sub.add_parser("reveal", help="Nachricht extrahieren + entschlüsseln.")
|
||||
r.add_argument("-s", "--stego", required=True, help="Stego-PNG.")
|
||||
r.add_argument("-k", "--key", required=True, help="Pad-Datei.")
|
||||
r.add_argument("-o", "--outfile", help="Ausgabedatei (sonst stdout).")
|
||||
r.set_defaults(func=cmd_reveal)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
args.func(args)
|
||||
except (ValueError, FileNotFoundError, struct.error) as exc:
|
||||
sys.exit(f"[!] {exc}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user