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:
parent
5b9cb85b0f
commit
26488b40ce
2 changed files with 300 additions and 131 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue