k_card/k_phone/test/fido2_test.dart

168 lines
6.2 KiB
Dart

// FIDO2 Dart unit tests.
//
// Tests 1-2 run without any external dependency.
// Tests 3-6 require card_emulator_bridge.py on TCP port 8772:
//
// uv run --python 3.12 --with fido2 --with cbor2 --with cryptography \
// tests/card_emulator_bridge.py
//
// Then run: flutter test test/fido2_test.dart
import 'dart:convert';
import 'dart:typed_data';
import 'package:cbor/cbor.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import '../lib/ctaphid_channel.dart';
import '../lib/fido2_ops.dart';
void main() {
// -------------------------------------------------------------------------
// Test 1: CBOR encode/decode round-trip
// -------------------------------------------------------------------------
test('CBOR makeCredential request map encodes and decodes correctly', () {
final clientDataHash = Uint8List(32)..fillRange(0, 32, 0xAB);
final requestMap = CborMap({
CborSmallInt(1): CborBytes(clientDataHash),
CborSmallInt(2): CborMap({
CborString('id'): CborString(kRpId),
CborString('name'): CborString(kRpName),
}),
CborSmallInt(4): CborList([
CborMap({
CborString('type'): CborString('public-key'),
CborString('alg'): CborSmallInt(-7),
}),
]),
CborSmallInt(7): CborMap({
CborString('rk'): CborBool(false),
CborString('uv'): CborBool(false),
}),
});
final encoded = Uint8List.fromList(cbor.encode(requestMap));
expect(encoded, isNotEmpty);
final decoded = cbor.decode(encoded);
expect(decoded, isA<CborMap>());
final m = decoded as CborMap;
final hash = m[CborSmallInt(1)];
expect(hash, isA<CborBytes>());
expect((hash as CborBytes).bytes, equals(clientDataHash));
final rp = m[CborSmallInt(2)] as CborMap;
expect((rp[CborString('id')] as CborString).toString(), equals(kRpId));
});
// -------------------------------------------------------------------------
// Test 2: clientDataHash is SHA256 of known JSON
// -------------------------------------------------------------------------
test('clientDataHash matches SHA256 of known clientDataJSON', () {
// Fixed challenge for deterministic test
final challenge = Uint8List.fromList(List.generate(32, (i) => i));
final challengeB64 = base64Url.encode(challenge).replaceAll('=', '');
final clientDataJson =
'{"type":"webauthn.create","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}';
final expected = Uint8List.fromList(sha256.convert(utf8.encode(clientDataJson)).bytes);
expect(expected.length, equals(32));
expect(expected, isNot(equals(Uint8List(32))));
});
// -------------------------------------------------------------------------
// Tests 3-6: require card_emulator_bridge.py on 127.0.0.1:8772
// -------------------------------------------------------------------------
group('emulator bridge', () {
late int cid;
late String credentialDataB64;
setUpAll(() async {
useEmulator(host: '127.0.0.1', port: 8772);
final connected = await openCard();
if (!connected) {
markTestSkipped('card_emulator_bridge.py not reachable on :8772');
return;
}
cid = await ctaphidInit();
});
tearDownAll(() async {
await closeCard();
});
// -----------------------------------------------------------------------
// Test 3: makeCredential returns valid AttestedCredentialData
// -----------------------------------------------------------------------
test('makeCredential returns non-empty credentialData with valid structure', () async {
final result = await makeCredential(cid, 'testuser', displayName: 'Test User');
expect(result.credentialData, isNotEmpty);
expect(result.userId.length, equals(32));
// AttestedCredentialData: aaguid(16) + credIdLen(2) + credId + coseKey
final cd = result.credentialData;
expect(cd.length, greaterThan(18));
final credIdLen = (cd[16] << 8) | cd[17];
expect(credIdLen, greaterThan(0));
expect(cd.length, greaterThanOrEqualTo(18 + credIdLen + 1)); // at least 1 byte of COSE key
credentialDataB64 = result.credentialDataB64;
expect(credentialDataB64, isNotEmpty);
});
// -----------------------------------------------------------------------
// Test 4: getAssertion returns non-empty authData and signature
// -----------------------------------------------------------------------
test('getAssertion returns authData and signature bytes', () async {
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
final result = await getAssertion(cid, credentialDataB64);
expect(result.authData, isNotEmpty);
expect(result.signature, isNotEmpty);
expect(result.clientDataHash.length, equals(32));
});
// -----------------------------------------------------------------------
// Test 5: verifyAssertion accepts valid signature
// -----------------------------------------------------------------------
test('verifyAssertion accepts valid assertion signature', () async {
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
final assertion = await getAssertion(cid, credentialDataB64);
final ok = verifyAssertion(
credentialDataB64,
assertion.authData,
assertion.signature,
assertion.clientDataHash,
);
expect(ok, isTrue);
});
// -----------------------------------------------------------------------
// Test 6: verifyAssertion rejects tampered authData
// -----------------------------------------------------------------------
test('verifyAssertion rejects tampered authData', () async {
expect(credentialDataB64, isNotEmpty, reason: 'requires test 3 to pass first');
final assertion = await getAssertion(cid, credentialDataB64);
// Flip one byte in authData (byte 32 = flags byte)
final tampered = Uint8List.fromList(assertion.authData);
tampered[32] ^= 0xFF;
final ok = verifyAssertion(
credentialDataB64,
tampered,
assertion.signature,
assertion.clientDataHash,
);
expect(ok, isFalse);
});
});
}