Skip to content

Commit aed7e96

Browse files
committed
Introduce support for FIDO2 to protect keys
Use FIDO2 devices with hmac-secret extension to generate key encryption key (KEK) instead of passphrase processed with argon2.
1 parent 2f62b77 commit aed7e96

8 files changed

Lines changed: 262 additions & 19 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ s3 = ["borgstore[rest,blake3,s3] ~= 0.6.1"]
5959
sftp = ["borgstore[rest,blake3,sftp] ~= 0.6.1"]
6060
rclone = ["borgstore[rest,blake3,rclone] ~= 0.6.1"]
6161
cockpit = ["textual>=6.8.0"] # might also work with older versions, untested
62+
fido2 = ["fido2 >= 0.9.1"]
6263

6364
[project.urls]
6465
"Homepage" = "https://borgbackup.org/"

src/borg/archiver/key_cmds.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def do_key_change_passphrase(self, args, repository, manifest):
2121
key = manifest.key
2222
if not hasattr(key, "change_passphrase"):
2323
raise CommandError("This repository is not encrypted, cannot change the passphrase.")
24-
key.change_passphrase()
24+
key.change_passphrase(args)
2525
logger.info("Key updated")
2626
if hasattr(key, "find_key"):
2727
# print key location to make backing it up easier
@@ -270,6 +270,10 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
270270
parents=[common_parser], description=self.do_key_change_passphrase.__doc__, epilog=change_passphrase_epilog
271271
)
272272
key_parsers.add_subcommand("change-passphrase", subparser, help="change the repository passphrase")
273+
subparser.add_argument(
274+
"--fido2-device", metavar="DEVICE", dest="fido2_device", default=None,
275+
help="select fido2 device to protect the repository key, use ``fido2-token -L`` to list available devices."
276+
)
273277

274278
add_epilog = process_epilog(
275279
"""
@@ -288,12 +292,14 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
288292
keys require a unique, user-defined ``--label``.
289293
"""
290294
)
295+
291296
subparser = ArgumentParser(parents=[common_parser], description=self.do_key_add.__doc__, epilog=add_epilog)
292297
key_parsers.add_subcommand("add", subparser, help="add a borg key (independent passphrase)")
293298
subparser.add_argument(
294299
"--label", metavar="LABEL", dest="label", required=True, help="label for the new borg key (must be unique)"
295300
)
296301

302+
297303
remove_epilog = process_epilog(
298304
"""
299305
Remove a borg key from the repository.

src/borg/archiver/repo_create_cmd.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,10 @@ def build_parser_repo_create(self, subparsers, common_parser, mid_common_parser)
261261
help="copy the crypt_key (used for authenticated encryption) from the key of the other repository "
262262
"(default: new random key).",
263263
)
264+
subparser.add_argument(
265+
"--fido2-device",
266+
metavar="DEVICE",
267+
dest="fido2_device",
268+
help="select fido2 device to protect the repository key, use ``fido2-token -L`` "
269+
"to list available devices.",
270+
)

src/borg/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@
244244
"pbkdf2": "sha256",
245245
# encrypt-then-MAC, kdf: argon2, encryption: chacha20, authentication: poly1305
246246
"argon2": "argon2 chacha20-poly1305",
247+
# Fido2 hmac-secret
248+
"fido2": "fido2 hmac-secret chacha20-poly1305",
247249
}
248250

249251

src/borg/crypto/fido2.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import os
2+
import sys
3+
4+
from binascii import b2a_hex
5+
from ..logger import create_logger
6+
7+
logger = create_logger()
8+
9+
try:
10+
from fido2.ctap2 import Ctap2, ClientPin
11+
from fido2.ctap import CtapError
12+
from fido2.hid import CtapHidDevice, get_descriptor, open_connection
13+
from fido2.cose import ES256
14+
15+
has_fido2 = True
16+
except ImportError:
17+
has_fido2 = False
18+
19+
20+
class Fido2Operations:
21+
@classmethod
22+
def find_device(cls, credential_id, rp_id="org.borgbackup.fido2"):
23+
if not has_fido2:
24+
raise ValueError("No FIDO2 support found. Install the 'fido2' module.")
25+
for d in CtapHidDevice.list_devices():
26+
ctap2 = Ctap2(d)
27+
28+
# It's not our device
29+
if "hmac-secret" not in ctap2.info.extensions:
30+
continue
31+
32+
# According to CTAP 2.1 specification, to do pre-flight we
33+
# need to set up option to false with optionally
34+
# pinUvAuthParam in assertion[1]. But for authenticator
35+
# that doesn't support user presence, once up option is
36+
# present, the authenticator may return
37+
# CTAP2_ERR_UNSUPPORTED_OPTION[2]. So we simplely omit
38+
# the option in that case.
39+
# Reference:
40+
# 1: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#pre-flight
41+
# 2: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#authenticatorGetAssertion
42+
# (in step 5)
43+
options = None
44+
if ctap2.info.options.get("up", True):
45+
options = {"up": False}
46+
try:
47+
ctap2.get_assertion(
48+
rp_id=rp_id,
49+
client_data_hash=b"\x00" * 32,
50+
allow_list=[{"type": "public-key", "id": credential_id}],
51+
extensions=None,
52+
options=options,
53+
pin_uv_param=None,
54+
pin_uv_protocol=None,
55+
event=None,
56+
on_keepalive=None,
57+
)
58+
except CtapError as e:
59+
if CtapError.ERR.NO_CREDENTIALS == e.code:
60+
continue
61+
raise e
62+
logger.info(f"Found the FIDO2 device matching the credential: {d.descriptor.path}.")
63+
return d.descriptor.path
64+
else:
65+
logger.error("No matching FIDO2 device found.")
66+
67+
def __init__(self, device=None, pin=None):
68+
if not has_fido2:
69+
raise ValueError("No FIDO2 support found. Install the 'fido2' module.")
70+
if not device:
71+
raise ValueError("FIDO2 device not specified.")
72+
self._device_path = device
73+
self._pin = pin
74+
75+
descriptor = get_descriptor(self._device_path)
76+
hid_device = CtapHidDevice(descriptor, open_connection(descriptor))
77+
self._ctap2 = Ctap2(hid_device)
78+
self._client_pin = ClientPin(self._ctap2)
79+
80+
# TODO: verify that the device supports hmac-secret
81+
# if not 'hmac-secret' in self._ctap2.info.extensions:
82+
# # Oh no!
83+
84+
# Defaults are per table in 5.4 in FIDO2 spec
85+
self.has_rk = self._ctap2.info.options.get("rk", False)
86+
self.has_client_pin = self._ctap2.info.options.get("clientPin", False)
87+
self.has_up = self._ctap2.info.options.get("up", True)
88+
self.has_uv = self._ctap2.info.options.get("uv", False)
89+
90+
def _hmac_secret_input(self, salt1):
91+
key_agreement, self._shared_secret = self._client_pin._get_shared_secret()
92+
salt_enc = self._client_pin.protocol.encrypt(self._shared_secret, salt1)
93+
salt_auth = self._client_pin.protocol.authenticate(self._shared_secret, salt_enc)
94+
return {1: key_agreement, 2: salt_enc, 3: salt_auth, 4: self._client_pin.protocol.VERSION}
95+
96+
def _hmac_secret_output(self, data):
97+
decrypted = self._client_pin.protocol.decrypt(self._shared_secret, data)
98+
return decrypted[:32]
99+
100+
def _get_assertion(self, salt, credential_id, rp_id="org.borgbackup.fido2"):
101+
return self._ctap2.get_assertion(
102+
rp_id=rp_id,
103+
client_data_hash=b"\x00" * 32,
104+
allow_list=[{"type": "public-key", "id": credential_id}],
105+
extensions={"hmac-secret": self._hmac_secret_input(salt)},
106+
options=None,
107+
pin_uv_param=None,
108+
pin_uv_protocol=self._client_pin.protocol.VERSION,
109+
event=None,
110+
on_keepalive=None,
111+
)
112+
113+
def use_hmac_hash(self, salt, credential_id):
114+
115+
# TODO: replace with…
116+
print("\nTouch your authenticator device now...\n", file=sys.stderr)
117+
assertion = self._get_assertion(salt, credential_id)
118+
if not assertion.auth_data.extensions.get("hmac-secret"):
119+
raise Exception("Failed to get assertion with hmac-secret")
120+
121+
secret = self._hmac_secret_output(assertion.auth_data.extensions["hmac-secret"])
122+
return secret
123+
124+
def generate_hmac_hash(self, user, rp_id="org.borgbackup.fido2"):
125+
# TODO: decide whether to use or not credentialProtectionPolicy
126+
if self._pin:
127+
pin_token = self._client_pin.get_pin_token(self._pin, ClientPin.PERMISSION.MAKE_CREDENTIAL, rp_id)
128+
pin_auth = self._client_pin.protocol.authenticate(pin_token, b"\x00" * 32)
129+
elif self.has_client_pin:
130+
raise ValueError("PIN required but not provided")
131+
132+
if not (self.has_rk or self.has_uv):
133+
cred_options = None
134+
else:
135+
cred_options = {}
136+
if self.has_rk:
137+
cred_options["rk"] = False
138+
if self.has_uv:
139+
cred_options["uv"] = False
140+
141+
print("\nTouch your authenticator device now...\n", file=sys.stderr)
142+
result = self._ctap2.make_credential(
143+
client_data_hash=b"\x00" * 32,
144+
rp={"id": rp_id, "name": "Borg Repository"},
145+
user={"id": user, "name": b2a_hex(user).decode("ascii")},
146+
key_params=[{"type": "public-key", "alg": ES256.ALGORITHM}],
147+
exclude_list=None,
148+
extensions={"hmac-secret": True},
149+
options=cred_options,
150+
pin_uv_param=pin_auth,
151+
pin_uv_protocol=self._client_pin.protocol.VERSION,
152+
event=None,
153+
on_keepalive=None,
154+
)
155+
156+
if result.auth_data.extensions.get("hmac-secret") is None:
157+
raise Exception("Failed to create credential with hmac-secret")
158+
logger.info("New credential created with the hmac-secret extension.")
159+
160+
credential_id = result.auth_data.credential_data.credential_id
161+
162+
salt = os.urandom(32)
163+
print("\nTouch your authenticator device now...\n", file=sys.stderr)
164+
assertion = self._get_assertion(salt, credential_id)
165+
166+
if not assertion.auth_data.extensions.get("hmac-secret"):
167+
raise Exception("Failed to get assertion with hmac-secret")
168+
logger.info("An assertion with hmac-secret value created.")
169+
170+
secret = self._hmac_secret_output(assertion.auth_data.extensions["hmac-secret"])
171+
172+
return credential_id, salt, secret

src/borg/crypto/key.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from .low_level import bytes_to_int, num_cipher_blocks, hmac_sha256
3333
from .low_level import AES256_OCB, CHACHA20_POLY1305
3434
from . import low_level
35-
35+
from .fido2 import Fido2Operations
3636

3737
def keyfile_name_for(content: bytes) -> str:
3838
return sha256(content).hexdigest()
@@ -508,6 +508,7 @@ class FlexiKey:
508508
def detect(cls, repository, manifest_data, *, other=False):
509509
key = cls(repository)
510510
target = key.find_key()
511+
# TODO: ask for "PIN" when applicable
511512
prompt = "Enter passphrase for key %s: " % target
512513
passphrase = Passphrase.env_passphrase(other=other)
513514
# a repository may have multiple borg keys, one per passphrase; try the
@@ -563,6 +564,8 @@ def decrypt_key_file(self, data, passphrase):
563564
self._encrypted_key_label = encrypted_key.get("label")
564565
if encrypted_key.algorithm == "argon2 chacha20-poly1305":
565566
return self.decrypt_key_file_argon2(encrypted_key, passphrase)
567+
elif encrypted_key.algorithm == "fido2 hmac-secret chacha20-poly1305":
568+
return self.decrypt_key_file_fido2(encrypted_key, passphrase)
566569
else:
567570
raise UnsupportedKeyFormatError()
568571

@@ -601,9 +604,21 @@ def decrypt_key_file_argon2(self, encrypted_key, passphrase):
601604
except low_level.IntegrityError:
602605
return None
603606

607+
def decrypt_key_file_fido2(self, encrypted_key, pin):
608+
device = Fido2Operations.find_device(encrypted_key.fido2_credential_id)
609+
operations = Fido2Operations(device, pin)
610+
secret = operations.use_hmac_hash(encrypted_key.salt, encrypted_key.fido2_credential_id)
611+
ae_cipher = CHACHA20_POLY1305(key=secret, iv=0, header_len=0, aad_offset=0)
612+
try:
613+
return ae_cipher.decrypt(encrypted_key.data)
614+
except low_level.IntegrityError:
615+
return None
616+
604617
def encrypt_key_file(self, data, passphrase, algorithm, label=None):
605618
if algorithm == "argon2 chacha20-poly1305":
606619
return self.encrypt_key_file_argon2(data, passphrase, label=label)
620+
elif algorithm == "fido2 hmac-secret chacha20-poly1305":
621+
return self.encrypt_key_file_fido2(data, passphrase, args)
607622
else:
608623
raise ValueError(f"Unexpected algorithm: {algorithm}")
609624

@@ -623,25 +638,52 @@ def encrypt_key_file_argon2(self, data, passphrase, label=None):
623638
encrypted_key = EncryptedKey(**kw)
624639
return msgpack.packb(encrypted_key.as_dict())
625640

626-
def _save(self, passphrase, algorithm, label=None):
641+
def encrypt_key_file_fido2(self, data, pin, args):
642+
operations = Fido2Operations(args.fido2_device, pin)
643+
credential_id, salt, secret = operations.generate_hmac_hash(user=self.repository_id)
644+
ae_cipher = CHACHA20_POLY1305(key=secret, iv=0, header_len=0, aad_offset=0)
645+
encrypted_key = EncryptedKey(
646+
version=1,
647+
algorithm="fido2 hmac-secret chacha20-poly1305",
648+
salt=salt,
649+
data=ae_cipher.encrypt(data),
650+
fido2_credential_id=credential_id,
651+
)
652+
return msgpack.packb(encrypted_key.as_dict())
653+
654+
def _save(self, passphrase, algorithm, args, label=None):
627655
key = Key(
628656
version=2,
629657
repository_id=self.repository_id,
630658
crypt_key=self.crypt_key,
631659
id_key=self.id_key,
632660
chunk_seed=self.chunk_seed,
633661
)
634-
data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm, label=label)
662+
data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm, args, label=label)
635663
key_data = "\n".join(textwrap.wrap(binascii.b2a_base64(data).decode("ascii")))
636664
return key_data
637665

638-
def change_passphrase(self, passphrase=None):
639-
if passphrase is None:
640-
passphrase = Passphrase.new(allow_empty=True, only_new=True)
641-
# replace the borg key we unlocked with: keep its label, write the new borg key, then
642-
# (for repokey) delete the previously-loaded borg key (keyfile mode auto-erases it in save()).
643-
old_id = self._loaded_key_id
644-
self.save(self.target, passphrase, algorithm=self._encrypted_key_algorithm, label=self._loaded_label)
666+
def change_passphrase(self, args, passphrase=None):
667+
if args.fido2_device:
668+
operations = Fido2Operations(args.fido2_device)
669+
if operations.has_client_pin:
670+
# TODO: try to be more descriptive about the device
671+
passphrase = Passphrase.new(only_new=True, pin_prompt=f"Enter PIN for {args.fido2_device}: ")
672+
else:
673+
passphrase = Passphrase("")
674+
key_algorithm = KEY_ALGORITHMS["fido2"]
675+
else:
676+
if passphrase is None:
677+
passphrase = Passphrase.new(allow_empty=True, only_new=True)
678+
# replace the borg key we unlocked with: keep its label, write the new borg key, then
679+
# (for repokey) delete the previously-loaded borg key (keyfile mode auto-erases it in save()).
680+
old_id = self._loaded_key_id
681+
682+
key_algorithm = self._encrypted_key_algorithm
683+
# If fido2 was used before change it to argon2
684+
if key_algorithm == KEY_ALGORITHMS["fido2"]:
685+
key_algorithm = KEY_ALGORITHMS["argon2"]
686+
self.save(self.target, passphrase, algorithm=key_algorithm, args=args, label=self._loaded_label))
645687
if self.storage == KeyBlobStorage.REPO and old_id and hasattr(self.repository, "delete_key"):
646688
if self._loaded_key_id != old_id:
647689
self.repository.delete_key(old_id)
@@ -675,11 +717,16 @@ def create(cls, repository, args, *, other_key=None):
675717
key.init_from_given_data(crypt_key=crypt_key, id_key=id_key, chunk_seed=chunk_seed)
676718
else:
677719
key.init_from_random_data()
678-
passphrase = Passphrase.new(allow_empty=True)
720+
if args.fido2_device:
721+
key_algorithm = KEY_ALGORITHMS["fido2"]
722+
passphrase = Passphrase.new(pin_prompt="Enter PIN for {args.fido2_device}: ")
723+
else:
724+
key_algorithm = KEY_ALGORITHMS["argon2"]
725+
passphrase = Passphrase.new(allow_empty=True)
679726
key.init_ciphers()
680727
target = key.get_new_target(args)
681728
# the first borg key of a repository is the protected "admin" key.
682-
key.save(target, passphrase, create=True, algorithm=KEY_ALGORITHMS["argon2"], label=ADMIN_LABEL)
729+
key.save(target, passphrase, key_algorithm, args, create=True, label=ADMIN_LABEL)
683730
logger.info('Key in "%s" created.' % key.target)
684731
logger.info("Keep this key safe. Your data will be inaccessible without it.")
685732
return key
@@ -873,10 +920,10 @@ def load(self, target, passphrase):
873920
else:
874921
return self.load_any(passphrase)
875922

876-
def save(self, target, passphrase, algorithm, create=False, label=None, replace=True):
923+
def save(self, target, passphrase, algorithm, args, create=False, label=None, replace=True):
877924
# replace=True replaces the previously-loaded borg key (change-passphrase semantics);
878925
# replace=False adds an additional borg key, keeping the existing ones (key add).
879-
key_data = self._save(passphrase, algorithm, label=label)
926+
key_data = self._save(passphrase, algorithm, args, label=label)
880927
if self.storage == KeyBlobStorage.KEYFILE:
881928
old_target = getattr(self, "target", None)
882929
keys_dir = get_keys_dir()
@@ -1222,8 +1269,8 @@ def load_any(self, passphrase):
12221269
self.logically_encrypted = False
12231270
return success
12241271

1225-
def save(self, target, passphrase, algorithm, create=False, label=None, replace=True):
1226-
super().save(target, passphrase, algorithm, create=create, label=label, replace=replace)
1272+
def save(self, target, passphrase, algorithm, args, create=False, label=None, replace=True):
1273+
super().save(target, passphrase, algorithm, args, create=create, label=label, replace=replace)
12271274
self.logically_encrypted = False
12281275

12291276
def init_from_given_data(self, *, crypt_key, id_key, chunk_seed):

0 commit comments

Comments
 (0)