Skip to content

mimi89999/Yokekey

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Yokekey – Linked FIDO2 Authenticators

⚠ Early proof of concept. This project demonstrates the linked-authenticator idea but does not provide meaningful tamper resistance. The group secret and PIN are stored in plain text on the board's filesystem. Anyone with physical access to the board can read the secret and clone the authenticator without knowing the PIN. Do not use this for anything beyond experimentation.

The backup problem nobody talks about

You buy a hardware security key and set it up on every account that matters: email, banking, cloud infrastructure. Your credentials are bound to tamper-resistant hardware.

Now what happens when you lose that key?

The security-conscious answer is to buy a second key and register it everywhere as a backup. You keep it in a safe, a drawer, a deposit box, somewhere away from your primary. That's the whole point of a backup: it lives somewhere else.

Here's the catch. You're at a café signing up for a new service and you register your primary key, but your backup is at home. You tell yourself you'll add it later. Weeks pass, you forget, and now your most important accounts have a backup key but this new one doesn't. Multiply that by every account you create on the go and the backup story quietly falls apart.

This is the gap between how hardware authenticators should work and how they actually work in practice. The backup key is never there when you're creating accounts, and enrolling it later requires you to remember, retrieve it, and re-authenticate everywhere.

yokekey fixes this with linked authenticators. Two hardware security keys perform a one-time pairing ceremony, a brief ECDH key exchange over USB, and from that point on both keys derive identical credentials for every site. No cloud, no private key export, no second enrollment.

Register on a new service with whichever key you have on hand. Go home, plug in the backup and it can already sign in. The backup is always current, by construction.

Pairing overview

1. Plug in Key A  →  "pair begin"   →  get A's public key
2. Plug in Key B  →  "pair begin"   →  get B's public key
3. Plug in Key A  →  "pair finish"  →  give it B's public key
4. Plug in Key B  →  "pair finish"  →  give it A's public key

Done. Both keys now share a group secret via ECDH.
Every credential either key creates, the other can sign for.

No private key ever leaves either device. The shared secret is derived independently by each key using standard elliptic-curve Diffie-Hellman, then stored on flash.

What you get

  • Instant backup – register once, both keys work. No second enrollment needed.
  • No cloud dependency – the shared secret lives on-device, never transmitted.
  • Works with existing sites – standard WebAuthn; no relying party changes required (non-discoverable credentials).
  • Physical pairing only – keys must be present on the same machine to pair. No remote attack surface.

Project overview

Minimal CTAP2 USB HID authenticator written in MicroPython for portability across microcontroller families. Tested on RP2350; should work on any board with USB device support and a hardware RNG. Implements authenticatorGetInfo, authenticatorMakeCredential, and authenticatorGetAssertion with user-presence via a physical button and non-resident (server-side) credentials derived from the group secret established during pairing.

A single authenticator can also be initialised standalone (solo init) if linking isn't needed.

ECDSA P-256 signing and public-key derivation are performed by a native C module (_ec_p256) that calls mbedtls directly, giving constant-time EC math and roughly 100× faster signatures than the pure-Python fallback.

Hardware wiring

Connect a momentary push-button between the configured GPIO pin and GND:

D1 / GPIO27 ──┤ button ├── GND

The pin defaults to GPIO27 (D1 on XIAO-style boards; configurable via UP_BUTTON_PIN in config.py). An internal pull-up is enabled; the button is active-low.

Prerequisites

  • A MicroPython-supported board with USB device support and a hardware RNG (tested on RP2350)
  • MicroPython source tree – https://github.com/micropython/micropython
  • A GCC cross-compiler for your target (e.g. arm-none-eabi-gcc)
  • CMake ≥ 3.13 (for CMake-based ports like rp2)
  • usb-device package from micropython-lib (provides usb.device.core)

Building MicroPython with the native EC module

The _ec_p256 C module must be compiled into the MicroPython firmware. It links against the mbedtls library already bundled with MicroPython, so no extra dependencies are needed.

There are two ways to deploy the application:

  • Option A (recommended): build a single firmware image with all Python files frozen in – nothing to copy afterwards.
  • Option B (development): build firmware with just the C module, then copy .py files over USB with mpremote.

Both options start with the same initial setup.

1. Clone MicroPython and initialise submodules

git clone https://github.com/micropython/micropython.git
cd micropython

# Build the cross-compiler (needed once)
make -C mpy-cross

# Initialise the port-specific submodules (includes mbedtls)
# Replace <port> with your target: rp2, esp32, stm32, …
cd ports/<port>
make submodules
cd ../..

Option A – Complete firmware image (frozen)

This bakes the C module and all Python files into a single firmware image. The board boots straight into the authenticator with no further setup. Use this for production or when you want a reproducible build.

