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
9 changed files with 46254 additions and 78 deletions
|
|
@ -0,0 +1,307 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||||
show passwordRowOf;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/keys_device.dart';
|
||||
import '../support/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
// The keys of an account that signs in with SimpleLogin on a signed-in
|
||||
// device (Halkyon Learn's ADR 0020, 7 and D19): the password proven with
|
||||
// its own proof key, a signature over the register's nonce, before the
|
||||
// device gets the keys wrapped under it. Every way a proof is refused, and
|
||||
// a session's wrong proofs ending it as ADR 0017's do.
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'The keys of an account that signs in with SimpleLogin',
|
||||
// Sessions ended for wrong proofs, in transactions of their own: these
|
||||
// tests delete what they make instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late KeysDevice device;
|
||||
|
||||
/// With keys, signed in on her phone and her laptop.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
late UuidValue annasLaptop;
|
||||
|
||||
/// Without keys, signed in on his laptop.
|
||||
late UuidValue ben;
|
||||
late UuidValue bensLaptop;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
await deleteAll(session);
|
||||
device = await KeysDevice.make();
|
||||
anna = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await giveKeys(session, anna, device);
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
method: 'simplelogin',
|
||||
);
|
||||
annasLaptop = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
method: 'simplelogin',
|
||||
);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
bensLaptop = await signInNewDevice(session, ben, method: 'simplelogin');
|
||||
});
|
||||
tearDown(() => deleteAll(sessionBuilder.build()));
|
||||
|
||||
TestSessionBuilder signedIn(UuidValue person, UuidValue on) =>
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{identified},
|
||||
authId: on.uuid,
|
||||
),
|
||||
);
|
||||
TestSessionBuilder onPhone() => signedIn(anna, annasPhone);
|
||||
|
||||
Future<Set<UuidValue>> sessionsOf(UuidValue person) async => {
|
||||
for (final row in await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
))
|
||||
row.id!,
|
||||
};
|
||||
|
||||
group('unlocking them', () {
|
||||
test('gives the salt and costs, then the keys once the password '
|
||||
'proved right', () async {
|
||||
final challenge = await endpoints.keys.begin(onPhone());
|
||||
|
||||
expect(Uint8List.sublistView(challenge.salt), device.salt);
|
||||
expect(challenge.costs.toJson(), newPasswordCosts.toJson());
|
||||
expect(challenge.nonce.lengthInBytes, 32);
|
||||
|
||||
final keys = await endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(challenge),
|
||||
);
|
||||
|
||||
expect(
|
||||
Uint8List.sublistView(keys.signingPublicKey),
|
||||
device.signingPublicKey,
|
||||
);
|
||||
expect(
|
||||
Uint8List.sublistView(keys.encryptionPublicKey),
|
||||
device.encryptionPublicKey,
|
||||
);
|
||||
expect(keys.passwordKeys.toJson(), device.passwordKeys.toJson());
|
||||
});
|
||||
|
||||
test('refuses a signature by another key: the recovery phrase, or '
|
||||
"another password's", () async {
|
||||
for (final keys in [
|
||||
device.recoveryProofKeys,
|
||||
(await KeysDevice.make(seed: 5)).passwordProofKeys,
|
||||
]) {
|
||||
final challenge = await endpoints.keys.begin(onPhone());
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(challenge, keys: keys),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a proof made for another purpose or change', () async {
|
||||
for (final (purpose, digest) in [
|
||||
(KeyProofPurpose.deleteAccount, const <int>[]),
|
||||
(KeyProofPurpose.unlockKeys, const [1]),
|
||||
]) {
|
||||
final challenge = await endpoints.keys.begin(onPhone());
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(challenge, purpose: purpose, digest: digest),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a proof made for another instance, which a hostile '
|
||||
'one could relay here', () async {
|
||||
final challenge = await endpoints.keys.begin(onPhone());
|
||||
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(
|
||||
challenge,
|
||||
instanceUrl: 'https://evil.example/',
|
||||
claimedUrl: testInstanceUrl,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(
|
||||
await endpoints.keys.begin(onPhone()),
|
||||
instanceUrl: 'https://evil.example/',
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.otherInstance),
|
||||
);
|
||||
});
|
||||
|
||||
test('takes each challenge once, and only from whom it was begun '
|
||||
'for', () async {
|
||||
final challenge = await endpoints.keys.begin(onPhone());
|
||||
await endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(challenge),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(onPhone(), await device.prove(challenge)),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
);
|
||||
final annas = await endpoints.keys.begin(onPhone());
|
||||
await giveKeys(session, ben, await KeysDevice.make(seed: 3));
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
signedIn(ben, bensLaptop),
|
||||
await device.prove(annas),
|
||||
),
|
||||
refused(PasswordRefusal.challengeExpired),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses an account without such keys: none, or a password '
|
||||
'that signs in (ADR 0017)', () async {
|
||||
await expectLater(
|
||||
endpoints.keys.begin(signedIn(ben, bensLaptop)),
|
||||
refused(PasswordRefusal.noPassword),
|
||||
);
|
||||
await AccountPassword.db.insertRow(
|
||||
session,
|
||||
passwordRowOf(
|
||||
ben,
|
||||
(await PasswordDevice.make()).setup(),
|
||||
now: DateTime.now().toUtc(),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.keys.begin(signedIn(ben, bensLaptop)),
|
||||
refused(PasswordRefusal.noPassword),
|
||||
);
|
||||
});
|
||||
|
||||
test('takes no session at all', () async {
|
||||
final notSignedIn = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.keys.begin(notSignedIn),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test("a session's wrong proofs count for it alone: the tenth in a "
|
||||
"row ends it, the owner's other session unlocking as before, and "
|
||||
'the owner is warned', () async {
|
||||
final other = await KeysDevice.make(seed: 5);
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await other.prove(await endpoints.keys.begin(onPhone())),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await other.prove(await endpoints.keys.begin(onPhone())),
|
||||
),
|
||||
refused(PasswordRefusal.sessionEnded),
|
||||
);
|
||||
|
||||
expect(await sessionsOf(anna), {annasLaptop});
|
||||
expect(
|
||||
(await endpoints.account.signInWays(
|
||||
signedIn(anna, annasLaptop),
|
||||
)).sessionEndedForProofsAt,
|
||||
isNotNull,
|
||||
);
|
||||
final laptop = signedIn(anna, annasLaptop);
|
||||
await endpoints.keys.unlock(
|
||||
laptop,
|
||||
await device.prove(await endpoints.keys.begin(laptop)),
|
||||
);
|
||||
});
|
||||
|
||||
test('a right proof clears the wrong ones before it', () async {
|
||||
final other = await KeysDevice.make(seed: 5);
|
||||
for (var round = 0; round < 2; round++) {
|
||||
for (var i = 0; i < 9; i++) {
|
||||
await expectLater(
|
||||
endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await other.prove(await endpoints.keys.begin(onPhone())),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
}
|
||||
await endpoints.keys.unlock(
|
||||
onPhone(),
|
||||
await device.prove(await endpoints.keys.begin(onPhone())),
|
||||
);
|
||||
}
|
||||
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
||||
});
|
||||
});
|
||||
|
||||
group('the ways in', () {
|
||||
test('say the account has keys, and no password that signs in, nor '
|
||||
'an authenticator to add', () async {
|
||||
// Where password sign-in is offered, which takes mail too.
|
||||
final pod = session.serverpod;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
EmailCodes.of(pod).mailer = Mailbox();
|
||||
addTearDown(() => EmailCodes.of(pod).mailer = null);
|
||||
final ways = await endpoints.account.signInWays(onPhone());
|
||||
|
||||
expect(ways.keys, isTrue);
|
||||
expect(ways.password, isFalse);
|
||||
expect(ways.passwordOnly, isFalse);
|
||||
await expectLater(
|
||||
endpoints.authenticator.enrol(
|
||||
onPhone(),
|
||||
name: 'Phone',
|
||||
label: 'anna',
|
||||
),
|
||||
refused(PasswordRefusal.noPassword),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -277,7 +277,7 @@ void main() {
|
|||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
))!.verifier!,
|
||||
);
|
||||
final mine = device.privateValue;
|
||||
|
||||
|
|
@ -337,7 +337,7 @@ void main() {
|
|||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
))!.verifier!,
|
||||
),
|
||||
kept,
|
||||
);
|
||||
|
|
@ -794,7 +794,7 @@ void main() {
|
|||
Uint8List.sublistView(freshProofKey),
|
||||
);
|
||||
expect(
|
||||
Uint8List.sublistView(after.verifier),
|
||||
Uint8List.sublistView(after.verifier!),
|
||||
Uint8List.sublistView(newPassword.verifier),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ void main() {
|
|||
where: (t) => t.authUserId.equals(anna),
|
||||
))!;
|
||||
expect(
|
||||
Uint8List.sublistView(kept.verifier),
|
||||
Uint8List.sublistView(kept.verifier!),
|
||||
Uint8List.sublistView(device.verifier),
|
||||
);
|
||||
final ways = await endpoints.account.signInWays(asAnna());
|
||||
|
|
@ -574,7 +574,7 @@ void main() {
|
|||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
))!.verifier!,
|
||||
),
|
||||
Uint8List.sublistView(other.verifier),
|
||||
);
|
||||
|
|
@ -2432,7 +2432,7 @@ void main() {
|
|||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
))!.verifier!,
|
||||
),
|
||||
Uint8List.sublistView(device.verifier),
|
||||
);
|
||||
|
|
@ -3228,7 +3228,7 @@ void main() {
|
|||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
))!.verifier!,
|
||||
),
|
||||
Uint8List.sublistView(fresh.verifier),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,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';
|
||||
|
||||
|
|
@ -607,6 +608,131 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("signs up someone new for a member's app, the handle checked "
|
||||
'first, whose keys then unlock with the password', () async {
|
||||
final device = await KeysDevice.make();
|
||||
final start = await remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-desktop',
|
||||
network: '203.0.113.0/24',
|
||||
);
|
||||
await remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
code: simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: 'sl-carla',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await remote.checkSimpleLoginHandle(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
handle: 'carla',
|
||||
network: '203.0.113.0/24',
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
final carla = await remote.signUpWithSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
||||
{identified.name},
|
||||
);
|
||||
final ways = await remote.signInWaysOf(session, carla);
|
||||
expect(
|
||||
(ways.simpleLogin, ways.keys, ways.password),
|
||||
(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
);
|
||||
final keys = await remote.unlockKeys(
|
||||
session,
|
||||
carla,
|
||||
await device.prove(
|
||||
await remote.beginPasswordKeyProof(session, carla),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
Uint8List.sublistView(keys.signingPublicKey),
|
||||
device.signingPublicKey,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a sign-up and keys as the register refuses, a '
|
||||
"handle's refusals crossing whole", () async {
|
||||
final device = await KeysDevice.make();
|
||||
final start = await remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-desktop',
|
||||
network: '203.0.113.0/24',
|
||||
);
|
||||
await remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
code: simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: 'sl-carla',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await remote.checkSimpleLoginHandle(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
handle: 'admin',
|
||||
network: '203.0.113.0/24',
|
||||
),
|
||||
ProfileRefusal.handleReserved,
|
||||
);
|
||||
await expectLater(
|
||||
remote.signUpWithSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
handle: 'admin',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(ProfileRefusal.handleReserved),
|
||||
);
|
||||
await expectLater(
|
||||
remote.signUpWithSimpleLogin(
|
||||
session,
|
||||
attempt: const Uuid().v7obj(),
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
await expectLater(
|
||||
remote.beginPasswordKeyProof(session, ben),
|
||||
refused(PasswordRefusal.noPassword),
|
||||
);
|
||||
final carla = await remote.signUpWithSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
await expectLater(
|
||||
remote.unlockKeys(
|
||||
session,
|
||||
carla,
|
||||
await (await KeysDevice.make(seed: 5)).prove(
|
||||
await remote.beginPasswordKeyProof(session, carla),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses where the register has no SimpleLogin app', () async {
|
||||
useSimpleLogin(sessionBuilder, null);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.dart';
|
||||
|
|
@ -7,6 +9,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/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
|
|
@ -533,6 +536,425 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('a sign-in stands for ten minutes (D20)', () {
|
||||
test(
|
||||
'signed out or in, under its attempt, for the account that '
|
||||
'signed in alone, and says whether it has keys and its handle',
|
||||
() async {
|
||||
await giveKeys(session, ben, await KeysDevice.make());
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Ben',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final idp = AuthServices.instance.oidcIdp;
|
||||
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
final annas = await allowed(asAnna(), subject: 'sl-anna');
|
||||
|
||||
final signedIn = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
code: bens.code,
|
||||
);
|
||||
final linked = await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: annas.attempt,
|
||||
code: annas.code,
|
||||
);
|
||||
|
||||
expect((signedIn.hasKeys, signedIn.handle), (true, 'Ben'));
|
||||
expect((linked.hasKeys, linked.handle), (false, null));
|
||||
for (final (attempt, person) in [
|
||||
(bens.attempt, ben),
|
||||
(annas.attempt, anna),
|
||||
]) {
|
||||
expect(
|
||||
await idp.isKept(session, attempt: attempt, caller: person),
|
||||
isTrue,
|
||||
);
|
||||
expect(await idp.isKept(session, attempt: attempt), isFalse);
|
||||
expect(
|
||||
await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => idp.isKept(session, attempt: attempt, caller: person),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('signing up someone new (D18)', () {
|
||||
late KeysDevice device;
|
||||
|
||||
setUp(() async => device = await KeysDevice.make());
|
||||
|
||||
/// Carla, new here, allowed at SimpleLogin: the attempt her identity
|
||||
/// waits under.
|
||||
Future<UuidValue> newcomer({String subject = 'sl-carla'}) async {
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: subject,
|
||||
name: 'Carla Müller',
|
||||
);
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
||||
isA<ProfileRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
test('makes the account with its handle and keys, identified, signed '
|
||||
'in with SimpleLogin, its identity taken once', () async {
|
||||
final attempt = await newcomer();
|
||||
|
||||
final signedUp = await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'Carla_M',
|
||||
setup: device.setup(),
|
||||
);
|
||||
|
||||
final carla = signedUp.authUserId;
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
||||
{identified.name},
|
||||
);
|
||||
expect((await identitiesOf(carla)).single.subject, 'sl-carla');
|
||||
final profile = (await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
))!;
|
||||
expect((profile.handle, profile.handleKey), ('Carla_M', 'carla_m'));
|
||||
expect(profile.displayName, isEmpty);
|
||||
final keys = (await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
))!;
|
||||
expect(keys.verifier, isNull);
|
||||
expect(
|
||||
Uint8List.sublistView(keys.passwordProofKey!),
|
||||
device.passwordProofKey,
|
||||
);
|
||||
expect(
|
||||
(await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
)).single.method,
|
||||
simpleLoginMethod,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'Carla_N',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
|
||||
final again = await allowed(notSignedIn(), subject: 'sl-carla');
|
||||
final signedIn = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: again.attempt,
|
||||
code: again.code,
|
||||
);
|
||||
expect(signedIn.session!.authUserId, carla);
|
||||
expect((signedIn.hasKeys, signedIn.handle), (true, 'Carla_M'));
|
||||
});
|
||||
|
||||
test('refuses a handle someone has, whatever its case, and one nobody '
|
||||
'may have, the identity still waiting', () async {
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Ben',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final attempt = await newcomer();
|
||||
|
||||
for (final (handle, reason) in [
|
||||
('BEN', ProfileRefusal.handleTaken),
|
||||
('admin', ProfileRefusal.handleReserved),
|
||||
('carla müller', ProfileRefusal.handleInvalid),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: handle,
|
||||
setup: device.setup(),
|
||||
),
|
||||
refusedAsProfile(reason),
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses keys out of range, the identity still waiting', () async {
|
||||
final attempt = await newcomer();
|
||||
final setup = device.setup();
|
||||
|
||||
for (final wrong in [
|
||||
setup.copyWith(
|
||||
costs: setup.costs.copyWith(opsLimit: 1),
|
||||
),
|
||||
setup.copyWith(salt: ByteData(15)),
|
||||
setup.copyWith(passwordProofKey: ByteData(31)),
|
||||
setup.copyWith(
|
||||
passwordKeys: setup.passwordKeys.copyWith(nonce: ByteData(12)),
|
||||
),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: wrong,
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
PasswordRefusal.invalidSetup,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: setup,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses an attempt nobody new waits under: unknown, past its '
|
||||
"half hour, or an account's sign-in", () async {
|
||||
final late = await newcomer();
|
||||
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
code: bens.code,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v4obj(),
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
await expectLater(
|
||||
withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
|
||||
() => endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: late,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
handle: 'ben2',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
expect(await identitiesOf(ben), hasLength(1));
|
||||
});
|
||||
|
||||
test('refuses a signed-in caller, whose session it would leave for '
|
||||
"another person's", () async {
|
||||
final attempt = await newcomer();
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
asAnna(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
});
|
||||
|
||||
test('makes one account of an identity waiting twice', () async {
|
||||
final first = await newcomer();
|
||||
final second = await newcomer();
|
||||
final carla = (await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: first,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
)).authUserId;
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: second,
|
||||
handle: 'carla2',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.takenByAnotherPerson),
|
||||
);
|
||||
expect(await identitiesOf(carla), hasLength(1));
|
||||
expect(
|
||||
await PublicProfile.db.count(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals('carla2'),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('signs up nobody where SimpleLogin is not offered', () async {
|
||||
final attempt = await newcomer();
|
||||
useSimpleLogin(sessionBuilder, null);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
});
|
||||
|
||||
group('checking a handle', () {
|
||||
test('says whether it is free as saving a profile would, whatever '
|
||||
'its case, taking nothing', () async {
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Ben',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final attempt = await newcomer();
|
||||
|
||||
for (final (handle, reason) in [
|
||||
('carla', null),
|
||||
('bEn', ProfileRefusal.handleTaken),
|
||||
('support', ProfileRefusal.handleReserved),
|
||||
('carla!', ProfileRefusal.handleInvalid),
|
||||
]) {
|
||||
expect(
|
||||
await endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: handle,
|
||||
),
|
||||
reason,
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
});
|
||||
|
||||
test('only for an attempt someone new waits under', () async {
|
||||
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
code: bens.code,
|
||||
);
|
||||
|
||||
for (final attempt in [const Uuid().v4obj(), bens.attempt]) {
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('at most 30 times an hour for an attempt, and 60 from one '
|
||||
'network', () async {
|
||||
final first = await newcomer();
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: first,
|
||||
handle: 'carla$i',
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: first,
|
||||
handle: 'carla',
|
||||
),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
// A check refused counts for neither: the network has 30.
|
||||
final second = await newcomer(subject: 'sl-dora');
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: second,
|
||||
handle: 'dora$i',
|
||||
);
|
||||
}
|
||||
final third = await newcomer(subject: 'sl-emil');
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: third,
|
||||
handle: 'emil',
|
||||
),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the identity goes with the account', () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
||||
await endpoints.simpleLogin.complete(
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
@ -20,6 +20,14 @@ import 'package:halkyon_base_server/src/generated/auth/sign_in_ways.dart'
|
|||
as _ik4ah9vo;
|
||||
import 'package:halkyon_base_server/src/generated/instance/instance_basics.dart'
|
||||
as _ihusd8tw;
|
||||
import 'package:halkyon_base_server/src/generated/keys/device_keys.dart'
|
||||
as _izazrxr6;
|
||||
import 'package:halkyon_base_server/src/generated/keys/keys_challenge.dart'
|
||||
as _i052zfjx;
|
||||
import 'package:halkyon_base_server/src/generated/keys/keys_proof.dart'
|
||||
as _iy592cuk;
|
||||
import 'package:halkyon_base_server/src/generated/keys/keys_setup.dart'
|
||||
as _ii64ejy6;
|
||||
import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
|
||||
as _ip34xal6;
|
||||
import 'package:halkyon_base_server/src/generated/password/authenticator_enrolment.dart'
|
||||
|
|
@ -200,6 +208,8 @@ class TestEndpoints {
|
|||
|
||||
late final _InstanceEndpoint instance;
|
||||
|
||||
late final _KeysEndpoint keys;
|
||||
|
||||
late final _AccountRecoveryEndpoint accountRecovery;
|
||||
|
||||
late final _AuthenticatorEndpoint authenticator;
|
||||
|
|
@ -246,6 +256,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
keys = _KeysEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
accountRecovery = _AccountRecoveryEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -576,6 +590,78 @@ class _InstanceEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _KeysEndpoint {
|
||||
_KeysEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_i052zfjx.KeysChallenge> begin(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'keys',
|
||||
method: 'begin',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'keys',
|
||||
methodName: 'begin',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_i052zfjx.KeysChallenge>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<_izazrxr6.DeviceKeys> unlock(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
_iy592cuk.KeysProof proof,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'keys',
|
||||
method: 'unlock',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'keys',
|
||||
methodName: 'unlock',
|
||||
parameters: _ist.testObjectToJson({'proof': proof}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_izazrxr6.DeviceKeys>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountRecoveryEndpoint {
|
||||
_AccountRecoveryEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
@ -1661,4 +1747,76 @@ class _SimpleLoginEndpoint {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<_idszjcpz.ProfileRefusal?> checkHandle(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue attempt,
|
||||
required String handle,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'simpleLogin',
|
||||
method: 'checkHandle',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'simpleLogin',
|
||||
methodName: 'checkHandle',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'attempt': attempt,
|
||||
'handle': handle,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_idszjcpz.ProfileRefusal?>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<_iacs.AuthSuccess> signUp(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue attempt,
|
||||
required String handle,
|
||||
required _ii64ejy6.KeysSetup setup,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'simpleLogin',
|
||||
method: 'signUp',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'simpleLogin',
|
||||
methodName: 'signUp',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'attempt': attempt,
|
||||
'handle': handle,
|
||||
'setup': setup,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iacs.AuthSuccess>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,40 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("has the devices' purposes, each with a label of its own, and "
|
||||
'signs the bytes they sign for each', () {
|
||||
final challenge = UuidValue.fromString(
|
||||
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
|
||||
);
|
||||
final nonce = List.generate(32, (i) => i * 3 % 256);
|
||||
final labels = KeyProofPurpose.values.map((purpose) => purpose.label);
|
||||
|
||||
expect(
|
||||
labels,
|
||||
device.KeyProofPurpose.values.map((purpose) => purpose.label),
|
||||
);
|
||||
expect(labels.toSet(), hasLength(KeyProofPurpose.values.length));
|
||||
for (final (index, purpose) in KeyProofPurpose.values.indexed) {
|
||||
expect(
|
||||
keyProofMessage(
|
||||
challenge,
|
||||
nonce,
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: purpose,
|
||||
digest: [index],
|
||||
),
|
||||
device.keyProofMessage(
|
||||
challenge.toBytes(),
|
||||
nonce,
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: device.KeyProofPurpose.values[index],
|
||||
digest: [index],
|
||||
),
|
||||
reason: purpose.label,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("binds SRP's identity as devices do, for every purpose, with the "
|
||||
'digest of what it sets', () {
|
||||
final challenge = UuidValue.fromString(
|
||||
|
|
|
|||
Loading…
Reference in a new issue