Files
AeroMc-Launcher/scripts/sign-release.js
T
2026-10-07 07:32:08 +00:00

228 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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) };
}
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 fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur/zugang`, { headers: kopfzeilen(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 fetch(`${zugang.seite}/wp-json/aeromc/v1/update-signatur`, {
method: 'POST', headers: kopfzeilen(zugang), 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);
});