fix(keys): new passwords have at least 12 characters

The register can't check a password it never sees, and should its
database leak, the verifier and the keys wrapped under the password fall
to guessing at Argon2id's cost (the second reading's M8). Devices now
refuse a new password shorter than 12 characters, counted as code points
once normalised (halkyon_keys' checkNewPassword), at sign-up, a reset
keeping the keys and a change alike, before anything is derived; the new
password's field says so, and the refusal has its own text (en, de).
Signing in takes any length, so a password set before keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-20 21:25:47 +02:00
commit b33b99a50a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
21 changed files with 216 additions and 29 deletions

View file

@ -206,5 +206,7 @@
"passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.", "passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
"passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.", "passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.",
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.", "passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal." "passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
"passwordMinLength": "Mindestens {count} Zeichen",
"passwordTooShort": "Ein neues Passwort braucht mindestens {count} Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten."
} }

View file

@ -432,5 +432,21 @@
"passwordInstanceUnproven": "The instance couldn't prove it knows your password, so this device trusts nothing it says. Try again; if it happens again, the instance isn't the one it claims to be.", "passwordInstanceUnproven": "The instance couldn't prove it knows your password, so this device trusts nothing it says. Try again; if it happens again, the instance isn't the one it claims to be.",
"passwordKeysMissing": "Your account's keys aren't on this device. Sign out and in again with your password here.", "passwordKeysMissing": "Your account's keys aren't on this device. Sign out and in again with your password here.",
"passwordUnsafeCosts": "The instance asked for your password to be checked in a way no account uses, so nothing was sent. If it happens again, the instance isn't the one it claims to be.", "passwordUnsafeCosts": "The instance asked for your password to be checked in a way no account uses, so nothing was sent. If it happens again, the instance isn't the one it claims to be.",
"passwordFailed": "Couldn't reach the instance. Check the connection and try again." "passwordFailed": "Couldn't reach the instance. Check the connection and try again.",
"passwordMinLength": "At least {count} characters",
"@passwordMinLength": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"passwordTooShort": "A new password needs at least {count} characters: should the instance's database ever leak, a short one is guessed first.",
"@passwordTooShort": {
"placeholders": {
"count": {
"type": "int"
}
}
}
} }

View file

@ -1344,6 +1344,18 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Couldn\'t reach the instance. Check the connection and try again.'** /// **'Couldn\'t reach the instance. Check the connection and try again.'**
String get passwordFailed; String get passwordFailed;
/// No description provided for @passwordMinLength.
///
/// In en, this message translates to:
/// **'At least {count} characters'**
String passwordMinLength(int count);
/// No description provided for @passwordTooShort.
///
/// In en, this message translates to:
/// **'A new password needs at least {count} characters: should the instance\'s database ever leak, a short one is guessed first.'**
String passwordTooShort(int count);
} }
class _HalkyonAppLocalizationsDelegate class _HalkyonAppLocalizationsDelegate

View file

@ -773,4 +773,14 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get passwordFailed => String get passwordFailed =>
'Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.'; 'Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.';
@override
String passwordMinLength(int count) {
return 'Mindestens $count Zeichen';
}
@override
String passwordTooShort(int count) {
return 'Ein neues Passwort braucht mindestens $count Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten.';
}
} }

View file

