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:
parent
c6831b0b7b
commit
090d901e92
1 changed files with 46254 additions and 78 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue