From 4c04d3b12bfa83325e054b1a8fba8b2525070a25 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 20:32:54 +0200 Subject: [PATCH] feat(sign-in): an npub, recognised, confirmed as read-only, then written Phase 4 of docs/npub-sign-in.md. The field accepts a third thing. CredentialParser recognises npub1... and nostr:npub1... as SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix that name a point on the curve -- the counterpart of the isValid() a secret is checked with, since not every x coordinate has a point above it. PublicKeyOnly goes, and its string with it; an npub that does not decode, or names no point, is InvalidKey. Hex stays a secret: a private key and an x-only public key are the same size, the confirm step shows the derived npub so a public key pasted as hex is visible for what it is, and guessing would never fire when it mattered, since an x coordinate is almost always also a valid scalar. The test pins the vector's own public key, pasted as hex, landing on a different npub. The confirm step's third arm says what the user is about to get and not get, once, before the choice: this profile and the people it follows; messages closed and nothing sent; paste the nsec later to open it. The landing caption, the field's label and its placeholder name the third input, the last with "to look around" for a user who does not know the word read-only. The view model takes the third writer the way it takes the other two and commits through one shared write-and-map. signInToProfile is idempotent by pubkey. Until now nothing called it twice for one key -- the writers refused a second sign-in before it got there. Pasting the nsec of a key held read-only 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. A repository test signs in twice and counts one. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@bfad1f39a2030ccf52cee7bf390bb9d68d33e016 --- .../composeResources/values/strings.xml | 9 ++-- .../repository/DatabaseNostrRepository.kt | 11 ++++ .../compose/identity/CredentialParser.kt | 52 +++++++++++++----- .../compose/ui/composable/LandingScreen.kt | 4 +- .../compose/ui/composable/SignInScreen.kt | 25 ++++++--- .../ui/composable/navigation/MantraNavHost.kt | 1 + .../ui/view/model/SignInToProfileViewModel.kt | 52 ++++++++++-------- .../repository/SignInToProfileTwiceJvmTest.kt | 54 +++++++++++++++++++ .../identity/CredentialParserJvmTest.kt | 32 +++++++++-- .../model/SignInToProfileViewModelJvmTest.kt | 23 ++++++++ 10 files changed, 214 insertions(+), 49 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6c2c5394..1f3907b6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -264,7 +264,6 @@ This will be shown when people open your profile. This will be the display name for this chat room. This will be the display name for your profile and also important for search. - This will give you read only access to the profile. Read only This will give you write access to the profile. Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. @@ -649,13 +648,13 @@ Copied the raw JSON A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. - An npub is a public key. Mantra needs the secret key, the nsec, to sign as you. Enter a recovery phrase or an nsec to sign in. Paste Recognised as a nostr secret key. No wallet comes with it. Recognised as a recovery phrase. It also restores the wallet. - Recovery phrase or nsec - Sign in with a recovery phrase or an nsec + 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. + Recovery phrase, nsec or npub + Sign in with a recovery phrase, an nsec, or an npub to look around Signed in Signing you in… That is not a recovery phrase or a nostr key. @@ -664,7 +663,7 @@ The key could not be saved on this device. Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm. This key is already on this device. - Twelve words, or nsec1… + Twelve words, nsec1… or npub1… Use a different key You are signing in as diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index d9218c2b..44c45d97 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -258,6 +258,17 @@ class DatabaseNostrRepository( override suspend fun signInToProfile( publicKey: HexKey ) { + // Idempotent by pubkey. Until the read-only kind, nothing called this twice for + // one key -- the writers refused a second sign-in before it got here. Pasting the + // nsec of a key held read-only 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. + if (database.unsignedNostrEventDao().getLocalAccounts().any { it.unsignedNostrEvent?.pubKey == publicKey }) { + logger.d("signInToProfile: $publicKey already has an account here, nothing to plant") + return + } + val profileEventTemplate = MetadataEvent.createNew() saveUnsignedNostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt index ffa2f11c..b8560fde 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt @@ -2,16 +2,18 @@ package press.mantra.compose.identity import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 +import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.MnemonicCode import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.XonlyPublicKey import fr.acinq.phoenix.managers.nostrPublicKeyHex import fr.acinq.phoenix.utils.MnemonicLanguage /** * What a user pasted into the sign-in field, once it has been recognised. * - * Both carry the nostr public key, because the confirm step shows it and the duplicate - * check compares it. Neither prints its secret. + * All three carry the nostr public key, because the confirm step shows it and the + * duplicate check compares it. None prints a secret. */ sealed interface SignInCredential { val nostrPublicKey: HexKey @@ -31,6 +33,11 @@ sealed interface SignInCredential { ) : SignInCredential { override fun toString(): String = "NostrSecret(npub=$nostrPublicKey, key=)" } + + /** A bare nostr public key. Nothing can be signed with it: a profile to look at. */ + data class NostrPublicKey( + override val nostrPublicKey: HexKey, + ) : SignInCredential } /** Why an input was not accepted. The screen turns each into a sentence. */ @@ -44,12 +51,9 @@ enum class CredentialProblem { /** Words, but the checksum or the wordlist says no: a wrong word, or one out of order. */ InvalidPhrase, - /** An nsec or hex that does not decode to a valid secp256k1 secret. */ + /** An nsec or hex that does not decode to a valid secp256k1 secret, or an npub that does not decode to a point on the curve. */ InvalidKey, - /** An npub. A public key cannot sign, and every write path here assumes a key that can. */ - PublicKeyOnly, - /** An ncryptsec (NIP-49). Decrypting one is a follow-on; say so rather than fail opaquely. */ EncryptedKey, @@ -63,11 +67,18 @@ enum class CredentialProblem { /** * Recognises a pasted credential. Pure, so it can be pinned row by row. * - * A user does not choose an input type; they paste what they have. The two accepted - * shapes are unambiguous -- words have spaces, keys do not -- and the two refused - * shapes are named for what they are rather than lumped in with garbage, because a - * user who pasted an npub or an ncryptsec did something reasonable and should be told - * what to do instead. + * A user does not choose an input type; they paste what they have. The three accepted + * shapes are unambiguous -- words have spaces, keys do not, and a bech32 prefix says + * which key -- and the one refused shape is named for what it is rather than lumped in + * with garbage, because a user who pasted an ncryptsec did something reasonable and + * should be told what to do instead. + * + * **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. The confirm step shows the + * derived npub, so a public key pasted as hex is visible for what it is; guessing -- + * "not a valid secret, so try it as a public key" -- would never fire when it mattered, + * since an x coordinate is, with overwhelming probability, also a valid scalar. An npub + * has to arrive as an npub. */ object CredentialParser { @@ -91,7 +102,7 @@ object CredentialParser { // prefix is how a key arrives from a link. val token = text.removePrefix("nostr:").lowercase() return when { - token.startsWith("npub1") -> Result.Refused(CredentialProblem.PublicKeyOnly) + token.startsWith("npub1") -> npub(token) token.startsWith("ncryptsec1") -> Result.Refused(CredentialProblem.EncryptedKey) token.startsWith("nsec1") -> nsec(token) hex64.matches(token) -> secret(runCatching { PrivateKey.fromHex(token) }.getOrNull()) @@ -119,4 +130,21 @@ object CredentialParser { if (privateKey == null || !privateKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex())) } + + /** + * Thirty-two bytes under the `npub` prefix that name a point on the curve -- the + * counterpart of the `isValid()` a secret is checked with, since not every x + * coordinate has a point above it. + */ + private fun npub(token: String): Result { + val bytes = runCatching { + val (hrp, data) = Bech32.decodeBytes(token) + require(hrp == "npub") { "not an npub" } + require(data.size == 32) { "not an x-only key" } + data + }.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey) + val xOnly = XonlyPublicKey(ByteVector32(bytes)) + if (!xOnly.publicKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) + return Result.Recognised(SignInCredential.NostrPublicKey(xOnly.value.toHex())) + } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt index acf73b4d..f061ccc5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt @@ -27,7 +27,7 @@ import mantra.composeapp.generated.resources.keep_the_feed_alive import mantra.composeapp.generated.resources.learn_more import mantra.composeapp.generated.resources.mantra import mantra.composeapp.generated.resources.sign_in -import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_or_an_nsec +import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_an_nsec_or import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent @@ -82,7 +82,7 @@ fun LandingScreen( } Text( - text = stringResource(Res.string.sign_in_with_a_recovery_phrase_or_an_nsec), + text = stringResource(Res.string.sign_in_with_a_recovery_phrase_an_nsec_or), modifier = Modifier.padding(MaterialTheme.spacing.space125), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index e66ecf99..050702ba 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -38,13 +39,13 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.lifecycle.viewmodel.compose.viewModel import mantra.composeapp.generated.resources.Res import mantra.composeapp.generated.resources.a_password_protected_key_ncryptsec_is_not -import mantra.composeapp.generated.resources.an_npub_is_a_public_key_mantra_needs_the import mantra.composeapp.generated.resources.enter_a_recovery_phrase_or_an_nsec_to_sign_in import mantra.composeapp.generated.resources.next import mantra.composeapp.generated.resources.paste import mantra.composeapp.generated.resources.recognised_as_a_nostr_secret_key_no_wallet +import mantra.composeapp.generated.resources.recognised_as_a_public_key_you_will_see import mantra.composeapp.generated.resources.recognised_as_a_recovery_phrase_it_also -import mantra.composeapp.generated.resources.recovery_phrase_or_nsec +import mantra.composeapp.generated.resources.recovery_phrase_nsec_or_npub import mantra.composeapp.generated.resources.sign_in import mantra.composeapp.generated.resources.signed_in import mantra.composeapp.generated.resources.signing_you_in @@ -54,9 +55,10 @@ import mantra.composeapp.generated.resources.that_recovery_phrase_is_not_valid_c import mantra.composeapp.generated.resources.the_key_could_not_be_saved_on_this_device import mantra.composeapp.generated.resources.the_words_or_the_key_never_leave_this_device import mantra.composeapp.generated.resources.this_key_is_already_on_this_device -import mantra.composeapp.generated.resources.twelve_words_or_nsec1 +import mantra.composeapp.generated.resources.twelve_words_nsec1_or_npub1 import mantra.composeapp.generated.resources.use_a_different_key import mantra.composeapp.generated.resources.you_are_signing_in_as +import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.WalletId import org.jetbrains.compose.resources.stringResource @@ -93,6 +95,7 @@ import press.mantra.compose.ui.view.state.SignInToProfileUIState fun SignInToProfileScreen( nostrRepository: NostrRepository, writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -105,6 +108,7 @@ fun SignInToProfileScreen( factory = SignInToProfileViewModel.factory( nostrRepository = nostrRepository, writeNostrKey = writeNostrKey, + writeNostrPublicKey = writeNostrPublicKey, writeRecoveryPhrase = writeRecoveryPhrase, ) ) @@ -191,8 +195,8 @@ private fun InputPrompt(viewModel: SignInToProfileViewModel) { keyboardType = KeyboardType.Ascii, ), textStyle = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace), - label = { Text(text = stringResource(Res.string.recovery_phrase_or_nsec), maxLines = 1) }, - placeholder = { Text(text = stringResource(Res.string.twelve_words_or_nsec1), maxLines = 1) }, + label = { Text(text = stringResource(Res.string.recovery_phrase_nsec_or_npub), maxLines = 1) }, + placeholder = { Text(text = stringResource(Res.string.twelve_words_nsec1_or_npub1), maxLines = 1) }, trailingIcon = { IconButton( onClick = { @@ -260,6 +264,15 @@ private fun Confirm( style = MaterialTheme.typography.bodyMedium, ) } + is SignInCredential.NostrPublicKey -> { + // What the user is about to get and not get, said here and nowhere + // else: this is the one step before the choice is made. + Icon(Icons.Default.Visibility, contentDescription = Decorative) + Text( + text = stringResource(Res.string.recognised_as_a_public_key_you_will_see), + style = MaterialTheme.typography.bodyMedium, + ) + } } } @@ -286,7 +299,6 @@ private fun problemMessage(problem: CredentialProblem): String = stringResource( CredentialProblem.NotRecognised -> Res.string.that_is_not_a_recovery_phrase_or_a_nostr_key CredentialProblem.InvalidPhrase -> Res.string.that_recovery_phrase_is_not_valid_check CredentialProblem.InvalidKey -> Res.string.that_nostr_secret_key_is_not_valid - CredentialProblem.PublicKeyOnly -> Res.string.an_npub_is_a_public_key_mantra_needs_the CredentialProblem.EncryptedKey -> Res.string.a_password_protected_key_ncryptsec_is_not CredentialProblem.AlreadyOnThisDevice -> Res.string.this_key_is_already_on_this_device CredentialProblem.CouldNotWrite -> Res.string.the_key_could_not_be_saved_on_this_device @@ -303,6 +315,7 @@ private fun SignInToProfileScreenPreview() { SignInToProfileScreen( nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, writeNostrKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, + writeNostrPublicKey = { IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys }, writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) }, onNavigateBack = {}, onSignedIn = {}, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5d83c85a..45da90f4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -704,6 +704,7 @@ fun MantraNavHost( SignInToProfileScreen( nostrRepository = databaseNostrRepository, writeNostrKey = { privateKey -> sovereignWalletViewModel.writeNostrKey(privateKey) }, + writeNostrPublicKey = { publicKey -> sovereignWalletViewModel.writeNostrPublicKey(publicKey) }, writeRecoveryPhrase = { words, onWritten, onError -> sovereignWalletViewModel.writeSeed( words, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt index 36993576..82e9a8be 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.bitcoin.PrivateKey import fr.acinq.phoenix.data.WalletId import kotlinx.coroutines.CancellationException @@ -25,14 +26,15 @@ import press.mantra.compose.ui.view.state.SignInToProfileUIState import press.mantra.compose.ui.view.state.form.SignInToProfileFormState /** - * Signs in with a secret the user already has: a recovery phrase or an nsec. + * Signs in with something the user already has: a recovery phrase, an nsec, or -- to + * look and not sign -- an npub. * - * The two writers are injected as functions rather than reached for, so the commit can - * be driven in a test without a key store, and because the recovery-phrase writer is - * `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives a - * state machine of its own. + * The three writers are injected as functions rather than reached for, so the commit + * can be driven in a test without a key store, and because the recovery-phrase writer + * is `SovereignWalletViewModel.writeSeed`, which lives in another view model and drives + * a state machine of its own. * - * What this does not do is decide where the user goes next. It writes the secret, plants + * What this does not do is decide where the user goes next. It writes the credential, plants * the placeholder account, and hands the new id to [onSignedIn]; the nav host re-lists * identities, selects that one and goes to startup, which is the same tail the create * flow uses. The **order** inside [commit] is load-bearing: the account has to be in the @@ -43,6 +45,7 @@ import press.mantra.compose.ui.view.state.form.SignInToProfileFormState class SignInToProfileViewModel( private val nostrRepository: NostrRepository, private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + private val writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, private val writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -57,6 +60,7 @@ class SignInToProfileViewModel( fun factory( nostrRepository: NostrRepository, writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrCredentialResult, + writeNostrPublicKey: suspend (HexKey) -> IdentityWriter.WriteNostrCredentialResult, writeRecoveryPhrase: ( words: List, onWritten: (WalletId) -> Unit, @@ -67,6 +71,7 @@ class SignInToProfileViewModel( SignInToProfileViewModel( nostrRepository = nostrRepository, writeNostrKey = writeNostrKey, + writeNostrPublicKey = writeNostrPublicKey, writeRecoveryPhrase = writeRecoveryPhrase, ) } @@ -102,25 +107,30 @@ class SignInToProfileViewModel( data class Failed(val problem: CredentialProblem) : Outcome } + private suspend fun write(what: String, writer: suspend () -> IdentityWriter.WriteNostrCredentialResult): Outcome = try { + when (val result = writer()) { + is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) + is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) + is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e("could not write the $what", e) + Outcome.Failed(CredentialProblem.CouldNotWrite) + } + /** - * Writes the credential's secret to its store, then plants the placeholder account - * that tells the navigation machine this pubkey has a history to fetch rather than a - * profile to create. Suspends until both are done or one has failed. + * Writes the credential to its store, then plants the placeholder account that tells + * the navigation machine this pubkey has a history to fetch rather than a profile to + * create. Suspends until both are done or one has failed. The plant is idempotent by + * pubkey, which is what lets the nsec of a key held read-only sign in over it. */ suspend fun commit(credential: SignInCredential): Outcome { val written: Outcome = when (credential) { - is SignInCredential.NostrSecret -> try { - when (val result = writeNostrKey(credential.privateKey)) { - is IdentityWriter.WriteNostrCredentialResult.Written -> Outcome.SignedIn(result.id) - is IdentityWriter.WriteNostrCredentialResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice) - is IdentityWriter.WriteNostrCredentialResult.CannotLoadKeys -> Outcome.Failed(CredentialProblem.CouldNotWrite) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.e("could not write the nostr key", e) - Outcome.Failed(CredentialProblem.CouldNotWrite) - } + is SignInCredential.NostrSecret -> write("nostr key") { writeNostrKey(credential.privateKey) } + + is SignInCredential.NostrPublicKey -> write("nostr public key") { writeNostrPublicKey(credential.nostrPublicKey) } is SignInCredential.RecoveryPhrase -> { // Callback-shaped writer, awaited: the seed writer drives WritingSeedState and diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt new file mode 100644 index 00000000..cd124312 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/repository/SignInToProfileTwiceJvmTest.kt @@ -0,0 +1,54 @@ +package press.mantra.compose.database.repository + +import androidx.room3.Room +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.builder.getRoomDatabase +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Signing in twice plants one account. + * + * Until the read-only kind nothing called `signInToProfile` twice for one pubkey: the + * writers refused a second sign-in before it got there. Pasting the nsec of a key held + * read-only 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. `getLocalAccounts` is every kind-0 row, so the + * count is the assertion. + */ +class SignInToProfileTwiceJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val scope = CoroutineScope(Job()) + private val repository = DatabaseNostrRepository(db, scope) + + private val publicKey = "a".repeat(64) + private val someoneElse = "b".repeat(64) + + @AfterTest + fun closeDb() { + scope.cancel() + db.close() + } + + @Test + fun `a second sign-in for the same key plants nothing, and another key still gets its own account`() = runBlocking { + repository.signInToProfile(publicKey) + repository.signInToProfile(publicKey) + repository.signInToProfile(someoneElse) + + val accounts = repository.getLocalAccounts() + + assertEquals(listOf(publicKey, someoneElse).sorted(), accounts.mapNotNull { it.unsignedNostrEvent?.pubKey }.sorted()) + val placeholder = accounts.first { it.unsignedNostrEvent?.pubKey == publicKey }.unsignedNostrEvent!! + assertEquals(GENESIS_AT, placeholder.signedAt, "the placeholder is what it was: signed at genesis, so the notary skips it") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt index e299b4d3..fd444982 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt @@ -1,8 +1,10 @@ package press.mantra.compose.identity +import press.mantra.compose.extensions.hexToNpubHrp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs +import kotlin.test.assertNotEquals /** * Every row of the sign-in screen's table, in and out. @@ -72,9 +74,33 @@ class CredentialParserJvmTest { } @Test - fun `a public key is named for what it is`() { - assertEquals(CredentialProblem.PublicKeyOnly, refused(npub)) - assertEquals(CredentialProblem.PublicKeyOnly, refused("nostr:$npub")) + fun `an npub, a nostr-prefixed npub and the upper-cased form all name the same public key`() { + for (input in listOf(npub, "nostr:$npub", npub.uppercase())) { + val credential = assertIs(recognised(input), "for '$input'") + assertEquals(publicKey, credential.nostrPublicKey) + } + } + + /** + * Sixty-four hex characters are a secret and stay one: a private key and an x-only + * public key are the same size, and the parser cannot tell them apart -- nor may it + * guess, since an x coordinate is almost always also a valid scalar and the guess + * would never fire. The public key of the vector, pasted as hex, is a *different* + * secret whose npub the confirm step shows. + */ + @Test + fun `sixty-four hex characters are still a secret, even when they are a public key`() { + val credential = assertIs(recognised(publicKey)) + assertNotEquals(publicKey, credential.nostrPublicKey) + } + + @Test + fun `an npub that does not decode, or names no point on the curve, is an invalid key`() { + assertEquals(CredentialProblem.InvalidKey, refused(npub.dropLast(1) + "q")) + // The field prime, bech32-encoded under npub: sixty-four hex characters that are + // not an x coordinate. + val notOnTheCurve = "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f".hexToNpubHrp() + assertEquals(CredentialProblem.InvalidKey, refused(notOnTheCurve)) } @Test diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt index 06baab6d..94c086ef 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/SignInToProfileViewModelJvmTest.kt @@ -31,6 +31,7 @@ class SignInToProfileViewModelJvmTest { private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey) private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, publicKey) + private val nostrPublicKey = SignInCredential.NostrPublicKey(publicKey) /** Records the order of effects; throws on anything the sign-in should not touch. */ private class Recorder : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { @@ -43,13 +44,35 @@ class SignInToProfileViewModelJvmTest { private fun viewModel( recorder: Recorder, nostrKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, + publicKeyOutcome: () -> IdentityWriter.WriteNostrCredentialResult = { IdentityWriter.WriteNostrCredentialResult.Written(id) }, seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) }, ) = SignInToProfileViewModel( nostrRepository = recorder, writeNostrKey = { recorder.effects += "writeNostrKey"; nostrKeyOutcome() }, + writeNostrPublicKey = { recorder.effects += "writeNostrPublicKey"; publicKeyOutcome() }, writeRecoveryPhrase = { _, onWritten, onError -> recorder.effects += "writeRecoveryPhrase"; seedOutcome(onWritten, onError) }, ) + @Test + fun `a public key is written, then its account is planted, then the id comes back`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder).commit(nostrPublicKey) + + assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome) + assertEquals(listOf("writeNostrPublicKey", "signInToProfile:$publicKey"), recorder.effects) + } + + @Test + fun `the npub of a key already here is refused, and no account is planted`() = runBlocking { + val recorder = Recorder() + + val outcome = viewModel(recorder, publicKeyOutcome = { IdentityWriter.WriteNostrCredentialResult.AlreadyExists }).commit(nostrPublicKey) + + assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome) + assertEquals(listOf("writeNostrPublicKey"), recorder.effects) + } + @Test fun `an nsec is written, then its account is planted, then the id comes back`() = runBlocking { val recorder = Recorder()