fix(auth): a password set up for good stays, through resets too

`password.set` replaced a password whenever the account had no confirmed
authenticator, which is also the state an authenticator's or the
account's reset leaves behind; and in that state a code by mail or GitHub
signed in again. So whoever got a session another way could replace the
password and the keys, skipping the reset's 72 hours.

An account had a confirmed authenticator while it has one or a reset of it
stands (`hadConfirmedAuthenticator`: the reset's row stays until a new
authenticator is confirmed). Then `password.set` is refused
(`passwordExists`), and the account signs in with its password alone:
codes by mail and GitHub are refused (`usePassword`), the latter through
`SignInWays.passwordOnly`, which members read from the register.

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 14:16:23 +02:00
commit 4e425cb17f
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 167 additions and 19 deletions

View file

@ -1248,6 +1248,67 @@ void main() {
);
});
test('once carried out, leaves the password to no session alone, and '
'the account to its password, until one is confirmed '
'again', () async {
await annaComplete();
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: anna),
);
await resetByMail();
now = now.add(resetDelay);
expect((await signIn()).needsAuthenticator, isTrue);
expect(await TotpAuthenticator.db.count(session), 0);
final other = await PasswordDevice.make(seed: 99);
await expectLater(
endpoints.password.set(asAnna(), setup: other.setup()),
refused(PasswordRefusal.passwordExists),
);
expect(
Uint8List.sublistView(
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.verifier,
),
Uint8List.sublistView(device.verifier),
);
// Nor does a code by mail or GitHub sign in in its place.
expect(await signsInWithPassword(session, anna), isTrue);
final ben = await newAnonymousPerson(session);
final asBen = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
),
);
final request = await endpoints.emailCode.sendCode(
asBen,
address: 'anna@example.org',
language: 'en',
);
await expectLater(
endpoints.emailCode.verifyCode(
asBen,
request: request,
code: mailbox.codeFor('anna@example.org'),
),
refusedAsEmail(EmailSignInRefusal.usePassword),
);
await expectLater(
gitHubAccountToSignIn(session, '1001'),
throwsA(
isA<GitHubSignInRefusedException>().having(
(error) => error.reason,
'reason',
GitHubSignInRefusal.usePassword,
),
),
);
});
test('is called off by signing in with a code meanwhile', () async {
final complete = await annaComplete();
await resetByMail();
@ -1659,6 +1720,21 @@ void main() {
fresh.signingPublicKey,
);
await expectLater(finish(fresh), refused(PasswordRefusal.noReset));
// A session alone replaces neither, before an authenticator is
// confirmed again.
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
refused(PasswordRefusal.passwordExists),
);
expect(
Uint8List.sublistView(
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.verifier,
),
Uint8List.sublistView(fresh.verifier),
);
});
test('is called off by signing in with the password', () async {

View file

@ -219,6 +219,8 @@ final class _Register implements AccountRegister {
email: addresses.containsValue(person),
password: passwords.contains(person),
authenticator: authenticated.contains(person),
passwordOnly:
passwords.contains(person) && authenticated.contains(person),
),
);