fix(auth)!: a session alone never gets a code checked

The third reading found a stolen session guessing TOTP codes without end:
nine wrong codes for new recovery codes, then removing an unconfirmed
authenticator of its own, a success that cleared the session's count. As
Proton has it, every signed-in change of how an account is secured now
takes the owner's proof (OwnerProof): the password's SRP proof for a
change's challenge and a TOTP or recovery code. Adding an authenticator
once one is confirmed, removing a confirmed one, new recovery codes, a new
phrase, a new password and deleting take it; only the first authenticator
is confirmed with its own code alone, and removing an unconfirmed one
takes nothing. A proof given where none is taken is refused, and a
confirmed authenticator's code is never checked for a session alone, so
no success passes a proof unchecked.

A session's proofs count for it alone, before they are checked, cleared
only by one accepted; the tenth wrong in a row ends the session. They
never count toward the account's waits nor wait for them, so whoever
knows the password no longer holds a signed-in owner out of changing it
(the second finding), and a new password forgets those waits. Beginning a
change counts nothing against the account (the third finding): 30 an
hour per session, holding back that session alone. Deleting takes a TOTP
code wherever an authenticator is confirmed, a code by mail where no
second factor works (the fourth finding), which SignInWays now tells
(secondFactorWorks). Signed-in calls no longer take the address: nothing
there mails. wrongExistingCode goes, a wrong existing code being a wrong
proof.

The app follows in the next commit.

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-21 10:33:25 +02:00
commit a9459bb875
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1461 additions and 1026 deletions

View file

