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
14 changed files with 22557 additions and 60 deletions

View file

@ -24,6 +24,15 @@ final class _Mailbox implements CodeMailer {
sent.add((to: to, code: code, language: language));
}
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {}
/// The last code mailed to [address].
String codeFor(String address) =>
sent.lastWhere((mail) => mail.to == address).code;
@ -38,6 +47,15 @@ final class _Refusing implements CodeMailer {
required String code,
required String language,
}) async => throw const SocketException('Connection refused');
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async => throw const SocketException('Connection refused');
}
void main() {

View file

@ -1,9 +1,11 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
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';
void main() {
@ -169,6 +171,71 @@ void main() {
expect(info.gitHubClientId, isNull);
});
group('deprecated (ADR 0017)', () {
tearDown(() {
usePasswordKeys(session.serverpod, null);
EmailCodes.of(session.serverpod).mailer = null;
useGitHubSignInEnds(session.serverpod, null);
});
test('links no new GitHub account where people sign in with a '
'password, and still signs in those who have one', () async {
usePasswordKeys(session.serverpod, testPasswordKeys);
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
await expectLater(
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
throwsA(_refusal(GitHubSignInRefusal.deprecated)),
);
expect(await _isAnonymous(session, anna), isTrue);
expect(await _gitHubAccount(session, '2002'), isNull);
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
expect(await gitHubAccountToSignIn(session, '1001'), ben);
});
test('signs nobody in and links nothing from the day it ends, after '
'which pruning forgets every GitHub ID', () async {
final ends = DateTime.utc(2027, 1, 22);
useGitHubSignInEnds(session.serverpod, ends);
expect(
await forgetGitHubAccounts(
session,
now: ends.subtract(const Duration(seconds: 1)),
),
0,
);
await withClock(Clock.fixed(ends), () async {
await expectLater(
endpoints.gitHubSignIn.signIn(
notSignedIn(),
code: 'code',
codeVerifier: 'verifier',
redirectUri: _redirectUri,
),
throwsA(_refusal(GitHubSignInRefusal.ended)),
);
await expectLater(
endpoints.gitHubUpgrade.upgrade(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{},
authId: annasPhone.uuid,
),
),
code: 'code',
codeVerifier: 'verifier',
redirectUri: _redirectUri,
),
throwsA(_refusal(GitHubSignInRefusal.ended)),
);
});
expect(await forgetGitHubAccounts(session, now: ends), 1);
expect(await GitHubAccount.db.count(session), 0);
});
});
});
}

View file

