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:
parent
200a2df32f
commit
a3a8ed3890
1 changed files with 22557 additions and 60 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
|
|
@ -13,6 +14,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';
|
||||
|
|
@ -31,6 +33,15 @@ final class _Mailbox implements CodeMailer {
|
|||
required String language,
|
||||
}) async => sent.add((to: to, code: code));
|
||||
|
||||
@override
|
||||
Future<void> sendResetNotice(
|
||||
Session session, {
|
||||
required String to,
|
||||
required AccountResetKind kind,
|
||||
required DateTime takesEffectAt,
|
||||
required String language,
|
||||
}) async {}
|
||||
|
||||
String codeFor(String address) =>
|
||||
sent.lastWhere((mail) => mail.to == address).code;
|
||||
}
|
||||
|
|
@ -442,6 +453,216 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
group('passwords (ADR 0017)', () {
|
||||
late DateTime now;
|
||||
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||
withClock(Clock(() => now), body);
|
||||
|
||||
setUp(() {
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
});
|
||||
|
||||
test('are offered as the register offers them', () async {
|
||||
expect(await remote.passwordSignInOffered(session), isTrue);
|
||||
usePasswordKeys(pod, null);
|
||||
expect(await remote.passwordSignInOffered(session), isFalse);
|
||||
await expectLater(
|
||||
remote.beginPasswordSignIn(
|
||||
session,
|
||||
address: 'ben@example.org',
|
||||
network: '203.0.113.7',
|
||||
),
|
||||
refused(PasswordRefusal.unavailable),
|
||||
);
|
||||
});
|
||||
|
||||
test("cross for a member's device, every operation, with what the "
|
||||
'register refuses', () async {
|
||||
final device = await PasswordDevice.make();
|
||||
await atNow(() async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
final request = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
await expectLater(
|
||||
remote.setPassword(
|
||||
session,
|
||||
person: ben,
|
||||
setup: device.setup().copyWith(salt: ByteData(3)),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
|
||||
final enrolment = await remote.enrolAuthenticator(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
name: 'Phone',
|
||||
label: 'anna@example.org',
|
||||
);
|
||||
final codes = await remote.confirmAuthenticator(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment),
|
||||
);
|
||||
expect(codes, hasLength(recoveryCodeCount));
|
||||
expect(
|
||||
(await remote.authenticatorsOf(session, annaOnTodo)).single.id,
|
||||
enrolment.authenticator,
|
||||
);
|
||||
final spare = await remote.enrolAuthenticator(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
name: 'Spare',
|
||||
label: '',
|
||||
);
|
||||
await remote.removeAuthenticator(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
authenticator: spare.authenticator,
|
||||
);
|
||||
final ways = await remote.signInWaysOf(session, annaOnTodo);
|
||||
expect(ways.password && ways.authenticator, isTrue);
|
||||
|
||||
now = now.add(totpPeriod);
|
||||
final challenge = await remote.beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
final proof = await remote.completePasswordSignIn(
|
||||
session,
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
);
|
||||
expect(proof.account, annaOnTodo);
|
||||
expect(answer.verifiesServer(proof.serverProof), isTrue);
|
||||
expect(proof.passwordKeys.toJson(), device.passwordKeys.toJson());
|
||||
final again = await remote.beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
final againAnswer = device.answer(again);
|
||||
await expectLater(
|
||||
remote.completePasswordSignIn(
|
||||
session,
|
||||
challenge: again.challenge,
|
||||
clientValue: againAnswer.clientValue,
|
||||
clientProof: againAnswer.clientProof,
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
|
||||
now = now.add(totpPeriod);
|
||||
expect(
|
||||
await remote.newRecoveryCodes(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
code: codeOf(enrolment, at: now),
|
||||
),
|
||||
hasLength(recoveryCodeCount),
|
||||
);
|
||||
|
||||
final keyChallenge = await remote.beginKeyProof(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
expect(
|
||||
keyChallenge.recoveryKeys.toJson(),
|
||||
device.recoveryKeys.toJson(),
|
||||
);
|
||||
now = now.add(totpPeriod);
|
||||
expect(
|
||||
await remote.resetPasswordWithPhrase(
|
||||
session,
|
||||
challenge: keyChallenge.challenge,
|
||||
signature: await device.sign(keyChallenge),
|
||||
password: device.newPassword(),
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
annaOnTodo,
|
||||
);
|
||||
|
||||
final forReset = await remote.beginPasswordSignIn(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
final resetAnswer = device.answer(forReset);
|
||||
final resetRequest = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
final pending = await remote.resetAuthenticator(
|
||||
session,
|
||||
challenge: forReset.challenge,
|
||||
clientValue: resetAnswer.clientValue,
|
||||
clientProof: resetAnswer.clientProof,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: resetRequest,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
);
|
||||
expect(pending.takesEffectAt, now.add(resetDelay));
|
||||
|
||||
final startRequest = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
await remote.startAccountReset(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: startRequest,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
);
|
||||
now = now.add(resetDelay);
|
||||
final fresh = await PasswordDevice.make(seed: 99);
|
||||
final completeRequest = await remote.sendSignInCode(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
network: '203.0.113.7',
|
||||
);
|
||||
expect(
|
||||
await remote.completeAccountReset(
|
||||
session,
|
||||
address: 'anna@example.org',
|
||||
request: completeRequest,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
setup: fresh.setup(),
|
||||
),
|
||||
annaOnTodo,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('deleting an account', () {
|
||||
test('deletes it here and tells every member, keeping no notice '
|
||||
'once they confirmed', () async {
|
||||
|
|
@ -660,6 +881,17 @@ final class _NoSession implements Session {
|
|||
}
|
||||
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await SignInChallenge.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await TotpAuthenticator.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await AccountDeletionNotice.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
|
|
|
|||
Loading…
Reference in a new issue