Skip to content

Protect borg keys with FIDO2 hardware tokens (hmac-secret) - #10399

Closed
ThomasWaldmann wants to merge 5 commits into
borgbackup:masterfrom
ThomasWaldmann:fido2-key
Closed

ThomasWaldmann wants to merge 5 commits into
borgbackup:masterfrom
ThomasWaldmann:fido2-key

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

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-by on the adapted commits.

What it does

borg key add --fido2-device --label mytoken

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_DEVICE pins a device on multi-token machines — env-only, matching the convention that unlock knobs are env vars).

Design decisions

  • FIDO2 keys are just labeled borg keys in the multi-key model. repo-create and key change-passphrase are 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.
  • No signature churn: all fido2 parameters are keyword-only and stop at add_key/save. Manifest.load / key_factory / detect / load_any / decrypt_key_file keep their master signatures; the whole existing testsuite passes untouched.
  • The passphrase-retry loop never drives the token (each attempt is a physical touch that no typed passphrase can change): unlock order is env passphrase → fido2 blobs (each exactly once) → interactive retries. A repo with only fido2 keys and no token present fails with a clear error instead of prompting.
  • User verification is real, not theater: when the token has a client PIN or built-in UV (biometrics) configured, a pinUvAuthToken is used at enrollment and every unlock; a biometric token never hits a PIN prompt. Since the hmac-secret output differs between assertions with and without UV, the blob records fido2_uv_required and unlock repeats exactly what enrollment did. The PIN comes from BORG_FIDO2_PIN or its own prompt, never from BORG_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.
  • Enrollment is atomic: credential, assertion and encryption all complete before anything is stored — a missed touch or wrong PIN leaves the repository's key set unchanged.
  • A fido2 repokey repo counts as logically_encrypted, so it is not treated as a "previously unknown unencrypted repository" on a second machine; key change-location moves a fido2 blob verbatim (re-encrypting would silently mint a new credential); key export warns that the backup is useless without the enrolled token.
  • Device identifiers are treated as opaque platform strings (/dev/hidrawN on Linux, decimal IOKit registry ids on macOS, \\?\hid#... on Windows) — no filesystem checks.
  • python-fido2 (>= 1.1, tested through 2.2.1) is an optional extra (pip install 'borgbackup[fido2]'), imported lazily so no borg invocation pays its startup cost; all failures surface as borg Errors with exit codes 55–57.

Commits

  1. 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, breaking BORG_OTHER_PASSCOMMAND).
  2. The hardened CTAP2 wrapper (crypto/fido2.py) + unit tests against a scripted fake CTAP stack (no hardware in CI).
  3. The key layer: EncryptedKey format (fido2_credential_id, fido2_up_required, fido2_uv_required), KEK derivation, unlock flow, atomic enrollment.
  4. CLI wiring: key add --fido2-device/--fido2-touch (the only two FIDO2 options; they appear in no other command), change-location/export handling.
  5. Docs: env vars, data-structures.rst, an FAQ with an explicit threat model, CHANGES, install extra.

Testing

  • 41 new tests, all hardware-free: fake CTAP stack for the wrapper, a fake token monkeypatched at the key.py boundary for the key layer and CLI. Full testsuite green.
  • End-to-end verified on real hardware (YubiKey 5C NFC, fw 5.4.3, macOS): enrollment (PIN + two touches), unlock via token (PIN + one touch), repo-info showing encrypted, export warning, and the clean --fido2-touch=no refusal 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

@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.50267% with 43 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.34%. Comparing base (d576bb4) to head (8bd684a).
⚠️ Report is 5 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/borg/crypto/fido2.py 86.95% 23 Missing and 4 partials ⚠️
src/borg/crypto/key.py 88.96% 6 Missing and 10 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann

ThomasWaldmann commented Sep 21, 2026

Copy link
Copy Markdown
Member Author

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#11

These can work without UP (without the "touch"), maybe rather use one of these?

@steelman

Copy link
Copy Markdown
Contributor

Considering that backups usually should be able to run unattended, this makes the whole approach questionable.

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.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

@steelman Or just using PIV or pkcs#11.

The linux kernel keyring would be very platform-specific.

@ThomasWaldmann

ThomasWaldmann commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

Thinking about just using age and its plugins in a BORG_PASSCOMMAND - that would save us from implementing and maintaining all this code.

https://github.com/FiloSottile/age

For that, we don't need code, just some docs: #10404

ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Sep 22, 2026
…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>
@ThomasWaldmann
ThomasWaldmann marked this pull request as draft September 22, 2026 16:29
ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Sep 22, 2026
…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>
ThomasWaldmann and others added 5 commits September 23, 2026 02:19
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>
@steelman

steelman commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

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. python3-fido2 on the other hand is packaged and Borg can depend on it.

@ThomasWaldmann

ThomasWaldmann commented Sep 23, 2026

Copy link
Copy Markdown
Member Author

@steelman I have added a note about age to #6458, so packagers can be aware of it.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

Closing this for now:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants