⚠ 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.
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.
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.
- 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.
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.
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.
- 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-devicepackage from micropython-lib (providesusb.device.core)
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
.pyfiles over USB withmpremote.
Both options start with the same initial setup.
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 ../..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.
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 \
allcd micropython/ports/stm32
YOKEKEY=$(realpath ~/yokekey)
make FROZEN_MANIFEST=$YOKEKEY/manifest.py \
USER_C_MODULES=$YOKEKEY/cmod/_ec_p256 \
BOARD=PYBV11 \
allFlash 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 PythonCustomising
config.py: because the file is frozen into the image, you must edit it before building. If you need to changeUP_BUTTON_PIN, or any other setting, editconfig.pyin youryokekey/checkout and rebuild. Alternatively, place a modifiedconfig.pyon the filesystem withmpremote– 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.
Use this during development: build firmware with only the C module, then iterate on the Python files by copying them over USB.
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 \
allcd micropython/ports/stm32
make USER_C_MODULES=~/yokekey/cmod/_ec_p256 \
BOARD=PYBV11 \
allFlash the firmware you just built using the normal method for your board
(drag-and-drop .uf2, dfu-util, etc.).
Connect to the REPL and confirm the module loads:
>>> import _ec_p256
>>> _ec_p256.sign
<function>If you used Option A (frozen firmware), skip this section entirely.
mpremote mip install usb-deviceBefore deploying, edit config.py:
UP_BUTTON_PIN– GPIO pin number for the user-presence button (default 27).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_secretthat is established at runtime via the pairing command. See the Pairing section below.
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 resetNote: you do not copy anything from
cmod/– the C module is already inside the firmware you flashed in step 4.
Important:
boot.pymust be copied afterusb_fido.pyandconfig.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.
Set KEEP_REPL = True in config.py. The board will expose both
the CDC serial REPL and the FIDO HID interface.
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.
lsusb | grep 1209 # Linux
system_profiler SPUSBDataType | grep -A6 "FIDO" # macOSThe 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 |
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.
Both authenticators exchange ephemeral public keys and derive the same
group_secret via ECDH + HKDF, without ever transmitting private key
material:
- A: pair_begin – send
{0x01: 0x01}→ response contains A's public key as a COSE_Key in map key0x01 - B: pair_begin – send
{0x01: 0x01}→ response contains B's public key - A: pair_finish – send
{0x01: 0x02, 0x02: <B's COSE_Key>}→ A computes and stores thegroup_secret - B: pair_finish – send
{0x01: 0x02, 0x02: <A's COSE_Key>}→ B computes and stores the samegroup_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.
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 authenticatorauthenticatorReset (0x07) erases the group_secret and PIN state.
The authenticator must be re-paired afterwards.
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.
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
allowListis required forgetAssertion(no discoverable credentials).
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.
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".
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.
- 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
MIT