'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 " (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, };