feat(identity): a seed's key is a credential, and the seed is the wallet attached to it

Phase 1 of docs/multiple-profiles.md. No library change: the file format, the
encrypted writer and the manager all exist, and the app already wrote the
credentials file from the seed writer -- to delete a public entry a seed
superseded. This changes what it writes there, and what the listing believes.

Until now a seed's nostr key was never in nostr-credentials.dat. It was
derived from the words at listing, to know which npub to show, and from the
running node at activation, to know which key to sign with -- so a seed-backed
profile existed only as a derivation, and the app had to start a Lightning node
to find out who it was. Both sign-in plans made "one key, one file" an
invariant, and it was the wrong one: it said a wallet is a profile. Now the
credentials file is the list of profiles and a seed is a wallet attached to the
entry its key derives.

writeMnemonic writes two things, credential first: a Secret for the derived key
under its x-only pubkey, replacing a public entry where there is one, and then
the seed. Credential first because a crash between the two leaves a bare-key
profile the phrase completes, which is a valid thing to hold and says the model
out loud -- the profile exists, then a wallet is attached to it. The one
refusal it drops is the phrase of a key held as a bare secret, SeedAlreadyExists
under the nsec plan: the device did not have that wallet, so this is the
profile acquiring the wallet that derives it. The entry stays a secret for the
same key, the id becomes the wallet's, and the bare key's preference files go
with the old id -- the profile's preferences are the wallet's now, fresh, which
is right since there is a new secret to back up. The same seed twice is still
refused, by wallet id, and is the only way a profile with a wallet attached is
offered its phrase again.

SeedCredentials.reconcile is the repair for every seed already on a device,
beside migrateFromNostrKeys in listIdentities and shaped like it: a named,
idempotent write, one file write for however many seeds are missing, nothing at
all on a device with none or one already repaired. A failed write is a result,
not a throw, and carries the map that was read: the listing goes on with it and
merge derives the key of a seed that has no credential, so a seed this could
not repair is still listed. A failed write must never hide a wallet. The plan
had the repair running before the credentials file was read; it runs after,
and hands the listing what it returns, so the file is decrypted once -- the
doc now says so.

StoredIdentity.merge inverts: the credentials are the list, and each seed is
attached to the Secret its key derives -- listed once, as Mnemonic under the
wallet's id, carrying the credential's key -- where before the seeds were the
list and a secret for a seed's key was listed twice under two ids. Mnemonic
gains privateKey. A Public for a seed's key is skipped with a log line, since
the next repair upgrades it; a seed with no credential is listed by derivation,
with a log line. IdentityKind.Mnemonic's doc changes to what the kind now
means: the name records the attachment, not the source.

setActiveWallet takes the StoredIdentity.Mnemonic and builds the identity from
the credential's key, with the node's derivation as a cross-check -- a check(),
because a node disagreeing with the credentials file is the one corruption
worth refusing to run under, and it cannot fail for a file the repair wrote.
The node still starts for a profile with a wallet attached: not for the key any
more, but for what startNewBusiness does besides -- metadata, preferences,
last-used build, and on Android the channel watcher a restored Phoenix phrase
may need. Making it lazy is now one branch and is named as its own decision.

forgetNostrCredential refuses a second thing: a key a seed derives. The seed
would derive it again and the next repair would write it back, so a forget
that succeeded would undo itself. NotACredential becomes WalletAttached in the
writer's result and ForgetIdentity's outcome, since that is now the only reason
a signing profile cannot be forgotten -- a key not in the file at all can, since
the repair, only be a seed's key the repair could not write.

The two sign-in docs' tables each gain a row pointing here for the invariant
this supersedes.

Tests: SeedCredentialsJvmTest against a device from before -- seed.dat written
directly, no credentials -- writes exactly what is missing in one write; a
device already repaired is not written to again, checked by the file's bytes
since a rewrite would carry a fresh iv; a public entry for a seed's key is
upgraded; bare keys are untouched; and a write that fails, with the key store
locked, is reported with the map that was read and the wallet is still listed.
IdentityWriterJvmTest drives the callback-shaped seed writer through a
CompletableDeferred on a real Main dispatcher, since it reports after a real
one-second delay: a phrase writes both files, its nsec and npub are then
duplicates and its forget is WalletAttached; the same phrase twice is refused;
the phrase of a bare key attaches, under the wallet's id, with the bare key's
preferences gone; the phrase of a key held read-only attaches and the entry
becomes a secret. StoredIdentityJvmTest lists a secret-plus-seed once with the
credential's key, a seed without a credential by derivation, and a public entry
for a seed's key as the wallet only. Not under test: the activation's
cross-check, because a PhoenixBusiness cannot be built without a node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@3008137e3f
This commit is contained in:
Kgothatso Ngako
2026-09-13 00:20:22 +02:00
parent 44324c902b
commit d957ee8de5
16 changed files with 649 additions and 111 deletions

View File

@@ -11,9 +11,9 @@ import press.mantra.compose.repository.NostrRepository
* Lifted out of `NostrSecretViewModel.forgetKey`, where it was written for an nsec, so
* that the two exits a read-only identity has -- sign out, and *use a different key* on
* the not-found screen -- run the same sequence rather than a copy of it. The first step
* is the same call for both credential kinds since the credentials file; only a mnemonic
* identity is refused, because removing a seed is a wallet question this does not
* answer.
* is the same call for both credential kinds since the credentials file; only a profile
* with a wallet attached is refused, because removing a seed is a wallet question this
* does not answer, and because the seed would derive the key again.
*
* The two writers are injected as functions so the sequence can be pinned in a test
* without a key store, as the nsec view model's already is.
@@ -24,8 +24,8 @@ object ForgetIdentity {
/** The device no longer holds anything for the identity. */
data object Forgotten : Outcome
/** A mnemonic identity: its key is in the seed, and this does not remove seeds. */
data object NotACredential : Outcome
/** A profile with a wallet attached: the seed derives its key, and this does not remove seeds. */
data object WalletAttached : Outcome
/** The credentials file could not be read; nothing was changed. */
data object CannotForget : Outcome
@@ -44,7 +44,7 @@ object ForgetIdentity {
nostrRepository.forgetLocalAccount(identity.nostrPublicKey)
Outcome.Forgotten
}
is IdentityWriter.ForgetNostrCredentialResult.NotACredential -> Outcome.NotACredential
is IdentityWriter.ForgetNostrCredentialResult.WalletAttached -> Outcome.WalletAttached
is IdentityWriter.ForgetNostrCredentialResult.CannotLoadKeys -> Outcome.CannotForget
}
}

