#!/usr/bin/env python3 """ Low-level CTAP2 probe for ChromeCard host debugging. This bypasses the higher-level Fido2Client/WebAuthn helpers so we can inspect raw makeCredential/getAssertion behavior, keepalive callbacks, and transport errors on the host stack. """ from __future__ import annotations import argparse import hashlib import json import secrets import sys import time import traceback from typing import Any try: from fido2.ctap import CtapError from fido2.ctap2 import Ctap2 from fido2.hid import CtapHidDevice from fido2.hid.linux import get_descriptor, open_connection except Exception as exc: print("Missing dependency: python-fido2", file=sys.stderr) print("Install with: python3 -m pip install fido2", file=sys.stderr) print(f"Import error: {exc}", file=sys.stderr) sys.exit(2) def _json_default(value: Any) -> Any: if isinstance(value, bytes): return value.hex() if isinstance(value, set): return sorted(value) if hasattr(value, "items"): return dict(value.items()) return str(value) def _now() -> str: return time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) def log(message: str) -> None: print(f"[{_now()}] {message}", file=sys.stderr, flush=True) def list_devices() -> list[CtapHidDevice]: return list(CtapHidDevice.list_devices()) def describe_device(dev: CtapHidDevice) -> dict[str, Any]: desc = getattr(dev, "descriptor", None) return { "product_name": getattr(desc, "product_name", None), "manufacturer": getattr(desc, "manufacturer_string", None), "vendor_id": getattr(desc, "vid", None), "product_id": getattr(desc, "pid", None), "path": getattr(desc, "path", None), } def get_ctap2(dev: CtapHidDevice) -> Ctap2: return Ctap2(dev) def get_device(index: int, device_path: str | None) -> CtapHidDevice: if device_path: descriptor = get_descriptor(device_path) return CtapHidDevice(descriptor, open_connection(descriptor)) devs = list_devices() if not devs: raise SystemExit("No CTAP HID devices found.") if index < 0 or index >= len(devs): raise SystemExit(f"Invalid --index {index}; found {len(devs)} device(s).") return devs[index] def print_json(payload: dict[str, Any]) -> None: print(json.dumps(payload, indent=2, default=_json_default)) def keepalive_logger(status: int) -> None: log(f"keepalive status={status}") def _coerce_hex_bytes(value: str | None, label: str) -> bytes | None: if value is None: return None raw = value.strip().lower() if raw.startswith("0x"): raw = raw[2:] try: return bytes.fromhex(raw) except ValueError as exc: raise SystemExit(f"invalid hex for {label}: {value}") from exc def _client_data_hash(label: str) -> bytes: return hashlib.sha256(label.encode("utf-8")).digest() def _key_params() -> list[dict[str, Any]]: return [ {"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -257}, ] def do_info(ctap2: Ctap2, device_meta: dict[str, Any]) -> int: info = ctap2.get_info() print_json({"device": device_meta, "ctap2_info": info}) return 0 def do_make_credential(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[str, Any]) -> int: rp = {"id": args.rp_id, "name": args.rp_name or args.rp_id} user_id = args.user_id.encode("utf-8") user = { "id": user_id, "name": args.user_name, "displayName": args.user_display_name or args.user_name, } client_data_hash = _client_data_hash(f"chromecard-make-credential:{args.rp_id}:{args.user_name}") options = {"rk": args.resident_key, "uv": args.user_verification} log( "starting makeCredential " f"rp_id={args.rp_id} user={args.user_name} rk={options['rk']} uv={options['uv']}" ) try: response = ctap2.make_credential( client_data_hash=client_data_hash, rp=rp, user=user, key_params=_key_params(), options=options, on_keepalive=keepalive_logger, ) except CtapError as exc: print_json( { "operation": "makeCredential", "device": device_meta, "rp": rp, "user": user, "options": options, "error_type": "CtapError", "error_code": getattr(exc, "code", None), "error_name": str(getattr(exc, "code", None)), "message": str(exc), } ) return 1 except Exception as exc: print_json( { "operation": "makeCredential", "device": device_meta, "rp": rp, "user": user, "options": options, "error_type": type(exc).__name__, "message": str(exc), "traceback": traceback.format_exc(), } ) return 1 auth_data = getattr(response, "auth_data", None) credential_data = getattr(auth_data, "credential_data", None) print_json( { "operation": "makeCredential", "device": device_meta, "rp": rp, "user": user, "options": options, "fmt": getattr(response, "fmt", None), "auth_data": auth_data, "credential_id_hex": getattr(credential_data, "credential_id", b"").hex() if credential_data is not None else None, "credential_data_hex": bytes(credential_data).hex() if credential_data is not None else None, "att_stmt": getattr(response, "att_stmt", None), } ) return 0 def do_get_assertion(ctap2: Ctap2, args: argparse.Namespace, device_meta: dict[str, Any]) -> int: allow_credential = _coerce_hex_bytes(args.allow_credential_id, "allow-credential-id") allow_list = [{"type": "public-key", "id": allow_credential}] if allow_credential else None client_data_hash = _client_data_hash(f"chromecard-get-assertion:{args.rp_id}") options = {"up": True, "uv": args.user_verification} log( "starting getAssertion " f"rp_id={args.rp_id} allow_list={1 if allow_list else 0} uv={options['uv']}" ) try: response = ctap2.get_assertion( rp_id=args.rp_id, client_data_hash=client_data_hash, allow_list=allow_list, options=options, on_keepalive=keepalive_logger, ) except CtapError as exc: print_json( { "operation": "getAssertion", "device": device_meta, "rp_id": args.rp_id, "allow_list": allow_list, "options": options, "error_type": "CtapError", "error_code": getattr(exc, "code", None), "error_name": str(getattr(exc, "code", None)), "message": str(exc), } ) return 1 except Exception as exc: print_json( { "operation": "getAssertion", "device": device_meta, "rp_id": args.rp_id, "allow_list": allow_list, "options": options, "error_type": type(exc).__name__, "message": str(exc), "traceback": traceback.format_exc(), } ) return 1 assertions: list[dict[str, Any]] = [] for item in getattr(response, "assertions", []) or []: assertions.append( { "credential": getattr(item, "credential", None), "auth_data": getattr(item, "auth_data", None), "signature": getattr(item, "signature", None), "user": getattr(item, "user", None), "number_of_credentials": getattr(item, "number_of_credentials", None), } ) print_json( { "operation": "getAssertion", "device": device_meta, "rp_id": args.rp_id, "allow_list": allow_list, "options": options, "assertions": assertions, } ) return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Low-level CTAP2 host probe") parser.add_argument("--index", type=int, default=0, help="Device index from --list output") parser.add_argument( "--device-path", help="Use a specific hidraw node such as /dev/hidraw0 instead of scanning all devices", ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("list", help="List CTAP HID devices") subparsers.add_parser("info", help="Fetch CTAP2 getInfo") make_credential = subparsers.add_parser("make-credential", help="Run raw CTAP2 makeCredential") make_credential.add_argument("--rp-id", default="localhost") make_credential.add_argument("--rp-name", default="ChromeCard Local Probe") make_credential.add_argument("--user-name", default="probe-user") make_credential.add_argument("--user-display-name", default="Probe User") make_credential.add_argument("--user-id", default=secrets.token_hex(16)) make_credential.add_argument("--resident-key", action="store_true") make_credential.add_argument("--user-verification", action="store_true") get_assertion = subparsers.add_parser("get-assertion", help="Run raw CTAP2 getAssertion") get_assertion.add_argument("--rp-id", default="localhost") get_assertion.add_argument("--allow-credential-id", help="Credential id as hex") get_assertion.add_argument("--user-verification", action="store_true") return parser def main() -> int: parser = build_parser() args = parser.parse_args() if args.command == "list": devs = list_devices() print_json( { "devices": [describe_device(dev) for dev in devs], } ) return 0 if devs else 1 dev = get_device(args.index, args.device_path) device_meta = describe_device(dev) ctap2 = get_ctap2(dev) if args.command == "info": return do_info(ctap2, device_meta) if args.command == "make-credential": return do_make_credential(ctap2, args, device_meta) if args.command == "get-assertion": return do_get_assertion(ctap2, args, device_meta) parser.error(f"unsupported command: {args.command}") return 2 if __name__ == "__main__": raise SystemExit(main())