fix(auth)!: the first authenticator takes the password, and a reset ends every session

The fourth reading's second finding: a stolen session outlived the reset
of the authenticators (carrying it out ended no session) and confirmed
the account's first authenticator on its own code alone, ahead of the
owner, so the owner's confirmation and sign-in were refused after.

- Carrying out a reset of the authenticators ends every session of the
  account, on the register at once and on every member by notice, as the
  other resets do; the sign-in that carries it out gets its session after
  that moment (PasswordProof.sessionsEndedBefore, issueSessionAfter).
- Confirming the first authenticator takes the password's proof for a
  change's challenge, without a code (proveOwnersPassword), counted per
  session as the owner's proofs are: a session of a lost phone, or of a
  setup never finished, claims no second factor.

The app sends the password with every confirmation: the sign-up's, a
reset's and the account section's (which asks for it), and a sign-in's
own unlock where the session is still held back.

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 11:43:22 +02:00
commit d7855162c7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 548 additions and 95 deletions

View file

@ -182,10 +182,13 @@ void main() {
label: 'anna@example.org',
);
await atNow(
() => endpoints.authenticator.confirm(
() async => await endpoints.authenticator.confirm(
onPhone(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
proof: device.ownerProof(
await endpoints.password.beginChange(onPhone()),
),
),
);
nextStep();
@ -462,7 +465,8 @@ void main() {
'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++) {
// One went on confirming the first authenticator, in setUp.
for (var i = 1; i < changesBegunPerSession; i++) {
await atNow(() => endpoints.password.beginChange(onPhone()));
}
await expectLater(

View file

@ -91,6 +91,18 @@ void main() {
),
);
/// Anna's password alone, from [from] (her phone unless given), for a
/// change's challenge: what her first authenticator takes (the fourth
/// reading).
Future<OwnerProof> passwordAlone({
TestSessionBuilder? from,
PasswordDevice? with_,
}) => atNow(
() async => (with_ ?? device).ownerProof(
await endpoints.password.beginChange(from ?? asAnna()),
),
);
/// A code mailed to [address], as the device asks for it: the request
/// and the code.
Future<({UuidValue request, String code})> mailedCode(
@ -150,6 +162,7 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(),
);
nextStep();
return (enrolment: enrolment, recoveryCodes: codes);
@ -507,6 +520,7 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(with_: other),
);
});
@ -1216,6 +1230,7 @@ void main() {
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
proof: await passwordAlone(),
);
});
@ -1271,6 +1286,174 @@ void main() {
},
);
test(
'the first is confirmed only with the password too, so a '
'session alone claims no second factor (the fourth reading)',
() async {
await annaSetsPassword();
final enrolment = await endpoints.authenticator.enrol(
asAnna(),
name: 'Phone',
label: '',
);
Future<List<String>> confirm({OwnerProof? proof}) => atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
proof: proof,
),
);
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensLaptop.uuid,
),
);
final bensDevice = await PasswordDevice.make(seed: 7);
final bensCode = await mailedCode('ben@example.org');
await endpoints.password.set(
asBen,
setup: bensDevice.setup(),
request: bensCode.request,
code: bensCode.code,
);
await expectLater(
confirm(),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
confirm(
proof: await atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
privateValue: BigInt.from(12345),
),
),
),
refused(PasswordRefusal.wrongCredentials),
);
// Ben's own password, for a challenge of his: not Anna's.
await expectLater(
confirm(
proof: await atNow(
() async => bensDevice.ownerProof(
await endpoints.password.beginChange(asBen),
),
),
),
refused(PasswordRefusal.wrongCredentials),
);
// A code with it, which nothing here checks.
await expectLater(
confirm(
proof: await atNow(
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
totpCode: codeOf(enrolment, at: now),
),
),
),
refused(PasswordRefusal.invalidSetup),
);
expect(
(await TotpAuthenticator.db.findById(
session,
enrolment.authenticator,
))!.confirmedAt,
isNull,
);
// Each wrong one counted for the session (no proof checks none).
expect(
(await SessionCodeFailures.db.findFirstRow(
session,
where: (t) => t.sessionId.equals(annasPhone),
))?.failures,
2,
);
final codes = await confirm(proof: await passwordAlone());
expect(codes, hasLength(recoveryCodeCount));
expect(
await SessionCodeFailures.db.count(
session,
where: (t) => t.sessionId.equals(annasPhone),
),
0,
);
},
);
test("a session that outlived its device's loss claims no second "
'factor once the authenticators were reset: the reset carried '
'out ends every session, and the first takes the password '
'(the fourth reading)', () async {
await annaComplete();
// Anna's phone, session and authenticator, is stolen; she asks for
// the reset with her password and a code by mail.
await atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge);
final code = await mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.authenticatorReset,
);
await endpoints.accountRecovery.resetAuthenticator(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
address: 'anna@example.org',
language: 'en',
request: code.request,
code: code.code,
host: testHost,
);
});
now = now.add(resetDelay);
// Her password alone on a new device carries it out.
expect((await signIn()).needsAuthenticator, isTrue);
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNull,
);
// Were the thief's session still taken, it would confirm nothing
// of its own without her password.
final thiefs = await atNow(
() => endpoints.authenticator.enrol(
asAnna(),
name: 'Thief',
label: '',
),
);
await expectLater(
atNow(
() => endpoints.authenticator.confirm(
asAnna(),
authenticator: thiefs.authenticator,
code: codeOf(thiefs, at: now),
),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(
await TotpAuthenticator.db.count(
session,
where: (t) => t.confirmedAt.notEquals(null),
),
0,
);
});
test('need a password, a name, and stop at ten', () async {
await expectLater(
endpoints.authenticator.enrol(asAnna(), name: 'Phone', label: ''),
@ -1425,7 +1608,7 @@ 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 {
"first takes its owner's password, never another's", () async {
final complete = await annaComplete();
await expectLater(
atNow(
@ -1465,10 +1648,11 @@ void main() {
asBen,
authenticator: bens.authenticator,
code: codeOf(bens, at: now),
// Anna's password, for a challenge of hers.
proof: await ownerProof(),
),
),
refused(PasswordRefusal.invalidSetup),
refused(PasswordRefusal.wrongCredentials),
);
});
@ -1683,8 +1867,10 @@ void main() {
test('count proofs a session sends at once before they are checked: '
'nine checked, the session ended', () async {
await annaComplete();
// 29 more than the one confirming the first authenticator took:
// the session's 30 changes begun an hour.
final proofs = [
for (var i = 0; i < 30; i++)
for (var i = 0; i < 29; i++)
await ownerProof(totpCode: '${100000 + i}'),
];
final reasons = await Future.wait([
@ -1707,7 +1893,7 @@ void main() {
);
expect(
reasons.where((r) => r == PasswordRefusal.sessionEnded),
hasLength(21),
hasLength(20),
);
expect(
await ServerSideSession.db.findById(session, annasPhone),
@ -1952,6 +2138,19 @@ void main() {
expect(signedIn.needsAuthenticator, isTrue);
expect(await TotpAuthenticator.db.count(session), 0);
expect(await RecoveryCode.db.count(session), 0);
// Carried out, it ended every session made before (the fourth
// reading): only the one this sign-in made is left.
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNull,
);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
// The password alone, whatever code comes with it.
expect(
(await signIn(
@ -1960,10 +2159,12 @@ void main() {
isTrue,
);
final again = await atNow(() => signInNewDevice(session, anna));
final asAnnaAgain = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{identified},
authId: again.uuid,
),
);
final fresh = await endpoints.authenticator.enrol(
@ -1974,10 +2175,11 @@ void main() {
// Signing in elsewhere meanwhile leaves it to be confirmed.
expect((await signIn()).needsAuthenticator, isTrue);
final codes = await atNow(
() => endpoints.authenticator.confirm(
() async => await endpoints.authenticator.confirm(
asAnnaAgain,
authenticator: fresh.authenticator,
code: codeOf(fresh, at: now),
proof: await passwordAlone(from: asAnnaAgain),
),
);
expect(codes, hasLength(recoveryCodeCount));

View file

@ -51,10 +51,13 @@ void main() {
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
/// [person], signed in on a device whose session's ID is theirs.
TestSessionBuilder as(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
authId: person.uuid,
),
);
@ -153,10 +156,14 @@ void main() {
label: 'anna@example.org',
);
final codes = await atNow(
() => endpoints.authenticator.confirm(
() async => await endpoints.authenticator.confirm(
as(anna),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
// The first takes the password too (the fourth reading).
proof: device.ownerProof(
await endpoints.password.beginChange(as(anna)),
),
),
);
expect(codes, hasLength(10));
@ -328,10 +335,14 @@ void main() {
label: 'anna@example.org',
);
await atNow(
() => endpoints.authenticator.confirm(
() async => await endpoints.authenticator.confirm(
as(anna),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
// The first takes the password too (the fourth reading).
proof: device.ownerProof(
await endpoints.password.beginChange(as(anna)),
),
),
);
now = now.add(totpPeriod);