363 lines
12 KiB
Dart
363 lines
12 KiB
Dart
// 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.
|
|
// _emulatorRxWaiter is replaced on each call to _receivePacket so that
|
|
// concurrent waiters don't share a Completer and accidentally wake each other.
|
|
StreamSubscription<List<int>>? _emulatorSub;
|
|
final _emulatorRxBuf = <int>[];
|
|
Completer<void>? _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<bool> 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<bool>('openCard') ?? false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
} on PlatformException catch (e) {
|
|
throw CtapHidException('openCard failed: ${e.message}');
|
|
}
|
|
}
|
|
|
|
/// Closes the card handle / TCP connection.
|
|
Future<void> closeCard() async {
|
|
if (_emulatorMode) {
|
|
_emulatorSub?.cancel();
|
|
_emulatorSub = null;
|
|
_emulatorRxBuf.clear();
|
|
_emulatorSocket?.destroy();
|
|
_emulatorSocket = null;
|
|
return;
|
|
}
|
|
try {
|
|
await _channel.invokeMethod<void>('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<bool> isCardAttached() async {
|
|
if (_emulatorMode) return _emulatorSub != null;
|
|
try {
|
|
return await _channel.invokeMethod<bool>('isCardAttached') ?? false;
|
|
} on MissingPluginException {
|
|
return false;
|
|
} on PlatformException {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Sends a CTAPHID INIT to allocate a channel, returns the allocated CID.
|
|
Future<int> 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<Uint8List> ctap2Cbor(int cid, Uint8List cbor) async {
|
|
return _ctaphidRoundtrip(cid, kCtaphidCbor, cbor);
|
|
}
|
|
|
|
/// Sends a CTAP1/U2F message and returns the response payload.
|
|
Future<Uint8List> ctap1Msg(int cid, Uint8List apdu) async {
|
|
return _ctaphidRoundtrip(cid, kCtaphidMsg, apdu);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal: request/response
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Full CTAPHID round-trip: fragment request, send, receive, reassemble.
|
|
Future<Uint8List> _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.
|
|
// Limitation: keepalives and continuation packets after the last request
|
|
// packet call _receivePacket(), which only works in emulator mode.
|
|
// In practice this is safe because CTAP2 responses for typical credential
|
|
// sizes fit in a single init packet and the card does not send keepalives
|
|
// synchronously before the response to the last request packet.
|
|
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<void> _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<Uint8List> _sendPacket(Uint8List packet) async {
|
|
assert(packet.length == kHidPacketSize);
|
|
try {
|
|
final r = await _channel.invokeMethod<Uint8List>('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<Uint8List> _receivePacket() async {
|
|
if (_emulatorSub == null) throw CtapHidException('Emulator socket not open');
|
|
while (_emulatorRxBuf.length < kHidPacketSize) {
|
|
_emulatorRxWaiter = Completer<void>();
|
|
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<Uint8List> _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 = await _receivePacket(); // USB continuation unimplemented — see _ctaphidRoundtrip
|
|
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<Uint8List> _buildPackets({
|
|
required int cid,
|
|
required int cmd,
|
|
required Uint8List data,
|
|
}) {
|
|
final packets = <Uint8List>[];
|
|
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';
|
|
}
|