fix(keys): a device refuses Argon2id costs beyond the register's ceiling

PasswordKdf.fromJson refused costs below the floor but took any above it,
so an instance could ask a device for 100 GiB and crash the app at every
sign-in. It now refuses more than the register's own ceiling (16 passes,
1 GiB; KeyRefusal.excessivePasswordKdf), and the app reads either refusal,
before deriving or sending anything, as PasswordProblem.unsafeCosts with
a text of its own (before, costs below the floor read as keys missing).

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 20:04:37 +02:00
commit e126e1d4fb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 126 additions and 4 deletions

View file

@ -205,5 +205,6 @@
"passwordUnusable": "Dieses Passwort geht nicht: Es ist leer oder enthält Zeichen, die kein Text sind.",
"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.",
"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."
}

View file

@ -431,5 +431,6 @@
"passwordUnusable": "This password can't be used: it's empty, or holds characters that aren't text.",
"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.",
"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."
}

View file

@ -1333,6 +1333,12 @@ abstract class HalkyonAppLocalizations {
/// **'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.'**
String get passwordKeysMissing;
/// No description provided for @passwordUnsafeCosts.
///
/// In en, this message translates to:
/// **'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.'**
String get passwordUnsafeCosts;
/// No description provided for @passwordFailed.
///
/// In en, this message translates to:

View file

@ -766,6 +766,10 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
String get passwordKeysMissing =>
'Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.';
@override
String get 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.';
@override
String get passwordFailed =>
'Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.';

View file

@ -749,6 +749,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
String get passwordKeysMissing =>
'Your account\'s keys aren\'t on this device. Sign out and in again with your password here.';
@override
String get 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.';
@override
String get passwordFailed =>
'Couldn\'t reach the instance. Check the connection and try again.';

View file

@ -62,7 +62,7 @@ class SodiumAccountCrypto() implements AccountCrypto {
}) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final kdf = PasswordKdf.fromJson(costs.toJson());
final kdf = _kdfOf(costs);
final unlocked = derivePasswordKeys(
sodium,
password: password,
@ -250,6 +250,18 @@ class SodiumAccountCrypto() implements AccountCrypto {
);
}
/// Argon2id's [costs] as an instance named them, refused
/// ([PasswordProblem.unsafeCosts]) outside the bounds every account is
/// within, before anything is derived: below them guessing the password
/// would be cheaper, above them the device would run out of memory.
PasswordKdf _kdfOf(protocol.PasswordCosts costs) {
try {
return PasswordKdf.fromJson(costs.toJson());
} on KeyRefused {
throw const PasswordFailed(PasswordProblem.unsafeCosts);
}
}
/// What [step] gives, a password libsodium can't take ([PasswordRefused])
/// refused as [PasswordProblem.passwordUnusable] and keys that don't
/// rebuild as [PasswordProblem.keysMissing]: on the isolate, so the

View file

@ -91,6 +91,11 @@ enum PasswordProblem() {
/// keys don't open: nothing it said is trusted.
instanceUnproven,
/// The instance named costs for the password no account may have (below
/// the floor, which would make guessing it cheaper, or beyond what a
/// device can spare): nothing was derived or sent.
unsafeCosts,
/// The account's keys aren't on this device: sign in again here.
keysMissing,

View file

@ -43,6 +43,7 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
PasswordProblem.phraseDoesNotFit => l10n.passwordPhraseDoesNotFit,
PasswordProblem.passwordUnusable => l10n.passwordUnusable,
PasswordProblem.instanceUnproven => l10n.passwordInstanceUnproven,
PasswordProblem.unsafeCosts => l10n.passwordUnsafeCosts,
PasswordProblem.keysMissing => l10n.passwordKeysMissing,
PasswordProblem.failed => l10n.passwordFailed,
};