Skip to content

Commit

Permalink
Implement fingerprinting for donation nags
Browse files Browse the repository at this point in the history
Fingerprints consist of the Mojang UUID and installation path,
hashed using a securely generated salt. The fingerprint which is
saved to disk cannot be used to recover the data that was used
to create it.

When the fingerprint has been detected as having changed, certain
options will be reset again so that donation nags are shown once more.

This solves problems where (most) mod packs accidentally disable
the config options to show the user our donation nags.
  • Loading branch information
jellysquid3 committed Jan 26, 2024
1 parent a792601 commit 523c39c
Show file tree
Hide file tree
Showing 14 changed files with 462 additions and 53 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package me.jellysquid.mods.sodium.client;

import me.jellysquid.mods.sodium.client.gui.SodiumGameOptions;
import me.jellysquid.mods.sodium.client.data.config.UserConfig;
import me.jellysquid.mods.sodium.client.data.fingerprint.FingerprintMeasure;
import me.jellysquid.mods.sodium.client.data.fingerprint.HashedFingerprint;
import me.jellysquid.mods.sodium.client.gui.console.Console;
import me.jellysquid.mods.sodium.client.gui.console.message.MessageLevel;
import me.jellysquid.mods.sodium.client.util.FlawlessFrames;
Expand All @@ -14,7 +16,7 @@
import java.io.IOException;

