Borg protects each borg key with a passphrase, and that passphrase can be supplied
programmatically via BORG_PASSCOMMAND (see Environment Variables). This guide shows how to
combine that with age, a small and modern file encryption tool: a strong random
passphrase is stored age-encrypted on disk, and borg decrypts it on demand through the
passcommand.
The point of this indirection is age’s plugin ecosystem: age identities can live in hardware. That way, the borg passphrase - and with it, the repository - can be bound to a FIDO2 security key, a YubiKey’s PIV application, a machine’s TPM 2.0 chip, or a Mac’s Secure Enclave, without borg itself needing to know anything about that hardware. Depending on the hardware and its configuration, unlocking either requires user presence (a touch on the token - good for interactive use) or works silently (good for unattended backups).
All variants below follow the same three steps; only the age recipient differs.
Generate a strong random passphrase and keep an offline copy of it (e.g. on paper, in a safe place). This copy is your recovery path if the age setup or the hardware is ever lost:
head -c 32 /dev/urandom | base64 > passphrase
cat passphrase # write this down / print it, then store it offline
Encrypt the passphrase to one or more age recipients and remove the plaintext:
age --recipient <RECIPIENT> --output ~/.config/borg/passphrase.age passphrase
shred --remove passphrase # or: rm -P passphrase (BSD/macOS)
Let borg obtain the passphrase through the matching age identity. What the identity
is and how it is created depends on the chosen variant (a key file, or a reference to
a hardware key) - each example below creates its own identity.txt:
export BORG_PASSCOMMAND="age --decrypt --identity ~/.config/borg/identity.txt ~/.config/borg/passphrase.age"
With BORG_PASSCOMMAND set, all borg commands work as usual - including
borg repo-create, which uses the passcommand’s output as the new repository’s
passphrase, so the whole setup can be done before the repository even exists.
The encrypted passphrase.age file and the identity file must be available on every
machine that shall unlock the repository. Neither is usable without the corresponding
secret (the age key resp. the hardware), so they do not need the same level of protection
as a plaintext passphrase - but treat them as part of your backup configuration and back
them up accordingly.
To get familiar with the pattern, start with a plain age key pair (no hardware involved):
age-keygen --output ~/.config/borg/identity.txt
# note the "public key: age1..." line it prints - that is the recipient
head -c 32 /dev/urandom | base64 > passphrase
age --recipient age1... --output ~/.config/borg/passphrase.age passphrase
shred --remove passphrase
export BORG_PASSCOMMAND="age --decrypt --identity ~/.config/borg/identity.txt ~/.config/borg/passphrase.age"
borg -r /path/to/repo repo-create --encryption=aes256-ocb
borg -r /path/to/repo repo-info
Security-wise this is roughly equivalent to keeping the passphrase in a protected file
(the identity file is the secret here), so by itself it mainly buys convenience: one
identity can unlock the passphrases of many repositories, and you can later re-encrypt
passphrase.age to additional recipients - including hardware ones - without touching
borg. The interesting setups bind the identity to hardware, below.
age plugin |
hardware |
unattended use |
user presence per unlock |
|---|---|---|---|
any FIDO2 security key with the hmac-secret extension (YubiKey, Nitrokey, SoloKeys, …) |
no |
always a touch; PIN optional |
|
YubiKey series 4/5 (PIV) |
yes (policy
|
configurable: never, cached or always (+ optional PIN) |
|
TPM 2.0 chip (bound to the machine, no token needed) |
yes |
none (optional PIN) |
|
Apple Secure Enclave (macOS 14+, bound to the Mac) |
yes (access
control
|
configurable: none, or Touch ID and/or passcode |
Choosing:
For interactive use (manual backups, restores, browsing), a FIDO2 security key with
age-plugin-fido2-hmac is the most universal choice: it works with any vendor’s
FIDO2 token, and every unlock requires a physical touch - malware on the machine cannot
silently unlock the repository while the token is plugged in. Note that the touch
requirement is mandated by the FIDO2/CTAP specification, so this option can not serve
unattended backups.
For unattended backups with a token, use a YubiKey’s PIV application with touch and
PIN policies set to never. Such a key acts like an uncopyable keyfile: stealing
all files from the machine is useless without the physical YubiKey, but code running on
the machine can unlock silently while the YubiKey is plugged in (it is device-bound,
not presence-gated).
For unattended backups bound to the machine itself - no token to plug in or lose - use the TPM on Linux/Windows hardware, or the Secure Enclave on Macs. Same trade-off: copied files are useless elsewhere, code on the machine can unlock.
A repository can combine these: the same passphrase encrypted to several recipients, or
several borg keys (see borg key add) for different purposes.
Generate a credential on the token (you will be asked whether decryption shall also require the token’s PIN, and whether to use a separate identity file):
age-plugin-fido2-hmac -g > ~/.config/borg/identity.txt
Then encrypt the passphrase to the identity and set the passcommand:
age --encrypt --identity ~/.config/borg/identity.txt --output ~/.config/borg/passphrase.age passphrase
export BORG_PASSCOMMAND="age --decrypt --identity ~/.config/borg/identity.txt ~/.config/borg/passphrase.age"
Every borg command that needs the key will now make the token blink and wait for a touch. The credential is stateless (nothing is stored on the token), so you can create as many as you like. The identity file contains the credential id and salt - useless without the token, like everything else in this setup.
Requires the PC/SC smartcard service (pcscd) on Linux. Generate a P-256 key on the
YubiKey with policies that allow silent use:
age-plugin-yubikey --generate --name borg --pin-policy never --touch-policy never
# prints the recipient: age1yubikey1...
age --recipient age1yubikey1... --output ~/.config/borg/passphrase.age passphrase
The identity file is only a reference (serial and slot) and can be regenerated from the plugged-in YubiKey at any time - nothing to lose:
age-plugin-yubikey --identity --slot <SLOT> > ~/.config/borg/identity.txt
export BORG_PASSCOMMAND="age --decrypt --identity ~/.config/borg/identity.txt ~/.config/borg/passphrase.age"
Backups from cron or a systemd timer now work while the YubiKey is plugged in. For an
interactive, presence-gated variant of the same mechanism, generate with
--touch-policy always (or cached, which allows further unlocks for 15 seconds
after a touch) and/or a PIN policy.
TPM 2.0 (Linux/Windows):
age-plugin-tpm --generate --output ~/.config/borg/identity.txt
age-plugin-tpm -y ~/.config/borg/identity.txt # prints the recipient
age --recipient age1tpm1... --output ~/.config/borg/passphrase.age passphrase
export BORG_PASSCOMMAND="age --decrypt --identity ~/.config/borg/identity.txt ~/.config/borg/passphrase.age"
The identity file is a TPM-sealed key blob: it only works on this machine’s TPM.
Apple Secure Enclave (macOS 14+):
age-plugin-se keygen --access-control=none --output ~/.config/borg/identity.txt
age-plugin-se recipients --input ~/.config/borg/identity.txt # prints the recipient
--access-control=none allows silent, unattended use; policies like
any-biometry instead require Touch ID for every unlock (presence-gated, like the
FIDO2 option - but bound to this Mac).
age encrypts to any number of recipients, and any one of them can decrypt. This is the recommended way to handle hardware loss or failure - for example, two YubiKeys (for the drawer and the keychain) plus the machine’s TPM:
age --recipient age1yubikey1...A --recipient age1yubikey1...B \
--recipient age1tpm1... --output ~/.config/borg/passphrase.age passphrase
If one YubiKey is lost, the other (or the TPM) still unlocks; then generate a replacement
and re-create passphrase.age with a new recipient set. Note that age’s built-in
passphrase encryption (age --passphrase) cannot be combined with other recipients - that is
what the offline paper copy of the borg passphrase is for.
To revoke a compromised unlocker (not just stop using it), re-creating
passphrase.age is not enough - whoever had the hardware may have decrypted and kept
the passphrase. Change it: borg key change-passphrase with a fresh random passphrase,
then re-encrypt that one to the remaining recipients.
If the hardware is unavailable (token unplugged, TPM of another machine), the
passcommand fails and the borg command aborts. There is no automatic fallback: to use
the recovery passphrase, unset BORG_PASSCOMMAND and enter it at the prompt (or use
another borg key, see borg key add).
All of these setups protect the passphrase (and thus the borg key and repository) at rest. On the machine at unlock time, borg necessarily obtains the passphrase and the decrypted key material - a compromised client still gets them, whatever holds the age identity. Presence-gated options (FIDO2, PIV/SE with touch policies) at least prevent silent unlocking; device-bound options without presence do not.
A repository is only as secure as its weakest borg key: an age-protected passphrase on one borg key does not strengthen a weak passphrase on another one.
age and its plugins are separate binaries that must be installed (they are packaged in
the usual distributions and Homebrew) and available in PATH (or, for the plugins,
given via AGE_PLUGIN_PATH) wherever borg shall unlock the repository - including on
the machine you will one day restore on. The offline paper copy of the passphrase keeps
working without any of them.