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