Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static java.util.logging.Level.SEVERE;

import com.dansplugins.detectionsystem.commands.AafCommand;
import com.dansplugins.detectionsystem.encryption.IpEncryption;
import com.dansplugins.detectionsystem.listeners.PlayerJoinListener;
import com.dansplugins.detectionsystem.logins.LoginRepository;
import com.dansplugins.detectionsystem.logins.LoginService;
Expand Down Expand Up @@ -80,8 +81,14 @@ public void onEnable() {
jooqSettings
);

// Encryption
IpEncryption ipEncryption = new IpEncryption(getLogger(), getDataFolder());

// Migrate existing plaintext IP addresses to encrypted format
migrateExistingIpAddresses(dsl, ipEncryption);

// Repositories
LoginRepository loginRepository = new LoginRepository(dsl);
LoginRepository loginRepository = new LoginRepository(dsl, ipEncryption);

// Services
loginService = new LoginService(loginRepository);
Expand Down Expand Up @@ -112,4 +119,88 @@ public LoginService getLoginService() {
public NotificationService getNotificationService() {
return notificationService;
}

/**
* Migrates existing plaintext IP addresses to encrypted format.
* This is a one-time operation that runs on startup after the encryption system is initialized.
*
* The detection of plaintext vs encrypted addresses is based on attempting to decrypt each
* stored value. If decryption succeeds, the value is assumed to be already encrypted. If
* decryption throws an exception, the value is treated as plaintext and will be encrypted.
*
* All migration operations are performed within a single database transaction to ensure atomicity.
*/
private void migrateExistingIpAddresses(DSLContext dsl, IpEncryption ipEncryption) {
try {
getLogger().info("Checking for plaintext IP addresses that need encryption...");

// Use a transaction to ensure atomicity of the migration
dsl.transaction(configuration -> {
DSLContext txDsl = DSL.using(configuration);

// Fetch all login records
var records = txDsl.selectFrom(com.dansplugins.detectionsystem.jooq.Tables.AAF_LOGIN_RECORD)
.fetch();

int totalRecords = records.size();
int alreadyEncrypted = 0;
int migrated = 0;
int failed = 0;
java.util.List<String> failedRecords = new java.util.ArrayList<>();

getLogger().info("Processing " + totalRecords + " login records...");

for (var record : records) {
String currentAddress = record.getAddress();

// Skip records with no stored address
if (currentAddress == null || currentAddress.trim().isEmpty()) {
continue;
}

// Check if this address is already encrypted
if (ipEncryption.isEncrypted(currentAddress)) {
alreadyEncrypted++;
continue;
}

// This appears to be plaintext - encrypt it
try {
String encryptedIp = ipEncryption.encrypt(currentAddress);

// Update the record with encrypted IP using the record's update method
record.setAddress(encryptedIp);
record.update();

migrated++;
} catch (Exception e) {
failed++;
String recordId = record.getMinecraftUuid() + ":" + currentAddress;
failedRecords.add(recordId);
getLogger().warning("Failed to encrypt IP for record " + record.getMinecraftUuid() + ": " + e.getMessage());
}
}

// Log summary
getLogger().info("IP address migration completed:");
getLogger().info(" Total records: " + totalRecords);
getLogger().info(" Already encrypted: " + alreadyEncrypted);
getLogger().info(" Newly encrypted: " + migrated);
getLogger().info(" Failed: " + failed);

if (failed > 0) {
getLogger().warning("The following records failed to encrypt:");
for (String failedRecord : failedRecords) {
getLogger().warning(" - " + failedRecord);
}
getLogger().warning("These records may need manual intervention.");
}
});

} catch (Exception e) {
getLogger().severe("Failed to migrate existing IP addresses: " + e.getMessage());
getLogger().severe("The plugin will continue to run, but historical data may not be accessible.");
// Don't fail startup - the plugin can still function with new data
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package com.dansplugins.detectionsystem.encryption;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.Base64;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;

/**
* Utility class for encrypting and decrypting IP addresses.
* <p>
* This class uses AES in {@code AES/ECB/PKCS5Padding} mode with a single, consistent key
* stored on disk. Using a fixed key and ECB mode makes the encryption <em>deterministic</em>:
* the same IP address will always produce the same ciphertext. This determinism is required
* so that encrypted IPs can be used for equality comparisons and database lookups (for example,
* to detect alternate accounts that share an IP).
* <p>
* <strong>Security trade-offs:</strong>
* <ul>
* <li>ECB mode does <em>not</em> hide patterns in the data. If the same IP address is
* encrypted multiple times, the resulting ciphertext will be identical each time.</li>
* <li>This design is intentionally weaker than using a randomized mode (such as GCM or CBC
* with a random IV), but it is chosen here to support deterministic lookups.</li>
* <li>Do not reuse this class for encrypting highly sensitive data where pattern leakage is
* unacceptable; it is intended only for this specific IP-detection use case.</li>
* </ul>
* <p>
* <strong>Key management:</strong>
* <ul>
* <li>The key file <em>must</em> be backed up. If the key is lost or overwritten, any IP
* addresses encrypted with the old key can no longer be decrypted, and all historical
* database lookups based on those encrypted IPs will fail.</li>
* <li>If the key changes, previously stored ciphertexts become permanently unusable; there is
* no way to recover the data without the original key.</li>
* <li>On startup, a prominent warning is logged about the importance of backing up the key file.</li>
* </ul>
*/
public final class IpEncryption {

private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding";
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using AES in ECB mode is a significant security weakness. ECB mode does not provide semantic security and reveals patterns in encrypted data. While deterministic encryption is needed for database lookups, a better approach would be to use AES-SIV (Synthetic IV) mode or to implement a keyed hash function (HMAC) for indexing purposes while storing the actual encrypted data with a secure mode like AES-GCM. If AES-SIV is not available, consider using AES in CBC mode with a deterministic IV derived from the plaintext (though this still has limitations).

Copilot uses AI. Check for mistakes.
private static final String KEY_FILENAME = "ip-encryption.key";

private final SecretKey secretKey;
private final Logger logger;
private final File dataFolder;

public IpEncryption(Logger logger, File dataFolder) {
this.logger = logger;
this.dataFolder = dataFolder;
this.secretKey = getOrCreateKey();
logKeyBackupWarning();
}

/**
* Logs a prominent warning about the importance of backing up the encryption key.
*/
private void logKeyBackupWarning() {
logger.warning("=================================================================");
logger.warning("IMPORTANT: IP Encryption Key Information");
logger.warning("=================================================================");
logger.warning("IP addresses are encrypted using a key file stored at:");
logger.warning(" " + getKeyFile().getAbsolutePath());
logger.warning("");
logger.warning("*** CRITICAL: BACK UP THIS KEY FILE ***");
logger.warning("");
logger.warning("If this key file is lost, corrupted, or deleted:");
logger.warning(" - All encrypted IP addresses in the database will be unrecoverable");
logger.warning(" - Alternate account detection will fail for historical data");
logger.warning(" - You will lose access to all stored IP information");
logger.warning("");
logger.warning("Back up this file regularly along with your database backups.");
logger.warning("=================================================================");
}

/**
* Encrypts an IP address string.
*
* @param ipAddress The IP address to encrypt
* @return Base64 encoded encrypted IP address
* @throws IllegalArgumentException if ipAddress is null or empty
* @throws RuntimeException if encryption fails
*/
public String encrypt(String ipAddress) {
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encrypt method doesn't validate the input. While IP addresses from InetAddress.getHostAddress() should be safe, the method is public and could theoretically be called with null or invalid input. Consider adding null checks and input validation to make the API more robust. At minimum, add a null check to fail fast with a clear error message rather than deep in the encryption logic.

Suggested change
public String encrypt(String ipAddress) {
public String encrypt(String ipAddress) {
if (ipAddress == null || ipAddress.trim().isEmpty()) {
throw new IllegalArgumentException("IP address to encrypt must not be null or empty");
}

Copilot uses AI. Check for mistakes.
if (ipAddress == null || ipAddress.trim().isEmpty()) {
throw new IllegalArgumentException("IP address to encrypt must not be null or empty");
}

try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
Comment on lines +99 to +100
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new Cipher instance on every encrypt/decrypt operation is inefficient. Cipher initialization is computationally expensive. Consider using ThreadLocal to cache Cipher instances per thread, or use a cipher pool. This will significantly improve performance, especially under load when many players are joining simultaneously.

Copilot uses AI. Check for mistakes.
byte[] encrypted = cipher.doFinal(ipAddress.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
logger.severe("Failed to encrypt IP address");
throw new RuntimeException("IP encryption failed", e);
Comment on lines +103 to +105
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encrypt and decrypt methods in IpEncryption expose the plaintext IP address in log messages when exceptions occur. The error message "Failed to encrypt IP address: " and "Failed to decrypt IP address: " could potentially be followed by exception messages that include the plaintext data. This defeats the purpose of encryption from a logging perspective. Consider removing the IP address from error messages or sanitizing exception messages before logging to avoid accidentally logging sensitive data.

Copilot uses AI. Check for mistakes.
}
}

/**
* Decrypts an encrypted IP address string.
*
* @param encryptedIp Base64 encoded encrypted IP address
* @return Decrypted IP address string
* @throws IllegalArgumentException if encryptedIp is null or empty
* @throws RuntimeException if decryption fails
*/
public String decrypt(String encryptedIp) {
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decrypt method doesn't validate the input. Similar to encrypt, consider adding null checks and validation for the Base64 encoded string. Invalid Base64 strings will cause exceptions deep in the decryption logic, making debugging more difficult.

Copilot uses AI. Check for mistakes.
if (encryptedIp == null || encryptedIp.trim().isEmpty()) {
throw new IllegalArgumentException("Encrypted IP address must not be null or empty");
}

try {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, secretKey);
Comment on lines +123 to +124
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new Cipher instance on every decrypt operation is inefficient for the same reasons as the encrypt method. Consider using ThreadLocal to cache Cipher instances.

Copilot uses AI. Check for mistakes.
byte[] decoded = Base64.getDecoder().decode(encryptedIp);
byte[] decrypted = cipher.doFinal(decoded);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
logger.severe("Failed to decrypt IP address");
throw new RuntimeException("IP decryption failed", e);
}
}

/**
* Checks if a given string appears to be an encrypted IP address by attempting to decrypt it.
*
* @param address The address to check
* @return true if the address can be successfully decrypted, false otherwise
*/
public boolean isEncrypted(String address) {
if (address == null || address.trim().isEmpty()) {
return false;
}

try {
decrypt(address);
return true;
} catch (Exception e) {
return false;
}
}

/**
* Gets the key file path.
*/
private File getKeyFile() {
return new File(dataFolder, KEY_FILENAME);
}

/**
* Gets the existing encryption key or creates a new one if it doesn't exist.
*
* @throws RuntimeException if the key cannot be loaded or created
*/
private SecretKey getOrCreateKey() {
File keyFile = getKeyFile();
Path keyPath = keyFile.toPath();

if (keyFile.exists()) {
try {
byte[] keyBytes = Files.readAllBytes(keyPath);
if (keyBytes.length != 32) { // AES-256 key is 32 bytes
logger.severe("Encryption key file is corrupted (invalid size). Expected 32 bytes, got " + keyBytes.length);
logger.severe("*** ALL EXISTING ENCRYPTED DATA WILL BE UNRECOVERABLE ***");
throw new RuntimeException("Corrupted encryption key file. Cannot proceed without data loss.");
}
logger.info("Loaded existing IP encryption key from " + keyFile.getAbsolutePath());
return new SecretKeySpec(keyBytes, ALGORITHM);
} catch (IOException e) {
logger.severe("Failed to read encryption key file: " + e.getMessage());
logger.severe("*** ALL EXISTING ENCRYPTED DATA WILL BE UNRECOVERABLE IF KEY IS REGENERATED ***");
throw new RuntimeException("Cannot read encryption key file. Check file permissions and integrity.", e);
} catch (SecurityException e) {
logger.severe("Security manager prevented reading encryption key file: " + e.getMessage());
throw new RuntimeException("Security policy prevents reading encryption key.", e);
}
}

return createNewKey(keyPath);
}

/**
* Creates a new encryption key and saves it to disk with restricted permissions.
*
* @throws RuntimeException if key generation or saving fails
*/
private SecretKey createNewKey(Path keyPath) {
try {
KeyGenerator keyGen = KeyGenerator.getInstance(ALGORITHM);
keyGen.init(256, new java.security.SecureRandom());
SecretKey secretKey = keyGen.generateKey();

// Ensure directory exists
Files.createDirectories(keyPath.getParent());

// Save key to file
Files.write(keyPath, secretKey.getEncoded());
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encryption key file is stored without setting appropriate file permissions. After creating the key file, you should set restrictive permissions (e.g., 600 on Unix systems) to prevent unauthorized access. The key file contains sensitive cryptographic material that, if compromised, would allow decryption of all stored IP addresses. Consider using Java's PosixFilePermissions to set appropriate permissions after file creation.

Copilot uses AI. Check for mistakes.

// Set restrictive file permissions (Unix-like systems only)
setRestrictivePermissions(keyPath);

logger.info("Generated new IP encryption key at " + keyPath.toAbsolutePath());
logger.warning("NEW ENCRYPTION KEY CREATED - Back up this file immediately!");

return secretKey;
} catch (Exception e) {
logger.severe("Failed to create encryption key: " + e.getMessage());
throw new RuntimeException("Key generation failed", e);
}
}

/**
* Sets restrictive file permissions on the key file (owner read/write only).
* This method is best-effort and will silently fail on non-POSIX systems.
*/
private void setRestrictivePermissions(Path keyPath) {
try {
Set<PosixFilePermission> perms = new HashSet<>();
perms.add(PosixFilePermission.OWNER_READ);
perms.add(PosixFilePermission.OWNER_WRITE);
Files.setPosixFilePermissions(keyPath, perms);
logger.info("Set restrictive permissions (600) on encryption key file");
} catch (UnsupportedOperationException e) {
// POSIX file permissions not supported on this system (e.g., Windows)
logger.info("File permission restrictions not available on this system");
} catch (Exception e) {
logger.warning("Could not set restrictive permissions on key file: " + e.getMessage());
}
}
}
Loading