From 6c7e6ab5f571d78112f085f4732e0a5af714c32f Mon Sep 17 00:00:00 2001 From: Christopher Koch Date: Wed, 14 Oct 2026 15:07:38 -0400 Subject: [PATCH] Skey: a FIDO2 security key on the Raspberry Pi Pico Firmware for the Pico / Pico 2 that acts as a USB FIDO2 authenticator: CTAP2 and U2F, passkeys, BOOTSEL as the touch button, and clientPIN with PIN/UV auth protocols 1 and 2. A host simulator runs the same core so the python-fido2 test suite exercises it without hardware. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 7 + CMakeLists.txt | 82 +++++ README.md | 147 +++++++++ firmware/button.c | 60 ++++ firmware/button.h | 5 + firmware/main.c | 24 ++ firmware/mbedtls_config.h | 26 ++ firmware/platform_pico.c | 86 ++++++ firmware/tusb_config.h | 20 ++ firmware/usb.c | 147 +++++++++ host/host.h | 10 + host/main.c | 23 ++ host/platform_host.c | 121 ++++++++ src/attestation.h | 10 + src/cbor.c | 215 +++++++++++++ src/cbor.h | 59 ++++ src/cred.c | 48 +++ src/cred.h | 19 ++ src/crypto.c | 161 ++++++++++ src/crypto.h | 36 +++ src/ctap.h | 49 +++ src/ctap1.c | 129 ++++++++ src/ctap2.c | 614 ++++++++++++++++++++++++++++++++++++++ src/ctaphid.c | 266 +++++++++++++++++ src/ctaphid.h | 23 ++ src/pin.c | 395 ++++++++++++++++++++++++ src/pin.h | 26 ++ src/platform.h | 36 +++ src/presence.c | 60 ++++ src/presence.h | 17 ++ src/storage.c | 271 +++++++++++++++++ src/storage.h | 60 ++++ src/util.h | 14 + tests/conftest.py | 106 +++++++ tests/test_ctap1.py | 55 ++++ tests/test_ctap2.py | 266 +++++++++++++++++ tests/test_ctaphid.py | 62 ++++ tests/test_pin.py | 212 +++++++++++++ tests/test_presence.py | 141 +++++++++ tools/gen_attestation.py | 103 +++++++ udev/69-skey-snap.rules | 7 + 41 files changed, 4218 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 firmware/button.c create mode 100644 firmware/button.h create mode 100644 firmware/main.c create mode 100644 firmware/mbedtls_config.h create mode 100644 firmware/platform_pico.c create mode 100644 firmware/tusb_config.h create mode 100644 firmware/usb.c create mode 100644 host/host.h create mode 100644 host/main.c create mode 100644 host/platform_host.c create mode 100644 src/attestation.h create mode 100644 src/cbor.c create mode 100644 src/cbor.h create mode 100644 src/cred.c create mode 100644 src/cred.h create mode 100644 src/crypto.c create mode 100644 src/crypto.h create mode 100644 src/ctap.h create mode 100644 src/ctap1.c create mode 100644 src/ctap2.c create mode 100644 src/ctaphid.c create mode 100644 src/ctaphid.h create mode 100644 src/pin.c create mode 100644 src/pin.h create mode 100644 src/platform.h create mode 100644 src/presence.c create mode 100644 src/presence.h create mode 100644 src/storage.c create mode 100644 src/storage.h create mode 100644 src/util.h create mode 100644 tests/conftest.py create mode 100644 tests/test_ctap1.py create mode 100644 tests/test_ctap2.py create mode 100644 tests/test_ctaphid.py create mode 100644 tests/test_pin.py create mode 100644 tests/test_presence.py create mode 100644 tools/gen_attestation.py create mode 100644 udev/69-skey-snap.rules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e756c9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build*/ +deps/ +# Attestation private key, created on first build. +attestation/ +skey_flash.bin +__pycache__/ +.pytest_cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1a0e271 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,82 @@ +cmake_minimum_required(VERSION 3.13) + +# Two builds share the authenticator core in src/: +# default Pico firmware (pass -DPICO_BOARD=pico2 for a Pico 2) +# -DSKEY_HOST=ON skey_sim, a desktop simulator driven by the tests in tests/ +option(SKEY_HOST "Build the host simulator instead of the Pico firmware" OFF) + +if (SKEY_HOST) + project(skey C) +else() + if (NOT PICO_SDK_PATH) + if (DEFINED ENV{PICO_SDK_PATH}) + set(PICO_SDK_PATH $ENV{PICO_SDK_PATH}) + else() + set(PICO_SDK_PATH ${CMAKE_CURRENT_LIST_DIR}/deps/pico-sdk) + endif() + endif() + include(${PICO_SDK_PATH}/pico_sdk_init.cmake) + project(skey C CXX ASM) + pico_sdk_init() +endif() + +set(CMAKE_C_STANDARD 11) +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +set(GENERATED_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated) +add_custom_command( + OUTPUT ${GENERATED_DIR}/attestation_data.c + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_attestation.py + --key-dir ${CMAKE_CURRENT_SOURCE_DIR}/attestation + --out ${GENERATED_DIR}/attestation_data.c + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tools/gen_attestation.py + COMMENT "Generating attestation certificate") + +set(CORE_SOURCES + src/cbor.c + src/cred.c + src/crypto.c + src/ctap1.c + src/ctap2.c + src/ctaphid.c + src/pin.c + src/presence.c + src/storage.c) +set(WARNINGS -Wall -Wextra) + +if (SKEY_HOST) + option(SKEY_SANITIZE "Build the simulator with ASan and UBSan" ON) + find_path(MBEDTLS_INCLUDE_DIR mbedtls/ecdsa.h) + find_library(MBEDCRYPTO_LIBRARY mbedcrypto) + if (NOT MBEDTLS_INCLUDE_DIR OR NOT MBEDCRYPTO_LIBRARY) + message(FATAL_ERROR "mbedtls not found (apt install libmbedtls-dev)") + endif() + + add_executable(skey_sim host/main.c host/platform_host.c ${CORE_SOURCES} ${GENERATED_DIR}/attestation_data.c) + target_include_directories(skey_sim PRIVATE src host ${MBEDTLS_INCLUDE_DIR}) + target_link_libraries(skey_sim PRIVATE ${MBEDCRYPTO_LIBRARY}) + target_compile_options(skey_sim PRIVATE ${WARNINGS}) + if (SKEY_SANITIZE) + target_compile_options(skey_sim PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(skey_sim PRIVATE -fsanitize=address,undefined) + endif() +else() + set(PICO_SOURCES firmware/button.c firmware/main.c firmware/platform_pico.c firmware/usb.c) + add_executable(skey ${PICO_SOURCES} ${CORE_SOURCES} ${GENERATED_DIR}/attestation_data.c) + target_include_directories(skey PRIVATE src firmware) + target_link_libraries(skey PRIVATE + pico_stdlib + pico_rand + pico_unique_id + hardware_flash + tinyusb_device + pico_mbedtls) + # The deepest path (makeCredential -> mbedtls ECDSA) needs ~2.3 KB of stack, + # more than the SDK's 2 KB default. Reserve all of SCRATCH_Y instead. + target_compile_definitions(skey PRIVATE PICO_STACK_SIZE=0x1000) + # Only our own sources: the SDK and mbedtls compile into this target too. + set_source_files_properties(${PICO_SOURCES} ${CORE_SOURCES} PROPERTIES COMPILE_OPTIONS "${WARNINGS}") + pico_enable_stdio_usb(skey 0) + pico_enable_stdio_uart(skey 0) + pico_add_extra_outputs(skey) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..a872f97 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Skey + +A FIDO2 / WebAuthn security key built on a Raspberry Pi Pico or Pico 2. It +plugs in over USB and works with browsers for passkeys and 2FA, and with +`ssh-keygen -t ecdsa-sk`. No drivers are needed. + +## What works + +- CTAP2: makeCredential, getAssertion, getNextAssertion, getInfo, reset, selection +- U2F (CTAP1) register/authenticate, so older sites work too +- ES256 (P-256) credentials with packed attestation +- Up to 64 discoverable credentials (passkeys) +- Touch to confirm, using the Pico's BOOTSEL button +- PIN (clientPIN, PIN/UV auth protocols 1 and 2), for sites that require user + verification +- Unlimited non-discoverable credentials. The credential ID carries everything + needed to re-derive the key, so nothing is stored for them. + +Not yet supported: hmac-secret, credential management. + +## Layout + +``` +src/ authenticator core: CTAPHID, CTAP1, CTAP2, CBOR, crypto, storage +firmware/ Pico specifics: USB descriptors, flash, RNG, LED, BOOTSEL button +host/ desktop simulator speaking CTAPHID over stdin/stdout +tests/ python-fido2 tests run against the simulator +tools/ attestation certificate generator +``` + +The core only talks to hardware through `src/platform.h`, so the simulator +runs exactly the same protocol and storage code as the firmware. The +simulator's flash file even follows NOR rules: erase whole sectors, and +program whole pages that can only clear bits. + +## Setup + +```bash +sudo apt install build-essential cmake ninja-build git gcc-arm-none-eabi \ + libnewlib-arm-none-eabi libstdc++-arm-none-eabi-newlib libmbedtls-dev \ + python3-fido2 python3-pytest libusb-1.0-0-dev pkg-config + +git clone --depth 1 https://github.com/raspberrypi/pico-sdk deps/pico-sdk +git -C deps/pico-sdk submodule update --init --depth 1 lib/tinyusb lib/mbedtls +``` + +Set `PICO_SDK_PATH` instead if you already have an SDK checkout. + +## Simulator and tests + +```bash +cmake -B build-host -G Ninja -DSKEY_HOST=ON +cmake --build build-host +python3 -m pytest tests +``` + +The simulator approves every request unless `SKEY_BUTTON_FILE` is set. When it +is set, creating that file counts as a button press. The tests in +`tests/test_presence.py` use this. + +## Firmware + +```bash +cmake -B build-pico -G Ninja -DPICO_BOARD=pico # or pico2 +cmake --build build-pico +``` + +Hold BOOTSEL while plugging in the Pico, then copy `build-pico/skey.uf2` to the +`RPI-RP2` (or `RP2350`) drive. The board restarts as "Skey FIDO2 Key". + +## Using the button + +The BOOTSEL button is the touch button. Press it briefly; holding it while +plugging in still enters the bootloader as usual. + +| LED | Meaning | +|---|---| +| off | idle | +| on | handling a request | +| fast blink | waiting for a touch: press BOOTSEL | +| slow blink | an older U2F site asked for a touch: press BOOTSEL | + +- A press only counts if it happens after the request arrives, and each press + approves one request. +- An unanswered request times out after 30 seconds. +- Closing the browser prompt cancels the wait. + +To try it out: + +- https://webauthn.io: register, then authenticate +- `fido2-token -L` and `fido2-token -I ` (package `fido2-tools`) +- `ssh-keygen -t ecdsa-sk` for an SSH key backed by the device + +On Linux, systemd recognizes FIDO devices by their HID usage page, so no udev +rules are needed. The exception is snap-packaged browsers, such as Ubuntu's +Firefox. Snaps can only open keys on a fixed list of USB IDs, so without the +rule below the browser waits forever and never sees the key: + +```bash +sudo cp udev/69-skey-snap.rules /etc/udev/rules.d/ +sudo udevadm control --reload +``` + +Then unplug the key and plug it back in. + +## PIN + +The key works without a PIN. Once you set one: + +- every new registration asks for it; +- logins ask for it when the site requires user verification; +- the key returns account names, so the browser can show which passkey you are + picking. + +Set or change it in Chrome under Settings > Privacy and security > Security > +Manage security keys. From a terminal, use `fido2-token` (package +`fido2-tools`): + +```bash +fido2-token -L # find the device, e.g. /dev/hidraw11 +fido2-token -S /dev/hidraw11 # set a PIN +fido2-token -C /dev/hidraw11 # change it +``` + +A PIN must be at least 4 characters. 8 wrong guesses in a row lock the PIN +for good, and only a reset (which also deletes every credential) clears it. +After 3 wrong guesses, the key also needs unplugging before it accepts more. + +## Security notes + +- **Secrets live in flash.** The master secret sits in the last 64 KB of flash + in plain form. Anyone holding the key can read it via BOOTSEL. Pico 2 secure + boot plus OTP storage would fix that. +- **Attestation key.** The first build creates one in `attestation/` + (gitignored). Every key you flash from that checkout shares it, just like a + vendor's batch attestation. +- **Reset window.** `authenticatorReset` only works within 10 seconds of + plugging in. +- **Randomness.** The Pico 2 has a hardware TRNG. The RP2040 relies on ROSC + jitter via `pico_rand`, whitened with SHA-256. + +## Roadmap + +1. Credential management (list and delete passkeys), then advertise FIDO_2_1 +2. hmac-secret / prf extension +3. Pico 2: keep the master secret in OTP and enable secure boot +4. Optional external button on a GPIO pin diff --git a/firmware/button.c b/firmware/button.c new file mode 100644 index 0000000..a86ef69 --- /dev/null +++ b/firmware/button.c @@ -0,0 +1,60 @@ +// The BOOTSEL button doubles as the touch button. +#include "button.h" + +#include "hardware/gpio.h" +#include "hardware/structs/ioqspi.h" +#include "hardware/structs/sio.h" +#include "hardware/sync.h" +#include "platform.h" + +#define SAMPLE_INTERVAL_MS 10 +#define DEBOUNCE_MS 30 +#define QSPI_SS_INDEX 1 + +static bool pressed; +static uint32_t last_sample_ms; +static uint32_t last_change_ms; +static bool press_pending; +static uint32_t press_ms; + +// BOOTSEL pulls the flash chip-select line low. Reading it means briefly +// releasing that line, so flash is unusable: run from RAM with interrupts off. +static bool __no_inline_not_in_flash_func(bootsel_pressed)(void) { + uint32_t irq = save_and_disable_interrupts(); + hw_write_masked(&ioqspi_hw->io[QSPI_SS_INDEX].ctrl, GPIO_OVERRIDE_LOW << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB, + IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS); + for (volatile int i = 0; i < 1000; i++) { + // let the line settle; sleep functions live in flash + } +#if PICO_RP2040 + bool down = !(sio_hw->gpio_hi_in & (1u << QSPI_SS_INDEX)); +#else + bool down = !(sio_hw->gpio_hi_in & SIO_GPIO_HI_IN_QSPI_CSN_BITS); +#endif + hw_write_masked(&ioqspi_hw->io[QSPI_SS_INDEX].ctrl, GPIO_OVERRIDE_NORMAL << IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_LSB, + IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS); + restore_interrupts(irq); + return down; +} + +void button_poll(void) { + uint32_t now = platform_millis(); + if (now - last_sample_ms < SAMPLE_INTERVAL_MS) return; + last_sample_ms = now; + + bool down = bootsel_pressed(); + if (down == pressed || now - last_change_ms < DEBOUNCE_MS) return; + pressed = down; + last_change_ms = now; + if (pressed) { + press_pending = true; + press_ms = now; + } +} + +bool platform_button_take_press(uint32_t max_age_ms) { + button_poll(); + if (!press_pending) return false; + press_pending = false; + return platform_millis() - press_ms <= max_age_ms; +} diff --git a/firmware/button.h b/firmware/button.h new file mode 100644 index 0000000..7ad2336 --- /dev/null +++ b/firmware/button.h @@ -0,0 +1,5 @@ +#pragma once + +// Samples the touch button. Call from the main loop so presses made between +// requests are seen. +void button_poll(void); diff --git a/firmware/main.c b/firmware/main.c new file mode 100644 index 0000000..200257a --- /dev/null +++ b/firmware/main.c @@ -0,0 +1,24 @@ +#include "button.h" +#include "ctaphid.h" +#include "pin.h" +#include "platform.h" +#include "presence.h" +#include "storage.h" +#include "tusb.h" + +int main(void) { + uint8_t packet[HID_PACKET_SIZE]; + + platform_init(); + storage_init(); + pin_init(); + ctaphid_init(); + tud_init(0); + + for (;;) { + while (platform_hid_receive(packet)) ctaphid_handle_packet(packet); + ctaphid_tick(); + button_poll(); + presence_tick(); + } +} diff --git a/firmware/mbedtls_config.h b/firmware/mbedtls_config.h new file mode 100644 index 0000000..2641968 --- /dev/null +++ b/firmware/mbedtls_config.h @@ -0,0 +1,26 @@ +// mbedtls build configuration for the firmware: SHA-256, HMAC, P-256 ECDSA/ECDH +// and AES-256-CBC (PIN protocols) only. +#ifndef SKEY_MBEDTLS_CONFIG_H +#define SKEY_MBEDTLS_CONFIG_H + +#define MBEDTLS_NO_PLATFORM_ENTROPY + +#define MBEDTLS_MD_C +#define MBEDTLS_SHA256_C + +#define MBEDTLS_BIGNUM_C +#define MBEDTLS_ECP_C +#define MBEDTLS_ECP_DP_SECP256R1_ENABLED +#define MBEDTLS_ECP_NIST_OPTIM +#define MBEDTLS_ECDSA_C +#define MBEDTLS_ECDSA_DETERMINISTIC +#define MBEDTLS_HMAC_DRBG_C +#define MBEDTLS_ASN1_PARSE_C +#define MBEDTLS_ASN1_WRITE_C + +#define MBEDTLS_AES_C +#define MBEDTLS_CIPHER_MODE_CBC +#define MBEDTLS_AES_ROM_TABLES // keep the lookup tables in flash, not RAM +#define MBEDTLS_AES_FEWER_TABLES + +#endif diff --git a/firmware/platform_pico.c b/firmware/platform_pico.c new file mode 100644 index 0000000..89b2e55 --- /dev/null +++ b/firmware/platform_pico.c @@ -0,0 +1,86 @@ +#include + +#include "crypto.h" +#include "hardware/flash.h" +#include "hardware/sync.h" +#include "pico/rand.h" +#include "pico/stdlib.h" +#include "platform.h" + +// The storage region is the last 64 KB of flash, far past the firmware image. +#define STORAGE_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - STORAGE_SIZE) + +_Static_assert(STORAGE_SECTOR_SIZE == FLASH_SECTOR_SIZE, "sector size mismatch"); +_Static_assert(STORAGE_PAGE_SIZE == FLASH_PAGE_SIZE, "page size mismatch"); + +extern char __flash_binary_end; + +void platform_led(bool on) { +#ifdef PICO_DEFAULT_LED_PIN + gpio_put(PICO_DEFAULT_LED_PIN, on); +#else + (void)on; +#endif +} + +void platform_init(void) { + if ((uintptr_t)&__flash_binary_end > XIP_BASE + STORAGE_FLASH_OFFSET) + platform_panic("firmware overlaps the storage region"); +#ifdef PICO_DEFAULT_LED_PIN + gpio_init(PICO_DEFAULT_LED_PIN); + gpio_set_dir(PICO_DEFAULT_LED_PIN, GPIO_OUT); +#endif +} + +uint32_t platform_millis(void) { + return to_ms_since_boot(get_absolute_time()); +} + +// pico_rand mixes fresh hardware entropy (ROSC jitter, timers, and the TRNG +// on RP2350) into every call. Each output block hashes many of those calls. +void platform_random(uint8_t *buf, size_t len) { + uint64_t samples[32]; + uint8_t block[32]; + while (len > 0) { + for (size_t i = 0; i < 32; i++) samples[i] = get_rand_64(); + crypto_part_t part = {samples, sizeof(samples)}; + crypto_sha256(&part, 1, block); + size_t n = len < sizeof(block) ? len : sizeof(block); + memcpy(buf, block, n); + buf += n; + len -= n; + } + crypto_wipe(samples, sizeof(samples)); + crypto_wipe(block, sizeof(block)); +} + +void platform_panic(const char *msg) { + panic("%s", msg); +} + +void platform_wink(void) { + for (int i = 0; i < 3; i++) { + platform_led(true); + sleep_ms(100); + platform_led(false); + sleep_ms(100); + } +} + +void platform_flash_read(uint32_t offset, void *buf, size_t len) { + memcpy(buf, (const void *)(XIP_BASE + STORAGE_FLASH_OFFSET + offset), len); +} + +// Flash is unavailable for execution while it is being written, so interrupts +// (whose handlers live in flash) stay off for the duration. +void platform_flash_erase(uint32_t offset) { + uint32_t irq = save_and_disable_interrupts(); + flash_range_erase(STORAGE_FLASH_OFFSET + offset, FLASH_SECTOR_SIZE); + restore_interrupts(irq); +} + +void platform_flash_program(uint32_t offset, const void *buf, size_t len) { + uint32_t irq = save_and_disable_interrupts(); + flash_range_program(STORAGE_FLASH_OFFSET + offset, buf, len); + restore_interrupts(irq); +} diff --git a/firmware/tusb_config.h b/firmware/tusb_config.h new file mode 100644 index 0000000..10fe63c --- /dev/null +++ b/firmware/tusb_config.h @@ -0,0 +1,20 @@ +#ifndef TUSB_CONFIG_H +#define TUSB_CONFIG_H + +#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED) + +#ifndef CFG_TUSB_OS +#define CFG_TUSB_OS OPT_OS_NONE +#endif + +#define CFG_TUD_ENDPOINT0_SIZE 64 + +// One HID interface: the FIDO CTAPHID transport. +#define CFG_TUD_HID 1 +#define CFG_TUD_HID_EP_BUFSIZE 64 +#define CFG_TUD_CDC 0 +#define CFG_TUD_MSC 0 +#define CFG_TUD_MIDI 0 +#define CFG_TUD_VENDOR 0 + +#endif diff --git a/firmware/usb.c b/firmware/usb.c new file mode 100644 index 0000000..d8d9617 --- /dev/null +++ b/firmware/usb.c @@ -0,0 +1,147 @@ +// USB descriptors and the HID plumbing for the FIDO interface. +#include + +#include "pico/unique_id.h" +#include "platform.h" +#include "tusb.h" + +// pid.codes test VID/PID: fine for personal use, not for distribution. Hosts +// recognize FIDO devices by the HID usage page, not the IDs. +#define USB_VID 0x1209 +#define USB_PID 0x0001 + +#define SEND_TIMEOUT_MS 1000 +#define RX_QUEUE_LEN 16 // power of two + +enum { STR_LANGID, STR_MANUFACTURER, STR_PRODUCT, STR_SERIAL, STR_INTERFACE }; + +static const tusb_desc_device_t desc_device = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = 0x0200, + .bDeviceClass = 0x00, + .bDeviceSubClass = 0x00, + .bDeviceProtocol = 0x00, + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + .idVendor = USB_VID, + .idProduct = USB_PID, + .bcdDevice = 0x0100, + .iManufacturer = STR_MANUFACTURER, + .iProduct = STR_PRODUCT, + .iSerialNumber = STR_SERIAL, + .bNumConfigurations = 1, +}; + +// FIDO HID report descriptor (CTAP 2.1, section 11.2.8.1): 64-byte reports both ways. +static const uint8_t desc_hid_report[] = { + 0x06, 0xd0, 0xf1, // Usage Page (FIDO Alliance) + 0x09, 0x01, // Usage (CTAPHID) + 0xa1, 0x01, // Collection (Application) + 0x09, 0x20, // Usage (Input Report Data) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xff, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x40, // Report Count (64) + 0x81, 0x02, // Input (Data, Variable, Absolute) + 0x09, 0x21, // Usage (Output Report Data) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xff, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x40, // Report Count (64) + 0x91, 0x02, // Output (Data, Variable, Absolute) + 0xc0, // End Collection +}; + +#define EP_HID_OUT 0x01 +#define EP_HID_IN 0x81 +#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_INOUT_DESC_LEN) + +static const uint8_t desc_configuration[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, CONFIG_TOTAL_LEN, 0, 100), + TUD_HID_INOUT_DESCRIPTOR(0, STR_INTERFACE, HID_ITF_PROTOCOL_NONE, sizeof(desc_hid_report), EP_HID_OUT, + EP_HID_IN, CFG_TUD_HID_EP_BUFSIZE, 5), +}; + +static uint8_t rx_queue[RX_QUEUE_LEN][HID_PACKET_SIZE]; +static unsigned rx_head, rx_tail; + +const uint8_t *tud_descriptor_device_cb(void) { + return (const uint8_t *)&desc_device; +} + +const uint8_t *tud_descriptor_configuration_cb(uint8_t index) { + (void)index; + return desc_configuration; +} + +const uint8_t *tud_hid_descriptor_report_cb(uint8_t instance) { + (void)instance; + return desc_hid_report; +} + +const uint16_t *tud_descriptor_string_cb(uint8_t index, uint16_t langid) { + static uint16_t desc[32]; + char serial[2 * PICO_UNIQUE_BOARD_ID_SIZE_BYTES + 1]; + const char *str; + size_t len; + (void)langid; + + switch (index) { + case STR_LANGID: + desc[1] = 0x0409; // English (US) + desc[0] = (uint16_t)(TUSB_DESC_STRING << 8 | 4); + return desc; + case STR_MANUFACTURER: str = "Skey"; break; + case STR_PRODUCT: str = "Skey FIDO2 Key"; break; + case STR_SERIAL: + pico_get_unique_board_id_string(serial, sizeof(serial)); + str = serial; + break; + case STR_INTERFACE: str = "FIDO"; break; + default: return NULL; + } + len = strlen(str); + if (len > 31) len = 31; + for (size_t i = 0; i < len; i++) desc[1 + i] = (uint8_t)str[i]; + desc[0] = (uint16_t)(TUSB_DESC_STRING << 8 | (2 * len + 2)); + return desc; +} + +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t *buffer, + uint16_t reqlen) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)reqlen; + return 0; +} + +// Runs inside tud_task(). Packets are queued so they are handled from the main +// loop, never re-entrantly while a response is being sent. +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, + const uint8_t *buffer, uint16_t bufsize) { + (void)instance; + (void)report_id; + (void)report_type; + if (bufsize != HID_PACKET_SIZE || rx_head - rx_tail == RX_QUEUE_LEN) return; + memcpy(rx_queue[rx_head % RX_QUEUE_LEN], buffer, HID_PACKET_SIZE); + rx_head++; +} + +bool platform_hid_receive(uint8_t packet[HID_PACKET_SIZE]) { + tud_task(); + if (rx_head == rx_tail) return false; + memcpy(packet, rx_queue[rx_tail % RX_QUEUE_LEN], HID_PACKET_SIZE); + rx_tail++; + return true; +} + +void platform_hid_send(const uint8_t packet[HID_PACKET_SIZE]) { + uint32_t start = platform_millis(); + while (!tud_hid_ready()) { + if (!tud_mounted() || platform_millis() - start > SEND_TIMEOUT_MS) return; // host went away + tud_task(); + } + tud_hid_report(0, packet, HID_PACKET_SIZE); +} diff --git a/host/host.h b/host/host.h new file mode 100644 index 0000000..a443e2c --- /dev/null +++ b/host/host.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +#include "platform.h" + +// Reads one packet from stdin, waiting up to timeout_ms. Exits the process +// when stdin is closed. +bool host_receive(uint8_t packet[HID_PACKET_SIZE], int timeout_ms); diff --git a/host/main.c b/host/main.c new file mode 100644 index 0000000..972f2be --- /dev/null +++ b/host/main.c @@ -0,0 +1,23 @@ +// Simulator: speaks CTAPHID over stdin/stdout, one 64-byte packet at a time. +#include "ctaphid.h" +#include "host.h" +#include "pin.h" +#include "platform.h" +#include "presence.h" +#include "storage.h" + +#define TICK_MS 50 + +int main(void) { + uint8_t packet[HID_PACKET_SIZE]; + + platform_init(); + storage_init(); + pin_init(); + ctaphid_init(); + for (;;) { + if (host_receive(packet, TICK_MS)) ctaphid_handle_packet(packet); + ctaphid_tick(); + presence_tick(); + } +} diff --git a/host/platform_host.c b/host/platform_host.c new file mode 100644 index 0000000..6fb62e9 --- /dev/null +++ b/host/platform_host.c @@ -0,0 +1,121 @@ +// Simulator backend: flash is emulated in a file (SKEY_FLASH_FILE), randomness +// comes from the OS, and HID packets travel over stdin/stdout. +// +// The button: without SKEY_BUTTON_FILE every request is approved. With it, +// creating that file is a press (its mtime is the press time), and the +// simulator deletes it when the press is used. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "host.h" +#include "platform.h" + +static uint8_t flash[STORAGE_SIZE]; +static const char *flash_path; +static const char *button_path; +static struct timespec start; + +void platform_init(void) { + flash_path = getenv("SKEY_FLASH_FILE"); + if (!flash_path) flash_path = "skey_flash.bin"; + button_path = getenv("SKEY_BUTTON_FILE"); + memset(flash, 0xff, sizeof(flash)); + FILE *f = fopen(flash_path, "rb"); + if (f) { + if (fread(flash, 1, sizeof(flash), f) != sizeof(flash)) memset(flash, 0xff, sizeof(flash)); + fclose(f); + } + clock_gettime(CLOCK_MONOTONIC, &start); +} + +uint32_t platform_millis(void) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (uint32_t)((now.tv_sec - start.tv_sec) * 1000 + (now.tv_nsec - start.tv_nsec) / 1000000); +} + +void platform_random(uint8_t *buf, size_t len) { + while (len > 0) { + ssize_t n = getrandom(buf, len, 0); + if (n < 0) platform_panic("getrandom failed"); + buf += n; + len -= (size_t)n; + } +} + +void platform_panic(const char *msg) { + fprintf(stderr, "skey_sim: %s\n", msg); + abort(); +} + +bool platform_button_take_press(uint32_t max_age_ms) { + struct stat st; + struct timespec now; + if (!button_path) return true; + if (stat(button_path, &st) != 0) return false; + unlink(button_path); + clock_gettime(CLOCK_REALTIME, &now); + int64_t age_ms = (int64_t)(now.tv_sec - st.st_mtim.tv_sec) * 1000 + (now.tv_nsec - st.st_mtim.tv_nsec) / 1000000; + return age_ms <= (int64_t)max_age_ms; +} + +void platform_led(bool on) { + (void)on; +} + +void platform_wink(void) { + fprintf(stderr, "skey_sim: wink\n"); +} + +static void save_flash(void) { + FILE *f = fopen(flash_path, "wb"); + if (!f || fwrite(flash, 1, sizeof(flash), f) != sizeof(flash)) platform_panic("cannot write flash file"); + fclose(f); +} + +void platform_flash_read(uint32_t offset, void *buf, size_t len) { + if (offset > STORAGE_SIZE || len > STORAGE_SIZE - offset) platform_panic("flash read out of range"); + memcpy(buf, flash + offset, len); +} + +void platform_flash_erase(uint32_t offset) { + if (offset % STORAGE_SECTOR_SIZE || offset >= STORAGE_SIZE) platform_panic("bad flash erase"); + memset(flash + offset, 0xff, STORAGE_SECTOR_SIZE); + save_flash(); +} + +// Behaves like NOR flash: whole pages only, and programming only clears bits. +void platform_flash_program(uint32_t offset, const void *buf, size_t len) { + const uint8_t *src = buf; + if (offset % STORAGE_PAGE_SIZE || len % STORAGE_PAGE_SIZE || offset > STORAGE_SIZE || + len > STORAGE_SIZE - offset) + platform_panic("bad flash program"); + for (size_t i = 0; i < len; i++) flash[offset + i] &= src[i]; + save_flash(); +} + +void platform_hid_send(const uint8_t packet[HID_PACKET_SIZE]) { + fwrite(packet, 1, HID_PACKET_SIZE, stdout); + fflush(stdout); +} + +bool host_receive(uint8_t packet[HID_PACKET_SIZE], int timeout_ms) { + struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN}; + if (poll(&pfd, 1, timeout_ms) <= 0) return false; + for (size_t got = 0; got < HID_PACKET_SIZE;) { + ssize_t n = read(STDIN_FILENO, packet + got, HID_PACKET_SIZE - got); + if (n <= 0) exit(0); // the host closed the pipe: unplugged + got += (size_t)n; + } + return true; +} + +bool platform_hid_receive(uint8_t packet[HID_PACKET_SIZE]) { + return host_receive(packet, 0); +} diff --git a/src/attestation.h b/src/attestation.h new file mode 100644 index 0000000..c564cd4 --- /dev/null +++ b/src/attestation.h @@ -0,0 +1,10 @@ +// Batch attestation identity, generated at build time by tools/gen_attestation.py. +#pragma once + +#include +#include + +extern const uint8_t skey_aaguid[16]; +extern const uint8_t attestation_key[32]; +extern const uint8_t attestation_cert[]; +extern const size_t attestation_cert_len; diff --git a/src/cbor.c b/src/cbor.c new file mode 100644 index 0000000..c559a4a --- /dev/null +++ b/src/cbor.c @@ -0,0 +1,215 @@ +#include "cbor.h" + +#include + +#include "ctap.h" + +// CTAP2 messages nest at most four levels deep. +#define MAX_DEPTH 8 + +void cbor_reader_init(cbor_reader_t *r, const uint8_t *buf, size_t len) { + r->p = buf; + r->end = buf + len; +} + +int cbor_peek_type(const cbor_reader_t *r) { + return r->p < r->end ? *r->p >> 5 : -1; +} + +static size_t remaining(const cbor_reader_t *r) { + return (size_t)(r->end - r->p); +} + +// Decodes an item head. Indefinite lengths are rejected: CTAP2 only accepts +// canonical CBOR. +static int read_head(cbor_reader_t *r, uint8_t *major, uint64_t *val) { + if (r->p >= r->end) return CTAP2_ERR_INVALID_CBOR; + uint8_t ib = *r->p++; + uint8_t ai = ib & 0x1f; + *major = ib >> 5; + if (ai < 24) { + *val = ai; + return 0; + } + if (ai > 27) return CTAP2_ERR_INVALID_CBOR; + size_t n = (size_t)1 << (ai - 24); + if (remaining(r) < n) return CTAP2_ERR_INVALID_CBOR; + uint64_t v = 0; + for (size_t i = 0; i < n; i++) v = v << 8 | *r->p++; + *val = v; + return 0; +} + +static int read_typed(cbor_reader_t *r, int want, uint64_t *val) { + int type = cbor_peek_type(r); + if (type < 0) return CTAP2_ERR_INVALID_CBOR; + if (type != want) return CTAP2_ERR_CBOR_UNEXPECTED_TYPE; + uint8_t major; + return read_head(r, &major, val); +} + +int cbor_read_uint(cbor_reader_t *r, uint64_t *out) { + return read_typed(r, CBOR_UINT, out); +} + +int cbor_read_int(cbor_reader_t *r, int64_t *out) { + int type = cbor_peek_type(r); + if (type < 0) return CTAP2_ERR_INVALID_CBOR; + if (type != CBOR_UINT && type != CBOR_NEGINT) return CTAP2_ERR_CBOR_UNEXPECTED_TYPE; + uint8_t major; + uint64_t v; + int err = read_head(r, &major, &v); + if (err) return err; + if (v > INT64_MAX) return CTAP2_ERR_INVALID_CBOR; + *out = major == CBOR_UINT ? (int64_t)v : -1 - (int64_t)v; + return 0; +} + +static int read_string(cbor_reader_t *r, int want, const uint8_t **data, size_t *len) { + uint64_t n; + int err = read_typed(r, want, &n); + if (err) return err; + if (n > remaining(r)) return CTAP2_ERR_INVALID_CBOR; + *data = r->p; + *len = (size_t)n; + r->p += n; + return 0; +} + +int cbor_read_bytes(cbor_reader_t *r, const uint8_t **data, size_t *len) { + return read_string(r, CBOR_BYTES, data, len); +} + +int cbor_read_text(cbor_reader_t *r, const char **text, size_t *len) { + const uint8_t *data; + int err = read_string(r, CBOR_TEXT, &data, len); + if (!err) *text = (const char *)data; + return err; +} + +static int read_container(cbor_reader_t *r, int want, size_t *count, size_t bytes_per_entry) { + uint64_t n; + int err = read_typed(r, want, &n); + if (err) return err; + // Every entry takes at least one byte per item, which bounds the count. + if (n > remaining(r) / bytes_per_entry) return CTAP2_ERR_INVALID_CBOR; + *count = (size_t)n; + return 0; +} + +int cbor_read_array(cbor_reader_t *r, size_t *count) { + return read_container(r, CBOR_ARRAY, count, 1); +} + +int cbor_read_map(cbor_reader_t *r, size_t *count) { + return read_container(r, CBOR_MAP, count, 2); +} + +int cbor_read_bool(cbor_reader_t *r, bool *out) { + if (r->p >= r->end) return CTAP2_ERR_INVALID_CBOR; + if (*r->p != 0xf4 && *r->p != 0xf5) return CTAP2_ERR_CBOR_UNEXPECTED_TYPE; + *out = *r->p++ == 0xf5; + return 0; +} + +static int skip(cbor_reader_t *r, int depth) { + if (depth > MAX_DEPTH) return CTAP2_ERR_INVALID_CBOR; + uint8_t major; + uint64_t v; + int err = read_head(r, &major, &v); + if (err) return err; + switch (major) { + case CBOR_BYTES: + case CBOR_TEXT: + if (v > remaining(r)) return CTAP2_ERR_INVALID_CBOR; + r->p += v; + return 0; + case CBOR_ARRAY: + case CBOR_MAP: + if (v > remaining(r)) return CTAP2_ERR_INVALID_CBOR; + if (major == CBOR_MAP) v *= 2; + for (uint64_t i = 0; i < v; i++) { + err = skip(r, depth + 1); + if (err) return err; + } + return 0; + case CBOR_TAG: + return skip(r, depth + 1); + default: + // Integers and simple values are fully described by their head. + return 0; + } +} + +int cbor_skip(cbor_reader_t *r) { + return skip(r, 0); +} + +int cbor_read_uint_key(cbor_reader_t *r, uint64_t *key) { + if (cbor_peek_type(r) == CBOR_UINT) return cbor_read_uint(r, key); + *key = UINT64_MAX; + return cbor_skip(r); +} + +void cbor_writer_init(cbor_writer_t *w, uint8_t *buf, size_t cap) { + w->buf = buf; + w->cap = cap; + w->len = 0; + w->overflow = false; +} + +static void put(cbor_writer_t *w, const void *data, size_t len) { + if (w->overflow || len > w->cap - w->len) { + w->overflow = true; + return; + } + if (len) memcpy(w->buf + w->len, data, len); + w->len += len; +} + +static void put_head(cbor_writer_t *w, uint8_t major, uint64_t v) { + uint8_t head[9]; + size_t n = v < 24 ? 0 : v <= 0xff ? 1 : v <= 0xffff ? 2 : v <= 0xffffffff ? 4 : 8; + static const uint8_t ai_for_size[9] = {[1] = 24, [2] = 25, [4] = 26, [8] = 27}; + head[0] = (uint8_t)(major << 5 | (n ? ai_for_size[n] : v)); + for (size_t i = 0; i < n; i++) head[1 + i] = (uint8_t)(v >> (8 * (n - 1 - i))); + put(w, head, 1 + n); +} + +void cbor_put_uint(cbor_writer_t *w, uint64_t v) { + put_head(w, CBOR_UINT, v); +} + +void cbor_put_int(cbor_writer_t *w, int64_t v) { + if (v >= 0) + put_head(w, CBOR_UINT, (uint64_t)v); + else + put_head(w, CBOR_NEGINT, (uint64_t)(-1 - v)); +} + +void cbor_put_bytes(cbor_writer_t *w, const uint8_t *data, size_t len) { + put_head(w, CBOR_BYTES, len); + put(w, data, len); +} + +void cbor_put_text(cbor_writer_t *w, const char *text, size_t len) { + put_head(w, CBOR_TEXT, len); + put(w, text, len); +} + +void cbor_put_cstr(cbor_writer_t *w, const char *text) { + cbor_put_text(w, text, strlen(text)); +} + +void cbor_put_array(cbor_writer_t *w, size_t count) { + put_head(w, CBOR_ARRAY, count); +} + +void cbor_put_map(cbor_writer_t *w, size_t count) { + put_head(w, CBOR_MAP, count); +} + +void cbor_put_bool(cbor_writer_t *w, bool v) { + uint8_t b = v ? 0xf5 : 0xf4; + put(w, &b, 1); +} diff --git a/src/cbor.h b/src/cbor.h new file mode 100644 index 0000000..fb49425 --- /dev/null +++ b/src/cbor.h @@ -0,0 +1,59 @@ +// Minimal CBOR reader/writer covering what CTAP2 uses. +#pragma once + +#include +#include +#include + +enum { + CBOR_UINT = 0, + CBOR_NEGINT = 1, + CBOR_BYTES = 2, + CBOR_TEXT = 3, + CBOR_ARRAY = 4, + CBOR_MAP = 5, + CBOR_TAG = 6, + CBOR_SIMPLE = 7, +}; + +// Reader functions return 0 on success or a CTAP2 error code. Strings are +// returned as pointers into the input buffer. +typedef struct { + const uint8_t *p; + const uint8_t *end; +} cbor_reader_t; + +void cbor_reader_init(cbor_reader_t *r, const uint8_t *buf, size_t len); +int cbor_peek_type(const cbor_reader_t *r); // major type, or -1 at end of input +int cbor_read_uint(cbor_reader_t *r, uint64_t *out); +int cbor_read_int(cbor_reader_t *r, int64_t *out); +int cbor_read_bytes(cbor_reader_t *r, const uint8_t **data, size_t *len); +int cbor_read_text(cbor_reader_t *r, const char **text, size_t *len); +int cbor_read_array(cbor_reader_t *r, size_t *count); +int cbor_read_map(cbor_reader_t *r, size_t *count); +int cbor_read_bool(cbor_reader_t *r, bool *out); +int cbor_skip(cbor_reader_t *r); + +// Reads a map key that should be an unsigned integer, as in CTAP requests. +// Any other key is skipped and reported as UINT64_MAX, so the caller treats +// it as unknown and skips its value. +int cbor_read_uint_key(cbor_reader_t *r, uint64_t *key); + +// The writer emits minimal-length heads; callers write map keys in CTAP2 +// canonical order. Running out of space sets `overflow`. +typedef struct { + uint8_t *buf; + size_t cap; + size_t len; + bool overflow; +} cbor_writer_t; + +void cbor_writer_init(cbor_writer_t *w, uint8_t *buf, size_t cap); +void cbor_put_uint(cbor_writer_t *w, uint64_t v); +void cbor_put_int(cbor_writer_t *w, int64_t v); +void cbor_put_bytes(cbor_writer_t *w, const uint8_t *data, size_t len); +void cbor_put_text(cbor_writer_t *w, const char *text, size_t len); +void cbor_put_cstr(cbor_writer_t *w, const char *text); +void cbor_put_array(cbor_writer_t *w, size_t count); +void cbor_put_map(cbor_writer_t *w, size_t count); +void cbor_put_bool(cbor_writer_t *w, bool v); diff --git a/src/cred.c b/src/cred.c new file mode 100644 index 0000000..7a076f6 --- /dev/null +++ b/src/cred.c @@ -0,0 +1,48 @@ +#include "cred.h" + +#include + +#include "crypto.h" +#include "platform.h" +#include "storage.h" + +#define CRED_VERSION 1 +#define NONCE_LEN 32 +#define TAG_LEN 16 +#define CREATE_ATTEMPTS 4 + +// `label` is always 8 bytes, which keeps the HMAC input unambiguous. +static void derive(const char label[8], const uint8_t *cred_id, const uint8_t rp_id_hash[32], uint8_t out[32]) { + crypto_part_t parts[] = { + {label, 8}, + {cred_id, 1 + NONCE_LEN}, + {rp_id_hash, 32}, + }; + crypto_hmac_sha256(storage_master_secret(), parts, 3, out); +} + +bool cred_create(const uint8_t rp_id_hash[32], uint8_t cred_id[CRED_ID_LEN], uint8_t priv[32], uint8_t pub[64]) { + // A derived key is an invalid scalar with probability ~2^-32; pick a new nonce if so. + for (int attempt = 0; attempt < CREATE_ATTEMPTS; attempt++) { + cred_id[0] = CRED_VERSION; + platform_random(cred_id + 1, NONCE_LEN); + derive("skey-key", cred_id, rp_id_hash, priv); + if (crypto_p256_public(priv, pub)) { + uint8_t tag[32]; + derive("skey-tag", cred_id, rp_id_hash, tag); + memcpy(cred_id + 1 + NONCE_LEN, tag, TAG_LEN); + return true; + } + } + crypto_wipe(priv, 32); + return false; +} + +bool cred_unwrap(const uint8_t rp_id_hash[32], const uint8_t *cred_id, size_t len, uint8_t priv[32]) { + uint8_t tag[32]; + if (len != CRED_ID_LEN || cred_id[0] != CRED_VERSION) return false; + derive("skey-tag", cred_id, rp_id_hash, tag); + if (!crypto_equal(tag, cred_id + 1 + NONCE_LEN, TAG_LEN)) return false; + derive("skey-key", cred_id, rp_id_hash, priv); + return true; +} diff --git a/src/cred.h b/src/cred.h new file mode 100644 index 0000000..1b51767 --- /dev/null +++ b/src/cred.h @@ -0,0 +1,19 @@ +// Credential IDs are self-contained: the private key is re-derived from the +// device master secret, so non-resident credentials need no storage. +// +// credential ID = version (1) || nonce (32) || tag (16) +// private key = HMAC(master, "skey-key" || version || nonce || rpIdHash) +// tag = HMAC(master, "skey-tag" || version || nonce || rpIdHash) +#pragma once + +#include +#include +#include + +#define CRED_ID_LEN 49 + +// Makes a new credential bound to rp_id_hash. +bool cred_create(const uint8_t rp_id_hash[32], uint8_t cred_id[CRED_ID_LEN], uint8_t priv[32], uint8_t pub[64]); + +// Recovers the private key if this device issued cred_id for rp_id_hash. +bool cred_unwrap(const uint8_t rp_id_hash[32], const uint8_t *cred_id, size_t len, uint8_t priv[32]); diff --git a/src/crypto.c b/src/crypto.c new file mode 100644 index 0000000..3862f69 --- /dev/null +++ b/src/crypto.c @@ -0,0 +1,161 @@ +#include "crypto.h" + +#include + +#include "mbedtls/aes.h" +#include "mbedtls/bignum.h" +#include "mbedtls/ecdsa.h" +#include "mbedtls/ecp.h" +#include "mbedtls/md.h" +#include "mbedtls/platform_util.h" +#include "platform.h" + +// Used by mbedtls for side-channel blinding only; signatures are deterministic. +static int rng(void *ctx, unsigned char *buf, size_t len) { + (void)ctx; + platform_random(buf, len); + return 0; +} + +static void digest(const uint8_t *hmac_key, const crypto_part_t *parts, size_t count, uint8_t out[32]) { + mbedtls_md_context_t ctx; + int err; + mbedtls_md_init(&ctx); + err = mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), hmac_key != NULL); + if (!err) err = hmac_key ? mbedtls_md_hmac_starts(&ctx, hmac_key, 32) : mbedtls_md_starts(&ctx); + for (size_t i = 0; i < count && !err; i++) { + err = hmac_key ? mbedtls_md_hmac_update(&ctx, parts[i].data, parts[i].len) + : mbedtls_md_update(&ctx, parts[i].data, parts[i].len); + } + if (!err) err = hmac_key ? mbedtls_md_hmac_finish(&ctx, out) : mbedtls_md_finish(&ctx, out); + mbedtls_md_free(&ctx); + if (err) platform_panic("sha256 failed"); +} + +void crypto_sha256(const crypto_part_t *parts, size_t count, uint8_t out[32]) { + digest(NULL, parts, count, out); +} + +void crypto_hmac_sha256(const uint8_t key[32], const crypto_part_t *parts, size_t count, uint8_t out[32]) { + digest(key, parts, count, out); +} + +bool crypto_p256_public(const uint8_t priv[32], uint8_t pub[64]) { + mbedtls_ecp_group grp; + mbedtls_mpi d; + mbedtls_ecp_point q; + uint8_t point[65]; + size_t len = 0; + + mbedtls_ecp_group_init(&grp); + mbedtls_mpi_init(&d); + mbedtls_ecp_point_init(&q); + bool ok = mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1) == 0 && + mbedtls_mpi_read_binary(&d, priv, 32) == 0 && mbedtls_ecp_check_privkey(&grp, &d) == 0 && + mbedtls_ecp_mul(&grp, &q, &d, &grp.G, rng, NULL) == 0 && + mbedtls_ecp_point_write_binary(&grp, &q, MBEDTLS_ECP_PF_UNCOMPRESSED, &len, point, + sizeof(point)) == 0 && + len == sizeof(point); + if (ok) memcpy(pub, point + 1, 64); + mbedtls_ecp_point_free(&q); + mbedtls_mpi_free(&d); + mbedtls_ecp_group_free(&grp); + return ok; +} + +// Encodes a 32-byte unsigned big-endian integer as a minimal DER INTEGER. +static size_t der_integer(uint8_t *out, const uint8_t v[32]) { + size_t skip = 0; + while (skip < 31 && v[skip] == 0) skip++; + size_t len = 32 - skip; + size_t pad = v[skip] >> 7; // keep the value positive + out[0] = 0x02; + out[1] = (uint8_t)(pad + len); + out[2] = 0; + memcpy(out + 2 + pad, v + skip, len); + return 2 + pad + len; +} + +size_t crypto_p256_sign(const uint8_t priv[32], const crypto_part_t *parts, size_t count, + uint8_t sig[P256_SIG_MAX_DER]) { + uint8_t hash[32], r_bytes[32], s_bytes[32]; + mbedtls_ecp_group grp; + mbedtls_mpi d, r, s; + + crypto_sha256(parts, count, hash); + mbedtls_ecp_group_init(&grp); + mbedtls_mpi_init(&d); + mbedtls_mpi_init(&r); + mbedtls_mpi_init(&s); + bool ok = mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1) == 0 && + mbedtls_mpi_read_binary(&d, priv, 32) == 0 && + mbedtls_ecdsa_sign_det_ext(&grp, &r, &s, &d, hash, sizeof(hash), MBEDTLS_MD_SHA256, rng, + NULL) == 0 && + mbedtls_mpi_write_binary(&r, r_bytes, 32) == 0 && mbedtls_mpi_write_binary(&s, s_bytes, 32) == 0; + mbedtls_mpi_free(&s); + mbedtls_mpi_free(&r); + mbedtls_mpi_free(&d); + mbedtls_ecp_group_free(&grp); + if (!ok) return 0; + + size_t len = der_integer(sig + 2, r_bytes); + len += der_integer(sig + 2 + len, s_bytes); + sig[0] = 0x30; + sig[1] = (uint8_t)len; + return 2 + len; +} + +bool crypto_p256_ecdh(const uint8_t priv[32], const uint8_t peer[64], uint8_t shared_x[32]) { + mbedtls_ecp_group grp; + mbedtls_mpi d; + mbedtls_ecp_point q, z; + uint8_t point[65]; + size_t len = 0; + + point[0] = 0x04; // uncompressed + memcpy(point + 1, peer, 64); + mbedtls_ecp_group_init(&grp); + mbedtls_mpi_init(&d); + mbedtls_ecp_point_init(&q); + mbedtls_ecp_point_init(&z); + bool ok = mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1) == 0 && + mbedtls_mpi_read_binary(&d, priv, 32) == 0 && + mbedtls_ecp_point_read_binary(&grp, &q, point, sizeof(point)) == 0 && + mbedtls_ecp_check_pubkey(&grp, &q) == 0 && mbedtls_ecp_mul(&grp, &z, &d, &q, rng, NULL) == 0 && + mbedtls_ecp_point_write_binary(&grp, &z, MBEDTLS_ECP_PF_UNCOMPRESSED, &len, point, + sizeof(point)) == 0 && + len == sizeof(point); + if (ok) memcpy(shared_x, point + 1, 32); + crypto_wipe(point, sizeof(point)); + mbedtls_ecp_point_free(&z); + mbedtls_ecp_point_free(&q); + mbedtls_mpi_free(&d); + mbedtls_ecp_group_free(&grp); + return ok; +} + +bool crypto_aes256_cbc(bool encrypt, const uint8_t key[32], const uint8_t iv[16], const uint8_t *in, uint8_t *out, + size_t len) { + mbedtls_aes_context ctx; + uint8_t chain[16]; // mbedtls updates the IV in place + + if (len % 16) return false; + memcpy(chain, iv, sizeof(chain)); + mbedtls_aes_init(&ctx); + bool ok = (encrypt ? mbedtls_aes_setkey_enc(&ctx, key, 256) : mbedtls_aes_setkey_dec(&ctx, key, 256)) == 0 && + mbedtls_aes_crypt_cbc(&ctx, encrypt ? MBEDTLS_AES_ENCRYPT : MBEDTLS_AES_DECRYPT, len, chain, in, + out) == 0; + mbedtls_aes_free(&ctx); + return ok; +} + +bool crypto_equal(const void *a, const void *b, size_t len) { + const uint8_t *x = a, *y = b; + uint8_t diff = 0; + for (size_t i = 0; i < len; i++) diff |= x[i] ^ y[i]; + return diff == 0; +} + +void crypto_wipe(void *buf, size_t len) { + mbedtls_platform_zeroize(buf, len); +} diff --git a/src/crypto.h b/src/crypto.h new file mode 100644 index 0000000..ce5781b --- /dev/null +++ b/src/crypto.h @@ -0,0 +1,36 @@ +// SHA-256, HMAC-SHA256 and P-256 ECDSA, backed by mbedtls. +#pragma once + +#include +#include +#include + +#define P256_SIG_MAX_DER 72 + +// Hash inputs are given as a list of buffers that are processed back to back. +typedef struct { + const void *data; + size_t len; +} crypto_part_t; + +void crypto_sha256(const crypto_part_t *parts, size_t count, uint8_t out[32]); +void crypto_hmac_sha256(const uint8_t key[32], const crypto_part_t *parts, size_t count, uint8_t out[32]); + +// Computes the public key (x || y) for a private key; fails if priv is not a +// valid P-256 scalar. +bool crypto_p256_public(const uint8_t priv[32], uint8_t pub[64]); + +// Signs SHA-256(parts) with deterministic ECDSA (RFC 6979). Writes a DER +// signature and returns its length, or 0 on failure. +size_t crypto_p256_sign(const uint8_t priv[32], const crypto_part_t *parts, size_t count, + uint8_t sig[P256_SIG_MAX_DER]); + +// ECDH: the x coordinate of priv * peer. Fails if peer is not on the curve. +bool crypto_p256_ecdh(const uint8_t priv[32], const uint8_t peer[64], uint8_t shared_x[32]); + +// AES-256-CBC without padding; len must be a multiple of 16. in may equal out. +bool crypto_aes256_cbc(bool encrypt, const uint8_t key[32], const uint8_t iv[16], const uint8_t *in, uint8_t *out, + size_t len); + +bool crypto_equal(const void *a, const void *b, size_t len); // constant time +void crypto_wipe(void *buf, size_t len); diff --git a/src/ctap.h b/src/ctap.h new file mode 100644 index 0000000..9c5e3ea --- /dev/null +++ b/src/ctap.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +// CTAP status codes (CTAP 2.1, section 8.2). +#define CTAP2_OK 0x00 +#define CTAP1_ERR_INVALID_COMMAND 0x01 +#define CTAP1_ERR_INVALID_PARAMETER 0x02 +#define CTAP1_ERR_INVALID_LENGTH 0x03 +#define CTAP2_ERR_CBOR_UNEXPECTED_TYPE 0x11 +#define CTAP2_ERR_INVALID_CBOR 0x12 +#define CTAP2_ERR_MISSING_PARAMETER 0x14 +#define CTAP2_ERR_CREDENTIAL_EXCLUDED 0x19 +#define CTAP2_ERR_PROCESSING 0x21 +#define CTAP2_ERR_UNSUPPORTED_ALGORITHM 0x26 +#define CTAP2_ERR_OPERATION_DENIED 0x27 +#define CTAP2_ERR_KEY_STORE_FULL 0x28 +#define CTAP2_ERR_UNSUPPORTED_OPTION 0x2B +#define CTAP2_ERR_INVALID_OPTION 0x2C +#define CTAP2_ERR_KEEPALIVE_CANCEL 0x2D +#define CTAP2_ERR_NO_CREDENTIALS 0x2E +#define CTAP2_ERR_USER_ACTION_TIMEOUT 0x2F +#define CTAP2_ERR_NOT_ALLOWED 0x30 +#define CTAP2_ERR_PIN_INVALID 0x31 +#define CTAP2_ERR_PIN_BLOCKED 0x32 +#define CTAP2_ERR_PIN_AUTH_INVALID 0x33 +#define CTAP2_ERR_PIN_AUTH_BLOCKED 0x34 +#define CTAP2_ERR_PIN_NOT_SET 0x35 +#define CTAP2_ERR_PUAT_REQUIRED 0x36 // PIN_REQUIRED in CTAP 2.0 +#define CTAP2_ERR_PIN_POLICY_VIOLATION 0x37 +#define CTAP2_ERR_INVALID_SUBCOMMAND 0x3E +#define CTAP2_ERR_UNAUTHORIZED_PERMISSION 0x40 +#define CTAP1_ERR_OTHER 0x7F + +// Returns from the calling function if `expr` yields a non-zero status. +#define CHECK(expr) \ + do { \ + int err_ = (expr); \ + if (err_) return err_; \ + } while (0) + +// Handles a CTAPHID_CBOR request. Writes the status byte and CBOR body to +// resp and returns the response length. +size_t ctap2_handle(const uint8_t *req, size_t len, uint8_t *resp, size_t resp_cap); + +// Handles a CTAPHID_MSG request (a U2F APDU). Writes the response data and +// status word to resp and returns the response length. +size_t ctap1_handle(const uint8_t *req, size_t len, uint8_t *resp); diff --git a/src/ctap1.c b/src/ctap1.c new file mode 100644 index 0000000..c55d0d9 --- /dev/null +++ b/src/ctap1.c @@ -0,0 +1,129 @@ +// U2F / CTAP1 over CTAPHID_MSG (FIDO U2F Raw Message Formats v1.2). +#include + +#include "attestation.h" +#include "cred.h" +#include "crypto.h" +#include "ctap.h" +#include "presence.h" +#include "storage.h" +#include "util.h" + +#define U2F_REGISTER 0x01 +#define U2F_AUTHENTICATE 0x02 +#define U2F_VERSION 0x03 + +#define AUTH_ENFORCE_PRESENCE 0x03 +#define AUTH_CHECK_ONLY 0x07 +#define AUTH_DONT_ENFORCE_PRESENCE 0x08 + +#define SW_NO_ERROR 0x9000 +#define SW_WRONG_LENGTH 0x6700 +#define SW_CONDITIONS_NOT_SATISFIED 0x6985 +#define SW_WRONG_DATA 0x6a80 +#define SW_INS_NOT_SUPPORTED 0x6d00 +#define SW_CLA_NOT_SUPPORTED 0x6e00 +#define SW_UNKNOWN 0x6f00 + +static size_t status(uint8_t *resp, size_t len, uint16_t sw) { + resp[len] = (uint8_t)(sw >> 8); + resp[len + 1] = (uint8_t)sw; + return len + 2; +} + +static size_t u2f_register(const uint8_t *data, size_t lc, uint8_t *resp) { + const uint8_t *challenge = data, *app_id = data + 32; + uint8_t cred_id[CRED_ID_LEN], priv[32], pub[64]; + static const uint8_t reserved = 0x00; + + if (lc != 64) return status(resp, 0, SW_WRONG_LENGTH); + if (!presence_check()) return status(resp, 0, SW_CONDITIONS_NOT_SATISFIED); + if (!cred_create(app_id, cred_id, priv, pub)) return status(resp, 0, SW_UNKNOWN); + crypto_wipe(priv, sizeof(priv)); + + // 0x05 | public key | key handle length | key handle | certificate | signature + size_t len = 0; + resp[len++] = 0x05; + resp[len++] = 0x04; // uncompressed point + memcpy(resp + len, pub, 64); + len += 64; + resp[len++] = CRED_ID_LEN; + memcpy(resp + len, cred_id, CRED_ID_LEN); + len += CRED_ID_LEN; + memcpy(resp + len, attestation_cert, attestation_cert_len); + len += attestation_cert_len; + + crypto_part_t signed_data[] = { + {&reserved, 1}, {app_id, 32}, {challenge, 32}, {cred_id, CRED_ID_LEN}, {resp + 1, 65}, + }; + size_t sig_len = crypto_p256_sign(attestation_key, signed_data, 5, resp + len); + if (!sig_len) return status(resp, 0, SW_UNKNOWN); + return status(resp, len + sig_len, SW_NO_ERROR); +} + +static size_t u2f_authenticate(uint8_t mode, const uint8_t *data, size_t lc, uint8_t *resp) { + const uint8_t *challenge = data, *app_id = data + 32, *key_handle = data + 65; + uint8_t priv[32]; + + if (lc < 65 || lc != 65u + data[64]) return status(resp, 0, SW_WRONG_LENGTH); + if (mode != AUTH_ENFORCE_PRESENCE && mode != AUTH_CHECK_ONLY && mode != AUTH_DONT_ENFORCE_PRESENCE) + return status(resp, 0, SW_WRONG_DATA); + if (!cred_unwrap(app_id, key_handle, data[64], priv)) return status(resp, 0, SW_WRONG_DATA); + + // Check-only answers "this key handle is mine" with a presence error. + uint16_t sw = SW_NO_ERROR; + uint8_t flags = 0; + if (mode == AUTH_CHECK_ONLY) { + sw = SW_CONDITIONS_NOT_SATISFIED; + } else if (mode == AUTH_ENFORCE_PRESENCE) { + if (presence_check()) + flags = 0x01; + else + sw = SW_CONDITIONS_NOT_SATISFIED; + } + if (sw != SW_NO_ERROR) { + crypto_wipe(priv, sizeof(priv)); + return status(resp, 0, sw); + } + + // user presence | counter | signature + resp[0] = flags; + put_be32(resp + 1, storage_counter_next()); + crypto_part_t signed_data[] = {{app_id, 32}, {resp, 5}, {challenge, 32}}; + size_t sig_len = crypto_p256_sign(priv, signed_data, 3, resp + 5); + crypto_wipe(priv, sizeof(priv)); + if (!sig_len) return status(resp, 0, SW_UNKNOWN); + return status(resp, 5 + sig_len, SW_NO_ERROR); +} + +size_t ctap1_handle(const uint8_t *req, size_t len, uint8_t *resp) { + const uint8_t *data = req + 4; + size_t lc = 0; + + if (len < 4) return status(resp, 0, SW_WRONG_LENGTH); + if (len > 4) { + if (req[4] == 0 && len >= 7) { // extended length: 00 Lc1 Lc2 + lc = (size_t)req[5] << 8 | req[6]; + data = req + 7; + } else { + lc = req[4]; + data = req + 5; + } + // Anything past the data is Le, which we don't need. + if (lc > len - (size_t)(data - req)) return status(resp, 0, SW_WRONG_LENGTH); + } + if (req[0] != 0) return status(resp, 0, SW_CLA_NOT_SUPPORTED); + + switch (req[1]) { + case U2F_REGISTER: + return u2f_register(data, lc, resp); + case U2F_AUTHENTICATE: + return u2f_authenticate(req[2], data, lc, resp); + case U2F_VERSION: + if (lc != 0) return status(resp, 0, SW_WRONG_LENGTH); + memcpy(resp, "U2F_V2", 6); + return status(resp, 6, SW_NO_ERROR); + default: + return status(resp, 0, SW_INS_NOT_SUPPORTED); + } +} diff --git a/src/ctap2.c b/src/ctap2.c new file mode 100644 index 0000000..2729a09 --- /dev/null +++ b/src/ctap2.c @@ -0,0 +1,614 @@ +// CTAP2 authenticator API (CTAP 2.0, with the CTAP 2.1 behaviors browsers rely on). +#include + +#include "attestation.h" +#include "cbor.h" +#include "cred.h" +#include "crypto.h" +#include "ctap.h" +#include "pin.h" +#include "platform.h" +#include "presence.h" +#include "storage.h" +#include "util.h" + +#define CMD_MAKE_CREDENTIAL 0x01 +#define CMD_GET_ASSERTION 0x02 +#define CMD_GET_INFO 0x04 +#define CMD_CLIENT_PIN 0x06 +#define CMD_RESET 0x07 +#define CMD_GET_NEXT_ASSERTION 0x08 +#define CMD_SELECTION 0x0b + +#define COSE_ALG_ES256 (-7) +#define MAX_MSG_SIZE 2048 + +#define FLAG_UP 0x01 +#define FLAG_UV 0x04 +#define FLAG_AT 0x40 + +#define RESET_WINDOW_MS 10000 +#define NEXT_ASSERTION_TIMEOUT_MS 30000 + +// rpIdHash | flags | counter | aaguid | credIdLen | credId | COSE key +#define AUTH_DATA_HEADER 37 +#define AUTH_DATA_MAX 256 + +// Strings point into the request buffer. +typedef struct { + const char *data; + size_t len; +} text_t; + +typedef struct { + const uint8_t *data; + size_t len; +} bytes_t; + +typedef struct { + bytes_t id; + text_t name; + text_t display_name; +} user_t; + +typedef struct { + bool rk, uv, up; + bool rk_present, up_present; +} options_t; + +// A PublicKeyCredentialDescriptor list, validated during parsing and walked again later. +typedef struct { + bool present; + size_t count; + cbor_reader_t items; +} cred_list_t; + +// Discoverable credentials still to be returned by authenticatorGetNextAssertion. +static struct { + bool active; + uint8_t rp_id_hash[32]; + uint8_t client_data_hash[32]; + uint8_t flags; + int16_t slots[RK_SLOTS]; + size_t count; + size_t next; + uint32_t last_ms; +} pending; + +static bool text_eq(text_t t, const char *s) { + size_t n = strlen(s); + return t.len == n && memcmp(t.data, s, n) == 0; +} + +static int read_text(cbor_reader_t *r, text_t *t) { + return cbor_read_text(r, &t->data, &t->len); +} + +static int read_bytes(cbor_reader_t *r, bytes_t *b) { + return cbor_read_bytes(r, &b->data, &b->len); +} + +static int read_hash(cbor_reader_t *r, const uint8_t **hash) { + bytes_t b; + CHECK(read_bytes(r, &b)); + if (b.len != 32) return CTAP1_ERR_INVALID_LENGTH; + *hash = b.data; + return 0; +} + +static int parse_rp(cbor_reader_t *r, text_t *rp_id) { + size_t n; + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + text_t key; + CHECK(read_text(r, &key)); + if (text_eq(key, "id")) + CHECK(read_text(r, rp_id)); + else + CHECK(cbor_skip(r)); + } + return rp_id->data ? 0 : CTAP2_ERR_MISSING_PARAMETER; +} + +static int parse_user(cbor_reader_t *r, user_t *user) { + size_t n; + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + text_t key; + CHECK(read_text(r, &key)); + if (text_eq(key, "id")) + CHECK(read_bytes(r, &user->id)); + else if (text_eq(key, "name")) + CHECK(read_text(r, &user->name)); + else if (text_eq(key, "displayName")) + CHECK(read_text(r, &user->display_name)); + else + CHECK(cbor_skip(r)); + } + return user->id.data ? 0 : CTAP2_ERR_MISSING_PARAMETER; +} + +static int parse_cred_params(cbor_reader_t *r, bool *es256) { + size_t n; + CHECK(cbor_read_array(r, &n)); + for (size_t i = 0; i < n; i++) { + size_t m; + int64_t alg = 0; + bool has_alg = false; + text_t type = {0}; + CHECK(cbor_read_map(r, &m)); + for (size_t j = 0; j < m; j++) { + text_t key; + CHECK(read_text(r, &key)); + if (text_eq(key, "alg")) { + CHECK(cbor_read_int(r, &alg)); + has_alg = true; + } else if (text_eq(key, "type")) { + CHECK(read_text(r, &type)); + } else { + CHECK(cbor_skip(r)); + } + } + if (!has_alg || !type.data) return CTAP2_ERR_MISSING_PARAMETER; + if (text_eq(type, "public-key") && alg == COSE_ALG_ES256) *es256 = true; + } + return 0; +} + +static int read_descriptor(cbor_reader_t *r, bytes_t *id, bool *public_key) { + size_t n; + text_t type = {0}; + id->data = NULL; + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + text_t key; + CHECK(read_text(r, &key)); + if (text_eq(key, "id")) + CHECK(read_bytes(r, id)); + else if (text_eq(key, "type")) + CHECK(read_text(r, &type)); + else + CHECK(cbor_skip(r)); + } + if (!id->data || !type.data) return CTAP2_ERR_MISSING_PARAMETER; + *public_key = text_eq(type, "public-key"); + return 0; +} + +static int parse_cred_list(cbor_reader_t *r, cred_list_t *list) { + CHECK(cbor_read_array(r, &list->count)); + list->present = true; + list->items = *r; + for (size_t i = 0; i < list->count; i++) { + bytes_t id; + bool public_key; + CHECK(read_descriptor(r, &id, &public_key)); + } + return 0; +} + +// Finds the first credential in the list that this device issued for the RP. +static bool find_in_list(const cred_list_t *list, const uint8_t rp_id_hash[32], bytes_t *id, uint8_t priv[32]) { + cbor_reader_t r = list->items; + for (size_t i = 0; i < list->count; i++) { + bool public_key; + read_descriptor(&r, id, &public_key); // already validated + if (public_key && cred_unwrap(rp_id_hash, id->data, id->len, priv)) return true; + } + return false; +} + +static int parse_options(cbor_reader_t *r, options_t *opt) { + size_t n; + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + text_t key; + CHECK(read_text(r, &key)); + if (text_eq(key, "rk")) { + CHECK(cbor_read_bool(r, &opt->rk)); + opt->rk_present = true; + } else if (text_eq(key, "uv")) { + CHECK(cbor_read_bool(r, &opt->uv)); + } else if (text_eq(key, "up")) { + CHECK(cbor_read_bool(r, &opt->up)); + opt->up_present = true; + } else { + CHECK(cbor_skip(r)); + } + } + return 0; +} + +// Platforms send an empty pinUvAuthParam to ask "touch to pick this +// authenticator"; after the touch, the answer says whether a PIN is set. +static int empty_pin_auth(void) { + CHECK(presence_wait()); + return pin_is_set() ? CTAP2_ERR_PIN_INVALID : CTAP2_ERR_PIN_NOT_SET; +} + +static int check_pin_auth(const bytes_t *pin_auth, uint64_t protocol, const uint8_t client_data_hash[32], + uint8_t permission, const uint8_t rp_id_hash[32]) { + if (!protocol) return CTAP2_ERR_MISSING_PARAMETER; + return pin_check_token(protocol, pin_auth->data, pin_auth->len, client_data_hash, permission, rp_id_hash); +} + +static void hash_rp_id(text_t rp_id, uint8_t out[32]) { + crypto_part_t part = {rp_id.data, rp_id.len}; + crypto_sha256(&part, 1, out); +} + +// Copies at most `cap` bytes of UTF-8 without splitting a character. +static uint8_t copy_text(char *dst, size_t cap, text_t src) { + size_t n = src.len; + if (!src.data) return 0; + if (n > cap) { + n = cap; + while (n > 0 && ((uint8_t)src.data[n] & 0xc0) == 0x80) n--; + } + memcpy(dst, src.data, n); + return (uint8_t)n; +} + +static int store_resident(const uint8_t rp_id_hash[32], text_t rp_id, const user_t *user, + const uint8_t cred_id[CRED_ID_LEN], uint32_t created) { + static rk_record_t rec; + memset(&rec, 0, sizeof(rec)); + rec.created = created; + memcpy(rec.rp_id_hash, rp_id_hash, 32); + memcpy(rec.cred_id, cred_id, CRED_ID_LEN); + rec.user_id_len = (uint8_t)user->id.len; + memcpy(rec.user_id, user->id.data, user->id.len); + rec.rp_id_len = copy_text(rec.rp_id, sizeof(rec.rp_id), rp_id); + rec.user_name_len = copy_text(rec.user_name, sizeof(rec.user_name), user->name); + rec.display_name_len = copy_text(rec.display_name, sizeof(rec.display_name), user->display_name); + return storage_rk_save(&rec); +} + +static void put_cose_key(cbor_writer_t *w, const uint8_t pub[64]) { + cbor_put_map(w, 5); + cbor_put_int(w, 1); // kty: EC2 + cbor_put_int(w, 2); + cbor_put_int(w, 3); // alg: ES256 + cbor_put_int(w, COSE_ALG_ES256); + cbor_put_int(w, -1); // crv: P-256 + cbor_put_int(w, 1); + cbor_put_int(w, -2); // x + cbor_put_bytes(w, pub, 32); + cbor_put_int(w, -3); // y + cbor_put_bytes(w, pub + 32, 32); +} + +static int make_credential(cbor_reader_t *r, cbor_writer_t *w) { + const uint8_t *client_data_hash = NULL; + text_t rp_id = {0}; + user_t user = {0}; + bool has_params = false, es256 = false; + cred_list_t exclude = {0}; + options_t opt = {.up = true}; + bytes_t pin_auth = {0}; + uint64_t pin_protocol = 0; + size_t n; + + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + uint64_t key; + CHECK(cbor_read_uint_key(r, &key)); + switch (key) { + case 0x01: CHECK(read_hash(r, &client_data_hash)); break; + case 0x02: CHECK(parse_rp(r, &rp_id)); break; + case 0x03: CHECK(parse_user(r, &user)); break; + case 0x04: CHECK(parse_cred_params(r, &es256)); has_params = true; break; + case 0x05: CHECK(parse_cred_list(r, &exclude)); break; + case 0x07: CHECK(parse_options(r, &opt)); break; + case 0x08: CHECK(read_bytes(r, &pin_auth)); break; + case 0x09: CHECK(cbor_read_uint(r, &pin_protocol)); break; + default: CHECK(cbor_skip(r)); break; // extensions, ... + } + } + if (!client_data_hash || !rp_id.data || !user.id.data || !has_params) return CTAP2_ERR_MISSING_PARAMETER; + if (pin_auth.data && pin_auth.len == 0) return empty_pin_auth(); + if (!es256) return CTAP2_ERR_UNSUPPORTED_ALGORITHM; + if (opt.uv) return CTAP2_ERR_UNSUPPORTED_OPTION; + if (opt.up_present && !opt.up) return CTAP2_ERR_INVALID_OPTION; + if (user.id.len > RK_MAX_USER_ID) return CTAP1_ERR_INVALID_LENGTH; + + uint8_t rp_id_hash[32], priv[32], pub[64], cred_id[CRED_ID_LEN]; + hash_rp_id(rp_id, rp_id_hash); + + // Once a PIN is set, every registration needs it (CTAP 2.0 behavior). + bool uv = pin_auth.data != NULL; + if (uv) + CHECK(check_pin_auth(&pin_auth, pin_protocol, client_data_hash, PIN_PERMISSION_MC, rp_id_hash)); + else if (pin_is_set()) + return CTAP2_ERR_PUAT_REQUIRED; + + bytes_t excluded; + if (exclude.present && find_in_list(&exclude, rp_id_hash, &excluded, priv)) { + crypto_wipe(priv, sizeof(priv)); + CHECK(presence_wait()); // the spec requires a touch before saying so + return CTAP2_ERR_CREDENTIAL_EXCLUDED; + } + CHECK(presence_wait()); + + if (!cred_create(rp_id_hash, cred_id, priv, pub)) return CTAP2_ERR_PROCESSING; + crypto_wipe(priv, sizeof(priv)); // only the attestation key signs here + + uint32_t counter = storage_counter_next(); + if (opt.rk) CHECK(store_resident(rp_id_hash, rp_id, &user, cred_id, counter)); + + uint8_t auth_data[AUTH_DATA_MAX]; + size_t len = 0; + memcpy(auth_data, rp_id_hash, 32); + len += 32; + auth_data[len++] = FLAG_UP | FLAG_AT | (uv ? FLAG_UV : 0); + put_be32(auth_data + len, counter); + len += 4; + memcpy(auth_data + len, skey_aaguid, 16); + len += 16; + auth_data[len++] = 0; + auth_data[len++] = CRED_ID_LEN; + memcpy(auth_data + len, cred_id, CRED_ID_LEN); + len += CRED_ID_LEN; + cbor_writer_t cose; + cbor_writer_init(&cose, auth_data + len, sizeof(auth_data) - len); + put_cose_key(&cose, pub); + len += cose.len; + + // Packed attestation signed by the batch attestation key. + uint8_t sig[P256_SIG_MAX_DER]; + crypto_part_t signed_data[] = {{auth_data, len}, {client_data_hash, 32}}; + size_t sig_len = crypto_p256_sign(attestation_key, signed_data, 2, sig); + if (!sig_len) return CTAP2_ERR_PROCESSING; + + cbor_put_map(w, 3); + cbor_put_uint(w, 0x01); + cbor_put_cstr(w, "packed"); + cbor_put_uint(w, 0x02); + cbor_put_bytes(w, auth_data, len); + cbor_put_uint(w, 0x03); + cbor_put_map(w, 3); + cbor_put_cstr(w, "alg"); + cbor_put_int(w, COSE_ALG_ES256); + cbor_put_cstr(w, "sig"); + cbor_put_bytes(w, sig, sig_len); + cbor_put_cstr(w, "x5c"); + cbor_put_array(w, 1); + cbor_put_bytes(w, attestation_cert, attestation_cert_len); + return 0; +} + +// Lists the RP's discoverable credentials, newest first. +static size_t find_resident(const uint8_t rp_id_hash[32], int16_t slots[RK_SLOTS]) { + static rk_record_t rec; + uint32_t created[RK_SLOTS]; + size_t n = 0; + for (int slot = 0; slot < RK_SLOTS; slot++) { + if (!storage_rk_read(slot, &rec) || memcmp(rec.rp_id_hash, rp_id_hash, 32) != 0) continue; + size_t i = n++; + for (; i > 0 && created[i - 1] < rec.created; i--) { + slots[i] = slots[i - 1]; + created[i] = created[i - 1]; + } + slots[i] = (int16_t)slot; + created[i] = rec.created; + } + return n; +} + +// Looks up the resident record for a credential ID, if there is one. +static bool find_resident_by_id(const uint8_t rp_id_hash[32], bytes_t id, rk_record_t *rec) { + for (int slot = 0; slot < RK_SLOTS; slot++) { + if (storage_rk_read(slot, rec) && memcmp(rec->rp_id_hash, rp_id_hash, 32) == 0 && id.len == CRED_ID_LEN && + memcmp(rec->cred_id, id.data, CRED_ID_LEN) == 0) + return true; + } + return false; +} + +static void put_user(cbor_writer_t *w, const rk_record_t *user, bool verified) { + // Without user verification, only the user ID may be revealed. + bool name = verified && user->user_name_len > 0; + bool display_name = verified && user->display_name_len > 0; + cbor_put_map(w, 1 + name + display_name); + cbor_put_cstr(w, "id"); + cbor_put_bytes(w, user->user_id, user->user_id_len); + if (name) { + cbor_put_cstr(w, "name"); + cbor_put_text(w, user->user_name, user->user_name_len); + } + if (display_name) { + cbor_put_cstr(w, "displayName"); + cbor_put_text(w, user->display_name, user->display_name_len); + } +} + +static int write_assertion(cbor_writer_t *w, const uint8_t rp_id_hash[32], const uint8_t client_data_hash[32], + uint8_t flags, const uint8_t *cred_id, size_t cred_id_len, const uint8_t priv[32], + const rk_record_t *user, size_t number_of_credentials) { + uint8_t auth_data[AUTH_DATA_HEADER]; + uint8_t sig[P256_SIG_MAX_DER]; + + memcpy(auth_data, rp_id_hash, 32); + auth_data[32] = flags; + put_be32(auth_data + 33, storage_counter_next()); + crypto_part_t signed_data[] = {{auth_data, sizeof(auth_data)}, {client_data_hash, 32}}; + size_t sig_len = crypto_p256_sign(priv, signed_data, 2, sig); + if (!sig_len) return CTAP2_ERR_PROCESSING; + + cbor_put_map(w, 3 + (user != NULL) + (number_of_credentials > 0)); + cbor_put_uint(w, 0x01); + cbor_put_map(w, 2); + cbor_put_cstr(w, "id"); + cbor_put_bytes(w, cred_id, cred_id_len); + cbor_put_cstr(w, "type"); + cbor_put_cstr(w, "public-key"); + cbor_put_uint(w, 0x02); + cbor_put_bytes(w, auth_data, sizeof(auth_data)); + cbor_put_uint(w, 0x03); + cbor_put_bytes(w, sig, sig_len); + if (user) { + cbor_put_uint(w, 0x04); + put_user(w, user, flags & FLAG_UV); + } + if (number_of_credentials > 0) { + cbor_put_uint(w, 0x05); + cbor_put_uint(w, number_of_credentials); + } + return 0; +} + +static int get_assertion(cbor_reader_t *r, cbor_writer_t *w) { + static rk_record_t rec; + text_t rp_id = {0}; + const uint8_t *client_data_hash = NULL; + cred_list_t allow = {0}; + options_t opt = {.up = true}; + bytes_t pin_auth = {0}; + uint64_t pin_protocol = 0; + size_t n; + + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + uint64_t key; + CHECK(cbor_read_uint_key(r, &key)); + switch (key) { + case 0x01: CHECK(read_text(r, &rp_id)); break; + case 0x02: CHECK(read_hash(r, &client_data_hash)); break; + case 0x03: CHECK(parse_cred_list(r, &allow)); break; + case 0x05: CHECK(parse_options(r, &opt)); break; + case 0x06: CHECK(read_bytes(r, &pin_auth)); break; + case 0x07: CHECK(cbor_read_uint(r, &pin_protocol)); break; + default: CHECK(cbor_skip(r)); break; + } + } + if (!rp_id.data || !client_data_hash) return CTAP2_ERR_MISSING_PARAMETER; + if (pin_auth.data && pin_auth.len == 0) return empty_pin_auth(); + if (opt.uv || opt.rk_present) return CTAP2_ERR_UNSUPPORTED_OPTION; + + uint8_t rp_id_hash[32], priv[32]; + bytes_t id; + const rk_record_t *user = NULL; + size_t count = 0; + hash_rp_id(rp_id, rp_id_hash); + + uint8_t flags = opt.up ? FLAG_UP : 0; + if (pin_auth.data) { + CHECK(check_pin_auth(&pin_auth, pin_protocol, client_data_hash, PIN_PERMISSION_GA, rp_id_hash)); + flags |= FLAG_UV; + } + + if (allow.count > 0) { + if (!find_in_list(&allow, rp_id_hash, &id, priv)) return CTAP2_ERR_NO_CREDENTIALS; + if (find_resident_by_id(rp_id_hash, id, &rec)) user = &rec; + } else { + count = find_resident(rp_id_hash, pending.slots); + if (count == 0 || !storage_rk_read(pending.slots[0], &rec)) return CTAP2_ERR_NO_CREDENTIALS; + if (!cred_unwrap(rp_id_hash, rec.cred_id, CRED_ID_LEN, priv)) return CTAP2_ERR_NO_CREDENTIALS; + id.data = rec.cred_id; + id.len = CRED_ID_LEN; + user = &rec; + } + + int err = opt.up ? presence_wait() : CTAP2_OK; + if (err) { + crypto_wipe(priv, sizeof(priv)); + return err; + } + err = write_assertion(w, rp_id_hash, client_data_hash, flags, id.data, id.len, priv, user, + count > 1 ? count : 0); + crypto_wipe(priv, sizeof(priv)); + if (err) return err; + + if (count > 1) { + pending.active = true; + memcpy(pending.rp_id_hash, rp_id_hash, 32); + memcpy(pending.client_data_hash, client_data_hash, 32); + pending.flags = flags; + pending.count = count; + pending.next = 1; + pending.last_ms = platform_millis(); + } + return 0; +} + +static int get_next_assertion(cbor_writer_t *w) { + static rk_record_t rec; + uint8_t priv[32]; + + if (!pending.active || pending.next >= pending.count || + platform_millis() - pending.last_ms > NEXT_ASSERTION_TIMEOUT_MS) { + pending.active = false; + return CTAP2_ERR_NOT_ALLOWED; + } + int slot = pending.slots[pending.next++]; + pending.last_ms = platform_millis(); + if (!storage_rk_read(slot, &rec) || !cred_unwrap(pending.rp_id_hash, rec.cred_id, CRED_ID_LEN, priv)) + return CTAP2_ERR_NOT_ALLOWED; + int err = write_assertion(w, pending.rp_id_hash, pending.client_data_hash, pending.flags, rec.cred_id, + CRED_ID_LEN, priv, &rec, 0); + crypto_wipe(priv, sizeof(priv)); + return err; +} + +static int get_info(cbor_writer_t *w) { + cbor_put_map(w, 5); + cbor_put_uint(w, 0x01); // versions + cbor_put_array(w, 2); + cbor_put_cstr(w, "U2F_V2"); + cbor_put_cstr(w, "FIDO_2_0"); + cbor_put_uint(w, 0x03); // aaguid + cbor_put_bytes(w, skey_aaguid, 16); + cbor_put_uint(w, 0x04); // options + cbor_put_map(w, 5); + cbor_put_cstr(w, "rk"); + cbor_put_bool(w, true); + cbor_put_cstr(w, "up"); + cbor_put_bool(w, true); + cbor_put_cstr(w, "plat"); + cbor_put_bool(w, false); + cbor_put_cstr(w, "clientPin"); + cbor_put_bool(w, pin_is_set()); + cbor_put_cstr(w, "pinUvAuthToken"); + cbor_put_bool(w, true); + cbor_put_uint(w, 0x05); // maxMsgSize + cbor_put_uint(w, MAX_MSG_SIZE); + cbor_put_uint(w, 0x06); // pinUvAuthProtocols, preferred first + cbor_put_array(w, 2); + cbor_put_uint(w, 2); + cbor_put_uint(w, 1); + return 0; +} + +static int reset(void) { + // Only allowed shortly after power-up, so malware can't wipe a plugged-in key. + if (platform_millis() > RESET_WINDOW_MS) return CTAP2_ERR_NOT_ALLOWED; + CHECK(presence_wait()); + storage_reset(); + pin_init(); + return 0; +} + +size_t ctap2_handle(const uint8_t *req, size_t len, uint8_t *resp, size_t resp_cap) { + cbor_reader_t r; + cbor_writer_t w; + int status; + + cbor_reader_init(&r, req + 1, len - 1); + cbor_writer_init(&w, resp + 1, resp_cap - 1); + if (req[0] != CMD_GET_NEXT_ASSERTION) pending.active = false; + + switch (req[0]) { + case CMD_MAKE_CREDENTIAL: status = make_credential(&r, &w); break; + case CMD_GET_ASSERTION: status = get_assertion(&r, &w); break; + case CMD_GET_INFO: status = get_info(&w); break; + case CMD_CLIENT_PIN: status = pin_handle(&r, &w); break; + case CMD_RESET: status = reset(); break; + case CMD_GET_NEXT_ASSERTION: status = get_next_assertion(&w); break; + case CMD_SELECTION: status = presence_wait(); break; + default: status = CTAP1_ERR_INVALID_COMMAND; break; + } + if (status == CTAP2_OK && w.overflow) status = CTAP1_ERR_OTHER; + + resp[0] = (uint8_t)status; + return status == CTAP2_OK ? 1 + w.len : 1; +} diff --git a/src/ctaphid.c b/src/ctaphid.c new file mode 100644 index 0000000..657d2c6 --- /dev/null +++ b/src/ctaphid.c @@ -0,0 +1,266 @@ +#include "ctaphid.h" + +#include + +#include "ctap.h" +#include "util.h" + +#define CID_BROADCAST 0xffffffffu + +#define CTAPHID_PING 0x81 +#define CTAPHID_MSG 0x83 +#define CTAPHID_INIT 0x86 +#define CTAPHID_WINK 0x88 +#define CTAPHID_CBOR 0x90 +#define CTAPHID_CANCEL 0x91 +#define CTAPHID_KEEPALIVE 0xbb +#define CTAPHID_ERROR 0xbf + +#define ERR_INVALID_CMD 0x01 +#define ERR_INVALID_LEN 0x03 +#define ERR_INVALID_SEQ 0x04 +#define ERR_MSG_TIMEOUT 0x05 +#define ERR_CHANNEL_BUSY 0x06 +#define ERR_INVALID_CHANNEL 0x0b + +#define PROTOCOL_VERSION 2 +#define DEVICE_VERSION_MAJOR 0 +#define DEVICE_VERSION_MINOR 1 +#define DEVICE_VERSION_BUILD 0 +#define CAPABILITY_WINK 0x01 +#define CAPABILITY_CBOR 0x04 + +#define INIT_DATA (HID_PACKET_SIZE - 7) +#define CONT_DATA (HID_PACKET_SIZE - 5) +#define MAX_MESSAGE (INIT_DATA + 128 * CONT_DATA) +#define MESSAGE_TIMEOUT_MS 500 +#define KEEPALIVE_INTERVAL_MS 100 + +// The message being received. Only one channel can be mid-message at a time. +static struct { + bool active; + uint32_t cid; + uint8_t cmd; + uint16_t len; + uint16_t received; + uint8_t next_seq; + uint32_t last_ms; + uint8_t data[MAX_MESSAGE]; +} rx; + +// The request being handled. While a handler waits for the user, packets keep +// arriving: CANCEL or INIT on this channel abort it, other channels are busy. +static struct { + bool active; + uint32_t cid; + bool cancelled; + bool resynced; // the host reset the channel and expects no response + uint32_t last_keepalive_ms; +} busy; + +static uint8_t tx[MAX_MESSAGE]; +static uint32_t next_cid; + +void ctaphid_init(void) { + rx.active = false; + next_cid = 1; +} + +static void send_message(uint32_t cid, uint8_t cmd, const uint8_t *data, size_t len) { + uint8_t packet[HID_PACKET_SIZE] = {0}; + size_t n = len < INIT_DATA ? len : INIT_DATA; + put_be32(packet, cid); + packet[4] = cmd; + packet[5] = (uint8_t)(len >> 8); + packet[6] = (uint8_t)len; + if (n) memcpy(packet + 7, data, n); + platform_hid_send(packet); + + for (size_t off = n, seq = 0; off < len; off += n, seq++) { + n = len - off < CONT_DATA ? len - off : CONT_DATA; + memset(packet + 4, 0, sizeof(packet) - 4); + packet[4] = (uint8_t)seq; + memcpy(packet + 5, data + off, n); + platform_hid_send(packet); + } +} + +static void send_error(uint32_t cid, uint8_t code) { + send_message(cid, CTAPHID_ERROR, &code, 1); +} + +static void handle_init(uint32_t cid, const uint8_t *packet) { + uint8_t resp[17]; + if ((packet[5] << 8 | packet[6]) != 8) { + send_error(cid, ERR_INVALID_LEN); + return; + } + // INIT on an existing channel resynchronizes it. + if (rx.active && rx.cid == cid) rx.active = false; + + uint32_t new_cid = cid; + if (cid == CID_BROADCAST) { + new_cid = next_cid++; + if (next_cid == CID_BROADCAST) next_cid = 1; + } + memcpy(resp, packet + 7, 8); // nonce + put_be32(resp + 8, new_cid); + resp[12] = PROTOCOL_VERSION; + resp[13] = DEVICE_VERSION_MAJOR; + resp[14] = DEVICE_VERSION_MINOR; + resp[15] = DEVICE_VERSION_BUILD; + resp[16] = CAPABILITY_WINK | CAPABILITY_CBOR; + send_message(cid, CTAPHID_INIT, resp, sizeof(resp)); +} + +static void respond(uint8_t cmd, const uint8_t *data, size_t len) { + if (!busy.resynced) send_message(busy.cid, cmd, data, len); +} + +static void dispatch(void) { + size_t len = rx.len; + uint8_t error; + + busy.active = true; + busy.cid = rx.cid; + busy.cancelled = false; + busy.resynced = false; + busy.last_keepalive_ms = platform_millis(); + platform_led(true); + + switch (rx.cmd) { + case CTAPHID_PING: + respond(CTAPHID_PING, rx.data, len); + break; + case CTAPHID_MSG: + len = ctap1_handle(rx.data, len, tx); + respond(CTAPHID_MSG, tx, len); + break; + case CTAPHID_CBOR: + if (len == 0) { + error = ERR_INVALID_LEN; + respond(CTAPHID_ERROR, &error, 1); + break; + } + len = ctap2_handle(rx.data, len, tx, sizeof(tx)); + respond(CTAPHID_CBOR, tx, len); + break; + case CTAPHID_WINK: + platform_wink(); + respond(CTAPHID_WINK, NULL, 0); + break; + default: + error = ERR_INVALID_CMD; + respond(CTAPHID_ERROR, &error, 1); + break; + } + + platform_led(false); + busy.active = false; + rx.active = false; +} + +// Packets that arrive while a request is being handled. +static void handle_packet_while_busy(uint32_t cid, const uint8_t *packet) { + uint8_t cmd = packet[4]; + if (!(cmd & 0x80)) return; // continuation packets have nothing to continue + if (cid == busy.cid && (cmd == CTAPHID_CANCEL || cmd == CTAPHID_INIT)) { + busy.cancelled = true; + if (cmd == CTAPHID_INIT) { + busy.resynced = true; + handle_init(cid, packet); + } + } else if (cmd == CTAPHID_INIT && cid == CID_BROADCAST) { + handle_init(cid, packet); // allocating a channel doesn't disturb anyone + } else if (cmd != CTAPHID_CANCEL) { + send_error(cid, ERR_CHANNEL_BUSY); + } +} + +bool ctaphid_keepalive(uint8_t status) { + uint8_t packet[HID_PACKET_SIZE]; + uint32_t now = platform_millis(); + if (now - busy.last_keepalive_ms >= KEEPALIVE_INTERVAL_MS) { + respond(CTAPHID_KEEPALIVE, &status, 1); + busy.last_keepalive_ms = now; + } + while (!busy.cancelled && platform_hid_receive(packet)) ctaphid_handle_packet(packet); + return !busy.cancelled; +} + +static void handle_init_packet(uint32_t cid, const uint8_t *packet) { + uint8_t cmd = packet[4]; + uint16_t len = (uint16_t)(packet[5] << 8 | packet[6]); + + if (cmd == CTAPHID_INIT) { + if (cid == 0) + send_error(cid, ERR_INVALID_CHANNEL); + else + handle_init(cid, packet); + return; + } + if (cid == 0 || cid == CID_BROADCAST) { + send_error(cid, ERR_INVALID_CHANNEL); + return; + } + if (cmd == CTAPHID_CANCEL) return; // nothing in progress to cancel + if (rx.active) { + if (rx.cid != cid) { + send_error(cid, ERR_CHANNEL_BUSY); + return; + } + // A new message before the previous one finished. + rx.active = false; + send_error(cid, ERR_INVALID_SEQ); + return; + } + if (len > MAX_MESSAGE) { + send_error(cid, ERR_INVALID_LEN); + return; + } + + size_t n = len < INIT_DATA ? len : INIT_DATA; + memcpy(rx.data, packet + 7, n); + rx.active = true; + rx.cid = cid; + rx.cmd = cmd; + rx.len = len; + rx.received = (uint16_t)n; + rx.next_seq = 0; + rx.last_ms = platform_millis(); +} + +static void handle_cont_packet(uint32_t cid, const uint8_t *packet) { + // Continuation packets for messages we are not receiving are ignored. + if (!rx.active || rx.cid != cid) return; + if (packet[4] != rx.next_seq) { + rx.active = false; + send_error(cid, ERR_INVALID_SEQ); + return; + } + size_t n = rx.len - rx.received < CONT_DATA ? rx.len - rx.received : CONT_DATA; + memcpy(rx.data + rx.received, packet + 5, n); + rx.received = (uint16_t)(rx.received + n); + rx.next_seq++; + rx.last_ms = platform_millis(); +} + +void ctaphid_handle_packet(const uint8_t packet[HID_PACKET_SIZE]) { + uint32_t cid = get_be32(packet); + if (busy.active) { + handle_packet_while_busy(cid, packet); + return; + } + if (packet[4] & 0x80) + handle_init_packet(cid, packet); + else + handle_cont_packet(cid, packet); + if (rx.active && rx.received == rx.len) dispatch(); +} + +void ctaphid_tick(void) { + if (rx.active && platform_millis() - rx.last_ms > MESSAGE_TIMEOUT_MS) { + rx.active = false; + send_error(rx.cid, ERR_MSG_TIMEOUT); + } +} diff --git a/src/ctaphid.h b/src/ctaphid.h new file mode 100644 index 0000000..7bf2283 --- /dev/null +++ b/src/ctaphid.h @@ -0,0 +1,23 @@ +// CTAPHID transport (CTAP 2.1, section 11.2): reassembles 64-byte HID reports +// into messages and dispatches them to the U2F and CTAP2 handlers. +#pragma once + +#include + +#include "platform.h" + +void ctaphid_init(void); + +// Handles one output report from the host. +void ctaphid_handle_packet(const uint8_t packet[HID_PACKET_SIZE]); + +// Call regularly; expires messages whose continuation packets stopped coming. +void ctaphid_tick(void); + +#define CTAPHID_STATUS_PROCESSING 1 +#define CTAPHID_STATUS_UPNEEDED 2 + +// For request handlers that block, e.g. waiting for a touch. Call it in the +// wait loop: it sends periodic keepalives and handles packets that arrive +// meanwhile. Returns false once the host has cancelled the request. +bool ctaphid_keepalive(uint8_t status); diff --git a/src/pin.c b/src/pin.c new file mode 100644 index 0000000..3ab9f88 --- /dev/null +++ b/src/pin.c @@ -0,0 +1,395 @@ +#include "pin.h" + +#include + +#include "crypto.h" +#include "ctap.h" +#include "platform.h" +#include "storage.h" + +#define SUB_GET_RETRIES 0x01 +#define SUB_GET_KEY_AGREEMENT 0x02 +#define SUB_SET_PIN 0x03 +#define SUB_CHANGE_PIN 0x04 +#define SUB_GET_PIN_TOKEN 0x05 +#define SUB_GET_TOKEN_WITH_PERMISSIONS 0x09 + +#define MAX_RETRIES 8 +#define MAX_CONSECUTIVE_FAILURES 3 // then the key must be unplugged +#define MIN_PIN_CODE_POINTS 4 +#define PADDED_PIN_LEN 64 +#define PIN_HASH_LEN 16 +#define TOKEN_LIFETIME_MS (10 * 60 * 1000) +#define COSE_ALG_ECDH_ES_HKDF_256 (-25) + +// Protocol 1 shares a 32-byte AES/HMAC key; protocol 2 an HMAC key followed by an AES key. +#define SHARED_SECRET_MAX 64 +#define IV_LEN 16 + +static pin_state_t state; +static uint8_t consecutive_failures; // since power-up + +// Key agreement key pair, new at power-up and after each wrong PIN. +static uint8_t ka_priv[32]; +static uint8_t ka_pub[64]; + +static struct { + bool valid; + uint8_t value[32]; + uint8_t permissions; + bool rp_bound; + uint8_t rp_id_hash[32]; + uint32_t issued_ms; +} token; + +typedef struct { + uint64_t protocol; + uint64_t subcommand; + bool has_key_agreement; + uint8_t key_agreement[64]; + const uint8_t *pin_uv_auth_param; + size_t pin_uv_auth_param_len; + const uint8_t *new_pin_enc; + size_t new_pin_enc_len; + const uint8_t *pin_hash_enc; + size_t pin_hash_enc_len; + bool has_permissions; + uint64_t permissions; + const char *rp_id; + size_t rp_id_len; +} request_t; + +static void new_key_agreement(void) { + for (int attempt = 0; attempt < 4; attempt++) { + platform_random(ka_priv, sizeof(ka_priv)); + if (crypto_p256_public(ka_priv, ka_pub)) return; + } + platform_panic("key agreement key generation failed"); +} + +static void reset_token(void) { + platform_random(token.value, sizeof(token.value)); + token.valid = false; + token.permissions = 0; + token.rp_bound = false; +} + +void pin_init(void) { + if (!storage_pin_load(&state)) { + memset(&state, 0, sizeof(state)); + state.retries = MAX_RETRIES; + } + consecutive_failures = 0; + new_key_agreement(); + reset_token(); +} + +bool pin_is_set(void) { + return state.set; +} + +static bool protocol_supported(uint64_t protocol) { + return protocol == 1 || protocol == 2; +} + +// HKDF-SHA-256 with a zero salt and a 32-byte output (RFC 5869). +static void hkdf(const uint8_t ikm[32], const char *info, uint8_t out[32]) { + static const uint8_t zero_salt[32] = {0}; + static const uint8_t block_index = 0x01; + uint8_t prk[32]; + crypto_part_t extract = {ikm, 32}; + crypto_hmac_sha256(zero_salt, &extract, 1, prk); + crypto_part_t expand[] = {{info, strlen(info)}, {&block_index, 1}}; + crypto_hmac_sha256(prk, expand, 2, out); + crypto_wipe(prk, sizeof(prk)); +} + +// The shared secret with the platform's key agreement key (CTAP 2.1, 6.5.6 and 6.5.7). +static int shared_secret(const request_t *req, uint8_t out[SHARED_SECRET_MAX]) { + uint8_t z[32]; + if (!req->has_key_agreement) return CTAP2_ERR_MISSING_PARAMETER; + if (!crypto_p256_ecdh(ka_priv, req->key_agreement, z)) return CTAP1_ERR_INVALID_PARAMETER; + if (req->protocol == 1) { + crypto_part_t part = {z, sizeof(z)}; + crypto_sha256(&part, 1, out); + } else { + hkdf(z, "CTAP2 HMAC key", out); + hkdf(z, "CTAP2 AES key", out + 32); + } + crypto_wipe(z, sizeof(z)); + return 0; +} + +// Protocol 1 uses a zero IV; protocol 2 prepends a random one. +static bool decrypt(uint64_t protocol, const uint8_t *shared, const uint8_t *in, size_t in_len, uint8_t *out, + size_t out_len) { + static const uint8_t zero_iv[IV_LEN] = {0}; + if (protocol == 1) return in_len == out_len && crypto_aes256_cbc(false, shared, zero_iv, in, out, out_len); + return in_len == IV_LEN + out_len && crypto_aes256_cbc(false, shared + 32, in, in + IV_LEN, out, out_len); +} + +static size_t encrypt(uint64_t protocol, const uint8_t *shared, const uint8_t *in, size_t len, uint8_t *out) { + static const uint8_t zero_iv[IV_LEN] = {0}; + if (protocol == 1) return crypto_aes256_cbc(true, shared, zero_iv, in, out, len) ? len : 0; + platform_random(out, IV_LEN); + return crypto_aes256_cbc(true, shared + 32, out, in, out + IV_LEN, len) ? IV_LEN + len : 0; +} + +// The MAC is HMAC-SHA-256, truncated to 16 bytes in protocol 1. Keys are +// 32 bytes: a pinUvAuthToken, or the first part of the shared secret. +static bool verify(uint64_t protocol, const uint8_t key[32], const crypto_part_t *parts, size_t count, + const uint8_t *mac, size_t mac_len) { + uint8_t expected[32]; + crypto_hmac_sha256(key, parts, count, expected); + size_t want = protocol == 1 ? 16 : 32; + return mac && mac_len == want && crypto_equal(expected, mac, want); +} + +// Validates a decrypted, zero-padded new PIN and stores it. +static int store_new_pin(const uint8_t padded[PADDED_PIN_LEN]) { + size_t len = 0, code_points = 0; + while (len < PADDED_PIN_LEN && padded[len]) { + if ((padded[len] & 0xc0) != 0x80) code_points++; + len++; + } + if (len == PADDED_PIN_LEN || code_points < MIN_PIN_CODE_POINTS) return CTAP2_ERR_PIN_POLICY_VIOLATION; + + uint8_t hash[32]; + crypto_part_t part = {padded, len}; + crypto_sha256(&part, 1, hash); + state.set = true; + state.retries = MAX_RETRIES; + memcpy(state.hash, hash, PIN_HASH_LEN); + storage_pin_save(&state); + crypto_wipe(hash, sizeof(hash)); + return 0; +} + +// Checks pinHashEnc against the stored PIN. A retry is spent before the +// comparison, so cutting power mid-check doesn't give a free guess. +static int check_pin(const request_t *req, const uint8_t *shared) { + uint8_t pin_hash[PIN_HASH_LEN]; + + if (!req->pin_hash_enc) return CTAP2_ERR_MISSING_PARAMETER; + if (state.retries == 0) return CTAP2_ERR_PIN_BLOCKED; + if (consecutive_failures >= MAX_CONSECUTIVE_FAILURES) return CTAP2_ERR_PIN_AUTH_BLOCKED; + + state.retries--; + storage_pin_save(&state); + bool match = decrypt(req->protocol, shared, req->pin_hash_enc, req->pin_hash_enc_len, pin_hash, + sizeof(pin_hash)) && + crypto_equal(pin_hash, state.hash, PIN_HASH_LEN); + crypto_wipe(pin_hash, sizeof(pin_hash)); + if (!match) { + new_key_agreement(); + consecutive_failures++; + if (state.retries == 0) return CTAP2_ERR_PIN_BLOCKED; + if (consecutive_failures >= MAX_CONSECUTIVE_FAILURES) return CTAP2_ERR_PIN_AUTH_BLOCKED; + return CTAP2_ERR_PIN_INVALID; + } + consecutive_failures = 0; + state.retries = MAX_RETRIES; + storage_pin_save(&state); + return 0; +} + +static int set_pin(const request_t *req, const uint8_t *shared) { + uint8_t padded[PADDED_PIN_LEN]; + crypto_part_t signed_data = {req->new_pin_enc, req->new_pin_enc_len}; + + if (!req->new_pin_enc || !req->pin_uv_auth_param) return CTAP2_ERR_MISSING_PARAMETER; + if (state.set) return CTAP2_ERR_PIN_AUTH_INVALID; + if (!verify(req->protocol, shared, &signed_data, 1, req->pin_uv_auth_param, req->pin_uv_auth_param_len)) + return CTAP2_ERR_PIN_AUTH_INVALID; + if (!decrypt(req->protocol, shared, req->new_pin_enc, req->new_pin_enc_len, padded, sizeof(padded))) + return CTAP1_ERR_INVALID_PARAMETER; + int err = store_new_pin(padded); + crypto_wipe(padded, sizeof(padded)); + return err; +} + +static int change_pin(const request_t *req, const uint8_t *shared) { + uint8_t padded[PADDED_PIN_LEN]; + crypto_part_t signed_data[] = { + {req->new_pin_enc, req->new_pin_enc_len}, + {req->pin_hash_enc, req->pin_hash_enc_len}, + }; + + if (!req->new_pin_enc || !req->pin_hash_enc || !req->pin_uv_auth_param) return CTAP2_ERR_MISSING_PARAMETER; + if (!state.set) return CTAP2_ERR_PIN_NOT_SET; + if (state.retries == 0) return CTAP2_ERR_PIN_BLOCKED; + if (!verify(req->protocol, shared, signed_data, 2, req->pin_uv_auth_param, req->pin_uv_auth_param_len)) + return CTAP2_ERR_PIN_AUTH_INVALID; + CHECK(check_pin(req, shared)); + if (!decrypt(req->protocol, shared, req->new_pin_enc, req->new_pin_enc_len, padded, sizeof(padded))) + return CTAP1_ERR_INVALID_PARAMETER; + int err = store_new_pin(padded); + crypto_wipe(padded, sizeof(padded)); + reset_token(); + return err; +} + +static int get_token(const request_t *req, const uint8_t *shared, cbor_writer_t *w) { + uint8_t permissions = PIN_PERMISSION_MC | PIN_PERMISSION_GA; + uint8_t encrypted[IV_LEN + sizeof(token.value)]; + + if (req->subcommand == SUB_GET_TOKEN_WITH_PERMISSIONS) { + if (!req->has_permissions) return CTAP2_ERR_MISSING_PARAMETER; + if (req->permissions == 0) return CTAP1_ERR_INVALID_PARAMETER; + if (req->permissions & ~(uint64_t)(PIN_PERMISSION_MC | PIN_PERMISSION_GA)) + return CTAP2_ERR_UNAUTHORIZED_PERMISSION; + permissions = (uint8_t)req->permissions; + } + if (!state.set) return CTAP2_ERR_PIN_NOT_SET; + CHECK(check_pin(req, shared)); + + // Each new token replaces the previous one. + reset_token(); + token.valid = true; + token.permissions = permissions; + token.issued_ms = platform_millis(); + if (req->rp_id) { + crypto_part_t part = {req->rp_id, req->rp_id_len}; + crypto_sha256(&part, 1, token.rp_id_hash); + token.rp_bound = true; + } + + size_t len = encrypt(req->protocol, shared, token.value, sizeof(token.value), encrypted); + if (!len) return CTAP2_ERR_PROCESSING; + cbor_put_map(w, 1); + cbor_put_uint(w, 0x02); // pinUvAuthToken + cbor_put_bytes(w, encrypted, len); + return 0; +} + +static void put_key_agreement(cbor_writer_t *w) { + cbor_put_map(w, 1); + cbor_put_uint(w, 0x01); // keyAgreement + cbor_put_map(w, 5); + cbor_put_int(w, 1); // kty: EC2 + cbor_put_int(w, 2); + cbor_put_int(w, 3); // alg: ECDH-ES+HKDF-256 + cbor_put_int(w, COSE_ALG_ECDH_ES_HKDF_256); + cbor_put_int(w, -1); // crv: P-256 + cbor_put_int(w, 1); + cbor_put_int(w, -2); + cbor_put_bytes(w, ka_pub, 32); + cbor_put_int(w, -3); + cbor_put_bytes(w, ka_pub + 32, 32); +} + +static int read_coordinate(cbor_reader_t *r, uint8_t out[32]) { + const uint8_t *data; + size_t len; + CHECK(cbor_read_bytes(r, &data, &len)); + if (len != 32) return CTAP1_ERR_INVALID_PARAMETER; + memcpy(out, data, 32); + return 0; +} + +// The platform's key agreement key: a P-256 COSE_Key. +static int read_key_agreement(cbor_reader_t *r, uint8_t pub[64]) { + size_t n; + int64_t kty = 0, crv = 0; + bool has_x = false, has_y = false; + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + int64_t key; + CHECK(cbor_read_int(r, &key)); + switch (key) { + case 1: CHECK(cbor_read_int(r, &kty)); break; + case -1: CHECK(cbor_read_int(r, &crv)); break; + case -2: CHECK(read_coordinate(r, pub)); has_x = true; break; + case -3: CHECK(read_coordinate(r, pub + 32)); has_y = true; break; + default: CHECK(cbor_skip(r)); break; // alg varies between platforms + } + } + return kty == 2 && crv == 1 && has_x && has_y ? 0 : CTAP1_ERR_INVALID_PARAMETER; +} + +static int parse_request(cbor_reader_t *r, request_t *req) { + size_t n; + bool has_subcommand = false; + CHECK(cbor_read_map(r, &n)); + for (size_t i = 0; i < n; i++) { + uint64_t key; + CHECK(cbor_read_uint_key(r, &key)); + switch (key) { + case 0x01: CHECK(cbor_read_uint(r, &req->protocol)); break; + case 0x02: CHECK(cbor_read_uint(r, &req->subcommand)); has_subcommand = true; break; + case 0x03: + CHECK(read_key_agreement(r, req->key_agreement)); + req->has_key_agreement = true; + break; + case 0x04: CHECK(cbor_read_bytes(r, &req->pin_uv_auth_param, &req->pin_uv_auth_param_len)); break; + case 0x05: CHECK(cbor_read_bytes(r, &req->new_pin_enc, &req->new_pin_enc_len)); break; + case 0x06: CHECK(cbor_read_bytes(r, &req->pin_hash_enc, &req->pin_hash_enc_len)); break; + case 0x09: + CHECK(cbor_read_uint(r, &req->permissions)); + req->has_permissions = true; + break; + case 0x0a: CHECK(cbor_read_text(r, &req->rp_id, &req->rp_id_len)); break; + default: CHECK(cbor_skip(r)); break; + } + } + return has_subcommand ? 0 : CTAP2_ERR_MISSING_PARAMETER; +} + +int pin_handle(cbor_reader_t *r, cbor_writer_t *w) { + static request_t req; + uint8_t shared[SHARED_SECRET_MAX]; + int err; + + memset(&req, 0, sizeof(req)); + CHECK(parse_request(r, &req)); + + if (req.subcommand == SUB_GET_RETRIES) { + cbor_put_map(w, 1); + cbor_put_uint(w, 0x03); // pinRetries + cbor_put_uint(w, state.retries); + return 0; + } + if (!req.protocol) return CTAP2_ERR_MISSING_PARAMETER; + if (!protocol_supported(req.protocol)) return CTAP1_ERR_INVALID_PARAMETER; + + switch (req.subcommand) { + case SUB_GET_KEY_AGREEMENT: + put_key_agreement(w); + return 0; + case SUB_SET_PIN: + case SUB_CHANGE_PIN: + case SUB_GET_PIN_TOKEN: + case SUB_GET_TOKEN_WITH_PERMISSIONS: + break; + default: + return CTAP2_ERR_INVALID_SUBCOMMAND; // including the built-in UV ones + } + + CHECK(shared_secret(&req, shared)); + if (req.subcommand == SUB_SET_PIN) + err = set_pin(&req, shared); + else if (req.subcommand == SUB_CHANGE_PIN) + err = change_pin(&req, shared); + else + err = get_token(&req, shared, w); + crypto_wipe(shared, sizeof(shared)); + return err; +} + +int pin_check_token(uint64_t protocol, const uint8_t *param, size_t param_len, const uint8_t client_data_hash[32], + uint8_t permission, const uint8_t rp_id_hash[32]) { + crypto_part_t signed_data = {client_data_hash, 32}; + + if (!protocol_supported(protocol)) return CTAP1_ERR_INVALID_PARAMETER; + if (token.valid && platform_millis() - token.issued_ms > TOKEN_LIFETIME_MS) reset_token(); + if (!token.valid || !verify(protocol, token.value, &signed_data, 1, param, param_len)) + return CTAP2_ERR_PIN_AUTH_INVALID; + if (!(token.permissions & permission)) return CTAP2_ERR_PIN_AUTH_INVALID; + if (token.rp_bound && memcmp(token.rp_id_hash, rp_id_hash, 32) != 0) return CTAP2_ERR_PIN_AUTH_INVALID; + // A token requested without an RP belongs to the first RP it is used for. + if (!token.rp_bound) { + memcpy(token.rp_id_hash, rp_id_hash, 32); + token.rp_bound = true; + } + return 0; +} diff --git a/src/pin.h b/src/pin.h new file mode 100644 index 0000000..d0e23f9 --- /dev/null +++ b/src/pin.h @@ -0,0 +1,26 @@ +// clientPIN (CTAP 2.1, section 6.5): the PIN and its retry counters, PIN/UV +// auth protocols 1 and 2, and the pinUvAuthToken. +#pragma once + +#include +#include +#include + +#include "cbor.h" + +#define PIN_PERMISSION_MC 0x01 // makeCredential +#define PIN_PERMISSION_GA 0x02 // getAssertion + +// Call at power-up, after storage_init(), and again after a reset. +void pin_init(void); + +bool pin_is_set(void); + +// authenticatorClientPIN. +int pin_handle(cbor_reader_t *r, cbor_writer_t *w); + +// Checks a request's pinUvAuthParam: a MAC of clientDataHash under the current +// pinUvAuthToken, which must carry `permission` and belong to this RP. +// Returns a CTAP status code. +int pin_check_token(uint64_t protocol, const uint8_t *param, size_t param_len, const uint8_t client_data_hash[32], + uint8_t permission, const uint8_t rp_id_hash[32]); diff --git a/src/platform.h b/src/platform.h new file mode 100644 index 0000000..04d87d5 --- /dev/null +++ b/src/platform.h @@ -0,0 +1,36 @@ +// Hardware abstraction. Implemented by pico/ (firmware) and host/ (simulator). +#pragma once + +#include +#include +#include + +#define HID_PACKET_SIZE 64 + +// Persistent storage is a small region of NOR flash: erase sets a whole sector +// to 0xFF, and programming writes whole pages and can only clear bits. +#define STORAGE_SECTOR_SIZE 4096u +#define STORAGE_PAGE_SIZE 256u +#define STORAGE_SECTORS 16u +#define STORAGE_SIZE (STORAGE_SECTORS * STORAGE_SECTOR_SIZE) + +void platform_init(void); +uint32_t platform_millis(void); +void platform_random(uint8_t *buf, size_t len); +void platform_panic(const char *msg); + +// The touch button. Each press is reported once: returns true if there is an +// unused press made within the last max_age_ms, and uses it up. +bool platform_button_take_press(uint32_t max_age_ms); + +void platform_led(bool on); +void platform_wink(void); + +// Offsets are relative to the start of the storage region. +void platform_flash_read(uint32_t offset, void *buf, size_t len); +void platform_flash_erase(uint32_t offset); +void platform_flash_program(uint32_t offset, const void *buf, size_t len); + +void platform_hid_send(const uint8_t packet[HID_PACKET_SIZE]); +// Takes the next output report from the host without blocking. +bool platform_hid_receive(uint8_t packet[HID_PACKET_SIZE]); diff --git a/src/presence.c b/src/presence.c new file mode 100644 index 0000000..7785c34 --- /dev/null +++ b/src/presence.c @@ -0,0 +1,60 @@ +#include "presence.h" + +#include "ctap.h" +#include "ctaphid.h" +#include "platform.h" + +#define WAIT_TIMEOUT_MS 30000 +#define WAIT_BLINK_MS 125 + +// A U2F request succeeds if the button was pressed within this window, which +// covers the gap between the host's retries. +#define U2F_PRESS_MAX_AGE_MS 3000 +#define U2F_PROMPT_MS 1000 +#define U2F_BLINK_MS 250 + +static bool prompting; +static uint32_t prompt_until_ms; + +int presence_wait(void) { + uint32_t start = platform_millis(); + int status = CTAP2_ERR_USER_ACTION_TIMEOUT; + + for (;;) { + uint32_t elapsed = platform_millis() - start; + if (elapsed >= WAIT_TIMEOUT_MS) break; + platform_led(elapsed / WAIT_BLINK_MS % 2 == 0); + if (!ctaphid_keepalive(CTAPHID_STATUS_UPNEEDED)) { + status = CTAP2_ERR_KEEPALIVE_CANCEL; + break; + } + // Only a press made after the request arrived counts. + if (platform_button_take_press(elapsed)) { + status = CTAP2_OK; + break; + } + } + platform_led(true); // back to "busy" + return status; +} + +bool presence_check(void) { + if (platform_button_take_press(U2F_PRESS_MAX_AGE_MS)) { + prompting = false; + return true; + } + prompting = true; + prompt_until_ms = platform_millis() + U2F_PROMPT_MS; + return false; +} + +void presence_tick(void) { + if (!prompting) return; + uint32_t now = platform_millis(); + if ((int32_t)(now - prompt_until_ms) >= 0) { + prompting = false; + platform_led(false); + return; + } + platform_led(now / U2F_BLINK_MS % 2 == 0); +} diff --git a/src/presence.h b/src/presence.h new file mode 100644 index 0000000..88a4c2b --- /dev/null +++ b/src/presence.h @@ -0,0 +1,17 @@ +// User presence: waiting for a touch of the button. +#pragma once + +#include + +// CTAP2: blocks until the button is pressed, keeping the host informed with +// keepalives. Returns CTAP2_OK, CTAP2_ERR_KEEPALIVE_CANCEL if the host +// cancelled, or CTAP2_ERR_USER_ACTION_TIMEOUT. +int presence_wait(void); + +// U2F: hosts retry until a request succeeds, so this never blocks. Returns +// true if the button was pressed recently; otherwise starts blinking the LED +// to ask for a touch and returns false. +bool presence_check(void); + +// Call regularly from the main loop to drive the LED between U2F retries. +void presence_tick(void); diff --git a/src/storage.c b/src/storage.c new file mode 100644 index 0000000..b47bbbd --- /dev/null +++ b/src/storage.c @@ -0,0 +1,271 @@ +#include "storage.h" + +#include + +#include "ctap.h" +#include "platform.h" + +// Layout of the storage region, in 4 KB sectors: +// 0 identity (master secret) +// 1-2 signature counter log, used alternately +// 3-10 resident credentials, 8 slots per sector +// 11-12 PIN state log, used alternately +// 13-15 unused +#define SECTOR(n) ((uint32_t)(n) * STORAGE_SECTOR_SIZE) +#define IDENTITY_OFFSET SECTOR(0) +#define COUNTER_OFFSET SECTOR(1) +#define COUNTER_END SECTOR(3) +#define RK_OFFSET SECTOR(3) +#define RK_PER_SECTOR ((int)(STORAGE_SECTOR_SIZE / sizeof(rk_record_t))) +#define RK_SECTORS (RK_SLOTS / RK_PER_SECTOR) +#define PIN_OFFSET SECTOR(11) +#define PIN_END SECTOR(13) + +_Static_assert(RK_OFFSET + SECTOR(RK_SECTORS) <= PIN_OFFSET, "resident credentials overlap the PIN log"); +_Static_assert(PIN_END <= STORAGE_SIZE, "storage layout does not fit the region"); + +#define IDENTITY_MAGIC 0x31494b53u // "SKI1" +#define RK_MAGIC 0x4b524b53u // "SKRK" +#define PIN_MAGIC 0x4e494b53u // "SKIN" +#define ERASED 0xffffffffu + +typedef struct { + uint32_t magic; + uint8_t master_secret[32]; +} identity_t; + +// One entry of the PIN log; the entry with the highest sequence number is current. +typedef struct { + uint32_t magic; // written last + uint32_t seq; + uint8_t set; + uint8_t retries; + uint8_t hash[16]; + uint8_t reserved[6]; +} pin_record_t; + +_Static_assert(STORAGE_PAGE_SIZE % sizeof(pin_record_t) == 0, "PIN records must not straddle pages"); + +static identity_t identity; +static uint32_t counter; +static uint32_t counter_next_offset; +static uint32_t pin_seq; +static uint32_t pin_next_offset; + +static bool is_erased(const void *buf, size_t len) { + const uint8_t *p = buf; + for (size_t i = 0; i < len; i++) { + if (p[i] != 0xff) return false; + } + return true; +} + +// Writes arbitrary bytes. Flash programs whole pages, so the data is padded +// with 0xFF, which leaves the rest of each page unchanged. +static void program(uint32_t offset, const void *data, size_t len) { + static uint8_t page[STORAGE_PAGE_SIZE]; + const uint8_t *src = data; + while (len > 0) { + uint32_t page_start = offset & ~(STORAGE_PAGE_SIZE - 1); + size_t in_page = offset - page_start; + size_t n = STORAGE_PAGE_SIZE - in_page; + if (n > len) n = len; + memset(page, 0xff, sizeof(page)); + memcpy(page + in_page, src, n); + platform_flash_program(page_start, page, sizeof(page)); + offset += n; + src += n; + len -= n; + } +} + +static void new_identity(void) { + identity.magic = IDENTITY_MAGIC; + platform_random(identity.master_secret, sizeof(identity.master_secret)); + platform_flash_erase(IDENTITY_OFFSET); + program(IDENTITY_OFFSET, &identity, sizeof(identity)); +} + +// The counter log holds increasing values; the current counter is the largest. +static void load_counter(void) { + counter = 0; + counter_next_offset = COUNTER_OFFSET; + for (uint32_t off = COUNTER_OFFSET; off < COUNTER_END; off += sizeof(uint32_t)) { + uint32_t v; + platform_flash_read(off, &v, sizeof(v)); + if (v != ERASED && v >= counter) { + counter = v; + counter_next_offset = off + sizeof(uint32_t); + } + } +} + +void storage_init(void) { + platform_flash_read(IDENTITY_OFFSET, &identity, sizeof(identity)); + if (identity.magic != IDENTITY_MAGIC) { + // First boot, or the region holds something else: start from scratch. + for (uint32_t off = 0; off < STORAGE_SIZE; off += STORAGE_SECTOR_SIZE) platform_flash_erase(off); + new_identity(); + } + load_counter(); +} + +const uint8_t *storage_master_secret(void) { + return identity.master_secret; +} + +uint32_t storage_counter_next(void) { + uint32_t off = counter_next_offset; + if (off % STORAGE_SECTOR_SIZE == 0) { + // Starting a fresh sector. The one being erased only holds older + // values; the current value stays in the other sector until the new + // one is written. + if (off == COUNTER_END) off = COUNTER_OFFSET; + platform_flash_erase(off); + } + counter++; + program(off, &counter, sizeof(counter)); + counter_next_offset = off + sizeof(counter); + return counter; +} + +static uint32_t rk_offset(int slot) { + return RK_OFFSET + (uint32_t)slot * sizeof(rk_record_t); +} + +typedef enum { SLOT_FREE, SLOT_LIVE, SLOT_DEAD } slot_state_t; + +// Dead slots hold deleted or half-written records and need an erase. +static slot_state_t slot_state(int slot, rk_record_t *rec) { + platform_flash_read(rk_offset(slot), rec, sizeof(*rec)); + if (rec->magic == RK_MAGIC) return SLOT_LIVE; + return is_erased(rec, sizeof(*rec)) ? SLOT_FREE : SLOT_DEAD; +} + +// The magic goes in last, so an interrupted write never looks valid. +static void write_record(int slot, const rk_record_t *rec) { + static rk_record_t body; + uint32_t magic = RK_MAGIC; + body = *rec; + body.magic = ERASED; + program(rk_offset(slot), &body, sizeof(body)); + program(rk_offset(slot), &magic, sizeof(magic)); +} + +static int find_free_slot(void) { + static rk_record_t scratch; + for (int slot = 0; slot < RK_SLOTS; slot++) { + if (slot_state(slot, &scratch) == SLOT_FREE) return slot; + } + return -1; +} + +static int find_same_user(const rk_record_t *rec) { + static rk_record_t cur; + for (int slot = 0; slot < RK_SLOTS; slot++) { + if (storage_rk_read(slot, &cur) && memcmp(cur.rp_id_hash, rec->rp_id_hash, 32) == 0 && + cur.user_id_len == rec->user_id_len && memcmp(cur.user_id, rec->user_id, rec->user_id_len) == 0) + return slot; + } + return -1; +} + +// Reclaims dead slots by rewriting each sector that has any. The sector's +// live records are held in RAM while it is erased. +static void compact(void) { + static rk_record_t live[RK_PER_SECTOR]; + for (int sector = 0; sector < RK_SECTORS; sector++) { + int first = sector * RK_PER_SECTOR; + int n = 0; + bool dead = false; + for (int i = 0; i < RK_PER_SECTOR; i++) { + slot_state_t state = slot_state(first + i, &live[n]); + if (state == SLOT_LIVE) n++; + if (state == SLOT_DEAD) dead = true; + } + if (!dead) continue; + platform_flash_erase(RK_OFFSET + SECTOR(sector)); + for (int i = 0; i < n; i++) write_record(first + i, &live[i]); + } +} + +bool storage_rk_read(int slot, rk_record_t *out) { + platform_flash_read(rk_offset(slot), out, sizeof(*out)); + return out->magic == RK_MAGIC && out->user_id_len <= RK_MAX_USER_ID && out->rp_id_len <= RK_MAX_RP_ID && + out->user_name_len <= RK_MAX_NAME && out->display_name_len <= RK_MAX_NAME; +} + +int storage_rk_save(const rk_record_t *rec) { + int old = find_same_user(rec); + int slot = find_free_slot(); + if (slot < 0) { + // Make room: drop the credential being replaced, then reclaim dead slots. + if (old >= 0) { + storage_rk_delete(old); + old = -1; + } + compact(); + slot = find_free_slot(); + if (slot < 0) return CTAP2_ERR_KEY_STORE_FULL; + } + write_record(slot, rec); + if (old >= 0) storage_rk_delete(old); + return CTAP2_OK; +} + +void storage_rk_delete(int slot) { + uint32_t zero = 0; + program(rk_offset(slot), &zero, sizeof(zero)); +} + +void storage_reset(void) { + for (int sector = 0; sector < RK_SECTORS; sector++) platform_flash_erase(RK_OFFSET + SECTOR(sector)); + for (uint32_t off = PIN_OFFSET; off < PIN_END; off += STORAGE_SECTOR_SIZE) platform_flash_erase(off); + new_identity(); +} + +bool storage_pin_load(pin_state_t *out) { + pin_record_t rec; + bool found = false; + pin_seq = 0; + pin_next_offset = PIN_OFFSET; + for (uint32_t off = PIN_OFFSET; off < PIN_END; off += sizeof(rec)) { + platform_flash_read(off, &rec, sizeof(rec)); + if (rec.magic != PIN_MAGIC || (found && rec.seq <= pin_seq)) continue; + found = true; + pin_seq = rec.seq; + pin_next_offset = off + sizeof(rec); + out->set = rec.set; + out->retries = rec.retries; + memcpy(out->hash, rec.hash, sizeof(out->hash)); + } + return found; +} + +// Appends an entry, the same way as the counter log: entries fill one sector, +// then the other sector (which only holds older entries) is erased and used. +void storage_pin_save(const pin_state_t *state) { + pin_record_t rec; + uint32_t off = pin_next_offset; + for (;; off += sizeof(rec)) { + if (off % STORAGE_SECTOR_SIZE == 0) { + if (off == PIN_END) off = PIN_OFFSET; + platform_flash_erase(off); + break; + } + // Skip anything a power cut left half-written. + platform_flash_read(off, &rec, sizeof(rec)); + if (is_erased(&rec, sizeof(rec))) break; + } + + // The magic goes in last, so an interrupted write never looks valid. + uint32_t magic = PIN_MAGIC; + memset(&rec, 0xff, sizeof(rec)); + rec.seq = ++pin_seq; + rec.set = state->set; + rec.retries = state->retries; + memcpy(rec.hash, state->hash, sizeof(rec.hash)); + program(off, &rec, sizeof(rec)); + program(off, &magic, sizeof(magic)); + pin_next_offset = off + sizeof(rec); +} diff --git a/src/storage.h b/src/storage.h new file mode 100644 index 0000000..ef6abf5 --- /dev/null +++ b/src/storage.h @@ -0,0 +1,60 @@ +// Persistent state: master secret, signature counter and resident credentials. +#pragma once + +#include +#include + +#include "cred.h" + +#define RK_SLOTS 64 +#define RK_MAX_USER_ID 64 +#define RK_MAX_RP_ID 128 +#define RK_MAX_NAME 64 + +// One resident (discoverable) credential. Exactly one 512-byte flash slot. +typedef struct { + uint32_t magic; // managed by storage.c + uint32_t created; // signature counter at creation, orders credentials + uint8_t rp_id_hash[32]; + uint8_t cred_id[CRED_ID_LEN]; + uint8_t user_id_len; + uint8_t user_id[RK_MAX_USER_ID]; + uint8_t rp_id_len; + char rp_id[RK_MAX_RP_ID]; + uint8_t user_name_len; + char user_name[RK_MAX_NAME]; + uint8_t display_name_len; + char display_name[RK_MAX_NAME]; + uint8_t reserved[99]; +} rk_record_t; + +_Static_assert(sizeof(rk_record_t) == 512, "rk_record_t must fill one slot"); + +void storage_init(void); +const uint8_t *storage_master_secret(void); + +// Increments the global signature counter and returns the new value. +uint32_t storage_counter_next(void); + +// Generates a new master secret and deletes all resident credentials and the PIN. +void storage_reset(void); + +// Returns false if the slot does not hold a credential. +bool storage_rk_read(int slot, rk_record_t *out); + +// Stores a credential, replacing any with the same RP and user ID. Returns a +// CTAP status code. +int storage_rk_save(const rk_record_t *rec); + +void storage_rk_delete(int slot); + +// PIN state. The PIN itself is never stored, only LEFT(SHA-256(PIN), 16). +typedef struct { + bool set; + uint8_t retries; + uint8_t hash[16]; +} pin_state_t; + +// Returns false if no PIN state was saved since the last reset. +bool storage_pin_load(pin_state_t *out); +void storage_pin_save(const pin_state_t *state); diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..96db480 --- /dev/null +++ b/src/util.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +static inline void put_be32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)v; +} + +static inline uint32_t get_be32(const uint8_t *p) { + return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16 | (uint32_t)p[2] << 8 | p[3]; +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6df4921 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,106 @@ +"""Runs the authenticator simulator and exposes it as a python-fido2 HID device.""" + +import os +import pathlib +import subprocess +import threading + +import pytest +from fido2.hid import CtapHidDevice +from fido2.hid.base import CtapHidConnection, HidDescriptor + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SIMULATOR = pathlib.Path(os.environ.get("SKEY_SIM", ROOT / "build-host" / "skey_sim")) +PACKET_SIZE = 64 + + +class PipeConnection(CtapHidConnection): + """Carries HID reports over the simulator's stdin/stdout.""" + + def __init__(self, proc: subprocess.Popen): + self.proc = proc + + def write_packet(self, data: bytes) -> None: + assert len(data) == PACKET_SIZE + self.proc.stdin.write(data) + self.proc.stdin.flush() + + def read_packet(self) -> bytes: + data = self.proc.stdout.read(PACKET_SIZE) + if len(data) != PACKET_SIZE: + raise OSError(f"simulator exited with {self.proc.wait()}") + return data + + def close(self) -> None: + self.proc.stdin.close() + assert self.proc.wait(timeout=10) == 0, "simulator crashed" + + +class Simulator: + """One simulated key. Restarting keeps its flash, like unplugging a real one. + + Without a button file every request is approved; with one, press() is a + touch of the button. + """ + + def __init__(self, flash_file: pathlib.Path, button_file: pathlib.Path | None = None): + self.flash_file = flash_file + self.button_file = button_file + self.device = None + + def start(self) -> CtapHidDevice: + if not SIMULATOR.exists(): + pytest.fail(f"{SIMULATOR} not found; build it with: cmake -B build-host -DSKEY_HOST=ON && cmake --build build-host") + env = {**os.environ, "SKEY_FLASH_FILE": str(self.flash_file)} + if self.button_file: + env["SKEY_BUTTON_FILE"] = str(self.button_file) + proc = subprocess.Popen([SIMULATOR], stdin=subprocess.PIPE, stdout=subprocess.PIPE, env=env) + descriptor = HidDescriptor( + path="skey-sim", + vid=0x1209, + pid=0x0001, + report_size_in=PACKET_SIZE, + report_size_out=PACKET_SIZE, + product_name="Skey simulator", + serial_number="sim", + ) + self.device = CtapHidDevice(descriptor, PipeConnection(proc)) + return self.device + + def stop(self) -> None: + if self.device: + self.device.close() + self.device = None + + def restart(self) -> CtapHidDevice: + self.stop() + return self.start() + + def press(self) -> None: + self.button_file.touch() + + def press_after(self, seconds: float) -> threading.Timer: + timer = threading.Timer(seconds, self.press) + timer.start() + return timer + + +@pytest.fixture +def sim(tmp_path): + simulator = Simulator(tmp_path / "flash.bin") + simulator.start() + yield simulator + simulator.stop() + + +@pytest.fixture +def device(sim): + return sim.device + + +@pytest.fixture +def button_sim(tmp_path): + simulator = Simulator(tmp_path / "flash.bin", tmp_path / "button") + simulator.start() + yield simulator + simulator.stop() diff --git a/tests/test_ctap1.py b/tests/test_ctap1.py new file mode 100644 index 0000000..c418e96 --- /dev/null +++ b/tests/test_ctap1.py @@ -0,0 +1,55 @@ +import hashlib +import os + +import pytest +from fido2.ctap1 import APDU, ApduError, Ctap1 +from fido2.ctap2 import Ctap2 + +APP_ID = hashlib.sha256(b"example.com").digest() + + +def test_version(device): + assert Ctap1(device).get_version() == "U2F_V2" + + +def test_register_and_authenticate(device): + ctap1 = Ctap1(device) + challenge = os.urandom(32) + reg = ctap1.register(challenge, APP_ID) + reg.verify(APP_ID, challenge) + + challenge = os.urandom(32) + sig = ctap1.authenticate(challenge, APP_ID, reg.key_handle) + sig.verify(APP_ID, challenge, reg.public_key) + assert sig.user_presence & 1 + + +def test_check_only(device): + ctap1 = Ctap1(device) + reg = ctap1.register(os.urandom(32), APP_ID) + with pytest.raises(ApduError) as e: + ctap1.authenticate(os.urandom(32), APP_ID, reg.key_handle, check_only=True) + assert e.value.code == APDU.USE_NOT_SATISFIED + + +def test_wrong_app_id(device): + ctap1 = Ctap1(device) + reg = ctap1.register(os.urandom(32), APP_ID) + with pytest.raises(ApduError) as e: + ctap1.authenticate(os.urandom(32), hashlib.sha256(b"evil.com").digest(), reg.key_handle) + assert e.value.code == APDU.WRONG_DATA + + +def test_u2f_credential_works_with_ctap2(device): + reg = Ctap1(device).register(os.urandom(32), APP_ID) + client_data_hash = os.urandom(32) + assertion = Ctap2(device).get_assertion( + "example.com", client_data_hash, allow_list=[{"type": "public-key", "id": reg.key_handle}] + ) + assert assertion.credential["id"] == reg.key_handle + + +def test_unknown_instruction(device): + with pytest.raises(ApduError) as e: + Ctap1(device).send_apdu(ins=0x55) + assert e.value.code == 0x6D00 # INS not supported diff --git a/tests/test_ctap2.py b/tests/test_ctap2.py new file mode 100644 index 0000000..25eff61 --- /dev/null +++ b/tests/test_ctap2.py @@ -0,0 +1,266 @@ +import hashlib +import os + +import pytest +from fido2 import cbor +from fido2.attestation import PackedAttestation +from fido2.ctap import CtapError +from fido2.ctap2 import Ctap2 +from fido2.hid import CTAPHID +from fido2.webauthn import AuthenticatorData + +RP = {"id": "example.com", "name": "Example"} +ALICE = {"id": b"alice-id", "name": "alice", "displayName": "Alice"} +BOB = {"id": b"bob-id", "name": "bob", "displayName": "Bob"} +ES256 = [{"type": "public-key", "alg": -7}] + + +def descriptor(credential_id): + return {"type": "public-key", "id": credential_id} + + +def make(ctap, user=ALICE, rp=RP, rk=False, **kwargs): + options = {"rk": True} if rk else None + return ctap.make_credential(os.urandom(32), rp, user, ES256, options=options, **kwargs) + + +def ctap_error(call): + with pytest.raises(CtapError) as e: + call() + return e.value.code + + +def test_get_info(device): + info = Ctap2(device).get_info() + assert "FIDO_2_0" in info.versions + assert "U2F_V2" in info.versions + assert info.options["rk"] is True + assert info.options["up"] is True + assert info.options["clientPin"] is False # supported, no PIN set yet + assert info.pin_uv_protocols == [2, 1] + assert len(info.aaguid) == 16 + + +def test_make_credential_attestation(device): + ctap = Ctap2(device) + client_data_hash = os.urandom(32) + att = ctap.make_credential(client_data_hash, RP, ALICE, ES256) + + assert att.fmt == "packed" + PackedAttestation().verify(att.att_stmt, att.auth_data, client_data_hash) + assert att.auth_data.rp_id_hash == hashlib.sha256(b"example.com").digest() + assert att.auth_data.is_user_present() + assert not att.auth_data.is_user_verified() + assert att.auth_data.credential_data.aaguid == ctap.info.aaguid + + +def test_get_assertion(device): + ctap = Ctap2(device) + cred = make(ctap).auth_data.credential_data + client_data_hash = os.urandom(32) + + assertion = ctap.get_assertion(RP["id"], client_data_hash, allow_list=[descriptor(cred.credential_id)]) + + assertion.verify(client_data_hash, cred.public_key) + assert assertion.credential["id"] == cred.credential_id + assert assertion.auth_data.is_user_present() + assert assertion.user is None # not a discoverable credential + + +def test_counter_increases(device): + ctap = Ctap2(device) + att = make(ctap) + allow = [descriptor(att.auth_data.credential_data.credential_id)] + counters = [att.auth_data.counter] + for _ in range(3): + counters.append(ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow).auth_data.counter) + assert counters == sorted(set(counters)) + + +def test_picks_own_credential_from_allow_list(device): + ctap = Ctap2(device) + cred = make(ctap).auth_data.credential_data + allow = [descriptor(os.urandom(49)), descriptor(b"short"), descriptor(cred.credential_id)] + assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow) + assert assertion.credential["id"] == cred.credential_id + + +def test_credential_is_bound_to_rp(device): + ctap = Ctap2(device) + cred = make(ctap).auth_data.credential_data + code = ctap_error(lambda: ctap.get_assertion("evil.com", os.urandom(32), allow_list=[descriptor(cred.credential_id)])) + assert code == CtapError.ERR.NO_CREDENTIALS + + +def test_tampered_credential_rejected(device): + ctap = Ctap2(device) + cred_id = bytearray(make(ctap).auth_data.credential_data.credential_id) + cred_id[5] ^= 1 + code = ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(bytes(cred_id))])) + assert code == CtapError.ERR.NO_CREDENTIALS + + +def test_exclude_list(device): + ctap = Ctap2(device) + cred = make(ctap).auth_data.credential_data + code = ctap_error(lambda: make(ctap, exclude_list=[descriptor(cred.credential_id)])) + assert code == CtapError.ERR.CREDENTIAL_EXCLUDED + # The same ID under another RP isn't ours, so it doesn't block. + make(ctap, rp={"id": "other.com"}, exclude_list=[descriptor(cred.credential_id)]) + + +def test_unsupported_algorithm(device): + ctap = Ctap2(device) + code = ctap_error(lambda: ctap.make_credential(os.urandom(32), RP, ALICE, [{"type": "public-key", "alg": -257}])) + assert code == CtapError.ERR.UNSUPPORTED_ALGORITHM + + +def test_uv_not_supported(device): + ctap = Ctap2(device) + code = ctap_error(lambda: ctap.make_credential(os.urandom(32), RP, ALICE, ES256, options={"uv": True})) + assert code == CtapError.ERR.UNSUPPORTED_OPTION + + +def test_missing_parameter(device): + ctap = Ctap2(device) + code = ctap_error(lambda: ctap.send_cbor(Ctap2.CMD.MAKE_CREDENTIAL, {1: os.urandom(32), 2: RP, 4: ES256})) + assert code == CtapError.ERR.MISSING_PARAMETER + + +def test_empty_pin_auth_means_select(device): + ctap = Ctap2(device) + code = ctap_error(lambda: make(ctap, pin_uv_param=b"", pin_uv_protocol=1)) + assert code == CtapError.ERR.PIN_NOT_SET + + +def test_silent_assertion(device): + ctap = Ctap2(device) + cred = make(ctap).auth_data.credential_data + assertion = ctap.get_assertion( + RP["id"], os.urandom(32), allow_list=[descriptor(cred.credential_id)], options={"up": False} + ) + assert not assertion.auth_data.is_user_present() + + +def test_discoverable_credentials(device): + ctap = Ctap2(device) + alice = make(ctap, ALICE, rk=True).auth_data.credential_data + bob = make(ctap, BOB, rk=True).auth_data.credential_data + make(ctap, ALICE, rp={"id": "other.com"}, rk=True) + client_data_hash = os.urandom(32) + + first = ctap.get_assertion(RP["id"], client_data_hash) + second = ctap.get_next_assertion() + + assert first.number_of_credentials == 2 + assert second.number_of_credentials is None + # Newest first; without user verification only the user ID is returned. + assert first.user == {"id": BOB["id"]} + assert second.user == {"id": ALICE["id"]} + first.verify(client_data_hash, bob.public_key) + second.verify(client_data_hash, alice.public_key) + assert ctap_error(ctap.get_next_assertion) == CtapError.ERR.NOT_ALLOWED + + +def test_discoverable_via_allow_list_includes_user(device): + ctap = Ctap2(device) + cred = make(ctap, ALICE, rk=True).auth_data.credential_data + assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(cred.credential_id)]) + assert assertion.user == {"id": ALICE["id"]} + + +def test_no_discoverable_credentials(device): + ctap = Ctap2(device) + make(ctap) # not discoverable + assert ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32))) == CtapError.ERR.NO_CREDENTIALS + + +def test_same_user_replaces_credential(device): + ctap = Ctap2(device) + make(ctap, ALICE, rk=True) + newest = make(ctap, ALICE, rk=True).auth_data.credential_data + assertion = ctap.get_assertion(RP["id"], os.urandom(32)) + assert assertion.number_of_credentials is None + assert assertion.credential["id"] == newest.credential_id + + +def test_replacing_many_times_reclaims_space(device): + # Each replacement leaves a dead slot; this needs several compactions. + ctap = Ctap2(device) + for _ in range(150): + cred = make(ctap, ALICE, rk=True).auth_data.credential_data + assertion = ctap.get_assertion(RP["id"], os.urandom(32)) + assert assertion.credential["id"] == cred.credential_id + assert assertion.number_of_credentials is None + + +def test_key_store_full(device): + ctap = Ctap2(device) + for i in range(64): + make(ctap, {"id": b"user-%d" % i, "name": "u%d" % i}, rk=True) + code = ctap_error(lambda: make(ctap, {"id": b"one-too-many", "name": "x"}, rk=True)) + assert code == CtapError.ERR.KEY_STORE_FULL + # Replacing an existing user still works when full. + make(ctap, {"id": b"user-0", "name": "u0"}, rk=True) + + +def test_state_survives_restart(sim): + ctap = Ctap2(sim.device) + resident = make(ctap, ALICE, rk=True) + plain = make(ctap, BOB).auth_data.credential_data + + ctap = Ctap2(sim.restart()) + + assertion = ctap.get_assertion(RP["id"], os.urandom(32)) + assert assertion.credential["id"] == resident.auth_data.credential_data.credential_id + assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(plain.credential_id)]) + assert assertion.auth_data.counter > resident.auth_data.counter + + +def test_counter_survives_sector_rollover(sim): + # The counter log holds 1024 values per sector; cross into the second sector and back. + ctap = Ctap2(sim.device) + cred = make(ctap).auth_data.credential_data + allow = [descriptor(cred.credential_id)] + last = 0 + for _ in range(2100): + counter = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow, options={"up": False}).auth_data.counter + assert counter > last + last = counter + + ctap = Ctap2(sim.restart()) + assert ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow).auth_data.counter == last + 1 + + +def test_reset(device): + ctap = Ctap2(device) + plain = make(ctap).auth_data.credential_data + make(ctap, ALICE, rk=True) + + ctap.reset() + + code = ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32), allow_list=[descriptor(plain.credential_id)])) + assert code == CtapError.ERR.NO_CREDENTIALS + assert ctap_error(lambda: ctap.get_assertion(RP["id"], os.urandom(32))) == CtapError.ERR.NO_CREDENTIALS + + +def test_responses_are_canonical_cbor(device): + # Browsers reject non-canonical CBOR (e.g. map keys out of order); python-fido2 + # does not, so compare the raw bytes against a canonical re-encoding. + def raw_call(cmd, request=None): + payload = bytes([cmd]) + (cbor.encode(request) if request else b"") + response = device.call(CTAPHID.CBOR, payload) + assert response[0] == 0 + assert cbor.encode(cbor.decode(response[1:])) == response[1:] + return cbor.decode(response[1:]) + + raw_call(Ctap2.CMD.GET_INFO) + raw_call(Ctap2.CMD.CLIENT_PIN, {1: 2, 2: 2}) # getKeyAgreement + raw_call(Ctap2.CMD.CLIENT_PIN, {2: 1}) # getPinRetries + client_data_hash = os.urandom(32) + att = raw_call(Ctap2.CMD.MAKE_CREDENTIAL, {1: client_data_hash, 2: RP, 3: ALICE, 4: ES256, 7: {"rk": True}}) + make(Ctap2(device), BOB, rk=True) + raw_call(Ctap2.CMD.GET_ASSERTION, {1: RP["id"], 2: client_data_hash}) + raw_call(Ctap2.CMD.GET_NEXT_ASSERTION) + cred_id = AuthenticatorData(att[2]).credential_data.credential_id + raw_call(Ctap2.CMD.GET_ASSERTION, {1: RP["id"], 2: client_data_hash, 3: [descriptor(cred_id)]}) diff --git a/tests/test_ctaphid.py b/tests/test_ctaphid.py new file mode 100644 index 0000000..473a8cb --- /dev/null +++ b/tests/test_ctaphid.py @@ -0,0 +1,62 @@ +import os +import struct + +import pytest +from fido2.ctap import CtapError +from fido2.hid import CAPABILITY, CTAPHID + + +def test_init_assigns_channel(device): + assert device._channel_id not in (0, 0xFFFFFFFF) + assert device.capabilities & CAPABILITY.CBOR + assert device.capabilities & CAPABILITY.WINK + + +def test_ping_single_packet(device): + assert device.ping(b"hello") == b"hello" + + +def test_ping_multi_packet(device): + # 57 bytes in the first packet, then 59 per continuation packet. + payload = os.urandom(3000) + assert device.ping(payload) == payload + + +def test_ping_largest_message(device): + payload = os.urandom(57 + 128 * 59) + assert device.ping(payload) == payload + + +def test_wink(device): + device.wink() + + +def test_unknown_command(device): + with pytest.raises(CtapError) as e: + device.call(0x40) + assert e.value.code == CtapError.ERR.INVALID_COMMAND + + +def test_out_of_order_continuation(device): + conn = device._connection + cid = device._channel_id + conn.write_packet(struct.pack(">IBH", cid, 0x80 | CTAPHID.PING, 100).ljust(64, b"\0")) + conn.write_packet(struct.pack(">IB", cid, 1).ljust(64, b"\0")) # expected seq 0 + response = conn.read_packet() + assert response[:7] == struct.pack(">IBH", cid, 0x80 | CTAPHID.ERROR, 1) + assert response[7] == CtapError.ERR.INVALID_SEQ + assert device.ping(b"still alive") == b"still alive" + + +def test_other_channel_busy_while_receiving(device): + conn = device._connection + cid = device._channel_id + conn.write_packet(struct.pack(">IBH", cid, 0x80 | CTAPHID.PING, 100).ljust(64, b"\0")) + conn.write_packet(struct.pack(">IBH", cid + 1, 0x80 | CTAPHID.PING, 1).ljust(64, b"\0")) + response = conn.read_packet() + assert response[:8] == struct.pack(">IBHB", cid + 1, 0x80 | CTAPHID.ERROR, 1, CtapError.ERR.CHANNEL_BUSY) + # Finish the original message. + conn.write_packet(struct.pack(">IB", cid, 0).ljust(64, b"\0")) + response = conn.read_packet() + assert response[:7] == struct.pack(">IBH", cid, 0x80 | CTAPHID.PING, 100) + conn.read_packet() # continuation packet with the rest of the echo diff --git a/tests/test_pin.py b/tests/test_pin.py new file mode 100644 index 0000000..f4fa859 --- /dev/null +++ b/tests/test_pin.py @@ -0,0 +1,212 @@ +"""clientPIN: setting and changing the PIN, retries, and PIN tokens.""" + +import os + +import pytest +from fido2.ctap import CtapError +from fido2.ctap2 import Ctap2 +from fido2.ctap2.pin import ClientPin, PinProtocolV1, PinProtocolV2 + +RP = {"id": "example.com", "name": "Example"} +ALICE = {"id": b"alice-id", "name": "alice", "displayName": "Alice"} +ES256 = [{"type": "public-key", "alg": -7}] +PIN = "1234" +MC_GA = ClientPin.PERMISSION.MAKE_CREDENTIAL | ClientPin.PERMISSION.GET_ASSERTION + + +@pytest.fixture(params=[PinProtocolV1, PinProtocolV2], ids=["v1", "v2"]) +def protocol(request): + return request.param() + + +def ctap_error(call): + with pytest.raises(CtapError) as e: + call() + return e.value.code + + +def with_pin(cp, token, client_data_hash): + return {"pin_uv_param": cp.protocol.authenticate(token, client_data_hash), "pin_uv_protocol": cp.protocol.VERSION} + + +def make(cp, token=None, rp=RP, rk=False): + client_data_hash = os.urandom(32) + pin = with_pin(cp, token, client_data_hash) if token else {} + options = {"rk": True} if rk else None + return cp.ctap.make_credential(client_data_hash, rp, ALICE, ES256, options=options, **pin) + + +def assert_with(cp, token, rp_id=RP["id"], allow_list=None): + client_data_hash = os.urandom(32) + pin = with_pin(cp, token, client_data_hash) if token else {} + return cp.ctap.get_assertion(rp_id, client_data_hash, allow_list=allow_list, **pin) + + +def setup_pin(device, protocol): + cp = ClientPin(Ctap2(device), protocol) + cp.set_pin(PIN) + return cp + + +def test_set_pin(device, protocol): + cp = setup_pin(device, protocol) + assert Ctap2(device).get_info().options["clientPin"] is True + assert cp.get_pin_retries()[0] == 8 + + +def test_set_pin_only_once(device, protocol): + cp = setup_pin(device, protocol) + assert ctap_error(lambda: cp.set_pin("5678")) == CtapError.ERR.PIN_AUTH_INVALID + + +def test_pin_needs_four_characters(device, protocol): + cp = ClientPin(Ctap2(device), protocol) + + def set_raw(pin: str): + key_agreement, shared = cp._get_shared_secret() + new_pin_enc = cp.protocol.encrypt(shared, pin.encode().ljust(64, b"\0")) + cp.ctap.client_pin( + cp.protocol.VERSION, + ClientPin.CMD.SET_PIN, + key_agreement=key_agreement, + new_pin_enc=new_pin_enc, + pin_uv_param=cp.protocol.authenticate(shared, new_pin_enc), + ) + + assert ctap_error(lambda: set_raw("123")) == CtapError.ERR.PIN_POLICY_VIOLATION + # Characters, not bytes: three two-byte characters are still too short. + assert ctap_error(lambda: set_raw("ééé")) == CtapError.ERR.PIN_POLICY_VIOLATION + set_raw("éééé") + cp.get_pin_token("éééé") + + +def test_registration_requires_pin_once_set(device, protocol): + cp = setup_pin(device, protocol) + assert ctap_error(lambda: make(cp)) == CtapError.ERR.PUAT_REQUIRED + + +def test_register_and_login_with_pin(device, protocol): + cp = setup_pin(device, protocol) + token = cp.get_pin_token(PIN, MC_GA, RP["id"]) + + att = make(cp, token, rk=True) + assertion = assert_with(cp, token) + + assert att.auth_data.is_user_verified() + assert assertion.auth_data.is_user_verified() + # With the PIN verified, the account names are returned too. + assert assertion.user == {"id": ALICE["id"], "name": "alice", "displayName": "Alice"} + assert assertion.credential["id"] == att.auth_data.credential_data.credential_id + + +def test_login_without_pin_is_not_verified(device, protocol): + cp = setup_pin(device, protocol) + make(cp, cp.get_pin_token(PIN, MC_GA, RP["id"]), rk=True) + + assertion = assert_with(cp, None) + + assert not assertion.auth_data.is_user_verified() + assert assertion.user == {"id": ALICE["id"]} + + +def test_wrong_pin_and_power_cycle_lockout(sim, protocol): + cp = setup_pin(sim.device, protocol) + assert ctap_error(lambda: cp.get_pin_token("0000")) == CtapError.ERR.PIN_INVALID + assert cp.get_pin_retries()[0] == 7 + assert ctap_error(lambda: cp.get_pin_token("0000")) == CtapError.ERR.PIN_INVALID + assert ctap_error(lambda: cp.get_pin_token("0000")) == CtapError.ERR.PIN_AUTH_BLOCKED + # Locked until unplugged, even with the right PIN. + assert ctap_error(lambda: cp.get_pin_token(PIN)) == CtapError.ERR.PIN_AUTH_BLOCKED + + cp = ClientPin(Ctap2(sim.restart()), protocol) + assert cp.get_pin_retries()[0] == 5 + cp.get_pin_token(PIN) + assert cp.get_pin_retries()[0] == 8 + + +def test_pin_blocked_after_eight_failures_until_reset(sim, protocol): + cp = setup_pin(sim.device, protocol) + codes = [] + for attempt in range(8): + if attempt in (3, 6): + cp = ClientPin(Ctap2(sim.restart()), protocol) + codes.append(ctap_error(lambda: cp.get_pin_token("0000"))) + assert codes[-1] == CtapError.ERR.PIN_BLOCKED + assert ctap_error(lambda: cp.get_pin_token(PIN)) == CtapError.ERR.PIN_BLOCKED + + ctap = Ctap2(sim.restart()) + ctap.reset() + assert ctap.get_info().options["clientPin"] is False + + +def test_change_pin(device, protocol): + cp = setup_pin(device, protocol) + assert ctap_error(lambda: cp.change_pin("0000", "5678")) == CtapError.ERR.PIN_INVALID + assert cp.get_pin_retries()[0] == 7 + + cp.change_pin(PIN, "5678") + + assert ctap_error(lambda: cp.get_pin_token(PIN)) == CtapError.ERR.PIN_INVALID + cp.get_pin_token("5678") + assert cp.get_pin_retries()[0] == 8 + + +def test_pin_survives_restart(sim, protocol): + setup_pin(sim.device, protocol) + cp = ClientPin(Ctap2(sim.restart()), protocol) + assert cp.ctap.info.options["clientPin"] is True + make(cp, cp.get_pin_token(PIN, MC_GA, RP["id"])) + + +def test_token_permissions(device, protocol): + cp = setup_pin(device, protocol) + token = cp.get_pin_token(PIN, ClientPin.PERMISSION.GET_ASSERTION, RP["id"]) + assert ctap_error(lambda: make(cp, token)) == CtapError.ERR.PIN_AUTH_INVALID + + +def test_token_bound_to_rp(device, protocol): + cp = setup_pin(device, protocol) + make(cp, cp.get_pin_token(PIN, MC_GA, "other.com"), rp={"id": "other.com"}, rk=True) + token = cp.get_pin_token(PIN, MC_GA, RP["id"]) + assert ctap_error(lambda: assert_with(cp, token, rp_id="other.com")) == CtapError.ERR.PIN_AUTH_INVALID + + +def test_unsupported_permission_costs_no_retry(device, protocol): + cp = setup_pin(device, protocol) + code = ctap_error(lambda: cp.get_pin_token(PIN, ClientPin.PERMISSION.CREDENTIAL_MGMT)) + assert code == CtapError.ERR.UNAUTHORIZED_PERMISSION + assert cp.get_pin_retries()[0] == 8 + + +def test_new_token_replaces_old(device, protocol): + cp = setup_pin(device, protocol) + old = cp.get_pin_token(PIN, MC_GA, RP["id"]) + cp.get_pin_token(PIN, MC_GA, RP["id"]) + assert ctap_error(lambda: make(cp, old)) == CtapError.ERR.PIN_AUTH_INVALID + + +def test_legacy_get_pin_token(device, protocol, monkeypatch): + # CTAP 2.0 platforms use getPINToken (subcommand 5), which has no permissions. + monkeypatch.setattr(ClientPin, "is_token_supported", staticmethod(lambda info: False)) + cp = setup_pin(device, protocol) + token = cp.get_pin_token(PIN) + assert make(cp, token).auth_data.is_user_verified() + + +def test_bad_pin_auth(device, protocol): + cp = setup_pin(device, protocol) + cp.get_pin_token(PIN, MC_GA, RP["id"]) + bogus = {"pin_uv_param": os.urandom(32 if protocol.VERSION == 2 else 16), "pin_uv_protocol": protocol.VERSION} + code = ctap_error(lambda: cp.ctap.make_credential(os.urandom(32), RP, ALICE, ES256, **bogus)) + assert code == CtapError.ERR.PIN_AUTH_INVALID + code = ctap_error(lambda: cp.ctap.make_credential(os.urandom(32), RP, ALICE, ES256, pin_uv_param=os.urandom(32))) + assert code == CtapError.ERR.MISSING_PARAMETER + + +def test_empty_pin_auth_with_pin_set(device, protocol): + # The "touch to select" probe answers PIN_INVALID once a PIN exists. + cp = setup_pin(device, protocol) + code = ctap_error( + lambda: cp.ctap.make_credential(os.urandom(32), RP, ALICE, ES256, pin_uv_param=b"", pin_uv_protocol=protocol.VERSION) + ) + assert code == CtapError.ERR.PIN_INVALID diff --git a/tests/test_presence.py b/tests/test_presence.py new file mode 100644 index 0000000..bdce8b2 --- /dev/null +++ b/tests/test_presence.py @@ -0,0 +1,141 @@ +"""The touch button: requests wait for a press, with keepalives and cancel.""" + +import os +import struct +import threading +import time + +import pytest +from fido2.ctap import STATUS, CtapError +from fido2.ctap1 import APDU, ApduError, Ctap1 +from fido2.ctap2 import Ctap2 +from fido2.hid import CTAPHID +from fido2 import cbor + +RP = {"id": "example.com", "name": "Example"} +ALICE = {"id": b"alice-id", "name": "alice"} +ES256 = [{"type": "public-key", "alg": -7}] +TOUCH_DELAY = 0.3 + + +def make(ctap, **kwargs): + return ctap.make_credential(os.urandom(32), RP, ALICE, ES256, **kwargs) + + +def test_waits_for_touch_with_keepalives(button_sim): + ctap = Ctap2(button_sim.device) + statuses = [] + button_sim.press_after(TOUCH_DELAY) + start = time.monotonic() + + att = make(ctap, on_keepalive=statuses.append) + + assert time.monotonic() - start >= TOUCH_DELAY + assert STATUS.UPNEEDED in statuses + assert att.auth_data.is_user_present() + + +def test_assertion_waits_for_touch(button_sim): + ctap = Ctap2(button_sim.device) + button_sim.press_after(0.05) # presses only count once the request has arrived + cred = make(ctap).auth_data.credential_data + allow = [{"type": "public-key", "id": cred.credential_id}] + + button_sim.press_after(TOUCH_DELAY) + start = time.monotonic() + assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow) + + assert time.monotonic() - start >= TOUCH_DELAY + assert assertion.auth_data.is_user_present() + + +def test_press_before_request_does_not_count(button_sim): + ctap = Ctap2(button_sim.device) + button_sim.press() + time.sleep(0.1) + button_sim.press_after(TOUCH_DELAY) + start = time.monotonic() + + make(ctap) + + assert time.monotonic() - start >= TOUCH_DELAY + + +def test_silent_assertion_needs_no_touch(button_sim): + ctap = Ctap2(button_sim.device) + button_sim.press_after(0.05) # presses only count once the request has arrived + cred = make(ctap).auth_data.credential_data + allow = [{"type": "public-key", "id": cred.credential_id}] + assertion = ctap.get_assertion(RP["id"], os.urandom(32), allow_list=allow, options={"up": False}) + assert not assertion.auth_data.is_user_present() + + +def test_cancel(button_sim): + ctap = Ctap2(button_sim.device) + cancel = threading.Event() + threading.Timer(TOUCH_DELAY, cancel.set).start() + + with pytest.raises(CtapError) as e: + make(ctap, event=cancel) + + assert e.value.code == CtapError.ERR.KEEPALIVE_CANCEL + assert ctap.get_info().versions # still responsive + + +def test_u2f_fails_until_touched(button_sim): + ctap1 = Ctap1(button_sim.device) + app_id, challenge = os.urandom(32), os.urandom(32) + with pytest.raises(ApduError) as e: + ctap1.register(challenge, app_id) + assert e.value.code == APDU.USE_NOT_SATISFIED + + # Hosts retry; a press since the last attempt lets the next one through. + button_sim.press() + reg = ctap1.register(challenge, app_id) + reg.verify(app_id, challenge) + + with pytest.raises(ApduError) as e: + ctap1.authenticate(challenge, app_id, reg.key_handle) + assert e.value.code == APDU.USE_NOT_SATISFIED + button_sim.press() + ctap1.authenticate(challenge, app_id, reg.key_handle).verify(app_id, challenge, reg.public_key) + + +def send_message(conn, cid, cmd, data): + conn.write_packet((struct.pack(">IBH", cid, 0x80 | cmd, len(data)) + data[:57]).ljust(64, b"\0")) + data, seq = data[57:], 0 + while data: + conn.write_packet((struct.pack(">IB", cid, seq) + data[:59]).ljust(64, b"\0")) + data, seq = data[59:], seq + 1 + + +def read_until(conn, cid, cmd): + """Reads packets until an init packet for (cid, cmd); returns all seen.""" + seen = [] + while True: + packet = conn.read_packet() + r_cid, r_cmd = struct.unpack_from(">IB", packet) + seen.append((r_cid, r_cmd, packet)) + if (r_cid, r_cmd) == (cid, 0x80 | cmd): + return seen + + +def test_other_channels_busy_while_waiting(button_sim): + conn = button_sim.device._connection + cid = button_sim.device._channel_id + request = bytes([Ctap2.CMD.MAKE_CREDENTIAL]) + cbor.encode({1: os.urandom(32), 2: RP, 3: ALICE, 4: ES256}) + send_message(conn, cid, CTAPHID.CBOR, request) + read_until(conn, cid, CTAPHID.KEEPALIVE) + + send_message(conn, cid + 1, CTAPHID.PING, b"hi") + seen = read_until(conn, cid + 1, CTAPHID.ERROR) + assert seen[-1][2][7] == CtapError.ERR.CHANNEL_BUSY + + button_sim.press() + seen = read_until(conn, cid, CTAPHID.CBOR) + assert seen[-1][2][7] == 0 # CTAP2_OK + assert all(r_cid == cid for r_cid, _, _ in seen) + # Drain the rest of the response so the connection closes cleanly. + length = struct.unpack_from(">H", seen[-1][2], 5)[0] + for _ in range((max(0, length - 57) + 58) // 59): + conn.read_packet() diff --git a/tools/gen_attestation.py b/tools/gen_attestation.py new file mode 100644 index 0000000..f613ac7 --- /dev/null +++ b/tools/gen_attestation.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Creates the batch attestation key and certificate and emits them as C source. + +The key and certificate are kept in --key-dir and reused, so rebuilding keeps +the same attestation identity. Delete that directory to start over. +""" + +import argparse +import datetime +import pathlib +import uuid + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +AAGUID = uuid.UUID("225e7977-54f1-4d15-8e35-295b11658d5c") +OID_FIDO_AAGUID = x509.ObjectIdentifier("1.3.6.1.4.1.45724.1.1.4") + + +def create(key_path: pathlib.Path, cert_path: pathlib.Path) -> None: + key = ec.generate_private_key(ec.SECP256R1()) + # Subject fields required for packed attestation certificates (WebAuthn 8.2.1). + name = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Skey DIY"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Authenticator Attestation"), + x509.NameAttribute(NameOID.COMMON_NAME, "Skey Pico Attestation"), + ] + ) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(datetime.datetime(2049, 12, 31, tzinfo=datetime.timezone.utc)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.UnrecognizedExtension(OID_FIDO_AAGUID, b"\x04\x10" + AAGUID.bytes), critical=False) + .sign(key, hashes.SHA256()) + ) + key_path.parent.mkdir(parents=True, exist_ok=True) + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + key_path.chmod(0o600) + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.DER)) + + +def cert_aaguid(cert_der: bytes) -> bytes: + cert = x509.load_der_x509_certificate(cert_der) + try: + return cert.extensions.get_extension_for_oid(OID_FIDO_AAGUID).value.value[2:] + except x509.ExtensionNotFound: + return b"" + + +def c_bytes(data: bytes) -> str: + rows = [", ".join(f"0x{b:02x}" for b in data[i : i + 12]) for i in range(0, len(data), 12)] + return "{\n " + ",\n ".join(rows) + ",\n}" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--key-dir", type=pathlib.Path, required=True) + parser.add_argument("--out", type=pathlib.Path, required=True) + args = parser.parse_args() + + key_path = args.key_dir / "attestation_key.pem" + cert_path = args.key_dir / "attestation_cert.der" + if not key_path.exists() or not cert_path.exists() or cert_aaguid(cert_path.read_bytes()) != AAGUID.bytes: + create(key_path, cert_path) + + key = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + private_value = key.private_numbers().private_value.to_bytes(32, "big") + cert_der = cert_path.read_bytes() + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + f"""// Generated by tools/gen_attestation.py. Do not edit. +#include "attestation.h" + +const uint8_t skey_aaguid[16] = {c_bytes(AAGUID.bytes)}; + +const uint8_t attestation_key[32] = {c_bytes(private_value)}; + +const uint8_t attestation_cert[] = {c_bytes(cert_der)}; + +const size_t attestation_cert_len = sizeof(attestation_cert); +""" + ) + + +if __name__ == "__main__": + main() diff --git a/udev/69-skey-snap.rules b/udev/69-skey-snap.rules new file mode 100644 index 0000000..de03ada --- /dev/null +++ b/udev/69-skey-snap.rules @@ -0,0 +1,7 @@ +# Lets snap-packaged Firefox use the Skey security key. +# +# snapd only lets snaps open FIDO keys whose USB IDs are on a fixed list, and +# Skey's pid.codes test ID isn't on it. This adds the tag snapd looks for. The +# file must sort before 70-snap.firefox.rules, so that snapd's own rule sees +# the tag and grants access as soon as the key is plugged in. +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="0001", TAG+="snap_firefox_firefox"