The provided manifest.py includes the port's default manifest, pulls in the usb-device package from micropython-lib, and freezes every .py file in the project.

CMake-based ports (rp2, esp32, …)

cd micropython/ports/rp2

# Set both FROZEN_MANIFEST and USER_C_MODULES.
# Use absolute paths – relative paths can break in out-of-tree builds.
YOKEKEY=$(realpath ~/yokekey)

make FROZEN_MANIFEST=$YOKEKEY/manifest.py \
     USER_C_MODULES=$YOKEKEY/cmod/micropython.cmake \
     BOARD=SEEED_XIAO_RP2350 \
     all

Make-based ports (stm32, …)

cd micropython/ports/stm32

YOKEKEY=$(realpath ~/yokekey)

make FROZEN_MANIFEST=$YOKEKEY/manifest.py \
     USER_C_MODULES=$YOKEKEY/cmod/_ec_p256 \
     BOARD=PYBV11 \
     all

Flash and verify

Flash the resulting firmware (.uf2, dfu-util, etc.) as usual for your board. On first boot the device should enumerate as a FIDO HID device immediately – no files to copy.

Connect to the REPL (if KEEP_REPL = True) and confirm:

>>> import _ec_p256       # C module
>>> import authenticator  # frozen Python

Customising config.py: because the file is frozen into the image, you must edit it before building. If you need to change UP_BUTTON_PIN, or any other setting, edit config.py in your yokekey/ checkout and rebuild. Alternatively, place a modified config.py on the filesystem with mpremote – a filesystem copy shadows a frozen module of the same name.

Note: the authenticator will not create or verify credentials until it has been paired (or solo-initialised). See the Pairing section below.

Option B – C module only + mpremote copy

Use this during development: build firmware with only the C module, then iterate on the Python files by copying them over USB.

2. Build firmware with the user C module

CMake-based ports (rp2, …)
cd micropython/ports/rp2

# Point USER_C_MODULES at the cmod/ directory in your yokekey checkout.
make USER_C_MODULES=~/yokekey/cmod/micropython.cmake \
     BOARD=SEEED_XIAO_RP2350 \
     all
Make-based ports (stm32, …)
cd micropython/ports/stm32

make USER_C_MODULES=~/yokekey/cmod/_ec_p256 \
     BOARD=PYBV11 \
     all

3. Flash the firmware

Flash the firmware you just built using the normal method for your board (drag-and-drop .uf2, dfu-util, etc.).

4. Verify the C module

Connect to the REPL and confirm the module loads:

>>> import _ec_p256
>>> _ec_p256.sign
<function>

Installing the Python application files (Option B only)

If you used Option A (frozen firmware), skip this section entirely.

Install the USB device library

mpremote mip install usb-device

Configure your device

Before deploying, edit config.py:

  1. UP_BUTTON_PIN – GPIO pin number for the user-presence button (default 27).
  2. UP_TIMEOUT_MS – how long to wait for a button press (default 30 s).

Note: there is no credential secret to configure. The authenticator derives credentials from a group_secret that is established at runtime via the pairing command. See the Pairing section below.

Copy files

pip install mpremote

mpremote cp config.py           :config.py
mpremote cp ctap_defs.py        :ctap_defs.py
mpremote cp cbor.py             :cbor.py
mpremote cp ec_p256.py          :ec_p256.py
mpremote cp crypto_utils.py      :crypto_utils.py
mpremote cp credentials.py      :credentials.py
mpremote cp pairing.py          :pairing.py
mpremote cp user_presence.py    :user_presence.py
mpremote cp authenticator.py    :authenticator.py
mpremote cp make_credential.py  :make_credential.py
mpremote cp get_assertion.py    :get_assertion.py
mpremote cp ctap_hid.py         :ctap_hid.py
mpremote cp usb_fido.py         :usb_fido.py
mpremote cp pin_store.py        :pin_store.py
mpremote cp pin_protocol.py     :pin_protocol.py
mpremote cp get_info.py         :get_info.py
mpremote cp client_pin.py       :client_pin.py
mpremote cp reset.py            :reset.py
mpremote cp selection.py        :selection.py
mpremote cp main.py             :main.py
mpremote cp boot.py             :boot.py

mpremote reset

Note: you do not copy anything from cmod/ – the C module is already inside the firmware you flashed in step 4.

Important: boot.py must be copied after usb_fido.py and config.py (its dependencies). On reset the board will immediately enumerate as a FIDO HID device – the REPL serial port will disappear.

Or use Thonny: save each file to the board via File → Save As → MicroPython device.

Keep the REPL during development

Set KEEP_REPL = True in config.py. The board will expose both the CDC serial REPL and the FIDO HID interface.

