From cf43973b3657735cd7fd95ad6b2224a248bf7e26 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:14:29 +0200 Subject: [PATCH] feat(identity): a key the identity may not have Phase 1 of docs/npub-sign-in.md. A type change and nothing a user can see. Identity.nostrPrivateKey becomes nullable and nostrPublicKey becomes a constructor field, because the kind this plan adds -- NostrPublic, a bare public key -- has nothing to derive a pubkey from. A data class would let the two disagree, so two checks in init refuse an identity whose kind and key disagree about whether there is one, and one whose key does not derive the public key it was given. Identity.signing derives the pubkey and refuses the read-only kind; Identity.readOnly builds the other; nothing else constructs one now. The id of a read-only identity is toWalletId() of the x-only key -- the same id its nsec would have -- which is what will let a read-only identity become a signing one and keep its preferences. canSign is a property of the identity rather than nostrPrivateKey != null at each site, so that a signer with no local key has one place to answer. Every reader of the key already reached it through ?. on a nullable identity, so the compiler forces nothing here; the sites that need a decision are the plan's Phase 5, found by reading. The one site that did change is KeyRecoveryScreen, whose two whens branched on NostrSecret with an else that meant "mnemonic": both are exhaustive now, so the new kind is not handed the phrase option by default. Tests: NavigationIdentityRoutingJvmTest gains a read-only fixture with the same id and pubkey as the secret one, the routing case for it (by its account, like any other), and the three refusals plus the factory's own. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@15766596e463d4c7f57b8a636ac9be9eb83b1272 --- .../press/mantra/compose/identity/Identity.kt | 94 ++++++++++++-- .../ui/composable/KeyRecoveryScreen.kt | 22 +++- .../SovereignWalletStartupScreen.kt | 2 +- .../ui/view/model/SovereignWalletViewModel.kt | 2 +- .../identity/NsecRestoreRoundTripJvmTest.kt | 2 +- .../model/NavigationIdentityRoutingJvmTest.kt | 118 +++++++++++++++++- .../view/model/NostrSecretViewModelJvmTest.kt | 2 +- 7 files changed, 221 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt index 7a920d38..9ed2737e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/Identity.kt @@ -7,6 +7,7 @@ import fr.acinq.bitcoin.XonlyPublicKey import fr.acinq.bitcoin.byteVector import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.WalletId +import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.utils.preferences.InternalPrefs import fr.acinq.phoenix.utils.preferences.UserPrefs @@ -16,7 +17,8 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs * The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at * `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the * key and a wallet; a bare key yields nothing further, so an identity made from - * one can never grow a node behind it. See docs/nsec-sign-in.md. + * one can never grow a node behind it; and a bare public key yields nothing at all, + * not even a signature. See docs/nsec-sign-in.md and docs/npub-sign-in.md. */ enum class IdentityKind { /** Twelve words. The nostr key is derived from them, and so is a wallet. */ @@ -24,10 +26,14 @@ enum class IdentityKind { /** A bare nostr secret. Nothing else can be derived from it. */ NostrSecret, + + /** A bare nostr public key. Nothing can be signed, opened or derived from it. */ + NostrPublic, } /** - * The signing identity the app is running as. + * The signing identity the app is running as -- or, for [IdentityKind.NostrPublic], + * the identity it is looking at. * * This is what every consumer of "the active user" reads, in place of reaching * through the wallet -- `business.walletManager.keyManager.value.nostrPrivateKey()` @@ -37,32 +43,96 @@ enum class IdentityKind { * Marmot bundle password is `hash160` of it. None of them can tell where it came * from, and after this type none of them need to. * + * [nostrPublicKey] is a field rather than a derivation because a read-only identity + * has nothing to derive it from. The two checks in `init` keep the field and the key + * from disagreeing, which a data class would otherwise happily allow; [signing] and + * [readOnly] are the two ways to get one built without tripping them. + * * @param id keys the per-identity preference files and the wallet metadata. For a * [IdentityKind.Mnemonic] identity it is the hash160 of the node id, as it always - * was; for a [IdentityKind.NostrSecret] identity it is the hash160 of the x-only - * public key ([toWalletId]). Same shape, so nothing downstream can tell the kinds - * apart by the id -- the kind is here, where it can be asked. + * was; for the two bare kinds it is the hash160 of the x-only public key + * ([toWalletId]) -- the *same* id whether the device holds the secret or only the + * public key, which is what lets a read-only identity become a signing one and keep + * its preferences. Same shape as a wallet's, so nothing downstream can tell the + * kinds apart by the id -- the kind is here, where it can be asked. + * @param nostrPrivateKey null only for [IdentityKind.NostrPublic]. * @param business the running node. Non-null only for [IdentityKind.Mnemonic]. */ data class Identity( val id: WalletId, val kind: IdentityKind, - val nostrPrivateKey: PrivateKey, - val userPrefs: UserPrefs, - val internalPrefs: InternalPrefs, - val business: PhoenixBusiness?, -) { /** - * X-only, hex -- the form every nostr call site wants. Computed once. + * X-only, hex -- the form every nostr call site wants. * * Not `publicKey().toHex()`: that is the 33-byte compressed encoding, sixty-six * hex characters, and it is what the library's own `nostrPublicKey()` returns. * Nostr keys are the 32-byte x coordinate. */ - val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex() + val nostrPublicKey: HexKey, + val nostrPrivateKey: PrivateKey?, + val userPrefs: UserPrefs, + val internalPrefs: InternalPrefs, + val business: PhoenixBusiness?, +) { + /** + * What the notary, the pumps and every write entrance ask. + * + * A property of the identity rather than `nostrPrivateKey != null` repeated at each + * site, so that a signer that holds no local key -- a remote one, some day -- has + * one place to answer *true*. + */ + val canSign: Boolean get() = nostrPrivateKey != null + + init { + require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) { + "identity kind $kind and its key disagree about whether there is one" + } + require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) { + "identity key does not derive the public key it was given" + } + } /** The key must never reach a log. `data class` would print it. */ override fun toString(): String = "Identity(id=$id, kind=$kind, key=)" + + companion object { + /** An identity that holds its secret: the public key is derived, never supplied. */ + fun signing( + id: WalletId, + kind: IdentityKind, + nostrPrivateKey: PrivateKey, + userPrefs: UserPrefs, + internalPrefs: InternalPrefs, + business: PhoenixBusiness?, + ): Identity { + require(kind != IdentityKind.NostrPublic) { "a read-only identity has no secret to sign with" } + return Identity( + id = id, + kind = kind, + nostrPublicKey = nostrPrivateKey.nostrPublicKeyHex(), + nostrPrivateKey = nostrPrivateKey, + userPrefs = userPrefs, + internalPrefs = internalPrefs, + business = business, + ) + } + + /** An identity the device holds only the public key of. No secret, no node. */ + fun readOnly( + id: WalletId, + nostrPublicKey: HexKey, + userPrefs: UserPrefs, + internalPrefs: InternalPrefs, + ): Identity = Identity( + id = id, + kind = IdentityKind.NostrPublic, + nostrPublicKey = nostrPublicKey, + nostrPrivateKey = null, + userPrefs = userPrefs, + internalPrefs = internalPrefs, + business = null, + ) + } } /** diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index b70a798c..551b594b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -129,10 +129,17 @@ fun KeyRecoveryScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - // A bare-key identity has no coins to speak of. + // A bare-key identity has no coins to speak of. Exhaustive rather than + // `else`: the `else` here used to mean "mnemonic", and a kind added later + // would have fallen into it and been promised coins. A read-only identity + // is never offered this screen (docs/npub-sign-in.md, Phase 5); it reads + // as the bare-key kind so that, if it ever is, the sentence is at least + // not about a wallet. text = when (activeIdentity?.kind) { - IdentityKind.NostrSecret -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet) - else -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they) + IdentityKind.NostrSecret, + IdentityKind.NostrPublic -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet) + IdentityKind.Mnemonic, + null -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they) }, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center @@ -174,7 +181,14 @@ fun KeyRecoveryScreen( } ) - else -> KeyRecoveryOption( + // Nothing to recover: the device holds no secret for this identity. The + // row that leads here is hidden for the kind, so this arm is not reached; + // it exists so that the `when` says so rather than handing a read-only + // identity the phrase option through an `else`. + IdentityKind.NostrPublic -> Unit + + IdentityKind.Mnemonic, + null -> KeyRecoveryOption( icon = Icons.Default.Spellcheck, title = stringResource(Res.string.recovery_phrase), description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 9bd960f7..6a4ab6e1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -189,7 +189,7 @@ fun SovereignWalletStartupScreen( // recomposes into the `activeIdentity != null` branch // above, which is what reports the startup. sovereignWalletViewModel.setActiveIdentity( - Identity( + Identity.signing( id = identity.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = identity.privateKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 9997f884..5f6e36ee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -158,7 +158,7 @@ class SovereignWalletViewModel( val dataStoreManager = DataStoreManager(business) setActiveIdentity( - Identity( + Identity.signing( id = walletId, kind = IdentityKind.Mnemonic, nostrPrivateKey = keyManager.nostrPrivateKey(), diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt index 01c30bc6..97f23166 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/NsecRestoreRoundTripJvmTest.kt @@ -126,7 +126,7 @@ class NsecRestoreRoundTripJvmTest { // 3. What the startup screen's NostrSecret branch does: an identity, no node. val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain) - val identity = Identity( + val identity = Identity.signing( id = stored.id, kind = IdentityKind.NostrSecret, nostrPrivateKey = stored.privateKey, diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt index ce80da88..d49b2187 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NavigationIdentityRoutingJvmTest.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PrivateKey +import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.utils.preferences.InternalPrefs import fr.acinq.phoenix.utils.preferences.UserPrefs import kotlinx.coroutines.CoroutineScope @@ -31,6 +32,10 @@ import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStart import press.mantra.compose.ui.view.state.NavigationUIState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Instant /** @@ -65,7 +70,7 @@ class NavigationIdentityRoutingJvmTest { temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() } - private fun nostrSecretIdentity() = Identity( + private fun nostrSecretIdentity() = Identity.signing( id = privateKey.publicKey().xOnly().toWalletId(), kind = IdentityKind.NostrSecret, nostrPrivateKey = privateKey, @@ -74,6 +79,14 @@ class NavigationIdentityRoutingJvmTest { business = null, ) + /** The same key, held as a public key only: same id, no secret, no node. */ + private fun readOnlyIdentity() = Identity.readOnly( + id = privateKey.publicKey().xOnly().toWalletId(), + nostrPublicKey = privateKey.nostrPublicKeyHex(), + userPrefs = UserPrefs(prefsStore("user")), + internalPrefs = InternalPrefs(prefsStore("internal")), + ) + /** Everything `observeProfile` reads, and nothing else; every other member throws. */ private class Device( private val accounts: List @@ -176,4 +189,107 @@ class NavigationIdentityRoutingJvmTest { viewModel.navigationUIState.value, ) } + + /** + * A read-only identity is the same identity minus the secret: same public key, same + * id, and the machine routes it by its account exactly as it routes the nsec. + */ + @Test + fun `a read-only identity has the id and public key its secret would, and cannot sign`() { + val readOnly = readOnlyIdentity() + val signing = nostrSecretIdentity() + + assertEquals(signing.nostrPublicKey, readOnly.nostrPublicKey) + assertEquals(signing.id, readOnly.id) + assertNull(readOnly.nostrPrivateKey) + assertNull(readOnly.business) + assertFalse(readOnly.canSign) + assertTrue(signing.canSign) + assertEquals(IdentityKind.NostrPublic, readOnly.kind) + } + + @Test + fun `a read-only identity is routed by its account, like any other`() { + val identity = readOnlyIdentity() + val scope = CoroutineScope(Job()) + val viewModel = NavigationViewModel( + activeIdentityStateFlow = MutableStateFlow(identity), + initialNavigationUIState = stranded, + nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))), + scope = scope, + ) + + try { + val destination = runBlocking { + withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } } + } + + assertEquals( + NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey), + destination, + "a read-only identity was not routed by its account", + ) + } finally { + scope.cancel() + } + } + + /** + * The two checks that make [Identity.nostrPublicKey] safe to be a field. Without + * them a data class would accept a key filed under someone else's public key, or a + * "read-only" identity that could in fact sign. + */ + @Test + fun `an identity whose kind and key disagree is refused, and so is one whose key does not derive its public key`() { + val prefs = UserPrefs(prefsStore("user")) + val internal = InternalPrefs(prefsStore("internal")) + + // A secret filed under a public key it does not derive. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPublicKey = "a".repeat(64), + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // A read-only kind that nonetheless carries a key. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrPublic, + nostrPublicKey = privateKey.nostrPublicKeyHex(), + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // A signing kind with no key to sign with. + assertFailsWith { + Identity( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrSecret, + nostrPublicKey = privateKey.nostrPublicKeyHex(), + nostrPrivateKey = null, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + // And the factory refuses to build a "signing" read-only identity at all. + assertFailsWith { + Identity.signing( + id = privateKey.publicKey().xOnly().toWalletId(), + kind = IdentityKind.NostrPublic, + nostrPrivateKey = privateKey, + userPrefs = prefs, + internalPrefs = internal, + business = null, + ) + } + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt index a6bd120c..1e8c8de6 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/NostrSecretViewModelJvmTest.kt @@ -68,7 +68,7 @@ class NostrSecretViewModelJvmTest { temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath() } - private fun identity() = Identity( + private fun identity() = Identity.signing( id = privateKey.publicKey().xOnly().toWalletId(), kind = IdentityKind.NostrSecret, nostrPrivateKey = privateKey,