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 Ngako
2026-09-12 20:32:54 +02:00
parent 6532bfc45f
commit 4c04d3b12b
10 changed files with 214 additions and 49 deletions

View File

@@ -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. -->

View File

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

View File

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

View File

@@ -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

View File

@@ -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 = {},

View File

@@ -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,

View File

@@ -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