k_card/raw_ctap_probe.py

312 lines
10 KiB
Python

#!/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
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 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")
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()
devs = list_devices()
if args.command == "list":
print_json(
{
"devices": [describe_device(dev) for dev in devs],
}
)
return 0 if devs else 1
if not devs:
print("No CTAP HID devices found.", file=sys.stderr)
return 1
if args.index < 0 or args.index >= len(devs):
print(f"Invalid --index {args.index}; found {len(devs)} device(s).", file=sys.stderr)
return 2
dev = devs[args.index]
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())