Recovery

If the board no longer presents a REPL (production mode), re-enter programming mode by holding BOOTSEL while plugging in USB, then re-flash the MicroPython .uf2.

Verification

lsusb | grep 1209                                    # Linux
system_profiler SPUSBDataType | grep -A6 "FIDO"      # macOS

Pairing

The authenticator must be paired (or solo-initialised) before it can create or verify credentials. Without a group_secret, all makeCredential and getAssertion calls return CTAP2_ERR_NOT_ALLOWED.

Pairing uses a vendor CTAP command (0x40, authenticatorLinkDevice) with three subcommands:

Subcommand Name Description
0x00 Solo init Generate a random group_secret (single device)
0x01 Pair begin Generate ephemeral P-256 keypair, return public key
0x02 Pair finish Receive peer public key, derive group_secret via ECDH

Solo init (single authenticator)

Send vendor command 0x40 with {0x01: 0x00}. The authenticator generates a random 32-byte group_secret and stores it on flash. User presence (button press) is required.

Linking two authenticators

Both authenticators exchange ephemeral public keys and derive the same group_secret via ECDH + HKDF, without ever transmitting private key material:

  1. A: pair_begin – send {0x01: 0x01} → response contains A's public key as a COSE_Key in map key 0x01
  2. B: pair_begin – send {0x01: 0x01} → response contains B's public key
  3. A: pair_finish – send {0x01: 0x02, 0x02: <B's COSE_Key>} → A computes and stores the group_secret
  4. B: pair_finish – send {0x01: 0x02, 0x02: <A's COSE_Key>} → B computes and stores the same group_secret

The shared secret is derived as:

shared_x = ECDH(my_priv, peer_pub)          # x-coordinate
context  = "FIDO-LINK-v1" || sort(pubA, pubB)
group_secret = HKDF-SHA256(shared_x, context)

Both authenticators now derive identical credential keys for any RP. User presence is required for each step.

Host-side pairing tool

The included yokekey_pair.py script automates the pairing ceremony from a host machine. It uses python-fido2 to send the vendor CTAP commands to both keys over USB HID:

pip install fido2 cryptography
python yokekey_pair.py solo     # solo init (one key)
python yokekey_pair.py reset    # factory reset
python yokekey_pair.py pair     # pair with simulated second authenticator

Reset

authenticatorReset (0x07) erases the group_secret and PIN state. The authenticator must be re-paired afterwards.

Design notes

Native EC P-256 module

The _ec_p256 C module wraps two mbedtls functions:

Python call What it does
_ec_p256.compute_public_key(priv_32B) mbedtls_ecp_mul(G, d) → 64 bytes (x ‖ y)
_ec_p256.sign(priv_32B, hash_32B) mbedtls_ecdsa_sign → 64 bytes (r ‖ s), low-S normalised

The Python-side ec_p256.py converts between int and bytes representations and provides COSE/DER encoding, keeping the same public API as the original pure-Python module.

Nonce generation is handled internally by mbedtls's CTR-DRBG seeded from the hardware entropy source. The k_bytes parameter in ecdsa_sign() is accepted for backwards compatibility but is ignored.

Non-resident (server-side) credentials

Private keys are never stored on the device. Instead, each credential ID encodes a random nonce plus an HMAC tag. The private key is re-derived deterministically from HMAC-SHA256(group_secret, rpIdHash || nonce). This means:

  • Performing an authenticatorReset (or re-pairing) invalidates all previously issued credentials.
  • The authenticator has no storage limit for credentials.
  • An allowList is required for getAssertion (no discoverable credentials).

User presence

Every makeCredential and getAssertion (unless options.up = false) requires the user to press the physical button within the timeout window. While waiting, the authenticator sends CTAPHID_KEEPALIVE(UP_NEEDED) packets every 100 ms so the host knows the device is alive and waiting.

Attestation

makeCredential returns packed self-attestation: the newly created credential key signs its own attestation statement. No attestation certificate is involved, so most RPs will treat it as equivalent to "none".

Configuration

Edit config.py – USB identity, AAGUID, firmware version, options, algorithms, transports, button pin, and timeout. Protocol constants in ctap_defs.py should not need changing.

Limitations

  • Non-discoverable credentials only – Discoverable (resident) credentials store the private key on-device; this scheme derives keys from the credential ID returned by the RP.
  • Signature counters – Linked authenticators cannot maintain synchronised counters independently; counters are not enforced.
  • Attestation – The RP sees a single credential; it cannot distinguish which physical authenticator signed. New attestation semantics would be needed to express "one of these linked authenticators."

Draft implementation – not affiliated with FIDO Alliance

License

MIT

About

Pairable FIDO2 key. Register one, sign in with either.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages