|
| 1 | +# FIDO2 large-blob storage — roadmap & design |
| 2 | + |
| 3 | +- **Date:** 2026-06-30 |
| 4 | +- **Status:** Design approved in brainstorming; roadmap captured for review. Not |
| 5 | + scheduled to a specific release — tiers land as they're built, around whatever |
| 6 | + v0.7.3 turns up in the field. |
| 7 | +- **Scope:** The CTAP 2.1 `authenticatorLargeBlobs` array that is common across |
| 8 | + modern FIDO2 keys (YubiKey 5.5.1+, Token2, Solo/Nitrokey, etc.). Not the |
| 9 | + Molto2 device (see *Out of scope*). |
| 10 | + |
| 11 | +## The one idea everything hangs on |
| 12 | + |
| 13 | +**The user decides what their large blob is for.** |
| 14 | + |
| 15 | +The large blob is a single small (~1 KB), key-global side area — separate from |
| 16 | +FIDO credentials, separate from PIV/OpenPGP. keyroost's job is to make that |
| 17 | +space *legible* and then let the person *deliberately claim it* for a purpose: |
| 18 | + |
| 19 | +- leave it alone (just inspect what's there), |
| 20 | +- keep a plain-text note (world-readable), |
| 21 | +- lock a few secrets into an **encrypted note**, or |
| 22 | +- hold an **SSH certificate** the way OpenSSH/Yubico intend. |
| 23 | + |
| 24 | +That "choose how to use it" is the top-level UX. Newcomers pick a **purpose |
| 25 | +preset**; power users get a **capacity meter + raw hex/ASCII view** underneath. |
| 26 | +One space, one deliberate choice, clearly surfaced. |
| 27 | + |
| 28 | +## Why keyroost is the right home for this |
| 29 | + |
| 30 | +keyroost is a friendly, cross-platform, cross-vendor management layer for |
| 31 | +hardware security keys. First-party tooling for the large blob is thin and |
| 32 | +getting thinner: |
| 33 | + |
| 34 | +- The only established large-blob tooling is `fido2-token`/libfido2 — |
| 35 | + developer-grade, Linux-centric (`/dev/hidraw0` device paths), no GUI |
| 36 | + ([Yubico: Storing SSH Certificates](https://developers.yubico.com/SSH/Storing_SSH_Certificates.html)). |
| 37 | +- **YubiKey Manager GUI reached end-of-life on 2026-02-19**; Yubico now points |
| 38 | + users to *Yubico Authenticator*, which does **not** manage large blobs, PIV |
| 39 | + data objects, or SSH-cert-on-key. The friendly-GUI surface for this work just |
| 40 | + disappeared. |
| 41 | + |
| 42 | +keyroost already implements the hard part — a checksum-safe, structural |
| 43 | +large-blob read/write layer (`keyroost-ctap/src/large_blobs.rs`) with a |
| 44 | +plain-text note layer, exposed via `keyroostctl fido large-blob …` and the GUI |
| 45 | +Storage sub-view. This roadmap builds *purpose* on top of that foundation. |
| 46 | + |
| 47 | +## What a large blob actually is (and isn't) |
| 48 | + |
| 49 | +Grounding, because it's widely conflated with SSH auth: |
| 50 | + |
| 51 | +- **SSH auth on a security key** uses a FIDO2 **discoverable/resident |
| 52 | + credential** (`ssh-keygen -t ed25519-sk -O resident`). The private seed never |
| 53 | + leaves the key; `ssh-keygen -K` regenerates the stub files anywhere. This has |
| 54 | + **nothing to do with the large blob** — it's the credential store, which |
| 55 | + keyroost already manages (creds list/delete). |
| 56 | +- **The large blob** is a separate ~1 KB scratch area. Its only spec-blessed SSH |
| 57 | + use is holding the **CA-signed SSH certificate** (`-cert.pub`) so the cert |
| 58 | + travels with the resident credential. So the blob adds value for SSH **only if |
| 59 | + you use SSH certificates** — an org/CA/fleet workflow, not plain sk-key auth. |
| 60 | + |
| 61 | +Consequence for the roadmap: the blob is not a storage product. It's a |
| 62 | +*legibility problem* plus a *small pool of leftable space*. The tiers below are |
| 63 | +ordered by honest value, not novelty. |
| 64 | + |
| 65 | +## The tiers |
| 66 | + |
| 67 | +| Tier | Purpose | Audience | New crypto/transport | Status | |
| 68 | +|---|---|---|---|---| |
| 69 | +| **A** | **Legibility** — decode & export everything in the blob | anyone curious about their key | none | to build (raw hex exists) | |
| 70 | +| **B** | **Plain-text note** | casual | none | **shipped** | |
| 71 | +| **C1** | **Encrypted note (passphrase)** — portable | recovery codes, wallet seeds | KDF + AEAD | to build | |
| 72 | +| **C2** | **Encrypted note (device-bound)** — hardware-hardened | offline-attack-averse | `hmac-secret` | to build (additive on C1) | |
| 73 | +| **D** | **SSH-cert companion** — real interop | SSH-CA / fleet users | `largeBlobKey` AEAD | to build | |
| 74 | + |
| 75 | +Sequencing is **pure engineering layering**, not demand-gated. We build C1, then |
| 76 | +layer C2 and D as they come. No tier waits on a usage signal to justify the |
| 77 | +next. |
| 78 | + |
| 79 | +### A — Legibility (the foundation) |
| 80 | + |
| 81 | +Make the blob's contents readable and exportable, whatever put them there. |
| 82 | + |
| 83 | +- **Entry recognition.** Classify each entry in the array: |
| 84 | + - keyroost plain-text note (existing magic prefix), |
| 85 | + - keyroost encrypted note (new container, see C), |
| 86 | + - opaque relying-party / AEAD data (read-only; keyroost never rewrites it), |
| 87 | + - **SSH certificate** — recognize the OpenSSH cert format and decode the |
| 88 | + human-relevant fields (type, key-id, principals, valid-from/valid-to, |
| 89 | + critical options). |
| 90 | +- **Views.** Per-entry hex + ASCII (exists), plus a parsed/structured view for |
| 91 | + recognized types. A **capacity meter**: total / used / free bytes and entry |
| 92 | + count, so scarcity is visible before the user commits space. |
| 93 | +- **Export.** Save any entry's bytes to a file (e.g. dump an SSH cert back to |
| 94 | + `-cert.pub`). |
| 95 | +- Everything here is read-only inspection + export; no new crypto, low risk. |
| 96 | + This is keyroost's defensible core and the immediate ykman-GUI-gap filler. |
| 97 | + |
| 98 | +### C — Encrypted note |
| 99 | + |
| 100 | +The value center. AEAD-encrypt small, high-value "break-glass" data that should |
| 101 | +live physically on the key: Signal/session recovery codes, a wallet seed, key |
| 102 | +info. Stored as a keyroost-authored entry alongside (not replacing) RP data. |
| 103 | + |
| 104 | +**On-blob container format (designed once, in C1, to carry both modes):** |
| 105 | + |
| 106 | +``` |
| 107 | +magic keyroost encrypted-note tag (distinct from the plain-note magic) |
| 108 | +version u8 format version |
| 109 | +protection_mode u8 0 = passphrase (C1), 1 = device-bound, 2 = passphrase+device (C2) |
| 110 | +kdf_id u8 e.g. Argon2id / scrypt identifier |
| 111 | +kdf_params … cost params + salt (passphrase modes) |
| 112 | +hmac_cred_ref … credential/salt reference (device-bound modes; absent in C1) |
| 113 | +nonce … AEAD nonce |
| 114 | +ciphertext+tag … AEAD(plaintext) |
| 115 | +``` |
| 116 | + |
| 117 | +The `version` + `protection_mode` fields are the whole point of doing the format |
| 118 | +work in C1: C2 becomes purely additive — new mode value, no migration, existing |
| 119 | +notes keep decrypting. |
| 120 | + |
| 121 | +**Capacity reality.** ~1 KB total blob, minus existing entries, minus container |
| 122 | +overhead (salt + nonce + tag + header ≈ 60–90 bytes). This holds a handful of |
| 123 | +short codes, not a vault. The UI states this plainly and the capacity meter |
| 124 | +enforces the expectation. |
| 125 | + |
| 126 | +#### C1 — passphrase mode (portable) |
| 127 | + |
| 128 | +- Key = strong KDF(passphrase, salt): **Argon2id** (preferred) or scrypt, tuned |
| 129 | + for offline-attack resistance since the blob is world-readable. |
| 130 | +- AEAD = **AES-256-GCM** or **ChaCha20-Poly1305** over the note text. |
| 131 | +- **Portable by design:** the encrypted entry can be exported to a file, backed |
| 132 | + up, and imported into any key/device; the same passphrase decrypts. Matches |
| 133 | + the "travels between devices / lives on the hardware" use cases. |
| 134 | +- **Threat note:** world-readable blob ⇒ a stolen key permits an *offline* |
| 135 | + brute-force. Passphrase strength and KDF cost are the only defense; the UI |
| 136 | + must say so and default the KDF cost high. |
| 137 | + |
| 138 | +#### C2 — device-bound mode (additive) |
| 139 | + |
| 140 | +- Fold in the FIDO2 **`hmac-secret`** extension (this key + PIN + touch) so |
| 141 | + decryption requires the physical key — removing the offline-attack path. |
| 142 | +- **Composition rule (to finalize):** default to *passphrase AND device* — both |
| 143 | + required — so a stolen blob is uncrackable without the key, and a note is not |
| 144 | + silently single-factor. (Alternative "passphrase OR device" is more convenient |
| 145 | + but weakest-link; decision deferred to C2 design, but the container reserves |
| 146 | + space for either.) |
| 147 | +- **Capability handling:** detect `hmac-secret` support; hide/disable the bind |
| 148 | + toggle where unsupported. keyroost already recognizes `hmac-secret`. |
| 149 | +- **Tradeoff surfaced in UI:** device-bound notes are *not* portable and die |
| 150 | + with the key — correct for break-glass copies, wrong for sole-copy secrets. |
| 151 | + |
| 152 | +### D — SSH-cert companion (finish what has no GUI) |
| 153 | + |
| 154 | +Genuine interop with the Yubico/OpenSSH large-blob SSH-cert flow, cross-platform |
| 155 | +for any key with a large blob. |
| 156 | + |
| 157 | +- Implement CTAP **`largeBlobKey`** + per-credential **AES-256-GCM** so keyroost |
| 158 | + can *author and retrieve* SSH-cert entries that `fido2-token`/OpenSSH |
| 159 | + round-trip — not just display them (A already displays them). |
| 160 | +- Store a CA-signed cert against its resident credential; retrieve/export it on a |
| 161 | + fresh machine. This is the "as Yubico intends" path, minus the Linux-only |
| 162 | + `fido2-token` friction. |
| 163 | +- Smallest audience, largest build — hence last. A depends on none of this; D |
| 164 | + builds on A's SSH-cert recognition. |
| 165 | + |
| 166 | +## Dependencies & conventions |
| 167 | + |
| 168 | +- **New crypto dependency — needs the standard "vendor over depend" discussion.** |
| 169 | + C needs a KDF (Argon2id/scrypt) + an AEAD (AES-256-GCM / ChaCha20-Poly1305); |
| 170 | + D needs AES-256-GCM. Precedent exists: `keyroost-token2otp` already carries a |
| 171 | + scoped RustCrypto exception (`aes`, `cbc`, `sha2`, `p256`, `zeroize`). Options |
| 172 | + to decide at C1 design time: (a) extend that RustCrypto exception, (b) vendor |
| 173 | + the primitives in-tree as with SM4/SHA-1. `zeroize` for plaintext/secret |
| 174 | + buffers either way. |
| 175 | +- **Writes stay checksum-safe and structural** via the existing |
| 176 | + `large_blobs.rs` path (re-read array, apply, recompute checksum, PIN-authed |
| 177 | + write). Encrypted/SSH entries are just new entry *types* in that same array. |
| 178 | +- **Honesty in the UI is a feature.** Plain notes: "world-readable, not for |
| 179 | + secrets." Encrypted notes: state the offline-attack caveat (C1) and the |
| 180 | + not-portable/dies-with-key caveat (C2). Capacity meter always visible. |
| 181 | + |
| 182 | +## Out of scope / separate work |
| 183 | + |
| 184 | +- **Molto2 per-profile title (≤12 chars).** A field on the Molto2 device, *not* |
| 185 | + a FIDO2 large blob. Tracked separately in `TODO-v0.7.5.md`. |
| 186 | +- **OpenPGP private DOs (0101–0104) and login-data (005E).** The OpenPGP-applet |
| 187 | + analog of arbitrary storage; smaller, PW-gated, gpg-ecosystem-specific. A |
| 188 | + possible future "device storage" story, not part of this large-blob roadmap. |
| 189 | +- **PIV data objects (Printed Info / Discovery).** No natural free-text fit; |
| 190 | + everything PIV does is keys/certs. Skip. |
| 191 | +- **Storing the SSH pubkey next to the resident key.** Neat but redundant — |
| 192 | + regenerable via `ssh-keygen -K`. Not worth a tier. |
| 193 | +- **Nitrokey product integration.** A separate future track; noted so the |
| 194 | + cross-vendor direction is on record. |
| 195 | + |
| 196 | +## Testing |
| 197 | + |
| 198 | +- **A:** fixtures for each entry type (plain note, RP/AEAD opaque, OpenSSH cert); |
| 199 | + assert correct classification + field decode; export byte-exactness. |
| 200 | +- **C:** known-answer vectors for the container (KDF + AEAD) so encryption is |
| 201 | + reproducible; round-trip encrypt→store→read→decrypt; wrong-passphrase and |
| 202 | + tamper (bad tag) rejection; capacity-exceeded handling. |
| 203 | +- **D:** interop vectors validating a keyroost-written SSH-cert entry is |
| 204 | + retrievable by `fido2-token`/OpenSSH, and vice-versa. |
| 205 | +- Any change to command/APDU construction keeps the existing known-answer suites |
| 206 | + green (project convention). |
| 207 | + |
| 208 | +## Open decisions (to settle at each tier's design time) |
| 209 | + |
| 210 | +1. KDF choice + cost defaults (Argon2id vs scrypt) and AEAD choice |
| 211 | + (AES-256-GCM vs ChaCha20-Poly1305) — C1. |
| 212 | +2. Where the crypto lives: extend the RustCrypto exception vs vendor in-tree — C1. |
| 213 | +3. C2 composition rule: passphrase-AND-device vs passphrase-OR-device. |
| 214 | +4. Whether the "purpose preset" picker is per-key state keyroost remembers, or |
| 215 | + purely a per-action framing at add-time — A/C UX. |
0 commit comments