@ -3,6 +3,8 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:cryptography/cryptography.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/password_endpoint.dart'
show changesBegunPerSession;
import 'package:halkyon_base_server/src/password/passwords.dart'
show writeNewPassword;
import 'package:serverpod/serverpod.dart';
@ -71,6 +73,13 @@ void main() {
authId: annasPhone.uuid,
),
);
TestSessionBuilder onLaptop() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{identified},
authId: annasLaptop.uuid,
),
);
Future<({UuidValue request, String code})> mailedCode(
String address, {
@ -106,28 +115,30 @@ void main() {
);
});
/// Changes Anna's password from her phone, the old one proved by
/// [privateValue] (hers by default) with [totpCode] (her
/// authenticator's now by default).
/// Changes Anna's password from her phone (or [from]), the old one
/// proved by [privateValue] (hers by default) with [totpCode] (her
/// authenticator's now by default); sent as [as] where given.
Future<void> change({
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
NewPassword? password,
TestSessionBuilder? from,
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(onPhone());
final answer = device.answer(challenge, privateValue: privateValue);
final challenge = await endpoints.password.beginChange(
from ?? onPhone(),
);
await endpoints.password.change(
as ?? onPhone(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: withoutCode ? null : totpCode ?? codeOf(enrolment, at: now),
as ?? from ?? onPhone(),
proof: device.ownerProof(
challenge,
privateValue: privateValue,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
),
password: password ?? device.newPassword(),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
});
@ -344,20 +355,16 @@ void main() {
),
);
await expectLater(
atNow(() async {
final answer = device.answer(signInChallenge);
await endpoints.password.change(
atNow(
() => endpoints.password.change(
onPhone(),
challenge: signInChallenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
proof: device.ownerProof(
signInChallenge,
totpCode: codeOf(enrolment, at: now),
),
password: device.newPassword(),
host: testHost,
address: 'anna@example.org',
language: 'en',
);
}),
),
),
refused(PasswordRefusal.challengeExpired),
);
final changeChallenge = await atNow(
@ -381,43 +388,89 @@ void main() {
);
});
test("takes the account's address, refused for another before "
'the code is checked; its wrong codes make codes wait, the owner '
'mailed', () async {
await expectLater(
atNow(() async {
final challenge = await endpoints.password.beginChange(
onPhone(),
);
final answer = device.answer(challenge);
await endpoints.password.change(
onPhone(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
// Another device's: a refused change leaves Anna's as it is.
password: (await PasswordDevice.make()).newPassword(),
host: testHost,
address: 'ben@example.org',
language: 'en',
);
}),
refused(PasswordRefusal.wrongCredentials),
);
expect(await SecondFactorFailures.db.count(session), 0);
// Past the hour of the changes begun (ten an hour per account).
now = now.add(const Duration(hours: 1, seconds: 1));
test("a session's wrong proofs count for it alone: the tenth in a "
"row ends it, the account's codes never waiting, the owner's "
'other sessions changing as before', () async {
// Another device's new password: refused changes leave Anna's.
final other = await PasswordDevice.make();
for (var i = 0; i < 10; i++) {
for (var i = 0; i < 9; i++) {
await expectLater(
change(totpCode: '000000', password: other.newPassword()),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(mailbox.codesWait.single.to, 'anna@example.org');
expect(mailbox.codesWait.single.language, 'en');
await expectLater(
change(
privateValue: BigInt.from(4242),
password: other.newPassword(),
),
refused(PasswordRefusal.sessionEnded),
);
expect(await sessionsOf(anna), {annasLaptop});
// A proof sent on by the ended session is refused all the same.
await expectLater(
change(password: other.newPassword()),
refused(PasswordRefusal.sessionEnded),
);
expect(await SecondFactorFailures.db.count(session), 0);
expect(mailbox.codesWait, isEmpty);
nextStep();
await change(from: onLaptop());
});
test('a proof accepted clears the count: wrong ones before it never '
'add up to ten with those after', () async {
final other = await PasswordDevice.make();
for (var i = 0; i < 9; i++) {
await expectLater(
change(totpCode: '000000', password: other.newPassword()),
refused(PasswordRefusal.wrongCredentials),
);
}
nextStep();
await change(password: other.newPassword());
device = other;
for (var i = 0; i < 9; i++) {
await expectLater(
change(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(await sessionsOf(anna), contains(annasPhone));
});
test('whoever knows the password holds the signed-in owner out of '
"nothing: codes waiting after wrong ones at sign-in don't hold "
'back a change, which forgets them', () async {
// The third reading's second finding: ten wrong codes at sign-in.
for (var i = 0; i < 10; i++) {
await expectLater(
signIn(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(mailbox.codesWait, hasLength(1));
expect(await SecondFactorFailures.db.count(session), 1);
await change();
expect(await SecondFactorFailures.db.count(session), 0);
});
test('beginning changes counts nothing against the owner: a session '
'spending its own begins holds back no other session', () async {
// The third reading's third finding: ten begun an hour per account
// let a stolen session keep its owner from changing anything.
for (var i = 0; i < changesBegunPerSession; i++) {
await atNow(() => endpoints.password.beginChange(onPhone()));
}
await expectLater(
atNow(() => endpoints.password.beginChange(onPhone())),
refused(PasswordRefusal.tooManyAttempts),
);
await change(from: onLaptop());
});
test('is begun only by someone with a password', () async {
@ -480,20 +533,20 @@ void main() {
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(onPhone());
final answer = device.answer(challenge, privateValue: privateValue);
await endpoints.password.setRecoveryKeys(
as ?? onPhone(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: withoutProof ? ByteData(32) : answer.clientProof,
final proof = device.ownerProof(
challenge,
privateValue: privateValue,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
);
await endpoints.password.setRecoveryKeys(
as ?? onPhone(),
proof: withoutProof
? proof.copyWith(clientProof: ByteData(32))
: proof,
recoveryKeys: recoveryKeys ?? fresh,
recoveryProofKey: recoveryProofKey ?? freshProofKey,
host: testHost,
address: 'anna@example.org',
language: 'en',
);
});
@ -669,8 +722,6 @@ void main() {
? null
: totpCode ?? codeOf(enrolment, at: now),
host: testHost,
address: 'anna@example.org',
language: 'en',
),
);
});
@ -751,8 +802,6 @@ void main() {
request: mailed?.request,
code: mailed?.code,
host: testHost,
address: 'ben@example.org',
language: 'en',
),
);
});
@ -761,9 +810,11 @@ void main() {
deleteBen(),
refused(PasswordRefusal.wrongCredentials),
);
// A TOTP code, once the password proved right: no authenticator
// to check it against.
await expectLater(
deleteBen(totpCode: '123456'),
refused(PasswordRefusal.wrongCredentials),
refused(PasswordRefusal.setupIncomplete),
);
await expectLater(
deleteBen(mailedFor: EmailCodePurpose.signUp),
@ -828,8 +879,6 @@ void main() {
request: mailed.request,
code: mailed.code,
host: testHost,
address: 'anna@example.org',
language: 'en',
),
);
});
@ -837,6 +886,39 @@ void main() {
},
);
test(
'with the reset of the authenticators taken effect but not carried '
'out, takes a TOTP code from them too, as the app learns from '
'the ways in',
() async {
// The third reading's fourth finding: the app offered a code
// field there, which the instance refused.
await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: anna,
kind: AccountResetKind.authenticator,
takesEffectAt: now.subtract(const Duration(hours: 1)),
createdAt: now.subtract(const Duration(hours: 73)),
),
);
final ways = await endpoints.account.signInWays(onPhone());
expect(ways.authenticator, isTrue);
expect(ways.secondFactorWorks, isFalse);
await delete();
expect(await AuthUser.db.findById(session, anna), isNull);
},
);
test('a second factor works while nothing was reset', () async {
expect(
(await endpoints.account.signInWays(onPhone())).secondFactorWorks,
isTrue,
);
});
test(
'of an anonymous person takes its session alone, as before',
() async {

View file

@ -75,6 +75,22 @@ void main() {
authentication: AuthenticationOverride.unauthenticated(),
);
/// Anna's proof from her phone (`OwnerProof`): her password (or
/// [privateValue]'s) for a change's challenge, and [totpCode] or
/// [recoveryCode].
Future<OwnerProof> ownerProof({
String? totpCode,
String? recoveryCode,
BigInt? privateValue,
}) => atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
totpCode: totpCode,
recoveryCode: recoveryCode,
privateValue: privateValue,
),
);
/// A code mailed to [address], as the device asks for it: the request
/// and the code.
Future<({UuidValue request, String code})> mailedCode(
@ -993,16 +1009,17 @@ void main() {
'holds nobody out of their codes', () async {
final complete = await annaComplete();
for (var i = 0; i < 10; i++) {
final proof = await ownerProof(totpCode: '000000');
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: '000000',
proof: proof,
),
),
refused(
i < 9
? PasswordRefusal.wrongCode
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
);
@ -1164,12 +1181,15 @@ void main() {
),
refused(PasswordRefusal.wrongCode),
);
final proof = await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
);
final codes = await atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: second.authenticator,
code: codeOf(second, at: now),
existingCode: codeOf(complete.enrolment, at: now),
proof: proof,
),
);
expect(codes, isEmpty);
@ -1181,44 +1201,56 @@ void main() {
);
});
test('take, once one is confirmed, a code from one confirmed before '
'to add another: a session alone adds none', () async {
test("take, once one is confirmed, the owner's proof to add another: "
'the password and a code from one confirmed before; a session '
'alone adds none', () async {
final complete = await annaComplete();
final another = await endpoints.authenticator.enrol(
asAnna(),
name: 'Not hers',
label: '',
);
Future<List<String>> confirm({
String? code,
String? existingCode,
}) => atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: another.authenticator,
code: code ?? codeOf(another, at: now),
existingCode: existingCode,
),
);
Future<List<String>> confirm({String? code, OwnerProof? proof}) =>
atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: another.authenticator,
code: code ?? codeOf(another, at: now),
proof: proof,
),
);
await expectLater(
confirm(),
refused(PasswordRefusal.wrongExistingCode),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
confirm(existingCode: '000000'),
refused(PasswordRefusal.wrongExistingCode),
confirm(proof: await ownerProof(totpCode: '000000')),
refused(PasswordRefusal.wrongCredentials),
);
// Its own code is no code from one confirmed before.
await expectLater(
confirm(existingCode: codeOf(another, at: now)),
refused(PasswordRefusal.wrongExistingCode),
confirm(
proof: await ownerProof(totpCode: codeOf(another, at: now)),
),
refused(PasswordRefusal.wrongCredentials),
);
// A wrong code from the new one takes nothing of the other's.
await expectLater(
confirm(
proof: await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
privateValue: BigInt.from(4242),
),
),
refused(PasswordRefusal.wrongCredentials),
);
// A wrong code from the new one takes nothing of the proof's.
await expectLater(
confirm(
code: '000000',
existingCode: codeOf(complete.enrolment, at: now),
proof: await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
),
),
refused(PasswordRefusal.wrongCode),
);
@ -1230,7 +1262,11 @@ void main() {
);
expect(
await confirm(existingCode: codeOf(complete.enrolment, at: now)),
await confirm(
proof: await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
),
),
isEmpty,
);
expect(
@ -1246,6 +1282,55 @@ void main() {
);
});
test("never have a confirmed one's code checked for a session "
'alone: confirming it again is refused as unknown, and the '
'first takes no proof', () async {
final complete = await annaComplete();
await expectLater(
atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: complete.enrolment.authenticator,
code: codeOf(complete.enrolment, at: now),
),
),
refused(PasswordRefusal.unknownAuthenticator),
);
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensLaptop.uuid,
),
);
final code = await mailedCode('ben@example.org');
await endpoints.password.set(
asBen,
setup: (await PasswordDevice.make(seed: 7)).setup(),
request: code.request,
code: code.code,
);
final bens = await endpoints.authenticator.enrol(
asBen,
name: 'Phone',
label: '',
);
await expectLater(
atNow(
() async => await endpoints.authenticator.confirm(
asBen,
authenticator: bens.authenticator,
code: codeOf(bens, at: now),
proof: await ownerProof(),
),
),
refused(PasswordRefusal.invalidSetup),
);
});
test("are another person's to nobody: unknown", () async {
final complete = await annaComplete();
final ben = await newAnonymousPerson(session);
@ -1277,8 +1362,8 @@ void main() {
expect(await TotpAuthenticator.db.count(session), 1);
});
test('are removed with a code while another stays, never the '
'last', () async {
test("are removed with the owner's proof while another stays, never "
'the last', () async {
final complete = await annaComplete();
final unconfirmed = await endpoints.authenticator.enrol(
asAnna(),
@ -1291,10 +1376,12 @@ void main() {
);
await expectLater(
atNow(
() => endpoints.authenticator.remove(
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
code: codeOf(complete.enrolment, at: now),
proof: await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
),
),
),
refused(PasswordRefusal.lastAuthenticator),
@ -1305,12 +1392,15 @@ void main() {
name: 'Tablet',
label: '',
);
final proof = await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
);
await atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: second.authenticator,
code: codeOf(second, at: now),
existingCode: codeOf(complete.enrolment, at: now),
proof: proof,
),
);
nextStep();
@ -1319,23 +1409,36 @@ void main() {
asAnna(),
authenticator: complete.enrolment.authenticator,
),
refused(PasswordRefusal.wrongCode),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
atNow(
() => endpoints.authenticator.remove(
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
code: '123456',
proof: await ownerProof(totpCode: '123456'),
),
),
refused(PasswordRefusal.wrongCode),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
atNow(
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
proof: await ownerProof(
totpCode: codeOf(second, at: now),
privateValue: BigInt.from(4242),
),
),
),
refused(PasswordRefusal.wrongCredentials),
);
await atNow(
() => endpoints.authenticator.remove(
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
code: codeOf(second, at: now),
proof: await ownerProof(totpCode: codeOf(second, at: now)),
),
);
@ -1345,18 +1448,37 @@ void main() {
);
});
test('give new recovery codes for a code, the old ones no longer '
'signing in', () async {
test("give new recovery codes for the owner's proof, the old ones no "
'longer signing in', () async {
final complete = await annaComplete();
await expectLater(
endpoints.authenticator.newRecoveryCodes(asAnna(), code: '000000'),
refused(PasswordRefusal.wrongCode),
atNow(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(totpCode: '000000'),
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
atNow(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
privateValue: BigInt.from(4242),
),
),
),
refused(PasswordRefusal.wrongCredentials),
);
final fresh = await atNow(
() => endpoints.authenticator.newRecoveryCodes(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: codeOf(complete.enrolment, at: now),
proof: await ownerProof(
totpCode: codeOf(complete.enrolment, at: now),
),
),
);
@ -1371,19 +1493,19 @@ void main() {
);
});
test('count the codes a signed-in session tries: the tenth wrong '
test('count the proofs a signed-in session sends: the tenth wrong '
'in a row ends it; a right one before clears the count', () async {
final complete = await annaComplete();
Future<List<String>> newCodes(String code) => atNow(
() => endpoints.authenticator.newRecoveryCodes(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: code,
proof: await ownerProof(totpCode: code),
),
);
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes('000000'),
refused(PasswordRefusal.wrongCode),
refused(PasswordRefusal.wrongCredentials),
);
}
await newCodes(codeOf(complete.enrolment, at: now));
@ -1391,7 +1513,7 @@ void main() {
for (var i = 0; i < 9; i++) {
await expectLater(
newCodes('000000'),
refused(PasswordRefusal.wrongCode),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(
@ -1417,16 +1539,20 @@ void main() {
await signIn(totpCode: codeOf(complete.enrolment, at: now));
});
test('count codes a session sends at once before they are checked: '
test('count proofs a session sends at once before they are checked: '
'nine checked, the session ended', () async {
await annaComplete();
final reasons = await Future.wait([
final proofs = [
for (var i = 0; i < 30; i++)
await ownerProof(totpCode: '${100000 + i}'),
];
final reasons = await Future.wait([
for (final proof in proofs)
atNow(() async {
try {
await endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: '${100000 + i}',
proof: proof,
);
return null;
} on PasswordRefusedException catch (refusal) {
@ -1435,7 +1561,7 @@ void main() {
}),
]);
expect(
reasons.where((r) => r == PasswordRefusal.wrongCode),
reasons.where((r) => r == PasswordRefusal.wrongCredentials),
hasLength(9),
);
expect(
@ -1448,26 +1574,26 @@ void main() {
);
});
test('take back a try that checked no code', () async {
test('take back a try that checked no proof', () async {
final complete = await annaComplete();
for (var i = 0; i < 9; i++) {
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
() async => await endpoints.authenticator.newRecoveryCodes(
asAnna(),
code: '000000',
proof: await ownerProof(totpCode: '000000'),
),
),
refused(PasswordRefusal.wrongCode),
refused(PasswordRefusal.wrongCredentials),
);
}
// The last authenticator: refused before any code is checked.
// The last authenticator: refused before any proof is checked.
await expectLater(
atNow(
() => endpoints.authenticator.remove(
() async => await endpoints.authenticator.remove(
asAnna(),
authenticator: complete.enrolment.authenticator,
code: '000000',
proof: await ownerProof(totpCode: '000000'),
),
),
refused(PasswordRefusal.lastAuthenticator),
@ -1478,6 +1604,62 @@ void main() {
);
});
test('let no session clear its count without a proof accepted: '
'removing an unconfirmed authenticator of its own clears '
'nothing', () async {
// The third reading's first finding: a session tried nine codes,
// removed an authenticator it had just enrolled (a success, which
// cleared the count) and went on, without end.
await annaComplete();
for (var round = 0; round < 2; round++) {
for (var i = 0; i < (round == 0 ? 9 : 1); i++) {
final proof = await ownerProof(totpCode: '000000');
await expectLater(
atNow(
() => endpoints.authenticator.newRecoveryCodes(
asAnna(),
proof: proof,
),
),
refused(
round == 0
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
);
}
if (round == 1) {
break;
}
final own = await endpoints.authenticator.enrol(
asAnna(),
name: 'Mine',
label: '',
);
// A proof given where none is taken is refused, never passed.
await expectLater(
endpoints.authenticator.remove(
asAnna(),
authenticator: own.authenticator,
proof: await ownerProof(totpCode: '000000'),
),
refused(PasswordRefusal.invalidSetup),
);
await endpoints.authenticator.remove(
asAnna(),
authenticator: own.authenticator,
);
expect(
(await SessionCodeFailures.db.findFirstRow(session))?.failures,
9,
);
}
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNull,
);
});
test("keep their secrets bound to their row: another row's sealed "
'secret opens nowhere else', () async {
final complete = await annaComplete();