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

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