diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index 6fb4df3e38..5e334b9760 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.account import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.crypto.SecureStorageException import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull @@ -45,10 +47,64 @@ sealed class AccountState { ) : AccountState() } -class AccountManager { +class AccountManager( + private val secureStorage: SecureKeyStorage, +) { private val _accountState = MutableStateFlow(AccountState.LoggedOut) val accountState: StateFlow = _accountState.asStateFlow() + /** + * Loads the last saved account from secure storage. + * Call on app startup. + */ + suspend fun loadSavedAccount(): Result { + return try { + // For simplicity, we'll store the last logged-in npub in a simple file + // and use SecureKeyStorage to retrieve the private key + val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) + + val privKeyHex = secureStorage.getPrivateKey(lastNpub) + ?: return Result.failure(Exception("Private key not found for $lastNpub")) + + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Saves the current account to secure storage. + */ + suspend fun saveCurrentAccount(): Result { + val current = currentAccount() ?: return Result.failure(Exception("No account logged in")) + if (current.isReadOnly || current.nsec == null) { + return Result.failure(Exception("Cannot save read-only account")) + } + + return try { + val privKeyHex = decodePrivateKeyAsHexOrNull(current.nsec) + ?: return Result.failure(Exception("Invalid nsec format")) + + secureStorage.savePrivateKey(current.npub, privKeyHex) + saveLastNpub(current.npub) + Result.success(Unit) + } catch (e: SecureStorageException) { + Result.failure(e) + } + } + fun generateNewAccount(): AccountState.LoggedIn { val keyPair = KeyPair() val signer = NostrSignerInternal(keyPair) @@ -115,11 +171,41 @@ class AccountManager { return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) } - fun logout() { + suspend fun logout(deleteKey: Boolean = false) { + val current = currentAccount() + if (deleteKey && current != null) { + try { + secureStorage.deletePrivateKey(current.npub) + clearLastNpub() + } catch (e: SecureStorageException) { + // Log error but still logout + } + } _accountState.value = AccountState.LoggedOut } fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn + + // Simple file-based storage for last npub (non-sensitive data) + private fun getLastNpub(): String? { + val file = getPrefsFile() + return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null + } + + private fun saveLastNpub(npub: String) { + val file = getPrefsFile() + file.parentFile?.mkdirs() + file.writeText(npub) + } + + private fun clearLastNpub() { + getPrefsFile().delete() + } + + private fun getPrefsFile(): java.io.File { + val homeDir = System.getProperty("user.home") + return java.io.File(homeDir, ".amethyst/last_account.txt") + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b328301a6b..2954046a1a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -60,6 +60,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -84,6 +85,11 @@ import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -207,10 +213,17 @@ fun App( ) { var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } - val accountManager = remember { AccountManager() } + val secureStorage = remember { SecureKeyStorage() } + val accountManager = remember { AccountManager(secureStorage) } val accountState by accountManager.accountState.collectAsState() + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Try to load saved account on startup DisposableEffect(Unit) { + scope.launch { + accountManager.loadSavedAccount() + } + relayManager.addDefaultRelays() relayManager.connect() onDispose { @@ -376,6 +389,8 @@ fun ProfileScreen( account: AccountState.LoggedIn, accountManager: AccountManager, ) { + val scope = rememberCoroutineScope() + Column { Text( "Profile", @@ -393,7 +408,7 @@ fun ProfileScreen( Spacer(Modifier.height(24.dp)) OutlinedButton( - onClick = { accountManager.logout() }, + onClick = { scope.launch { accountManager.logout() } }, colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( contentColor = Color.Red, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 68761246e6..d32026cefb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.account.AccountManager import com.vitorpamplona.amethyst.commons.account.AccountState import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard +import kotlinx.coroutines.launch @Composable fun LoginScreen( @@ -48,6 +50,7 @@ fun LoginScreen( ) { var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() Column( modifier = Modifier.fillMaxSize().padding(32.dp), @@ -73,7 +76,11 @@ fun LoginScreen( LoginCard( onLogin = { keyInput -> accountManager.loginWithKey(keyInput).map { - onLoginSuccess() + // Save account to secure storage + scope.launch { + accountManager.saveCurrentAccount() + onLoginSuccess() + } } }, onGenerateNew = { @@ -89,7 +96,11 @@ fun LoginScreen( nsec = generatedAccount!!.nsec, onContinue = { showNewKeyDialog = false - onLoginSuccess() + // Save generated account + scope.launch { + accountManager.saveCurrentAccount() + onLoginSuccess() + } }, ) } diff --git a/docs/secure-key-storage-migration.md b/docs/secure-key-storage-migration.md new file mode 100644 index 0000000000..bec77ca40d --- /dev/null +++ b/docs/secure-key-storage-migration.md @@ -0,0 +1,208 @@ +# SecureKeyStorage Migration Guide + +## Overview + +SecureKeyStorage is now in `quartz/nip01Core/crypto/` as a KMP module for secure nsec (private key) storage. + +**Location:** `com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage` + +## Platform Implementations + +| Platform | Backend | Encryption | Hardware-Backed | +|----------|---------|------------|-----------------| +| **Android** | EncryptedSharedPreferences + Android Keystore | AES-256-GCM | ✅ (StrongBox when available) | +| **Desktop macOS** | macOS Keychain (via java-keyring) | OS-managed | ✅ (T2/M1+ chips) | +| **Desktop Windows** | Credential Manager (via java-keyring) | DPAPI | ✅ (TPM when available) | +| **Desktop Linux** | Secret Service/KWallet (via java-keyring) | OS-managed | ⚠️ (depends on setup) | +| **Fallback** | Encrypted file (~/.amethyst/keys.enc) | AES-256-GCM (PBKDF2) | ❌ | + +## API + +```kotlin +// Create instance (Android requires Context) +val storage = SecureKeyStorage(context) // Android +val storage = SecureKeyStorage() // Desktop + +// Save private key +suspend fun savePrivateKey(npub: String, privKeyHex: String) + +// Retrieve private key +suspend fun getPrivateKey(npub: String): String? + +// Delete private key +suspend fun deletePrivateKey(npub: String): Boolean + +// Check if exists +suspend fun hasPrivateKey(npub: String): Boolean +``` + +## Migrating Amethyst + +### Current Implementation + +**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt` + +- Uses `EncryptedSharedPreferences` directly +- Private keys stored in per-account encrypted prefs: `secret_keeper_$npub` +- Key: `NOSTR_PRIVKEY` + +**Current flow:** +```kotlin +// Save +val prefs = EncryptedStorage.preferences(context, npub) +prefs.edit().putString(PrefKeys.NOSTR_PRIVKEY, privKeyHex).apply() + +// Load +val privKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) +``` + +### New Implementation + +**Import:** +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage +import com.vitorpamplona.quartz.nip01Core.crypto.SecureStorageException +``` + +**New flow:** +```kotlin +// Initialize once (in Application or DI module) +val secureStorage = SecureKeyStorage(applicationContext) + +// Save +suspend fun saveAccount(npub: String, privKeyHex: String) { + try { + secureStorage.savePrivateKey(npub, privKeyHex) + } catch (e: SecureStorageException) { + Log.e("Account", "Failed to save key", e) + } +} + +// Load +suspend fun loadAccount(npub: String): String? { + return try { + secureStorage.getPrivateKey(npub) + } catch (e: SecureStorageException) { + Log.e("Account", "Failed to load key", e) + null + } +} + +// Delete +suspend fun deleteAccount(npub: String) { + secureStorage.deletePrivateKey(npub) +} +``` + +### Migration Steps + +1. **Update LocalPreferences.kt:** + - Add `SecureKeyStorage` instance + - Replace `EncryptedStorage.preferences()` calls with `SecureKeyStorage` methods + - Wrap in coroutines (`withContext(Dispatchers.IO)`) + +2. **Update AccountSecretsEncryptedStores.kt:** + - Already uses DataStore - can be replaced or kept for NWC secrets + - Consider using `SecureKeyStorage` for consistency + +3. **Handle Existing Keys:** + - **Option A (Automatic Migration):** On first launch, read from old storage → save to new storage → delete old + - **Option B (Keep Dual Storage):** Keep old code for read, use new for writes, phase out old storage later + - **Option C (Clean Break):** Require users to re-enter nsec on first launch after update + +4. **Desktop Integration:** + - Create `SecureKeyStorage()` instance (no context needed) + - Use same API calls as Android + - Fallback password prompt if OS keyring unavailable (auto-triggered) + +### Example Migration (Option A) + +```kotlin +// In LocalPreferences +private val secureStorage = SecureKeyStorage(applicationContext) + +suspend fun migrateToNewStorage(npub: String) { + // Check if already migrated + if (secureStorage.hasPrivateKey(npub)) return + + // Read from old storage + val oldPrefs = EncryptedStorage.preferences(applicationContext, npub) + val privKey = oldPrefs.getString(PrefKeys.NOSTR_PRIVKEY, null) ?: return + + // Save to new storage + secureStorage.savePrivateKey(npub, privKey) + + // Clean up old storage (optional) + oldPrefs.edit().remove(PrefKeys.NOSTR_PRIVKEY).apply() +} + +suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? { + npub ?: return null + + // Auto-migrate if needed + migrateToNewStorage(npub) + + // Load from new storage + val privKey = secureStorage.getPrivateKey(npub) + + // ... rest of loading logic +} +``` + +## Desktop Fallback Behavior + +If OS keyring unavailable (headless server, permission denied): + +1. User prompted for master password on first `savePrivateKey()` call +2. Password cached in memory for session +3. Keys stored encrypted in `~/.amethyst/keys.enc` +4. Format: `:` (one per line) +5. Encryption: AES-256-GCM with PBKDF2-derived key (100k iterations) + +**Security Note:** Fallback is less secure than OS keyring. Warn users to use OS-integrated keychain when possible. + +## Testing + +### Android +```kotlin +@Test +fun testSecureStorage() = runTest { + val storage = SecureKeyStorage(context) + val npub = "npub1test..." + val privKey = "nsec1test..." + + storage.savePrivateKey(npub, privKey) + assertEquals(privKey, storage.getPrivateKey(npub)) + assertTrue(storage.hasPrivateKey(npub)) + + storage.deletePrivateKey(npub) + assertFalse(storage.hasPrivateKey(npub)) +} +``` + +### Desktop +```kotlin +@Test +fun testDesktopStorage() = runTest { + val storage = SecureKeyStorage() + + // Test with OS keyring (if available) + storage.savePrivateKey("npub1test...", "nsec1test...") + assertNotNull(storage.getPrivateKey("npub1test...")) +} +``` + +## Security Considerations + +- **Android:** Keys protected by hardware when available (TEE/StrongBox) +- **Desktop:** Keys stored in OS-native credential managers (encrypted at rest) +- **Fallback:** User-password-protected file (PBKDF2 + AES-256-GCM) +- **Never log private keys** - use `SecureStorageException` for errors +- **Clear from memory** - keys returned as strings (consider SecureString for future enhancement) + +## References + +- Android Implementation: `quartz/src/androidMain/kotlin/.../SecureKeyStorage.kt` +- Desktop Implementation: `quartz/src/jvmMain/kotlin/.../SecureKeyStorage.kt` +- java-keyring: https://github.com/javakeyring/java-keyring +- Android EncryptedSharedPreferences: https://developer.android.com/topic/security/data diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c9693c5f..0918278b9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ firebaseBom = "34.7.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.20.1" +javaKeyring = "1.0.1" jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" @@ -118,6 +119,7 @@ firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } +java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a9f1d39e69..7bf19ba52c 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -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) } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyStoreEncryption.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyStoreEncryption.kt new file mode 100644 index 0000000000..548ad6b215 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyStoreEncryption.kt @@ -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) + } +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt new file mode 100644 index 0000000000..2f76f72e61 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt @@ -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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt new file mode 100644 index 0000000000..188dd07c05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt @@ -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) diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt new file mode 100644 index 0000000000..b0dc7e6d1d --- /dev/null +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/SecureKeyStorage.kt @@ -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 { + 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) + } +}