fix(auth)!: a first password takes a fresh proof of the account's way in

The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).

- An account with an address proves it anew: a code mailed to that
  address for this (EmailCodePurpose.firstPassword, its own mail), never
  the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
  a GitHub sign-in (the session's method and creation time, where the
  session lives; members tell the register), refused signInAgain before
  any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
  they add.

The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.

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:31:22 +02:00
commit f0413531ff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 762 additions and 66 deletions

View file

@ -117,6 +117,25 @@ void main() {
);
}
/// A code mailed to Anna's own address, for a password set while
/// signed in (the fourth reading: a session alone sets none).
Future<({UuidValue request, String code})> ownAddressCode() => mailedCode(
'anna@example.org',
purpose: EmailCodePurpose.firstPassword,
);
/// Anna's password set anew from her phone (until an authenticator is
/// confirmed), with a code to her own address.
Future<void> annaSetsAgain(PasswordDevice with_) async {
final code = await ownAddressCode();
await endpoints.password.set(
asAnna(),
setup: with_.setup(),
request: code.request,
code: code.code,
);
}
/// Anna's password and a confirmed authenticator: its enrolment, and
/// the recovery codes it brought.
Future<({AuthenticatorEnrolment enrolment, List<String> recoveryCodes})>
@ -208,14 +227,15 @@ void main() {
expect(ways.authenticator, isFalse);
});
test('needs no code from someone whose address is theirs', () async {
test('takes, from someone whose address is theirs, a code mailed to '
'that address for it', () async {
await annaSetsPassword();
await AccountPassword.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await endpoints.password.set(asAnna(), setup: device.setup());
await annaSetsAgain(device);
expect(
await AccountPassword.db.count(
@ -224,6 +244,127 @@ void main() {
),
1,
);
expect(mailbox.codes.last.purpose, EmailCodePurpose.firstPassword);
});
test('is refused to a session alone where the account has an '
'address, and for a code asked for anything else or another '
'address (the fourth reading)', () async {
// An account with an address and no password, as codes by mail
// left them: whoever took its session sets nothing.
await annaSetsPassword();
await AccountPassword.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
final thief = await PasswordDevice.make(seed: 99);
await expectLater(
endpoints.password.set(asAnna(), setup: thief.setup()),
refused(PasswordRefusal.wrongCredentials),
);
final signUp = await mailedCode('anna@example.org');
await expectLater(
endpoints.password.set(
asAnna(),
setup: thief.setup(),
request: signUp.request,
code: signUp.code,
),
refusedAsEmail(EmailSignInRefusal.codeExpired),
);
final theirs = await mailedCode(
'thief@example.org',
purpose: EmailCodePurpose.firstPassword,
);
await expectLater(
endpoints.password.set(
asAnna(),
setup: thief.setup(),
request: theirs.request,
code: theirs.code,
),
refusedAsEmail(EmailSignInRefusal.otherAddressLinked),
);
expect(await AccountPassword.db.count(session), 0);
expect(
(await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.addressHash,
addressHash(pod, 'anna@example.org'),
);
});
group('for someone who signs in with GitHub alone', () {
setUp(() async {
await GitHubAccount.db.insertRow(
session,
GitHubAccount(userIdentifier: '1001', authUserId: anna),
);
await grantIdentified(session, anna);
});
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{identified},
authId: device.uuid,
),
);
test('is refused to a session not made by a GitHub sign-in of the '
'last ten minutes, before the code is used, so a stolen '
'session claims nothing (the fourth reading)', () async {
final thief = await PasswordDevice.make(seed: 99);
final code = await mailedCode('thief@example.org');
final stale = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
now = now.add(gitHubSignInFreshFor + const Duration(seconds: 1));
for (final device in [annasPhone, stale]) {
await expectLater(
atNow(
() => endpoints.password.set(
from(device),
setup: thief.setup(),
request: code.request,
code: code.code,
),
),
refused(PasswordRefusal.signInAgain),
);
}
expect(await AccountPassword.db.count(session), 0);
expect(
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
expect(await gitHubAccountToSignIn(session, '1001'), anna);
// Signed in with GitHub just now, the same code still serves.
final fresh = await atNow(
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await atNow(
() => endpoints.password.set(
from(fresh),
setup: device.setup(),
request: code.request,
code: code.code,
),
);
expect(
await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
});
});
test('is refused without an address, keeping nothing', () async {
@ -346,7 +487,7 @@ void main() {
'then is refused', () async {
await annaSetsPassword();
final other = await PasswordDevice.make(seed: 99);
await endpoints.password.set(asAnna(), setup: other.setup());
await annaSetsAgain(other);
expect(
Uint8List.sublistView(
(await AccountPassword.db.findFirstRow(
@ -370,7 +511,7 @@ void main() {
});
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
annaSetsAgain(device),
refused(PasswordRefusal.passwordExists),
);
});
@ -1866,7 +2007,7 @@ void main() {
final other = await PasswordDevice.make(seed: 99);
await expectLater(
endpoints.password.set(asAnna(), setup: other.setup()),
annaSetsAgain(other),
refused(PasswordRefusal.passwordExists),
);
expect(
@ -2647,10 +2788,10 @@ void main() {
fresh.signingPublicKey,
);
await expectLater(finish(fresh), refused(PasswordRefusal.noReset));
// A session alone replaces neither, before an authenticator is
// confirmed again.
// A session (even with a code to the address) replaces neither,
// before an authenticator is confirmed again.
await expectLater(
endpoints.password.set(asAnna(), setup: device.setup()),
annaSetsAgain(device),
refused(PasswordRefusal.passwordExists),
);
expect(

View file

@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/instance_api.dart';
@ -43,6 +44,10 @@ final class _Register implements AccountRegister {
final passwords = <UuidValue>{};
final authenticated = <UuidValue>{};
/// The people a password was set for with a GitHub sign-in of the
/// member's made just now.
final freshGitHubSignIns = <UuidValue>{};
/// What a password sign-in proves; null refuses it.
PasswordProof? proof;
@ -205,9 +210,13 @@ final class _Register implements AccountRegister {
required PasswordSetup setup,
UuidValue? request,
String? code,
bool freshGitHubSignIn = false,
}) => _answer('setPassword', () {
add(person);
passwords.add(person);
if (freshGitHubSignIn) {
freshGitHubSignIns.add(person);
}
});
@override
@ -733,6 +742,7 @@ void main() {
);
expect(register.passwords, {anna});
expect(register.freshGitHubSignIns, isEmpty);
expect(await scopesOf(anna), contains(identified.name));
expect(
await AnonymousAccount.db.count(
@ -743,6 +753,38 @@ void main() {
);
});
test(
'tell the register whether the session asking was made by a '
'GitHub sign-in of the last ten minutes (the fourth reading)',
() async {
final device = await PasswordDevice.make();
Future<void> setFrom(UuidValue device_) => endpoints.password.set(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: device_.uuid,
),
),
setup: device.setup(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
);
final old = await withClock(
Clock.fixed(DateTime.now().subtract(const Duration(minutes: 11))),
() => signInNewDevice(session, anna, method: gitHubMethod),
);
await setFrom(old);
expect(register.freshGitHubSignIns, isEmpty);
await setFrom(annasPhone);
expect(register.freshGitHubSignIns, isEmpty);
await setFrom(
await signInNewDevice(session, anna, method: gitHubMethod),
);
expect(register.freshGitHubSignIns, {anna});
},
);
test('are offered as the register offers them, and not while it is '
'out of reach', () async {
expect(

View file

@ -55,6 +55,7 @@ Future<UuidValue> signInNewDevice(
Session session,
UuidValue person, {
Set<Scope> scopes = const {},
String method = 'test',
}) async {
Future<Set<UuidValue>> sessionIds() async => {
for (final serverSideSession in await ServerSideSession.db.find(
@ -68,7 +69,7 @@ Future<UuidValue> signInNewDevice(
await AuthServices.instance.tokenManager.createToken(
session,
authUserId: person,
method: 'test',
method: method,
scopes: scopes,
);
return (await sessionIds()).difference(before).single;