// CTAP2 FIDO2 operations — makeCredential, getAssertion, verifyAssertion. // Mirrors the direct-CTAP2 path in k_proxy_app.py. // // Wire format: first byte of ctap2Cbor response is a CTAP status code (0x00 = OK), // remaining bytes are a CBOR map. Request is a CBOR map with no status prefix. import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:cbor/cbor.dart'; import 'package:crypto/crypto.dart'; import 'package:pointycastle/export.dart'; import 'ctaphid_channel.dart'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const String kRpId = 'localhost'; const String kOrigin = 'https://localhost'; const String kRpName = 'ChromeCard Proxy'; // --------------------------------------------------------------------------- // Public result types // --------------------------------------------------------------------------- class MakeCredentialResult { /// Raw AttestedCredentialData bytes: aaguid(16) + credIdLen(2) + credId + coseKey final Uint8List credentialData; /// base64url of credentialData — store this in EnrollmentDb String get credentialDataB64 => _b64uEncode(credentialData); /// The 32-byte user handle used during registration final Uint8List userId; /// base64url of userId — store this in EnrollmentDb String get userIdB64 => _b64uEncode(userId); MakeCredentialResult({required this.credentialData, required this.userId}); } class GetAssertionResult { final Uint8List authData; final Uint8List signature; final Uint8List clientDataHash; final String clientDataJson; GetAssertionResult({ required this.authData, required this.signature, required this.clientDataHash, required this.clientDataJson, }); } // --------------------------------------------------------------------------- // makeCredential // --------------------------------------------------------------------------- /// Runs CTAP2 authenticatorMakeCredential against the card on [cid]. /// Returns credential data that should be persisted in the enrollment store. Future makeCredential( int cid, String username, { String? displayName, Uint8List? userId, }) async { final uid = userId ?? _randomBytes(32); final challenge = _randomBytes(32); final clientDataJson = _buildClientDataJson('webauthn.create', challenge); final clientDataHash = _sha256(utf8.encode(clientDataJson)); // CBOR map: authenticatorMakeCredential (CTAP2 spec integer keys throughout) final requestMap = CborMap({ CborSmallInt(1): CborBytes(clientDataHash), CborSmallInt(2): CborMap({ CborString('id'): CborString(kRpId), CborString('name'): CborString(kRpName), }), CborSmallInt(3): CborMap({ CborString('id'): CborBytes(uid), CborString('name'): CborString(username), CborString('displayName'): CborString(displayName ?? username), }), CborSmallInt(4): CborList([ CborMap({ CborString('type'): CborString('public-key'), CborString('alg'): CborSmallInt(-7), }), ]), CborSmallInt(7): CborMap({ // rk=false: non-resident — credential ID is stored externally in EnrollmentDb // rather than on the card, so multiple users can enroll on one card. // uv=false: no PIN; authentication uses user-presence (fingerprint touch) only. CborString('rk'): CborBool(false), CborString('uv'): CborBool(false), }), }); // CTAP2 over CTAPHID: first byte is the authenticatorMakeCredential (0x01) command code. final encoded = Uint8List.fromList([0x01, ...cbor.encode(requestMap)]); final response = await ctap2Cbor(cid, encoded); final responseMap = _parseCtapResponse(response); final authData = _requireBytes(responseMap, 2, 'makeCredential authData'); final credData = _extractAttestedCredentialData(authData); return MakeCredentialResult(credentialData: credData, userId: uid); } // --------------------------------------------------------------------------- // getAssertion // --------------------------------------------------------------------------- /// Runs CTAP2 authenticatorGetAssertion against the card on [cid]. /// [credentialDataB64] is the base64url of the stored AttestedCredentialData. /// [challenge] overrides the random challenge — use for per-request token binding. Future getAssertion( int cid, String credentialDataB64, { Uint8List? challenge, }) async { final credData = _b64uDecode(credentialDataB64); final credId = _extractCredentialId(credData); final actualChallenge = challenge ?? _randomBytes(32); final clientDataJson = _buildClientDataJson('webauthn.get', actualChallenge); final clientDataHash = _sha256(utf8.encode(clientDataJson)); final requestMap = CborMap({ CborSmallInt(1): CborString(kRpId), CborSmallInt(2): CborBytes(clientDataHash), CborSmallInt(3): CborList([ CborMap({ CborString('type'): CborString('public-key'), CborString('id'): CborBytes(credId), }), ]), CborSmallInt(5): CborMap({ CborString('up'): CborBool(true), // require fingerprint touch (user presence) CborString('uv'): CborBool(false), // no PIN }), }); // CTAP2 over CTAPHID: first byte is the authenticatorGetAssertion (0x02) command code. final encoded = Uint8List.fromList([0x02, ...cbor.encode(requestMap)]); final response = await ctap2Cbor(cid, encoded); final responseMap = _parseCtapResponse(response); final authData = _requireBytes(responseMap, 2, 'getAssertion authData'); final signature = _requireBytes(responseMap, 3, 'getAssertion signature'); return GetAssertionResult( authData: authData, signature: signature, clientDataHash: clientDataHash, clientDataJson: clientDataJson, ); } // --------------------------------------------------------------------------- // verifyAssertion // --------------------------------------------------------------------------- /// Verifies the ECDSA-P256 assertion signature. /// [credentialDataB64] is the stored base64url AttestedCredentialData. /// Returns true if the signature is valid. bool verifyAssertion( String credentialDataB64, Uint8List authData, Uint8List signature, Uint8List clientDataHash, ) { final credData = _b64uDecode(credentialDataB64); final coseKey = _extractCoseKey(credData); final pubKey = _coseKeyToEcPublicKey(coseKey); // CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON). final message = Uint8List(authData.length + clientDataHash.length) ..setRange(0, authData.length, authData) ..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash); final (r, s) = _decodeDerSignature(signature); final verifier = ECDSASigner(SHA256Digest()) ..init(false, PublicKeyParameter(pubKey)); try { return verifier.verifySignature(message, ECSignature(r, s)); } catch (_) { return false; } } // --------------------------------------------------------------------------- // AuthData parsing helpers // --------------------------------------------------------------------------- /// Extracts AttestedCredentialData from a full authData blob. /// authData layout: /// [0:32] rpIdHash /// [32] flags /// [33:37] signCount (uint32 BE) /// [37:53] aaguid (16 bytes) ← attested cred data starts here /// [53:55] credIdLen (uint16 BE) /// [55:55+n] credId /// [55+n:] COSE key (CBOR) Uint8List _extractAttestedCredentialData(Uint8List authData) { if (authData.length < 55) { throw FormatException('authData too short for attested credential data: ${authData.length}'); } // The attested credential data is everything from offset 37 onward. return Uint8List.fromList(authData.sublist(37)); } /// Extracts the credential ID from AttestedCredentialData bytes. /// Layout: aaguid(16) + credIdLen(2) + credId(n) + coseKey Uint8List _extractCredentialId(Uint8List credData) { if (credData.length < 18) { throw FormatException('credentialData too short: ${credData.length}'); } final credIdLen = (credData[16] << 8) | credData[17]; if (credData.length < 18 + credIdLen) { throw FormatException('credentialData truncated before credId end'); } return Uint8List.fromList(credData.sublist(18, 18 + credIdLen)); } /// Extracts the COSE key bytes from AttestedCredentialData. Uint8List _extractCoseKey(Uint8List credData) { if (credData.length < 18) { throw FormatException('credentialData too short for COSE key'); } final credIdLen = (credData[16] << 8) | credData[17]; final coseStart = 18 + credIdLen; if (credData.length <= coseStart) { throw FormatException('credentialData has no COSE key bytes'); } return Uint8List.fromList(credData.sublist(coseStart)); } /// Parses a COSE EC2 key and returns an ECPublicKey for pointycastle. ECPublicKey _coseKeyToEcPublicKey(Uint8List coseKeyBytes) { final decoded = cbor.decode(coseKeyBytes); if (decoded is! CborMap) throw FormatException('COSE key is not a CBOR map'); Uint8List? x, y; for (final entry in decoded.entries) { final k = entry.key; final v = entry.value; // COSE key -2 = x, -3 = y (represented as CborSmallInt or CborInt) final ki = _cborInt(k); if (ki == -2 && v is CborBytes) x = Uint8List.fromList(v.bytes); if (ki == -3 && v is CborBytes) y = Uint8List.fromList(v.bytes); } if (x == null || y == null) throw FormatException('COSE key missing x or y coordinate'); final domainParams = ECDomainParameters('prime256v1'); final point = domainParams.curve.createPoint( BigInt.parse(x.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16), BigInt.parse(y.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16), ); return ECPublicKey(point, domainParams); } int _cborInt(CborValue v) { if (v is CborSmallInt) return v.value; if (v is CborInt) return v.toInt(); throw FormatException('expected CBOR int, got ${v.runtimeType}'); } /// DER-decode an ECDSA signature into (r, s) BigInts. (BigInt, BigInt) _decodeDerSignature(Uint8List der) { // SEQUENCE { INTEGER r, INTEGER s } if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag'); // P-256 signatures are always ≤72 bytes, so the SEQUENCE length fits in one byte. // Multi-byte BER length encoding (0x81/0x82 prefix) is not handled here. var offset = 2; // skip 0x30 + one-byte length if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r'); final rLen = der[offset + 1]; final rBytes = der.sublist(offset + 2, offset + 2 + rLen); offset += 2 + rLen; if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for s'); final sLen = der[offset + 1]; final sBytes = der.sublist(offset + 2, offset + 2 + sLen); return (_bigIntFromBytes(rBytes), _bigIntFromBytes(sBytes)); } BigInt _bigIntFromBytes(Uint8List bytes) { var result = BigInt.zero; for (final b in bytes) { result = (result << 8) | BigInt.from(b); } return result; } // --------------------------------------------------------------------------- // CTAP response parsing // --------------------------------------------------------------------------- CborMap _parseCtapResponse(Uint8List response) { if (response.isEmpty) throw FormatException('empty CTAP response'); final status = response[0]; if (status != 0x00) throw FormatException('CTAP error: 0x${status.toRadixString(16)}'); final decoded = cbor.decode(response.sublist(1)); if (decoded is! CborMap) throw FormatException('CTAP response body is not a CBOR map'); return decoded; } Uint8List _requireBytes(CborMap map, int key, String field) { final v = map[CborSmallInt(key)]; if (v == null) throw FormatException('$field: missing key $key in CTAP response'); if (v is! CborBytes) throw FormatException('$field: expected bytes, got ${v.runtimeType}'); return Uint8List.fromList(v.bytes); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- String _buildClientDataJson(String type, Uint8List challenge) { final challengeB64 = _b64uEncode(challenge); return '{"type":"$type","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}'; } Uint8List _sha256(List data) { return Uint8List.fromList(sha256.convert(data).bytes); } Uint8List _randomBytes(int n) { final rng = Random.secure(); return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256))); } String _b64uEncode(Uint8List data) { return base64Url.encode(data).replaceAll('=', ''); } Uint8List _b64uDecode(String s) { // base64url strips trailing '='; restore padding to the nearest multiple of 4. final padded = s + '=' * ((4 - s.length % 4) % 4); return Uint8List.fromList(base64Url.decode(padded)); }