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:
2026-07-14 21:46:06 +00:00
parent ad0b309acf
commit 30127db247
9 changed files with 549 additions and 469 deletions

5
.gitignore vendored
View File

@@ -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
View File

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

View File

@@ -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)}. " "Erzeugt ein neues Schlüsselpaar.")
"Erzeuge/tausche ein neues Pad."
)
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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
web/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -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 &amp; 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 &amp; 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 &amp; entschlüsseln</button> <button class="action" id="r-go">Aufdecken &amp; 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) { cv.width = img.naturalWidth; cv.height = img.naturalHeight;
const rgb = new Uint8Array((data.length / 4) * 3); const cx = cv.getContext('2d'); cx.drawImage(img, 0, 0);
let j = 0; const idata = cx.getImageData(0, 0, cv.width, cv.height);
for (let i = 0; i < data.length; i += 4) { const rgb = new Uint8Array((idata.data.length / 4) * 3);
rgb[j++] = data[i]; rgb[j++] = data[i + 1]; rgb[j++] = data[i + 2]; 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 };
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) ---- // ---- Senden ----
async function padKey(bytes) { $('s-go').onclick = async () => {
const h = await crypto.subtle.digest("SHA-256", bytes); const st = $('s-status');
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 { try {
const text = $("h-msg").value; const text = $('s-msg').value;
if (!text) throw new Error("Leere Nachricht."); if (!text) throw new Error('Leere Nachricht.');
if (!hidePad) throw new Error("Bitte Pad-Datei wählen."); if (!$('s-key').files[0]) throw new Error('Bitte send.key wählen.');
const cf = $("h-carrier").files[0]; if (!$('s-carrier').files[0]) throw new Error('Bitte Trägerbild wählen.');
if (!cf) throw new Error("Bitte Trägerbild wählen.");
const offset = parseInt($("h-offset").value || "0", 10); const key = SP.parseKey(await readFile($('s-key').files[0]));
const plain = enc.encode(text); if (key.role !== SP.ROLE_SEND) throw new Error('Das ist ein Empfangsschlüssel. Zum Senden brauchst du deinen send.key.');
const ct = SP.otpXor(plain, hidePad, offset); const plain = encTxt.encode(text);
const payload = SP.buildPayload(offset, ct); const ct = SP.otpXor(plain, key.material, key.sendOffset);
const payload = SP.buildStego(key.streamId, key.sendOffset, ct);
const img = await loadImage(cf); const img = await loadImage($('s-carrier').files[0]);
const cv = document.createElement("canvas"); const { cv, cx, idata, rgb } = imageToRgb(img);
cv.width = img.naturalWidth; cv.height = img.naturalHeight;
const cx = cv.getContext("2d");
cx.drawImage(img, 0, 0);
const idata = cx.getImageData(0, 0, cv.width, cv.height);
const rgb = rgbaToRgb(idata.data);
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 = [
['meine_seite__send.key', SP.buildKey(SP.ROLE_SEND, sidOut, 0, matOut)],
['meine_seite__recv.key', SP.buildKey(SP.ROLE_RECV, sidIn, 0, matIn)],
['partner_seite__send.key', SP.buildKey(SP.ROLE_SEND, sidIn, 0, matIn)],
['partner_seite__recv.key', SP.buildKey(SP.ROLE_RECV, sidOut, 0, matOut)],
];
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);
} }
const url = URL.createObjectURL(new Blob([buf], { type: "application/octet-stream" })); show(st, 'ok', `${n} B je Richtung erzeugt. Speichere die 4 Dateien und sortiere sie auf den Stick.`);
const dl = $("k-dl"); dl.href = url; dl.style.display = "inline-block"; } catch (err) { show(st, 'err', '✗ ' + err.message); }
localStorage.setItem("sp_lastpad_hint", String(n));
show(st, "ok", `${n} Bytes echter Zufall erzeugt (crypto.getRandomValues). Speichern & offline teilen.`);
} 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
View 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
View 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)));
});