Skip to content

R03 — CTAP 2.1, clientPIN, credentialManagement and extensions

Status: NOT EXECUTED as a complete hardware procedure — T2B1 and T3B1.

Use the shared record, with separate results per required model/OS. Host/emulator output is separate evidence.

Preconditions

R02 passed; configured known FIDO PIN; python-fido2 from uv.lock; only one USB authenticator connected; both PIN protocols tested on each model.

Steps

  1. Copy the standalone script below into a private campaign file and run it from the repository root with uv run python. It uses physical HID and prompts for a hidden PIN. Approve the requested registrations/assertions on-device.
  2. Check getInfo: FIDO_2_1, clientPin/credMgmt/largeBlobs/pinUvAuthToken options, credProtect/hmac-secret/largeBlobKey extensions. No unimplemented biometric or enterprise option may be advertised.
  3. Change the PIN using a standard client: old PIN fails, new PIN succeeds. Use one incorrect attempt and confirm its counter stays reduced after reconnecting. Do not exhaust retries outside R15.
  4. Also verify real browser use of the passkey. Retain evidence, then delete the script’s entries using the local menu.

Expected result

Every advertised feature executes. credProtect=3 hides credentials without UV; signatures verify; hmac-secret repeats for equal challenges and changes for a different challenge; largeBlob reads back unchanged; credentialManagement lists the RP and credentials. PIN protocols 1 and 2 pass.

Evidence to retain

getInfo, client versions, script output/assertions, PIN counters, browser success and deleted-entry inventory. Publish no PIN, token or hmac-secret.

Campaign HID script

This script body was exercised against the repository’s simulated CTAP stack to verify python-fido2 calls; that provides no hardware result. It creates two test credentials (one per PIN protocol) and changes largeBlobs. Copy it into a private campaign file and run with uv run python. The device FIDO PIN must already be configured.

import getpass
import hashlib
import os

from fido2.ctap import CtapError
from fido2.ctap2 import CredentialManagement, Ctap2
from fido2.ctap2.blob import LargeBlobs
from fido2.ctap2.pin import ClientPin, PinProtocolV1, PinProtocolV2
from fido2.hid import CtapHidDevice

# Connect only the disposable USB authenticator under test.
devices = list(CtapHidDevice.list_devices())
if len(devices) != 1:
    raise SystemExit("Connect only the test feuristic, then retry")
device = devices[0]
try:
    ctap = Ctap2(device)
    info = ctap.get_info()
    print("Versions:", info.versions, "Options:", info.options)
    print("Extensions:", info.extensions)
    assert "FIDO_2_1" in info.versions
    for option in ("clientPin", "credMgmt", "largeBlobs", "pinUvAuthToken"):
        assert info.options.get(option), option
    assert {"credProtect", "hmac-secret", "largeBlobKey"} <= set(info.extensions)
    pin = getpass.getpass("Test device FIDO PIN: ")
    rp = "acceptance.example"
    cdh = hashlib.sha256(b"feuristic R03 test").digest()
    perm = ClientPin.PERMISSION
    for protocol_type in (PinProtocolV1, PinProtocolV2):
        protocol = protocol_type()
        client = ClientPin(ctap, protocol)

        def authorization(permission, rp_id=None):
            token = client.get_pin_token(pin, permission, rp_id)
            return protocol.authenticate(token, cdh)

        att = ctap.make_credential(
            cdh,
            {"id": rp},
            {"id": os.urandom(32), "name": "R03-test"},
            [{"type": "public-key", "alg": -7}],
            extensions={"credProtect": 3, "hmac-secret": True, "largeBlobKey": True},
            options={"rk": True},
            pin_uv_param=authorization(perm.MAKE_CREDENTIAL, rp),
            pin_uv_protocol=protocol.VERSION,
        )
        credential = att.auth_data.credential_data
        allow = [{"type": "public-key", "id": credential.credential_id}]
        assert att.auth_data.extensions["credProtect"] == 3
        try:
            ctap.get_assertion(rp, cdh, allow)
        except CtapError as error:
            assert error.code == CtapError.ERR.NO_CREDENTIALS, error
        else:
            raise AssertionError("credProtect=3 released an assertion without UV")

        def challenge(salts):
            auth = authorization(perm.GET_ASSERTION, rp)
            agreement, shared = client._get_shared_secret()
            encrypted = protocol.encrypt(shared, salts)
            extension = {
                1: agreement,
                2: encrypted,
                3: protocol.authenticate(shared, encrypted),
                4: protocol.VERSION,
            }
            assertion = ctap.get_assertion(
                rp, cdh, allow,
                extensions={"hmac-secret": extension},
                pin_uv_param=auth, pin_uv_protocol=protocol.VERSION,
            )
            credential.public_key.verify(bytes(assertion.auth_data) + cdh, assertion.signature)
            return protocol.decrypt(shared, assertion.auth_data.extensions["hmac-secret"])

        first = challenge(b"A" * 32)
        assert first == challenge(b"A" * 32)
        assert first != challenge(b"B" * 32)
        token = client.get_pin_token(pin, perm.LARGE_BLOB_WRITE)
        blobs = LargeBlobs(ctap, protocol, token)
        data = b"feuristic R03 largeBlob"
        blobs.put_blob(att.large_blob_key, data)
        assert blobs.get_blob(att.large_blob_key) == data
        token = client.get_pin_token(pin, perm.CREDENTIAL_MGMT)
        management = CredentialManagement(ctap, protocol, token)
        assert management.enumerate_rps()
        assert management.enumerate_creds(hashlib.sha256(rp.encode()).digest())
        print("PIN protocol", protocol.VERSION, ": PASS; metadata", management.get_metadata())
    print("R03 script completed; browser/PIN-change checks remain separate")
finally:
    device.close()