v2: richtungsgetrennte Schluessel, Offset im Schluessel, PWA
- Neues Schluesselmodell: pro Beziehung zwei Stroeme (send.key/recv.key), Sende-Offset lebt im Schluessel-Header und wird beim Senden fortgeschrieben. Loest two-time-pad bei bidirektionaler Nutzung deterministisch. - Stream-Identifier im Bild -> falscher Schluessel wird erkannt statt Kauderwelsch zu liefern. Rollen-Guards (send/recv) gegen Fehlbenutzung. - CLI genkey erzeugt gerichtetes Keypaar (meine_seite/partner_seite). - Web-App auf v2 umgestellt; Senden liefert aktualisierten send.key zum Download (Browser-Sandbox kann nicht auf den Stick schreiben). - PWA: manifest + service worker (offline) + icons. - Interop CLI<->Web in beide Richtungen per Node-Test abgesichert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,6 +8,11 @@
|
|||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
!docs/*.png
|
!docs/*.png
|
||||||
|
!web/icon-*.png
|
||||||
|
|
||||||
|
# Test-Schlüsselordner
|
||||||
|
/t2/
|
||||||
|
/t/
|
||||||
|
|
||||||
# Interop-Test-Artefakte (Helfer selbst werden committet)
|
# Interop-Test-Artefakte (Helfer selbst werden committet)
|
||||||
*.bin
|
*.bin
|
||||||
|
|||||||
197
README.md
197
README.md
@@ -2,142 +2,165 @@
|
|||||||
|
|
||||||
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`-Zufall)
|
Verschlüsselt Text mit einem echten One-Time-Pad (XOR gegen `os.urandom`- bzw.
|
||||||
und versteckt den Ciphertext in den niederwertigsten Bits eines Trägerfotos. Das
|
`crypto.getRandomValues`-Zufall) und versteckt den Ciphertext in den
|
||||||
Ergebnis sieht aus wie ein ganz normales Bild, trägt die Nachricht aber bit-genau
|
niederwertigsten Bits eines Trägerfotos. Das Ergebnis sieht aus wie ein ganz
|
||||||
in sich. Gedacht als unauffälliger Kanal, dessen Inhalt selbst bei Mitlesen des
|
normales Bild, trägt die Nachricht aber bit-genau in sich.
|
||||||
Transports nicht rekonstruierbar ist.
|
|
||||||
|
Es gibt zwei Frontends mit **identischem Dateiformat**:
|
||||||
|
|
||||||
|
* `schattenpost.py` — Kommandozeilen-Tool (Python + Pillow).
|
||||||
|
* `web/` — eine client-side Web-App / PWA (läuft im Browser, auch iOS).
|
||||||
|
|
||||||
## Warum das (theoretisch) unknackbar ist
|
## Warum das (theoretisch) unknackbar ist
|
||||||
|
|
||||||
One-Time-Pad ist der einzige Cipher mit *informationstheoretischer* Sicherheit —
|
One-Time-Pad ist der einzige Cipher mit *informationstheoretischer* Sicherheit —
|
||||||
nicht nur „rechnerisch schwer", sondern beweisbar unknackbar. Das gilt aber nur,
|
beweisbar unknackbar, solange **alle drei** Regeln gelten:
|
||||||
solange **alle drei** Regeln eingehalten werden:
|
|
||||||
|
|
||||||
1. Der Schlüssel (das *Pad*) ist echt zufällig.
|
1. Der Schlüssel ist echt zufällig.
|
||||||
2. Das Pad ist mindestens so lang wie die Nachricht.
|
2. Der Schlüssel ist mindestens so lang wie die Nachricht.
|
||||||
3. Jeder Pad-Abschnitt wird **nur ein einziges Mal** benutzt.
|
3. Jeder Schlüsselabschnitt wird **nur ein einziges Mal** benutzt.
|
||||||
|
|
||||||
Regel 3 ist der klassische Todesstoß (`two-time-pad`). schattenpost erzwingt sie
|
Regel 3 ist der klassische Todesstoß (`two-time-pad`). schattenpost erzwingt sie
|
||||||
automatisch über einen Offset-Zähler (siehe unten).
|
über **richtungsgetrennte Schlüssel** + einen Offset, der im Schlüssel selbst
|
||||||
|
lebt.
|
||||||
|
|
||||||
## Installation
|
## Das Schlüsselmodell
|
||||||
|
|
||||||
|
Jede Beziehung nutzt **zwei unabhängige Schlüsselströme** — einen pro
|
||||||
|
Senderichtung. Jede Seite hält zwei Dateien:
|
||||||
|
|
||||||
|
| Datei | Zweck | Verhalten |
|
||||||
|
|------------|-------|-----------|
|
||||||
|
| `send.key` | Senden | Trägt den **Sende-Offset** im Header; er wandert bei jeder Nachricht vor. |
|
||||||
|
| `recv.key` | Empfangen | Wird nur **gelesen** (Offset kommt aus dem Bild), nie verändert. |
|
||||||
|
|
||||||
|
Dein `send.key` besteht aus demselben Zufallsmaterial wie der `recv.key` deines
|
||||||
|
Partners — und umgekehrt. Weil du und dein Partner zum Senden **nie denselben
|
||||||
|
Strom** benutzt, könnt ihr gleichzeitig schreiben, ohne je denselben
|
||||||
|
Schlüsselbereich zu treffen.
|
||||||
|
|
||||||
|
Ein zufälliger **Stream-Identifier** in jedem Schlüssel landet auch im Bild.
|
||||||
|
Dadurch erkennt das Aufdecken sofort, ob der richtige `recv.key` benutzt wird —
|
||||||
|
statt stillen Kauderwelsch zu liefern.
|
||||||
|
|
||||||
|
### OPSEC-Empfehlung
|
||||||
|
|
||||||
|
Lege die Schlüssel auf einen **USB-Stick** und häng ihn nur ans gerade benutzte
|
||||||
|
Gerät. Dann existiert jeder `send.key` (mit seinem Offset) **physisch nur einmal**
|
||||||
|
— zwei Zähler können nicht auseinanderdriften, und auf Handy/Laptop liegt nie ein
|
||||||
|
Schlüssel herum. Mehrere Kontakte = mehrere Schlüsselordner auf dem Stick.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install Pillow
|
pip install Pillow
|
||||||
```
|
```
|
||||||
|
|
||||||
Sonst nur Python-Stdlib.
|
### 1. Schlüsselpaar erzeugen (einmalig, pro Kontakt)
|
||||||
|
|
||||||
## Benutzung
|
|
||||||
|
|
||||||
### 1. Pad erzeugen (einmalig)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 schattenpost.py genkey -o pad.key -s 1000000 # 1 MB Pad
|
python3 schattenpost.py genkey -o kontakt_bob -s 1000000 # 1 MB je Richtung
|
||||||
```
|
```
|
||||||
|
|
||||||
Das erzeugte `pad.key` **sicher und persönlich** an den Gesprächspartner
|
Erzeugt zwei Ordner:
|
||||||
übergeben (USB-Stick, kein digitaler Kanal!). Beide Seiten brauchen die
|
|
||||||
byte-identische Datei. Daneben entsteht `pad.key.offset` — der Verbrauchszähler.
|
|
||||||
|
|
||||||
### 2. Nachricht verstecken
|
* `kontakt_bob_meine_seite/` → **du** behältst ihn (enthält `send.key` + `recv.key`).
|
||||||
|
* `kontakt_bob_partner_seite/` → **dem Partner** geben (Stick, persönlich).
|
||||||
|
|
||||||
|
### 2. Senden
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 schattenpost.py hide \
|
python3 schattenpost.py hide \
|
||||||
-t "Treffpunkt morgen 18 Uhr am alten Hafen." \
|
-t "Treffpunkt morgen 18 Uhr." \
|
||||||
-k pad.key \
|
-k kontakt_bob_meine_seite/send.key \
|
||||||
-c urlaubsfoto.jpg \
|
-c urlaubsfoto.jpg \
|
||||||
-o harmlos.png
|
-o harmlos.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ `-i datei.txt` statt `-t`, oder Text über stdin.
|
Der Sende-Offset im `send.key` wird dabei automatisch fortgeschrieben.
|
||||||
|
Alternativ `-i datei` statt `-t`, oder Text über stdin.
|
||||||
|
|
||||||
### 3. Nachricht rausholen (Gegenseite)
|
### 3. Empfangen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 schattenpost.py reveal -s harmlos.png -k pad.key
|
python3 schattenpost.py reveal -s harmlos.png -k kontakt_bob_meine_seite/recv.key
|
||||||
```
|
```
|
||||||
|
|
||||||
Mit `-o klartext.txt` in eine Datei statt auf stdout.
|
Mit `-o klartext.txt` in eine Datei statt auf stdout.
|
||||||
|
|
||||||
## ⚠️ Der eine wichtige Haken
|
## Weboberfläche & PWA (`web/`)
|
||||||
|
|
||||||
**Verschicke das Stego-PNG immer als Datei/Dokument, niemals als „Foto".**
|
Eine **client-side** App: Verschlüsselung und Steganografie laufen zu 100 % im
|
||||||
|
Browser. Schlüssel, Klartext und Nachricht verlassen das Gerät nie. Ein Server,
|
||||||
|
der die Dateien ausliefert, sieht nichts.
|
||||||
|
|
||||||
Messenger (WhatsApp, Telegram-„Foto", …) jagen Fotos durch verlustbehaftete
|
* **Überall erreichbar, iOS inklusive** (nur Safari-Standard-APIs).
|
||||||
JPEG-Kompression. Das verändert die Pixelwerte und **zerstört die versteckten
|
* **PWA**: über „Zum Home-Bildschirm" installierbar; der Service Worker cacht die
|
||||||
Bits sofort** — `reveal` schlägt dann fehl. Als Dokument/Datei bleibt das PNG
|
App-Shell → danach **offline** nutzbar (kein Server mehr nötig).
|
||||||
bit-genau erhalten.
|
* **Format-kompatibel zum CLI** — am Terminal senden, am Handy empfangen und
|
||||||
|
umgekehrt. Abgesichert durch `web/_interop_test.mjs`, der exakt den Krypto-Kern
|
||||||
|
aus der ausgelieferten `index.html` lädt und beide Richtungen gegen das CLI prüft.
|
||||||
|
|
||||||
## Wie der Pad-Wiederverwendungsschutz funktioniert
|
### Rollenverteilung (wichtig wegen der Browser-Sandbox)
|
||||||
|
|
||||||
- `pad.key.offset` merkt sich den nächsten unbenutzten Byte-Offset im Pad.
|
Ein Browser darf **nicht** in eine Datei auf dem Stick zurückschreiben. Deshalb:
|
||||||
- `hide` verschlüsselt ab diesem Offset, schreibt den Offset **in den
|
|
||||||
Stego-Header** und zählt den Zähler um die Nachrichtenlänge weiter.
|
|
||||||
- `reveal` liest den Offset aus dem Bild und weiß automatisch, welcher
|
|
||||||
Pad-Abschnitt gilt.
|
|
||||||
|
|
||||||
So wird garantiert kein Pad-Byte doppelt verwendet — solange der Zähler nicht
|
* 📥 **Empfangen**: überall per Web/PWA — der `recv.key` wird nur gelesen. ✓
|
||||||
manuell zurückgedreht wird. Der Sender führt den Zähler; der Empfänger braucht
|
* 📤 **Senden**: am Laptop am besten per **CLI** (schreibt den Offset direkt auf
|
||||||
ihn nicht (der Offset steckt im Bild).
|
den Stick). In der Web-App bekommst du nach dem Verstecken den **aktualisierten
|
||||||
|
`send.key` zum Download** und musst ihn auf dem Stick ersetzen — sonst würde der
|
||||||
## Container-Format
|
nächste Sendevorgang denselben Schlüsselbereich erneut benutzen.
|
||||||
|
|
||||||
Vor der LSB-Einbettung wird ein kleiner Header vorangestellt:
|
|
||||||
|
|
||||||
| Feld | Größe | Inhalt |
|
|
||||||
|---------|-------|-------------------------------|
|
|
||||||
| magic | 4 B | `SPST` |
|
|
||||||
| version | 1 B | `1` |
|
|
||||||
| offset | 8 B | Pad-Offset (uint64, BE) |
|
|
||||||
| length | 4 B | Ciphertext-Länge (uint32, BE) |
|
|
||||||
| ciphertext | n B | XOR(plaintext, pad[offset…]) |
|
|
||||||
|
|
||||||
Die Bits werden linear (MSB zuerst) in die LSBs der R/G/B-Kanäle geschrieben.
|
|
||||||
|
|
||||||
## Weboberfläche (`web/index.html`)
|
|
||||||
|
|
||||||
Eine **einzige, self-contained HTML-Datei** — kein Framework, kein CDN, kein
|
|
||||||
Build. Verschlüsselung und Steganografie laufen zu 100 % im Browser
|
|
||||||
(client-side): Pad, Klartext und Nachricht verlassen das Gerät nie. Ein Server,
|
|
||||||
der die Datei ausliefert, ist ein reiner „dummer" Fileserver und sieht nichts.
|
|
||||||
|
|
||||||
* **Überall erreichbar, iOS inklusive.** Nutzt nur Safari-Standard-APIs
|
|
||||||
(Canvas, FileReader, `crypto.getRandomValues`).
|
|
||||||
* **Format-kompatibel zum CLI.** Am Terminal verstecken, am Handy im Browser
|
|
||||||
aufdecken — und umgekehrt. Byte-Interop ist per Test abgesichert
|
|
||||||
(`web/_interop_test.mjs` lädt exakt den Krypto-Kern aus der ausgelieferten
|
|
||||||
`index.html` und prüft beide Richtungen gegen das Python-CLI).
|
|
||||||
* **Offset-Tracking** merkt sich pro Pad (via `localStorage`) den nächsten
|
|
||||||
freien Offset. Achtung: Der Zähler ist pro Browser/Gerät. Wer abwechselnd über
|
|
||||||
mehrere Instanzen sendet, muss den Offset manuell synchron halten — sonst
|
|
||||||
droht Pad-Wiederverwendung.
|
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
Es ist eine statische Datei — einfach irgendwo hinlegen:
|
Statische Dateien — einfach hosten:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# lokal testen
|
python3 -m http.server -d web 8000 # lokal: http://localhost:8000
|
||||||
python3 -m http.server -d web 8000 # -> http://localhost:8000
|
|
||||||
|
|
||||||
# produktiv: web/index.html hinter einen beliebigen Webserver / Static-Host
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Produktiv hinter einen beliebigen Static-Host / Webserver (HTTPS nötig, damit
|
||||||
|
der Service Worker / die PWA-Installation greift).
|
||||||
|
|
||||||
### ⚠️ iOS-Eigenheit
|
### ⚠️ iOS-Eigenheit
|
||||||
|
|
||||||
Stego-Bild über **„In Dateien sichern"** ablegen und als **Datei** teilen —
|
Stego-Bild über **„In Dateien sichern"** ablegen und als **Datei** teilen —
|
||||||
nicht in „Fotos" speichern. iOS re-komprimiert Bilder aus der Fotos-Mediathek
|
nicht in „Fotos" speichern. iOS re-komprimiert Bilder aus der Fotos-Mediathek
|
||||||
und würde die Nachricht zerstören.
|
und würde die Nachricht zerstören.
|
||||||
|
|
||||||
|
## Formate
|
||||||
|
|
||||||
|
**Schlüsseldatei** (`SPK1`, 24-Byte-Header + Zufallsmaterial):
|
||||||
|
|
||||||
|
| Feld | Größe | Inhalt |
|
||||||
|
|------|-------|--------|
|
||||||
|
| magic | 4 B | `SPK1` |
|
||||||
|
| version | 1 B | `1` |
|
||||||
|
| role | 1 B | `1` = send, `0` = recv |
|
||||||
|
| stream_id | 8 B | Strom-Kennung (identisch im Paar) |
|
||||||
|
| send_offset | 8 B | nächster freier Offset (uint64 BE) |
|
||||||
|
| *(padding)* | 2 B | |
|
||||||
|
| material | n B | echtes Zufalls-Pad |
|
||||||
|
|
||||||
|
**Stego-Container im Bild** (`SPS2`, 25 B Header, dann Ciphertext, LSB MSB-first
|
||||||
|
in die R/G/B-Kanäle):
|
||||||
|
|
||||||
|
| Feld | Größe | Inhalt |
|
||||||
|
|------|-------|--------|
|
||||||
|
| magic | 4 B | `SPS2` |
|
||||||
|
| version | 1 B | `2` |
|
||||||
|
| stream_id | 8 B | welcher Strom (Schlüssel-Check) |
|
||||||
|
| offset | 8 B | Pad-Offset (uint64 BE) |
|
||||||
|
| length | 4 B | Ciphertext-Länge (uint32 BE) |
|
||||||
|
|
||||||
## Grenzen / Hinweise
|
## Grenzen / Hinweise
|
||||||
|
|
||||||
- **Steganografie versteckt, authentifiziert aber nicht.** Ein Angreifer, der
|
* **Steganografie versteckt, authentifiziert aber nicht.** Wer das Format kennt,
|
||||||
das Format kennt, könnte Bits kippen. Für Manipulationsschutz bräuchte es
|
könnte Bits kippen. Für Manipulationsschutz bräuchte es zusätzlich einen MAC.
|
||||||
zusätzlich einen MAC (z.B. HMAC mit einem separaten Pad-Bereich).
|
* **Der eine Rest-Fall:** derselbe `send.key` gleichzeitig auf zwei Geräten (ohne
|
||||||
- Die Sicherheit steht und fällt mit der **geheimen, sicheren Übergabe des
|
Stick-Prinzip) kann trotzdem kollidieren — das Bild-Offset hilft nur dem
|
||||||
Pads**. Das ist der eigentliche Aufwand bei OTP — es gibt kein Schlüssel-
|
Empfänger. Regel: **ein `send.key`, ein aktives Sendegerät.**
|
||||||
Austauschprotokoll, das dir das abnimmt.
|
* Verschickte Stego-Bilder **immer als Datei/Dokument**, nie als „Foto".
|
||||||
- Pads gehören **niemals** in ein Repository. Die `.gitignore` blockt `*.key`
|
* Schlüssel gehören **niemals** in ein Repository — die `.gitignore` blockt
|
||||||
und `*.offset` bewusst.
|
`*.key` bewusst.
|
||||||
|
|||||||
284
schattenpost.py
284
schattenpost.py
@@ -6,19 +6,31 @@ Verschlüsselt Text mit einem echten One-Time-Pad (XOR) und versteckt den
|
|||||||
Ciphertext in den niederwertigsten Bits eines Trägerfotos. Das Ergebnis sieht
|
Ciphertext in den niederwertigsten Bits eines Trägerfotos. Das Ergebnis sieht
|
||||||
aus wie ein ganz normales Bild, trägt aber die Nachricht bit-genau in sich.
|
aus wie ein ganz normales Bild, trägt aber die Nachricht bit-genau in sich.
|
||||||
|
|
||||||
WICHTIG:
|
RICHTUNGSGETRENNTE SCHLÜSSEL
|
||||||
* Verschickt das Stego-Bild IMMER als Datei/Dokument, niemals als "Foto" —
|
----------------------------
|
||||||
Messenger jagen Fotos durch verlustbehaftete Kompression und zerstören dabei
|
Jede Beziehung nutzt ZWEI unabhängige Schlüsselströme — einen pro Senderichtung.
|
||||||
die versteckten Bits. Als Dokument bleibt das PNG unangetastet.
|
Dadurch können sich zwei gleichzeitig sendende Parteien nie überschneiden
|
||||||
* Das Pad (der Schlüssel) muss auf beiden Seiten byte-identisch sein und darf
|
(kein „two-time-pad"). Jede Seite hält:
|
||||||
NIE zweimal für dieselben Bytes benutzt werden. Der Offset-Zähler
|
|
||||||
(pad.key.offset) sorgt automatisch dafür, dass kein Byte doppelt verbraucht
|
send.key — dein Sendeschlüssel. Trägt den Sende-Offset IM Header; er wandert
|
||||||
wird — solange ihr ihn nicht manuell zurückdreht.
|
bei jeder Nachricht vor. Der Partner hat dieselben Bytes als recv.key.
|
||||||
|
recv.key — dein Empfangsschlüssel. Wird nur GELESEN (Offset kommt aus dem Bild),
|
||||||
|
nie verändert.
|
||||||
|
|
||||||
|
Der Sende-Offset lebt also im Schlüssel selbst. Legt man den Schlüssel auf einen
|
||||||
|
USB-Stick und hängt ihn nur ans gerade benutzte Gerät, gibt es physisch nur einen
|
||||||
|
einzigen Offset-Zähler — kein Auseinanderdriften über mehrere Geräte möglich.
|
||||||
|
|
||||||
|
WICHTIG
|
||||||
|
-------
|
||||||
|
* Stego-Bild IMMER als Datei/Dokument verschicken, nie als „Foto" — Messenger
|
||||||
|
komprimieren Fotos verlustbehaftet und zerstören die versteckten Bits.
|
||||||
|
* send.key niemals doppelt/parallel auf zwei Geräten bespielen (siehe oben).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
genkey Erzeugt ein echtes Zufalls-Pad.
|
genkey Erzeugt ein gerichtetes Schlüsselpaar für eine Beziehung.
|
||||||
hide Verschlüsselt + versteckt eine Nachricht in einem Trägerbild.
|
hide Verschlüsselt + versteckt eine Nachricht (braucht send.key).
|
||||||
reveal Extrahiert + entschlüsselt eine Nachricht aus einem Stego-Bild.
|
reveal Extrahiert + entschlüsselt eine Nachricht (braucht recv.key).
|
||||||
|
|
||||||
Abhängigkeit: Pillow (pip install Pillow)
|
Abhängigkeit: Pillow (pip install Pillow)
|
||||||
"""
|
"""
|
||||||
@@ -30,67 +42,92 @@ import sys
|
|||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
MAGIC = b"SPST" # Schattenpost-Container-Kennung
|
# --- Schlüsseldatei ---------------------------------------------------------
|
||||||
VERSION = 1
|
KEY_MAGIC = b"SPK1"
|
||||||
# Header: magic(4) + version(1) + offset(8, uint64 BE) + length(4, uint32 BE)
|
KEY_VERSION = 1
|
||||||
HEADER_FMT = ">4sBQI"
|
ROLE_RECV = 0
|
||||||
HEADER_LEN = struct.calcsize(HEADER_FMT)
|
ROLE_SEND = 1
|
||||||
|
# magic(4) ver(1) role(1) stream_id(8) send_offset(uint64 BE, 8) + 2 pad = 24
|
||||||
|
KEY_FMT = ">4sBB8sQ2x"
|
||||||
|
KEY_HEADER_LEN = struct.calcsize(KEY_FMT)
|
||||||
|
KEY_OFFSET_POS = 14 # Byteposition des send_offset-Feldes (für In-Place-Update)
|
||||||
|
|
||||||
|
# --- Stego-Container (im Bild) ---------------------------------------------
|
||||||
|
STEGO_MAGIC = b"SPS2"
|
||||||
|
STEGO_VERSION = 2
|
||||||
|
# magic(4) ver(1) stream_id(8) offset(uint64 BE, 8) length(uint32 BE, 4) = 25
|
||||||
|
STEGO_FMT = ">4sB8sQI"
|
||||||
|
STEGO_HEADER_LEN = struct.calcsize(STEGO_FMT)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# One-Time-Pad
|
# One-Time-Pad
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def otp_xor(data: bytes, key: bytes) -> bytes:
|
def otp_xor(data: bytes, material: bytes, offset: int) -> bytes:
|
||||||
"""XOR data gegen key. key muss mindestens so lang wie data sein."""
|
if offset + len(data) > len(material):
|
||||||
if len(key) < len(data):
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Pad zu kurz: brauche {len(data)} Bytes, habe nur {len(key)}."
|
f"Schlüssel erschöpft: ab Offset {offset} brauche ich {len(data)} "
|
||||||
|
f"Bytes, habe nur {len(material) - offset}."
|
||||||
)
|
)
|
||||||
return bytes(d ^ k for d, k in zip(data, key))
|
return bytes(d ^ material[offset + i] for i, d in enumerate(data))
|
||||||
|
|
||||||
|
|
||||||
def load_pad(path: str) -> bytes:
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Schlüsseldateien
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def read_key(path: str) -> dict:
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
return fh.read()
|
raw = fh.read()
|
||||||
|
if len(raw) < KEY_HEADER_LEN:
|
||||||
|
raise ValueError(f"{path}: keine gültige Schlüsseldatei (zu kurz).")
|
||||||
|
magic, ver, role, stream_id, send_offset = struct.unpack(
|
||||||
|
KEY_FMT, raw[:KEY_HEADER_LEN]
|
||||||
|
)
|
||||||
|
if magic != KEY_MAGIC:
|
||||||
|
raise ValueError(f"{path}: keine schattenpost-Schlüsseldatei.")
|
||||||
|
if ver != KEY_VERSION:
|
||||||
|
raise ValueError(f"{path}: unbekannte Schlüsselversion {ver}.")
|
||||||
|
return {
|
||||||
|
"role": role,
|
||||||
|
"stream_id": stream_id,
|
||||||
|
"send_offset": send_offset,
|
||||||
|
"material": raw[KEY_HEADER_LEN:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def read_offset(pad_path: str) -> int:
|
def write_key(path: str, role: int, stream_id: bytes, send_offset: int,
|
||||||
"""Nächster unbenutzter Offset im Pad. Fehlt die Datei -> 0."""
|
material: bytes) -> None:
|
||||||
off_path = pad_path + ".offset"
|
header = struct.pack(KEY_FMT, KEY_MAGIC, KEY_VERSION, role, stream_id,
|
||||||
if not os.path.exists(off_path):
|
send_offset)
|
||||||
return 0
|
with open(path, "wb") as fh:
|
||||||
with open(off_path, "r") as fh:
|
fh.write(header + material)
|
||||||
return int(fh.read().strip() or "0")
|
|
||||||
|
|
||||||
|
|
||||||
def write_offset(pad_path: str, value: int) -> None:
|
def advance_send_offset(path: str, new_offset: int) -> None:
|
||||||
with open(pad_path + ".offset", "w") as fh:
|
"""Schreibt nur das 8-Byte-Offset-Feld in-place (kein Rewrite des Materials)."""
|
||||||
fh.write(str(value))
|
with open(path, "r+b") as fh:
|
||||||
|
fh.seek(KEY_OFFSET_POS)
|
||||||
|
fh.write(struct.pack(">Q", new_offset))
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# LSB-Steganografie
|
# LSB-Steganografie
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _bits_from_bytes(data: bytes):
|
def _bits_from_bytes(data: bytes):
|
||||||
"""Liefert die Bits von data, MSB zuerst pro Byte."""
|
|
||||||
for byte in data:
|
for byte in data:
|
||||||
for i in range(7, -1, -1):
|
for i in range(7, -1, -1):
|
||||||
yield (byte >> i) & 1
|
yield (byte >> i) & 1
|
||||||
|
|
||||||
|
|
||||||
def embed(carrier_path: str, payload: bytes, out_path: str) -> None:
|
def embed(carrier_path: str, payload: bytes, out_path: str) -> None:
|
||||||
"""Bettet payload in die LSBs von carrier ein und speichert als PNG."""
|
|
||||||
img = Image.open(carrier_path).convert("RGB")
|
img = Image.open(carrier_path).convert("RGB")
|
||||||
width, height = img.size
|
width, height = img.size
|
||||||
capacity_bits = width * height * 3
|
|
||||||
need_bits = len(payload) * 8
|
need_bits = len(payload) * 8
|
||||||
if need_bits > capacity_bits:
|
if need_bits > width * height * 3:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Trägerbild zu klein: brauche {need_bits} Bits, "
|
f"Trägerbild zu klein: brauche {need_bits // 8} Bytes, Platz für "
|
||||||
f"Platz für {capacity_bits} ({need_bits // 8} vs "
|
f"{width * height * 3 // 8}. Nimm ein größeres Foto."
|
||||||
f"{capacity_bits // 8} Bytes). Nimm ein größeres Foto."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
px = img.load()
|
px = img.load()
|
||||||
bits = _bits_from_bytes(payload)
|
bits = _bits_from_bytes(payload)
|
||||||
done = False
|
done = False
|
||||||
@@ -99,62 +136,55 @@ def embed(carrier_path: str, payload: bytes, out_path: str) -> None:
|
|||||||
break
|
break
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
r, g, b = px[x, y]
|
r, g, b = px[x, y]
|
||||||
channels = [r, g, b]
|
ch = [r, g, b]
|
||||||
for c in range(3):
|
for c in range(3):
|
||||||
bit = next(bits, None)
|
bit = next(bits, None)
|
||||||
if bit is None:
|
if bit is None:
|
||||||
done = True
|
done = True
|
||||||
break
|
break
|
||||||
channels[c] = (channels[c] & ~1) | bit
|
ch[c] = (ch[c] & ~1) | bit
|
||||||
px[x, y] = (channels[0], channels[1], channels[2])
|
px[x, y] = (ch[0], ch[1], ch[2])
|
||||||
if done:
|
if done:
|
||||||
break
|
break
|
||||||
|
|
||||||
img.save(out_path, "PNG")
|
img.save(out_path, "PNG")
|
||||||
|
|
||||||
|
|
||||||
def _read_bits(px, width, height, n_bits):
|
def _read_bits(px, width, height, n_bits):
|
||||||
"""Liest n_bits LSBs linear aus dem Bild und gibt sie als Bytes zurück."""
|
|
||||||
out = bytearray()
|
out = bytearray()
|
||||||
acc = 0
|
acc = count = read = 0
|
||||||
count = 0
|
|
||||||
read = 0
|
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
r, g, b = px[x, y]
|
for c in px[x, y]:
|
||||||
for c in (r, g, b):
|
|
||||||
acc = (acc << 1) | (c & 1)
|
acc = (acc << 1) | (c & 1)
|
||||||
count += 1
|
count += 1
|
||||||
read += 1
|
read += 1
|
||||||
if count == 8:
|
if count == 8:
|
||||||
out.append(acc)
|
out.append(acc)
|
||||||
acc = 0
|
acc = count = 0
|
||||||
count = 0
|
|
||||||
if read >= n_bits:
|
if read >= n_bits:
|
||||||
return bytes(out)
|
return bytes(out)
|
||||||
return bytes(out)
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
def extract(stego_path: str) -> tuple[int, bytes]:
|
def extract(stego_path: str) -> dict:
|
||||||
"""Liest Header + Ciphertext aus einem Stego-Bild. Gibt (offset, ct)."""
|
|
||||||
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)
|
||||||
header = _read_bits(px, width, height, HEADER_LEN * 8)
|
magic, ver, stream_id, offset, length = struct.unpack(STEGO_FMT, header)
|
||||||
magic, version, offset, length = struct.unpack(HEADER_FMT, header)
|
if magic != STEGO_MAGIC:
|
||||||
if magic != MAGIC:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Kein Schattenpost-Container gefunden (Magic passt nicht). "
|
"Kein schattenpost-Container gefunden (Magic passt nicht). "
|
||||||
"Wurde das Bild vielleicht doch als Foto komprimiert?"
|
"Wurde das Bild als Foto verlustbehaftet komprimiert?"
|
||||||
)
|
)
|
||||||
if version != VERSION:
|
if ver != STEGO_VERSION:
|
||||||
raise ValueError(f"Unbekannte Version {version}.")
|
raise ValueError(f"Unbekannte Container-Version {ver}.")
|
||||||
|
blob = _read_bits(px, width, height, (STEGO_HEADER_LEN + length) * 8)
|
||||||
total_bits = (HEADER_LEN + length) * 8
|
return {
|
||||||
blob = _read_bits(px, width, height, total_bits)
|
"stream_id": stream_id,
|
||||||
ciphertext = blob[HEADER_LEN:HEADER_LEN + length]
|
"offset": offset,
|
||||||
return offset, ciphertext
|
"ciphertext": blob[STEGO_HEADER_LEN:STEGO_HEADER_LEN + length],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -162,15 +192,29 @@ def extract(stego_path: str) -> tuple[int, bytes]:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def cmd_genkey(args):
|
def cmd_genkey(args):
|
||||||
size = args.size
|
size = args.size
|
||||||
if os.path.exists(args.output) and not args.force:
|
mine = args.output + "_meine_seite"
|
||||||
sys.exit(f"[!] {args.output} existiert schon. --force zum Überschreiben.")
|
theirs = args.output + "_partner_seite"
|
||||||
with open(args.output, "wb") as fh:
|
for d in (mine, theirs):
|
||||||
fh.write(os.urandom(size))
|
if os.path.exists(d) and not args.force:
|
||||||
write_offset(args.output, 0)
|
sys.exit(f"[!] {d} existiert schon. --force zum Überschreiben.")
|
||||||
print(f"[+] Pad erzeugt: {args.output} ({size} Bytes, echtes os.urandom)")
|
os.makedirs(d, exist_ok=True)
|
||||||
print(f"[+] Offset-Zähler zurückgesetzt: {args.output}.offset -> 0")
|
|
||||||
print("[i] Gib dieses Pad sicher an deinen Gesprächspartner weiter "
|
# Zwei unabhängige Ströme: sid_out = ich->Partner, sid_in = Partner->ich
|
||||||
"(z.B. USB-Stick, persönlich). Beide brauchen dieselbe Datei.")
|
sid_out, sid_in = os.urandom(8), os.urandom(8)
|
||||||
|
mat_out, mat_in = os.urandom(size), os.urandom(size)
|
||||||
|
|
||||||
|
# Meine Seite: sende mit Strom OUT, empfange Strom IN
|
||||||
|
write_key(os.path.join(mine, "send.key"), ROLE_SEND, sid_out, 0, mat_out)
|
||||||
|
write_key(os.path.join(mine, "recv.key"), ROLE_RECV, sid_in, 0, mat_in)
|
||||||
|
# Partnerseite (spiegelverkehrt): sendet mit Strom IN, empfängt Strom OUT
|
||||||
|
write_key(os.path.join(theirs, "send.key"), ROLE_SEND, sid_in, 0, mat_in)
|
||||||
|
write_key(os.path.join(theirs, "recv.key"), ROLE_RECV, sid_out, 0, mat_out)
|
||||||
|
|
||||||
|
print(f"[+] Schlüsselpaar erzeugt ({size} Bytes je Richtung, echter Zufall).")
|
||||||
|
print(f" {mine}/ <- DU behältst diesen Ordner")
|
||||||
|
print(f" {theirs}/ <- gib DIESEN Ordner dem Partner (Stick, persönlich)")
|
||||||
|
print("[i] Jede Seite hat send.key (zum Senden) + recv.key (zum Empfangen).")
|
||||||
|
print("[i] send.key nur an EINEM Gerät gleichzeitig benutzen.")
|
||||||
|
|
||||||
|
|
||||||
def cmd_hide(args):
|
def cmd_hide(args):
|
||||||
@@ -181,49 +225,53 @@ def cmd_hide(args):
|
|||||||
plaintext = fh.read()
|
plaintext = fh.read()
|
||||||
else:
|
else:
|
||||||
plaintext = sys.stdin.buffer.read()
|
plaintext = sys.stdin.buffer.read()
|
||||||
|
|
||||||
if not plaintext:
|
if not plaintext:
|
||||||
sys.exit("[!] Leere Nachricht.")
|
sys.exit("[!] Leere Nachricht.")
|
||||||
|
|
||||||
pad = load_pad(args.key)
|
key = read_key(args.key)
|
||||||
offset = read_offset(args.key)
|
if key["role"] != ROLE_SEND:
|
||||||
segment = pad[offset:offset + len(plaintext)]
|
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):
|
if len(segment) < len(plaintext):
|
||||||
sys.exit(
|
sys.exit(f"[!] Sendeschlüssel erschöpft: ab Offset {offset} bleiben nur "
|
||||||
f"[!] Pad erschöpft: ab Offset {offset} bleiben nur "
|
|
||||||
f"{len(segment)} Bytes, Nachricht braucht {len(plaintext)}. "
|
f"{len(segment)} Bytes, Nachricht braucht {len(plaintext)}. "
|
||||||
"Erzeuge/tausche ein neues Pad."
|
"Erzeugt ein neues Schlüsselpaar.")
|
||||||
)
|
|
||||||
|
|
||||||
ciphertext = otp_xor(plaintext, segment)
|
ciphertext = otp_xor(plaintext, key["material"], offset)
|
||||||
header = struct.pack(HEADER_FMT, MAGIC, VERSION, offset, len(ciphertext))
|
header = struct.pack(STEGO_FMT, STEGO_MAGIC, STEGO_VERSION,
|
||||||
payload = header + ciphertext
|
key["stream_id"], offset, len(ciphertext))
|
||||||
|
embed(args.carrier, header + ciphertext, args.output)
|
||||||
|
advance_send_offset(args.key, offset + len(plaintext))
|
||||||
|
|
||||||
embed(args.carrier, payload, args.output)
|
print(f"[+] Verschlüsselt ({len(plaintext)} B) ab Sende-Offset {offset}.")
|
||||||
write_offset(args.key, offset + len(plaintext))
|
|
||||||
|
|
||||||
print(f"[+] Nachricht verschlüsselt ({len(plaintext)} Bytes) "
|
|
||||||
f"mit Pad-Offset {offset}.")
|
|
||||||
print(f"[+] Versteckt in: {args.output}")
|
print(f"[+] Versteckt in: {args.output}")
|
||||||
print(f"[+] Pad-Offset weitergezählt -> {offset + len(plaintext)}")
|
print(f"[+] Sende-Offset im Schlüssel weitergezählt -> "
|
||||||
print("[i] Verschicke das PNG als DATEI/Dokument, nicht als Foto!")
|
f"{offset + len(plaintext)}")
|
||||||
|
print("[i] Als DATEI/Dokument verschicken, nicht als Foto!")
|
||||||
|
|
||||||
|
|
||||||
def cmd_reveal(args):
|
def cmd_reveal(args):
|
||||||
offset, ciphertext = extract(args.stego)
|
info = extract(args.stego)
|
||||||
pad = load_pad(args.key)
|
key = read_key(args.key)
|
||||||
segment = pad[offset:offset + len(ciphertext)]
|
if key["role"] != ROLE_RECV:
|
||||||
if len(segment) < len(ciphertext):
|
sys.exit("[!] Das ist ein Sendeschlüssel (send.key). Zum Empfangen "
|
||||||
sys.exit(
|
"brauchst du deinen recv.key.")
|
||||||
f"[!] Pad passt nicht: ab Offset {offset} brauche ich "
|
if info["stream_id"] != key["stream_id"]:
|
||||||
f"{len(ciphertext)} Bytes, habe nur {len(segment)}."
|
sys.exit("[!] Falscher Schlüssel: dieses Bild gehört zu einem anderen "
|
||||||
)
|
"Kontakt/Strom. Nimm den passenden recv.key.")
|
||||||
plaintext = otp_xor(ciphertext, segment)
|
offset, ct = info["offset"], info["ciphertext"]
|
||||||
|
segment = key["material"][offset:offset + len(ct)]
|
||||||
|
if len(segment) < len(ct):
|
||||||
|
sys.exit(f"[!] Schlüssel passt nicht: ab Offset {offset} brauche ich "
|
||||||
|
f"{len(ct)} Bytes, habe nur {len(segment)}.")
|
||||||
|
plaintext = otp_xor(ct, key["material"], offset)
|
||||||
|
|
||||||
if args.outfile:
|
if args.outfile:
|
||||||
with open(args.outfile, "wb") as fh:
|
with open(args.outfile, "wb") as fh:
|
||||||
fh.write(plaintext)
|
fh.write(plaintext)
|
||||||
print(f"[+] Entschlüsselt ({len(plaintext)} Bytes, Offset {offset}) "
|
print(f"[+] Entschlüsselt ({len(plaintext)} B, Offset {offset}) "
|
||||||
f"-> {args.outfile}")
|
f"-> {args.outfile}")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -237,31 +285,31 @@ def cmd_reveal(args):
|
|||||||
def build_parser():
|
def build_parser():
|
||||||
p = argparse.ArgumentParser(
|
p = argparse.ArgumentParser(
|
||||||
prog="schattenpost",
|
prog="schattenpost",
|
||||||
description="One-Time-Pad + LSB-Steganografie für vertrauliche Nachrichten.",
|
description="One-Time-Pad + LSB-Steganografie, richtungsgetrennte Schlüssel.",
|
||||||
)
|
)
|
||||||
sub = p.add_subparsers(dest="cmd", required=True)
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
g = sub.add_parser("genkey", help="Echtes Zufalls-Pad erzeugen.")
|
g = sub.add_parser("genkey", help="Gerichtetes Schlüsselpaar erzeugen.")
|
||||||
g.add_argument("-o", "--output", required=True, help="Pad-Datei.")
|
g.add_argument("-o", "--output", required=True,
|
||||||
|
help="Basisname (erzeugt <name>_meine_seite/ + _partner_seite/).")
|
||||||
g.add_argument("-s", "--size", type=int, default=1_000_000,
|
g.add_argument("-s", "--size", type=int, default=1_000_000,
|
||||||
help="Größe in Bytes (Default 1 MB).")
|
help="Bytes je Richtung (Default 1 MB).")
|
||||||
g.add_argument("--force", action="store_true", help="Überschreiben erlauben.")
|
g.add_argument("--force", action="store_true")
|
||||||
g.set_defaults(func=cmd_genkey)
|
g.set_defaults(func=cmd_genkey)
|
||||||
|
|
||||||
h = sub.add_parser("hide", help="Nachricht verschlüsseln + verstecken.")
|
h = sub.add_parser("hide", help="Nachricht verschlüsseln + verstecken.")
|
||||||
h.add_argument("-t", "--text", help="Nachrichtentext (sonst --infile/stdin).")
|
h.add_argument("-t", "--text")
|
||||||
h.add_argument("-i", "--infile", help="Datei als Nachricht.")
|
h.add_argument("-i", "--infile")
|
||||||
h.add_argument("-k", "--key", required=True, help="Pad-Datei.")
|
h.add_argument("-k", "--key", required=True, help="Dein send.key.")
|
||||||
h.add_argument("-c", "--carrier", required=True, help="Trägerbild.")
|
h.add_argument("-c", "--carrier", required=True, help="Trägerbild.")
|
||||||
h.add_argument("-o", "--output", required=True, help="Stego-PNG-Ausgabe.")
|
h.add_argument("-o", "--output", required=True, help="Stego-PNG.")
|
||||||
h.set_defaults(func=cmd_hide)
|
h.set_defaults(func=cmd_hide)
|
||||||
|
|
||||||
r = sub.add_parser("reveal", help="Nachricht extrahieren + entschlüsseln.")
|
r = sub.add_parser("reveal", help="Nachricht extrahieren + entschlüsseln.")
|
||||||
r.add_argument("-s", "--stego", required=True, help="Stego-PNG.")
|
r.add_argument("-s", "--stego", required=True)
|
||||||
r.add_argument("-k", "--key", required=True, help="Pad-Datei.")
|
r.add_argument("-k", "--key", required=True, help="Dein recv.key.")
|
||||||
r.add_argument("-o", "--outfile", help="Ausgabedatei (sonst stdout).")
|
r.add_argument("-o", "--outfile")
|
||||||
r.set_defaults(func=cmd_reveal)
|
r.set_defaults(func=cmd_reveal)
|
||||||
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,41 @@
|
|||||||
// Interop-Test: lädt EXAKT den Core-Block aus der ausgelieferten index.html
|
// Interop-Test v2: lädt EXAKT den Core aus der ausgelieferten index.html und
|
||||||
// und prüft Byte-Kompatibilität mit dem Python-CLI in beide Richtungen.
|
// prüft Byte-Kompatibilität mit dem Python-CLI (richtungsgetrennte Schlüssel).
|
||||||
import { readFileSync, writeFileSync } from "node:fs";
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
|
const U = (p) => new Uint8Array(readFileSync(new URL(p, import.meta.url)));
|
||||||
|
const html = readFileSync(new URL('./index.html', import.meta.url), 'utf8');
|
||||||
const core = html.match(/==CORE START==.*?\*\/([\s\S]*?)\/\*\s*==CORE END==/)[1];
|
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)();
|
new Function(core)();
|
||||||
const SP = globalThis.SP;
|
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++; };
|
||||||
|
|
||||||
// ---------- A: CLI -> Web (mit dem CLI erzeugtes Bild im Browser-Core aufdecken)
|
// A: CLI (send.key) -> Web-Core (recv.key)
|
||||||
{
|
{
|
||||||
const dims = readFileSync(new URL("./_a_dims.txt", import.meta.url), "utf8").trim();
|
const rgb = U('./_a_rgb.bin');
|
||||||
const rgb = new Uint8Array(readFileSync(new URL("./_a_rgb.bin", import.meta.url)));
|
const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key'));
|
||||||
const pad = new Uint8Array(readFileSync(new URL("../pad.key", import.meta.url)));
|
const info = SP.extract(rgb);
|
||||||
const expected = "Treffpunkt morgen 18 Uhr am alten Hafen. Bring das Buch mit. 🕵️";
|
const plain = SP.otpXor(info.ciphertext, recv.material, info.offset);
|
||||||
|
|
||||||
const { offset, ciphertext } = SP.extract(rgb);
|
|
||||||
const plain = SP.otpXor(ciphertext, pad, offset);
|
|
||||||
const text = new TextDecoder().decode(plain);
|
const text = new TextDecoder().decode(plain);
|
||||||
console.log("A) CLI → Web (dims " + dims + ")");
|
console.log('A) CLI → Web');
|
||||||
ok(offset === 0, "Offset aus Bild gelesen = 0");
|
ok(SP.sameStream(info.streamId, recv.streamId), 'stream_id passt zum recv.key');
|
||||||
ok(text === expected, "Klartext stimmt: " + JSON.stringify(text));
|
ok(text === 'MsgA vom CLI 🕵️', 'Klartext: ' + JSON.stringify(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- B: Web -> CLI (im Browser-Core einbetten, vom CLI aufdecken lassen)
|
// B: Web-Core (send.key) -> CLI (recv.key)
|
||||||
{
|
{
|
||||||
const [w, h] = readFileSync(new URL("./_b_dims.txt", import.meta.url), "utf8").trim().split(" ").map(Number);
|
const rgb = U('./_b_rgb.bin');
|
||||||
const rgb = new Uint8Array(readFileSync(new URL("./_b_rgb.bin", import.meta.url)));
|
const send = SP.parseKey(U('../t2/rel_partner_seite/send.key'));
|
||||||
const pad = new Uint8Array(readFileSync(new URL("../pad.key", import.meta.url)));
|
const msg = 'MsgB aus dem Browser-Core 🌐';
|
||||||
const msg = "Nachricht direkt aus dem Browser-Core 🌐 — Interop!";
|
ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND');
|
||||||
const offset = 500; // frischer Pad-Bereich
|
|
||||||
|
|
||||||
const plain = new TextEncoder().encode(msg);
|
const plain = new TextEncoder().encode(msg);
|
||||||
const ct = SP.otpXor(plain, pad, offset);
|
const ct = SP.otpXor(plain, send.material, send.sendOffset);
|
||||||
const payload = SP.buildPayload(offset, ct);
|
const payload = SP.buildStego(send.streamId, send.sendOffset, ct);
|
||||||
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");
|
console.log('B) Web → CLI');
|
||||||
ok(true, `eingebettet (${plain.length} B ab Offset ${offset}) → _b_out_rgb.bin (${w}x${h})`);
|
ok(true, `eingebettet (${plain.length} B ab Offset ${send.sendOffset}) → _b_out_rgb.bin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(fails ? 1 : 0);
|
process.exit(fails ? 1 : 0);
|
||||||
|
|||||||
BIN
web/icon-192.png
Normal file
BIN
web/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
web/icon-512.png
Normal file
BIN
web/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
436
web/index.html
436
web/index.html
@@ -1,11 +1,13 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>schattenpost</title>
|
<title>schattenpost</title>
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#0e1116">
|
||||||
|
<link rel="apple-touch-icon" href="icon-192.png">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0e1116; --panel: #161b22; --panel2: #1c232c; --border: #2b3440;
|
--bg: #0e1116; --panel: #161b22; --panel2: #1c232c; --border: #2b3440;
|
||||||
--fg: #e6edf3; --muted: #8b98a5; --accent: #6ea8fe; --accent2: #3d8bfd;
|
--fg: #e6edf3; --muted: #8b98a5; --accent: #6ea8fe; --accent2: #3d8bfd;
|
||||||
--ok: #3fb950; --warn: #d29922; --err: #f85149;
|
|
||||||
--radius: 12px;
|
--radius: 12px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
.panel.hidden { display: none; }
|
.panel.hidden { display: none; }
|
||||||
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; font-weight: 600; }
|
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; font-weight: 600; }
|
||||||
label:first-child { margin-top: 0; }
|
label:first-child { margin-top: 0; }
|
||||||
input[type=text], input[type=number], textarea {
|
input[type=text], input[type=number], textarea, select {
|
||||||
width: 100%; background: var(--panel2); color: var(--fg); border: 1px solid var(--border);
|
width: 100%; background: var(--panel2); color: var(--fg); border: 1px solid var(--border);
|
||||||
border-radius: 8px; padding: 11px; font: inherit; resize: vertical;
|
border-radius: 8px; padding: 11px; font: inherit; resize: vertical;
|
||||||
}
|
}
|
||||||
@@ -44,23 +46,19 @@
|
|||||||
width: 100%; margin-top: 18px; padding: 13px; border: 0; border-radius: 9px;
|
width: 100%; margin-top: 18px; padding: 13px; border: 0; border-radius: 9px;
|
||||||
background: var(--accent2); color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
|
background: var(--accent2); color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||||
}
|
}
|
||||||
button.action:disabled { opacity: .45; cursor: not-allowed; }
|
|
||||||
button.ghost { background: var(--panel2); border: 1px solid var(--border); color: var(--fg); }
|
|
||||||
.row { display: flex; gap: 10px; align-items: flex-end; }
|
.row { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
.row > * { flex: 1; }
|
.row > * { flex: 1; }
|
||||||
.msg { margin-top: 14px; padding: 11px 13px; border-radius: 8px; font-size: 13.5px; display: none; white-space: pre-wrap; word-break: break-word; }
|
.msg { margin-top: 14px; padding: 11px 13px; border-radius: 8px; font-size: 13.5px; display: none; white-space: pre-wrap; word-break: break-word; }
|
||||||
.msg.show { display: block; }
|
.msg.show { display: block; }
|
||||||
.msg.ok { background: rgba(63,185,80,.12); border: 1px solid rgba(63,185,80,.4); color: #7ee787; }
|
.msg.ok { background: rgba(63,185,80,.12); border: 1px solid rgba(63,185,80,.4); color: #7ee787; }
|
||||||
.msg.err { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.4); color: #ff7b72; }
|
.msg.err { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.4); color: #ff7b72; }
|
||||||
.msg.info { background: rgba(110,168,254,.10); border: 1px solid rgba(110,168,254,.35); color: var(--accent); }
|
|
||||||
.note { background: rgba(210,153,34,.10); border: 1px solid rgba(210,153,34,.35); color: #e3b341;
|
.note { background: rgba(210,153,34,.10); border: 1px solid rgba(210,153,34,.35); color: #e3b341;
|
||||||
border-radius: 8px; padding: 11px 13px; font-size: 13px; margin-top: 14px; }
|
border-radius: 8px; padding: 11px 13px; font-size: 13px; margin-top: 14px; }
|
||||||
.out { background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 12px;
|
.out { background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 12px;
|
||||||
margin-top: 14px; white-space: pre-wrap; word-break: break-word; font-size: 14.5px; }
|
margin-top: 14px; white-space: pre-wrap; word-break: break-word; font-size: 14.5px; display: none; }
|
||||||
.preview { max-width: 100%; border-radius: 8px; margin-top: 12px; display: none; }
|
.preview { max-width: 100%; border-radius: 8px; margin-top: 12px; display: none; }
|
||||||
.offbox { display: flex; gap: 10px; align-items: center; font-size: 13px; color: var(--muted); margin-top: 10px; }
|
a.dl { display: block; margin-top: 10px; color: var(--accent); font-weight: 600; text-decoration: none;
|
||||||
.offbox input { width: 110px; }
|
background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 11px 13px; }
|
||||||
a.dl { display: inline-block; margin-top: 12px; color: var(--accent); font-weight: 600; text-decoration: none; }
|
|
||||||
footer { color: var(--muted); font-size: 12px; text-align: center; margin-top: 8px; line-height: 1.7; }
|
footer { color: var(--muted); font-size: 12px; text-align: center; margin-top: 8px; line-height: 1.7; }
|
||||||
code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
|
code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -71,70 +69,64 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button data-tab="hide" class="active">Verstecken</button>
|
<button data-tab="send" class="active">Senden</button>
|
||||||
<button data-tab="reveal">Aufdecken</button>
|
<button data-tab="recv">Empfangen</button>
|
||||||
<button data-tab="key">Pad erzeugen</button>
|
<button data-tab="key">Schlüssel</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ VERSTECKEN ============ -->
|
<!-- ============ SENDEN ============ -->
|
||||||
<section class="panel" id="tab-hide">
|
<section class="panel" id="tab-send">
|
||||||
<label>Nachricht</label>
|
<label>Nachricht</label>
|
||||||
<textarea id="h-msg" placeholder="Dein Geheimtext…"></textarea>
|
<textarea id="s-msg" placeholder="Dein Geheimtext…"></textarea>
|
||||||
|
<label>Dein Sendeschlüssel (send.key)</label>
|
||||||
<label>Pad-Datei (der geheime Schlüssel)</label>
|
<input type="file" id="s-key" accept=".key,application/octet-stream,*/*">
|
||||||
<input type="file" id="h-pad" accept=".key,application/octet-stream,*/*">
|
<label>Trägerbild (harmloses Foto)</label>
|
||||||
<div class="offbox">
|
<input type="file" id="s-carrier" accept="image/*">
|
||||||
Pad-Offset:
|
<button class="action" id="s-go">Verschlüsseln & verstecken</button>
|
||||||
<input type="number" id="h-offset" min="0" value="0">
|
<div class="msg" id="s-status"></div>
|
||||||
<span id="h-offhint"></span>
|
<img class="preview" id="s-preview">
|
||||||
</div>
|
<a class="dl" id="s-dlimg" style="display:none" download="harmlos.png">⬇︎ Stego-Bild speichern</a>
|
||||||
|
<a class="dl" id="s-dlkey" style="display:none" download="send.key">🔑 Aktualisierten send.key speichern</a>
|
||||||
<label>Trägerbild (dein harmloses Foto)</label>
|
<div class="note" id="s-note" style="display:none">
|
||||||
<input type="file" id="h-carrier" accept="image/*">
|
📎 <b>Bild</b> als <b>Datei/Dokument</b> verschicken, nie als „Foto".<br>
|
||||||
|
🔑 <b>Wichtig:</b> Ersetze deinen <code>send.key</code> auf dem Stick durch die
|
||||||
<button class="action" id="h-go">Verschlüsseln & verstecken</button>
|
aktualisierte Datei — sonst benutzt du beim nächsten Mal denselben
|
||||||
<div class="msg" id="h-status"></div>
|
Schlüsselbereich erneut. (Am Laptop erledigt das CLI das automatisch.)
|
||||||
<img class="preview" id="h-preview">
|
|
||||||
<a class="dl" id="h-dl" style="display:none" download="harmlos.png">⬇︎ Stego-Bild speichern</a>
|
|
||||||
<div class="note" id="h-note" style="display:none">
|
|
||||||
📎 <b>Wichtig:</b> Speichere das Bild über „In Dateien sichern" und verschicke es im Messenger
|
|
||||||
als <b>Datei/Dokument</b> — <b>niemals als „Foto"</b>. Sonst komprimiert der Messenger es neu
|
|
||||||
und zerstört die versteckte Nachricht.
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ AUFDECKEN ============ -->
|
<!-- ============ EMPFANGEN ============ -->
|
||||||
<section class="panel hidden" id="tab-reveal">
|
<section class="panel hidden" id="tab-recv">
|
||||||
<label>Stego-Bild</label>
|
<label>Stego-Bild</label>
|
||||||
<input type="file" id="r-img" accept="image/*,.png">
|
<input type="file" id="r-img" accept="image/*,.png">
|
||||||
|
<label>Dein Empfangsschlüssel (recv.key)</label>
|
||||||
<label>Pad-Datei (dasselbe Pad wie der Absender)</label>
|
<input type="file" id="r-key" accept=".key,application/octet-stream,*/*">
|
||||||
<input type="file" id="r-pad" accept=".key,application/octet-stream,*/*">
|
|
||||||
|
|
||||||
<button class="action" id="r-go">Aufdecken & entschlüsseln</button>
|
<button class="action" id="r-go">Aufdecken & entschlüsseln</button>
|
||||||
<div class="msg" id="r-status"></div>
|
<div class="msg" id="r-status"></div>
|
||||||
<div class="out" id="r-out" style="display:none"></div>
|
<div class="out" id="r-out"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ PAD ERZEUGEN ============ -->
|
<!-- ============ SCHLÜSSEL ============ -->
|
||||||
<section class="panel hidden" id="tab-key">
|
<section class="panel hidden" id="tab-key">
|
||||||
<label>Größe</label>
|
<label>Größe je Richtung</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="number" id="k-size" min="1" value="1">
|
<input type="number" id="k-size" min="1" value="1">
|
||||||
<select id="k-unit" style="flex:0 0 90px; background:var(--panel2); color:var(--fg); border:1px solid var(--border); border-radius:8px; padding:11px;">
|
<select id="k-unit" style="flex:0 0 90px;">
|
||||||
<option value="1048576">MB</option>
|
<option value="1048576">MB</option>
|
||||||
<option value="1024">KB</option>
|
<option value="1024">KB</option>
|
||||||
<option value="1">Bytes</option>
|
<option value="1">Bytes</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="note">
|
<div class="note">
|
||||||
🔑 Das Pad ist der Schlüssel. Erzeuge es <b>einmal</b> und übergib es dem Empfänger
|
🔑 Erzeugt ein <b>gerichtetes Schlüsselpaar</b> für eine Beziehung: zwei
|
||||||
<b>persönlich/offline</b> (z.B. USB-Stick). Beide brauchen die byte-identische Datei.
|
getrennte Ströme, damit du und dein Partner gleichzeitig senden könnt, ohne
|
||||||
Ein Pad-Byte darf nie zweimal verschlüsselt werden — der Offset unter „Verstecken" zählt automatisch hoch.
|
denselben Schlüsselbereich zu treffen. Du bekommst 4 Dateien — sortiere sie
|
||||||
|
<b>auf einen USB-Stick</b>: <code>meine_seite/</code> behältst du,
|
||||||
|
<code>partner_seite/</code> gibst du dem Partner (persönlich!).
|
||||||
</div>
|
</div>
|
||||||
<button class="action" id="k-go">Zufalls-Pad erzeugen</button>
|
<button class="action" id="k-go">Schlüsselpaar erzeugen</button>
|
||||||
<div class="msg" id="k-status"></div>
|
<div class="msg" id="k-status"></div>
|
||||||
<a class="dl" id="k-dl" style="display:none" download="pad.key">⬇︎ Pad speichern</a>
|
<div id="k-downloads"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@@ -143,251 +135,223 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
/* ==CORE START== (pure, testbar in Node — keine DOM/Canvas/crypto-APIs hier drin) */
|
/* ==CORE START== (pure, testbar in Node — keine DOM/Canvas/crypto-APIs) */
|
||||||
const SP = (function () {
|
const SP = (function () {
|
||||||
const MAGIC = Uint8Array.of(0x53, 0x50, 0x53, 0x54); // "SPST"
|
const enc = (s) => new TextEncoder().encode(s);
|
||||||
const VERSION = 1;
|
const KEY_MAGIC = enc('SPK1'), KEY_VERSION = 1, KEY_HEADER_LEN = 24;
|
||||||
const HEADER_LEN = 17; // magic(4)+ver(1)+offset(8)+length(4)
|
const ROLE_RECV = 0, ROLE_SEND = 1;
|
||||||
|
const STEGO_MAGIC = enc('SPS2'), STEGO_VERSION = 2, STEGO_HEADER_LEN = 25;
|
||||||
|
|
||||||
function otpXor(data, key, offset) {
|
const eq4 = (a, b, off) => a[0]===b[off]&&a[1]===b[off+1]&&a[2]===b[off+2]&&a[3]===b[off+3];
|
||||||
if (offset + data.length > key.length) {
|
const sameStream = (a, b) => { if (a.length!==b.length) return false;
|
||||||
throw new Error(
|
for (let i=0;i<a.length;i++) if (a[i]!==b[i]) return false; return true; };
|
||||||
`Pad zu kurz/erschöpft: ab Offset ${offset} bräuchte ich ${data.length} Bytes, ` +
|
|
||||||
`habe nur ${key.length - offset}.`);
|
function otpXor(data, material, offset) {
|
||||||
|
if (offset + data.length > material.length) {
|
||||||
|
throw new Error(`Schlüssel erschöpft: ab Offset ${offset} bräuchte ich `
|
||||||
|
+ `${data.length} Bytes, habe nur ${material.length - offset}.`);
|
||||||
}
|
}
|
||||||
const out = new Uint8Array(data.length);
|
const out = new Uint8Array(data.length);
|
||||||
for (let i = 0; i < data.length; i++) out[i] = data[i] ^ key[offset + i];
|
for (let i = 0; i < data.length; i++) out[i] = data[i] ^ material[offset + i];
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(offset, ciphertext) {
|
function parseKey(bytes) {
|
||||||
const header = new Uint8Array(HEADER_LEN);
|
if (bytes.length < KEY_HEADER_LEN) throw new Error('Keine gültige Schlüsseldatei (zu kurz).');
|
||||||
header.set(MAGIC, 0);
|
if (!eq4(KEY_MAGIC, bytes, 0)) throw new Error('Keine schattenpost-Schlüsseldatei.');
|
||||||
header[4] = VERSION;
|
if (bytes[4] !== KEY_VERSION) throw new Error('Unbekannte Schlüsselversion ' + bytes[4] + '.');
|
||||||
const dv = new DataView(header.buffer);
|
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
dv.setBigUint64(5, BigInt(offset), false); // big-endian
|
return {
|
||||||
dv.setUint32(13, ciphertext.length, false);
|
role: bytes[5],
|
||||||
const out = new Uint8Array(HEADER_LEN + ciphertext.length);
|
streamId: bytes.slice(6, 14),
|
||||||
out.set(header, 0);
|
sendOffset: Number(dv.getBigUint64(14, false)),
|
||||||
out.set(ciphertext, HEADER_LEN);
|
material: bytes.slice(KEY_HEADER_LEN),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKey(role, streamId, sendOffset, material) {
|
||||||
|
const out = new Uint8Array(KEY_HEADER_LEN + material.length);
|
||||||
|
out.set(KEY_MAGIC, 0);
|
||||||
|
out[4] = KEY_VERSION; out[5] = role;
|
||||||
|
out.set(streamId, 6);
|
||||||
|
new DataView(out.buffer).setBigUint64(14, BigInt(sendOffset), false);
|
||||||
|
out.set(material, KEY_HEADER_LEN);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bettet payload-Bytes (MSB zuerst) in die LSBs von rgb ein. rgb = R,G,B linear (kein Alpha).
|
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);
|
||||||
|
dv.setBigUint64(13, BigInt(offset), false);
|
||||||
|
dv.setUint32(21, ciphertext.length, false);
|
||||||
|
out.set(ciphertext, STEGO_HEADER_LEN);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rgb = R,G,B linear (kein Alpha)
|
||||||
function embed(rgb, payload) {
|
function embed(rgb, payload) {
|
||||||
const need = payload.length * 8;
|
const need = payload.length * 8;
|
||||||
if (need > rgb.length) {
|
if (need > rgb.length) throw new Error(`Trägerbild zu klein: brauche `
|
||||||
throw new Error(
|
+ `${payload.length} B, Platz für ${(rgb.length/8)|0}. Nimm ein größeres Foto.`);
|
||||||
`Trägerbild zu klein: brauche ${need} Bits (${payload.length} B), ` +
|
|
||||||
`Platz für ${rgb.length} (${(rgb.length / 8) | 0} B). Nimm ein größeres Foto.`);
|
|
||||||
}
|
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
for (let i = 0; i < payload.length; i++) {
|
for (let i = 0; i < payload.length; i++)
|
||||||
const byte = payload[i];
|
for (let b = 7; b >= 0; b--) { rgb[idx] = (rgb[idx] & 0xFE) | ((payload[i]>>b)&1); idx++; }
|
||||||
for (let b = 7; b >= 0; b--) {
|
|
||||||
rgb[idx] = (rgb[idx] & 0xFE) | ((byte >> b) & 1);
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rgb;
|
return rgb;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBytesAt(rgb, nBytes) {
|
function readBytesAt(rgb, nBytes) {
|
||||||
const out = new Uint8Array(nBytes);
|
const out = new Uint8Array(nBytes);
|
||||||
let ch = 0;
|
let ch = 0;
|
||||||
for (let i = 0; i < nBytes; i++) {
|
for (let i = 0; i < nBytes; i++) { let a=0; for (let b=0;b<8;b++){a=(a<<1)|(rgb[ch]&1);ch++;} out[i]=a; }
|
||||||
let acc = 0;
|
|
||||||
for (let b = 0; b < 8; b++) { acc = (acc << 1) | (rgb[ch] & 1); ch++; }
|
|
||||||
out[i] = acc;
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extract(rgb) {
|
function extract(rgb) {
|
||||||
if (rgb.length < HEADER_LEN * 8) throw new Error("Bild zu klein für einen Header.");
|
if (rgb.length < STEGO_HEADER_LEN * 8) throw new Error('Bild zu klein für einen Header.');
|
||||||
const header = readBytesAt(rgb, HEADER_LEN);
|
const h = readBytesAt(rgb, STEGO_HEADER_LEN);
|
||||||
for (let i = 0; i < 4; i++) {
|
if (!eq4(STEGO_MAGIC, h, 0)) throw new Error(
|
||||||
if (header[i] !== MAGIC[i]) {
|
'Kein schattenpost-Container gefunden. Wurde das Bild als Foto komprimiert?');
|
||||||
throw new Error(
|
if (h[4] !== STEGO_VERSION) throw new Error('Unbekannte Container-Version ' + h[4] + '.');
|
||||||
"Kein Schattenpost-Container gefunden. Wurde das Bild als Foto (verlustbehaftet) komprimiert?");
|
const dv = new DataView(h.buffer);
|
||||||
}
|
const streamId = h.slice(5, 13);
|
||||||
}
|
const offset = Number(dv.getBigUint64(13, false));
|
||||||
if (header[4] !== VERSION) throw new Error(`Unbekannte Version ${header[4]}.`);
|
const length = dv.getUint32(21, false);
|
||||||
const dv = new DataView(header.buffer);
|
const full = readBytesAt(rgb, STEGO_HEADER_LEN + length);
|
||||||
const offset = Number(dv.getBigUint64(5, false));
|
return { streamId, offset, ciphertext: full.slice(STEGO_HEADER_LEN) };
|
||||||
const length = dv.getUint32(13, false);
|
|
||||||
const full = readBytesAt(rgb, HEADER_LEN + length);
|
|
||||||
return { offset, ciphertext: full.slice(HEADER_LEN) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { MAGIC, VERSION, HEADER_LEN, otpXor, buildPayload, embed, extract };
|
return { ROLE_RECV, ROLE_SEND, KEY_HEADER_LEN, STEGO_HEADER_LEN,
|
||||||
|
otpXor, 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;
|
||||||
/* ==CORE END== */
|
/* ==CORE END== */
|
||||||
|
|
||||||
/* ---- ab hier UI-Schicht (Browser only) ---- */
|
/* ---- UI-Schicht (Browser only) ---- */
|
||||||
if (typeof document !== "undefined") (function () {
|
if (typeof document !== 'undefined') (function () {
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const enc = new TextEncoder();
|
const encTxt = new TextEncoder(), decTxt = new TextDecoder();
|
||||||
const dec = new TextDecoder();
|
const show = (el, kind, text) => { el.className = 'msg show ' + kind; el.textContent = text; };
|
||||||
|
const blobUrl = (bytes, mime) => URL.createObjectURL(new Blob([bytes], { type: mime || 'application/octet-stream' }));
|
||||||
|
|
||||||
function show(el, kind, text) {
|
document.querySelectorAll('.tabs button').forEach((btn) => {
|
||||||
el.className = "msg show " + kind;
|
|
||||||
el.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Tabs ----
|
|
||||||
document.querySelectorAll(".tabs button").forEach((btn) => {
|
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
document.querySelectorAll(".tabs button").forEach((b) => b.classList.remove("active"));
|
document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active'));
|
||||||
btn.classList.add("active");
|
btn.classList.add('active');
|
||||||
["hide", "reveal", "key"].forEach((t) =>
|
['send', 'recv', 'key'].forEach((t) => $('tab-' + t).classList.toggle('hidden', t !== btn.dataset.tab));
|
||||||
$("tab-" + t).classList.toggle("hidden", t !== btn.dataset.tab));
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const readFile = (file) => new Promise((res, rej) => {
|
const readFile = (file) => new Promise((res, rej) => {
|
||||||
const fr = new FileReader();
|
const fr = new FileReader();
|
||||||
fr.onload = () => res(new Uint8Array(fr.result));
|
fr.onload = () => res(new Uint8Array(fr.result));
|
||||||
fr.onerror = rej;
|
fr.onerror = () => rej(new Error('Datei konnte nicht gelesen werden.'));
|
||||||
fr.readAsArrayBuffer(file);
|
fr.readAsArrayBuffer(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadImage = (file) => new Promise((res, rej) => {
|
const loadImage = (file) => new Promise((res, rej) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => res(img);
|
img.onload = () => res(img);
|
||||||
img.onerror = () => rej(new Error("Bild konnte nicht geladen werden."));
|
img.onerror = () => rej(new Error('Bild konnte nicht geladen werden.'));
|
||||||
img.src = URL.createObjectURL(file);
|
img.src = URL.createObjectURL(file);
|
||||||
});
|
});
|
||||||
|
function imageToRgb(img) {
|
||||||
// ImageData -> reines RGB (Alpha verwerfen), kompatibel zum Python-CLI
|
const cv = document.createElement('canvas');
|
||||||
function rgbaToRgb(data) {
|
|
||||||
const rgb = new Uint8Array((data.length / 4) * 3);
|
|
||||||
let j = 0;
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
rgb[j++] = data[i]; rgb[j++] = data[i + 1]; rgb[j++] = data[i + 2];
|
|
||||||
}
|
|
||||||
return rgb;
|
|
||||||
}
|
|
||||||
function rgbIntoRgba(rgb, data) {
|
|
||||||
let j = 0;
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
data[i] = rgb[j++]; data[i + 1] = rgb[j++]; data[i + 2] = rgb[j++]; data[i + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Offset-Tracking pro Pad (localStorage, an Pad-Hash gebunden) ----
|
|
||||||
async function padKey(bytes) {
|
|
||||||
const h = await crypto.subtle.digest("SHA-256", bytes);
|
|
||||||
return "sp_off_" + Array.from(new Uint8Array(h).slice(0, 8))
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
}
|
|
||||||
let hidePad = null, hidePadKeyName = null;
|
|
||||||
|
|
||||||
$("h-pad").onchange = async (e) => {
|
|
||||||
const f = e.target.files[0]; if (!f) return;
|
|
||||||
hidePad = await readFile(f);
|
|
||||||
hidePadKeyName = await padKey(hidePad);
|
|
||||||
const saved = parseInt(localStorage.getItem(hidePadKeyName) || "0", 10);
|
|
||||||
$("h-offset").value = saved;
|
|
||||||
$("h-offhint").textContent = `(${hidePad.length} B Pad, gemerkter Stand: ${saved})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Verstecken ----
|
|
||||||
$("h-go").onclick = async () => {
|
|
||||||
const st = $("h-status");
|
|
||||||
try {
|
|
||||||
const text = $("h-msg").value;
|
|
||||||
if (!text) throw new Error("Leere Nachricht.");
|
|
||||||
if (!hidePad) throw new Error("Bitte Pad-Datei wählen.");
|
|
||||||
const cf = $("h-carrier").files[0];
|
|
||||||
if (!cf) throw new Error("Bitte Trägerbild wählen.");
|
|
||||||
|
|
||||||
const offset = parseInt($("h-offset").value || "0", 10);
|
|
||||||
const plain = enc.encode(text);
|
|
||||||
const ct = SP.otpXor(plain, hidePad, offset);
|
|
||||||
const payload = SP.buildPayload(offset, ct);
|
|
||||||
|
|
||||||
const img = await loadImage(cf);
|
|
||||||
const cv = document.createElement("canvas");
|
|
||||||
cv.width = img.naturalWidth; cv.height = img.naturalHeight;
|
cv.width = img.naturalWidth; cv.height = img.naturalHeight;
|
||||||
const cx = cv.getContext("2d");
|
const cx = cv.getContext('2d'); cx.drawImage(img, 0, 0);
|
||||||
cx.drawImage(img, 0, 0);
|
|
||||||
const idata = cx.getImageData(0, 0, cv.width, cv.height);
|
const idata = cx.getImageData(0, 0, cv.width, cv.height);
|
||||||
|
const rgb = new Uint8Array((idata.data.length / 4) * 3);
|
||||||
|
for (let i = 0, j = 0; i < idata.data.length; i += 4) { rgb[j++]=idata.data[i]; rgb[j++]=idata.data[i+1]; rgb[j++]=idata.data[i+2]; }
|
||||||
|
return { cv, cx, idata, rgb };
|
||||||
|
}
|
||||||
|
|
||||||
const rgb = rgbaToRgb(idata.data);
|
// ---- Senden ----
|
||||||
|
$('s-go').onclick = async () => {
|
||||||
|
const st = $('s-status');
|
||||||
|
try {
|
||||||
|
const text = $('s-msg').value;
|
||||||
|
if (!text) throw new Error('Leere Nachricht.');
|
||||||
|
if (!$('s-key').files[0]) throw new Error('Bitte send.key wählen.');
|
||||||
|
if (!$('s-carrier').files[0]) throw new Error('Bitte Trägerbild wählen.');
|
||||||
|
|
||||||
|
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);
|
||||||
|
const ct = SP.otpXor(plain, key.material, key.sendOffset);
|
||||||
|
const payload = SP.buildStego(key.streamId, key.sendOffset, ct);
|
||||||
|
|
||||||
|
const img = await loadImage($('s-carrier').files[0]);
|
||||||
|
const { cv, cx, idata, rgb } = imageToRgb(img);
|
||||||
SP.embed(rgb, payload);
|
SP.embed(rgb, payload);
|
||||||
rgbIntoRgba(rgb, idata.data);
|
for (let i = 0, j = 0; i < idata.data.length; i += 4) { idata.data[i]=rgb[j++]; idata.data[i+1]=rgb[j++]; idata.data[i+2]=rgb[j++]; idata.data[i+3]=255; }
|
||||||
cx.putImageData(idata, 0, 0);
|
cx.putImageData(idata, 0, 0);
|
||||||
|
|
||||||
cv.toBlob((blob) => {
|
cv.toBlob((blob) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
$("h-preview").src = url; $("h-preview").style.display = "block";
|
$('s-preview').src = url; $('s-preview').style.display = 'block';
|
||||||
const dl = $("h-dl"); dl.href = url; dl.style.display = "inline-block";
|
const dl = $('s-dlimg'); dl.href = url; dl.style.display = 'block';
|
||||||
$("h-note").style.display = "block";
|
// Aktualisierter Sendeschlüssel (Offset fortgeschrieben)
|
||||||
// Offset fortschreiben (gegen Pad-Wiederverwendung)
|
const nextOff = key.sendOffset + plain.length;
|
||||||
const next = offset + plain.length;
|
const newKey = SP.buildKey(SP.ROLE_SEND, key.streamId, nextOff, key.material);
|
||||||
localStorage.setItem(hidePadKeyName, String(next));
|
const dk = $('s-dlkey'); dk.href = blobUrl(newKey); dk.style.display = 'block';
|
||||||
$("h-offset").value = next;
|
$('s-note').style.display = 'block';
|
||||||
show(st, "ok",
|
show(st, 'ok', `Versteckt. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`);
|
||||||
`Versteckt. ${plain.length} B verschlüsselt ab Offset ${offset}. ` +
|
}, 'image/png');
|
||||||
`Nächster freier Offset: ${next}.`);
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
||||||
}, "image/png");
|
|
||||||
} catch (err) {
|
|
||||||
show(st, "err", "✗ " + err.message);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Aufdecken ----
|
// ---- Empfangen ----
|
||||||
$("r-go").onclick = async () => {
|
$('r-go').onclick = async () => {
|
||||||
const st = $("r-status"), out = $("r-out");
|
const st = $('r-status'), out = $('r-out');
|
||||||
out.style.display = "none";
|
out.style.display = 'none';
|
||||||
try {
|
try {
|
||||||
const imf = $("r-img").files[0];
|
if (!$('r-img').files[0]) throw new Error('Bitte Stego-Bild wählen.');
|
||||||
if (!imf) throw new Error("Bitte Stego-Bild wählen.");
|
if (!$('r-key').files[0]) throw new Error('Bitte recv.key wählen.');
|
||||||
const pf = $("r-pad").files[0];
|
const key = SP.parseKey(await readFile($('r-key').files[0]));
|
||||||
if (!pf) throw new Error("Bitte Pad-Datei wählen.");
|
if (key.role !== SP.ROLE_RECV) throw new Error('Das ist ein Sendeschlüssel. Zum Empfangen brauchst du deinen recv.key.');
|
||||||
const pad = await readFile(pf);
|
const img = await loadImage($('r-img').files[0]);
|
||||||
|
const { rgb } = imageToRgb(img);
|
||||||
const img = await loadImage(imf);
|
const info = SP.extract(rgb);
|
||||||
const cv = document.createElement("canvas");
|
if (!SP.sameStream(info.streamId, key.streamId))
|
||||||
cv.width = img.naturalWidth; cv.height = img.naturalHeight;
|
throw new Error('Falscher Schlüssel: dieses Bild gehört zu einem anderen Kontakt.');
|
||||||
const cx = cv.getContext("2d");
|
const plain = SP.otpXor(info.ciphertext, key.material, info.offset);
|
||||||
cx.drawImage(img, 0, 0);
|
let text; try { text = decTxt.decode(plain); } catch { text = '[Binärdaten]'; }
|
||||||
const idata = cx.getImageData(0, 0, cv.width, cv.height);
|
out.textContent = text; out.style.display = 'block';
|
||||||
const rgb = rgbaToRgb(idata.data);
|
show(st, 'ok', `Entschlüsselt (${plain.length} B, Offset ${info.offset}).`);
|
||||||
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
||||||
const { offset, ciphertext } = SP.extract(rgb);
|
|
||||||
const plain = SP.otpXor(ciphertext, pad, offset);
|
|
||||||
let text;
|
|
||||||
try { text = dec.decode(plain); } catch { text = "[Binärdaten, nicht als Text darstellbar]"; }
|
|
||||||
out.textContent = text; out.style.display = "block";
|
|
||||||
show(st, "ok", `Entschlüsselt (${plain.length} B, Pad-Offset ${offset}).`);
|
|
||||||
} catch (err) {
|
|
||||||
show(st, "err", "✗ " + err.message);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Pad erzeugen ----
|
// ---- Schlüssel erzeugen ----
|
||||||
$("k-go").onclick = () => {
|
$('k-go').onclick = () => {
|
||||||
const st = $("k-status");
|
const st = $('k-status'), box = $('k-downloads');
|
||||||
|
box.innerHTML = '';
|
||||||
try {
|
try {
|
||||||
const n = Math.floor(parseFloat($("k-size").value) * parseInt($("k-unit").value, 10));
|
const n = Math.floor(parseFloat($('k-size').value) * parseInt($('k-unit').value, 10));
|
||||||
if (!n || n < 1) throw new Error("Ungültige Größe.");
|
if (!n || n < 1) throw new Error('Ungültige Größe.');
|
||||||
if (n > 64 * 1048576) throw new Error("Bitte höchstens 64 MB.");
|
if (n > 64 * 1048576) throw new Error('Bitte höchstens 64 MB je Richtung.');
|
||||||
const buf = new Uint8Array(n);
|
const rnd = (len) => { const b = new Uint8Array(len);
|
||||||
// crypto.getRandomValues: max 65536 Bytes pro Aufruf
|
for (let o = 0; o < len; o += 65536) crypto.getRandomValues(b.subarray(o, Math.min(o + 65536, len)));
|
||||||
for (let off = 0; off < n; off += 65536) {
|
return b; };
|
||||||
crypto.getRandomValues(buf.subarray(off, Math.min(off + 65536, n)));
|
const sidOut = rnd(8), sidIn = rnd(8), matOut = rnd(n), matIn = rnd(n);
|
||||||
}
|
const files = [
|
||||||
const url = URL.createObjectURL(new Blob([buf], { type: "application/octet-stream" }));
|
['meine_seite__send.key', SP.buildKey(SP.ROLE_SEND, sidOut, 0, matOut)],
|
||||||
const dl = $("k-dl"); dl.href = url; dl.style.display = "inline-block";
|
['meine_seite__recv.key', SP.buildKey(SP.ROLE_RECV, sidIn, 0, matIn)],
|
||||||
localStorage.setItem("sp_lastpad_hint", String(n));
|
['partner_seite__send.key', SP.buildKey(SP.ROLE_SEND, sidIn, 0, matIn)],
|
||||||
show(st, "ok", `${n} Bytes echter Zufall erzeugt (crypto.getRandomValues). Speichern & offline teilen.`);
|
['partner_seite__recv.key', SP.buildKey(SP.ROLE_RECV, sidOut, 0, matOut)],
|
||||||
} catch (err) {
|
];
|
||||||
show(st, "err", "✗ " + err.message);
|
for (const [name, bytes] of files) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'dl'; a.href = blobUrl(bytes); a.download = name;
|
||||||
|
a.textContent = '⬇︎ ' + name;
|
||||||
|
box.appendChild(a);
|
||||||
}
|
}
|
||||||
|
show(st, 'ok', `${n} B je Richtung erzeugt. Speichere die 4 Dateien und sortiere sie auf den Stick.`);
|
||||||
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator)
|
||||||
|
window.addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(() => {}));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
17
web/manifest.webmanifest
Normal file
17
web/manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "schattenpost",
|
||||||
|
"short_name": "schattenpost",
|
||||||
|
"description": "One-Time-Pad + Steganografie — vertrauliche Nachrichten, komplett lokal im Browser.",
|
||||||
|
"lang": "de",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#0e1116",
|
||||||
|
"theme_color": "#0e1116",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
28
web/sw.js
Normal file
28
web/sw.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/* 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 SHELL = [
|
||||||
|
'./',
|
||||||
|
'./index.html',
|
||||||
|
'./manifest.webmanifest',
|
||||||
|
'./icon-192.png',
|
||||||
|
'./icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
if (e.request.method !== 'GET') return;
|
||||||
|
e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request)));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user