SPS3: Poly1305-Einmal-MAC + Public-Ready README/LICENSE

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>
This commit is contained in:
Claude Code
2026-07-15 10:46:04 +00:00
parent dac9550b46
commit 7569cd3fab
8 changed files with 406 additions and 88 deletions

21
LICENSE Normal file
View File

@@ -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.

123
README.md
View File

@@ -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.

View File

@@ -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:

View File

@@ -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 <in.png> <out.bin> -> stdout: "W H"
if sys.argv[1] == "mkcarrier": # mkcarrier <out.png> <W> <H> -> 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 <in.png> <out.bin> -> stdout: "W H"
img = Image.open(sys.argv[2]).convert("RGB")
w, h = img.size
with open(sys.argv[3], "wb") as fh:

44
web/_interop_run.sh Executable file
View File

@@ -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 =="

View File

@@ -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`);
}

View File

@@ -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<a.length;i++) if (a[i]!==b[i]) return false; return true; };
const concat = (a, b) => { 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); }
};

View File

@@ -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',