Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ s3 = ["borgstore[rest,blake3,s3] ~= 0.6.1"]
sftp = ["borgstore[rest,blake3,sftp] ~= 0.6.1"]
rclone = ["borgstore[rest,blake3,rclone] ~= 0.6.1"]
cockpit = ["textual>=6.8.0"] # might also work with older versions, untested
fido2 = ["fido2 >= 0.9.1"]

[project.urls]
"Homepage" = "https://borgbackup.org/"
Expand Down
9 changes: 8 additions & 1 deletion src/borg/archiver/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def wrapper(self, args, **kwargs):
from ..legacy.repoobj import RepoObj1

ro_cls = RepoObj1
manifest_ = Manifest.load(repository, compatibility, other=False, ro_cls=ro_cls)
manifest_ = Manifest.load(repository, compatibility, args, other=False, ro_cls=ro_cls)
kwargs["manifest"] = manifest_
if "compression" in args:
manifest_.repo_objs.compressor = args.compression.compressor
Expand Down Expand Up @@ -562,6 +562,13 @@ def define_common_options(add_common_option):
action=Highlander,
help="repository to use",
)
add_common_option(
"--fido2-device",
metavar="DEVICE",
dest="fido2_device",
default=os.environ.get("BORG_FIDO2_DEVICE", "none"),
help="select fido2 device to protect the repository key, use ``fido2-token -L`` to list available devices.",
)


def build_matcher(inclexcl_patterns, include_paths, pattern_roots=()):
Expand Down
10 changes: 9 additions & 1 deletion src/borg/archiver/key_cmds.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def do_key_change_passphrase(self, args, repository, manifest):
key = manifest.key
if not hasattr(key, "change_passphrase"):
raise CommandError("This repository is not encrypted, cannot change the passphrase.")
key.change_passphrase()
key.change_passphrase(args)
logger.info("Key updated")
if hasattr(key, "find_key"):
# print key location to make backing it up easier
Expand Down Expand Up @@ -270,6 +270,13 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
parents=[common_parser], description=self.do_key_change_passphrase.__doc__, epilog=change_passphrase_epilog
)
key_parsers.add_subcommand("change-passphrase", subparser, help="change the repository passphrase")
subparser.add_argument(
"--fido2-device",
metavar="DEVICE",
dest="fido2_device",
default=None,
help="select fido2 device to protect the repository key, use ``fido2-token -L`` to list available devices.",
)

add_epilog = process_epilog(
"""
Expand All @@ -288,6 +295,7 @@ def build_parser_keys(self, subparsers, common_parser, mid_common_parser):
keys require a unique, user-defined ``--label``.
"""
)

subparser = ArgumentParser(parents=[common_parser], description=self.do_key_add.__doc__, epilog=add_epilog)
key_parsers.add_subcommand("add", subparser, help="add a borg key (independent passphrase)")
subparser.add_argument(
Expand Down
2 changes: 2 additions & 0 deletions src/borg/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@
"pbkdf2": "sha256",
# encrypt-then-MAC, kdf: argon2, encryption: chacha20, authentication: poly1305
"argon2": "argon2 chacha20-poly1305",
# Fido2 hmac-secret
"fido2": "fido2 hmac-secret chacha20-poly1305",
}


Expand Down
172 changes: 172 additions & 0 deletions src/borg/crypto/fido2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import os
import sys

from binascii import b2a_hex
from ..logger import create_logger

logger = create_logger()

try:
from fido2.ctap2 import Ctap2, ClientPin
from fido2.ctap import CtapError
from fido2.hid import CtapHidDevice, get_descriptor, open_connection
from fido2.cose import ES256

has_fido2 = True
except ImportError:
has_fido2 = False


class Fido2Operations:
@classmethod
def find_device(cls, credential_id, rp_id="org.borgbackup.fido2"):
if not has_fido2:
raise ValueError("No FIDO2 support found. Install the 'fido2' module.")
for d in CtapHidDevice.list_devices():
ctap2 = Ctap2(d)

# It's not our device
if "hmac-secret" not in ctap2.info.extensions:
continue

# According to CTAP 2.1 specification, to do pre-flight we
# need to set up option to false with optionally
# pinUvAuthParam in assertion[1]. But for authenticator
# that doesn't support user presence, once up option is
# present, the authenticator may return
# CTAP2_ERR_UNSUPPORTED_OPTION[2]. So we simplely omit
# the option in that case.
# Reference:
# 1: https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#pre-flight
# 2: https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#authenticatorGetAssertion
# (in step 5)
options = None
if ctap2.info.options.get("up", True):
options = {"up": False}
try:
ctap2.get_assertion(
rp_id=rp_id,
client_data_hash=b"\x00" * 32,
allow_list=[{"type": "public-key", "id": credential_id}],
extensions=None,
options=options,
pin_uv_param=None,
pin_uv_protocol=None,
event=None,
on_keepalive=None,
)
except CtapError as e:
if CtapError.ERR.NO_CREDENTIALS == e.code:
continue
raise e
logger.info(f"Found the FIDO2 device matching the credential: {d.descriptor.path}.")
return d.descriptor.path
else:
logger.error("No matching FIDO2 device found.")

