Files
amethyst/docs/secure-key-storage-migration.md
2026-01-06 06:49:59 +02:00

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 EncryptedSharedPreferences directly
  • 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

  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)

// 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

@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 SecureStorageException for errors
  • Clear from memory - keys returned as strings (consider SecureString for future enhancement)

References