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
8 changed files with 966 additions and 137 deletions

View file

@ -96,6 +96,7 @@ void main() {
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode ?? codeOf(enrolment, at: now),
host: testHost,
);
});
@ -118,6 +119,7 @@ void main() {
clientProof: answer.clientProof,
totpCode: withoutCode ? null : totpCode ?? codeOf(enrolment, at: now),
password: password ?? device.newPassword(),
host: testHost,
);
});
@ -357,6 +359,7 @@ void main() {
: totpCode ?? codeOf(enrolment, at: now),
recoveryKeys: recoveryKeys ?? fresh,
recoveryProofKey: recoveryProofKey ?? freshProofKey,
host: testHost,
);
});
@ -374,15 +377,21 @@ void main() {
address: 'anna@example.org',
);
nextStep();
final signature = await device.sign(proof, keys: keys);
final newPassword = device.newPassword();
final signature = await device.sign(
proof,
keys: keys,
password: newPassword,
);
try {
await atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: proof.challenge,
signature: signature,
password: device.newPassword(),
password: newPassword,
totpCode: codeOf(enrolment, at: now),
host: testHost,
),
);
return true;
@ -467,14 +476,16 @@ void main() {
notSignedIn(),
address: 'anna@example.org',
);
final signature = await device.sign(proof);
final newPassword = device.newPassword();
final signature = await device.sign(proof, password: newPassword);
final signedIn = await atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: proof.challenge,
signature: signature,
password: device.newPassword(),
password: newPassword,
totpCode: codeOf(enrolment, at: now),
host: testHost,
),
);

View file

@ -137,6 +137,7 @@ void main() {
clientProof: answer.clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
host: testHost,
);
});
@ -383,6 +384,7 @@ void main() {
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
);
@ -521,6 +523,7 @@ void main() {
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
);
@ -547,6 +550,7 @@ void main() {
clientValue: lateAnswer.clientValue,
clientProof: lateAnswer.clientProof,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
),
refused(PasswordRefusal.challengeExpired),
@ -558,6 +562,7 @@ void main() {
challenge: const Uuid().v7obj(),
clientValue: lateAnswer.clientValue,
clientProof: lateAnswer.clientProof,
host: testHost,
),
),
refused(PasswordRefusal.challengeExpired),
@ -583,6 +588,7 @@ void main() {
),
clientProof: ByteData(32),
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
),
refused(PasswordRefusal.wrongCredentials),
@ -590,6 +596,65 @@ void main() {
}
});
test('refuses a proof made for another host, as an instance the '
'device was lured into adding would relay it, alike; and tells a '
'device naming another host that the instance has another '
'name', () async {
final complete = await annaComplete();
// The device proved its password to learn.evil.example, which
// passes A and M1 on as they are.
await expectLater(
atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(
challenge,
host: 'learn.evil.example',
);
return await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
);
}),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(
challenge,
host: 'learn.evil.example',
);
return await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(complete.enrolment, at: now),
host: 'learn.evil.example',
);
}),
refused(PasswordRefusal.otherInstance),
);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(anna),
),
1,
);
});
test('says setup is incomplete only to the right password', () async {
await annaSetsPassword();
@ -1136,6 +1201,7 @@ void main() {
language: 'de',
request: code.request,
code: code.code,
host: testHost,
);
});
@ -1276,6 +1342,7 @@ void main() {
language: 'en',
keyChallenge: keyChallenge.challenge,
signature: await device.sign(keyChallenge),
host: testHost,
);
});
@ -1336,6 +1403,7 @@ void main() {
keyChallenge,
keys: await otherSigningKeys(),
),
host: testHost,
);
}),
refused(PasswordRefusal.wrongCredentials),
@ -1354,6 +1422,7 @@ void main() {
clientProof: answer.clientProof,
address: 'anna@example.org',
language: 'en',
host: testHost,
);
}),
refused(PasswordRefusal.wrongCredentials),
@ -1386,6 +1455,7 @@ void main() {
language: 'en',
request: code.request,
code: code.code,
host: testHost,
),
),
refusedAsEmail(EmailSignInRefusal.mailFailed),
@ -1406,6 +1476,7 @@ void main() {
notSignedIn(),
address: address,
);
final newPassword = password ?? device.newPassword();
return await endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: challenge.challenge,
@ -1416,9 +1487,11 @@ void main() {
: accountsKey
? device.signingKeys
: null,
password: newPassword,
),
password: password ?? device.newPassword(),
password: newPassword,
totpCode: totpCode,
host: testHost,
);
});
@ -1551,14 +1624,16 @@ void main() {
address: 'anna@example.org',
),
);
final signature = await device.sign(challenge);
final newPassword = device.newPassword();
final signature = await device.sign(challenge, password: newPassword);
await expectLater(
atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: challenge.challenge,
signature: signature,
password: device.newPassword(),
password: newPassword,
host: testHost,
),
),
refused(PasswordRefusal.wrongCredentials),
@ -1569,8 +1644,9 @@ void main() {
notSignedIn(),
challenge: challenge.challenge,
signature: signature,
password: device.newPassword(),
password: newPassword,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
),
refused(PasswordRefusal.challengeExpired),
@ -1588,8 +1664,9 @@ void main() {
notSignedIn(),
challenge: passwordChallenge.challenge,
signature: signature,
password: device.newPassword(),
password: newPassword,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
),
),
refused(PasswordRefusal.challengeExpired),
@ -1640,6 +1717,60 @@ void main() {
);
});
test('takes no proof made for another host, for an authenticator '
'reset, or for another password than the one sent: whoever '
'stands between swaps in nothing', () async {
final complete = await annaComplete();
final kept = (await AccountPassword.db.findFirstRow(session))!;
Future<AuthSuccess> reset(
Future<ByteData> Function(KeyProofChallenge, NewPassword) sign,
) => atNow(() async {
final challenge = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
final newPassword = device.newPassword();
return await endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: challenge.challenge,
signature: await sign(challenge, newPassword),
password: newPassword,
totpCode: codeOf(complete.enrolment, at: now),
host: testHost,
);
});
await expectLater(
reset(
(challenge, password) => device.sign(
challenge,
password: password,
host: 'learn.evil.example',
),
),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
reset((challenge, password) => device.sign(challenge)),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
reset(
(challenge, password) => device.sign(
challenge,
password: password.copyWith(
verifier: device.newPassword().verifier,
),
),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(
(await AccountPassword.db.findFirstRow(session))!.toJson(),
kept.toJson(),
);
});
test('counts at most 5 proofs begun for an address an hour', () async {
for (var i = 0; i < 5; i++) {
await atNow(
@ -1768,6 +1899,7 @@ void main() {
language: 'en',
request: code.request,
code: code.code,
host: testHost,
);
});
// An hour on, whoever reads her mail asks for the account's reset.

View file

@ -103,6 +103,7 @@ void main() {
totpCode: totpCode,
request: mailed?.request,
code: wrongMailedCode ?? mailed?.code,
host: testHost,
);
});

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);

