fix(auth): limits by address and network, so nobody holds an owner out

Anyone who knew an address could spend its whole hour: 10 sign-ins, 5
phrase proofs and 3 codes, each new code voiding the last (the second
reading's M3). Now:
- sign-ins begun count per address and network (10 an hour), phrase
  proofs likewise (5), and networks as before (30);
- failed proofs count per address from every network: past 30 an hour
  (wrong passwords, phrases' signatures by another key, an address nobody
  has alike) no sign-in or proof begins for it anywhere, so guessing from
  many networks makes at most 30 guesses an hour, while holding an owner
  out takes failing without end from at least three; longer windows
  would outlive the hourly pruning of the limits' notes;
- a signed-in change of password begins 10 times an hour per account;
- codes by mail count per address and network (3 an hour), per address
  from every network (10, no mailbox flooded) and per network (10), and
  up to 3 are valid at once, a fourth voiding the oldest, so codes others
  ask for don't void the one its owner is typing.

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:36:02 +02:00
commit 26488b40ce
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 300 additions and 131 deletions

View file

@ -86,16 +86,35 @@ String addressHash(Serverpod pod, String address) {
).convert(utf8.encode('email-address:$address')).toString();
}
/// How many codes one address gets in an hour.
final _codesPerAddress = DatabaseRateLimiter(
/// How many codes one network asks for one address in an hour. 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.
final _codesPerAddressAndNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
source: 'codes_per_address',
source: 'codes_per_address_and_network',
maxAttempts: 3,
timeframe: const Duration(hours: 1),
),
);
/// How many codes are mailed to one address in an hour, from every
/// network: no mailbox is flooded.
final _codesPerAddress = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'email_sign_in',
source: 'codes_per_address',
maxAttempts: 10,
timeframe: const Duration(hours: 1),
),
);
/// How many codes for one address are valid at once: one asked for later
/// by someone else doesn't void the one its owner is typing, until more
/// than this many follow it within its 15 minutes.
const liveCodesPerAddress = 3;
/// How many codes one network (the visitor's IP address, `networkOf`) asks
/// for in an hour.
final _codesPerNetwork = DatabaseRateLimiter(
@ -108,9 +127,10 @@ final _codesPerNetwork = DatabaseRateLimiter(
);
/// Mails a new code to [address] and returns the request it answers, in the
/// same way whether anybody has the address or not. An earlier code for the
/// address stops being valid. Refused for no address, too many codes, or a
/// mail that couldn't be sent, leaving no request behind
/// same way whether anybody has the address or not. Up to
/// [liveCodesPerAddress] codes for the address are valid at once; the
/// oldest beyond them stops being valid. Refused for no address, too many
/// codes, or a mail that couldn't be sent, leaving no request behind
/// ([EmailSignInRefusedException]). The codes a network asks for are counted
/// by the address the request came from, or [network] where a member of the
/// instance asks for its device (ADR 0016).
@ -130,26 +150,31 @@ Future<UuidValue> sendSignInCode(
throw EmailSignInRefusedException(reason: EmailSignInRefusal.notAnAddress);
}
final hash = addressHash(pod, to);
final from = network ?? networkOf(session);
// The address first: a code it can't have takes nothing of the network's.
if (!await _codesPerAddress.tryRecordAttempt(session, key: hash) ||
!await _codesPerNetwork.tryRecordAttempt(
if (!await _codesPerAddressAndNetwork.tryRecordAttempt(
session,
key: network ?? networkOf(session),
)) {
key: '$hash|$from',
) ||
!await _codesPerAddress.tryRecordAttempt(session, key: hash) ||
!await _codesPerNetwork.tryRecordAttempt(session, key: from)) {
throw EmailSignInRefusedException(reason: EmailSignInRefusal.tooManyCodes);
}
final code = newShortCode();
return await session.db.transaction((transaction) async {
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id" IN
// (SELECT "challengeId" FROM "email_code_request"
// WHERE "addressHash" = $1); its request goes with it (ON DELETE
// CASCADE)
// The oldest beyond the codes left valid with this one go:
// SELECT * FROM "email_code_request" WHERE "addressHash" = $1
// ORDER BY "createdAt" DESC, "id" DESC OFFSET $2
// DELETE FROM "serverpod_auth_idp_secret_challenge" WHERE "id" IN (…);
// their requests go with them (ON DELETE CASCADE)
await _forget(
session,
await EmailCodeRequest.db.find(
session,
where: (t) => t.addressHash.equals(hash),
orderByList: (t) => [t.createdAt.desc(), t.id.desc()],
offset: liveCodesPerAddress - 1,
transaction: transaction,
),
transaction: transaction,

View file

@ -80,17 +80,40 @@ const _wrappingNonceLength = 24;
/// (16).
const wrappedKeysLength = 32 + 64 + 16;
/// How many sign-ins one address begins in an hour: every one is a guess at
/// the password and the code.
final _beginsPerAddress = DatabaseRateLimiter(
/// How many sign-ins one address begins from one network (IP address) in
/// 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
/// guesses many networks together make.
final _beginsPerAddressAndNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'password_sign_in',
source: 'begins_per_address',
source: 'begins_per_address_and_network',
maxAttempts: 10,
timeframe: const Duration(hours: 1),
),
);
/// 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
/// 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(
RateLimiterConfig(
domain: 'password_sign_in',
source: 'failed_proofs_per_address',
maxAttempts: 30,
timeframe: const Duration(hours: 1),
),
);
/// How many sign-ins and proofs of the recovery phrase one network (IP
/// address) begins in an hour.
final _beginsPerNetwork = DatabaseRateLimiter(
@ -102,16 +125,42 @@ final _beginsPerNetwork = DatabaseRateLimiter(
),
);
/// How many proofs of the recovery phrase one address begins in an hour.
final _keyProofsPerAddress = DatabaseRateLimiter(
/// How many changes one signed-in account begins in an hour: each is a
/// guess at the password by whoever holds the session.
final _changesBegunPerAccount = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'password_sign_in',
source: 'key_proofs_per_address',
source: 'changes_begun_per_account',
maxAttempts: 10,
timeframe: const Duration(hours: 1),
),
);
/// How many proofs of the recovery phrase one address begins from one
/// network in an hour; failed ones count toward [_failedProofsPerAddress]
/// too.
final _keyProofsPerAddressAndNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'password_sign_in',
source: 'key_proofs_per_address_and_network',
maxAttempts: 5,
timeframe: const Duration(hours: 1),
),
);
/// 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) {
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);
PasswordRefusedException refusedFor(PasswordRefusal reason) =>
PasswordRefusedException(reason: reason);
@ -525,9 +574,13 @@ Future<PasswordChallenge> beginPasswordSignIn(
final pod = session.serverpod;
final keys = requirePasswordKeys(pod);
final hash = _hashOfTyped(pod, address);
await _refuseAddressFailingTooOften(session, hash);
// The address first: a sign-in it can't begin takes nothing of the
// network's.
if (!await _beginsPerAddress.tryRecordAttempt(session, key: hash) ||
if (!await _beginsPerAddressAndNetwork.tryRecordAttempt(
session,
key: '$hash|$network',
) ||
!await _beginsPerNetwork.tryRecordAttempt(session, key: network)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
@ -658,9 +711,10 @@ Future<PasswordChallenge> beginPasswordChange(
if (identity == null || password == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
if (!await _beginsPerAddress.tryRecordAttempt(
await _refuseAddressFailingTooOften(session, identity.addressHash);
if (!await _changesBegunPerAccount.tryRecordAttempt(
session,
key: identity.addressHash,
key: person.uuid,
)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
@ -739,14 +793,18 @@ Future<PasswordProved> provePassword(
: numberOf(clientBytes),
clientProof: Uint8List.sublistView(clientProof),
);
return switch ((verification, password)) {
(SrpAccepted(:final serverProof), final password?) => (
if ((verification, password) case (
SrpAccepted(:final serverProof),
final password?,
)) {
return (
password: password,
serverProof: serverProof,
addressHash: kept.addressHash,
),
_ => throw refusedFor(PasswordRefusal.wrongCredentials),
};
);
}
await _countFailedProof(session, kept.addressHash);
throw refusedFor(PasswordRefusal.wrongCredentials);
}
/// Takes the challenge [id] for [purpose]: deletes it and returns it, so no
@ -976,7 +1034,11 @@ Future<KeyProofChallenge> beginKeyProof(
final pod = session.serverpod;
final keys = requirePasswordKeys(pod);
final hash = _hashOfTyped(pod, address);
if (!await _keyProofsPerAddress.tryRecordAttempt(session, key: hash) ||
await _refuseAddressFailingTooOften(session, hash);
if (!await _keyProofsPerAddressAndNetwork.tryRecordAttempt(
session,
key: '$hash|$network',
) ||
!await _beginsPerNetwork.tryRecordAttempt(session, key: network)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
@ -1058,6 +1120,7 @@ Future<AccountPassword> proveRecoveryPhrase(
: Uint8List.sublistView(proofKey),
);
if (!signed || password == null || proofKey == null) {
await _countFailedProof(session, kept.addressHash);
throw refusedFor(PasswordRefusal.wrongCredentials);
}
return password;