From 48f27f656dcb19ff52be57790586eb15f3e7fdc0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 14 Feb 2024 12:54:08 -0500 Subject: [PATCH] Adds NIP49 to login and back up key screens --- .../ui/screen/AccountStateViewModel.kt | 62 ++++- .../ui/screen/loggedIn/AccountBackupDialog.kt | 240 ++++++++++++++++- .../ui/screen/loggedOff/LoginScreen.kt | 246 ++++++++++++++---- app/src/main/res/values/strings.xml | 16 +- .../quartz/crypto/CryptoUtils.kt | 17 ++ .../com/vitorpamplona/quartz/crypto/Nip49.kt | 12 +- 6 files changed, 519 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 484943d4a..4ceb37bc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.relays.Client +import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.Nip19 @@ -193,6 +194,42 @@ class AccountStateViewModel() : ViewModel() { } } + fun login( + key: String, + password: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + onError: (String?) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + if (key.startsWith("ncryptsec")) { + val newKey = + try { + CryptoUtils.decryptNIP49(key, password) + } catch (e: Exception) { + if (e is CancellationException) throw e + onError(e.message) + return@launch + } + + if (newKey == null) { + onError("Could not decrypt key with provided password") + Log.e("Login", "Could not decrypt ncryptsec") + } else { + loginSync(newKey, useProxy, proxyPort, loginWithExternalSigner, packageName) { + onError(null) + } + } + } else { + loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName) { + onError(null) + } + } + } + } + fun login( key: String, useProxy: Boolean, @@ -202,13 +239,24 @@ class AccountStateViewModel() : ViewModel() { onError: () -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - try { - loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Login", "Could not sign in", e) - onError() - } + loginSync(key, useProxy, proxyPort, loginWithExternalSigner, packageName, onError) + } + } + + suspend fun loginSync( + key: String, + useProxy: Boolean, + proxyPort: Int, + loginWithExternalSigner: Boolean = false, + packageName: String = "", + onError: () -> Unit, + ) { + try { + loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("Login", "Could not sign in", e) + onError() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt index 28871c35d..fbed6de3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountBackupDialog.kt @@ -36,23 +36,48 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -67,10 +92,14 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.note.authenticate import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.crypto.CryptoUtils +import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toNsec import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +@OptIn(ExperimentalComposeUiApi::class) @Composable fun AccountBackupDialog( accountViewModel: AccountViewModel, @@ -80,12 +109,23 @@ fun AccountBackupDialog( onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Surface(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { Column( - modifier = Modifier.background(MaterialTheme.colorScheme.background).fillMaxSize(), + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), ) { Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -93,7 +133,10 @@ fun AccountBackupDialog( } Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 30.dp), + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 30.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -101,13 +144,105 @@ fun AccountBackupDialog( style = RichTextStyle().resolveDefaults(), ) { Markdown( - content = stringResource(R.string.account_backup_tips_md), + content = stringResource(R.string.account_backup_tips2_md), ) } - Spacer(modifier = Modifier.height(30.dp)) + Spacer(modifier = Modifier.height(10.dp)) NSecCopyButton(accountViewModel) + + Spacer(modifier = Modifier.height(30.dp)) + + Material3RichText( + style = RichTextStyle().resolveDefaults(), + ) { + Markdown( + content = stringResource(R.string.account_backup_tips3_md), + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + val password = remember { mutableStateOf(TextFieldValue("")) } + var errorMessage by remember { mutableStateOf("") } + var showCharsPassword by remember { mutableStateOf(false) } + + val autofillNode = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { password.value = TextFieldValue(it) }, + ) + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNode + + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNode.boundingBox = coordinates.boundsInWindow() + } + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + }, + value = password.value, + onValueChange = { + password.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) + + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + EncryptNSecCopyButton(accountViewModel, password) } } } @@ -160,6 +295,56 @@ private fun NSecCopyButton(accountViewModel: AccountViewModel) { } } +@Composable +private fun EncryptNSecCopyButton( + accountViewModel: AccountViewModel, + password: MutableState, +) { + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) + } + } + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + authenticate( + title = context.getString(R.string.copy_my_secret_key), + context = context, + keyguardLauncher = keyguardLauncher, + onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + enabled = password.value.text.isNotBlank(), + ) { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + imageVector = Icons.Default.Key, + contentDescription = + stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), + modifier = Modifier.padding(end = 5.dp), + ) + Text( + stringResource(id = R.string.encrypt_and_copy_my_secret_key), + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + fun Context.getFragmentActivity(): FragmentActivity? { var currentContext = this while (currentContext is ContextWrapper) { @@ -189,3 +374,46 @@ private fun copyNSec( } } } + +private fun encryptCopyNSec( + password: MutableState, + context: Context, + scope: CoroutineScope, + accountViewModel: AccountViewModel, + clipboardManager: ClipboardManager, +) { + if (password.value.text.isBlank()) { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.password_is_required), + Toast.LENGTH_SHORT, + ) + .show() + } + } else { + accountViewModel.account.keyPair.privKey?.let { + val key = CryptoUtils.encryptNIP49(it.toHexKey(), password.value.text) + if (key != null) { + clipboardManager.setText(AnnotatedString(key)) + scope.launch { + Toast.makeText( + context, + context.getString(R.string.secret_key_copied_to_clipboard), + Toast.LENGTH_SHORT, + ) + .show() + } + } else { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.failed_to_encrypt_key), + Toast.LENGTH_SHORT, + ) + .show() + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 501347bf3..bd3d29274 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -90,10 +91,12 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.PackageUtils import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -142,6 +145,16 @@ fun LoginPage( val scope = rememberCoroutineScope() var loginWithExternalSigner by remember { mutableStateOf(false) } + var processingLogin by remember { mutableStateOf(false) } + + val password = remember { mutableStateOf(TextFieldValue("")) } + val needsPassword = + remember { + derivedStateOf { + key.value.text.startsWith("ncryptsec1") + } + } + if (loginWithExternalSigner) { val externalSignerLauncher = remember { ExternalSignerLauncher("", signerPackageName = "") } val id = remember { UUID.randomUUID().toString() } @@ -243,33 +256,47 @@ fun LoginPage( Spacer(modifier = Modifier.height(40.dp)) - var showPassword by remember { mutableStateOf(false) } + var showCharsKey by remember { mutableStateOf(false) } + var showCharsPassword by remember { mutableStateOf(false) } - val autofillNode = + val autofillNodeKey = AutofillNode( autofillTypes = listOf(AutofillType.Password), onFill = { key.value = TextFieldValue(it) }, ) + + val autofillNodePassword = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { key.value = TextFieldValue(it) }, + ) + val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode + LocalAutofillTree.current += autofillNodeKey + LocalAutofillTree.current += autofillNodePassword OutlinedTextField( modifier = Modifier .onGloballyPositioned { coordinates -> - autofillNode.boundingBox = coordinates.boundsInWindow() + autofillNodeKey.boundingBox = coordinates.boundsInWindow() } .onFocusChanged { focusState -> autofill?.run { if (focusState.isFocused) { - requestAutofillForNode(autofillNode) + requestAutofillForNode(autofillNodeKey) } else { - cancelAutofillForNode(autofillNode) + cancelAutofillForNode(autofillNodeKey) } } }, value = key.value, - onValueChange = { key.value = it }, + onValueChange = { + key.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, keyboardOptions = KeyboardOptions( autoCorrect = false, @@ -284,12 +311,12 @@ fun LoginPage( }, trailingIcon = { Row { - IconButton(onClick = { showPassword = !showPassword }) { + IconButton(onClick = { showCharsKey = !showCharsKey }) { Icon( imageVector = - if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + if (showCharsKey) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, contentDescription = - if (showPassword) { + if (showCharsKey) { stringResource(R.string.show_password) } else { stringResource( @@ -322,22 +349,32 @@ fun LoginPage( } }, visualTransformation = - if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + if (showCharsKey) VisualTransformation.None else PasswordVisualTransformation(), keyboardActions = KeyboardActions( onGo = { if (!acceptedTerms.value) { - termsAcceptanceIsRequired = - context.getString(R.string.acceptance_of_terms_is_required) + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) } if (key.value.text.isBlank()) { errorMessage = context.getString(R.string.key_is_required) } - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountStateViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + processingLogin = false + errorMessage = + if (it != null) { + context.getString(R.string.invalid_key_with_message, it) + } else { + context.getString(R.string.invalid_key) + } } } }, @@ -353,39 +390,128 @@ fun LoginPage( Spacer(modifier = Modifier.height(10.dp)) - if (PackageUtils.isOrbotInstalled(context)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = useProxy.value, - onCheckedChange = { - if (it) { - connectOrbotDialogOpen = true + if (needsPassword.value) { + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNodePassword.boundingBox = coordinates.boundsInWindow() } - }, - ) - - Text(stringResource(R.string.connect_via_tor)) - } - - if (connectOrbotDialogOpen) { - ConnectOrbotDialog( - onClose = { connectOrbotDialogOpen = false }, - onPost = { - connectOrbotDialogOpen = false - useProxy.value = true - }, - onError = { - scope.launch { - Toast.makeText( - context, - it, - Toast.LENGTH_LONG, + .onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNodePassword) + } else { + cancelAutofillForNode(autofillNodePassword) + } + } + }, + value = password.value, + onValueChange = { + password.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, + keyboardOptions = + KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { + Text( + text = stringResource(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password, + ) + }, ) - .show() } - }, - proxyPort, - ) + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardActions = + KeyboardActions( + onGo = { + if (!acceptedTerms.value) { + termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required) + } + + if (key.value.text.isBlank()) { + errorMessage = context.getString(R.string.key_is_required) + } + + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + processingLogin = false + errorMessage = + if (it != null) { + context.getString(R.string.invalid_key_with_message, it) + } else { + context.getString(R.string.invalid_key) + } + } + } + }, + ), + ) + + Spacer(modifier = Modifier.height(10.dp)) + + if (PackageUtils.isOrbotInstalled(context)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = useProxy.value, + onCheckedChange = { + if (it) { + connectOrbotDialogOpen = true + } + }, + ) + + Text(stringResource(R.string.connect_via_tor)) + } + + if (connectOrbotDialogOpen) { + ConnectOrbotDialog( + onClose = { connectOrbotDialogOpen = false }, + onPost = { + connectOrbotDialogOpen = false + useProxy.value = true + }, + onError = { + scope.launch { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG, + ) + .show() + } + }, + proxyPort, + ) + } } } @@ -448,19 +574,33 @@ fun LoginPage( errorMessage = context.getString(R.string.key_is_required) } - if (acceptedTerms.value && key.value.text.isNotBlank()) { - accountStateViewModel.login(key.value.text, useProxy.value, proxyPort.value.toInt()) { - errorMessage = context.getString(R.string.invalid_key) + if (needsPassword.value && password.value.text.isBlank()) { + errorMessage = context.getString(R.string.password_is_required) + } + + if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) { + processingLogin = true + accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt()) { + processingLogin = false + errorMessage = + if (it != null) { + context.getString(R.string.invalid_key_with_message, it) + } else { + context.getString(R.string.invalid_key) + } } } }, shape = RoundedCornerShape(Size35dp), modifier = Modifier.height(50.dp), ) { - Text( - text = stringResource(R.string.login), - modifier = Modifier.padding(horizontal = 40.dp), - ) + Row(modifier = Modifier.padding(horizontal = 40.dp)) { + if (processingLogin) { + LoadingAnimation() + Spacer(modifier = DoubleHorzSpacer) + } + Text(stringResource(R.string.login)) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ad75e819..a84536ad2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,12 +142,15 @@ Clear App Logo nsec.. or npub.. + password to open the key Show Password Hide Password Invalid key + Invalid key: %1$s "I accept the " terms of use Acceptance of terms is required + Password is required Key is required A name is required Login @@ -200,15 +203,22 @@ Mark all New as read Mark all as read Backup Keys - + ## Key Backup and Safety Tips - \n\nYour account is secured by a secret key. The key is long random string starting with **nsec1**. Anyone who has access to your secret key can publish content using your identity. + \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. \n\n- Do **not** put your secret key in any website or software you do not trust. - \n- Amethyst developers will **never** ask you for your secret key. + \n- Amethyst developers will **never** ask for your secret key. \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. + + For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. + \n\nIf you lose your password, you will not be able to recover your key. + + + Failed to encrypt your private key Secret key (nsec) copied to clipboard Copy my secret key + Encrypt and copy my secret key Authentication failed Biometrics failed to authenticate the owner of this phone Biometrics failed to authenticate the owner of this phone. Error: %1$s diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt index 65e093780..d0e366d02 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/CryptoUtils.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.crypto import android.util.Log +import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.Event import fr.acinq.secp256k1.Secp256k1 import java.security.MessageDigest @@ -34,6 +35,7 @@ object CryptoUtils { private val nip04 = Nip04(secp256k1, random) private val nip44v1 = Nip44v1(secp256k1, random) private val nip44v2 = Nip44v2(secp256k1, random) + private val nip49 = Nip49(secp256k1, random) fun clearCache() { nip04.clearCache() @@ -262,6 +264,21 @@ object CryptoUtils { } } + fun decryptNIP49( + payload: String, + password: String, + ): String? { + if (payload.isEmpty() || password.isEmpty()) return null + return nip49.decrypt(payload, password) + } + + fun encryptNIP49( + key: HexKey, + password: String, + ): String? { + return nip49.encrypt(key, password, 16, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + } + class EncryptedInfoString( val ciphertext: String, val nonce: String, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt index 2d53fc2d7..2a282e5e3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/crypto/Nip49.kt @@ -39,14 +39,14 @@ class Nip49(val secp256k1: Secp256k1, val random: SecureRandom) { fun decrypt( nCryptSec: String, password: String, - ): HexKey? { + ): HexKey { return decrypt(EncryptedInfo.decodePayload(nCryptSec), password) } fun decrypt( encryptedInfo: EncryptedInfo?, password: String = "", - ): String? { + ): String { check(encryptedInfo != null) { "Couldn't decode key" } check(encryptedInfo.version == EncryptedInfo.V) { "invalid version" } @@ -66,6 +66,8 @@ class Nip49(val secp256k1: Secp256k1, val random: SecureRandom) { key, ) + check(m.any { it > 0 }) { "Incorrect password" } + return m.toHexKey() } @@ -129,9 +131,9 @@ class Nip49(val secp256k1: Secp256k1, val random: SecureRandom) { companion object { const val V: Byte = 0x02 - const val UNSAFE_HANDLING = 0x00 - const val SAFE_HANDLING = 0x01 - const val CLIENT_DOES_NOT_TRACK = 0x02 + const val UNSAFE_HANDLING: Byte = 0x00 + const val SAFE_HANDLING: Byte = 0x01 + const val CLIENT_DOES_NOT_TRACK: Byte = 0x02 fun decodePayload(nCryptSec: String): EncryptedInfo? { val byteArray =