'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); const readline = require('readline'); const { execFileSync } = require('child_process'); const { signaturText, pruefeSignatur, holeSignatur, SIGNATUR_API } = require('../src/updater'); const ROOT = path.join(__dirname, '..'); const DIST = path.join(ROOT, 'dist'); const ORDNER = path.join(os.homedir(), '.aeromc-release'); const OEFFENTLICH = path.join(ROOT, 'src', 'update-schluessel.pem'); const PRIVAT = process.env.AEROMC_SIGN_KEY || path.join(ORDNER, 'release-key.pem'); const ZUGANG = path.join(ORDNER, 'wordpress.json'); const DATEIEN = { setup: 'AeroMC-Setup.exe', portable: 'AeroMC-Portable.exe' }; const WP_STANDARD = new URL(SIGNATUR_API).origin; // --------------------------------------------------------------------------- // Schlüssel // --------------------------------------------------------------------------- function schluesselAnlegen() { if (fs.existsSync(PRIVAT)) { console.log(`Es gibt bereits einen Release-Schlüssel: ${PRIVAT}`); console.log('Er wird nicht überschrieben – sonst nähmen bestehende Launcher keine Updates mehr an.'); return; } const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); fs.mkdirSync(path.dirname(PRIVAT), { recursive: true }); fs.writeFileSync(PRIVAT, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 }); fs.writeFileSync(OEFFENTLICH, publicKey.export({ type: 'spki', format: 'pem' })); console.log(`Privater Schlüssel: ${PRIVAT} (SICHERN, niemals weitergeben!)`); console.log(`Öffentlicher Schlüssel: ${OEFFENTLICH} (wird in den Launcher eingebaut)`); } function dpapi(skript, eingabe) { return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], { input: eingabe, encoding: 'utf8', windowsHide: true }).trim(); } function verschluesseln(text) { return dpapi('$t = [Console]::In.ReadToEnd(); ConvertTo-SecureString $t -AsPlainText -Force | ConvertFrom-SecureString', text); } function entschluesseln(hex) { return dpapi('$h = [Console]::In.ReadToEnd().Trim(); $s = ConvertTo-SecureString $h; ' + '[Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', hex); } function frage(text, verdeckt) { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); if (verdeckt) { rl._writeToOutput = (s) => { if (s.includes(text)) process.stdout.write(text); else if (s === '\r\n' || s === '\n') process.stdout.write(s); }; } rl.question(text, (antwort) => { rl.close(); if (verdeckt) process.stdout.write('\n'); resolve(antwort.trim()); }); }); } function ladeZugang() { if (!fs.existsSync(ZUGANG)) return null; const z = JSON.parse(fs.readFileSync(ZUGANG, 'utf8').replace(/^/, '')); return { seite: z.seite, benutzer: z.benutzer, passwort: entschluesseln(z.passwort) }; } /* * Anfrage MIT Zugangsdaten an die WordPress-Seite. * Weiterleitungen werden nie verfolgt: Sonst gingen die Kopfzeilen – vor allem * der eigene X-AeroMC-Auth, den fetch bei einem Wechsel der Domain nicht von * selbst entfernt – samt Anwendungspasswort an das Ziel der Weiterleitung. * Gesendet wird außerdem nur über HTTPS und nur an die hinterlegte Seite. */ async function wordpressAnfrage(zugang, pfad, optionen) { let ziel; try { ziel = new URL(zugang.seite + pfad); } catch { throw new Error('Die hinterlegte WordPress-Adresse ist ungültig.'); } if (ziel.protocol !== 'https:') throw new Error('Zugangsdaten werden nur über HTTPS gesendet – WordPress-Adresse prüfen.'); const res = await fetch(ziel, Object.assign({}, optionen, { headers: kopfzeilen(zugang), redirect: 'manual' })); if (res.status >= 300 && res.status < 400) { const wohin = res.headers.get('location') || 'unbekannt'; throw new Error(`Die Seite leitet weiter (HTTP ${res.status} → ${wohin}). Die Zugangsdaten wurden NICHT dorthin gesendet. ` + ' Bitte die genaue Adresse eintragen: node scripts/sign-release.js wordpress'); } return res; } function kopfzeilen(zugang) { const daten = Buffer.from(`${zugang.benutzer}:${zugang.passwort}`).toString('base64'); return { Authorization: 'Basic ' + daten, // Ersatz für Server, die "Authorization" nicht an PHP weitergeben (siehe signaturen.php) 'X-AeroMC-Auth': daten, 'Content-Type': 'application/json', 'User-Agent': 'AeroMC-Release', }; } // Übersetzt die Diagnose des Plugins (ab 1.5.4) in eine klare Meldung function zugangsFehler(d, benutzer) { if (!d || !d.version) { return 'Das Plugin auf der Seite ist zu alt – bitte "AeroMC Freunde" 1.5.4 oder neuer installieren.'; } if (!d.anwendungspasswoerter) return 'Anwendungspasswörter sind auf der Seite abgeschaltet (z. B. durch ein Sicherheits-Plugin).'; if (!d.authorization_kommt_an && !d.eigener_kopf_kommt_an) { return 'Der Server leitet keine Anmeldedaten an WordPress weiter (beide Header fehlen) – das muss der Hoster freigeben.'; } switch (d.grund) { case 'invalid_username': return `Den Benutzer "${benutzer}" gibt es nicht (Benutzername, nicht E-Mail-Adresse).`; case 'incorrect_password': return 'Falsches Anwendungspasswort. Achtung: Gemeint ist NICHT das Login-Passwort, sondern das unter ' + 'Benutzer → Profil → "Anwendungspasswörter" erzeugte (Form "abcd efgh ijkl …").'; case 'application_passwords_disabled': case 'application_passwords_disabled_for_user': return `Für "${benutzer}" sind Anwendungspasswörter abgeschaltet.`; default: break; } if (d.benutzer && !d.darf_hochladen) { return `Anmeldung als "${d.benutzer}" klappt, aber der Benutzer hat nicht die Rolle "AeroMC Release" (Benutzer bearbeiten → Rolle).`; } return 'Anmeldung abgelehnt' + (d.grund ? ` (${d.grund})` : '') + '.'; } async function pruefeZugang(zugang) { const res = await wordpressAnfrage(zugang, '/wp-json/aeromc/v1/update-signatur/zugang', {}); if (res.status === 404) throw new Error('Die Seite kennt die Signatur-Schnittstelle nicht – ist das Plugin "AeroMC Freunde" (ab 1.5.4) installiert und aktiviert?'); let d = null; try { d = await res.json(); } catch { /* keine JSON-Antwort */ } if (res.ok && d && d.ok) return d; if (res.status === 401 || res.status === 403) throw new Error(zugangsFehler(d, zugang.benutzer)); throw new Error('WordPress antwortet mit HTTP ' + res.status); } async function wordpressEinrichten() { console.log('WordPress-Zugang für das automatische Hochladen der Signaturen einrichten.'); console.log('Vorher in WordPress: Benutzer mit der Rolle "AeroMC Release" anlegen, dann in dessen Profil'); console.log('unter "Anwendungspasswörter" ein Passwort erzeugen (Name z. B. "AeroMC Build").\n'); const seite = (await frage(`Adresse der WordPress-Seite [${WP_STANDARD}]: `)) || WP_STANDARD; if (!/^https:\/\//i.test(seite)) throw new Error('Bitte eine https://-Adresse angeben.'); const benutzer = await frage('WordPress-Benutzername: '); const passwort = await frage('Anwendungspasswort (Eingabe bleibt unsichtbar): ', true); if (!benutzer || !passwort) throw new Error('Benutzername und Passwort werden gebraucht.'); const zugang = { seite: seite.replace(/\/+$/, ''), benutzer, passwort }; const antwort = await pruefeZugang(zugang); fs.mkdirSync(ORDNER, { recursive: true }); fs.writeFileSync(ZUGANG, JSON.stringify({ seite: zugang.seite, benutzer, passwort: verschluesseln(passwort) }, null, 2), { mode: 0o600 }); console.log(`\n✔ Zugang geprüft (angemeldet als ${antwort.benutzer}) und gespeichert: ${ZUGANG}`); console.log(' Das Passwort ist mit Windows-DPAPI verschlüsselt.'); } function sha256(datei) { const h = crypto.createHash('sha256'); const fd = fs.openSync(datei, 'r'); const buf = Buffer.alloc(4 * 1024 * 1024); let n; while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) h.update(buf.subarray(0, n)); fs.closeSync(fd); return h.digest('hex'); } function signieren(art) { const name = DATEIEN[art]; if (!name) throw new Error(`Unbekannte Art "${art}" – erwartet: setup, portable, schluessel, wordpress oder hochladen.`); const datei = path.join(DIST, name); if (!fs.existsSync(datei)) throw new Error(`${datei} fehlt – erst bauen.`); if (!fs.existsSync(PRIVAT)) { throw new Error(`Kein Release-Schlüssel unter ${PRIVAT}. Einmalig "node scripts/sign-release.js schluessel" ausführen.`); } const privat = crypto.createPrivateKey(fs.readFileSync(PRIVAT, 'utf8')); const eingebaut = crypto.createPublicKey(fs.readFileSync(OEFFENTLICH, 'utf8')).export({ type: 'spki', format: 'der' }); const passend = crypto.createPublicKey(privat).export({ type: 'spki', format: 'der' }); if (!eingebaut.equals(passend)) { throw new Error('Der private Schlüssel passt nicht zu src/update-schluessel.pem – Signatur wäre für den Launcher wertlos.'); } const version = require(path.join(ROOT, 'package.json')).version; const hash = sha256(datei); const sig = { format: 'AeroMC-Update/1', datei: name, version, sha256: hash, signatur: crypto.sign(null, signaturText(name, version, hash), privat).toString('base64'), }; pruefeSignatur(sig, name, version, hash); fs.writeFileSync(datei + '.sig', JSON.stringify(sig, null, 2) + '\n'); console.log(`\n» ${name} ${version} signiert`); return sig; } async function hochladen(sig) { const zugang = ladeZugang(); if (!zugang) { throw new Error('Kein WordPress-Zugang eingerichtet – Signatur NICHT hochgeladen.\n' + ' Einmalig ausführen: node scripts/sign-release.js wordpress\n' + ` danach: node scripts/sign-release.js hochladen ${sig.datei === DATEIEN.setup ? 'setup' : 'portable'}`); } // Nie die Signatur einer schon veröffentlichten Datei überschreiben: Dann passte // die EXE im Gitea-Release nicht mehr, und kein Launcher nähme das Update an. const vorhanden = await holeSignatur(sig.datei, sig.version); if (vorhanden && vorhanden.sha256 !== sig.sha256 && process.env.AEROMC_SIG_ERSETZEN !== '1') { throw new Error(`Für ${sig.datei} ${sig.version} liegt schon eine andere Signatur auf WordPress – ` + 'diese Version ist vermutlich bereits veröffentlicht. Bitte die Versionsnummer in package.json erhöhen.\n' + ' (Nur wenn die Version sicher NICHT veröffentlicht ist: AEROMC_SIG_ERSETZEN=1 setzen.)'); } if (vorhanden && vorhanden.sha256 === sig.sha256) { console.log('» Signatur liegt bereits unverändert auf WordPress.'); return; } const res = await wordpressAnfrage(zugang, '/wp-json/aeromc/v1/update-signatur', { method: 'POST', body: JSON.stringify(sig), }); let antwort = {}; try { antwort = await res.json(); } catch { /* leer */ } if (!res.ok || !antwort.ok) { throw new Error(`Hochladen fehlgeschlagen (HTTP ${res.status}): ${antwort.message || 'unbekannter Fehler'}`); } // Gegenprobe: liefert die öffentliche Schnittstelle genau das, was der Launcher braucht? const zurueck = await holeSignatur(sig.datei, sig.version); if (!zurueck || zurueck.signatur !== sig.signatur || zurueck.sha256 !== sig.sha256) { throw new Error('Signatur wurde hochgeladen, die Seite liefert aber etwas anderes zurück (Cache?).'); } console.log(`» Signatur auf ${zugang.seite} hochgeladen und geprüft${antwort.geprueft ? ' (vom Server gegengeprüft)' : ''} – ins Gitea-Release kommt nur die EXE.`); } async function main() { const [art, zusatz] = process.argv.slice(2); if (art === 'schluessel') return schluesselAnlegen(); if (art === 'wordpress') return wordpressEinrichten(); if (art === 'hochladen') { const name = DATEIEN[zusatz]; if (!name) throw new Error('Erwartet: hochladen setup|portable'); const sigDatei = path.join(DIST, name + '.sig'); if (!fs.existsSync(sigDatei)) throw new Error(`${sigDatei} fehlt – erst bauen und signieren.`); const sig = JSON.parse(fs.readFileSync(sigDatei, 'utf8')); pruefeSignatur(sig, name, sig.version, sha256(path.join(DIST, name))); return hochladen(sig); } return hochladen(signieren(art)); } main().catch((err) => { console.error('\n✖ ' + err.message); process.exit(1); });