Upload via GUI (194 Dateien)

This commit is contained in:
2026-10-07 07:32:02 +00:00
parent 7e7a2021ed
commit 3ac5a6c14d
20 changed files with 2196 additions and 489 deletions
+362
View File
@@ -0,0 +1,362 @@
'use strict';
/*
* integritaet.js – Manipulationsschutz für Instanzen
* --------------------------------------------------
* Für jede Instanz hält der Launcher ein "Siegel" fest:
* - die Startdaten aus instance.json, mit denen sich Code ausführen ließe
* (Java-Pfad, JVM-Argumente, Befehle vor dem Start / nach dem Beenden)
* - den SHA-256 jeder Mod-Datei im mods-Ordner
* Das Siegel ist mit einem geheimen Schlüssel unterschrieben (HMAC), der per
* Windows-DPAPI verschlüsselt im Datenordner liegt.
*
* Als Manipulation gilt:
* - eine bekannte Mod-Datei hat bei gleichem Namen einen anderen Inhalt
* (so arbeitet Jar-infizierende Schadsoftware wie "Fractureiser")
* - die Startdaten wurden außerhalb des Launchers geändert
* - das Siegel selbst wurde verändert
* Keine Manipulation: neue, gelöschte, aktivierte oder deaktivierte Mods – das
* ist der normale Weg, Mods per Explorer hinzuzufügen oder zu entfernen.
*
* Eine manipulierte Instanz wird gesperrt: Sie bleibt erhalten, lässt sich aber
* nicht mehr starten, kopieren, exportieren oder teilen. Der Spieler kann sie
* aus einer sauberen Sicherung als neue Instanz wiederherstellen und muss die
* gesperrte selbst löschen.
*
* Ehrlich gesagt: Ein Schadprogramm, das gezielt AeroMC angreift und unter
* demselben Windows-Konto läuft, kann auch den DPAPI-Schlüssel benutzen. Gegen
* allgemeine Jar-Infektionen, fremde Tools und Hände am Dateisystem hilft es.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ENC = 'enc:v1:';
const MOD_ENDUNGEN = /\.(jar|zip|litemod)(\.disabled)?$/i;
let datenOrdner = null;
let store = null;
let schluessel = null;
let zustand = { version: 1, instanzen: {} };
// Pfad -> { groesse, zeit, hash }: nur für die schnelle Prüfung beim Launcher-Start
let hashCache = new Map();
function zustandsDatei() { return path.join(datenOrdner, 'integritaet.json'); }
function schluesselDatei() { return path.join(datenOrdner, 'integritaet.key'); }
function init(userData, tresor, storeModul) {
datenOrdner = userData;
store = storeModul;
schluessel = ladeSchluessel(tresor);
if (!schluessel) return;
let roh = null;
try { roh = JSON.parse(fs.readFileSync(zustandsDatei(), 'utf8')); } catch { /* erster Start oder Datei fehlt */ }
zustand = { version: 1, instanzen: (roh && roh.instanzen && typeof roh.instanzen === 'object') ? roh.instanzen : {} };
// Siegel und Sperre sind getrennt unterschrieben: Wer nur die Sperre aus der
// Datei löscht, bekommt sie zurück – der gute Stand (für die Prüfung der
// Sicherungen) bleibt dabei gültig. Wer am Siegel selbst dreht, verliert ihn.
const jetzt = () => new Date().toISOString();
for (const [id, e] of Object.entries(zustand.instanzen)) {
if (!e || e.mac !== siegelMac(id, e)) {
zustand.instanzen[id] = versiegeltEintrag(id, {
felder: {}, dateien: {},
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde verändert.'] },
});
} else if (e.sperrMac !== sperrMac(id, e.gesperrt)) {
zustand.instanzen[id] = versiegeltEintrag(id, {
felder: e.felder, dateien: e.dateien,
gesperrt: (e.gesperrt && e.gesperrt.seit) ? e.gesperrt
: { seit: jetzt(), gruende: ['Die Sperre dieser Instanz wurde von Hand aufgehoben.'] },
});
}
}
// Liste aller versiegelten Instanzen: ein gelöschter Eintrag fällt auf
if (roh && Array.isArray(roh.ids) && roh.idsMac === listenMac(roh.ids)) {
for (const id of roh.ids) {
if (!zustand.instanzen[id]) {
zustand.instanzen[id] = versiegeltEintrag(id, {
felder: {}, dateien: {},
gesperrt: { seit: jetzt(), gruende: ['Das Siegel dieser Instanz wurde entfernt.'] },
});
}
}
}
speichere();
}
function ladeSchluessel(tresor) {
const verfuegbar = (() => { try { return !!(tresor && tresor.available()); } catch { return false; } })();
if (fs.existsSync(schluesselDatei())) {
try {
const inhalt = fs.readFileSync(schluesselDatei(), 'utf8').trim();
const roh = inhalt.startsWith(ENC) ? tresor.decrypt(inhalt.slice(ENC.length)) : inhalt;
const s = Buffer.from(roh, 'hex');
if (s.length === 32) return s;
throw new Error('falsche Länge');
} catch (err) {
// Vorhanden, aber nicht lesbar (z. B. Datenordner von einem anderen PC
// kopiert). Nicht einfach neu anlegen – dann sähen ALLE Siegel verändert
// aus und jede Instanz wäre gesperrt. Schutz bleibt dann aus.
console.warn('Integritäts-Schlüssel nicht lesbar – Manipulationsschutz inaktiv:', err.message);
return null;
}
}
const neu = crypto.randomBytes(32);
const text = verfuegbar ? ENC + tresor.encrypt(neu.toString('hex')) : neu.toString('hex');
fs.mkdirSync(datenOrdner, { recursive: true });
fs.writeFileSync(schluesselDatei(), text);
return neu;
}
function speichere() {
const tmp = zustandsDatei() + '.tmp';
const ids = Object.keys(zustand.instanzen).sort();
fs.writeFileSync(tmp, JSON.stringify(Object.assign({}, zustand, { ids, idsMac: listenMac(ids) }), null, 1));
fs.renameSync(tmp, zustandsDatei());
}
function sortiert(obj) {
return Object.keys(obj || {}).sort().map((k) => [k, obj[k]]);
}
function hmac(daten) {
return crypto.createHmac('sha256', schluessel).update(JSON.stringify(daten)).digest('hex');
}
function siegelMac(id, e) { return hmac(['siegel', id, sortiert(e.felder), sortiert(e.dateien)]); }
function sperrMac(id, gesperrt) { return hmac(['sperre', id, gesperrt || null]); }
function listenMac(ids) { return hmac(['liste', ids]); }
function versiegeltEintrag(id, e) {
const eintrag = { felder: e.felder, dateien: e.dateien, gesperrt: e.gesperrt || null, stand: new Date().toISOString() };
eintrag.mac = siegelMac(id, eintrag);
eintrag.sperrMac = sperrMac(id, eintrag.gesperrt);
return eintrag;
}
// Die sicherheitsrelevanten Startdaten einer Instanz
function startDaten(inst) {
const j = (inst && inst.java) || {};
return {
javaPfad: String(j.path || ''),
jvmArgumente: String(j.extraArgs || ''),
vorStart: String((inst && inst.preLaunchCommand) || ''),
nachEnde: String((inst && inst.postExitCommand) || ''),
};
}
const FELD_NAMEN = {
javaPfad: 'Java-Pfad', jvmArgumente: 'JVM-Argumente', vorStart: 'Befehl vor dem Start', nachEnde: 'Befehl nach dem Beenden',
};
// Alle Mod-Dateien (auch Unterordner wie mods/1.12.2/). Schlüssel ohne
// ".disabled" und in Kleinbuchstaben – Umschalten ist keine Veränderung.
function modDateien(gameDir) {
const basis = path.join(gameDir, 'mods');
const ergebnis = {};
const lauf = (ordner, tiefe) => {
let eintraege;
try { eintraege = fs.readdirSync(ordner, { withFileTypes: true }); } catch { return; }
for (const e of eintraege) {
const voll = path.join(ordner, e.name);
if (e.isDirectory()) { if (tiefe < 2) lauf(voll, tiefe + 1); continue; }
if (!e.isFile() || !MOD_ENDUNGEN.test(e.name)) continue;
const rel = path.relative(basis, voll).replace(/\\/g, '/');
ergebnis[rel.replace(/\.disabled$/i, '').toLowerCase()] = voll;
}
};
lauf(basis, 0);
return ergebnis;
}
function hashDatei(datei) {
return new Promise((resolve, reject) => {
const h = crypto.createHash('sha256');
fs.createReadStream(datei).on('error', reject).on('data', (d) => h.update(d)).on('end', () => resolve(h.digest('hex')));
});
}
// schnell=true: unveränderte Größe+Zeit -> Hash aus dem Zwischenspeicher
async function hashe(datei, schnell) {
const st = fs.statSync(datei);
const c = hashCache.get(datei);
if (schnell && c && c.groesse === st.size && c.zeit === st.mtimeMs) return c.hash;
const hash = await hashDatei(datei);
hashCache.set(datei, { groesse: st.size, zeit: st.mtimeMs, hash });
return hash;
}
async function aktuellerStand(id, schnell) {
const inst = store.getInstance(id);
if (!inst) return null;
const dateien = {};
for (const [schl, voll] of Object.entries(modDateien(store.gameDir(id)))) {
try { dateien[schl] = await hashe(voll, schnell); } catch { /* gesperrt/weg -> beim nächsten Mal */ }
}
return { felder: startDaten(inst), dateien };
}
function status(id) {
const e = zustand.instanzen[id];
return e && e.gesperrt ? { gesperrt: true, seit: e.gesperrt.seit, gruende: e.gesperrt.gruende } : { gesperrt: false };
}
function istGesperrt(id) { return status(id).gesperrt; }
function sperre(id, gruende) {
if (!schluessel) return;
const e = zustand.instanzen[id] || { felder: {}, dateien: {} };
// das letzte gute Siegel bleibt stehen – daran wird eine Sicherung gemessen
zustand.instanzen[id] = versiegeltEintrag(id, {
felder: e.felder, dateien: e.dateien,
gesperrt: { seit: new Date().toISOString(), gruende },
});
speichere();
}
/*
* Prüft eine Instanz gegen ihr Siegel.
* -> { status: 'ok' | 'neu' | 'gesperrt', gruende?, neuGesperrt? }
* Ohne Befund werden neue und entfernte Mods ins Siegel übernommen.
*/
async function pruefe(id, { schnell = false } = {}) {
if (!schluessel) return { status: 'ok' };
const vorhanden = zustand.instanzen[id];
if (vorhanden && vorhanden.gesperrt) return { status: 'gesperrt', gruende: vorhanden.gesperrt.gruende };
const jetzt = await aktuellerStand(id, schnell);
if (!jetzt) return { status: 'ok' };
if (!vorhanden) {
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
speichere();
return { status: 'neu' };
}
const gruende = [];
for (const [k, name] of Object.entries(FELD_NAMEN)) {
if ((vorhanden.felder[k] || '') !== jetzt.felder[k]) gruende.push(`${name} wurde außerhalb des Launchers geändert.`);
}
for (const [datei, hash] of Object.entries(jetzt.dateien)) {
if (vorhanden.dateien[datei] && vorhanden.dateien[datei] !== hash) gruende.push(`Mod-Datei verändert: ${datei}`);
}
if (gruende.length) {
sperre(id, gruende);
return { status: 'gesperrt', gruende, neuGesperrt: true };
}
const geaendert = JSON.stringify(sortiert(vorhanden.dateien)) !== JSON.stringify(sortiert(jetzt.dateien));
if (geaendert) {
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
speichere();
}
return { status: 'ok' };
}
// Nach einer Änderung durch den Launcher selbst (Mod-Update, Bearbeiten …)
async function versiegele(id) {
if (!schluessel) return;
if (istGesperrt(id)) return;
const jetzt = await aktuellerStand(id, false);
if (!jetzt) return;
zustand.instanzen[id] = versiegeltEintrag(id, jetzt);
speichere();
}
function entferne(id) {
if (!schluessel) return;
if (!zustand.instanzen[id]) return;
delete zustand.instanzen[id];
speichere();
}
function alleGesperrten() {
const r = {};
for (const [id, e] of Object.entries(zustand.instanzen)) if (e.gesperrt) r[id] = { seit: e.gesperrt.seit, gruende: e.gesperrt.gruende };
return r;
}
// Das letzte gute Siegel (für die Prüfung einer Sicherung)
function gutesSiegel(id) {
const e = zustand.instanzen[id];
return e ? { felder: e.felder, dateien: e.dateien } : null;
}
// ---------------------------------------------------------------------------
// Wiederherstellen aus einer Sicherung (AeroMC-Backup-*.zip, siehe backup.js)
// ---------------------------------------------------------------------------
const unterPfad = (p) => String(p || '').replace(/\\/g, '/');
/*
* Neueste Sicherung, in der die Instanz steckt UND deren Mods zum letzten
* guten Siegel passen. Eine Sicherung, die erst nach der Manipulation entstand,
* enthält die veränderte Datei und wird übersprungen.
* backupPfade: neueste zuerst
*/
async function sucheSaubereSicherung(id, backupPfade) {
const unzipper = require('unzipper');
const gut = gutesSiegel(id);
const praefix = `instances/${id}/`;
const uebersprungen = [];
for (const zip of backupPfade) {
let verzeichnis;
try { verzeichnis = await unzipper.Open.file(zip); } catch { continue; }
const eintraege = verzeichnis.files.filter((f) => f.type === 'File' && unterPfad(f.path).startsWith(praefix));
if (!eintraege.some((f) => unterPfad(f.path) === praefix + 'instance.json')) continue;
let sauber = true;
for (const f of eintraege) {
const m = unterPfad(f.path).slice(praefix.length).match(/^minecraft\/mods\/(.+)$/i);
if (!m || !MOD_ENDUNGEN.test(m[1])) continue;
const schl = m[1].replace(/\.disabled$/i, '').toLowerCase();
if (!gut || !gut.dateien[schl]) continue;
const hash = crypto.createHash('sha256').update(await f.buffer()).digest('hex');
if (hash !== gut.dateien[schl]) { sauber = false; break; }
}
if (sauber) return { zip, eintraege, praefix, uebersprungen };
uebersprungen.push(path.basename(zip));
}
return { zip: null, uebersprungen };
}
/*
* Stellt die Instanz als NEUE Instanz "<Name> (wiederhergestellt)" wieder her.
* Die gesperrte bleibt unangetastet liegen.
*/
async function stelleWiederHer(id, backupPfade) {
const { pipeline } = require('stream/promises');
const fund = await sucheSaubereSicherung(id, backupPfade);
if (!fund.zip) {
throw new Error(fund.uebersprungen.length
? `Keine saubere Sicherung gefunden – ${fund.uebersprungen.length} Sicherung(en) enthalten bereits die veränderten Dateien.`
: 'Keine Sicherung mit dieser Instanz gefunden. Sicherungen lassen sich unter Optionen → Sicherung anlegen.');
}
const alt = store.getInstance(id);
const name = `${(alt && alt.name) || id} (wiederhergestellt)`;
const neuId = store.uniqueFolderId(name);
const ziel = store.instancePath(neuId);
fs.mkdirSync(ziel, { recursive: true });
for (const f of fund.eintraege) {
const rel = unterPfad(f.path).slice(fund.praefix.length);
const dest = path.join(ziel, ...rel.split('/'));
if (!dest.startsWith(ziel + path.sep)) continue; // nichts außerhalb des Ordners
fs.mkdirSync(path.dirname(dest), { recursive: true });
await pipeline(f.stream(), fs.createWriteStream(dest));
}
const datei = path.join(ziel, 'instance.json');
const inst = JSON.parse(fs.readFileSync(datei, 'utf8'));
inst.id = neuId;
inst.name = name;
// Startdaten auf den letzten geprüften Stand setzen
const gut = gutesSiegel(id);
if (gut && gut.felder && Object.keys(gut.felder).length) {
inst.java = Object.assign({}, inst.java, { path: gut.felder.javaPfad || '', extraArgs: gut.felder.jvmArgumente || '' });
inst.preLaunchCommand = gut.felder.vorStart || '';
inst.postExitCommand = gut.felder.nachEnde || '';
}
fs.writeFileSync(datei, JSON.stringify(inst, null, 2));
await versiegele(neuId);
return { id: neuId, name, sicherung: path.basename(fund.zip), uebersprungen: fund.uebersprungen };
}
module.exports = {
init, pruefe, versiegele, sperre, status, istGesperrt, entferne, alleGesperrten, gutesSiegel,
sucheSaubereSicherung, stelleWiederHer,
startDaten, modDateien, hashDatei, FELD_NAMEN,
};