Benachrichtigung bei neuer Anfrage: Push aufs Handy
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]>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
/* =====================================================================
|
||||
webdesign-push.js — Geraete verwalten und Meldungen verschicken
|
||||
|
||||
DIE SCHLUESSEL ERZEUGT DER SERVER SELBST
|
||||
|
||||
Ein Push-Absender braucht ein dauerhaftes Schluesselpaar. Ueblich
|
||||
waere, es von Hand zu erzeugen und in die .env zu schreiben.
|
||||
|
||||
Das geht hier nicht: Der Dienst liegt unter /home/dogiintern mit
|
||||
Rechten 700. Also erzeugt der Server das Paar beim ersten Start
|
||||
selbst und legt es in der Datenbank ab -- den privaten Teil
|
||||
verschluesselt, ueber denselben Weg wie die PayPal-Zugangsdaten.
|
||||
|
||||
Das ist nicht nur ein Notbehelf, sondern besser: Es gibt keinen
|
||||
Einrichtungsschritt, der vergessen werden kann, und der private
|
||||
Schluessel steht nie in einer Datei, die jemand versehentlich kopiert.
|
||||
|
||||
WICHTIG: Das Paar darf sich nicht aendern. Wechselt der oeffentliche
|
||||
Schluessel, sind alle bestehenden Anmeldungen wertlos -- jedes Geraet
|
||||
muesste sich neu anmelden, ohne dass jemand merkt, warum ploetzlich
|
||||
nichts mehr ankommt. Deshalb wird nur erzeugt, wenn nichts da ist.
|
||||
|
||||
WAS IN EINER MELDUNG STEHT
|
||||
|
||||
Nur die Anfragenummer und das Paket -- keine Namen, keine
|
||||
E-Mail-Adressen. Eine Push-Meldung erscheint auf einem
|
||||
Sperrbildschirm, den auch jemand sieht, der nur zufaellig daneben
|
||||
steht. Wer die Anfrage lesen will, oeffnet die Verwaltung.
|
||||
|
||||
Der Push-Dienst selbst kann ohnehin nichts mitlesen: Der Inhalt ist
|
||||
fuer das Zielgeraet verschluesselt (siehe lib/webpush.js).
|
||||
===================================================================== */
|
||||
|
||||
import crypto from "crypto";
|
||||
import { db } from "../db.js";
|
||||
import { encryptCode, decryptCode } from "./crypto.js";
|
||||
import { schluesselpaarErzeugen, senden } from "./webpush.js";
|
||||
|
||||
const SCHLUESSEL_OEFFENTLICH = "wd_push_vapid_oeffentlich";
|
||||
const SCHLUESSEL_PRIVAT = "wd_push_vapid_privat";
|
||||
|
||||
let vapid = null;
|
||||
|
||||
function einstellungLesen(schluessel) {
|
||||
const zeile = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(schluessel);
|
||||
return zeile ? zeile.value : null;
|
||||
}
|
||||
|
||||
function einstellungSchreiben(schluessel, wert) {
|
||||
db.prepare(
|
||||
`INSERT INTO app_settings (key, value, updated_at) VALUES (?,?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
||||
).run(schluessel, wert, new Date().toISOString());
|
||||
}
|
||||
|
||||
/* Beim Start aufzurufen. Erzeugt das Paar nur beim allerersten Mal. */
|
||||
export async function vapidSicherstellen() {
|
||||
const oeffentlich = einstellungLesen(SCHLUESSEL_OEFFENTLICH);
|
||||
const privatRoh = einstellungLesen(SCHLUESSEL_PRIVAT);
|
||||
|
||||
if (oeffentlich && privatRoh) {
|
||||
try {
|
||||
vapid = {
|
||||
oeffentlich,
|
||||
privat: await decryptCode(privatRoh),
|
||||
kontakt: kontakt(),
|
||||
};
|
||||
return { zustand: "vorhanden" };
|
||||
} catch (e) {
|
||||
/* Der ENCRYPTION_KEY wurde gewechselt. Neu erzeugen waere hier
|
||||
GENAU FALSCH: Es wuerde alle Anmeldungen unbrauchbar machen,
|
||||
ohne dass jemand erfaehrt, warum keine Meldungen mehr kommen.
|
||||
Lieber sichtbar nichts tun. */
|
||||
console.error("[push] VAPID-Schluessel nicht entschluesselbar — Push ist aus. "
|
||||
+ "Ursache ist fast immer ein gewechselter ENCRYPTION_KEY.");
|
||||
return { zustand: "unlesbar" };
|
||||
}
|
||||
}
|
||||
|
||||
const paar = schluesselpaarErzeugen();
|
||||
einstellungSchreiben(SCHLUESSEL_OEFFENTLICH, paar.oeffentlich);
|
||||
einstellungSchreiben(SCHLUESSEL_PRIVAT, await encryptCode(paar.privat));
|
||||
vapid = { oeffentlich: paar.oeffentlich, privat: paar.privat, kontakt: kontakt() };
|
||||
console.log("[push] Neues VAPID-Schluesselpaar erzeugt und gespeichert.");
|
||||
return { zustand: "neu" };
|
||||
}
|
||||
|
||||
/* Die Norm verlangt eine Kontaktmoeglichkeit, damit ein Push-Dienst bei
|
||||
Problemen jemanden erreichen kann. */
|
||||
function kontakt() {
|
||||
return process.env.PUSH_KONTAKT || "mailto:[email protected]";
|
||||
}
|
||||
|
||||
export function istBereit() { return !!vapid; }
|
||||
export function oeffentlicherSchluessel() { return vapid ? vapid.oeffentlich : null; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
GERAETE
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
export function geraetAnmelden(abo, benutzerId, bezeichnung) {
|
||||
if (!abo || !abo.endpoint || !abo.keys || !abo.keys.p256dh || !abo.keys.auth) {
|
||||
throw new Error("Anmeldung unvollstaendig.");
|
||||
}
|
||||
const jetzt = new Date().toISOString();
|
||||
|
||||
/* Bei erneuter Anmeldung desselben Geraets den vorhandenen Eintrag
|
||||
auffrischen statt einen zweiten anzulegen -- sonst kaeme jede
|
||||
Meldung mehrfach. Der Fehlerzaehler wird dabei zurueckgesetzt: Wer
|
||||
sich gerade erfolgreich anmeldet, ist offensichtlich erreichbar. */
|
||||
db.prepare(
|
||||
`INSERT INTO wd_push_geraete
|
||||
(id, endpunkt, p256dh, auth, benutzer_id, bezeichnung, erstellt_am, fehler_zaehler)
|
||||
VALUES (?,?,?,?,?,?,?,0)
|
||||
ON CONFLICT(endpunkt) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
benutzer_id = excluded.benutzer_id,
|
||||
bezeichnung = excluded.bezeichnung,
|
||||
fehler_zaehler = 0,
|
||||
letzter_fehler = NULL`
|
||||
).run(
|
||||
crypto.randomUUID(), String(abo.endpoint), String(abo.keys.p256dh),
|
||||
String(abo.keys.auth), benutzerId || null,
|
||||
String(bezeichnung || "").slice(0, 80) || null, jetzt
|
||||
);
|
||||
|
||||
return { angemeldet: true };
|
||||
}
|
||||
|
||||
export function geraetAbmelden(endpunkt) {
|
||||
const r = db.prepare(`DELETE FROM wd_push_geraete WHERE endpunkt = ?`).run(String(endpunkt));
|
||||
return { abgemeldet: r.changes > 0 };
|
||||
}
|
||||
|
||||
/* Fuer die Anzeige. Die volle Adresse wird NICHT herausgegeben -- sie
|
||||
nuetzt in der Oberflaeche nichts und gehoert nicht in ein Protokoll
|
||||
oder einen Screenshot. Zum Abmelden genuegt die Kennung. */
|
||||
export function geraeteListe(benutzerId) {
|
||||
const zeilen = benutzerId
|
||||
? db.prepare(`SELECT * FROM wd_push_geraete WHERE benutzer_id = ? ORDER BY erstellt_am DESC`).all(benutzerId)
|
||||
: db.prepare(`SELECT * FROM wd_push_geraete ORDER BY erstellt_am DESC`).all();
|
||||
|
||||
return zeilen.map((z) => ({
|
||||
id: z.id,
|
||||
bezeichnung: z.bezeichnung || "Unbenanntes Geraet",
|
||||
dienst: dienstName(z.endpunkt),
|
||||
erstellt: z.erstellt_am,
|
||||
zuletztOk: z.zuletzt_ok_am,
|
||||
fehler: z.fehler_zaehler,
|
||||
letzterFehler: z.letzter_fehler,
|
||||
}));
|
||||
}
|
||||
|
||||
/* "Google", "Mozilla", "Microsoft" -- hilft beim Wiedererkennen, ohne
|
||||
die Adresse preiszugeben. */
|
||||
function dienstName(endpunkt) {
|
||||
try {
|
||||
const h = new URL(endpunkt).hostname;
|
||||
if (h.includes("google")) return "Google (Chrome/Android)";
|
||||
if (h.includes("mozilla")) return "Mozilla (Firefox)";
|
||||
if (h.includes("microsoft") || h.includes("windows")) return "Microsoft (Edge)";
|
||||
if (h.includes("apple") || h.includes("icloud")) return "Apple (Safari/iPhone)";
|
||||
return h;
|
||||
} catch { return "unbekannt"; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
SENDEN
|
||||
--------------------------------------------------------------------- */
|
||||
|
||||
export async function meldeAllen(inhalt) {
|
||||
if (!vapid) return { gesendet: 0, grund: "Push ist nicht eingerichtet." };
|
||||
|
||||
const geraete = db.prepare(`SELECT * FROM wd_push_geraete`).all();
|
||||
if (!geraete.length) return { gesendet: 0, grund: "Kein Geraet angemeldet." };
|
||||
|
||||
const nachricht = JSON.stringify(inhalt);
|
||||
const jetzt = new Date().toISOString();
|
||||
let gesendet = 0, entfernt = 0, fehler = 0;
|
||||
|
||||
/* Alle gleichzeitig. Bei einem halben Dutzend Geraeten faellt das
|
||||
kaum ins Gewicht -- nacheinander waere es aber die Summe aller
|
||||
Zeitueberschreitungen, und ein einziges haengendes Geraet wuerde
|
||||
die Meldung fuer alle anderen verzoegern. */
|
||||
const ergebnisse = await Promise.allSettled(
|
||||
geraete.map((g) => senden(
|
||||
{ endpunkt: g.endpunkt, p256dh: g.p256dh, auth: g.auth },
|
||||
nachricht, vapid
|
||||
))
|
||||
);
|
||||
|
||||
ergebnisse.forEach((e, i) => {
|
||||
const g = geraete[i];
|
||||
if (e.status !== "fulfilled") {
|
||||
fehler++;
|
||||
merkeFehler(g.id, String(e.reason?.message || e.reason).slice(0, 200));
|
||||
return;
|
||||
}
|
||||
if (e.value.zustand === "ok") {
|
||||
gesendet++;
|
||||
db.prepare(`UPDATE wd_push_geraete SET zuletzt_ok_am = ?, fehler_zaehler = 0, letzter_fehler = NULL WHERE id = ?`)
|
||||
.run(jetzt, g.id);
|
||||
} else if (e.value.zustand === "abgemeldet") {
|
||||
/* Endgueltig weg. Aufraeumen, sonst waechst die Liste mit
|
||||
Karteileichen und jede Meldung dauert laenger. */
|
||||
entfernt++;
|
||||
db.prepare(`DELETE FROM wd_push_geraete WHERE id = ?`).run(g.id);
|
||||
} else {
|
||||
fehler++;
|
||||
merkeFehler(g.id, `HTTP ${e.value.status}: ${e.value.text || ""}`.slice(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
return { gesendet, entfernt, fehler };
|
||||
}
|
||||
|
||||
function merkeFehler(id, text) {
|
||||
db.prepare(`UPDATE wd_push_geraete SET fehler_zaehler = fehler_zaehler + 1, letzter_fehler = ? WHERE id = ?`)
|
||||
.run(text, id);
|
||||
}
|
||||
|
||||
/* Der Stand fuer die Anzeige in der Verwaltung. Beantwortet die Frage,
|
||||
die man tatsaechlich hat: Wuerde ich es mitbekommen, wenn jetzt eine
|
||||
Anfrage kaeme? */
|
||||
export function stand() {
|
||||
const anzahl = db.prepare(`SELECT count(*) AS n FROM wd_push_geraete`).get().n;
|
||||
const mitFehlern = db.prepare(`SELECT count(*) AS n FROM wd_push_geraete WHERE fehler_zaehler > 0`).get().n;
|
||||
return {
|
||||
eingerichtet: !!vapid,
|
||||
oeffentlicherSchluessel: vapid ? vapid.oeffentlich : null,
|
||||
geraete: anzahl,
|
||||
geraeteMitFehlern: mitFehlern,
|
||||
/* Die eine Zeile, auf die es ankommt. */
|
||||
wirktMeldung: !!vapid && anzahl > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =====================================================================
|
||||
webpush.js — Push-Nachrichten aufs Handy, ohne Fremdpaket
|
||||
|
||||
WARUM SELBST GEBAUT UND NICHT "npm install web-push"
|
||||
|
||||
Der interne Dienst liegt unter /home/dogiintern und steht auf 700.
|
||||
Dort komme ich weder an npm noch an die .env. Jede neue Abhaengigkeit
|
||||
haette also bedeutet: Filipe muss bei jedem Schritt mitmachen, und
|
||||
jede spaetere Aenderung wieder.
|
||||
|
||||
Node bringt alles Noetige mit -- P-256, HKDF und AES-128-GCM sind
|
||||
eingebaut. Auf dem Server nachgeprueft (Node 24). Also kein fremder
|
||||
Code, keine Lieferkette, keine Version, die irgendwann nicht mehr
|
||||
gepflegt wird.
|
||||
|
||||
WIE PUSH FUNKTIONIERT (kurz)
|
||||
|
||||
Der Browser meldet sich beim Push-Dienst seines Herstellers an und
|
||||
bekommt eine Adresse. Diese Adresse plus zwei Schluessel schickt er
|
||||
uns. Wollen wir etwas senden, verschluesseln wir die Nachricht fuer
|
||||
genau dieses Geraet und legen sie bei der Adresse ab.
|
||||
|
||||
Entscheidend: Der Push-Dienst leitet nur weiter. Er kann den Inhalt
|
||||
NICHT lesen -- die Nachricht ist fuer den Browser des Empfaengers
|
||||
verschluesselt, nicht fuer ihn. Deshalb ist es vertretbar, hier
|
||||
ueberhaupt etwas zu senden, was mit Kundendaten zu tun hat.
|
||||
|
||||
ZWEI NORMEN
|
||||
|
||||
RFC 8291 regelt die Verschluesselung, RFC 8292 den Nachweis, dass die
|
||||
Nachricht wirklich von uns kommt (VAPID). Beides ist hier umgesetzt.
|
||||
|
||||
GEPRUEFT GEGEN DIE NORM
|
||||
|
||||
Die Verschluesselung wird nicht nur "sieht richtig aus" getestet,
|
||||
sondern gegen das Rechenbeispiel in RFC 8291, Abschnitt 5: dieselben
|
||||
Eingaben muessen Zeichen fuer Zeichen dasselbe Ergebnis liefern.
|
||||
Dafuer lassen sich Salt und Sitzungsschluessel von aussen vorgeben --
|
||||
im Betrieb werden sie immer zufaellig erzeugt.
|
||||
===================================================================== */
|
||||
|
||||
import crypto from "crypto";
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Base64 in der URL-Variante. Push benutzt durchgehend diese Form:
|
||||
ohne "=", und mit - und _ statt + und /.
|
||||
--------------------------------------------------------------------- */
|
||||
export function b64u(buf) {
|
||||
return Buffer.from(buf).toString("base64")
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function ausB64u(s) {
|
||||
return Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VAPID-SCHLUESSELPAAR
|
||||
|
||||
Der oeffentliche Teil darf jeder sehen -- er steht sogar im Browser.
|
||||
Der private Teil ist das Geheimnis: Wer ihn hat, kann in unserem Namen
|
||||
Push-Nachrichten schicken.
|
||||
|
||||
Der oeffentliche Schluessel wird als "unkomprimierter Punkt" gebraucht:
|
||||
ein 0x04 gefolgt von X und Y, zusammen 65 Byte. Node liefert ihn als
|
||||
JWK mit getrennten X- und Y-Werten, deshalb hier zusammengesetzt.
|
||||
--------------------------------------------------------------------- */
|
||||
export function schluesselpaarErzeugen() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
|
||||
namedCurve: "prime256v1",
|
||||
});
|
||||
const jwk = publicKey.export({ format: "jwk" });
|
||||
const roh = Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
ausB64u(jwk.x),
|
||||
ausB64u(jwk.y),
|
||||
]);
|
||||
return {
|
||||
oeffentlich: b64u(roh),
|
||||
privat: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VAPID-NACHWEIS
|
||||
|
||||
Ein kurzlebiger, signierter Ausweis. Er sagt dem Push-Dienst: Diese
|
||||
Nachricht kommt von dem, dem dieser oeffentliche Schluessel gehoert.
|
||||
|
||||
FALLSTRICK: Node liefert ECDSA-Signaturen standardmaessig im
|
||||
DER-Format (mit Laengenangaben und fuehrenden Nullen). JWT verlangt
|
||||
aber die nackten 64 Byte -- r direkt gefolgt von s. Ohne
|
||||
"dsaEncoding: ieee-p1363" wird die Signatur abgelehnt, und der Fehler
|
||||
lautet nur "invalid JWT", was in die voellig falsche Richtung zeigt.
|
||||
--------------------------------------------------------------------- */
|
||||
export function vapidKopf(endpunkt, privatPem, oeffentlich, kontakt, jetztSek) {
|
||||
const url = new URL(endpunkt);
|
||||
const jetzt = jetztSek || Math.floor(Date.now() / 1000);
|
||||
|
||||
const kopf = b64u(JSON.stringify({ typ: "JWT", alg: "ES256" }));
|
||||
const rumpf = b64u(JSON.stringify({
|
||||
aud: url.origin,
|
||||
/* Zwoelf Stunden. Die Norm erlaubt bis 24, aber kuerzer ist besser:
|
||||
Faengt jemand den Ausweis ab, ist er schneller wertlos. */
|
||||
exp: jetzt + 12 * 60 * 60,
|
||||
sub: kontakt,
|
||||
}));
|
||||
|
||||
const daten = Buffer.from(`${kopf}.${rumpf}`);
|
||||
const sig = crypto.sign("sha256", daten, {
|
||||
key: crypto.createPrivateKey(privatPem),
|
||||
dsaEncoding: "ieee-p1363",
|
||||
});
|
||||
|
||||
return `vapid t=${kopf}.${rumpf}.${b64u(sig)}, k=${oeffentlich}`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VERSCHLUESSELUNG NACH RFC 8291
|
||||
|
||||
Ablauf:
|
||||
1. Ein Sitzungsschluesselpaar erzeugen (gilt nur fuer diese eine
|
||||
Nachricht).
|
||||
2. Daraus und aus dem oeffentlichen Schluessel des Geraets ein
|
||||
gemeinsames Geheimnis rechnen (ECDH).
|
||||
3. Mit dem Authentifizierungsgeheimnis des Geraets zu einem
|
||||
Grundschluessel verruehren (HKDF).
|
||||
4. Daraus Inhaltsschluessel und Zaehlwert ableiten.
|
||||
5. Verschluesseln, und alles zusammensetzen, was der Empfaenger zum
|
||||
Entschluesseln braucht.
|
||||
|
||||
Der zusammengesetzte Koerper sieht so aus:
|
||||
Salt (16) | Blockgroesse (4) | Laenge (1) | Sitzungsschluessel (65) | Inhalt
|
||||
--------------------------------------------------------------------- */
|
||||
export function verschluesseln(nachricht, p256dh, auth, opt = {}) {
|
||||
const empfaenger = ausB64u(p256dh);
|
||||
const geheimnis = ausB64u(auth);
|
||||
|
||||
if (empfaenger.length !== 65 || empfaenger[0] !== 4) {
|
||||
throw new Error("Geraeteschluessel hat nicht die erwartete Form.");
|
||||
}
|
||||
if (geheimnis.length !== 16) {
|
||||
throw new Error("Authentifizierungsgeheimnis muss 16 Byte lang sein.");
|
||||
}
|
||||
|
||||
/* Im Betrieb immer zufaellig. Vorgebbar nur, damit sich das
|
||||
Rechenbeispiel der Norm nachrechnen laesst. */
|
||||
const salt = opt.salt ? Buffer.from(opt.salt) : crypto.randomBytes(16);
|
||||
|
||||
const ecdh = crypto.createECDH("prime256v1");
|
||||
if (opt.sitzungPrivat) ecdh.setPrivateKey(Buffer.from(opt.sitzungPrivat));
|
||||
else ecdh.generateKeys();
|
||||
const sitzungOeffentlich = ecdh.getPublicKey();
|
||||
|
||||
const gemeinsam = ecdh.computeSecret(empfaenger);
|
||||
|
||||
/* Schritt 3. Das "info" bindet beide oeffentlichen Schluessel mit ein
|
||||
-- dadurch passt der Schluessel nur zu genau diesem Geraet und
|
||||
genau dieser Nachricht. */
|
||||
const grundInfo = Buffer.concat([
|
||||
Buffer.from("WebPush: info\0"),
|
||||
empfaenger,
|
||||
sitzungOeffentlich,
|
||||
]);
|
||||
const grund = Buffer.from(
|
||||
crypto.hkdfSync("sha256", gemeinsam, geheimnis, grundInfo, 32)
|
||||
);
|
||||
|
||||
const cek = Buffer.from(
|
||||
crypto.hkdfSync("sha256", grund, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)
|
||||
);
|
||||
const nonce = Buffer.from(
|
||||
crypto.hkdfSync("sha256", grund, salt, Buffer.from("Content-Encoding: nonce\0"), 12)
|
||||
);
|
||||
|
||||
/* Das 0x02 markiert das Ende. Es gehoert mit verschluesselt -- ein
|
||||
Empfaenger prueft daran, dass die Nachricht vollstaendig ist. */
|
||||
const klar = Buffer.concat([Buffer.from(nachricht, "utf8"), Buffer.from([2])]);
|
||||
|
||||
const chiffre = crypto.createCipheriv("aes-128-gcm", cek, nonce);
|
||||
const inhalt = Buffer.concat([chiffre.update(klar), chiffre.final(), chiffre.getAuthTag()]);
|
||||
|
||||
const blockgroesse = Buffer.alloc(4);
|
||||
blockgroesse.writeUInt32BE(opt.blockgroesse || 4096, 0);
|
||||
|
||||
return Buffer.concat([
|
||||
salt,
|
||||
blockgroesse,
|
||||
Buffer.from([sitzungOeffentlich.length]),
|
||||
sitzungOeffentlich,
|
||||
inhalt,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
SENDEN
|
||||
|
||||
Der Rueckgabewert unterscheidet drei Faelle, und die Unterscheidung
|
||||
ist wichtiger als sie aussieht:
|
||||
|
||||
ok -- angekommen
|
||||
abgemeldet -- das Geraet gibt es nicht mehr (404/410). Der Eintrag
|
||||
MUSS geloescht werden, sonst sammeln sich Karteileichen
|
||||
an und jede Meldung dauert laenger, weil jedes Mal
|
||||
erfolglos an tote Adressen gesendet wird.
|
||||
fehler -- alles andere (Netz, Dienst gestoert). Eintrag behalten.
|
||||
--------------------------------------------------------------------- */
|
||||
export async function senden(abo, nachricht, vapid, opt = {}) {
|
||||
const koerper = verschluesseln(nachricht, abo.p256dh, abo.auth, opt);
|
||||
|
||||
const antwort = await fetch(abo.endpunkt, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
/* Wie lange der Dienst die Nachricht aufhebt, falls das Geraet
|
||||
gerade aus ist. Vier Stunden: Eine Anfrage von heute Nacht ist
|
||||
morgen frueh noch von Belang. */
|
||||
TTL: String(opt.ttl == null ? 4 * 60 * 60 : opt.ttl),
|
||||
Urgency: opt.dringlichkeit || "normal",
|
||||
Authorization: vapidKopf(abo.endpunkt, vapid.privat, vapid.oeffentlich, vapid.kontakt),
|
||||
},
|
||||
body: koerper,
|
||||
});
|
||||
|
||||
if (antwort.status === 404 || antwort.status === 410) {
|
||||
return { zustand: "abgemeldet", status: antwort.status };
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
const text = await antwort.text().catch(() => "");
|
||||
return { zustand: "fehler", status: antwort.status, text: text.slice(0, 200) };
|
||||
}
|
||||
return { zustand: "ok", status: antwort.status };
|
||||
}
|
||||
Reference in New Issue
Block a user