diff --git a/docs/README.md b/docs/README.md index 70f1efd3..c5857a54 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ silent, or a decision that looked arbitrary and was not. | [mls-skipped-keys.md](./mls-skipped-keys.md) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app | | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | +| [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | | [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme | | [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand | @@ -39,3 +40,7 @@ jvm-target note first if you want to know why its adaptive-layout phase exists. The curated-to-mantra note is a phased plan that has not been built, though its dry run has: it is about the repository rather than the app, and reads alone, except that its first decision leans on the derivation note's one rule. +The nsec sign-in note is a phased plan that has not been built; it inherits the +key-storage decision from the jvm-target note and drives the navigation state +machine `NavigationViewModel.processLocalAccount` implements, so read it with the +code open. diff --git a/docs/nsec-sign-in.md b/docs/nsec-sign-in.md new file mode 100644 index 00000000..c9aad8b8 --- /dev/null +++ b/docs/nsec-sign-in.md @@ -0,0 +1,992 @@ +# Signing in with an nsec + +How a user who already has a nostr key gets it onto this device, why that key can +never have a wallet behind it, and the ten call sites that make the whole thing +smaller than it looks. + +Read this against [jvm-target.md](./jvm-target.md) for the key-storage decision it +inherits, and with `NavigationViewModel.processLocalAccount` open for the state +machine it drives — the machine is already built, and half of this plan is about +finding its unreachable entrance. + +**Not built.** Phases 1–8 below, one commit each, in the order given. Phases 1 and +2 are independent and can go in parallel; nothing after Phase 3 can start before +it, because Phase 3 is where the compiler first checks both halves against each +other. + +## The constraint + +The profile's nostr key is not stored anywhere. It is derived, every time the +wallet starts, at the NIP-06 path: + +```kotlin +// lightning-kmp-app/library/.../managers/WalletManager.kt:105 +fun LocalKeyManager.nostrPrivateKey(): PrivateKey { + val path = KeyPath(if (isMainnet()) "m/44'/1237'/0'/0/0" else "m/44'/1237'/1'/0/0") + return derivePrivateKey(path).privateKey +} +``` + +That is BIP32 child derivation — `HMAC-SHA512(chainCode, parentKey ‖ index)` at +every step — and it runs one way. Holding the leaf tells you nothing about the +seed above it, and no seed can be chosen to land on a given leaf. So the object the +whole app hangs off, `LocalKeyManager`, is out of reach for an nsec by +construction: + +```kotlin +// lightning-kmp/modules/core/.../crypto/LocalKeyManager.kt:34 +data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwapInExtendedPublicKey: String) +``` + +No `LocalKeyManager` means no node key, no `PhoenixBusiness`, no channels, no +on-chain wallet. **"Restore only the nostr key" is not a product decision** — it +is the only thing an nsec can be restored *as*. Every design below starts from +that, and the ones in the [appendix](#appendix--what-was-considered-and-rejected) +that tried to get around it are there because they cannot. + +One consequence worth stating early: the twelve words and the nsec are not two +encodings of one thing, so a user who signs in with an nsec and later wants a +wallet is creating a *second* secret with a *second* recovery story. That is out +of scope here and is said so at the end. + +## What the app actually needs from the wallet + +Nothing but the key. That is the finding that makes this tractable. + +Every consumer of the wallet in `composeApp` is the same expression: + +```kotlin +activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() +``` + +ten times, in eight files: + +| site | what it does with the key | +|---|---| +| [NotaryViewModel.kt:71](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | signs the queues, seals Marmot bundles with `nsecPassword` | +| [SynchronizationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt) | the three pumps' key pair | +| [RelaysSocketManager.kt:68](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | the pubkey whose relay list to follow | +| [NavigationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | the pubkey whose account to observe | +| [DkgRitualViewModel.kt:476](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt), 516, 570 | the ChillDKG host key, derived from the nostr key | +| [SelectChatRoomTypeViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt) | same | +| [SelectSubgroupAdminsViewModel.kt:208](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt) | same | +| [NostrNotaryRepository.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrNotaryRepository.kt) | signs — but nothing constructs this class; it only has to compile | + +And that is the whole list. There is no call into `peerManager`, `paymentsManager`, +`balanceManager`, `sendManager` or `lnurlManager` anywhere in the app. The +Lightning node — Electrum connection, peer connection to the LSP, the WorkManager +watchers `schedulePlatformLogic` sets up — is started on every cold boot as a +**side effect of reaching a 32-byte key**. + +Everything downstream of the key is already independent of the seed. +`ChillDkgRitualManager.deriveHostSecretKey` hashes the nostr key +([ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt)); +`nsecPassword` is `hash160` of it; the notary and pumps build a quartz `KeyPair` +from its bytes. None of them would notice where the key came from. + +So the leverage point is one type: put a *signing identity* between the app and +the wallet, and an nsec becomes "an identity with no wallet behind it". The ten +sites collapse to one flow, and two of them get simpler — the `flatMapLatest` over +the key manager's `StateFlow` in the notary and the relay manager exists only +because the key arrives late, after the node; an identity is whole from the moment +it exists. + +## What is already built + +More than you would expect. The nostr half of "restore" was designed for the +app's earlier life as Torch and left in place, unreachable, when the seed became +the only way in. + +| piece | where | state | +|---|---|---| +| `signInToProfile(pubkey)` — plants a kind‑0 `UnsignedNostrEvent` stamped `signedAt = GENESIS_AT`, so the notary skips it and the navigation machine treats the account as "signed, needs syncing" | [DatabaseNostrRepository.kt:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built, no caller | +| the state machine that takes it from there: `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` | [NavigationViewModel.kt:96](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built | +| the `purpose = "sign-in"` relay sync for kinds 0, 3, 10002, 10005, 10007, 10012, 10050 and 10086 — profile, contacts, and every relay list the app reads | [UnqueuedProfileSynchronizationViewModel.kt:57](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/UnqueuedProfileSynchronizationViewModel.kt) | built, asks one relay — see [Phase 5](#phase-5--finding-the-profile-and-not-finding-it) | +| a `SignInToProfileViewModel` that parses `nsec1…`/`npub1…`/`nostr:` with quartz's `Nip19Parser` and calls `signInToProfile`; a `SignInToProfileUIState` with `InputPrompt`, `ConfirmNsecSignIn(nsec, hexKey)`, `ConfirmNpubSignIn`, `Error`; a `SignInToProfileFormState` | [SignInToProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt) | built, unreferenced — and it **drops the nsec on the floor**; nothing ever stored it | +| `String.extractKeyPairFromPrivateKeyOrThrow()` — hex or bech32 in, `(nsec, npub)` out, `InvalidNostrPrivateKeyException` otherwise | [Credentials.kt:60](../composeApp/src/commonMain/kotlin/press/mantra/compose/extensions/Credentials.kt) | built | +| strings: `enter_the_nsec_or_npub_read_only_that_you`, `sign_in_to_nsec`, `sign_in_with_an_npub`, `be_sure_to_keep_this_nsec_safe`, `sign_in_to_torch_via_nsec_or_remote_signer` | `strings.xml` | present, unused | +| `ActiveWallet.business` is already `PhoenixBusiness?` | [Wallet.kt:50](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/data/Wallet.kt) | nullable today | +| `keyStoreEncryption` / `keyStoreDecryption` — public `expect` functions with android, jvm and ios actuals, parameterised by key alias | [KeyStoreFunctions.kt](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/security/KeyStoreFunctions.kt) | usable as-is | +| `platformWriteSeed(…, isRestoringWallet = …)` — the seed writer already has a restore flag | [NavigationViewModel.android.kt:64](../composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt) | built; recovery-phrase sign-in is nearly free once a screen exists | +| `SignInToProfileScreen` | [SignInScreen.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt) | a sentence saying sign in is not available | + +The gap, then, is exactly two things: **somewhere for the nsec to live**, and +**something for the app to read the key from that is not the node**. Everything +else is wiring. + +## What actually blocks it + +Four things, in dependency order. + +**The at-rest store is shaped for words.** `seed.dat` is +`EncryptedSeed.V2.MultipleSeed`: the platform keystore's AES over a JSON +`Map>` +([EncryptedSeed.kt:60](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/security/EncryptedSeed.kt)), +and `SeedManager.loadAndDecrypt` runs `MnemonicCode.toSeed` on every entry and +builds a `LocalKeyManager` from it to learn the wallet id +([SeedManager.kt:48](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/managers/SeedManager.kt)). +A 32-byte key has no place in that shape, and the [appendix](#extending-encryptedseed-with-a-version-4-payload) +says why it should not be given one. + +**The identity is carried by the node.** `activeWalletInUI: +StateFlow` is set only from `StartBusinessResult.Success` +([SovereignWalletViewModel.kt:118](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)), +and twenty-five parameters across eighteen files are typed on it. + +**Startup only knows about wallets.** `SovereignWalletStartupScreen` sends an +empty `availableWallets` to the landing page +([SovereignWalletStartupScreen.kt:91](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)) +and starts whatever is selected with `startupNode(words)` (line 171). An nsec +stored anywhere else is invisible to it, and `NavigationViewModel.observeProfile` +answers a null `business` by navigating back to startup +([NavigationViewModel.kt:187](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt)) +— a loop, for an identity that will never have one. + +**The bootstrap asks the wrong relay.** The sign-in sync queues its `REQ` at +`Relays.DefaultDMRelayList`, which is `listOf(ephemeral)` — +`wss://ephemeral.mantra.press` +([Relays.kt:61](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt)). +That is the right relay for a profile *this app* created. An identity that has +lived on Damus for three years has never heard of it, and the machine has no exit +for "nothing came back". + +## The one decision to make first + +Should a mnemonic identity still start the Lightning node to reach its key? + +`CreateProfileViewModel` already derives the pubkey without one — it constructs +`LocalKeyManager(seed)` locally and reads `nostrPublicKey()` off it +([CreateProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt)). +Doing the same at startup would make both kinds of identity symmetric — secret → +key in memory, node optional — and would take the Electrum and LSP connections off +every cold boot of what is, today, a chat application. + +**This plan keeps the node start for mnemonic identities.** Three reasons. +`BusinessManager.startNewBusiness` does more than load a key — it registers wallet +metadata, applies Electrum and Tor preferences, records the last-used app code, +and the comments in `SovereignWalletStartupScreen` and `SovereignWalletViewModel` +are the record of a flow that has already bitten twice; moving it is its own piece +of work with its own review. The lazy-start path will have to exist anyway the +day a wallet feature ships. And Phase 1 makes the later change small: once +everything reads an `Identity`, whether the node is behind it is one branch in one +place. + +So: the nsec branch has no node, the mnemonic branch keeps the one it has, and +"start the node lazily" is a follow-on named under [Out of scope](#out-of-scope). + +--- + +## Phase 1 — an identity in front of the wallet + +**App only. No behaviour change.** This is the refactor everything else stands on, +and it should land alone so that its diff is boring. + +### The type + +```kotlin +package press.mantra.compose.identity + +enum class IdentityKind { + /** Twelve words. The nostr key is derived from them, and so is a wallet. */ + Mnemonic, + /** A bare nostr secret. Nothing else can be derived from it. */ + NostrSecret, +} + +data class Identity( + val id: WalletId, + val kind: IdentityKind, + val nostrPrivateKey: PrivateKey, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + /** The running node. Non-null only for [IdentityKind.Mnemonic]. */ + val business: PhoenixBusiness?, +) { + /** X-only, hex — the form every nostr call site wants. Computed once. */ + val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + + override fun toString() = "Identity(id=$id, kind=$kind, key=)" +} +``` + +It replaces `ActiveWallet` in the app rather than wrapping it. `ActiveWallet` is +declared in the library but nothing in the library uses it — it is an app type +that happens to live in the wrong module — and flattening its three fields into +`Identity` means `KeyRecoveryScreen` and `RecoveryPhraseViewModel`, which read +`internalPrefs` off the active wallet today, keep working for an identity that +has no wallet. + +**`WalletId` stays as the id.** The field is called `nodeIdHash` and for an nsec +identity it will not be one, but every preference in the library keys on the type +— `loadUserPrefsForWallet`, `loadInternalPrefsForWallet`, `UserWalletMetadata`, +`getDefaultWallet` — and a second id type would mean a second copy of each. For +an nsec identity: + +```kotlin +// app side; WalletId has no companion to hang this off +fun XonlyPublicKey.toWalletId(): WalletId = + WalletId(Crypto.hash160(value).byteVector().toHex()) +``` + +Same shape as `WalletId(nodeId: PublicKey)` — forty hex characters of a +`hash160` — so nothing downstream can tell the kinds apart by the id, which is +what you want from an id. The kind is on the `Identity`, where it can be asked. +The two hashes are over different keys (the node key at `m/50'/0'`, the nostr +key at `m/44'/1237'/…`), so a mnemonic wallet and its own nostr identity would +never share an id even if both were registered — which is exactly the case +Phase 4's dedupe has to catch by pubkey instead. + +### The flow + +`SovereignWalletViewModel` gains + +```kotlin +private val _activeIdentity = MutableStateFlow(null) +val activeIdentity = _activeIdentity.asStateFlow() +``` + +and `setActiveWallet(walletId, business)` becomes `setActiveIdentity(identity)`, +with the mnemonic caller building the identity from the node it just started: + +```kotlin +val keyManager = business.walletManager.keyManager.value + ?: error("business started without a key manager") +Identity( + id = walletId, + kind = IdentityKind.Mnemonic, + nostrPrivateKey = keyManager.nostrPrivateKey(), + userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId), + business = business, +) +``` + +`activeWalletInUI` goes. The twenty-five `StateFlow` parameters +become `StateFlow` — a rename in most of them; the eighteen files are +listed by `grep -rl 'StateFlow' composeApp/src/commonMain`. + +### The ten sites + +Each becomes a read of `activeIdentity.value?.nostrPrivateKey` (or +`?.nostrPublicKey`). Two deserve a word: + +- **NotaryViewModel** and **RelaysSocketManager** currently `flatMapLatest` from + the wallet flow into the key manager flow, because the key arrives after the + node. With an identity there is one flow and one `collectLatest`, and the + children it launches are cancelled on identity change exactly as they are now. + Keep the `distinctUntilChanged` on the key: a `data class` `Identity` compares + by value and the prefs objects inside it are stable per id, so it already + behaves, but the comment on `observeUnsignedNostrEvents` is about a + `distinctUntilChanged` that bit once; do not remove one without reading it. +- **NavigationViewModel.observeProfile** loses its `business == null → + StartupPhoenix` branch. That branch was the only thing that made a nodeless + identity loop; a null *identity* still means "go start something", which is + right. + +`WalletManagerExtension.kt` — the app's own `LocalKeyManager.nostrPublicKey()` — +is used by exactly these sites and can go with them. + +### Tests + +`NavigationRoutingTest` constructs a `NavigationViewModel` around a +`MutableStateFlow`; it becomes `MutableStateFlow` and +gains one case: an identity with `business = null` and a local account routes to +`ProfileLoaded`, not `StartupPhoenix`. That is the regression this phase is for, +and it is the assertion Phase 3 will lean on. + +--- + +## Phase 2 — the nostr key store + +**Library. Independent of Phase 1.** + +A sibling of the seed file, not a change to it: `node-data/nostr-keys.dat`, +encrypted under the same keystore key, with the same write discipline, read by a +manager shaped like `SeedManager`. + +### Why the same alias + +`KeyStoreNames.KEY_NO_AUTH`. Not for convenience — because Android gives no +choice: + +```kotlin +// KeystoreHelper.kt:74 +private fun getKeyForName(keyName: String): SecretKey = when (keyName) { + KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired() + KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired() + else -> throw IllegalArgumentException("unhandled key=$keyName") +} +``` + +A new alias is a library change on that platform regardless, and both existing +aliases already resolve to the one key. On the jvm, `JvmKeyStore.unlock` runs in +`Main.kt` before anything reads +([Main.kt:146](../composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt)), so a +second file under the same alias costs nothing there either; the ios keychain +helper is likewise keyed by alias. + +### The format + +``` +byte 0 file version = 1 +bytes 1..16 iv +bytes 17.. ciphertext of UTF-8 JSON: { "": "", … } +``` + +One version byte, not two. `EncryptedSeed.V2` carries a second because it has to +distinguish `SingleSeed` from `MultipleSeed`; this file has one shape and a new +shape would be a new version. Keyed by the **public** key so that a lookup and a +duplicate check are the same map operation, and so that the store can list what +it holds without decrypting anything more than it already has. + +```kotlin +package fr.acinq.phoenix.security + +class EncryptedNostrKeys(val iv: ByteArray, val ciphertext: ByteArray) { + fun decryptAndGetKeyMap(): Map + fun serialize(): ByteArray + companion object { + const val VERSION: Byte = 1 + fun deserialize(bytes: ByteArray): EncryptedNostrKeys + fun encrypt(keys: Map): EncryptedNostrKeys // KEY_NO_AUTH + } +} +``` + +```kotlin +package fr.acinq.phoenix.managers + +object NostrKeyManager { + fun loadAndDecrypt(phoenixGlobal: PhoenixGlobal): DecryptNostrKeysResult + fun loadAndDecryptOrNull(phoenixGlobal: PhoenixGlobal): Map? // empty map when no file + fun writeToDisk(phoenixGlobal: PhoenixGlobal, keys: EncryptedNostrKeys) +} +``` + +`writeToDisk` is `SeedManager.writeSeedToDir` with the type changed: encrypt to +`temporary_nostr_keys.dat`, read it back, compare `iv` and `ciphertext` +byte-for-byte, `atomicMove` over `nostr-keys.dat`, delete the temp file on any +failure. That discipline exists because a truncated seed file is an unrecoverable +wallet; a truncated key file is an unrecoverable identity, and the reasoning +transfers whole. Lift the body into a private helper both managers call rather +than copying it — the diff will show whether that is one function or two. + +`DecryptNostrKeysResult` mirrors `DecryptSeedResult` — `Success(map)`, +`Failure.FileNotFound`, `SerializationError`, `KeyStoreFailure(cause)`, +`DecryptionError(cause)`, `FileUnreadable` — and the exception mapping is the +one `gracefulMultiSeedDecryption` does per platform +([TechnicalExtensions.android.kt:17](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/utils/extensions/TechnicalExtensions.android.kt)): +`SerializationException`/`IllegalArgumentException` → serialization, +`KeyStoreException` → keystore, else → decryption. Those three `inline` actuals +are typed on `DecryptSeedResult`; rather than three more, pull the classification +into a common `expect fun classifyDecryptionFailure(e: Exception): DecryptionFailureKind` +and have both result types map from it. + +### Two small things while in this file + +**`LocalKeyManager.nostrPublicKey()` in the library is not a nostr public key.** +It returns `nostrPrivateKey().publicKey().toHex()` — the 33-byte compressed +encoding, sixty-six hex characters. Nostr keys are x-only, sixty-four. Nothing in +the app calls it (the app has its own, correct, `press.mantra.compose.extensions.nostrPublicKey`), +which is the only reason it has not mattered. Fix it or delete it; a helper this +plan does want is + +```kotlin +fun PrivateKey.nostrPublicKeyHex(): String = publicKey().xOnly().value.toHex() +``` + +so that the app's `Identity.nostrPublicKey` and the store's map key are computed +the same way in one place. + +**Testnet derives at account `1'`.** `m/44'/1237'/1'/0/0` is legal — NIP-06 leaves +the account index to the application — but an imported nsec bypasses the chain +entirely, so a testnet build signs with whatever key was pasted. That is correct; +it is only worth a comment next to the path so nobody "fixes" it. + +### Tests + +Two, in the library, each following a test that already exists. + +`EncryptedNostrKeysTest` in `commonTest`, after `EncryptedSeedTest`: the +serialized layout spelled out as literal bytes — version byte, sixteen iv bytes, +the rest — because, as that test's header says, the format is a compatibility +contract and the expected bytes must not be derived from the constants under test. + +A round trip in `jvmTest`, after `JvmKeyStoreTest`: `JvmKeyStore.unlock` against +a temp directory, `NostrKeyManager.writeToDisk`, `loadAndDecrypt`, and the same +map back. This is the one platform where encrypt-and-decrypt can be exercised on +the host, and it covers the atomic write path, which the layout test cannot. + +--- + +## Phase 3 — listing and starting identities + +**App. Needs Phases 1 and 2.** + +### Listing + +`SovereignWalletViewModel.listAvailableWallets` reads one store and exposes +`availableWallets: Map`. It becomes `listIdentities`, +reads both, and exposes + +```kotlin +sealed interface StoredIdentity { + val id: WalletId + val nostrPublicKey: HexKey + data class Mnemonic(val userWallet: UserWallet, override val nostrPublicKey: HexKey) : StoredIdentity { + override val id get() = userWallet.walletId + } + data class NostrSecret(override val id: WalletId, override val nostrPublicKey: HexKey, val privateKey: PrivateKey) : StoredIdentity +} +val availableIdentities: StateFlow> +``` + +For a mnemonic entry the nostr pubkey is one `LocalKeyManager(MnemonicCode.toSeed(words))` +away; `SeedManager.loadAndDecrypt` has already built exactly that key manager to +learn the node id, so this is the second derivation, not a new cost. The pubkey +is needed at listing time for one reason — see [dedupe](#dedupe-on-the-nostr-key-not-the-id) +in Phase 4 — and it is what the selector should show for an nsec identity, where +there is no node id to show. + +On secrets in memory: `availableWallets` holds decrypted words today for the +lifetime of the view model, because `startupNode(words)` needs them. +`StoredIdentity.NostrSecret` holds the private key for the same reason and is no +worse. Tightening that — decrypt at activation, hold only ids and pubkeys in the +list — is a real improvement and applies to both kinds equally, which is why it +is not done here. + +Metadata registration is unchanged: the loop that saves a `UserWalletMetadata` +with a random avatar for every id it has not seen +([SovereignWalletViewModel.kt:128](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)) +works on `WalletId` and does not care what is behind it. + +### Starting + +`SovereignWalletStartupScreen` picks a `StoredIdentity` where it now picks a +`UserWallet`, and `LoadWallet` — the screen-lock gate — takes the identity rather +than the wallet. It only ever read `walletId` off the wallet, to look up the lock +preferences, so the change is the parameter type; and it matters that the gate +sits *outside* the branch, so a future lock is not something to remember to add +twice: + +```kotlin +LoadWallet(stored, metadata, userPrefs, promptScreenLockImmediately) { stored -> + when (stored) { + is StoredIdentity.Mnemonic -> + sovereignWalletStartupViewModel.startupNode(stored.id, stored.userWallet.words) { business -> + sovereignWalletViewModel.setActiveIdentity(identityFor(stored.id, business)) + onSuccessfulStartup() + } + is StoredIdentity.NostrSecret -> { + sovereignWalletViewModel.setActiveIdentity( + Identity( + id = stored.id, + kind = IdentityKind.NostrSecret, + nostrPrivateKey = stored.privateKey, + userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id), + internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id), + business = null, + ) + ) + onSuccessfulStartup() + } + } + loadingIdentity = null +} +``` + +No `platformStartupLogic`, no `schedulePlatformLogic`, no `StartupViewState` +transitions for the nsec branch — an nsec identity is active the moment it is +read. The `StartingBusiness`/`BusinessActive` spinners that follow the `when` in +the screen today are reached only from the mnemonic branch, which is the one that +still has something to wait for. + +`availableWallets.isEmpty()` (line 91) becomes `availableIdentities.isEmpty()`. +The default-wallet and desired-wallet logic keys on `WalletId` and needs nothing. + +### The other entrance, and the race it hides + +`NavigationViewModel` decides where a boot goes from two places, and only one of +them is the flow the ten sites feed. `onSuccessfulStartup` calls +`loadNostrProfile(startupRoute)`, which reads + +```kotlin +val activeUserPublicKey = nostrRepository.getLocalAccounts().firstOrNull()?.profile?.publicKey +if (activeUserPublicKey == null) { … Landing … } +``` + +Two things are wrong with that line for this plan. It takes the *first* kind-0 +account on the device, whichever identity is active — harmless with one wallet, +wrong the moment there are two. And it keys off `profile.publicKey`, which is the +`Profile` row the notary upserts when it *signs* the kind 0. A placeholder +account planted by `signInToProfile` has an `UnsignedNostrEvent` and no +`Profile`, so this reads null and answers **`Landing`** — for a key that has just +been imported. Meanwhile `observeProfile`, collecting the same account through +the identity flow, answers `UnqueuedProfileSynchronization`. Both write +`_navigationUIState`; the order is whichever coroutine runs last, and `Landing` +navigates with `popUpTo(0)`. + +The create flow has the same window today — between `createNewProfile` and the +notary signing the kind 0, there is no `Profile` either — and gets away with it +because the flow collector keeps emitting as the rows change. An imported +identity's rows change less often. So, in this phase: + +```kotlin +val identity = activeIdentity.value +val account = nostrRepository.getLocalAccounts() + .firstOrNull { it.unsignedNostrEvent?.pubKey == identity?.nostrPublicKey } +val activeUserPublicKey = account?.unsignedNostrEvent?.pubKey +``` + +Read by the active identity, keyed on the unsigned event's `pubKey` — the row +`signInToProfile` writes and `getLocalAccounts()` selects on — and pass the +account straight to `processLocalAccount`, which already knows what a placeholder +means. `NavigationRoutingTest` gains the fixture it is missing: an account whose +`profile` is null and whose `signedAt` is `GENESIS_AT`, asserted to land on +`UnqueuedProfileSynchronization` and never on `Landing`. + +### The selector + +The app's `WalletsSelector` takes `Map` and shows +`userWallet.nodeId` in monospace +([WalletsSelector.kt:55](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt)). +It takes `Map` and shows the npub for an nsec identity +and the node id for a mnemonic one — or the npub for both, which is the identifier +the rest of the app uses and the one a user might actually recognise. The +`onWalletClick` callback carries the `StoredIdentity`. + +### Hoist the seed writer while you are here + +The three `platformWriteSeed` actuals — android, jvm, ios — are byte-identical +(`diff` of the three bodies is empty). Every symbol they use is `commonMain`: +`SeedManager`, `EncryptedSeed`, `LocalKeyManager`, `DataStoreManager`, +`AppVersion`. They are an `expect` because something once needed to differ and +nothing does now. Before adding a second writer beside them, make them one +common function; then add + +```kotlin +suspend fun writeNostrKey(phoenixGlobal, privateKey: PrivateKey, isTorEnabled, customElectrumServer): WalletId +``` + +next to it, with the same shape: load the existing map, refuse a duplicate, add, +encrypt, write, save the per-id prefs, return the id. The Tor and Electrum prefs +are meaningless for an nsec identity; save them anyway, because +`loadUserPrefsForWallet` is what creates the prefs file and the recovery screens +read it. + +### Tests + +`NavigationRoutingTest` already covers the state machine from a local account +inward. Add, in `jvmTest`, a `SovereignWalletViewModel` listing test that seeds +both stores through the jvm keystore (Phase 2's fixture) and asserts the merged +map: one entry per secret, ids of the right shape, pubkeys that match what +`Identity` computes. This is the test that would have caught the two stores +disagreeing about what an id is. + +--- + +## Phase 4 — the sign-in screen + +**App. Needs Phase 3.** `SignInToProfileScreen` becomes a screen. + +### Shape + +One screen, one text field, two things it accepts — because a user does not +choose an input type, they paste what they have, and the two are unambiguous: + +| pasted | recognised as | validation | +|---|---|---| +| twelve (or twenty-four) space-separated words | recovery phrase | `MnemonicCode.validate(words, English.wordlist())` — the checksum catches a wrong word | +| `nsec1…`, or `nostr:nsec1…` | nostr secret | quartz `Nip19Parser.uriToRoute(...)?.entity is NSec`, as the skeleton already does | +| 64 hex characters | nostr secret | `extractKeyPairFromPrivateKeyOrThrow()` accepts it; the confirm step shows the npub so a wrong paste is visible | +| `npub1…` | rejected, with the reason | see [Out of scope](#out-of-scope) — the app cannot do anything with a key it cannot sign with | +| `ncryptsec1…` | rejected, with the reason | NIP-49; a cheap follow-on, named in Out of scope | + +The field is a `TextFieldForm` with `SignInToProfileFormState`, which exists. +Trim, collapse whitespace, lower-case a hex string; do not lower-case words +(the wordlist is lower-case already and a capitalised word is a paste artefact +worth showing rather than silently fixing). + +### Flow + +`SignInToProfileUIState` already has the states; use them, with `ConfirmNsecSignIn` +generalised to a `Confirm(kind, npub, hexKey)`: + +1. **InputPrompt.** The field, a paste button, and the sentence from + `enter_the_nsec_or_npub_read_only_that_you` rewritten for what is actually + accepted — a recovery phrase or an nsec. Sentence case. New string. +2. **Confirm.** The derived npub, in full, in monospace, with which kind was + recognised, and one button. This is the step that catches the paste of the + wrong nsec, and for a recovery phrase it is where the user learns which + profile the words open. The nsec itself is never echoed back. +3. **Committing.** `LoadingDataIndicator`. What happens is in the next section. +4. **Error**, through `ErrorState` with an `onRetry` that returns to the prompt; + `null` is not the right answer here. Failures are named: invalid checksum, + not a key, already on this device, could not write. + +Four states, said so, wrapped in `ScreenStateTransition` since the `when` is the +body. Snackbar for the one success message, read above the handler through +`rememberNotifier`. Content root has `readableContent()`; the field spans it. + +### Committing an nsec + +```kotlin +val privateKey = PrivateKey.fromHex(entity.hex) // NSec.hex, 32 bytes, from the parser +val pubkey = privateKey.nostrPublicKeyHex() +rejectIfKnown(pubkey) // below +val id = writeNostrKey(phoenixGlobal, privateKey, …) // Phase 3's writer +nostrRepository.signInToProfile(publicKey = pubkey) // the kind-0 placeholder, GENESIS_AT +sovereignWalletViewModel.listIdentities { + sovereignWalletViewModel.switchToWallet(id) + navController.navigate(SovereignWalletStartupRoute) +} +``` + +The tail is the one `CreateProfileRoute` already uses after `writeSeed` +([MantraNavHost.kt:505](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)): +re-list, select, go to startup. Startup finds a `StoredIdentity.NostrSecret`, +activates it, `NavigationViewModel` observes its pubkey, finds the placeholder +account with `signedAt != null` and no sync requests, and lands on +`UnqueuedProfileSynchronization`. From there the existing machine runs. + +The order of the two middle lines is not a style choice. The placeholder has to +be in the database **before** the identity is activated: `observeProfile` starts +collecting the account the moment the identity flow emits, and an account that +is not there yet reads as `null`, which `processLocalAccount` answers with +`Landing`. It would correct itself when the row landed, through a `popUpTo(0)` +the user can see. Write the key, plant the placeholder, then re-list. + +### Committing a recovery phrase + +```kotlin +MnemonicCode.validate(words, wordlist) +val pubkey = LocalKeyManager(MnemonicCode.toSeed(words, "").byteVector(), chain, xpub).nostrPrivateKey().nostrPublicKeyHex() +rejectIfKnown(pubkey) +sovereignWalletViewModel.writeSeed(words, isRestoringWallet = true, onSeedWritten = { id -> + nostrRepository.signInToProfile(pubkey) + listIdentities { switchToWallet(id); navigate(SovereignWalletStartupRoute) } +}) +``` + +Same tail. `writeSeed` already carries `isRestoringWallet`; the only new line is +the `signInToProfile` that tells the machine this pubkey has a history to fetch +rather than a profile to create. Without it a restored wallet with no local rows +goes to `Landing` and is offered "create profile" for a key that already has one +— that is the state of restore today, and it is why the two inputs belong on one +screen. + +### Dedupe on the nostr key, not the id + +`platformWriteSeed` refuses a seed whose `WalletId` is already in the map. That +is the wrong key for this check. A mnemonic wallet and an imported nsec can be +the **same npub** with **different ids** — one is `hash160(nodeId)`, the other +`hash160(nostrPubkey)` — and the database is keyed by pubkey: `getLocalAccounts()` +is `SELECT * FROM UnsignedNostrEvent WHERE kind = 0`, joined to `Profile` on +`pubKey`. Two identities for one pubkey would share every row and disagree about +which is active. + +So `rejectIfKnown(pubkey)` checks the merged `availableIdentities` by +`nostrPublicKey`, for both inputs. The existing `WalletId` check stays; it is a +cheaper first pass for the seed case, not a replacement. + +### Landing + +`LandingScreen`'s "Sign in" already navigates to `SignInRoute`. The caption +beneath it, `sign_in_to_torch_via_nsec_or_remote_signer`, promises a remote +signer this plan does not deliver; change it to name what the screen accepts. + +### Tests + +`SignInToProfileViewModel` gets a `jvmTest` beside +`EditGroupCuratedSchemaViewModelJvmTest`: each row of the table above, in and +out — the npub for a known nsec test vector (NIP-06's own vectors give a +mnemonic → nsec → npub triple, which covers both inputs against the same +expected key); a wrong word; an `npub`; an `ncryptsec`; a duplicate. The +repository is `NostrRepository.NO_OP_NOSTR_REPOSITORY` with `signInToProfile` +overridden to record, so the test also proves the placeholder is planted exactly +once and only after the write succeeds. + +--- + +## Phase 5 — finding the profile, and not finding it + +**App. Needs nothing above it to compile, but is pointless before Phase 4 gives +it a caller.** + +### Ask the relays that would know + +`UnqueuedProfileSynchronizationViewModel.queueSynchronization` fans the sign-in +`REQ` out over `Relays.DefaultDMRelayList` — one relay, ours. Change it to +`Relays.DefaultIndexerRelayList` +([Relays.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/Relays.kt): +purplepag.es, indexer.coracle.social, user.kindpag.es, directory.yabu.me, +nostr1) **plus** `ephemeral`. Indexer relays exist to hold everyone's kinds 0, 3 +and 10002; that is the whole of their purpose, and it is exactly the list the +sync asks for. Our own relay stays in the set so a profile created here is found +here. + +Then follow the answer. When the sync indexes a kind 10002 for this pubkey, queue +a second, `level = 1`, `purpose = "sign-in"` request at the relays it names, for +the same kinds. That is the outbox model doing what it is for, and the +`SynchronizeNostrEventRequest.level` field is already there to mark the hop. +Bound it at one hop; the user's own relays are where their relay list is +authoritative, and anything past that is the feed, not the profile. + +### Give the machine an exit + +Today `processLocalAccount` reads + +```kotlin +unsignedNostrEvent.signedAt != null && synchronizeNostrEventRequests.isNotEmpty() -> UnsyncedProfile +``` + +and `UnsyncedProfile` shows "we are searching the internet to find your profile" +with a spinner, forever. Nothing records that a search *finished empty*. A +request goes `pending → sent` when its `REQ` is dispatched +([DatabaseNostrRepository.kt:489](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)) +and `sent → processed` only when an event arrives for it (line 532, which also +stamps the `nostrEventId`). A relay that answers with EOSE and nothing else +leaves its request at `sent` for good; the EOSE branch in +`SynchronizationViewModel` closes the subscription and writes nothing down. So +"still searching" and "searched, found nothing" are the same row. + +Two changes: + +1. **Record completion.** On EOSE, CLOSED, or the bounded timeout the + subscription already enforces, upsert any request still at `sent` with + `status = "complete"`. A request already at `processed` stays there — an + event arrived, which is the better answer. This is the one place the app + learns a relay has said everything it has, and it is currently thrown away. +2. **Observe it.** `UnsyncedProfileViewModel` watches the account's sign-in + requests; when none is still `pending` or `sent` and there is still no kind 0 + indexed for the pubkey, it moves the screen to a **not-found** state: "We could not + find a profile for this key on the relays we asked" with two actions — + *try again* (re-queue at level 0) and *set one up*. The second opens the + existing `CreateProfileScreen` form (name, bio) and calls + `nostrRepository.createNewProfile(pubkey, name, bio)` — the six-event + bootstrap a new key gets — **without** the seed generation that + `CreateProfileViewModel.createAccount` does first. The key already exists; only + the events do not. + +The relay list that "set one up" writes is the default one, which is the right +answer for a key that has never published anywhere. + +This exit is not nsec-specific. A restored recovery phrase whose profile was +never published, or a device offline at sign-in, sits on the same spinner today. +It is listed here because an imported nsec is the first path that will hit it +routinely: many nostr keys are made in a client that never wrote a kind 0. + +### Tests + +The navigation state stays `UnsyncedProfile` throughout — not-found is a state +of the *screen*, decided by its view model, so `NavigationRoutingTest` needs +nothing here. `UnsyncedProfileViewModel` gets a `jvmTest` over a fake repository +that emits the account's sign-in requests in sequence — all `pending`, then +`sent`, then `complete` — with no kind 0 indexed, and asserts the screen state +flips to not-found on the last emission and not before; and a second run where +one request goes `processed` instead, asserting it never flips at all. + +--- + +## Phase 6 — recovery, for an identity with no phrase + +**App. Needs Phase 3.** + +`KeyRecoveryScreen` offers one backup: *recovery phrase*, routing to +`RecoveryPhraseRoute`, whose view model reads `userWallet.words` out of the +decrypted seed map +([RecoveryPhraseViewModel.kt:114](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/RecoveryPhraseViewModel.kt)). +For an nsec identity there are no words, and `NoPhraseForThisWallet` is the +honest error it would show. Instead: + +- **`KeyRecoveryScreen` branches on `identity.kind`.** A mnemonic identity keeps + the phrase option. An nsec identity gets a *nostr secret key* option in its + place — `Icons.Default.Key`, `be_sure_to_keep_this_nsec_safe` as the + description, which exists — routing to a `NostrSecretRoute`. +- **`NostrSecretScreen`** is `RecoveryPhraseScreen` with the word grid replaced by + the nsec: hidden until revealed, revealed in monospace with a copy action, the + same "I have saved it" and "I understand" checkboxes, hidden again on leaving. + It reads the key from `NostrKeyManager.loadAndDecrypt` at reveal time, as the + phrase screen reads the seed file — not from the active identity — so the + screen's contract ("nothing secret held longer than it is shown") is the same + for both. The nsec is `privateKey.value.toHex().hexToNsecHrp()` — + `Credentials.kt` already has the encoder. +- **The backup flags reuse.** `isManualSeedBackupDone` and + `isSeedLossDisclaimerRead` live in the per-id `InternalPrefs` + ([InternalPrefs.kt:66](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/InternalPrefs.kt)), + so `showSeedBackupNotice` already means "this identity's secret is not backed + up" for whichever secret it is. The screens say "phrase" in three strings; + those become kind-aware or neutral ("recovery information", which the cloud + backup row already uses). +- **The disclaimer changes meaning.** `i_understand_that_if_i_lose_this_phone_and` + says "…I lose this profile and the funds in its wallet". An nsec identity has + no wallet; a new string for that kind, so the app is not warning about funds + it cannot hold. +- **Forget this key.** An import needs an inverse. Under the key options, for an + nsec identity only: remove the key from `nostr-keys.dat`, delete its prefs + (`DataStoreManager.deleteNodeUserPrefs` exists), hide its metadata, clear the + active identity, and go to startup. Behind a confirmation that names the npub + and says the profile stays on the relays. This is the first real "sign out" + in the app — `ActiveProfileScreen`'s button routes to + `ImplementationPendingRoute("Sign out")` — and the general case stays pending; + removing a *seed* is a wallet question and is not this plan's to answer. + +The two wallet-shaped rows on `KeyRecoveryScreen` — cloud backup, emergency kit +— are `ImplementationPendingRoute` today and stay for both kinds. + +--- + +## Phase 7 — the tests that actually prove it + +The per-phase tests above are unit tests of one seam each. Two more say the whole +thing works. + +**A restore round trip, on the jvm.** Unlock the keystore into a temp dir, write +an nsec through Phase 3's writer, list identities, activate the `NostrSecret`, +hand the `NavigationViewModel` a repository holding the `GENESIS_AT` placeholder, +and assert it lands on `UnqueuedProfileSynchronization`; then hand it the same +account with a kind 0 indexed and assert `ProfileLoaded`. This is the path a user +takes, end to end, with the network faked at the repository. + +**Nothing Lightning ran.** For the nsec activation above, assert +`platformStartupLogic` was not called. Wrap the `expect` in a counting fake for +the test, or — cheaper — assert `Identity.business == null` and that +`SovereignWalletStartupViewModel.state` stayed `Init`. An nsec identity that +quietly started a node would be a bug that nothing in the UI would reveal. + +And the one that already exists: `./gradlew :composeApp:m3Audit` over the two +new screens. The string budget is zero title-case literals and the spacing budget +is zero `.dp` in spacing positions; a sign-in form is exactly where a stray `16.dp` +and a "Sign In" arrive. + +--- + +## Phase 8 — rollout + +**Library first.** Phase 2 is a commit on `kngako/lightning-kmp-app`; the app +builds against the submodule checkout through the composite build, so it reaches +the app as a submodule pointer bump in the same commit as Phase 3. Tag it, since +JitPack consumers resolve by tag, and the README there says so. + +**Phase 1 ships alone,** before any of the rest is visible. It touches about +twenty files and changes nothing a user can see, and that is the point: if the release +after it behaves differently, the cause is in one diff. + +**Phases 3 and 4 ship together.** A build that can list an nsec identity but not +create one is fine; a build that can create one and not list it strands the user +at "Initializing…", which is where the comment at the top of +`SovereignWalletStartupScreen` says this app has stranded people before. + +**Phase 5 can ship before 4** — it fixes restore for recovery phrases too — but +should not ship *after* 4 by more than a release, for the reason given there. + +**Old builds and the new file.** A build without Phase 3 does not read +`nostr-keys.dat` and does not know the file exists. A user who imports an nsec +and then downgrades sees the identity vanish from the selector and nothing else +breaks; the file is still there for the next upgrade. `seed.dat` is untouched +throughout, so no build old or new misreads a wallet. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | identity in front of the wallet (app) | 1–2 | — | +| 2 | nostr key store (library) | 1 | — | +| 3 | listing and starting identities; the other entrance; hoist the seed writer | 2 | 1, 2 | +| 4 | the sign-in screen, both inputs, dedupe | 1–2 | 3 | +| 5 | indexer relays, one hop, the not-found exit | 1 | — | +| 6 | recovery for an nsec identity; forget this key | 1 | 3 | +| 7 | round-trip tests | 0.5 | 4, 5 | +| 8 | rollout | — | all | + +**Roughly one and a half to two focused weeks**, with Phases 1 and 2 in parallel +if two people are on it. Phase 3 is where the estimate is least reliable: it is +the first time both halves meet, and the startup screen has a history. + +## Out of scope + +- **A wallet for an nsec identity.** Not possible from the nsec, for the reason + in [The constraint](#the-constraint). Possible as a *second* secret — generate + a seed, attach it to the identity — but then one profile has two things to back + up and every recovery screen in this plan has to say which. Its own plan. +- **Starting the node lazily for mnemonic identities.** Recommended, separable, + and made small by Phase 1. See [The one decision](#the-one-decision-to-make-first). +- **`npub` sign-in (read only).** The skeleton parses it, and `signInToProfile` + would accept it. But every write path in the app assumes a key: the notary + would have nothing to sign with and the state machine would park on + `UnsignedProfile` the first time anything was queued. A read-only mode is a + product, not a branch. +- **NIP-49 `ncryptsec`.** A passphrase-encrypted nsec. quartz 1.14.0 ships + `nip49PrivKeyEnc.Nip49.decrypt`, so it is one call and a passphrase field; it + belongs in Phase 4's table once the plain nsec path is proven, and is a cheap + follow-on. +- **NIP-46 remote signer, NIP-55 Android signer.** The landing caption promises + one. Both need a signing *interface* — sign, NIP-44 encrypt, decrypt — in place + of a raw key, and `deriveHostSecretKey` (ChillDKG) needs the raw key bytes and + cannot be done through a remote signer at all. That is a different design with + a real constraint in it, and `NostrNotaryRepository`'s `isExternalSignerLogin` + stub is where it would start. +- **Tightening secrets in memory.** Both `availableWallets` today and + `StoredIdentity` here hold decrypted secrets for the view model's life. + Decrypt-at-activation applies to both kinds and should be done once, for both. +- **General sign out.** Phase 6 removes an *nsec*. Removing a seed is a wallet + operation with funds behind it, and `ActiveProfileScreen`'s button stays + pending. + +## Appendix — what was considered and rejected + +### Extending `EncryptedSeed` with a version-4 payload + +Put both kinds in `seed.dat`: a new `V2` variant (version byte 4 — 1 is single, +2 is retired, 3 is multiple) whose JSON is +`{ "": { "type": "mnemonic", "words": [...] } | { "type": "nsec", "key": "…" } }`, +and make `UserWallet` a sealed type. + +One file, one migration, one keystore alias — and rejected. The library is a +Phoenix fork whose history is visibly a stream of upstream ports; `seed.dat` is +Phoenix's format, and a fork of it is a merge conflict on every port from now on. +An older build reading the new file throws `unhandled V2 seed version=4` and +reports the *wallet* unreadable — a user who imported an nsec and downgraded +would lose access to their funds' seed until they upgraded, which is a worse +failure than the sibling file's "the nsec is not listed". And `BusinessManager.startNewBusiness(words)` +on three platforms, `RecoveryPhraseViewModel`, `WalletsSelector` and every +`UserWallet.words` site would have to learn a variant that has no words. A class +named `EncryptedSeed` should hold seeds. + +### Encoding the nsec as a mnemonic + +Thirty-two bytes is two hundred and fifty-six bits of entropy, which BIP39 +encodes as twenty-four words. So an nsec *can* be written as a valid mnemonic. +It is not the same thing as a seed whose NIP-06 leaf is that nsec — the words +would be run through `MnemonicCode.toSeed` and `LocalKeyManager` and derive a +completely different nostr key — and a store that cannot tell "words that are a +seed" from "words that are a key in disguise" is a store with a silent +misinterpretation in it. Rejected without a second look. + +### Deriving a seed from the nsec + +Not possible. Stated in [The constraint](#the-constraint); listed here so nobody +spends an afternoon confirming it. + +### The node as the key's carrier + +Leave `activeWalletInUI` as it is and build a fake `PhoenixBusiness`, or a fake +`LocalKeyManager`, for an nsec identity so the ten sites need not change. +`LocalKeyManager` is a `data class` with a seed; there is no interface to fake, +and a `PhoenixBusiness` lazily constructs eleven managers that expect a node. +Every "fake" would be a real object in a state its authors never intended, and +the ten sites are ten lines. + +### A new keystore alias for the key file + +Cleaner in principle — a compromise of one key would not be a compromise of +both. But `KeystoreHelper.getKeyForName` maps both existing aliases to **the same +key**, so the separation is already notional on Android, and a third alias means +touching that switch. Reuse, and note that separating the seed's and the key's +keystore entries is a hardening item for the wallet as much as for this. + +### Plaintext in DataStore + +`UserPrefs` is a DataStore file per wallet id and it would be one line to put the +nsec in it. It is unencrypted on disk. The seed has never been stored that way +here, and the key that signs as the user should not be the first.