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
6 changed files with 300 additions and 131 deletions

View file

@ -79,7 +79,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`. - **GitHub keeps only the numeric GitHub ID**: our own slim login (halkyon_base's `github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks halkyon_base's `instance.basics()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason. - **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so. - **Switching devices' person** (halkyon_app's `switchToGitHub` and `useSession`, on whether the device holds content as Learn counts it, `PersonDependencies.holdsContent`: its studysets, Recently deleted's too): swap the session directly, never through "signed out" (which would leave the device asking how to start), then delete the account left behind if it was anonymous (nobody could sign into it again), never an identified one. Someone identified is never offered a switch: an address or GitHub account that is another account's is said to be so.
- **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished and the resets. Eight Crockford characters (halkyon_base's `short_code.dart`, shared with connect codes), 15 minutes, 3 tries, a newer one replacing the older; at most 3 codes an hour per address and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would. - **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished and the resets. Eight Crockford characters (halkyon_base's `short_code.dart`, shared with connect codes), 15 minutes, 3 tries, up to 3 valid at once for an address (a fourth voids the oldest); at most 3 codes an hour per address and network, 10 per address from every network and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_app): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub." - **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_app): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.

View file

@ -30,7 +30,10 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
- GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only - GitHub keeps only the numeric GitHub ID, in Serverpod's own table; email keeps only
HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its HMAC-SHA256 of the address, keyed with `emailSignInPepper` (`email_identity`), and its
codes as Argon2id hashes (`email_code_request`). Codes are eight Crockford characters, codes as Argon2id hashes (`email_code_request`). Codes are eight Crockford characters,
valid 15 minutes, 3 tries; at most 3 an hour per address and 10 per network. valid 15 minutes, 3 tries; up to 3 valid at once for an address (a fourth voids the
oldest, so codes others ask for don't void the one its owner is typing); at most 3 an
hour per address and network, 10 per address from every network (no mailbox flooded)
and 10 per network.
- Profiles: a handle as Mastodon's (1 to 30 letters, digits or underscores, unique - Profiles: a handle as Mastodon's (1 to 30 letters, digits or underscores, unique
whatever their case, a few reserved for the instance), fixed once the host says so whatever their case, a few reserved for the instance), fixed once the host says so
(`fixHandle`; Halkyon Learn at the first publish); a display name, a bio, up to 4 (`fixHandle`; Halkyon Learn at the first publish); a display name, a bio, up to 4
@ -205,7 +208,12 @@ Built on the three parts above, on the register (members forward every step thro
address's hash, the same queries and arithmetic, and the same refusal: a wrong address's hash, the same queries and arithmetic, and the same refusal: a wrong
password, an unknown address and a wrong, used or missing code are all password, an unknown address and a wrong, used or missing code are all
`wrongCredentials`; `setupIncomplete` and `notDue` come only after a right password. `wrongCredentials`; `setupIncomplete` and `notDue` come only after a right password.
At most 10 sign-ins begun per address and 30 per network an hour. At most 10 sign-ins begun per address and network an hour (5 proofs of the phrase),
30 per network, and, from every network together, 30 failed proofs per address
(wrong passwords, phrases' signatures by another key; an address nobody has alike),
past which no sign-in or proof begins for it anywhere until the hour has passed: so
whoever knows an address and fills its count from one network holds no other back,
and guessing from many makes at most 30 guesses an hour.
- **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI - **Authenticators:** a secret made on the register, shown once as an `otpauth://` URI
(issuer: the account domain); confirmed by a code; while the account has a confirmed (issuer: the account domain); confirmed by a code; while the account has a confirmed
one, another counts only with a code from that one too (`wrongExistingCode`), so a one, another counts only with a code from that one too (`wrongExistingCode`), so a

View file

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

View file

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

View file

@ -244,8 +244,9 @@ void main() {
expect(await _addressesOf(session, anna), isEmpty); expect(await _addressesOf(session, anna), isEmpty);
}); });
test('a code past its 15 minutes, a code used, and one replaced by ' test('a code past its 15 minutes, a code used, and the oldest of '
'a newer code', () async { 'four: up to three are valid at once, so codes others ask for '
"don't void the one the owner is typing", () async {
final late = await endpoints.emailCode.sendCode( final late = await endpoints.emailCode.sendCode(
asAnna(), asAnna(),
address: 'anna@example.org', address: 'anna@example.org',
@ -264,75 +265,71 @@ void main() {
), ),
); );
final replaced = await endpoints.emailCode.sendCode( Future<(UuidValue, String)> ask(String network) async {
asAnna(), final request = await sendSignInCode(
address: 'anna@example.org',
language: 'en',
);
final replacedCode = mailbox.codeFor('anna@example.org');
final newer = await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org',
language: 'en',
);
await expectLater(
proveAddressCode(
session, session,
request: replaced,
code: replacedCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
final newerCode = mailbox.codeFor('anna@example.org');
await proveAddressCode(
session,
request: newer,
code: newerCode,
);
await expectLater(
proveAddressCode(
session,
request: newer,
code: newerCode,
),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
});
test('a fourth code for one address within an hour, and an '
'eleventh from one network', () async {
for (var i = 0; i < 3; i++) {
await endpoints.emailCode.sendCode(
asAnna(),
address: 'anna@example.org', address: 'anna@example.org',
language: 'en', language: 'en',
network: network,
); );
return (request, mailbox.codeFor('anna@example.org'));
}
final (owners, ownersCode) = await ask('203.0.113.1');
final (second, secondCode) = await ask('198.51.100.7');
final (third, thirdCode) = await ask('198.51.100.7');
// Three valid: the owner's still is.
await proveAddressCode(session, request: owners, code: ownersCode);
final (fourth, fourthCode) = await ask('198.51.100.7');
final (fifth, _) = await ask('192.0.2.9');
// Five asked for, three valid: the two oldest left went.
await expectLater(
proveAddressCode(session, request: second, code: secondCode),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
await proveAddressCode(session, request: third, code: thirdCode);
await proveAddressCode(session, request: fourth, code: fourthCode);
await expectLater(
proveAddressCode(session, request: fourth, code: fourthCode),
throwsA(_refusal(EmailSignInRefusal.codeExpired)),
);
expect(fifth, isNotNull);
});
test('a fourth code for one address from one network within an '
'hour, an eleventh for one address from any, and an eleventh '
'from one network', () async {
Future<void> ask(String address, String network) => sendSignInCode(
session,
address: address,
language: 'en',
network: network,
);
for (var i = 0; i < 3; i++) {
await ask('anna@example.org', '203.0.113.1');
} }
await expectLater( await expectLater(
endpoints.emailCode.sendCode( ask('Anna@Example.org', '203.0.113.1'),
asAnna(),
address: 'Anna@Example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
); );
// Another network still gets one: whoever filled the first count
// holds back no other, the owner's among them.
for (var i = 0; i < 7; i++) { for (var i = 0; i < 7; i++) {
await endpoints.emailCode.sendCode( await ask('anna@example.org', '198.51.100.${i ~/ 3}');
asAnna(),
address: 'anna$i@example.org',
language: 'en',
);
} }
// Ten mailed to one address in the hour: no more, from anywhere.
await expectLater( await expectLater(
endpoints.emailCode.sendCode( ask('anna@example.org', '192.0.2.200'),
asAnna(),
address: 'anna.else@example.org',
language: 'en',
),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)), throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
); );
expect(mailbox.sent, hasLength(10)); for (var i = 0; i < 10; i++) {
await ask('anna$i@example.org', '192.0.2.9');
}
await expectLater(
ask('anna.else@example.org', '192.0.2.9'),
throwsA(_refusal(EmailSignInRefusal.tooManyCodes)),
);
expect(mailbox.sent, hasLength(20));
}); });
test('saying how someone signs in when not signed in', () async { test('saying how someone signs in when not signed in', () async {

View file

@ -2,6 +2,8 @@ import 'dart:typed_data';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart'; import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/passwords.dart'
show beginKeyProof, beginPasswordSignIn;
import 'package:halkyon_base_server/src/srp/srp_math.dart'; import 'package:halkyon_base_server/src/srp/srp_math.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_auth_idp_server/core.dart';
@ -668,51 +670,85 @@ void main() {
); );
}); });
test('counts at most 10 sign-ins begun for an address and 30 from a ' test('counts sign-ins begun per address and network, 10 an hour, '
'network in an hour, an unknown address as any', () async { 'and per network, 30; another network signs the address in all '
'the same, an unknown address counted as any', () async {
Future<void> begin(String address, String network) => atNow(
() => beginPasswordSignIn(
session,
address: address,
network: network,
),
);
for (final address in ['anna@example.org', 'nobody@example.org']) { for (final address in ['anna@example.org', 'nobody@example.org']) {
for (var i = 0; i < 10; i++) { for (var i = 0; i < 10; i++) {
await atNow( await begin(address, '203.0.113.1');
() => endpoints.passwordSignIn.begin( }
notSignedIn(), await expectLater(
address: address, begin(address, '203.0.113.1'),
), refused(PasswordRefusal.tooManyAttempts),
);
// Whoever filled the first network's count holds no other back.
await begin(address, '198.51.100.7');
}
for (var i = 0; i < 30; i++) {
await begin('third$i@example.org', '192.0.2.9');
}
await expectLater(
begin('fresh@example.org', '192.0.2.9'),
refused(PasswordRefusal.tooManyAttempts),
);
now = now.add(const Duration(hours: 1, seconds: 1));
await begin('anna@example.org', '203.0.113.1');
});
test('counts failed proofs per address from every network: past 30 '
'an hour, no sign-in begins for it anywhere, an address nobody '
'has alike', () async {
final complete = await annaComplete();
Future<void> failFrom(String address, String network) => atNow(
() async {
final challenge = await beginPasswordSignIn(
session,
address: address,
network: network,
); );
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,
),
refused(PasswordRefusal.wrongCredentials),
);
},
);
for (final address in ['anna@example.org', 'nobody@example.org']) {
for (var i = 0; i < 30; i++) {
// Ten from each network, within its own count.
await failFrom(address, '203.0.113.${i ~/ 10}');
} }
await expectLater( await expectLater(
atNow( atNow(
() => endpoints.passwordSignIn.begin( () => beginPasswordSignIn(
notSignedIn(), session,
address: address, address: address,
network: '198.51.100.7',
), ),
), ),
refused(PasswordRefusal.tooManyAttempts), refused(PasswordRefusal.tooManyAttempts),
); );
} }
for (var i = 0; i < 10; i++) { // An hour on, the count is past: the owner signs in.
await atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'third$i@example.org',
),
);
}
await expectLater(
atNow(
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'fresh@example.org',
),
),
refused(PasswordRefusal.tooManyAttempts),
);
now = now.add(const Duration(hours: 1, seconds: 1)); now = now.add(const Duration(hours: 1, seconds: 1));
await atNow( await signIn(totpCode: codeOf(complete.enrolment, at: now));
() => endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
),
);
}); });
test('takes each recovery code once, in place of a code', () async { test('takes each recovery code once, in place of a code', () async {
@ -1771,25 +1807,65 @@ void main() {
); );
}); });
test('counts at most 5 proofs begun for an address an hour', () async { test(
for (var i = 0; i < 5; i++) { 'counts at most 5 proofs begun for an address from a network '
await atNow( 'an hour; another network begins its own, and failed proofs '
() => endpoints.accountRecovery.beginKeyProof( "count toward the address's ceiling as failed sign-ins do",
notSignedIn(), () async {
address: 'nobody@example.org', final complete = await annaComplete();
Future<KeyProofChallenge> begin(String network) => atNow(
() => beginKeyProof(
session,
address: 'anna@example.org',
network: network,
), ),
); );
} for (var i = 0; i < 5; i++) {
await expectLater( await begin('203.0.113.1');
atNow( }
() => endpoints.accountRecovery.beginKeyProof( await expectLater(
notSignedIn(), begin('203.0.113.1'),
address: 'nobody@example.org', refused(PasswordRefusal.tooManyAttempts),
);
// A proof by another key fails, and counts.
for (var i = 0; i < 30; i++) {
final challenge = await begin('198.51.100.${i ~/ 5}');
final newPassword = device.newPassword();
final signature = await device.sign(
challenge,
keys: await otherSigningKeys(),
password: newPassword,
);
await expectLater(
atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: challenge.challenge,
signature: signature,
password: newPassword,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
begin('192.0.2.9'),
refused(PasswordRefusal.tooManyAttempts),
);
await expectLater(
atNow(
() => beginPasswordSignIn(
session,
address: 'anna@example.org',
network: '192.0.2.9',
),
), ),
), refused(PasswordRefusal.tooManyAttempts),
refused(PasswordRefusal.tooManyAttempts), );
); },
}); );
}); });
group('a lost password without the phrase', () { group('a lost password without the phrase', () {