mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+9
-1
@@ -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>
|
||||
|
||||
+7
-5
@@ -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,
|
||||
|
||||
+7
-5
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user