feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written

Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence
about alpha testing and becomes the screen: one field, two things it accepts,
and the npub it signs as shown before anything is written.

One field, because a user does not choose an input type -- they paste what they
have -- and the two shapes are unambiguous: words have spaces, keys do not.
CredentialParser is that decision as a pure function. Words go through
MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed
nsec or sixty-four hex characters decode to a PrivateKey and must pass
isValid(). The two shapes a reasonable person might paste and that cannot
work are refused by name rather than lumped in with garbage: an npub is
PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey
(NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is,
and a capital is a paste artefact worth showing rather than silently fixing.

Problems with the input stay on the field, as supporting text, while the
prompt is still showing; problems after confirming -- the key was already
here, the write failed -- are a state of the screen, through ErrorState with
a retry back to the field. Four states, wrapped in ScreenStateTransition.

Confirm shows the npub in full, in monospace, and which kind was recognised,
with the one consequence that differs: a recovery phrase also restores the
wallet, a bare key comes with none. The secret itself is never echoed.

SignInToProfileViewModel.commit is the effect, and its order is the point.
The secret is written to its store first -- IdentityWriter.writeNostrKey for
an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a
phrase, both injected as functions so the commit can be driven without a key
store -- and only on success is the placeholder account planted with
nostrRepository.signInToProfile. The placeholder before the write would send
the user to fetch a profile they can never sign for; the write before the
placeholder is what the nav host then does. Both writers already refuse a
duplicate by nostr public key, so a wallet's own nostr secret pasted as an
nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare.

The nav host wires the route with the same tail the create flow uses after
writeSeed: re-list identities, select the new one, go to startup with the form
popped, so back does not return to a field holding a secret. Startup finds
the identity, activates it, and NavigationViewModel takes it from the
placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from
both entrances.

Strings: nineteen new, sentence case, including the refusals as sentences
that say what to do instead. The six Torch-era sign-in strings nothing
referenced any more are gone, and the landing caption no longer promises a
remote signer this does not deliver.

Tests. CredentialParserJvmTest is every row of the table in and out, all
three spellings of NIP-06's vector -- words, nsec, hex, with and without the
nostr: prefix, in either case -- asserted to land on the same public key,
which is the equality the duplicate check rests on; and each refusal by name.
SignInToProfileViewModelJvmTest records the effects of a commit and asserts
their order and count: write then plant for both kinds, and no planting after
a refusal or a throw.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885
tests) and :composeApp:m3Audit, which found no spacing literal or title-case
string in the new screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@220616019e
This commit is contained in:
Kgothatso Ngako
2026-09-12 12:05:35 +02:00
parent 24ca50ca48
commit 2e31daf117
10 changed files with 834 additions and 90 deletions

View File

@@ -91,7 +91,6 @@
<string name="encrypt_and_back_your_recovery_information">Encrypt and back your recovery information up to your Google Drive or iCloud.</string>
<string name="enter_the_name_you_want_to_use_for_your">Enter the name you want to use for your group</string>
<string name="enter_the_name_you_want_to_use_for_your_2">Enter the name you want to use for your profile</string>
<string name="enter_the_nsec_or_npub_read_only_that_you">Enter the nsec or npub (read only) that you want to sign in as</string>
<string name="enter_the_translation_for_this_chunk">Enter the translation for this chunk</string>
<string name="events_are_indexed_so_that_we_can_deliver_a">Events are indexed so that we can deliver a premium local first experience.</string>
<string name="everything_else">Everything else</string>
@@ -168,7 +167,6 @@
<string name="no_versions">No versions.</string>
<string name="not_now">Not now</string>
<string name="nothing_was_created_and_no_key_exists_it_is">Nothing was created and no key exists. It is safe to run it again.</string>
<string name="nsec_npub_nip_05_static_address">nsec, npub, nip-05 (static address)</string>
<string name="open_chat">Open chat</string>
<string name="original">Original</string>
<string name="original_text">Original text</string>
@@ -228,10 +226,6 @@
<string name="this_device_does_not_hold_the_event">This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature.</string>
<string name="sign">Sign</string>
<string name="sign_in">Sign in</string>
<string name="sign_in_is_not_yet_available_while_mantra_is">Sign in is not yet available while Mantra is in alpha testing.</string>
<string name="sign_in_to_nsec">Sign in to nsec</string>
<string name="sign_in_to_torch_via_nsec_or_remote_signer">Sign in to Mantra via nsec, or remote signer</string>
<string name="sign_in_with_an_npub">Sign in with an npub</string>
<string name="sign_out">Sign out</string>
<string name="sign_with_the_group_s_key">Sign with the group's key</string>
<string name="signed_their_part">Signed their part</string>
@@ -576,4 +570,24 @@
<string name="content_must_be_text">Content must be text.</string>
<string name="the_content_is_not_a_profile">The content isn't a profile. A kind 0 carries a JSON object of profile fields.</string>
<string name="this_schema_cannot_be_signed_as_it_is">This schema can't be signed as it is:</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="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>
<string name="that_nostr_secret_key_is_not_valid">That nostr secret key is not valid.</string>
<string name="that_recovery_phrase_is_not_valid_check">That recovery phrase is not valid. Check each word and the order.</string>
<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="use_a_different_key">Use a different key</string>
<string name="you_are_signing_in_as">You are signing in as</string>
</resources>

