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

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:halkyon_base_client/srp.dart' as device;
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
@ -13,17 +14,122 @@ Uint8List _hex(String hex) => Uint8List.fromList([
int.parse(hex.substring(i, i + 2), radix: 16),
]);
/// A phrase's proof for an authenticator's reset on learn.halkyon.eu.
Uint8List _message(UuidValue challenge, List<int> nonce) => keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
purpose: KeyProofPurpose.authenticatorReset,
);
void main() {
group("The recovery phrase's proof", () {
test("signs the bytes devices sign (halkyon_base_client's)", () {
test("signs the bytes devices sign (halkyon_base_client's), for each "
"purpose, and proves SRP's identity as they do", () {
final challenge = UuidValue.fromString(
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
);
final nonce = List.generate(32, (i) => i * 7 % 256);
final password = NewPassword(
salt: ByteData.sublistView(Uint8List.fromList([1, 2, 3])),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
verifier: ByteData.sublistView(Uint8List.fromList([4, 5])),
passwordKeys: WrappedKeys(
nonce: ByteData.sublistView(Uint8List.fromList([6])),
cipherText: ByteData.sublistView(Uint8List.fromList([7, 8, 9])),
),
);
final digest = newPasswordDigest(password);
expect(
keyProofMessage(challenge, nonce),
device.keyProofMessage(challenge.toBytes(), nonce),
digest,
device.newPasswordDigest(
salt: [1, 2, 3],
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
verifier: [4, 5],
keysNonce: [6],
keysCipherText: [7, 8, 9],
),
);
expect(
keyProofMessage(
challenge,
nonce,
host: 'Learn.Halkyon.EU',
purpose: KeyProofPurpose.passwordReset,
digest: digest,
),
device.keyProofMessage(
challenge.toBytes(),
nonce,
host: 'learn.halkyon.eu',
purpose: device.KeyProofPurpose.passwordReset,
digest: digest,
),
);
expect(
keyProofMessage(
challenge,
nonce,
host: 'learn.halkyon.eu',
purpose: KeyProofPurpose.authenticatorReset,
),
device.keyProofMessage(
challenge.toBytes(),
nonce,
host: 'learn.halkyon.eu',
purpose: device.KeyProofPurpose.authenticatorReset,
),
);
expect(
passwordIdentity(challenge, 'learn.halkyon.eu'),
device.passwordIdentity(challenge.toBytes(), 'LEARN.halkyon.eu'),
);
});
test("takes the host a device names only where it is the instance's "
'own, but in development', () {
expect(
deviceHost(
runMode: ServerpodRunMode.production,
publicHost: 'learn.halkyon.eu',
claimed: ' Learn.Halkyon.eu ',
),
'learn.halkyon.eu',
);
for (final runMode in [
ServerpodRunMode.production,
ServerpodRunMode.staging,
ServerpodRunMode.test,
]) {
expect(
() => deviceHost(
runMode: runMode,
publicHost: 'learn.halkyon.eu',
claimed: 'learn.evil.example',
),
throwsA(
isA<PasswordRefusedException>().having(
(refused) => refused.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
}
expect(
deviceHost(
runMode: ServerpodRunMode.development,
publicHost: 'localhost',
claimed: '10.0.2.2',
),
'10.0.2.2',
);
});
@ -102,13 +208,13 @@ void main() {
);
final nonce = List.filled(32, 9);
final signature = (await Ed25519().sign(
keyProofMessage(challenge, nonce),
_message(challenge, nonce),
keyPair: keys,
)).bytes;
expect(
await isAccountSignature(
keyProofMessage(challenge, nonce),
_message(challenge, nonce),
signature: signature,
publicKey: publicKey,
),
@ -116,7 +222,7 @@ void main() {
);
expect(
await isAccountSignature(
keyProofMessage(
_message(
UuidValue.fromString('01a15902-7e93-77fd-9704-4a7dc81613f7'),
nonce,
),
@ -127,7 +233,7 @@ void main() {
);
expect(
await isAccountSignature(
keyProofMessage(challenge, List.filled(32, 8)),
_message(challenge, List.filled(32, 8)),
signature: signature,
publicKey: publicKey,
),