fix(auth): a reset waits while its owner is held out, and devices show it

The full reading's H1: an owner could be kept from calling a reset off, and
the reset then went through. Whoever knew the password and had the mailbox
asked an authenticators' reset and kept the codes waiting, so the owner's
sign-ins were refused until it took effect; whoever had the mailbox filled
the address's proofs ceiling from three networks and completed an account
reset. Now every hold-out of the owner after a reset was asked (a wait
that begins, an attempt a wait refuses, a sign-in or proof refused by the
address's ceiling) moves the account's resets not carried out to take
effect no sooner than 72 hours after it ends (pushOutResets). A reset
carried out (the password alone signing in until an authenticator is
confirmed) is never pushed, nor a lapsed one brought back.

Signed-in devices are told too: SignInWays carries when a waiting reset
of each kind takes effect, the account section shows them with Call it
off, which takes the owner's proof (password and code; password.
callOffResets, forwarded by members) and calls every reset off, as no wait
holds a signed-in owner's proof back. Texts in English and German.

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 13:17:30 +02:00
commit 9d0d037c86
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -238,6 +238,208 @@ void main() {
orderBy: (t) => t.kind,
);
group('wait while their owner is held out (H1)', () {
test('codes made to wait push a reset of the authenticators out: '
'whoever knows the password and has the mailbox carries out '
'nothing while the owner is held back, and the owner calls it '
'off once codes are taken again', () async {
final enrolment = await annaComplete();
final asked = await askAuthenticatorReset();
expect(mailbox.notices, hasLength(1));
for (var i = 0; i < 10; i++) {
await expectLater(
signInFrom('192.0.2.9', totp: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
}
final waitEnds = mailbox.codesWait.single.until;
expect(
(await resets()).single.takesEffectAt,
waitEnds.add(resetDelay),
);
// Anna, held back while codes wait: her try pushes it no less.
nextStep();
await expectLater(
signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now)),
refused(PasswordRefusal.codesWait),
);
// When it would have taken effect, the password alone carries
// out nothing.
now = asked.takesEffectAt;
await expectLater(
signInFrom('192.0.2.10'),
refused(PasswordRefusal.notDue),
);
// Codes taken again: Anna signs in, and it is called off.
now = waitEnds.add(const Duration(minutes: 1));
await signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now));
expect(await resets(), isEmpty);
});
test("the address's ceiling filled from three networks pushes the "
"account's reset out: whoever has the mailbox completes nothing "
'while the owner is held back', () async {
final enrolment = await annaComplete();
final asked = await askAccountReset();
final wrong = await PasswordDevice.make(seed: 77);
for (final network in ['192.0.2.1', '192.0.2.2', '192.0.2.3']) {
for (var i = 0; i < 10; i++) {
await expectLater(
signInFrom(network, with_: wrong),
refused(PasswordRefusal.wrongCredentials),
);
}
}
final heldOutAt = now;
await expectLater(
signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now)),
refused(PasswordRefusal.tooManyAttempts),
);
expect(
(await resets()).single.takesEffectAt,
heldOutAt.add(const Duration(hours: 1)).add(resetDelay),
);
now = asked.takesEffectAt;
final other = await PasswordDevice.make(seed: 5);
await expectLater(
completeAccountReset(other.setup()),
refused(PasswordRefusal.notDue),
);
// The hour over, Anna signs in and calls it off.
nextStep();
await signInFrom('198.51.100.7', totp: codeOf(enrolment, at: now));
expect(await resets(), isEmpty);
});
test('a reset carried out is never pushed: its owner, mid-way, '
'still signs in with the password alone', () async {
await annaComplete();
final asked = await askAuthenticatorReset();
now = asked.takesEffectAt;
expect(
(await signInFrom('198.51.100.7')).needsAuthenticator,
isTrue,
);
// 29 more proofs fill the address's 30.
final wrong = await PasswordDevice.make(seed: 77);
for (var i = 0; i < 29; i++) {
await expectLater(
signInFrom('192.0.2.${i ~/ 10 + 1}', with_: wrong),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
signInFrom('198.51.100.8'),
refused(PasswordRefusal.tooManyAttempts),
);
now = now.add(const Duration(hours: 1, minutes: 1));
expect(
(await signInFrom('198.51.100.8')).needsAuthenticator,
isTrue,
);
});
test('a reset that lapsed is never brought back by a push', () async {
await annaComplete();
final asked = await askAccountReset();
now = asked.takesEffectAt.add(
resetLapsesAfter + const Duration(minutes: 1),
);
final wrong = await PasswordDevice.make(seed: 77);
for (final network in ['192.0.2.1', '192.0.2.2', '192.0.2.3']) {
for (var i = 0; i < 10; i++) {
await expectLater(
signInFrom(network, with_: wrong),
refused(PasswordRefusal.wrongCredentials),
);
}
}
await expectLater(
signInFrom('198.51.100.7'),
refused(PasswordRefusal.tooManyAttempts),
);
expect((await resets()).single.takesEffectAt, asked.takesEffectAt);
});
test("are shown to the owner's signed-in devices, each kind with "
'when it takes effect, none lapsed or carried out', () async {
await annaComplete();
var ways = await endpoints.account.signInWays(asAnna());
expect(ways.authenticatorResetAt, isNull);
expect(ways.accountResetAt, isNull);
final authenticator = await askAuthenticatorReset();
final account = await askAccountReset();
ways = await endpoints.account.signInWays(asAnna());
expect(ways.authenticatorResetAt, authenticator.takesEffectAt);
expect(ways.accountResetAt, account.takesEffectAt);
// Lapsed: shown no more.
now = account.takesEffectAt.add(
resetLapsesAfter + const Duration(minutes: 1),
);
ways = await atNow(() => endpoints.account.signInWays(asAnna()));
expect(ways.authenticatorResetAt, isNull);
expect(ways.accountResetAt, isNull);
});
test("are called off by the signed-in owner's proof, even while "
'codes wait', () async {
final enrolment = await annaComplete();
await askAuthenticatorReset();
await askAccountReset();
for (var i = 0; i < 10; i++) {
await expectLater(
signInFrom('192.0.2.9', totp: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
}
nextStep();
await atNow(
() async => await endpoints.password.callOffResets(
asAnna(),
proof: device.ownerProof(
await endpoints.password.beginChange(asAnna()),
totpCode: codeOf(enrolment, at: now),
),
),
);
expect(await resets(), isEmpty);
});
test(
'are called off only with the password and a code: a session '
'alone, a wrong password or a wrong code calls off nothing',
() async {
final enrolment = await annaComplete();
await askAuthenticatorReset();
final wrong = await PasswordDevice.make(seed: 77);
for (final proof in <Future<OwnerProof> Function()>[
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
),
() async => wrong.ownerProof(
await endpoints.password.beginChange(asAnna()),
totpCode: codeOf(enrolment, at: now),
),
() async => device.ownerProof(
await endpoints.password.beginChange(asAnna()),
totpCode: '000000',
),
]) {
await expectLater(
atNow(
() async => await endpoints.password.callOffResets(
asAnna(),
proof: await proof(),
),
),
refused(PasswordRefusal.wrongCredentials),
);
}
expect(await resets(), hasLength(1));
},
);
});
group("the phrase's codes count apart from the password's (M3)", () {
test('codes waiting after the password never hold back the phrase: '
'its owner resets the password with it and a code', () async {