Created
April 28, 2025 10:14
-
-
Save remithaunay/a5f67ebceba12358c1fcd227e48bf250 to your computer and use it in GitHub Desktop.
SharedPreferences with Tink and Android KeyStore
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Securely stores string data in SharedPreferences using Tink's AEAD (Authenticated Encryption with | |
| * Associated Data) primitive, backed by AES-256-GCM encryption and Android KeyStore for key management. | |
| */ | |
| object SecureStorage { | |
| // Constants for SharedPreferences and Tink keyset configuration | |
| private const val TAG = "SecureStorage" // For logging | |
| private const val KEYSET_NAME = "tink_keyset" // Name of the Tink keyset stored in SharedPreferences | |
| private const val PREFS_NAME = "secure_prefs" // Name of the SharedPreferences file | |
| private const val MASTER_KEY_URI = "android-keystore://tink_master_key" // URI for the master key in KeyStore | |
| // Register Tink's AEAD configuration once during class initialization | |
| init { | |
| try { | |
| AeadConfig.register() | |
| } catch (e: GeneralSecurityException) { | |
| Logger.log( | |
| LogCategory.IO, | |
| LogLevel.ERROR, | |
| "Failed to register AeadConfig: ${e.message}", | |
| ) | |
| // Note: App can continue, but encryption/decryption will fail later | |
| } | |
| } | |
| // Lazily initialize SharedPreferences for storing encrypted data | |
| private val prefs: SharedPreferences by lazy { | |
| appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) | |
| } | |
| // Lazily initialize the AEAD primitive for encryption/decryption | |
| private val aead: Aead? by lazy { | |
| try { | |
| val keysetHandle = | |
| AndroidKeysetManager | |
| .Builder() | |
| .withKeyTemplate(AeadKeyTemplates.AES256_GCM) // Use AES-256-GCM for strong encryption | |
| .withSharedPref(appContext, KEYSET_NAME, PREFS_NAME) // Store keyset in SharedPreferences | |
| .withMasterKeyUri(MASTER_KEY_URI) // Secure keys with Android KeyStore | |
| .build() | |
| .keysetHandle | |
| keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) | |
| } catch (e: Exception) { | |
| Logger.log( | |
| LogCategory.IO, | |
| LogLevel.ERROR, | |
| "Failed to initialize AEAD primitive: ${e.message}", | |
| ) | |
| null // Return null to indicate failure; methods will handle this | |
| } | |
| } | |
| /** | |
| * Retrieves a string value for the given key, decrypting it if it exists. | |
| * | |
| * @param key The key associated with the encrypted value. | |
| * @return The decrypted string, or null if the key doesn't exist, decryption fails, or initialization failed. | |
| */ | |
| fun getString(key: String): String? { | |
| val aeadInstance = | |
| aead ?: run { | |
| Logger.log( | |
| LogCategory.IO, | |
| LogLevel.ERROR, | |
| "AEAD not initialized; cannot decrypt", | |
| ) | |
| return null | |
| } | |
| val encryptedBase64 = prefs.getString(key, null) ?: return null | |
| return try { | |
| val ciphertext = Base64.decode(encryptedBase64, Base64.NO_WRAP) | |
| val decrypted = aeadInstance.decrypt(ciphertext, null) // No associated data used | |
| String(decrypted, StandardCharsets.UTF_8) | |
| } catch (e: Exception) { | |
| Logger.log( | |
| LogCategory.IO, | |
| LogLevel.WARNING, | |
| "Decryption failed for key: $key, error: ${e.message}", | |
| ) | |
| null // Return null for tampered data, wrong key, or other failures | |
| } | |
| } | |
| /** | |
| * Stores a string value for the given key, encrypting it before saving. If the value is null, | |
| * the key is removed from storage. | |
| * | |
| * @param key The key to associate with the value. | |
| * @param value The string to encrypt and store, or null to remove the key. | |
| */ | |
| fun setString( | |
| key: String, | |
| value: String?, | |
| ) { | |
| val aeadInstance = | |
| aead ?: run { | |
| Logger.log( | |
| LogCategory.IO, | |
| LogLevel.ERROR, | |
| "AEAD not initialized; cannot encrypt", | |
| ) | |
| return | |
| } | |
| // Limit input to 8,192 characters (~8 KB) to prevent SharedPreferences performance issues | |
| // Encrypted output (~11 KB after Base64) remains safe for a single entry | |
| if (value != null && value.length > 8192) { | |
| throw IllegalArgumentException( | |
| "Input exceeds 8,192 characters. Use alternative storage for large data.", | |
| ) | |
| } | |
| val editor = prefs.edit() | |
| if (value == null) { | |
| editor.remove(key).apply() | |
| return | |
| } | |
| try { | |
| val plaintext = value.toByteArray(StandardCharsets.UTF_8) | |
| val ciphertext = aeadInstance.encrypt(plaintext, null) // No associated data used | |
| val base64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP) | |
| editor.putString(key, base64).apply() | |
| } catch (e: Exception) { | |
| Logger.log( | |
| LogCategory.IO, | |
| LogLevel.ERROR, | |
| "Encryption failed for key: $key, error: ${e.message}", | |
| ) | |
| // Don't save if encryption fails to avoid storing plaintext or invalid data | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment