feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)

Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.

- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
  phrase's key, the public keys and a nonce, for the account with that
  handle (whatever its case). Only an account with keys and a SimpleLogin
  identity is mended, a link it had, as nobody adds SimpleLogin signed
  out (D10); a handle nobody has, or any other account's, gets a decoy
  from `signInDecoyKey`, the same each time. At most 10 an hour per
  handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
  binding the attempt (`mendLinkDigest`, on both sides), so a proof
  relayed from another sign-in links nobody's identity but the device's
  own; checked for the waiting identity first, which leaves the proof
  unspent. Every session of the account ends, in every service, and the
  sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
  (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
  SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
  API carry them.

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 22:26:58 +02:00
commit e5b9578916
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -955,6 +955,346 @@ void main() {
});
});
group('mending a broken link (D7)', () {
/// With keys and the handle `Dora`, her SimpleLogin link broken:
/// SimpleLogin gives her `sl-dora-new` now, the account keeps
/// `sl-dora-old`. Signed in on her laptop.
late UuidValue dora;
late UuidValue dorasLaptop;
late KeysDevice dorasKeys;
setUp(() async {
usePasswordKeys(pod, testPasswordKeys);
dorasKeys = await KeysDevice.make(seed: 5);
dora = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await giveKeys(session, dora, dorasKeys);
await AccountRegister.of(pod).saveProfile(
session,
dora,
handle: 'Dora',
displayName: '',
bio: '',
links: const [],
);
await OidcIdentity.db.insertRow(
session,
OidcIdentity(
authUserId: dora,
provider: simpleLoginProvider,
subject: 'sl-dora-old',
),
);
dorasLaptop = await signInNewDevice(
session,
dora,
method: simpleLoginMethod,
);
});
/// The attempt Dora's new identity waits under, someone new to the
/// register.
Future<UuidValue> waiting({String subject = 'sl-dora-new'}) async {
final (:attempt, :code) = await allowed(
notSignedIn(),
subject: subject,
);
await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: attempt,
code: code,
);
return attempt;
}
Future<KeyProofChallenge> beginMend(
UuidValue attempt, {
String handle = 'Dora',
}) => endpoints.simpleLogin.beginMend(
notSignedIn(),
attempt: attempt,
handle: handle,
);
Matcher refusedAsPassword(PasswordRefusal reason) => throwsA(
isA<PasswordRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
test('takes the handle, whatever its case, and the recovery phrase: '
'the new identity in place of the old, every other session '
'ended, signed in for a change, a lost password too', () async {
final attempt = await waiting();
final challenge = await beginMend(attempt, handle: 'DORA');
expect(
challenge.recoveryKeys.toJson(),
dorasKeys.recoveryKeys.toJson(),
);
final mended = await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
challenge,
digest: mendLinkDigest(attempt),
),
);
expect(mended.outcome, SimpleLoginOutcome.signedIn);
expect(mended.session!.authUserId, dora);
expect((mended.hasKeys, mended.handle), (true, 'Dora'));
expect((await identitiesOf(dora)).single.subject, 'sl-dora-new');
expect(
await ServerSideSession.db.findById(session, dorasLaptop),
isNull,
);
final sessions = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(dora),
);
expect(sessions.single.method, simpleLoginMethod);
// The sign-in stands ten minutes: her password lost too, the
// phrase sets a new one.
final onPhone = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
dora.uuid,
{identified},
authId: sessions.single.id!.uuid,
),
);
final next = await KeysDevice.make(seed: 6);
await endpoints.keys.resetPassword(
onPhone,
proof: await dorasKeys.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
);
// And SimpleLogin signs her in with the new identity.
final again = await allowed(notSignedIn(), subject: 'sl-dora-new');
expect(
(await endpoints.simpleLogin.complete(
notSignedIn(),
attempt: again.attempt,
code: again.code,
)).session!.authUserId,
dora,
);
});
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 {
// Ben: no keys. Carla: keys, but never a SimpleLogin link (nobody
// adds SimpleLogin signed out, D10).
await AccountRegister.of(pod).saveProfile(
session,
ben,
handle: 'Ben',
displayName: '',
bio: '',
links: const [],
);
final carla = (await AuthServices.instance.authUsers.create(
session,
scopes: {identified},
)).id;
await giveKeys(session, carla, await KeysDevice.make(seed: 8));
await AccountRegister.of(pod).saveProfile(
session,
carla,
handle: 'Carla',
displayName: '',
bio: '',
links: const [],
);
final attempt = await waiting();
for (final handle in ['nobody', 'Ben', 'Carla']) {
final decoy = await beginMend(attempt, handle: handle);
expect(
(await beginMend(attempt, handle: handle)).recoveryKeys.toJson(),
decoy.recoveryKeys.toJson(),
reason: '$handle: the same decoy every time',
);
expect(
decoy.recoveryKeys.cipherText.lengthInBytes,
dorasKeys.recoveryKeys.cipherText.lengthInBytes,
);
await expectLater(
endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
decoy,
digest: mendLinkDigest(attempt),
),
),
refusedAsPassword(PasswordRefusal.wrongCredentials),
reason: handle,
);
}
expect((await identitiesOf(dora)).single.subject, 'sl-dora-old');
});
test('refuses a proof by another key, for another attempt or '
'another instance, the identity still waiting', () async {
final attempt = await waiting();
final other = await waiting(subject: 'sl-someone');
for (final (proof, reason) in [
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
keys: dorasKeys.passwordProofKeys,
),
PasswordRefusal.wrongCredentials,
),
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(other),
),
PasswordRefusal.wrongCredentials,
),
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
instanceUrl: 'https://evil.example/',
claimedUrl: testInstanceUrl,
),
PasswordRefusal.wrongCredentials,
),
(
await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
instanceUrl: 'https://evil.example/',
),
PasswordRefusal.otherInstance,
),
]) {
await expectLater(
endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: proof,
),
refusedAsPassword(reason),
);
}
expect((await identitiesOf(dora)).single.subject, 'sl-dora-old');
await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: await dorasKeys.provePhrase(
await beginMend(attempt),
digest: mendLinkDigest(attempt),
),
);
});
test('refuses an attempt nobody new waits under, and a signed-in '
'caller', () 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(
beginMend(attempt),
refused(OidcRefusal.unknownAttempt),
);
}
final attempt = await waiting();
final challenge = await beginMend(attempt);
await expectLater(
endpoints.simpleLogin.beginMend(
asAnna(),
attempt: attempt,
handle: 'Dora',
),
refused(OidcRefusal.wrongCaller),
);
final proof = await dorasKeys.provePhrase(
challenge,
digest: mendLinkDigest(attempt),
);
await expectLater(
endpoints.simpleLogin.mend(
asAnna(),
attempt: attempt,
proof: proof,
),
refused(OidcRefusal.wrongCaller),
);
await expectLater(
withClock(
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
() => endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: proof,
),
),
refused(OidcRefusal.unknownAttempt),
);
// Checked before the proof, which still answers.
await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: attempt,
proof: proof,
);
});
test('begins at most 10 times an hour for a handle, whatever its '
'case, and 30 from a network', () async {
final attempt = await waiting();
for (var i = 0; i < 10; i++) {
await beginMend(attempt, handle: i.isEven ? 'dora' : 'DORA');
}
await expectLater(
beginMend(attempt),
refusedAsPassword(PasswordRefusal.tooManyAttempts),
);
// One refused counts for neither: the network has 10.
for (var i = 0; i < 20; i++) {
await beginMend(attempt, handle: 'someone$i');
}
await expectLater(
beginMend(attempt, handle: 'someone'),
refusedAsPassword(PasswordRefusal.tooManyAttempts),
);
});
test('takes the key for decoys, without which a server with a '
'SimpleLogin app stops as it starts', () async {
final attempt = await waiting();
checkSimpleLoginMending(pod);
usePasswordKeys(pod, null);
await expectLater(
beginMend(attempt),
refusedAsPassword(PasswordRefusal.unavailable),
);
expect(() => checkSimpleLoginMending(pod), throwsStateError);
useSimpleLoginApp(pod, null);
checkSimpleLoginMending(pod);
});
});
test('the identity goes with the account', () async {
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
await endpoints.simpleLogin.complete(