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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP
This commit is contained in:
Claude
2026-09-26 20:54:20 +00:00
parent e0f200a5ac
commit b3aebaa95b
6 changed files with 57 additions and 11 deletions
@@ -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,
@@ -667,6 +667,7 @@
<string name="account_backup_encrypted_title">Password-protected copy</string>
<string name="account_backup_encrypted_password">Password</string>
<string name="account_backup_encrypted_body">An ncryptsec1… that only works with your password.</string>
<string name="account_backup_password_min_length">At least %1$d characters</string>
<string name="account_backup_encrypted_repeat_password">Repeat password</string>
<string name="account_backup_encrypted_password_mismatch">Passwords don't match</string>
<string name="account_backup_encrypt">Encrypt key</string>
@@ -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,
@@ -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,
@@ -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,
@@ -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"))
}
}