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
6 changed files with 1256 additions and 24 deletions

View file

@ -391,6 +391,40 @@ final class _Register implements AccountRegister {
keysChanges.add((change: 'phrase', proof: proof, keepSession: null));
});
/// The handles mends began for, the proofs mends came with (as the
/// member bound them), and what the next mend proves.
final mendsBegun = <String>[];
final mendProofs = <KeysProof>[];
SimpleLoginProof? mended;
@override
Future<KeyProofChallenge> beginSimpleLoginMend(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) => _answer('beginSimpleLoginMend', () {
networks.add(network);
mendsBegun.add(handle);
return KeyProofChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
);
});
@override
Future<SimpleLoginProof> mendSimpleLogin(
Session session, {
required UuidValue attempt,
required KeysProof proof,
}) {
mendProofs.add(proof);
return _answer('mendSimpleLogin', () => mended!);
}
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,
@ -1356,6 +1390,71 @@ void main() {
expect(await AuthUser.db.count(session), people);
});
test('begins mending a link at the register, the device network '
'passed on, and never for a signed-in caller', () async {
await endpoints.simpleLogin.beginMend(
notSignedIn(),
attempt: const Uuid().v7obj(),
handle: 'Ben',
);
expect(register.asked, ['beginSimpleLoginMend']);
expect(register.mendsBegun, ['Ben']);
expect(register.networks, hasLength(1));
await expectLater(
endpoints.simpleLogin.beginMend(
asAnna(),
attempt: const Uuid().v7obj(),
handle: 'Ben',
),
throwsA(
isA<OidcRefusedException>().having(
(error) => error.reason,
'reason',
OidcRefusal.wrongCaller,
),
),
);
expect(register.asked, hasLength(1));
});
test('mends a link at the register, the proof bound to the base URL '
"the device addressed here, the account's sessions here made "
'before the register ended them ending too', () async {
register.mended = SimpleLoginProof(
outcome: SimpleLoginOutcome.signedIn,
account: ben,
hasKeys: true,
handle: 'Ben',
endedBefore: DateTime.now().toUtc(),
);
final completed = await endpoints.simpleLogin.mend(
notSignedIn(),
attempt: const Uuid().v7obj(),
proof: KeysProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: 'HTTP://LOCALHOST:0',
),
);
expect(register.mendProofs.single.instanceUrl, testInstanceUrl);
expect(completed.session!.authUserId, ben);
expect((completed.hasKeys, completed.handle), (true, 'Ben'));
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNull,
);
expect(
(await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
)).single.method,
simpleLoginMethod,
);
});
test('is offered as the register offers it, and not while it is out '
'of reach', () async {
expect(

View file

@ -907,6 +907,92 @@ void main() {
expect(await AuthUser.db.findById(session, carla), isNull);
});
test(
"mends a member's account's broken link with its handle and "
'recovery phrase, a decoy and its refusal crossing whole',
() async {
usePasswordKeys(pod, testPasswordKeys);
final device = await KeysDevice.make();
final first = await remote.beginSimpleLogin(
session,
marker: 'todo-desktop',
network: '203.0.113.0/24',
);
await remote.completeSimpleLogin(
session,
attempt: first.attempt,
code: simpleLogin.authorize(
first.authorizationUrl,
subject: 'sl-dora-old',
),
);
final dora = await remote.signUpWithSimpleLogin(
session,
attempt: first.attempt,
handle: 'Dora',
setup: device.setup(),
);
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-dora-new',
),
);
final decoy = await remote.beginSimpleLoginMend(
session,
attempt: start.attempt,
handle: 'nobody',
network: '203.0.113.0/24',
);
await expectLater(
remote.mendSimpleLogin(
session,
attempt: start.attempt,
proof: await device.provePhrase(
decoy,
digest: mendLinkDigest(start.attempt),
),
),
refused(PasswordRefusal.wrongCredentials),
);
final mended = await remote.mendSimpleLogin(
session,
attempt: start.attempt,
proof: await device.provePhrase(
await remote.beginSimpleLoginMend(
session,
attempt: start.attempt,
handle: 'dora',
network: '203.0.113.0/24',
),
digest: mendLinkDigest(start.attempt),
),
);
expect(mended.outcome, SimpleLoginOutcome.signedIn);
expect(mended.account, dora);
expect(mended.endedBefore, isNotNull);
expect(
(await OidcIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(dora),
))!.subject,
'sl-dora-new',
);
// Every member is told to end the account's sessions.
await sessionNoticesDelivered();
expect(members.sessionsTold.single.$2, dora);
},
);
test('refuses where the register has no SimpleLogin app', () async {
useSimpleLogin(sessionBuilder, null);

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(

View file

@ -104,6 +104,26 @@ final class KeysDevice {
claimedUrl: claimedUrl,
);
/// The recovery phrase's proof for [challenge] (signed out, as mending a
/// SimpleLogin link takes it), by the phrase's proof key or [keys] where
/// given, as [prove] makes the password's.
Future<KeysProof> provePhrase(
KeyProofChallenge challenge, {
KeyProofPurpose purpose = KeyProofPurpose.mendLink,
List<int> digest = const [],
SimpleKeyPair? keys,
String instanceUrl = testInstanceUrl,
String? claimedUrl,
}) => _proof(
challenge.challenge,
challenge.nonce,
purpose: purpose,
digest: digest,
keys: keys ?? recoveryProofKeys,
instanceUrl: instanceUrl,
claimedUrl: claimedUrl,
);
/// A change's proof (D20): the password's proof for [challenge] (or the
/// recovery phrase's for [phrase], by its proof key, where given), with
/// the SimpleLogin sign-in under [attempt].

View file

@ -2001,4 +2001,74 @@ class _SimpleLoginEndpoint {
}
});
}
_ida.Future<_iaysae70.KeyProofChallenge> beginMend(
_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: 'beginMend',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'simpleLogin',
methodName: 'beginMend',
parameters: _ist.testObjectToJson({
'attempt': attempt,
'handle': handle,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_iaysae70.KeyProofChallenge>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_i8g1l2iv.SimpleLoginCompleted> mend(
_ist.TestSessionBuilder sessionBuilder, {
required _is.UuidValue attempt,
required _iy592cuk.KeysProof proof,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'simpleLogin',
method: 'mend',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'simpleLogin',
methodName: 'mend',
parameters: _ist.testObjectToJson({
'attempt': attempt,
'proof': proof,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_i8g1l2iv.SimpleLoginCompleted>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}

View file

@ -160,7 +160,8 @@ void main() {
);
});
test("digests a new phrase and an authenticator's ID as devices do", () {
test("digests a new phrase, an authenticator's ID and a mended link's "
'attempt as devices do', () {
final keys = WrappedKeys(
nonce: ByteData.sublistView(Uint8List.fromList([1, 2])),
cipherText: ByteData.sublistView(Uint8List.fromList([3, 4, 5])),
@ -177,6 +178,7 @@ void main() {
),
);
expect(authenticatorDigest(id), device.authenticatorDigest(id.toBytes()));
expect(mendLinkDigest(id), device.mendLinkDigest(id.toBytes()));
});
test('digests a new password for keys without SRP as devices do, '