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