Skip to content

Commit 388a963

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 252663f commit 388a963

File tree

10 files changed

+269
-19
lines changed

10 files changed

+269
-19
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies = [
4242
[project.optional-dependencies]
4343
llfuse = ["llfuse >= 1.3.8"]
4444
pyfuse3 = ["pyfuse3 >= 3.1.1"]
45+
fido2 = ["fido2 >= 0.9.1"]
4546
nofuse = []
4647

4748
[project.urls]

scripts/shell_completions/bash/borg

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ _borg()
8585
local opts="-a --match-archives ${archive_filter_opts} ${common_opts}"
8686
;;
8787
*' repo-create '*)
88-
local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key ${common_opts}"
88+
local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key --fido2-device ${common_opts}"
8989
;;
9090
*' repo-list '*)
9191
local opts="--short --format --json ${common_opts} -a --match-archives ${archive_filter_opts} --deleted"
@@ -136,8 +136,9 @@ _borg()
136136
;;
137137
# umount
138138
# no specific options
139-
# key change-passphrase
140-
# no specific options
139+
*' change-passphrase '*)
140+
local opts="${common_opts} --fido2-device"
141+
;;
141142
*' change-location '*)
142143
local opts="${common_opts} keyfile repokey --keep"
143144
;;

scripts/shell_completions/zsh/_borg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ _borg-key() {
412412
case $line[1] in
413413
(change-passphrase)
414414
_arguments -s -w -S : \
415+
'--fido2-device=[select a FIDO2 device, use "fido2-token -L" to list available devices]:devpath:_files -P /dev/ -W /dev' \
415416
$common_options
416417
;;
417418
(export)
@@ -556,6 +557,7 @@ _borg-repo-create() {
556557

557558
_arguments -s -w -S : \
558559
'(-e --encryption)'{-e,--encryption}'=[select encryption key mode (required)]:MODE:(none authenticated authenticated-blake2 keyfile-aes-ocb repokey-aes-ocb keyfile-chacha20-poly1305 repokey-chacha20-poly1305 keyfile-blake2-aes-ocb repokey-blake2-aes-ocb keyfile-blake2-chacha20-poly1305 repokey-blake2-chacha20-poly1305)' \
560+
'--fido2-device=[select a FIDO2 device, use "fido2-token -L" to list available devices]:devpath:_files -P /dev/ -W /dev' \
559561
$common_repo_options \
560562
'--make-parent-dirs[create parent directories]'
561563
}

src/borg/archiver/key_cmds.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def do_change_passphrase(self, args, repository, manifest):
2323
key = manifest.key
2424
if not hasattr(key, "change_passphrase"):
2525
raise CommandError("This repository is not encrypted, cannot change the passphrase.")
26-
key.change_passphrase()
26+
key.change_passphrase(args)
2727
logger.info("Key updated")
2828
if hasattr(key, "find_key"):
2929
# print key location to make backing it up easier
@@ -241,6 +241,14 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
241241
help="change repository passphrase",
242242
)
243243
subparser.set_defaults(func=self.do_change_passphrase)
244+
subparser.add_argument(
245+
"--fido2-device",
246+
metavar="DEVICE",
247+
dest="fido2_device",
248+
default=None,
249+
help="select fido2 device to protect the repository key, use ``fido2-token -L`` "
250+
"to list available devices.",
251+
)
244252

245253
change_location_epilog = process_epilog(
246254
"""

src/borg/archiver/repo_create_cmd.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,10 @@ def build_parser_repo_create(self, subparsers, common_parser, mid_common_parser)
229229
help="copy the crypt_key (used for authenticated encryption) from the key of the other repo "
230230
"(default: new random key).",
231231
)
232+
subparser.add_argument(
233+
"--fido2-device",
234+
metavar="DEVICE",
235+
dest="fido2_device",
236+
help="select fido2 device to protect the repository key, use ``fido2-token -L`` "
237+
"to list available devices.",
238+
)

src/borg/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@
157157
"pbkdf2": "sha256",
158158
# encrypt-then-MAC, kdf: argon2, encryption: chacha20, authentication: poly1305
159159
"argon2": "argon2 chacha20-poly1305",
160+
# Fido2 hmac-secret
161+
"fido2": "fido2 hmac-secret chacha20-poly1305",
160162
}
161163

162164

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

0 commit comments

Comments
 (0)