diff --git a/README.md b/README.md index e69de29..e2d6828 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,35 @@ +# TruthSystems Mod + +See [GITHUB_README.md](GITHUB_README.md) for the full project overview. + +## Hardcore Integrity Keeper (HIK) + +HIK is the fifth subsystem of TruthSystems — a cryptographic enforcement layer that makes Hardcore mode **truly permanent**. + +### What it does + +| Feature | Mechanism | +|---|---| +| **Death sealing** | SHA-256 checksummed death records in `/soulbind/.json` | +| **Spectator lock** | Locked players are held in spectator mode and cannot change game mode via commands | +| **Archive countdown** | Grace period (`spectator_grace_minutes`) before world is flagged for archival | +| **level.dat tamper detection** | SHA-256 of `level.dat` compared on every server start | +| **Rollback detection** | Game tick persisted every 30 s; backwards tick = rollback flag | +| **LAN cheat blocking** | Mixin disables "Allow Cheats" on `ShareToLanScreen` for Hardcore worlds | +| **Read-only enforcement** | Block-break/place cancelled when world integrity ≠ CLEAN | +| **HUD overlay** | Green `HIK: CLEAN` / red `HIK: ` rendered in the top-left corner | + +### Configuration + +Config file: `config/truthsystems-hik.toml` (auto-generated on first run). + +See [docs/HIK_ARCHITECTURE.md](docs/HIK_ARCHITECTURE.md) for full architecture documentation and +[docs/MODPACK_COMPATIBILITY.md](docs/MODPACK_COMPATIBILITY.md) for the LAN-screen mixin compatibility notes. + +### Subsystems + +- `com.truthsystems.hardcore.soulbind` — death seals & event handling +- `com.truthsystems.hardcore.integrity` — level.dat watching & HUD overlay +- `com.truthsystems.hardcore.backup` — session IDs & rollback detection +- `com.truthsystems.hardcore.spectator` — lock enforcement, countdown, command interception +- `com.truthsystems.hardcore.lan` — LAN mixin, cheat watcher, read-only enforcer diff --git a/VERIFICATION_HASHES.txt b/VERIFICATION_HASHES.txt index b6f14ec..3b35251 100644 --- a/VERIFICATION_HASHES.txt +++ b/VERIFICATION_HASHES.txt @@ -2,24 +2,43 @@ # Covenant: SIGMA_LORA_COVENANT v1.0 # Generated: 2026-02-04 -B98012CC51C79489E657D0F50DE25E4D9DBE2A6A23206A02C438A928D36A56CB build.gradle +7C0B010B82D2EE34B4CEBBC1CD14747038742ECDA88B38A7F3DECE20ACA50C15 build.gradle 58A820524B1197F432CC0720F670689FA7F634B2FB68554C581B8685A7AE50FC gradle.properties A5B8EEE8C2B7A9C124348F51C42C47DCFE5A9A1F625BF8E3A8436D737F77FA8E settings.gradle -F8F171F9A1E0907C6640F037DCA4FA6D7EDCA3D68D65B945EB760D5A969F5A6E src\main\java\com\truthsystems\TruthSystems.java -AF9C015572F763564AD67C0150A939709F3A5D165CF6088CD7A32A66EBE5CA5A src\main\java\com\truthsystems\audit\BlacklistManager.java -CD8E926930EC95AE43BB9A57CB35A3EDD17EEFB779E2566000074A2FBA1D11BF src\main\java\com\truthsystems\audit\CovenantCompliant.java -9D4FAB098F563BA09CCD9DC8999D13E42EFD49AB7B891D456A473F55DF8DFE04 src\main\java\com\truthsystems\audit\CovenantVerifier.java -888B311B3F0C0BFE88B3C19A41F0B8326E268EAB1A75EB815397EEEE3DA0EBAF src\main\java\com\truthsystems\audit\ErrorLogger.java -7C8DB103908C0FC8B5669AD190D831CB32CA19593D389B218776EC00851D8A6C src\main\java\com\truthsystems\causality\RedstoneGraph.java -8B5093F44D2E0863A77DFA2D1803D44B4800735579EA636FBBEDCFD304C22B64 src\main\java\com\truthsystems\debugger\DebugMetrics.java -5724D41C31F4F2B4CD8CC2D8A05F7E9BB244F79DECC0369E3748C25972DFBA1A src\main\java\com\truthsystems\inverter\InversionLogic.java -0F97E73A29E5B01E3C0106FD71A74BA213359C6D2D481DBC8154D6F0A506CEAA src\main\java\com\truthsystems\inverter\InverterBlock.java -1F17C29CF19C931615E3E7B542C7E4659FB026FA1C28CC46C230DDF866611FB6 src\main\java\com\truthsystems\merkle\ChunkHasher.java -C2978A5D9512FC97D6A16E5E14BE35FB290513CC64CE1B2D9786ABA047267291 src\main\java\com\truthsystems\merkle\NotaryBlock.java -6804C8CE6E04BFB71AACC9B19B9BB4FEF89583E4C9FCA66623A4042D26FFA568 src\main\java\com\truthsystems\merkle\NotaryBlockEntity.java -6089B437CE5EA855C0ED7EF17749D05DA5F31C1E6438A613FDC3478D84A755FD src\main\java\com\truthsystems\registry\ModBlockEntities.java -C3C3C65906DCC30B8294C8FB52EBDBF73DD4CEA3BC936D9AE395C06F65EC8DCF src\main\java\com\truthsystems\registry\ModBlocks.java -400ECEB7F6FE67114C0DCAE90E74C9260FE293C6B6EBC718A272237335888514 src\main\java\com\truthsystems\registry\ModEntities.java -1EEA022A7698C3D1233FBB3A0EFF4DDA7494D0C99781845BF72BC4872A180C7B src\main\java\com\truthsystems\registry\ModItems.java -654B843C709CC026DED6AE3A32FB5731D16C37491D76E23933096659AD36AFEA src\main\resources\META-INF\mods.toml -7B25B64405B6844BD1557B34B15072CD9BEE7D5FC92EFCC9D230AD4EEB0E55C6 src\main\resources\pack.mcmeta +FD2CB2BF70E8CBC253312500B088F65C221A0643D22B662C180D81FC149273A7 src/main/java/com/truthsystems/TruthSystems.java +AF9C015572F763564AD67C0150A939709F3A5D165CF6088CD7A32A66EBE5CA5A src/main/java/com/truthsystems/audit/BlacklistManager.java +CD8E926930EC95AE43BB9A57CB35A3EDD17EEFB779E2566000074A2FBA1D11BF src/main/java/com/truthsystems/audit/CovenantCompliant.java +E70570A7103E0299BE7226D840C79178E7A7B97E9E9921F62317B40EBC7FC447 src/main/java/com/truthsystems/audit/CovenantVerifier.java +9F4A5631D563F4C3F630320FF77DFA6FA6F6068B02F227013F3DFDCF913235D9 src/main/java/com/truthsystems/audit/ErrorLogger.java +7C8DB103908C0FC8B5669AD190D831CB32CA19593D389B218776EC00851D8A6C src/main/java/com/truthsystems/causality/RedstoneGraph.java +8B5093F44D2E0863A77DFA2D1803D44B4800735579EA636FBBEDCFD304C22B64 src/main/java/com/truthsystems/debugger/DebugMetrics.java +02EB8862C58D709EBA2AF55A24B485DBA84CB860954C454CA5C631D922F97B2A src/main/java/com/truthsystems/hardcore/HikConfig.java +FA5FC17087FF0FD9876D7B0B8E726D039741192BA779B80432024842BC0D7720 src/main/java/com/truthsystems/hardcore/HikCovenantCompliance.java +3BFB892B91120B321C6807001B716B630C1A3B4BDDCFBD957F6DC64B3484B8ED src/main/java/com/truthsystems/hardcore/backup/BackupDetector.java +48607C09BCDD018C110E8396E38695C6173A0B51DAC80950EC07EAED01974901 src/main/java/com/truthsystems/hardcore/backup/SessionIdManager.java +06C9F60E5A22EF995B09D5C8FD13EE1F74E3C526A2205E741B1DFB720808253D src/main/java/com/truthsystems/hardcore/integrity/IntegrityOverlay.java +0B9839DA39FDA3434757637BBAAAFF90D2B2B3A79F563E5EF393490351055C49 src/main/java/com/truthsystems/hardcore/integrity/LevelDatWatcher.java +F67459326F42FC10D5DABB6093BCD67DDFB1375C1A3D7CFD97CD9D82B24BC8D1 src/main/java/com/truthsystems/hardcore/integrity/WorldChecksumValidator.java +0A5A313AB4A7642DC215ABA4C8C68D002C946D3AD66FF66F6A0631B5AEA00F48 src/main/java/com/truthsystems/hardcore/lan/CheatFlagWatcher.java +70B3E245D0FDF8C64B1C7F7435ADB2541C4798B32A6E4980300288884E58E42B src/main/java/com/truthsystems/hardcore/lan/LanMenuMixin.java +A0FB5C8800359200EDABDA044EF75C9286772A6AD7663120AE9FCCD2F46603CF src/main/java/com/truthsystems/hardcore/lan/MixinConfigPlugin.java +72C0A14F62B031E47AD32FBD083C9EFFA5535370C7A3B208BAC3EBB20A9F715A src/main/java/com/truthsystems/hardcore/lan/ReadOnlyWorldEnforcer.java +0761B57BBF9D34AA1BD41DA715A64CF70699CED6903E702EAFD29F7DC6A96F67 src/main/java/com/truthsystems/hardcore/soulbind/DeathLogEntry.java +479CA767F46E46E587351681AFDC4FAD2D85042834AFD94410EE3A0DE13C3313 src/main/java/com/truthsystems/hardcore/soulbind/DeathSealManager.java +1E3A87E4AA2967EF06F514DBD38E07C56FDEBA069929BEBC46029AE7D4F0485B src/main/java/com/truthsystems/hardcore/soulbind/IntegrityFlag.java +ABAAD58985A7A5BA9CAC2F2A0F09000949BCAA5A07DFDB2E0BEC935E05D8BC9B src/main/java/com/truthsystems/hardcore/soulbind/SoulbindEventHandler.java +481DAF1B7F60F0C3B619D3D27C485517DA1ADD5FDACBA0E4E5743C0B3F2461D5 src/main/java/com/truthsystems/hardcore/spectator/ArchiveCountdown.java +C99B1F718BCFEDC806722CC1BE23E175AD84974BDFE5A98CF1AE767559D7464D src/main/java/com/truthsystems/hardcore/spectator/CommandInterceptor.java +436FC52F1F4B9975DD9989E65A66C33FB5962D01AC1A15645F930FBAB235ABA0 src/main/java/com/truthsystems/hardcore/spectator/SpectatorLockHandler.java +5724D41C31F4F2B4CD8CC2D8A05F7E9BB244F79DECC0369E3748C25972DFBA1A src/main/java/com/truthsystems/inverter/InversionLogic.java +0F97E73A29E5B01E3C0106FD71A74BA213359C6D2D481DBC8154D6F0A506CEAA src/main/java/com/truthsystems/inverter/InverterBlock.java +1F17C29CF19C931615E3E7B542C7E4659FB026FA1C28CC46C230DDF866611FB6 src/main/java/com/truthsystems/merkle/ChunkHasher.java +C2978A5D9512FC97D6A16E5E14BE35FB290513CC64CE1B2D9786ABA047267291 src/main/java/com/truthsystems/merkle/NotaryBlock.java +6804C8CE6E04BFB71AACC9B19B9BB4FEF89583E4C9FCA66623A4042D26FFA568 src/main/java/com/truthsystems/merkle/NotaryBlockEntity.java +6089B437CE5EA855C0ED7EF17749D05DA5F31C1E6438A613FDC3478D84A755FD src/main/java/com/truthsystems/registry/ModBlockEntities.java +C3C3C65906DCC30B8294C8FB52EBDBF73DD4CEA3BC936D9AE395C06F65EC8DCF src/main/java/com/truthsystems/registry/ModBlocks.java +400ECEB7F6FE67114C0DCAE90E74C9260FE293C6B6EBC718A272237335888514 src/main/java/com/truthsystems/registry/ModEntities.java +1EEA022A7698C3D1233FBB3A0EFF4DDA7494D0C99781845BF72BC4872A180C7B src/main/java/com/truthsystems/registry/ModItems.java +E1E8ECF228D431318F871FEAD50730ABC1CF606290865725B93684193EF5E6C6 src/main/resources/META-INF/mods.toml +7B25B64405B6844BD1557B34B15072CD9BEE7D5FC92EFCC9D230AD4EEB0E55C6 src/main/resources/pack.mcmeta +265FC654B960FB245AA6E8D41C1C2AFF80B3C6AF141CDFB1DD32A5373372A216 src/main/resources/truthsystems.mixins.json diff --git a/build.gradle b/build.gradle index a89b8fc..8aa2ac7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,8 +1,20 @@ -plugins { - id 'java' - id 'net.minecraftforge.gradle' version '6.0.18' +buildscript { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url = 'https://maven.minecraftforge.net/' } + maven { url = 'https://repo.spongepowered.org/repository/maven-public/' } + } + dependencies { + classpath 'net.minecraftforge.gradle:ForgeGradle:6.0.18' + classpath 'org.spongepowered:mixingradle:0.7-SNAPSHOT' + } } +apply plugin: 'java' +apply plugin: 'net.minecraftforge.gradle' +apply plugin: 'org.spongepowered.mixin' + group = 'com.truthsystems' version = '1.0.0' @@ -42,12 +54,27 @@ repositories { dependencies { minecraft 'net.minecraftforge:forge:1.20.1-47.4.10' + annotationProcessor 'org.spongepowered:mixin:0.8.5:processor' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1' + testImplementation 'org.mockito:mockito-core:5.8.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.8.0' } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' } +mixin { + add sourceSets.main, "truthsystems.mixins.refmap.json" + config "truthsystems.mixins.json" +} + +test { + useJUnitPlatform() +} + jar { manifest { attributes([ diff --git a/docs/HIK_ARCHITECTURE.md b/docs/HIK_ARCHITECTURE.md new file mode 100644 index 0000000..f698ad4 --- /dev/null +++ b/docs/HIK_ARCHITECTURE.md @@ -0,0 +1,136 @@ +# HIK Architecture — Hardcore Integrity Keeper + +## What is HIK? + +The **Hardcore Integrity Keeper (HIK)** is a subsystem of the TruthSystems Forge mod that +enforces the *permanence of death* in Minecraft Hardcore worlds. It combines cryptographic +sealing, tamper detection, spectator-lock enforcement, and cheat-flag watching to make +Hardcore truly hardcore — even against external save-file manipulation or LAN cheat toggles. + +HIK operates under the Σ_LORA_COVENANT v1.0 and is governed by the **LOGOS** and +**CHALCEDON** principles: every integrity state is explicitly named, every check is +cryptographically falsifiable, and the infrastructure exists to serve the player's chosen +challenge — not to override it. + +--- + +## Config Keys (`truthsystems-hik.toml`) + +| Key | Default | Description | +|---|---|---| +| `enable_hik` | `true` | Master switch for the entire HIK subsystem | +| `archive_path` | `"saves/archived"` | Directory for archived Hardcore world copies | +| `spectator_grace_minutes` | `10` | Minutes before an archived world is flagged for archival | +| `verification_api_port` | `0` | HTTP verification API port (0 = disabled, Phase 2) | +| `allow_sealed_backups` | `true` | Allow server-side backups even after a death seal | +| `strict_inventory_provenance` | `false` | Full inventory provenance tracking (Phase 2, no-op) | +| `compromise_on_lan_cheats` | `true` | Mark world COMPROMISED when LAN cheats are enabled | +| `read_only_on_external_cheat_flag` | `true` | Block block-break/place when world is not CLEAN | + +--- + +## Architecture Overview + +HIK is organized into five subsystems under `com.truthsystems.hardcore`: + +### 1. `soulbind` — Death Sealing + +| Class | Role | +|---|---| +| `IntegrityFlag` | Enum of all possible world integrity states | +| `DeathLogEntry` | POJO holding death record; self-checksums via SHA-256 | +| `DeathSealManager` | Persists/loads death seals in `/soulbind/.json` | +| `SoulbindEventHandler` | Forge event handler: creates seals on death, enforces spectator on login | + +**Flow**: Player dies in Hardcore → `LivingDeathEvent` → `DeathLogEntry` created with +`computeChecksum()` → written to disk → player set to SPECTATOR. On next login, +`PlayerLoggedInEvent` checks `isSealed()` and re-enforces spectator if necessary. + +### 2. `integrity` — World File Tamper Detection + +| Class | Role | +|---|---| +| `WorldChecksumValidator` | Computes SHA-256 of `level.dat`; persists to `hik_integrity.json` | +| `LevelDatWatcher` | Listens to `ServerStartingEvent`; delegates to `WorldChecksumValidator` | +| `IntegrityOverlay` | Client-side HUD overlay (green/red label showing integrity state) | + +**Flow**: Server starts → `LevelDatWatcher.onServerStarting` → `initOrValidate` reads +`hik_integrity.json`; if hash differs, `markCompromised(TAMPERED_LEVEL_DAT)`. + +### 3. `backup` — Session Continuity & Rollback Detection + +| Class | Role | +|---|---| +| `SessionIdManager` | Generates/persists stable UUID per world in `hik_session.json` | +| `BackupDetector` | Persists game tick every 600 ticks; rollback if tick goes backwards | + +**Flow**: Every 600 ticks (`≈30 s`), `BackupDetector.onLevelTick` persists the current +game time. If the recorded tick is *higher* than the current tick, a rollback is +inferred and the world is marked `ROLLBACK_DETECTED`. + +### 4. `spectator` — Lock Enforcement & Archive Countdown + +| Class | Role | +|---|---| +| `SpectatorLockHandler` | In-memory set of locked UUIDs; per-tick spectator re-enforcement | +| `ArchiveCountdown` | Countdown map (uuid → expiry ms); triggers archive-pending log | +| `CommandInterceptor` | Blocks `gamemode`, `gm`, `give`, `tp`, `teleport`, `kill` for locked players | + +**Flow**: On death seal → `SpectatorLockHandler.lockPlayer(uuid)` + +`ArchiveCountdown.startCountdown(uuid)`. Per tick, spectator mode is re-applied. +Commands are intercepted via `CommandEvent`. + +### 5. `lan` — LAN Cheat & Read-Only Enforcement + +| Class | Role | +|---|---| +| `LanMenuMixin` | Mixin on `ShareToLanScreen`; disables "Allow Cheats" button on Hardcore worlds | +| `CheatFlagWatcher` | Server tick watcher; detects `getAllowCommands()` transition to `true` | +| `ReadOnlyWorldEnforcer` | Cancels `BlockEvent.BreakEvent` / `EntityPlaceEvent` when integrity ≠ CLEAN | + +--- + +## Integrity State Machine + +``` +UNKNOWN ──(world load, hash matches)──▶ CLEAN +CLEAN ──(hash mismatch)─────────────▶ TAMPERED_LEVEL_DAT +CLEAN ──(death seal tampering)───────▶ TAMPERED_DEATH_LOG +CLEAN ──(tick rollback)──────────────▶ ROLLBACK_DETECTED +CLEAN ──(LAN cheats enabled)─────────▶ LAN_CHEAT_DETECTED +* ──(any of the above)───────────▶ COMPROMISED (generic alias) +``` + +Once compromised, `ReadOnlyWorldEnforcer` prevents all block mutations if +`read_only_on_external_cheat_flag = true`. + +--- + +## Known Limitations / Deferred Items + +- **Verification API** (`verification_api_port`): HTTP endpoint for external audit queries + is not implemented in MVP. Port config key is reserved for Phase 2. +- **Ed25519 signing**: Death seals currently use SHA-256 checksums only. Asymmetric + signing (Ed25519) is deferred to Phase 2 for non-repudiation. +- **Full inventory provenance** (`strict_inventory_provenance`): Item-level tracking + (who crafted/picked up each item) is a no-op in MVP. +- **Merkle tracking integration**: The existing `ChunkHasher` / `NotaryBlock` Merkle + system is not yet wired into HIK's per-chunk integrity checks. +- **Archive automation**: `ArchiveCountdown` logs "archive pending" but does not yet + copy the world directory to `archive_path`. Automation is Phase 2. +- **`SpectatorLockHandler` persistence**: The in-memory lock set is rebuilt from + `DeathSealManager.isSealed()` checks on login; explicit startup hydration is Phase 2. + +--- + +## MVP Scope + +The MVP delivers: +1. Cryptographic death sealing with tamper-detectable checksums +2. `level.dat` hash comparison on every world load +3. Game-tick rollback detection (every 30 s) +4. Per-tick spectator mode enforcement + command interception +5. LAN cheat detection and read-only enforcement +6. Client HUD overlay showing integrity state +7. Forge config file with all tunable knobs +8. Mixin to disable "Allow Cheats" on the LAN share screen for Hardcore worlds diff --git a/docs/MODPACK_COMPATIBILITY.md b/docs/MODPACK_COMPATIBILITY.md new file mode 100644 index 0000000..fe3aadd --- /dev/null +++ b/docs/MODPACK_COMPATIBILITY.md @@ -0,0 +1,26 @@ +# HIK Modpack Compatibility + +## LAN screen mixin + +HIK applies a single client-side mixin to `ShareToLanScreen` in order to disable +the vanilla **Allow Cheats** toggle for Hardcore worlds. + +### Conflict strategy + +- `LanMenuMixin` uses `@Inject(at = @At("RETURN"))`, not `@Overwrite` +- Mixin priority is lowered to `900` so other mods can build the final button list first +- `MixinConfigPlugin` skips the mixin entirely if the target screen is unavailable + +### Button matching + +The filter is intentionally narrow and only disables labels containing both +`allow` and `cheat` so unrelated LAN buttons remain untouched. + +### Manual compatibility checks recommended + +Before shipping a pack, verify LAN screen behaviour alongside any mod that also +patches the pause/LAN UI, especially: + +- Essential +- LAN World Plug-n-Play +- Any custom menu overhaul mod diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/truthsystems/TruthSystems.java b/src/main/java/com/truthsystems/TruthSystems.java index 0b1650a..7bf7979 100644 --- a/src/main/java/com/truthsystems/TruthSystems.java +++ b/src/main/java/com/truthsystems/TruthSystems.java @@ -6,14 +6,19 @@ */ package com.truthsystems; +import com.mojang.logging.LogUtils; +import com.truthsystems.hardcore.HikConfig; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.eventbus.api.IEventBus; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.fml.ModLoadingContext; import com.truthsystems.registry.*; +import org.slf4j.Logger; @Mod(TruthSystems.MODID) public class TruthSystems { public static final String MODID = "truthsystems"; + private static final Logger LOGGER = LogUtils.getLogger(); @SuppressWarnings("removal") public TruthSystems(FMLJavaModLoadingContext context) { @@ -22,5 +27,14 @@ public TruthSystems(FMLJavaModLoadingContext context) { ModItems.REGISTER.register(bus); ModBlockEntities.REGISTER.register(bus); ModEntities.REGISTER.register(bus); + + // Register HIK configuration + ModLoadingContext.get().registerConfig( + net.minecraftforge.fml.config.ModConfig.Type.COMMON, + HikConfig.SPEC, + "truthsystems-hik.toml" + ); + HikConfig.load(); + LOGGER.info("[HIK] Hardcore Integrity Keeper initialized. Version: 1.1.0-part1"); } } diff --git a/src/main/java/com/truthsystems/audit/CovenantVerifier.java b/src/main/java/com/truthsystems/audit/CovenantVerifier.java index a2529fd..caf1ec5 100644 --- a/src/main/java/com/truthsystems/audit/CovenantVerifier.java +++ b/src/main/java/com/truthsystems/audit/CovenantVerifier.java @@ -7,6 +7,12 @@ */ package com.truthsystems.audit; +import com.truthsystems.hardcore.backup.BackupDetector; +import com.truthsystems.hardcore.integrity.WorldChecksumValidator; +import com.truthsystems.hardcore.soulbind.DeathLogEntry; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import com.truthsystems.hardcore.spectator.SpectatorLockHandler; import net.minecraft.world.level.Level; import net.minecraft.core.BlockPos; import net.minecraft.world.level.block.state.BlockState; @@ -260,4 +266,95 @@ private static String hashBlockState(BlockState state) { private static boolean isWithinRadius(BlockPos target, BlockPos center, int radius) { return target.distSqr(center) <= radius * radius; } + + // ============================================================ + // HIK VERIFICATION METHODS (PRINCIPLE: LOGOS + CHALCEDON) + // ============================================================ + + /** + * Verify death seal integrity for a player in a Hardcore world. + * + * @param worldDir path to the world directory + * @param playerUuid player UUID string + * @param worldName world name for integrity map + * @return true if death seal exists and checksum is valid + */ + public static boolean verifyDeathSealIntegrity( + java.nio.file.Path worldDir, String playerUuid, String worldName) { + DeathLogEntry entry = DeathSealManager.loadSeal(worldDir, playerUuid); + if (entry == null) return false; + if (!entry.verifyChecksum()) { + DeathSealManager.markCompromised(worldName, IntegrityFlag.TAMPERED_DEATH_LOG); + return false; + } + return true; + } + + /** + * Verify world file integrity (level.dat checksum). + * + * @param worldDir path to the world directory + * @param worldName world name for integrity map + * @return true if checksum matches + */ + public static boolean verifyWorldFileIntegrity( + java.nio.file.Path worldDir, String worldName) { + return WorldChecksumValidator.validateChecksum(worldDir, worldName); + } + + /** + * Verify session continuity (no rollback detected). + * + * @param worldDir path to the world directory + * @param worldName world name + * @param currentTick current game tick + * @return true if no rollback detected + */ + public static boolean verifySessionContinuity( + java.nio.file.Path worldDir, String worldName, long currentTick) { + return !BackupDetector.detectRollback(worldDir, worldName, currentTick); + } + + /** + * Verify spectator lock is properly enforced for a locked player. + * + * @param player the player to check + * @return true if the player is properly locked in spectator mode + */ + public static boolean verifySpectatorLock(net.minecraft.server.level.ServerPlayer player) { + String uuid = player.getStringUUID(); + boolean locked = SpectatorLockHandler.isLocked(uuid); + return verifySpectatorLockState(locked, player.gameMode.getGameModeForPlayer()); + } + + /** + * Testable verifier for spectator-lock state. + */ + public static boolean verifySpectatorLockState( + boolean locked, net.minecraft.world.level.GameType currentGameType) { + if (!locked) { + return true; + } + return currentGameType == net.minecraft.world.level.GameType.SPECTATOR; + } + + /** + * Verify that LAN cheats are blocked for Hardcore worlds. + * + * @param server the Minecraft server + * @return true if no cheat violation detected (cheats are not enabled on a hardcore world) + */ + public static boolean verifyLanCheatBlocked(net.minecraft.server.MinecraftServer server) { + return verifyLanCheatBlockedState( + server.getWorldData().isHardcore(), server.getWorldData().getAllowCommands()); + } + + /** + * Testable verifier for Hardcore/LAN cheat state. + */ + public static boolean verifyLanCheatBlockedState( + boolean hardcore, boolean allowCommands) { + if (!hardcore) return true; + return !allowCommands; + } } diff --git a/src/main/java/com/truthsystems/audit/ErrorLogger.java b/src/main/java/com/truthsystems/audit/ErrorLogger.java index 1e72612..b3c4a26 100644 --- a/src/main/java/com/truthsystems/audit/ErrorLogger.java +++ b/src/main/java/com/truthsystems/audit/ErrorLogger.java @@ -29,7 +29,13 @@ public enum ErrorType { BIJECTIVE_VIOLATION, MERKLE_MISMATCH, CAUSALITY_LOOP, - DEBUGGER_ALERT + DEBUGGER_ALERT, + DEATH_SEAL_VIOLATION, + FILE_INTEGRITY_VIOLATION, + BACKUP_VIOLATION, + SPECTATOR_VIOLATION, + LAN_CHEAT_VIOLATION, + EVENT_CHAIN_VIOLATION } static { @@ -83,6 +89,12 @@ private static String getPrincipleViolated(ErrorType type) { case MERKLE_MISMATCH -> "LOGOS (Cryptographic Verification)"; case CAUSALITY_LOOP -> "CHALCEDON (Divine Order)"; case DEBUGGER_ALERT -> "KENOSIS (Self-Emptying Diagnostics)"; + case DEATH_SEAL_VIOLATION -> "LOGOS (Immutable Death Record)"; + case FILE_INTEGRITY_VIOLATION -> "LOGOS (Cryptographic Verification)"; + case BACKUP_VIOLATION -> "KENOSIS (No Self-Preservation)"; + case SPECTATOR_VIOLATION -> "CHALCEDON (Infrastructure Serves Users)"; + case LAN_CHEAT_VIOLATION -> "CHALCEDON (Divine Order)"; + case EVENT_CHAIN_VIOLATION -> "LOGOS (Hash Chain Integrity)"; }; } diff --git a/src/main/java/com/truthsystems/hardcore/HikConfig.java b/src/main/java/com/truthsystems/hardcore/HikConfig.java new file mode 100644 index 0000000..f6f4759 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/HikConfig.java @@ -0,0 +1,75 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Tractable Truth-Checking) + * CONSTRAINT: All HIK behaviour governed by user-visible config keys + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore; + +import net.minecraftforge.common.ForgeConfigSpec; + +public class HikConfig { + + public static final ForgeConfigSpec.Builder BUILDER = new ForgeConfigSpec.Builder(); + public static final ForgeConfigSpec SPEC; + + public static final ForgeConfigSpec.BooleanValue ENABLE_HIK; + public static final ForgeConfigSpec.ConfigValue ARCHIVE_PATH; + public static final ForgeConfigSpec.IntValue SPECTATOR_GRACE_MINUTES; + public static final ForgeConfigSpec.IntValue VERIFICATION_API_PORT; + public static final ForgeConfigSpec.BooleanValue ALLOW_SEALED_BACKUPS; + public static final ForgeConfigSpec.BooleanValue STRICT_INVENTORY_PROVENANCE; + public static final ForgeConfigSpec.BooleanValue COMPROMISE_ON_LAN_CHEATS; + public static final ForgeConfigSpec.BooleanValue READ_ONLY_ON_EXTERNAL_CHEAT_FLAG; + + static { + BUILDER.comment("Hardcore Integrity Keeper (HIK)").push("hardcore"); + + ENABLE_HIK = BUILDER + .comment("Master switch: enable the Hardcore Integrity Keeper subsystem.") + .define("enable_hik", true); + + ARCHIVE_PATH = BUILDER + .comment("Directory used for archived hardcore world saves.") + .define("archive_path", "saves/archived"); + + SPECTATOR_GRACE_MINUTES = BUILDER + .comment("Minutes a dead player remains in spectator before their world is archived.") + .defineInRange("spectator_grace_minutes", 10, 0, Integer.MAX_VALUE); + + VERIFICATION_API_PORT = BUILDER + .comment("Port for the optional HTTP verification API. 0 = disabled.") + .defineInRange("verification_api_port", 0, 0, 65535); + + ALLOW_SEALED_BACKUPS = BUILDER + .comment("Allow server-side backups even after a death seal is active.") + .define("allow_sealed_backups", true); + + STRICT_INVENTORY_PROVENANCE = BUILDER + .comment("Track and verify full inventory provenance (Phase 2 feature, currently no-op).") + .define("strict_inventory_provenance", false); + + COMPROMISE_ON_LAN_CHEATS = BUILDER + .comment("Mark world as COMPROMISED when LAN cheats are enabled on a Hardcore world.") + .define("compromise_on_lan_cheats", true); + + READ_ONLY_ON_EXTERNAL_CHEAT_FLAG = BUILDER + .comment("Block all block-break/place events when world integrity is not CLEAN.") + .define("read_only_on_external_cheat_flag", true); + + BUILDER.pop(); + SPEC = BUILDER.build(); + } + + private HikConfig() {} + + /** + * Explicit initialization hook used by the mod entrypoint. + * Forge loads the spec through {@code ModLoadingContext}; this method keeps + * HIK startup explicit and testable. + */ + public static void load() { + // no-op; spec registration is handled in TruthSystems + } +} diff --git a/src/main/java/com/truthsystems/hardcore/HikCovenantCompliance.java b/src/main/java/com/truthsystems/hardcore/HikCovenantCompliance.java new file mode 100644 index 0000000..d86ef64 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/HikCovenantCompliance.java @@ -0,0 +1,63 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: AGAPE (Direct Service Without Coercion) + * CONSTRAINT: HIK must expose covenant-compliance checks through the shared pipeline + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore; + +import com.truthsystems.audit.CovenantCompliant; +import com.truthsystems.hardcore.backup.SessionIdManager; +import com.truthsystems.hardcore.soulbind.DeathLogEntry; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Minimal {@link CovenantCompliant} adapter for HIK Part 1. + */ +public class HikCovenantCompliance implements CovenantCompliant { + + @Override + public boolean selfRepair(String errorId, String failingCheck) { + if (errorId == null || errorId.isBlank() || failingCheck == null || failingCheck.isBlank()) { + return false; + } + String normalizedCheck = failingCheck.toLowerCase(); + boolean targetedCheck = normalizedCheck.contains("hik") + || normalizedCheck.contains("death") + || normalizedCheck.contains("integrity") + || normalizedCheck.contains("backup"); + return targetedCheck && passBijectiveTest() && passMerkleVerification() && passCausalCheck(); + } + + @Override + public boolean passBijectiveTest() { + DeathLogEntry entry = DeathLogEntry.createSealed( + "hik-test-uuid", + "HIKTestPlayer", + "hik_test_world", + 1L, + "test", + "session-test"); + return entry.verifyChecksum(); + } + + @Override + public boolean passMerkleVerification() { + try { + Path tempDir = Files.createTempDirectory("hik-merkle-check"); + Files.writeString(tempDir.resolve("level.dat"), "hik"); + String sessionId = SessionIdManager.getOrCreateSessionId(tempDir, "hik_test_world"); + return sessionId != null && !sessionId.isBlank(); + } catch (Exception ex) { + return false; + } + } + + @Override + public boolean passCausalCheck() { + return true; + } +} diff --git a/src/main/java/com/truthsystems/hardcore/backup/BackupDetector.java b/src/main/java/com/truthsystems/hardcore/backup/BackupDetector.java new file mode 100644 index 0000000..b001d01 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/backup/BackupDetector.java @@ -0,0 +1,135 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Forensic Autopsy, No Interpretation) + * CONSTRAINT: Rollback of game time must be detected and flagged immediately + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.backup; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.truthsystems.TruthSystems; +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.audit.ErrorLogger; +import com.truthsystems.hardcore.HikConfig; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * Detects obvious rollback/restore signals by persisting the last-seen game + * tick to {@code /hik_continuity.json} every 600 ticks (≈30 s). + * + *

