Files
mantra-kmp/docs/nsec-sign-in.md
Kgothatso Ngako 69050bb743 docs: plan npub sign-in, starting from what a read-only identity is for
The nsec plan's out-of-scope note said a read-only mode is a product, not a
branch. This plan takes that at its word: what a public key can see here is
thin -- a profile card, its follows as search results, no feed, no rooms,
since every room is MLS or a gift wrap to the key that was not pasted -- so
the first section decides what such an identity is for before anything is
designed. It is a preview: the app as your own profile, before you paste a
secret into it. That one word settles the Messages tab (an empty state that
offers the upgrade), pasting the nsec of a read-only key (an upgrade in
place under the same id, not "already on this device"), sign out (real for
this kind only), and not-found (try again or a different key, never set one
up).

Eight phases: a nullable key on Identity, with the note that the compiler
will be silent about it; a plaintext list beside the two key files, app-side
through the public getDatadir and AtomicFileWrite so nothing needs a JitPack
tag; the third startup branch, a read-only KeyPair built in one place, and
only the two pumps that read; the sign-in screen, where hex stays a secret
because an x coordinate is almost always also a valid scalar; a LocalCanSign
capability and an inventory of every write entrance one tap from the three
tabs; two exits; two round trips; rollout.

Two traps found on the way are recorded where they bite: quartz's
KeyPair(privKey = null) generates a fresh key rather than meaning "no key",
and decryptGiftWrapSeal forwards exactly that; and signInToProfile plants a
second kind 0 on a second call, which nothing reached until the upgrade
path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@78807fe956
2026-09-13 12:03:15 +02:00

55 KiB
Raw Blame History

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 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.

Built, phases 1–7, one commit each, in the order given; Phase 8 is the rollout and is process rather than code. The phases are kept as written because they are the reasoning, and the code reads better against the argument it came from than against a summary of itself. Where the implementation chose differently the table below says so, and it did so in ways worth reading before touching any of it:

what the plan said what it turned out to be
WalletManagerExtension.nostrPublicKey() "can go with" the ten sites it stays: CreateProfileViewModel derives a fresh key's pubkey with it. Wrong by one caller
pull the failure classification into TechnicalExtensions.kt its own file, DecryptionFailure.kt. androidMain has a TechnicalExtensions.kt in the same package, and a common file of that name may hold only expects — anything with a body is a second TechnicalExtensionsKt facade the android target refuses. The graceful*Seed* actuals were left as they were
"lift the write into a helper both managers call" AtomicFileWrite.writeVerified, and SeedManager.writeSeedToDir now goes through it with its original check and exception type
the nsec branch of startup "reports the startup" itself it only sets the identity; the existing activeIdentity != null branch reports it. The mnemonic path reports twice (once from onStartupSuccess, once from that branch); the nsec path does not repeat the mistake
read loadNostrProfile(startupRoute) by the active identity done, with a fallback to the first account when there is no identity — which is how NavigationRoutingTest constructs it, and never how production reaches it
input problems "through ErrorState" on the field, as supporting text, while the prompt is still showing. ErrorState is for what happens after confirming — the key was already here, the write failed — where there is no field to put the message under
SignInToProfileUIState.Confirm(kind, npub, hexKey) Confirm(credential), with the SignInCredential carrying the pubkey; the two writers are injected as functions so commit is a suspend function with a result, testable without a key store
the not-found exit as a route a state of the screen: UnsyncedProfileViewModel.decide over the account, with NavigationUIState unchanged. A processed request — an event came back, just not a kind 0 — counts as finished; the plan's test sketch said the opposite and was wrong
"set one up" opens CreateProfileScreen an inline form on the not-found state. CreateProfileScreen is built around generating a seed and says so in its copy; and the kind 0 has to be written over the placeholder row, not beside it — see setUpProfileForExistingKey
the one hop, unspecified where in the sync pump, after saveNostrEvent, with a per-account set so five indexers answering with the same kind 10002 make one hop
forget: key, prefs, metadata, active identity plus the account's unsigned rows (forgetLocalAccount), which the plan did not list: the kind 0 that made it a local account, and anything queued that can now never be signed. Published events and the profile cache stay
assert platformStartupLogic was not called identity.business == null and the jvm BusinessManager.businessFlow empty afterwards — the observable fact rather than the call

