From 7569cd3fab4df42e538b79be2b60854f89cc853f Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 15 Jul 2026 10:46:04 +0000 Subject: [PATCH] SPS3: Poly1305-Einmal-MAC + Public-Ready README/LICENSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LICENSE | 21 ++++++ README.md | 123 ++++++++++++++++++++++++++++------ schattenpost.py | 148 +++++++++++++++++++++++++++++++---------- web/_interop_rawrgb.py | 8 ++- web/_interop_run.sh | 44 ++++++++++++ web/_interop_test.mjs | 40 ++++++++--- web/index.html | 108 +++++++++++++++++++++++------- web/sw.js | 2 +- 8 files changed, 406 insertions(+), 88 deletions(-) create mode 100644 LICENSE create mode 100755 web/_interop_run.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..673756b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Christian Gärtner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 5a05524..5cb5062 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,39 @@ # schattenpost -Vertrauliche Nachrichten via **One-Time-Pad** + **LSB-Steganografie**. +**Vertrauliche Nachrichten via One-Time-Pad + LSB-Steganografie.** -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. +schattenpost verschlüsselt Text mit einem echten One-Time-Pad (XOR gegen +`os.urandom`- bzw. `crypto.getRandomValues`-Zufall), authentifiziert ihn mit +einem Poly1305-Einmal-MAC und versteckt das Ganze 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**: +Zwei Frontends, **ein identisches Dateiformat**: -* `schattenpost.py` — Kommandozeilen-Tool (Python + Pillow). -* `web/` — eine client-side Web-App / PWA (läuft im Browser, auch iOS). +* **`schattenpost.py`** — Kommandozeilen-Tool (Python + Pillow). +* **`web/`** — client-side Web-App / PWA, läuft im Browser (auch iOS), offline-fähig. + +--- + +## ⚠️ Ehrliche Einordnung (bitte lesen) + +Dies ist ein **Hobby- und Lernprojekt**, kein auditiertes Sicherheitsprodukt. Die +Krypto-Bausteine (OTP, Poly1305 nach RFC 8439) sind solide und getestet, aber: + +* **Kein unabhängiges Sicherheits-Audit.** Nutzung auf eigenes Risiko. +* **Steganografie verschleiert den Inhalt, nicht die Existenz einer Kommunikation.** + Wer Metadaten/Traffic beobachtet, sieht *dass* du Bilder verschickst. +* **Die harte Nuss bleibt der Schlüsselaustausch.** Ein One-Time-Pad ist nur so + sicher wie die Art, wie ihr das Schlüsselmaterial *persönlich* übergebt (siehe + [OPSEC](#opsec-empfehlung)). Wird das Pad abgefangen oder kopiert, ist alles + hin. +* **Endgeräte-Sicherheit ist Voraussetzung.** Gegen ein kompromittiertes Gerät + (Keylogger, Screen-Grab) hilft keine Verschlüsselung. + +Kurz: mathematisch sauber, aber die Sicherheit steht und fällt mit *deiner* +Handhabung der Schlüssel und Geräte. + +--- ## Warum das (theoretisch) unknackbar ist @@ -25,6 +48,20 @@ Regel 3 ist der klassische Todesstoß (`two-time-pad`). schattenpost erzwingt si über **richtungsgetrennte Schlüssel** + einen Offset, der im Schlüssel selbst lebt. +### Authentifizierung (ohne die Sicherheit aufzugeben) + +OTP verschlüsselt, sagt aber nichts über *Echtheit* — wer das Format kennt, +könnte einzelne Bits kippen. schattenpost hängt deshalb an jede Nachricht einen +**Poly1305-Einmal-MAC** (RFC 8439). Der 32-Byte-MAC-Schlüssel wird pro Nachricht +**frisch aus demselben One-Time-Pad** gezogen — direkt hinter dem Keystream — und +nie wiederverwendet. Genau unter dieser Bedingung ist Poly1305 ein +*informationstheoretisch* sicherer Authenticator: die „beweisbar unknackbar"- +Zusage bleibt vollständig erhalten, kein rechnerisch-sicheres HMAC/SHA nötig. + +Der MAC deckt den Header (`stream_id`, `offset`, `length`) **und** den Ciphertext +ab (encrypt-then-MAC). Jede Veränderung am Bild — oder ein nicht synchroner +Schlüssel — lässt das Aufdecken hart abbrechen, statt stillen Müll zu liefern. + ## Das Schlüsselmodell Jede Beziehung nutzt **zwei unabhängige Schlüsselströme** — einen pro @@ -51,6 +88,12 @@ Gerät. Dann existiert jeder `send.key` (mit seinem Offset) **physisch nur einma — 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. +## Voraussetzungen + +* **CLI:** Python ≥ 3.8 und [Pillow](https://python-pillow.org/) (`pip install Pillow`). +* **Web:** ein moderner Browser. Kein Build-Schritt, keine Abhängigkeiten — die + App ist eine einzelne `index.html` mit eingebettetem Krypto-Kern. + ## CLI ```bash @@ -99,8 +142,10 @@ der die Dateien ausliefert, sieht nichts. * **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. + umgekehrt. Abgesichert durch `bash web/_interop_run.sh`: der Runner lädt exakt + den Krypto-Kern aus der ausgelieferten `index.html`, fährt beide Senderichtungen + CLI↔Web durch, prüft den Poly1305-MAC cross-implementation gegen den + RFC-8439-Vektor und verifiziert die Manipulations-Erkennung. ### Rollenverteilung (wichtig wegen der Browser-Sandbox) @@ -112,16 +157,14 @@ Ein Browser darf **nicht** in eine Datei auf dem Stick zurückschreiben. Deshalb `send.key` zum Download** und musst ihn auf dem Stick ersetzen — sonst würde der nächste Sendevorgang denselben Schlüsselbereich erneut benutzen. -### Deployment - -Statische Dateien — einfach hosten: +### Lokal starten ```bash -python3 -m http.server -d web 8000 # lokal: http://localhost:8000 +python3 -m http.server -d web 8000 # http://localhost:8000 ``` -Produktiv hinter einen beliebigen Static-Host / Webserver (HTTPS nötig, damit -der Service Worker / die PWA-Installation greift). +Produktiv hinter einen beliebigen Static-Host / Webserver. **HTTPS ist nötig**, +damit Service Worker und PWA-Installation greifen. ### ⚠️ iOS-Eigenheit @@ -143,24 +186,62 @@ und würde die Nachricht zerstören. | *(padding)* | 2 B | | | material | n B | echtes Zufalls-Pad | -**Stego-Container im Bild** (`SPS2`, 25 B Header, dann Ciphertext, LSB MSB-first +**Stego-Container im Bild** (`SPS3`, 41 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` | +| magic | 4 B | `SPS3` | +| version | 1 B | `3` | | stream_id | 8 B | welcher Strom (Schlüssel-Check) | | offset | 8 B | Pad-Offset (uint64 BE) | | length | 4 B | Ciphertext-Länge (uint32 BE) | +| tag | 16 B | Poly1305-MAC über die 25 B davor + Ciphertext | + +Pro Nachricht verbraucht der Pad-Strom **`length + 32` Bytes**: `length` für den +Keystream, 32 für den Poly1305-Einmalschlüssel (`r|s`). Der Sende-Offset wandert +entsprechend weiter. Alte `SPS2`-Bilder (ohne Tag) werden beim Aufdecken noch +gelesen, aber als **unauthentifiziert** markiert. + +## Projektstruktur + +``` +schattenpost.py CLI (genkey / hide / reveal) +web/ + index.html Web-App + Krypto-Kern (zwischen ==CORE START/END== markiert) + sw.js Service Worker (Offline-Cache der App-Shell) + manifest.webmanifest PWA-Manifest + icon-192.png, App-Icons + icon-512.png + _interop_run.sh Reproduzierbarer CLI↔Web-Interop-Test + _interop_test.mjs Web-Core-Seite des Tests (lädt den Kern aus index.html) + _interop_rawrgb.py Test-Helfer (PNG ↔ rohes RGB) +``` + +Dateien mit `_`-Präfix sind Entwickler-/Test-Werkzeuge, nicht Teil der App. + +## Tests + +```bash +pip install Pillow +bash web/_interop_run.sh +``` + +Prüft Poly1305 gegen den RFC-8439-Vektor (Python *und* JS), beide Senderichtungen +CLI↔Web und die Manipulations-Erkennung. ## Grenzen / Hinweise -* **Steganografie versteckt, authentifiziert aber nicht.** Wer das Format kennt, - könnte Bits kippen. Für Manipulationsschutz bräuchte es zusätzlich einen MAC. +* **Manipulationsschutz ist drin** (SPS3 / Poly1305-Einmal-MAC): Bit-Kippen am + Bild wird erkannt, das Aufdecken bricht ab. Steganografie *versteckt* aber + weiterhin nur — sie verschleiert nicht, *dass* überhaupt kommuniziert wird. * **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. + +## Lizenz + +[MIT](LICENSE) — mach damit, was du willst, aber ohne Gewähr. diff --git a/schattenpost.py b/schattenpost.py index bfea7d3..0aeb7ab 100644 --- a/schattenpost.py +++ b/schattenpost.py @@ -62,11 +62,19 @@ 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) +# 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 # --------------------------------------------------------------------------- # @@ -81,6 +89,40 @@ def otp_xor(data: bytes, material: bytes, offset: int) -> bytes: 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 # --------------------------------------------------------------------------- # @@ -179,21 +221,36 @@ 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, 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 als Foto verlustbehaftet komprimiert?" - ) - 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], - } + # 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?" + ) # --------------------------------------------------------------------------- # @@ -243,22 +300,27 @@ def cmd_hide(args): 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): + # 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 braucht {len(plaintext)}. " + f"{len(segment)} Bytes, Nachricht+MAC brauchen {need}. " "Erzeugt ein neues Schlüsselpaar.") 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, out_path) - advance_send_offset(args.key, offset + len(plaintext)) + 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 ({len(plaintext)} B) ab Sende-Offset {offset}.") + 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 -> " - f"{offset + len(plaintext)}") + 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!") @@ -272,10 +334,28 @@ def cmd_reveal(args): 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)}.") + + 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: diff --git a/web/_interop_rawrgb.py b/web/_interop_rawrgb.py index 765bc6d..0052a46 100644 --- a/web/_interop_rawrgb.py +++ b/web/_interop_rawrgb.py @@ -1,9 +1,15 @@ #!/usr/bin/env python3 """Test-Helfer: PNG <-> rohes RGB-Byte-Array (nur für den Interop-Test).""" +import os import sys from PIL import Image -if sys.argv[1] == "png2rgb": # png2rgb -> stdout: "W H" +if sys.argv[1] == "mkcarrier": # mkcarrier -> Rausch-Trägerbild + w, h = int(sys.argv[3]), int(sys.argv[4]) + img = Image.frombytes("RGB", (w, h), os.urandom(w * h * 3)) + img.save(sys.argv[2], "PNG") + print("ok") +elif sys.argv[1] == "png2rgb": # png2rgb -> stdout: "W H" img = Image.open(sys.argv[2]).convert("RGB") w, h = img.size with open(sys.argv[3], "wb") as fh: diff --git a/web/_interop_run.sh b/web/_interop_run.sh new file mode 100755 index 0000000..3316e17 --- /dev/null +++ b/web/_interop_run.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Reproduzierbarer Interop-Test CLI <-> Web-Core (SPS3 / Poly1305). +# Erzeugt frische Test-Schlüssel, fährt beide Senderichtungen durch und prüft, +# dass CLI und Browser-Core byte-genau dasselbe Format sprechen — inkl. MAC. +set -euo pipefail +cd "$(dirname "$0")/.." # Repo-Wurzel +PY=${PYTHON:-python3} + +echo "== Setup: frische, richtungsgetrennte Schlüssel ==" +$PY schattenpost.py genkey -o t2/rel -s 100000 --force >/dev/null +$PY web/_interop_rawrgb.py mkcarrier web/_carrier.png 200 200 >/dev/null + +echo "== A) CLI verschlüsselt+authentifiziert -> Bild ==" +$PY schattenpost.py hide -t "MsgA vom CLI 🕵️" \ + -k t2/rel_meine_seite/send.key -c web/_carrier.png -o web/_a.png >/dev/null +$PY web/_interop_rawrgb.py png2rgb web/_a.png web/_a_rgb.bin >/dev/null + +echo "== B) Trägerbild als rohes RGB für den Web-Core bereitstellen ==" +$PY web/_interop_rawrgb.py png2rgb web/_carrier.png web/_b_rgb.bin > web/_b_dims.txt + +echo "== Web-Core: A verifizieren, B einbetten ==" +node web/_interop_test.mjs + +echo "== B) Web-Ausgabe zurück nach PNG, vom CLI aufdecken+prüfen ==" +read -r W H < web/_b_dims.txt +$PY web/_interop_rawrgb.py rgb2png web/_b_out_rgb.bin "$W" "$H" web/_b_stego.png >/dev/null +OUT=$($PY schattenpost.py reveal -s web/_b_stego.png -k t2/rel_meine_seite/recv.key) +echo " CLI reveal: $OUT" +if [ "$OUT" = "MsgB aus dem Browser-Core 🌐" ]; then + echo " ✓ B round-trip (Web→CLI) inkl. MAC-Prüfung ok" +else + echo " ✗ B mismatch"; exit 1 +fi + +echo "== Poly1305 Python-Seite gegen RFC-8439-Vektor ==" +$PY - <<'PYEOF' +import schattenpost as sp +key = bytes.fromhex('85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b') +tag = sp.poly1305_mac(b'Cryptographic Forum Research Group', key) +assert tag.hex() == 'a8061dc1305136c6c22b8baf0c0127a9', tag.hex() +print(' ✓ Python-Poly1305 matcht RFC-Vektor') +PYEOF + +echo "== Alles grün ==" diff --git a/web/_interop_test.mjs b/web/_interop_test.mjs index 0b2c699..23a5ad4 100644 --- a/web/_interop_test.mjs +++ b/web/_interop_test.mjs @@ -1,5 +1,6 @@ -// 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). +// Interop-Test v3: lädt EXAKT den Core aus der ausgelieferten index.html und +// prüft Byte-Kompatibilität mit dem Python-CLI (richtungsgetrennte Schlüssel, +// SPS3 mit Poly1305-Einmal-MAC). Orchestriert von _interop_run.sh. import { readFileSync, writeFileSync } from 'node:fs'; const U = (p) => new Uint8Array(readFileSync(new URL(p, import.meta.url))); @@ -10,31 +11,54 @@ const SP = globalThis.SP; let fails = 0; const ok = (c, m) => { console.log((c ? ' ✓ ' : ' ✗ ') + m); if (!c) fails++; }; +const hex = (s) => new Uint8Array(s.match(/../g).map((h) => parseInt(h, 16))); -// A: CLI (send.key) -> Web-Core (recv.key) +// 0) Poly1305 gegen den RFC-8439-Testvektor (§2.5.2) — verankert beide Impls. +{ + console.log('0) Poly1305 RFC 8439'); + const key = hex('85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b'); + const msg = new TextEncoder().encode('Cryptographic Forum Research Group'); + const tag = SP.poly1305(msg, key); + ok([...tag].map((b) => b.toString(16).padStart(2, '0')).join('') + === 'a8061dc1305136c6c22b8baf0c0127a9', 'Tag matcht RFC-Vektor'); +} + +// A: CLI (send.key, SPS3) -> Web-Core (recv.key): extrahieren, MAC prüfen, entschlüsseln { const rgb = U('./_a_rgb.bin'); const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key')); const info = SP.extract(rgb); + console.log('A) CLI → Web'); + ok(info.version === SP.STEGO_VERSION, 'Container ist SPS3'); + ok(SP.sameStream(info.streamId, recv.streamId), 'stream_id passt zum recv.key'); + const macKey = recv.material.slice(info.offset + info.ciphertext.length, + info.offset + info.ciphertext.length + SP.MAC_KEY_LEN); + const expected = SP.poly1305(SP.concat(info.macInputHeader, info.ciphertext), macKey); + ok(SP.ctEqual(expected, info.tag), 'MAC verifiziert (CLI-Tag == Web-Tag)'); const plain = SP.otpXor(info.ciphertext, recv.material, info.offset); const text = new TextDecoder().decode(plain); - 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)); + + // Manipulation: ein Ciphertext-Bit kippen -> MAC muss anschlagen. + const bad = info.ciphertext.slice(); bad[0] ^= 1; + const badTag = SP.poly1305(SP.concat(info.macInputHeader, bad), macKey); + ok(!SP.ctEqual(badTag, info.tag), 'gekipptes Bit wird vom MAC erkannt'); } -// B: Web-Core (send.key) -> CLI (recv.key) +// B: Web-Core (send.key, SPS3) -> CLI (recv.key) { const rgb = U('./_b_rgb.bin'); const send = SP.parseKey(U('../t2/rel_partner_seite/send.key')); const msg = 'MsgB aus dem Browser-Core 🌐'; + console.log('B) Web → CLI'); ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND'); const plain = new TextEncoder().encode(msg); const ct = SP.otpXor(plain, send.material, send.sendOffset); - const payload = SP.buildStego(send.streamId, send.sendOffset, ct); + const macKey = send.material.slice(send.sendOffset + plain.length, + send.sendOffset + plain.length + SP.MAC_KEY_LEN); + const payload = SP.buildStego(send.streamId, send.sendOffset, ct, macKey); 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 ${send.sendOffset}) → _b_out_rgb.bin`); } diff --git a/web/index.html b/web/index.html index 6b6a457..b5247cf 100644 --- a/web/index.html +++ b/web/index.html @@ -140,11 +140,18 @@ const SP = (function () { const enc = (s) => new TextEncoder().encode(s); const KEY_MAGIC = enc('SPK1'), KEY_VERSION = 1, KEY_HEADER_LEN = 24; const ROLE_RECV = 0, ROLE_SEND = 1; - const STEGO_MAGIC = enc('SPS2'), STEGO_VERSION = 2, STEGO_HEADER_LEN = 25; + // v3 authentifiziert mit Poly1305-Einmal-MAC (Tag im Header). + const STEGO_MAGIC = enc('SPS3'), STEGO_VERSION = 3; + const STEGO_PRE_LEN = 25, STEGO_TAG_LEN = 16; + const STEGO_HEADER_LEN = STEGO_PRE_LEN + STEGO_TAG_LEN; // 41 + const MAC_KEY_LEN = 32; + const STEGO_MAGIC_V2 = enc('SPS2'), STEGO_VERSION_V2 = 2; // Alt, nur Aufdecken const eq4 = (a, b, off) => a[0]===b[off]&&a[1]===b[off+1]&&a[2]===b[off+2]&&a[3]===b[off+3]; const sameStream = (a, b) => { if (a.length!==b.length) return false; for (let i=0;i { const o = new Uint8Array(a.length + b.length); + o.set(a, 0); o.set(b, a.length); return o; }; function otpXor(data, material, offset) { if (offset + data.length > material.length) { @@ -156,6 +163,26 @@ const SP = (function () { return out; } + // --- Poly1305 (RFC 8439), Einmal-MAC aus frischem Pad-Material --- + const _leToBig = (b) => { let x = 0n; for (let i = b.length - 1; i >= 0; i--) x = (x << 8n) | BigInt(b[i]); return x; }; + const _bigToLe = (x, n) => { const o = new Uint8Array(n); for (let i = 0; i < n; i++) { o[i] = Number(x & 0xffn); x >>= 8n; } return o; }; + const _P1305_P = (1n << 130n) - 5n; + const _P1305_CLAMP = 0x0ffffffc0ffffffc0ffffffc0fffffffn; + function poly1305(msg, key) { + if (key.length !== 32) throw new Error('Poly1305-Schlüssel muss 32 Byte sein.'); + const r = _leToBig(key.subarray(0, 16)) & _P1305_CLAMP; + const s = _leToBig(key.subarray(16, 32)); + let acc = 0n; + for (let i = 0; i < msg.length; i += 16) { + const block = msg.subarray(i, Math.min(i + 16, msg.length)); + const n = _leToBig(block) | (1n << BigInt(8 * block.length)); + acc = (acc + n) * r % _P1305_P; + } + return _bigToLe((acc + s) % (1n << 128n), 16); + } + function ctEqual(a, b) { if (a.length !== b.length) return false; + let d = 0; for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; return d === 0; } + function parseKey(bytes) { if (bytes.length < KEY_HEADER_LEN) throw new Error('Keine gültige Schlüsseldatei (zu kurz).'); if (!eq4(KEY_MAGIC, bytes, 0)) throw new Error('Keine schattenpost-Schlüsseldatei.'); @@ -179,14 +206,19 @@ const SP = (function () { return out; } - function buildStego(streamId, offset, ciphertext) { - const out = new Uint8Array(STEGO_HEADER_LEN + ciphertext.length); - out.set(STEGO_MAGIC, 0); - out[4] = STEGO_VERSION; - out.set(streamId, 5); - const dv = new DataView(out.buffer); + // macKey: 32 Byte frisches Pad-Material (r|s). Tag deckt Vor-Header+Ciphertext ab. + function buildStego(streamId, offset, ciphertext, macKey) { + const pre = new Uint8Array(STEGO_PRE_LEN); + pre.set(STEGO_MAGIC, 0); + pre[4] = STEGO_VERSION; + pre.set(streamId, 5); + const dv = new DataView(pre.buffer); dv.setBigUint64(13, BigInt(offset), false); dv.setUint32(21, ciphertext.length, false); + const tag = poly1305(concat(pre, ciphertext), macKey); + const out = new Uint8Array(STEGO_HEADER_LEN + ciphertext.length); + out.set(pre, 0); + out.set(tag, STEGO_PRE_LEN); out.set(ciphertext, STEGO_HEADER_LEN); return out; } @@ -210,21 +242,32 @@ const SP = (function () { } function extract(rgb) { - if (rgb.length < STEGO_HEADER_LEN * 8) throw new Error('Bild zu klein für einen Header.'); - const h = readBytesAt(rgb, STEGO_HEADER_LEN); - if (!eq4(STEGO_MAGIC, h, 0)) throw new Error( - 'Kein schattenpost-Container gefunden. Wurde das Bild als Foto komprimiert?'); - if (h[4] !== STEGO_VERSION) throw new Error('Unbekannte Container-Version ' + h[4] + '.'); - const dv = new DataView(h.buffer); - const streamId = h.slice(5, 13); + if (rgb.length < STEGO_PRE_LEN * 8) throw new Error('Bild zu klein für einen Header.'); + const pre = readBytesAt(rgb, STEGO_PRE_LEN); + const ver = pre[4]; + const dv = new DataView(pre.buffer); + const streamId = pre.slice(5, 13); const offset = Number(dv.getBigUint64(13, false)); const length = dv.getUint32(21, false); - const full = readBytesAt(rgb, STEGO_HEADER_LEN + length); - return { streamId, offset, ciphertext: full.slice(STEGO_HEADER_LEN) }; + if (eq4(STEGO_MAGIC, pre, 0) && ver === STEGO_VERSION) { + const full = readBytesAt(rgb, STEGO_HEADER_LEN + length); + return { version: ver, streamId, offset, length, + tag: full.slice(STEGO_PRE_LEN, STEGO_HEADER_LEN), + macInputHeader: full.slice(0, STEGO_PRE_LEN), + ciphertext: full.slice(STEGO_HEADER_LEN, STEGO_HEADER_LEN + length) }; + } + if (eq4(STEGO_MAGIC_V2, pre, 0) && ver === STEGO_VERSION_V2) { + const full = readBytesAt(rgb, STEGO_PRE_LEN + length); + return { version: ver, streamId, offset, length, tag: null, + ciphertext: full.slice(STEGO_PRE_LEN, STEGO_PRE_LEN + length) }; + } + throw new Error('Kein schattenpost-Container gefunden. Wurde das Bild als Foto komprimiert?'); } - return { ROLE_RECV, ROLE_SEND, KEY_HEADER_LEN, STEGO_HEADER_LEN, - otpXor, parseKey, buildKey, buildStego, embed, extract, sameStream }; + return { ROLE_RECV, ROLE_SEND, KEY_HEADER_LEN, STEGO_HEADER_LEN, STEGO_PRE_LEN, + STEGO_VERSION, STEGO_VERSION_V2, MAC_KEY_LEN, + otpXor, poly1305, ctEqual, concat, parseKey, buildKey, buildStego, + embed, extract, sameStream }; })(); if (typeof module !== 'undefined' && module.exports) module.exports = SP; if (typeof globalThis !== 'undefined') globalThis.SP = SP; @@ -288,8 +331,14 @@ if (typeof document !== 'undefined') (function () { const key = SP.parseKey(await readFile($('s-key').files[0])); if (key.role !== SP.ROLE_SEND) throw new Error('Das ist ein Empfangsschlüssel. Zum Senden brauchst du deinen send.key.'); const plain = encTxt.encode(text); + // Keystream (len) + MAC-Schlüssel (32) frisch aus dem Pad. + const need = plain.length + SP.MAC_KEY_LEN; + if (key.sendOffset + need > key.material.length) + throw new Error(`Sendeschlüssel erschöpft: ab Offset ${key.sendOffset} ` + + `brauche ich ${need} B (Nachricht+MAC), habe nur ${key.material.length - key.sendOffset}.`); const ct = SP.otpXor(plain, key.material, key.sendOffset); - const payload = SP.buildStego(key.streamId, key.sendOffset, ct); + const macKey = key.material.slice(key.sendOffset + plain.length, key.sendOffset + need); + const payload = SP.buildStego(key.streamId, key.sendOffset, ct, macKey); const img = await loadImage($('s-carrier').files[0]); const { cv, cx, idata, rgb } = imageToRgb(img); @@ -301,12 +350,12 @@ if (typeof document !== 'undefined') (function () { const url = URL.createObjectURL(blob); $('s-preview').src = url; $('s-preview').style.display = 'block'; const dl = $('s-dlimg'); dl.href = url; dl.download = carrierName(); dl.style.display = 'block'; - // Aktualisierter Sendeschlüssel (Offset fortgeschrieben) - const nextOff = key.sendOffset + plain.length; + // Aktualisierter Sendeschlüssel (Offset um Nachricht+MAC fortgeschrieben) + const nextOff = key.sendOffset + need; const newKey = SP.buildKey(SP.ROLE_SEND, key.streamId, nextOff, key.material); const dk = $('s-dlkey'); dk.href = blobUrl(newKey); dk.style.display = 'block'; $('s-note').style.display = 'block'; - show(st, 'ok', `Versteckt. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`); + show(st, 'ok', `Versteckt + authentifiziert. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`); }, 'image/png'); } catch (err) { show(st, 'err', '✗ ' + err.message); } }; @@ -325,10 +374,23 @@ if (typeof document !== 'undefined') (function () { const info = SP.extract(rgb); if (!SP.sameStream(info.streamId, key.streamId)) throw new Error('Falscher Schlüssel: dieses Bild gehört zu einem anderen Kontakt.'); + let warn = ''; + if (info.version === SP.STEGO_VERSION) { + const need = info.ciphertext.length + SP.MAC_KEY_LEN; + if (info.offset + need > key.material.length) + throw new Error('Schlüssel passt nicht: zu kurz für Nachricht+MAC.'); + const macKey = key.material.slice(info.offset + info.ciphertext.length, info.offset + need); + const expected = SP.poly1305(SP.concat(info.macInputHeader, info.ciphertext), macKey); + if (!SP.ctEqual(expected, info.tag)) + throw new Error('Authentifizierung fehlgeschlagen: Bild verändert oder ' + + 'Schlüssel nicht synchron (Offset-Drift). Nachricht nicht vertrauen.'); + } else { + warn = ' ⚠︎ altes SPS2-Bild — unauthentifiziert.'; + } const plain = SP.otpXor(info.ciphertext, key.material, info.offset); let text; try { text = decTxt.decode(plain); } catch { text = '[Binärdaten]'; } out.textContent = text; out.style.display = 'block'; - show(st, 'ok', `Entschlüsselt (${plain.length} B, Offset ${info.offset}).`); + show(st, 'ok', `Entschlüsselt (${plain.length} B, Offset ${info.offset}).` + warn); } catch (err) { show(st, 'err', '✗ ' + err.message); } }; diff --git a/web/sw.js b/web/sw.js index 688955d..ab03ff1 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,7 +1,7 @@ /* schattenpost Service Worker — reines Offline-Caching der App-Shell. Sendet NICHTS an einen Server. Cache-first, kein Netzwerk-Fallback nötig, sobald die Shell einmal geladen ist. */ -const CACHE = 'schattenpost-v2'; +const CACHE = 'schattenpost-v3'; const SHELL = [ './', './index.html',