312 lines
10 KiB
Python
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())
|