The Phase 2 library commit was made on a branch cut from 59c11ed -- the nsec key-store commit, which is what the app's curated branch pins -- but that commit is not the library's default branch's tip: master is at e51e3ae, the merge of PR #1 that brought 59c11ed in. The two trees are identical, so the rebase is content-free; what changes is that claude/nostr-credentials is now one commit ahead of master rather than a sibling of its tip, and the app's pointer follows it to 84cc44c. The plan said the nsec library commit "sits on a remote branch called detached", which was true of where it was found and false of where it is: it reached master through the PR. What is still true, and still the rollout's one hard step, is that it is untagged -- the library has no tags at all -- and JitPack consumers resolve by tag. The two sentences that said otherwise are corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@00c36ec509
977 lines
54 KiB
Markdown
977 lines
54 KiB
Markdown
# Signing in with an npub
|
||
|
||
How a user looks at this app as a profile they hold no secret for, what such an
|
||
identity can and cannot do here, and why the answer to "what is it for" decides
|
||
every screen it touches.
|
||
|
||
Read this after [nsec-sign-in.md](./nsec-sign-in.md). It inherits the `Identity`
|
||
type, the two key stores, the sign-in screen and the not-found exit from there, and
|
||
most of what follows is that plan's out-of-scope note taken at its word — "a
|
||
read-only mode is a product, not a branch" — and asked what the product would be.
|
||
|
||
**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:
|
||
|
||
| what the plan said | what it turned out to be |
|
||
|---|---|
|
||
| the startup screen's third branch in Phase 3 | in Phase 2: `StoredIdentity` is sealed, and the compiler asked for the branch the moment `NostrPublic` existed. The right code either way, one phase early |
|
||
| `KeyRecoveryScreen`'s `NostrPublic` arm "unreachable, and says so" | for the option, `Unit` with the comment; for the sentence above it, grouped with `NostrSecret` — so that if the screen is ever reached it does not promise coins |
|
||
| "the old reader kept as `LegacyNostrKeysFile` for exactly one caller" | `LegacyNostrKeysFile` is the read half of what was `NostrKeyManager`, and `EncryptedNostrKeys` stays beside it, because the migration's test has to *produce* a v1 file and nothing else can |
|
||
| the migration, unspecified in its outcomes | `MigrationResult` — `Migrated(count)`, `NotNeeded`, `Failed(failure)` — and `listIdentities` maps a failure onto the same `ListWalletState.Error` an unreadable credentials file gets, per kind |
|
||
| the wire shape as "a `@Serializable sealed class`" | two types: `NostrCredential`, whose `Secret` carries a `PrivateKey` and cannot be built with a key that is not one, and a private serializable mirror inside the encrypted file whose `Secret` carries hex |
|
||
| the DAO guard as "belt and braces" | load-bearing between Phase 3 and Phase 5, when the room list's inbox sync still ran for a read-only identity; and verified both ways — the test fails with the guard removed |
|
||
| `ProvideSigningCapability` from the active identity | a null identity — startup, landing, sign-in — answers *true*: it is nobody, not read-only, and those screens have nothing to hide |
|
||
| `ForgetIdentity` "dispatches the first step on its kind" | no dispatch: since the credentials file the first step is one call for both credential kinds, and `forgetNostrCredential` itself answers `NotACredential` for a mnemonic |
|
||
| sign out and the not-found exit, shape unspecified | one `SignOutViewModel` owning the confirmation and the in-flight state, one `SignOutOfReadOnlyDialog` differing only in its title, and a `SignOutDependencies` bundle so each screen takes one nullable parameter rather than four lambdas that throw |
|
||
| `signInToProfile` "made idempotent by pubkey" | done, and tested by signing in twice and counting one; the sign-in view model's three writers now go through one shared write-and-map |
|
||
| the round trip "with the network faked at the repository" | with the *database* real: an in-memory Room under the app's own DAOs and a real `NotaryViewModel`, because "nothing was signed" is about what is not in the tables — plus a contrast case the plan did not ask for, the same harness as a signing identity producing a key package, so the assertions are known to bite |
|
||
| `use_a_different_key` as a new string | it existed: the sign-in screen's own "use a different key" button. One string, two screens, one meaning |
|
||
|
||
One thing found on the way that is in no phase: a compose test that looks for a
|
||
floating action button's label by text has to search the unmerged tree, because
|
||
`ExtendedFloatingActionButton` merges its label into the button's semantics — and
|
||
on the merged tree an `assertDoesNotExist` for a hidden control is vacuously true.
|
||
`ReadOnlyEntrancesJvmTest` uses the unmerged tree for every lookup for that reason.
|
||
|
||
The library commit is on `claude/nostr-credentials` in the submodule, at `84cc44c`,
|
||
one commit ahead of the library's `master` and bumped into the app by the Phase 2
|
||
commit; it has to be pushed with this branch and tagged for JitPack consumers
|
||
before any of this leaves the machine. The nsec plan's `59c11ed` reached `master`
|
||
through the library's PR #1 but was never tagged; Phase 8 below says why the two go
|
||
out on one tag.
|
||
|
||
## The constraint
|
||
|
||
A nostr public key can be addressed and can verify. It cannot do anything else,
|
||
and in this app "anything else" is most of the app:
|
||
|
||
| what needs the secret | where |
|
||
|---|---|
|
||
| signing every queued event — the kind 0, the follow list, key packages, group posts, proposals | `NotaryViewModel.observeUnsignedNostrEvents` |
|
||
| opening a NIP-17 gift wrap addressed to the user | [GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt) — NIP-44 with the recipient's key |
|
||
| joining a Marmot group — a key package has to be signed and published before any welcome can be addressed to it, and the welcome itself arrives as a gift wrap | `MarmotRepository.publishMarmotKeyPackageBundle`, `MarmotInboundManager` |
|
||
| the ChillDKG host key, `sha256(tag ‖ nostrPrivateKey)` | [ChillDkgRitualManager.kt:117](../composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt) |
|
||
|
||
What a public key *gets* is the events it has published — the kind 0, the kind 3,
|
||
the relay lists — and the profiles of the people it follows, because indexing a
|
||
kind 3 plants a placeholder for each and queues its sync
|
||
([NostrDao.kt:1128](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)).
|
||
In a client with a public feed, that is the client: read-only in Damus or Amethyst
|
||
means "browse as this person".
|
||
|
||
**This app has no public feed.** The home tab is a list of chat rooms, and every
|
||
room is MLS or a gift wrap, both encrypted to the key the user did not paste. The
|
||
curated lists, proposals, ceremonies and translations are reached only through a
|
||
room (`ChatRoomDetailScreen`). Search is over profiles, and trending notes is a
|
||
string that says "coming soon". So a read-only identity here sees its own profile
|
||
card, the people it follows — as search results, by name, once the sync has
|
||
fetched them; nothing renders the list itself — any profile's detail, and share
|
||
profile. That is the honest inventory, and it is thin — thin
|
||
enough that the first thing to decide is not how to build it but what it is
|
||
[for](#the-one-decision-to-make-first).
|
||
|
||
## What is already built
|
||
|
||
More than the nsec plan's out-of-scope note suggests, because that plan built most
|
||
of it on the way.
|
||
|
||
| piece | where | state |
|
||
|---|---|---|
|
||
| the parser recognises `npub1…` and `nostr:npub1…` — and refuses it, as `PublicKeyOnly` | [CredentialParser.kt:94](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt) | one branch from accepting it |
|
||
| `signInToProfile(pubkey)` plants the `GENESIS_AT` placeholder from a pubkey alone | [DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt) | built; needs one property, see [Phase 4](#a-sign-in-that-can-be-repeated) |
|
||
| the state machine from there — `UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded` — reads `identity.nostrPublicKey` and never a key | [NavigationViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built |
|
||
| the sign-in sync, the indexer relays, the one hop, and the not-found exit | `SignInSync`, `UnsyncedProfileViewModel` | built; the exit needs a third action, see [Phase 6](#phase-6--leaving) |
|
||
| `RelaysSocketManager` follows the identity's relay list by pubkey | [RelaysSocketManager.kt:70](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt) | works unchanged |
|
||
| the notary returns before doing anything when there is no key | [NotaryViewModel.kt:72](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt) | works unchanged — and so the key package bundle it would publish is never attempted |
|
||
| quartz's `KeyPair(pubKey = …)` with no private key, commented in its own source as "this is a read-only account" | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` | usable; it is why the DAO carries `privKey!!` at all |
|
||
| `Identity.business` is nullable; a read-only identity is `NostrSecret` minus the secret | [Identity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt) | one field from it |
|
||
| the forget flow — key out, prefs deleted, metadata hidden, account rows gone, re-list, selector | `NostrSecretViewModel.forgetKey`, `IdentityWriter.forgetNostrKey` | built for an nsec; the sequence transfers whole |
|
||
| `WalletsSelector` shows the npub for every kind | [WalletsSelector.kt:161](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | needs a "read only" label |
|
||
| `nostr-keys.dat`, with a version byte and a doc that says "a new shape would be a new version" | `EncryptedNostrKeys`, `NostrKeyManager`, `AtomicFileWrite` | the envelope, the atomic write and the manager shape all carry over; only the entries change |
|
||
| a string, `this_will_give_you_read_only_access_to_the` | `strings.xml` | present, unused, and says too little |
|
||
|
||
## What actually blocks it
|
||
|
||
Five things, in dependency order.
|
||
|
||
**The identity's key is not optional.** `Identity.nostrPrivateKey` is a
|
||
`PrivateKey` and `nostrPublicKey` is computed from it
|
||
([Identity.kt:50](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt),
|
||
62). Every reader of the identity compiles against that.
|
||
|
||
**There is nowhere to keep a public key.** Both stores hold secrets. `nostr-keys.dat`
|
||
is a map from public key to private key, and `EncryptedNostrKeys` refuses a file
|
||
whose entry does not derive the key it is filed under — the right rule for that
|
||
shape, which is why the answer is a new shape rather than a value smuggled into
|
||
this one ([Phase 2](#phase-2--one-credentials-file)).
|
||
|
||
**The pumps are gated on the private key.** `SynchronizationViewModel` runs its
|
||
four collectors inside `identity?.nostrPrivateKey?.let { … }`
|
||
([SynchronizationViewModel.kt:194](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt)).
|
||
A read-only identity's sign-in sync would be queued and never dispatched, and the
|
||
machine would park on `UnsyncedProfile` for good — the nsec plan's Phase 5 failure
|
||
again, from a different cause.
|
||
|
||
**The DAO opens what is addressed to it, or rolls back.** `storeNostrEvent` is
|
||
`@Transaction` and indexes inside it; a gift wrap addressed to the active key that
|
||
cannot be unsealed throws
|
||
([NostrDao.kt:448–459](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)),
|
||
and the throw takes the event with it. Worse, and not obvious: `decryptGiftWrapSeal`
|
||
builds `KeyPair(privKey = keyPair.privKey)`
|
||
([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)),
|
||
and in quartz a `KeyPair` given neither key *generates a fresh random one*. A
|
||
read-only pair forwarded there would try to open the wrap with a key nobody has,
|
||
fail, and roll back — silently correct-looking in the logs.
|
||
|
||
**Every write entrance is unconditional.** Some twenty-five view models write
|
||
something the notary has to sign or a key has to seal. Most are behind a chat
|
||
room and unreachable without one; but the "New chat" button,
|
||
follow, send message, key package management, key recovery and the not-found
|
||
screen's "set one up" are each one tap from the three tabs. A pasted npub that
|
||
lands on a screen offering "New chat" is not a preview, it is a broken app.
|
||
|
||
## The one decision to make first
|
||
|
||
What is a read-only identity *for?* Three answers were considered.
|
||
|
||
**Browsing.** Needs a public feed. This app does not have one, and a mode whose
|
||
whole content is "coming soon" should not ship ahead of it.
|
||
|
||
**Auditing a group from outside.** The curated lists a group publishes are public
|
||
events (kinds 31889, 31888, 31890), and a member's device is not the only place they
|
||
could be read. But nothing today reaches them except through a room, and building
|
||
a public list browser is a feature with its own screens — the feed problem in a
|
||
smaller shape.
|
||
|
||
**A preview.** Look at the app as your own profile — the name it has for you, the
|
||
people it knows you follow, what it found on the relays — before you paste a secret
|
||
into it. And, having looked, paste it.
|
||
|
||
**This plan takes the third.** It is what the thin inventory is actually good for,
|
||
and it settles four questions that would otherwise each be an argument of their own:
|
||
|
||
- The Messages tab shows an empty state that says why it is empty and offers the
|
||
one thing that fills it: signing in with the nsec.
|
||
- Pasting the nsec of a key already here read-only is an **upgrade in place**, not
|
||
`AlreadyOnThisDevice`. The device does not have that key; refusing it would be
|
||
false.
|
||
- Sign out is real for this kind, and only this kind: there is nothing on the
|
||
device to lose. It is the first sign out in the app, and the general case stays
|
||
pending for the reason the nsec plan gave.
|
||
- Not-found offers *try again* or *a different key*, never *set one up* — a profile
|
||
cannot be set up for a key that cannot sign its kind 0.
|
||
|
||
Everything below is downstream of that one word, preview, and if it is ever
|
||
revisited — a public feed lands, say — the places to change are the four above.
|
||
|
||
---
|
||
|
||
## Phase 1 — a key the identity may not have
|
||
|
||
**App only. No behaviour change.** A type change that ships alone, so its diff is
|
||
boring.
|
||
|
||
### The type
|
||
|
||
```kotlin
|
||
enum class IdentityKind {
|
||
Mnemonic,
|
||
NostrSecret,
|
||
/** A bare nostr public key. Nothing can be signed, opened or derived from it. */
|
||
NostrPublic,
|
||
}
|
||
|
||
data class Identity(
|
||
val id: WalletId,
|
||
val kind: IdentityKind,
|
||
val nostrPublicKey: HexKey,
|
||
/** Null only for [IdentityKind.NostrPublic]. */
|
||
val nostrPrivateKey: PrivateKey?,
|
||
val userPrefs: UserPrefs,
|
||
val internalPrefs: InternalPrefs,
|
||
val business: PhoenixBusiness?,
|
||
) {
|
||
/** What the notary, the pumps and every write entrance ask. */
|
||
val canSign: Boolean get() = nostrPrivateKey != null
|
||
|
||
init {
|
||
require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { "kind and key disagree" }
|
||
require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { "key does not derive its public key" }
|
||
}
|
||
|
||
override fun toString(): String = "Identity(id=$id, kind=$kind, key=<redacted>)"
|
||
|
||
companion object {
|
||
fun signing(id: WalletId, kind: IdentityKind, privateKey: PrivateKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs, business: PhoenixBusiness?): Identity
|
||
fun readOnly(id: WalletId, nostrPublicKey: HexKey, userPrefs: UserPrefs, internalPrefs: InternalPrefs): Identity
|
||
}
|
||
}
|
||
```
|
||
|
||
`nostrPublicKey` moves from a derived property to a constructor field, because for
|
||
one kind there is nothing to derive it from; the two `require`s in `init` keep the
|
||
field and the key from disagreeing, which a data class would otherwise happily
|
||
allow. The factories exist so that the two places that build a signing identity
|
||
today — `SovereignWalletViewModel.setActiveWallet` and the nsec branch of
|
||
`SovereignWalletStartupScreen` — keep deriving the pubkey in one place, and so that
|
||
nobody constructs a read-only identity with a key by accident.
|
||
|
||
`id` for a read-only identity is `toWalletId()` of the x-only key — the **same id**
|
||
its nsec would have. That is deliberate and it is what makes the upgrade in
|
||
[Phase 2](#the-upgrade-rule) free: preferences and metadata are keyed by id, and a
|
||
read-only identity that becomes a signing one keeps both.
|
||
|
||
### The readers
|
||
|
||
Every reader of `nostrPrivateKey` already reaches it through `?.` on a nullable
|
||
identity — `activeIdentityStateFlow.value?.nostrPrivateKey` in the DKG, chat-room
|
||
and subgroup view models, `.map { it?.nostrPrivateKey }` in the notary,
|
||
`identity?.nostrPrivateKey?.let` in the pumps. Making the field nullable changes
|
||
the type of none of those expressions, and **the compiler will be silent**. That
|
||
is worth saying plainly, because it is the opposite of Phase 1 of the nsec plan,
|
||
where twenty-five parameter types forced every site to be looked at: here nothing
|
||
forces anything, and the sites that need a decision are found by reading, in
|
||
[Phase 5](#phase-5--what-a-read-only-screen-shows), not by compiling.
|
||
|
||
One site does change. `KeyRecoveryScreen` branches on `activeIdentity?.kind` with
|
||
`NostrSecret` in one arm and `else` in the other
|
||
([KeyRecoveryScreen.kt:177](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt)),
|
||
and the `else` meant *mnemonic*. A read-only identity would fall into it and be
|
||
offered a recovery phrase it does not have. Make both `when`s in that file
|
||
exhaustive; the `NostrPublic` arm is unreachable after Phase 5 hides the row, and
|
||
an unreachable arm that says so is better than an `else` that will mean something
|
||
different next time a kind is added.
|
||
|
||
### Tests
|
||
|
||
`NavigationIdentityRoutingJvmTest` has a `nostrSecretIdentity()` fixture and the
|
||
case that a nodeless identity is routed by its account rather than to startup. It
|
||
gains a `readOnlyIdentity()` fixture and the same case: with the placeholder
|
||
account, `UnqueuedProfileSynchronization`; with a kind 0 indexed, `ProfileLoaded`.
|
||
And the two `require`s each get a one-line negative test, because they are the
|
||
whole reason the field is a constructor parameter.
|
||
|
||
---
|
||
|
||
## Phase 2 — one credentials file
|
||
|
||
**Library, then app. The library half needs nothing above it; the app half needs
|
||
Phase 1.** The only phase that touches the library, and the one place this plan
|
||
changes one of the nsec plan's stores rather than adding to them.
|
||
|
||
### The file that was written expecting this
|
||
|
||
`EncryptedNostrKeys`'s own doc says it: "this file has one shape, and a new shape
|
||
would be a new version." A read-only credential is the new shape. Rather than a
|
||
second file beside `nostr-keys.dat` holding public keys in the clear — the design
|
||
this plan first had, and rejected for the reason in the
|
||
[appendix](#a-plaintext-sibling-file-for-public-keys) — the file becomes what its
|
||
name should have been: **`nostr-credentials.dat`**, one entry per nostr public key,
|
||
each entry saying what the device holds for it.
|
||
|
||
```json
|
||
{
|
||
"<x-only pubkey hex>": { "type": "secret", "privateKey": "<private key hex>" },
|
||
"<x-only pubkey hex>": { "type": "public" }
|
||
}
|
||
```
|
||
|
||
Same envelope — version byte, sixteen-byte iv, ciphertext of UTF-8 JSON under
|
||
`KeyStoreNames.KEY_NO_AUTH` — same atomic write through `AtomicFileWrite`, same
|
||
manager shape. The JSON is a `Map<String, NostrCredential>` with `NostrCredential`
|
||
a `@Serializable sealed class` and `type` its class discriminator — kotlinx's
|
||
standard sealed polymorphism, `Json { classDiscriminator = "type" }`, which the
|
||
library does not use elsewhere (its cloud payloads pick a variant by a version
|
||
field) and which is chosen here because the two variants have different fields and
|
||
the map then decodes in one call with no hand-written dispatch.
|
||
|
||
```kotlin
|
||
package fr.acinq.phoenix.security
|
||
|
||
@Serializable
|
||
sealed class NostrCredential {
|
||
@Serializable @SerialName("secret") data class Secret(val privateKey: PrivateKey) : NostrCredential()
|
||
@Serializable @SerialName("public") data object Public : NostrCredential()
|
||
}
|
||
|
||
class EncryptedNostrCredentials(val iv: ByteArray, val ciphertext: ByteArray) {
|
||
fun decryptAndGetCredentials(): Map<String, NostrCredential>
|
||
fun serialize(): ByteArray
|
||
companion object {
|
||
const val VERSION: Byte = 1
|
||
fun deserialize(bytes: ByteArray): EncryptedNostrCredentials
|
||
fun encrypt(credentials: Map<String, NostrCredential>): EncryptedNostrCredentials
|
||
}
|
||
}
|
||
```
|
||
|
||
The read keeps the check that makes the file trustworthy and adds its counterpart:
|
||
a `secret` entry must derive the public key it is filed under, as today, and a
|
||
`public` entry's map key must be sixty-four hex characters naming a point on the
|
||
curve. Either failure is `SerializationError`, because either can only be
|
||
corruption.
|
||
|
||
Encrypting a public key protects nothing and costs nothing. What it buys is one
|
||
read path with one failure classification, and — a small thing, but real — the
|
||
list of profiles a user has looked at is a browsing record, and it is at rest
|
||
under the same key as everything else about them.
|
||
|
||
### Why the file is renamed rather than versioned in place
|
||
|
||
A version byte of 2 in `nostr-keys.dat` would be the natural move and it is the
|
||
wrong one. An older build's reader throws on an unknown version, `NostrKeyManager`
|
||
classifies that as `SerializationError`, and `listIdentities` **returns** on that
|
||
result before it publishes anything
|
||
([SovereignWalletViewModel.kt:218](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)):
|
||
the old build would list no identities at all, seed wallets included. That is the
|
||
`seed.dat` failure the nsec plan's appendix rejected a version-4 payload for. A
|
||
file the old build does not look for cannot do that to it.
|
||
|
||
So: `nostr-credentials.dat`, version 1 of a new file, and the old reader kept as
|
||
`LegacyNostrKeysFile` for exactly one caller.
|
||
|
||
### Migration
|
||
|
||
`NostrCredentialManager.migrateFromNostrKeys(phoenixGlobal)`: if
|
||
`nostr-credentials.dat` is absent and `nostr-keys.dat` exists, read the old file,
|
||
convert every entry to `Secret`, write the new file through the verified atomic
|
||
write, and **delete the old one**. Called once from `listIdentities`, which runs
|
||
from `SovereignWalletViewModel.init` before anything else touches either file, so
|
||
a read stays a read and the migration is a named step with a test of its own.
|
||
|
||
Delete, rather than leave a frozen copy for an older build to find. A copy goes
|
||
stale in the one direction that matters: a key the user asks the new build to
|
||
*forget* would survive in a file only the old build reads, and come back on a
|
||
downgrade. Losing sight of every nsec identity on a downgrade until the next
|
||
upgrade is the lesser failure, and [Phase 8](#phase-8--rollout) says so out loud.
|
||
|
||
As far as the repository can show, the migration will run for nobody: the library
|
||
commit that introduced `nostr-keys.dat` carries no tag and the app has none. It
|
||
exists because the repository cannot prove a negative, and because it is thirty
|
||
lines.
|
||
|
||
### Listing
|
||
|
||
```kotlin
|
||
sealed interface StoredIdentity {
|
||
…
|
||
data class NostrPublic(
|
||
override val id: WalletId,
|
||
override val nostrPublicKey: HexKey,
|
||
) : StoredIdentity {
|
||
override val kind: IdentityKind get() = IdentityKind.NostrPublic
|
||
}
|
||
}
|
||
```
|
||
|
||
`StoredIdentity.merge(wallets, credentials)` keeps its two inputs; a `Secret` is a
|
||
`NostrSecret` and a `Public` a `NostrPublic`. One entry per pubkey is the file's
|
||
own invariant, so there is no precedence between credentials to decide. There is
|
||
one between a seed and a credential, and it exists for the one upgrade that has to
|
||
span two files (below): a `Public` whose pubkey a seed derives is dropped, with a
|
||
log line, and the next write repairs the file.
|
||
|
||
### The writers
|
||
|
||
```kotlin
|
||
object IdentityWriter {
|
||
…
|
||
suspend fun writeNostrPublicKey(log, phoenixGlobal, globalPrefs, publicKey: HexKey, isTorEnabled, customElectrumServer): WriteNostrCredentialResult
|
||
suspend fun forgetNostrCredential(log, phoenixGlobal, id: WalletId, publicKey: HexKey): ForgetNostrCredentialResult
|
||
}
|
||
```
|
||
|
||
`writeNostrKey` stays and writes a `Secret`; `writeNostrPublicKey` writes a
|
||
`Public`; both refuse a duplicate by public key against the seeds *and* the
|
||
credentials, and both `prepareIdentity` — the Tor and Electrum preferences mean
|
||
even less to a read-only identity than to an nsec one, and are saved for the
|
||
reason the nsec plan gave. `forgetNostrKey` becomes `forgetNostrCredential` and
|
||
removes an entry of either kind; its `NotABareKey` becomes `NotACredential`, which
|
||
still means a mnemonic.
|
||
|
||
### The upgrade rule
|
||
|
||
`writeNostrKey`'s duplicate check learns one distinction:
|
||
|
||
| the pubkey is already here as | pasting its nsec | pasting its npub |
|
||
|---|---|---|
|
||
| a wallet (seed) | `AlreadyExists` | `AlreadyExists` |
|
||
| a `secret` credential | `AlreadyExists` | `AlreadyExists` |
|
||
| a `public` credential | **upgrade**: the entry becomes `secret`, in one write; `Written(id)` with the id unchanged | `AlreadyExists` |
|
||
|
||
One write, because it is one file. There is no window in which the device holds
|
||
both or neither, and nothing to reconcile afterwards — which is the whole reason
|
||
this phase is a library change and not a second file.
|
||
|
||
The one upgrade that still spans two files is a *recovery phrase* pasted over a
|
||
public credential: the seed goes to `seed.dat`, and that file cannot hold anything
|
||
else. `writeMnemonic` removes the `public` entry **first**, then writes the seed.
|
||
A crash between the two loses the read-only identity — recoverable by pasting the
|
||
npub again — rather than leaving one pubkey listed twice under two ids, which is
|
||
what the reverse order would do and what the merge rule above is there to catch
|
||
if it somehow happens anyway. The wallet's id is `hash160(nodeId)` and its
|
||
preferences are fresh, as a new wallet's are today.
|
||
|
||
### Tests
|
||
|
||
In the library, after the two that exist: `EncryptedNostrCredentialsTest` in
|
||
`commonTest`, the layout as literal bytes with one entry of each kind, because the
|
||
format is a compatibility contract from the moment it exists; and, in `jvmTest`, a
|
||
round trip through the key store, plus the migration — a v1 `nostr-keys.dat`
|
||
written by `LegacyNostrKeysFile`'s test fixture, read back as `Secret` entries in
|
||
`nostr-credentials.dat`, the old file gone.
|
||
|
||
In the app: `StoredIdentityJvmTest` gains a `Public` credential listed as
|
||
`NostrPublic`, and a `Public` whose pubkey a seed derives dropped.
|
||
`IdentityWriterJvmTest` gains: a public key is written once and refused the second
|
||
time; its nsec is then accepted, under the same id, and the entry is now `secret`;
|
||
the npub of an nsec already here is refused; forgetting a credential of either kind
|
||
leaves the others and takes its preferences.
|
||
|
||
---
|
||
|
||
## Phase 3 — listing, starting, and reading without a key
|
||
|
||
**App. Needs Phase 2.**
|
||
|
||
### Listing and starting
|
||
|
||
`SovereignWalletViewModel.listIdentities` runs the migration, then reads
|
||
`nostr-credentials.dat` where it read `nostr-keys.dat`, with the same five results
|
||
handled the same way; the change is the type of the map it hands to `merge`
|
||
([SovereignWalletViewModel.kt:242](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)).
|
||
|
||
`SovereignWalletStartupScreen` gains its third branch, beside the nsec one
|
||
([SovereignWalletStartupScreen.kt:185](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)):
|
||
|
||
```kotlin
|
||
is StoredIdentity.NostrPublic -> sovereignWalletViewModel.setActiveIdentity(
|
||
Identity.readOnly(
|
||
id = identity.id,
|
||
nostrPublicKey = identity.nostrPublicKey,
|
||
userPrefs = dataStoreManager.loadUserPrefsForWallet(identity.id),
|
||
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(identity.id),
|
||
)
|
||
)
|
||
```
|
||
|
||
No node, no `platformStartupLogic`, active the moment it is read — exactly the nsec
|
||
branch. The screen-lock gate wraps all three, as it was built to.
|
||
|
||
`WalletsSelector` shows the npub for every kind already; a read-only row adds the
|
||
words *read only* under it, in `labelSmall`, so the user can tell which of two
|
||
identities with the same avatar will let them send a message before they tap.
|
||
|
||
### The pumps
|
||
|
||
`SynchronizationViewModel`'s collector becomes
|
||
|
||
```kotlin
|
||
activeIdentityStateFlow.collectLatest { identity ->
|
||
identity?.let {
|
||
val keyPair = identity.toKeyPair()
|
||
…
|
||
supervisorScope {
|
||
launch(Dispatchers.IO) { observePendingSyncNostrEventRequests(keyPair) }
|
||
launch(Dispatchers.IO) { observePendingNegentropySynchronizeRequests(keyPair) }
|
||
if (identity.canSign) {
|
||
launch(Dispatchers.IO) { observePendingBroadcastNostrEventRequests(keyPair) }
|
||
launch(Dispatchers.IO) { liveSubscriptionManager.observe(keyPair) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**A read-only identity runs the two pumps that read and neither of the two that
|
||
write.** Stated as a rule, because Phase 5 leans on it: *a read-only identity never
|
||
sends anything to a relay*, not a signature and not a copy. The broadcast pump
|
||
would find nothing — nothing is ever signed, and the one control that queues an
|
||
already-signed event is hidden — and the
|
||
live subscriptions are the gift-wrap inbox and the group-membership follow, both of
|
||
which fetch things this identity cannot open, and the catch-up negentropy for the
|
||
same. Not running them is not an optimisation; it is the identity not asking for
|
||
what it cannot use. The sync pump and the negentropy pump are what the sign-in
|
||
sync, the one hop and *try again* are queued on, and they are all a preview needs.
|
||
|
||
`Identity.toKeyPair()` is the one place a quartz `KeyPair` is built from an
|
||
identity:
|
||
|
||
```kotlin
|
||
fun Identity.toKeyPair(): KeyPair = KeyPair(
|
||
privKey = nostrPrivateKey?.value?.toByteArray(),
|
||
pubKey = nostrPublicKey.hexToByteArray(),
|
||
)
|
||
```
|
||
|
||
With a private key, quartz recomputes the public key from it and the second
|
||
argument is a cross-check. Without one, it is the read-only constructor. What it
|
||
must never be is `KeyPair(privKey = null)` with nothing else — that is quartz's
|
||
"make me a new key", and it is what `decryptGiftWrapSeal` does today
|
||
([GiftWrapMessage.kt:126](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GiftWrapMessage.kt)).
|
||
The helper exists so that the trap has one place to be avoided; leave a comment on
|
||
it that says what the trap is.
|
||
|
||
### The one DAO guard
|
||
|
||
After the `isAddressedTo` check in `indexNostrEvent`
|
||
([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)):
|
||
|
||
```kotlin
|
||
if (activeKeyPair.privKey == null) {
|
||
// Addressed to us, and we cannot open it. Keep the event and the wrap we just
|
||
// stored, as the branch above does for wraps addressed to someone else; the
|
||
// key that opens this one may be signed in later.
|
||
logger.d("GiftWrap ${nostrEvent.id} is addressed to us, but this identity holds no key")
|
||
return@let
|
||
}
|
||
```
|
||
|
||
The two `privKey!!` sites further down (lines 898 and 1376) are downstream of a
|
||
successful unseal and a Marmot room this device is in; a read-only identity reaches
|
||
neither, and they stay as they are. With the live subscriptions off, and once
|
||
Phase 5 leaves the room list uncomposed, a gift wrap addressed to a read-only
|
||
identity arrives only if something else asks for kind 1059 — nothing else does —
|
||
so this guard is belt and braces. It stays because the alternative, when something
|
||
does ask, is a rolled-back transaction that logs as a decryption failure and is
|
||
not one; and between this phase and Phase 5 it is not belt and braces at all, it
|
||
is what keeps the room list's inbox sync from rolling back every wrap it fetches.
|
||
|
||
### Tests
|
||
|
||
`NostrDaoJvmTest` gains one case: a gift wrap addressed to a read-only `KeyPair`
|
||
is stored, its `GiftWrapMessage` row is stored, no seal is stored, and nothing
|
||
throws — `NostrNip17DaoJvmTest` has the fixtures for building the wrap. The
|
||
listing itself is covered where the nsec plan ended up covering it: `merge`, in
|
||
`StoredIdentityJvmTest` (Phase 2), and the round trip in Phase 7, which lists
|
||
through the view model against a real directory and a real key store. The nsec plan asked for a
|
||
`SovereignWalletViewModel` listing test of its own and the build did not write
|
||
one; this plan does not pretend to.
|
||
|
||
---
|
||
|
||
## Phase 4 — the sign-in screen
|
||
|
||
**App. Needs Phase 3.** The field accepts a third thing.
|
||
|
||
### Shape
|
||
|
||
| pasted | recognised as | validation |
|
||
|---|---|---|
|
||
| words | recovery phrase | unchanged |
|
||
| `nsec1…`, `nostr:nsec1…` | nostr secret | unchanged |
|
||
| 64 hex characters | **nostr secret, still** | see below |
|
||
| `npub1…`, `nostr:npub1…` | nostr public key, read only | bech32 decodes with hrp `npub` to 32 bytes that are an x coordinate on the curve — `XonlyPublicKey(bytes).publicKey.isValid()`, the counterpart of the `isValid()` a secret is checked with |
|
||
| `ncryptsec1…` | rejected, with the reason | unchanged |
|
||
|
||
**Hex stays a secret.** A private key and an x-only public key are both thirty-two
|
||
bytes, and sixty-four hex characters cannot say which it is. The parser has always
|
||
read hex as a secret and shown the derived npub on the confirm step so a wrong
|
||
paste is visible; a user who pastes a public key as hex will see an npub they do
|
||
not recognise and go back. Guessing — "it is not a valid secret, so try it as a
|
||
public key" — would never fire when it mattered: a public key's x coordinate is,
|
||
with overwhelming probability, also a valid scalar, so the parser would accept it
|
||
as a secret, derive an unrelated npub, and never reach the guess. An npub has to
|
||
arrive as an npub.
|
||
|
||
`CredentialProblem.PublicKeyOnly` goes, and with it the string
|
||
`an_npub_is_a_public_key_mantra_needs_the`. An npub that does not decode, or
|
||
decodes to a point that is not on the curve, is `InvalidKey`, which already says
|
||
"that nostr key is not valid".
|
||
|
||
### The confirm step
|
||
|
||
`SignInCredential` gains
|
||
|
||
```kotlin
|
||
data class NostrPublicKey(override val nostrPublicKey: HexKey) : SignInCredential
|
||
```
|
||
|
||
and `Confirm` a third arm, `Icons.Default.Visibility` beside a sentence that says
|
||
what the user is about to get and not get. `this_will_give_you_read_only_access_to_the`
|
||
says "This will give you read only access to the profile", which is true and not
|
||
enough: it does not say that messages stay closed, and it does not say what to do
|
||
about it. Replace it:
|
||
|
||
> Recognised as a public key. You will see this profile and the people it
|
||
> follows; messages stay closed and nothing can be sent. Paste the nsec later to
|
||
> open it.
|
||
|
||
Sentence case, one string, read on the confirm step and nowhere else. The button
|
||
still says *Sign in*.
|
||
|
||
### Committing
|
||
|
||
```kotlin
|
||
is SignInCredential.NostrPublicKey -> when (val result = writeNostrPublicKey(credential.nostrPublicKey)) {
|
||
is Written -> Outcome.SignedIn(result.id)
|
||
is AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice)
|
||
is CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite)
|
||
}
|
||
```
|
||
|
||
then `signInToProfile`, then the same tail as the other two: re-list, select, go to
|
||
startup. Startup finds a `StoredIdentity.NostrPublic`, activates it, and the machine
|
||
runs from the placeholder exactly as it does for an nsec — the sync pump is running
|
||
because Phase 3 made it run.
|
||
|
||
### A sign-in that can be repeated
|
||
|
||
`signInToProfile` inserts a new kind-0 `UnsignedNostrEvent` every time it is called
|
||
([DatabaseNostrRepository.kt:258](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)).
|
||
Until now nothing called it twice for one pubkey: the writers refused the second
|
||
sign-in before it got there. The upgrade is the first path that signs in a pubkey
|
||
whose account already exists, and a second placeholder would be "two kind-0 rows
|
||
for one pubkey, two accounts disagreeing about which is this one" — the failure
|
||
`setUpProfileForExistingKey`'s own comment describes.
|
||
|
||
Make it idempotent by pubkey: if a kind-0 `UnsignedNostrEvent` exists for the key,
|
||
do nothing. That is the right property regardless of the upgrade, and the existing
|
||
test `both credentials of one key plant the same account` becomes `…plant one
|
||
account, however many times they sign in`.
|
||
|
||
### Landing
|
||
|
||
The caption under *Sign in* names what the screen accepts, and the label and
|
||
placeholder on the field do too. Three strings change to say "a recovery phrase,
|
||
an nsec, or an npub to look around" — the last clause because a user who does not
|
||
know the word *read-only* should still be told the difference before they paste.
|
||
|
||
### Tests
|
||
|
||
`CredentialParserJvmTest`: an npub, a `nostr:`-prefixed npub and the upper-cased
|
||
form all name the same public key; an npub that does not decode is an invalid key;
|
||
sixty-four hex characters are still a secret. `SignInToProfileViewModelJvmTest`: a
|
||
public key is written, then its account is planted, then the id comes back; the
|
||
npub of a key already here is refused and plants nothing; the nsec of a key here
|
||
read-only is written under the same id and plants nothing new.
|
||
|
||
---
|
||
|
||
## Phase 5 — what a read-only screen shows
|
||
|
||
**App. Compiles on Phase 1; visible after Phase 3.** The half the nsec plan called
|
||
a product.
|
||
|
||
### One question, asked in one way
|
||
|
||
Screens do not receive the identity. `HomeScreen`, `ActiveProfileScreen` and the
|
||
detail widgets take an `activeUserPublicKey`, and `MetadataEventDetail` — where
|
||
*follow* and *send message* live — is several composables below anything that
|
||
could be handed more. Threading `canSign` down would be the snackbar host's
|
||
problem again: the same parameter in twenty-six lists and forgotten in the
|
||
twenty-seventh.
|
||
|
||
```kotlin
|
||
// ui/composable/widgets/SigningCapability.kt
|
||
val LocalCanSign: ProvidableCompositionLocal<Boolean> = staticCompositionLocalOf { true }
|
||
|
||
@Composable
|
||
fun ProvideSigningCapability(activeIdentity: StateFlow<Identity?>, content: @Composable () -> Unit)
|
||
```
|
||
|
||
Provided once, in `MantraNavHost` around the `NavHost`, from
|
||
`sovereignWalletViewModel.activeIdentity`. The default is `true` rather than an
|
||
error, and that is the one way this differs from `LocalSnackbarHostState`: the
|
||
provider sits above every screen and cannot be forgotten per screen, so the only
|
||
things composed outside it are previews and the existing compose tests, and those
|
||
should render as they always have.
|
||
|
||
It is a capability, not a kind. A screen asks "can this identity sign?", not "is
|
||
this an npub?", because the answer is what it needs and because a remote signer —
|
||
which can sign and holds no local key — would otherwise be a fourth value in every
|
||
`when` (see [Out of scope](#out-of-scope)).
|
||
|
||
### The inventory
|
||
|
||
Every write entrance reachable from the three tabs without a chat room, and what
|
||
a read-only identity gets instead. A row that says *hidden* is hidden, not
|
||
disabled: a disabled "New chat" invites the question "why", and the answer is on
|
||
the empty state beside it.
|
||
|
||
| screen | control | it writes | read only |
|
||
|---|---|---|---|
|
||
| `HomeScreen` | *New chat* (both layouts), the sheet, the npub dialog | key packages, welcomes, gift wraps | hidden; the tab shows the [empty state below](#the-messages-tab) |
|
||
| `HomeScreen` | `ChatRoomListViewModel.initiate` | queues the inbox sync and the MLS negentropy | not composed, so not queued |
|
||
| `MetadataEventDetail` | *follow*, *unfollow*, *follow back* ([:215](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a kind 3 | hidden; the "follows you" state still shows |
|
||
| `MetadataEventDetail` | *send message* ([:303](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt)) | a gift wrap | hidden |
|
||
| `MetadataEventDetail` | *edit profile* | a kind 0 | hidden (it is `ImplementationPendingRoute` today, and stays so for the other kinds) |
|
||
| `ActiveProfileScreen` | *key package management* ([:233](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | key packages | hidden |
|
||
| `ActiveProfileScreen` | *key recovery* ([:286](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | nothing, but there is nothing to recover | hidden |
|
||
| `ActiveProfileScreen` | *sign out* ([:341](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt)) | — | **real**, see [Phase 6](#phase-6--leaving) |
|
||
| `ShareProfileScreen` | *re-broadcast* ([:191](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt)) | nothing is signed, but a copy of the kind 0 is queued for the finder relays | hidden; a preview puts nothing on a relay, not even a copy, and the broadcast pump is not running to carry it |
|
||
| `UnsyncedProfileScreen` | *set one up* ([:217](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt)) | the six-event bootstrap | hidden; replaced, see Phase 6 |
|
||
| `SocialPreconditionScreen` | *invite a friend*, *view invites* | pending, and writes when they exist | hidden; only *skip for now* |
|
||
| `WriteNewNoteScreen` and the rest | — | — | unreachable: every route to them starts in a room or behind a control above |
|
||
|
||
Two things are *not* on the list because they are already handled. The notary's
|
||
key package bundle is behind its key check, so a read-only identity never publishes
|
||
one. And the DKG, chat-room and subgroup view models each answer a missing key with
|
||
an error state — they are unreachable, and if they were reached they would say so
|
||
rather than crash.
|
||
|
||
### The Messages tab
|
||
|
||
Where the room list would be, for a read-only identity:
|
||
|
||
```kotlin
|
||
EmptyState(
|
||
message = stringResource(Res.string.messages_need_the_secret_key_this_profile),
|
||
icon = Icons.Default.Lock,
|
||
action = {
|
||
TextButton(onClick = { onNavigateToRoute(SignInRoute) }) {
|
||
Text(stringResource(Res.string.sign_in_with_the_nsec))
|
||
}
|
||
},
|
||
)
|
||
```
|
||
|
||
> Messages need the secret key. This profile is read only: you can see it and the
|
||
> people it follows, but nothing here can be opened or sent.
|
||
|
||
`EmptyState`'s message is required for exactly this reason — an absence has to say
|
||
which absence it is — and its `action` slot is where the upgrade lives. The
|
||
`SignInRoute` it opens is the same screen as landing's; the nsec pasted there hits
|
||
the upgrade rule, and the tail takes the user through startup to a Messages tab
|
||
with rooms in it.
|
||
|
||
One column at every width. The two-pane layout is a list beside a detail, and
|
||
there is no list; an empty state that is the only thing on the screen is the one
|
||
case `readableContent()`'s centring is for.
|
||
|
||
The tab stays. Hiding it would leave a navigation bar with two items — the
|
||
conformance work promoted search and profile to peers precisely so the bar would
|
||
not be "strictly worse than the app bar it replaced", and two items is halfway
|
||
back to that — and the bar is where the upgrade is found.
|
||
|
||
### Tests
|
||
|
||
`ReadOnlyEntrancesJvmTest`, with `runDesktopComposeUiTest` as `ChatPaneLayoutJvmTest`
|
||
does: `HomeScreen` in its loaded state under `LocalCanSign provides false` has no
|
||
node with the text *New chat* and one with *Sign in with the nsec*; under `true`,
|
||
the reverse. `ActiveProfileScreen` under `false` has no *Key recovery* and has
|
||
*Sign out*. The inventory above is the list of assertions; a row without one is a
|
||
row that will regress.
|
||
|
||
And `./gradlew :composeApp:m3Audit` — an empty state with an action and a new
|
||
`when` arm is where a `16.dp` and a "Read Only" arrive.
|
||
|
||
---
|
||
|
||
## Phase 6 — leaving
|
||
|
||
**App. Needs Phases 2 and 5.** Two exits, one sequence.
|
||
|
||
### Sign out, for the kind that can
|
||
|
||
`NostrSecretViewModel.forgetKey` does five things in an order that matters — key
|
||
out of the file, preferences deleted, metadata hidden, account rows gone, then the
|
||
caller re-lists and clears the active identity — and the order is documented on the
|
||
function: a failure partway leaves the key on disk rather than an identity the
|
||
selector lists but nothing can open. The sequence is the same for a read-only
|
||
identity, and since Phase 2 the first step is the same call — `forgetNostrCredential`
|
||
removes an entry of either kind. Lift the sequence into a `ForgetIdentity` helper
|
||
that takes the identity, returns `NotACredential` for a mnemonic one, and is
|
||
called by `NostrSecretViewModel` and the two exits below alike.
|
||
|
||
`ActiveProfileScreen`'s *sign out* then does, for a read-only identity only, what
|
||
its colour has been promising: a confirmation dialog naming the npub —
|
||
|
||
> Sign out of this profile? This device holds no key for it, so there is nothing
|
||
> to lose. The profile stays on the relays, and you can sign in again any time.
|
||
|
||
— and `ForgetIdentity`, then the nav host's existing tail
|
||
(`listIdentities { resetToSelector() }`, which shows the selector or, if this was
|
||
the last identity, Landing). For the other two kinds the button keeps routing to
|
||
`ImplementationPendingRoute("Sign out")`, and the nsec plan's reason stands:
|
||
removing a seed is a wallet question.
|
||
|
||
### The not-found exit
|
||
|
||
`UnsyncedProfileScreen`'s not-found state offers *try again* and *set one up*. For
|
||
a read-only identity the second is hidden by Phase 5 — and then the state has
|
||
**no exit**: the profile tab is not reachable before `ProfileLoaded`, so a user
|
||
whose npub was not found on any relay could try again for ever. That is the nsec
|
||
plan's "parks on a spinner" with the spinner replaced by a button.
|
||
|
||
A third action, shown only where the second is not: *use a different key*, which
|
||
is `ForgetIdentity` behind the same confirmation and the same tail, landing on the
|
||
selector or Landing. The screen learns the two callbacks the way `NostrSecretScreen`
|
||
did — passed from the nav host — and reads `LocalCanSign` to decide which of the
|
||
two it shows.
|
||
|
||
### Tests
|
||
|
||
`ForgetIdentity` gets the `IdentityWriterJvmTest` treatment: a read-only identity
|
||
is forgotten and its list entry, preferences and account rows are gone; the other
|
||
entries remain. `UnsyncedProfileViewModel`'s existing decision test is unchanged —
|
||
not-found is still not-found — and the screen test asserts which action each kind
|
||
gets.
|
||
|
||
---
|
||
|
||
## Phase 7 — the tests that actually prove it
|
||
|
||
Two, beside `NsecRestoreRoundTripJvmTest`, whose fixture they reuse.
|
||
|
||
**The preview, end to end.** Write an npub through Phase 2's writer, list
|
||
identities, activate the `NostrPublic` one, hand `NavigationViewModel` a repository
|
||
holding the placeholder: `UnqueuedProfileSynchronization`. The same account with a
|
||
kind 0 indexed: `ProfileLoaded`. Then the assertion the nsec round trip makes about
|
||
the node, made about the key: **nothing was signed**. The only `UnsignedNostrEvent`
|
||
for the pubkey is the placeholder, still at `GENESIS_AT`; no key package bundle
|
||
row; no broadcast request; `identity.business == null`; `identity.canSign` false.
|
||
|
||
**The upgrade.** From the state above, write the nsec of the same pubkey through
|
||
`writeNostrKey`: `Written` with the same id; the credentials file holds one entry
|
||
for the pubkey and it is `secret`; `signInToProfile` planted nothing new, and the
|
||
account routes to `ProfileLoaded` as before. Re-list: one identity, `NostrSecret`,
|
||
same id, same metadata.
|
||
|
||
---
|
||
|
||
## Phase 8 — rollout
|
||
|
||
**Library first, and on the tag the nsec work still owes.** Phase 2 is a commit
|
||
on the submodule, reaching the app as a pointer bump in the Phase 2 commit. The
|
||
library commit that introduced `nostr-keys.dat` is on the library's `master` — it
|
||
arrived through PR #1 — but is untagged, and the library has no tags at all; the
|
||
nsec plan says it has to be tagged before any of that work leaves the machine, and
|
||
Phase 2 goes out on the same tag, so that no JitPack consumer ever sees the v1 file
|
||
without the reader that migrates it.
|
||
|
||
**Phase 1 ships alone.** A type change with two `require`s and one exhaustive
|
||
`when`; if the release after it behaves differently, the cause is in one diff.
|
||
|
||
**Phases 3 through 5 ship together.** A build with the file but not the screen is
|
||
harmless; a build with the screen but not the file strands the user at
|
||
"Initializing…"; and a build with both but not Phase 5 is the thing this plan is
|
||
for — an identity that looks signed in and offers "New chat". Phase 6 can follow
|
||
by a release: without it a read-only identity has no sign out and no not-found
|
||
exit, which is a dead end but not a lie.
|
||
|
||
**Old builds and the new file.** A build before Phase 3 does not read
|
||
`nostr-credentials.dat` and does not know it exists. If it also predates the nsec
|
||
work it is unaffected in every way. If it is a build *with* `nostr-keys.dat` and
|
||
the migration has run on this device, that file is gone and the build lists no
|
||
nsec identities until the next upgrade — the failure Phase 2 chose over a
|
||
forgotten key coming back, and the one line of this rollout worth a release note.
|
||
`seed.dat` is untouched throughout.
|
||
|
||
## Estimate
|
||
|
||
| phase | work | days | blocked by |
|
||
|---|---|---|---|
|
||
| 1 | a key the identity may not have | 0.5–1 | — |
|
||
| 2 | the credentials file, migration, the writers, the upgrade rule (library + app) | 1.5 | 1 |
|
||
| 3 | listing, starting, the pumps, the DAO guard | 1 | 2 |
|
||
| 4 | the sign-in screen; an idempotent sign-in | 0.5–1 | 3 |
|
||
| 5 | the capability, the inventory, the Messages tab | 1 | 1 to compile, 3 to see |
|
||
| 6 | sign out, and the not-found exit | 0.5 | 2, 5 |
|
||
| 7 | the two round trips | 0.5 | 4, 5, 6 |
|
||
| 8 | rollout | — | all |
|
||
|
||
**Roughly one focused week.** Phase 5 is where the estimate is least reliable:
|
||
the inventory was made by reading, and the compiler will not say whether it is
|
||
complete. The compose tests are how it is checked, and a row found late is a row
|
||
added to both.
|
||
|
||
## Out of scope
|
||
|
||
- **A public feed.** The thing that would make read-only *browsing* rather than
|
||
*previewing*. Its own plan, and when it lands the four decisions under
|
||
[the one decision](#the-one-decision-to-make-first) are the places to revisit.
|
||
- **NIP-46 remote signer, NIP-55 Android signer.** This plan builds half of what
|
||
they need — an identity whose secret is not on `Identity` — and none of the
|
||
other half, a signing interface the notary calls instead of a `NostrSignerSync`.
|
||
`LocalCanSign` is a Boolean on purpose: a remote signer answers it *true* —
|
||
which is why `canSign` is a property of the identity and not `nostrPrivateKey != null`
|
||
repeated at each site — and the question it adds is "can it derive?": the
|
||
ChillDKG host key needs the raw bytes, and no signer protocol gives them. A
|
||
second capability, not a third value of this one.
|
||
- **NIP-05 sign-in.** `alice@example.com` resolves to a pubkey with one HTTPS
|
||
request, and the app already accepts a NIP-05 for starting a message. It is a
|
||
natural fourth row in Phase 4's table, and it is a network round trip on a form
|
||
field, which is why `EditGroupCuratedSchemaViewModel.pubkeyOrNull` declined it.
|
||
A cheap follow-on once the npub path is proven.
|
||
- **NIP-49 `ncryptsec`.** Unchanged from the nsec plan.
|
||
- **Setting up a profile for a public key.** Impossible: the kind 0 has to be
|
||
signed. Said here so that nobody looks for the branch Phase 5 hid.
|
||
- **Reading a group's curated lists from outside it.** Public events, but every
|
||
path to them goes through a room today. The feed problem in a smaller shape.
|
||
- **Tightening secrets in memory.** Unchanged, and this plan adds no secret to
|
||
tighten.
|
||
|
||
## Appendix — what was considered and rejected
|
||
|
||
### A plaintext sibling file for public keys
|
||
|
||
This plan's first design: `nostr-public-keys.json` beside the two `.dat` files,
|
||
unencrypted because a public key is public, written through the same atomic
|
||
helper, and app-side — no library change, no tag. It was rejected in review, and
|
||
rightly. The upgrade became two writes across two files with a crash window
|
||
between them and a "secret wins" rule in `merge` to repair the window; forget
|
||
became two paths; and the advantage that paid for all of that was worth less than
|
||
it looked, because the library commit that introduced `nostr-keys.dat` is itself
|
||
untagged, so a credentials format rides the tag that work already owes. One file,
|
||
one entry per pubkey, one write.
|
||
|
||
### A sentinel entry in the version-1 format
|
||
|
||
An empty-string value, or a zero key, under the pubkey, in `nostr-keys.dat` as it
|
||
is. Rejected because the file's contract is the reason it is trustworthy:
|
||
`EncryptedNostrKeys` refuses an entry whose key does not derive its pubkey, and a
|
||
sentinel is exactly such an entry. A typed credential is what a sentinel is trying
|
||
to be, and the file's doc had already reserved a version for it.
|
||
|
||
### Bumping the version in place
|
||
|
||
Version byte 2 in `nostr-keys.dat`, with the typed entries. Rejected in
|
||
[Phase 2](#why-the-file-is-renamed-rather-than-versioned-in-place): an older
|
||
build's `listIdentities` returns on `SerializationError` before it publishes
|
||
anything, so the old build would list no identities at all, seed wallets included.
|
||
A new file name is invisible to a build that does not know it.
|
||
|
||
### A fake private key
|
||
|
||
Give a read-only identity a random `PrivateKey` so that nothing has to become
|
||
nullable. The notary would sign as a key nobody follows, the DAO would try to
|
||
unseal wraps with it, the key package bundle would be published under it, and
|
||
every one of those would look, in the logs, like success. The nsec plan rejected
|
||
the fake `LocalKeyManager` for the same reason: a real object in a state its
|
||
authors never intended. Nullable is honest.
|
||
|
||
### Gating on the kind at each site
|
||
|
||
`if (identity.kind == IdentityKind.NostrPublic) hide()`. Works today, and is the
|
||
wrong question: the site wants to know whether it can sign. When a remote signer
|
||
arrives — the nsec plan already names it — every such `if` is a bug, and the
|
||
compiler will point at none of them.
|
||
|
||
### Hiding the Messages tab
|
||
|
||
A read-only identity lands on its profile and the bar has two items. Rejected: the
|
||
bar is the navigation, M3 gives a navigation bar three to five destinations and the
|
||
conformance work made an IA decision to reach three, and the empty state is where
|
||
the upgrade is offered. An identity that cannot find the way to become a signing
|
||
one is a preview of nothing.
|
||
|
||
### Running all four pumps regardless
|
||
|
||
Let the broadcast pump idle and the live subscriptions fetch what they fetch; the
|
||
DAO guard keeps the wraps. Rejected for what it costs the user for nothing:
|
||
`GiftWrapMessage` rows that will never be opened, placeholder profiles for their
|
||
senders, profile syncs for each, and a socket held open for an inbox that cannot
|
||
be read. The pumps that run are the ones with something to do.
|
||
|
||
### A Room table for the list
|
||
|
||
`ReadOnlyIdentity(publicKey)`, listed by the repository. Rejected because the
|
||
startup listing is built from the key stores by a view model that has no
|
||
repository, and because the nsec plan's listing test exists to catch two stores
|
||
disagreeing about what an id is; a store in a different layer, read at a different
|
||
time, is a second opinion where the credentials file is meant to be the only one.
|