morphit/docs/adr/0017-yubikey-unlock.md

11 KiB

ADR-0017: YubiKey unlock via layered-CEK keystore

Status: Accepted Date: 2026-04-28 Deciders: Agorise team (Claude collaborating) Supersedes:Related:

  • ADR-0010 (key custody) — defines the encrypted-keystore model this ADR extends.
  • ADR-0007 (keygen curve and dblurt package) — explains why Blurt's secp256k1 isn't substitutable with WebAuthn's P-256.
  • ADR-0022 (cross-device QR pair) — shares the layered-encryption pattern thinking; both ADRs are about adding additional unwrap paths to a keystore.

Context

Morphit's keystore today encrypts the user's identity JSON (seed bytes + per-role keypairs) under a single key derived from a user-chosen passphrase via Argon2id. This is a single-factor lock: anyone who learns the passphrase can decrypt the keystore. Phishing, keyloggers, shoulder- surfing, and password-reuse all attack this surface.

Hardware security keys (YubiKey, NitroKey, etc.) raise the bar by tying decryption to physical possession of a device that no remote attacker can reach. The maintainer asked for YubiKey support.

The design space narrows quickly:

Option 1 — WebAuthn / U2F. Browser-native, but uses ECDSA over P-256. Blurt uses secp256k1. Signatures from WebAuthn cannot replace Blurt posting/active signatures, so the YubiKey would have to wrap a separate keystore-key, not the chain key directly. This is doable but adds complexity and doesn't make the keystore-key any stronger than HMAC- based options.

Option 2 — WebHID + YubiKey HMAC-SHA1 challenge-response. Same approach as KeePassXC, age-yubikey, pam_yubico. The host sends a 64-byte challenge over the OTP-applet HID protocol; the YubiKey returns HMAC-SHA1(slot_secret, challenge). We treat this as a high-entropy "password" and run it through Argon2id to derive a wrap key.

We chose Option 2. Rationale:

  • Same protocol as the existing Linux/Windows/macOS hardware- key tooling users already trust. Cross-tool muscle memory.
  • Doesn't require any chain-protocol changes. Keystore stays the only thing the YubiKey wraps; chain keys remain unaffected.
  • Symmetric primitive (HMAC) sidesteps the curve-mismatch issue entirely.
  • WebHID is permission-gated by the browser and origin-bound; same security model as WebUSB.

