SPS3: Poly1305-Einmal-MAC + Public-Ready README/LICENSE

Authentifizierung für CLI und Web:
- Poly1305 (RFC 8439) pur in Python (bigint) und JS (BigInt), keine neue Dependency
- MAC-Schluessel pro Nachricht frisch aus dem Pad (len+32), encrypt-then-MAC
  ueber Header + Ciphertext; reveal lehnt manipulierte/desynchrone Bilder hart ab
- Stego-Format SPS2 -> SPS3 (16-B-Tag im Header); alte SPS2-Bilder werden noch
  gelesen, aber als unauthentifiziert markiert
- Service-Worker-Cache auf v3 gebumpt
- Interop-Runner (web/_interop_run.sh): RFC-Vektor cross-impl, beide Richtungen,
  Tamper-Erkennung

README fuer Public ueberarbeitet (ehrliches Bedrohungsmodell, Voraussetzungen,
Projektstruktur, Tests) + MIT-LICENSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-07-15 10:46:04 +00:00
parent dac9550b46
commit 7569cd3fab
8 changed files with 406 additions and 88 deletions

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env python3
"""Test-Helfer: PNG <-> rohes RGB-Byte-Array (nur für den Interop-Test)."""
import os
import sys
from PIL import Image
if sys.argv[1] == "png2rgb": # png2rgb <in.png> <out.bin> -> stdout: "W H"
if sys.argv[1] == "mkcarrier": # mkcarrier <out.png> <W> <H> -> Rausch-Trägerbild
w, h = int(sys.argv[3]), int(sys.argv[4])
img = Image.frombytes("RGB", (w, h), os.urandom(w * h * 3))
img.save(sys.argv[2], "PNG")
print("ok")
elif 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:

44
web/_interop_run.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Reproduzierbarer Interop-Test CLI <-> Web-Core (SPS3 / Poly1305).
# Erzeugt frische Test-Schlüssel, fährt beide Senderichtungen durch und prüft,
# dass CLI und Browser-Core byte-genau dasselbe Format sprechen — inkl. MAC.
set -euo pipefail
cd "$(dirname "$0")/.." # Repo-Wurzel
PY=${PYTHON:-python3}
echo "== Setup: frische, richtungsgetrennte Schlüssel =="
$PY schattenpost.py genkey -o t2/rel -s 100000 --force >/dev/null
$PY web/_interop_rawrgb.py mkcarrier web/_carrier.png 200 200 >/dev/null
echo "== A) CLI verschlüsselt+authentifiziert -> Bild =="
$PY schattenpost.py hide -t "MsgA vom CLI 🕵️" \
-k t2/rel_meine_seite/send.key -c web/_carrier.png -o web/_a.png >/dev/null
$PY web/_interop_rawrgb.py png2rgb web/_a.png web/_a_rgb.bin >/dev/null
echo "== B) Trägerbild als rohes RGB für den Web-Core bereitstellen =="
$PY web/_interop_rawrgb.py png2rgb web/_carrier.png web/_b_rgb.bin > web/_b_dims.txt
echo "== Web-Core: A verifizieren, B einbetten =="
node web/_interop_test.mjs
echo "== B) Web-Ausgabe zurück nach PNG, vom CLI aufdecken+prüfen =="
read -r W H < web/_b_dims.txt
$PY web/_interop_rawrgb.py rgb2png web/_b_out_rgb.bin "$W" "$H" web/_b_stego.png >/dev/null
OUT=$($PY schattenpost.py reveal -s web/_b_stego.png -k t2/rel_meine_seite/recv.key)
echo " CLI reveal: $OUT"
if [ "$OUT" = "MsgB aus dem Browser-Core 🌐" ]; then
echo " ✓ B round-trip (Web→CLI) inkl. MAC-Prüfung ok"
else
echo " ✗ B mismatch"; exit 1
fi
echo "== Poly1305 Python-Seite gegen RFC-8439-Vektor =="
$PY - <<'PYEOF'
import schattenpost as sp
key = bytes.fromhex('85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b')
tag = sp.poly1305_mac(b'Cryptographic Forum Research Group', key)
assert tag.hex() == 'a8061dc1305136c6c22b8baf0c0127a9', tag.hex()
print(' ✓ Python-Poly1305 matcht RFC-Vektor')
PYEOF
echo "== Alles grün =="

View File

