feat(auth): a password with TOTP on the register, and resets

The register's side of signing in with an address, a password that never
leaves the device (SRP-6a) and a code from an authenticator (ADR 0017),
with members forwarding every step through AccountRegister:

- password.set: salt, Argon2id's costs, SRP's verifier, the public keys
  and the private keys wrapped twice, all made on the device; with the
  address a mailed code proves, an anonymous person becomes identified
  under their ID. It signs in once an authenticator is confirmed.
- passwordSignIn.begin and complete: SRP's two steps with a TOTP or
  recovery code. An address nobody has is answered from a decoy key as
  an account would be; every wrong credential is refused alike.
- authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound
  to their row), each code accepted once, ten recovery codes with the
  first, the last one kept.
- accountRecovery: a lost authenticator reset 72 hours after the
  password and a mailed code or the recovery phrase ask, a lost password
  set anew with the phrase's signature, or the account reset with new
  keys 72 hours after a mailed code asks; a mail at each start, a
  sign-in calling it off.
- An account with a password no longer signs in by a mailed code or
  GitHub; no new GitHub account is linked where passwords are offered,
  and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go.

Five tables, added by the module's migration; deletePerson deletes
their rows.

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 13:32:12 +02:00
commit a3a8ed3890
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -11,6 +11,7 @@ import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/password_device.dart';
import '../test_tools/serverpod_test_tools.dart';
const _secret = 'a shared secret only the instance knows';
@ -35,6 +36,16 @@ final class _Register implements AccountRegister {
final askedAbout = <Set<UuidValue>>[];
bool offered = true;
/// Whether it offers password sign-in.
bool passwordOffered = true;
/// The accounts with a password, and those with an authenticator too.
final passwords = <UuidValue>{};
final authenticated = <UuidValue>{};
/// What a password sign-in proves; null refuses it.
PasswordProof? proof;
/// Thrown by every call while set: the register refusing, or down.
Exception? failing;
@ -206,9 +217,168 @@ final class _Register implements AccountRegister {
() => SignInWays(
gitHub: gitHub.containsValue(person),
email: addresses.containsValue(person),
password: passwords.contains(person),
authenticator: authenticated.contains(person),
),
);
@override
Future<bool> passwordSignInOffered(Session session) =>
_answer('passwordSignInOffered', () => passwordOffered);
@override
Future<void> setPassword(
Session session, {
required UuidValue person,
required PasswordSetup setup,
UuidValue? request,
String? code,
}) => _answer('setPassword', () {
add(person);
passwords.add(person);
});
@override
Future<PasswordChallenge> beginPasswordSignIn(
Session session, {
required String address,
required String network,
}) => _answer('beginPasswordSignIn', () {
networks.add(network);
return PasswordChallenge(
challenge: const Uuid().v7obj(),
salt: ByteData(16),
costs: newPasswordCosts,
serverValue: ByteData(384),
);
});
PasswordProof _proven() =>
proof ??
(throw PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
));
@override
Future<PasswordProof> completePasswordSignIn(
Session session, {
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
}) => _answer('completePasswordSignIn', _proven);
@override
Future<AuthenticatorEnrolment> enrolAuthenticator(
Session session, {
required UuidValue person,
required String name,
required String label,
}) => _answer(
'enrolAuthenticator',
() => AuthenticatorEnrolment(
authenticator: const Uuid().v7obj(),
uri: 'otpauth://totp/learn.test:$label',
),
);
@override
Future<List<String>> confirmAuthenticator(
Session session, {
required UuidValue person,
required UuidValue authenticator,
required String code,
}) => _answer('confirmAuthenticator', () {
authenticated.add(person);
return const ['K7QX-M2PD'];
});
@override
Future<List<Authenticator>> authenticatorsOf(
Session session,
UuidValue person,
) => _answer('authenticatorsOf', () => const <Authenticator>[]);
@override
Future<void> removeAuthenticator(
Session session, {
required UuidValue person,
required UuidValue authenticator,
String? code,
}) => _answer('removeAuthenticator', () {});
@override
Future<List<String>> newRecoveryCodes(
Session session, {
required UuidValue person,
required String code,
}) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']);
@override
Future<ResetPending> resetAuthenticator(
Session session, {
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
required String address,
required String language,
UuidValue? request,
String? code,
UuidValue? keyChallenge,
ByteData? signature,
}) => _answer(
'resetAuthenticator',
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
);
@override
Future<KeyProofChallenge> beginKeyProof(
Session session, {
required String address,
required String network,
}) => _answer('beginKeyProof', () {
networks.add(network);
return KeyProofChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
);
});
@override
Future<UuidValue> resetPasswordWithPhrase(
Session session, {
required UuidValue challenge,
required ByteData signature,
required NewPassword password,
String? totpCode,
String? recoveryCode,
}) => _answer('resetPasswordWithPhrase', () => _proven().account);
@override
Future<ResetPending> startAccountReset(
Session session, {
required String address,
required String language,
required UuidValue request,
required String code,
}) => _answer(
'startAccountReset',
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
);
@override
Future<UuidValue> completeAccountReset(
Session session, {
required String address,
required UuidValue request,
required String code,
required PasswordSetup setup,
}) => _answer('completeAccountReset', () => _proven().account);
@override
Future<void> deleteAccount(Session session, UuidValue person) =>
_answer('deleteAccount', () {
@ -491,6 +661,135 @@ void main() {
});
});
group('passwords, by asking the register (ADR 0017)', () {
test('sign the account the register proved in here, made a person '
'here the first time, the device network passed on', () async {
final carla = const Uuid().v7obj();
register.proof = PasswordProof(
account: carla,
serverProof: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
needsAuthenticator: false,
);
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'carla@example.org',
);
final signedIn = await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
);
expect(register.asked, [
'beginPasswordSignIn',
'completePasswordSignIn',
]);
expect(register.networks, hasLength(1));
expect(signedIn.authUserId, carla);
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
passwordMethod,
);
});
test('refuse as the register refuses, signing nobody in', () async {
final people = await AuthUser.db.count(session);
await expectLater(
endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
),
throwsA(
isA<PasswordRefusedException>().having(
(error) => error.reason,
'reason',
PasswordRefusal.wrongCredentials,
),
),
);
expect(await AuthUser.db.count(session), people);
});
test('set one at the register, the person becoming identified '
'here', () async {
final device = await PasswordDevice.make();
await endpoints.password.set(
asAnna(),
setup: device.setup(),
request: const Uuid().v7obj(),
code: 'K7QX-M2PD',
);
expect(register.passwords, {anna});
expect(await scopesOf(anna), contains(identified.name));
expect(
await AnonymousAccount.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
0,
);
});
test('are offered as the register offers them, and not while it is '
'out of reach', () async {
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isTrue,
);
register.passwordOffered = false;
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isFalse,
);
register
..passwordOffered = true
..failing = RegisterUnavailableException('test');
expect(
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
isFalse,
);
});
test('keep GitHub from signing into an account that has one, as '
'the register knows it', () async {
register.gitHub['1001'] = ben;
expect(await gitHubAccountToSignIn(session, '1001'), ben);
register
..passwords.add(ben)
..authenticated.add(ben);
await expectLater(
gitHubAccountToSignIn(session, '1001'),
throwsA(
isA<GitHubSignInRefusedException>().having(
(error) => error.reason,
'reason',
GitHubSignInRefusal.usePassword,
),
),
);
});
});
group('profiles', () {
test('are saved and found at the register', () async {
final saved = await endpoints.profile.save(