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:
parent
009970faab
commit
dab2c80524
3 changed files with 966 additions and 137 deletions
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ void main() {
|
|||
totpCode: totpCode,
|
||||
request: mailed?.request,
|
||||
code: wrongMailedCode ?? mailed?.code,
|
||||
host: testHost,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue