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
4 changed files with 22557 additions and 60 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in a new issue