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