Diene hat gemeldet: "Die Benachrichtigungen werden nicht angezeigt,
wenn neue Nachrichten reinkommen. Erst, wenn man die App oeffnet."
Alles Messbare war gruen: Der Service Worker zeigt die Meldung bei
geschlossener App (pruef-push-zu, 13 Pruefungen), jede Anmeldung im
Haus stand auf fehler = 0, der Push-Dienst quittierte mit 201. Nur
den Kopf auf der Leitung hat nie jemand gemessen:
Urgency: normal
Beide Dienste behandeln das ausdruecklich als "darf warten".
Android/FCM haelt solche Meldungen zurueck, solange das Telefon
doest, und stellt sie zu, wenn es aufwacht -- typischerweise beim
Entsperren oder Oeffnen der App. Apple/APNs nennt es "verzoegert,
gebuendelt oder gedrosselt". Das ist Dienes Satz, Wort fuer Wort,
und es stand als Vorgabe im eigenen Quelltext.
zuletzt_ok konnte das nie zeigen: Es beweist, dass der DIENST
angenommen hat, nicht dass das GERAET etwas angezeigt hat.
Bis hierher hing die Dringlichkeit an der Ruheregel (dringend =
regel === "nie"), mit der Begruendung, "darf das nachts stoeren?"
und "darf das warten?" haetten dieselbe Antwort. Haben sie nicht:
Ein Chat um 3 Uhr soll schweigen, um 14 Uhr aber nicht vierzig
Minuten liegen bleiben. Jetzt zwei Felder -- in DERSELBEN Zeile
derselben Artenliste, damit sie nicht auseinanderlaufen koennen.
10 von 19 Arten sind eilig (Anruf, Chat, Erwaehnung, Support, Hilfe,
Termin, Wecker, zweimal Live, Probe). Die anderen neun duerfen
warten -- waere alles eilig, waere nichts mehr eilig.
DIE FALLE BEIM BEHEBEN: dringend steuerte auch die Haltbarkeit
(TTL 150). Ein einfach auf "dringend" gestellter Chat waere nach
150 s verfallen -- wer sein Telefon drei Minuten aus hat, haette die
Nachricht GAR nicht mehr bekommen. Aus "zu spaet" waere "nie"
geworden. Deshalb sind eilig und kurzlebig getrennt; kurzlebig
bleibt genau beim Anruf und der Probe.
Der alte Name kracht jetzt, statt still "normal" zu liefern.
pruef-push-eilig.mjs (neu, 13 Pruefungen): die Entscheidung je Art
unabhaengig notiert statt aus der Artenliste abgelesen, jede Art
muss eine Entscheidung haben, und der Kopf wird durch
benachrichtige() hindurch an einem nachgebauten Push-Dienst
gemessen. Gegenprobe gelaufen: ohne das Feld meldet sie
"chat_nachricht geht mit Urgency: normal hinaus", 4 Fehler.
Die Uhr ist dort eine Eingabe, keine Annahme -- die Zeitzone wird so
gewaehlt, dass der Lauf immer auf 12 Uhr faellt, sonst waere die
Pruefung nachts rot ohne Befund.
pruef-anruf-klingelt 25 -> 26. Dabei aufgefallen: Eine ihrer
Pruefungen war gruen, obwohl die Zeile aus dem Code verschwunden war
-- sie stand nur noch in einem Kommentar, der die alte Fassung
zitiert. Quelltextpruefungen lesen dort jetzt ohne Kommentare.
Co-Authored-By: Claude Opus 5 <[email protected]>
269 lines
11 KiB
JavaScript
269 lines
11 KiB
JavaScript
/* =====================================================================
|
|
workspace-push-krypto.js — Web Push, verschlüsselt und signiert.
|
|
|
|
Der reine Rechenteil: Er nimmt eine Anmeldung (Endpunkt + zwei
|
|
Schlüssel des Browsers) und einen Text, und macht daraus das, was
|
|
der Push-Dienst von Google/Mozilla/Apple entgegennimmt.
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM SELBST UND NICHT `web-push`
|
|
|
|
Der Perfektionsplan nannte die Bibliothek `web-push`. Beim Nachsehen
|
|
sprachen zwei Dinge dagegen:
|
|
|
|
LIZENZ web-push steht unter MPL-2.0. Die Hausregel lautet
|
|
"bevorzugt MIT oder Apache 2.0, die dem Nutzer selbst
|
|
gehört". MPL ist schwaches Copyleft -- benutzbar, aber
|
|
nicht das, was hier vorgegeben ist.
|
|
|
|
UMFANG Sie bringt fünf weitere Pakete mit (asn1.js, http_ece,
|
|
https-proxy-agent, jws, minimist), die ihrerseits welche
|
|
haben. Der Workspace läuft bisher mit DREI Abhängigkeiten
|
|
insgesamt; Anmeldung, Sitzungen und Datenbank kommen
|
|
vollständig aus Node selbst.
|
|
|
|
Node 24 bringt jede benötigte Rechenart mit -- nachgemessen:
|
|
createECDH, hkdfSync, createCipheriv (aes-128-gcm), createSign mit
|
|
ES256 und ieee-p1363-Kodierung. Damit ist der Eigenbau kein
|
|
Selbst-Erfinden von Kryptographie, sondern das Zusammensetzen
|
|
vorhandener, geprüfter Bausteine nach zwei RFCs.
|
|
|
|
---------------------------------------------------------------------
|
|
WAS HIER PASSIERT, IN ZWEI SCHRITTEN
|
|
|
|
1. VERSCHLÜSSELN (RFC 8291, "aes128gcm")
|
|
Der Server erzeugt für JEDE Nachricht ein neues Schlüsselpaar,
|
|
rechnet daraus mit dem öffentlichen Schlüssel des Browsers ein
|
|
gemeinsames Geheimnis (ECDH), leitet daraus über HKDF den
|
|
eigentlichen Schlüssel ab und verschlüsselt damit.
|
|
|
|
Der Push-Dienst selbst sieht dabei NUR Zeichensalat. Weder Google
|
|
noch Apple erfahren, was in der Benachrichtigung steht -- das ist
|
|
der Grund, warum dieser Weg überhaupt vertretbar ist.
|
|
|
|
2. SIGNIEREN (RFC 8292, "VAPID")
|
|
Ein kurzlebiges JWT, signiert mit dem privaten Serverschlüssel.
|
|
Es beweist dem Push-Dienst, dass die Nachricht wirklich von
|
|
dogfather-universe.com kommt.
|
|
|
|
---------------------------------------------------------------------
|
|
GEPRÜFT WIRD GEGEN DIE TESTVEKTOREN DER RFCs, nicht gegen das eigene
|
|
Gefühl. RFC 8291, Anhang A enthält vollständige Beispieldaten samt
|
|
erwartetem Ergebnis. Wenn diese Rechnung sie trifft, ist sie richtig
|
|
-- und wenn sich morgen jemand vertippt, fällt es sofort auf.
|
|
Siehe pruef-push.mjs.
|
|
===================================================================== */
|
|
|
|
import {
|
|
createECDH, hkdfSync, createCipheriv, createSign, randomBytes,
|
|
createPublicKey, createPrivateKey,
|
|
} from "node:crypto";
|
|
|
|
/* ---------- Kleinkram --------------------------------------------------- */
|
|
|
|
/** Base64 ohne Polster und mit URL-tauglichen Zeichen -- die einzige
|
|
* Schreibweise, die im Web-Push-Umfeld vorkommt. */
|
|
export const b64u = (buf) => Buffer.from(buf).toString("base64")
|
|
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
|
|
export const ausB64u = (s) => Buffer.from(
|
|
String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
|
|
/* ---------- 1. Verschlüsseln (RFC 8291) --------------------------------- */
|
|
|
|
/**
|
|
* Verschlüsselt einen Text für genau eine Anmeldung.
|
|
*
|
|
* @param {string} text was ankommen soll (JSON)
|
|
* @param {string} p256dhB64u öffentlicher Schlüssel des Browsers
|
|
* @param {string} authB64u das Zufallsgeheimnis des Browsers
|
|
* @param {Buffer} [salt] nur für die Prüfung gegen den Testvektor
|
|
* @param {object} [eigenes] dito -- sonst wird je Nachricht neu erzeugt
|
|
*/
|
|
export function verschluesseln(text, p256dhB64u, authB64u, salt = null, eigenes = null) {
|
|
const empfaenger = ausB64u(p256dhB64u); // 65 Byte, unkomprimiert
|
|
const auth = ausB64u(authB64u); // 16 Byte
|
|
|
|
if (empfaenger.length !== 65 || empfaenger[0] !== 4) {
|
|
throw new Error("Der Schluessel des Browsers hat nicht die erwartete Form.");
|
|
}
|
|
if (auth.length !== 16) {
|
|
throw new Error("Das Geheimnis des Browsers hat nicht die erwartete Laenge.");
|
|
}
|
|
|
|
/* Ein eigenes Paar JE NACHRICHT. Ein wiederverwendetes waere der
|
|
klassische Fehler an dieser Stelle: Aus zwei Nachrichten mit
|
|
demselben Schluessel und demselben Salz laesst sich der Klartext
|
|
angreifen. */
|
|
const ecdh = createECDH("prime256v1");
|
|
if (eigenes) ecdh.setPrivateKey(eigenes);
|
|
else ecdh.generateKeys();
|
|
const eigenerOeff = ecdh.getPublicKey(); // 65 Byte
|
|
const gemeinsam = ecdh.computeSecret(empfaenger);
|
|
|
|
const salz = salt || randomBytes(16);
|
|
|
|
/* Schritt 1: aus dem gemeinsamen Geheimnis und `auth` wird das
|
|
"pseudo random key". Der Info-Text ist im RFC woertlich
|
|
vorgeschrieben -- ein Tippfehler hier ergibt eine Nachricht, die
|
|
der Browser stumm verwirft. */
|
|
const prkInfo = Buffer.concat([
|
|
Buffer.from("WebPush: info\0"), empfaenger, eigenerOeff,
|
|
]);
|
|
const ikm = Buffer.from(hkdfSync("sha256", gemeinsam, auth, prkInfo, 32));
|
|
|
|
/* Schritt 2: daraus Schluessel (16 Byte) und Startwert (12 Byte). */
|
|
const cek = Buffer.from(hkdfSync("sha256", ikm, salz,
|
|
Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
|
const nonce = Buffer.from(hkdfSync("sha256", ikm, salz,
|
|
Buffer.from("Content-Encoding: nonce\0"), 12));
|
|
|
|
/* Das Polster: Der Klartext bekommt eine 0x02 angehaengt (letzter
|
|
Datensatz). Ohne dieses Byte verwirft der Browser die Nachricht --
|
|
ohne jede Meldung, und man sucht den Fehler im eigenen Code. */
|
|
const klartext = Buffer.concat([Buffer.from(text, "utf8"), Buffer.from([2])]);
|
|
|
|
const c = createCipheriv("aes-128-gcm", cek, nonce);
|
|
const geheim = Buffer.concat([c.update(klartext), c.final(), c.getAuthTag()]);
|
|
|
|
/* Der Kopf des Datensatzes: Salz, Datensatzgroesse, Laenge und
|
|
Inhalt des eigenen oeffentlichen Schluessels. */
|
|
const kopf = Buffer.alloc(21);
|
|
salz.copy(kopf, 0);
|
|
kopf.writeUInt32BE(4096, 16);
|
|
kopf.writeUInt8(eigenerOeff.length, 20);
|
|
|
|
return Buffer.concat([kopf, eigenerOeff, geheim]);
|
|
}
|
|
|
|
/* ---------- 2. Signieren (RFC 8292, VAPID) ------------------------------ */
|
|
|
|
/** Erzeugt ein neues VAPID-Schluesselpaar. Einmal je Server. */
|
|
export function schluesselErzeugen() {
|
|
const ecdh = createECDH("prime256v1");
|
|
ecdh.generateKeys();
|
|
return {
|
|
oeffentlich: b64u(ecdh.getPublicKey()),
|
|
privat: b64u(ecdh.getPrivateKey()),
|
|
};
|
|
}
|
|
|
|
/** Der private Schluessel als PKCS8, damit createSign ihn annimmt.
|
|
* Node kann aus rohen 32 Byte nichts machen -- der Umweg ueber DER
|
|
* ist der uebliche und braucht keine ASN.1-Bibliothek, weil das
|
|
* Geruest fuer P-256 immer gleich ist. */
|
|
function privatAlsSchluessel(privatRoh, oeffentlichRoh) {
|
|
const kopf = Buffer.from("308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b0201010420", "hex");
|
|
const mitte = Buffer.from("a144034200", "hex");
|
|
const der = Buffer.concat([kopf, privatRoh, mitte, oeffentlichRoh]);
|
|
return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
}
|
|
|
|
/**
|
|
* Baut die Kopfzeilen, die der Push-Dienst sehen will.
|
|
*
|
|
* @param {string} endpunkt die Adresse aus der Anmeldung
|
|
* @param {object} paar { oeffentlich, privat } als base64url
|
|
* @param {string} absender "mailto:..." -- wen der Dienst anschreibt,
|
|
* wenn etwas nicht stimmt
|
|
* @param {number} stunden wie lange das JWT gilt
|
|
*/
|
|
export function vapidKopf(endpunkt, paar, absender, stunden = 12) {
|
|
const ziel = new URL(endpunkt);
|
|
const jetzt = Math.floor(Date.now() / 1000);
|
|
|
|
const teil1 = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" }));
|
|
const teil2 = b64u(JSON.stringify({
|
|
aud: `${ziel.protocol}//${ziel.host}`,
|
|
/* Nicht laenger als 24 Stunden -- das schreibt der RFC vor, und
|
|
Firefox lehnt laengere ab. */
|
|
exp: jetzt + Math.min(stunden, 23) * 3600,
|
|
sub: absender,
|
|
}));
|
|
|
|
const schluessel = privatAlsSchluessel(ausB64u(paar.privat), ausB64u(paar.oeffentlich));
|
|
/* `ieee-p1363` ist die rohe Form (r||s, 64 Byte). Die Voreinstellung
|
|
waere DER -- die nimmt kein Push-Dienst an. */
|
|
const sig = createSign("SHA256")
|
|
.update(`${teil1}.${teil2}`)
|
|
.sign({ key: schluessel, dsaEncoding: "ieee-p1363" });
|
|
|
|
return {
|
|
Authorization: `vapid t=${teil1}.${teil2}.${b64u(sig)}, k=${paar.oeffentlich}`,
|
|
"Content-Encoding": "aes128gcm",
|
|
"Content-Type": "application/octet-stream",
|
|
};
|
|
}
|
|
|
|
/* ---------- 3. Verschicken ---------------------------------------------- */
|
|
|
|
/**
|
|
* Schickt eine Nachricht an eine Anmeldung.
|
|
*
|
|
* Gibt IMMER ein Ergebnis zurueck statt zu werfen -- der Aufrufer
|
|
* verschickt an viele Menschen, und eine abgelaufene Anmeldung darf den
|
|
* ganzen Lauf nicht abbrechen.
|
|
*
|
|
* `weg: true` heisst: Diese Anmeldung ist tot (Browser deinstalliert,
|
|
* Berechtigung entzogen) und gehoert geloescht. Der Push-Dienst sagt
|
|
* das mit 404 oder 410.
|
|
*/
|
|
/* DRINGLICHKEIT GEHT MIT (19.09.2026).
|
|
*
|
|
* Hier stand fest `Urgency: "normal"` -- fuer JEDE Benachrichtigung,
|
|
* auch fuer einen Anruf.
|
|
*
|
|
* Auf Android entscheidet dieser Kopf, ob der Push-Dienst sofort
|
|
* zustellt oder bis zum naechsten Aufwachen des Geraets wartet. Im
|
|
* Stromsparmodus koennen daraus Minuten werden. Ein Anruf klingelt
|
|
* 120 Sekunden -- eine Benachrichtigung, die danach ankommt, ist ein
|
|
* verpasster Anruf mit Zeitstempel.
|
|
*
|
|
* Und die Haltbarkeit gehoert dazu: Ein Anruf von vor einer Stunde
|
|
* soll NICHT nachtraeglich aufploppen. Deshalb bekommt er eine kurze
|
|
* TTL -- ist er vorbei, verfaellt auch die Meldung. */
|
|
export async function schicken(anmeldung, text, paar, absender,
|
|
{ ttl = 3600, eilig = false, kurzlebig = false,
|
|
...rest } = {}) {
|
|
/* DER ALTE NAME MUSS KRACHEN, NICHT DURCHRUTSCHEN (03.10.2026).
|
|
|
|
Bis heute hiess beides zusammen `dringend`. Wer den alten Namen
|
|
weitergibt, bekaeme sonst klaglos `Urgency: normal` -- also genau
|
|
den Fehler zurueck, der gerade behoben wurde, und niemand saehe
|
|
es. Ein unbekannter Schluessel ist hier deshalb ein Absturz. */
|
|
const unbekannt = Object.keys(rest);
|
|
if (unbekannt.length) {
|
|
throw new Error(
|
|
`schicken(): unbekannte Angabe ${unbekannt.join(", ")} -- ` +
|
|
`aus "dringend" wurden "eilig" (weckt das Geraet) und ` +
|
|
`"kurzlebig" (verfaellt nach 150 s).`);
|
|
}
|
|
try {
|
|
const koerper = verschluesseln(text, anmeldung.p256dh, anmeldung.auth);
|
|
const kopf = vapidKopf(anmeldung.endpunkt, paar, absender);
|
|
|
|
const a = await fetch(anmeldung.endpunkt, {
|
|
method: "POST",
|
|
headers: {
|
|
...kopf,
|
|
/* Haltbarkeit und Dringlichkeit sind zwei Fragen -- siehe
|
|
`eiligRegel` in workspace-push.js. */
|
|
TTL: String(kurzlebig ? Math.min(ttl, 150) : ttl),
|
|
Urgency: eilig ? "high" : "normal",
|
|
},
|
|
body: koerper,
|
|
/* Ein haengender Push-Dienst darf den Lauf nicht aufhalten. */
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
|
|
if (a.status === 404 || a.status === 410) return { ok: false, weg: true, status: a.status };
|
|
if (!a.ok) {
|
|
const grund = await a.text().catch(() => "");
|
|
return { ok: false, weg: false, status: a.status, grund: grund.slice(0, 200) };
|
|
}
|
|
return { ok: true, status: a.status };
|
|
} catch (fehler) {
|
|
return { ok: false, weg: false, status: 0, grund: String(fehler?.message || fehler).slice(0, 200) };
|
|
}
|
|
}
|