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:
parent
ae41937e0b
commit
24d46a8503
6 changed files with 246 additions and 37 deletions
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ Future<AccountPassword> _proveOwner(
|
|||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
purpose: SignInChallengePurpose.change,
|
||||
);
|
||||
final row = proved.password;
|
||||
if (row.authUserId != person) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
Loading…
Reference in a new issue