@@ -1,5 +1,6 @@
// 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).
// Interop-Test v3: lädt EXAKT den Core aus der ausgelieferten index.html und
// prüft Byte-Kompatibilität mit dem Python-CLI (richtungsgetrennte Schlüssel,
// SPS3 mit Poly1305-Einmal-MAC). Orchestriert von _interop_run.sh.
import { readFileSync, writeFileSync } from 'node:fs';
const U = (p) => new Uint8Array(readFileSync(new URL(p, import.meta.url)));
@@ -10,31 +11,54 @@ const SP = globalThis.SP;
let fails = 0;
const ok = (c, m) => { console.log((c ? ' ✓ ' : ' ✗ ') + m); if (!c) fails++; };
const hex = (s) => new Uint8Array(s.match(/../g).map((h) => parseInt(h, 16)));
// A: CLI (send.key) -> Web-Core (recv.key)
// 0) Poly1305 gegen den RFC-8439-Testvektor (§2.5.2) — verankert beide Impls.
{
console.log('0) Poly1305 RFC 8439');
const key = hex('85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b');
const msg = new TextEncoder().encode('Cryptographic Forum Research Group');
const tag = SP.poly1305(msg, key);
ok([...tag].map((b) => b.toString(16).padStart(2, '0')).join('')
=== 'a8061dc1305136c6c22b8baf0c0127a9', 'Tag matcht RFC-Vektor');
}
// A: CLI (send.key, SPS3) -> Web-Core (recv.key): extrahieren, MAC prüfen, entschlüsseln
{
const rgb = U('./_a_rgb.bin');
const recv = SP.parseKey(U('../t2/rel_partner_seite/recv.key'));
const info = SP.extract(rgb);
console.log('A) CLI → Web');
ok(info.version === SP.STEGO_VERSION, 'Container ist SPS3');
ok(SP.sameStream(info.streamId, recv.streamId), 'stream_id passt zum recv.key');
const macKey = recv.material.slice(info.offset + info.ciphertext.length,
info.offset + info.ciphertext.length + SP.MAC_KEY_LEN);
const expected = SP.poly1305(SP.concat(info.macInputHeader, info.ciphertext), macKey);
ok(SP.ctEqual(expected, info.tag), 'MAC verifiziert (CLI-Tag == Web-Tag)');
const plain = SP.otpXor(info.ciphertext, recv.material, info.offset);
const text = new TextDecoder().decode(plain);
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));
// Manipulation: ein Ciphertext-Bit kippen -> MAC muss anschlagen.
const bad = info.ciphertext.slice(); bad[0] ^= 1;
const badTag = SP.poly1305(SP.concat(info.macInputHeader, bad), macKey);
ok(!SP.ctEqual(badTag, info.tag), 'gekipptes Bit wird vom MAC erkannt');
}
// B: Web-Core (send.key) -> CLI (recv.key)
// B: Web-Core (send.key, SPS3) -> CLI (recv.key)
{
const rgb = U('./_b_rgb.bin');
const send = SP.parseKey(U('../t2/rel_partner_seite/send.key'));
const msg = 'MsgB aus dem Browser-Core 🌐';
console.log('B) Web → CLI');
ok(send.role === SP.ROLE_SEND, 'partner send.key hat Rolle SEND');
const plain = new TextEncoder().encode(msg);
const ct = SP.otpXor(plain, send.material, send.sendOffset);
const payload = SP.buildStego(send.streamId, send.sendOffset, ct);
const macKey = send.material.slice(send.sendOffset + plain.length,
send.sendOffset + plain.length + SP.MAC_KEY_LEN);
const payload = SP.buildStego(send.streamId, send.sendOffset, ct, macKey);
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 ${send.sendOffset}) → _b_out_rgb.bin`);
}

View File

@@ -140,11 +140,18 @@ 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;
const STEGO_MAGIC = enc('SPS2'), STEGO_VERSION = 2, STEGO_HEADER_LEN = 25;
// 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) {
@@ -156,6 +163,26 @@ const SP = (function () {
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.');
@@ -179,14 +206,19 @@ const SP = (function () {
return out;
}
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);
// 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;
}
@@ -210,21 +242,32 @@ const SP = (function () {
}
function extract(rgb) {
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);
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);
const full = readBytesAt(rgb, STEGO_HEADER_LEN + length);
return { streamId, offset, ciphertext: full.slice(STEGO_HEADER_LEN) };
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,
otpXor, parseKey, buildKey, buildStego, embed, extract, sameStream };
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;
@@ -288,8 +331,14 @@ if (typeof document !== 'undefined') (function () {
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 payload = SP.buildStego(key.streamId, key.sendOffset, ct);
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);
@@ -301,12 +350,12 @@ if (typeof document !== 'undefined') (function () {
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 fortgeschrieben)
const nextOff = key.sendOffset + plain.length;
// 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. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`);
show(st, 'ok', `Versteckt + authentifiziert. ${plain.length} B ab Offset ${key.sendOffset}. Neuer Offset: ${nextOff}.`);
}, 'image/png');
} catch (err) { show(st, 'err', '✗ ' + err.message); }
};
@@ -325,10 +374,23 @@ if (typeof document !== 'undefined') (function () {
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}).`);
show(st, 'ok', `Entschlüsselt (${plain.length} B, Offset ${info.offset}).` + warn);
} catch (err) { show(st, 'err', '✗ ' + err.message); }
};

View File

@@ -1,7 +1,7 @@
/* 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 CACHE = 'schattenpost-v3';
const SHELL = [
'./',
'./index.html',