public class SodiumClientMod implements ClientModInitializer {
private static SodiumGameOptions CONFIG;
private static UserConfig CONFIG;
private static Logger LOGGER;

private static String MOD_VERSION;
Expand All @@ -33,9 +35,15 @@ public void onInitializeClient() {
CONFIG = loadConfig();

FlawlessFrames.onClientInitialization();

try {
updateFingerprint();
} catch (Throwable t) {
LOGGER.error("Failed to update fingerprint", t);
}
}

public static SodiumGameOptions options() {
public static UserConfig options() {
if (CONFIG == null) {
throw new IllegalStateException("Config not yet available");
}
Expand All @@ -51,27 +59,27 @@ public static Logger logger() {
return LOGGER;
}

private static SodiumGameOptions loadConfig() {
private static UserConfig loadConfig() {
try {
return SodiumGameOptions.loadFromDisk();
return UserConfig.loadFromDisk();
} catch (Exception e) {
LOGGER.error("Failed to load configuration file", e);
LOGGER.error("Using default configuration file in read-only mode");

Console.instance().logMessage(MessageLevel.SEVERE, Text.translatable("sodium.console.config_not_loaded"), 12.5);

var config = SodiumGameOptions.defaults();
var config = UserConfig.defaults();
config.setReadOnly();

return config;
}
}

public static void restoreDefaultOptions() {
CONFIG = SodiumGameOptions.defaults();
CONFIG = UserConfig.defaults();

try {
SodiumGameOptions.writeToDisk(CONFIG);
UserConfig.writeToDisk(CONFIG);
} catch (IOException e) {
throw new RuntimeException("Failed to write config file", e);
}
Expand All @@ -84,4 +92,32 @@ public static String getVersion() {

return MOD_VERSION;
}

private static void updateFingerprint() {
var current = FingerprintMeasure.create();

if (current == null) {
return;
}

HashedFingerprint saved = null;

try {
saved = HashedFingerprint.loadFromDisk();
} catch (Throwable t) {
LOGGER.error("Failed to load existing fingerprint", t);
}

if (saved == null || !current.looselyMatches(saved)) {
HashedFingerprint.writeToDisk(current.hashed());

CONFIG.notifications.hideDonationButton = false;

try {
UserConfig.writeToDisk(CONFIG);
} catch (IOException e) {
LOGGER.error("Failed to update config file", e);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.jellysquid.mods.sodium.mixin;
package me.jellysquid.mods.sodium.client.data.config;

import me.jellysquid.mods.sodium.mixin.MixinOption;
import net.fabricmc.loader.api.FabricLoader;
import net.fabricmc.loader.api.ModContainer;
import net.fabricmc.loader.api.metadata.CustomValue;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package me.jellysquid.mods.sodium.client.gui;
package me.jellysquid.mods.sodium.client.data.config;

import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.annotations.SerializedName;
import me.jellysquid.mods.sodium.client.gui.options.TextProvider;
import me.jellysquid.mods.sodium.client.util.FileUtil;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.option.GraphicsMode;
import net.minecraft.text.Text;
Expand All @@ -14,9 +15,8 @@
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;

public class SodiumGameOptions {
public class UserConfig {
private static final String DEFAULT_FILE_NAME = "sodium-options.json";

public final QualitySettings quality = new QualitySettings();
Expand All @@ -26,12 +26,12 @@ public class SodiumGameOptions {

private boolean readOnly;

private SodiumGameOptions() {
private UserConfig() {
// NO-OP
}

public static SodiumGameOptions defaults() {
return new SodiumGameOptions();
public static UserConfig defaults() {
return new UserConfig();
}

public static class PerformanceSettings {
Expand Down Expand Up @@ -91,18 +91,18 @@ public boolean isFancy(GraphicsMode graphicsMode) {
.excludeFieldsWithModifiers(Modifier.PRIVATE)
.create();

public static SodiumGameOptions loadFromDisk() {
public static UserConfig loadFromDisk() {
Path path = getConfigPath();
SodiumGameOptions config;
UserConfig config;

if (Files.exists(path)) {
try (FileReader reader = new FileReader(path.toFile())) {
config = GSON.fromJson(reader, SodiumGameOptions.class);
config = GSON.fromJson(reader, UserConfig.class);
} catch (IOException e) {
throw new RuntimeException("Could not parse config", e);
}
} else {
config = new SodiumGameOptions();
config = new UserConfig();
}

try {
Expand All @@ -120,7 +120,7 @@ private static Path getConfigPath() {
.resolve(DEFAULT_FILE_NAME);
}

public static void writeToDisk(SodiumGameOptions config) throws IOException {
public static void writeToDisk(UserConfig config) throws IOException {
if (config.isReadOnly()) {
throw new IllegalStateException("Config file is read-only");
}
Expand All @@ -134,14 +134,7 @@ public static void writeToDisk(SodiumGameOptions config) throws IOException {
throw new IOException("Not a directory: " + dir);
}

// Use a temporary location next to the config's final destination
Path tempPath = path.resolveSibling(path.getFileName() + ".tmp");

// Write the file to our temporary location
Files.writeString(tempPath, GSON.toJson(config));

// Atomically replace the old config file (if it exists) with the temporary file
Files.move(tempPath, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
FileUtil.writeTextRobustly(GSON.toJson(config), path);
}

public boolean isReadOnly() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package me.jellysquid.mods.sodium.client.data.fingerprint;

import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.MinecraftClient;
import org.apache.commons.codec.binary.Hex;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Objects;

public record FingerprintMeasure(@NotNull String uuid, @NotNull String path) {
private static final int SALT_LENGTH = 64;

public static @Nullable FingerprintMeasure create() {
var uuid = MinecraftClient.getInstance().getSession().getUuidOrNull();
var path = FabricLoader.getInstance().getGameDir();

if (uuid == null || path == null) {
return null;
}

return new FingerprintMeasure(uuid.toString(), path.toAbsolutePath().toString());
}

public HashedFingerprint hashed() {
var date = Instant.now();
var salt = createSalt();

var uuidHashHex = sha512(salt, this.uuid());
var pathHashHex = sha512(salt, this.path());

return new HashedFingerprint(HashedFingerprint.CURRENT_VERSION, salt, uuidHashHex, pathHashHex, date.getEpochSecond());
}

public boolean looselyMatches(HashedFingerprint hashed) {
var uuidHashHex = sha512(hashed.saltHex(), this.uuid());
var pathHashHex = sha512(hashed.saltHex(), this.path());

return Objects.equals(uuidHashHex, hashed.uuidHashHex()) || Objects.equals(pathHashHex, hashed.pathHashHex());
}

private static String sha512(@NotNull String salt, @NotNull String message) {
MessageDigest md;

try {
md = MessageDigest.getInstance("SHA-512");
md.update(Hex.decodeHex(salt));
md.update(message.getBytes(StandardCharsets.UTF_8));
} catch (Throwable t) {
throw new RuntimeException("Failed to hash value", t);
}

return Hex.encodeHexString(md.digest());
}

private static String createSalt() {
var rng = new SecureRandom();

var salt = new byte[SALT_LENGTH];
rng.nextBytes(salt);

return Hex.encodeHexString(salt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package me.jellysquid.mods.sodium.client.data.fingerprint;

import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import me.jellysquid.mods.sodium.client.util.FileUtil;
import net.fabricmc.loader.api.FabricLoader;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;

public record HashedFingerprint(
@SerializedName("v")
int version,

@NotNull
@SerializedName("s")
String saltHex,

@NotNull
@SerializedName("u")
String uuidHashHex,

@NotNull
@SerializedName("p")
String pathHashHex,

@SerializedName("t")
long timestamp)
{
public static final int CURRENT_VERSION = 1;

public static @Nullable HashedFingerprint loadFromDisk() {
Path path = getFilePath();

if (!Files.exists(path)) {
return null;
}

HashedFingerprint data;

try {
data = new Gson()
.fromJson(Files.readString(path), HashedFingerprint.class);
} catch (IOException e) {
throw new RuntimeException("Failed to load data file", e);
}

if (data.version() != CURRENT_VERSION) {
return null;
}

return data;
}

public static void writeToDisk(@NotNull HashedFingerprint data) {
Objects.requireNonNull(data);

try {
FileUtil.writeTextRobustly(new Gson()
.toJson(data), getFilePath());
} catch (IOException e) {
throw new RuntimeException("Failed to save data file", e);
}
}

private static Path getFilePath() {
return FabricLoader.getInstance()
.getConfigDir()
.resolve("sodium-fingerprint.json");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package me.jellysquid.mods.sodium.client.gui;

import com.google.common.collect.ImmutableList;
import me.jellysquid.mods.sodium.client.data.config.UserConfig;
import me.jellysquid.mods.sodium.client.gl.arena.staging.MappedStagingBuffer;
import me.jellysquid.mods.sodium.client.gl.device.RenderDevice;
import me.jellysquid.mods.sodium.client.gui.options.*;
Expand All @@ -23,7 +24,7 @@
import java.util.ArrayList;
import java.util.List;

public class SodiumGameOptionPages {
public class RendererSettingsLayout {
private static final SodiumOptionsStorage sodiumOpts = new SodiumOptionsStorage();
private static final MinecraftOptionsStorage vanillaOpts = new MinecraftOptionsStorage();

Expand Down Expand Up @@ -160,17 +161,17 @@ public static OptionPage quality() {
}, opts -> opts.getCloudRenderMode().getValue())
.setImpact(OptionImpact.LOW)
.build())
.add(OptionImpl.createBuilder(SodiumGameOptions.GraphicsQuality.class, sodiumOpts)
.add(OptionImpl.createBuilder(UserConfig.GraphicsQuality.class, sodiumOpts)
.setName(Text.translatable("soundCategory.weather"))
.setTooltip(Text.translatable("sodium.options.weather_quality.tooltip"))
.setControl(option -> new CyclingControl<>(option, SodiumGameOptions.GraphicsQuality.class))
.setControl(option -> new CyclingControl<>(option, UserConfig.GraphicsQuality.class))
.setBinding((opts, value) -> opts.quality.weatherQuality = value, opts -> opts.quality.weatherQuality)
.setImpact(OptionImpact.MEDIUM)
.build())
.add(OptionImpl.createBuilder(SodiumGameOptions.GraphicsQuality.class, sodiumOpts)
.add(OptionImpl.createBuilder(UserConfig.GraphicsQuality.class, sodiumOpts)
.setName(Text.translatable("sodium.options.leaves_quality.name"))
.setTooltip(Text.translatable("sodium.options.leaves_quality.tooltip"))
.setControl(option -> new CyclingControl<>(option, SodiumGameOptions.GraphicsQuality.class))
.setControl(option -> new CyclingControl<>(option, UserConfig.GraphicsQuality.class))
.setBinding((opts, value) -> opts.quality.leavesQuality = value, opts -> opts.quality.leavesQuality)
.setImpact(OptionImpact.MEDIUM)
.setFlags(OptionFlag.REQUIRES_RENDERER_RELOAD)
Expand Down
Loading

0 comments on commit 523c39c

Please sign in to comment.