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 <noreply@anthropic.com>
Pulled-From: curated/curated@5efeae769f
This commit is contained in:
Kgothatso Ngako
2026-09-12 20:25:18 +02:00
parent cf43973b36
commit d5bd248364
15 changed files with 399 additions and 154 deletions

View File

@@ -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<HexKey>? {
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<HexKey>? =
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
}
}

View File

@@ -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=<redacted>)"
}
/**
* 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<WalletId, UserWallet>,
nostrKeys: Map<HexKey, PrivateKey>,
credentials: Map<HexKey, NostrCredential>,
): Map<WalletId, StoredIdentity> {
val merged = LinkedHashMap<WalletId, StoredIdentity>()
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")
}
}

View File

@@ -104,7 +104,7 @@ fun NostrSecretScreen(
phoenixGlobal: PhoenixGlobal,
activeIdentityStateFlow: StateFlow<Identity?>,
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,
)
)

View File

@@ -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<String>,
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 = {},

View File

@@ -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
},

View File

@@ -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()

View File

@@ -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<Identity?>,
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<Identity?>,
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,
)
}

View File

@@ -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<String>,
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<String>,
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

View File

@@ -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<HexKey, PrivateKey> = when (val result = NostrKeyManager.loadAndDecrypt(phoenixGlobal)) {
is DecryptNostrKeysResult.Failure.SerializationError -> {
log.e { "cannot deserialize nostr keys file" }
val credentials: Map<HexKey, NostrCredential> = 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,

View File

@@ -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<Unit> {
val stored = StoredIdentity.nostrSecret(first)
val written = assertIs<IdentityWriter.WriteNostrKeyResult.Written>(write(first))
val written = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(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<IdentityWriter.WriteNostrKeyResult.AlreadyExists>(write(first))
assertIs<IdentityWriter.WriteNostrCredentialResult.AlreadyExists>(write(first))
}
@Test
fun `forgetting one key leaves the other, and takes the preferences with it`() = runBlocking<Unit> {
fun `a public key is written once and refused the second time`() = runBlocking<Unit> {
val stored = StoredIdentity.nostrPublic(first.nostrPublicKeyHex())
val written = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(writePublic(first))
assertEquals(stored.id, written.id)
assertEquals(mapOf(stored.nostrPublicKey to NostrCredential.Public), credentials())
assertTrue(FileSystem.SYSTEM.exists(userPrefsFile(stored)))
assertIs<IdentityWriter.WriteNostrCredentialResult.AlreadyExists>(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<Unit> {
val readOnly = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(writePublic(first))
writePublic(second)
val upgraded = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(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<Unit> {
write(first)
write(second)
assertIs<IdentityWriter.WriteNostrCredentialResult.AlreadyExists>(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<Unit> {
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<IdentityWriter.ForgetNostrKeyResult.Forgotten>(result)
assertEquals(
mapOf(StoredIdentity.nostrSecret(second).nostrPublicKey to second),
NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal),
)
assertIs<IdentityWriter.ForgetNostrCredentialResult.Forgotten>(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.ForgetNostrCredentialResult.Forgotten>(
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.ForgetNostrKeyResult.NotABareKey>(
IdentityWriter.forgetNostrKey(log, phoenixGlobal, other.id, other.nostrPublicKey)
assertIs<IdentityWriter.ForgetNostrCredentialResult.NotACredential>(
IdentityWriter.forgetNostrCredential(log, phoenixGlobal, other.id, other.nostrPublicKey)
)
assertEquals(1, NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)?.size)
assertEquals(1, credentials()?.size)
}
}

View File

@@ -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<Unit> {
// 1. The sign-in screen's commit, minus the screen.
val written = assertIs<IdentityWriter.WriteNostrKeyResult.Written>(
val written = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(
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<StoredIdentity.NostrSecret>(identities[written.id], "the imported key is listed under the id the writer returned")

View File

@@ -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<StoredIdentity.Mnemonic>(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<StoredIdentity.NostrPublic>(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<StoredIdentity.Mnemonic>(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)

View File

@@ -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?>(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<Unit>()
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)
}
}

View File

@@ -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),