If the current tick is less than the persisted tick on the next write, a + * rollback is inferred and the world is marked + * {@link IntegrityFlag#ROLLBACK_DETECTED}. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class BackupDetector { + + private static final String CONTINUITY_FILE = "hik_continuity.json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final int TICK_CHECK_INTERVAL = 600; + private static final Map lastTickMap = new HashMap<>(); + + private BackupDetector() {} + + // ---- Forge event handler ---------------------------------------------------- + + @SubscribeEvent + public static void onLevelTick(TickEvent.LevelTickEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (event.phase != TickEvent.Phase.END) return; + if (!(event.level instanceof ServerLevel serverLevel)) return; + if (!serverLevel.getLevelData().isHardcore()) return; + + long gameTick = serverLevel.getGameTime(); + if (gameTick % TICK_CHECK_INTERVAL != 0) return; + + MinecraftServer server = serverLevel.getServer(); + Path worldDir = server.getWorldPath(LevelResource.ROOT).toAbsolutePath(); + String worldName = server.getWorldData().getLevelName(); + onWorldTick(worldDir, worldName, gameTick); + } + + // ---- Public API ------------------------------------------------------------- + + /** + * Saves the current game tick to the continuity file and updates the in-memory map. + */ + public static void recordTick(Path worldDir, String worldName, long currentTick) { + lastTickMap.put(worldName, currentTick); + Path continuityFile = worldDir.resolve(CONTINUITY_FILE); + JsonObject obj = new JsonObject(); + obj.addProperty("last_tick", currentTick); + obj.addProperty("world_name", worldName); + try (Writer writer = Files.newBufferedWriter(continuityFile)) { + GSON.toJson(obj, writer); + } catch (IOException e) { + System.err.println("[HIK] Failed to record tick: " + e.getMessage()); + } + } + + /** + * Returns {@code true} if the given tick is earlier than the persisted value, + * indicating a rollback. + */ + public static boolean detectRollback(Path worldDir, String worldName, long currentTick) { + Long inMemory = lastTickMap.get(worldName); + if (inMemory != null && currentTick < inMemory) return true; + + // Also check persisted value + long persisted = loadPersistedTick(worldDir); + if (persisted > 0 && currentTick < persisted) return true; + + return false; + } + + /** + * Called every 600 ticks. Checks for rollback; if detected, marks world + * compromised. Then records the current tick. + */ + public static void onWorldTick(Path worldDir, String worldName, long gameTick) { + boolean continuityValid = CovenantVerifier.verifySessionContinuity(worldDir, worldName, gameTick); + if (!continuityValid) { + DeathSealManager.markCompromised(worldName, IntegrityFlag.ROLLBACK_DETECTED); + ErrorLogger.logError( + ErrorLogger.ErrorType.BACKUP_VIOLATION, + TruthSystems.MODID, + "session_continuity_verification_failed", + "", + ""); + } + recordTick(worldDir, worldName, gameTick); + } + + // ---- Helpers ---------------------------------------------------------------- + + private static long loadPersistedTick(Path worldDir) { + Path continuityFile = worldDir.resolve(CONTINUITY_FILE); + if (!Files.exists(continuityFile)) return -1; + try (Reader reader = Files.newBufferedReader(continuityFile)) { + JsonObject obj = GSON.fromJson(reader, JsonObject.class); + if (obj == null || !obj.has("last_tick")) return -1; + return obj.get("last_tick").getAsLong(); + } catch (IOException e) { + System.err.println("[HIK] Failed to load persisted tick: " + e.getMessage()); + return -1; + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/backup/SessionIdManager.java b/src/main/java/com/truthsystems/hardcore/backup/SessionIdManager.java new file mode 100644 index 0000000..3395cf6 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/backup/SessionIdManager.java @@ -0,0 +1,93 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Forensic Autopsy, No Interpretation) + * CONSTRAINT: Session IDs provide causal continuity across restarts + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.backup; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Manages per-world session IDs. + * + *

A session ID is a stable UUID generated when a world is first seen by HIK. + * It is persisted to {@code /hik_session.json} and cached in memory. + */ +public class SessionIdManager { + + private static final String SESSION_FILE = "hik_session.json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Map sessionCache = new HashMap<>(); + + private SessionIdManager() {} + + /** + * Returns the cached session ID for {@code worldName}. + * If absent, attempts to load it from disk; if still absent, generates a new + * UUID, persists it, and caches it. + */ + public static String getOrCreateSessionId(Path worldDir, String worldName) { + String cached = sessionCache.get(worldName); + if (cached != null) return cached; + + String loaded = loadSessionId(worldDir); + if (loaded != null) { + sessionCache.put(worldName, loaded); + return loaded; + } + + String newId = UUID.randomUUID().toString(); + persistSessionId(worldDir, newId, System.currentTimeMillis()); + sessionCache.put(worldName, newId); + return newId; + } + + /** + * Writes the session data to {@code /hik_session.json}. + */ + public static void persistSessionId(Path worldDir, String sessionId, long createdAt) { + Path sessionFile = worldDir.resolve(SESSION_FILE); + JsonObject obj = new JsonObject(); + obj.addProperty("session_id", sessionId); + obj.addProperty("created_at", createdAt); + // Determine world name from directory name if available + String worldName = worldDir.getFileName() != null ? worldDir.getFileName().toString() : "unknown"; + obj.addProperty("world_name", worldName); + try (Writer writer = Files.newBufferedWriter(sessionFile)) { + GSON.toJson(obj, writer); + } catch (IOException e) { + System.err.println("[HIK] Failed to persist session ID: " + e.getMessage()); + } + } + + /** + * Reads the session ID from {@code /hik_session.json}. + * + * @return the session ID string, or {@code null} if absent or malformed. + */ + public static String loadSessionId(Path worldDir) { + Path sessionFile = worldDir.resolve(SESSION_FILE); + if (!Files.exists(sessionFile)) return null; + try (Reader reader = Files.newBufferedReader(sessionFile)) { + JsonObject obj = GSON.fromJson(reader, JsonObject.class); + if (obj == null || !obj.has("session_id")) return null; + return obj.get("session_id").getAsString(); + } catch (IOException e) { + System.err.println("[HIK] Failed to load session ID: " + e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/integrity/IntegrityOverlay.java b/src/main/java/com/truthsystems/hardcore/integrity/IntegrityOverlay.java new file mode 100644 index 0000000..f018a2f --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/integrity/IntegrityOverlay.java @@ -0,0 +1,64 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Forensic Autopsy, No Interpretation) + * CONSTRAINT: Client-side HUD must reflect world integrity state in real time + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.integrity; + +import com.truthsystems.hardcore.HikConfig; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.RenderGuiOverlayEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import com.truthsystems.TruthSystems; + +/** + * Client-side HUD overlay that shows the current world integrity state. + * + *

Rendered in the top-left corner: + *

    + *
  • GREEN "HIK: CLEAN" when integrity is {@link IntegrityFlag#CLEAN} + *
  • RED "HIK: <FLAG>" for any other state + *
+ * Not rendered when HIK is disabled or the current world is not Hardcore. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE, value = Dist.CLIENT) +public class IntegrityOverlay { + + @SubscribeEvent + public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + if (!HikConfig.ENABLE_HIK.get()) return; + + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || !mc.level.getLevelData().isHardcore()) return; + + String worldName = "unknown"; + if (mc.getSingleplayerServer() != null) { + worldName = mc.getSingleplayerServer().getWorldData().getLevelName(); + } + // In multiplayer, the server-side world name is not directly accessible from + // the client; the overlay will show UNKNOWN integrity in that case, which is + // the correct safe default (server-side HIK checks still operate normally). + + IntegrityFlag flag = DeathSealManager.getWorldIntegrity(worldName); + + String label; + int color; + if (flag == IntegrityFlag.CLEAN) { + label = "HIK: CLEAN"; + color = 0x55FF55; + } else { + label = "HIK: " + flag.name(); + color = 0xFF5555; + } + + GuiGraphics graphics = event.getGuiGraphics(); + graphics.drawString(mc.font, label, 2, 2, color); + } +} diff --git a/src/main/java/com/truthsystems/hardcore/integrity/LevelDatWatcher.java b/src/main/java/com/truthsystems/hardcore/integrity/LevelDatWatcher.java new file mode 100644 index 0000000..7885a96 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/integrity/LevelDatWatcher.java @@ -0,0 +1,91 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Cryptographic Verification) + * CONSTRAINT: Tampering with level.dat must be detected on every world load + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.integrity; + +import com.truthsystems.TruthSystems; +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.audit.ErrorLogger; +import com.truthsystems.hardcore.HikConfig; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.event.server.ServerStartingEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * Watches level.dat for changes by storing the last-seen hash in memory and + * comparing on demand. + * + *

On server start, delegates to {@link WorldChecksumValidator#initOrValidate}. + * Periodic tamper-checks are triggered by callers (e.g., {@link + * com.truthsystems.hardcore.backup.BackupDetector}). + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class LevelDatWatcher { + + private static final Map lastKnownHashes = new HashMap<>(); + + private LevelDatWatcher() {} + + // ---- Event handler ---------------------------------------------------------- + + @SubscribeEvent + public static void onServerStarting(ServerStartingEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + + MinecraftServer server = event.getServer(); + if (!server.getWorldData().isHardcore()) return; + + Path worldDir = server.getWorldPath(LevelResource.ROOT).toAbsolutePath(); + String worldName = server.getWorldData().getLevelName(); + onWorldLoad(worldDir, worldName); + } + + // ---- Public API ------------------------------------------------------------- + + /** + * Computes the current level.dat hash and delegates to + * {@link WorldChecksumValidator#initOrValidate} for persisted comparison. + */ + public static void onWorldLoad(Path worldDir, String worldName) { + String hash = WorldChecksumValidator.computeLevelDatHash(worldDir); + if (hash != null) { + lastKnownHashes.put(worldName, hash); + } + WorldChecksumValidator.initOrValidate(worldDir, worldName); + if (!CovenantVerifier.verifyWorldFileIntegrity(worldDir, worldName)) { + ErrorLogger.logError( + ErrorLogger.ErrorType.FILE_INTEGRITY_VIOLATION, + TruthSystems.MODID, + "world_file_integrity_verification_failed", + "", + ""); + } + } + + /** + * Computes the current hash and compares it to the last known value. + * If they differ, marks the world as {@link IntegrityFlag#TAMPERED_LEVEL_DAT}. + */ + public static void checkForTampering(Path worldDir, String worldName) { + String currentHash = WorldChecksumValidator.computeLevelDatHash(worldDir); + if (currentHash == null) return; + + String lastHash = lastKnownHashes.get(worldName); + if (lastHash != null && !lastHash.equals(currentHash)) { + DeathSealManager.markCompromised(worldName, IntegrityFlag.TAMPERED_LEVEL_DAT); + } + lastKnownHashes.put(worldName, currentHash); + } +} diff --git a/src/main/java/com/truthsystems/hardcore/integrity/WorldChecksumValidator.java b/src/main/java/com/truthsystems/hardcore/integrity/WorldChecksumValidator.java new file mode 100644 index 0000000..654c5df --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/integrity/WorldChecksumValidator.java @@ -0,0 +1,129 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Cryptographic Verification) + * CONSTRAINT: level.dat checksum must match persisted value; mismatch = TAMPERED + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.integrity; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Computes and validates SHA-256 checksums of key world files. + * + *

The checksum is stored in {@code /hik_integrity.json}. + */ +public class WorldChecksumValidator { + + private static final String CHECKSUM_FILE = "hik_integrity.json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private WorldChecksumValidator() {} + + /** + * On world load: compute level.dat hash. If a stored hash exists, compare it. + * If mismatched, mark world as {@link IntegrityFlag#TAMPERED_LEVEL_DAT}; + * otherwise persist the fresh hash. + */ + public static void initOrValidate(Path worldDir, String worldName) { + String currentHash = computeLevelDatHash(worldDir); + if (currentHash == null) return; + + String persistedHash = loadPersistedChecksum(worldDir); + if (persistedHash == null) { + // First run – persist baseline + persistChecksum(worldDir, currentHash); + DeathSealManager.setWorldIntegrity(worldName, IntegrityFlag.CLEAN); + } else if (!persistedHash.equals(currentHash)) { + DeathSealManager.markCompromised(worldName, IntegrityFlag.TAMPERED_LEVEL_DAT); + } else { + DeathSealManager.setWorldIntegrity(worldName, IntegrityFlag.CLEAN); + } + } + + /** + * Returns the SHA-256 hex digest of {@code /level.dat}, or + * {@code null} if the file cannot be read. + */ + public static String computeLevelDatHash(Path worldDir) { + Path levelDat = worldDir.resolve("level.dat"); + if (!Files.exists(levelDat)) return null; + try { + byte[] bytes = Files.readAllBytes(levelDat); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(bytes); + StringBuilder hex = new StringBuilder(); + for (byte b : hashBytes) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (IOException | NoSuchAlgorithmException e) { + System.err.println("[HIK] Failed to hash level.dat: " + e.getMessage()); + return null; + } + } + + /** + * Writes {@code {"level_dat_hash": ""}} to + * {@code /hik_integrity.json}. + */ + public static void persistChecksum(Path worldDir, String hash) { + Path checksumFile = worldDir.resolve(CHECKSUM_FILE); + JsonObject obj = new JsonObject(); + obj.addProperty("level_dat_hash", hash); + try (Writer writer = Files.newBufferedWriter(checksumFile)) { + GSON.toJson(obj, writer); + } catch (IOException e) { + System.err.println("[HIK] Failed to persist checksum: " + e.getMessage()); + } + } + + /** + * Reads and returns the persisted hash, or {@code null} if the file is absent + * or malformed. + */ + public static String loadPersistedChecksum(Path worldDir) { + Path checksumFile = worldDir.resolve(CHECKSUM_FILE); + if (!Files.exists(checksumFile)) return null; + try (Reader reader = Files.newBufferedReader(checksumFile)) { + JsonObject obj = GSON.fromJson(reader, JsonObject.class); + if (obj == null || !obj.has("level_dat_hash")) return null; + return obj.get("level_dat_hash").getAsString(); + } catch (IOException e) { + System.err.println("[HIK] Failed to load persisted checksum: " + e.getMessage()); + return null; + } + } + + /** + * Returns {@code true} if the current level.dat hash matches the persisted one. + * Marks the world as compromised on mismatch. + */ + public static boolean validateChecksum(Path worldDir, String worldName) { + String current = computeLevelDatHash(worldDir); + if (current == null) return false; + String persisted = loadPersistedChecksum(worldDir); + if (persisted == null) { + persistChecksum(worldDir, current); + return true; + } + if (!persisted.equals(current)) { + DeathSealManager.markCompromised(worldName, IntegrityFlag.TAMPERED_LEVEL_DAT); + return false; + } + return true; + } +} diff --git a/src/main/java/com/truthsystems/hardcore/lan/CheatFlagWatcher.java b/src/main/java/com/truthsystems/hardcore/lan/CheatFlagWatcher.java new file mode 100644 index 0000000..bb6327b --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/lan/CheatFlagWatcher.java @@ -0,0 +1,80 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Forensic Autopsy, No Interpretation) + * CONSTRAINT: Enabling cheats on a Hardcore world is a compromise event + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.lan; + +import com.truthsystems.TruthSystems; +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.audit.ErrorLogger; +import com.truthsystems.hardcore.HikConfig; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import net.minecraft.server.MinecraftServer; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.event.server.ServerStartingEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.util.HashMap; +import java.util.Map; + +/** + * Detects runtime cheat flag activation on Hardcore worlds. + * + *

On {@link ServerStartingEvent}, the initial cheats state is captured. + * Every 100 server ticks, the current state is compared to the last known + * value. If cheats transition from disabled to enabled on a Hardcore world, + * the world is marked {@link IntegrityFlag#LAN_CHEAT_DETECTED}. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class CheatFlagWatcher { + + private static final Map lastCheatsStateByWorld = new HashMap<>(); + private static int tickCounter = 0; + private static final int CHEAT_CHECK_INTERVAL_TICKS = 100; + + private CheatFlagWatcher() {} + + @SubscribeEvent + public static void onServerStarting(ServerStartingEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + MinecraftServer server = event.getServer(); + if (!server.getWorldData().isHardcore()) return; + String worldName = server.getWorldData().getLevelName(); + lastCheatsStateByWorld.put(worldName, server.getWorldData().getAllowCommands()); + } + + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (event.phase != TickEvent.Phase.END) return; + + tickCounter++; + if (tickCounter < CHEAT_CHECK_INTERVAL_TICKS) return; + tickCounter = 0; + + MinecraftServer server = event.getServer(); + if (server == null || !server.getWorldData().isHardcore()) return; + if (!HikConfig.COMPROMISE_ON_LAN_CHEATS.get()) return; + + String worldName = server.getWorldData().getLevelName(); + boolean lastCheatsState = lastCheatsStateByWorld.getOrDefault(worldName, false); + boolean currentCheatsState = server.getWorldData().getAllowCommands(); + if (!lastCheatsState && currentCheatsState) { + DeathSealManager.markCompromised(worldName, IntegrityFlag.LAN_CHEAT_DETECTED); + } + if (!CovenantVerifier.verifyLanCheatBlocked(server)) { + ErrorLogger.logError( + ErrorLogger.ErrorType.LAN_CHEAT_VIOLATION, + TruthSystems.MODID, + "lan_cheat_verification_failed", + "", + ""); + } + lastCheatsStateByWorld.put(worldName, currentCheatsState); + } +} diff --git a/src/main/java/com/truthsystems/hardcore/lan/LanMenuMixin.java b/src/main/java/com/truthsystems/hardcore/lan/LanMenuMixin.java new file mode 100644 index 0000000..c5706fc --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/lan/LanMenuMixin.java @@ -0,0 +1,47 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: CHALCEDON (Divine Order) + * CONSTRAINT: LAN cheats must be disabled on Hardcore worlds to preserve integrity + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.lan; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.ShareToLanScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import com.truthsystems.hardcore.HikConfig; + +/** + * Mixin to disable the LAN "Allow Cheats" option on Hardcore worlds. + * Uses @Inject rather than @Overwrite to be compatible with other mods. + * + * TODO (Phase 2): Also intercept the button click to show a warning message + * explaining that cheats cannot be enabled on Hardcore worlds. + */ +@Mixin(value = ShareToLanScreen.class, priority = 900) +public class LanMenuMixin { + + public static boolean matchesCheatToggleLabel(String label) { + String msg = label.toLowerCase(); + return msg.contains("allow") && msg.contains("cheat"); + } + + @Inject(method = "init", at = @At("RETURN")) + private void onInit(CallbackInfo ci) { + if (!HikConfig.ENABLE_HIK.get()) return; + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null || !mc.level.getLevelData().isHardcore()) return; + + ShareToLanScreen self = (ShareToLanScreen) (Object) this; + self.children().stream() + .filter(w -> w instanceof AbstractWidget) + .map(w -> (AbstractWidget) w) + .filter(btn -> matchesCheatToggleLabel(btn.getMessage().getString())) + .forEach(btn -> btn.active = false); + } +} diff --git a/src/main/java/com/truthsystems/hardcore/lan/MixinConfigPlugin.java b/src/main/java/com/truthsystems/hardcore/lan/MixinConfigPlugin.java new file mode 100644 index 0000000..7544273 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/lan/MixinConfigPlugin.java @@ -0,0 +1,60 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: CHALCEDON (Divine Order) + * CONSTRAINT: LAN-hardening mixins must fail closed without breaking modpack compatibility + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.lan; + +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +import java.util.List; +import java.util.Set; + +/** + * Minimal mixin config plugin that skips HIK LAN mixins if the target class is + * not present in the runtime environment. + */ +public class MixinConfigPlugin implements IMixinConfigPlugin { + + @Override + public void onLoad(String mixinPackage) { + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + try { + Class.forName(targetClassName, false, MixinConfigPlugin.class.getClassLoader()); + return true; + } catch (ClassNotFoundException ex) { + return false; + } + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) { + } + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, + IMixinInfo mixinInfo) { + } + + @Override + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, + IMixinInfo mixinInfo) { + } +} diff --git a/src/main/java/com/truthsystems/hardcore/lan/ReadOnlyWorldEnforcer.java b/src/main/java/com/truthsystems/hardcore/lan/ReadOnlyWorldEnforcer.java new file mode 100644 index 0000000..8be9a00 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/lan/ReadOnlyWorldEnforcer.java @@ -0,0 +1,64 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Truth-Only) + * CONSTRAINT: A compromised world must become read-only to prevent further corruption + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.lan; + +import com.truthsystems.TruthSystems; +import com.truthsystems.hardcore.HikConfig; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraftforge.event.level.BlockEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +/** + * Enforces read-only mode on Hardcore worlds whose integrity is not + * {@link IntegrityFlag#CLEAN}. + * + *

Block-break and block-place events are cancelled when + * {@link HikConfig#READ_ONLY_ON_EXTERNAL_CHEAT_FLAG} is enabled and the + * world integrity flag is anything other than {@code CLEAN}. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class ReadOnlyWorldEnforcer { + + private ReadOnlyWorldEnforcer() {} + + @SubscribeEvent + public static void onBlockBreak(BlockEvent.BreakEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (!HikConfig.READ_ONLY_ON_EXTERNAL_CHEAT_FLAG.get()) return; + + Level level = (Level) event.getLevel(); + if (!level.getLevelData().isHardcore()) return; + if (!(level instanceof ServerLevel serverLevel)) return; + + String worldName = serverLevel.getServer().getWorldData().getLevelName(); + IntegrityFlag flag = DeathSealManager.getWorldIntegrity(worldName); + if (flag != IntegrityFlag.CLEAN) { + event.setCanceled(true); + } + } + + @SubscribeEvent + public static void onBlockPlace(BlockEvent.EntityPlaceEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (!HikConfig.READ_ONLY_ON_EXTERNAL_CHEAT_FLAG.get()) return; + + Level level = (Level) event.getLevel(); + if (!level.getLevelData().isHardcore()) return; + if (!(level instanceof ServerLevel serverLevel)) return; + + String worldName = serverLevel.getServer().getWorldData().getLevelName(); + IntegrityFlag flag = DeathSealManager.getWorldIntegrity(worldName); + if (flag != IntegrityFlag.CLEAN) { + event.setCanceled(true); + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/soulbind/DeathLogEntry.java b/src/main/java/com/truthsystems/hardcore/soulbind/DeathLogEntry.java new file mode 100644 index 0000000..58dfd2e --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/soulbind/DeathLogEntry.java @@ -0,0 +1,118 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Cryptographic Verification) + * CONSTRAINT: Death records are immutable once sealed and checksum-protected + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.soulbind; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.nio.charset.StandardCharsets; + +/** + * Immutable record of a player's death in a Hardcore world. + * The {@code checksum} field is a SHA-256 digest of the key fields, + * allowing tamper detection after the entry is persisted. + */ +public class DeathLogEntry { + + private String playerUuid; + private String playerName; + private String worldName; + private long deathTimestamp; + private String deathCause; + private String sessionId; + private String checksum; + private boolean locked; + + /** Default constructor required by Gson. */ + public DeathLogEntry() {} + + public DeathLogEntry(String playerUuid, String playerName, String worldName, + long deathTimestamp, String deathCause, + String sessionId, String checksum, boolean locked) { + this.playerUuid = playerUuid; + this.playerName = playerName; + this.worldName = worldName; + this.deathTimestamp = deathTimestamp; + this.deathCause = deathCause; + this.sessionId = sessionId; + this.checksum = checksum; + this.locked = locked; + } + + /** + * Factory method that creates a new sealed entry with the checksum pre-computed. + * Prefer this over calling {@code computeChecksum()} manually. + */ + public static DeathLogEntry createSealed(String playerUuid, String playerName, + String worldName, long deathTimestamp, + String deathCause, String sessionId) { + DeathLogEntry entry = new DeathLogEntry( + playerUuid, playerName, worldName, + deathTimestamp, deathCause, sessionId, "", true); + entry.computeChecksum(); + return entry; + } + + // ---- Getters ---------------------------------------------------------------- + + public String getPlayerUuid() { return playerUuid; } + public String getPlayerName() { return playerName; } + public String getWorldName() { return worldName; } + public long getDeathTimestamp() { return deathTimestamp; } + public String getDeathCause() { return deathCause; } + public String getSessionId() { return sessionId; } + public String getChecksum() { return checksum; } + public boolean isLocked() { return locked; } + + // ---- Setters ---------------------------------------------------------------- + + public void setPlayerUuid(String playerUuid) { this.playerUuid = playerUuid; } + public void setPlayerName(String playerName) { this.playerName = playerName; } + public void setWorldName(String worldName) { this.worldName = worldName; } + public void setDeathTimestamp(long deathTimestamp) { this.deathTimestamp = deathTimestamp; } + public void setDeathCause(String deathCause) { this.deathCause = deathCause; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + public void setChecksum(String checksum) { this.checksum = checksum; } + public void setLocked(boolean locked) { this.locked = locked; } + + // ---- Checksum --------------------------------------------------------------- + + /** + * Computes SHA-256 of the key fields and stores it in {@code checksum}. + * Fields: playerUuid | playerName | worldName | deathTimestamp | deathCause | sessionId | locked + */ + public void computeChecksum() { + this.checksum = computeChecksumValue(); + } + + /** + * Returns {@code true} if the stored checksum matches a freshly computed one. + */ + public boolean verifyChecksum() { + if (this.checksum == null || this.checksum.isEmpty()) return false; + return this.checksum.equals(computeChecksumValue()); + } + + private String computeChecksumValue() { + String raw = playerUuid + "|" + playerName + "|" + worldName + "|" + + deathTimestamp + "|" + deathCause + "|" + sessionId + "|" + locked; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte b : hashBytes) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is always available on Java 17 + throw new IllegalStateException( + "SHA-256 not available on runtime '" + System.getProperty("java.runtime.version") + "'", + e); + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/soulbind/DeathSealManager.java b/src/main/java/com/truthsystems/hardcore/soulbind/DeathSealManager.java new file mode 100644 index 0000000..9265139 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/soulbind/DeathSealManager.java @@ -0,0 +1,123 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Cryptographic Verification) + * CONSTRAINT: Death seals persist across restarts; checksum mismatch triggers COMPROMISED + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.soulbind; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.truthsystems.audit.ErrorLogger; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * Persists and retrieves {@link DeathLogEntry} files for Hardcore worlds. + * + *

Files are stored at {@code /soulbind/.json}. + * An in-memory {@link IntegrityFlag} map is maintained per world name. + */ +public class DeathSealManager { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Map worldIntegrityMap = new HashMap<>(); + + private DeathSealManager() {} + + // ---- Persistence ------------------------------------------------------------ + + /** + * Writes a death log entry to {@code /soulbind/.json}. + */ + public static void sealDeath(Path worldDir, DeathLogEntry entry) { + Path soulbindDir = worldDir.resolve("soulbind"); + try { + Files.createDirectories(soulbindDir); + Path entryFile = soulbindDir.resolve(entry.getPlayerUuid() + ".json"); + try (Writer writer = Files.newBufferedWriter(entryFile)) { + GSON.toJson(entry, writer); + } + } catch (IOException e) { + ErrorLogger.logError(ErrorLogger.ErrorType.FILE_INTEGRITY_VIOLATION, + "HIK/DeathSealManager", "sealDeath_io_error", "", ""); + System.err.println("[HIK] Failed to seal death for " + entry.getPlayerUuid() + ": " + e.getMessage()); + } + } + + /** + * Loads a death log entry for the given UUID. + * + * @return the entry, or {@code null} if not found. Marks world as + * {@link IntegrityFlag#TAMPERED_DEATH_LOG} if checksum fails. + */ + public static DeathLogEntry loadSeal(Path worldDir, String uuid) { + Path entryFile = worldDir.resolve("soulbind").resolve(uuid + ".json"); + if (!Files.exists(entryFile)) return null; + try (Reader reader = Files.newBufferedReader(entryFile)) { + DeathLogEntry entry = GSON.fromJson(reader, DeathLogEntry.class); + if (entry == null) return null; + if (!entry.verifyChecksum()) { + String worldName = worldDir.getFileName() != null + ? worldDir.getFileName().toString() : "unknown"; + markCompromised(worldName, IntegrityFlag.TAMPERED_DEATH_LOG); + return entry; + } + return entry; + } catch (IOException e) { + System.err.println("[HIK] Failed to load seal for " + uuid + ": " + e.getMessage()); + return null; + } + } + + /** + * Returns {@code true} if the player has a valid, locked death seal. + */ + public static boolean isSealed(Path worldDir, String uuid) { + DeathLogEntry entry = loadSeal(worldDir, uuid); + return entry != null && entry.isLocked(); + } + + // ---- Integrity map ---------------------------------------------------------- + + /** + * Returns the current integrity flag for the named world. + * Defaults to {@link IntegrityFlag#UNKNOWN} if not set. + */ + public static IntegrityFlag getWorldIntegrity(String worldName) { + return worldIntegrityMap.getOrDefault(worldName, IntegrityFlag.UNKNOWN); + } + + /** Explicitly sets the integrity flag for a world. */ + public static void setWorldIntegrity(String worldName, IntegrityFlag flag) { + worldIntegrityMap.put(worldName, flag); + } + + /** + * Sets the integrity flag to the given reason and logs the event via + * {@link ErrorLogger}. + */ + public static void markCompromised(String worldName, IntegrityFlag reason) { + worldIntegrityMap.put(worldName, reason); + ErrorLogger.logError(mapErrorType(reason), + "HIK/DeathSealManager", reason.name(), "", ""); + System.err.println("[HIK] World '" + worldName + "' marked " + reason.name()); + } + + static ErrorLogger.ErrorType mapErrorType(IntegrityFlag reason) { + return switch (reason) { + case TAMPERED_DEATH_LOG -> ErrorLogger.ErrorType.DEATH_SEAL_VIOLATION; + case TAMPERED_LEVEL_DAT -> ErrorLogger.ErrorType.FILE_INTEGRITY_VIOLATION; + case ROLLBACK_DETECTED -> ErrorLogger.ErrorType.BACKUP_VIOLATION; + case LAN_CHEAT_DETECTED -> ErrorLogger.ErrorType.LAN_CHEAT_VIOLATION; + default -> ErrorLogger.ErrorType.MERKLE_MISMATCH; + }; + } +} diff --git a/src/main/java/com/truthsystems/hardcore/soulbind/IntegrityFlag.java b/src/main/java/com/truthsystems/hardcore/soulbind/IntegrityFlag.java new file mode 100644 index 0000000..866cad0 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/soulbind/IntegrityFlag.java @@ -0,0 +1,21 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: LOGOS (Forensic Autopsy, No Interpretation) + * CONSTRAINT: Every integrity state must be explicitly named and non-ambiguous + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.soulbind; + +/** + * Represents the integrity state of a Hardcore world as tracked by HIK. + */ +public enum IntegrityFlag { + CLEAN, + COMPROMISED, + TAMPERED_DEATH_LOG, + TAMPERED_LEVEL_DAT, + ROLLBACK_DETECTED, + LAN_CHEAT_DETECTED, + UNKNOWN +} diff --git a/src/main/java/com/truthsystems/hardcore/soulbind/SoulbindEventHandler.java b/src/main/java/com/truthsystems/hardcore/soulbind/SoulbindEventHandler.java new file mode 100644 index 0000000..9d0b6d7 --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/soulbind/SoulbindEventHandler.java @@ -0,0 +1,95 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: CHALCEDON (Divine Order) + * CONSTRAINT: Death in Hardcore is permanent; the seal must be enforced at every login + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.soulbind; + +import com.truthsystems.TruthSystems; +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.audit.ErrorLogger; +import com.truthsystems.hardcore.HikConfig; +import com.truthsystems.hardcore.backup.SessionIdManager; +import com.truthsystems.hardcore.spectator.ArchiveCountdown; +import com.truthsystems.hardcore.spectator.SpectatorLockHandler; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.GameType; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.event.entity.living.LivingDeathEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.nio.file.Path; + +/** + * Forge event handler that creates death seals on Hardcore death and + * enforces spectator mode on login for sealed players. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class SoulbindEventHandler { + + @SubscribeEvent + public static void onLivingDeath(LivingDeathEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (!(event.getEntity() instanceof ServerPlayer player)) return; + + ServerLevel level = (ServerLevel) player.level(); + if (!level.getLevelData().isHardcore()) return; + + MinecraftServer server = level.getServer(); + Path worldDir = server.getWorldPath(LevelResource.ROOT).toAbsolutePath(); + String uuid = player.getStringUUID(); + String name = player.getName().getString(); + String worldName = server.getWorldData().getLevelName(); + String cause = event.getSource().getLocalizedDeathMessage(player).getString(); + String sessionId = SessionIdManager.getOrCreateSessionId(worldDir, worldName); + + DeathLogEntry entry = DeathLogEntry.createSealed( + uuid, name, worldName, + System.currentTimeMillis(), cause, sessionId); + + DeathSealManager.sealDeath(worldDir, entry); + DeathSealManager.setWorldIntegrity(worldName, IntegrityFlag.CLEAN); + SpectatorLockHandler.lockPlayer(uuid); + ArchiveCountdown.startCountdown(uuid, worldName); + + if (!CovenantVerifier.verifyDeathSealIntegrity(worldDir, uuid, worldName)) { + ErrorLogger.logError( + ErrorLogger.ErrorType.DEATH_SEAL_VIOLATION, + TruthSystems.MODID, + "death_seal_write_verification_failed", + "", + ""); + } + + player.setGameMode(GameType.SPECTATOR); + } + + @SubscribeEvent + public static void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (!(event.getEntity() instanceof ServerPlayer player)) return; + + ServerLevel level = (ServerLevel) player.serverLevel(); + if (!level.getLevelData().isHardcore()) return; + + MinecraftServer server = player.getServer(); + if (server == null) return; + + Path worldDir = server.getWorldPath(LevelResource.ROOT).toAbsolutePath(); + String uuid = player.getStringUUID(); + + if (DeathSealManager.isSealed(worldDir, uuid)) { + SpectatorLockHandler.lockPlayer(uuid); + player.setGameMode(GameType.SPECTATOR); + player.displayClientMessage( + Component.literal("[HIK] Your death is sealed. You are in spectator mode."), false); + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/spectator/ArchiveCountdown.java b/src/main/java/com/truthsystems/hardcore/spectator/ArchiveCountdown.java new file mode 100644 index 0000000..03b398d --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/spectator/ArchiveCountdown.java @@ -0,0 +1,109 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: CHALCEDON (Divine Order) + * CONSTRAINT: Archived worlds transition after grace period; no indefinite spectator limbo + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.spectator; + +import com.truthsystems.TruthSystems; +import com.truthsystems.hardcore.HikConfig; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Manages the countdown before an archived world is flagged for archival. + * + *

When a player dies in Hardcore mode, a countdown is started equal to + * {@link HikConfig#SPECTATOR_GRACE_MINUTES} minutes. When the countdown + * expires, {@link #checkAndArchive} logs the archive-pending marker. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class ArchiveCountdown { + + private static final Map countdownEndTimes = new HashMap<>(); + private static final int ARCHIVE_CHECK_INTERVAL_TICKS = 100; + /** Maps player UUID -> world name so archive logging stays tied to the correct world. */ + private static final Map countdownWorldNames = new HashMap<>(); + private static int tickCounter = 0; + + private ArchiveCountdown() {} + + // ---- Countdown management --------------------------------------------------- + + /** + * Records the expiry time for the given player UUID. + * Overwrites any existing countdown. + */ + public static void startCountdown(String uuid) { + startCountdown(uuid, "unknown"); + } + + /** + * Records the expiry time for the given player UUID, associating it with a world name. + * Overwrites any existing countdown. + */ + public static void startCountdown(String uuid, String worldName) { + long endTime = System.currentTimeMillis() + + (long) HikConfig.SPECTATOR_GRACE_MINUTES.get() * 60_000L; + countdownEndTimes.put(uuid, endTime); + countdownWorldNames.put(uuid, worldName); + } + + /** + * Returns {@code true} if a countdown exists for this UUID and has expired. + */ + public static boolean isExpired(String uuid) { + Long endTime = countdownEndTimes.get(uuid); + if (endTime == null) return false; + return System.currentTimeMillis() > endTime; + } + + /** + * Returns the remaining time in seconds, or {@code 0} if expired or not started. + */ + public static long getRemainingSeconds(String uuid) { + Long endTime = countdownEndTimes.get(uuid); + if (endTime == null) return 0; + long remaining = endTime - System.currentTimeMillis(); + return remaining > 0 ? remaining / 1000 : 0; + } + + /** + * If the countdown has expired, logs an "archive pending" notice and removes + * the entry from the countdown map. + */ + public static void checkAndArchive(String uuid, String worldName) { + if (!isExpired(uuid)) return; + countdownEndTimes.remove(uuid); + countdownWorldNames.remove(uuid); + System.out.println("[HIK] ARCHIVE PENDING: player=" + uuid + + " world=" + worldName + + " archive_path=" + HikConfig.ARCHIVE_PATH.get()); + } + + // ---- Event handler ---------------------------------------------------------- + + @SubscribeEvent + public static void onServerTick(TickEvent.ServerTickEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (event.phase != TickEvent.Phase.END) return; + tickCounter++; + if (tickCounter < ARCHIVE_CHECK_INTERVAL_TICKS) return; + tickCounter = 0; + + // Iterate over a snapshot to avoid ConcurrentModificationException + for (String uuid : List.copyOf(countdownEndTimes.keySet())) { + if (isExpired(uuid)) { + String worldName = countdownWorldNames.getOrDefault(uuid, "unknown"); + checkAndArchive(uuid, worldName); + } + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/spectator/CommandInterceptor.java b/src/main/java/com/truthsystems/hardcore/spectator/CommandInterceptor.java new file mode 100644 index 0000000..987b35d --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/spectator/CommandInterceptor.java @@ -0,0 +1,69 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: CHALCEDON (Divine Order) + * CONSTRAINT: Locked players may not escape the death seal via commands + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.spectator; + +import com.truthsystems.TruthSystems; +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.audit.ErrorLogger; +import com.truthsystems.hardcore.HikConfig; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraftforge.event.CommandEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.util.List; + +/** + * Intercepts commands for players under a spectator death-seal lock. + * + *

The following commands are blocked for locked players: + * {@code gamemode}, {@code gm}, {@code give}, {@code tp}, {@code teleport}, + * {@code kill}. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class CommandInterceptor { + + private static final List BLOCKED_COMMANDS = + List.of("gamemode", "gm", "give", "tp", "teleport", "kill"); + + private CommandInterceptor() {} + + public static boolean isBlockedCommand(String input) { + String normalized = input.toLowerCase(); + String command = normalized.startsWith("/") ? normalized.substring(1) : normalized; + int spaceIdx = command.indexOf(' '); + String commandName = spaceIdx >= 0 ? command.substring(0, spaceIdx) : command; + return BLOCKED_COMMANDS.contains(commandName); + } + + @SubscribeEvent + public static void onCommand(CommandEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + + var source = event.getParseResults().getContext().getSource(); + if (!(source.getEntity() instanceof ServerPlayer player)) return; + if (!player.level().getLevelData().isHardcore()) return; + if (!SpectatorLockHandler.isLocked(player.getStringUUID())) return; + + String input = event.getParseResults().getReader().getString(); + if (isBlockedCommand(input)) { + if (!CovenantVerifier.verifySpectatorLock(player)) { + ErrorLogger.logError( + ErrorLogger.ErrorType.SPECTATOR_VIOLATION, + TruthSystems.MODID, + "spectator_lock_verification_failed", + "", + ""); + } + event.setCanceled(true); + player.displayClientMessage( + Component.literal("[HIK] Command blocked: death seal is active."), false); + } + } +} diff --git a/src/main/java/com/truthsystems/hardcore/spectator/SpectatorLockHandler.java b/src/main/java/com/truthsystems/hardcore/spectator/SpectatorLockHandler.java new file mode 100644 index 0000000..5bed8aa --- /dev/null +++ b/src/main/java/com/truthsystems/hardcore/spectator/SpectatorLockHandler.java @@ -0,0 +1,76 @@ +/* + * COVENANT: Σ_LORA_COVENANT v1.0 + * SYSTEM: Hardcore Integrity Keeper (HIK) + * PRINCIPLE: CHALCEDON (Divine Order) + * CONSTRAINT: Dead players must remain in spectator; the lock is non-negotiable + * FILE_HASH: [SHA256_AUTOGENERATED] + */ +package com.truthsystems.hardcore.spectator; + +import com.truthsystems.TruthSystems; +import com.truthsystems.hardcore.HikConfig; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.GameType; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Enforces spectator mode for players whose death has been sealed by HIK. + * + *

A per-tick enforcement is applied so that any attempt to change game mode + * (e.g., via mods or commands that bypass the command interceptor) is reverted. + */ +@Mod.EventBusSubscriber(modid = TruthSystems.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) +public class SpectatorLockHandler { + + private static final Set lockedPlayers = + Collections.synchronizedSet(new HashSet<>()); + + private SpectatorLockHandler() {} + + // ---- Lock management -------------------------------------------------------- + + /** Adds the player UUID to the spectator lock set. */ + public static void lockPlayer(String uuid) { + lockedPlayers.add(uuid); + } + + /** Removes the player UUID from the spectator lock set. */ + public static void unlockPlayer(String uuid) { + lockedPlayers.remove(uuid); + } + + /** Returns {@code true} if the UUID is in the spectator lock set. */ + public static boolean isLocked(String uuid) { + return lockedPlayers.contains(uuid); + } + + /** + * Sets the player to {@link GameType#SPECTATOR} if they are in the lock set + * and currently not already in spectator mode. + */ + public static void enforceSpectator(ServerPlayer player) { + if (isLocked(player.getStringUUID()) + && player.gameMode.getGameModeForPlayer() != GameType.SPECTATOR) { + player.setGameMode(GameType.SPECTATOR); + } + } + + // ---- Event handler ---------------------------------------------------------- + + @SubscribeEvent + public static void onPlayerTick(TickEvent.PlayerTickEvent event) { + if (!HikConfig.ENABLE_HIK.get()) return; + if (event.phase != TickEvent.Phase.END) return; + if (!(event.player instanceof ServerPlayer player)) return; + if (!player.level().getLevelData().isHardcore()) return; + if (isLocked(player.getStringUUID())) { + enforceSpectator(player); + } + } +} diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index d7c7e79..ea3264e 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -24,3 +24,5 @@ Four orthogonal truth-verification systems: versionRange="[1.20.1]" ordering="NONE" side="BOTH" +[[mixins]] + config="truthsystems.mixins.json" diff --git a/src/main/resources/truthsystems.mixins.json b/src/main/resources/truthsystems.mixins.json new file mode 100644 index 0000000..f39908e --- /dev/null +++ b/src/main/resources/truthsystems.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "com.truthsystems.hardcore.lan", + "plugin": "com.truthsystems.hardcore.lan.MixinConfigPlugin", + "compatibilityLevel": "JAVA_17", + "mixins": [], + "client": [ + "LanMenuMixin" + ], + "server": [], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/test/java/com/truthsystems/audit/CovenantVerifierHikTest.java b/src/test/java/com/truthsystems/audit/CovenantVerifierHikTest.java new file mode 100644 index 0000000..26602d4 --- /dev/null +++ b/src/test/java/com/truthsystems/audit/CovenantVerifierHikTest.java @@ -0,0 +1,33 @@ +package com.truthsystems.audit; + +import net.minecraft.world.level.GameType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CovenantVerifierHikTest { + + @Test + @DisplayName("HIK verifier: spectator lock requires spectator mode when locked") + void verifySpectatorLockStateChecksLockedPlayers() { + assertTrue(CovenantVerifier.verifySpectatorLockState(false, GameType.SURVIVAL), + "FALSIFIED: Unlocked player failed spectator verification"); + assertTrue(CovenantVerifier.verifySpectatorLockState(true, GameType.SPECTATOR), + "FALSIFIED: Locked spectator player failed verification"); + assertFalse(CovenantVerifier.verifySpectatorLockState(true, GameType.SURVIVAL), + "FALSIFIED: Locked survival player passed verification"); + } + + @Test + @DisplayName("HIK verifier: LAN cheats are forbidden only for Hardcore worlds") + void verifyLanCheatBlockedStateChecksHardcore() { + assertTrue(CovenantVerifier.verifyLanCheatBlockedState(false, true), + "FALSIFIED: Non-hardcore world failed LAN cheat verification"); + assertTrue(CovenantVerifier.verifyLanCheatBlockedState(true, false), + "FALSIFIED: Hardcore world without cheats failed verification"); + assertFalse(CovenantVerifier.verifyLanCheatBlockedState(true, true), + "FALSIFIED: Hardcore world with cheats enabled passed verification"); + } +} diff --git a/src/test/java/com/truthsystems/hardcore/backup/BackupDetectorTest.java b/src/test/java/com/truthsystems/hardcore/backup/BackupDetectorTest.java new file mode 100644 index 0000000..3dac418 --- /dev/null +++ b/src/test/java/com/truthsystems/hardcore/backup/BackupDetectorTest.java @@ -0,0 +1,53 @@ +package com.truthsystems.hardcore.backup; + +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BackupDetectorTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("HIK-INV-007: rollback is detected when game time moves backwards") + void rollbackDetectionTriggersOnLowerTick() throws Exception { + Path worldDir = Files.createDirectories(tempDir.resolve("rollback_world")); + BackupDetector.recordTick(worldDir, "rollback_world", 600L); + + assertTrue(BackupDetector.detectRollback(worldDir, "rollback_world", 100L), + "FALSIFIED: Lower game tick was not recognized as rollback"); + BackupDetector.onWorldTick(worldDir, "rollback_world", 100L); + assertFalse(CovenantVerifier.verifySessionContinuity(worldDir, "rollback_world", 100L), + "FALSIFIED: CovenantVerifier accepted a rolled-back session"); + assertEquals(IntegrityFlag.ROLLBACK_DETECTED, DeathSealManager.getWorldIntegrity("rollback_world"), + "FALSIFIED: Rollback did not mark the world compromised"); + } + + @Test + @DisplayName("HIK-INV-009: session IDs stay stable per world and differ across worlds") + void sessionIdsAreStableAndUnique() throws Exception { + Path worldA = Files.createDirectories(tempDir.resolve("world_a")); + Path worldB = Files.createDirectories(tempDir.resolve("world_b")); + + String worldAFirst = SessionIdManager.getOrCreateSessionId(worldA, "world_a"); + String worldASecond = SessionIdManager.getOrCreateSessionId(worldA, "world_a"); + String worldBId = SessionIdManager.getOrCreateSessionId(worldB, "world_b"); + + assertEquals(worldAFirst, worldASecond, + "FALSIFIED: Session ID changed within the same world"); + assertNotEquals(worldAFirst, worldBId, + "FALSIFIED: Different worlds shared the same session ID"); + } +} diff --git a/src/test/java/com/truthsystems/hardcore/integrity/WorldChecksumValidatorTest.java b/src/test/java/com/truthsystems/hardcore/integrity/WorldChecksumValidatorTest.java new file mode 100644 index 0000000..f1d8408 --- /dev/null +++ b/src/test/java/com/truthsystems/hardcore/integrity/WorldChecksumValidatorTest.java @@ -0,0 +1,53 @@ +package com.truthsystems.hardcore.integrity; + +import com.truthsystems.audit.CovenantVerifier; +import com.truthsystems.hardcore.soulbind.DeathSealManager; +import com.truthsystems.hardcore.soulbind.IntegrityFlag; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorldChecksumValidatorTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("HIK-INV-004: level.dat checksum persists and validates") + void levelDatChecksumRoundTrip() throws Exception { + Path worldDir = Files.createDirectories(tempDir.resolve("clean_world")); + Files.writeString(worldDir.resolve("level.dat"), "clean"); + + WorldChecksumValidator.initOrValidate(worldDir, "clean_world"); + + String persisted = WorldChecksumValidator.loadPersistedChecksum(worldDir); + assertNotNull(persisted, "FALSIFIED: level.dat hash was not persisted"); + assertTrue(CovenantVerifier.verifyWorldFileIntegrity(worldDir, "clean_world"), + "FALSIFIED: CovenantVerifier rejected a clean level.dat"); + assertEquals(IntegrityFlag.CLEAN, DeathSealManager.getWorldIntegrity("clean_world"), + "FALSIFIED: Clean world was not marked CLEAN"); + } + + @Test + @DisplayName("HIK-INV-005: level.dat tampering is detected") + void levelDatTamperDetected() throws Exception { + Path worldDir = Files.createDirectories(tempDir.resolve("tampered_world")); + Files.writeString(worldDir.resolve("level.dat"), "before"); + WorldChecksumValidator.initOrValidate(worldDir, "tampered_world"); + + Files.writeString(worldDir.resolve("level.dat"), "after"); + + assertFalse(WorldChecksumValidator.validateChecksum(worldDir, "tampered_world"), + "FALSIFIED: Modified level.dat still verified"); + assertEquals(IntegrityFlag.TAMPERED_LEVEL_DAT, DeathSealManager.getWorldIntegrity("tampered_world"), + "FALSIFIED: Modified level.dat did not mark the world compromised"); + } +} diff --git a/src/test/java/com/truthsystems/hardcore/lan/MixinCompatibilityTest.java b/src/test/java/com/truthsystems/hardcore/lan/MixinCompatibilityTest.java new file mode 100644 index 0000000..8538c41 --- /dev/null +++ b/src/test/java/com/truthsystems/hardcore/lan/MixinCompatibilityTest.java @@ -0,0 +1,20 @@ +package com.truthsystems.hardcore.lan; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MixinCompatibilityTest { + + @Test + @DisplayName("HIK-INV-017: LAN button filter matches only Allow Cheats labels") + void lanMenuFilterMatchesVanillaText() { + assertTrue(LanMenuMixin.matchesCheatToggleLabel("Allow Cheats: OFF")); + assertTrue(LanMenuMixin.matchesCheatToggleLabel("allow cheats: on")); + assertFalse(LanMenuMixin.matchesCheatToggleLabel("Start LAN World")); + assertFalse(LanMenuMixin.matchesCheatToggleLabel("Game Mode: Survival")); + assertFalse(LanMenuMixin.matchesCheatToggleLabel("Allow something else")); + } +} diff --git a/src/test/java/com/truthsystems/hardcore/soulbind/DeathSealManagerTest.java b/src/test/java/com/truthsystems/hardcore/soulbind/DeathSealManagerTest.java new file mode 100644 index 0000000..a8545f9 --- /dev/null +++ b/src/test/java/com/truthsystems/hardcore/soulbind/DeathSealManagerTest.java @@ -0,0 +1,58 @@ +package com.truthsystems.hardcore.soulbind; + +import com.truthsystems.audit.CovenantVerifier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeathSealManagerTest { + + @TempDir + Path tempDir; + + @Test + @DisplayName("HIK-INV-001: sealed death logs survive a round trip") + void sealedDeathLogRoundTrip() throws Exception { + Path worldDir = Files.createDirectories(tempDir.resolve("test_world")); + DeathLogEntry entry = DeathLogEntry.createSealed( + "uuid-1", "TestPlayer", "test_world", 123L, "fell", "session-1"); + + DeathSealManager.sealDeath(worldDir, entry); + + DeathLogEntry loaded = DeathSealManager.loadSeal(worldDir, "uuid-1"); + assertNotNull(loaded, "FALSIFIED: Sealed death log could not be reloaded"); + assertTrue(loaded.verifyChecksum(), "FALSIFIED: Round-tripped death log checksum no longer verifies"); + assertTrue(DeathSealManager.isSealed(worldDir, "uuid-1"), + "FALSIFIED: Locked player was not treated as sealed after reload"); + assertTrue(CovenantVerifier.verifyDeathSealIntegrity(worldDir, "uuid-1", "test_world"), + "FALSIFIED: CovenantVerifier rejected a valid death seal"); + } + + @Test + @DisplayName("HIK-INV-002: death log tampering is detected") + void tamperedDeathLogMarksWorldCompromised() throws Exception { + Path worldDir = Files.createDirectories(tempDir.resolve("tamper_world")); + DeathLogEntry entry = DeathLogEntry.createSealed( + "uuid-2", "TestPlayer", "tamper_world", 456L, "fell", "session-2"); + DeathSealManager.sealDeath(worldDir, entry); + + Path sealFile = worldDir.resolve("soulbind").resolve("uuid-2.json"); + String tampered = Files.readString(sealFile).replace("\"locked\": true", "\"locked\": false"); + Files.writeString(sealFile, tampered); + + DeathLogEntry loaded = DeathSealManager.loadSeal(worldDir, "uuid-2"); + assertNotNull(loaded, "FALSIFIED: Tampered death log vanished instead of being inspected"); + assertFalse(loaded.verifyChecksum(), + "FALSIFIED: Tampered death log still passed checksum verification"); + assertEquals(IntegrityFlag.TAMPERED_DEATH_LOG, DeathSealManager.getWorldIntegrity("tamper_world"), + "FALSIFIED: Tampered death log did not mark the world compromised"); + } +} diff --git a/src/test/java/com/truthsystems/hardcore/spectator/SpectatorLockHandlerTest.java b/src/test/java/com/truthsystems/hardcore/spectator/SpectatorLockHandlerTest.java new file mode 100644 index 0000000..7d3b8a9 --- /dev/null +++ b/src/test/java/com/truthsystems/hardcore/spectator/SpectatorLockHandlerTest.java @@ -0,0 +1,33 @@ +package com.truthsystems.hardcore.spectator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SpectatorLockHandlerTest { + + @Test + @DisplayName("HIK-INV-010: locked players stay locked until explicitly released") + void lockAndUnlockTransitionsAreExplicit() { + SpectatorLockHandler.lockPlayer("uuid-lock"); + assertTrue(SpectatorLockHandler.isLocked("uuid-lock"), + "FALSIFIED: Locked player was not tracked as locked"); + + SpectatorLockHandler.unlockPlayer("uuid-lock"); + assertFalse(SpectatorLockHandler.isLocked("uuid-lock"), + "FALSIFIED: Explicit unlock did not clear spectator lock"); + } + + @Test + @DisplayName("HIK-INV-011: blocked command matching is narrow and intentional") + void blockedCommandMatchingUsesCommandWord() { + assertTrue(CommandInterceptor.isBlockedCommand("/gamemode spectator"), + "FALSIFIED: /gamemode was not blocked"); + assertTrue(CommandInterceptor.isBlockedCommand("/give @p dirt"), + "FALSIFIED: /give was not blocked"); + assertFalse(CommandInterceptor.isBlockedCommand("givemethis"), + "FALSIFIED: Non-command prefix was blocked as if it were give"); + } +}