From 30127db24753be13c92ed568b0071b3f22d624af Mon Sep 17 00:00:00 2001 From: Artemis Date: Tue, 14 Jul 2026 21:46:06 +0000 Subject: [PATCH] v2: richtungsgetrennte Schluessel, Offset im Schluessel, PWA - Neues Schluesselmodell: pro Beziehung zwei Stroeme (send.key/recv.key), Sende-Offset lebt im Schluessel-Header und wird beim Senden fortgeschrieben. Loest two-time-pad bei bidirektionaler Nutzung deterministisch. - Stream-Identifier im Bild -> falscher Schluessel wird erkannt statt Kauderwelsch zu liefern. Rollen-Guards (send/recv) gegen Fehlbenutzung. - CLI genkey erzeugt gerichtetes Keypaar (meine_seite/partner_seite). - Web-App auf v2 umgestellt; Senden liefert aktualisierten send.key zum Download (Browser-Sandbox kann nicht auf den Stick schreiben). - PWA: manifest + service worker (offline) + icons. - Interop CLI<->Web in beide Richtungen per Node-Test abgesichert. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 + README.md | 197 ++++++++++-------- schattenpost.py | 286 +++++++++++++++----------- web/_interop_test.mjs | 53 +++-- web/icon-192.png | Bin 0 -> 1470 bytes web/icon-512.png | Bin 0 -> 3992 bytes web/index.html | 432 ++++++++++++++++++--------------------- web/manifest.webmanifest | 17 ++ web/sw.js | 28 +++ 9 files changed, 549 insertions(+), 469 deletions(-) create mode 100644 web/icon-192.png create mode 100644 web/icon-512.png create mode 100644 web/manifest.webmanifest create mode 100644 web/sw.js diff --git a/.gitignore b/.gitignore index 80135a7..f80f430 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ *.jpg *.jpeg !docs/*.png +!web/icon-*.png + +# Test-Schlüsselordner +/t2/ +/t/ # Interop-Test-Artefakte (Helfer selbst werden committet) *.bin diff --git a/README.md b/README.md index 9f5ef38..e86f063 100644 --- a/README.md +++ b/README.md @@ -2,142 +2,165 @@ 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. +Verschlüsselt Text mit einem echten One-Time-Pad (XOR gegen `os.urandom`- bzw. +`crypto.getRandomValues`-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. + +Es gibt zwei Frontends mit **identischem Dateiformat**: + +* `schattenpost.py` — Kommandozeilen-Tool (Python + Pillow). +* `web/` — eine client-side Web-App / PWA (läuft im Browser, auch iOS). ## 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: +beweisbar unknackbar, solange **alle drei** Regeln gelten: -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. +1. Der Schlüssel ist echt zufällig. +2. Der Schlüssel ist mindestens so lang wie die Nachricht. +3. Jeder Schlüsselabschnitt 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). +über **richtungsgetrennte Schlüssel** + einen Offset, der im Schlüssel selbst +lebt. -## Installation +## Das Schlüsselmodell + +Jede Beziehung nutzt **zwei unabhängige Schlüsselströme** — einen pro +Senderichtung. Jede Seite hält zwei Dateien: + +| Datei | Zweck | Verhalten | +|------------|-------|-----------| +| `send.key` | Senden | Trägt den **Sende-Offset** im Header; er wandert bei jeder Nachricht vor. | +| `recv.key` | Empfangen | Wird nur **gelesen** (Offset kommt aus dem Bild), nie verändert. | + +Dein `send.key` besteht aus demselben Zufallsmaterial wie der `recv.key` deines +Partners — und umgekehrt. Weil du und dein Partner zum Senden **nie denselben +Strom** benutzt, könnt ihr gleichzeitig schreiben, ohne je denselben +Schlüsselbereich zu treffen. + +Ein zufälliger **Stream-Identifier** in jedem Schlüssel landet auch im Bild. +Dadurch erkennt das Aufdecken sofort, ob der richtige `recv.key` benutzt wird — +statt stillen Kauderwelsch zu liefern. + +### OPSEC-Empfehlung + +Lege die Schlüssel auf einen **USB-Stick** und häng ihn nur ans gerade benutzte +Gerät. Dann existiert jeder `send.key` (mit seinem Offset) **physisch nur einmal** +— zwei Zähler können nicht auseinanderdriften, und auf Handy/Laptop liegt nie ein +Schlüssel herum. Mehrere Kontakte = mehrere Schlüsselordner auf dem Stick. + +## CLI ```bash pip install Pillow ``` -Sonst nur Python-Stdlib. - -## Benutzung - -### 1. Pad erzeugen (einmalig) +### 1. Schlüsselpaar erzeugen (einmalig, pro Kontakt) ```bash -python3 schattenpost.py genkey -o pad.key -s 1000000 # 1 MB Pad +python3 schattenpost.py genkey -o kontakt_bob -s 1000000 # 1 MB je Richtung ``` -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. +Erzeugt zwei Ordner: -### 2. Nachricht verstecken +* `kontakt_bob_meine_seite/` → **du** behältst ihn (enthält `send.key` + `recv.key`). +* `kontakt_bob_partner_seite/` → **dem Partner** geben (Stick, persönlich). + +### 2. Senden ```bash python3 schattenpost.py hide \ - -t "Treffpunkt morgen 18 Uhr am alten Hafen." \ - -k pad.key \ + -t "Treffpunkt morgen 18 Uhr." \ + -k kontakt_bob_meine_seite/send.key \ -c urlaubsfoto.jpg \ -o harmlos.png ``` -Alternativ `-i datei.txt` statt `-t`, oder Text über stdin. +Der Sende-Offset im `send.key` wird dabei automatisch fortgeschrieben. +Alternativ `-i datei` statt `-t`, oder Text über stdin. -### 3. Nachricht rausholen (Gegenseite) +### 3. Empfangen ```bash -python3 schattenpost.py reveal -s harmlos.png -k pad.key +python3 schattenpost.py reveal -s harmlos.png -k kontakt_bob_meine_seite/recv.key ``` Mit `-o klartext.txt` in eine Datei statt auf stdout. -## ⚠️ Der eine wichtige Haken +## Weboberfläche & PWA (`web/`) -**Verschicke das Stego-PNG immer als Datei/Dokument, niemals als „Foto".** +Eine **client-side** App: Verschlüsselung und Steganografie laufen zu 100 % im +Browser. Schlüssel, Klartext und Nachricht verlassen das Gerät nie. Ein Server, +der die Dateien ausliefert, sieht nichts. -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. +* **Überall erreichbar, iOS inklusive** (nur Safari-Standard-APIs). +* **PWA**: über „Zum Home-Bildschirm" installierbar; der Service Worker cacht die + App-Shell → danach **offline** nutzbar (kein Server mehr nötig). +* **Format-kompatibel zum CLI** — am Terminal senden, am Handy empfangen und + umgekehrt. Abgesichert durch `web/_interop_test.mjs`, der exakt den Krypto-Kern + aus der ausgelieferten `index.html` lädt und beide Richtungen gegen das CLI prüft. -## Wie der Pad-Wiederverwendungsschutz funktioniert +### Rollenverteilung (wichtig wegen der Browser-Sandbox) -- `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. +Ein Browser darf **nicht** in eine Datei auf dem Stick zurückschreiben. Deshalb: -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. - -## Weboberfläche (`web/index.html`) - -Eine **einzige, self-contained HTML-Datei** — kein Framework, kein CDN, kein -Build. Verschlüsselung und Steganografie laufen zu 100 % im Browser -(client-side): Pad, Klartext und Nachricht verlassen das Gerät nie. Ein Server, -der die Datei ausliefert, ist ein reiner „dummer" Fileserver und sieht nichts. - -* **Überall erreichbar, iOS inklusive.** Nutzt nur Safari-Standard-APIs - (Canvas, FileReader, `crypto.getRandomValues`). -* **Format-kompatibel zum CLI.** Am Terminal verstecken, am Handy im Browser - aufdecken — und umgekehrt. Byte-Interop ist per Test abgesichert - (`web/_interop_test.mjs` lädt exakt den Krypto-Kern aus der ausgelieferten - `index.html` und prüft beide Richtungen gegen das Python-CLI). -* **Offset-Tracking** merkt sich pro Pad (via `localStorage`) den nächsten - freien Offset. Achtung: Der Zähler ist pro Browser/Gerät. Wer abwechselnd über - mehrere Instanzen sendet, muss den Offset manuell synchron halten — sonst - droht Pad-Wiederverwendung. +* 📥 **Empfangen**: überall per Web/PWA — der `recv.key` wird nur gelesen. ✓ +* 📤 **Senden**: am Laptop am besten per **CLI** (schreibt den Offset direkt auf + den Stick). In der Web-App bekommst du nach dem Verstecken den **aktualisierten + `send.key` zum Download** und musst ihn auf dem Stick ersetzen — sonst würde der + nächste Sendevorgang denselben Schlüsselbereich erneut benutzen. ### Deployment -Es ist eine statische Datei — einfach irgendwo hinlegen: +Statische Dateien — einfach hosten: ```bash -# lokal testen -python3 -m http.server -d web 8000 # -> http://localhost:8000 - -# produktiv: web/index.html hinter einen beliebigen Webserver / Static-Host +python3 -m http.server -d web 8000 # lokal: http://localhost:8000 ``` +Produktiv hinter einen beliebigen Static-Host / Webserver (HTTPS nötig, damit +der Service Worker / die PWA-Installation greift). + ### ⚠️ iOS-Eigenheit Stego-Bild über **„In Dateien sichern"** ablegen und als **Datei** teilen — nicht in „Fotos" speichern. iOS re-komprimiert Bilder aus der Fotos-Mediathek und würde die Nachricht zerstören. +## Formate + +**Schlüsseldatei** (`SPK1`, 24-Byte-Header + Zufallsmaterial): + +| Feld | Größe | Inhalt | +|------|-------|--------| +| magic | 4 B | `SPK1` | +| version | 1 B | `1` | +| role | 1 B | `1` = send, `0` = recv | +| stream_id | 8 B | Strom-Kennung (identisch im Paar) | +| send_offset | 8 B | nächster freier Offset (uint64 BE) | +| *(padding)* | 2 B | | +| material | n B | echtes Zufalls-Pad | + +**Stego-Container im Bild** (`SPS2`, 25 B Header, dann Ciphertext, LSB MSB-first +in die R/G/B-Kanäle): + +| Feld | Größe | Inhalt | +|------|-------|--------| +| magic | 4 B | `SPS2` | +| version | 1 B | `2` | +| stream_id | 8 B | welcher Strom (Schlüssel-Check) | +| offset | 8 B | Pad-Offset (uint64 BE) | +| length | 4 B | Ciphertext-Länge (uint32 BE) | + ## 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. +* **Steganografie versteckt, authentifiziert aber nicht.** Wer das Format kennt, + könnte Bits kippen. Für Manipulationsschutz bräuchte es zusätzlich einen MAC. +* **Der eine Rest-Fall:** derselbe `send.key` gleichzeitig auf zwei Geräten (ohne + Stick-Prinzip) kann trotzdem kollidieren — das Bild-Offset hilft nur dem + Empfänger. Regel: **ein `send.key`, ein aktives Sendegerät.** +* Verschickte Stego-Bilder **immer als Datei/Dokument**, nie als „Foto". +* Schlüssel gehören **niemals** in ein Repository — die `.gitignore` blockt + `*.key` bewusst. diff --git a/schattenpost.py b/schattenpost.py index edcd0c9..ec2ea0a 100644 --- a/schattenpost.py +++ b/schattenpost.py @@ -6,19 +6,31 @@ 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. +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 echtes Zufalls-Pad. - hide Verschlüsselt + versteckt eine Nachricht in einem Trägerbild. - reveal Extrahiert + entschlüsselt eine Nachricht aus einem Stego-Bild. + 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) """ @@ -30,67 +42,92 @@ 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) +# --- 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) --------------------------------------------- +STEGO_MAGIC = b"SPS2" +STEGO_VERSION = 2 +# magic(4) ver(1) stream_id(8) offset(uint64 BE, 8) length(uint32 BE, 4) = 25 +STEGO_FMT = ">4sB8sQI" +STEGO_HEADER_LEN = struct.calcsize(STEGO_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): +def otp_xor(data: bytes, material: bytes, offset: int) -> bytes: + if offset + len(data) > len(material): raise ValueError( - f"Pad zu kurz: brauche {len(data)} Bytes, habe nur {len(key)}." + f"Schlüssel erschöpft: ab Offset {offset} brauche ich {len(data)} " + f"Bytes, habe nur {len(material) - offset}." ) - return bytes(d ^ k for d, k in zip(data, key)) + return bytes(d ^ material[offset + i] for i, d in enumerate(data)) -def load_pad(path: str) -> bytes: +# --------------------------------------------------------------------------- # +# Schlüsseldateien +# --------------------------------------------------------------------------- # +def read_key(path: str) -> dict: with open(path, "rb") as fh: - return fh.read() + 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 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_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 write_offset(pad_path: str, value: int) -> None: - with open(pad_path + ".offset", "w") as fh: - fh.write(str(value)) +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): - """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: + if need_bits > width * height * 3: 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." + 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 @@ -99,62 +136,55 @@ def embed(carrier_path: str, payload: bytes, out_path: str) -> None: break for x in range(width): r, g, b = px[x, y] - channels = [r, g, b] + ch = [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]) + 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): - """Liest n_bits LSBs linear aus dem Bild und gibt sie als Bytes zurück.""" out = bytearray() - acc = 0 - count = 0 - read = 0 + acc = count = read = 0 for y in range(height): for x in range(width): - r, g, b = px[x, y] - for c in (r, g, b): + for c in px[x, y]: acc = (acc << 1) | (c & 1) count += 1 read += 1 if count == 8: out.append(acc) - acc = 0 - count = 0 + acc = 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).""" +def extract(stego_path: str) -> dict: 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: + header = _read_bits(px, width, height, STEGO_HEADER_LEN * 8) + magic, ver, stream_id, offset, length = struct.unpack(STEGO_FMT, header) + if magic != STEGO_MAGIC: raise ValueError( - "Kein Schattenpost-Container gefunden (Magic passt nicht). " - "Wurde das Bild vielleicht doch als Foto komprimiert?" + "Kein schattenpost-Container gefunden (Magic passt nicht). " + "Wurde das Bild als Foto verlustbehaftet 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 + if ver != STEGO_VERSION: + raise ValueError(f"Unbekannte Container-Version {ver}.") + blob = _read_bits(px, width, height, (STEGO_HEADER_LEN + length) * 8) + return { + "stream_id": stream_id, + "offset": offset, + "ciphertext": blob[STEGO_HEADER_LEN:STEGO_HEADER_LEN + length], + } # --------------------------------------------------------------------------- # @@ -162,15 +192,29 @@ def extract(stego_path: str) -> tuple[int, bytes]: # --------------------------------------------------------------------------- # 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.") + 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): @@ -181,49 +225,53 @@ def cmd_hide(args): 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)] + 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"] + segment = key["material"][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." - ) + sys.exit(f"[!] Sendeschlüssel erschöpft: ab Offset {offset} bleiben nur " + f"{len(segment)} Bytes, Nachricht braucht {len(plaintext)}. " + "Erzeugt ein neues Schlüsselpaar.") - ciphertext = otp_xor(plaintext, segment) - header = struct.pack(HEADER_FMT, MAGIC, VERSION, offset, len(ciphertext)) - payload = header + ciphertext + ciphertext = otp_xor(plaintext, key["material"], offset) + header = struct.pack(STEGO_FMT, STEGO_MAGIC, STEGO_VERSION, + key["stream_id"], offset, len(ciphertext)) + embed(args.carrier, header + ciphertext, args.output) + advance_send_offset(args.key, offset + len(plaintext)) - 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"[+] Verschlüsselt ({len(plaintext)} B) ab Sende-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!") + print(f"[+] Sende-Offset im Schlüssel weitergezählt -> " + f"{offset + len(plaintext)}") + print("[i] Als DATEI/Dokument verschicken, 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) + 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"] + 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)}.") + 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)} Bytes, Offset {offset}) " + print(f"[+] Entschlüsselt ({len(plaintext)} B, Offset {offset}) " f"-> {args.outfile}") else: try: @@ -237,31 +285,31 @@ def cmd_reveal(args): def build_parser(): p = argparse.ArgumentParser( prog="schattenpost", - description="One-Time-Pad + LSB-Steganografie für vertrauliche Nachrichten.", + description="One-Time-Pad + LSB-Steganografie, richtungsgetrennte Schlüssel.", ) 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 = sub.add_parser("genkey", help="Gerichtetes Schlüsselpaar erzeugen.") + g.add_argument("-o", "--output", required=True, + help="Basisname (erzeugt _meine_seite/ + _partner_seite/).") 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.") + 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", 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("-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", required=True, help="Stego-PNG-Ausgabe.") + h.add_argument("-o", "--output", required=True, help="Stego-PNG.") 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.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 diff --git a/web/_interop_test.mjs b/web/_interop_test.mjs index bcd281a..0b2c699 100644 --- a/web/_interop_test.mjs +++ b/web/_interop_test.mjs @@ -1,46 +1,41 @@ -// Interop-Test: lädt EXAKT den Core-Block aus der ausgelieferten index.html -// und prüft Byte-Kompatibilität mit dem Python-CLI in beide Richtungen. -import { readFileSync, writeFileSync } from "node:fs"; +// Interop-Test v2: lädt EXAKT den Core aus der ausgelieferten index.html und +// prüft Byte-Kompatibilität mit dem Python-CLI (richtungsgetrennte Schlüssel). +import { readFileSync, writeFileSync } from 'node:fs'; -const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); +const U = (p) => new Uint8Array(readFileSync(new URL(p, import.meta.url))); +const html = readFileSync(new URL('./index.html', import.meta.url), 'utf8'); const core = html.match(/==CORE START==.*?\*\/([\s\S]*?)\/\*\s*==CORE END==/)[1]; -// Core in diesem Modul-Scope ausführen; er hängt SP an globalThis. new Function(core)(); const SP = globalThis.SP; let fails = 0; -const ok = (c, m) => { console.log((c ? " ✓ " : " ✗ ") + m); if (!c) fails++; }; +const ok = (c, m) => { console.log((c ? ' ✓ ' : ' ✗ ') + m); if (!c) fails++; }; -// ---------- A: CLI -> Web (mit dem CLI erzeugtes Bild im Browser-Core aufdecken) +// A: CLI (send.key) -> Web-Core (recv.key) { - const dims = readFileSync(new URL("./_a_dims.txt", import.meta.url), "utf8").trim(); - const rgb = new Uint8Array(readFileSync(new URL("./_a_rgb.bin", import.meta.url))); - const pad = new Uint8Array(readFileSync(new URL("../pad.key", import.meta.url))); - const expected = "Treffpunkt morgen 18 Uhr am alten Hafen. Bring das Buch mit. 🕵️"; - - const { offset, ciphertext } = SP.extract(rgb); - const plain = SP.otpXor(ciphertext, pad, offset); + const rgb = U('./_a_rgb.bin'); + const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key')); + const info = SP.extract(rgb); + const plain = SP.otpXor(info.ciphertext, recv.material, info.offset); const text = new TextDecoder().decode(plain); - console.log("A) CLI → Web (dims " + dims + ")"); - ok(offset === 0, "Offset aus Bild gelesen = 0"); - ok(text === expected, "Klartext stimmt: " + JSON.stringify(text)); + console.log('A) CLI → Web'); + ok(SP.sameStream(info.streamId, recv.streamId), 'stream_id passt zum recv.key'); + ok(text === 'MsgA vom CLI 🕵️', 'Klartext: ' + JSON.stringify(text)); } -// ---------- B: Web -> CLI (im Browser-Core einbetten, vom CLI aufdecken lassen) +// B: Web-Core (send.key) -> CLI (recv.key) { - const [w, h] = readFileSync(new URL("./_b_dims.txt", import.meta.url), "utf8").trim().split(" ").map(Number); - const rgb = new Uint8Array(readFileSync(new URL("./_b_rgb.bin", import.meta.url))); - const pad = new Uint8Array(readFileSync(new URL("../pad.key", import.meta.url))); - const msg = "Nachricht direkt aus dem Browser-Core 🌐 — Interop!"; - const offset = 500; // frischer Pad-Bereich - + const rgb = U('./_b_rgb.bin'); + const send = SP.parseKey(U('../t2/rel_partner_seite/send.key')); + const msg = 'MsgB aus dem Browser-Core 🌐'; + ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND'); const plain = new TextEncoder().encode(msg); - const ct = SP.otpXor(plain, pad, offset); - const payload = SP.buildPayload(offset, ct); + const ct = SP.otpXor(plain, send.material, send.sendOffset); + const payload = SP.buildStego(send.streamId, send.sendOffset, ct); SP.embed(rgb, payload); - writeFileSync(new URL("./_b_out_rgb.bin", import.meta.url), rgb); - console.log("B) Web → CLI"); - ok(true, `eingebettet (${plain.length} B ab Offset ${offset}) → _b_out_rgb.bin (${w}x${h})`); + writeFileSync(new URL('./_b_out_rgb.bin', import.meta.url), rgb); + console.log('B) Web → CLI'); + ok(true, `eingebettet (${plain.length} B ab Offset ${send.sendOffset}) → _b_out_rgb.bin`); } process.exit(fails ? 1 : 0); diff --git a/web/icon-192.png b/web/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..90f0ed0320ebe11b8de4e432e822707f6004e713 GIT binary patch literal 1470 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfwk4s#WAE}&fB~D{bJljj(=42 z%01aD%2lo~J?x%ZYE=2O7k?N^roFhc>q)MF>LzQh=eh-km(FH9dzbqDU)i}omiPSy zj~UNLW%5v_M=RGm(B`fCjD){|SE-se3kj1`R z=|X-YpB%H6{Wtac2j?q-n~T2NT&T2v@^rrK>4jMe@0d=#U_N~N#K|9A8q)mFf89vm z{y2-nl50hf?dt8%mMgk5F3ox)JAd~XZ-h9c!w)f)Xep>tQ_@ur<`og;K;zhG#&Ac|%+%?(FxkW>s=jEI= zXHNVG{ob=r{nv%;&uf-_E-B9~n9la~;phHOz20u2@ywZ1@0wqo-mkm5J+JJfSJj@q zTh^;f{10)njFq1k&-9Wb$*q3>{uB4|zGa19v%eN>R#IP_TFL(G^ZD!ZZ$|6HYoGnq zd8GaMbc<54r;m?++_XA8?%u6if8NiNn-_m+rSy8H#TnB-37@x^d!mF%clopByI&iL zyIuTvD!a74c&m)6?tHP0*33)$wQNn(uQyqKS=FmOtuC|dPw}-bw)&Z~YR^s;JuGu; z+uxsQ|G%!e$l^8q`m6kixhG5CU(V>;?{ZqkNWy7vjWxUbqTIW6O_4qe!jJoRDF?o6 z$xbqBsha7)-RCm%oc-cm+!||4pBOLx)c8Hj-+&|I`oE|G6(N4xwDX)_EgBd7o~^z8 z^%@x)*>m$&+;33If1T5@b?PhI1rIXkmj2%QtBsL~%kJ7uhiq+AH;0Qyczd^Q3sPnk zNwX2+Eq`&6M=W5*YY&r03)PL|91^D~UtG{W@5}^7*T?HNU%Vcc^`PO{OzcBfuP{4{xm7>xYCoQ-%tg4;)u7{n9b$D3gt~9@WUeED{LzaCmdHlAL%NbeU zdOZ0F)F2(OrF`zjZASN)xvH;8X0HZjA(`#xBBEDwyr`Gv-MEb}^Rv~(ySw)9y!9gO zr)}H!73Dq)?v%-2nViV9wqxpsZG8t>tTesh{wCM9ue*F8Tvd;9yI`%i({=Tf}g-B{`SjVDuH zO2~^!`7C%NXw)T=`kYP;Ha_k;GGV~nn=B_B$hoiFoJ zX+cL%Std|LpaZwptqY#!-Ez!_9Ddxc0OfA-{EA)Mr29py9rnJ~>(&L9%M6~belF{r G5}E*$ynX%v literal 0 HcmV?d00001 diff --git a/web/icon-512.png b/web/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d0109487cfd05f84c656baf57fa177774bafbe63 GIT binary patch literal 3992 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 M;pbkyp8d=J0T9ve^Z)<= literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html index 53c9609..a4091aa 100644 --- a/web/index.html +++ b/web/index.html @@ -1,11 +1,13 @@ schattenpost + + + @@ -71,70 +69,64 @@
- - - + + +
- -
+ +
- - - - -
- Pad-Offset: - - -
- - - - - -
- - -
- -