feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)

An account that signs in with SimpleLogin keeps its keys under a password
that signs nobody in (D19): `account_password` keeps the password's own
proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which
becomes nullable. The migration adds the column and drops the NOT NULL,
nothing else.

- `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity
  waits under the attempt gets, in one transaction, an account
  (identified), the link, a profile with the handle and the keys; then a
  session (D18). Refused, the identity still waiting, for a setup out of
  range and a handle refused as saving a profile refuses it.
- `simpleLogin.checkHandle(attempt, handle)`: whether someone new could
  take a handle, at most 30 an hour per attempt and 60 per network.
- `simpleLogin.complete` says, for an account, whether it has its keys and
  its handle, and keeps the sign-in ten minutes under its attempt, the
  account as its caller: D20's confirmation for the changes to come.
- `keys.begin`, `keys.unlock`: the keys for a signed-in device once the
  password is proven with its proof key, for a nonce, bound to the base
  URL the device addressed; wrong proofs counted per session as ADR
  0017's.
- An authenticator is enrolled only for a password that signs in.
- Members ask the register for all of it; `RemoteRegister` and the
  instance API carry the four new operations.

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-23 21:49:44 +02:00
commit 090d901e92
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,119 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/passwords.dart' show keysRowOf;
import 'package:serverpod/serverpod.dart';
import 'password_device.dart';
/// A device with the keys of an account that signs in with SimpleLogin
/// (Halkyon Learn's ADR 0020, D19), as halkyon_keys makes them, without
/// libsodium: the password's and the recovery phrase's proof keys real
/// Ed25519 pairs, so it proves either; what only a device opens (the
/// wrapped keys) random bytes of the right length.
final class KeysDevice {
KeysDevice._(
this._random,
this.passwordProofKeys,
this.passwordProofKey,
this.recoveryProofKeys,
this.recoveryProofKey,
) : salt = _bytes(_random, 16),
encryptionPublicKey = _bytes(_random, 32),
signingPublicKey = _bytes(_random, 32);
/// A new device's keys, from [seed].
static Future<KeysDevice> make({int seed = 23}) async {
final random = Random(seed);
final password = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
final phrase = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
return KeysDevice._(
random,
password,
Uint8List.fromList((await password.extractPublicKey()).bytes),
phrase,
Uint8List.fromList((await phrase.extractPublicKey()).bytes),
);
}
final Random _random;
/// The password's own proof key, from the password alone.
SimpleKeyPair passwordProofKeys;
Uint8List passwordProofKey;
/// The recovery phrase's own proof key, from the phrase alone.
SimpleKeyPair recoveryProofKeys;
Uint8List recoveryProofKey;
/// The password's salt, as the device made it.
Uint8List salt;
final Uint8List encryptionPublicKey;
final Uint8List signingPublicKey;
WrappedKeys _wrapped() => WrappedKeys(
nonce: ByteData.sublistView(_bytes(_random, 24)),
cipherText: ByteData.sublistView(_bytes(_random, wrappedKeysLength)),
);
/// The keys as the setup wrapped them.
late final WrappedKeys passwordKeys = _wrapped();
late final WrappedKeys recoveryKeys = _wrapped();
/// The keys the device sends for its account.
KeysSetup setup() => KeysSetup(
salt: ByteData.sublistView(salt),
costs: newPasswordCosts,
passwordProofKey: ByteData.sublistView(passwordProofKey),
encryptionPublicKey: ByteData.sublistView(encryptionPublicKey),
signingPublicKey: ByteData.sublistView(signingPublicKey),
passwordKeys: passwordKeys,
recoveryKeys: recoveryKeys,
recoveryProofKey: ByteData.sublistView(recoveryProofKey),
);
/// The proof of the password for [challenge] and [purpose] (with the
/// [digest] of what it sets) on [instanceUrl], by the password's proof
/// key or [keys] where given; claiming [claimedUrl] (the same unless
/// given) as the base URL it addressed.
Future<KeysProof> prove(
KeysChallenge challenge, {
KeyProofPurpose purpose = KeyProofPurpose.unlockKeys,
List<int> digest = const [],
SimpleKeyPair? keys,
String instanceUrl = testInstanceUrl,
String? claimedUrl,
}) async {
final signature = await Ed25519().sign(
keyProofMessage(
challenge.challenge,
Uint8List.sublistView(challenge.nonce),
instanceUrl: instanceUrl,
purpose: purpose,
digest: digest,
),
keyPair: keys ?? passwordProofKeys,
);
return KeysProof(
challenge: challenge.challenge,
signature: ByteData.sublistView(Uint8List.fromList(signature.bytes)),
instanceUrl: claimedUrl ?? instanceUrl,
);
}
}
/// [person]'s keys as [device] made them, written straight into the
/// register's table: an account that signs in with SimpleLogin.
Future<void> giveKeys(
Session session,
UuidValue person,
KeysDevice device,
) => AccountPassword.db.insertRow(
session,
keysRowOf(person, device.setup(), now: DateTime.now().toUtc()),
);
Uint8List _bytes(Random random, int length) =>
Uint8List.fromList([for (var i = 0; i < length; i++) random.nextInt(256)]);