Merge remote-tracking branch 'origin/nrobi144/desktop-phase1'

* origin/nrobi144/desktop-phase1:
  Fixes FilledFilter test
  moves keystore to commons to avoid unnecessary dependencies on Quartz
  Fixes missing tagsAll
This commit is contained in:
Vitor Pamplona
2026-01-07 11:41:30 -05:00
9 changed files with 32 additions and 23 deletions
@@ -1,102 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.crypto
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
internal class KeyStoreEncryption {
companion object {
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val PURPOSE = KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
private const val KEY_ALIAS = "AMETHYST_AES_KEY"
}
private val cipher = Cipher.getInstance(TRANSFORMATION)
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private fun getKey(): SecretKey {
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
}
private fun createKeyStrongBoxIfAvailable(): SecretKey? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
val keyParams =
KeyGenParameterSpec
.Builder(KEY_ALIAS, PURPOSE)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.setIsStrongBoxBacked(true)
.build()
val generator = KeyGenerator.getInstance(ALGORITHM, "AndroidKeyStore")
generator.init(keyParams)
generator.generateKey()
} catch (_: StrongBoxUnavailableException) {
null
}
} else {
null
}
private fun createKeyRegular(): SecretKey {
val keyParams =
KeyGenParameterSpec
.Builder(KEY_ALIAS, PURPOSE)
.setBlockModes(BLOCK_MODE)
.setEncryptionPaddings(PADDING)
.build()
val generator = KeyGenerator.getInstance(ALGORITHM, "AndroidKeyStore")
generator.init(keyParams)
return generator.generateKey()
}
private fun createKey(): SecretKey = createKeyStrongBoxIfAvailable() ?: createKeyRegular()
fun encrypt(bytes: ByteArray): ByteArray {
// Initializes the cipher in encrypt mode and encrypts data
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(bytes)
return iv + encrypted
}
fun decrypt(bytes: ByteArray): ByteArray {
// Extracts IV and decrypts the data
val iv = bytes.copyOfRange(0, 12) // GCM mode uses 12-byte IV
val data = bytes.copyOfRange(12, bytes.size)
cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
return cipher.doFinal(data)
}
}
@@ -1,123 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.crypto
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Android implementation of SecureKeyStorage using EncryptedSharedPreferences
* backed by Android Keystore (AES-256-GCM, hardware-backed when available).
*
* ## Security Features
*
* - **Hardware Security:** Uses Android Keystore (hardware-backed on supported devices with StrongBox)
* - **Encryption:** AES-256-GCM for both keys and values
* - **Key Derivation:** AES-256-SIV for preference keys, AES-256-GCM for values
* - **Application Context:** Uses applicationContext to prevent memory leaks
* - **Auto-backup Disabled:** EncryptedSharedPreferences automatically excluded from cloud backups
*
* **Note:** While the encryption keys are protected by hardware security modules (when available),
* the decrypted private keys returned by [getPrivateKey] are still subject to the String memory
* limitation described in [SecureKeyStorage].
*/
actual class SecureKeyStorage private actual constructor() {
actual companion object {
private const val PREFS_NAME = "amethyst_secure_keys"
private const val KEY_PREFIX = "privkey_"
private lateinit var appContext: Context
/**
* Creates a SecureKeyStorage instance for Android.
*
* @param context Android Context (will use applicationContext to avoid leaks)
* @return SecureKeyStorage instance
* @throws IllegalArgumentException if context is null or not a valid Context
*/
actual fun create(context: Any?): SecureKeyStorage {
require(context is Context) { "Android requires a valid Context" }
appContext = context.applicationContext
return SecureKeyStorage()
}
}
private val masterKey: MasterKey by lazy {
MasterKey
.Builder(appContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val encryptedPrefs by lazy {
EncryptedSharedPreferences.create(
appContext,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
actual suspend fun savePrivateKey(
npub: String,
privKeyHex: String,
) {
withContext(Dispatchers.IO) {
try {
encryptedPrefs.edit().putString(KEY_PREFIX + npub, privKeyHex).apply()
} catch (e: Exception) {
throw SecureStorageException("Failed to save private key", e)
}
}
}
actual suspend fun getPrivateKey(npub: String): String? =
withContext(Dispatchers.IO) {
try {
encryptedPrefs.getString(KEY_PREFIX + npub, null)
} catch (e: Exception) {
throw SecureStorageException("Failed to retrieve private key", e)
}
}
actual suspend fun deletePrivateKey(npub: String): Boolean =
withContext(Dispatchers.IO) {
try {
val key = KEY_PREFIX + npub
val existed = encryptedPrefs.contains(key)
if (existed) {
encryptedPrefs.edit().remove(key).apply()
}
existed
} catch (e: Exception) {
throw SecureStorageException("Failed to delete private key", e)
}
}
actual suspend fun hasPrivateKey(npub: String): Boolean =
withContext(Dispatchers.IO) {
encryptedPrefs.contains(KEY_PREFIX + npub)
}
}
@@ -1,109 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.crypto
/**
* Secure storage for private keys using platform-specific secure storage mechanisms.
*
* ## Platform Implementations
*
* **Android:** Android Keystore + EncryptedSharedPreferences (AES-256-GCM, StrongBox when available)
*
* **Desktop:** OS native credential managers (macOS Keychain, Windows Credential Manager, Linux Secret Service)
* with encrypted file fallback.
*
* ## Security Considerations
*
* **String Memory Limitation:** Private keys are returned as String (hexadecimal format). Unlike char arrays,
* Kotlin/JVM Strings are immutable and cannot be securely zeroed from memory after use. The private key
* will remain in memory until garbage collected, which may be delayed. This is an inherent limitation of
* the JVM platform.
*
* **Mitigation:** Private keys are only exposed through this API when needed for signing operations.
* They are encrypted at rest on all platforms. For maximum security, minimize the time private keys
* are held in memory and ensure they are dereferenced promptly after use.
*
* **Production Recommendation:** For high-security applications, consider using hardware security modules
* (HSM) or secure enclaves (Android StrongBox, iOS Secure Enclave) that never expose private keys to
* application memory.
*
* Use [SecureKeyStorage.create] factory method to create instances.
*/
expect class SecureKeyStorage private constructor() {
companion object {
/**
* Creates a SecureKeyStorage instance.
*
* @param context Platform-specific context (required on Android, ignored on Desktop)
* @return SecureKeyStorage instance
*/
fun create(context: Any? = null): SecureKeyStorage
}
/**
* Saves a private key securely for the given npub.
*
* @param npub The public key in npub (Bech32) format
* @param privKeyHex The private key in hexadecimal format
* @throws SecureStorageException if storage operation fails
*/
suspend fun savePrivateKey(
npub: String,
privKeyHex: String,
)
/**
* Retrieves a private key for the given npub.
*
* **Security Warning:** The returned String cannot be securely zeroed from memory (JVM limitation).
* Dereference the returned value immediately after use to minimize exposure time.
*
* @param npub The public key in npub (Bech32) format
* @return The private key in hexadecimal format, or null if not found
* @throws SecureStorageException if retrieval operation fails
*/
suspend fun getPrivateKey(npub: String): String?
/**
* Deletes a private key for the given npub.
*
* @param npub The public key in npub (Bech32) format
* @return true if the key was deleted, false if it didn't exist
* @throws SecureStorageException if deletion operation fails
*/
suspend fun deletePrivateKey(npub: String): Boolean
/**
* Checks if a private key exists for the given npub.
*
* @param npub The public key in npub (Bech32) format
* @return true if a private key exists, false otherwise
*/
suspend fun hasPrivateKey(npub: String): Boolean
}
/**
* Exception thrown when secure storage operations fail.
*/
class SecureStorageException(
message: String,
cause: Throwable? = null,
) : Exception(message, cause)
@@ -1,400 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.crypto
import com.github.javakeyring.BackendNotSupportedException
import com.github.javakeyring.Keyring
import com.github.javakeyring.PasswordAccessException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.RandomAccessFile
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.nio.file.attribute.PosixFilePermission
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
/**
* Desktop implementation of SecureKeyStorage using OS-native credential managers
* (macOS Keychain, Windows Credential Manager, Linux Secret Service/KWallet).
*
* Falls back to encrypted file storage with user-provided password if OS keyring
* is unavailable.
*
* ## Fallback Storage Security
*
* When OS keyring is unavailable, the implementation uses:
* - **Encryption:** AES-256-GCM with PBKDF2 (100k iterations)
* - **File Permissions:** Owner-only read/write (600 for files, 700 for directories) on Unix systems
* - **Atomic Writes:** Temp file + atomic move to prevent corruption
* - **File Locking:** Prevents concurrent access race conditions
*
* **Password Memory Limitation:** The fallback password is stored as a String and cannot be
* securely zeroed from memory. It remains cached for the application lifetime to avoid repeated
* password prompts. This is acceptable for desktop applications where the user's session is
* already trusted, but may not be suitable for shared/multi-user systems.
*/
actual class SecureKeyStorage private actual constructor() {
actual companion object {
/**
* Creates a SecureKeyStorage instance for Desktop.
*
* @param context Ignored on Desktop (no context needed)
* @return SecureKeyStorage instance
*/
actual fun create(context: Any?): SecureKeyStorage = SecureKeyStorage()
private const val SERVICE_NAME = "amethyst-desktop"
private const val FALLBACK_DIR = ".amethyst"
private const val FALLBACK_FILE = "keys.enc"
// Encryption constants for fallback
private const val ALGORITHM = "AES"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val KEY_LENGTH = 256
private const val ITERATION_COUNT = 100000
private const val IV_LENGTH = 12 // GCM standard
}
private var keyringAvailable: Boolean = true
private var fallbackPassword: String? = null
private val fallbackMutex = Mutex() // Protects concurrent access to fallback file
actual suspend fun savePrivateKey(
npub: String,
privKeyHex: String,
) {
withContext(Dispatchers.IO) {
try {
if (keyringAvailable) {
saveToKeyring(npub, privKeyHex)
} else {
saveToFallback(npub, privKeyHex)
}
} catch (e: BackendNotSupportedException) {
keyringAvailable = false
println("OS keyring not available, using fallback encrypted storage")
saveToFallback(npub, privKeyHex)
} catch (e: Exception) {
throw SecureStorageException("Failed to save private key", e)
}
}
}
actual suspend fun getPrivateKey(npub: String): String? =
withContext(Dispatchers.IO) {
try {
if (keyringAvailable) {
getFromKeyring(npub)
} else {
getFromFallback(npub)
}
} catch (e: BackendNotSupportedException) {
keyringAvailable = false
println("OS keyring not available, using fallback encrypted storage")
getFromFallback(npub)
} catch (e: PasswordAccessException) {
null // Key doesn't exist
} catch (e: Exception) {
throw SecureStorageException("Failed to retrieve private key", e)
}
}
actual suspend fun deletePrivateKey(npub: String): Boolean =
withContext(Dispatchers.IO) {
try {
if (keyringAvailable) {
deleteFromKeyring(npub)
} else {
deleteFromFallback(npub)
}
} catch (e: BackendNotSupportedException) {
keyringAvailable = false
deleteFromFallback(npub)
} catch (e: Exception) {
throw SecureStorageException("Failed to delete private key", e)
}
}
actual suspend fun hasPrivateKey(npub: String): Boolean = getPrivateKey(npub) != null
// Keyring-based storage
private fun saveToKeyring(
npub: String,
privKeyHex: String,
) {
val keyring = Keyring.create()
keyring.setPassword(SERVICE_NAME, npub, privKeyHex)
}
private fun getFromKeyring(npub: String): String? =
try {
val keyring = Keyring.create()
keyring.getPassword(SERVICE_NAME, npub)
} catch (e: PasswordAccessException) {
null
}
private fun deleteFromKeyring(npub: String): Boolean =
try {
val keyring = Keyring.create()
keyring.deletePassword(SERVICE_NAME, npub)
true
} catch (e: PasswordAccessException) {
false
}
// Fallback encrypted file storage
private suspend fun saveToFallback(
npub: String,
privKeyHex: String,
) {
fallbackMutex.withLock {
val password = getFallbackPassword()
val encrypted = encryptData(privKeyHex, password)
val fallbackFile = getFallbackFile()
// Create directory with restrictive permissions
fallbackFile.parentFile?.let { dir ->
if (!dir.exists()) {
dir.mkdirs()
setRestrictivePermissions(dir)
}
}
withFileLock(fallbackFile) {
val data = loadFallbackDataUnsafe().toMutableMap()
data[npub] = encrypted
atomicWriteFallbackData(fallbackFile, data)
}
}
}
private suspend fun getFromFallback(npub: String): String? {
val password = fallbackPassword ?: return null // No password set yet
return fallbackMutex.withLock {
val fallbackFile = getFallbackFile()
if (!fallbackFile.exists()) return@withLock null
withFileLock(fallbackFile) {
val data = loadFallbackDataUnsafe()
val encrypted = data[npub] ?: return@withFileLock null
try {
decryptData(encrypted, password)
} catch (e: Exception) {
null
}
}
}
}
private suspend fun deleteFromFallback(npub: String): Boolean {
return fallbackMutex.withLock {
val fallbackFile = getFallbackFile()
if (!fallbackFile.exists()) return@withLock false
withFileLock(fallbackFile) {
val data = loadFallbackDataUnsafe().toMutableMap()
val existed = data.remove(npub) != null
if (existed) {
if (data.isEmpty()) {
fallbackFile.delete()
} else {
atomicWriteFallbackData(fallbackFile, data)
}
}
existed
}
}
}
/**
* Loads fallback data without locking. Caller must hold mutex and file lock.
*/
private fun loadFallbackDataUnsafe(): Map<String, String> {
val fallbackFile = getFallbackFile()
if (!fallbackFile.exists()) return emptyMap()
return fallbackFile
.readLines()
.mapNotNull { line ->
val parts = line.split(":", limit = 2)
if (parts.size == 2) parts[0] to parts[1] else null
}.toMap()
}
/**
* Atomically writes fallback data using temp file + rename.
*/
private fun atomicWriteFallbackData(
fallbackFile: File,
data: Map<String, String>,
) {
val tempFile = File(fallbackFile.parentFile, "${fallbackFile.name}.tmp")
try {
// Write to temp file
tempFile.writeText(data.entries.joinToString("\n") { "${it.key}:${it.value}" })
setRestrictivePermissions(tempFile)
// Atomic rename
Files.move(
tempFile.toPath(),
fallbackFile.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} finally {
// Clean up temp file if it still exists
if (tempFile.exists()) {
tempFile.delete()
}
}
}
/**
* Executes block with file lock held.
*/
private fun <T> withFileLock(
file: File,
block: () -> T,
): T {
// Ensure lock file exists
val lockFile = File(file.parentFile, "${file.name}.lock")
lockFile.parentFile?.mkdirs()
if (!lockFile.exists()) {
lockFile.createNewFile()
setRestrictivePermissions(lockFile)
}
return RandomAccessFile(lockFile, "rw").use { raf ->
raf.channel.lock().use { lock ->
block()
}
}
}
private fun getFallbackFile(): File {
val homeDir = System.getProperty("user.home")
return File(homeDir, "$FALLBACK_DIR/$FALLBACK_FILE")
}
private fun getFallbackPassword(): String {
if (fallbackPassword == null) {
println("OS keyring not available. Fallback encrypted storage requires a password.")
val console = System.console()
fallbackPassword =
if (console != null) {
// Use Console.readPassword() for masked input
val password = console.readPassword("Enter master password: ")
password?.let {
val str = String(it)
it.fill('\u0000') // Clear the char array from memory
str
} ?: throw SecureStorageException("Password required for fallback storage")
} else {
// Fallback for non-interactive environments (testing, etc.)
print("Enter master password: ")
readLine() ?: throw SecureStorageException("Password required for fallback storage")
}
}
return fallbackPassword!!
}
private fun setRestrictivePermissions(file: File) {
try {
val path = file.toPath()
// Set owner-only read/write permissions (600 for files, 700 for directories)
val permissions =
if (file.isDirectory) {
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
)
} else {
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
)
}
Files.setPosixFilePermissions(path, permissions)
} catch (e: UnsupportedOperationException) {
// Windows doesn't support POSIX permissions - file system security handles this
// No action needed
} catch (e: Exception) {
// Log but don't fail - permissions are a security enhancement, not critical
System.err.println("Warning: Could not set restrictive file permissions: ${e.message}")
}
}
private fun encryptData(
plaintext: String,
password: String,
): String {
val salt = ByteArray(16).apply { SecureRandom().nextBytes(this) }
val iv = ByteArray(IV_LENGTH).apply { SecureRandom().nextBytes(this) }
val keySpec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH)
val secretKey = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec)
val key = SecretKeySpec(secretKey.encoded, ALGORITHM)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
val encrypted = cipher.doFinal(plaintext.toByteArray())
val combined = salt + iv + encrypted
return Base64.getEncoder().encodeToString(combined)
}
private fun decryptData(
ciphertext: String,
password: String,
): String {
val combined = Base64.getDecoder().decode(ciphertext)
val salt = combined.copyOfRange(0, 16)
val iv = combined.copyOfRange(16, 16 + IV_LENGTH)
val encrypted = combined.copyOfRange(16 + IV_LENGTH, combined.size)
val keySpec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH)
val secretKey = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(keySpec)
val key = SecretKeySpec(secretKey.encoded, ALGORITHM)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(encrypted)
return String(decrypted)
}
}