diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt
index 2e63408b3a..74248e5fb4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt
@@ -106,6 +106,7 @@ import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_tit
import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypting
import com.vitorpamplona.amethyst.commons.resources.account_backup_headline
import com.vitorpamplona.amethyst.commons.resources.account_backup_intro
+import com.vitorpamplona.amethyst.commons.resources.account_backup_password_min_length
import com.vitorpamplona.amethyst.commons.resources.account_backup_qr_code
import com.vitorpamplona.amethyst.commons.resources.account_backup_tap_to_reveal
import com.vitorpamplona.amethyst.commons.resources.account_backup_tip_developers
@@ -400,7 +401,8 @@ private fun EncryptedKeyCard(
// A typo in the password makes the backup permanently useless, so it must be typed twice.
val mismatch = repeated.isNotEmpty() && repeated != password
- val canEncrypt = password.isNotBlank() && repeated == password && !working
+ val longEnough = Nip49.isLongEnough(password)
+ val canEncrypt = longEnough && repeated == password && !working
fun encrypt() {
if (!canEncrypt) return
@@ -471,6 +473,12 @@ private fun EncryptedKeyCard(
onValueChange = { password = it },
singleLine = true,
label = { Text(stringRes(Res.string.account_backup_encrypted_password)) },
+ supportingText = {
+ Text(
+ text = stringRes(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH),
+ color = if (longEnough) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
keyboardOptions =
KeyboardOptions(
autoCorrectEnabled = false,
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index 798b9931e9..d925bac463 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -667,6 +667,7 @@
Password-protected copy
Password
An ncryptsec1… that only works with your password.
+ At least %1$d characters
Repeat password
Passwords don't match
Encrypt key
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt
index ea0d611615..5475ced1ee 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt
@@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.account_backup_password_min_length
import com.vitorpamplona.amethyst.commons.resources.action_copy
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain_warning
@@ -409,12 +410,13 @@ private fun EncryptedCopySection(nsec: String) {
label = { Text(stringResource(Res.string.new_key_encrypt_password_label)) },
singleLine = true,
isError = error,
- supportingText =
+ supportingText = {
if (error) {
- { Text(stringResource(Res.string.backup_keys_encrypt_failed)) }
+ Text(stringResource(Res.string.backup_keys_encrypt_failed))
} else {
- null
- },
+ Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH))
+ }
+ },
visualTransformation =
if (showChars) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
@@ -450,7 +452,7 @@ private fun EncryptedCopySection(nsec: String) {
}
}
},
- enabled = password.isNotBlank() && !working,
+ enabled = Nip49.isLongEnough(password) && !working,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt
index 1ca4f222fe..a81b62423f 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LockScope
import com.vitorpamplona.amethyst.commons.privacylock.LockState
import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor
import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.account_backup_password_min_length
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy
import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_encrypted
@@ -373,12 +374,13 @@ private fun EncryptedCopy(nsec: String) {
label = { Text(stringResource(Res.string.backup_keys_encrypt_password_label)) },
singleLine = true,
isError = error,
- supportingText =
+ supportingText = {
if (error) {
- { Text(stringResource(Res.string.backup_keys_encrypt_failed)) }
+ Text(stringResource(Res.string.backup_keys_encrypt_failed))
} else {
- null
- },
+ Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH))
+ }
+ },
visualTransformation =
if (showChars) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
@@ -416,7 +418,7 @@ private fun EncryptedCopy(nsec: String) {
}
}
},
- enabled = password.isNotBlank() && !working,
+ enabled = Nip49.isLongEnough(password) && !working,
) {
Icon(
symbol = MaterialSymbols.ContentCopy,
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt
index 76d808e9e8..101f1305ce 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt
@@ -31,6 +31,28 @@ import com.vitorpamplona.quartz.utils.UnicodeNormalizer
import kotlin.math.pow
class Nip49 {
+ companion object {
+ /**
+ * Shortest password Amethyst accepts when *creating* an ncryptsec. NIP-49 sets no
+ * minimum, and decrypting must accept any password other clients allowed, so this
+ * is a creation-time policy only.
+ *
+ * An ncryptsec can be attacked offline with no rate limit, at one scrypt(2^16)
+ * per guess. 12 characters keeps even a random-looking password out of reach of
+ * large GPU farms, while staying typeable on a phone at login.
+ */
+ const val MIN_PASSWORD_LENGTH = 12
+
+ /**
+ * Length as scrypt sees it: code points of the NFKC-normalized password, so
+ * an emoji (a UTF-16 surrogate pair) counts once and compatibility forms
+ * count as what they normalize to.
+ */
+ fun passwordLength(password: String): Int = UnicodeNormalizer().normalizeNFKC(password).count { !it.isLowSurrogate() }
+
+ fun isLongEnough(password: String): Boolean = passwordLength(password) >= MIN_PASSWORD_LENGTH
+ }
+
fun decrypt(
nCryptSec: String,
password: String,
diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt
index 79a0023191..e671727181 100644
--- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt
+++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt
@@ -106,4 +106,15 @@ class Nip49SpecTest {
val handCopied = Bech32Transcription.groups(specNcryptsec.uppercase()).joinToString("\n") { it.joinToString("-") }
assertEquals(specKey, nip49.decrypt(Bech32Transcription.normalize(handCopied), "nostr"))
}
+
+ @Test
+ fun passwordLengthCountsNormalizedCodePoints() {
+ assertEquals(11, Nip49.passwordLength("x".repeat(11)))
+ assertEquals(false, Nip49.isLongEnough("x".repeat(11)))
+ assertEquals(true, Nip49.isLongEnough("x".repeat(12)))
+ // A surrogate-pair emoji is one character, not two.
+ assertEquals(1, Nip49.passwordLength("\uD83D\uDD11"))
+ // The spec's normalization vector: 4 code points typed, 3 after NFKC.
+ assertEquals(3, Nip49.passwordLength("\u212B\u2126\u1E9B\u0323"))
+ }
}