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
8 changed files with 46254 additions and 78 deletions

View file

@ -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),
);
});
});
},
);
}

View file

@ -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),
);
});

View file

@ -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),
);

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(

View file

@ -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);

View file

@ -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(

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)]);

View file

@ -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();
}
});
}
}