test(app): the device's real cryptography meets the register's checks
The register's tests make the device's side from made-up bytes, and the app's from fakes; nothing held what the device's real cryptography (libsodium, on isolates) makes to what the register takes. Now register_interop_test.dart does: a new account's setup passes isPasswordSetup, the device's SRP answer checks with the register's srpVerify and its M2 with the device, bound to the instance and the purpose (another instance's or a change's identity refused), the keys open as made, a new password passes isNewPassword, and the phrase's proof for it checks against the phrase's own key, never the account's, with the device's framing of the message the register's. halkyon_base_server exports the checks it uses; halkyon_app takes it as a dev dependency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f3c3f7fb51
commit
a253700f8b
4 changed files with 195 additions and 1 deletions
|
|
@ -71,6 +71,10 @@ dev_dependencies:
|
|||
sdk: flutter
|
||||
# A test drives sign-in through its front, as Serverpod's GitHub sign-in does.
|
||||
flutter_web_auth_2: ^5.1.0
|
||||
# The register's own checks, which the device's real cryptography is held
|
||||
# to (register_interop_test.dart).
|
||||
halkyon_base_server:
|
||||
path: ../halkyon_base_server
|
||||
|
||||
flutter:
|
||||
# Generates HalkyonAppLocalizations from lib/l10n (see l10n.yaml).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_base_client/srp.dart' as device;
|
||||
import 'package:halkyon_base_server/base.dart' as register;
|
||||
import 'package:serverpod_client/serverpod_client.dart' show UuidValue;
|
||||
|
||||
/// The device's real cryptography (libsodium, as its build hook compiles
|
||||
/// it, on isolates of its own) against the register's own checks, byte for
|
||||
/// byte: what the app makes is what the register takes, and the proofs it
|
||||
/// makes check there. The register's tests use made-up bytes for the
|
||||
/// device's side; this is where both meet.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
const password = 'correct horse battery';
|
||||
const instanceUrl = 'https://Learn.Halkyon.EU';
|
||||
final challengeId = UuidValue.fromString(
|
||||
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
|
||||
);
|
||||
late NewAccountMaterial made;
|
||||
late register.PasswordSetup setup;
|
||||
|
||||
setUpAll(() async {
|
||||
made = await crypto.newAccount(password);
|
||||
setup = register.PasswordSetup.fromJson(made.setup.toJson());
|
||||
});
|
||||
|
||||
test("a new account's setup is one the register takes", () {
|
||||
expect(register.isPasswordSetup(setup), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
"signing in: the device's A and M1 check with the register, whose M2 "
|
||||
'the device checks; bound to the instance and to what it is for',
|
||||
() async {
|
||||
final verifier = register.numberOf(Uint8List.sublistView(setup.verifier));
|
||||
final challenge = register.srpChallenge(register.passwordGroup, verifier);
|
||||
final unlock = await crypto.unlock(
|
||||
password,
|
||||
salt: Uint8List.sublistView(made.setup.salt),
|
||||
costs: made.setup.costs,
|
||||
);
|
||||
final answer = await crypto.answer(
|
||||
unlock,
|
||||
protocol.PasswordChallenge(
|
||||
challenge: challengeId,
|
||||
salt: made.setup.salt,
|
||||
costs: made.setup.costs,
|
||||
serverValue: ByteData.sublistView(
|
||||
register.padded(
|
||||
challenge.publicValue,
|
||||
register.passwordGroup.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
instanceUrl: instanceUrl,
|
||||
);
|
||||
|
||||
register.SrpVerification verify(
|
||||
String url, {
|
||||
register.PasswordProofPurpose purpose =
|
||||
register.PasswordProofPurpose.signIn,
|
||||
}) => register.srpVerify(
|
||||
register.passwordGroup,
|
||||
identity: register.passwordIdentity(challengeId, url, purpose: purpose),
|
||||
salt: Uint8List.sublistView(setup.salt),
|
||||
verifier: verifier,
|
||||
challenge: challenge,
|
||||
clientValue: register.numberOf(
|
||||
Uint8List.sublistView(answer.clientValue),
|
||||
),
|
||||
clientProof: Uint8List.sublistView(answer.clientProof),
|
||||
);
|
||||
|
||||
final verified = verify('https://learn.halkyon.eu/');
|
||||
expect(verified, isA<register.SrpAccepted>());
|
||||
final serverProof = (verified as register.SrpAccepted).serverProof;
|
||||
expect(answer.verifiesServer(ByteData.sublistView(serverProof)), isTrue);
|
||||
expect(
|
||||
answer.verifiesServer(
|
||||
ByteData.sublistView(Uint8List.fromList(serverProof)..[0] ^= 1),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// Relayed by another instance, or taken for a change: refused.
|
||||
expect(verify('https://learn.evil.example/'), isA<register.SrpRefused>());
|
||||
expect(
|
||||
verify(
|
||||
'https://learn.halkyon.eu/',
|
||||
purpose: register.PasswordProofPurpose.newPassword,
|
||||
),
|
||||
isA<register.SrpRefused>(),
|
||||
);
|
||||
|
||||
// The keys open with the password's wrapping, as they were made.
|
||||
final keys = await crypto.openKeys(
|
||||
unlock,
|
||||
made.setup.passwordKeys,
|
||||
encryptionPublicKey: Uint8List.sublistView(
|
||||
made.setup.encryptionPublicKey,
|
||||
),
|
||||
signingPublicKey: Uint8List.sublistView(made.setup.signingPublicKey),
|
||||
);
|
||||
expect(keys, made.keys);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"a new password is one the register takes, and the phrase's proof "
|
||||
"for it checks against the phrase's own key, never the account's",
|
||||
() async {
|
||||
final newPassword = await crypto.newPassword(
|
||||
'another horse battery',
|
||||
made.keys,
|
||||
);
|
||||
expect(
|
||||
register.isNewPassword(
|
||||
register.NewPassword.fromJson(newPassword.toJson()),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final nonce = Uint8List.fromList(List.filled(32, 7));
|
||||
final signature = await crypto.signKeyProof(
|
||||
made.phrase,
|
||||
protocol.KeyProofChallenge(
|
||||
challenge: challengeId,
|
||||
nonce: ByteData.sublistView(nonce),
|
||||
encryptionPublicKey: made.setup.encryptionPublicKey,
|
||||
signingPublicKey: made.setup.signingPublicKey,
|
||||
recoveryKeys: made.setup.recoveryKeys,
|
||||
),
|
||||
instanceUrl: instanceUrl,
|
||||
password: newPassword,
|
||||
);
|
||||
final message = register.keyProofMessage(
|
||||
challengeId,
|
||||
nonce,
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: register.KeyProofPurpose.passwordReset,
|
||||
digest: register.newPasswordDigest(
|
||||
register.NewPassword.fromJson(newPassword.toJson()),
|
||||
),
|
||||
);
|
||||
Future<bool> checks(ByteData publicKey, [List<int>? signed]) =>
|
||||
register.isAccountSignature(
|
||||
signed ?? message,
|
||||
signature: Uint8List.sublistView(signature),
|
||||
publicKey: Uint8List.sublistView(publicKey),
|
||||
);
|
||||
|
||||
expect(await checks(setup.recoveryProofKey), isTrue);
|
||||
expect(await checks(setup.signingPublicKey), isFalse);
|
||||
// Another new password, or another instance: not what was signed.
|
||||
expect(
|
||||
await checks(
|
||||
setup.recoveryProofKey,
|
||||
register.keyProofMessage(
|
||||
challengeId,
|
||||
nonce,
|
||||
instanceUrl: 'https://learn.evil.example/',
|
||||
purpose: register.KeyProofPurpose.passwordReset,
|
||||
digest: register.newPasswordDigest(
|
||||
register.NewPassword.fromJson(newPassword.toJson()),
|
||||
),
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
// The device's framing of the message is the register's.
|
||||
expect(
|
||||
device.keyProofMessage(
|
||||
challengeId.toBytes(),
|
||||
nonce,
|
||||
instanceUrl: 'https://learn.halkyon.eu/',
|
||||
purpose: device.KeyProofPurpose.passwordReset,
|
||||
digest: register.newPasswordDigest(
|
||||
register.NewPassword.fromJson(newPassword.toJson()),
|
||||
),
|
||||
),
|
||||
message,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export 'src/password/key_proof.dart'
|
|||
PasswordProofPurpose,
|
||||
authenticatorDigest,
|
||||
canonicalInstanceUrl,
|
||||
isAccountSignature,
|
||||
keyProofMessage,
|
||||
newPasswordDigest,
|
||||
newPhraseDigest,
|
||||
|
|
@ -42,6 +43,8 @@ export 'src/password/password_config.dart';
|
|||
export 'src/password/passwords.dart'
|
||||
show
|
||||
gitHubSignInFreshFor,
|
||||
isNewPassword,
|
||||
isPasswordSetup,
|
||||
newPasswordCosts,
|
||||
passwordGroup,
|
||||
passwordMethod,
|
||||
|
|
@ -71,6 +74,7 @@ export 'src/register/people.dart' show issueSession;
|
|||
export 'src/register/sessions_ended.dart'
|
||||
show AccountSessionsEnded, deliverSessionNotices;
|
||||
export 'src/srp/srp_group.dart';
|
||||
export 'src/srp/srp_math.dart' show numberOf, padded;
|
||||
export 'src/srp/srp_server.dart'
|
||||
show
|
||||
SrpAccepted,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import 'package:clock/clock.dart';
|
|||
import 'package:cryptography/cryptography.dart';
|
||||
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 base URL devices address the tests' server by: its `apiServer`'s
|
||||
/// public scheme, host and port (config/test.yaml), which SRP's identity
|
||||
|
|
|
|||
Loading…
Reference in a new issue