commit d5e7c979d8d4f76a29ca4c0f221420b4f7dbada6 Author: Artemis Date: Tue Jul 14 20:41:21 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05b7095 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# NIEMALS Pads oder Offsets committen — das ist der geheime Schlüssel! +*.key +*.key.offset +*.offset + +# Test-/Ausgabebilder und Träger raushalten +*.png +*.jpg +*.jpeg +!docs/*.png + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbefc53 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# schattenpost + +Vertrauliche Nachrichten via **One-Time-Pad** + **LSB-Steganografie**. + +Verschlüsselt Text mit einem echten One-Time-Pad (XOR gegen `os.urandom`-Zufall) +und versteckt den Ciphertext in den niederwertigsten Bits eines Trägerfotos. Das +Ergebnis sieht aus wie ein ganz normales Bild, trägt die Nachricht aber bit-genau +in sich. Gedacht als unauffälliger Kanal, dessen Inhalt selbst bei Mitlesen des +Transports nicht rekonstruierbar ist. + +## Warum das (theoretisch) unknackbar ist + +One-Time-Pad ist der einzige Cipher mit *informationstheoretischer* Sicherheit — +nicht nur „rechnerisch schwer", sondern beweisbar unknackbar. Das gilt aber nur, +solange **alle drei** Regeln eingehalten werden: + +1. Der Schlüssel (das *Pad*) ist echt zufällig. +2. Das Pad ist mindestens so lang wie die Nachricht. +3. Jeder Pad-Abschnitt wird **nur ein einziges Mal** benutzt. + +Regel 3 ist der klassische Todesstoß (`two-time-pad`). schattenpost erzwingt sie +automatisch über einen Offset-Zähler (siehe unten). + +## Installation + +```bash +pip install Pillow +``` + +Sonst nur Python-Stdlib. + +## Benutzung + +### 1. Pad erzeugen (einmalig) + +```bash +python3 schattenpost.py genkey -o pad.key -s 1000000 # 1 MB Pad +``` + +Das erzeugte `pad.key` **sicher und persönlich** an den Gesprächspartner +übergeben (USB-Stick, kein digitaler Kanal!). Beide Seiten brauchen die +byte-identische Datei. Daneben entsteht `pad.key.offset` — der Verbrauchszähler. + +### 2. Nachricht verstecken + +```bash +python3 schattenpost.py hide \ + -t "Treffpunkt morgen 18 Uhr am alten Hafen." \ + -k pad.key \ + -c urlaubsfoto.jpg \ + -o harmlos.png +``` + +Alternativ `-i datei.txt` statt `-t`, oder Text über stdin. + +### 3. Nachricht rausholen (Gegenseite) + +```bash +python3 schattenpost.py reveal -s harmlos.png -k pad.key +``` + +Mit `-o klartext.txt` in eine Datei statt auf stdout. + +## ⚠️ Der eine wichtige Haken + +**Verschicke das Stego-PNG immer als Datei/Dokument, niemals als „Foto".** + +Messenger (WhatsApp, Telegram-„Foto", …) jagen Fotos durch verlustbehaftete +JPEG-Kompression. Das verändert die Pixelwerte und **zerstört die versteckten +Bits sofort** — `reveal` schlägt dann fehl. Als Dokument/Datei bleibt das PNG +bit-genau erhalten. + +## Wie der Pad-Wiederverwendungsschutz funktioniert + +- `pad.key.offset` merkt sich den nächsten unbenutzten Byte-Offset im Pad. +- `hide` verschlüsselt ab diesem Offset, schreibt den Offset **in den + Stego-Header** und zählt den Zähler um die Nachrichtenlänge weiter. +- `reveal` liest den Offset aus dem Bild und weiß automatisch, welcher + Pad-Abschnitt gilt. + +So wird garantiert kein Pad-Byte doppelt verwendet — solange der Zähler nicht +manuell zurückgedreht wird. Der Sender führt den Zähler; der Empfänger braucht +ihn nicht (der Offset steckt im Bild). + +## Container-Format + +Vor der LSB-Einbettung wird ein kleiner Header vorangestellt: + +| Feld | Größe | Inhalt | +|---------|-------|-------------------------------| +| magic | 4 B | `SPST` | +| version | 1 B | `1` | +| offset | 8 B | Pad-Offset (uint64, BE) | +| length | 4 B | Ciphertext-Länge (uint32, BE) | +| ciphertext | n B | XOR(plaintext, pad[offset…]) | + +Die Bits werden linear (MSB zuerst) in die LSBs der R/G/B-Kanäle geschrieben. + +## Grenzen / Hinweise + +- **Steganografie versteckt, authentifiziert aber nicht.** Ein Angreifer, der + das Format kennt, könnte Bits kippen. Für Manipulationsschutz bräuchte es + zusätzlich einen MAC (z.B. HMAC mit einem separaten Pad-Bereich). +- Die Sicherheit steht und fällt mit der **geheimen, sicheren Übergabe des + Pads**. Das ist der eigentliche Aufwand bei OTP — es gibt kein Schlüssel- + Austauschprotokoll, das dir das abnimmt. +- Pads gehören **niemals** in ein Repository. Die `.gitignore` blockt `*.key` + und `*.offset` bewusst. diff --git a/schattenpost.py b/schattenpost.py new file mode 100644 index 0000000..edcd0c9 --- /dev/null +++ b/schattenpost.py @@ -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()