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