fix(auth)!: proofs are bound to the instance the device addresses

Nothing tied a sign-in's proof to the instance it was made for: an
instance a person was lured into adding could pass A, M1 and the code on
to the real register and get a session there, and pass a phrase's proof
on with a password of its own in place of the person's (the second
reading's M6). SRP's identity is now a label, the challenge's ID and
the host the device addressed (passwordIdentity, in halkyon_base_client
and halkyon_base_server alike), and the phrase's proof signs that host,
what it is for (an authenticator's reset or a new password) and the new
password's digest (key proof v2). Every proving call carries the host;
in production it must be the server's apiServer.publicHost (otherwise
otherInstance, which the app shows as an instance it can't trust), in
development any; a member forwards its own. The tests prove that a
relayed sign-in, a proof for another host or purpose, and a swapped
password are refused, nothing changing.

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-20 21:20:12 +02:00
commit dab2c80524
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -48,6 +48,9 @@ final class _Register implements AccountRegister {
/// Thrown by every call while set: the register refusing, or down.
Exception? failing;
/// The hosts sign-ins were bound to, as members named them.
final hosts = <String>[];
Future<T> _answer<T>(String call, T Function() answer) async {
asked.add(call);
if (failing case final error?) {
@ -236,7 +239,11 @@ final class _Register implements AccountRegister {
String? recoveryCode,
UuidValue? request,
String? code,
}) => _answer('completePasswordSignIn', _proven);
required String host,
}) {
hosts.add(host);
return _answer('completePasswordSignIn', _proven);
}
@override
Future<UuidValue> signUpWithCode(
@ -310,6 +317,7 @@ final class _Register implements AccountRegister {
String? code,
UuidValue? keyChallenge,
ByteData? signature,
required String host,
}) => _answer(
'resetAuthenticator',
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
@ -342,6 +350,7 @@ final class _Register implements AccountRegister {
required NewPassword password,
String? totpCode,
String? recoveryCode,
required String host,
}) => _answer(
'resetPasswordWithPhrase',
() => (account: _proven().account, endedBefore: endedBefore),
@ -399,6 +408,7 @@ final class _Register implements AccountRegister {
String? recoveryCode,
required NewPassword password,
UuidValue? keepSession,
required String host,
}) => _answer('changePassword', () {
changed[person] = keepSession;
});
@ -414,6 +424,7 @@ final class _Register implements AccountRegister {
String? recoveryCode,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
required String host,
}) => _answer('setRecoveryKeys', () {});
@override
@ -633,6 +644,7 @@ void main() {
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
host: testHost,
);
expect(register.asked, [
@ -640,6 +652,8 @@ void main() {
'completePasswordSignIn',
]);
expect(register.networks, hasLength(1));
// Bound to the host the device addressed: this member's.
expect(register.hosts, [testHost]);
expect(signedIn.authUserId, carla);
expect(await scopesOf(carla), {identified.name});
expect(
@ -683,6 +697,7 @@ void main() {
challenge: const Uuid().v7obj(),
clientValue: ByteData(384),
clientProof: ByteData(32),
host: testHost,
),
throwsA(
isA<PasswordRefusedException>().having(
@ -1002,6 +1017,7 @@ void main() {
cipherText: ByteData(112),
),
),
host: testHost,
);
expect(register.asked, ['beginPasswordChange', 'changePassword']);
@ -1038,6 +1054,7 @@ void main() {
),
),
totpCode: '123456',
host: testHost,
);
expect(signedIn.authUserId, ben);