Weboberflaeche: self-contained, client-side, CLI-kompatibel
Eine einzelne index.html ohne Framework/CDN. Krypto + LSB-Steganografie laufen komplett im Browser (iOS-tauglich). Format-identisch zum CLI; Byte-Interop in beide Richtungen per Node-Test gegen das Python-CLI abgesichert (laedt den Krypto-Kern direkt aus der ausgelieferten index.html). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,6 +9,10 @@
|
||||
*.jpeg
|
||||
!docs/*.png
|
||||
|
||||
# Interop-Test-Artefakte (Helfer selbst werden committet)
|
||||
*.bin
|
||||
web/_*_dims.txt
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
35
README.md
35
README.md
@@ -96,6 +96,41 @@ Vor der LSB-Einbettung wird ein kleiner Header vorangestellt:
|
||||
|
||||
Die Bits werden linear (MSB zuerst) in die LSBs der R/G/B-Kanäle geschrieben.
|
||||
|
||||
## Weboberfläche (`web/index.html`)
|
||||
|
||||
Eine **einzige, self-contained HTML-Datei** — kein Framework, kein CDN, kein
|
||||
Build. Verschlüsselung und Steganografie laufen zu 100 % im Browser
|
||||
(client-side): Pad, Klartext und Nachricht verlassen das Gerät nie. Ein Server,
|
||||
der die Datei ausliefert, ist ein reiner „dummer" Fileserver und sieht nichts.
|
||||
|
||||
* **Überall erreichbar, iOS inklusive.** Nutzt nur Safari-Standard-APIs
|
||||
(Canvas, FileReader, `crypto.getRandomValues`).
|
||||
* **Format-kompatibel zum CLI.** Am Terminal verstecken, am Handy im Browser
|
||||
aufdecken — und umgekehrt. Byte-Interop ist per Test abgesichert
|
||||
(`web/_interop_test.mjs` lädt exakt den Krypto-Kern aus der ausgelieferten
|
||||
`index.html` und prüft beide Richtungen gegen das Python-CLI).
|
||||
* **Offset-Tracking** merkt sich pro Pad (via `localStorage`) den nächsten
|
||||
freien Offset. Achtung: Der Zähler ist pro Browser/Gerät. Wer abwechselnd über
|
||||
mehrere Instanzen sendet, muss den Offset manuell synchron halten — sonst
|
||||
droht Pad-Wiederverwendung.
|
||||
|
||||
### Deployment
|
||||
|
||||
Es ist eine statische Datei — einfach irgendwo hinlegen:
|
||||
|
||||
```bash
|
||||
# lokal testen
|
||||
python3 -m http.server -d web 8000 # -> http://localhost:8000
|
||||
|
||||
# produktiv: web/index.html hinter einen beliebigen Webserver / Static-Host
|
||||
```
|
||||
|
||||
### ⚠️ iOS-Eigenheit
|
||||
|
||||
Stego-Bild über **„In Dateien sichern"** ablegen und als **Datei** teilen —
|
||||
nicht in „Fotos" speichern. iOS re-komprimiert Bilder aus der Fotos-Mediathek
|
||||
und würde die Nachricht zerstören.
|
||||
|
||||
## Grenzen / Hinweise
|
||||
|
||||
- **Steganografie versteckt, authentifiziert aber nicht.** Ein Angreifer, der
|
||||
|
||||
18
web/_interop_rawrgb.py
Normal file
18
web/_interop_rawrgb.py
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test-Helfer: PNG <-> rohes RGB-Byte-Array (nur für den Interop-Test)."""
|
||||
import sys
|
||||
from PIL import Image
|
||||
|
||||
if sys.argv[1] == "png2rgb": # png2rgb <in.png> <out.bin> -> stdout: "W H"
|
||||
img = Image.open(sys.argv[2]).convert("RGB")
|
||||
w, h = img.size
|
||||
with open(sys.argv[3], "wb") as fh:
|
||||
fh.write(bytes([v for px in img.getdata() for v in px]))
|
||||
print(w, h)
|
||||
elif sys.argv[1] == "rgb2png": # rgb2png <in.bin> <W> <H> <out.png>
|
||||
w, h = int(sys.argv[3]), int(sys.argv[4])
|
||||
data = open(sys.argv[2], "rb").read()
|
||||
img = Image.new("RGB", (w, h))
|
||||
img.putdata([tuple(data[i:i + 3]) for i in range(0, len(data), 3)])
|
||||
img.save(sys.argv[5], "PNG")
|
||||
print("ok")
|
||||
46
web/_interop_test.mjs
Normal file
46
web/_interop_test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
// Interop-Test: lädt EXAKT den Core-Block aus der ausgelieferten index.html
|
||||
// und prüft Byte-Kompatibilität mit dem Python-CLI in beide Richtungen.
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
|
||||
const core = html.match(/==CORE START==.*?\*\/([\s\S]*?)\/\*\s*==CORE END==/)[1];
|
||||
// Core in diesem Modul-Scope ausführen; er hängt SP an globalThis.
|
||||
new Function(core)();
|
||||
const SP = globalThis.SP;
|
||||
|
||||
let fails = 0;
|
||||
const ok = (c, m) => { console.log((c ? " ✓ " : " ✗ ") + m); if (!c) fails++; };
|
||||
|
||||
// ---------- A: CLI -> Web (mit dem CLI erzeugtes Bild im Browser-Core aufdecken)
|
||||
{
|
||||
const dims = readFileSync(new URL("./_a_dims.txt", import.meta.url), "utf8").trim();
|
||||
const rgb = new Uint8Array(readFileSync(new URL("./_a_rgb.bin", import.meta.url)));
|
||||
const pad = new Uint8Array(readFileSync(new URL("../pad.key", import.meta.url)));
|
||||
const expected = "Treffpunkt morgen 18 Uhr am alten Hafen. Bring das Buch mit. 🕵️";
|
||||
|
||||
const { offset, ciphertext } = SP.extract(rgb);
|
||||
const plain = SP.otpXor(ciphertext, pad, offset);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
console.log("A) CLI → Web (dims " + dims + ")");
|
||||
ok(offset === 0, "Offset aus Bild gelesen = 0");
|
||||
ok(text === expected, "Klartext stimmt: " + JSON.stringify(text));
|
||||
}
|
||||
|
||||
// ---------- B: Web -> CLI (im Browser-Core einbetten, vom CLI aufdecken lassen)
|
||||
{
|
||||
const [w, h] = readFileSync(new URL("./_b_dims.txt", import.meta.url), "utf8").trim().split(" ").map(Number);
|
||||
const rgb = new Uint8Array(readFileSync(new URL("./_b_rgb.bin", import.meta.url)));
|
||||
const pad = new Uint8Array(readFileSync(new URL("../pad.key", import.meta.url)));
|
||||
const msg = "Nachricht direkt aus dem Browser-Core 🌐 — Interop!";
|
||||
const offset = 500; // frischer Pad-Bereich
|
||||
|
||||
const plain = new TextEncoder().encode(msg);
|
||||
const ct = SP.otpXor(plain, pad, offset);
|
||||
const payload = SP.buildPayload(offset, ct);
|
||||
SP.embed(rgb, payload);
|
||||
writeFileSync(new URL("./_b_out_rgb.bin", import.meta.url), rgb);
|
||||
console.log("B) Web → CLI");
|
||||
ok(true, `eingebettet (${plain.length} B ab Offset ${offset}) → _b_out_rgb.bin (${w}x${h})`);
|
||||
}
|
||||
|
||||
process.exit(fails ? 1 : 0);
|
||||
393
web/index.html
Normal file
393
web/index.html
Normal file
@@ -0,0 +1,393 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>schattenpost</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0e1116; --panel: #161b22; --panel2: #1c232c; --border: #2b3440;
|
||||
--fg: #e6edf3; --muted: #8b98a5; --accent: #6ea8fe; --accent2: #3d8bfd;
|
||||
--ok: #3fb950; --warn: #d29922; --err: #f85149;
|
||||
--radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
padding: env(safe-area-inset-top) 16px calc(env(safe-area-inset-bottom) + 24px);
|
||||
max-width: 640px; margin: 0 auto;
|
||||
}
|
||||
header { padding: 24px 0 8px; }
|
||||
h1 { font-size: 22px; margin: 0; letter-spacing: .3px; }
|
||||
h1 span { color: var(--accent); }
|
||||
.sub { color: var(--muted); font-size: 13px; margin-top: 4px; }
|
||||
.tabs { display: flex; gap: 6px; margin: 18px 0; background: var(--panel); padding: 5px; border-radius: var(--radius); }
|
||||
.tabs button {
|
||||
flex: 1; padding: 10px 6px; border: 0; border-radius: 8px; cursor: pointer;
|
||||
background: transparent; color: var(--muted); font-size: 14px; font-weight: 600;
|
||||
}
|
||||
.tabs button.active { background: var(--accent2); color: #fff; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 16px; }
|
||||
.panel.hidden { display: none; }
|
||||
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 6px; font-weight: 600; }
|
||||
label:first-child { margin-top: 0; }
|
||||
input[type=text], input[type=number], textarea {
|
||||
width: 100%; background: var(--panel2); color: var(--fg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 11px; font: inherit; resize: vertical;
|
||||
}
|
||||
textarea { min-height: 90px; }
|
||||
input[type=file] { width: 100%; color: var(--muted); font-size: 13px; }
|
||||
input[type=file]::file-selector-button {
|
||||
background: var(--panel2); color: var(--fg); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 8px 12px; margin-right: 10px; cursor: pointer; font: inherit;
|
||||
}
|
||||
button.action {
|
||||
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;
|
||||
}
|
||||
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 > * { 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.show { display: block; }
|
||||
.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.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;
|
||||
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;
|
||||
margin-top: 14px; white-space: pre-wrap; word-break: break-word; font-size: 14.5px; }
|
||||
.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; }
|
||||
.offbox input { width: 110px; }
|
||||
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; }
|
||||
code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
|
||||
</style>
|
||||
|
||||
<header>
|
||||
<h1>schatten<span>post</span></h1>
|
||||
<div class="sub">One-Time-Pad + Steganografie. Alles läuft in deinem Browser — nichts wird gesendet.</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button data-tab="hide" class="active">Verstecken</button>
|
||||
<button data-tab="reveal">Aufdecken</button>
|
||||
<button data-tab="key">Pad erzeugen</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ VERSTECKEN ============ -->
|
||||
<section class="panel" id="tab-hide">
|
||||
<label>Nachricht</label>
|
||||
<textarea id="h-msg" placeholder="Dein Geheimtext…"></textarea>
|
||||
|
||||
<label>Pad-Datei (der geheime Schlüssel)</label>
|
||||
<input type="file" id="h-pad" accept=".key,application/octet-stream,*/*">
|
||||
<div class="offbox">
|
||||
Pad-Offset:
|
||||
<input type="number" id="h-offset" min="0" value="0">
|
||||
<span id="h-offhint"></span>
|
||||
</div>
|
||||
|
||||
<label>Trägerbild (dein harmloses Foto)</label>
|
||||
<input type="file" id="h-carrier" accept="image/*">
|
||||
|
||||
<button class="action" id="h-go">Verschlüsseln & verstecken</button>
|
||||
<div class="msg" id="h-status"></div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- ============ AUFDECKEN ============ -->
|
||||
<section class="panel hidden" id="tab-reveal">
|
||||
<label>Stego-Bild</label>
|
||||
<input type="file" id="r-img" accept="image/*,.png">
|
||||
|
||||
<label>Pad-Datei (dasselbe Pad wie der Absender)</label>
|
||||
<input type="file" id="r-pad" accept=".key,application/octet-stream,*/*">
|
||||
|
||||
<button class="action" id="r-go">Aufdecken & entschlüsseln</button>
|
||||
<div class="msg" id="r-status"></div>
|
||||
<div class="out" id="r-out" style="display:none"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ PAD ERZEUGEN ============ -->
|
||||
<section class="panel hidden" id="tab-key">
|
||||
<label>Größe</label>
|
||||
<div class="row">
|
||||
<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;">
|
||||
<option value="1048576">MB</option>
|
||||
<option value="1024">KB</option>
|
||||
<option value="1">Bytes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="note">
|
||||
🔑 Das Pad ist der Schlüssel. Erzeuge es <b>einmal</b> und übergib es dem Empfänger
|
||||
<b>persönlich/offline</b> (z.B. USB-Stick). Beide brauchen die byte-identische Datei.
|
||||
Ein Pad-Byte darf nie zweimal verschlüsselt werden — der Offset unter „Verstecken" zählt automatisch hoch.
|
||||
</div>
|
||||
<button class="action" id="k-go">Zufalls-Pad erzeugen</button>
|
||||
<div class="msg" id="k-status"></div>
|
||||
<a class="dl" id="k-dl" style="display:none" download="pad.key">⬇︎ Pad speichern</a>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Krypto & Steganografie laufen ausschließlich lokal (client-side).<br>
|
||||
Format-kompatibel zum <code>schattenpost.py</code>-CLI.
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* ==CORE START== (pure, testbar in Node — keine DOM/Canvas/crypto-APIs hier drin) */
|
||||
const SP = (function () {
|
||||
const MAGIC = Uint8Array.of(0x53, 0x50, 0x53, 0x54); // "SPST"
|
||||
const VERSION = 1;
|
||||
const HEADER_LEN = 17; // magic(4)+ver(1)+offset(8)+length(4)
|
||||
|
||||
function otpXor(data, key, offset) {
|
||||
if (offset + data.length > key.length) {
|
||||
throw new Error(
|
||||
`Pad zu kurz/erschöpft: ab Offset ${offset} bräuchte ich ${data.length} Bytes, ` +
|
||||
`habe nur ${key.length - offset}.`);
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) out[i] = data[i] ^ key[offset + i];
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildPayload(offset, ciphertext) {
|
||||
const header = new Uint8Array(HEADER_LEN);
|
||||
header.set(MAGIC, 0);
|
||||
header[4] = VERSION;
|
||||
const dv = new DataView(header.buffer);
|
||||
dv.setBigUint64(5, BigInt(offset), false); // big-endian
|
||||
dv.setUint32(13, ciphertext.length, false);
|
||||
const out = new Uint8Array(HEADER_LEN + ciphertext.length);
|
||||
out.set(header, 0);
|
||||
out.set(ciphertext, HEADER_LEN);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Bettet payload-Bytes (MSB zuerst) in die LSBs von rgb ein. rgb = R,G,B linear (kein Alpha).
|
||||
function embed(rgb, payload) {
|
||||
const need = payload.length * 8;
|
||||
if (need > rgb.length) {
|
||||
throw new Error(
|
||||
`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;
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
const byte = payload[i];
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
rgb[idx] = (rgb[idx] & 0xFE) | ((byte >> b) & 1);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
|
||||
function readBytesAt(rgb, nBytes) {
|
||||
const out = new Uint8Array(nBytes);
|
||||
let ch = 0;
|
||||
for (let i = 0; i < nBytes; i++) {
|
||||
let acc = 0;
|
||||
for (let b = 0; b < 8; b++) { acc = (acc << 1) | (rgb[ch] & 1); ch++; }
|
||||
out[i] = acc;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extract(rgb) {
|
||||
if (rgb.length < HEADER_LEN * 8) throw new Error("Bild zu klein für einen Header.");
|
||||
const header = readBytesAt(rgb, HEADER_LEN);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (header[i] !== MAGIC[i]) {
|
||||
throw new Error(
|
||||
"Kein Schattenpost-Container gefunden. Wurde das Bild als Foto (verlustbehaftet) komprimiert?");
|
||||
}
|
||||
}
|
||||
if (header[4] !== VERSION) throw new Error(`Unbekannte Version ${header[4]}.`);
|
||||
const dv = new DataView(header.buffer);
|
||||
const offset = Number(dv.getBigUint64(5, false));
|
||||
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 };
|
||||
})();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = SP;
|
||||
if (typeof globalThis !== "undefined") globalThis.SP = SP;
|
||||
/* ==CORE END== */
|
||||
|
||||
/* ---- ab hier UI-Schicht (Browser only) ---- */
|
||||
if (typeof document !== "undefined") (function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
function show(el, kind, text) {
|
||||
el.className = "msg show " + kind;
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
// ---- Tabs ----
|
||||
document.querySelectorAll(".tabs button").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll(".tabs button").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
["hide", "reveal", "key"].forEach((t) =>
|
||||
$("tab-" + t).classList.toggle("hidden", t !== btn.dataset.tab));
|
||||
};
|
||||
});
|
||||
|
||||
const readFile = (file) => new Promise((res, rej) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => res(new Uint8Array(fr.result));
|
||||
fr.onerror = rej;
|
||||
fr.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
const loadImage = (file) => new Promise((res, rej) => {
|
||||
const img = new Image();
|
||||
img.onload = () => res(img);
|
||||
img.onerror = () => rej(new Error("Bild konnte nicht geladen werden."));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
// ImageData -> reines RGB (Alpha verwerfen), kompatibel zum Python-CLI
|
||||
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;
|
||||
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);
|
||||
rgbIntoRgba(rgb, idata.data);
|
||||
cx.putImageData(idata, 0, 0);
|
||||
|
||||
cv.toBlob((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
$("h-preview").src = url; $("h-preview").style.display = "block";
|
||||
const dl = $("h-dl"); dl.href = url; dl.style.display = "inline-block";
|
||||
$("h-note").style.display = "block";
|
||||
// Offset fortschreiben (gegen Pad-Wiederverwendung)
|
||||
const next = offset + plain.length;
|
||||
localStorage.setItem(hidePadKeyName, String(next));
|
||||
$("h-offset").value = next;
|
||||
show(st, "ok",
|
||||
`Versteckt. ${plain.length} B verschlüsselt ab Offset ${offset}. ` +
|
||||
`Nächster freier Offset: ${next}.`);
|
||||
}, "image/png");
|
||||
} catch (err) {
|
||||
show(st, "err", "✗ " + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Aufdecken ----
|
||||
$("r-go").onclick = async () => {
|
||||
const st = $("r-status"), out = $("r-out");
|
||||
out.style.display = "none";
|
||||
try {
|
||||
const imf = $("r-img").files[0];
|
||||
if (!imf) throw new Error("Bitte Stego-Bild wählen.");
|
||||
const pf = $("r-pad").files[0];
|
||||
if (!pf) throw new Error("Bitte Pad-Datei wählen.");
|
||||
const pad = await readFile(pf);
|
||||
|
||||
const img = await loadImage(imf);
|
||||
const cv = document.createElement("canvas");
|
||||
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);
|
||||
|
||||
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 ----
|
||||
$("k-go").onclick = () => {
|
||||
const st = $("k-status");
|
||||
try {
|
||||
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 > 64 * 1048576) throw new Error("Bitte höchstens 64 MB.");
|
||||
const buf = new Uint8Array(n);
|
||||
// crypto.getRandomValues: max 65536 Bytes pro Aufruf
|
||||
for (let off = 0; off < n; off += 65536) {
|
||||
crypto.getRandomValues(buf.subarray(off, Math.min(off + 65536, n)));
|
||||
}
|
||||
const url = URL.createObjectURL(new Blob([buf], { type: "application/octet-stream" }));
|
||||
const dl = $("k-dl"); dl.href = url; dl.style.display = "inline-block";
|
||||
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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user