fix(auth): every proof counts toward the address as it is checked

The ceiling of 30 proofs an hour per address was looked at only when a
sign-in began and counted only wrong passwords (the re-review's second
finding): sign-ins begun ahead from many networks got 60 wrong passwords
checked in an hour, and a right password, not counted, could be told from
a wrong one by whether the next sign-in still began. Every proof for an
address, of a password or of the recovery phrase, is now recorded before it
is checked, whatever its outcome, and refused past 30 an hour
(tooManyAttempts); the check at begin stays as an early refusal.

A change's proof is no longer counted toward the address (the fifth
finding: strangers' wrong passwords held a signed-in owner out of changing
the password, a new phrase and deletion). Its challenge has a purpose of
its own (change), counted per account as it begins (10 an hour), taken by
no sign-in, and a change takes no sign-in's challenge.

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 22:44:02 +02:00
commit 24d46a8503
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 246 additions and 37 deletions

View file

@ -18,7 +18,12 @@ enum SignInChallengePurpose implements _is.SerializableModel {
password,
/// Proving the recovery phrase by signing a nonce with the account's key.
keyProof;
keyProof,
/// Proving the password while signed in, to change it, set a new phrase
/// or delete the account: counted per account, never toward the
/// address's proofs, so strangers' guesses hold no signed-in owner back.
change;
static SignInChallengePurpose fromJson(String name) {
switch (name) {
@ -26,6 +31,8 @@ enum SignInChallengePurpose implements _is.SerializableModel {
return SignInChallengePurpose.password;
case 'keyProof':
return SignInChallengePurpose.keyProof;
case 'change':
return SignInChallengePurpose.change;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SignInChallengePurpose"',

View file

@ -190,6 +190,7 @@ Future<AccountPassword> _proveOwner(
clientValue: clientValue,
clientProof: clientProof,
host: host,
purpose: SignInChallengePurpose.change,
);
final row = proved.password;
if (row.authUserId != person) {

View file

@ -85,7 +85,7 @@ const wrappedKeysLength = 32 + 64 + 16;
/// an hour: every one is a guess at the password and the code. Counted per
/// address and network (the second reading's M3), so whoever knows an
/// address and fills its count from their network holds back no other
/// network, the owner's among them; [_failedProofsPerAddress] bounds the
/// network, the owner's among them; [_proofsPerAddress] bounds the
/// guesses many networks together make.
final _beginsPerAddressAndNetwork = DatabaseRateLimiter(
RateLimiterConfig(
@ -96,20 +96,25 @@ final _beginsPerAddressAndNetwork = DatabaseRateLimiter(
),
);
/// How many proofs for one address fail in an hour, from every network
/// (wrong passwords, and recovery phrases' signatures by another key, an
/// address nobody has alike): past it, no sign-in or proof begins for the
/// address anywhere until the hour has passed. 30: an owner rarely fails
/// once, so only guessing reaches it; whoever guesses from many networks
/// How many proofs for one address are checked in an hour, from every
/// network, whatever their outcome (wrong passwords, right ones, recovery
/// phrases' signatures, an address nobody has alike): each is recorded
/// before it is checked, so sign-ins begun ahead from many networks get no
/// more checked (the re-review's second finding), and a right password
/// counts as a wrong one, so reaching the ceiling tells nobody which it
/// was. Past it, no proof is checked and no sign-in begins for the address
/// anywhere until the hour has passed. 30: an owner rarely proves twice an
/// hour, so only guessing reaches it; whoever guesses from many networks
/// makes at most 30 guesses an hour (720 a day) against a password of 12
/// characters or more, with TOTP behind it; and holding an owner out takes
/// failing 30 times an hour, from at least three networks, without end.
/// Longer windows would outlive the hourly pruning of the limits' notes
/// (`forgetOldSignUpAttempts`).
final _failedProofsPerAddress = DatabaseRateLimiter(
/// 30 proofs an hour, from at least three networks, without end. Longer
/// windows would outlive the hourly pruning of the limits' notes
/// (`forgetOldSignUpAttempts`). Proofs while signed in (a change, a new
/// phrase, deletion) count per account instead ([_changesBegunPerAccount]).
final _proofsPerAddress = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'password_sign_in',
source: 'failed_proofs_per_address',
source: 'proofs_per_address',
maxAttempts: 30,
timeframe: const Duration(hours: 1),
),
@ -138,8 +143,8 @@ final _changesBegunPerAccount = DatabaseRateLimiter(
);
/// How many proofs of the recovery phrase one address begins from one
/// network in an hour; failed ones count toward [_failedProofsPerAddress]
/// too.
/// network in an hour; every proof checked counts toward
/// [_proofsPerAddress] too.
final _keyProofsPerAddressAndNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'password_sign_in',
@ -149,18 +154,23 @@ final _keyProofsPerAddressAndNetwork = DatabaseRateLimiter(
),
);
/// Refused ([PasswordRefusal.tooManyAttempts]) once [hash]'s address failed
/// [_failedProofsPerAddress]' proofs in the last hour.
Future<void> _refuseAddressFailingTooOften(Session session, String hash) async {
if (await _failedProofsPerAddress.countAttempts(session, key: hash) >=
_failedProofsPerAddress.config.maxAttempts) {
/// Refused ([PasswordRefusal.tooManyAttempts]) once [hash]'s address had
/// [_proofsPerAddress]' proofs checked in the last hour: an early refusal,
/// before a sign-in or a proof begins.
Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
if (await _proofsPerAddress.countAttempts(session, key: hash) >=
_proofsPerAddress.config.maxAttempts) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
}
/// Counts a proof for [hash]'s address that failed.
Future<void> _countFailedProof(Session session, String hash) =>
_failedProofsPerAddress.tryRecordAttempt(session, key: hash);
/// Counts a proof for [hash]'s address about to be checked; refused
/// ([PasswordRefusal.tooManyAttempts]) past [_proofsPerAddress]' ceiling.
Future<void> _countProof(Session session, String hash) async {
if (!await _proofsPerAddress.tryRecordAttempt(session, key: hash)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
}
PasswordRefusedException refusedFor(PasswordRefusal reason) =>
PasswordRefusedException(reason: reason);
@ -575,7 +585,7 @@ Future<PasswordChallenge> beginPasswordSignIn(
final pod = session.serverpod;
final keys = requirePasswordKeys(pod);
final hash = _hashOfTyped(pod, address);
await _refuseAddressFailingTooOften(session, hash);
await _refuseAddressProvingTooOften(session, hash);
// The address first: a sign-in it can't begin takes nothing of the
// network's.
if (!await _beginsPerAddressAndNetwork.tryRecordAttempt(
@ -703,9 +713,13 @@ Future<PasswordProof> completePasswordSignIn(
/// The first step of [person] proving their password while signed in, to
/// change it (`changePassword`): SRP's B for their account, with its salt
/// and costs; counted with the sign-ins begun for their address (10 an
/// hour, [PasswordRefusal.tooManyAttempts]). Refused for someone without a
/// password ([PasswordRefusal.noPassword]).
/// and costs; at most 10 begun an hour per account
/// ([PasswordRefusal.tooManyAttempts]), never counted toward the address's
/// proofs, so no stranger's guesses at the address hold a signed-in owner
/// back (the re-review's fifth finding). Its challenge is for a change
/// alone ([SignInChallengePurpose.change]): no sign-in takes it, and a
/// change takes no sign-in's. Refused for someone without a password
/// ([PasswordRefusal.noPassword]).
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
@ -720,7 +734,6 @@ Future<PasswordChallenge> beginPasswordChange(
if (identity == null || password == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
await _refuseAddressFailingTooOften(session, identity.addressHash);
if (!await _changesBegunPerAccount.tryRecordAttempt(
session,
key: person.uuid,
@ -736,7 +749,7 @@ Future<PasswordChallenge> beginPasswordChange(
session,
SignInChallenge(
authUserId: person,
purpose: SignInChallengePurpose.password,
purpose: SignInChallengePurpose.change,
addressHash: identity.addressHash,
secret: challenge.secret.toRadixString(16),
serverValue: challenge.publicValue.toRadixString(16),
@ -775,12 +788,14 @@ Future<PasswordProved> provePassword(
required ByteData clientValue,
required ByteData clientProof,
required String host,
SignInChallengePurpose purpose = SignInChallengePurpose.password,
}) async {
final kept = await takeChallenge(
session,
challenge,
SignInChallengePurpose.password,
);
final kept = await takeChallenge(session, challenge, purpose);
// Counted before it is checked, whatever its outcome (a change's per
// account, when it began).
if (purpose == SignInChallengePurpose.password) {
await _countProof(session, kept.addressHash);
}
final decoy = _decoyAccount(keys.decoyKey, kept.addressHash);
final password = await _passwordOf(session, kept.authUserId);
final clientBytes = Uint8List.sublistView(clientValue);
@ -812,7 +827,6 @@ Future<PasswordProved> provePassword(
addressHash: kept.addressHash,
);
}
await _countFailedProof(session, kept.addressHash);
throw refusedFor(PasswordRefusal.wrongCredentials);
}
@ -1096,7 +1110,7 @@ Future<KeyProofChallenge> beginKeyProof(
final pod = session.serverpod;
final keys = requirePasswordKeys(pod);
final hash = _hashOfTyped(pod, address);
await _refuseAddressFailingTooOften(session, hash);
await _refuseAddressProvingTooOften(session, hash);
if (!await _keyProofsPerAddressAndNetwork.tryRecordAttempt(
session,
key: '$hash|$network',
@ -1163,6 +1177,8 @@ Future<AccountPassword> proveRecoveryPhrase(
challenge,
SignInChallengePurpose.keyProof,
);
// Counted before it is checked, whatever its outcome.
await _countProof(session, kept.addressHash);
// Checked against the decoy's key for an address nobody has (or an
// account keeping no proof key), so it takes as long.
final decoy = await _decoyKeys(keys.decoyKey, kept.addressHash);
@ -1182,7 +1198,6 @@ Future<AccountPassword> proveRecoveryPhrase(
: Uint8List.sublistView(proofKey),
);
if (!signed || password == null || proofKey == null) {
await _countFailedProof(session, kept.addressHash);
throw refusedFor(PasswordRefusal.wrongCredentials);
}
return password;

View file

@ -6,3 +6,7 @@ values:
- password
### Proving the recovery phrase by signing a nonce with the account's key.
- keyProof
### Proving the password while signed in, to change it, set a new phrase
### or delete the account: counted per account, never toward the
### address's proofs, so strangers' guesses hold no signed-in owner back.
- change

View file

@ -290,6 +290,89 @@ void main() {
expect(members.sessionsTold, isEmpty);
});
test("is held back by no stranger's wrong passwords for the "
"owner's address", () async {
for (var i = 0; i < 30; i++) {
await atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
// From many networks, as a stranger would.
final answer = device.answer(
challenge,
privateValue: BigInt.from(4242),
);
await expectLater(
endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '000000',
host: testHost,
address: 'anna@example.org',
language: 'en',
),
refused(PasswordRefusal.wrongCredentials),
);
});
if (i % 10 == 9) {
// Past a network's ten an hour: the next hour, from scratch.
now = now.add(const Duration(minutes: 1));
await RateLimitedRequestAttempt.db.deleteWhere(
session,
where: (t) => t.source.equals('begins_per_address_and_network'),
);
}
}
await change();
});
test("takes a change's challenge alone, and a sign-in takes none of "
'those', () async {
final signInChallenge = await atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
),
);
await expectLater(
atNow(() async {
final answer = device.answer(signInChallenge);
await endpoints.password.change(
onPhone(),
challenge: signInChallenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
password: device.newPassword(),
host: testHost,
);
}),
refused(PasswordRefusal.challengeExpired),
);
final changeChallenge = await atNow(
() => endpoints.password.beginChange(onPhone()),
);
await expectLater(
atNow(() async {
final answer = device.answer(changeChallenge);
await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: changeChallenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
}),
refused(PasswordRefusal.challengeExpired),
);
});
test('is begun only by someone with a password', () async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);

View file

@ -2017,6 +2017,98 @@ void main() {
);
});
test('counts every proof as it is checked: of sign-ins begun ahead '
'from many networks, at most 30 an hour are checked', () async {
await annaComplete();
final challenges = [
for (var network = 0; network < 6; network++)
for (var i = 0; i < 10; i++)
await atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '203.0.113.$network',
),
),
];
final reasons = <PasswordRefusal>[];
for (final challenge in challenges) {
final answer = device.answer(
challenge,
privateValue: BigInt.from(4242),
);
try {
await atNow(
() => endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '000000',
host: testHost,
address: 'anna@example.org',
language: 'en',
),
);
} on PasswordRefusedException catch (refusal) {
reasons.add(refusal.reason);
}
}
expect(
reasons.where((r) => r == PasswordRefusal.wrongCredentials),
hasLength(30),
);
expect(
reasons.where((r) => r == PasswordRefusal.tooManyAttempts),
hasLength(30),
);
});
test('counts a right password as a wrong one: the ceiling tells '
'neither', () async {
await annaComplete();
Future<void> proveFrom(String network, {required bool right}) =>
atNow(() async {
final challenge = await beginPasswordSignIn(
session,
address: 'anna@example.org',
network: network,
);
final answer = device.answer(
challenge,
privateValue: right ? null : BigInt.from(4242),
);
await expectLater(
endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: '000000',
host: testHost,
address: 'anna@example.org',
language: 'en',
),
refused(PasswordRefusal.wrongCredentials),
);
});
for (var i = 0; i < 29; i++) {
await proveFrom('203.0.113.${i ~/ 10}', right: false);
}
// The right password with a wrong code: counted all the same.
await proveFrom('198.51.100.1', right: true);
await expectLater(
atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '198.51.100.2',
),
),
refused(PasswordRefusal.tooManyAttempts),
);
});
test(
'counts at most 5 proofs begun for an address from a network '
'an hour; another network begins its own, and failed proofs '