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:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -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/
|
||||||
108
README.md
Normal file
108
README.md
Normal file
@@ -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.
|
||||||
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