A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(security): encrypt client-side session storage with AES-256-GCM

Add ClientSecurityUtils with the same AES-256-GCM encryption used
server-side. ClientSessionManager now encrypts session files on save
and decrypts on load, with automatic migration from plaintext (v2)
to encrypted (v3) format. The encryption key is stored separately
with owner-only file permissions.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+174 -8
+132
src/client/kotlin/com/jollywhoppers/atproto/client/ClientSecurityUtils.kt
··· 1 + package com.jollywhoppers.atproto.client 2 + 3 + import kotlinx.serialization.Serializable 4 + import kotlinx.serialization.encodeToString 5 + import kotlinx.serialization.json.Json 6 + import org.slf4j.LoggerFactory 7 + import java.nio.file.Files 8 + import java.nio.file.Path 9 + import java.nio.file.StandardOpenOption 10 + import java.security.SecureRandom 11 + import java.util.Base64 12 + import javax.crypto.Cipher 13 + import javax.crypto.KeyGenerator 14 + import javax.crypto.SecretKey 15 + import javax.crypto.spec.GCMParameterSpec 16 + import javax.crypto.spec.SecretKeySpec 17 + 18 + /** 19 + * Client-side security utilities for encrypting session data. 20 + * Uses the same AES-256-GCM encryption as the server-side SecurityUtils. 21 + * 22 + * The encryption key is stored in the Minecraft config directory 23 + * with restricted permissions. Each client machine has its own key. 24 + */ 25 + object ClientSecurityUtils { 26 + private val logger = LoggerFactory.getLogger("atproto-connect-client-security") 27 + private const val ALGORITHM = "AES/GCM/NoPadding" 28 + private const val KEY_SIZE = 256 29 + private const val IV_SIZE = 12 30 + private const val TAG_SIZE = 128 31 + 32 + private val json = Json { prettyPrint = false } 33 + 34 + @Serializable 35 + private data class EncryptedData( 36 + val iv: String, 37 + val ciphertext: String, 38 + ) 39 + 40 + /** 41 + * Generates a new AES-256 encryption key. 42 + */ 43 + fun generateKey(): SecretKey { 44 + val keyGen = KeyGenerator.getInstance("AES") 45 + keyGen.init(KEY_SIZE, SecureRandom()) 46 + return keyGen.generateKey() 47 + } 48 + 49 + /** 50 + * Loads or generates the client's encryption key. 51 + * Stores the key in a secure file. 52 + */ 53 + fun loadOrGenerateKey(keyFile: Path): SecretKey { 54 + return if (Files.exists(keyFile)) { 55 + try { 56 + val encodedKey = Files.readAllBytes(keyFile) 57 + SecretKeySpec(encodedKey, "AES") 58 + } catch (e: Exception) { 59 + logger.warn("Failed to load existing key, generating new one", e) 60 + generateAndStoreKey(keyFile) 61 + } 62 + } else { 63 + generateAndStoreKey(keyFile) 64 + } 65 + } 66 + 67 + private fun generateAndStoreKey(keyFile: Path): SecretKey { 68 + val key = generateKey() 69 + Files.createDirectories(keyFile.parent) 70 + Files.write(keyFile, key.encoded, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING) 71 + 72 + // Try to set restricted permissions 73 + try { 74 + keyFile.toFile().setReadable(true, true) // owner-only read 75 + keyFile.toFile().setWritable(true, true) // owner-only write 76 + } catch (e: Exception) { 77 + logger.warn("Could not set restricted file permissions on key file", e) 78 + } 79 + 80 + logger.info("Generated new client encryption key") 81 + return key 82 + } 83 + 84 + /** 85 + * Encrypts a string using AES-256-GCM. 86 + * Returns a JSON string containing the IV and ciphertext (both base64-encoded). 87 + */ 88 + fun encrypt(plaintext: String, key: SecretKey): String { 89 + val iv = ByteArray(IV_SIZE) 90 + SecureRandom().nextBytes(iv) 91 + 92 + val cipher = Cipher.getInstance(ALGORITHM) 93 + val gcmSpec = GCMParameterSpec(TAG_SIZE, iv) 94 + cipher.init(Cipher.ENCRYPT_MODE, key, gcmSpec) 95 + 96 + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) 97 + 98 + val encryptedData = EncryptedData( 99 + iv = Base64.getEncoder().encodeToString(iv), 100 + ciphertext = Base64.getEncoder().encodeToString(ciphertext), 101 + ) 102 + 103 + return json.encodeToString(encryptedData) 104 + } 105 + 106 + /** 107 + * Decrypts a string that was encrypted with [encrypt]. 108 + */ 109 + fun decrypt(encryptedJson: String, key: SecretKey): String { 110 + val encryptedData = json.decodeFromString<EncryptedData>(encryptedJson) 111 + 112 + val iv = Base64.getDecoder().decode(encryptedData.iv) 113 + val ciphertext = Base64.getDecoder().decode(encryptedData.ciphertext) 114 + 115 + val cipher = Cipher.getInstance(ALGORITHM) 116 + val gcmSpec = GCMParameterSpec(TAG_SIZE, iv) 117 + cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec) 118 + 119 + val plaintext = cipher.doFinal(ciphertext) 120 + return String(plaintext, Charsets.UTF_8) 121 + } 122 + 123 + /** 124 + * Sanitizes a string for logging (hides sensitive content). 125 + */ 126 + fun sanitizeForLog(input: String): String { 127 + return when { 128 + input.length <= 8 -> "***" 129 + else -> "${input.take(4)}...${input.takeLast(4)}" 130 + } 131 + } 132 + }
+42 -8
src/client/kotlin/com/jollywhoppers/atproto/client/ClientSessionManager.kt
··· 31 31 ) { 32 32 private val logger = LoggerFactory.getLogger("atproto-connect-client") 33 33 private val storageFile: Path 34 + private val keyFile: Path 35 + private var encryptionKey: javax.crypto.SecretKey 34 36 private var currentSession: PlayerSession? = null 35 37 private var currentOAuthSession: OAuthSession? = null 36 38 private var dpopKeyPair: KeyPair? = null ··· 54 56 55 57 @Serializable 56 58 private data class SessionStorage( 57 - val version: Int = 2, 59 + val version: Int = 3, 58 60 val session: PlayerSession? = null, 61 + val encrypted: Boolean = false, 59 62 ) 60 63 61 64 init { ··· 63 66 val configDir = FabricLoader.getInstance().configDir.resolve("atproto-connect") 64 67 Files.createDirectories(configDir) 65 68 storageFile = configDir.resolve("client-session.json") 69 + keyFile = configDir.resolve(".session-key") 70 + 71 + // Load or generate encryption key 72 + encryptionKey = ClientSecurityUtils.loadOrGenerateKey(keyFile) 66 73 67 74 // Load existing session 68 75 load() ··· 248 255 249 256 /** 250 257 * Loads session from disk. 258 + * Supports both encrypted (v3) and plaintext (v1/v2) session files 259 + * for backward compatibility. 251 260 */ 252 261 private fun load() { 253 262 try { 254 263 if (Files.exists(storageFile)) { 255 264 val content = Files.readString(storageFile) 256 - val storage = json.decodeFromString<SessionStorage>(content) 265 + 266 + // Try to parse as encrypted first 267 + val storage = try { 268 + val decrypted = ClientSecurityUtils.decrypt(content, encryptionKey) 269 + json.decodeFromString<SessionStorage>(decrypted) 270 + } catch (_: Exception) { 271 + // Not encrypted (legacy format) — parse directly 272 + json.decodeFromString<SessionStorage>(content) 273 + } 274 + 257 275 currentSession = storage.session 258 276 logger.info("Loaded session from disk: ${currentSession?.handle ?: "none"} (auth: ${currentSession?.authType ?: "none"})") 277 + 278 + // If the session was loaded from a plaintext file, re-save it encrypted 279 + if (!storage.encrypted && currentSession != null) { 280 + logger.info("Migrating plaintext session to encrypted storage") 281 + save() 282 + } 259 283 } else { 260 284 logger.info("No existing session found") 261 285 } ··· 265 289 } 266 290 267 291 /** 268 - * Saves session to disk. 269 - * TODO: Add encryption for added security (server-side already has it). 292 + * Saves session to disk with AES-256-GCM encryption. 293 + * The session file is encrypted using the client's local key. 270 294 */ 271 295 private fun save() { 272 296 try { 273 297 Files.createDirectories(storageFile.parent) 274 298 275 299 val storage = SessionStorage( 276 - version = 2, 300 + version = 3, 277 301 session = currentSession, 302 + encrypted = true, 278 303 ) 279 304 280 - val content = json.encodeToString(storage) 305 + val plaintext = json.encodeToString(storage) 306 + val encrypted = ClientSecurityUtils.encrypt(plaintext, encryptionKey) 281 307 282 308 Files.writeString( 283 309 storageFile, 284 - content, 310 + encrypted, 285 311 StandardOpenOption.CREATE, 286 312 StandardOpenOption.TRUNCATE_EXISTING 287 313 ) 288 314 289 - logger.debug("Saved session to disk") 315 + // Set restricted file permissions 316 + try { 317 + storageFile.toFile().setReadable(true, true) 318 + storageFile.toFile().setWritable(true, true) 319 + } catch (_: Exception) { 320 + // Not critical — best effort 321 + } 322 + 323 + logger.debug("Saved encrypted session to disk") 290 324 } catch (e: Exception) { 291 325 logger.error("Failed to save session", e) 292 326 }