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

@ -13,6 +13,7 @@ import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/keys_device.dart';
import '../support/password_device.dart';
import '../test_tools/serverpod_test_tools.dart';
@ -47,6 +48,14 @@ final class _Register implements AccountRegister {
final simpleLoginLinked = <UuidValue>{};
SimpleLoginProof? simpleLoginProof;
/// The handles checked for someone new and what each check says, the
/// sign-ups of people new with SimpleLogin, and the proofs keys were
/// unlocked with, as members bound them.
final handlesChecked = <String>[];
ProfileRefusal? handleRefusal;
final simpleLoginSignUps = <({UuidValue attempt, String handle})>[];
final keysProofs = <KeysProof>[];
/// The accounts with a password, and those with an authenticator too.
final passwords = <UuidValue>{};
final authenticated = <UuidValue>{};
@ -282,11 +291,70 @@ final class _Register implements AccountRegister {
final proof = simpleLoginProof!;
if (proof.outcome == SimpleLoginOutcome.linked) {
simpleLoginLinked.add(caller!);
return SimpleLoginProof(outcome: proof.outcome, account: caller);
return proof.copyWith(account: caller);
}
return proof;
});
@override
Future<ProfileRefusal?> checkSimpleLoginHandle(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) => _answer('checkSimpleLoginHandle', () {
networks.add(network);
handlesChecked.add(handle);
return handleRefusal;
});
@override
Future<UuidValue> signUpWithSimpleLogin(
Session session, {
required UuidValue attempt,
required String handle,
required KeysSetup setup,
}) => _answer('signUpWithSimpleLogin', () {
final account = const Uuid().v7obj();
add(account);
simpleLoginSignUps.add((attempt: attempt, handle: handle));
return account;
});
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,
UuidValue person,
) => _answer(
'beginPasswordKeyProof',
() => KeysChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
salt: ByteData(16),
costs: newPasswordCosts,
),
);
@override
Future<DeviceKeys> unlockKeys(
Session session,
UuidValue person,
KeysProof proof,
) {
keysProofs.add(proof);
return _answer(
'unlockKeys',
() => DeviceKeys(
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
);
}
@override
Future<void> setPassword(
Session session, {
@ -1032,6 +1100,8 @@ void main() {
register.simpleLoginProof = SimpleLoginProof(
outcome: SimpleLoginOutcome.signedIn,
account: carla,
hasKeys: true,
handle: 'Carla',
);
final completed = await endpoints.simpleLogin.complete(
@ -1043,6 +1113,7 @@ void main() {
expect(register.asked, ['completeSimpleLogin']);
expect(completed.outcome, SimpleLoginOutcome.signedIn);
expect(completed.session!.authUserId, carla);
expect((completed.hasKeys, completed.handle), (true, 'Carla'));
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
@ -1124,6 +1195,94 @@ void main() {
expect(await scopesOf(anna), isNot(contains(identified.name)));
});
test('checks a handle for someone new at the register, the device '
'network passed on', () async {
final attempt = const Uuid().v7obj();
register.handleRefusal = ProfileRefusal.handleTaken;
expect(
await endpoints.simpleLogin.checkHandle(
notSignedIn(),
attempt: attempt,
handle: 'carla',
),
ProfileRefusal.handleTaken,
);
expect(register.asked, ['checkSimpleLoginHandle']);
expect(register.handlesChecked, ['carla']);
expect(register.networks, hasLength(1));
});
test('signs up someone new at the register, a person here with a '
'session of this server', () async {
final attempt = const Uuid().v7obj();
final signedUp = await endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: attempt,
handle: 'carla',
setup: (await KeysDevice.make()).setup(),
);
final carla = signedUp.authUserId;
expect(register.asked, ['signUpWithSimpleLogin']);
expect(register.simpleLoginSignUps.single, (
attempt: attempt,
handle: 'carla',
));
expect(register.accounts, contains(carla));
expect(await scopesOf(carla), {identified.name});
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(carla),
)).single.method,
simpleLoginMethod,
);
});
test('signs up nobody for a signed-in caller, nor as the register '
'refuses, making no person here', () async {
final people = await AuthUser.db.count(session);
final setup = (await KeysDevice.make()).setup();
await expectLater(
endpoints.simpleLogin.signUp(
asAnna(),
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: setup,
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
expect(register.asked, isEmpty);
register.failing = ProfileRefusedException(
reason: ProfileRefusal.handleTaken,
);
await expectLater(
endpoints.simpleLogin.signUp(
notSignedIn(),
attempt: const Uuid().v7obj(),
handle: 'carla',
setup: setup,
),
throwsA(
isA<ProfileRefusedException>().having(
(error) => error.reason,
'reason',
ProfileRefusal.handleTaken,
),
),
);
expect(await AuthUser.db.count(session), people);
});
test('is offered as the register offers it, and not while it is out '
'of reach', () async {
expect(
@ -1145,6 +1304,77 @@ void main() {
});
});
group('keys without a password that signs in, by asking the register '
'(ADR 0020, D19)', () {
KeysProof wrongProof() => KeysProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: testInstanceUrl,
);
test('are begun and unlocked at the register, the proof bound to '
'the base URL the device addressed here', () async {
final challenge = await endpoints.keys.begin(asBen());
final keys = await endpoints.keys.unlock(
asBen(),
KeysProof(
challenge: challenge.challenge,
signature: ByteData(64),
instanceUrl: testInstanceUrl,
),
);
expect(register.asked, ['beginPasswordKeyProof', 'unlockKeys']);
expect(keys.signingPublicKey.lengthInBytes, 32);
expect(register.keysProofs.single.challenge, challenge.challenge);
expect(register.keysProofs.single.instanceUrl, testInstanceUrl);
});
test('refuse a proof for another instance without asking the '
'register', () async {
await expectLater(
endpoints.keys.unlock(
asBen(),
wrongProof().copyWith(instanceUrl: 'https://evil.example/'),
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
expect(register.asked, isEmpty);
});
test("count the session's own wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.wrongCredentials,
);
for (var i = 0; i < 10; i++) {
await expectLater(
endpoints.keys.unlock(asBen(), wrongProof()),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
i < 9
? PasswordRefusal.wrongCredentials
: PasswordRefusal.sessionEnded,
),
),
);
}
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(register.warned, {ben});
});
});
group('profiles', () {
test('are saved and found at the register', () async {
final saved = await endpoints.profile.save(