Files
Skey/src/ctap2.c
T
ckochandClaude Opus 5.5 6c7e6ab5f5 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 <noreply@anthropic.com>
2026-10-14 15:07:38 -04:00

615 lines
20 KiB
C

// CTAP2 authenticator API (CTAP 2.0, with the CTAP 2.1 behaviors browsers rely on).
#include <string.h>
#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;
}