key storage

This commit is contained in:
nrobi144
2026-01-06 06:49:59 +02:00
parent 2079493fc4
commit 4339247815
10 changed files with 840 additions and 6 deletions
+7
View File
@@ -166,6 +166,9 @@ kotlin {
// LibSodium for ChaCha encryption (NIP-44)
implementation (libs.lazysodium.java)
implementation (libs.jna)
// Secure key storage via OS keychain (macOS/Windows/Linux)
implementation(libs.java.keyring)
}
}
@@ -188,6 +191,10 @@ kotlin {
// LibSodium for ChaCha encryption (NIP-44)
implementation ("com.goterl:lazysodium-android:5.2.0@aar")
implementation ("net.java.dev.jna:jna:5.18.1@aar")
// Secure key storage via Android Keystore
implementation(libs.androidx.security.crypto.ktx)
implementation(libs.androidx.datastore.preferences)
}
}
@@ -0,0 +1,102 @@
/**
* 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)
}
}
@@ -0,0 +1,92 @@
/**
* 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).
*/
actual class SecureKeyStorage(private val context: Context) {
companion object {
private const val PREFS_NAME = "amethyst_secure_keys"
private const val KEY_PREFIX = "privkey_"
}
private val masterKey: MasterKey by lazy {
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val encryptedPrefs by lazy {
EncryptedSharedPreferences.create(
context,
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)
}
}
@@ -0,0 +1,69 @@
/**
* 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.
*
* Android: Android Keystore + EncryptedDataStore (AES-256-GCM, StrongBox when available)
* Desktop: OS native credential managers (macOS Keychain, Windows Credential Manager, Linux Secret Service)
*/
expect class 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.
*
* @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)
@@ -0,0 +1,242 @@
/**
* 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.withContext
import java.io.File
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.
*/
actual class SecureKeyStorage {
companion object {
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
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 fun saveToFallback(npub: String, privKeyHex: String) {
val password = getFallbackPassword()
val encrypted = encryptData(privKeyHex, password)
val fallbackFile = getFallbackFile()
val data = loadFallbackData().toMutableMap()
data[npub] = encrypted
fallbackFile.parentFile?.mkdirs()
fallbackFile.writeText(data.entries.joinToString("\n") { "${it.key}:${it.value}" })
}
private fun getFromFallback(npub: String): String? {
val password = fallbackPassword ?: return null // No password set yet
val data = loadFallbackData()
val encrypted = data[npub] ?: return null
return try {
decryptData(encrypted, password)
} catch (e: Exception) {
null
}
}
private fun deleteFromFallback(npub: String): Boolean {
val fallbackFile = getFallbackFile()
if (!fallbackFile.exists()) return false
val data = loadFallbackData().toMutableMap()
val existed = data.remove(npub) != null
if (existed) {
if (data.isEmpty()) {
fallbackFile.delete()
} else {
fallbackFile.writeText(data.entries.joinToString("\n") { "${it.key}:${it.value}" })
}
}
return existed
}
private fun loadFallbackData(): 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()
}
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.")
print("Enter master password: ")
fallbackPassword = readLine() ?: throw SecureStorageException("Password required for fallback storage")
}
return fallbackPassword!!
}
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)
}
}