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:
21
LICENSE
Normal file
21
LICENSE
Normal 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
123
README.md
@@ -1,16 +1,39 @@
|
|||||||
# schattenpost
|
# 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.
|
schattenpost verschlüsselt Text mit einem echten One-Time-Pad (XOR gegen
|
||||||
`crypto.getRandomValues`-Zufall) und versteckt den Ciphertext in den
|
`os.urandom`- bzw. `crypto.getRandomValues`-Zufall), authentifiziert ihn mit
|
||||||
niederwertigsten Bits eines Trägerfotos. Das Ergebnis sieht aus wie ein ganz
|
einem Poly1305-Einmal-MAC und versteckt das Ganze in den niederwertigsten Bits
|
||||||
normales Bild, trägt die Nachricht aber bit-genau in sich.
|
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).
|
* **`schattenpost.py`** — Kommandozeilen-Tool (Python + Pillow).
|
||||||
* `web/` — eine client-side Web-App / PWA (läuft im Browser, auch iOS).
|
* **`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
|
## 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
|
über **richtungsgetrennte Schlüssel** + einen Offset, der im Schlüssel selbst
|
||||||
lebt.
|
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
|
## Das Schlüsselmodell
|
||||||
|
|
||||||
Jede Beziehung nutzt **zwei unabhängige Schlüsselströme** — einen pro
|
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
|
— 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.
|
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
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -99,8 +142,10 @@ der die Dateien ausliefert, sieht nichts.
|
|||||||
* **PWA**: über „Zum Home-Bildschirm" installierbar; der Service Worker cacht die
|
* **PWA**: über „Zum Home-Bildschirm" installierbar; der Service Worker cacht die
|
||||||
App-Shell → danach **offline** nutzbar (kein Server mehr nötig).
|
App-Shell → danach **offline** nutzbar (kein Server mehr nötig).
|
||||||
* **Format-kompatibel zum CLI** — am Terminal senden, am Handy empfangen und
|
* **Format-kompatibel zum CLI** — am Terminal senden, am Handy empfangen und
|
||||||
umgekehrt. Abgesichert durch `web/_interop_test.mjs`, der exakt den Krypto-Kern
|
umgekehrt. Abgesichert durch `bash web/_interop_run.sh`: der Runner lädt exakt
|
||||||
aus der ausgelieferten `index.html` lädt und beide Richtungen gegen das CLI prüft.
|
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)
|
### 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
|
`send.key` zum Download** und musst ihn auf dem Stick ersetzen — sonst würde der
|
||||||
nächste Sendevorgang denselben Schlüsselbereich erneut benutzen.
|
nächste Sendevorgang denselben Schlüsselbereich erneut benutzen.
|
||||||
|
|
||||||
### Deployment
|
### Lokal starten
|
||||||
|
|
||||||
Statische Dateien — einfach hosten:
|
|
||||||
|
|
||||||
```bash
|
```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
|
Produktiv hinter einen beliebigen Static-Host / Webserver. **HTTPS ist nötig**,
|
||||||
der Service Worker / die PWA-Installation greift).
|
damit Service Worker und PWA-Installation greifen.
|
||||||
|
|
||||||
### ⚠️ iOS-Eigenheit
|
### ⚠️ iOS-Eigenheit
|
||||||
|
|
||||||
@@ -143,24 +186,62 @@ und würde die Nachricht zerstören.
|
|||||||
| *(padding)* | 2 B | |
|
| *(padding)* | 2 B | |
|
||||||
| material | n B | echtes Zufalls-Pad |
|
| 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):
|
in die R/G/B-Kanäle):
|
||||||
|
|
||||||
| Feld | Größe | Inhalt |
|
| Feld | Größe | Inhalt |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| magic | 4 B | `SPS2` |
|
| magic | 4 B | `SPS3` |
|
||||||
| version | 1 B | `2` |
|
| version | 1 B | `3` |
|
||||||
| stream_id | 8 B | welcher Strom (Schlüssel-Check) |
|
| stream_id | 8 B | welcher Strom (Schlüssel-Check) |
|
||||||
| offset | 8 B | Pad-Offset (uint64 BE) |
|
| offset | 8 B | Pad-Offset (uint64 BE) |
|
||||||
| length | 4 B | Ciphertext-Länge (uint32 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
|
## Grenzen / Hinweise
|
||||||
|
|
||||||
* **Steganografie versteckt, authentifiziert aber nicht.** Wer das Format kennt,
|
* **Manipulationsschutz ist drin** (SPS3 / Poly1305-Einmal-MAC): Bit-Kippen am
|
||||||
könnte Bits kippen. Für Manipulationsschutz bräuchte es zusätzlich einen MAC.
|
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
|
* **Der eine Rest-Fall:** derselbe `send.key` gleichzeitig auf zwei Geräten (ohne
|
||||||
Stick-Prinzip) kann trotzdem kollidieren — das Bild-Offset hilft nur dem
|
Stick-Prinzip) kann trotzdem kollidieren — das Bild-Offset hilft nur dem
|
||||||
Empfänger. Regel: **ein `send.key`, ein aktives Sendegerät.**
|
Empfänger. Regel: **ein `send.key`, ein aktives Sendegerät.**
|
||||||
* Verschickte Stego-Bilder **immer als Datei/Dokument**, nie als „Foto".
|
* Verschickte Stego-Bilder **immer als Datei/Dokument**, nie als „Foto".
|
||||||
* Schlüssel gehören **niemals** in ein Repository — die `.gitignore` blockt
|
* Schlüssel gehören **niemals** in ein Repository — die `.gitignore` blockt
|
||||||
`*.key` bewusst.
|
`*.key` bewusst.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
[MIT](LICENSE) — mach damit, was du willst, aber ohne Gewähr.
|
||||||
|
|||||||
148
schattenpost.py
148
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)
|
KEY_OFFSET_POS = 14 # Byteposition des send_offset-Feldes (für In-Place-Update)
|
||||||
|
|
||||||
# --- Stego-Container (im Bild) ---------------------------------------------
|
# --- Stego-Container (im Bild) ---------------------------------------------
|
||||||
STEGO_MAGIC = b"SPS2"
|
# v3 authentifiziert zusätzlich mit einem Poly1305-Einmal-MAC (Tag im Header).
|
||||||
STEGO_VERSION = 2
|
STEGO_MAGIC = b"SPS3"
|
||||||
# magic(4) ver(1) stream_id(8) offset(uint64 BE, 8) length(uint32 BE, 4) = 25
|
STEGO_VERSION = 3
|
||||||
STEGO_FMT = ">4sB8sQI"
|
# Vor-Tag-Header: magic(4) ver(1) stream_id(8) offset(uint64 BE,8) len(uint32 BE,4)=25
|
||||||
STEGO_HEADER_LEN = struct.calcsize(STEGO_FMT)
|
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))
|
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
|
# Schlüsseldateien
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -179,21 +221,36 @@ def extract(stego_path: str) -> dict:
|
|||||||
img = Image.open(stego_path).convert("RGB")
|
img = Image.open(stego_path).convert("RGB")
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
px = img.load()
|
px = img.load()
|
||||||
header = _read_bits(px, width, height, STEGO_HEADER_LEN * 8)
|
# Vor-Tag-Header ist bei v2 und v3 identisch aufgebaut (25 B) -> erst lesen.
|
||||||
magic, ver, stream_id, offset, length = struct.unpack(STEGO_FMT, header)
|
pre = _read_bits(px, width, height, STEGO_PRE_LEN * 8)
|
||||||
if magic != STEGO_MAGIC:
|
magic, ver, stream_id, offset, length = struct.unpack(STEGO_PRE_FMT, pre)
|
||||||
raise ValueError(
|
if magic == STEGO_MAGIC and ver == STEGO_VERSION:
|
||||||
"Kein schattenpost-Container gefunden (Magic passt nicht). "
|
blob = _read_bits(px, width, height,
|
||||||
"Wurde das Bild als Foto verlustbehaftet komprimiert?"
|
(STEGO_HEADER_LEN + length) * 8)
|
||||||
)
|
return {
|
||||||
if ver != STEGO_VERSION:
|
"version": ver,
|
||||||
raise ValueError(f"Unbekannte Container-Version {ver}.")
|
"stream_id": stream_id,
|
||||||
blob = _read_bits(px, width, height, (STEGO_HEADER_LEN + length) * 8)
|
"offset": offset,
|
||||||
return {
|
"length": length,
|
||||||
"stream_id": stream_id,
|
"tag": blob[STEGO_PRE_LEN:STEGO_HEADER_LEN],
|
||||||
"offset": offset,
|
"mac_input_header": blob[:STEGO_PRE_LEN],
|
||||||
"ciphertext": blob[STEGO_HEADER_LEN:STEGO_HEADER_LEN + length],
|
"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 "
|
sys.exit("[!] Das ist ein Empfangsschlüssel (recv.key). Zum Senden "
|
||||||
"brauchst du deinen send.key.")
|
"brauchst du deinen send.key.")
|
||||||
offset = key["send_offset"]
|
offset = key["send_offset"]
|
||||||
segment = key["material"][offset:offset + len(plaintext)]
|
# Pro Nachricht: len(plaintext) Bytes Keystream + 32 Bytes MAC-Schlüssel.
|
||||||
if len(segment) < len(plaintext):
|
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 "
|
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.")
|
"Erzeugt ein neues Schlüsselpaar.")
|
||||||
|
|
||||||
ciphertext = otp_xor(plaintext, key["material"], offset)
|
ciphertext = otp_xor(plaintext, key["material"], offset)
|
||||||
header = struct.pack(STEGO_FMT, STEGO_MAGIC, STEGO_VERSION,
|
mac_key = key["material"][offset + len(plaintext):offset + need]
|
||||||
key["stream_id"], offset, len(ciphertext))
|
pre_header = struct.pack(STEGO_PRE_FMT, STEGO_MAGIC, STEGO_VERSION,
|
||||||
embed(args.carrier, header + ciphertext, out_path)
|
key["stream_id"], offset, len(ciphertext))
|
||||||
advance_send_offset(args.key, offset + len(plaintext))
|
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"[+] Versteckt in: {out_path}")
|
||||||
print(f"[+] Sende-Offset im Schlüssel weitergezählt -> "
|
print(f"[+] Sende-Offset im Schlüssel weitergezählt -> {offset + need} "
|
||||||
f"{offset + len(plaintext)}")
|
f"(Nachricht {len(plaintext)} + MAC {MAC_KEY_LEN}).")
|
||||||
print("[i] Als DATEI/Dokument verschicken, nicht als Foto!")
|
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 "
|
sys.exit("[!] Falscher Schlüssel: dieses Bild gehört zu einem anderen "
|
||||||
"Kontakt/Strom. Nimm den passenden recv.key.")
|
"Kontakt/Strom. Nimm den passenden recv.key.")
|
||||||
offset, ct = info["offset"], info["ciphertext"]
|
offset, ct = info["offset"], info["ciphertext"]
|
||||||
segment = key["material"][offset:offset + len(ct)]
|
|
||||||
if len(segment) < len(ct):
|
if info["version"] == STEGO_VERSION:
|
||||||
sys.exit(f"[!] Schlüssel passt nicht: ab Offset {offset} brauche ich "
|
# Authentifiziert: MAC-Schlüssel liegt direkt hinter dem Keystream.
|
||||||
f"{len(ct)} Bytes, habe nur {len(segment)}.")
|
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)
|
plaintext = otp_xor(ct, key["material"], offset)
|
||||||
|
|
||||||
if args.outfile:
|
if args.outfile:
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Test-Helfer: PNG <-> rohes RGB-Byte-Array (nur für den Interop-Test)."""
|
"""Test-Helfer: PNG <-> rohes RGB-Byte-Array (nur für den Interop-Test)."""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from PIL import Image
|
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")
|
img = Image.open(sys.argv[2]).convert("RGB")
|
||||||
w, h = img.size
|
w, h = img.size
|
||||||
with open(sys.argv[3], "wb") as fh:
|
with open(sys.argv[3], "wb") as fh:
|
||||||
|
|||||||
44
web/_interop_run.sh
Executable file
44
web/_interop_run.sh
Executable 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 =="
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Interop-Test v2: lädt EXAKT den Core aus der ausgelieferten index.html und
|
// 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).
|
// 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';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
const U = (p) => new Uint8Array(readFileSync(new URL(p, import.meta.url)));
|
const U = (p) => new Uint8Array(readFileSync(new URL(p, import.meta.url)));
|
||||||
@@ -10,31 +11,54 @@ const SP = globalThis.SP;
|
|||||||
|
|
||||||
let fails = 0;
|
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++; };
|
||||||
|
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 rgb = U('./_a_rgb.bin');
|
||||||
const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key'));
|
const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key'));
|
||||||
const info = SP.extract(rgb);
|
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 plain = SP.otpXor(info.ciphertext, recv.material, info.offset);
|
||||||
const text = new TextDecoder().decode(plain);
|
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));
|
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 rgb = U('./_b_rgb.bin');
|
||||||
const send = SP.parseKey(U('../t2/rel_partner_seite/send.key'));
|
const send = SP.parseKey(U('../t2/rel_partner_seite/send.key'));
|
||||||
const msg = 'MsgB aus dem Browser-Core 🌐';
|
const msg = 'MsgB aus dem Browser-Core 🌐';
|
||||||
|
console.log('B) Web → CLI');
|
||||||
ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND');
|
ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND');
|
||||||
const plain = new TextEncoder().encode(msg);
|
const plain = new TextEncoder().encode(msg);
|
||||||
const ct = SP.otpXor(plain, send.material, send.sendOffset);
|
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);
|
SP.embed(rgb, payload);
|
||||||
writeFileSync(new URL('./_b_out_rgb.bin', import.meta.url), rgb);
|
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`);
|
ok(true, `eingebettet (${plain.length} B ab Offset ${send.sendOffset}) → _b_out_rgb.bin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
web/index.html
108
web/index.html
@@ -140,11 +140,18 @@ const SP = (function () {
|
|||||||
const enc = (s) => new TextEncoder().encode(s);
|
const enc = (s) => new TextEncoder().encode(s);
|
||||||
const KEY_MAGIC = enc('SPK1'), KEY_VERSION = 1, KEY_HEADER_LEN = 24;
|
const KEY_MAGIC = enc('SPK1'), KEY_VERSION = 1, KEY_HEADER_LEN = 24;
|
||||||
const ROLE_RECV = 0, ROLE_SEND = 1;
|
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 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;
|
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; };
|
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) {
|
function otpXor(data, material, offset) {
|
||||||
if (offset + data.length > material.length) {
|
if (offset + data.length > material.length) {
|
||||||
@@ -156,6 +163,26 @@ const SP = (function () {
|
|||||||
return out;
|
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) {
|
function parseKey(bytes) {
|
||||||
if (bytes.length < KEY_HEADER_LEN) throw new Error('Keine gültige Schlüsseldatei (zu kurz).');
|
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.');
|
if (!eq4(KEY_MAGIC, bytes, 0)) throw new Error('Keine schattenpost-Schlüsseldatei.');
|
||||||
@@ -179,14 +206,19 @@ const SP = (function () {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStego(streamId, offset, ciphertext) {
|
// macKey: 32 Byte frisches Pad-Material (r|s). Tag deckt Vor-Header+Ciphertext ab.
|
||||||
const out = new Uint8Array(STEGO_HEADER_LEN + ciphertext.length);
|
function buildStego(streamId, offset, ciphertext, macKey) {
|
||||||
out.set(STEGO_MAGIC, 0);
|
const pre = new Uint8Array(STEGO_PRE_LEN);
|
||||||
out[4] = STEGO_VERSION;
|
pre.set(STEGO_MAGIC, 0);
|
||||||
out.set(streamId, 5);
|
pre[4] = STEGO_VERSION;
|
||||||
const dv = new DataView(out.buffer);
|
pre.set(streamId, 5);
|
||||||
|
const dv = new DataView(pre.buffer);
|
||||||
dv.setBigUint64(13, BigInt(offset), false);
|
dv.setBigUint64(13, BigInt(offset), false);
|
||||||
dv.setUint32(21, ciphertext.length, 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);
|
out.set(ciphertext, STEGO_HEADER_LEN);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -210,21 +242,32 @@ const SP = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extract(rgb) {
|
function extract(rgb) {
|
||||||
if (rgb.length < STEGO_HEADER_LEN * 8) throw new Error('Bild zu klein für einen Header.');
|
if (rgb.length < STEGO_PRE_LEN * 8) throw new Error('Bild zu klein für einen Header.');
|
||||||
const h = readBytesAt(rgb, STEGO_HEADER_LEN);
|
const pre = readBytesAt(rgb, STEGO_PRE_LEN);
|
||||||
if (!eq4(STEGO_MAGIC, h, 0)) throw new Error(
|
const ver = pre[4];
|
||||||
'Kein schattenpost-Container gefunden. Wurde das Bild als Foto komprimiert?');
|
const dv = new DataView(pre.buffer);
|
||||||
if (h[4] !== STEGO_VERSION) throw new Error('Unbekannte Container-Version ' + h[4] + '.');
|
const streamId = pre.slice(5, 13);
|
||||||
const dv = new DataView(h.buffer);
|
|
||||||
const streamId = h.slice(5, 13);
|
|
||||||
const offset = Number(dv.getBigUint64(13, false));
|
const offset = Number(dv.getBigUint64(13, false));
|
||||||
const length = dv.getUint32(21, false);
|
const length = dv.getUint32(21, false);
|
||||||
const full = readBytesAt(rgb, STEGO_HEADER_LEN + length);
|
if (eq4(STEGO_MAGIC, pre, 0) && ver === STEGO_VERSION) {
|
||||||
return { streamId, offset, ciphertext: full.slice(STEGO_HEADER_LEN) };
|
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,
|
return { ROLE_RECV, ROLE_SEND, KEY_HEADER_LEN, STEGO_HEADER_LEN, STEGO_PRE_LEN,
|
||||||
otpXor, parseKey, buildKey, buildStego, embed, extract, sameStream };
|
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 module !== 'undefined' && module.exports) module.exports = SP;
|
||||||
if (typeof globalThis !== 'undefined') globalThis.SP = 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]));
|
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.');
|
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);
|
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 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 img = await loadImage($('s-carrier').files[0]);
|
||||||
const { cv, cx, idata, rgb } = imageToRgb(img);
|
const { cv, cx, idata, rgb } = imageToRgb(img);
|
||||||
@@ -301,12 +350,12 @@ if (typeof document !== 'undefined') (function () {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
$('s-preview').src = url; $('s-preview').style.display = 'block';
|
$('s-preview').src = url; $('s-preview').style.display = 'block';
|
||||||
const dl = $('s-dlimg'); dl.href = url; dl.download = carrierName(); dl.style.display = 'block';
|
const dl = $('s-dlimg'); dl.href = url; dl.download = carrierName(); dl.style.display = 'block';
|
||||||
// Aktualisierter Sendeschlüssel (Offset fortgeschrieben)
|
// Aktualisierter Sendeschlüssel (Offset um Nachricht+MAC fortgeschrieben)
|
||||||
const nextOff = key.sendOffset + plain.length;
|
const nextOff = key.sendOffset + need;
|
||||||
const newKey = SP.buildKey(SP.ROLE_SEND, key.streamId, nextOff, key.material);
|
const newKey = SP.buildKey(SP.ROLE_SEND, key.streamId, nextOff, key.material);
|
||||||
const dk = $('s-dlkey'); dk.href = blobUrl(newKey); dk.style.display = 'block';
|
const dk = $('s-dlkey'); dk.href = blobUrl(newKey); dk.style.display = 'block';
|
||||||
$('s-note').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');
|
}, 'image/png');
|
||||||
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
||||||
};
|
};
|
||||||
@@ -325,10 +374,23 @@ if (typeof document !== 'undefined') (function () {
|
|||||||
const info = SP.extract(rgb);
|
const info = SP.extract(rgb);
|
||||||
if (!SP.sameStream(info.streamId, key.streamId))
|
if (!SP.sameStream(info.streamId, key.streamId))
|
||||||
throw new Error('Falscher Schlüssel: dieses Bild gehört zu einem anderen Kontakt.');
|
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);
|
const plain = SP.otpXor(info.ciphertext, key.material, info.offset);
|
||||||
let text; try { text = decTxt.decode(plain); } catch { text = '[Binärdaten]'; }
|
let text; try { text = decTxt.decode(plain); } catch { text = '[Binärdaten]'; }
|
||||||
out.textContent = text; out.style.display = 'block';
|
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); }
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* schattenpost Service Worker — reines Offline-Caching der App-Shell.
|
/* schattenpost Service Worker — reines Offline-Caching der App-Shell.
|
||||||
Sendet NICHTS an einen Server. Cache-first, kein Netzwerk-Fallback nötig,
|
Sendet NICHTS an einen Server. Cache-first, kein Netzwerk-Fallback nötig,
|
||||||
sobald die Shell einmal geladen ist. */
|
sobald die Shell einmal geladen ist. */
|
||||||
const CACHE = 'schattenpost-v2';
|
const CACHE = 'schattenpost-v3';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'./',
|
'./',
|
||||||
'./index.html',
|
'./index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user