363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
'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,
|
||
};
|