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 <noreply@anthropic.com>
Pulled-From: curated/curated@bfad1f39a2
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-13 12:03:15 +02:00
1 parent 6532bfc45f
commit 4c04d3b12b
10 files changed
+214 -49

No files matched your search

@@ -264,7 +264,6 @@
<string name="this_will_be_shown_when_people_open_your">This will be shown when people open your profile.</string>
<string name="this_will_be_the_display_name_for_this_chat">This will be the display name for this chat room.</string>
<string name="this_will_be_the_display_name_for_your">This will be the display name for your profile and also important for search.</string>
<string name="this_will_give_you_read_only_access_to_the">This will give you read only access to the profile.</string>
<string name="read_only">Read only</string>
<string name="this_will_give_you_write_access_to_the">This will give you write access to the profile.</string>
<string name="torch_will_be_broadcast_what_you_publish_to">Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised.</string>
@@ -649,13 +648,13 @@
<string name="copied_the_raw_json">Copied the raw JSON</string>
<!-- Sign in: docs/nsec-sign-in.md, phase 4. -->
<string name="a_password_protected_key_ncryptsec_is_not">A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec.</string>
<string name="an_npub_is_a_public_key_mantra_needs_the">An npub is a public key. Mantra needs the secret key, the nsec, to sign as you.</string>
<string name="enter_a_recovery_phrase_or_an_nsec_to_sign_in">Enter a recovery phrase or an nsec to sign in.</string>
<string name="paste">Paste</string>
<string name="recognised_as_a_nostr_secret_key_no_wallet">Recognised as a nostr secret key. No wallet comes with it.</string>
<string name="recognised_as_a_recovery_phrase_it_also">Recognised as a recovery phrase. It also restores the wallet.</string>
<string name="recovery_phrase_or_nsec">Recovery phrase or nsec</string>
<string name="sign_in_with_a_recovery_phrase_or_an_nsec">Sign in with a recovery phrase or an nsec</string>
<string name="recognised_as_a_public_key_you_will_see">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.</string>
<string name="recovery_phrase_nsec_or_npub">Recovery phrase, nsec or npub</string>
<string name="sign_in_with_a_recovery_phrase_an_nsec_or">Sign in with a recovery phrase, an nsec, or an npub to look around</string>
<string name="signed_in">Signed in</string>
<string name="signing_you_in">Signing you in…</string>
<string name="that_is_not_a_recovery_phrase_or_a_nostr_key">That is not a recovery phrase or a nostr key.</string>
@@ -664,7 +663,7 @@
<string name="the_key_could_not_be_saved_on_this_device">The key could not be saved on this device.</string>
<string name="the_words_or_the_key_never_leave_this_device">Whatever you paste stays on this device. Mantra checks it, shows you the profile it opens, and writes nothing until you confirm.</string>
<string name="this_key_is_already_on_this_device">This key is already on this device.</string>
<string name="twelve_words_or_nsec1">Twelve words, or nsec1…</string>
<string name="twelve_words_nsec1_or_npub1">Twelve words, nsec1… or npub1…</string>
<string name="use_a_different_key">Use a different key</string>
<string name="you_are_signing_in_as">You are signing in as</string>
<!-- Sign in: the search that found nothing, docs/nsec-sign-in.md, phase 5. -->
@@ -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(
@@ -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=<redacted>)"
}
/** 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()))
}
}
@@ -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
@@ -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<String>,
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 = {},
@@ -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,
@@ -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<String>,
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<String>,
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
@@ -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<MantraDatabase>()
)
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")
}
}
@@ -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<SignInCredential.NostrPublicKey>(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<SignInCredential.NostrSecret>(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
@@ -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()