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