Authentifizierung für CLI und Web: - Poly1305 (RFC 8439) pur in Python (bigint) und JS (BigInt), keine neue Dependency - MAC-Schluessel pro Nachricht frisch aus dem Pad (len+32), encrypt-then-MAC ueber Header + Ciphertext; reveal lehnt manipulierte/desynchrone Bilder hart ab - Stego-Format SPS2 -> SPS3 (16-B-Tag im Header); alte SPS2-Bilder werden noch gelesen, aber als unauthentifiziert markiert - Service-Worker-Cache auf v3 gebumpt - Interop-Runner (web/_interop_run.sh): RFC-Vektor cross-impl, beide Richtungen, Tamper-Erkennung README fuer Public ueberarbeitet (ehrliches Bedrohungsmodell, Voraussetzungen, Projektstruktur, Tests) + MIT-LICENSE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
418 lines
16 KiB
Python
418 lines
16 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.
|
|
|
|
RICHTUNGSGETRENNTE SCHLÜSSEL
|
|
----------------------------
|
|
Jede Beziehung nutzt ZWEI unabhängige Schlüsselströme — einen pro Senderichtung.
|
|
Dadurch können sich zwei gleichzeitig sendende Parteien nie überschneiden
|
|
(kein „two-time-pad"). Jede Seite hält:
|
|
|
|
send.key — dein Sendeschlüssel. Trägt den Sende-Offset IM Header; er wandert
|
|
bei jeder Nachricht vor. Der Partner hat dieselben Bytes als recv.key.
|
|
recv.key — dein Empfangsschlüssel. Wird nur GELESEN (Offset kommt aus dem Bild),
|
|
nie verändert.
|
|
|
|
Der Sende-Offset lebt also im Schlüssel selbst. Legt man den Schlüssel auf einen
|
|
USB-Stick und hängt ihn nur ans gerade benutzte Gerät, gibt es physisch nur einen
|
|
einzigen Offset-Zähler — kein Auseinanderdriften über mehrere Geräte möglich.
|
|
|
|
WICHTIG
|
|
-------
|
|
* Stego-Bild IMMER als Datei/Dokument verschicken, nie als „Foto" — Messenger
|
|
komprimieren Fotos verlustbehaftet und zerstören die versteckten Bits.
|
|
* send.key niemals doppelt/parallel auf zwei Geräten bespielen (siehe oben).
|
|
|
|
Subcommands:
|
|
genkey Erzeugt ein gerichtetes Schlüsselpaar für eine Beziehung.
|
|
hide Verschlüsselt + versteckt eine Nachricht (braucht send.key).
|
|
reveal Extrahiert + entschlüsselt eine Nachricht (braucht recv.key).
|
|
|
|
Abhängigkeit: Pillow (pip install Pillow)
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import struct
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
from PIL import Image
|
|
|
|
# Unauffälliger Default-Dateiname (Screenshot-Stil passt zu PNG; echte
|
|
# Kamerafotos sind JPG). Identisch zum Web-Frontend.
|
|
CARRIER_PREFIX = "Screenshot"
|
|
|
|
|
|
def default_carrier_name() -> str:
|
|
return datetime.now().strftime(f"{CARRIER_PREFIX}_%Y%m%d_%H%M%S.png")
|
|
|
|
# --- Schlüsseldatei ---------------------------------------------------------
|
|
KEY_MAGIC = b"SPK1"
|
|
KEY_VERSION = 1
|
|
ROLE_RECV = 0
|
|
ROLE_SEND = 1
|
|
# magic(4) ver(1) role(1) stream_id(8) send_offset(uint64 BE, 8) + 2 pad = 24
|
|
KEY_FMT = ">4sBB8sQ2x"
|
|
KEY_HEADER_LEN = struct.calcsize(KEY_FMT)
|
|
KEY_OFFSET_POS = 14 # Byteposition des send_offset-Feldes (für In-Place-Update)
|
|
|
|
# --- Stego-Container (im Bild) ---------------------------------------------
|
|
# v3 authentifiziert zusätzlich mit einem Poly1305-Einmal-MAC (Tag im Header).
|
|
STEGO_MAGIC = b"SPS3"
|
|
STEGO_VERSION = 3
|
|
# Vor-Tag-Header: magic(4) ver(1) stream_id(8) offset(uint64 BE,8) len(uint32 BE,4)=25
|
|
STEGO_PRE_FMT = ">4sB8sQI"
|
|
STEGO_PRE_LEN = struct.calcsize(STEGO_PRE_FMT) # 25
|
|
STEGO_TAG_LEN = 16 # Poly1305-Tag
|
|
STEGO_HEADER_LEN = STEGO_PRE_LEN + STEGO_TAG_LEN # 41
|
|
MAC_KEY_LEN = 32 # r(16)+s(16), aus dem Pad
|
|
|
|
# Alt-Format (nur noch beim Aufdecken akzeptiert, unauthentifiziert).
|
|
STEGO_MAGIC_V2 = b"SPS2"
|
|
STEGO_VERSION_V2 = 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# One-Time-Pad
|
|
# --------------------------------------------------------------------------- #
|
|
def otp_xor(data: bytes, material: bytes, offset: int) -> bytes:
|
|
if offset + len(data) > len(material):
|
|
raise ValueError(
|
|
f"Schlüssel erschöpft: ab Offset {offset} brauche ich {len(data)} "
|
|
f"Bytes, habe nur {len(material) - offset}."
|
|
)
|
|
return bytes(d ^ material[offset + i] for i, d in enumerate(data))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Poly1305 — informationstheoretisch sicherer Einmal-MAC (RFC 8439)
|
|
# --------------------------------------------------------------------------- #
|
|
# Der 32-Byte-Schlüssel (r|s) wird pro Nachricht frisch aus dem One-Time-Pad
|
|
# gezogen (direkt hinter dem Nachrichten-Keystream) und NIE wiederverwendet —
|
|
# genau die Bedingung, unter der Poly1305 fälschungssicher ist, ohne die
|
|
# informationstheoretische Sicherheit des OTP aufzugeben.
|
|
_P1305_P = (1 << 130) - 5
|
|
_P1305_CLAMP = 0x0ffffffc0ffffffc0ffffffc0fffffff
|
|
|
|
|
|
def poly1305_mac(msg: bytes, key: bytes) -> bytes:
|
|
if len(key) != 32:
|
|
raise ValueError("Poly1305-Schlüssel muss 32 Byte sein.")
|
|
r = int.from_bytes(key[:16], "little") & _P1305_CLAMP
|
|
s = int.from_bytes(key[16:32], "little")
|
|
acc = 0
|
|
for i in range(0, len(msg), 16):
|
|
block = msg[i:i + 16]
|
|
n = int.from_bytes(block, "little") | (1 << (8 * len(block)))
|
|
acc = (acc + n) * r % _P1305_P
|
|
return ((acc + s) % (1 << 128)).to_bytes(16, "little")
|
|
|
|
|
|
def ct_equal(a: bytes, b: bytes) -> bool:
|
|
"""Konstant-zeitiger Vergleich (kein Early-Exit-Timing-Leck)."""
|
|
if len(a) != len(b):
|
|
return False
|
|
diff = 0
|
|
for x, y in zip(a, b):
|
|
diff |= x ^ y
|
|
return diff == 0
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Schlüsseldateien
|
|
# --------------------------------------------------------------------------- #
|
|
def read_key(path: str) -> dict:
|
|
with open(path, "rb") as fh:
|
|
raw = fh.read()
|
|
if len(raw) < KEY_HEADER_LEN:
|
|
raise ValueError(f"{path}: keine gültige Schlüsseldatei (zu kurz).")
|
|
magic, ver, role, stream_id, send_offset = struct.unpack(
|
|
KEY_FMT, raw[:KEY_HEADER_LEN]
|
|
)
|
|
if magic != KEY_MAGIC:
|
|
raise ValueError(f"{path}: keine schattenpost-Schlüsseldatei.")
|
|
if ver != KEY_VERSION:
|
|
raise ValueError(f"{path}: unbekannte Schlüsselversion {ver}.")
|
|
return {
|
|
"role": role,
|
|
"stream_id": stream_id,
|
|
"send_offset": send_offset,
|
|
"material": raw[KEY_HEADER_LEN:],
|
|
}
|
|
|
|
|
|
def write_key(path: str, role: int, stream_id: bytes, send_offset: int,
|
|
material: bytes) -> None:
|
|
header = struct.pack(KEY_FMT, KEY_MAGIC, KEY_VERSION, role, stream_id,
|
|
send_offset)
|
|
with open(path, "wb") as fh:
|
|
fh.write(header + material)
|
|
|
|
|
|
def advance_send_offset(path: str, new_offset: int) -> None:
|
|
"""Schreibt nur das 8-Byte-Offset-Feld in-place (kein Rewrite des Materials)."""
|
|
with open(path, "r+b") as fh:
|
|
fh.seek(KEY_OFFSET_POS)
|
|
fh.write(struct.pack(">Q", new_offset))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# LSB-Steganografie
|
|
# --------------------------------------------------------------------------- #
|
|
def _bits_from_bytes(data: bytes):
|
|
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:
|
|
img = Image.open(carrier_path).convert("RGB")
|
|
width, height = img.size
|
|
need_bits = len(payload) * 8
|
|
if need_bits > width * height * 3:
|
|
raise ValueError(
|
|
f"Trägerbild zu klein: brauche {need_bits // 8} Bytes, Platz für "
|
|
f"{width * height * 3 // 8}. 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]
|
|
ch = [r, g, b]
|
|
for c in range(3):
|
|
bit = next(bits, None)
|
|
if bit is None:
|
|
done = True
|
|
break
|
|
ch[c] = (ch[c] & ~1) | bit
|
|
px[x, y] = (ch[0], ch[1], ch[2])
|
|
if done:
|
|
break
|
|
img.save(out_path, "PNG")
|
|
|
|
|
|
def _read_bits(px, width, height, n_bits):
|
|
out = bytearray()
|
|
acc = count = read = 0
|
|
for y in range(height):
|
|
for x in range(width):
|
|
for c in px[x, y]:
|
|
acc = (acc << 1) | (c & 1)
|
|
count += 1
|
|
read += 1
|
|
if count == 8:
|
|
out.append(acc)
|
|
acc = count = 0
|
|
if read >= n_bits:
|
|
return bytes(out)
|
|
return bytes(out)
|
|
|
|
|
|
def extract(stego_path: str) -> dict:
|
|
img = Image.open(stego_path).convert("RGB")
|
|
width, height = img.size
|
|
px = img.load()
|
|
# Vor-Tag-Header ist bei v2 und v3 identisch aufgebaut (25 B) -> erst lesen.
|
|
pre = _read_bits(px, width, height, STEGO_PRE_LEN * 8)
|
|
magic, ver, stream_id, offset, length = struct.unpack(STEGO_PRE_FMT, pre)
|
|
if magic == STEGO_MAGIC and ver == STEGO_VERSION:
|
|
blob = _read_bits(px, width, height,
|
|
(STEGO_HEADER_LEN + length) * 8)
|
|
return {
|
|
"version": ver,
|
|
"stream_id": stream_id,
|
|
"offset": offset,
|
|
"length": length,
|
|
"tag": blob[STEGO_PRE_LEN:STEGO_HEADER_LEN],
|
|
"mac_input_header": blob[:STEGO_PRE_LEN],
|
|
"ciphertext": blob[STEGO_HEADER_LEN:STEGO_HEADER_LEN + length],
|
|
}
|
|
if magic == STEGO_MAGIC_V2 and ver == STEGO_VERSION_V2:
|
|
blob = _read_bits(px, width, height,
|
|
(STEGO_PRE_LEN + length) * 8)
|
|
return {
|
|
"version": ver,
|
|
"stream_id": stream_id,
|
|
"offset": offset,
|
|
"length": length,
|
|
"tag": None,
|
|
"ciphertext": blob[STEGO_PRE_LEN:STEGO_PRE_LEN + length],
|
|
}
|
|
raise ValueError(
|
|
"Kein schattenpost-Container gefunden (Magic/Version passt nicht). "
|
|
"Wurde das Bild als Foto verlustbehaftet komprimiert?"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Commands
|
|
# --------------------------------------------------------------------------- #
|
|
def cmd_genkey(args):
|
|
size = args.size
|
|
mine = args.output + "_meine_seite"
|
|
theirs = args.output + "_partner_seite"
|
|
for d in (mine, theirs):
|
|
if os.path.exists(d) and not args.force:
|
|
sys.exit(f"[!] {d} existiert schon. --force zum Überschreiben.")
|
|
os.makedirs(d, exist_ok=True)
|
|
|
|
# Zwei unabhängige Ströme: sid_out = ich->Partner, sid_in = Partner->ich
|
|
sid_out, sid_in = os.urandom(8), os.urandom(8)
|
|
mat_out, mat_in = os.urandom(size), os.urandom(size)
|
|
|
|
# Meine Seite: sende mit Strom OUT, empfange Strom IN
|
|
write_key(os.path.join(mine, "send.key"), ROLE_SEND, sid_out, 0, mat_out)
|
|
write_key(os.path.join(mine, "recv.key"), ROLE_RECV, sid_in, 0, mat_in)
|
|
# Partnerseite (spiegelverkehrt): sendet mit Strom IN, empfängt Strom OUT
|
|
write_key(os.path.join(theirs, "send.key"), ROLE_SEND, sid_in, 0, mat_in)
|
|
write_key(os.path.join(theirs, "recv.key"), ROLE_RECV, sid_out, 0, mat_out)
|
|
|
|
print(f"[+] Schlüsselpaar erzeugt ({size} Bytes je Richtung, echter Zufall).")
|
|
print(f" {mine}/ <- DU behältst diesen Ordner")
|
|
print(f" {theirs}/ <- gib DIESEN Ordner dem Partner (Stick, persönlich)")
|
|
print("[i] Jede Seite hat send.key (zum Senden) + recv.key (zum Empfangen).")
|
|
print("[i] send.key nur an EINEM Gerät gleichzeitig benutzen.")
|
|
|
|
|
|
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.")
|
|
|
|
out_path = args.output or default_carrier_name()
|
|
key = read_key(args.key)
|
|
if key["role"] != ROLE_SEND:
|
|
sys.exit("[!] Das ist ein Empfangsschlüssel (recv.key). Zum Senden "
|
|
"brauchst du deinen send.key.")
|
|
offset = key["send_offset"]
|
|
# Pro Nachricht: len(plaintext) Bytes Keystream + 32 Bytes MAC-Schlüssel.
|
|
need = len(plaintext) + MAC_KEY_LEN
|
|
segment = key["material"][offset:offset + need]
|
|
if len(segment) < need:
|
|
sys.exit(f"[!] Sendeschlüssel erschöpft: ab Offset {offset} bleiben nur "
|
|
f"{len(segment)} Bytes, Nachricht+MAC brauchen {need}. "
|
|
"Erzeugt ein neues Schlüsselpaar.")
|
|
|
|
ciphertext = otp_xor(plaintext, key["material"], offset)
|
|
mac_key = key["material"][offset + len(plaintext):offset + need]
|
|
pre_header = struct.pack(STEGO_PRE_FMT, STEGO_MAGIC, STEGO_VERSION,
|
|
key["stream_id"], offset, len(ciphertext))
|
|
tag = poly1305_mac(pre_header + ciphertext, mac_key)
|
|
embed(args.carrier, pre_header + tag + ciphertext, out_path)
|
|
advance_send_offset(args.key, offset + need)
|
|
|
|
print(f"[+] Verschlüsselt + authentifiziert ({len(plaintext)} B) ab "
|
|
f"Sende-Offset {offset}.")
|
|
print(f"[+] Versteckt in: {out_path}")
|
|
print(f"[+] Sende-Offset im Schlüssel weitergezählt -> {offset + need} "
|
|
f"(Nachricht {len(plaintext)} + MAC {MAC_KEY_LEN}).")
|
|
print("[i] Als DATEI/Dokument verschicken, nicht als Foto!")
|
|
|
|
|
|
def cmd_reveal(args):
|
|
info = extract(args.stego)
|
|
key = read_key(args.key)
|
|
if key["role"] != ROLE_RECV:
|
|
sys.exit("[!] Das ist ein Sendeschlüssel (send.key). Zum Empfangen "
|
|
"brauchst du deinen recv.key.")
|
|
if info["stream_id"] != key["stream_id"]:
|
|
sys.exit("[!] Falscher Schlüssel: dieses Bild gehört zu einem anderen "
|
|
"Kontakt/Strom. Nimm den passenden recv.key.")
|
|
offset, ct = info["offset"], info["ciphertext"]
|
|
|
|
if info["version"] == STEGO_VERSION:
|
|
# Authentifiziert: MAC-Schlüssel liegt direkt hinter dem Keystream.
|
|
need = len(ct) + MAC_KEY_LEN
|
|
segment = key["material"][offset:offset + need]
|
|
if len(segment) < need:
|
|
sys.exit(f"[!] Schlüssel passt nicht: ab Offset {offset} brauche ich "
|
|
f"{need} Bytes (Nachricht+MAC), habe nur {len(segment)}.")
|
|
mac_key = key["material"][offset + len(ct):offset + need]
|
|
expected = poly1305_mac(info["mac_input_header"] + ct, mac_key)
|
|
if not ct_equal(expected, info["tag"]):
|
|
sys.exit("[!] Authentifizierung fehlgeschlagen: das Bild wurde "
|
|
"verändert, oder der Schlüssel ist nicht synchron "
|
|
"(Offset-Drift). Nachricht NICHT vertrauen.")
|
|
else:
|
|
segment = key["material"][offset:offset + len(ct)]
|
|
if len(segment) < len(ct):
|
|
sys.exit(f"[!] Schlüssel passt nicht: ab Offset {offset} brauche ich "
|
|
f"{len(ct)} Bytes, habe nur {len(segment)}.")
|
|
print("[i] Warnung: altes SPS2-Bild — unauthentifiziert, "
|
|
"keine Manipulationssicherheit.", file=sys.stderr)
|
|
|
|
plaintext = otp_xor(ct, key["material"], offset)
|
|
|
|
if args.outfile:
|
|
with open(args.outfile, "wb") as fh:
|
|
fh.write(plaintext)
|
|
print(f"[+] Entschlüsselt ({len(plaintext)} B, 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, richtungsgetrennte Schlüssel.",
|
|
)
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
g = sub.add_parser("genkey", help="Gerichtetes Schlüsselpaar erzeugen.")
|
|
g.add_argument("-o", "--output", required=True,
|
|
help="Basisname (erzeugt <name>_meine_seite/ + _partner_seite/).")
|
|
g.add_argument("-s", "--size", type=int, default=1_000_000,
|
|
help="Bytes je Richtung (Default 1 MB).")
|
|
g.add_argument("--force", action="store_true")
|
|
g.set_defaults(func=cmd_genkey)
|
|
|
|
h = sub.add_parser("hide", help="Nachricht verschlüsseln + verstecken.")
|
|
h.add_argument("-t", "--text")
|
|
h.add_argument("-i", "--infile")
|
|
h.add_argument("-k", "--key", required=True, help="Dein send.key.")
|
|
h.add_argument("-c", "--carrier", required=True, help="Trägerbild.")
|
|
h.add_argument("-o", "--output",
|
|
help="Stego-PNG (Default: unauffälliger Screenshot_-Name "
|
|
"mit Zeitstempel).")
|
|
h.set_defaults(func=cmd_hide)
|
|
|
|
r = sub.add_parser("reveal", help="Nachricht extrahieren + entschlüsseln.")
|
|
r.add_argument("-s", "--stego", required=True)
|
|
r.add_argument("-k", "--key", required=True, help="Dein recv.key.")
|
|
r.add_argument("-o", "--outfile")
|
|
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()
|