def __init__(self, device=None, pin=None):
if not has_fido2:
raise ValueError("No FIDO2 support found. Install the 'fido2' module.")
if not device:
raise ValueError("FIDO2 device not specified.")
self._device_path = device
self._pin = pin

descriptor = get_descriptor(self._device_path)
hid_device = CtapHidDevice(descriptor, open_connection(descriptor))
self._ctap2 = Ctap2(hid_device)
self._client_pin = ClientPin(self._ctap2)

# TODO: verify that the device supports hmac-secret
# if not 'hmac-secret' in self._ctap2.info.extensions:
# # Oh no!

# Defaults are per table in 5.4 in FIDO2 spec
self.has_rk = self._ctap2.info.options.get("rk", False)
self.has_client_pin = self._ctap2.info.options.get("clientPin", False)
self.has_up = self._ctap2.info.options.get("up", True)
self.has_uv = self._ctap2.info.options.get("uv", False)

def _hmac_secret_input(self, salt1):
key_agreement, self._shared_secret = self._client_pin._get_shared_secret()
salt_enc = self._client_pin.protocol.encrypt(self._shared_secret, salt1)
salt_auth = self._client_pin.protocol.authenticate(self._shared_secret, salt_enc)
return {1: key_agreement, 2: salt_enc, 3: salt_auth, 4: self._client_pin.protocol.VERSION}

def _hmac_secret_output(self, data):
decrypted = self._client_pin.protocol.decrypt(self._shared_secret, data)
return decrypted[:32]

def _get_assertion(self, salt, credential_id, rp_id="org.borgbackup.fido2"):
return self._ctap2.get_assertion(
rp_id=rp_id,
client_data_hash=b"\x00" * 32,
allow_list=[{"type": "public-key", "id": credential_id}],
extensions={"hmac-secret": self._hmac_secret_input(salt)},
options=None,
pin_uv_param=None,
pin_uv_protocol=self._client_pin.protocol.VERSION,
event=None,
on_keepalive=None,
)

def use_hmac_hash(self, salt, credential_id):

# TODO: replace with…
print("\nTouch your authenticator device now...\n", file=sys.stderr)
assertion = self._get_assertion(salt, credential_id)
if not assertion.auth_data.extensions.get("hmac-secret"):
raise Exception("Failed to get assertion with hmac-secret")

secret = self._hmac_secret_output(assertion.auth_data.extensions["hmac-secret"])
return secret

def generate_hmac_hash(self, user, rp_id="org.borgbackup.fido2"):
# TODO: decide whether to use or not credentialProtectionPolicy
if self._pin:
pin_token = self._client_pin.get_pin_token(self._pin, ClientPin.PERMISSION.MAKE_CREDENTIAL, rp_id)
pin_auth = self._client_pin.protocol.authenticate(pin_token, b"\x00" * 32)
elif self.has_client_pin:
raise ValueError("PIN required but not provided")

if not (self.has_rk or self.has_uv):
cred_options = None
else:
cred_options = {}
if self.has_rk:
cred_options["rk"] = False
if self.has_uv:
cred_options["uv"] = False

print("\nTouch your authenticator device now...\n", file=sys.stderr)
result = self._ctap2.make_credential(
client_data_hash=b"\x00" * 32,
rp={"id": rp_id, "name": "Borg Repository"},
user={"id": user, "name": b2a_hex(user).decode("ascii")},
key_params=[{"type": "public-key", "alg": ES256.ALGORITHM}],
exclude_list=None,
extensions={"hmac-secret": True},
options=cred_options,
pin_uv_param=pin_auth,
pin_uv_protocol=self._client_pin.protocol.VERSION,
event=None,
on_keepalive=None,
)

if result.auth_data.extensions.get("hmac-secret") is None:
raise Exception("Failed to create credential with hmac-secret")
logger.info("New credential created with the hmac-secret extension.")

credential_id = result.auth_data.credential_data.credential_id

salt = os.urandom(32)
print("\nTouch your authenticator device now...\n", file=sys.stderr)
assertion = self._get_assertion(salt, credential_id)

if not assertion.auth_data.extensions.get("hmac-secret"):
raise Exception("Failed to get assertion with hmac-secret")
logger.info("An assertion with hmac-secret value created.")

secret = self._hmac_secret_output(assertion.auth_data.extensions["hmac-secret"])

return credential_id, salt, secret
Loading
Loading