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:
@@ -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>
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user