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

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