@ -14,6 +14,15 @@ final class _NoMail implements CodeMailer {
required String code,
required String language,
}) async {}
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {}
}
void main() {

File diff suppressed because it is too large Load diff

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(

View file

@ -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(

View file

@ -45,6 +45,9 @@ void useBase(
tokenManagerBuilders: tokenManagerBuilders,
identityProviderBuilders: identityProviderBuilders(pod),
);
// No password sign-in unless a test gives it keys, whatever the test
// run's passwords hold.
usePasswordKeys(pod, null);
}
/// Signs [person] in on a new device and returns the new session's id.

View file

@ -0,0 +1,55 @@
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
/// Keeps every code and notice mailed, in place of a mail service.
final class Mailbox implements CodeMailer {
final codes = <({String to, String code, String language})>[];
final notices =
<
({
String to,
AccountResetKind kind,
DateTime takesEffectAt,
String language,
})
>[];
/// Whether it takes no mail, as a mail service down.
bool refusing = false;
@override
Future<void> send(
Session session, {
required String to,
required String code,
required String language,
}) async {
if (refusing) {
throw Exception('Connection refused');
}
codes.add((to: to, code: code, language: language));
}
@override
Future<void> sendResetNotice(
Session session, {
required String to,
required AccountResetKind kind,
required DateTime takesEffectAt,
required String language,
}) async {
if (refusing) {
throw Exception('Connection refused');
}
notices.add((
to: to,
kind: kind,
takesEffectAt: takesEffectAt,
language: language,
));
}
/// The last code mailed to [address].
String codeFor(String address) =>
codes.lastWhere((mail) => mail.to == address).code;
}

View file

@ -0,0 +1,144 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:cryptography/cryptography.dart';
import 'package:halkyon_base_client/srp.dart' as srp;
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/srp/srp_math.dart';
/// The instance keys the tests' register holds (ADR 0017).
const testPasswordKeys = (
totpKey: 'a TOTP key of the tests, 32 characters at least',
recoveryCodeKey: 'a recovery code key of the tests, long enough',
decoyKey: 'a decoy key of the tests, also long enough here',
);
/// A device with an account's password, as halkyon_keys makes one, without
/// libsodium: SRP's x and the account's keys random, the signing key a real
/// Ed25519 pair, so it can prove the recovery phrase. What the register
/// checks is all here; what only a device opens (the wrapped keys) is
/// random bytes of the right length.
final class PasswordDevice {
PasswordDevice._(this._random, this.signingKeys, this.signingPublicKey)
: salt = _bytes(_random, 16),
privateValue = numberOf(_bytes(_random, 32)),
encryptionPublicKey = _bytes(_random, 32);
/// A new device's password, from [seed].
static Future<PasswordDevice> make({int seed = 17}) async {
final random = Random(seed);
final keys = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
final public = await keys.extractPublicKey();
return PasswordDevice._(random, keys, Uint8List.fromList(public.bytes));
}
final Random _random;
/// The account's signing key pair, which the recovery phrase opens.
final SimpleKeyPair signingKeys;
final Uint8List signingPublicKey;
final Uint8List encryptionPublicKey;
/// The password's salt and x, as Argon2id would make them.
Uint8List salt;
BigInt privateValue;
final _clientGroup = srp.SrpGroup.rfc5054Group3072;
/// v = g^x mod N, as long as N.
ByteData get verifier => ByteData.sublistView(
padded(
srp.srpVerifier(_clientGroup, privateValue),
passwordGroup.length,
),
);
WrappedKeys _wrapped() => WrappedKeys(
nonce: ByteData.sublistView(_bytes(_random, 24)),
cipherText: ByteData.sublistView(_bytes(_random, wrappedKeysLength)),
);
/// The password's keys as the setup wrapped them.
late final WrappedKeys passwordKeys = _wrapped();
late final WrappedKeys recoveryKeys = _wrapped();
/// The setup the device sends for this password.
PasswordSetup setup() => PasswordSetup(
salt: ByteData.sublistView(salt),
costs: newPasswordCosts,
verifier: verifier,
encryptionPublicKey: ByteData.sublistView(encryptionPublicKey),
signingPublicKey: ByteData.sublistView(signingPublicKey),
passwordKeys: passwordKeys,
recoveryKeys: recoveryKeys,
);
/// A new password for the same keys: the device's salt and x change.
NewPassword newPassword() {
salt = _bytes(_random, 16);
privateValue = numberOf(_bytes(_random, 32));
return NewPassword(
salt: ByteData.sublistView(salt),
costs: newPasswordCosts,
verifier: verifier,
passwordKeys: _wrapped(),
);
}
/// The device's answer to [challenge] (A and M1), with its password or
/// the [privateValue] of another; and whether a register's M2 is the one
/// that answer expects.
({
ByteData clientValue,
ByteData clientProof,
bool Function(ByteData serverProof) verifiesServer,
})
answer(PasswordChallenge challenge, {BigInt? privateValue}) {
final client = srp.SrpClient(_clientGroup, random: _random);
final step = client.respond(
identity: challenge.challenge.toBytes(),
salt: Uint8List.sublistView(challenge.salt),
privateValue: privateValue ?? this.privateValue,
serverValue: numberOf(Uint8List.sublistView(challenge.serverValue)),
);
final proof = step as srp.SrpClientProof;
return (
clientValue: ByteData.sublistView(
padded(client.publicValue, passwordGroup.length),
),
clientProof: ByteData.sublistView(proof.proof),
verifiesServer: (serverProof) =>
proof.verifiesServer(Uint8List.sublistView(serverProof)),
);
}
/// The signature proving the recovery phrase for [challenge]: the
/// account's own, or [keys]' where given.
Future<ByteData> sign(
KeyProofChallenge challenge, {
SimpleKeyPair? keys,
}) async {
final signature = await Ed25519().sign(
keyProofMessage(
challenge.challenge,
Uint8List.sublistView(challenge.nonce),
),
keyPair: keys ?? signingKeys,
);
return ByteData.sublistView(Uint8List.fromList(signature.bytes));
}
}
/// Another signing key pair than any account's.
Future<SimpleKeyPair> otherSigningKeys() =>
Ed25519().newKeyPairFromSeed(List.filled(32, 7));
/// The code [enrolment]'s authenticator shows at [at] (now by default).
String codeOf(AuthenticatorEnrolment enrolment, {DateTime? at}) => totpCode(
base32Decode(Uri.parse(enrolment.uri).queryParameters['secret']!),
step: totpStepAt(at ?? clock.now()),
);
Uint8List _bytes(Random random, int length) =>
Uint8List.fromList([for (var i = 0; i < length; i++) random.nextInt(256)]);

View file

@ -20,6 +20,22 @@ import 'package:halkyon_base_server/src/generated/auth/sign_in_ways.dart'
as _ik4ah9vo;
import 'package:halkyon_base_server/src/generated/instance/instance_basics.dart'
as _ihusd8tw;
import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
as _ip34xal6;
import 'package:halkyon_base_server/src/generated/password/authenticator_enrolment.dart'
as _id229ey1;
import 'package:halkyon_base_server/src/generated/password/key_proof_challenge.dart'
as _iaysae70;
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
as _ijye8mcv;
import 'package:halkyon_base_server/src/generated/password/password_challenge.dart'
as _isz0atlx;
import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
as _ikhyee47;
import 'package:halkyon_base_server/src/generated/password/password_signed_in.dart'
as _ikpyb5fh;
import 'package:halkyon_base_server/src/generated/password/reset_pending.dart'
as _isakxto6;
import 'package:halkyon_base_server/src/generated/profile/profile.dart'
as _it4tom37;
import 'package:halkyon_base_server/src/generated/profile/profile_refusal.dart'
@ -175,6 +191,14 @@ class TestEndpoints {
late final _InstanceEndpoint instance;
late final _AccountRecoveryEndpoint accountRecovery;
late final _AuthenticatorEndpoint authenticator;
late final _PasswordEndpoint password;
late final _PasswordSignInEndpoint passwordSignIn;
late final _ProfileEndpoint profile;
late final _ProfilesEndpoint profiles;
@ -215,6 +239,22 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
accountRecovery = _AccountRecoveryEndpoint(
endpoints,
serializationManager,
);
authenticator = _AuthenticatorEndpoint(
endpoints,
serializationManager,
);
password = _PasswordEndpoint(
endpoints,
serializationManager,
);
passwordSignIn = _PasswordSignInEndpoint(
endpoints,
serializationManager,
);
profile = _ProfileEndpoint(
endpoints,
serializationManager,
@ -597,6 +637,559 @@ class _InstanceEndpoint {
}
}
class _AccountRecoveryEndpoint {
_AccountRecoveryEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_is.UuidValue> sendCode(
_ist.TestSessionBuilder sessionBuilder, {
required String address,
required String language,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'accountRecovery',
method: 'sendCode',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'accountRecovery',
methodName: 'sendCode',
parameters: _ist.testObjectToJson({
'address': address,
'language': language,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_is.UuidValue>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_isakxto6.ResetPending> resetAuthenticator(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
required String address,
required String language,
_is.UuidValue? request,
String? code,
_is.UuidValue? keyChallenge,
_idt.ByteData? signature,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'accountRecovery',
method: 'resetAuthenticator',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'accountRecovery',
methodName: 'resetAuthenticator',
parameters: _ist.testObjectToJson({
'challenge': challenge,
'clientValue': clientValue,
'clientProof': clientProof,
'address': address,
'language': language,
'request': request,
'code': code,
'keyChallenge': keyChallenge,
'signature': signature,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isakxto6.ResetPending>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_iaysae70.KeyProofChallenge> beginKeyProof(
_ist.TestSessionBuilder sessionBuilder, {
required String address,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'accountRecovery',
method: 'beginKeyProof',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'accountRecovery',
methodName: 'beginKeyProof',
parameters: _ist.testObjectToJson({'address': address}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iaysae70.KeyProofChallenge>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_iacs.AuthSuccess> resetPassword(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue challenge,
required _idt.ByteData signature,
required _ijye8mcv.NewPassword password,
String? totpCode,
String? recoveryCode,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'accountRecovery',
method: 'resetPassword',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'accountRecovery',
methodName: 'resetPassword',
parameters: _ist.testObjectToJson({
'challenge': challenge,
'signature': signature,
'password': password,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_isakxto6.ResetPending> startAccountReset(
_ist.TestSessionBuilder sessionBuilder, {
required String address,
required String language,
required _is.UuidValue request,
required String code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'accountRecovery',
method: 'startAccountReset',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'accountRecovery',
methodName: 'startAccountReset',
parameters: _ist.testObjectToJson({
'address': address,
'language': language,
'request': request,
'code': code,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isakxto6.ResetPending>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_iacs.AuthSuccess> completeAccountReset(
_ist.TestSessionBuilder sessionBuilder, {
required String address,
required _is.UuidValue request,
required String code,
required _ikhyee47.PasswordSetup setup,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'accountRecovery',
method: 'completeAccountReset',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'accountRecovery',
methodName: 'completeAccountReset',
parameters: _ist.testObjectToJson({
'address': address,
'request': request,
'code': code,
'setup': setup,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iacs.AuthSuccess>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _AuthenticatorEndpoint {
_AuthenticatorEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_id229ey1.AuthenticatorEnrolment> enrol(
_ist.TestSessionBuilder sessionBuilder, {
required String name,
required String label,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'authenticator',
method: 'enrol',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'authenticator',
methodName: 'enrol',
parameters: _ist.testObjectToJson({
'name': name,
'label': label,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_id229ey1.AuthenticatorEnrolment>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<String>> confirm(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue authenticator,
required String code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'authenticator',
method: 'confirm',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'authenticator',
methodName: 'confirm',
parameters: _ist.testObjectToJson({
'authenticator': authenticator,
'code': code,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<String>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<_ip34xal6.Authenticator>> list(
_ist.TestSessionBuilder sessionBuilder,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'authenticator',
method: 'list',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'authenticator',
methodName: 'list',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_ip34xal6.Authenticator>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> remove(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue authenticator,
String? code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'authenticator',
method: 'remove',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'authenticator',
methodName: 'remove',
parameters: _ist.testObjectToJson({
'authenticator': authenticator,
'code': code,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<String>> newRecoveryCodes(
_ist.TestSessionBuilder sessionBuilder, {
required String code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'authenticator',
method: 'newRecoveryCodes',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'authenticator',
methodName: 'newRecoveryCodes',
parameters: _ist.testObjectToJson({'code': code}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<String>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PasswordEndpoint {
_PasswordEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<void> set(
_ist.TestSessionBuilder sessionBuilder, {
required _ikhyee47.PasswordSetup setup,
_is.UuidValue? request,
String? code,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'password',
method: 'set',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'password',
methodName: 'set',
parameters: _ist.testObjectToJson({
'setup': setup,
'request': request,
'code': code,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PasswordSignInEndpoint {
_PasswordSignInEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<_isz0atlx.PasswordChallenge> begin(
_ist.TestSessionBuilder sessionBuilder, {
required String address,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'passwordSignIn',
method: 'begin',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'passwordSignIn',
methodName: 'begin',
parameters: _ist.testObjectToJson({'address': address}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isz0atlx.PasswordChallenge>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_ikpyb5fh.PasswordSignedIn> complete(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'passwordSignIn',
method: 'complete',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'passwordSignIn',
methodName: 'complete',
parameters: _ist.testObjectToJson({
'challenge': challenge,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ikpyb5fh.PasswordSignedIn>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _ProfileEndpoint {
_ProfileEndpoint(
this._endpointDispatch,

View file

@ -131,6 +131,49 @@ void main() {
});
});
group("a reset's notice", () {
({String subject, String body}) notice(
AccountResetKind kind,
String language,
) => resetMail(
kind: kind,
takesEffectAt: DateTime.utc(2026, 10, 23, 14, 5, 59),
language: language,
app: 'Halkyon Learn',
instance: 'learn.halkyon.eu',
);
test('says what goes when, in UTC, on which instance, and that signing '
'in calls it off, in German for German and English else', () {
for (final kind in AccountResetKind.values) {
for (final language in ['en', 'de', 'fr']) {
final mail = notice(kind, language);
expect(mail.body, contains('2026-10-23 14:05 UTC'));
expect(mail.body, contains('learn.halkyon.eu'));
expect(mail.subject, contains('Halkyon Learn'));
expect(
mail.body,
language == 'de'
? contains('bricht es ab')
: contains('calls it off'),
);
}
}
expect(
notice(AccountResetKind.account, 'en').body,
contains('can no longer be read'),
);
});
test('holds no link and no code: it helps nobody but the owner', () {
for (final kind in AccountResetKind.values) {
final mail = notice(kind, 'en');
expect(mail.body, isNot(contains('http')));
expect(mail.body, isNot(matches(RegExp('[0-9A-Z]{4}-[0-9A-Z]{4}'))));
}
});
});
group('codes go', () {
final smtp = SmtpCodeMailer(
server: SmtpServer('localhost'),

View file

@ -0,0 +1,138 @@
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
Uint8List _hex(String hex) => Uint8List.fromList([
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
]);
void main() {
group("The recovery phrase's proof", () {
test("checks Ed25519 signatures as RFC 8032's test vector 2", () async {
// RFC 8032, 7.1, TEST 2.
final publicKey = _hex(
'3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c',
);
final message = _hex('72');
final signature = _hex(
'92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da'
'085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00',
);
expect(
await isAccountSignature(
message,
signature: signature,
publicKey: publicKey,
),
isTrue,
);
expect(
await isAccountSignature(
_hex('73'),
signature: signature,
publicKey: publicKey,
),
isFalse,
);
final changed = Uint8List.fromList(signature)..[0] ^= 1;
expect(
await isAccountSignature(
message,
signature: changed,
publicKey: publicKey,
),
isFalse,
);
});
test('refuses what is malformed rather than failing', () async {
final message = Uint8List(3);
expect(
await isAccountSignature(
message,
signature: Uint8List(63),
publicKey: Uint8List(32),
),
isFalse,
);
expect(
await isAccountSignature(
message,
signature: Uint8List(64),
publicKey: Uint8List(31),
),
isFalse,
);
expect(
await isAccountSignature(
message,
signature: Uint8List(64),
publicKey: Uint8List(32)..fillRange(0, 32, 0xff),
),
isFalse,
);
});
test('signs its challenge alone: another challenge or nonce makes '
'another message', () async {
final keys = await Ed25519().newKeyPairFromSeed(List.filled(32, 3));
final publicKey = (await keys.extractPublicKey()).bytes;
final challenge = UuidValue.fromString(
'01a15902-7e93-77fd-9704-4a7dc81613f6',
);
final nonce = List.filled(32, 9);
final signature = (await Ed25519().sign(
keyProofMessage(challenge, nonce),
keyPair: keys,
)).bytes;
expect(
await isAccountSignature(
keyProofMessage(challenge, nonce),
signature: signature,
publicKey: publicKey,
),
isTrue,
);
expect(
await isAccountSignature(
keyProofMessage(
UuidValue.fromString('01a15902-7e93-77fd-9704-4a7dc81613f7'),
nonce,
),
signature: signature,
publicKey: publicKey,
),
isFalse,
);
expect(
await isAccountSignature(
keyProofMessage(challenge, List.filled(32, 8)),
signature: signature,
publicKey: publicKey,
),
isFalse,
);
});
test('decoy keys from a seed are points of the curve, the same for the '
'same seed', () async {
final seed = List.filled(32, 5);
final key = await ed25519PublicKeyFromSeed(seed);
expect(key, hasLength(32));
expect(await ed25519PublicKeyFromSeed(seed), key);
expect(
key,
(await (await Ed25519().newKeyPairFromSeed(
seed,
)).extractPublicKey()).bytes,
);
});
});
}

View file

@ -0,0 +1,102 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/src/password/totp_secrets.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
void main() {
const key = 'a TOTP key of the tests, 32 characters at least';
final account = UuidValue.fromString('01a15902-7e93-77fd-9704-4a7dc81613f6');
final authenticator = UuidValue.fromString(
'01a15902-e6ca-758d-924f-4acd7867dd02',
);
final secret = Uint8List.fromList(List.generate(20, (i) => i * 7));
Future<ByteData> seal() => sealTotpSecret(
secret,
totpKey: key,
account: account,
authenticator: authenticator,
);
group('A TOTP secret sealed under the instance key', () {
test('opens for its row with the key, and holds no plain secret', () async {
final sealed = await seal();
expect(
await openTotpSecret(
sealed,
totpKey: key,
account: account,
authenticator: authenticator,
),
secret,
);
final bytes = Uint8List.sublistView(sealed);
expect(bytes.first, 1);
expect(bytes.length, 1 + 24 + secret.length + 16);
expect(
String.fromCharCodes(bytes),
isNot(contains(String.fromCharCodes(secret))),
);
});
test('is sealed anew each time: a random nonce', () async {
expect(
Uint8List.sublistView(await seal()),
isNot(Uint8List.sublistView(await seal())),
);
});
test('opens with no other key, for no other row, and not once '
'changed', () async {
final sealed = await seal();
final otherId = UuidValue.fromString(
'01a15902-e6ca-758d-924f-4acd7867dd03',
);
final changed = Uint8List.fromList(Uint8List.sublistView(sealed));
changed[30] ^= 1;
final opening = <String, Future<Uint8List> Function()>{
'another key': () => openTotpSecret(
sealed,
totpKey: '${key}2',
account: account,
authenticator: authenticator,
),
"another person's row": () => openTotpSecret(
sealed,
totpKey: key,
account: otherId,
authenticator: authenticator,
),
'another authenticator': () => openTotpSecret(
sealed,
totpKey: key,
account: account,
authenticator: otherId,
),
'changed': () => openTotpSecret(
ByteData.sublistView(changed),
totpKey: key,
account: account,
authenticator: authenticator,
),
'another format': () => openTotpSecret(
ByteData.sublistView(Uint8List.fromList([2, ...changed.skip(1)])),
totpKey: key,
account: account,
authenticator: authenticator,
),
'cut short': () => openTotpSecret(
ByteData(30),
totpKey: key,
account: account,
authenticator: authenticator,
),
};
for (final MapEntry(key: what, value: open) in opening.entries) {
await expectLater(open(), throwsStateError, reason: what);
}
});
});
}

View file

@ -0,0 +1,96 @@
import 'package:halkyon_base_server/base.dart';
import 'package:test/test.dart';
void main() {
group("The instance's keys for password sign-in", () {
final long = 'k' * passwordKeyMinLength;
test('are read from the passwords, all three or none', () {
final keys = passwordKeysFrom(
{
'totpKey': '${long}t',
'recoveryCodeKey': '${long}r',
'signInDecoyKey': '${long}d',
}.get,
);
expect(keys, (
totpKey: '${long}t',
recoveryCodeKey: '${long}r',
decoyKey: '${long}d',
));
expect(passwordKeysFrom(<String, String>{}.get), isNull);
expect(
passwordKeysFrom({'totpKey': long, 'recoveryCodeKey': long}.get),
isNull,
);
});
test('count empty values as unset, as Compose passes them on', () {
expect(
passwordKeysFrom(
{'totpKey': '', 'recoveryCodeKey': long, 'signInDecoyKey': long}.get,
),
isNull,
);
});
test('are refused when one is set but too short, so the server stops', () {
for (final name in ['totpKey', 'recoveryCodeKey', 'signInDecoyKey']) {
expect(
() => passwordKeysFrom(
{
'totpKey': long,
'recoveryCodeKey': long,
'signInDecoyKey': long,
name: 'k' * (passwordKeyMinLength - 1),
}.get,
),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains(name),
),
),
reason: name,
);
}
expect(
() => passwordKeysFrom({'totpKey': 'short'}.get),
throwsStateError,
);
});
});
group('The end of GitHub sign-in', () {
test('is the start, in UTC, of the day the operator names', () {
expect(gitHubSignInEndsFrom('2027-01-22'), DateTime.utc(2027, 1, 22));
expect(gitHubSignInEndsFrom(' 2027-01-22 '), DateTime.utc(2027, 1, 22));
});
test('is none while unset or empty', () {
expect(gitHubSignInEndsFrom(null), isNull);
expect(gitHubSignInEndsFrom(''), isNull);
});
test('is refused for anything but a day, so the server stops', () {
for (final value in [
'2027-1-22',
'22.01.2027',
'2027-02-30',
'2027-01-22T00:00:00Z',
'soon',
]) {
expect(
() => gitHubSignInEndsFrom(value),
throwsStateError,
reason: value,
);
}
});
});
}
extension on Map<String, String> {
String? get(String name) => this[name];
}