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:

OffsetLengthAlgorithmPurpose
0–3132 bytesX25519 (Curve25519 ECDH)Encryption / key exchange
32–6332 bytesEd25519 seedSigning / 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:

ContextPath
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

CriterionA: FIDO2 hmac-secretB: On-Device CryptoC: Encrypted BlobD: PIV PKCS#11
FeasibilityHIGHLOW-MODERATEHIGHLOW
RNS modificationNoneSignificantNoneSignificant
Key in memoryYes (runtime)NeverYes (runtime)Never
Key at restNot on diskOn hardwareEncrypted on diskOn hardware
Implementation~50–80 linesWeeks~80–120 linesWeeks+, immature
PortabilityYubiKey onlyYubiKey onlyYubiKey + blob fileYubiKey only
Hardware reqYubiKey 5 (fw 5.2+)YubiKey 5 (fw 5.2.3+ OpenPGP / 5.7+ PIV)Any YubiKey w/ HMACYubiKey 5.7+ only
Ecosystem maturityMatureWireGuard precedentMatureImmature

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

  1. One-time setup: Create a FIDO2 resident credential with hmac-secret enabled
  2. Identity derivation (every session):
    • Send two application-specific salts: SHA-256("styrene-signing-v1") and SHA-256("styrene-encryption-v1")
    • YubiKey returns two independent 32-byte outputs
    • Load as Ed25519 seed and X25519 private key via Identity.from_bytes()
  3. 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

PropertyAssessment
PRF seedNever leaves YubiKey secure element
Derived keysExist in process memory during session
At restNothing on disk (derived on demand)
PIN enforcementRequired — FIDO2 user verification
Touch enforcementConfigurable per credential
Loss recoveryBack up credential ID; PRF seed is device-bound
Multi-identityDifferent salts → different identities from same YubiKey

Why hmac-secret Over HMAC-SHA1 Slot

HMAC-SHA1 (OTP slot)FIDO2 hmac-secret
Output size20 bytes (needs HKDF expansion)32 bytes (native)
Simultaneous outputs12 (salt1 + salt2 → perfect for dual-key)
Secret protectionProgrammable from host (copyable at creation)PRF seed generated on-device, never extractable
Slot consumptionUses OTP slot 1 or 2Uses FIDO2 credential (unlimited)
PIN requiredNo (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() and Identity.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

  1. Generate a standard RNS identity (64-byte private key)
  2. Derive an AES-256-GCM encryption key from YubiKey HMAC challenge-response (or FIDO2 hmac-secret)
  3. Encrypt the identity and store the blob on disk
  4. At runtime: challenge YubiKey → derive decryption key → decrypt → load identity
  5. 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

AppletEd25519X25519Min FirmwareRaw Signatures
OpenPGPSigning + AuthDecryption5.2.3No (PGP-wrapped)
FIDO2CredentialsN/A5.2.xNo (CTAP2-wrapped)
PIVSigning + AuthECDH5.7.0Yes

For FIDO2 hmac-secret (Approach A), algorithm support is irrelevant — the PRF operates independently of credential key type.


Python Library Landscape

LibraryMaintainerAppletsUse Case
python-fido2Yubico (official)FIDO2/CTAP2hmac-secret derivation (Approach A)
yubikey-managerYubico (official)PIV, OpenPGP, OTP, FIDO2PIV Ed25519 (Approach D)
python-gnupgCommunityOpenPGP (via gpg)On-card signing (Approach B)
pyscardCommunityRaw smartcardLow-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

  1. Identity revocation: How does the fleet learn a YubiKey (and thus identity) is compromised? The Hub identity registry could maintain a revocation list.
  2. Fleet device keys: Is hardware-backed identity needed for unattended fleet devices (RPi Zero 2W), or only for operator workstations?
  3. Multi-identity: Same YubiKey, different salts → distinct identities (personal, fleet admin, hub operator). Namespace design TBD.
  4. RNS upstream appetite: Would Mark Qvist accept a PR adding abstract signing/ECDH to the Identity class?
  5. 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

Graph