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
|
sdk: flutter
|
||||||
# A test drives sign-in through its front, as Serverpod's GitHub sign-in does.
|
# A test drives sign-in through its front, as Serverpod's GitHub sign-in does.
|
||||||
flutter_web_auth_2: ^5.1.0
|
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:
|
flutter:
|
||||||
# Generates HalkyonAppLocalizations from lib/l10n (see l10n.yaml).
|
# 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,
|
PasswordProofPurpose,
|
||||||
authenticatorDigest,
|
authenticatorDigest,
|
||||||
canonicalInstanceUrl,
|
canonicalInstanceUrl,
|
||||||
|
isAccountSignature,
|
||||||
keyProofMessage,
|
keyProofMessage,
|
||||||
newPasswordDigest,
|
newPasswordDigest,
|
||||||
newPhraseDigest,
|
newPhraseDigest,
|
||||||
|
|
@ -42,6 +43,8 @@ export 'src/password/password_config.dart';
|
||||||
export 'src/password/passwords.dart'
|
export 'src/password/passwords.dart'
|
||||||
show
|
show
|
||||||
gitHubSignInFreshFor,
|
gitHubSignInFreshFor,
|
||||||
|
isNewPassword,
|
||||||
|
isPasswordSetup,
|
||||||
newPasswordCosts,
|
newPasswordCosts,
|
||||||
passwordGroup,
|
passwordGroup,
|
||||||
passwordMethod,
|
passwordMethod,
|
||||||
|
|
@ -71,6 +74,7 @@ export 'src/register/people.dart' show issueSession;
|
||||||
export 'src/register/sessions_ended.dart'
|
export 'src/register/sessions_ended.dart'
|
||||||
show AccountSessionsEnded, deliverSessionNotices;
|
show AccountSessionsEnded, deliverSessionNotices;
|
||||||
export 'src/srp/srp_group.dart';
|
export 'src/srp/srp_group.dart';
|
||||||
|
export 'src/srp/srp_math.dart' show numberOf, padded;
|
||||||
export 'src/srp/srp_server.dart'
|
export 'src/srp/srp_server.dart'
|
||||||
show
|
show
|
||||||
SrpAccepted,
|
SrpAccepted,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'package:clock/clock.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:halkyon_base_client/srp.dart' as srp;
|
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||||
import 'package:halkyon_base_server/base.dart';
|
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
|
/// The base URL devices address the tests' server by: its `apiServer`'s
|
||||||
/// public scheme, host and port (config/test.yaml), which SRP's identity
|
/// public scheme, host and port (config/test.yaml), which SRP's identity
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue