feat(base): a reset of the keys without the phrase, and the keys' proof key for a password that signs in (ADR 0020, D31–D34)

An account that signs in with SimpleLogin alone, its password and its
recovery phrase both lost, asks for new keys with a SimpleLogin sign-in
(`keys.startReset`); another sign-in carries it out 72 hours later
(`keys.completeReset`, `notDue` with when before), with the same keys
rewrapped where the device still holds them (D33). No mail: every
signed-in device shows it, and any right proof of the password's or the
phrase's key, or a mend, calls it off; `keys.callOffResets` takes the
password's proof alone (D32). The row is ADR 0017's account reset of kind
`account`, so no migration; ADR 0017's reset by mail now takes only an
account whose password signs in.

An account with ADR 0017's password that added SimpleLogin had keys but
no proof key, so no new device signed in with SimpleLogin could open
them: `password.setProofKey` sets the one that same password makes, with
the owner's proof binding it (D34), and ADR 0017's new password takes it
away. Only an account that signs in with SimpleLogin alone changes
through `keys`. `simpleLogin.complete` says whether the password signs
in, whether the keys have the proof key, and when a reset takes effect.

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-24 21:51:00 +02:00
commit fe2ef02c92
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 1973 additions and 52 deletions

View file

@ -920,6 +920,393 @@ void main() {
});
});
group('a reset, the password and the phrase lost (D31–D33)', () {
late KeysDevice next;
/// The tests' time, moved on by hand.
late DateTime now;
/// Anna's tablet, signed in with SimpleLogin a moment ago, without
/// her keys.
late UuidValue annasTablet;
setUp(() async {
next = await KeysDevice.make(seed: 11);
now = DateTime.now().toUtc();
annasTablet = await signInNewDevice(
session,
anna,
method: 'simplelogin',
);
});
/// [body] at the tests' time.
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
TestSessionBuilder onTablet() => signedIn(anna, annasTablet);
TestSessionBuilder onLaptop() => signedIn(anna, annasLaptop);
/// Asks for the reset on [as] (her tablet unless given), with a
/// SimpleLogin sign-in of hers just now.
Future<ResetPending> start({TestSessionBuilder? as}) => atNow(() async {
final device = as ?? onTablet();
return await endpoints.keys.startReset(
device,
attempt: await signInAgain(device, 'sl-anna'),
);
});
/// Carries it out on [as] (her tablet unless given) with [setup]
/// (the next keys unless given) and a SimpleLogin sign-in of hers
/// just now.
Future<void> complete({TestSessionBuilder? as, KeysSetup? setup}) =>
atNow(() async {
final device = as ?? onTablet();
await endpoints.keys.completeReset(
device,
attempt: await signInAgain(device, 'sl-anna'),
setup: setup ?? next.setup(),
);
});
Future<List<AccountReset>> resetsOf(UuidValue person) =>
AccountReset.db.find(
session,
where: (t) => t.authUserId.equals(person),
);
Future<AccountPassword> keysOf(UuidValue person) async =>
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
))!;
test('is asked with a SimpleLogin sign-in alone, taken, and takes '
'effect 72 hours later; asked again meanwhile, it keeps its '
'time, which her devices and the next sign-in are told', () async {
final attempt = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
final pending = await atNow(
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
);
expect(pending.takesEffectAt, now.add(resetDelay));
expect(
(await resetsOf(anna)).single.kind,
AccountResetKind.account,
);
expect(
await idp().isKept(session, attempt: attempt, caller: anna),
isFalse,
);
now = now.add(const Duration(hours: 1));
expect((await start()).takesEffectAt, pending.takesEffectAt);
expect(
(await atNow(
() => endpoints.account.signInWays(onPhone()),
)).accountResetAt,
pending.takesEffectAt,
);
final signedOut = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
final completed = await atNow(() async {
final begun = await endpoints.simpleLogin.begin(
signedOut,
back: SimpleLoginReturn.loopback,
);
return await endpoints.simpleLogin.complete(
signedOut,
attempt: begun.attempt,
code: simpleLogin.authorize(
begun.authorizationUrl,
subject: 'sl-anna',
),
);
});
expect(completed.accountResetAt, pending.takesEffectAt);
// Her keys stay as they were until it is carried out.
expect(
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
device.passwordProofKey,
);
});
test('is carried out once due, with a new SimpleLogin sign-in: the '
'new keys in place of the old, her link staying, the reset gone '
'and every other session ended', () async {
final before = await keysOf(anna);
await start();
now = now.add(resetDelay);
await complete();
final row = await keysOf(anna);
expect(row.id, before.id);
expect(row.verifier, isNull);
expect(
Uint8List.sublistView(row.passwordProofKey!),
next.passwordProofKey,
);
expect(
Uint8List.sublistView(row.recoveryProofKey!),
next.recoveryProofKey,
);
expect(
Uint8List.sublistView(row.signingPublicKey),
next.signingPublicKey,
);
expect(await resetsOf(anna), isEmpty);
expect(await sessionsOf(anna), {annasTablet});
expect(
(await OidcIdentity.db.find(
session,
where: (t) => t.authUserId.equals(anna),
)).single.subject,
'sl-anna',
);
await expectLater(
endpoints.keys.unlock(
onTablet(),
await device.prove(await endpoints.keys.begin(onTablet())),
),
refused(PasswordRefusal.wrongCredentials),
);
final keys = await endpoints.keys.unlock(
onTablet(),
await next.prove(await endpoints.keys.begin(onTablet())),
);
expect(
Uint8List.sublistView(keys.signingPublicKey),
next.signingPublicKey,
);
});
test('keeps the keys a device of hers still holds, wrapped under the '
'new password and phrase (D33)', () async {
await start(as: onPhone());
now = now.add(resetDelay);
final rewrapped = next.setup().copyWith(
encryptionPublicKey: ByteData.sublistView(
device.encryptionPublicKey,
),
signingPublicKey: ByteData.sublistView(device.signingPublicKey),
);
await complete(as: onPhone(), setup: rewrapped);
final row = await keysOf(anna);
expect(
Uint8List.sublistView(row.signingPublicKey),
device.signingPublicKey,
);
expect(
Uint8List.sublistView(row.passwordProofKey!),
next.passwordProofKey,
);
expect(await sessionsOf(anna), {annasPhone});
});
test('refuses before its time, saying when; keys out of range; and '
'once it lapsed, or without one, nothing changing', () async {
final pending = await start();
now = pending.takesEffectAt.subtract(const Duration(minutes: 1));
await expectLater(
complete(),
throwsA(
isA<PasswordRefusedException>()
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
.having(
(e) => e.takesEffectAt,
'takesEffectAt',
pending.takesEffectAt,
),
),
);
now = pending.takesEffectAt;
await expectLater(
complete(
setup: next.setup().copyWith(salt: ByteData(3)),
),
refused(PasswordRefusal.invalidSetup),
);
expect(await resetsOf(anna), hasLength(1));
now = now.add(resetLapsesAfter);
await expectLater(complete(), refused(PasswordRefusal.noReset));
await AccountReset.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await expectLater(complete(), refused(PasswordRefusal.noReset));
expect(
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
device.passwordProofKey,
);
expect(
await sessionsOf(anna),
containsAll([annasPhone, annasLaptop]),
);
});
test("refuses without a SimpleLogin sign-in of hers: none, another's, "
'one run out or taken, asking for nothing then', () async {
final bens = await atNow(
() => signInAgain(signedIn(ben, bensLaptop), 'sl-ben'),
);
final ranOut = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
now = now.add(const Duration(minutes: 11));
for (final attempt in [const Uuid().v4obj(), bens, ranOut]) {
await expectLater(
atNow(
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
}
expect(await resetsOf(anna), isEmpty);
await start();
now = now.add(resetDelay);
final taken = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
await atNow(
() => endpoints.keys.completeReset(
onTablet(),
attempt: taken,
setup: next.setup(),
),
);
await start();
now = now.add(resetDelay);
final other = (await KeysDevice.make(seed: 12)).setup();
await expectLater(
atNow(
() => endpoints.keys.completeReset(
onTablet(),
attempt: taken,
setup: other,
),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
expect(await resetsOf(anna), hasLength(1));
});
test('is for an account with such keys alone: none, or a password '
'that signs in (ADR 0017, its reset by mail)', () async {
final bens = signedIn(ben, bensLaptop);
Future<void> startAndComplete() => atNow(() async {
await expectLater(
endpoints.keys.startReset(
bens,
attempt: await signInAgain(bens, 'sl-ben'),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.completeReset(
bens,
attempt: await signInAgain(bens, 'sl-ben'),
setup: next.setup(),
),
refused(PasswordRefusal.noPassword),
);
});
await startAndComplete();
await AccountPassword.db.insertRow(
session,
passwordRowOf(
ben,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
await startAndComplete();
expect(await resetsOf(ben), isEmpty);
});
test("is called off by her password's proof on a signed-in device "
'(D32), never by another key, nor a proof for another '
'purpose', () async {
await start();
Future<void> callOff(KeysProof proof) =>
endpoints.keys.callOffResets(onPhone(), proof: proof);
for (final keys in [
next.passwordProofKeys,
device.recoveryProofKeys,
]) {
await expectLater(
callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
keys: keys,
),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPhrase,
),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(await resetsOf(anna), hasLength(1));
await callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
),
);
expect(await resetsOf(anna), isEmpty);
await expectLater(
endpoints.keys.callOffResets(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
proof: await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('is called off by any proof of her password or her phrase: '
'opening her keys on a device, or a lost password set '
'anew', () async {
await start();
await endpoints.keys.unlock(
onLaptop(),
await device.prove(await endpoints.keys.begin(onLaptop())),
);
expect(await resetsOf(anna), isEmpty);
await start();
final attempt = await atNow(() => signInAgain(onPhone(), 'sl-anna'));
await atNow(
() async => await endpoints.keys.resetPassword(
onPhone(),
proof: await device.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone()),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
),
);
expect(await resetsOf(anna), isEmpty);
});
});
group('the ways in', () {
test('say the account has keys, and no password that signs in, nor '
'an authenticator to add', () async {

View file

@ -15,6 +15,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';
@ -1243,6 +1244,179 @@ void main() {
);
});
});
group("the keys' proof key, for SimpleLogin (ADR 0020, D34)", () {
/// The proof key her password makes for her keys, as a device
/// derives it beside SRP's x.
late KeysDevice keys;
setUp(() async => keys = await KeysDevice.make(seed: 31));
/// Sets [key] (her password's unless given) with the owner's proof
/// made for [provedFor] (the key sent unless given) and [purpose],
/// by her password (or [privateValue]'s) and a code (or [totpCode],
/// or none).
Future<void> setProofKey({
ByteData? key,
ByteData? provedFor,
PasswordProofPurpose purpose = PasswordProofPurpose.proofKey,
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
}) => atNow(() async {
final sent = key ?? ByteData.sublistView(keys.passwordProofKey);
await endpoints.password.setProofKey(
onPhone(),
proof: device.ownerProof(
await endpoints.password.beginChange(onPhone()),
purpose: purpose,
digest: proofKeyDigest(provedFor ?? sent),
privateValue: privateValue,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
),
proofKey: sent,
);
});
Future<AccountPassword> row() async =>
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
test('is set with her password and a code, and her keys then open '
'with the password alone on a device signed in with SimpleLogin, '
'as they open on her phone', () async {
await expectLater(
endpoints.keys.begin(onLaptop()),
refused(PasswordRefusal.noPassword),
);
await setProofKey();
final challenge = await endpoints.keys.begin(onLaptop());
expect(Uint8List.sublistView(challenge.salt), device.salt);
final opened = await endpoints.keys.unlock(
onLaptop(),
await keys.prove(challenge),
);
expect(opened.passwordKeys.toJson(), device.passwordKeys.toJson());
expect(
Uint8List.sublistView(opened.signingPublicKey),
device.signingPublicKey,
);
expect((await row()).verifier!.lengthInBytes, isPositive);
});
test('refuses without a code, a wrong code or password, a proof for '
'another key or purpose, and a key out of range, setting '
'none', () async {
await expectLater(
setProofKey(withoutCode: true),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
setProofKey(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
setProofKey(privateValue: BigInt.from(7)),
refused(PasswordRefusal.wrongCredentials),
);
nextStep();
await expectLater(
setProofKey(provedFor: ByteData(32)),
refused(PasswordRefusal.wrongCredentials),
);
nextStep();
await expectLater(
setProofKey(purpose: PasswordProofPurpose.newPhrase),
refused(PasswordRefusal.wrongCredentials),
);
nextStep();
await expectLater(
setProofKey(key: ByteData(31)),
refused(PasswordRefusal.invalidSetup),
);
expect((await row()).passwordProofKey, isNull);
});
test("changes nothing through the keys' own changes, which are an "
"account's that signs in with SimpleLogin alone: her password "
'still signs in', () async {
await setProofKey();
nextStep();
final other = await KeysDevice.make(seed: 32);
final before = await row();
final attempt = const Uuid().v7obj();
await expectLater(
endpoints.keys.changePassword(
onPhone(),
proof: await keys.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPassword,
digest: newKeysPasswordDigest(other.password()),
),
password: other.password(),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.beginPhrase(onPhone()),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.setRecoveryKeys(
onPhone(),
proof: await keys.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPhrase,
digest: newPhraseDigest(
other.recoveryKeys,
ByteData.sublistView(other.recoveryProofKey),
),
),
recoveryKeys: other.recoveryKeys,
recoveryProofKey: ByteData.sublistView(other.recoveryProofKey),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.startReset(onPhone(), attempt: attempt),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.completeReset(
onPhone(),
attempt: attempt,
setup: other.setup(),
),
refused(PasswordRefusal.noPassword),
);
final after = await row();
expect(after.toJson(), before.toJson());
expect((await signIn()).authUserId, anna);
});
test('goes with a new password, as its key was made of the old: set '
'again the same way', () async {
await setProofKey();
nextStep();
await change();
expect((await row()).passwordProofKey, isNull);
await expectLater(
endpoints.keys.begin(onLaptop()),
refused(PasswordRefusal.noPassword),
);
});
});
},
);
}

View file

@ -13,6 +13,7 @@ import 'package:test/test.dart';
import '../support/curve25519.dart';
import '../support/fixtures.dart';
import '../support/keys_device.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
@ -3325,6 +3326,35 @@ void main() {
);
});
test('is no way into an account whose password only opens its keys, '
"as SimpleLogin's do (ADR 0020, D31): its address's codes ask "
'for no reset and carry out none waiting', () async {
await giveKeys(session, anna, await KeysDevice.make());
await expectLater(start(), refused(PasswordRefusal.noPassword));
// One its SimpleLogin sign-in asked for (`keys.startReset`), due.
await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: anna,
kind: AccountResetKind.account,
takesEffectAt: now.subtract(const Duration(hours: 1)),
createdAt: now.subtract(resetDelay),
),
);
await expectLater(
finish(await PasswordDevice.make(seed: 99)),
refused(PasswordRefusal.noPassword),
);
final row = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
expect(row.verifier, isNull);
expect(await AccountReset.db.count(session), 1);
expect(mailbox.notices, isEmpty);
});
test('takes only a code asked for it: one asked for anything else '
'does nothing, and stays as it was', () async {
await annaComplete();

View file

@ -403,6 +403,51 @@ final class _Register implements AccountRegister {
keysChanges.add((change: 'phrase', proof: proof, keepSession: null));
});
/// The resets of keys asked and carried out, by person, with the
/// sign-in each came with and the session kept.
final keysResets =
<({String change, UuidValue attempt, UuidValue? keepSession})>[];
@override
Future<ResetPending> startKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
}) => _answer('startKeysReset', () {
keysResets.add((change: 'start', attempt: attempt, keepSession: null));
return ResetPending(takesEffectAt: DateTime.utc(2026, 10, 27));
});
@override
Future<void> completeKeysReset(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
UuidValue? keepSession,
}) => _answer('completeKeysReset', () {
keysResets.add((
change: 'complete',
attempt: attempt,
keepSession: keepSession,
));
});
@override
Future<void> callOffKeysResets(
Session session, {
required UuidValue person,
required KeysProof proof,
}) => _answer('callOffKeysResets', () => keysProofs.add(proof));
@override
Future<void> setPasswordProofKey(
Session session, {
required UuidValue person,
required OwnerProof proof,
required ByteData proofKey,
}) => _answer('setPasswordProofKey', () => changedWith.add(proof));
/// The handles mends began for, the proofs mends came with (as the
/// member bound them), and what the next mend proves.
final mendsBegun = <String>[];
@ -1705,6 +1750,103 @@ void main() {
});
});
group('a reset of keys (ADR 0020, D31–D33)', () {
test('is asked and carried out at the register with the sign-in, '
'this session kept where every other ends', () async {
final attempt = const Uuid().v7obj();
final pending = await endpoints.keys.startReset(
asBen(),
attempt: attempt,
);
await endpoints.keys.completeReset(
asBen(),
attempt: attempt,
setup: (await KeysDevice.make()).setup(),
);
expect(pending.takesEffectAt, DateTime.utc(2026, 10, 27));
expect(register.asked, ['startKeysReset', 'completeKeysReset']);
expect(register.keysResets, [
(change: 'start', attempt: attempt, keepSession: null),
(change: 'complete', attempt: attempt, keepSession: bensLaptop),
]);
});
test('is refused before its time as the register refuses it, with '
'when', () async {
register.failing = PasswordRefusedException(
reason: PasswordRefusal.notDue,
takesEffectAt: DateTime.utc(2026, 10, 27),
);
await expectLater(
endpoints.keys.completeReset(
asBen(),
attempt: const Uuid().v7obj(),
setup: (await KeysDevice.make()).setup(),
),
throwsA(
isA<PasswordRefusedException>()
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
.having(
(e) => e.takesEffectAt,
'takesEffectAt',
DateTime.utc(2026, 10, 27),
),
),
);
});
test('is called off at the register with the proof bound to the base '
'URL the device addressed here, never one for another '
'instance', () async {
final proof = KeysProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: testInstanceUrl,
);
await endpoints.keys.callOffResets(asBen(), proof: proof);
await expectLater(
endpoints.keys.callOffResets(
asBen(),
proof: proof.copyWith(instanceUrl: 'https://evil.example/'),
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
expect(register.asked, ['callOffKeysResets']);
expect(register.keysProofs.single.instanceUrl, testInstanceUrl);
});
});
group("the keys' proof key of a password that signs in (D34)", () {
test('is set at the register with the proof bound to the base URL '
'the device addressed here', () async {
await endpoints.password.setProofKey(
asBen(),
proof: OwnerProof(
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
instanceUrl: testInstanceUrl,
),
proofKey: ByteData(32),
);
expect(register.asked, ['setPasswordProofKey']);
expect(register.changedWith.single.instanceUrl, testInstanceUrl);
});
});
group('profiles', () {
test('are saved and found at the register', () async {
final saved = await endpoints.profile.save(

View file

@ -2,6 +2,8 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/password/passwords.dart'
show passwordRowOf;
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:halkyon_oidc_server/testing.dart';
import 'package:serverpod/serverpod.dart';
@ -620,6 +622,64 @@ void main() {
}
},
);
test('says whether the password signs in too and opens the keys '
'here (D34), and when a reset of the keys takes effect '
'(D31)', () async {
await giveKeys(session, ben, await KeysDevice.make());
final waiting = DateTime.now().toUtc().add(resetDelay);
await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: ben,
kind: AccountResetKind.account,
takesEffectAt: waiting,
createdAt: DateTime.now().toUtc(),
),
);
final annas = await AccountPassword.db.insertRow(
session,
passwordRowOf(
anna,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
Future<SimpleLoginCompleted> signIn(
TestSessionBuilder as,
String subject,
) async {
final (:attempt, :code) = await allowed(as, subject: subject);
return await endpoints.simpleLogin.complete(
as,
attempt: attempt,
code: code,
);
}
final bens = await signIn(notSignedIn(), 'sl-ben');
final linked = await signIn(asAnna(), 'sl-anna');
await AccountPassword.db.updateRow(
session,
annas.copyWith(passwordProofKey: ByteData(32)),
);
final annasAgain = await signIn(notSignedIn(), 'sl-anna');
expect(
(bens.hasKeys, bens.passwordSignsIn, bens.hasProofKey),
(true, false, true),
);
expect(bens.accountResetAt, waiting);
expect(
(linked.hasKeys, linked.passwordSignsIn, linked.hasProofKey),
(true, true, false),
);
expect(linked.accountResetAt, isNull);
expect(
(annasAgain.passwordSignsIn, annasAgain.hasProofKey),
(true, true),
);
});
});
group('signing up someone new (D18)', () {
@ -1174,6 +1234,38 @@ void main() {
);
});
test('calls off a reset of her keys waiting: the phrase proved her '
'the owner (D31)', () async {
await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: dora,
kind: AccountResetKind.account,
takesEffectAt: DateTime.now().toUtc().add(resetDelay),
createdAt: DateTime.now().toUtc(),
),
);
final attempt = await waiting();
final mended = await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
),
);
expect(mended.accountResetAt, isNull);
expect(
await AccountReset.db.count(
session,
where: (t) => t.authUserId.equals(dora),
),
0,
);
});
test('answers a handle nobody has, or one of an account it would not '
'mend, with a decoy of its own, refusing its proof '
'alike', () async {

View file

@ -740,6 +740,103 @@ class _KeysEndpoint {
}
});
}
_ida.Future<_isakxto6.ResetPending> startReset(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue attempt,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'keys',
method: 'startReset',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'keys',
methodName: 'startReset',
parameters: _ist.testObjectToJson({'attempt': attempt}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_isakxto6.ResetPending>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> completeReset(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue attempt,
required _ii64ejy6.KeysSetup setup,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'keys',
method: 'completeReset',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'keys',
methodName: 'completeReset',
parameters: _ist.testObjectToJson({
'attempt': attempt,
'setup': setup,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> callOffResets(
_ist.TestSessionBuilder sessionBuilder, {
required _iy592cuk.KeysProof proof,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'keys',
method: 'callOffResets',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'keys',
methodName: 'callOffResets',
parameters: _ist.testObjectToJson({'proof': proof}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _AccountRecoveryEndpoint {
@ -1284,6 +1381,41 @@ class _PasswordEndpoint {
});
}
_ida.Future<void> setProofKey(
_ist.TestSessionBuilder sessionBuilder, {
required _ifhod73s.OwnerProof proof,
required _idt.ByteData proofKey,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'password',
method: 'setProofKey',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'password',
methodName: 'setProofKey',
parameters: _ist.testObjectToJson({
'proof': proof,
'proofKey': proofKey,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<void>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<void> setRecoveryKeys(
_ist.TestSessionBuilder sessionBuilder, {
required _ifhod73s.OwnerProof proof,