k_card/k_phone/lib/ctaphid_channel.dart

356 lines
11 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.
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; 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<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 = _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<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';
}