6.4 KiB
6.4 KiB
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
// 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
EncryptedSharedPreferencesdirectly - Private keys stored in per-account encrypted prefs:
secret_keeper_$npub - Key:
NOSTR_PRIVKEY
Current flow:
// 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:
import com.vitorpamplona.quartz.nip01Core.crypto.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.crypto.SecureStorageException
New flow:
// 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
-
Update LocalPreferences.kt:
- Add
SecureKeyStorageinstance - Replace
EncryptedStorage.preferences()calls withSecureKeyStoragemethods - Wrap in coroutines (
withContext(Dispatchers.IO))
- Add
-
Update AccountSecretsEncryptedStores.kt:
- Already uses DataStore - can be replaced or kept for NWC secrets
- Consider using
SecureKeyStoragefor consistency
-
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
-
Desktop Integration:
- Create
SecureKeyStorage()instance (no context needed) - Use same API calls as Android
- Fallback password prompt if OS keyring unavailable (auto-triggered)
- Create
Example Migration (Option A)
// 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):
- User prompted for master password on first
savePrivateKey()call - Password cached in memory for session
- Keys stored encrypted in
~/.amethyst/keys.enc - Format:
<npub>:<base64-encrypted-key>(one per line) - 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
@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
@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
SecureStorageExceptionfor 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