Protect borg keys with FIDO2 hardware tokens (hmac-secret) - #10399
ThomasWaldmann wants to merge 5 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #10399 +/- ##
==========================================
+ Coverage 88.28% 88.34% +0.05%
==========================================
Files 103 104 +1
Lines 18904 19246 +342
Branches 2937 2994 +57
==========================================
+ Hits 16690 17002 +312
- Misses 1540 1560 +20
- Partials 674 684 +10 ☔ View full report in Codecov by Harness. |
fido2 hmac-secret -> touch required!?The standard mandating "touch is required" ("UP" = user presence) makes this (using the yubikey) unusable for unattended backups. Considering that backups usually should be able to run unattended, this makes the whole approach questionable. PIV or pkcs#11These can work without UP (without the "touch"), maybe rather use one of these? |
eb3485e to
6abcdcd
Compare
I know, I am probably an outlier, but I backup my desktops/laptops "manually" and this isn't a problem for me. An alternative approach is to decrypt the key and cache it in a safe manner using e.g. Linux' kernel keyring infrastructure. |
|
@steelman Or just using PIV or pkcs#11. The linux kernel keyring would be very platform-specific. |
|
Thinking about just using https://github.com/FiloSottile/age For that, we don't need code, just some docs: #10404 |
…keys) New deployment chapter showing how to combine BORG_PASSCOMMAND with age: a strong random borg passphrase stored age-encrypted, decrypted on demand - and, via the age plugin ecosystem, bound to hardware: any FIDO2 security key (age-plugin-fido2-hmac, touch-gated by CTAP spec, interactive use), YubiKey PIV (age-plugin-yubikey, policies allow silent unattended use), TPM 2.0 (age-plugin-tpm) and Apple Secure Enclave (age-plugin-se) for machine-bound unattended backups. The guide starts with a plain no-hardware example to introduce the pattern, gives an at-a-glance table of what works unattended vs. what requires user presence, covers multi-recipient redundancy (several YubiKeys / TPM unlocking the same passphrase), the offline paper copy as the recovery path, revocation (change-passphrase, not just re-encrypting) and an honest threat-model note (at-rest protection; presence-gated options prevent silent unlocking, device-bound ones do not). This is the supported way to use hardware-backed key protection with borg, instead of borg-native hardware integrations. See the discussion in borgbackup#8995 / borgbackup#10399. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…keys) New deployment chapter showing how to combine BORG_PASSCOMMAND with age: a strong random borg passphrase stored age-encrypted, decrypted on demand - and, via the age plugin ecosystem, bound to hardware: any FIDO2 security key (age-plugin-fido2-hmac, touch-gated by CTAP spec, interactive use), YubiKey PIV (age-plugin-yubikey, policies allow silent unattended use), TPM 2.0 (age-plugin-tpm) and Apple Secure Enclave (age-plugin-se) for machine-bound unattended backups. The guide starts with a plain no-hardware example to introduce the pattern, gives an at-a-glance table of what works unattended vs. what requires user presence, covers multi-recipient redundancy (several YubiKeys / TPM unlocking the same passphrase), the offline paper copy as the recovery path, revocation (change-passphrase, not just re-encrypting) and an honest threat-model note (at-rest protection; presence-gated options prevent silent unlocking, device-bound ones do not). This is the supported way to use hardware-backed key protection with borg, instead of borg-native hardware integrations. See the discussion in borgbackup#8995 / borgbackup#10399. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wrapper (crypto/fido2.py) covers what borg needs from a FIDO2 authenticator: enumerate/open devices, find the token holding a credential via a silent CTAP 2.1 pre-flight probe (auto-discovery, BORG_FIDO2_DEVICE pins a device), enroll a non-resident hmac-secret credential and reproduce its device-bound 32-byte secret. User verification is treated generically (client PIN or built-in UV, e.g. biometrics - the latter never causes a PIN prompt) and, when the token has UV configured, enforced via a real pinUvAuthToken in both makeCredential and getAssertion - the hmac-secret output differs with and without UV, so unlock has to repeat exactly what enrollment did. PIN acquisition is separate from the passphrase machinery (BORG_FIDO2_PIN or an interactive prompt, never BORG_PASSPHRASE): a wrong PIN burns one of the token's few CTAP retries, so a typed PIN is sent to the token exactly once per invocation. Touchless keys (up=false) are verified at enrollment time: tokens that firmware-enforce user presence for hmac-secret (e.g. YubiKey fw 5.4.x, CTAP2_ERR_UP_REQUIRED) fail the enrollment cleanly instead of storing a key that could never unlock touchlessly. The module imports cleanly without python-fido2 and is only imported lazily (fido2.hid pulls in ctypes HID backends and cryptography). All failures surface as borg Errors (Fido2Error family, rc 55-57, defined in crypto/key.py so the startup-time error registry sees them). Unit tests run against a scripted fake CTAP2 stack, no hardware needed; device discovery/open paths additionally verified on real hardware (YubiKey 5C NFC on macOS, where device ids are IOKit registry ids, not filesystem paths). Adapted from borgbackup#8995. Co-authored-by: Łukasz Stelmach <stlman@poczta.fm> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A FIDO2-protected borg key is just another labeled borg key whose EncryptedKey blob uses the new "fido2 hmac-secret chacha20-poly1305" algorithm: the KEK is derived (HKDF-SHA256 with a versioned domain string, never the raw token output) from the token's hmac-secret for the stored salt and credential id. The blob also records what enrollment did, so unlock repeats it exactly: fido2_up_required (touch; absent = true) and fido2_uv_required (PIN/biometrics; absent = false - the hmac-secret output differs with and without UV). credential id and salt are plaintext; they are useless without the token, and flipping the flags only yields a wrong secret (DoS at worst). Unlock integration in FlexiKey.detect: the passphrase flow only ever touches passphrase-protected borg keys, fido2 blobs are attempted exactly once (each attempt is a physical touch that no typed passphrase can change), in the order env passphrase -> fido2 -> interactive retries. A missing token or missing python-fido2 only skips the fido2 keys so mixed repos fall back to the passphrase flow; a repo with only fido2 keys then fails with a clear Fido2Error and no passphrase prompts. A fido2-unlocked or -saved borg key always counts as logically_encrypted with a non-empty secret, so a fido2 repokey repo is not treated as an unknown unencrypted repository on a new machine. Enrollment is only reachable via FlexiKey.add_key (fido2_device True = auto-select the single plugged-in token, str = explicit device; fido2_touch=False stores a touchless key, verified against the token at enrollment). All fido2 parameters are keyword-only and stop at add_key/save - Manifest.load, key_factory, detect, load_any and decrypt_key_file keep their signatures, and device pinning at unlock is env-only (BORG_FIDO2_DEVICE). Enrollment completes (credential, assertion, encryption) before anything is stored, so a failed key add leaves the repository's key set unchanged. change_passphrase refuses to operate on a fido2-unlocked borg key instead of silently minting a new credential, and change_blob_location moves a fido2 blob verbatim between keyfile and repokey storage (same content, same content-derived key id, no re-enrollment). The borg 1.x legacy classes keep their two-argument _load/decrypt_key_file/encrypt_key_file signatures and never see fido2 keys. Adapted from borgbackup#8995. Co-authored-by: Łukasz Stelmach <stlman@poczta.fm> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
borg key add gets the only two FIDO2 options of the CLI (they are not common options and appear in no other command): - --fido2-device [DEVICE]: protect the new borg key with a FIDO2 token's hmac-secret instead of a passphrase. Without DEVICE, the single plugged-in token is used (with several, borg errors out listing the candidates). Unlock needs no option at all: the matching token is auto-discovered, BORG_FIDO2_DEVICE (env-only, matching the convention that unlock knobs are env vars) pins one. - --fido2-touch=yes|no (default yes): whether unlocking needs a touch; "no" is verified against the token at enrollment. repo-create and key change-passphrase are untouched: the admin key stays a passphrase(argon2) key, so every FIDO2 repo keeps a passphrase fallback by construction (the admin key is already unremovable). key change-location moves a FIDO2 blob verbatim (no re-encryption, which would mint a new credential); key export warns that the exported backup of a fido2 blob is useless without the enrolled token (KeyManager now reports the selected blob's algorithm). BORG_FIDO2_PIN is scrubbed from subprocess environments like the other secrets. Shell completions pick the new options up automatically (they are generated from the argparse definitions by borg completion). See borgbackup#8995. Co-authored-by: Łukasz Stelmach <stlman@poczta.fm> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- environment docs: BORG_FIDO2_DEVICE (env-only unlock device pinning, the deliberate equivalent of there being no --passphrase option; platform identifier forms) and BORG_FIDO2_PIN (with the CTAP retry warning); regenerate environment.rst.inc and key_add.rst.inc. - internals/data-structures: the EncryptedKey section now describes both KEK derivations (argon2 from a passphrase, HKDF-SHA256 from a FIDO2 token's hmac-secret) and the new fido2_* fields. - FAQ: a how-to for FIDO2-protected borg keys with an explicit threat model - what a fido2 borg key protects (a stolen blob without the token; with UV: blob and token; UP-enforcing tokens gate unlocks on a physical touch), what it does not (a compromised client at unlock still gets the key material; the hmac-secret is static until re-enrollment), that the repo is only as secure as its weakest borg key, the token-loss story via the admin key, and what --fido2-touch=no really is (a device-bound, uncopyable key, not presence-gated). - CHANGES.rst entry; pyproject: fido2 = ["fido2 >= 1.1"] extra. See borgbackup#8995. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CTAP 2.1 section 12.5 (hmac-secret) requires authenticators to reject up=false: "If 'up' is set to false, authenticator returns CTAP2_ERR_UNSUPPORTED_OPTION." So refusing --fido2-touch=no is not a per-vendor firmware quirk but spec compliance (YubiKeys refuse on all firmware versions, answering CTAP2_ERR_UP_REQUIRED instead of the prescribed code); only tokens deviating from the spec on this point support touchless keys at all. Accordingly, map CTAP2_ERR_UNSUPPORTED_OPTION and INVALID_OPTION (in addition to UP_REQUIRED) to the clear touchless-refusal message when up=false was requested, so a strictly spec-compliant token gets the same helpful error as a YubiKey, and say in the docs (key add epilog, --fido2-touch help, FAQ, data-structures) that the spec mandates the refusal, rather than "many tokens enforce it in firmware". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6abcdcd to
8bd684a
Compare
|
I guess with the age-plugin-fido2-hmac (and/or age-plugin-fido2prf) plugin the wolf's full and the sheep's alive. However (a non-technical note), I am not quite sure if depending on external code in this particular case is good. While age is available (for example) in Debian, neither plugin is. |
|
Closing this for now:
|
Protect borg keys with a FIDO2 hardware token's hmac-secret (e.g. a YubiKey) instead of a passphrase.
This is a ground-up rewrite of the integration layer of #8995 (thanks @steelman!), keeping its validated concept and on-disk approach, but built natively for the multi-key architecture on current master. A full review of #8995 concluded that the CTAP wrapper and wire format were sound while the key-layer integration predated (and fought) the multi-key refactor; steelman's authorship is preserved via
Co-authored-byon the adapted commits.What it does
adds a FIDO2-protected borg key alongside the existing ones: the token reproduces a device-bound 32-byte secret for a stored credential id + salt (CTAP2 hmac-secret), and the KEK is derived from it via HKDF-SHA256 with a versioned domain string (
borg fido2 kek v1, leaving a v2 slot open). The key blob is useless without the physical token.Unlocking needs no option at all: the matching plugged-in token is found via a silent CTAP 2.1 pre-flight probe (
BORG_FIDO2_DEVICEpins a device on multi-token machines — env-only, matching the convention that unlock knobs are env vars).Design decisions
repo-createandkey change-passphraseare untouched: the admin key stays a passphrase(argon2) key and is already unremovable, so every FIDO2 repo keeps a passphrase recovery path by construction — no token-lost-equals-repo-lost failure mode.add_key/save.Manifest.load/key_factory/detect/load_any/decrypt_key_filekeep their master signatures; the whole existing testsuite passes untouched.fido2_uv_requiredand unlock repeats exactly what enrollment did. The PIN comes fromBORG_FIDO2_PINor its own prompt, never fromBORG_PASSPHRASE(a wrong PIN burns one of the token's few CTAP retries, so a typed PIN is sent exactly once per invocation).--fido2-touch=no(default: yes) stores a touchless key for unattended backups — verified against the token at enrollment: UP-enforcing tokens (e.g. YubiKey fw 5.4.x,CTAP2_ERR_UP_REQUIRED) fail the enrollment cleanly instead of storing a key that could never unlock.logically_encrypted, so it is not treated as a "previously unknown unencrypted repository" on a second machine;key change-locationmoves a fido2 blob verbatim (re-encrypting would silently mint a new credential);key exportwarns that the backup is useless without the enrolled token./dev/hidrawNon Linux, decimal IOKit registry ids on macOS,\\?\hid#...on Windows) — no filesystem checks.pip install 'borgbackup[fido2]'), imported lazily so no borg invocation pays its startup cost; all failures surface as borgErrors with exit codes 55–57.Commits
BORG_NEW_PASSCOMMAND/BORG_NEW_PASSPHRASE_FD(independent preliminary from [RFC] Introduce support for FIDO2 to protect keys #8995, with its group-selection bug fixed: the original selected the NEW passcommand on the wrong flag, breakingBORG_OTHER_PASSCOMMAND).crypto/fido2.py) + unit tests against a scripted fake CTAP stack (no hardware in CI).fido2_credential_id,fido2_up_required,fido2_uv_required), KEK derivation, unlock flow, atomic enrollment.key add --fido2-device/--fido2-touch(the only two FIDO2 options; they appear in no other command), change-location/export handling.Testing
repo-infoshowing encrypted, export warning, and the clean--fido2-touch=norefusal with the key set left unchanged.Threat model in one line: a stolen key blob is useless without the token (with UV: blob and token are not enough); a compromised client at unlock time still gets the key material, and the repo remains only as secure as its weakest borg key — the FAQ spells this out honestly.
Closes #8995.
🤖 Generated with Claude Code