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:
Tizian 「ティツィアーン」 2026-10-21 15:11:29 +02:00
commit a253700f8b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 195 additions and 1 deletions

View file

@ -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).

View file

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

View file

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

View file

@ -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