key storage
This commit is contained in:
+88
-2
@@ -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>(AccountState.LoggedOut)
|
||||
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Loads the last saved account from secure storage.
|
||||
* Call on app startup.
|
||||
*/
|
||||
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> {
|
||||
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<Unit> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(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,
|
||||
|
||||
@@ -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<AccountState.LoggedIn?>(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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: `<npub>:<base64-encrypted-key>` (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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
+69
@@ -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)
|
||||
+242
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user