View File

@@ -0,0 +1,122 @@
package press.mantra.compose.identity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import fr.acinq.bitcoin.MnemonicCode
import fr.acinq.bitcoin.PrivateKey
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.
*/
sealed interface SignInCredential {
val nostrPublicKey: HexKey
/** Twelve (or more) words: a wallet, and the nostr key derived from it. */
data class RecoveryPhrase(
val words: List<String>,
override val nostrPublicKey: HexKey,
) : SignInCredential {
override fun toString(): String = "RecoveryPhrase(npub=$nostrPublicKey, words=***)"
}
/** A bare nostr secret. No wallet comes with it. */
data class NostrSecret(
val privateKey: PrivateKey,
override val nostrPublicKey: HexKey,
) : SignInCredential {
override fun toString(): String = "NostrSecret(npub=$nostrPublicKey, key=<redacted>)"
}
}
/** Why an input was not accepted. The screen turns each into a sentence. */
enum class CredentialProblem {
/** Nothing was entered. */
Empty,
/** Not words, not an nsec, not hex: nothing this screen knows how to read. */
NotRecognised,
/** 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. */
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,
/** An identity with this nostr public key is already on the device, as a wallet or a key. */
AlreadyOnThisDevice,
/** The secret could not be written to disk. */
CouldNotWrite,
}
/**
* 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.
*/
object CredentialParser {
sealed interface Result {
data class Recognised(val credential: SignInCredential) : Result
data class Refused(val problem: CredentialProblem) : Result
}
private val hex64 = Regex("[0-9a-f]{64}")
fun parse(raw: String): Result {
val text = raw.trim()
if (text.isEmpty()) return Result.Refused(CredentialProblem.Empty)
// Whitespace anywhere inside means words. Not lower-cased: the wordlist already
// is, and a capitalised word is a paste artefact worth showing rather than fixing.
val words = text.split(Regex("\\s+"))
if (words.size > 1) return recoveryPhrase(words)
// Bech32 is case-insensitive as a whole but rejects mixed case, and a `nostr:`
// 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("ncryptsec1") -> Result.Refused(CredentialProblem.EncryptedKey)
token.startsWith("nsec1") -> nsec(token)
hex64.matches(token) -> secret(runCatching { PrivateKey.fromHex(token) }.getOrNull())
else -> Result.Refused(CredentialProblem.NotRecognised)
}
}
private fun recoveryPhrase(words: List<String>): Result = try {
MnemonicCode.validate(mnemonics = words, wordlist = MnemonicLanguage.English.wordlist())
Result.Recognised(SignInCredential.RecoveryPhrase(words, StoredIdentity.nostrPublicKeyOf(words)))
} catch (e: Exception) {
Result.Refused(CredentialProblem.InvalidPhrase)
}
private fun nsec(token: String): Result {
val bytes = runCatching {
val (hrp, data) = Bech32.decodeBytes(token)
require(hrp == "nsec") { "not an nsec" }
data
}.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey)
return secret(runCatching { PrivateKey(bytes) }.getOrNull())
}
private fun secret(privateKey: PrivateKey?): Result {
if (privateKey == null || !privateKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey)
return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex()))
}
}

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_to_torch_via_nsec_or_remote_signer
import mantra.composeapp.generated.resources.sign_in_with_a_recovery_phrase_or_an_nsec
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_to_torch_via_nsec_or_remote_signer),
text = stringResource(Res.string.sign_in_with_a_recovery_phrase_or_an_nsec),
modifier = Modifier.padding(MaterialTheme.spacing.space125),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center

