203 lines
7.5 KiB
Java
203 lines
7.5 KiB
Java
package com.spotifymc.auth;
|
|
|
|
import com.google.gson.Gson;
|
|
import com.google.gson.GsonBuilder;
|
|
import com.google.gson.JsonObject;
|
|
import com.google.gson.JsonParser;
|
|
import net.fabricmc.loader.api.FabricLoader;
|
|
|
|
import javax.crypto.Cipher;
|
|
import javax.crypto.SecretKey;
|
|
import javax.crypto.spec.GCMParameterSpec;
|
|
import javax.crypto.spec.SecretKeySpec;
|
|
import java.io.File;
|
|
import java.io.FileReader;
|
|
import java.io.FileWriter;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.security.MessageDigest;
|
|
import java.security.SecureRandom;
|
|
import java.util.Base64;
|
|
|
|
/**
|
|
* Manages secure local storage of OAuth tokens using AES-GCM encryption.
|
|
* Protects tokens from being stored in plaintext in the config folder.
|
|
*/
|
|
public class TokenStorage {
|
|
|
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
|
private static final String ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding";
|
|
private static final int GCM_TAG_LENGTH = 128;
|
|
private static final int GCM_IV_LENGTH = 12;
|
|
|
|
private final Path credentialsFilePath;
|
|
private final SecretKey encryptionKey;
|
|
|
|
public record StoredTokens(String accessToken, String refreshToken, long expiresAtMs) {
|
|
public boolean isExpired() {
|
|
// Buffer by 60 seconds to refresh proactively before actual expiration
|
|
return System.currentTimeMillis() >= (expiresAtMs - 60_000L);
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "StoredTokens[accessToken=***REDACTED***, refreshToken=***REDACTED***, expiresAtMs=" + expiresAtMs + "]";
|
|
}
|
|
}
|
|
|
|
public TokenStorage() {
|
|
this(resolveDefaultConfigDir());
|
|
}
|
|
|
|
public TokenStorage(Path configDir) {
|
|
try {
|
|
Files.createDirectories(configDir);
|
|
} catch (Exception ignored) {
|
|
}
|
|
this.credentialsFilePath = configDir.resolve("credentials.json");
|
|
this.encryptionKey = deriveMachineKey();
|
|
}
|
|
|
|
private static Path resolveDefaultConfigDir() {
|
|
try {
|
|
if (FabricLoader.getInstance() != null && FabricLoader.getInstance().getConfigDir() != null) {
|
|
return FabricLoader.getInstance().getConfigDir().resolve("spotifymc");
|
|
}
|
|
} catch (Throwable ignored) {
|
|
}
|
|
return Path.of(System.getProperty("java.io.tmpdir"), "spotifymc-test");
|
|
}
|
|
|
|
/**
|
|
* Derives a machine-local AES-256 key based on local environment attributes and salt.
|
|
* Prevents credentials.json from being simply copied or viewed in plaintext.
|
|
*/
|
|
private SecretKey deriveMachineKey() {
|
|
try {
|
|
String seed = System.getProperty("user.name", "default")
|
|
+ "-" + System.getProperty("os.name", "unknown")
|
|
+ "-SpotifyMC-SecretSalt-v1";
|
|
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
|
byte[] keyBytes = sha256.digest(seed.getBytes(StandardCharsets.UTF_8));
|
|
return new SecretKeySpec(keyBytes, "AES");
|
|
} catch (Exception e) {
|
|
throw new RuntimeException("Failed to derive encryption key", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves tokens encrypted to local disk.
|
|
*/
|
|
public synchronized void saveTokens(String accessToken, String refreshToken, long expiresInSeconds) {
|
|
try {
|
|
long expiresAt = System.currentTimeMillis() + (expiresInSeconds * 1000L);
|
|
|
|
JsonObject payload = new JsonObject();
|
|
payload.addProperty("accessToken", accessToken);
|
|
payload.addProperty("refreshToken", refreshToken);
|
|
payload.addProperty("expiresAtMs", expiresAt);
|
|
|
|
String plaintext = payload.toString();
|
|
String encrypted = encrypt(plaintext);
|
|
|
|
JsonObject fileObj = new JsonObject();
|
|
fileObj.addProperty("version", 1);
|
|
fileObj.addProperty("encryptedData", encrypted);
|
|
|
|
File file = credentialsFilePath.toFile();
|
|
try (FileWriter writer = new FileWriter(file, StandardCharsets.UTF_8)) {
|
|
GSON.toJson(fileObj, writer);
|
|
}
|
|
} catch (Exception e) {
|
|
System.err.println("[SpotifyMC] Failed to save encrypted tokens: " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads and decrypts stored tokens from local disk.
|
|
*
|
|
* @return StoredTokens if present and valid, or null.
|
|
*/
|
|
public synchronized StoredTokens loadTokens() {
|
|
File file = credentialsFilePath.toFile();
|
|
if (!file.exists() || file.length() == 0) {
|
|
return null;
|
|
}
|
|
|
|
try (FileReader reader = new FileReader(file, StandardCharsets.UTF_8)) {
|
|
JsonObject fileObj = JsonParser.parseReader(reader).getAsJsonObject();
|
|
if (!fileObj.has("encryptedData")) {
|
|
return null;
|
|
}
|
|
|
|
String encrypted = fileObj.get("encryptedData").getAsString();
|
|
String decrypted = decrypt(encrypted);
|
|
|
|
JsonObject payload = JsonParser.parseString(decrypted).getAsJsonObject();
|
|
String accessToken = payload.get("accessToken").getAsString();
|
|
String refreshToken = payload.has("refreshToken") && !payload.get("refreshToken").isJsonNull()
|
|
? payload.get("refreshToken").getAsString()
|
|
: null;
|
|
long expiresAtMs = payload.get("expiresAtMs").getAsLong();
|
|
|
|
return new StoredTokens(accessToken, refreshToken, expiresAtMs);
|
|
} catch (Exception e) {
|
|
System.err.println("[SpotifyMC] Failed to read or decrypt stored tokens: " + e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears and deletes the stored credentials.
|
|
*/
|
|
public synchronized void clear() {
|
|
try {
|
|
Files.deleteIfExists(credentialsFilePath);
|
|
} catch (Exception ignored) {
|
|
}
|
|
}
|
|
|
|
public boolean hasTokens() {
|
|
return Files.exists(credentialsFilePath);
|
|
}
|
|
|
|
private String encrypt(String plaintext) throws Exception {
|
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
|
new SecureRandom().nextBytes(iv);
|
|
|
|
Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
|
|
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
|
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, spec);
|
|
|
|
byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
|
|
|
byte[] combined = new byte[iv.length + cipherText.length];
|
|
System.arraycopy(iv, 0, combined, 0, iv.length);
|
|
System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length);
|
|
|
|
return Base64.getEncoder().encodeToString(combined);
|
|
}
|
|
|
|
private String decrypt(String encryptedBase64) throws Exception {
|
|
byte[] combined = Base64.getDecoder().decode(encryptedBase64);
|
|
if (combined.length < GCM_IV_LENGTH) {
|
|
throw new IllegalArgumentException("Corrupted encrypted data payload");
|
|
}
|
|
|
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
|
System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH);
|
|
|
|
int cipherTextLength = combined.length - GCM_IV_LENGTH;
|
|
byte[] cipherText = new byte[cipherTextLength];
|
|
System.arraycopy(combined, GCM_IV_LENGTH, cipherText, 0, cipherTextLength);
|
|
|
|
Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM);
|
|
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
|
|
cipher.init(Cipher.DECRYPT_MODE, encryptionKey, spec);
|
|
|
|
byte[] decryptedBytes = cipher.doFinal(cipherText);
|
|
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
|
}
|
|
}
|