From b3aebaa95bc0bc400b5d1490c2816e2830b5b374 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:54:20 +0000 Subject: [PATCH] feat: require 12+ character passwords when creating an ncryptsec NIP-49 sets no minimum, but an ncryptsec can be brute-forced offline with no rate limit at one scrypt(2^16) per guess. The Android backup screen, the desktop backup card and desktop onboarding now require at least 12 characters before encrypting. A hint under the password field turns primary once it is met. The rule lives in quartz (Nip49.MIN_PASSWORD_LENGTH / isLongEnough). It counts code points of the NFKC-normalized password, i.e. what scrypt actually sees, so an emoji counts once. It applies only at creation: decrypting and login still accept any password, so ncryptsecs made elsewhere keep opening. The CLI is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../loggedIn/keyBackup/AccountBackupScreen.kt | 10 ++++++++- .../composeResources/values/strings.xml | 1 + .../desktop/ui/auth/NewKeyOnboardingScreen.kt | 12 +++++----- .../desktop/ui/keyBackup/BackupKeysCard.kt | 12 +++++----- .../quartz/nip49PrivKeyEnc/Nip49.kt | 22 +++++++++++++++++++ .../quartz/nip49PrivKeyEnc/Nip49SpecTest.kt | 11 ++++++++++ 6 files changed, 57 insertions(+), 11 deletions(-) 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")) + } }