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,