The cost: WebHID is currently Chromium-only (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox and Safari users can't use this feature. We surface this clearly in the UI and let those users keep using passphrase unlock.

Decision

Adopt a layered-CEK keystore extension that supports any combination of passphrase wraps and YubiKey wraps.

Envelope format

The existing single-passphrase envelope continues to work unchanged. New envelopes that mix passphrase + YubiKey use a layered shape, distinguished by scheme: 'layered-cek':

KeystoreEnvelope = SimplePassphraseEnvelope | LayeredCekEnvelope

SimplePassphraseEnvelope {
  scheme?: 'simple-passphrase'  // optional; default for legacy envelopes
  v: 1
  kdf: 'argon2id'
  kdfParams: { opslimit, memlimit }
  salt:       base64
  nonce:      base64
  ciphertext: base64           // identity-JSON encrypted directly
  createdAt:  number
}

LayeredCekEnvelope {
  scheme: 'layered-cek'
  v: 1
  cekNonce:   base64           // nonce for CEK→identity AEAD
  ciphertext: base64           // identity-JSON encrypted under the CEK
  wraps: WrappedCek[]          // one or more independent unwrap paths
  createdAt:  number
}

WrappedCek = WrappedCekPassphrase | WrappedCekYubikey

WrappedCekPassphrase {
  kind: 'passphrase'
  kdf:  'argon2id'
  kdfParams: { opslimit, memlimit }
  salt:       base64
  nonce:      base64
  ciphertext: base64           // CEK encrypted under Argon2id(passphrase, salt)
}

WrappedCekYubikey {
  kind:           'yubikey'
  schemaVersion:  1
  slot:           1 | 2
  challenge:      base64       // 64-byte random challenge
  kdf:            'argon2id'
  kdfParams:      { opslimit, memlimit }
  salt:           base64
  nonce:          base64
  ciphertext:     base64       // CEK encrypted under
                                // Argon2id(HMAC-SHA1(slot_secret, challenge), salt)
  label:          string       // user-supplied, ≤ 64 chars
  enrolledAt:     number
}

Decryption

A CEK (32 random bytes) encrypts the identity-JSON via XSalsa20- Poly1305 AEAD (libsodium secretbox). Each wrap independently encrypts the same CEK under a different derived key. The unwrap-then-decrypt flow:

  1. Read envelope JSON.
  2. If scheme === 'simple-passphrase' (or omitted): use the pre-Batch-I path — Argon2id(passphrase, salt) → key → decrypt identity directly.
  3. If scheme === 'layered-cek': pick a wrap (passphrase or YubiKey based on user choice), derive its wrap key, decrypt the CEK, then decrypt the identity with the CEK.

State progression (A → B)

The user enrolls hardware-key support in two steps:

  • State A (default after enrollment): envelope has both a passphrase wrap and a yubikey wrap. Either factor unlocks. This is "convenient hardware key" — the YubiKey is a second unlock method but doesn't replace the passphrase.
  • State B (opt-in, hardened): envelope has only yubikey wraps. The passphrase wrap is removed. Only the physical key unlocks. Lose the YubiKey → recover via 12-word seed phrase only.

The (A)→(B) transition (hardenToYubikeyOnly) is gated behind an explicit acknowledgment. The reverse (softenToAlsoPassphrase) requires a fresh YubiKey unlock to recover the CEK.

CEK rotation on enrollment

Adding a YubiKey to an existing keystore rotates the CEK. All existing wraps must be rebuilt against the new CEK. This is the safer default — old wraps cannot decrypt the new envelope even if their key material is leaked. The trade-off: enrolling a second YubiKey forces re-enrollment of the first. The cost is one extra Argon2id per wrap during enrollment, which only happens once.

A future flag could preserve existing wraps if multi-YubiKey users find this annoying; we prefer the safer default until demand is shown.

Threat model

T1: Stolen device (no YubiKey present)

  • State A: passphrase wrap still works. Defense: passphrase entropy + Argon2id cost. Same as pre-Batch-I.
  • State B: no usable wrap. Even a full memory dump of the running app shows no usable secret. Strict improvement over (A).

T2: Phished/keylogged passphrase (YubiKey not stolen)

  • State A: attacker has passphrase, defeats the keystore. Same posture as pre-Batch-I. YubiKey doesn't help here.
  • State B: attacker has only the passphrase, which doesn't decrypt anything. YubiKey unwrap is the only path. This is the security win of (B).

T3: Stolen YubiKey alone

The YubiKey HMAC secret can be replayed forever, but the attacker also needs the encrypted keystore blob — which sits in the user's localStorage on their device. No keystore = no ciphertext = nothing to decrypt. YubiKey alone is insufficient.

T4: Stolen YubiKey + access to encrypted keystore

(Via backup export, cloud sync of localStorage, malware on the user's device, etc.)

  • State A: attacker can use yubikey wrap to recover CEK. Argon2id over HMAC output is the only friction. This is the known cost of (A) — the YubiKey gives you a SECOND unlock path, not a STRONGER one.
  • State B: same — YubiKey alone is sufficient with the ciphertext. Defense: keep the YubiKey safe. Same posture as a stolen passphrase in (A).

T5: Browser exploit during YubiKey unwrap

HMAC-SHA1 output transits the WebHID layer in browser memory for one operation. We Argon2id-stretch it before use so a brief read of the HMAC raw bytes still requires GPU time to brute-force the wrap key. We memzero() the HMAC buffer immediately after Argon2id consumes it.

T6: WebHID transport interception

Same-origin policy + USB-permission UX prevents cross-origin access. No mitigation available against a malicious WebHID polyfill or an attacker with arbitrary code execution on the user's device; users with that level of compromise have larger problems.

Browser support

WebHID is Chromium-only as of writing (Chrome, Edge, Brave, Arc, Vivaldi, Opera, and other Chromium derivatives). Firefox and Safari do not expose navigator.hid.

Settings → Hardware key feature-detects via isWebHidSupported() and renders an "your browser doesn't support hardware keys" card on Firefox/Safari. Login page also feature-detects: state-A users on Firefox don't see the "Unlock with YubiKey" button; state-B users on Firefox see the YubiKey-required UI with the unlock button disabled plus a clear message ("Use Chrome / Edge / Brave, or use your seed phrase to enter").

Wire fidelity caveat

The WebHID transport layer in apps/web/src/lib/crypto/yubikey/transport.ts implements the OTP-applet HID feature-report protocol from public Yubico documentation. This implementation has NOT been live-fired against a physical YubiKey from the dev sandbox; the HID frame layout (8-byte feature reports, 7-byte payload + 1 status, slot commands 0x30/0x38, RESP_PENDING + WAIT polling) is best-effort.

The wrap/unwrap math (Argon2id over HMAC output, ChaCha20- Poly1305 AEAD, CEK rotation) is independently smoke-tested in apps/indexer/scripts/yubikey-protocol-smoke.ts with a deterministic stub HMAC. The remaining surface that can fail at integration time is narrow: the byte-level USB feature- report shape.

A user with a real YubiKey must validate the integration in the browser before this batch is declared production- ready. The first-class test is: enroll a YubiKey in Settings → Hardware key, sign out, log back in via "Unlock with YubiKey", and observe that the identity store rehydrates.

Rollback path

This change is non-destructive. Pre-Batch-I keystores remain valid forever — readers default scheme to 'simple- passphrase' when the field is missing. A user who enrolls a YubiKey can always:

  • Remove the YubiKey wrap (unenrollWrap) — falls back to passphrase-only unlock.
  • Soften from state B to state A (softenToAlsoPassphrase) — re-adds a passphrase wrap.
  • Re-import from seed phrase — produces a fresh simple- passphrase envelope.

There is no path that silently loses access; the smallest "unrecoverable" pre-condition (state B + lost YubiKey) is recoverable via the user's 12-word seed phrase, which is the universal fallback for non-custodial accounts.

What this ADR doesn't decide

  • Multi-YubiKey backup convenience. Today, enrolling YK#2 invalidates YK#1's wrap (CEK rotation). A future ADR could add a preserve_existing flag for users who want to enroll N keys without re-enrolling each one. Holding off until someone asks.
  • WebAuthn/passkeys integration. We chose WebHID for the HMAC-SHA1 reasons above. If the platform later exposes a symmetric-friendly hardware-key API (e.g., HMAC over WebAuthn via a hypothetical extension), we could revisit. Not on the roadmap.
  • Touch-required vs touch-optional slots. The user's YubiKey configuration determines this — if they programmed the slot with "require touch," the response holds until tap; otherwise it returns immediately. We poll either way and show "tap your YubiKey" UI for both.