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:
parent
5fb28bd817
commit
e126e1d4fb
13 changed files with 126 additions and 4 deletions
|
|
@ -207,7 +207,14 @@ released, stays at 1), after the second reading.
|
|||
part (`AccountSecuritySection`): setting up a password (anonymous people, GitHub's, an
|
||||
account with only an address), finishing the setup, the authenticators (adding one
|
||||
takes a code from one before), new recovery codes, changing the password, a new
|
||||
phrase; GitHub's end date.
|
||||
phrase (with the password and a code); GitHub's end date.
|
||||
- **The device holds the costs to the register's bounds:** Argon2id's costs an instance
|
||||
names (as it begins a sign-in or a change) are refused below the floor (3 passes,
|
||||
64 MiB: guessing would be cheaper) and above the ceiling (16 passes, 1 GiB, the
|
||||
register's own: a hostile instance could ask for any memory and crash the app), before
|
||||
anything is derived or sent (`PasswordKdf.maximum`, `KeyRefusal.excessivePasswordKdf`;
|
||||
the app's `unsafeCosts`, found at step 3's review, 20 Oct). The two bounds move
|
||||
together with the register's.
|
||||
- **The keys on the device:** unlocked after signing in, kept in the system's keyring
|
||||
per instance and person (flutter_secure_storage), gone once another person signs in or
|
||||
nobody, and with the instance's removal from the device. Anonymous people's device keys
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,32 @@ void main() {
|
|||
signingPublicKey: account.keys.signingPublicKey,
|
||||
);
|
||||
|
||||
test("refuses costs an instance mustn't ask for, before deriving "
|
||||
'anything: below the floor, or beyond what a device can spare', () async {
|
||||
for (final costs in [
|
||||
protocol.PasswordCosts(
|
||||
algorithm: 'argon2id13',
|
||||
opsLimit: 1,
|
||||
memLimit: 8 * 1024 * 1024,
|
||||
),
|
||||
protocol.PasswordCosts(
|
||||
algorithm: 'argon2id13',
|
||||
opsLimit: 3,
|
||||
memLimit: 100 * 1024 * 1024 * 1024,
|
||||
),
|
||||
]) {
|
||||
await expectLater(
|
||||
crypto.unlock(
|
||||
'correct horse battery staple',
|
||||
salt: Uint8List.sublistView(account.setup.salt),
|
||||
costs: costs,
|
||||
),
|
||||
failsWith(PasswordProblem.unsafeCosts),
|
||||
reason: '${costs.memLimit}',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a new account: what the register takes, its keys opening with the '
|
||||
'password on another device, the verifier made of the same x', () async {
|
||||
final setup = account.setup;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ enum KeyRefusal() {
|
|||
/// have, or of another algorithm.
|
||||
weakPasswordKdf,
|
||||
|
||||
/// Argon2id's costs named for an account are above what any account may
|
||||
/// have: more memory or passes than a device may be asked to spend.
|
||||
excessivePasswordKdf,
|
||||
|
||||
/// A recovery phrase of another length than BIP-39's.
|
||||
phraseLength,
|
||||
|
||||
|
|
|
|||
|
|
@ -34,14 +34,28 @@ final class const PasswordKdf({
|
|||
required final int memLimit,
|
||||
}) {
|
||||
/// The costs [json] names; refused ([KeyRefused]) for another algorithm or
|
||||
/// costs below [minimum].
|
||||
/// costs below [minimum] ([KeyRefusal.weakPasswordKdf]), and for costs
|
||||
/// above [maximum] ([KeyRefusal.excessivePasswordKdf]).
|
||||
factory fromJson(Map<String, Object?> json) => switch (json) {
|
||||
{
|
||||
'algorithm': 'argon2id13',
|
||||
'opsLimit': final int opsLimit,
|
||||
'memLimit': final int memLimit,
|
||||
}
|
||||
when opsLimit >= minimum.opsLimit && memLimit >= minimum.memLimit =>
|
||||
when opsLimit < minimum.opsLimit || memLimit < minimum.memLimit =>
|
||||
throw const KeyRefused(KeyRefusal.weakPasswordKdf),
|
||||
{
|
||||
'algorithm': 'argon2id13',
|
||||
'opsLimit': final int opsLimit,
|
||||
'memLimit': final int memLimit,
|
||||
}
|
||||
when opsLimit > maximum.opsLimit || memLimit > maximum.memLimit =>
|
||||
throw const KeyRefused(KeyRefusal.excessivePasswordKdf),
|
||||
{
|
||||
'algorithm': 'argon2id13',
|
||||
'opsLimit': final int opsLimit,
|
||||
'memLimit': final int memLimit,
|
||||
} =>
|
||||
PasswordKdf(opsLimit: opsLimit, memLimit: memLimit),
|
||||
_ => throw const KeyRefused(KeyRefusal.weakPasswordKdf),
|
||||
};
|
||||
|
|
@ -51,6 +65,15 @@ final class const PasswordKdf({
|
|||
/// below it any more, or those accounts no longer open.
|
||||
static const minimum = PasswordKdf(opsLimit: 3, memLimit: 64 * 1024 * 1024);
|
||||
|
||||
/// The ceiling no account's costs may go above, the register's own
|
||||
/// (halkyon_base_server's `passwords.dart`): a register asking for more
|
||||
/// would have the device run out of memory, or work for minutes, at every
|
||||
/// sign-in. Moved together with the register's.
|
||||
static const maximum = PasswordKdf(
|
||||
opsLimit: 16,
|
||||
memLimit: 1024 * 1024 * 1024,
|
||||
);
|
||||
|
||||
/// What new passwords take: 64 MiB and 3 passes, a second or so on a
|
||||
/// phone. Raising it leaves accounts made before at their own costs, at
|
||||
/// or above [minimum].
|
||||
|
|
|
|||
|
|
@ -103,6 +103,34 @@ Future<void> main() async {
|
|||
expect(read.memLimit, PasswordKdf.minimum.memLimit);
|
||||
});
|
||||
|
||||
test("the ceiling itself is read: the register's own, 16 passes and "
|
||||
'1 GiB', () {
|
||||
final read = PasswordKdf.fromJson(PasswordKdf.maximum.toJson());
|
||||
expect(read.opsLimit, 16);
|
||||
expect(read.memLimit, 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('above the ceiling are refused: a register asking for more would '
|
||||
'make the device run out of memory, or time', () {
|
||||
for (final json in <Map<String, Object?>>[
|
||||
{'algorithm': 'argon2id13', 'opsLimit': 17, 'memLimit': 64 << 20},
|
||||
{'algorithm': 'argon2id13', 'opsLimit': 3, 'memLimit': (1 << 30) + 1},
|
||||
{'algorithm': 'argon2id13', 'opsLimit': 3, 'memLimit': 100 << 30},
|
||||
]) {
|
||||
expect(
|
||||
() => PasswordKdf.fromJson(json),
|
||||
throwsA(
|
||||
isA<KeyRefused>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
KeyRefusal.excessivePasswordKdf,
|
||||
),
|
||||
),
|
||||
reason: '$json',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trip', () {
|
||||
const raised = PasswordKdf(opsLimit: 4, memLimit: 128 * 1024 * 1024);
|
||||
final json = raised.toJson();
|
||||
|
|
|
|||
Loading…
Reference in a new issue