YubiKey-Backed RNS Identity
Summary: Feasibility study for deriving or storing Reticulum mesh identities on a YubiKey, enabling transient operator identity that migrates between devices by moving the hardware token.
Date: 2026-02-18 Context: Operator identity portability — the same mesh identity should follow the operator across workstations without copying key files.
Executive Summary
An RNS identity is a 64-byte raw binary blob (32-byte X25519 + 32-byte Ed25519 seed). No PKCS, no PEM — just raw bytes loaded via Identity.from_bytes(). This simplicity makes hardware-backed derivation straightforward.
Recommended approach: Use the FIDO2 hmac-secret extension (YubiKey firmware 5.2+) to deterministically derive identity key material from the hardware token’s internal PRF. Zero RNS patches required. The YubiKey becomes the identity — plug it in, derive the keypair, operate the mesh.
Four approaches were evaluated; two are immediately feasible, two require upstream RNS changes.
RNS Identity Internals
Key Structure
Each RNS identity is a dual-keypair:
| Offset | Length | Algorithm | Purpose |
|---|---|---|---|
| 0–31 | 32 bytes | X25519 (Curve25519 ECDH) | Encryption / key exchange |
| 32–63 | 32 bytes | Ed25519 seed | Signing / verification |
Address Derivation
identity_hash = SHA-256(pub_X25519 ‖ pub_Ed25519)[:16] → 128-bit address
destination_hash = SHA-256(name_hash[:10] ‖ identity_hash)[:16]
The same identity yields different destination hashes per application (LXMF delivery, styrene operator, etc.).
Storage Format
Identity files are raw binary, exactly 64 bytes. No headers, no encoding. Locations:
| Context | Path |
|---|---|
| Reticulum transport | ~/.reticulum/storage/identity |
| NomadNet | ~/.nomadnetwork/storage/identity |
| styrened (system) | /etc/styrene/identity |
| styrened (user) | ~/.styrene/operator.key |
Loading
identity = RNS.Identity.from_bytes(prv_bytes) # 64-byte private key
identity = RNS.Identity.from_file("/path/to/identity")
No abstract interface exists for signing or ECDH — the Identity class calls self.sig_prv.sign() and self.prv.exchange() directly on PyCA key objects. Any approach that avoids loading raw key bytes into memory requires modifying RNS upstream.
Approach Comparison
| Criterion | A: FIDO2 hmac-secret | B: On-Device Crypto | C: Encrypted Blob | D: PIV PKCS#11 |
|---|---|---|---|---|
| Feasibility | HIGH | LOW-MODERATE | HIGH | LOW |
| RNS modification | None | Significant | None | Significant |
| Key in memory | Yes (runtime) | Never | Yes (runtime) | Never |
| Key at rest | Not on disk | On hardware | Encrypted on disk | On hardware |
| Implementation | ~50–80 lines | Weeks | ~80–120 lines | Weeks+, immature |
| Portability | YubiKey only | YubiKey only | YubiKey + blob file | YubiKey only |
| Hardware req | YubiKey 5 (fw 5.2+) | YubiKey 5 (fw 5.2.3+ OpenPGP / 5.7+ PIV) | Any YubiKey w/ HMAC | YubiKey 5.7+ only |
| Ecosystem maturity | Mature | WireGuard precedent | Mature | Immature |
Approach A: FIDO2 hmac-secret Derivation (Recommended)
Mechanism
The FIDO2 hmac-secret extension provides a hardware-backed PRF (pseudorandom function). The YubiKey maintains an internal seed in its secure element that never leaves the device. Given a credential and a salt, it returns a deterministic 32-byte HMAC-SHA256 output.
┌─────────────┐
salt_sign ───────►│ │──────► 32 bytes ──► Ed25519 seed
│ YubiKey │
salt_encrypt ────►│ hmac-secret│──────► 32 bytes ──► X25519 key
│ PRF │
PIN/touch ───────►│ │
└─────────────┘
│
(PRF seed never
leaves device)
Flow
- One-time setup: Create a FIDO2 resident credential with
hmac-secretenabled - Identity derivation (every session):
- Send two application-specific salts:
SHA-256("styrene-signing-v1")andSHA-256("styrene-encryption-v1") - YubiKey returns two independent 32-byte outputs
- Load as Ed25519 seed and X25519 private key via
Identity.from_bytes()
- Send two application-specific salts:
- Same YubiKey + same salts = same identity on any machine
Python Implementation Sketch
from fido2.hid import CtapHidDevice
from fido2.client import Fido2Client
from hashlib import sha256
import RNS
SALT_SIGN = sha256(b"styrene-signing-v1").digest() # 32 bytes
SALT_ENC = sha256(b"styrene-encryption-v1").digest() # 32 bytes
RP_ID = "styrene.mesh"
# Credential creation (one-time)
def create_credential(client):
result = client.make_credential({
"rp": {"id": RP_ID, "name": "Styrene Mesh"},
"user": {"id": b"operator", "name": "operator"},
"pubKeyCredParams": [{"type": "public-key", "alg": -8}], # EdDSA
"extensions": {"hmacCreateSecret": True},
"authenticatorSelection": {"residentKey": "required"},
})
return result.attestation_object.auth_data.credential_data
# Identity derivation (every session)
def derive_identity(client, credential_id):
result = client.get_assertion({
"rpId": RP_ID,
"allowCredentials": [{"type": "public-key", "id": credential_id}],
"extensions": {"hmacGetSecret": {"salt1": SALT_SIGN, "salt2": SALT_ENC}},
})
hmac = result.get_response(0).extension_results
signing_seed = hmac["hmacGetSecret"]["output1"] # 32 bytes
encryption_key = hmac["hmacGetSecret"]["output2"] # 32 bytes
prv_bytes = encryption_key + signing_seed # 64 bytes: X25519 ‖ Ed25519
identity = RNS.Identity.from_bytes(prv_bytes)
return identity
Security Properties
| Property | Assessment |
|---|---|
| PRF seed | Never leaves YubiKey secure element |
| Derived keys | Exist in process memory during session |
| At rest | Nothing on disk (derived on demand) |
| PIN enforcement | Required — FIDO2 user verification |
| Touch enforcement | Configurable per credential |
| Loss recovery | Back up credential ID; PRF seed is device-bound |
| Multi-identity | Different salts → different identities from same YubiKey |
Why hmac-secret Over HMAC-SHA1 Slot
| HMAC-SHA1 (OTP slot) | FIDO2 hmac-secret | |
|---|---|---|
| Output size | 20 bytes (needs HKDF expansion) | 32 bytes (native) |
| Simultaneous outputs | 1 | 2 (salt1 + salt2 → perfect for dual-key) |
| Secret protection | Programmable from host (copyable at creation) | PRF seed generated on-device, never extractable |
| Slot consumption | Uses OTP slot 1 or 2 | Uses FIDO2 credential (unlimited) |
| PIN required | No (touch optional) | Yes (FIDO2 user verification) |
Approach B: On-Device Crypto (OpenPGP / PIV)
Mechanism
Store Ed25519 and X25519 keys directly on the YubiKey. All signing and ECDH operations happen on-chip — the private key never enters host memory.
Architecture (modeled on openpgpcard-wireguard-go)
RNS Identity ──[agent protocol]──► styrene-identity-agent ──[PC/SC]──► YubiKey
│
sign() → raw Ed25519 sig
exchange() → X25519 shared secret
Blockers
- No abstract interface in RNS:
Identity.sign()andIdentity.decrypt()call PyCA key objects directly. Requires patching the Identity class to support a pluggable crypto backend. - PIV Ed25519: Only available on firmware 5.7+ (shipped late 2024). PKCS#11 Ed25519 tooling is immature — even OpenSSH cannot use it yet.
- OpenPGP applet: Supports Ed25519 since firmware 5.2.3, but wraps signatures in PGP packet format. Raw Ed25519 extraction requires additional unwrapping.
Precedent
The openpgpcard-wireguard-go project patches the WireGuard Go client to proxy X25519 ECDH through an OpenPGP card. The architecture works but WireGuard only needs X25519 — RNS additionally needs Ed25519 signing, doubling the integration surface.
Approach C: Encrypted Key Blob
Mechanism
- Generate a standard RNS identity (64-byte private key)
- Derive an AES-256-GCM encryption key from YubiKey HMAC challenge-response (or FIDO2 hmac-secret)
- Encrypt the identity and store the blob on disk
- At runtime: challenge YubiKey → derive decryption key → decrypt → load identity
- To migrate: copy encrypted blob + move YubiKey
When to Prefer Over Approach A
- Pre-existing identities: Wrap an already-generated identity without changing its hash
- Fleet devices: Encrypted blob on disk for devices that boot with a YubiKey attached
- Backup flexibility: Encrypted blob can exist in multiple locations (cloud, USB, etc.)
Tradeoffs
Same runtime security as Approach A (key in memory during use), but adds a file dependency. The encrypted blob is useless without the YubiKey. Supports backup scenarios that pure derivation (Approach A) does not — with Approach A, if you lose the YubiKey and have no backup credential, the identity is gone.
Approach D: PIV PKCS#11
Specialization of Approach B using PIV applet (firmware 5.7+) via YKCS11. Currently infeasible: Ed25519-over-PKCS#11 ecosystem is immature, python-pkcs11 EdDSA support is untested with real hardware, and OpenSSH cannot use Ed25519 via PKCS#11 (upstream patch unmerged). Revisit when tooling matures.
YubiKey Algorithm Support Matrix
| Applet | Ed25519 | X25519 | Min Firmware | Raw Signatures |
|---|---|---|---|---|
| OpenPGP | Signing + Auth | Decryption | 5.2.3 | No (PGP-wrapped) |
| FIDO2 | Credentials | N/A | 5.2.x | No (CTAP2-wrapped) |
| PIV | Signing + Auth | ECDH | 5.7.0 | Yes |
For FIDO2 hmac-secret (Approach A), algorithm support is irrelevant — the PRF operates independently of credential key type.
Python Library Landscape
| Library | Maintainer | Applets | Use Case |
|---|---|---|---|
python-fido2 | Yubico (official) | FIDO2/CTAP2 | hmac-secret derivation (Approach A) |
yubikey-manager | Yubico (official) | PIV, OpenPGP, OTP, FIDO2 | PIV Ed25519 (Approach D) |
python-gnupg | Community | OpenPGP (via gpg) | On-card signing (Approach B) |
pyscard | Community | Raw smartcard | Low-level PC/SC access |
Recommendation
Phase 1: FIDO2 hmac-secret Derivation (Approach A)
Implement as a styrened identity provider. Zero RNS patches. ~50–80 lines of core logic. Works on all YubiKey 5 series with current firmware (5.2+). The YubiKey IS the operator identity.
Phase 2: Encrypted Blob Wrapper (Approach C)
Add as an option for pre-existing identities and fleet devices. Shares the same HMAC derivation step. ~30–40 additional lines atop Phase 1.
Phase 3: Upstream RNS Abstract Signing Interface (Approach B)
Contribute a PR to Reticulum adding a pluggable signing/ECDH interface to the Identity class. This unblocks on-device crypto (key never in memory) for the entire RNS ecosystem. Wait for PIV Ed25519 PKCS#11 tooling to mature before attempting Approach D.
Open Questions
- Identity revocation: How does the fleet learn a YubiKey (and thus identity) is compromised? The Hub identity registry could maintain a revocation list.
- Fleet device keys: Is hardware-backed identity needed for unattended fleet devices (RPi Zero 2W), or only for operator workstations?
- Multi-identity: Same YubiKey, different salts → distinct identities (personal, fleet admin, hub operator). Namespace design TBD.
- RNS upstream appetite: Would Mark Qvist accept a PR adding abstract signing/ECDH to the Identity class?
- Credential backup: FIDO2 resident credentials are device-bound. Recovery strategy: generate on two YubiKeys simultaneously, or accept that losing the YubiKey means re-provisioning.
Sources
- Reticulum Identity.py source — key generation, serialization, crypto operations
- YubiKey 5.7 Firmware Specifics — PIV Ed25519/X25519 support
- FIDO2 hmac-secret Extension — hardware PRF specification
- CTAP2 HMAC Secret Deep Dive — protocol internals
- python-fido2 hmac_secret.py example
- openpgpcard-wireguard-go — WireGuard X25519 on OpenPGP card precedent
- age-plugin-yubikey — age encryption identity on YubiKey PIV
- YubiKey OpenPGP 3.4 Ed25519 support
- YKCS11 Ed25519 issue — PKCS#11 ecosystem immaturity
- drduh/YubiKey-Guide — comprehensive YubiKey setup reference