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:
-102
@@ -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)
|
||||
}
|
||||
}
|
||||
-123
@@ -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)
|
||||
}
|
||||
}
|
||||
-109
@@ -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)
|
||||
-400
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user