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

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];
}