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
55 KiB
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
flatMapLatestfrom the wallet flow into the key manager flow, because the key arrives after the node. With an identity there is one flow and onecollectLatest, and the children it launches are cancelled on identity change exactly as they are now. Keep thedistinctUntilChangedon the key: adata classIdentitycompares by value and the prefs objects inside it are stable per id, so it already behaves, but the comment onobserveUnsignedNostrEventsis about adistinctUntilChangedthat bit once; do not remove one without reading it. - NavigationViewModel.observeProfile loses its
business == null → StartupPhoenixbranch. 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):
- InputPrompt. The field, a paste button, and the sentence from
enter_the_nsec_or_npub_read_only_that_yourewritten for what is actually accepted — a recovery phrase or an nsec. Sentence case. New string. - 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.
- Committing.
LoadingDataIndicator. What happens is in the next section. - Error, through
ErrorStatewith anonRetrythat returns to the prompt;nullis 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:
- Record completion. On EOSE, CLOSED, or the bounded timeout the
subscription already enforces, upsert any request still at
sentwithstatus = "complete". A request already atprocessedstays 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. - Observe it.
UnsyncedProfileViewModelwatches the account's sign-in requests; when none is stillpendingorsentand 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 existingCreateProfileScreenform (name, bio) and callsnostrRepository.createNewProfile(pubkey, name, bio)— the six-event bootstrap a new key gets — without the seed generation thatCreateProfileViewModel.createAccountdoes 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:
KeyRecoveryScreenbranches onidentity.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_safeas the description, which exists — routing to aNostrSecretRoute.NostrSecretScreenisRecoveryPhraseScreenwith 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 fromNostrKeyManager.loadAndDecryptat 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 isprivateKey.value.toHex().hexToNsecHrp()—Credentials.ktalready has the encoder.- The backup flags reuse.
isManualSeedBackupDoneandisSeedLossDisclaimerReadlive in the per-idInternalPrefs(InternalPrefs.kt:66), soshowSeedBackupNoticealready 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_andsays "…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.deleteNodeUserPrefsexists), 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 toImplementationPendingRoute("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.
npubsign-in (read only). The skeleton parses it, andsignInToProfilewould 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 onUnsignedProfilethe 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 shipsnip49PrivKeyEnc.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, andNostrNotaryRepository'sisExternalSignerLoginstub is where it would start. - Tightening secrets in memory. Both
availableWalletstoday andStoredIdentityhere 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.