Two things found on the way that are not in any phase. DataStoreManager caches each id's preferences in a companion object for the life of the process, so a test that reuses a key across temporary directories is served the wrong file (IdentityWriterJvmTest uses fresh keys for that reason). And the library commit is on claude/nostr-key-store in the submodule, at 59c11ed, bumped into the app by the Phase 3 commit; it has to be pushed with this branch, and tagged for JitPack consumers, before any of this leaves the machine.

The constraint

The profile's nostr key is not stored anywhere. It is derived, every time the wallet starts, at the NIP-06 path:

// 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:

// 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 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:

activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()

ten times, in eight files:

site what it does with the key
NotaryViewModel.kt:71 signs the queues, seals Marmot bundles with nsecPassword
SynchronizationViewModel.kt:193 the three pumps' key pair
RelaysSocketManager.kt:68 the pubkey whose relay list to follow
NavigationViewModel.kt:193 the pubkey whose account to observe
DkgRitualViewModel.kt:476, 516, 570 the ChillDKG host key, derived from the nostr key
SelectChatRoomTypeViewModel.kt:193 same
SelectSubgroupAdminsViewModel.kt:208 same
NostrNotaryRepository.kt:70 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); 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 built, no caller
the state machine that takes it from there: UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded NavigationViewModel.kt:96 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 built, asks one relay — see Phase 5
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 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 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 nullable today
keyStoreEncryption / keyStoreDecryption — public expect functions with android, jvm and ios actuals, parameterised by key alias KeyStoreFunctions.kt usable as-is
platformWriteSeed(…, isRestoringWallet = …) — the seed writer already has a restore flag NavigationViewModel.android.kt:64 built; recovery-phrase sign-in is nearly free once a screen exists
SignInToProfileScreen 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<nodeIdHash, List<word>> (EncryptedSeed.kt:60), and SeedManager.loadAndDecrypt runs MnemonicCode.toSeed on every entry and builds a LocalKeyManager from it to learn the wallet id (SeedManager.kt:48). A 32-byte key has no place in that shape, and the appendix says why it should not be given one.

The identity is carried by the node. activeWalletInUI: StateFlow<ActiveWallet?> is set only from StartBusinessResult.Success (SovereignWalletViewModel.kt:118), 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) 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) — 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). 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). 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.


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

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=<redacted>)"
}

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:

// 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

private val _activeIdentity = MutableStateFlow<Identity?>(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:

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<ActiveWallet?> parameters become StateFlow<Identity?> — a rename in most of them; the eighteen files are listed by grep -rl 'StateFlow<ActiveWallet?>' 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<ActiveWallet?>; it becomes MutableStateFlow<Identity?> 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:

// 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), 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: { "<x-only pubkey hex>": "<private key hex>", … }

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.

package fr.acinq.phoenix.security

class EncryptedNostrKeys(val iv: ByteArray, val ciphertext: ByteArray) {
    fun decryptAndGetKeyMap(): Map<String, PrivateKey>
    fun serialize(): ByteArray
    companion object {
        const val VERSION: Byte = 1
        fun deserialize(bytes: ByteArray): EncryptedNostrKeys
        fun encrypt(keys: Map<String, PrivateKey>): EncryptedNostrKeys   // KEY_NO_AUTH
    }
}
package fr.acinq.phoenix.managers

object NostrKeyManager {
    fun loadAndDecrypt(phoenixGlobal: PhoenixGlobal): DecryptNostrKeysResult
    fun loadAndDecryptOrNull(phoenixGlobal: PhoenixGlobal): Map<String, PrivateKey>?   // 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): 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

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<WalletId, UserWallet>. It becomes listIdentities, reads both, and exposes

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<Map<WalletId, StoredIdentity>>

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 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) 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:

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

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:

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<WalletId, UserWallet> and shows userWallet.nodeId in monospace (WalletsSelector.kt:55). It takes Map<WalletId, StoredIdentity> 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

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 — 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

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): 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

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: 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

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

Phases 1–7 are now implemented, in one sitting and in order. What each turned out to require, as against what was predicted here, is in the table at the top and in the commit messages on this branch. The two surprises were not in any phase's description: the android source set's file-name collision in Phase 2, and the process-global preferences cache that only a test with a fixed key could have found.

Out of scope

  • A wallet for an nsec identity. Not possible from the nsec, for the reason in 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.
  • 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 — and npub-sign-in.md is the plan for that product, starting from what it is for.
  • 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 { "<id>": { "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; 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.