Files
mantra-kmp/docs/npub-sign-in.md
Kgothatso Ngako 89e364c1bd chore(library): base the credentials branch on the library's master
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
2026-09-13 12:03:15 +02:00

54 KiB
Raw Blame History

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

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

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 one branch from accepting it
signInToProfile(pubkey) plants the GENESIS_AT placeholder from a pubkey alone DatabaseNostrRepository.kt:258 built; needs one property, see Phase 4
the state machine from there — UnqueuedProfileSynchronization → UnsyncedProfile → UnindexedProfile → ProfileLoaded — reads identity.nostrPublicKey and never a key 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
RelaysSocketManager follows the identity's relay list by pubkey RelaysSocketManager.kt:70 works unchanged
the notary returns before doing anything when there is no key NotaryViewModel.kt:72 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 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 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, 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).

The pumps are gated on the private key. SynchronizationViewModel runs its four collectors inside identity?.nostrPrivateKey?.let { … } (SynchronizationViewModel.kt:194). 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), and the throw takes the event with it. Worse, and not obvious: decryptGiftWrapSeal builds KeyPair(privKey = keyPair.privKey) (GiftWrapMessage.kt:126), 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

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 requires 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 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, 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), 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 whens 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 requires 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 — 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.

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

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

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

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

SovereignWalletStartupScreen gains its third branch, beside the nsec one (SovereignWalletStartupScreen.kt:185):

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

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:

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

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

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

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

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

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
HomeScreen ChatRoomListViewModel.initiate queues the inbox sync and the MLS negentropy not composed, so not queued
MetadataEventDetail follow, unfollow, follow back (:215) a kind 3 hidden; the "follows you" state still shows
MetadataEventDetail send message (:303) 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) key packages hidden
ActiveProfileScreen key recovery (:286) nothing, but there is nothing to recover hidden
ActiveProfileScreen sign out (:341) — real, see Phase 6
ShareProfileScreen re-broadcast (:191) 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) 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:

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