fix(app)!: every owner's proof binds the change it approves

The device's answer takes what it is made for and the digest of what it
sets, as the register now requires: the new password (made before the old
one's proof, which binds it), the new phrase's wrapping and proof key, the
authenticator confirmed (the first or another) or removed, new recovery
codes, calling resets off, deleting, and asking for an authenticator's
reset apart from signing in. proof_digests.dart maps the protocol's objects
onto the shared framing; the key proof's password digest uses it too.

The fake gateway refuses a proof made for anything but the call's own
change, as the register does, so every flow's tests hold the repository to
it; the fake's authenticators have UUIDs, as the register's do.

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 14:17:33 +02:00
commit 0a7679396b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,87 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/src/password/data/proof_digests.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_base_client/srp.dart' as srp;
ByteData _bytes(List<int> bytes) =>
ByteData.sublistView(Uint8List.fromList(bytes));
protocol.NewPassword _password({
List<int> salt = const [1, 2],
int opsLimit = 3,
List<int> verifier = const [3, 4],
List<int> nonce = const [5],
List<int> cipherText = const [6, 7],
}) => protocol.NewPassword(
salt: _bytes(salt),
costs: protocol.PasswordCosts(
algorithm: 'argon2id13',
opsLimit: opsLimit,
memLimit: 64 * 1024 * 1024,
),
verifier: _bytes(verifier),
passwordKeys: protocol.WrappedKeys(
nonce: _bytes(nonce),
cipherText: _bytes(cipherText),
),
);
void main() {
test("a new password's digest is the shared framing's over every part "
'the device sends, so none can be swapped', () {
final digest = newPasswordDigestOf(_password());
expect(
digest,
srp.newPasswordDigest(
salt: [1, 2],
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
verifier: [3, 4],
keysNonce: [5],
keysCipherText: [6, 7],
),
);
for (final other in [
_password(salt: [1, 3]),
_password(opsLimit: 4),
_password(verifier: [3, 5]),
_password(nonce: [6]),
_password(cipherText: [6, 8]),
]) {
expect(newPasswordDigestOf(other), isNot(digest));
}
});
test("a new phrase's digest takes its wrapping and its proof key", () {
final keys = protocol.WrappedKeys(
nonce: _bytes([1]),
cipherText: _bytes([2, 3]),
);
expect(
newPhraseDigestOf(keys, _bytes([4, 5])),
srp.newPhraseDigest(
keysNonce: [1],
keysCipherText: [2, 3],
proofKey: [4, 5],
),
);
expect(
newPhraseDigestOf(keys, _bytes([4, 6])),
isNot(newPhraseDigestOf(keys, _bytes([4, 5]))),
);
});
test("an authenticator's digest is its ID's, as the register reads it", () {
const id = '01994a3c-7b2e-7c00-8a1b-0123456789ab';
expect(
authenticatorDigestOf(id),
srp.authenticatorDigest(protocol.UuidValue.fromString(id).toBytes()),
);
});
}