// Dart side of the USB HID platform channel + TCP emulator transport. // // Two transport modes: // USB mode (default): calls into Kotlin MainActivity via MethodChannel. // Emulator mode: TCP socket to card_emulator_bridge.py on port 8772. // // Call useEmulator() before openCard() to switch to emulator mode. // All CTAPHID framing, fragmentation, and reassembly lives here in Dart. import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/services.dart'; const _channel = MethodChannel('com.chromecard.kphone/usb_hid'); // ChromeCard USB IDs (matches udev rule 70-chromecard-fido.rules) const int kVendorId = 0x1209; const int kProductId = 0x0005; // CTAPHID constants const int kCtaphidBroadcastChannel = 0xFFFFFFFF; const int kCtaphidInit = 0x06; const int kCtaphidMsg = 0x03; const int kCtaphidCbor = 0x10; const int kCtaphidCancel = 0x11; const int kCtaphidError = 0x3F; const int kCtaphidKeepalive = 0x3B; const int kHidPacketSize = 64; // --------------------------------------------------------------------------- // Transport selection // --------------------------------------------------------------------------- bool _emulatorMode = false; String _emulatorHost = '127.0.0.1'; int _emulatorPort = 8772; Socket? _emulatorSocket; // Persistent read state for the emulator TCP socket. // Socket is a single-subscription stream — we must subscribe exactly once // and accumulate all incoming bytes into a buffer. StreamSubscription>? _emulatorSub; final _emulatorRxBuf = []; Completer? _emulatorRxWaiter; bool _emulatorSocketOpen = false; void _emulatorStartReading(Socket sock) { _emulatorRxBuf.clear(); _emulatorRxWaiter = null; _emulatorSocketOpen = true; _emulatorSub?.cancel(); _emulatorSub = sock.listen( (chunk) { _emulatorRxBuf.addAll(chunk); final w = _emulatorRxWaiter; if (w != null && !w.isCompleted) w.complete(); }, onDone: () { _emulatorSocketOpen = false; final w = _emulatorRxWaiter; if (w != null && !w.isCompleted) w.completeError(const SocketException('Emulator socket closed')); }, onError: (Object e) { _emulatorSocketOpen = false; final w = _emulatorRxWaiter; if (w != null && !w.isCompleted) w.completeError(e); }, ); } /// Switch to emulator mode — connects to card_emulator_bridge.py. /// Must be called before openCard(). void useEmulator({String host = '127.0.0.1', int port = 8772}) { _emulatorMode = true; _emulatorHost = host; _emulatorPort = port; } /// Switch back to USB mode. void useUsb() { _emulatorMode = false; _emulatorSocketOpen = false; _emulatorSub?.cancel(); _emulatorSub = null; _emulatorRxBuf.clear(); _emulatorSocket?.destroy(); _emulatorSocket = null; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /// Opens the ChromeCard USB device (or emulator TCP connection). Future openCard() async { if (_emulatorMode) { try { _emulatorSub?.cancel(); _emulatorSocket?.destroy(); _emulatorSocket = await Socket.connect(_emulatorHost, _emulatorPort); _emulatorStartReading(_emulatorSocket!); return true; } catch (e) { return false; } } try { return await _channel.invokeMethod('openCard') ?? false; } on MissingPluginException { return false; } on PlatformException catch (e) { throw CtapHidException('openCard failed: ${e.message}'); } } /// Closes the card handle / TCP connection. Future closeCard() async { if (_emulatorMode) { _emulatorSub?.cancel(); _emulatorSub = null; _emulatorRxBuf.clear(); _emulatorSocket?.destroy(); _emulatorSocket = null; return; } try { await _channel.invokeMethod('closeCard'); } on MissingPluginException { return; } on PlatformException catch (e) { throw CtapHidException('closeCard failed: ${e.message}'); } } /// Returns true if a card (or emulator) is currently open. Future isCardAttached() async { if (_emulatorMode) return _emulatorSub != null; try { return await _channel.invokeMethod('isCardAttached') ?? false; } on MissingPluginException { return false; } on PlatformException { return false; } } /// Sends a CTAPHID INIT to allocate a channel, returns the allocated CID. Future ctaphidInit() async { final nonce = Uint8List(8); final rng = Random.secure(); for (var i = 0; i < 8; i++) nonce[i] = rng.nextInt(256); final responsePayload = await _ctaphidRoundtrip( kCtaphidBroadcastChannel, kCtaphidInit, nonce, ); if (responsePayload.length < 12) { throw CtapHidException('INIT response too short: ${responsePayload.length}'); } // Response payload: nonce(8) + CID(4) + ... final cid = (responsePayload[8] << 24) | (responsePayload[9] << 16) | (responsePayload[10] << 8) | responsePayload[11]; return cid; } /// Sends a CTAP2 CBOR command and returns the response payload. Future ctap2Cbor(int cid, Uint8List cbor) async { return _ctaphidRoundtrip(cid, kCtaphidCbor, cbor); } /// Sends a CTAP1/U2F message and returns the response payload. Future ctap1Msg(int cid, Uint8List apdu) async { return _ctaphidRoundtrip(cid, kCtaphidMsg, apdu); } // --------------------------------------------------------------------------- // Internal: request/response // --------------------------------------------------------------------------- /// Full CTAPHID round-trip: fragment request, send, receive, reassemble. Future _ctaphidRoundtrip(int cid, int cmd, Uint8List data) async { final requestPackets = _buildPackets(cid: cid, cmd: cmd, data: data); if (_emulatorMode) { // Emulator: send all request packets at once, then read response. // The bridge buffers all request packets and sends keepalives as needed, // but since we write everything before reading, we just send and drain. for (final pkt in requestPackets) { await _sendPacketOnly(pkt); } // Read the response init packet (bridge may have sent keepalives first). var first = await _receivePacket(); while (_isKeepalive(first)) { first = await _receivePacket(); } return await _reassembleResponse(first, cid); } // USB: platform channel returns one response per send; keepalive loop as before. Uint8List lastReceived = Uint8List(kHidPacketSize); for (final pkt in requestPackets) { lastReceived = await _sendPacket(pkt); } while (_isKeepalive(lastReceived)) { lastReceived = await _receivePacket(); } return await _reassembleResponse(lastReceived, cid); } /// Send one 64-byte packet (emulator mode writes to socket; USB invokes platform channel). Future _sendPacketOnly(Uint8List packet) async { assert(packet.length == kHidPacketSize); if (_emulatorMode) { if (!_emulatorSocketOpen) throw CtapHidException('Emulator socket closed'); final sock = _emulatorSocket; if (sock == null) throw CtapHidException('Emulator socket not open'); sock.add(packet); await sock.flush(); return; } // USB: sendCtaphid returns the response; handled by the USB round-trip path. throw CtapHidException('_sendPacketOnly not used for USB'); } /// Send one 64-byte packet and receive one response (USB mode). Future _sendPacket(Uint8List packet) async { assert(packet.length == kHidPacketSize); try { final r = await _channel.invokeMethod('sendCtaphid', packet); return r ?? Uint8List(kHidPacketSize); } on MissingPluginException { throw CtapHidException('USB plugin not available'); } on PlatformException catch (e) { throw CtapHidException('USB transfer failed: ${e.message}'); } } /// Receive one 64-byte packet from the emulator buffer. /// Waits until the persistent socket listener has buffered enough bytes. Future _receivePacket() async { if (_emulatorSub == null) throw CtapHidException('Emulator socket not open'); while (_emulatorRxBuf.length < kHidPacketSize) { _emulatorRxWaiter = Completer(); await _emulatorRxWaiter!.future; } final pkt = Uint8List.fromList(_emulatorRxBuf.take(kHidPacketSize).toList()); _emulatorRxBuf.removeRange(0, kHidPacketSize); return pkt; } /// Reassemble a full CTAPHID response from an init packet + any continuations. Future _reassembleResponse(Uint8List initPacket, int expectedCid) async { _checkCid(initPacket, expectedCid); final cmd = initPacket[4] & 0x7F; final payloadLen = (initPacket[5] << 8) | initPacket[6]; final firstChunk = min(payloadLen, kHidPacketSize - 7); final result = BytesBuilder(); result.add(initPacket.sublist(7, 7 + firstChunk)); var received = firstChunk; while (received < payloadLen) { final contPacket = _emulatorMode ? await _receivePacket() : await _receivePacket(); if (_isKeepalive(contPacket)) continue; _checkCid(contPacket, expectedCid); final chunk = min(payloadLen - received, kHidPacketSize - 5); result.add(contPacket.sublist(5, 5 + chunk)); received += chunk; } final payload = result.toBytes(); if (cmd == kCtaphidError) { throw CtapHidException( 'CTAPHID error: 0x${payload.isNotEmpty ? payload[0].toRadixString(16) : "??"}'); } return payload; } bool _isKeepalive(Uint8List pkt) => pkt.length >= 5 && (pkt[4] & 0x7F) == kCtaphidKeepalive; void _checkCid(Uint8List pkt, int expected) { if (pkt.length < 4) return; final got = (pkt[0] << 24) | (pkt[1] << 16) | (pkt[2] << 8) | pkt[3]; if (got != expected && expected != kCtaphidBroadcastChannel) { throw CtapHidException( 'CID mismatch: got 0x${got.toRadixString(16)}, ' 'expected 0x${expected.toRadixString(16)}'); } } // --------------------------------------------------------------------------- // Packet building // --------------------------------------------------------------------------- List _buildPackets({ required int cid, required int cmd, required Uint8List data, }) { final packets = []; const initPayload = kHidPacketSize - 7; const contPayload = kHidPacketSize - 5; final init = Uint8List(kHidPacketSize); init[0] = (cid >> 24) & 0xFF; init[1] = (cid >> 16) & 0xFF; init[2] = (cid >> 8) & 0xFF; init[3] = cid & 0xFF; init[4] = (cmd & 0x7F) | 0x80; init[5] = (data.length >> 8) & 0xFF; init[6] = data.length & 0xFF; final firstChunk = min(data.length, initPayload); init.setRange(7, 7 + firstChunk, data); packets.add(init); var offset = firstChunk; var seq = 0; while (offset < data.length) { final cont = Uint8List(kHidPacketSize); cont[0] = (cid >> 24) & 0xFF; cont[1] = (cid >> 16) & 0xFF; cont[2] = (cid >> 8) & 0xFF; cont[3] = cid & 0xFF; cont[4] = seq & 0x7F; final chunk = min(data.length - offset, contPayload); cont.setRange(5, 5 + chunk, data, offset); packets.add(cont); offset += chunk; seq++; } return packets; } // --------------------------------------------------------------------------- // Exception // --------------------------------------------------------------------------- class CtapHidException implements Exception { final String message; CtapHidException(this.message); @override String toString() => 'CtapHidException: $message'; }