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>
278 lines
9.7 KiB
Python
278 lines
9.7 KiB
Python
#!/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()
|