- Footer der Web-App verlinkt aufs Gitea-Repo mit kleinem Gitea-Logo (inline SVG) - Dockerfile (nginx:alpine) liefert web/ statisch aus -> Coolify Git-Deploy - deploy/nginx.conf: CSP frame-ancestors erlaubt Einbettung als iframe nur von gaertner.onl; sw.js/manifest ungecacht; Dev-Artefakte (_*) werden geblockt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
447 lines
22 KiB
HTML
447 lines
22 KiB
HTML
<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;
|
|
--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, select {
|
|
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;
|
|
}
|
|
.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; }
|
|
.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; display: none; }
|
|
.preview { max-width: 100%; border-radius: 8px; margin-top: 12px; display: 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; }
|
|
footer a.repo { display: inline-flex; align-items: center; gap: 6px; margin-top: 12px;
|
|
color: var(--muted); text-decoration: none; font-weight: 600; }
|
|
footer a.repo:hover { color: var(--accent); }
|
|
footer a.repo svg { display: block; flex: 0 0 auto; }
|
|
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="send" class="active">Senden</button>
|
|
<button data-tab="recv">Empfangen</button>
|
|
<button data-tab="key">Schlüssel</button>
|
|
</div>
|
|
|
|
<!-- ============ SENDEN ============ -->
|
|
<section class="panel" id="tab-send">
|
|
<label>Nachricht</label>
|
|
<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 & 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>
|
|
|
|
<!-- ============ EMPFANGEN ============ -->
|
|
<section class="panel hidden" id="tab-recv">
|
|
<label>Stego-Bild</label>
|
|
<input type="file" id="r-img" accept="image/*,.png">
|
|
<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 & entschlüsseln</button>
|
|
<div class="msg" id="r-status"></div>
|
|
<div class="out" id="r-out"></div>
|
|
</section>
|
|
|
|
<!-- ============ SCHLÜSSEL ============ -->
|
|
<section class="panel hidden" id="tab-key">
|
|
<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;">
|
|
<option value="1048576">MB</option>
|
|
<option value="1024">KB</option>
|
|
<option value="1">Bytes</option>
|
|
</select>
|
|
</div>
|
|
<div class="note">
|
|
🔑 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">Schlüsselpaar erzeugen</button>
|
|
<div class="msg" id="k-status"></div>
|
|
<div id="k-downloads"></div>
|
|
</section>
|
|
|
|
<footer>
|
|
Krypto & Steganografie laufen ausschließlich lokal (client-side).<br>
|
|
Format-kompatibel zum <code>schattenpost.py</code>-CLI.<br>
|
|
<a class="repo" href="https://gitea.gaertner.onl/christian/schattenpost" target="_blank" rel="noopener noreferrer">
|
|
<svg viewBox="0 0 32 32" width="15" height="15" role="img" aria-label="Gitea">
|
|
<path fill="#609926" d="M6 8h13.5v8.5A6.75 6.75 0 0 1 12.75 23.25h-.5A6.75 6.75 0 0 1 6 16.5V8z"/>
|
|
<path fill="none" stroke="#609926" stroke-width="2.4" d="M19.5 10.5h3.25a3.25 3.25 0 0 1 0 6.5H19.5"/>
|
|
<g fill="#fff">
|
|
<circle cx="10.6" cy="11.4" r="1.5"/>
|
|
<circle cx="15.4" cy="11.4" r="1.5"/>
|
|
<circle cx="13" cy="18.4" r="1.5"/>
|
|
</g>
|
|
<path fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round"
|
|
d="M10.6 12.9v.6a2.4 2.4 0 0 0 2.4 2.4M15.4 12.9v.6a2.4 2.4 0 0 1-2.4 2.4M13 15.9v1"/>
|
|
</svg>
|
|
Quellcode auf Gitea
|
|
</a>
|
|
</footer>
|
|
|
|
<script>
|
|
/* ==CORE START== (pure, testbar in Node — keine DOM/Canvas/crypto-APIs) */
|
|
const SP = (function () {
|
|
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;
|
|
// v3 authentifiziert mit Poly1305-Einmal-MAC (Tag im Header).
|
|
const STEGO_MAGIC = enc('SPS3'), STEGO_VERSION = 3;
|
|
const STEGO_PRE_LEN = 25, STEGO_TAG_LEN = 16;
|
|
const STEGO_HEADER_LEN = STEGO_PRE_LEN + STEGO_TAG_LEN; // 41
|
|
const MAC_KEY_LEN = 32;
|
|
const STEGO_MAGIC_V2 = enc('SPS2'), STEGO_VERSION_V2 = 2; // Alt, nur Aufdecken
|
|
|
|
const eq4 = (a, b, off) => a[0]===b[off]&&a[1]===b[off+1]&&a[2]===b[off+2]&&a[3]===b[off+3];
|
|
const 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; };
|
|
const concat = (a, b) => { const o = new Uint8Array(a.length + b.length);
|
|
o.set(a, 0); o.set(b, a.length); return o; };
|
|
|
|
function otpXor(data, material, offset) {
|
|
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] ^ material[offset + i];
|
|
return out;
|
|
}
|
|
|
|
// --- Poly1305 (RFC 8439), Einmal-MAC aus frischem Pad-Material ---
|
|
const _leToBig = (b) => { let x = 0n; for (let i = b.length - 1; i >= 0; i--) x = (x << 8n) | BigInt(b[i]); return x; };
|
|
const _bigToLe = (x, n) => { const o = new Uint8Array(n); for (let i = 0; i < n; i++) { o[i] = Number(x & 0xffn); x >>= 8n; } return o; };
|
|
const _P1305_P = (1n << 130n) - 5n;
|
|
const _P1305_CLAMP = 0x0ffffffc0ffffffc0ffffffc0fffffffn;
|
|
function poly1305(msg, key) {
|
|
if (key.length !== 32) throw new Error('Poly1305-Schlüssel muss 32 Byte sein.');
|
|
const r = _leToBig(key.subarray(0, 16)) & _P1305_CLAMP;
|
|
const s = _leToBig(key.subarray(16, 32));
|
|
let acc = 0n;
|
|
for (let i = 0; i < msg.length; i += 16) {
|
|
const block = msg.subarray(i, Math.min(i + 16, msg.length));
|
|
const n = _leToBig(block) | (1n << BigInt(8 * block.length));
|
|
acc = (acc + n) * r % _P1305_P;
|
|
}
|
|
return _bigToLe((acc + s) % (1n << 128n), 16);
|
|
}
|
|
function ctEqual(a, b) { if (a.length !== b.length) return false;
|
|
let d = 0; for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; return d === 0; }
|
|
|
|
function parseKey(bytes) {
|
|
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;
|
|
}
|
|
|
|
// macKey: 32 Byte frisches Pad-Material (r|s). Tag deckt Vor-Header+Ciphertext ab.
|
|
function buildStego(streamId, offset, ciphertext, macKey) {
|
|
const pre = new Uint8Array(STEGO_PRE_LEN);
|
|
pre.set(STEGO_MAGIC, 0);
|
|
pre[4] = STEGO_VERSION;
|
|
pre.set(streamId, 5);
|
|
const dv = new DataView(pre.buffer);
|
|
dv.setBigUint64(13, BigInt(offset), false);
|
|
dv.setUint32(21, ciphertext.length, false);
|
|
const tag = poly1305(concat(pre, ciphertext), macKey);
|
|
const out = new Uint8Array(STEGO_HEADER_LEN + ciphertext.length);
|
|
out.set(pre, 0);
|
|
out.set(tag, STEGO_PRE_LEN);
|
|
out.set(ciphertext, STEGO_HEADER_LEN);
|
|
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 `
|
|
+ `${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++)
|
|
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 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 < STEGO_PRE_LEN * 8) throw new Error('Bild zu klein für einen Header.');
|
|
const pre = readBytesAt(rgb, STEGO_PRE_LEN);
|
|
const ver = pre[4];
|
|
const dv = new DataView(pre.buffer);
|
|
const streamId = pre.slice(5, 13);
|
|
const offset = Number(dv.getBigUint64(13, false));
|
|
const length = dv.getUint32(21, false);
|
|
if (eq4(STEGO_MAGIC, pre, 0) && ver === STEGO_VERSION) {
|
|
const full = readBytesAt(rgb, STEGO_HEADER_LEN + length);
|
|
return { version: ver, streamId, offset, length,
|
|
tag: full.slice(STEGO_PRE_LEN, STEGO_HEADER_LEN),
|
|
macInputHeader: full.slice(0, STEGO_PRE_LEN),
|
|
ciphertext: full.slice(STEGO_HEADER_LEN, STEGO_HEADER_LEN + length) };
|
|
}
|
|
if (eq4(STEGO_MAGIC_V2, pre, 0) && ver === STEGO_VERSION_V2) {
|
|
const full = readBytesAt(rgb, STEGO_PRE_LEN + length);
|
|
return { version: ver, streamId, offset, length, tag: null,
|
|
ciphertext: full.slice(STEGO_PRE_LEN, STEGO_PRE_LEN + length) };
|
|
}
|
|
throw new Error('Kein schattenpost-Container gefunden. Wurde das Bild als Foto komprimiert?');
|
|
}
|
|
|
|
return { ROLE_RECV, ROLE_SEND, KEY_HEADER_LEN, STEGO_HEADER_LEN, STEGO_PRE_LEN,
|
|
STEGO_VERSION, STEGO_VERSION_V2, MAC_KEY_LEN,
|
|
otpXor, poly1305, ctEqual, concat, parseKey, buildKey, buildStego,
|
|
embed, extract, sameStream };
|
|
})();
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = SP;
|
|
if (typeof globalThis !== 'undefined') globalThis.SP = SP;
|
|
/* ==CORE END== */
|
|
|
|
/* ---- UI-Schicht (Browser only) ---- */
|
|
if (typeof document !== 'undefined') (function () {
|
|
const $ = (id) => document.getElementById(id);
|
|
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' }));
|
|
|
|
// Unauffälliger Dateiname mit aktuellem Zeitstempel (Screenshot-Stil passt zu PNG).
|
|
// Für Kamera-Look: PREFIX auf 'IMG' setzen. Format: <PREFIX>_YYYYMMDD_HHMMSS.png
|
|
const CARRIER_PREFIX = 'Screenshot';
|
|
function carrierName() {
|
|
const d = new Date(), p = (n) => String(n).padStart(2, '0');
|
|
return `${CARRIER_PREFIX}_${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}`
|
|
+ `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}.png`;
|
|
}
|
|
|
|
document.querySelectorAll('.tabs button').forEach((btn) => {
|
|
btn.onclick = () => {
|
|
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(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.src = URL.createObjectURL(file);
|
|
});
|
|
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 };
|
|
}
|
|
|
|
// ---- Senden ----
|
|
$('s-go').onclick = async () => {
|
|
const st = $('s-status');
|
|
try {
|
|
const text = $('s-msg').value;
|
|
if (!text) throw new Error('Leere Nachricht.');
|
|
if (!$('s-key').files[0]) throw new Error('Bitte send.key wählen.');
|
|
if (!$('s-carrier').files[0]) throw new Error('Bitte Trägerbild wählen.');
|
|
|
|
const key = SP.parseKey(await readFile($('s-key').files[0]));
|
|
if (key.role !== SP.ROLE_SEND) throw new Error('Das ist ein Empfangsschlüssel. Zum Senden brauchst du deinen send.key.');
|
|
const plain = encTxt.encode(text);
|
|
// Keystream (len) + MAC-Schlüssel (32) frisch aus dem Pad.
|
|
const need = plain.length + SP.MAC_KEY_LEN;
|
|
if (key.sendOffset + need > key.material.length)
|
|
throw new Error(`Sendeschlüssel erschöpft: ab Offset ${key.sendOffset} `
|
|
+ `brauche ich ${need} B (Nachricht+MAC), habe nur ${key.material.length - key.sendOffset}.`);
|
|
const ct = SP.otpXor(plain, key.material, key.sendOffset);
|
|
const macKey = key.material.slice(key.sendOffset + plain.length, key.sendOffset + need);
|
|
const payload = SP.buildStego(key.streamId, key.sendOffset, ct, macKey);
|
|
|
|
const img = await loadImage($('s-carrier').files[0]);
|
|
const { cv, cx, idata, rgb } = imageToRgb(img);
|
|
SP.embed(rgb, payload);
|
|
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);
|
|
$('s-preview').src = url; $('s-preview').style.display = 'block';
|
|
const dl = $('s-dlimg'); dl.href = url; dl.download = carrierName(); dl.style.display = 'block';
|
|
// Aktualisierter Sendeschlüssel (Offset um Nachricht+MAC fortgeschrieben)
|
|
const nextOff = key.sendOffset + need;
|
|
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 + authentifiziert. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`);
|
|
}, 'image/png');
|
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
|
};
|
|
|
|
// ---- Empfangen ----
|
|
$('r-go').onclick = async () => {
|
|
const st = $('r-status'), out = $('r-out');
|
|
out.style.display = 'none';
|
|
try {
|
|
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.');
|
|
let warn = '';
|
|
if (info.version === SP.STEGO_VERSION) {
|
|
const need = info.ciphertext.length + SP.MAC_KEY_LEN;
|
|
if (info.offset + need > key.material.length)
|
|
throw new Error('Schlüssel passt nicht: zu kurz für Nachricht+MAC.');
|
|
const macKey = key.material.slice(info.offset + info.ciphertext.length, info.offset + need);
|
|
const expected = SP.poly1305(SP.concat(info.macInputHeader, info.ciphertext), macKey);
|
|
if (!SP.ctEqual(expected, info.tag))
|
|
throw new Error('Authentifizierung fehlgeschlagen: Bild verändert oder '
|
|
+ 'Schlüssel nicht synchron (Offset-Drift). Nachricht nicht vertrauen.');
|
|
} else {
|
|
warn = ' ⚠︎ altes SPS2-Bild — unauthentifiziert.';
|
|
}
|
|
const plain = SP.otpXor(info.ciphertext, key.material, info.offset);
|
|
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}).` + warn);
|
|
} catch (err) { show(st, 'err', '✗ ' + err.message); }
|
|
};
|
|
|
|
// ---- 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 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);
|
|
}
|
|
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>
|