BEFUND ZUERST, DENN ER WAR ANDERS ALS ERWARTET
Es gab sehr wohl eine Benachrichtigung -- ueber einen Discord-Webhook,
sauber gebaut, bewusst ohne Kundendaten im Text. Sie begann aber mit:
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
if (!url) return;
Und diese Variable ist in .env.example nirgends aufgefuehrt. Eine
Variable, die niemand kennt, wird nicht gesetzt; dann kehrte die
Funktion wortlos zurueck. Kein Fehler, keine Protokollzeile. Es gab
eine Benachrichtigung, die nur im Quelltext existierte -- und niemand
konnte das bemerken, weil "funktioniert" und "kaputt" identisch
aussehen, solange nichts passiert.
WAS JETZT DA IST
Push aufs Handy, ohne Fremdpaket. Der interne Dienst liegt unter
/home/dogiintern mit Rechten 700 -- dort ist kein npm erreichbar, jede
Abhaengigkeit haette dauerhafte Handarbeit bedeutet. Node bringt P-256,
HKDF und AES-128-GCM selbst mit.
Die Schluessel erzeugt der Server beim ersten Start selbst und legt den
privaten Teil verschluesselt in der Datenbank ab (derselbe Weg wie die
PayPal-Zugangsdaten). Damit gibt es keinen Einrichtungsschritt, der
vergessen werden kann.
GEPRUEFT
Gegen RFC 8291 statt gegen ein Bauchgefuehl: alle fuenf Zwischenwerte
aus Anhang A stimmen (ECDH-Geheimnis, PRK_key, IKM, CEK, NONCE), und
die fertige Nachricht ist Byte fuer Byte die aus Abschnitt 5 der Norm.
Bei Kryptographie erzeugt ein Ableitungsfehler keinen Absturz, sondern
Bytes, die genauso zufaellig aussehen wie richtige.
Dazu ein Kettentest mit einem echten Empfaenger, der entschluesselt:
Kopfzeilen, Inhalt, keine Kundendaten in der Meldung, 410 loescht das
Geraet, 500 loescht es NICHT (sonst kostet eine einzelne Stoerung die
Anmeldung). 50 Pruefungen, alle gruen.
DREI ENTSCHEIDUNGEN
1. In der Meldung stehen nur Nummer und Paket. Sie erscheint auf einem
Sperrbildschirm, den auch jemand sieht, der zufaellig danebensteht.
2. Ist der Schluessel unlesbar, wird KEIN neuer erzeugt. Das waere der
bequeme Weg und der schlimmste: Ein neuer oeffentlicher Schluessel
macht schlagartig jede Anmeldung wertlos, ohne dass jemand erfaehrt,
warum nichts mehr ankommt.
3. Die Verwaltung zeigt den Zustand an und hat einen Testknopf. Genau
das fehlte dem Discord-Weg. Bei verweigerter Erlaubnis erscheint
kein Knopf, der nichts bewirkt, sondern der Weg ueber die
Browsereinstellungen.
Das stille "if (!url) return;" ist ersetzt: Jeder Weg wird einzeln
protokolliert -- auch und gerade, wenn er uebersprungen wird.
Co-Authored-By: Claude Opus 5 <[email protected]>
177 lines
8.1 KiB
JavaScript
177 lines
8.1 KiB
JavaScript
/* =====================================================================
|
|
Prueft die Push-Verschluesselung gegen die Norm selbst.
|
|
|
|
Bei Kryptographie hilft "sieht plausibel aus" nichts. Ein Fehler in
|
|
der Schluesselableitung erzeugt keinen Absturz -- er erzeugt Bytes,
|
|
die genauso zufaellig aussehen wie richtige. Auffallen wuerde es erst
|
|
daran, dass auf keinem Handy je eine Meldung ankommt, und die Suche
|
|
danach faengt dann garantiert an der falschen Stelle an.
|
|
|
|
Deshalb wird hier gegen das Rechenbeispiel aus RFC 8291 geprueft --
|
|
Abschnitt 5 fuer die fertige Nachricht, Anhang A fuer die
|
|
Zwischenwerte. Diese Zwischenwerte sind der eigentliche Gewinn: Weicht
|
|
einer ab, weiss man sofort, WELCHER Schritt falsch ist, statt nur zu
|
|
wissen, dass irgendetwas nicht stimmt.
|
|
|
|
Quelle: https://www.rfc-editor.org/rfc/rfc8291.txt
|
|
===================================================================== */
|
|
import crypto from "crypto";
|
|
import {
|
|
b64u, ausB64u, verschluesseln, schluesselpaarErzeugen, vapidKopf,
|
|
} from "./lib/webpush.js";
|
|
|
|
let ok = 0, fehl = 0;
|
|
const pruefe = (name, gut, info) => {
|
|
if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); }
|
|
else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); }
|
|
};
|
|
|
|
/* --- Die Werte aus der Norm, unveraendert ------------------------- */
|
|
const V = {
|
|
klartext: "When I grow up, I want to be a watermelon",
|
|
authSecret: "BTBZMqHH6r4Tts7J_aSIgg",
|
|
uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
|
|
uaPrivate: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
|
|
asPublic: "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8",
|
|
asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw",
|
|
salt: "DGv6ra1nlYgDCS1FRnbzlw",
|
|
// Anhang A
|
|
shared: "kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs",
|
|
prkKey: "Snr3JMxaHVDXHWJn5wdC52WjpCtd2EIEGBykDcZW32k",
|
|
ikm: "S4lYMb_L0FxCeq0WhDx813KgSYqU26kOyzWUdsXYyrg",
|
|
cek: "oIhVW04MRdy2XN9CiKLxTg",
|
|
nonce: "4h_95klXJ5E_qnoN",
|
|
};
|
|
|
|
console.log("1. DIE EINZELNEN ABLEITUNGSSCHRITTE (RFC 8291, Anhang A)");
|
|
|
|
const ecdh = crypto.createECDH("prime256v1");
|
|
ecdh.setPrivateKey(ausB64u(V.asPrivate));
|
|
pruefe("Sitzungsschluessel passt zum privaten Teil",
|
|
b64u(ecdh.getPublicKey()) === V.asPublic, b64u(ecdh.getPublicKey()).slice(0, 24) + "…");
|
|
|
|
const gemeinsam = ecdh.computeSecret(ausB64u(V.uaPublic));
|
|
pruefe("gemeinsames Geheimnis (ECDH)", b64u(gemeinsam) === V.shared, b64u(gemeinsam));
|
|
|
|
const prkKey = crypto.createHmac("sha256", ausB64u(V.authSecret)).update(gemeinsam).digest();
|
|
pruefe("PRK_key", b64u(prkKey) === V.prkKey, b64u(prkKey));
|
|
|
|
const keyInfo = Buffer.concat([
|
|
Buffer.from("WebPush: info\0"), ausB64u(V.uaPublic), ausB64u(V.asPublic),
|
|
]);
|
|
const ikm = Buffer.from(
|
|
crypto.hkdfSync("sha256", gemeinsam, ausB64u(V.authSecret), keyInfo, 32)
|
|
);
|
|
pruefe("IKM", b64u(ikm) === V.ikm, b64u(ikm));
|
|
|
|
const cek = Buffer.from(
|
|
crypto.hkdfSync("sha256", ikm, ausB64u(V.salt), Buffer.from("Content-Encoding: aes128gcm\0"), 16)
|
|
);
|
|
pruefe("CEK (Inhaltsschluessel)", b64u(cek) === V.cek, b64u(cek));
|
|
|
|
const nonce = Buffer.from(
|
|
crypto.hkdfSync("sha256", ikm, ausB64u(V.salt), Buffer.from("Content-Encoding: nonce\0"), 12)
|
|
);
|
|
pruefe("NONCE (Zaehlwert)", b64u(nonce) === V.nonce, b64u(nonce));
|
|
|
|
console.log("\n2. DIE FERTIGE NACHRICHT");
|
|
|
|
const koerper = verschluesseln(V.klartext, V.uaPublic, V.authSecret, {
|
|
salt: ausB64u(V.salt),
|
|
sitzungPrivat: ausB64u(V.asPrivate),
|
|
blockgroesse: 4096,
|
|
});
|
|
|
|
/* Der Kopf ist unverschluesselt und laesst sich Feld fuer Feld
|
|
nachrechnen. Stimmt er, sitzt der Empfaenger nicht vor einer
|
|
Nachricht, deren Aufbau er gar nicht erst lesen kann. */
|
|
pruefe("Salt steht vorn", b64u(koerper.subarray(0, 16)) === V.salt);
|
|
pruefe("Blockgroesse 4096", koerper.readUInt32BE(16) === 4096, String(koerper.readUInt32BE(16)));
|
|
pruefe("Laengenangabe 65", koerper[20] === 65, String(koerper[20]));
|
|
pruefe("Sitzungsschluessel eingebettet", b64u(koerper.subarray(21, 86)) === V.asPublic);
|
|
|
|
/* Und der Inhalt selbst. Der Vergleich laeuft gegen die Zeile aus
|
|
Abschnitt 5 der Norm -- dieselbe Eingabe muss Byte fuer Byte
|
|
dasselbe ergeben. */
|
|
const AUS_DER_NORM =
|
|
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" +
|
|
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT" +
|
|
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN";
|
|
|
|
const meins = b64u(koerper);
|
|
pruefe("Nachricht stimmt mit der Norm ueberein",
|
|
meins === AUS_DER_NORM,
|
|
meins === AUS_DER_NORM ? `${koerper.length} Byte` : `meins ${meins.length} / Norm ${AUS_DER_NORM.length} Zeichen`);
|
|
|
|
if (meins !== AUS_DER_NORM) {
|
|
/* Zeigt, AB WO es auseinanderlaeuft -- das grenzt den Fehler
|
|
zwischen Kopf, Schluessel und Inhalt ein. */
|
|
let i = 0;
|
|
while (i < Math.min(meins.length, AUS_DER_NORM.length) && meins[i] === AUS_DER_NORM[i]) i++;
|
|
console.log(` erste Abweichung an Zeichen ${i} (Kopf endet bei 115)`);
|
|
console.log(` meins: …${meins.slice(Math.max(0, i - 10), i + 20)}`);
|
|
console.log(` Norm : …${AUS_DER_NORM.slice(Math.max(0, i - 10), i + 20)}`);
|
|
}
|
|
|
|
console.log("\n3. RUNDLAUF: laesst sich das Verschluesselte wieder oeffnen?");
|
|
/* Gegenprobe mit frischen Zufallswerten. Der Normvergleich oben prueft
|
|
einen einzigen festen Fall; hier wird geprueft, dass es auch mit
|
|
selbst erzeugten Schluesseln aufgeht -- also im Betrieb. */
|
|
const empfaenger = crypto.createECDH("prime256v1");
|
|
empfaenger.generateKeys();
|
|
const authNeu = crypto.randomBytes(16);
|
|
const text = "Neue Anfrage A-2608-0007 (onepager)";
|
|
|
|
const paket = verschluesseln(text, b64u(empfaenger.getPublicKey()), b64u(authNeu));
|
|
|
|
/* Entschluesseln wie ein Browser es taete. */
|
|
const rSalt = paket.subarray(0, 16);
|
|
const rAsPub = paket.subarray(21, 86);
|
|
const rInhalt = paket.subarray(86);
|
|
const rGemeinsam = empfaenger.computeSecret(rAsPub);
|
|
const rIkm = Buffer.from(crypto.hkdfSync("sha256", rGemeinsam, authNeu,
|
|
Buffer.concat([Buffer.from("WebPush: info\0"), empfaenger.getPublicKey(), rAsPub]), 32));
|
|
const rCek = Buffer.from(crypto.hkdfSync("sha256", rIkm, rSalt,
|
|
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
|
const rNonce = Buffer.from(crypto.hkdfSync("sha256", rIkm, rSalt,
|
|
Buffer.from("Content-Encoding: nonce\0"), 12));
|
|
|
|
const entschluesseler = crypto.createDecipheriv("aes-128-gcm", rCek, rNonce);
|
|
entschluesseler.setAuthTag(rInhalt.subarray(rInhalt.length - 16));
|
|
const klar = Buffer.concat([
|
|
entschluesseler.update(rInhalt.subarray(0, rInhalt.length - 16)),
|
|
entschluesseler.final(),
|
|
]);
|
|
const zurueck = klar.subarray(0, klar.length - 1).toString("utf8");
|
|
pruefe("entschluesselter Text ist der gesendete", zurueck === text, zurueck);
|
|
pruefe("Abschlusszeichen 0x02 sitzt am Ende", klar[klar.length - 1] === 2);
|
|
|
|
console.log("\n4. VAPID-AUSWEIS");
|
|
const paar = schluesselpaarErzeugen();
|
|
pruefe("oeffentlicher Schluessel ist 65 Byte", ausB64u(paar.oeffentlich).length === 65,
|
|
ausB64u(paar.oeffentlich).length + " Byte");
|
|
pruefe("beginnt mit 0x04 (unkomprimierter Punkt)", ausB64u(paar.oeffentlich)[0] === 4);
|
|
|
|
const kopf = vapidKopf("https://fcm.googleapis.com/fcm/send/abc", paar.privat,
|
|
paar.oeffentlich, "mailto:[email protected]", 1000000);
|
|
pruefe("Kopf hat die Form 'vapid t=…, k=…'", /^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/.test(kopf));
|
|
|
|
const [teilA, teilB, teilC] = kopf.slice(8).split(", ")[0].split(".");
|
|
const rumpf = JSON.parse(ausB64u(teilB).toString());
|
|
pruefe("Empfaenger ist der Ursprung des Endpunkts", rumpf.aud === "https://fcm.googleapis.com", rumpf.aud);
|
|
pruefe("laeuft nach 12 Stunden ab", rumpf.exp === 1000000 + 43200, String(rumpf.exp - 1000000) + " Sekunden");
|
|
|
|
/* Die 64 Byte sind der eigentliche Fallstrick: Node liefert ohne
|
|
ausdrueckliche Angabe eine DER-Signatur (70-72 Byte), die jeder
|
|
Push-Dienst wortlos ablehnt. */
|
|
pruefe("Signatur ist 64 Byte roh, nicht DER", ausB64u(teilC).length === 64,
|
|
ausB64u(teilC).length + " Byte");
|
|
|
|
const echt = crypto.verify("sha256", Buffer.from(`${teilA}.${teilB}`),
|
|
{ key: crypto.createPublicKey(crypto.createPrivateKey(paar.privat)), dsaEncoding: "ieee-p1363" },
|
|
ausB64u(teilC));
|
|
pruefe("Signatur ist gueltig", echt);
|
|
|
|
console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`);
|
|
process.exit(fehl ? 1 : 0);
|