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
8 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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue