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

@ -7,6 +7,11 @@ import 'package:halkyon_base_client/srp.dart' as srp;
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_base_server/src/srp/srp_math.dart';
/// The host devices address the tests' server by: its `apiServer.publicHost`
/// (config/test.yaml), which SRP's identity and the phrase's proofs are
/// bound to.
const testHost = 'localhost';
/// The instance keys the tests' register holds (ADR 0017).
const testPasswordKeys = (
totpKey: 'a TOTP key of the tests, 32 characters at least',
@ -119,18 +124,22 @@ final class PasswordDevice {
);
}
/// The device's answer to [challenge] (A and M1), with its password or
/// the [privateValue] of another; and whether a register's M2 is the one
/// that answer expects.
/// The device's answer to [challenge] (A and M1) on [host], with its
/// password or the [privateValue] of another; and whether a register's M2
/// is the one that answer expects.
({
ByteData clientValue,
ByteData clientProof,
bool Function(ByteData serverProof) verifiesServer,
})
answer(PasswordChallenge challenge, {BigInt? privateValue}) {
answer(
PasswordChallenge challenge, {
BigInt? privateValue,
String host = testHost,
}) {
final client = srp.SrpClient(_clientGroup, random: _random);
final step = client.respond(
identity: challenge.challenge.toBytes(),
identity: srp.passwordIdentity(challenge.challenge.toBytes(), host),
salt: Uint8List.sublistView(challenge.salt),
privateValue: privateValue ?? this.privateValue,
serverValue: numberOf(Uint8List.sublistView(challenge.serverValue)),
@ -146,16 +155,24 @@ final class PasswordDevice {
);
}
/// The signature proving the recovery phrase for [challenge]: by the
/// phrase's proof key, or [keys]' where given.
/// The signature proving the recovery phrase for [challenge] on [host]:
/// for the new [password] it sets, or for an authenticator's reset
/// without one; by the phrase's proof key, or [keys]' where given.
Future<ByteData> sign(
KeyProofChallenge challenge, {
SimpleKeyPair? keys,
NewPassword? password,
String host = testHost,
}) async {
final signature = await Ed25519().sign(
keyProofMessage(
challenge.challenge,
Uint8List.sublistView(challenge.nonce),
host: host,
purpose: password == null
? KeyProofPurpose.authenticatorReset
: KeyProofPurpose.passwordReset,
digest: password == null ? const [] : newPasswordDigest(password),
),
keyPair: keys ?? proofKeys,
);