View File

@@ -14,16 +14,21 @@ import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
/**
* Where an identity's key came from, which decides what else it can have.
* What the device holds beside an identity's key, which decides what else it can have.
*
* The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at
* `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the
* key and a wallet; a bare key yields nothing further, so an identity made from
* one can never grow a node behind it; and a bare public key yields nothing at all,
* not even a signature. See docs/nsec-sign-in.md and docs/npub-sign-in.md.
* not even a signature. See docs/nsec-sign-in.md, docs/npub-sign-in.md and
* docs/multiple-profiles.md.
*/
enum class IdentityKind {
/** Twelve words. The nostr key is derived from them, and so is a wallet. */
/**
* A key the device also holds a seed for. The key is a credential like any other
* -- it is read from the credentials file, not derived from the node -- and the seed
* is the wallet attached to it. The name records the attachment, not the source.
*/
Mnemonic,
/** A bare nostr secret. Nothing else can be derived from it. */

View File

@@ -43,10 +43,15 @@ import press.mantra.compose.ui.view.model.WritingSeedState
* 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
* With two exceptions, which are 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.
* in one write. And a bare secret already here is not a duplicate of the recovery phrase
* that derives it: the device does not have that wallet, and pasting the phrase is the
* profile acquiring the wallet that derives it. [writeMnemonic] writes the credential
* first and then the seed -- see docs/multiple-profiles.md, Phase 1 -- and every seed's
* key is a `Secret` entry in the credentials file, so that the file is the list of
* profiles and the seed is a wallet attached to one of them.
*/
object IdentityWriter {
@@ -130,34 +135,43 @@ object IdentityWriter {
return@launch
}
existingSeeds.containsKey(newWalletId) -> {
// The same seed, and so the same nostr key with a wallet already attached.
// Two different seeds cannot derive one nostr key, so this is the only
// way a profile with a wallet attached is offered its phrase again.
log.i("attempting to import a seed that already exists, aborting...")
onWritingSeedError.invoke(
WritingSeedState.Error.SeedAlreadyExists
)
return@launch
}
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...")
onWritingSeedError.invoke(
WritingSeedState.Error.SeedAlreadyExists
)
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),
)
// The credential first, then the seed. What the credentials file holds
// for this key beforehand decides what the write means, not whether it
// happens: nothing, and this is a new profile with a wallet; a public
// entry, and it is the read-only upgrade landing on a wallet; a secret,
// and it is a bare key acquiring the wallet that derives it, which used
// to be refused as a duplicate and is not one -- the device did not have
// the wallet. In every case the entry becomes the derived key.
//
// A crash between the two writes leaves a bare-key profile whose key
// the phrase derives: a valid thing to hold, and pasting the phrase
// again completes it. The reverse order would leave a seed with no
// credential, which the listing derives around and the repair fixes --
// also recoverable, but this order says the model out loud: the profile
// exists, and then a wallet is attached to it.
val existingEntry = existingCredentials[newNostrPublicKey]
val bareKeyId = if (existingEntry != null) StoredIdentity.nostrPublic(newNostrPublicKey).id else null
when (existingEntry) {
null -> Unit
is NostrCredential.Public -> log.i("the seed's nostr key is here read-only; it becomes a profile with a wallet")
is NostrCredential.Secret -> log.i("the seed's nostr key is here as a bare key; attaching the wallet")
}
NostrCredentialManager.writeToDisk(
phoenixGlobal,
EncryptedNostrCredentials.encrypt(
existingCredentials + (newNostrPublicKey to NostrCredential.Secret(keyManager.nostrPrivateKey()))
),
)
val newSeedMap = existingSeeds + (newWalletId to mnemonics)
val encrypted = EncryptedSeed.V2.encrypt(newSeedMap)
SeedManager.writeSeedToDisk(phoenixGlobal, encrypted, overwrite = true)
@@ -166,6 +180,13 @@ object IdentityWriter {
} else {
log.i("successfully created wallet=$newWalletId")
}
if (bareKeyId != null) {
// The profile's id is its wallet's now, so its preferences are the
// wallet's -- fresh, as a new wallet's are, which is right: there is
// a new secret to back up. The files under the bare key's id would
// otherwise sit on the disk with nothing reading them.
DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(bareKeyId)
}
}
}
@@ -281,8 +302,14 @@ object IdentityWriter {
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()
/**
* A seed derives the key: the profile has a wallet attached, and removing a seed is
* a wallet question this does not answer. Also the answer for a key that is not in
* `nostr-credentials.dat` at all, which since the repair can only be a seed's key
* the repair could not write -- and either way, taking the entry would not take the
* key: the seed would derive it again and the next listing would put it back.
*/
data object WalletAttached : ForgetNostrCredentialResult()
data object CannotLoadKeys : ForgetNostrCredentialResult()
}
@@ -291,7 +318,8 @@ object IdentityWriter {
* 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].
* in again. Only for a profile with no wallet attached -- see
* [ForgetNostrCredentialResult.WalletAttached].
*/
suspend fun forgetNostrCredential(
log: Logger,
@@ -299,14 +327,19 @@ object IdentityWriter {
id: WalletId,
nostrPublicKey: HexKey,
): ForgetNostrCredentialResult {
val seeds = seedPublicKeys(phoenixGlobal)
val existing = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal)
if (existing == null) {
if (seeds == null || existing == null) {
log.e("could not load the existing keys, aborting...")
return ForgetNostrCredentialResult.CannotLoadKeys
}
if (nostrPublicKey in seeds) {
log.i("asked to forget a key a seed derives; the wallet has to go first")
return ForgetNostrCredentialResult.WalletAttached
}
if (!existing.containsKey(nostrPublicKey)) {
log.i("asked to forget a key that is not a credential on this device")
return ForgetNostrCredentialResult.NotACredential
return ForgetNostrCredentialResult.WalletAttached
}
NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(existing - nostrPublicKey))

View File

@@ -0,0 +1,77 @@
package press.mantra.compose.identity
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.UserWallet
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.NostrCredentialManager
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.security.EncryptedNostrCredentials
import fr.acinq.phoenix.security.NostrCredential
/**
* The credential a seed always implied.
*
* Until docs/multiple-profiles.md, a seed's nostr key was never written to the
* credentials file: it was derived from the words at listing and from the running node
* at activation, and the writers kept one key out of two files. That put the wallet
* where the profile should be. Now the credentials file is the list of profiles and a
* seed is a wallet attached to one, so every seed's key has to be in it -- written by
* `IdentityWriter.writeMnemonic` for a seed created or restored from here on, and by
* this for every seed that was already on the device.
*
* A named, idempotent write that runs from `SovereignWalletViewModel.listIdentities`,
* beside `NostrCredentialManager.migrateFromNostrKeys` and for the same reason: a
* listing that writes is a surprise, so the write is a step with a name, a result and
* a test, rather than a side effect of reading. On a device with no seeds, or one
* already repaired, it writes nothing.
*/
object SeedCredentials {
sealed interface Result {
/** The credentials the listing should use: repaired where the write succeeded, as read where it did not. */
val credentials: Map<HexKey, NostrCredential>
/** [count] seeds had no secret credential, and now do. One write. */
data class Written(val count: Int, override val credentials: Map<HexKey, NostrCredential>) : Result
/** Every seed's key was already a secret credential. Nothing was written. */
data class NotNeeded(override val credentials: Map<HexKey, NostrCredential>) : Result
/**
* The write failed; the file is as it was and [credentials] is what was read. The
* listing goes on: `StoredIdentity.merge` derives the key of a seed that has no
* credential, so a seed this could not repair is still listed. A failed write must
* never hide a wallet, which is why this is a result and not a throw.
*/
data class Failed(val cause: Throwable, override val credentials: Map<HexKey, NostrCredential>) : Result
}
/**
* Writes a `Secret` for every seed in [wallets] whose derived key is not one in
* [credentials], replacing a `Public` entry for that key where there is one -- the
* read-only upgrade landing on the wallet that was here all along.
*/
fun reconcile(
log: Logger,
phoenixGlobal: PhoenixGlobal,
wallets: Map<WalletId, UserWallet>,
credentials: Map<HexKey, NostrCredential>,
): Result {
val missing = wallets.values
.map { StoredIdentity.nostrPrivateKeyOf(it.words) }
.filter { credentials[it.nostrPublicKeyHex()] !is NostrCredential.Secret }
if (missing.isEmpty()) return Result.NotNeeded(credentials)
val repaired = credentials + missing.associate { it.nostrPublicKeyHex() to NostrCredential.Secret(it) }
return try {
NostrCredentialManager.writeToDisk(phoenixGlobal, EncryptedNostrCredentials.encrypt(repaired))
log.i { "wrote the credential for ${missing.size} seed(s) that had none" }
Result.Written(missing.size, repaired)
} catch (e: Exception) {
log.e("could not write the credentials of ${missing.size} seed(s); listing them by derivation", e)
Result.Failed(e, credentials)
}
}
}

View File

@@ -16,31 +16,43 @@ import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.security.NostrCredential
/**
* An identity this device holds something for, as listed before any of them is started.
* A profile this device holds something for, as listed before any of them is started.
*
* 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.
* The credentials file is the list of profiles: a `Secret` entry for every key the
* device can sign as, a `Public` one for every key it can only look at. `seed.dat` is
* the list of wallets, and a wallet is *attached* to the profile whose key it derives
* -- matched by public key, since a seed's key is written to the credentials file when
* the seed is (`IdentityWriter.writeMnemonic`) and repaired into it for every seed
* already here (`SeedCredentials.reconcile`). See docs/multiple-profiles.md, Phase 1.
* The nostr public key is on every kind because it is the one thing they have in
* common and the one thing that attaches a wallet to a profile or refuses a duplicate.
*
* On what this holds: `SovereignWalletViewModel.availableWallets` has always carried
* decrypted words for the view model's life, because starting the node needs them.
* [NostrSecret] carries its key for the same reason and is no worse; decrypting at
* activation instead would be an improvement for both kinds and belongs to both.
* [NostrSecret] and [Mnemonic] carry their key for the same reason and are no worse;
* decrypting at activation instead would be an improvement for every kind and belongs
* to all of them.
*/
sealed interface StoredIdentity {
val id: WalletId
val kind: IdentityKind
val nostrPublicKey: HexKey
/**
* A key the device also holds a seed for. The key is the credential's, so that
* activating this profile does not depend on the node to learn who it is; the seed
* is the wallet attached to it, and its id is the wallet's -- `hash160(nodeId)`, as
* it has always been -- because the library keys the node's preferences and
* metadata by that id and nothing has to move.
*/
data class Mnemonic(
val userWallet: UserWallet,
override val nostrPublicKey: HexKey,
val privateKey: PrivateKey,
) : StoredIdentity {
override val id: WalletId get() = userWallet.walletId
override val kind: IdentityKind get() = IdentityKind.Mnemonic
override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey)"
override val nostrPublicKey: HexKey = privateKey.nostrPublicKeyHex()
override fun toString(): String = "StoredIdentity.Mnemonic(id=$id, npub=$nostrPublicKey, key=<redacted>)"
}
data class NostrSecret(
@@ -65,18 +77,23 @@ sealed interface StoredIdentity {
companion object {
/**
* The nostr public key a wallet's words derive. `SeedManager.loadAndDecrypt` has
* already built exactly this key manager to learn the node id, so this is the second
* derivation of it, not a new cost.
* The nostr key a wallet's words derive, at the NIP-06 path. `SeedManager.loadAndDecrypt`
* has already built exactly this key manager to learn the node id, so this is the
* second derivation of it, not a new cost. It is what attaches a seed to its
* credential, and what the credential is repaired from when a seed has none.
*/
fun nostrPublicKeyOf(words: List<String>): HexKey = LocalKeyManager(
fun nostrPrivateKeyOf(words: List<String>): PrivateKey = LocalKeyManager(
seed = MnemonicCode.toSeed(words, "").byteVector(),
chain = NodeParamsManager.chain,
remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub,
).nostrPrivateKey().nostrPublicKeyHex()
).nostrPrivateKey()
/** The x-only public key of [nostrPrivateKeyOf]. */
fun nostrPublicKeyOf(words: List<String>): HexKey = nostrPrivateKeyOf(words).nostrPublicKeyHex()
/** A wallet whose credential is missing: the key is derived from the words, as it always was. */
fun mnemonic(userWallet: UserWallet): Mnemonic =
Mnemonic(userWallet, nostrPublicKeyOf(userWallet.words))
Mnemonic(userWallet, nostrPrivateKeyOf(userWallet.words))
fun nostrSecret(privateKey: PrivateKey): NostrSecret {
val xOnly = privateKey.publicKey().xOnly()
@@ -93,36 +110,38 @@ sealed interface StoredIdentity {
)
/**
* One map from the two stores. [credentials] is keyed by x-only public key hex, as
* One map from the two stores: the credentials are the list, and the seeds are
* attached to it. [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.
* A `Secret` whose key a seed derives is that seed's profile, listed once, as a
* [Mnemonic] under the wallet's id and carrying the credential's key. A `Public`
* for such a key is skipped with a log line: `IdentityWriter.writeMnemonic` replaces
* it with a secret before it writes the seed, and `SeedCredentials.reconcile` does
* the same for one left behind, so it is a state the next listing no longer sees.
*
* A seed with no credential at all -- the repair failed to write, or a crash fell
* between the seed writer's two files -- is listed as it always was, with its key
* derived from the words. A failed write must never hide a wallet.
*/
fun merge(
wallets: Map<WalletId, UserWallet>,
credentials: Map<HexKey, NostrCredential>,
): Map<WalletId, StoredIdentity> {
val walletsByPublicKey = wallets.values.associateBy { nostrPublicKeyOf(it.words) }
val merged = LinkedHashMap<WalletId, StoredIdentity>()
wallets.values.forEach { userWallet -> merged[userWallet.walletId] = mnemonic(userWallet) }
val seedPublicKeys = merged.values.map { it.nostrPublicKey }.toSet()
credentials.forEach { (publicKeyHex, credential) ->
when (credential) {
is NostrCredential.Secret -> {
val stored = nostrSecret(credential.privateKey)
val wallet = walletsByPublicKey[publicKeyHex]
val stored = if (wallet != null) Mnemonic(wallet, credential.privateKey) else 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" }
if (publicKeyHex in walletsByPublicKey) {
log.w { "public credential for a key a seed derives; the next repair upgrades it" }
} else {
val stored = runCatching { nostrPublic(publicKeyHex) }.getOrNull()
if (stored != null) merged[stored.id] = stored
@@ -130,6 +149,12 @@ sealed interface StoredIdentity {
}
}
}
walletsByPublicKey.values.forEach { wallet ->
if (wallet.walletId !in merged) {
log.w { "wallet=${wallet.walletId} has no credential for its nostr key; deriving it" }
merged[wallet.walletId] = mnemonic(wallet)
}
}
return merged
}

View File

@@ -177,8 +177,12 @@ fun SovereignWalletStartupScreen(
doLoadWallet = { identity ->
when (identity) {
is StoredIdentity.Mnemonic -> {
// The node starts for the wallet attached to this profile --
// its preferences, its metadata, the channel watcher -- and no
// longer for the key, which the identity reads from the
// credential the node is handed to cross-check.
sovereignWalletStartupViewModel.startupNode(walletId = identity.id, words = identity.userWallet.words, onStartupSuccess = {
sovereignWalletViewModel.setActiveWallet(walletId = identity.id, business = it)
sovereignWalletViewModel.setActiveWallet(stored = identity, business = it)
onSuccessfulStartup.invoke()
})
}

View File

@@ -186,7 +186,7 @@ class NostrSecretViewModel(
_forgetting.value = NostrSecretUIState.Forgetting.Idle
withContext(Dispatchers.Main) { onForgotten() }
}
is ForgetIdentity.Outcome.NotACredential,
is ForgetIdentity.Outcome.WalletAttached,
is ForgetIdentity.Outcome.CannotForget -> {
_forgetting.value = NostrSecretUIState.Forgetting.Failed
}

View File

@@ -76,7 +76,7 @@ class SignOutViewModel(
_state.value = State.Idle
withContext(Dispatchers.Main) { onSignedOut() }
}
is ForgetIdentity.Outcome.NotACredential,
is ForgetIdentity.Outcome.WalletAttached,
is ForgetIdentity.Outcome.CannotForget -> {
_state.value = State.Failed
}

View File

@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.identity.SeedCredentials
import press.mantra.compose.identity.StoredIdentity
sealed class WritingSeedState {
@@ -87,8 +88,9 @@ class SovereignWalletViewModel(
val listWalletState = _listWalletState.asStateFlow()
/**
* Every identity this device holds a secret for, from both stores -- `seed.dat` and
* `nostr-keys.dat` -- keyed by the id the preferences and metadata use.
* Every profile this device holds something for -- the credentials file, with each
* seed attached to the profile its key derives -- keyed by the id the preferences
* and metadata use.
*/
private val _availableIdentities = MutableStateFlow<Map<WalletId, StoredIdentity>>(emptyMap())
val availableIdentities = _availableIdentities.asStateFlow()
@@ -145,10 +147,17 @@ class SovereignWalletViewModel(
}
/**
* Activates the identity a just-started node derives: the mnemonic kind, with the
* node behind it.
* Activates a profile with a wallet attached, once its node has started.
*
* The key is the credential's, not the node's. The node still derives it -- and is
* asked to, as a cross-check -- but the credentials file is what the app signs with
* now, and a node that disagrees with it is the one corruption worth refusing to run
* under: the throw lands in the startup view model's handler and shows as a startup
* error. It cannot happen for a file the repair wrote, since that derived the key
* from the same seed.
*/
fun setActiveWallet(walletId: WalletId, business: PhoenixBusiness) {
fun setActiveWallet(stored: StoredIdentity.Mnemonic, business: PhoenixBusiness) {
val walletId = stored.id
val keyManager = business.walletManager.keyManager.value
if (keyManager == null) {
// `startNewBusiness` loads the wallet before it reports success, so a business
@@ -157,13 +166,16 @@ class SovereignWalletViewModel(
log.e { "business for wallet=$walletId started without a key manager" }
return
}
check(keyManager.nostrPrivateKey() == stored.privateKey) {
"the node for wallet=$walletId derives a different nostr key than its credential holds"
}
val dataStoreManager = DataStoreManager(business)
setActiveIdentity(
Identity.signing(
id = walletId,
kind = IdentityKind.Mnemonic,
nostrPrivateKey = keyManager.nostrPrivateKey(),
nostrPrivateKey = stored.privateKey,
userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId = walletId),
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId = walletId),
business = business,
@@ -173,12 +185,14 @@ class SovereignWalletViewModel(
}
/**
* Reads both stores and publishes the merged list, registering metadata for any id
* seen for the first time.
* Reads both stores, repairs the credentials file so that every seed's key is in it,
* and publishes the merged list, registering metadata for any id seen for the first
* time.
*
* A failure in either file is surfaced, not skipped: a corrupt `nostr-keys.dat` would
* otherwise drop every imported identity from the list without a word, and the seed
* file has always been handled this way.
* file has always been handled this way. A failure of the *repair* is the one thing
* logged and not surfaced -- the listing derives around it, see [SeedCredentials].
*/
fun listIdentities(onDone: () -> Unit) {
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, e ->
@@ -237,7 +251,7 @@ class SovereignWalletViewModel(
}
}
val credentials: Map<HexKey, NostrCredential> = when (val result = NostrCredentialManager.loadAndDecrypt(phoenixGlobal)) {
val readCredentials: 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
@@ -262,6 +276,23 @@ class SovereignWalletViewModel(
is DecryptNostrCredentialsResult.Success -> result.credentials
}
// The second write a listing makes, and for the same reason as the first: a
// seed's key belongs in the credentials file, and every seed from before that
// was so gets its entry here, once. The listing uses what the repair returns --
// the repaired map, or the one read if the write failed -- so a seed is listed
// either way.
val credentials = when (val repair = SeedCredentials.reconcile(log, phoenixGlobal, wallets, readCredentials)) {
is SeedCredentials.Result.Written -> {
log.i { "wrote the credential of ${repair.count} seed(s) into the credentials file" }
repair.credentials
}
is SeedCredentials.Result.NotNeeded -> repair.credentials
is SeedCredentials.Result.Failed -> {
log.e("the credentials of ${wallets.size} seed(s) could not be repaired; listing by derivation", repair.cause)
repair.credentials
}
}
val identities = StoredIdentity.merge(wallets, credentials)
val metadataMap = getAvailableWalletsMeta(phoenixGlobal).first()

View File

@@ -74,12 +74,12 @@ class ForgetIdentityJvmTest {
}
@Test
fun `a mnemonic identity is refused before anything else is touched`() = runBlocking {
fun `a profile with a wallet attached is refused before anything else is touched`() = runBlocking {
val recorder = Recorder()
val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.NotACredential)
val outcome = forget(recorder, IdentityWriter.ForgetNostrCredentialResult.WalletAttached)
assertEquals(ForgetIdentity.Outcome.NotACredential, outcome)
assertEquals(ForgetIdentity.Outcome.WalletAttached, outcome)
assertEquals(listOf("forgetNostrCredential"), recorder.effects)
}

View File

@@ -2,19 +2,35 @@ package press.mantra.compose.identity
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.byteVector
import fr.acinq.lightning.Lightning
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.NostrCredentialManager
import fr.acinq.phoenix.managers.SeedManager
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.MnemonicLanguage
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okio.FileSystem
import okio.SYSTEM
import press.mantra.compose.ui.view.model.WritingSeedState
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
@@ -23,18 +39,21 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* The writers for a bare key and a bare public key, against a real key store and a
* real directory.
* The three writers, against a real key store and a real directory.
*
* What is pinned is the duplicate rule, its one exception, and the inverse. A second
* What is pinned is the duplicate rule, its two exceptions, 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.
* had; a bare secret already here is *not* a duplicate of the phrase that derives it,
* and the seed attaches to it; a phrase writes its key to the credentials file as well
* as its words to the seed file; 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 -- unless a seed derives the key, in which case nothing is touched.
*/
class IdentityWriterJvmTest {
@@ -51,8 +70,14 @@ class IdentityWriterJvmTest {
private val first = PrivateKey(Lightning.randomBytes(32))
private val second = PrivateKey(Lightning.randomBytes(32))
private val scope = CoroutineScope(Job())
@BeforeTest
fun setUp() {
// The seed writer reports on Main, as the create screen's state machine needs it
// to; a real dispatcher rather than a test one, because it reports after a real
// one-second delay and a virtual clock would never reach it.
Dispatchers.setMain(Dispatchers.Default)
storeDir = Files.createTempDirectory("mantra-identity-writer-store").toFile()
appDir = Files.createTempDirectory("mantra-identity-writer-app").toFile()
JvmKeyStore.lock()
@@ -67,11 +92,48 @@ class IdentityWriterJvmTest {
@AfterTest
fun tearDown() {
scope.cancel()
Dispatchers.resetMain()
JvmKeyStore.lock()
storeDir.deleteRecursively()
appDir.deleteRecursively()
}
/** Twelve fresh words, and what they derive. */
private class Phrase {
val words: List<String> = MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist())
private val keyManager = LocalKeyManager(
seed = MnemonicCode.toSeed(words, "").byteVector(),
chain = NodeParamsManager.chain,
remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub,
)
val walletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey)
val nostrKey = StoredIdentity.nostrPrivateKeyOf(words)
val nostrPublicKey = nostrKey.nostrPublicKeyHex()
}
/** The callback-shaped seed writer, awaited: exactly one of its two callbacks fires. */
private suspend fun writePhrase(phrase: Phrase): Result<WalletId> {
val outcome = CompletableDeferred<Result<WalletId>>()
IdentityWriter.writeMnemonic(
log = log,
phoenixGlobal = phoenixGlobal,
globalPrefs = globalPrefs,
writingState = WritingSeedState.Init,
viewModelScope = scope,
mnemonics = phrase.words,
onWritingSeedError = { outcome.complete(Result.failure(IllegalStateException(it.toString()))) },
onWritingSeedStateWriting = {},
isRestoringWallet = false,
isTorEnabled = false,
customElectrumServer = null,
onSeedWritten = { outcome.complete(Result.success(it)) },
)
return withTimeout(15_000) { outcome.await() }
}
private fun seeds() = SeedManager.loadAndDecryptOrNull(phoenixGlobal)
private suspend fun write(key: PrivateKey) = IdentityWriter.writeNostrKey(
log = log,
phoenixGlobal = phoenixGlobal,
@@ -173,15 +235,98 @@ class IdentityWriterJvmTest {
assertFalse(FileSystem.SYSTEM.exists(userPrefsFile(readOnly)))
}
/** A wallet's nostr key is in the seed, not here; removing a seed is a wallet question. */
/**
* Not in the file at all. Since the repair that can only be a seed's key the repair
* could not write, and the answer is the same as for one it did: a wallet is attached.
*/
@Test
fun `a key that is not in the file is not forgotten`() = runBlocking<Unit> {
write(first)
val other = StoredIdentity.nostrSecret(second)
assertIs<IdentityWriter.ForgetNostrCredentialResult.NotACredential>(
assertIs<IdentityWriter.ForgetNostrCredentialResult.WalletAttached>(
IdentityWriter.forgetNostrCredential(log, phoenixGlobal, other.id, other.nostrPublicKey)
)
assertEquals(1, credentials()?.size)
}
/**
* The credential a seed always implied. A phrase writes two files: its key as a secret
* in the credentials file, and its words in the seed file. The profile's id is the
* wallet's, and the nsec of that key is then a duplicate -- the device holds more than
* the key for it.
*/
@Test
fun `a phrase writes its key to the credentials file as well as its words to the seed file`() = runBlocking<Unit> {
val phrase = Phrase()
val id = writePhrase(phrase).getOrThrow()
assertEquals(phrase.walletId, id)
assertEquals(mapOf(phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey)), credentials())
assertEquals(setOf(phrase.walletId), seeds()?.keys)
assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb")))
assertIs<IdentityWriter.WriteNostrCredentialResult.AlreadyExists>(write(phrase.nostrKey))
assertIs<IdentityWriter.WriteNostrCredentialResult.AlreadyExists>(writePublic(phrase.nostrKey))
assertIs<IdentityWriter.ForgetNostrCredentialResult.WalletAttached>(
IdentityWriter.forgetNostrCredential(log, phoenixGlobal, id, phrase.nostrPublicKey)
)
assertEquals(1, credentials()?.size, "the forget changed nothing")
}
/** The same seed twice is the one thing the phrase writer still refuses. */
@Test
fun `the same phrase twice is refused`() = runBlocking<Unit> {
val phrase = Phrase()
writePhrase(phrase).getOrThrow()
val second = writePhrase(phrase)
assertTrue(second.isFailure)
assertTrue(second.exceptionOrNull()?.message?.contains("SeedAlreadyExists") == true, "$second")
assertEquals(1, seeds()?.size)
}
/**
* The second exception to the duplicate rule, and the first attachment: the phrase of
* a key held as a bare secret used to be refused, and is the profile acquiring the
* wallet that derives it. The entry stays a secret for the same key; the id becomes
* the wallet's, and the bare key's preference files go with the old id.
*/
@Test
fun `the phrase of a key held as a bare secret attaches its wallet`() = runBlocking<Unit> {
val phrase = Phrase()
val bare = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(write(phrase.nostrKey))
val bareUserPrefs = userPrefsFile(StoredIdentity.nostrSecret(phrase.nostrKey))
assertTrue(FileSystem.SYSTEM.exists(bareUserPrefs))
val id = writePhrase(phrase).getOrThrow()
assertEquals(phrase.walletId, id)
assertNotEquals(bare.id, id, "the profile's id is its wallet's now")
assertEquals(mapOf(phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey)), credentials())
assertEquals(setOf(phrase.walletId), seeds()?.keys)
assertFalse(FileSystem.SYSTEM.exists(bareUserPrefs), "the bare key's preferences went with its id")
assertTrue(FileSystem.SYSTEM.exists(computePreferencePath(phoenixGlobal.ctx, "userprefs_${id.nodeIdHash}.preferences_pb")))
}
/** The read-only upgrade, landing on a wallet: the public entry becomes a secret, and the seed attaches. */
@Test
fun `the phrase of a key held read-only attaches its wallet and the entry becomes a secret`() = runBlocking<Unit> {
val phrase = Phrase()
writePublic(phrase.nostrKey)
writePublic(second)
val id = writePhrase(phrase).getOrThrow()
assertEquals(phrase.walletId, id)
assertEquals(
mapOf(
phrase.nostrPublicKey to NostrCredential.Secret(phrase.nostrKey),
second.nostrPublicKeyHex() to NostrCredential.Public,
),
credentials(),
)
}
}

View File

@@ -0,0 +1,192 @@
package press.mantra.compose.identity
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.bitcoin.byteVector
import fr.acinq.lightning.Lightning
import fr.acinq.lightning.crypto.LocalKeyManager
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.NostrCredentialManager
import fr.acinq.phoenix.managers.SeedManager
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.security.EncryptedNostrCredentials
import fr.acinq.phoenix.security.EncryptedSeed
import fr.acinq.phoenix.security.JvmKeyStore
import fr.acinq.phoenix.security.NostrCredential
import fr.acinq.phoenix.utils.MnemonicLanguage
import fr.acinq.phoenix.utils.PlatformContext
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The repair that runs at listing, against a device from before the credentials file
* held a seed's key: `seed.dat` written directly, as every wallet on every device was
* until docs/multiple-profiles.md, and no credential for any of them.
*
* What is pinned is that the repair writes exactly what is missing, once; that a
* device already repaired is not written to again -- the file's bytes are compared,
* since a rewrite would carry a fresh iv and could not be told apart by its contents;
* that a public entry for a seed's key is upgraded rather than left beside a secret;
* and that a write that fails is reported with the map that was read, not thrown.
*/
class SeedCredentialsJvmTest {
private lateinit var storeDir: File
private lateinit var appDir: File
private lateinit var phoenixGlobal: PhoenixGlobal
private val log = Logger.withTag("SeedCredentialsJvmTest")
private class Seed(val words: List<String>) {
val keyManager = LocalKeyManager(
seed = MnemonicCode.toSeed(words, "").byteVector(),
chain = NodeParamsManager.chain,
remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub,
)
val walletId = WalletId(keyManager.nodeKeys.nodeKey.publicKey)
val nostrKey = StoredIdentity.nostrPrivateKeyOf(words)
val nostrPublicKey = nostrKey.nostrPublicKeyHex()
}
private fun freshSeed() = Seed(MnemonicCode.toMnemonics(Lightning.randomBytes(16), MnemonicLanguage.English.wordlist()))
private val first = freshSeed()
private val second = freshSeed()
@BeforeTest
fun setUp() {
storeDir = Files.createTempDirectory("mantra-seed-credentials-store").toFile()
appDir = Files.createTempDirectory("mantra-seed-credentials-app").toFile()
JvmKeyStore.lock()
JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir)
phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir))
}
@AfterTest
fun tearDown() {
JvmKeyStore.lock()
storeDir.deleteRecursively()
appDir.deleteRecursively()
}
/** A device from before: the seeds on disk, and nothing said about their keys. */
private fun writeSeedsOnly(vararg seeds: Seed) {
SeedManager.writeSeedToDisk(
phoenixGlobal,
EncryptedSeed.V2.encrypt(seeds.associate { it.walletId to it.words }),
overwrite = true,
)
}
private fun wallets() = SeedManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("seed store unreadable")
private fun credentials() = NostrCredentialManager.loadAndDecryptOrNull(phoenixGlobal) ?: error("credentials unreadable")
private fun credentialsFileBytes(): ByteArray? =
File(SeedManager.getDatadir(phoenixGlobal.ctx).toString(), "nostr-credentials.dat").takeIf { it.exists() }?.readBytes()
@Test
fun `every seed without a credential gets one, in one write`() {
writeSeedsOnly(first, second)
assertNull(credentialsFileBytes(), "the device from before had no credentials file")
val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials())
val written = assertIs<SeedCredentials.Result.Written>(result)
assertEquals(2, written.count)
val expected = mapOf(
first.nostrPublicKey to NostrCredential.Secret(first.nostrKey),
second.nostrPublicKey to NostrCredential.Secret(second.nostrKey),
)
assertEquals(expected, written.credentials, "the map the listing is handed")
assertEquals(expected, credentials(), "and the file")
}
@Test
fun `a device already repaired is not written to again`() {
writeSeedsOnly(first)
SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials())
val bytesAfterRepair = credentialsFileBytes()
val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials())
val notNeeded = assertIs<SeedCredentials.Result.NotNeeded>(result)
assertEquals(mapOf(first.nostrPublicKey to NostrCredential.Secret(first.nostrKey)), notNeeded.credentials)
assertTrue(bytesAfterRepair.contentEquals(credentialsFileBytes()), "a rewrite would carry a fresh iv")
}
@Test
fun `a device with no seeds needs nothing`() {
val result = SeedCredentials.reconcile(log, phoenixGlobal, emptyMap(), emptyMap())
assertIs<SeedCredentials.Result.NotNeeded>(result)
assertNull(credentialsFileBytes())
}
/** The read-only upgrade the seed writer performs, done for a device where it was left undone. */
@Test
fun `a public entry for a seed's key becomes a secret`() {
writeSeedsOnly(first)
NostrCredentialManager.writeToDisk(
phoenixGlobal,
EncryptedNostrCredentials.encrypt(mapOf(first.nostrPublicKey to NostrCredential.Public)),
)
val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials())
val written = assertIs<SeedCredentials.Result.Written>(result)
assertEquals(1, written.count)
assertEquals(mapOf(first.nostrPublicKey to NostrCredential.Secret(first.nostrKey)), credentials())
}
/** Other people's entries are left exactly where they were. */
@Test
fun `bare keys already in the file are untouched`() {
writeSeedsOnly(first)
val bare = StoredIdentity.nostrSecret(fr.acinq.bitcoin.PrivateKey(Lightning.randomBytes(32)))
NostrCredentialManager.writeToDisk(
phoenixGlobal,
EncryptedNostrCredentials.encrypt(mapOf(bare.nostrPublicKey to NostrCredential.Secret(bare.privateKey))),
)
val written = assertIs<SeedCredentials.Result.Written>(SeedCredentials.reconcile(log, phoenixGlobal, wallets(), credentials()))
assertEquals(1, written.count)
assertEquals(
mapOf(
bare.nostrPublicKey to NostrCredential.Secret(bare.privateKey),
first.nostrPublicKey to NostrCredential.Secret(first.nostrKey),
),
credentials(),
)
}
/**
* The write fails -- here because the key store is locked, which is how it fails on
* a device whose keystore entry is gone -- and the listing must go on with what it
* read. A failed write must never hide a wallet.
*/
@Test
fun `a write that fails is reported with the map that was read`() {
writeSeedsOnly(first)
val wallets = wallets()
val read = credentials()
JvmKeyStore.lock()
val result = SeedCredentials.reconcile(log, phoenixGlobal, wallets, read)
val failed = assertIs<SeedCredentials.Result.Failed>(result)
assertEquals(read, failed.credentials)
assertNull(credentialsFileBytes(), "nothing reached the disk")
// And the listing built on it still holds the wallet, by derivation.
val listed = assertIs<StoredIdentity.Mnemonic>(StoredIdentity.merge(wallets, failed.credentials)[first.walletId])
assertEquals(first.nostrKey, listed.privateKey)
}
}

View File

@@ -15,8 +15,8 @@ import kotlin.test.assertNotEquals
*
* The vector is NIP-06's own: twelve words, the key at `m/44'/1237'/0'/0/0`, and the
* x-only public key. It pins that a wallet's `nostrPublicKey` is the same key an nsec
* import of that wallet's nostr secret would produce -- which is the whole basis of the
* duplicate check in `IdentityWriter`.
* import of that wallet's nostr secret would produce -- which is what attaches a seed
* to its credential, and what the duplicate check in `IdentityWriter` compares.
*/
class StoredIdentityJvmTest {
@@ -43,6 +43,7 @@ class StoredIdentityJvmTest {
val merged = StoredIdentity.merge(
wallets = mapOf(wallet.walletId to wallet),
credentials = mapOf(
nip06PublicKey to NostrCredential.Secret(nip06PrivateKey),
bareKey.publicKey().xOnly().value.toHex() to NostrCredential.Secret(bareKey),
readOnlyKey to NostrCredential.Public,
),
@@ -51,6 +52,7 @@ class StoredIdentityJvmTest {
assertEquals(3, merged.size)
val mnemonic = assertIs<StoredIdentity.Mnemonic>(merged[wallet.walletId])
assertEquals(nip06PublicKey, mnemonic.nostrPublicKey)
assertEquals(nip06PrivateKey, mnemonic.privateKey, "the credential's key, not a derivation")
assertEquals(IdentityKind.Mnemonic, mnemonic.kind)
val secret = assertIs<StoredIdentity.NostrSecret>(merged[bareKey.publicKey().xOnly().toWalletId()])
@@ -73,27 +75,47 @@ class StoredIdentityJvmTest {
}
/**
* The same npub as a wallet, imported as a bare key, gets a *different* id -- one
* hash is of the node key, the other of the nostr key -- so a map keyed by id holds
* both. That is not a bug in the merge; it is why the writers dedupe by public key.
* The normal state of a wallet since docs/multiple-profiles.md: its key is a secret
* in the credentials file, and the seed is attached to that entry. Listed once, under
* the wallet's id, with the credential's key -- not twice under two ids, which is what
* this map held before the credentials file was the list of profiles.
*/
@Test
fun `a wallet and its own nostr key as a bare secret do not collide by id`() {
fun `a secret whose key a seed derives is listed once, as the wallet, with the credential's key`() {
val merged = StoredIdentity.merge(
wallets = mapOf(wallet.walletId to wallet),
credentials = mapOf(nip06PublicKey to NostrCredential.Secret(nip06PrivateKey)),
)
assertEquals(2, merged.size)
assertEquals(1, merged.values.map { it.nostrPublicKey }.toSet().size, "one npub, twice")
assertEquals(1, merged.size)
val mnemonic = assertIs<StoredIdentity.Mnemonic>(merged[wallet.walletId])
assertEquals(nip06PrivateKey, mnemonic.privateKey)
assertEquals(wallet, mnemonic.userWallet)
}
/**
* 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.
* The state a device from before the repair is in, and the one a failed repair
* leaves: the seed is listed anyway, with its key derived from the words, so that a
* missing entry never hides a wallet.
*/
@Test
fun `a seed with no credential is listed with its key derived`() {
val merged = StoredIdentity.merge(
wallets = mapOf(wallet.walletId to wallet),
credentials = emptyMap(),
)
assertEquals(1, merged.size)
val mnemonic = assertIs<StoredIdentity.Mnemonic>(merged[wallet.walletId])
assertEquals(nip06PrivateKey, mnemonic.privateKey)
assertEquals(nip06PublicKey, mnemonic.nostrPublicKey)
}
/**
* A public entry for a key a seed derives is the state the seed writer's two-file
* upgrade leaves if it dies between its writes, and the one the repair upgrades at
* the next listing. Until then the wallet is listed by derivation, and the public
* entry is not listed at all: one npub, once.
*/
@Test
fun `a public key a seed already derives is listed as the wallet only`() {
@@ -103,7 +125,8 @@ class StoredIdentityJvmTest {
)
assertEquals(1, merged.size)
assertIs<StoredIdentity.Mnemonic>(merged[wallet.walletId])
val mnemonic = assertIs<StoredIdentity.Mnemonic>(merged[wallet.walletId])
assertEquals(nip06PrivateKey, mnemonic.privateKey)
}
@Test