From d5bd24836426abc186b4b4e849b6e2db4f27fea5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:25:18 +0200 Subject: [PATCH] feat(identity): one credentials file, with a read-only kind and the upgrade in one write Phase 2 of docs/npub-sign-in.md: the library half is lightning-kmp-app 01962f3 (claude/nostr-credentials), bumped in here; this is the app half. nostr-keys.dat becomes nostr-credentials.dat, one typed entry per public key -- a secret, or only the public key -- so that a profile signed in to read-only and the same profile with its nsec pasted later are one entry in one file. StoredIdentity gains NostrPublic, whose id is the one its secret would have (hash160 of the x-only key), and merge reads the typed map. It keeps the one precedence it needs: a public entry for a key a seed already derives lists the wallet only, which is the state the seed writer's two-file upgrade can leave behind if it dies between its writes. A secret for a seed's key is still listed twice, as before -- that is a duplicate the writers refuse, not a state the listing hides. IdentityWriter: writeNostrPublicKey beside writeNostrKey, both refusing a duplicate by public key against the seeds and the credentials, with the one exception that is the point of the file -- the nsec of a key held read-only is not a duplicate, since the device does not have that secret. writeNostrKey replaces the public entry with a secret one in a single write, under the id it already had, so the read-only identity's preferences are the ones the signing identity keeps. writeMnemonic has to span two files for the same upgrade and removes the credential first, then writes the seed: a crash between the two loses the read-only identity, which the npub pasted again restores, rather than listing one npub twice under two ids. forgetNostrKey becomes forgetNostrCredential and removes an entry of either kind; NotABareKey becomes NotACredential and still means a mnemonic. The startup screen's third branch is here rather than in Phase 3 because StoredIdentity is sealed and the compiler asked for it: a read-only identity is active the moment it is read, as the nsec one is. NostrSecretViewModel reads the secret entry and answers a public one with "no key for this identity", which the screen it belongs to will never show once Phase 5 hides the row. Tests: the writer's upgrade in both directions -- the nsec of a read-only key accepted under the same id, the npub of a secret refused -- and forget of either kind; merge listing all three kinds, the shared id of a public key and its secret, and the seed-over-public precedence. Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library's master as 84cc44c with an identical tree. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@5efeae769f423181cceade4fcb958449082ff5d0 --- .../mantra/compose/identity/IdentityWriter.kt | 184 ++++++++++++------ .../mantra/compose/identity/StoredIdentity.kt | 69 +++++-- .../ui/composable/NostrSecretScreen.kt | 4 +- .../compose/ui/composable/SignInScreen.kt | 4 +- .../SovereignWalletStartupScreen.kt | 14 ++ .../ui/composable/navigation/MantraNavHost.kt | 2 +- .../ui/view/model/NostrSecretViewModel.kt | 41 ++-- .../ui/view/model/SignInToProfileViewModel.kt | 10 +- .../ui/view/model/SovereignWalletViewModel.kt | 46 +++-- .../compose/identity/IdentityWriterJvmTest.kt | 109 ++++++++--- .../identity/NsecRestoreRoundTripJvmTest.kt | 6 +- .../compose/identity/StoredIdentityJvmTest.kt | 44 ++++- .../view/model/NostrSecretViewModelJvmTest.kt | 12 +- .../model/SignInToProfileViewModelJvmTest.kt | 6 +- lightning-kmp-app | 2 +- 15 files changed, 399 insertions(+), 154 deletions(-) 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