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

View File

@@ -1,46 +1,41 @@
// 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";
// Interop-Test v2: lädt EXAKT den Core aus der ausgelieferten index.html und
// prüft Byte-Kompatibilität mit dem Python-CLI (richtungsgetrennte Schlüssel).
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];
// 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++; };
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 = 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 rgb = U('./_a_rgb.bin');
const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key'));
const info = SP.extract(rgb);
const plain = SP.otpXor(info.ciphertext, recv.material, info.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));
console.log('A) CLI → Web');
ok(SP.sameStream(info.streamId, recv.streamId), 'stream_id passt zum recv.key');
ok(text === 'MsgA vom CLI 🕵️', 'Klartext: ' + JSON.stringify(text));
}
// ---------- 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 = 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 rgb = U('./_b_rgb.bin');
const send = SP.parseKey(U('../t2/rel_partner_seite/send.key'));
const msg = 'MsgB aus dem Browser-Core 🌐';
ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND');
const plain = new TextEncoder().encode(msg);
const ct = SP.otpXor(plain, pad, offset);
const payload = SP.buildPayload(offset, ct);
const ct = SP.otpXor(plain, send.material, send.sendOffset);
const payload = SP.buildStego(send.streamId, send.sendOffset, 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})`);
writeFileSync(new URL('./_b_out_rgb.bin', import.meta.url), rgb);
console.log('B) Web → CLI');
ok(true, `eingebettet (${plain.length} B ab Offset ${send.sendOffset}) → _b_out_rgb.bin`);
}
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 name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<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>
: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; }
@@ -30,7 +32,7 @@
.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 {
input[type=text], input[type=number], textarea, select {
width: 100%; background: var(--panel2); color: var(--fg); border: 1px solid var(--border);
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;
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; }
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; }
.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; }
a.dl { display: block; margin-top: 10px; color: var(--accent); font-weight: 600; text-decoration: none;
background: var(--panel2); border: 1px solid var(--border); border-radius: 8px; padding: 11px 13px; }
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>
@@ -71,70 +69,64 @@
</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>
<button data-tab="send" class="active">Senden</button>
<button data-tab="recv">Empfangen</button>
<button data-tab="key">Schlüssel</button>
</div>
<!-- ============ VERSTECKEN ============ -->
<section class="panel" id="tab-hide">
<!-- ============ SENDEN ============ -->
<section class="panel" id="tab-send">
<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 &amp; 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.
<textarea id="s-msg" placeholder="Dein Geheimtext…"></textarea>
<label>Dein Sendeschlüssel (send.key)</label>
<input type="file" id="s-key" accept=".key,application/octet-stream,*/*">
<label>Trägerbild (harmloses Foto)</label>
<input type="file" id="s-carrier" accept="image/*">
<button class="action" id="s-go">Verschlüsseln &amp; verstecken</button>
<div class="msg" id="s-status"></div>
<img class="preview" id="s-preview">
<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>
<div class="note" id="s-note" style="display:none">
📎 <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
aktualisierte Datei — sonst benutzt du beim nächsten Mal denselben
Schlüsselbereich erneut. (Am Laptop erledigt das CLI das automatisch.)
</div>
</section>
<!-- ============ AUFDECKEN ============ -->
<section class="panel hidden" id="tab-reveal">
<!-- ============ EMPFANGEN ============ -->
<section class="panel hidden" id="tab-recv">
<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,*/*">
<label>Dein Empfangsschlüssel (recv.key)</label>
<input type="file" id="r-key" accept=".key,application/octet-stream,*/*">
<button class="action" id="r-go">Aufdecken &amp; entschlüsseln</button>
<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>
<!-- ============ PAD ERZEUGEN ============ -->
<!-- ============ SCHLÜSSEL ============ -->
<section class="panel hidden" id="tab-key">
<label>Größe</label>
<label>Größe je Richtung</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;">
<select id="k-unit" style="flex:0 0 90px;">
<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.
🔑 Erzeugt ein <b>gerichtetes Schlüsselpaar</b> für eine Beziehung: zwei
getrennte Ströme, damit du und dein Partner gleichzeitig senden könnt, ohne
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>
<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>
<a class="dl" id="k-dl" style="display:none" download="pad.key">⬇︎ Pad speichern</a>
<div id="k-downloads"></div>
</section>
<footer>
@@ -143,251 +135,223 @@
</footer>
<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 MAGIC = Uint8Array.of(0x53, 0x50, 0x53, 0x54); // "SPST"
const VERSION = 1;
const HEADER_LEN = 17; // magic(4)+ver(1)+offset(8)+length(4)
const enc = (s) => new TextEncoder().encode(s);
const KEY_MAGIC = enc('SPK1'), KEY_VERSION = 1, KEY_HEADER_LEN = 24;
const ROLE_RECV = 0, ROLE_SEND = 1;
const STEGO_MAGIC = enc('SPS2'), STEGO_VERSION = 2, STEGO_HEADER_LEN = 25;
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 eq4 = (a, b, off) => a[0]===b[off]&&a[1]===b[off+1]&&a[2]===b[off+2]&&a[3]===b[off+3];
const sameStream = (a, b) => { if (a.length!==b.length) return false;
for (let i=0;i<a.length;i++) if (a[i]!==b[i]) return false; return true; };
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);
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;
}
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);
function parseKey(bytes) {
if (bytes.length < KEY_HEADER_LEN) throw new Error('Keine gültige Schlüsseldatei (zu kurz).');
if (!eq4(KEY_MAGIC, bytes, 0)) throw new Error('Keine schattenpost-Schlüsseldatei.');
if (bytes[4] !== KEY_VERSION) throw new Error('Unbekannte Schlüsselversion ' + bytes[4] + '.');
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return {
role: bytes[5],
streamId: bytes.slice(6, 14),
sendOffset: Number(dv.getBigUint64(14, false)),
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;
}
// 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) {
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.`);
}
if (need > rgb.length) throw new Error(`Trägerbild zu klein: brauche `
+ `${payload.length} B, Platz für ${(rgb.length/8)|0}. 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++;
}
}
for (let i = 0; i < payload.length; i++)
for (let b = 7; b >= 0; b--) { rgb[idx] = (rgb[idx] & 0xFE) | ((payload[i]>>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;
}
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; }
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) };
if (rgb.length < STEGO_HEADER_LEN * 8) throw new Error('Bild zu klein für einen Header.');
const h = readBytesAt(rgb, STEGO_HEADER_LEN);
if (!eq4(STEGO_MAGIC, h, 0)) throw new Error(
'Kein schattenpost-Container gefunden. Wurde das Bild als Foto komprimiert?');
if (h[4] !== STEGO_VERSION) throw new Error('Unbekannte Container-Version ' + h[4] + '.');
const dv = new DataView(h.buffer);
const streamId = h.slice(5, 13);
const offset = Number(dv.getBigUint64(13, false));
const length = dv.getUint32(21, false);
const full = readBytesAt(rgb, STEGO_HEADER_LEN + length);
return { streamId, offset, ciphertext: full.slice(STEGO_HEADER_LEN) };
}
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 globalThis !== "undefined") globalThis.SP = SP;
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 () {
/* ---- UI-Schicht (Browser only) ---- */
if (typeof document !== 'undefined') (function () {
const $ = (id) => document.getElementById(id);
const enc = new TextEncoder();
const dec = new TextDecoder();
const encTxt = new TextEncoder(), decTxt = 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) {
el.className = "msg show " + kind;
el.textContent = text;
}
// ---- Tabs ----
document.querySelectorAll(".tabs button").forEach((btn) => {
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));
document.querySelectorAll('.tabs button').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
['send', 'recv', '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.onerror = () => rej(new Error('Datei konnte nicht gelesen werden.'));
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.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;
}
function imageToRgb(img) {
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 = 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 };
}
// ---- 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");
// ---- Senden ----
$('s-go').onclick = async () => {
const st = $('s-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 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 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 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(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);
const img = await loadImage($('s-carrier').files[0]);
const { cv, cx, idata, rgb } = imageToRgb(img);
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);
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);
}
$('s-preview').src = url; $('s-preview').style.display = 'block';
const dl = $('s-dlimg'); dl.href = url; dl.style.display = 'block';
// Aktualisierter Sendeschlüssel (Offset fortgeschrieben)
const nextOff = key.sendOffset + plain.length;
const newKey = SP.buildKey(SP.ROLE_SEND, key.streamId, nextOff, key.material);
const dk = $('s-dlkey'); dk.href = blobUrl(newKey); dk.style.display = 'block';
$('s-note').style.display = 'block';
show(st, 'ok', `Versteckt. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`);
}, '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";
// ---- Empfangen ----
$('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);
}
if (!$('r-img').files[0]) throw new Error('Bitte Stego-Bild wählen.');
if (!$('r-key').files[0]) throw new Error('Bitte recv.key wählen.');
const key = SP.parseKey(await readFile($('r-key').files[0]));
if (key.role !== SP.ROLE_RECV) throw new Error('Das ist ein Sendeschlüssel. Zum Empfangen brauchst du deinen recv.key.');
const img = await loadImage($('r-img').files[0]);
const { rgb } = imageToRgb(img);
const info = SP.extract(rgb);
if (!SP.sameStream(info.streamId, key.streamId))
throw new Error('Falscher Schlüssel: dieses Bild gehört zu einem anderen Kontakt.');
const plain = SP.otpXor(info.ciphertext, key.material, info.offset);
let text; try { text = decTxt.decode(plain); } catch { text = '[Binärdaten]'; }
out.textContent = text; out.style.display = 'block';
show(st, 'ok', `Entschlüsselt (${plain.length} B, Offset ${info.offset}).`);
} catch (err) { show(st, 'err', '✗ ' + err.message); }
};
// ---- Pad erzeugen ----
$("k-go").onclick = () => {
const st = $("k-status");
// ---- Schlüssel erzeugen ----
$('k-go').onclick = () => {
const st = $('k-status'), box = $('k-downloads');
box.innerHTML = '';
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 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 je Richtung.');
const rnd = (len) => { const b = new Uint8Array(len);
for (let o = 0; o < len; o += 65536) crypto.getRandomValues(b.subarray(o, Math.min(o + 65536, len)));
return b; };
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" }));
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);
}
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>

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