View File

@@ -3,45 +3,304 @@ package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Spellcheck
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import press.mantra.compose.ui.theme.spacing
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
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.back
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_recovery_phrase_it_also
import mantra.composeapp.generated.resources.recovery_phrase_or_nsec
import mantra.composeapp.generated.resources.sign_in
import mantra.composeapp.generated.resources.signed_in
import mantra.composeapp.generated.resources.signing_you_in
import mantra.composeapp.generated.resources.that_is_not_a_recovery_phrase_or_a_nostr_key
import mantra.composeapp.generated.resources.that_nostr_secret_key_is_not_valid
import mantra.composeapp.generated.resources.that_recovery_phrase_is_not_valid_check
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.use_a_different_key
import mantra.composeapp.generated.resources.you_are_signing_in_as
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.phoenix.data.WalletId
import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.sign_in_is_not_yet_available_while_mantra_is
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.identity.CredentialProblem
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.identity.SignInCredential
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.ErrorState
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.theme.ConformancePreviews
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.view.model.SignInToProfileViewModel
import press.mantra.compose.ui.view.model.WritingSeedState
import press.mantra.compose.ui.view.state.SignInToProfileUIState
// The message is the whole screen while sign in is held back, so it is centred in
// the window rather than given the leading edge the readable column normally keeps.
/**
* Signing in with a secret the user already has.
*
* One field, two things it accepts -- a recovery phrase or an nsec -- because a user
* does not choose an input type, they paste what they have. What was recognised, and
* the npub it signs as, is shown before anything is written; that is the step that
* catches the wrong key. Problems with the input stay on the field; problems after
* confirming are a state of the screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SignInToProfileScreen() {
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.fillMaxSize().padding(innerPadding)
.readableContent()
.padding(MaterialTheme.spacing.screenMargin),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(Res.string.sign_in_is_not_yet_available_while_mantra_is),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
fun SignInToProfileScreen(
nostrRepository: NostrRepository,
writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult,
writeRecoveryPhrase: (
words: List<String>,
onWritten: (WalletId) -> Unit,
onError: (WritingSeedState.Error) -> Unit,
) -> Unit,
onNavigateBack: () -> Unit,
onSignedIn: (WalletId) -> Unit,
) {
val viewModel: SignInToProfileViewModel = viewModel(
factory = SignInToProfileViewModel.factory(
nostrRepository = nostrRepository,
writeNostrKey = writeNostrKey,
writeRecoveryPhrase = writeRecoveryPhrase,
)
)
// Both composable, and the handler that shows this is not: read above it.
val notify = rememberNotifier(rememberCoroutineScope())
val signedIn = stringResource(Res.string.signed_in)
// imePadding: there is a text field, and without it the keyboard covers it.
Scaffold(
modifier = Modifier.imePadding(),
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.sign_in)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
}
)
}
) { innerPadding ->
ScreenStateTransition(
state = viewModel.uiState.value,
modifier = Modifier.fillMaxSize().padding(innerPadding),
label = "sign in",
) { uiState ->
when (val state = uiState) {
is SignInToProfileUIState.InputPrompt -> InputPrompt(viewModel)
is SignInToProfileUIState.Confirm -> Confirm(
credential = state.credential,
onConfirm = {
viewModel.confirm(state.credential) { id ->
notify(signedIn)
onSignedIn(id)
}
},
onUseADifferentKey = { viewModel.useADifferentKey() },
)
is SignInToProfileUIState.Committing -> LoadingDataIndicator(
text = stringResource(Res.string.signing_you_in)
)
is SignInToProfileUIState.Error -> ErrorState(
message = problemMessage(state.problem),
onRetry = { viewModel.retry() },
)
}
}
}
}
@Composable
private fun InputPrompt(viewModel: SignInToProfileViewModel) {
val field = viewModel.signInToProfileFormState.textFieldForm.textFieldState
val problem = viewModel.inputProblem.value
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.readableContent()
.padding(MaterialTheme.spacing.screenMargin),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap),
) {
Text(
text = stringResource(Res.string.the_words_or_the_key_never_leave_this_device),
style = MaterialTheme.typography.bodyMedium,
)
TextField(
modifier = Modifier.fillMaxWidth(),
state = field,
isError = problem != null,
supportingText = problem?.let { { Text(text = problemMessage(it)) } },
// Two lines shows a whole phrase without scrolling and an nsec on one; four is
// room for a phrase pasted with line breaks.
lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 2, maxHeightInLines = 4),
// A key is not prose: no capitalisation, no autocorrect, no suggestions rewriting
// a word of the phrase. Monospace so an nsec can be read back character by character.
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
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) },
trailingIcon = {
IconButton(
onClick = {
clipboardManager.getText()?.text?.let { field.setTextAndPlaceCursorAtEnd(it) }
}
) {
Icon(
Icons.Default.ContentPaste,
contentDescription = stringResource(Res.string.paste)
)
}
},
)
Button(
modifier = Modifier.align(Alignment.End),
onClick = { viewModel.recognise() },
) {
Text(text = stringResource(Res.string.next))
}
}
}
@Composable
private fun Confirm(
credential: SignInCredential,
onConfirm: () -> Unit,
onUseADifferentKey: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.readableContent()
.padding(MaterialTheme.spacing.screenMargin),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap),
) {
Text(
text = stringResource(Res.string.you_are_signing_in_as),
style = MaterialTheme.typography.bodyMedium,
)
// In full, so a wrong paste can be seen for what it is. The secret is never echoed.
Text(
text = credential.nostrPublicKey.hexToNpubHrp(),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
)
Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
) {
// The label beside each icon says what it is; the icon only decorates it.
when (credential) {
is SignInCredential.RecoveryPhrase -> {
Icon(Icons.Default.Spellcheck, contentDescription = Decorative)
Text(
text = stringResource(Res.string.recognised_as_a_recovery_phrase_it_also),
style = MaterialTheme.typography.bodyMedium,
)
}
is SignInCredential.NostrSecret -> {
Icon(Icons.Default.Key, contentDescription = Decorative)
Text(
text = stringResource(Res.string.recognised_as_a_nostr_secret_key_no_wallet),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
) {
Button(onClick = onConfirm) {
Text(text = stringResource(Res.string.sign_in))
}
TextButton(onClick = onUseADifferentKey) {
Text(text = stringResource(Res.string.use_a_different_key))
}
}
}
}
/** Each refusal is a sentence that says what to do instead, not a code. */
@Composable
private fun problemMessage(problem: CredentialProblem): String = stringResource(
when (problem) {
CredentialProblem.Empty -> Res.string.enter_a_recovery_phrase_or_an_nsec_to_sign_in
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
}
)
@ConformancePreviews
@Composable
private fun SignInToProfileScreenPreview() {
@@ -49,7 +308,13 @@ private fun SignInToProfileScreenPreview() {
Surface(
modifier = Modifier.fillMaxSize()
) {
SignInToProfileScreen()
SignInToProfileScreen(
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
writeNostrKey = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys },
writeRecoveryPhrase = { _, _, onError -> onError(WritingSeedState.Error.CannotLoadSeedMap) },
onNavigateBack = {},
onSignedIn = {},
)
}
}
}

