diff --git a/AGENTS.md b/AGENTS.md index 71c9b01..fa9a476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,67 @@ # AGENTS.md -- zig-ctap2 -## Capabilities +## Persona -- CBOR encoding/decoding (CTAP2 subset) -- CTAP2 command encoding and response parsing -- CTAPHID USB HID transport framing -- Platform USB HID device enumeration and I/O -- CTAP2 Client PIN protocol v2 (ECDH + AES + HMAC) +You are working on zig-ctap2, a portable CTAP2/FIDO2 library written in Zig with a C FFI surface. It communicates directly with USB security keys (YubiKey, SoloKeys, etc.) over HID -- IOKit on macOS, hidraw on Linux. No Apple entitlements or platform authentication frameworks needed. Part of the [Tinyland Zig Libraries](https://libs.tinyland.dev). + +## Stack + +- **Language:** Zig 0.14.1+ +- **Output:** Static C library (`libctap2.a`) + Zig module +- **Dependencies:** None (pure Zig `std.crypto` for ECDH/AES/SHA/HMAC) +- **Header:** `include/ctap2.h` (17 C FFI functions) +- **Platform I/O:** IOKit + CoreFoundation (macOS), hidraw (Linux) +- **Tests:** Unit tests per module + property-based tests (1000 iterations) + hardware integration tests (YubiKey) +- **Docs:** Zig autodoc (`zig build docs`) + +## Structure + +``` +src/ffi.zig C FFI exports (17 functions) +src/cbor.zig Minimal CBOR encoder/decoder (CTAP2 subset) +src/ctap2.zig CTAP2 command encoding and response parsing +src/ctaphid.zig CTAPHID transport framing (64-byte packets) +src/pin.zig Client PIN protocol v2 (ECDH P-256 + AES-256-CBC + HMAC-SHA-256) +src/hid.zig Platform-selected HID transport +src/hid_macos.zig macOS USB HID via IOKit +src/hid_linux.zig Linux USB HID via hidraw +include/ctap2.h C header +tests/pbt_*.zig Property-based tests +tests/hardware_test.zig Hardware integration tests (YubiKey) +examples/ C usage examples +``` + +## Commands + +```bash +zig build # static library -> zig-out/lib/ +zig build -Doptimize=ReleaseFast # optimized build +zig build test # unit tests (no hardware) +zig build test-pbt # property-based tests (1000 iterations) +zig build test-hardware # hardware tests (needs YubiKey) +zig build docs # generate API documentation +``` + +## Style + +- Format with `zig fmt` +- All `pub` and `export` functions require `///` doc comments +- C FFI exports live exclusively in `src/ffi.zig` +- Protocol implementations in `src/.zig` (cbor, ctap2, ctaphid, pin) +- Platform HID transports in `src/hid_macos.zig` and `src/hid_linux.zig` +- Property-based tests in `tests/pbt_.zig` +- Error convention: negative = library error, 0 = success, positive = CTAP2 device status byte + +## Boundaries + +- **Do not** add browser or WebKit dependencies -- this is a USB HID library +- **Do not** bypass USB HID framing (CTAPHID packet structure is required by spec) +- **Do not** introduce OpenSSL, BoringSSL, CommonCrypto, or any C crypto dependency +- **Do not** add allocator-dependent APIs to the FFI surface (all buffers are caller-provided) +- **Do not** link IOKit/CoreFoundation in the static library (resolved at final link by the consumer) +- **Do** keep the library stateless and thread-safe +- **Do** ensure all new commands have both unit tests and property-based tests where applicable +- **Do** test against real hardware (YubiKey) for any transport-layer changes ## C FFI Exports (ctap2.h) @@ -21,12 +76,13 @@ | `ctap2_parse_make_credential_response` | `int` | Parse a raw MakeCredential response (status byte + CBOR attestation object). | | `ctap2_parse_get_assertion_response` | `int` | Parse a raw GetAssertion response (status byte + CBOR). fallback_cred_id: credential ID to use when the response omits key 1 (CTAP2 spec: single-entry allowList). Pass NULL/0 if no fallback. | | `ctap2_get_pin_retries` | `int` | Get PIN retry count from the authenticator. out_retries: receives the number of remaining PIN retries. Returns CTAP2_OK on success, or negative error code. | -| `ctap2_get_pin_token` | `int` | Get a PIN token for authentication. Performs the full PIN protocol v2 handshake (key agreement + ECDH + PIN encryption) and returns a decrypted 32-byte PIN token. pin: null-terminated UTF-8 PIN string. out_pin_token: receives the 32-byte decrypted PIN token. out_pin_token_len: must be >= 32. Returns CTAP2_OK on success, positive CTAP2 status byte on device error (e.g. 0x31 = wrong PIN), or negative error code. | -| `ctap2_make_credential_with_pin` | `int` | Same as the parsed functions above, but with optional PIN auth. Pass pin_token=NULL, pin_protocol=0 for no PIN authentication. Pass pin_token=<32-byte token from ctap2_get_pin_token>, pin_protocol=2 to include pinAuth in the CTAP2 command. | -| `ctap2_get_assertion_with_pin` | `int` | | -| `ctap2_make_credential_with_keepalive` | `int` | | -| `ctap2_get_assertion_with_keepalive` | `int` | | -| `ctap2_debug_last_ioreturn` | `int` | Debug: get the last IOReturn error code from HID operations. | +| `ctap2_get_pin_token` | `int` | Get a PIN token for authentication. Performs the full PIN protocol v2 handshake (key agreement + ECDH + PIN encryption) and returns a decrypted 32-byte PIN token. | +| `ctap2_make_credential_with_pin` | `int` | MakeCredential with optional PIN auth. Pass pin_token=NULL, pin_protocol=0 for no PIN. Pass pin_token=<32-byte token>, pin_protocol=2 for PIN-authenticated. | +| `ctap2_get_assertion_with_pin` | `int` | GetAssertion with optional PIN auth. Same token/protocol convention. | +| `ctap2_make_credential_with_keepalive` | `int` | MakeCredential with keepalive callback (status 1=processing, 2=user presence needed). Returns raw response bytes. | +| `ctap2_get_assertion_with_keepalive` | `int` | GetAssertion with keepalive callback. Returns raw response bytes. | +| `ctap2_status_message` | `const char *` | Map a CTAP2 status byte to a human-readable message string. | +| `ctap2_debug_last_ioreturn` | `int` | Debug: get the last IOReturn error code from HID operations (macOS only). | ## Error Conventions @@ -34,42 +90,28 @@ Defined in `ctap2.h`: | Code | Value | Meaning | |------|-------|---------| -| `CTAP2_OK` | 0 | | -| `CTAP2_ERR_NO_DEVICE` | -1 | | -| `CTAP2_ERR_TIMEOUT` | -2 | | -| `CTAP2_ERR_PROTOCOL` | -3 | | -| `CTAP2_ERR_BUFFER_TOO_SMALL` | -4 | | -| `CTAP2_ERR_OPEN_FAILED` | -5 | | -| `CTAP2_ERR_WRITE_FAILED` | -6 | | -| `CTAP2_ERR_READ_FAILED` | -7 | | -| `CTAP2_ERR_CBOR` | -8 | | -| `CTAP2_ERR_DEVICE` | -9 | | -| `CTAP2_ERR_PIN` | -10 | | +| `CTAP2_OK` | 0 | Success | +| `CTAP2_ERR_NO_DEVICE` | -1 | No FIDO2 device connected | +| `CTAP2_ERR_TIMEOUT` | -2 | Device communication timeout | +| `CTAP2_ERR_PROTOCOL` | -3 | CTAPHID protocol error | +| `CTAP2_ERR_BUFFER_TOO_SMALL` | -4 | Output buffer too small | +| `CTAP2_ERR_OPEN_FAILED` | -5 | Failed to open HID device | +| `CTAP2_ERR_WRITE_FAILED` | -6 | USB write failed | +| `CTAP2_ERR_READ_FAILED` | -7 | USB read failed | +| `CTAP2_ERR_CBOR` | -8 | CBOR encoding/decoding error | +| `CTAP2_ERR_DEVICE` | -9 | CTAP2 device error | +| `CTAP2_ERR_PIN` | -10 | PIN protocol error | +| `CTAP2_ERR_NOT_ACCESSIBLE` | -11 | Devices found but not openable (permissions) | ## Platform Requirements **macOS:** -- Frameworks: CoreFoundation, IOKit +- Frameworks: CoreFoundation, IOKit (linked at final build, not in the static lib) +- Entitlement: `com.apple.security.device.usb` (hardened runtime) +- Permission: Input Monitoring (System Settings > Privacy & Security) - Targets: arm64, x86_64 **Linux:** -- Libraries: hidraw (kernel) +- hidraw kernel support (default on most distributions) +- Read/write access to `/dev/hidraw*` devices (udev rule or group membership) - Targets: arm64, x86_64 - -## Build - -```bash -zig build # static library -> zig-out/lib/ -zig build -Doptimize=ReleaseFast # optimized build -zig build test # unit tests -zig build test-pbt # property-based tests -``` - -## Linking - -The library builds as a static archive. Include the header -from `include/` and link `zig-out/lib/libctap2.a`. - -At final link time, the consuming application must link platform frameworks/libraries. -The static library intentionally does not link them to support cross-compilation. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0af54f3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing to zig-ctap2 + +## Installation + +### Zig Package Manager (recommended) + +```bash +zig fetch --save git+https://github.com/Jesssullivan/zig-ctap2.git +``` + +Then in your `build.zig`: + +```zig +const dep = b.dependency("zig-ctap2", .{ .target = target, .optimize = optimize }); +exe.root_module.addImport("zig-ctap2", dep.module("zig-ctap2")); +``` + +### Git Submodule (C FFI consumers) + +```bash +git submodule add https://github.com/Jesssullivan/zig-ctap2.git vendor/ctap2 +cd vendor/ctap2 && zig build -Doptimize=ReleaseFast +``` + +Link `-lctap2` and include `ctap2.h`. At final link time, add platform frameworks: +- **macOS:** `-framework IOKit -framework CoreFoundation` +- **Linux:** no extra libraries needed (uses hidraw via kernel) + +## Development + +### Prerequisites + +- Zig 0.14.1+ +- **macOS:** IOKit and CoreFoundation (available by default) +- **Linux:** hidraw kernel support (enabled by default on most distributions) +- **Hardware tests:** USB security key (YubiKey 5C NFC recommended) + +### Build & Test + +```bash +zig build # static library (libctap2.a) +zig build test # unit tests (no hardware needed) +zig build test-pbt # property-based tests (1000 iterations) +zig build test-hardware # hardware tests (requires YubiKey + YUBIKEY_TESTS=1) +zig build docs # generate API documentation +``` + +### Code Style + +- `zig fmt` for formatting +- All `pub` and `export` functions need `///` doc comments +- C FFI exports go in `src/ffi.zig` +- Protocol implementations in `src/.zig` (cbor, ctap2, ctaphid, pin) +- Platform HID transports in `src/hid_macos.zig` and `src/hid_linux.zig` +- Property-based tests in `tests/pbt_.zig` + +### Adding a new CTAP2 command + +1. Add the encoder/parser in `src/ctap2.zig` (or `src/pin.zig` for PIN commands) +2. Add `export fn ctap2_` wrapper in `src/ffi.zig` +3. Add the C declaration to `include/ctap2.h` +4. Add unit tests in the module +5. Wire the test file into `build.zig` +6. Update `AGENTS.md` FFI table + +### Hardware Testing + +Hardware tests require a physical FIDO2 security key: + +```bash +# Connect a YubiKey, then: +YUBIKEY_TESTS=1 zig build test-hardware +``` + +Tests run getInfo, enumerate devices, and verify CTAPHID framing against real hardware. They do not create or consume credentials. + +## Filing Issues + +Open an issue at [github.com/Jesssullivan/zig-ctap2/issues](https://github.com/Jesssullivan/zig-ctap2/issues). + +## License + +Dual-licensed under [Zlib](https://opensource.org/licenses/Zlib) and [MIT](https://opensource.org/licenses/MIT). diff --git a/LLMS.txt b/LLMS.txt deleted file mode 100644 index 6a829c1..0000000 --- a/LLMS.txt +++ /dev/null @@ -1,139 +0,0 @@ -# zig-ctap2 - -> Portable CTAP2/FIDO2 library in Zig — direct USB HID communication with security keys (YubiKey, SoloKeys, etc.), no Apple entitlements or platform authentication frameworks needed. - -## Source Structure - -- `src/cbor.zig` - Minimal CBOR encoder/decoder for CTAP2. -- `src/ctap2.zig` - CTAP2 command encoding and response parsing. -- `src/ctaphid.zig` - CTAPHID transport framing for FIDO2 USB HID communication. -- `src/ffi.zig` - C FFI exports for libctap2. -- `src/hid.zig` - Platform-selected USB HID transport for FIDO2 devices. -- `src/hid_linux.zig` - Linux USB HID transport via hidraw. -- `src/hid_macos.zig` - macOS USB HID transport via IOKit. -- `src/pin.zig` - CTAP2 Client PIN protocol v2 (authenticatorClientPIN comm... -- `include/ctap2.h` - C header -- 17 functions - -## C API (ctap2.h) - -- `ctap2_device_count`: Get the number of connected FIDO2 devices. -- `ctap2_make_credential`: Perform authenticatorMakeCredential. client_data_hash must be 32 bytes (SHA-256 of clientDataJSON). Returns bytes written to result_buf, or negative error code. result_buf contains the raw CTAP2 response (status byte + CBOR). -- `ctap2_get_assertion`: Perform authenticatorGetAssertion. client_data_hash must be 32 bytes. allow_list_ids is an array of pointers to credential IDs. allow_list_id_lens is an array of lengths for each credential ID. Returns bytes written to result_buf, or negative error code. -- `ctap2_get_info`: Perform authenticatorGetInfo. Returns bytes written to result_buf, or negative error code. result_buf contains the raw CTAP2 response (status byte + CBOR). -- `ctap2_make_credential_parsed`: Combined: send makeCredential + parse response. Output buffers should be at least 1024 bytes for credential_id, and 4096 bytes for attestation_object. -- `ctap2_get_assertion_parsed`: Combined: send getAssertion + parse response. Output buffers should be at least 1024 bytes each. allow_list_ids/allow_list_id_lens can be NULL when allow_list_count is 0. -- `ctap2_parse_make_credential_response`: Parse a raw MakeCredential response (status byte + CBOR attestation object). -- `ctap2_parse_get_assertion_response`: Parse a raw GetAssertion response (status byte + CBOR). fallback_cred_id: credential ID to use when the response omits key 1 (CTAP2 spec: single-entry allowList). Pass NULL/0 if no fallback. -- `ctap2_get_pin_retries`: Get PIN retry count from the authenticator. out_retries: receives the number of remaining PIN retries. Returns CTAP2_OK on success, or negative error code. -- `ctap2_get_pin_token`: Get a PIN token for authentication. Performs the full PIN protocol v2 handshake (key agreement + ECDH + PIN encryption) and returns a decrypted 32-byte PIN token. pin: null-terminated UTF-8 PIN string. out_pin_token: receives the 32-byte decrypted PIN token. out_pin_token_len: must be >= 32. Returns CTAP2_OK on success, positive CTAP2 status byte on device error (e.g. 0x31 = wrong PIN), or negative error code. -- `ctap2_make_credential_with_pin`: Same as the parsed functions above, but with optional PIN auth. Pass pin_token=NULL, pin_protocol=0 for no PIN authentication. Pass pin_token=<32-byte token from ctap2_get_pin_token>, pin_protocol=2 to include pinAuth in the CTAP2 command. -- `ctap2_get_assertion_with_pin` -- `ctap2_make_credential_with_keepalive` -- `ctap2_get_assertion_with_keepalive` -- `ctap2_debug_last_ioreturn`: Debug: get the last IOReturn error code from HID operations. - -## Zig API - -### cbor.zig -- `Value` (union) -- `MapEntry` (struct) -- `Encoder` (struct) -- `Header` (struct) -- `Decoder` (struct) -- `init` -- `written` -- `encodeUint`: Encode an unsigned integer. -- `encodeNegInt`: Encode a negative integer (CBOR stores as -1 - n). -- `encodeByteString`: Encode a byte string. -- `encodeTextString`: Encode a text string. -- `beginArray`: Begin an array of known length. -- `beginMap`: Begin a map of known length. -- `encodeBool`: Encode a boolean. -- `encodeNull`: Encode null. -- `init` -- `remaining` -- `decodeUint`: Decode a single unsigned integer. -- `decodeByteString`: Decode a byte string, returning a slice into the source data. -- `decodeTextString`: Decode a text string, returning a slice into the source data. -- `decodeArrayHeader`: Decode an array header, returning the element count. -- `decodeMapHeader`: Decode a map header, returning the entry count. -- `peekMajorType`: Peek at the major type of the next value without consuming it. -- `skipValue`: Skip a single CBOR value (including nested structures). -- `decodeRawHeader`: Decode a header and return the raw major type + arg for flexible handling. - -### ctap2.zig -- `CommandCode` (enum) -- `StatusCode` (enum) -- `MakeCredentialResult` (struct) -- `GetAssertionResult` (struct) -- `statusMessage`: Map a CTAP2 status byte to a human-readable message string. -- `encodeMakeCredential`: Encode a makeCredential request into CBOR. -- `encodeGetAssertion`: Encode a getAssertion request into CBOR. -- `encodeGetInfo`: Encode a getInfo request. -- `parseMakeCredentialResponse`: Parse a raw CTAP2 authenticatorMakeCredential response. The response format is: status_byte(1) + CBOR_map The CBOR map has integer keys: 1 = fmt (text string) 2 = authData (byte string) 3 = attStmt (map) From authData we extract the credential ID: rpIdHash(32) + flags(1) + signCount(4) + [aaguid(16) + credIdLen(2) + credentialId(credIdLen) + ...] -- `parseGetAssertionResponse`: Parse a raw CTAP2 authenticatorGetAssertion response. The response format is: status_byte(1) + CBOR_map The CBOR map has integer keys: 1 = credential (map with "id" byte string) — optional per spec 2 = authData (byte string) 3 = signature (byte string) 4 = user (map with "id" byte string) — optional Per CTAP2 spec: key 1 (credential) is omitted when the allowList in the request had exactly one entry. In that case, use the fallback credential ID. - -### ctaphid.zig -- `Command` (enum) -- `KeepaliveStatus` (enum) -- `InitHeader` (struct) -- `InitResponse` (struct) -- `buildInitPacket`: Build an initialization packet. -- `buildContPacket`: Build a continuation packet. -- `fragmentMessage`: Fragment a message into CTAPHID packets. Returns the number of packets written to `out`. -- `parseInitPacket` -- `reassembleMessage`: Reassemble a complete message from init + continuation packets. `read_fn` is called to get each subsequent packet. -- `parseInitResponse`: Parse a CTAPHID_INIT response payload. - -### hid_linux.zig -- `Device` (struct) -- `write`: Write a 64-byte packet to the device. -- `read`: Read a 64-byte packet from the device with timeout. -- `close`: Close the device. -- `enumerate`: Enumerate connected FIDO2 USB HID devices. -- `openFirst`: Find and open the first available FIDO2 device. - -### hid_macos.zig -- `Device` (struct) -- `write` -- `read` -- `close` -- `enumerate` -- `openFirst` - -### pin.zig -- `SubCommand` (enum) -- `PINRetriesResult` (struct) -- `CoseKey` (struct) -- `EphemeralKeyPair` (struct) -- `SharedSecret` (struct) -- `PINTokenResult` (struct) -- `generateKeyPair`: Generate an ephemeral ECDH P-256 key pair for key agreement. -- `deriveSharedSecret`: Perform ECDH: multiply their public point by our private scalar. Returns SHA-256 of the x-coordinate of the shared point. -- `computeHmac`: Compute HMAC-SHA-256(key, message). -- `computePinAuth`: Compute pinAuth: first 16 bytes of HMAC-SHA-256(pinToken, message). Used for authenticating commands with a PIN token. -- `aes256CbcEncrypt`: AES-256-CBC encrypt (with zero IV, per CTAP2 PIN protocol v2 spec). Input must be a multiple of 16 bytes. Returns the ciphertext (same length as input). -- `aes256CbcDecrypt`: AES-256-CBC decrypt (with zero IV, per CTAP2 PIN protocol v2 spec). Input must be a multiple of 16 bytes. Returns the plaintext (same length as input). -- `encodeGetPINRetries`: Encode a getPINRetries request. Request: {1: pinUvAuthProtocol(2), 2: subCommand(1)} -- `encodeGetKeyAgreement`: Encode a getKeyAgreement request. Request: {1: pinUvAuthProtocol(2), 2: subCommand(2)} -- `encodeGetPINToken`: Encode a getPinUvAuthTokenUsingPinWithPermissions request (subCommand 0x09). Request: {1: protocol, 2: subCommand(9), 3: keyAgreement(COSE_Key), 6: pinHashEnc} -- `parsePINRetriesResponse`: Parse a getPINRetries response. Response CBOR (after status byte): {3: pinRetries, 4: powerCycleState(optional)} -- `parseKeyAgreementResponse`: Parse a getKeyAgreement response. Response CBOR (after status byte): {1: keyAgreement(COSE_Key)} COSE_Key: {1: kty(2), 3: alg(-25), -1: crv(1), -2: x(32 bytes), -3: y(32 bytes)} -- `parsePINTokenResponse`: Parse a getPINToken response. Response CBOR (after status byte): {2: pinUvAuthToken(encrypted bytes)} -- `encryptPINHash`: Prepare the encrypted PIN hash for a getPINToken request. Takes a UTF-8 PIN string, hashes it with SHA-256, takes the first 16 bytes, pads to 64 bytes, and encrypts with AES-256-CBC using the shared secret. Returns the 64-byte encrypted PIN hash. -- `encodeMakeCredentialWithPIN`: Encode a makeCredential command with pinAuth and pinUvAuthProtocol. This adds parameters 8 (pinUvAuthProtocol) and 9 (pinAuth) to the command. pinAuth = LEFT(HMAC-SHA-256(pinToken, clientDataHash), 16) -- `encodeGetAssertionWithPIN`: Encode a getAssertion command with pinAuth and pinUvAuthProtocol. - -## Build - -``` -zig build # build static library -zig build test # run unit tests -zig build test-pbt # property-based tests -``` - -## Platform Support - -- macOS (arm64, x86_64) -- Linux (arm64, x86_64) - diff --git a/README.md b/README.md index fd3ebe1..6facd59 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,36 @@ Apple's `ASAuthorizationController` requires a restricted entitlement + provisio ## Requirements -- Zig 0.15.2+ +- Zig 0.14.1+ - macOS 13+ (IOKit) or Linux (hidraw) - USB security key (tested with YubiKey 5C NFC) +## Installation + +### Zig Package Manager (recommended) + +```bash +zig fetch --save git+https://github.com/Jesssullivan/zig-ctap2.git +``` + +Then in your `build.zig`: + +```zig +const dep = b.dependency("zig-ctap2", .{ .target = target, .optimize = optimize }); +exe.root_module.addImport("zig-ctap2", dep.module("zig-ctap2")); +``` + +### Git Submodule (C FFI consumers) + +```bash +git submodule add https://github.com/Jesssullivan/zig-ctap2.git vendor/ctap2 +cd vendor/ctap2 && zig build -Doptimize=ReleaseFast +``` + +Link `-lctap2` and include `ctap2.h`. At final link time, add platform frameworks: +- **macOS:** `-framework IOKit -framework CoreFoundation` +- **Linux:** no extra libraries needed (uses hidraw via kernel) + ## Build ```bash diff --git a/build.zig b/build.zig index a70492b..f7bb9c0 100644 --- a/build.zig +++ b/build.zig @@ -4,7 +4,14 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - // Static library for C FFI + // Zig module for package manager consumers + _ = b.addModule("zig-ctap2", .{ + .root_source_file = b.path("src/ffi.zig"), + .target = target, + .optimize = optimize, + }); + + // Static library for C FFI consumers const lib = b.addLibrary(.{ .name = "ctap2", .root_module = b.createModule(.{ @@ -36,7 +43,6 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path(test_file), .target = target, .optimize = optimize, - }), }); test_step.dependOn(&b.addRunArtifact(t).step); @@ -116,4 +122,22 @@ pub fn build(b: *std.Build) void { hw_test.root_module.linkFramework("CoreFoundation", .{}); hw_step.dependOn(&b.addRunArtifact(hw_test).step); + + // Documentation generation + const docs_step = b.step("docs", "Generate API documentation"); + const docs_lib = b.addLibrary(.{ + .name = "ctap2", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/ffi.zig"), + .target = target, + .optimize = optimize, + }), + .linkage = .static, + }); + const install_docs = b.addInstallDirectory(.{ + .source_dir = docs_lib.getEmittedDocs(), + .install_dir = .prefix, + .install_subdir = "docs", + }); + docs_step.dependOn(&install_docs.step); } diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..fb3c152 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,16 @@ +.{ + .name = .zig_ctap2, + .version = "0.4.0", + .fingerprint = 0x6bdb9d0bc4e1aa2a, + .minimum_zig_version = "0.14.1", + .dependencies = .{}, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "include", + "tests", + "LICENSE", + "README.md", + }, +} diff --git a/examples/get_info.c b/examples/get_info.c new file mode 100644 index 0000000..3c59c1e --- /dev/null +++ b/examples/get_info.c @@ -0,0 +1,75 @@ +/* + * zig-ctap2 example: Device enumeration and CTAP2 getInfo. + * + * Enumerates connected FIDO2 devices and sends an authenticatorGetInfo + * command to the first one. No user interaction needed -- getInfo just + * queries the device's capabilities. + * + * Build: + * cd .. && zig build -Doptimize=ReleaseFast + * # macOS: + * cc examples/get_info.c -Iinclude -Lzig-out/lib -lctap2 \ + * -framework IOKit -framework CoreFoundation -o examples/get_info + * # Linux: + * cc examples/get_info.c -Iinclude -Lzig-out/lib -lctap2 -o examples/get_info + * + * ./examples/get_info + */ + +#include "ctap2.h" +#include +#include + +static void print_hex(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) + printf("%02x", data[i]); +} + +int main(void) { + /* Step 1: Enumerate devices */ + int count = ctap2_device_count(); + printf("FIDO2 devices found: %d\n", count); + + if (count <= 0) { + printf("No FIDO2 device connected. Plug in a YubiKey and retry.\n"); + return 1; + } + + /* Step 2: Send authenticatorGetInfo */ + uint8_t buf[4096]; + int result = ctap2_get_info(buf, sizeof(buf)); + + if (result < 0) { + printf("ctap2_get_info failed: %d\n", result); + return 1; + } + + printf("getInfo response: %d bytes\n", result); + + /* First byte is the CTAP2 status (0x00 = success) */ + printf("Status: 0x%02x (%s)\n", buf[0], ctap2_status_message(buf[0])); + + if (buf[0] != 0x00) { + printf("Device returned error status.\n"); + return 1; + } + + /* Dump raw CBOR response (status byte + CBOR map) */ + printf("Raw response hex:\n "); + print_hex(buf, (size_t)result); + printf("\n"); + + /* + * The CBOR map (starting at buf[1]) contains device info: + * key 1: versions (array of strings, e.g. "FIDO_2_0", "FIDO_2_1") + * key 2: extensions (array of strings) + * key 3: aaguid (16-byte identifier) + * key 4: options (map of capability booleans) + * ... + * + * Full CBOR parsing is left to the application. + * See src/cbor.zig for the decoder used internally. + */ + + return 0; +} diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..8d037b6 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,217 @@ +# zig-ctap2 -- Complete API Reference + +> Portable CTAP2/FIDO2 library in Zig -- direct USB HID communication with security keys (YubiKey, SoloKeys, etc.), no Apple entitlements or platform authentication frameworks needed. + +Part of the [Tinyland Zig Libraries](https://libs.tinyland.dev). License: Zlib OR MIT. + +## Installation + +``` +zig fetch --save git+https://github.com/Jesssullivan/zig-ctap2.git +``` + +Then in build.zig: + +```zig +const dep = b.dependency("zig-ctap2", .{ .target = target, .optimize = optimize }); +exe.root_module.addImport("zig-ctap2", dep.module("zig-ctap2")); +``` + +Or as a git submodule for C FFI consumers: + +``` +git submodule add https://github.com/Jesssullivan/zig-ctap2.git vendor/ctap2 +cd vendor/ctap2 && zig build -Doptimize=ReleaseFast +``` + +Link `-lctap2`. Include `#include "ctap2.h"`. +macOS: also link `-framework IOKit -framework CoreFoundation`. + +## Source Structure + +- `src/ffi.zig` - C FFI exports (17 functions) +- `src/cbor.zig` - Minimal CBOR encoder/decoder for CTAP2 subset +- `src/ctap2.zig` - CTAP2 command encoding and response parsing +- `src/ctaphid.zig` - CTAPHID transport framing (64-byte USB HID packets) +- `src/pin.zig` - CTAP2 Client PIN protocol v2 (ECDH P-256 + AES-256-CBC + HMAC-SHA-256) +- `src/hid.zig` - Platform-selected USB HID transport +- `src/hid_macos.zig` - macOS USB HID via IOKit +- `src/hid_linux.zig` - Linux USB HID via hidraw +- `include/ctap2.h` - C header (17 functions + error codes) +- `tests/pbt_*.zig` - Property-based tests (1000-iteration roundtrips) +- `tests/hardware_test.zig` - Hardware integration tests (YubiKey) +- `examples/get_info.c` - C usage example + +## C API Reference (ctap2.h) + +All functions are blocking (with timeouts) and thread-safe. Buffers are caller-provided. + +### Error Codes + +- `CTAP2_OK` (0): Success +- `CTAP2_ERR_NO_DEVICE` (-1): No FIDO2 device connected +- `CTAP2_ERR_TIMEOUT` (-2): Device communication timeout +- `CTAP2_ERR_PROTOCOL` (-3): CTAPHID protocol error +- `CTAP2_ERR_BUFFER_TOO_SMALL` (-4): Output buffer too small +- `CTAP2_ERR_OPEN_FAILED` (-5): Failed to open HID device +- `CTAP2_ERR_WRITE_FAILED` (-6): USB write failed +- `CTAP2_ERR_READ_FAILED` (-7): USB read failed +- `CTAP2_ERR_CBOR` (-8): CBOR encoding/decoding error +- `CTAP2_ERR_DEVICE` (-9): CTAP2 device error +- `CTAP2_ERR_PIN` (-10): PIN protocol error +- `CTAP2_ERR_NOT_ACCESSIBLE` (-11): Devices found but not openable (permissions) + +Return value convention: negative = library error, 0 = success, positive = CTAP2 device status byte (e.g. 0x27 = user denied, 0x31 = wrong PIN). + +### Device Enumeration + +- `int ctap2_device_count(void)`: Get the number of connected FIDO2 USB HID devices. Returns count (0 if none). + +### Raw Response Functions + +These return the raw CTAP2 response (status byte + CBOR) in result_buf. The caller is responsible for parsing the CBOR response. + +- `int ctap2_make_credential(const uint8_t *client_data_hash, const char *rp_id, const char *rp_name, const uint8_t *user_id, size_t user_id_len, const char *user_name, const char *user_display_name, const int32_t *alg_ids, size_t alg_count, bool resident_key, uint8_t *result_buf, size_t result_buf_len)`: Perform authenticatorMakeCredential. client_data_hash must be 32 bytes (SHA-256 of clientDataJSON). Returns bytes written to result_buf, or negative error code. + +- `int ctap2_get_assertion(const uint8_t *client_data_hash, const char *rp_id, const uint8_t *const *allow_list_ids, const size_t *allow_list_id_lens, size_t allow_list_count, uint8_t *result_buf, size_t result_buf_len)`: Perform authenticatorGetAssertion. Returns bytes written to result_buf, or negative error code. + +- `int ctap2_get_info(uint8_t *result_buf, size_t result_buf_len)`: Perform authenticatorGetInfo. Returns bytes written to result_buf, or negative error code. + +### Parsed Response Functions + +These perform the CTAP2 command AND parse the CBOR, returning structured fields. + +- `int ctap2_make_credential_parsed(const uint8_t *client_data_hash, const char *rp_id, const char *rp_name, const uint8_t *user_id, size_t user_id_len, const char *user_name, const char *user_display_name, const int32_t *alg_ids, size_t alg_count, bool resident_key, uint8_t *out_credential_id, size_t *out_credential_id_len, uint8_t *out_attestation_object, size_t *out_attestation_object_len)`: Combined send + parse. Output buffers: credential_id >= 1024 bytes, attestation_object >= 4096 bytes. + +- `int ctap2_get_assertion_parsed(const uint8_t *client_data_hash, const char *rp_id, const uint8_t *const *allow_list_ids, const size_t *allow_list_id_lens, size_t allow_list_count, uint8_t *out_credential_id, size_t *out_credential_id_len, uint8_t *out_auth_data, size_t *out_auth_data_len, uint8_t *out_signature, size_t *out_signature_len, uint8_t *out_user_handle, size_t *out_user_handle_len)`: Combined send + parse. Output buffers >= 1024 bytes each. allow_list_ids/lens can be NULL when count is 0. + +### Pure Parsing Functions (no I/O) + +- `int ctap2_parse_make_credential_response(const uint8_t *response_data, size_t response_len, uint8_t *out_credential_id, size_t *out_credential_id_len, uint8_t *out_attestation_object, size_t *out_attestation_object_len)`: Parse a raw MakeCredential response (status byte + CBOR attestation object). + +- `int ctap2_parse_get_assertion_response(const uint8_t *response_data, size_t response_len, const uint8_t *fallback_cred_id, size_t fallback_cred_id_len, uint8_t *out_credential_id, size_t *out_credential_id_len, uint8_t *out_auth_data, size_t *out_auth_data_len, uint8_t *out_signature, size_t *out_signature_len, uint8_t *out_user_handle, size_t *out_user_handle_len)`: Parse a raw GetAssertion response. fallback_cred_id: credential ID to use when response omits key 1 (CTAP2 spec: single-entry allowList). Pass NULL/0 if no fallback. + +### PIN Protocol Functions + +- `int ctap2_get_pin_retries(int *out_retries)`: Get PIN retry count. out_retries receives remaining retries. + +- `int ctap2_get_pin_token(const char *pin, uint8_t *out_pin_token, size_t out_pin_token_len)`: Full PIN protocol v2 handshake: getKeyAgreement, ECDH P-256, AES-256-CBC PIN encryption, getPinUvAuthTokenUsingPinWithPermissions. pin: null-terminated UTF-8. out_pin_token: 32 bytes. out_pin_token_len: must be >= 32. + +### PIN-Authenticated Functions + +- `int ctap2_make_credential_with_pin(const uint8_t *client_data_hash, const char *rp_id, const char *rp_name, const uint8_t *user_id, size_t user_id_len, const char *user_name, const char *user_display_name, const int32_t *alg_ids, size_t alg_count, bool resident_key, const uint8_t *pin_token, uint8_t pin_protocol, uint8_t *out_credential_id, size_t *out_credential_id_len, uint8_t *out_attestation_object, size_t *out_attestation_object_len)`: PIN-authenticated registration. pin_token=NULL/pin_protocol=0 for no PIN. pin_token=<32 bytes>/pin_protocol=2 for PIN auth. + +- `int ctap2_get_assertion_with_pin(const uint8_t *client_data_hash, const char *rp_id, const uint8_t *const *allow_list_ids, const size_t *allow_list_id_lens, size_t allow_list_count, const uint8_t *pin_token, uint8_t pin_protocol, uint8_t *out_credential_id, size_t *out_credential_id_len, uint8_t *out_auth_data, size_t *out_auth_data_len, uint8_t *out_signature, size_t *out_signature_len, uint8_t *out_user_handle, size_t *out_user_handle_len)`: PIN-authenticated assertion. + +### Keepalive Callback Functions + +- `typedef void (*ctap2_keepalive_callback_t)(uint8_t status)`: Callback type. Status: 1=processing, 2=user presence needed. + +- `int ctap2_make_credential_with_keepalive(const uint8_t *client_data_hash, const char *rp_id, const char *rp_name, const uint8_t *user_id, size_t user_id_len, const char *user_name, const char *user_display_name, const int32_t *alg_ids, size_t alg_count, bool resident_key, ctap2_keepalive_callback_t keepalive_cb, uint8_t *result_buf, size_t result_buf_len)`: MakeCredential with keepalive callback. Returns raw response bytes. + +- `int ctap2_get_assertion_with_keepalive(const uint8_t *client_data_hash, const char *rp_id, const uint8_t *const *allow_list_ids, const size_t *allow_list_id_lens, size_t allow_list_count, ctap2_keepalive_callback_t keepalive_cb, uint8_t *result_buf, size_t result_buf_len)`: GetAssertion with keepalive callback. Returns raw response bytes. + +### Utility Functions + +- `const char *ctap2_status_message(uint8_t status)`: Map a CTAP2 status byte to a human-readable error message. Returns static string. + +- `int ctap2_debug_last_ioreturn(void)`: Last IOReturn error code from HID write (macOS only). Returns -1 on other platforms. + +## Zig API Reference + +### cbor.zig +- `Value` (union): Decoded CBOR value (uint, neg_int, byte_string, text_string, array, map, boolean, null_val). +- `MapEntry` (struct): key/value pair for CBOR maps. +- `Encoder` (struct): Streaming CBOR encoder into a fixed buffer. + - `init(buf) -> Encoder` + - `written() -> []const u8` + - `encodeUint(value)`: Encode an unsigned integer. + - `encodeNegInt(n)`: Encode a negative integer (CBOR stores as -1 - n). + - `encodeByteString(data)`: Encode a byte string. + - `encodeTextString(text)`: Encode a text string. + - `beginArray(count)`: Begin an array of known length. + - `beginMap(count)`: Begin a map of known length. + - `encodeBool(value)`: Encode a boolean. + - `encodeNull()`: Encode null. +- `Decoder` (struct): Streaming CBOR decoder from a byte slice. + - `init(data) -> Decoder` + - `remaining() -> usize` + - `decodeUint() -> !u64`: Decode an unsigned integer. + - `decodeByteString() -> ![]const u8`: Decode a byte string (slice into source). + - `decodeTextString() -> ![]const u8`: Decode a text string (slice into source). + - `decodeArrayHeader() -> !usize`: Decode array header, return element count. + - `decodeMapHeader() -> !usize`: Decode map header, return entry count. + - `peekMajorType() -> !u3`: Peek at major type without consuming. + - `skipValue()`: Skip a CBOR value including nested structures. + - `decodeRawHeader() -> !Header`: Raw major type + argument. + +### ctap2.zig +- `CommandCode` (enum): CTAP2 command codes (make_credential=0x01, get_assertion=0x02, get_info=0x04, client_pin=0x06). +- `StatusCode` (enum): CTAP2 status codes with human-readable names. +- `MakeCredentialResult` (struct): Parsed result with status, credential_id, attestation_object (slices into response buffer). +- `GetAssertionResult` (struct): Parsed result with status, credential_id, auth_data, signature, user_handle. +- `statusMessage(status: u8) -> [*:0]const u8`: Map status byte to message string. +- `encodeMakeCredential(buf, client_data_hash, rp_id, rp_name, user_id, user_name, user_display_name, alg_ids, resident_key) -> ![]const u8`: Encode makeCredential CBOR command. +- `encodeGetAssertion(buf, rp_id, client_data_hash, allow_list) -> ![]const u8`: Encode getAssertion CBOR command. +- `encodeGetInfo(buf) -> ![]const u8`: Encode getInfo command. +- `parseMakeCredentialResponse(response) -> !MakeCredentialResult`: Parse response (status byte + CBOR map with keys: 1=fmt, 2=authData, 3=attStmt). Extracts credential ID from authData. +- `parseGetAssertionResponse(response, fallback_cred_id) -> !GetAssertionResult`: Parse response (status byte + CBOR map with keys: 1=credential, 2=authData, 3=signature, 4=user). Key 1 omitted for single-entry allowList per spec. + +### ctaphid.zig +- `Command` (enum): CTAPHID commands (init=0x06, cbor=0x10, keepalive=0x3b, error_=0x3f, ...). +- `KeepaliveStatus` (enum): processing=1, upneeded=2. +- `InitHeader` (struct): Parsed init packet header (cid, cmd, payload_len). +- `InitResponse` (struct): Parsed CTAPHID_INIT response (cid, protocol_version, capabilities). +- `buildInitPacket(cid, cmd, len, data) -> Packet`: Build a 64-byte init packet. +- `buildContPacket(cid, seq, data) -> Packet`: Build a 64-byte continuation packet. +- `fragmentMessage(cid, cmd, payload, out) -> !usize`: Fragment message into packets. +- `parseInitPacket(pkt) -> !InitHeader`: Parse init packet header. +- `reassembleMessage(init_pkt, read_fn, buf) -> !usize`: Reassemble from init + continuation packets. +- `parseInitResponse(data) -> !InitResponse`: Parse CTAPHID_INIT response. + +### hid.zig / hid_macos.zig / hid_linux.zig +- `Device` (struct): Platform HID device handle. + - `write(pkt) -> !void`: Write a 64-byte packet. + - `read(timeout_ms) -> !Packet`: Read a 64-byte packet with timeout. + - `close()`: Close the device. +- `enumerate(allocator) -> ![]Device`: Enumerate connected FIDO2 USB HID devices. +- `openFirst(allocator) -> !Device`: Find and open the first available FIDO2 device. + +### pin.zig +- `SubCommand` (enum): PIN protocol sub-commands (getPINRetries=0x01, getKeyAgreement=0x02, ...). +- `PINRetriesResult` (struct): retries count. +- `CoseKey` (struct): ECDH P-256 public key in COSE format (x: [32]u8, y: [32]u8). +- `EphemeralKeyPair` (struct): private_key + public_key for ECDH. +- `SharedSecret` (struct): 32-byte SHA-256(ECDH shared point x-coordinate). +- `PINTokenResult` (struct): Decrypted 32-byte PIN token. +- `generateKeyPair() -> EphemeralKeyPair`: Generate ECDH P-256 ephemeral key pair. +- `deriveSharedSecret(private, peer_key) -> !SharedSecret`: ECDH shared secret derivation. +- `computeHmac(key, message) -> [32]u8`: HMAC-SHA-256. +- `computePinAuth(pin_token, message) -> [16]u8`: First 16 bytes of HMAC-SHA-256(pinToken, message). +- `aes256CbcEncrypt(key, data) -> ![N]u8`: AES-256-CBC encrypt (zero IV, per CTAP2 spec). +- `aes256CbcDecrypt(key, data) -> ![N]u8`: AES-256-CBC decrypt (zero IV, per CTAP2 spec). +- `encodeGetPINRetries(buf) -> ![]const u8`: Encode getPINRetries request. +- `encodeGetKeyAgreement(buf) -> ![]const u8`: Encode getKeyAgreement request. +- `encodeGetPINToken(buf, our_pub, pin_hash_enc) -> ![]const u8`: Encode getPinUvAuthTokenUsingPinWithPermissions. +- `parsePINRetriesResponse(data) -> !PINRetriesResult`: Parse getPINRetries response. +- `parseKeyAgreementResponse(data) -> !CoseKey`: Parse getKeyAgreement response (COSE_Key). +- `parsePINTokenResponse(data, shared_secret) -> !PINTokenResult`: Parse and decrypt PIN token. +- `encryptPINHash(pin, shared_secret) -> ![64]u8`: SHA-256(PIN)[0:16], pad to 64, AES-256-CBC encrypt. +- `encodeMakeCredentialWithPIN(buf, ..., pin_token) -> ![]const u8`: makeCredential + pinAuth/pinUvAuthProtocol. +- `encodeGetAssertionWithPIN(buf, ..., pin_token) -> ![]const u8`: getAssertion + pinAuth/pinUvAuthProtocol. + +## Build + +``` +zig build # static library +zig build -Doptimize=ReleaseFast # optimized build +zig build test # unit tests (no hardware) +zig build test-pbt # property-based tests (1000 iterations) +zig build test-hardware # hardware tests (needs YubiKey + YUBIKEY_TESTS=1) +zig build docs # generate API docs +``` + +## Platform Support + +- macOS (arm64, x86_64) -- IOKit + CoreFoundation. Requires `com.apple.security.device.usb` entitlement and Input Monitoring permission. +- Linux (arm64, x86_64) -- hidraw kernel support. Requires read/write access to `/dev/hidraw*`. diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..f981eaf --- /dev/null +++ b/llms.txt @@ -0,0 +1,50 @@ +# zig-ctap2 + +> Portable CTAP2/FIDO2 library in Zig -- direct USB HID communication with security keys (YubiKey, SoloKeys, etc.), no Apple entitlements or platform authentication frameworks needed. + +Part of the [Tinyland Zig Libraries](https://libs.tinyland.dev). + +## Installation + +``` +zig fetch --save git+https://github.com/Jesssullivan/zig-ctap2.git +``` + +## Full Documentation + +- [API reference](https://libs.tinyland.dev/api/zig-ctap2/): Zig autodoc +- [llms-full.txt](https://raw.githubusercontent.com/Jesssullivan/zig-ctap2/main/llms-full.txt): Complete API dump for LLMs +- [AGENTS.md](https://github.com/Jesssullivan/zig-ctap2/blob/main/AGENTS.md): Agent instructions + +## C API (17 functions) + +- `ctap2_device_count()`: Enumerate connected FIDO2 devices +- `ctap2_get_info(buf, len)`: Query device capabilities (authenticatorGetInfo) +- `ctap2_make_credential(...)`: Register a credential (raw CBOR response) +- `ctap2_get_assertion(...)`: Authenticate with a credential (raw CBOR response) +- `ctap2_make_credential_parsed(...)`: Register + parse (credential_id, attestation_object) +- `ctap2_get_assertion_parsed(...)`: Authenticate + parse (credential_id, auth_data, signature, user_handle) +- `ctap2_parse_make_credential_response(...)`: Parse raw MakeCredential response (no I/O) +- `ctap2_parse_get_assertion_response(...)`: Parse raw GetAssertion response (no I/O) +- `ctap2_get_pin_retries(retries)`: Get remaining PIN retry count +- `ctap2_get_pin_token(pin, token, len)`: Full PIN protocol v2 handshake (ECDH + AES) +- `ctap2_make_credential_with_pin(...)`: PIN-authenticated registration +- `ctap2_get_assertion_with_pin(...)`: PIN-authenticated assertion +- `ctap2_make_credential_with_keepalive(...)`: Registration with keepalive callback +- `ctap2_get_assertion_with_keepalive(...)`: Assertion with keepalive callback +- `ctap2_status_message(status)`: Map status byte to error string +- `ctap2_debug_last_ioreturn()`: Last IOKit error code (macOS debug) + +## Build + +``` +zig build # build static library +zig build test # run unit tests +zig build test-pbt # property-based tests +zig build docs # generate API docs +``` + +## Platform Support + +- macOS (arm64, x86_64) -- IOKit + CoreFoundation +- Linux (arm64, x86_64) -- hidraw diff --git a/src/ffi.zig b/src/ffi.zig index 449b48b..36aed89 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -3,7 +3,6 @@ /// These functions are called from Swift via the bridging header. /// All functions are blocking (with timeouts) and thread-safe. /// Result data is written to caller-provided buffers. - const std = @import("std"); const builtin = @import("builtin"); const cbor = @import("cbor.zig"); @@ -923,6 +922,10 @@ export fn ctap2_get_assertion_with_pin( // a keepalive callback invoked when the device is waiting for // user presence (touch). Status byte: 1=processing, 2=upneeded. +/// Perform authenticatorMakeCredential with a keepalive callback. +/// The callback is invoked when the device sends CTAPHID_KEEPALIVE +/// (status 1 = processing, 2 = user presence needed). +/// Returns bytes written to result_buf, or negative error code. export fn ctap2_make_credential_with_keepalive( client_data_hash: [*]const u8, rp_id: [*:0]const u8, @@ -975,6 +978,10 @@ export fn ctap2_make_credential_with_keepalive( return @intCast(result_len); } +/// Perform authenticatorGetAssertion with a keepalive callback. +/// The callback is invoked when the device sends CTAPHID_KEEPALIVE +/// (status 1 = processing, 2 = user presence needed). +/// Returns bytes written to result_buf, or negative error code. export fn ctap2_get_assertion_with_keepalive( client_data_hash: [*]const u8, rp_id: [*:0]const u8,