View file

@ -555,6 +555,7 @@ void main() {
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment, at: now),
host: testHost,
);
expect(proof.account, annaOnTodo);
expect(answer.verifiesServer(proof.serverProof), isTrue);
@ -572,6 +573,7 @@ void main() {
clientValue: againAnswer.clientValue,
clientProof: againAnswer.clientProof,
totpCode: codeOf(enrolment, at: now),
host: testHost,
),
refused(PasswordRefusal.wrongCredentials),
);
@ -596,13 +598,18 @@ void main() {
device.recoveryKeys.toJson(),
);
now = now.add(totpPeriod);
final newPassword = device.newPassword();
expect(
await remote.resetPasswordWithPhrase(
session,
challenge: keyChallenge.challenge,
signature: await device.sign(keyChallenge),
password: device.newPassword(),
signature: await device.sign(
keyChallenge,
password: newPassword,
),
password: newPassword,
totpCode: codeOf(enrolment, at: now),
host: testHost,
),
(account: annaOnTodo, endedBefore: now),
);
@ -630,6 +637,7 @@ void main() {
language: 'en',
request: resetRequest,
code: mailbox.codeFor('anna@example.org'),
host: testHost,
);
expect(pending.takesEffectAt, now.add(resetDelay));
@ -727,6 +735,7 @@ void main() {
totpCode: codeOf(enrolment),
password: device.newPassword(),
keepSession: annasPhone,
host: testHost,
);
expect(members.sessionsTold.single, (
_todo,
@ -757,6 +766,7 @@ void main() {
recoveryProofKey: ByteData.sublistView(
device.recoveryProofKey,
),
host: testHost,
);
}
@ -861,6 +871,7 @@ void main() {
clientProof: answer.clientProof,
request: await code(),
code: mailbox.codeFor('carla@example.org'),
host: testHost,
);
expect(proof.account, carla);
expect(proof.needsAuthenticator, isTrue);

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,
);

View file

@ -575,6 +575,7 @@ class _AccountRecoveryEndpoint {
String? code,
_is.UuidValue? keyChallenge,
_idt.ByteData? signature,
required String host,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -597,6 +598,7 @@ class _AccountRecoveryEndpoint {
'code': code,
'keyChallenge': keyChallenge,
'signature': signature,
'host': host,
}),
serializationManager: _serializationManager,
);
@ -651,6 +653,7 @@ class _AccountRecoveryEndpoint {
required _ijye8mcv.NewPassword password,
String? totpCode,
String? recoveryCode,
required String host,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -669,6 +672,7 @@ class _AccountRecoveryEndpoint {
'password': password,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'host': host,
}),
serializationManager: _serializationManager,
);
@ -1028,6 +1032,7 @@ class _PasswordEndpoint {
String? totpCode,
String? recoveryCode,
required _ijye8mcv.NewPassword password,
required String host,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1047,6 +1052,7 @@ class _PasswordEndpoint {
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'password': password,
'host': host,
}),
serializationManager: _serializationManager,
);
@ -1072,6 +1078,7 @@ class _PasswordEndpoint {
String? recoveryCode,
required _iji0wmu8.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
required String host,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1092,6 +1099,7 @@ class _PasswordEndpoint {
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey,
'host': host,
}),
serializationManager: _serializationManager,
);
@ -1159,6 +1167,7 @@ class _PasswordSignInEndpoint {
String? recoveryCode,
_is.UuidValue? request,
String? code,
required String host,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1179,6 +1188,7 @@ class _PasswordSignInEndpoint {
'recoveryCode': recoveryCode,
'request': request,
'code': code,
'host': host,
}),
serializationManager: _serializationManager,
);

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,
),