diff --git a/.gitignore b/.gitignore index 05b7095..80135a7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ *.jpeg !docs/*.png +# Interop-Test-Artefakte (Helfer selbst werden committet) +*.bin +web/_*_dims.txt + # Python __pycache__/ *.pyc diff --git a/README.md b/README.md index cbefc53..9f5ef38 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,41 @@ Vor der LSB-Einbettung wird ein kleiner Header vorangestellt: 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. + +### Deployment + +Es ist eine statische Datei — einfach irgendwo hinlegen: + +```bash +# lokal testen +python3 -m http.server -d web 8000 # -> http://localhost:8000 + +# produktiv: web/index.html hinter einen beliebigen Webserver / Static-Host +``` + +### ⚠️ 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. + ## Grenzen / Hinweise - **Steganografie versteckt, authentifiziert aber nicht.** Ein Angreifer, der diff --git a/web/_interop_rawrgb.py b/web/_interop_rawrgb.py new file mode 100644 index 0000000..765bc6d --- /dev/null +++ b/web/_interop_rawrgb.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Test-Helfer: PNG <-> rohes RGB-Byte-Array (nur für den Interop-Test).""" +import sys +from PIL import Image + +if 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: + fh.write(bytes([v for px in img.getdata() for v in px])) + print(w, h) +elif sys.argv[1] == "rgb2png": # rgb2png + w, h = int(sys.argv[3]), int(sys.argv[4]) + data = open(sys.argv[2], "rb").read() + img = Image.new("RGB", (w, h)) + img.putdata([tuple(data[i:i + 3]) for i in range(0, len(data), 3)]) + img.save(sys.argv[5], "PNG") + print("ok") diff --git a/web/_interop_test.mjs b/web/_interop_test.mjs new file mode 100644 index 0000000..bcd281a --- /dev/null +++ b/web/_interop_test.mjs @@ -0,0 +1,46 @@ +// 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"; + +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++; }; + +// ---------- A: CLI -> Web (mit dem CLI erzeugtes Bild im Browser-Core aufdecken) +{ + 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 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)); +} + +// ---------- B: Web -> CLI (im Browser-Core einbetten, vom CLI aufdecken lassen) +{ + 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 plain = new TextEncoder().encode(msg); + const ct = SP.otpXor(plain, pad, offset); + const payload = SP.buildPayload(offset, 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})`); +} + +process.exit(fails ? 1 : 0); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..53c9609 --- /dev/null +++ b/web/index.html @@ -0,0 +1,393 @@ + + +schattenpost + + +
+

schattenpost

+
One-Time-Pad + Steganografie. Alles läuft in deinem Browser — nichts wird gesendet.
+
+ +
+ + + +
+ + +
+ + + + + +
+ Pad-Offset: + + +
+ + + + + +
+ + + +
+ + + + + + + +
+ Krypto & Steganografie laufen ausschließlich lokal (client-side).
+ Format-kompatibel zum schattenpost.py-CLI. +
+ +