// 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()); final m = decoded as CborMap; final hash = m[CborSmallInt(1)]; expect(hash, isA()); 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); }); }); }