@ -756,6 +756,16 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get passwordFailed => String get passwordFailed =>
'Couldn\'t reach the instance. Check the connection and try again.'; 'Couldn\'t reach the instance. Check the connection and try again.';
@override
String passwordMinLength(int count) {
return 'At least $count characters';
}
@override
String passwordTooShort(int count) {
return 'A new password needs at least $count characters: should the instance\'s database ever leak, a short one is guessed first.';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -149,6 +149,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
UnlockedAccountKeys keys, UnlockedAccountKeys keys,
) => Isolate.run( ) => Isolate.run(
() => _guarded(() async { () => _guarded(() async {
checkNewPassword(password);
final sodium = await SodiumSumoInit.init(); final sodium = await SodiumSumoInit.init();
final opened = _keysOf(sodium, keys); final opened = _keysOf(sodium, keys);
final salt = _own(newPasswordSalt(sodium)); final salt = _own(newPasswordSalt(sodium));
@ -287,15 +288,20 @@ PasswordKdf _kdfOf(protocol.PasswordCosts costs) {
} }
} }
/// What [step] gives, a password libsodium can't take ([PasswordRefused]) /// What [step] gives, a password that can't be taken ([PasswordRefused])
/// refused as [PasswordProblem.passwordUnusable] and keys that don't /// refused as [PasswordProblem.passwordTooShort] or
/// rebuild as [PasswordProblem.keysMissing]: on the isolate, so the /// [PasswordProblem.passwordUnusable] and keys that don't rebuild as
/// refusal that crosses back is a `PasswordFailed`. /// [PasswordProblem.keysMissing]: on the isolate, so the refusal that
/// crosses back is a `PasswordFailed`.
Future<T> _guarded<T>(Future<T> Function() step) async { Future<T> _guarded<T>(Future<T> Function() step) async {
try { try {
return await step(); return await step();
} on PasswordRefused { } on PasswordRefused catch (refused) {
throw const PasswordFailed(PasswordProblem.passwordUnusable); throw PasswordFailed(switch (refused.reason) {
PasswordRefusal.tooShort => PasswordProblem.passwordTooShort,
PasswordRefusal.empty ||
PasswordRefusal.controlCharacter => PasswordProblem.passwordUnusable,
});
} on KeyRefused { } on KeyRefused {
throw const PasswordFailed(PasswordProblem.keysMissing); throw const PasswordFailed(PasswordProblem.keysMissing);
} }
@ -309,6 +315,7 @@ NewAccountMaterial _material(
AccountKeys keys, { AccountKeys keys, {
required String phrase, required String phrase,
}) { }) {
checkNewPassword(password);
final salt = _own(newPasswordSalt(sodium)); final salt = _own(newPasswordSalt(sodium));
final unlocked = derivePasswordKeys(sodium, password: password, salt: salt); final unlocked = derivePasswordKeys(sodium, password: password, salt: salt);
final SecureKey recoveryKey; final SecureKey recoveryKey;

View file

@ -87,6 +87,9 @@ enum PasswordProblem() {
/// The password can't be used: empty, or with control characters. /// The password can't be used: empty, or with control characters.
passwordUnusable, passwordUnusable,
/// A new password is shorter than halkyon_keys' `newPasswordMinLength`.
passwordTooShort,
/// The instance couldn't prove it knows the password (SRP's M2), or its /// The instance couldn't prove it knows the password (SRP's M2), or its
/// keys don't open: nothing it said is trusted. /// keys don't open: nothing it said is trusted.
instanceUnproven, instanceUnproven,

View file

@ -173,6 +173,7 @@ class _AccountRecoveryState() extends State<_AccountRecovery> {
PasswordField( PasswordField(
controller: _password, controller: _password,
label: l10n.passwordNewPassword, label: l10n.passwordNewPassword,
showsMinimum: true,
isNew: true, isNew: true,
enabled: enabled, enabled: enabled,
), ),

View file

@ -277,6 +277,7 @@ class _ChangePasswordState() extends State<_ChangePassword> {
PasswordField( PasswordField(
controller: _new, controller: _new,
label: l10n.passwordNewPassword, label: l10n.passwordNewPassword,
showsMinimum: true,
isNew: true, isNew: true,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View file

@ -194,6 +194,7 @@ class _AccountSetupState() extends State<_AccountSetup> {
PasswordField( PasswordField(
controller: _password, controller: _password,
label: l10n.passwordNewPassword, label: l10n.passwordNewPassword,
showsMinimum: true,
isNew: true, isNew: true,
enabled: enabled, enabled: enabled,
autofocus: true, autofocus: true,

View file

@ -1,5 +1,6 @@
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart'; import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/password/model/password_failed.dart'; import 'package:halkyon_app/src/password/model/password_failed.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -42,6 +43,9 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
PasswordProblem.notAPhrase => l10n.passwordNotAPhrase, PasswordProblem.notAPhrase => l10n.passwordNotAPhrase,
PasswordProblem.phraseDoesNotFit => l10n.passwordPhraseDoesNotFit, PasswordProblem.phraseDoesNotFit => l10n.passwordPhraseDoesNotFit,
PasswordProblem.passwordUnusable => l10n.passwordUnusable, PasswordProblem.passwordUnusable => l10n.passwordUnusable,
PasswordProblem.passwordTooShort => l10n.passwordTooShort(
newPasswordMinLength,
),
PasswordProblem.instanceUnproven => l10n.passwordInstanceUnproven, PasswordProblem.instanceUnproven => l10n.passwordInstanceUnproven,
PasswordProblem.unsafeCosts => l10n.passwordUnsafeCosts, PasswordProblem.unsafeCosts => l10n.passwordUnsafeCosts,
PasswordProblem.keysMissing => l10n.passwordKeysMissing, PasswordProblem.keysMissing => l10n.passwordKeysMissing,

View file

@ -6,6 +6,7 @@ import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_scope.dart'; import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/password/model/account_authenticator.dart'; import 'package:halkyon_app/src/password/model/account_authenticator.dart';
import 'package:halkyon_app/src/password/model/phrase_check.dart'; import 'package:halkyon_app/src/password/model/phrase_check.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show newPasswordMinLength;
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -24,6 +25,9 @@ class const PasswordField({
final bool enabled = true, final bool enabled = true,
final bool autofocus = false, final bool autofocus = false,
final bool isNew = false, final bool isNew = false,
/// Whether the field says how long a new password must be.
final bool showsMinimum = false,
final TextInputAction textInputAction = TextInputAction.next, final TextInputAction textInputAction = TextInputAction.next,
final ValueChanged<String>? onSubmitted, final ValueChanged<String>? onSubmitted,
super.key, super.key,
@ -62,6 +66,9 @@ class _PasswordFieldState() extends State<PasswordField> {
context, context,
InputDecoration( InputDecoration(
labelText: widget.label, labelText: widget.label,
helperText: widget.showsMinimum
? l10n.passwordMinLength(newPasswordMinLength)
: null,
errorText: widget.errorText, errorText: widget.errorText,
suffixIcon: IconButton( suffixIcon: IconButton(
tooltip: _shown ? l10n.passwordHide : l10n.passwordShow, tooltip: _shown ? l10n.passwordHide : l10n.passwordShow,

View file

@ -92,6 +92,14 @@ class FakeAccountCrypto() implements AccountCrypto {
} }
} }
/// As the device checks a new password: at least 12 characters.
static void _checkNew(String password) {
_check(password);
if (password.runes.length < 12) {
throw const PasswordFailed(PasswordProblem.passwordTooShort);
}
}
NewAccountMaterial _material( NewAccountMaterial _material(
String password, String password,
UnlockedAccountKeys keys, UnlockedAccountKeys keys,
@ -113,7 +121,7 @@ class FakeAccountCrypto() implements AccountCrypto {
@override @override
Future<NewAccountMaterial> newAccount(String password) async { Future<NewAccountMaterial> newAccount(String password) async {
_check(password); _checkNew(password);
return _material(password, keysNumbered(10 * ++_accounts), fakePhrase); return _material(password, keysNumbered(10 * ++_accounts), fakePhrase);
} }
@ -123,7 +131,7 @@ class FakeAccountCrypto() implements AccountCrypto {
UnlockedAccountKeys keys, { UnlockedAccountKeys keys, {
required String phrase, required String phrase,
}) async { }) async {
_check(password); _checkNew(password);
return _material(password, keys, phrase); return _material(password, keys, phrase);
} }
@ -171,7 +179,7 @@ class FakeAccountCrypto() implements AccountCrypto {
String password, String password,
UnlockedAccountKeys keys, UnlockedAccountKeys keys,
) async { ) async {
_check(password); _checkNew(password);
return protocol.NewPassword( return protocol.NewPassword(
salt: ByteData(16), salt: ByteData(16),
costs: _costs, costs: _costs,

View file

@ -51,7 +51,7 @@ void main() {
Future<void> withPhrase({ Future<void> withPhrase({
String phrase = fakePhraseForTests, String phrase = fakePhraseForTests,
String password = 'new horse', String password = 'a new horse here',
String? again, String? again,
String code = fakeTotpCode, String code = fakeTotpCode,
}) => cubit.phraseEntered( }) => cubit.phraseEntered(
@ -86,7 +86,7 @@ void main() {
at(AccountRecoveryStep.done), at(AccountRecoveryStep.done),
], ],
); );
expect(anna.password, 'new horse'); expect(anna.password, 'a new horse here');
expect(anna.passwordKeys, isNot(same(keysBefore))); expect(anna.passwordKeys, isNot(same(keysBefore)));
expect( expect(
auth.status, auth.status,

View file

@ -78,6 +78,11 @@ void main() {
); );
expect(cubit.state.address, 'anna@example.org'); expect(cubit.state.address, 'anna@example.org');
// Shorter than 12 characters: refused on the device, nothing sent.
await cubit.passwordEntered('short horse', 'short horse');
expect(cubit.state.step, AccountSetupStep.password);
expect(cubit.state.problem?.problem, PasswordProblem.passwordTooShort);
expect(gateway.calls, isEmpty);
await cubit.passwordEntered('correct horse', 'correct hrose'); await cubit.passwordEntered('correct horse', 'correct hrose');
expect(cubit.state.passwordsDiffer, isTrue); expect(cubit.state.passwordsDiffer, isTrue);
expect(gateway.calls, isEmpty); expect(gateway.calls, isEmpty);
@ -263,7 +268,7 @@ void main() {
address: 'anna@example.org', address: 'anna@example.org',
); );
expect(cubit.state, at(AccountSetupStep.password)); expect(cubit.state, at(AccountSetupStep.password));
await cubit.passwordEntered('new horse', 'new horse'); await cubit.passwordEntered('a new horse here', 'a new horse here');
expect(cubit.state, at(AccountSetupStep.phrase)); expect(cubit.state, at(AccountSetupStep.phrase));
cubit.phraseWrittenDown(); cubit.phraseWrittenDown();
await cubit.phraseChecked(wordsAsked(cubit.state)); await cubit.phraseChecked(wordsAsked(cubit.state));
@ -283,7 +288,7 @@ void main() {
await cubit.recoveryCodesKept(); await cubit.recoveryCodesKept();
expect(cubit.state, at(AccountSetupStep.done)); expect(cubit.state, at(AccountSetupStep.done));
expect(account.password, 'new horse'); expect(account.password, 'a new horse here');
expect( expect(
auth.status, auth.status,
SignedIn(authUserId: account.person, isIdentified: true), SignedIn(authUserId: account.person, isIdentified: true),
@ -307,7 +312,7 @@ void main() {
expect( expect(
await statesOf( await statesOf(
cubit, cubit,
() => cubit.passwordEntered('new horse', 'new horse'), () => cubit.passwordEntered('a new horse here', 'a new horse here'),
), ),
[ [
// A cubit's first state goes out even when it equals the one it // A cubit's first state goes out even when it equals the one it
@ -324,7 +329,7 @@ void main() {
expect(cubit.state, at(AccountSetupStep.done)); expect(cubit.state, at(AccountSetupStep.done));
expect(Uint8List.sublistView(account.encryptionPublicKey), keysBefore); expect(Uint8List.sublistView(account.encryptionPublicKey), keysBefore);
expect(account.password, 'new horse'); expect(account.password, 'a new horse here');
}), }),
); );
}); });

View file

@ -196,7 +196,7 @@ void main() {
await expectLater( await expectLater(
passwords.signUp( passwords.signUp(
await passwords.makePassword('another'), await passwords.makePassword('another password'),
address: 'anna@example.org', address: 'anna@example.org',
request: request, request: request,
code: gateway.codeFor('anna@example.org'), code: gateway.codeFor('anna@example.org'),
@ -368,7 +368,7 @@ void main() {
passwords.resetPassword( passwords.resetPassword(
address: 'anna@example.org', address: 'anna@example.org',
phrase: otherFakePhrase, phrase: otherFakePhrase,
newPassword: 'new', newPassword: 'a new password',
totpCode: fakeTotpCode, totpCode: fakeTotpCode,
), ),
failsWith(PasswordProblem.phraseDoesNotFit), failsWith(PasswordProblem.phraseDoesNotFit),
@ -377,7 +377,7 @@ void main() {
passwords.resetPassword( passwords.resetPassword(
address: 'anna@example.org', address: 'anna@example.org',
phrase: 'too short', phrase: 'too short',
newPassword: 'new', newPassword: 'a new password',
totpCode: fakeTotpCode, totpCode: fakeTotpCode,
), ),
failsWith(PasswordProblem.notAPhrase), failsWith(PasswordProblem.notAPhrase),
@ -386,11 +386,11 @@ void main() {
final pending = await passwords.resetPassword( final pending = await passwords.resetPassword(
address: 'anna@example.org', address: 'anna@example.org',
phrase: fakePhrase, phrase: fakePhrase,
newPassword: 'new', newPassword: 'a new password',
totpCode: fakeTotpCode, totpCode: fakeTotpCode,
); );
expect(anna.password, 'new'); expect(anna.password, 'a new password');
expect(pending.keys, crypto.keysNumbered(200)); expect(pending.keys, crypto.keysNumbered(200));
}); });

View file

@ -92,6 +92,32 @@ void main() {
); );
}); });
test(
'a new password has at least 12 characters, for a new account, a '
'reset keeping the keys and a change alike; signing in takes any',
() async {
Matcher tooShort() => throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.passwordTooShort,
),
);
await expectLater(crypto.newAccount('eleven char'), tooShort());
await expectLater(
crypto.keepingKeys('eleven char', account.keys, phrase: account.phrase),
tooShort(),
);
await expectLater(
crypto.newPassword('eleven char', account.keys),
tooShort(),
);
// A password set before the minimum (none outside development) still
// unlocks: unlocking checks no length.
expect(await unlock('short'), isA<PasswordUnlock>());
},
);
test('a wrong password opens nothing; an empty one is refused', () async { test('a wrong password opens nothing; an empty one is refused', () async {
await expectLater( await expectLater(
open( open(

View file

@ -259,8 +259,8 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(field('Email address'), 'anna@example.org'); await tester.enterText(field('Email address'), 'anna@example.org');
await tester.enterText(field('Recovery phrase'), fakePhrase); await tester.enterText(field('Recovery phrase'), fakePhrase);
await tester.enterText(field('New password'), 'remembered'); await tester.enterText(field('New password'), 'remembered at last');
await tester.enterText(field('New password again'), 'remembered'); await tester.enterText(field('New password again'), 'remembered at last');
await tester.enterText( await tester.enterText(
field('Code from your authenticator app'), field('Code from your authenticator app'),
fakeTotpCode, fakeTotpCode,
@ -268,7 +268,7 @@ void main() {
await tapButton(tester, 'Sign in'); await tapButton(tester, 'Sign in');
expect(find.text('App of ${anna.person}'), findsOneWidget); expect(find.text('App of ${anna.person}'), findsOneWidget);
expect(anna.password, 'remembered'); expect(anna.password, 'remembered at last');
}); });
/// Signs in as far as the code: the sign-in sheet, [address] and /// Signs in as far as the code: the sign-in sheet, [address] and
@ -429,8 +429,8 @@ void main() {
); );
await tapButton(tester, 'Continue'); await tapButton(tester, 'Continue');
await tester.enterText(field('New password'), 'a new start'); await tester.enterText(field('New password'), 'a new start again');
await tester.enterText(field('New password again'), 'a new start'); await tester.enterText(field('New password again'), 'a new start again');
await tapButton(tester, 'Continue'); await tapButton(tester, 'Continue');
expect(find.text('Your recovery phrase'), findsOneWidget); expect(find.text('Your recovery phrase'), findsOneWidget);
await tapButton(tester, "I've written it down"); await tapButton(tester, "I've written it down");
@ -449,7 +449,7 @@ void main() {
await tapButton(tester, "I've kept them"); await tapButton(tester, "I've kept them");
expect(find.text('App of ${anna.person}'), findsOneWidget); expect(find.text('App of ${anna.person}'), findsOneWidget);
expect(anna.password, 'a new start'); expect(anna.password, 'a new start again');
expect(anna.passwordKeys, isNot(same(keysBefore))); expect(anna.passwordKeys, isNot(same(keysBefore)));
expect(anna.accountReset, isNull); expect(anna.accountReset, isNull);
expect(anna.finished, isTrue); expect(anna.finished, isTrue);

View file

@ -14,7 +14,12 @@ export 'src/key_refused.dart';
export 'src/password_keys.dart' export 'src/password_keys.dart'
show PasswordKdf, PasswordKeys, derivePasswordKeys, newPasswordSalt; show PasswordKdf, PasswordKeys, derivePasswordKeys, newPasswordSalt;
export 'src/password_text.dart' export 'src/password_text.dart'
show PasswordRefusal, PasswordRefused, normalizePassword; show
PasswordRefusal,
PasswordRefused,
checkNewPassword,
newPasswordMinLength,
normalizePassword;
export 'src/recovery_phrase.dart' export 'src/recovery_phrase.dart'
show show
entropyOfRecoveryPhrase, entropyOfRecoveryPhrase,

View file

@ -26,6 +26,25 @@ String normalizePassword(String password) {
return normalized; return normalized;
} }
/// The fewest characters (Unicode code points, once normalised) a new
/// password has (decided 20 Oct 2026, at the second reading): should the
/// register's database leak, its verifier and the keys wrapped under it
/// fall to guessing, at Argon2id's cost per guess, and the register can't
/// check a password it never sees.
const newPasswordMinLength = 12;
/// [password] as [normalizePassword] takes it, for a new password: refused
/// ([PasswordRefused], [PasswordRefusal.tooShort]) below
/// [newPasswordMinLength] characters too. Signing in takes any length: the
/// minimum holds only where a password is set.
String checkNewPassword(String password) {
final normalized = normalizePassword(password);
if (normalized.runes.length < newPasswordMinLength) {
throw const PasswordRefused(PasswordRefusal.tooShort);
}
return normalized;
}
/// Unicode's spaces (category Zs) other than U+0020. /// Unicode's spaces (category Zs) other than U+0020.
const _otherSpaces = { const _otherSpaces = {
0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, // 0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, //
@ -52,4 +71,7 @@ enum PasswordRefusal() {
/// It holds a control character (a tab, a line break, …), which no /// It holds a control character (a tab, a line break, …), which no
/// keyboard types the same way everywhere. /// keyboard types the same way everywhere.
controlCharacter, controlCharacter,
/// A new password shorter than [newPasswordMinLength].
tooShort,
} }

Some files were not shown because too many files have changed in this diff Show more