View File

@@ -662,7 +662,36 @@ fun MantraNavHost(
)
}
composable<SignInRoute> {
SignInToProfileScreen()
SignInToProfileScreen(
nostrRepository = databaseNostrRepository,
writeNostrKey = { privateKey -> sovereignWalletViewModel.writeNostrKey(privateKey) },
writeRecoveryPhrase = { words, onWritten, onError ->
sovereignWalletViewModel.writeSeed(
words,
isRestoringWallet = true,
onSeedWritten = onWritten,
onSeedWriteError = onError,
)
},
onNavigateBack = {
navController.popBackStack()
},
// The tail the create flow uses after writeSeed: re-list, select, go to
// startup. Startup finds the new identity, activates it, and the navigation
// machine takes it from the placeholder account the sign-in planted. The
// form is popped so that back does not return to a field holding a secret.
onSignedIn = { walletId ->
sovereignWalletViewModel.loadSovereignData(walletId)
sovereignWalletViewModel.listIdentities {
sovereignWalletViewModel.switchToWallet(walletId)
navController.navigate(
route = SovereignWalletStartupRoute
) {
popUpTo(0)
}
}
},
)
}
composable<UnsignedProfileRoute> { backStackEntry ->
backStackEntry.toRoute<UnsignedProfileRoute>()

View File

@@ -8,30 +8,66 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.phoenix.data.WalletId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.identity.CredentialParser
import press.mantra.compose.identity.CredentialProblem
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.identity.SignInCredential
import press.mantra.compose.repository.NostrRepository
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.
*
* 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.
*
* What this does not do is decide where the user goes next. It writes the secret, 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
* database before the identity is activated, because `NavigationViewModel.observeProfile`
* starts reading the moment the identity flow emits, and an account that is not there
* yet reads as "go and make one".
*/
class SignInToProfileViewModel(
initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState,
val signInToProfileFormState: press.mantra.compose.ui.view.state.form.SignInToProfileFormState = press.mantra.compose.ui.view.state.form.SignInToProfileFormState(),
val nostrRepository: press.mantra.compose.repository.NostrRepository
): ViewModel() {
private val nostrRepository: NostrRepository,
private val writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult,
private val writeRecoveryPhrase: (
words: List<String>,
onWritten: (WalletId) -> Unit,
onError: (WritingSeedState.Error) -> Unit,
) -> Unit,
val signInToProfileFormState: SignInToProfileFormState = SignInToProfileFormState(),
) : ViewModel() {
companion object {
private const val TAG = "SignInToProfileViewModel"
fun factory(
initialWriteNewNoteUIState: press.mantra.compose.ui.view.state.SignInToProfileUIState,
nostrRepository: press.mantra.compose.repository.NostrRepository
nostrRepository: NostrRepository,
writeNostrKey: suspend (PrivateKey) -> IdentityWriter.WriteNostrKeyResult,
writeRecoveryPhrase: (
words: List<String>,
onWritten: (WalletId) -> Unit,
onError: (WritingSeedState.Error) -> Unit,
) -> Unit,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
SignInToProfileViewModel(
initialWriteNewNoteUIState,
nostrRepository = nostrRepository
nostrRepository = nostrRepository,
writeNostrKey = writeNostrKey,
writeRecoveryPhrase = writeRecoveryPhrase,
)
}
}
@@ -39,51 +75,98 @@ class SignInToProfileViewModel(
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
val uiState: MutableState<SignInToProfileUIState> = mutableStateOf(SignInToProfileUIState.InputPrompt)
var signInToProfileUIState: MutableState<press.mantra.compose.ui.view.state.SignInToProfileUIState> = mutableStateOf(
initialWriteNewNoteUIState
)
/** Why the field's contents were refused, shown as its supporting text; null when they were not. */
val inputProblem: MutableState<CredentialProblem?> = mutableStateOf(null)
fun validateCredential(): Entity? {
return signInToProfileFormState.textFieldForm.textFieldState.text.toString().let { credentialText ->
if (credentialText.isBlank()) {
signInToProfileFormState.textFieldForm.errorMessage.value = "Please input a credential"
return null
} else {
signInToProfileFormState.textFieldForm.errorMessage.value = null
/** Reads the field and either moves to [SignInToProfileUIState.Confirm] or marks the field. */
fun recognise() {
when (val result = CredentialParser.parse(signInToProfileFormState.textFieldForm.textFieldState.text.toString())) {
is CredentialParser.Result.Refused -> inputProblem.value = result.problem
is CredentialParser.Result.Recognised -> {
inputProblem.value = null
uiState.value = SignInToProfileUIState.Confirm(result.credential)
}
Nip19Parser.uriToRoute(signInToProfileFormState.textFieldForm.textFieldState.text.toString())?.entity
}
}
fun signInToAccount(
) {
logger.d { "signInToAccount" }
validateCredential().let { credentialEntity ->
isActionPending.value = true
/** Back to the field, keeping what was typed. */
fun useADifferentKey() {
uiState.value = SignInToProfileUIState.InputPrompt
}
viewModelScope.launch(Dispatchers.IO) {
when (credentialEntity) {
is NPub -> {
nostrRepository.signInToProfile(
publicKey = credentialEntity.hex
)
}
is NSec -> {
nostrRepository.signInToProfile(
publicKey = credentialEntity.toPubKeyHex()
)
}
else -> {
// TODO: Might want to give reasons for the error.
signInToProfileUIState.value = press.mantra.compose.ui.view.state.SignInToProfileUIState.Error
}
/** The outcome of [commit], for the screen to act on and the tests to assert. */
sealed interface Outcome {
data class SignedIn(val id: WalletId) : Outcome
data class Failed(val problem: CredentialProblem) : Outcome
}
/**
* 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.
*/
suspend fun commit(credential: SignInCredential): Outcome {
val written: Outcome = when (credential) {
is SignInCredential.NostrSecret -> try {
when (val result = writeNostrKey(credential.privateKey)) {
is IdentityWriter.WriteNostrKeyResult.Written -> Outcome.SignedIn(result.id)
is IdentityWriter.WriteNostrKeyResult.AlreadyExists -> Outcome.Failed(CredentialProblem.AlreadyOnThisDevice)
is IdentityWriter.WriteNostrKeyResult.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.RecoveryPhrase -> {
// Callback-shaped writer, awaited: the seed writer drives WritingSeedState and
// reports through two callbacks, exactly one of which fires.
val outcome = CompletableDeferred<Outcome>()
writeRecoveryPhrase(
credential.words,
{ id -> outcome.complete(Outcome.SignedIn(id)) },
{ error ->
outcome.complete(
Outcome.Failed(
when (error) {
is WritingSeedState.Error.SeedAlreadyExists -> CredentialProblem.AlreadyOnThisDevice
is WritingSeedState.Error.CannotLoadSeedMap,
is WritingSeedState.Error.Generic -> CredentialProblem.CouldNotWrite
}
)
)
},
)
outcome.await()
}
}
if (written is Outcome.SignedIn) {
// Before the identity is activated -- see the class comment.
nostrRepository.signInToProfile(publicKey = credential.nostrPublicKey)
}
return written
}
}
/** [commit] from the confirm button, moving the screen through Committing to wherever it ends. */
fun confirm(credential: SignInCredential, onSignedIn: (WalletId) -> Unit) {
if (uiState.value is SignInToProfileUIState.Committing) return
uiState.value = SignInToProfileUIState.Committing
viewModelScope.launch(Dispatchers.IO) {
when (val outcome = commit(credential)) {
is Outcome.SignedIn -> withContext(Dispatchers.Main) { onSignedIn(outcome.id) }
is Outcome.Failed -> uiState.value = SignInToProfileUIState.Error(outcome.problem)
}
}
}
/** From the error state back to the field. */
fun retry() {
uiState.value = SignInToProfileUIState.InputPrompt
}
}

View File

@@ -353,6 +353,20 @@ class SovereignWalletViewModel(
)
}
/**
* Writes a bare nostr key as a new identity, with the same initialisation options
* [writeSeed] applies. Suspends; the caller is the sign-in view model's coroutine.
*/
suspend fun writeNostrKey(privateKey: PrivateKey): IdentityWriter.WriteNostrKeyResult =
IdentityWriter.writeNostrKey(
log = log,
phoenixGlobal = phoenixGlobal,
globalPrefs = getGlobalPrefs(),
privateKey = privateKey,
isTorEnabled = isTorEnabled.value,
customElectrumServer = customElectrumServer.value,
)
companion object {
private const val TAG = "SovereignWalletViewModel"
fun factory(

View File

@@ -1,20 +1,27 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.identity.CredentialProblem
import press.mantra.compose.identity.SignInCredential
abstract class SignInToProfileUIState {
data object Error: SignInToProfileUIState()
/**
* The sign-in screen's four states. Problems with the *input* are not one of them: those
* stay on the field as supporting text while the prompt is still showing. [Error] is for
* what happens after the user has confirmed -- the write failed, or the key was already
* here -- where there is no field to put the message under.
*/
sealed interface SignInToProfileUIState {
/** The field, waiting for a recovery phrase or an nsec. */
data object InputPrompt : SignInToProfileUIState
data object InputPrompt: SignInToProfileUIState()
/**
* What was recognised, and the npub it signs as, before anything is written. This is
* the step that catches a paste of the wrong key; for a recovery phrase it is where
* the user learns which profile the words open. The secret itself is never shown.
*/
data class Confirm(val credential: SignInCredential) : SignInToProfileUIState
data class ConfirmNpubSignIn(
val npub: String,
val hexKey: HexKey
) : SignInToProfileUIState()
/** Writing the secret and planting the account. */
data object Committing : SignInToProfileUIState
data class ConfirmNsecSignIn(
val nsec: String,
val hexKey: HexKey
) : SignInToProfileUIState()
}
data class Error(val problem: CredentialProblem) : SignInToProfileUIState
}

View File

@@ -0,0 +1,91 @@
package press.mantra.compose.identity
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* Every row of the sign-in screen's table, in and out.
*
* The accepted rows all use NIP-06's own vector, so the three ways of writing one key --
* twelve words, an nsec, sixty-four hex characters -- are asserted to land on the same
* public key. That equality is what the duplicate check in `IdentityWriter` rests on.
*/
class CredentialParserJvmTest {
private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean"
private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp"
private val hex = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"
private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917"
private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu"
private fun recognised(input: String): SignInCredential =
assertIs<CredentialParser.Result.Recognised>(CredentialParser.parse(input), "for '$input'").credential
private fun refused(input: String): CredentialProblem =
assertIs<CredentialParser.Result.Refused>(CredentialParser.parse(input), "for '$input'").problem
@Test
fun `twelve words are a recovery phrase, and derive the NIP-06 key`() {
val credential = assertIs<SignInCredential.RecoveryPhrase>(recognised(words))
assertEquals(words.split(" "), credential.words)
assertEquals(publicKey, credential.nostrPublicKey)
}
@Test
fun `a phrase pasted with line breaks and extra spaces is the same phrase`() {
val credential = assertIs<SignInCredential.RecoveryPhrase>(
recognised(" leader monkey\nparrot ring\tguide accident before fence cannon height naive bean \n")
)
assertEquals(publicKey, credential.nostrPublicKey)
}
@Test
fun `an nsec, a nostr-prefixed nsec and the hex all name the same key`() {
for (input in listOf(nsec, "nostr:$nsec", hex, nsec.uppercase(), hex.uppercase())) {
val credential = assertIs<SignInCredential.NostrSecret>(recognised(input), "for '$input'")
assertEquals(publicKey, credential.nostrPublicKey, "for '$input'")
assertEquals(hex, credential.privateKey.value.toHex(), "for '$input'")
}
}
@Test
fun `nothing is nothing`() {
assertEquals(CredentialProblem.Empty, refused(""))
assertEquals(CredentialProblem.Empty, refused(" \n "))
}
@Test
fun `a single word that is not a key is not recognised`() {
assertEquals(CredentialProblem.NotRecognised, refused("hello"))
assertEquals(CredentialProblem.NotRecognised, refused("nostr:"))
assertEquals(CredentialProblem.NotRecognised, refused("7f7ff03d")) // hex, but not 64 of it
}
@Test
fun `a wrong word, a missing word or a capitalised word is an invalid phrase`() {
assertEquals(CredentialProblem.InvalidPhrase, refused(words.replace("bean", "beans")))
assertEquals(CredentialProblem.InvalidPhrase, refused(words.substringBeforeLast(" ")))
// The checksum would pass if this were lower-cased; the wordlist is lower-case and
// a capital is a paste artefact the user should see rather than have silently fixed.
assertEquals(CredentialProblem.InvalidPhrase, refused(words.replaceFirstChar { it.uppercase() }))
}
@Test
fun `a public key is named for what it is`() {
assertEquals(CredentialProblem.PublicKeyOnly, refused(npub))
assertEquals(CredentialProblem.PublicKeyOnly, refused("nostr:$npub"))
}
@Test
fun `an encrypted key is named for what it is`() {
assertEquals(CredentialProblem.EncryptedKey, refused("ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"))
}
@Test
fun `an nsec that does not decode, or a hex that is not a valid secret, is an invalid key`() {
assertEquals(CredentialProblem.InvalidKey, refused(nsec.dropLast(1) + "q"))
assertEquals(CredentialProblem.InvalidKey, refused("0".repeat(64)))
assertEquals(CredentialProblem.InvalidKey, refused("f".repeat(64)))
}
}

View File

@@ -0,0 +1,119 @@
package press.mantra.compose.ui.view.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.phoenix.data.WalletId
import kotlinx.coroutines.runBlocking
import press.mantra.compose.identity.CredentialProblem
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.identity.SignInCredential
import press.mantra.compose.identity.StoredIdentity
import press.mantra.compose.repository.NostrRepository
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* What a confirmed sign-in does, and in what order.
*
* The write and the placeholder are two effects, and the second must follow the first
* and only on success: a placeholder for a key that was not saved would send the user
* to fetch a profile they can never sign for, and a placeholder planted before the
* write is a race the navigation observer can lose. The repository fake records every
* call, so both "exactly once" and "after" are assertable.
*/
class SignInToProfileViewModelJvmTest {
private val privateKey = PrivateKey(ByteVector32("7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a"))
private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917"
private val words = "leader monkey parrot ring guide accident before fence cannon height naive bean".split(" ")
private val id = WalletId("ab".repeat(20))
private val nostrSecret = SignInCredential.NostrSecret(privateKey, publicKey)
private val recoveryPhrase = SignInCredential.RecoveryPhrase(words, 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 {
val effects = mutableListOf<String>()
override suspend fun signInToProfile(publicKey: HexKey) {
effects += "signInToProfile:$publicKey"
}
}
private fun viewModel(
recorder: Recorder,
nostrKeyOutcome: () -> IdentityWriter.WriteNostrKeyResult = { IdentityWriter.WriteNostrKeyResult.Written(id) },
seedOutcome: ((WalletId) -> Unit, (WritingSeedState.Error) -> Unit) -> Unit = { onWritten, _ -> onWritten(id) },
) = SignInToProfileViewModel(
nostrRepository = recorder,
writeNostrKey = { recorder.effects += "writeNostrKey"; nostrKeyOutcome() },
writeRecoveryPhrase = { _, onWritten, onError -> recorder.effects += "writeRecoveryPhrase"; seedOutcome(onWritten, onError) },
)
@Test
fun `an nsec is written, then its account is planted, then the id comes back`() = runBlocking {
val recorder = Recorder()
val outcome = viewModel(recorder).commit(nostrSecret)
assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome)
assertEquals(listOf("writeNostrKey", "signInToProfile:$publicKey"), recorder.effects)
}
@Test
fun `a recovery phrase goes through the seed writer and plants the same account`() = runBlocking {
val recorder = Recorder()
val outcome = viewModel(recorder).commit(recoveryPhrase)
assertEquals(SignInToProfileViewModel.Outcome.SignedIn(id), outcome)
assertEquals(listOf("writeRecoveryPhrase", "signInToProfile:$publicKey"), recorder.effects)
}
@Test
fun `a key already on the device is refused, and no account is planted`() = runBlocking {
val recorder = Recorder()
val outcome = viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.AlreadyExists }).commit(nostrSecret)
assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome)
assertEquals(listOf("writeNostrKey"), recorder.effects)
}
@Test
fun `a seed already on the device is the same refusal`() = runBlocking {
val recorder = Recorder()
val outcome = viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.SeedAlreadyExists) })
.commit(recoveryPhrase)
assertEquals(SignInToProfileViewModel.Outcome.Failed(CredentialProblem.AlreadyOnThisDevice), outcome)
assertEquals(listOf("writeRecoveryPhrase"), recorder.effects)
}
@Test
fun `a store that cannot be read, or a write that throws, is could-not-write`() = runBlocking {
val recorder = Recorder()
assertEquals(
SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite),
viewModel(recorder, nostrKeyOutcome = { IdentityWriter.WriteNostrKeyResult.CannotLoadKeys }).commit(nostrSecret),
)
assertEquals(
SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite),
viewModel(recorder, nostrKeyOutcome = { throw IllegalStateException("disk") }).commit(nostrSecret),
)
assertEquals(
SignInToProfileViewModel.Outcome.Failed(CredentialProblem.CouldNotWrite),
viewModel(recorder, seedOutcome = { _, onError -> onError(WritingSeedState.Error.Generic(IllegalStateException("disk"))) })
.commit(recoveryPhrase),
)
assertEquals(emptyList(), recorder.effects.filter { it.startsWith("signInToProfile") })
}
/** The credential the parser builds for the phrase and the one it builds for the key agree on the account. */
@Test
fun `both credentials of one key plant the same account`() {
assertEquals(StoredIdentity.nostrPublicKeyOf(words), nostrSecret.nostrPublicKey)
}
}