diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt index 40ff06dc..5fcea706 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt @@ -11,12 +11,13 @@ import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.managers.nostrPublicKeyHex -import fr.acinq.phoenix.security.EncryptedNostrKeys +import fr.acinq.phoenix.security.EncryptedNostrCredentials import fr.acinq.phoenix.security.EncryptedSeed +import fr.acinq.phoenix.security.NostrCredential import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -28,8 +29,8 @@ import press.mantra.compose.AppVersion import press.mantra.compose.ui.view.model.WritingSeedState /** - * Puts a new identity's secret on disk, one function per kind, and prepares the - * per-identity preferences both kinds read. + * Puts a new identity on disk, one function per kind, and prepares the per-identity + * preferences every kind reads. * * [writeMnemonic] was an `expect` with three byte-identical actuals -- android, jvm, ios -- * using nothing but commonMain: SeedManager, EncryptedSeed, LocalKeyManager, @@ -37,19 +38,24 @@ import press.mantra.compose.ui.view.model.WritingSeedState * differ and nothing does now, so it is one function here, and the second writer sits * next to it rather than being triplicated in turn. * - * Both refuse a duplicate **by nostr public key**, not only by id. A wallet and an + * All three refuse a duplicate **by nostr public key**, not only by id. A wallet and an * imported key can be the same npub under different ids -- one is hash160 of the node * key, the other hash160 of the nostr key -- and the database is keyed by pubkey, so two * identities for one pubkey would share every row and disagree about which is active. + * + * With one exception, which is the point of the credentials file: a public key already + * here read-only is not a duplicate of the secret that signs as it. The device does not + * have that secret, and refusing it would be false. [writeNostrKey] replaces the entry + * in one write; [writeMnemonic] has to span two files and says which goes first. */ object IdentityWriter { - /** The nostr public keys of every identity already on this device, or null if a store could not be read. */ - private fun knownNostrPublicKeys(phoenixGlobal: PhoenixGlobal): Set? { - val wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null - val nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: return null - return StoredIdentity.merge(wallets, nostrKeys).values.map { it.nostrPublicKey }.toSet() - } + /** What the seed store says, or null if it could not be read. */ + private fun seedPublicKeys(phoenixGlobal: PhoenixGlobal): Set? = + SeedManager.loadAndDecryptOrNull(phoenixGlobal) + ?.values + ?.map { StoredIdentity.nostrPublicKeyOf(it.words) } + ?.toSet() /** * Creates the preference files a new id needs and records the choices made before @@ -107,8 +113,8 @@ object IdentityWriter { val existingSeeds = SeedManager.loadAndDecryptOrNull(phoenixGlobal)?.map { it.key to it.value.words }?.toMap() - // Null when nostr-keys.dat exists and cannot be read; an empty map when it is absent. - val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + // Null when nostr-credentials.dat exists and cannot be read; an empty map when it is absent. + val existingCredentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) val seed = MnemonicCode.toSeed(mnemonics, "").toByteVector() val keyManager = LocalKeyManager(seed, NodeParamsManager.chain, NodeParamsManager.remoteSwapInXpub) @@ -116,7 +122,7 @@ object IdentityWriter { val newNostrPublicKey = keyManager.nostrPrivateKey().nostrPublicKeyHex() when { - existingSeeds == null || existingNostrKeys == null -> { + existingSeeds == null || existingCredentials == null -> { log.e("could not load the existing seed map, aborting...") onWritingSeedError.invoke( WritingSeedState.Error.CannotLoadSeedMap @@ -130,7 +136,7 @@ object IdentityWriter { ) return@launch } - existingNostrKeys.containsKey(newNostrPublicKey) -> { + existingCredentials[newNostrPublicKey] is NostrCredential.Secret -> { // The same npub is already here as a bare key. The id check above cannot // see that -- different hash, different key -- so it is asked by pubkey. log.i("attempting to import a seed whose nostr key is already here, aborting...") @@ -140,6 +146,18 @@ object IdentityWriter { return@launch } else -> { + if (existingCredentials[newNostrPublicKey] is NostrCredential.Public) { + // The npub is here read-only, and the words that sign as it have just + // been pasted: an upgrade, across two files. The credential goes + // first, then the seed. A crash between the two loses the read-only + // identity, which the npub pasted again restores; the reverse order + // would leave one npub listed twice under two ids. + log.i("the seed's nostr key is here read-only; replacing it with the wallet") + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(existingCredentials - newNostrPublicKey), + ) + } val newSeedMap = existingSeeds + (newWalletId to mnemonics) val encrypted = EncryptedSeed.V2.encrypt(newSeedMap) SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true) @@ -160,21 +178,28 @@ object IdentityWriter { } } - sealed class WriteNostrKeyResult { - data class Written(val id: WalletId) : WriteNostrKeyResult() + sealed class WriteNostrCredentialResult { + data class Written(val id: WalletId) : WriteNostrCredentialResult() - /** An identity with this nostr public key is already on the device, as a wallet or as a key. */ - data object AlreadyExists : WriteNostrKeyResult() + /** + * An identity with this nostr public key is already on the device and holds at least + * what was pasted: a wallet or a secret, for either input; a public key, for an npub. + */ + data object AlreadyExists : WriteNostrCredentialResult() /** One of the two stores exists and could not be read; nothing was written. */ - data object CannotLoadKeys : WriteNostrKeyResult() + data object CannotLoadKeys : WriteNostrCredentialResult() } /** - * Adds [privateKey] to `nostr-keys.dat` and prepares its preferences. Suspending and - * result-shaped rather than callback-shaped: nothing was built around a state machine - * for it, and the caller is a coroutine already. Throws on an I/O or key store failure - * during the write, as [writeMnemonic]'s handler would have caught. + * Adds [privateKey] to `nostr-credentials.dat` as a secret and prepares its + * preferences. Suspending and result-shaped rather than callback-shaped: nothing was + * built around a state machine for it, and the caller is a coroutine already. Throws + * on an I/O or key store failure during the write, as [writeMnemonic]'s handler would + * have caught. + * + * A public entry for the same key is the one thing that is not a duplicate: the entry + * becomes a secret one, in a single write, under the id it already had. */ suspend fun writeNostrKey( log: Logger, @@ -183,65 +208,112 @@ object IdentityWriter { privateKey: PrivateKey, isTorEnabled: Boolean, customElectrumServer: ElectrumConfig.Custom?, - ): WriteNostrKeyResult { + ): WriteNostrCredentialResult { log.d("writing nostr key to disk...") val stored = StoredIdentity.nostrSecret(privateKey) - val known = knownNostrPublicKeys(phoenixGlobal) - val existingNostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) - if (known == null || existingNostrKeys == null) { + val seeds = seedPublicKeys(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { log.e("could not load the existing keys, aborting...") - return WriteNostrKeyResult.CannotLoadKeys + return WriteNostrCredentialResult.CannotLoadKeys } - if (stored.nostrPublicKey in known) { + if (stored.nostrPublicKey in seeds || existing[stored.nostrPublicKey] is NostrCredential.Secret) { log.i("attempting to import a nostr key that is already here, aborting...") - return WriteNostrKeyResult.AlreadyExists + return WriteNostrCredentialResult.AlreadyExists } + val upgrading = existing[stored.nostrPublicKey] is NostrCredential.Public - val encrypted = EncryptedNostrKeys.encrypt(existingNostrKeys + (stored.nostrPublicKey to privateKey)) - NostrKeyManager.writeToDisk(phoenixGlobal, encrypted) - log.i("successfully imported nostr key for identity=${stored.id}") + val encrypted = EncryptedNostrCredentials.encrypt( + existing + (stored.nostrPublicKey to NostrCredential.Secret(privateKey)) + ) + NostrCredentialManager.writeToDisk(phoenixGlobal, encrypted) + if (upgrading) { + log.i("read-only identity=${stored.id} now holds its key") + } else { + log.i("successfully imported nostr key for identity=${stored.id}") + } prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) - return WriteNostrKeyResult.Written(stored.id) - } - - sealed class ForgetNostrKeyResult { - data object Forgotten : ForgetNostrKeyResult() - - /** The key is not in `nostr-keys.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */ - data object NotABareKey : ForgetNostrKeyResult() - - data object CannotLoadKeys : ForgetNostrKeyResult() + return WriteNostrCredentialResult.Written(stored.id) } /** - * The inverse of [writeNostrKey]: removes the key from `nostr-keys.dat` and deletes - * the identity's preference files. The profile stays on the relays, and the same key - * can be signed in again. Only for a bare key -- see [ForgetNostrKeyResult.NotABareKey]. + * Adds [nostrPublicKey] to `nostr-credentials.dat` as a public entry -- an identity + * the device can look at and not sign as -- and prepares its preferences. Refused if + * the device already holds anything at all for the key, since anything is more than + * this. */ - suspend fun forgetNostrKey( + suspend fun writeNostrPublicKey( + log: Logger, + phoenixGlobal: PhoenixGlobal, + globalPrefs: GlobalPrefs, + nostrPublicKey: HexKey, + isTorEnabled: Boolean, + customElectrumServer: ElectrumConfig.Custom?, + ): WriteNostrCredentialResult { + log.d("writing nostr public key to disk...") + val stored = StoredIdentity.nostrPublic(nostrPublicKey) + + val seeds = seedPublicKeys(phoenixGlobal) + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + if (seeds == null || existing == null) { + log.e("could not load the existing keys, aborting...") + return WriteNostrCredentialResult.CannotLoadKeys + } + if (stored.nostrPublicKey in seeds || existing.containsKey(stored.nostrPublicKey)) { + log.i("attempting to add a public key the device already holds something for, aborting...") + return WriteNostrCredentialResult.AlreadyExists + } + + NostrCredentialManager.writeToDisk( + phoenixGlobal, + EncryptedNostrCredentials.encrypt(existing + (stored.nostrPublicKey to NostrCredential.Public)), + ) + log.i("added read-only identity=${stored.id}") + + prepareIdentity(phoenixGlobal, globalPrefs, stored.id, isTorEnabled, customElectrumServer) + + return WriteNostrCredentialResult.Written(stored.id) + } + + sealed class ForgetNostrCredentialResult { + data object Forgotten : ForgetNostrCredentialResult() + + /** The key is not in `nostr-credentials.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */ + data object NotACredential : ForgetNostrCredentialResult() + + data object CannotLoadKeys : ForgetNostrCredentialResult() + } + + /** + * The inverse of [writeNostrKey] and [writeNostrPublicKey] alike: removes the entry, + * whichever kind it is, from `nostr-credentials.dat` and deletes the identity's + * preference files. The profile stays on the relays, and the same key can be signed + * in again. Only for a credential -- see [ForgetNostrCredentialResult.NotACredential]. + */ + suspend fun forgetNostrCredential( log: Logger, phoenixGlobal: PhoenixGlobal, id: WalletId, nostrPublicKey: HexKey, - ): ForgetNostrKeyResult { - val existing = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) + ): ForgetNostrCredentialResult { + val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) if (existing == null) { log.e("could not load the existing keys, aborting...") - return ForgetNostrKeyResult.CannotLoadKeys + return ForgetNostrCredentialResult.CannotLoadKeys } if (!existing.containsKey(nostrPublicKey)) { - log.i("asked to forget a key that is not a bare key on this device") - return ForgetNostrKeyResult.NotABareKey + log.i("asked to forget a key that is not a credential on this device") + return ForgetNostrCredentialResult.NotACredential } - NostrKeyManager.writeToDisk(phoenixGlobal, EncryptedNostrKeys.encrypt(existing - nostrPublicKey)) - log.i("forgot nostr key for identity=$id") + NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(existing - nostrPublicKey)) + log.i("forgot nostr credential for identity=$id") DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(id) - return ForgetNostrKeyResult.Forgotten + return ForgetNostrCredentialResult.Forgotten } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt index 6ca37701..ec309a7d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt @@ -1,5 +1,6 @@ package press.mantra.compose.identity +import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.MnemonicCode @@ -12,14 +13,16 @@ import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.NodeParamsManager import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.managers.nostrPublicKeyHex +import fr.acinq.phoenix.security.NostrCredential /** - * An identity this device holds the secret for, as listed before any of them is started. + * An identity this device holds something for, as listed before any of them is started. * - * Two stores feed this -- `seed.dat` for wallets, `nostr-keys.dat` for bare keys -- and - * the startup screen wants one list. The nostr public key is on both kinds because it - * is the one thing they have in common and the one thing a duplicate check has to - * compare: a wallet and an imported key can be the *same* npub under different ids. + * Two stores feed this -- `seed.dat` for wallets, `nostr-credentials.dat` for bare keys + * and bare public keys -- and the startup screen wants one list. The nostr public key + * is on every kind because it is the one thing they have in common and the one thing a + * duplicate check has to compare: a wallet and an imported key can be the *same* npub + * under different ids. * * On what this holds: `SovereignWalletViewModel.availableWallets` has always carried * decrypted words for the view model's life, because starting the node needs them. @@ -49,6 +52,17 @@ sealed interface StoredIdentity { override fun toString(): String = "StoredIdentity.NostrSecret(id=$id, npub=$nostrPublicKey, key=)" } + /** + * A public key the device holds nothing else for. Its id is the one its secret would + * have, so that pasting the nsec later keeps the preferences and metadata. + */ + data class NostrPublic( + override val id: WalletId, + override val nostrPublicKey: HexKey, + ) : StoredIdentity { + override val kind: IdentityKind get() = IdentityKind.NostrPublic + } + companion object { /** * The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has @@ -73,24 +87,53 @@ sealed interface StoredIdentity { ) } + fun nostrPublic(publicKeyHex: HexKey): NostrPublic = NostrPublic( + id = publicKeyHex.toXonlyPublicKey().toWalletId(), + nostrPublicKey = publicKeyHex, + ) + /** - * One map from the two stores. [nostrKeys] is keyed by x-only public key hex, as - * `nostr-keys.dat` is; the id of each is derived from that key, so a stored key - * whose map key disagrees with its own public key is dropped here too, though - * `EncryptedNostrKeys` refuses such a file before it gets this far. + * One map from the two stores. [credentials] is keyed by x-only public key hex, as + * `nostr-credentials.dat` is; a secret's id is derived from its own key, so a + * stored secret whose map key disagrees with its public key is dropped here too, + * though `EncryptedNostrCredentials` refuses such a file before it gets this far. + * + * One entry per public key is the credentials file's own invariant, so there is no + * precedence between credentials to decide. There is one between a seed and a + * credential, for the one upgrade that has to span both files -- a recovery phrase + * pasted over a public credential, which `IdentityWriter.writeMnemonic` performs as + * two writes: a `Public` whose public key a seed derives is dropped, with a log line, + * and the next write repairs the file. A *secret* for a seed's key is kept, as it + * always was: two ids for one npub is a duplicate the writers refuse, not a state + * the listing hides. */ fun merge( wallets: Map, - nostrKeys: Map, + credentials: Map, ): Map { val merged = LinkedHashMap() wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) } - nostrKeys.forEach { (publicKeyHex, privateKey) -> - val stored = nostrSecret(privateKey) - if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + val seedPublicKeys = merged.values.map { it.nostrPublicKey }.toSet() + credentials.forEach { (publicKeyHex, credential) -> + when (credential) { + is NostrCredential.Secret -> { + val stored = nostrSecret(credential.privateKey) + if (stored.nostrPublicKey == publicKeyHex) merged[stored.id] = stored + } + is NostrCredential.Public -> { + if (publicKeyHex in seedPublicKeys) { + log.w { "public credential for a key a seed already derives; listing the wallet only" } + } else { + val stored = runCatching { nostrPublic(publicKeyHex) }.getOrNull() + if (stored != null) merged[stored.id] = stored + } + } + } } return merged } + + private val log = Logger.withTag("StoredIdentity") } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt index bf04707a..705261a7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrSecretScreen.kt @@ -104,7 +104,7 @@ fun NostrSecretScreen( phoenixGlobal: PhoenixGlobal, activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, - forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, hideIdentityMetadata: suspend (Identity) -> Unit, onNavigateBack: () -> Unit, onForgotten: () -> Unit, @@ -114,7 +114,7 @@ fun NostrSecretScreen( phoenixGlobal = phoenixGlobal, activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, - forgetNostrKey = forgetNostrKey, + forgetNostrCredential = forgetNostrCredential, hideIdentityMetadata = hideIdentityMetadata, ) ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index 8dac4889..e66ecf99 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -92,7 +92,7 @@ import press.mantra.compose.ui.view.state.SignInToProfileUIState @Composable fun SignInToProfileScreen( nostrRepository: NostrRepository, - writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -302,7 +302,7 @@ private fun SignInToProfileScreenPreview() { ) { SignInToProfileScreen( nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - writeNostrKey = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }, + writeNostrKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, onNavigateBack = {}, onSignedIn = {}, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 6a4ab6e1..32297e2b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -199,6 +199,20 @@ fun SovereignWalletStartupScreen( ) ) } + is StoredIdentity.NostrPublic -> { + // Nothing to start and nothing to decrypt: the + // public key is the whole of what the device + // holds, and the identity is active the moment + // it is read, as the nsec branch above is. + sovereignWalletViewModel.setActiveIdentity( + Identity.readOnly( + id = identity.id, + nostrPublicKey = identity.nostrPublicKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id), + ) + ) + } } loadingIdentity = null }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 532f3200..5d83c85a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -909,7 +909,7 @@ fun MantraNavHost( phoenixGlobal = phoenixGlobal, activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity, nostrRepository = databaseNostrRepository, - forgetNostrKey = { identity -> sovereignWalletViewModel.forgetNostrKey(identity) }, + forgetNostrCredential = { identity -> sovereignWalletViewModel.forgetNostrCredential(identity) }, hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) }, onNavigateBack = { navController.popBackStack() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt index 6662236e..ff121980 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModel.kt @@ -7,8 +7,9 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.data.DecryptNostrKeysResult -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.data.DecryptNostrCredentialsResult +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.security.NostrCredential import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,7 +53,7 @@ class NostrSecretViewModel( val phoenixGlobal: PhoenixGlobal, val activeIdentityStateFlow: StateFlow, private val nostrRepository: NostrRepository, - private val forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + private val forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, private val hideIdentityMetadata: suspend (Identity) -> Unit, ) : ViewModel() { @@ -103,25 +104,27 @@ class NostrSecretViewModel( // Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread. val result = withContext(Dispatchers.IO) { - NostrKeyManager.loadAndDecrypt(phoenixGlobal) + NostrCredentialManager.loadAndDecrypt(phoenixGlobal) } _uiState.value = when (result) { - is DecryptNostrKeysResult.Success -> { - val privateKey = result.keys[identity.nostrPublicKey] - if (privateKey == null) { - logger.e { "key file holds no key for identity=${identity.id}" } + is DecryptNostrCredentialsResult.Success -> { + // A public entry is not a key either: this screen is not offered to a + // read-only identity, and if it were reached there is nothing to show. + val credential = result.credentials[identity.nostrPublicKey] as? NostrCredential.Secret + if (credential == null) { + logger.e { "credentials file holds no secret for identity=${identity.id}" } NostrSecretUIState.Error.NoKeyForThisIdentity } else { - NostrSecretUIState.Revealed(privateKey.value.toHex().hexToNsecHrp()) + NostrSecretUIState.Revealed(credential.privateKey.value.toHex().hexToNsecHrp()) } } - is DecryptNostrKeysResult.Failure.FileNotFound -> { - logger.e { "no key file, so no key for identity=${identity.id}" } + is DecryptNostrCredentialsResult.Failure.FileNotFound -> { + logger.e { "no credentials file, so no key for identity=${identity.id}" } NostrSecretUIState.Error.NoKeyForThisIdentity } - is DecryptNostrKeysResult.Failure -> { - logger.e { "unable to read the nostr keys: $result" } + is DecryptNostrCredentialsResult.Failure -> { + logger.e { "unable to read the nostr credentials: $result" } NostrSecretUIState.Error.KeysUnreadable } } @@ -174,15 +177,15 @@ class NostrSecretViewModel( logger.e("could not forget the key", throwable) _forgetting.value = NostrSecretUIState.Forgetting.Failed }) { - when (forgetNostrKey(identity)) { - is IdentityWriter.ForgetNostrKeyResult.Forgotten -> { + when (forgetNostrCredential(identity)) { + is IdentityWriter.ForgetNostrCredentialResult.Forgotten -> { hideIdentityMetadata(identity) nostrRepository.forgetLocalAccount(identity.nostrPublicKey) _forgetting.value = NostrSecretUIState.Forgetting.Idle withContext(Dispatchers.Main) { onForgotten() } } - is IdentityWriter.ForgetNostrKeyResult.NotABareKey, - is IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys -> { + is IdentityWriter.ForgetNostrCredentialResult.NotACredential, + is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> { _forgetting.value = NostrSecretUIState.Forgetting.Failed } } @@ -196,7 +199,7 @@ class NostrSecretViewModel( phoenixGlobal: PhoenixGlobal, activeIdentityStateFlow: StateFlow, nostrRepository: NostrRepository, - forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult, + forgetNostrCredential: suspend (Identity) -> IdentityWriter.ForgetNostrCredentialResult, hideIdentityMetadata: suspend (Identity) -> Unit, ): ViewModelProvider.Factory = viewModelFactory { initializer { @@ -204,7 +207,7 @@ class NostrSecretViewModel( phoenixGlobal = phoenixGlobal, activeIdentityStateFlow = activeIdentityStateFlow, nostrRepository = nostrRepository, - forgetNostrKey = forgetNostrKey, + forgetNostrCredential = forgetNostrCredential, hideIdentityMetadata = hideIdentityMetadata, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index df24172a..36993576 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -42,7 +42,7 @@ import press.mantra.compose.ui.view.state.form.SignInToProfileFormState */ class SignInToProfileViewModel( private val nostrRepository: NostrRepository, - private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, private val writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -56,7 +56,7 @@ class SignInToProfileViewModel( fun factory( nostrRepository: NostrRepository, - writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult, + writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -111,9 +111,9 @@ class SignInToProfileViewModel( val written: Outcome = when (credential) { is SignInCredential.NostrSecret -> try { when (val result = writeNostrKey(credential.privateKey)) { - is IdentityWriter.WriteNostrKeyResult.Written -> Outcome.SignedIn(result.id) - is IdentityWriter.WriteNostrKeyResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) - is IdentityWriter.WriteNostrKeyResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) + is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) } } catch (e: CancellationException) { throw e diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 5f6e36ee..ecb26905 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -14,14 +14,15 @@ import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.PhoenixGlobal import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey -import fr.acinq.phoenix.data.DecryptNostrKeysResult +import fr.acinq.phoenix.data.DecryptNostrCredentialsResult import fr.acinq.phoenix.data.DecryptSeedResult import fr.acinq.phoenix.data.ElectrumConfig import fr.acinq.phoenix.data.ListWalletState import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.managers.DataStoreManager -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager +import fr.acinq.phoenix.security.NostrCredential import fr.acinq.phoenix.managers.nostrPrivateKey import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata @@ -214,32 +215,32 @@ class SovereignWalletViewModel( is DecryptSeedResult.Success -> result.userWalletsMap } - val nostrKeys: Map = when (val result = NostrKeyManager.loadAndDecrypt(phoenixGlobal)) { - is DecryptNostrKeysResult.Failure.SerializationError -> { - log.e { "cannot deserialize nostr keys file" } + val credentials: Map = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) { + is DecryptNostrCredentialsResult.Failure.SerializationError -> { + log.e { "cannot deserialize nostr credentials file" } _listWalletState.value = ListWalletState.Error.Serialization return@launch } - is DecryptNostrKeysResult.Failure.DecryptionError -> { - log.e("cannot decrypt nostr keys file: ", throwable = result.cause) + is DecryptNostrCredentialsResult.Failure.DecryptionError -> { + log.e("cannot decrypt nostr credentials file: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.GeneralException(result.cause) return@launch } - is DecryptNostrKeysResult.Failure.KeyStoreFailure -> { + is DecryptNostrCredentialsResult.Failure.KeyStoreFailure -> { log.e("key store failure: ", throwable = result.cause) _listWalletState.value = ListWalletState.Error.DecryptionError.KeystoreFailure(result.cause) return@launch } - is DecryptNostrKeysResult.Failure.FileUnreadable -> { - log.e("aborting, unreadable nostr keys file") + is DecryptNostrCredentialsResult.Failure.FileUnreadable -> { + log.e("aborting, unreadable nostr credentials file") _listWalletState.value = ListWalletState.Error.Generic(null) return@launch } - is DecryptNostrKeysResult.Failure.FileNotFound -> emptyMap() - is DecryptNostrKeysResult.Success -> result.keys + is DecryptNostrCredentialsResult.Failure.FileNotFound -> emptyMap() + is DecryptNostrCredentialsResult.Success -> result.credentials } - val identities = StoredIdentity.merge(wallets, nostrKeys) + val identities = StoredIdentity.merge(wallets, credentials) val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first() identities.keys.forEach { walletId -> @@ -357,7 +358,7 @@ class SovereignWalletViewModel( * Writes a bare nostr key as a new identity, with the same initialisation options * [writeSeed] applies. Suspends; the caller is the sign-in view model's coroutine. */ - suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrKeyResult = + suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrCredentialResult = IdentityWriter.writeNostrKey( log = log, phoenixGlobal = phoenixGlobal, @@ -367,9 +368,20 @@ class SovereignWalletViewModel( customElectrumServer = customElectrumServer.value, ) - /** The inverse of [writeNostrKey]. Only for a bare key; see [IdentityWriter.forgetNostrKey]. */ - suspend fun forgetNostrKey(identity: Identity): IdentityWriter.ForgetNostrKeyResult = - IdentityWriter.forgetNostrKey( + /** Writes a public key as a read-only identity, with the same options. */ + suspend fun writeNostrPublicKey(nostrPublicKey: HexKey): IdentityWriter.WriteNostrCredentialResult = + IdentityWriter.writeNostrPublicKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = getGlobalPrefs(), + nostrPublicKey = nostrPublicKey, + isTorEnabled = isTorEnabled.value, + customElectrumServer = customElectrumServer.value, + ) + + /** The inverse of both writers above. Only for a credential; see [IdentityWriter.forgetNostrCredential]. */ + suspend fun forgetNostrCredential(identity: Identity): IdentityWriter.ForgetNostrCredentialResult = + IdentityWriter.forgetNostrCredential( log = log, phoenixGlobal = phoenixGlobal, id = identity.id, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt index db77f8b4..28e2cb61 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentityWriterJvmTest.kt @@ -5,9 +5,11 @@ import co.touchlab.kermit.Logger import fr.acinq.bitcoin.PrivateKey import fr.acinq.lightning.Lightning import fr.acinq.phoenix.PhoenixGlobal -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager import fr.acinq.phoenix.managers.computePreferencePath +import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.security.JvmKeyStore +import fr.acinq.phoenix.security.NostrCredential import fr.acinq.phoenix.utils.PlatformContext import fr.acinq.phoenix.utils.preferences.GlobalPrefs import kotlinx.coroutines.runBlocking @@ -24,12 +26,15 @@ import kotlin.test.assertIs import kotlin.test.assertTrue /** - * The two writers for a bare key, against a real key store and a real directory. + * The writers for a bare key and a bare public key, against a real key store and a + * real directory. * - * What is pinned is the duplicate rule and the inverse. A second import of the same key - * is refused by public key, the same npub imported twice being the one thing the id - * check cannot see; and forgetting takes the key out of the file and the identity's - * preference files off the disk, leaving every other key where it was. + * What is pinned is the duplicate rule, its one exception, and the inverse. A second + * import of the same key is refused by public key, the same npub imported twice being + * the one thing the id check cannot see; a public key already here is *not* a duplicate + * of the secret that signs as it, and the entry becomes a secret one under the id it + * had; and forgetting takes the entry, whichever kind, out of the file and the identity's + * preference files off the disk, leaving every other entry where it was. */ class IdentityWriterJvmTest { @@ -76,36 +81,96 @@ class IdentityWriterJvmTest { customElectrumServer = null, ) - private fun userPrefsFile(stored: StoredIdentity.NostrSecret) = + private suspend fun writePublic(key: PrivateKey) = IdentityWriter.writeNostrPublicKey( + log = log, + phoenixGlobal = phoenixGlobal, + globalPrefs = globalPrefs, + nostrPublicKey = key.nostrPublicKeyHex(), + isTorEnabled = false, + customElectrumServer = null, + ) + + private fun credentials() = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) + + private fun userPrefsFile(stored: StoredIdentity) = computePreferencePath(phoenixGlobal.ctx, "userprefs_${stored.id.nodeIdHash}.preferences_pb") @Test fun `a key is written once and refused the second time, by public key`() = runBlocking { val stored = StoredIdentity.nostrSecret(first) - val written = assertIs(write(first)) + val written = assertIs(write(first)) assertEquals(stored.id, written.id) - assertEquals(mapOf(stored.nostrPublicKey to first), NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)) + assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Secret(first)), credentials()) assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)), "the preferences file the recovery screens read") - assertIs(write(first)) + assertIs(write(first)) } @Test - fun `forgetting one key leaves the other, and takes the preferences with it`() = runBlocking { + fun `a public key is written once and refused the second time`() = runBlocking { + val stored = StoredIdentity.nostrPublic(first.nostrPublicKeyHex()) + + val written = assertIs(writePublic(first)) + assertEquals(stored.id, written.id) + assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Public), credentials()) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored))) + + assertIs(writePublic(first)) + } + + /** + * The one exception to the duplicate rule, and the reason the two kinds share a + * file: the device does not hold this secret, so refusing it would be false. One + * write, same id, and the preferences the read-only identity had are the ones the + * signing identity keeps. + */ + @Test + fun `the nsec of a key held read-only is accepted, under the same id, and the entry becomes a secret`() = runBlocking { + val readOnly = assertIs(writePublic(first)) + writePublic(second) + + val upgraded = assertIs(write(first)) + + assertEquals(readOnly.id, upgraded.id) + assertEquals( + mapOf( + first.nostrPublicKeyHex() to NostrCredential.Secret(first), + second.nostrPublicKeyHex() to NostrCredential.Public, + ), + credentials(), + ) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(first)))) + } + + /** The other direction is a duplicate: the device already holds more than an npub for the key. */ + @Test + fun `the npub of a key held as a secret is refused`() = runBlocking { write(first) - write(second) + + assertIs(writePublic(first)) + assertEquals(mapOf(first.nostrPublicKeyHex() to NostrCredential.Secret(first)), credentials()) + } + + @Test + fun `forgetting one entry leaves the others, whichever kind, and takes the preferences with it`() = runBlocking { + write(first) + writePublic(second) val stored = StoredIdentity.nostrSecret(first) - val result = IdentityWriter.forgetNostrKey(log, phoenixGlobal, stored.id, stored.nostrPublicKey) + val result = IdentityWriter.forgetNostrCredential(log, phoenixGlobal, stored.id, stored.nostrPublicKey) - assertIs(result) - assertEquals( - mapOf(StoredIdentity.nostrSecret(second).nostrPublicKey to second), - NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal), - ) + assertIs(result) + assertEquals(mapOf(second.nostrPublicKeyHex() to NostrCredential.Public), credentials()) assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(stored))) - assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrSecret(second)))) + assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(StoredIdentity.nostrPublic(second.nostrPublicKeyHex())))) + + val readOnly = StoredIdentity.nostrPublic(second.nostrPublicKeyHex()) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, readOnly.id, readOnly.nostrPublicKey) + ) + assertEquals(emptyMap(), credentials()) + assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(readOnly))) } /** A wallet's nostr key is in the seed, not here; removing a seed is a wallet question. */ @@ -114,9 +179,9 @@ class IdentityWriterJvmTest { write(first) val other = StoredIdentity.nostrSecret(second) - assertIs( - IdentityWriter.forgetNostrKey(log, phoenixGlobal, other.id, other.nostrPublicKey) + assertIs( + IdentityWriter.forgetNostrCredential(log, phoenixGlobal, other.id, other.nostrPublicKey) ) - assertEquals(1, NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)?.size) + assertEquals(1, credentials()?.size) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt index 97f23166..085dac31 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt @@ -9,7 +9,7 @@ import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.jvm.BusinessManager import fr.acinq.phoenix.managers.DataStoreManager import fr.acinq.phoenix.managers.NodeParamsManager -import fr.acinq.phoenix.managers.NostrKeyManager +import fr.acinq.phoenix.managers.NostrCredentialManager import fr.acinq.phoenix.managers.SeedManager import fr.acinq.phoenix.managers.computePreferencePath import fr.acinq.phoenix.security.JvmKeyStore @@ -113,14 +113,14 @@ class NsecRestoreRoundTripJvmTest { @Test fun `an nsec written, listed, activated and routed, with no node started`() = runBlocking { // 1. The sign-in screen's commit, minus the screen. - val written = assertIs( + val written = assertIs( IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, privateKey, isTorEnabled = false, customElectrumServer = null) ) // 2. What startup lists: both stores, merged. val identities = StoredIdentity.merge( wallets = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable"), - nostrKeys = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"), + credentials = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("key store unreadable"), ) val stored = assertIs(identities[written.id], "the imported key is listed under the id the writer returned") diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt index b1a44869..5f962472 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/StoredIdentityJvmTest.kt @@ -4,6 +4,7 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.security.NostrCredential import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -29,6 +30,7 @@ class StoredIdentityJvmTest { words = words, ) private val bareKey = PrivateKey(ByteVector32("02".repeat(32))) + private val readOnlyKey = PrivateKey(ByteVector32("03".repeat(32))).publicKey().xOnly().value.toHex() @Test fun `a wallet's nostr key is the NIP-06 derivation of its words`() { @@ -40,10 +42,13 @@ class StoredIdentityJvmTest { fun `both stores land in one map, each under its own id`() { val merged = StoredIdentity.merge( wallets = mapOf(wallet.walletId to wallet), - nostrKeys = mapOf(bareKey.publicKey().xOnly().value.toHex() to bareKey), + credentials = mapOf( + bareKey.publicKey().xOnly().value.toHex() to NostrCredential.Secret(bareKey), + readOnlyKey to NostrCredential.Public, + ), ) - assertEquals(2, merged.size) + assertEquals(3, merged.size) val mnemonic = assertIs(merged[wallet.walletId]) assertEquals(nip06PublicKey, mnemonic.nostrPublicKey) assertEquals(IdentityKind.Mnemonic, mnemonic.kind) @@ -52,6 +57,19 @@ class StoredIdentityJvmTest { assertEquals(IdentityKind.NostrSecret, secret.kind) assertEquals(40, secret.id.nodeIdHash.length, "same shape as a wallet's id") assertNotEquals(wallet.walletId, secret.id) + + val public = assertIs(merged[readOnlyKey.toXonlyPublicKey().toWalletId()]) + assertEquals(IdentityKind.NostrPublic, public.kind) + assertEquals(readOnlyKey, public.nostrPublicKey) + } + + /** The id a read-only identity gets is the one its secret would get, so an upgrade keeps it. */ + @Test + fun `a public key and its secret get the same id`() { + assertEquals( + StoredIdentity.nostrSecret(bareKey).id, + StoredIdentity.nostrPublic(bareKey.publicKey().xOnly().value.toHex()).id, + ) } /** @@ -63,18 +81,36 @@ class StoredIdentityJvmTest { fun `a wallet and its own nostr key as a bare secret do not collide by id`() { val merged = StoredIdentity.merge( wallets = mapOf(wallet.walletId to wallet), - nostrKeys = mapOf(nip06PublicKey to nip06PrivateKey), + credentials = mapOf(nip06PublicKey to NostrCredential.Secret(nip06PrivateKey)), ) assertEquals(2, merged.size) assertEquals(1, merged.values.map { it.nostrPublicKey }.toSet().size, "one npub, twice") } + /** + * The one precedence the merge decides. A recovery phrase pasted over a public + * credential is written across two files -- the credential removed first, then the + * seed -- and a crash between the two would leave neither, never both; this is the + * rule for the "both" that is not supposed to happen, so that it lists the wallet + * rather than one npub twice. + */ + @Test + fun `a public key a seed already derives is listed as the wallet only`() { + val merged = StoredIdentity.merge( + wallets = mapOf(wallet.walletId to wallet), + credentials = mapOf(nip06PublicKey to NostrCredential.Public), + ) + + assertEquals(1, merged.size) + assertIs(merged[wallet.walletId]) + } + @Test fun `a key filed under a public key it does not derive is dropped`() { val merged = StoredIdentity.merge( wallets = emptyMap(), - nostrKeys = mapOf(nip06PublicKey to bareKey), + credentials = mapOf(nip06PublicKey to NostrCredential.Secret(bareKey)), ) assertEquals(emptyMap(), merged) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt index 1e8c8de6..209d1e12 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt @@ -79,26 +79,26 @@ class NostrSecretViewModelJvmTest { private fun viewModel( recorder: Recorder, - forgetOutcome: IdentityWriter.ForgetNostrKeyResult, + forgetOutcome: IdentityWriter.ForgetNostrCredentialResult, ) = NostrSecretViewModel( phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = temporaryFolder.newFolder())), activeIdentityStateFlow = MutableStateFlow(identity()), nostrRepository = recorder, - forgetNostrKey = { recorder.effects += "forgetNostrKey"; forgetOutcome }, + forgetNostrCredential = { recorder.effects += "forgetNostrCredential"; forgetOutcome }, hideIdentityMetadata = { recorder.effects += "hideIdentityMetadata" }, ) @Test fun `the key comes out first, then the metadata, the account, and the caller is told`() { val recorder = Recorder() - val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.Forgotten) + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.Forgotten) val told = CompletableDeferred() viewModel.forgetKey { told.complete(Unit) } runBlocking { withTimeout(10_000) { told.await() } } assertEquals( - listOf("forgetNostrKey", "hideIdentityMetadata", "forgetLocalAccount"), + listOf("forgetNostrCredential", "hideIdentityMetadata", "forgetLocalAccount"), recorder.effects, ) assertEquals(NostrSecretUIState.Forgetting.Idle, viewModel.forgetting.value) @@ -107,7 +107,7 @@ class NostrSecretViewModelJvmTest { @Test fun `a key that could not be removed leaves everything else alone`() { val recorder = Recorder() - val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys) + val viewModel = viewModel(recorder, IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys) viewModel.forgetKey { error("must not be told") } val state = runBlocking { @@ -115,6 +115,6 @@ class NostrSecretViewModelJvmTest { } assertEquals(NostrSecretUIState.Forgetting.Failed, state) - assertEquals(listOf("forgetNostrKey"), recorder.effects) + assertEquals(listOf("forgetNostrCredential"), recorder.effects) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt index cc247437..06baab6d 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -42,7 +42,7 @@ class SignInToProfileViewModelJvmTest { private fun viewModel( recorder: Recorder, - nostrKeyOutcome: () -> IdentityWriter.WriteNostrKeyResult = { IdentityWriter.WriteNostrKeyResult.Written(id) }, + nostrKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) }, ) = SignInToProfileViewModel( nostrRepository = recorder, @@ -74,7 +74,7 @@ class SignInToProfileViewModelJvmTest { fun `a key already on the device is refused, and no account is planted`() = runBlocking { val recorder = Recorder() - val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.AlreadyExists }).commit(nostrSecret) + val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrSecret) assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) assertEquals(listOf("writeNostrKey"), recorder.effects) @@ -97,7 +97,7 @@ class SignInToProfileViewModelJvmTest { assertEquals( SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), - viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }).commit(nostrSecret), + viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }).commit(nostrSecret), ) assertEquals( SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite), diff --git a/lightning-kmp-app b/lightning-kmp-app index 59c11ed9..84cc44c8 160000 --- a/lightning-kmp-app +++ b/lightning-kmp-app @@ -1 +1 @@ -Subproject commit 59c11ed926ac05c820b98afb4cc961a5a3506310 +Subproject commit 84cc44c855e3d173e26016b807c449153a3af597