diff --git a/pyproject.toml b/pyproject.toml index 9aeb4b7c99..e03cc59481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ [project.optional-dependencies] llfuse = ["llfuse >= 1.3.8"] pyfuse3 = ["pyfuse3 >= 3.1.1"] +fido2 = ["fido2 >= 0.9.1"] nofuse = [] [project.urls] diff --git a/scripts/shell_completions/bash/borg b/scripts/shell_completions/bash/borg index 6acd63d40b..50baa62d76 100644 --- a/scripts/shell_completions/bash/borg +++ b/scripts/shell_completions/bash/borg @@ -85,7 +85,7 @@ _borg() local opts="-a --match-archives ${archive_filter_opts} ${common_opts}" ;; *' repo-create '*) - local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key ${common_opts}" + local opts="--other-repo --from-borg1 -e --encryption --copy-crypt-key --fido2-device ${common_opts}" ;; *' repo-list '*) local opts="--short --format --json ${common_opts} -a --match-archives ${archive_filter_opts} --deleted" @@ -136,8 +136,9 @@ _borg() ;; # umount # no specific options - # key change-passphrase - # no specific options + *' change-passphrase '*) + local opts="${common_opts} --fido2-device" + ;; *' change-location '*) local opts="${common_opts} keyfile repokey --keep" ;; diff --git a/scripts/shell_completions/zsh/_borg b/scripts/shell_completions/zsh/_borg index 7b71daf331..5b48257612 100644 --- a/scripts/shell_completions/zsh/_borg +++ b/scripts/shell_completions/zsh/_borg @@ -412,6 +412,7 @@ _borg-key() { case $line[1] in (change-passphrase) _arguments -s -w -S : \ + '--fido2-device=[select a FIDO2 device, use "fido2-token -L" to list available devices]:devpath:_files -P /dev/ -W /dev' \ $common_options ;; (export) @@ -556,6 +557,7 @@ _borg-repo-create() { _arguments -s -w -S : \ '(-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)' \ + '--fido2-device=[select a FIDO2 device, use "fido2-token -L" to list available devices]:devpath:_files -P /dev/ -W /dev' \ $common_repo_options \ '--make-parent-dirs[create parent directories]' } diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py index 7900d5f1c3..5e5801b7a3 100644 --- a/src/borg/archiver/_common.py +++ b/src/borg/archiver/_common.py @@ -123,7 +123,7 @@ def wrapper(self, args, **kwargs): f"You can use 'borg transfer' to copy archives from old to new repos." ) if manifest or cache: - manifest_ = Manifest.load(repository, compatibility, other=False) + manifest_ = Manifest.load(repository, compatibility, args, other=False) kwargs["manifest"] = manifest_ if "compression" in args: manifest_.repo_objs.compressor = args.compression.compressor @@ -570,6 +570,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): diff --git a/src/borg/archiver/key_cmds.py b/src/borg/archiver/key_cmds.py index 50e0315b4d..b219fe2530 100644 --- a/src/borg/archiver/key_cmds.py +++ b/src/borg/archiver/key_cmds.py @@ -23,7 +23,7 @@ def do_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 diff --git a/src/borg/constants.py b/src/borg/constants.py index 31d810628f..464907accd 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -157,6 +157,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", } diff --git a/src/borg/crypto/fido2.py b/src/borg/crypto/fido2.py new file mode 100644 index 0000000000..dd5bbeff43 --- /dev/null +++ b/src/borg/crypto/fido2.py @@ -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 diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 08cb284c22..31adcbdaeb 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -1,6 +1,7 @@ import binascii import hmac import os +import stat import textwrap from hashlib import sha256, pbkdf2_hmac from pathlib import Path @@ -32,6 +33,8 @@ from .low_level import AES256_CTR_HMAC_SHA256, AES256_CTR_BLAKE2b, AES256_OCB, CHACHA20_POLY1305 from . import low_level +from .fido2 import Fido2Operations + # workaround for lost passphrase or key in "authenticated" or "authenticated-blake2" mode AUTHENTICATED_NO_KEY = "authenticated_no_key" in workarounds @@ -103,10 +106,10 @@ def identify_key(manifest_data): raise UnsupportedPayloadError(key_type) -def key_factory(repository, manifest_chunk, *, other=False, ro_cls=RepoObj): +def key_factory(repository, manifest_chunk, args, *, other=False, ro_cls=RepoObj): manifest_data = ro_cls.extract_crypted_data(manifest_chunk) assert manifest_data, "manifest data must not be zero bytes long" - return identify_key(manifest_data).detect(repository, manifest_data, other=other) + return identify_key(manifest_data).detect(repository, manifest_data, args, other=other) def uses_same_chunker_secret(other_key, key): @@ -262,7 +265,7 @@ def create(cls, repository, args, **kw): return cls(repository) @classmethod - def detect(cls, repository, manifest_data, *, other=False): + def detect(cls, repository, manifest_data, args, *, other=False): return cls(repository) def id_hash(self, data): @@ -385,30 +388,31 @@ class FlexiKey: STORAGE: ClassVar[str] = KeyBlobStorage.NO_STORAGE # override in subclass @classmethod - def detect(cls, repository, manifest_data, *, other=False): + def detect(cls, repository, manifest_data, args, *, other=False): key = cls(repository) target = key.find_key() + # TODO: ask for "PIN" when applicable prompt = "Enter passphrase for key %s: " % target passphrase = Passphrase.env_passphrase(other=other) if passphrase is None: passphrase = Passphrase() - if not key.load(target, passphrase): + if not key.load(target, passphrase, args): for retry in range(0, 3): passphrase = Passphrase.getpass(prompt) - if key.load(target, passphrase): + if key.load(target, passphrase, args): break Passphrase.display_debug_info(passphrase) else: raise PasswordRetriesExceeded else: - if not key.load(target, passphrase): + if not key.load(target, passphrase, args): Passphrase.display_debug_info(passphrase) raise PassphraseWrong key.init_ciphers(manifest_data) key._passphrase = passphrase return key - def _load(self, key_data, passphrase): + def _load(self, key_data, passphrase, args): try: key = binascii.a2b_base64(key_data) except (ValueError, binascii.Error): @@ -416,7 +420,7 @@ def _load(self, key_data, passphrase): if len(key) < 20: # this is in no way a precise check, usually we have about 400b key data. raise KeyfileInvalidError(self.repository._location.canonical_path(), "(repokey)") - data = self.decrypt_key_file(key, passphrase) + data = self.decrypt_key_file(key, passphrase, args) if data: data = msgpack.unpackb(data) key = Key(internal_dict=data) @@ -429,7 +433,7 @@ def _load(self, key_data, passphrase): return True return False - def decrypt_key_file(self, data, passphrase): + def decrypt_key_file(self, data, passphrase, args): unpacker = get_limited_unpacker("key") unpacker.feed(data) data = unpacker.unpack() @@ -442,6 +446,8 @@ def decrypt_key_file(self, data, passphrase): return self.decrypt_key_file_pbkdf2(encrypted_key, passphrase) elif encrypted_key.algorithm == "argon2 chacha20-poly1305": return self.decrypt_key_file_argon2(encrypted_key, passphrase) + elif encrypted_key.algorithm == "fido2 hmac-secret chacha20-poly1305": + return self.decrypt_key_file_fido2(encrypted_key, passphrase, args) else: raise UnsupportedKeyFormatError() @@ -501,11 +507,29 @@ def decrypt_key_file_argon2(self, encrypted_key, passphrase): except low_level.IntegrityError: return None - def encrypt_key_file(self, data, passphrase, algorithm): + def decrypt_key_file_fido2(self, encrypted_key, pin, args): + device = args.fido2_device + if device == "auto": + device = Fido2Operations.find_device(encrypted_key.fido2_credential_id) + if device == "none" or not (os.access(device, os.F_OK) and stat.S_ISCHR(os.stat(device).st_mode)): + # The device may be invalid despite passing this check, but if we are here + # it is definitely invalid. + raise ValueError(f"Invalid or unspecified FIDO2 device: {device}") + operations = Fido2Operations(device, pin) + secret = operations.use_hmac_hash(encrypted_key.salt, encrypted_key.fido2_credential_id) + ae_cipher = CHACHA20_POLY1305(key=secret, iv=0, header_len=0, aad_offset=0) + try: + return ae_cipher.decrypt(encrypted_key.data) + except low_level.IntegrityError: + return None + + def encrypt_key_file(self, data, passphrase, algorithm, args): if algorithm == "sha256": return self.encrypt_key_file_pbkdf2(data, passphrase) elif algorithm == "argon2 chacha20-poly1305": return self.encrypt_key_file_argon2(data, passphrase) + elif algorithm == "fido2 hmac-secret chacha20-poly1305": + return self.encrypt_key_file_fido2(data, passphrase, args) else: raise ValueError(f"Unexpected algorithm: {algorithm}") @@ -531,7 +555,20 @@ def encrypt_key_file_argon2(self, data, passphrase): ) return msgpack.packb(encrypted_key.as_dict()) - def _save(self, passphrase, algorithm): + def encrypt_key_file_fido2(self, data, pin, args): + operations = Fido2Operations(args.fido2_device, pin) + credential_id, salt, secret = operations.generate_hmac_hash(user=self.repository_id) + ae_cipher = CHACHA20_POLY1305(key=secret, iv=0, header_len=0, aad_offset=0) + encrypted_key = EncryptedKey( + version=1, + algorithm="fido2 hmac-secret chacha20-poly1305", + salt=salt, + data=ae_cipher.encrypt(data), + fido2_credential_id=credential_id, + ) + return msgpack.packb(encrypted_key.as_dict()) + + def _save(self, passphrase, algorithm, args): key = Key( version=2, repository_id=self.repository_id, @@ -539,14 +576,27 @@ def _save(self, passphrase, algorithm): id_key=self.id_key, chunk_seed=self.chunk_seed, ) - data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm) + data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm, args) key_data = "\n".join(textwrap.wrap(binascii.b2a_base64(data).decode("ascii"))) return key_data - def change_passphrase(self, passphrase=None): - if passphrase is None: - passphrase = Passphrase.new(allow_empty=True) - self.save(self.target, passphrase, algorithm=self._encrypted_key_algorithm) + def change_passphrase(self, args, passphrase=None): + if args.fido2_device: + operations = Fido2Operations(args.fido2_device) + if operations.has_client_pin: + # TODO: try to be more descriptive about the device + passphrase = Passphrase.new(only_new=True, pin_prompt=f"Enter PIN for {args.fido2_device}: ") + else: + passphrase = Passphrase("") + key_algorithm = KEY_ALGORITHMS["fido2"] + else: + if passphrase is None: + passphrase = Passphrase.new(allow_empty=True, only_new=True) + key_algorithm = self._encrypted_key_algorithm + # If fido2 was used before change it to argon2 + if key_algorithm == KEY_ALGORITHMS["fido2"]: + key_algorithm = KEY_ALGORITHMS["argon2"] + self.save(self.target, passphrase, algorithm=key_algorithm, args=args) @classmethod def create(cls, repository, args, *, other_key=None): @@ -569,10 +619,15 @@ def create(cls, repository, args, *, other_key=None): key.init_from_given_data(crypt_key=crypt_key, id_key=other_key.id_key, chunk_seed=other_key.chunk_seed) else: key.init_from_random_data() - passphrase = Passphrase.new(allow_empty=True) + if args.fido2_device: + key_algorithm = KEY_ALGORITHMS["fido2"] + passphrase = Passphrase.new(pin_prompt="Enter PIN for {args.fido2_device}: ") + else: + key_algorithm = KEY_ALGORITHMS["argon2"] + passphrase = Passphrase.new(allow_empty=True) key.init_ciphers() target = key.get_new_target(args) - key.save(target, passphrase, create=True, algorithm=KEY_ALGORITHMS["argon2"]) + key.save(target, passphrase, key_algorithm, args, create=True) logger.info('Key in "%s" created.' % target) logger.info("Keep this key safe. Your data will be inaccessible without it.") return key @@ -676,7 +731,7 @@ def _get_new_target_in_keys_dir(self, args): path = Path(filename + ".%d" % i) return str(path) - def load(self, target, passphrase): + def load(self, target, passphrase, args): if self.STORAGE == KeyBlobStorage.KEYFILE: with open(target) as fd: key_data = "".join(fd.readlines()[1:]) @@ -695,13 +750,13 @@ def load(self, target, passphrase): key_data = key_data.decode("utf-8") # remote repo: msgpack issue #99, getting bytes else: raise TypeError("Unsupported borg key storage type") - success = self._load(key_data, passphrase) + success = self._load(key_data, passphrase, args) if success: self.target = target return success - def save(self, target, passphrase, algorithm, create=False): - key_data = self._save(passphrase, algorithm) + def save(self, target, passphrase, algorithm, args, create=False): + key_data = self._save(passphrase, algorithm, args) if self.STORAGE == KeyBlobStorage.KEYFILE: if create and os.path.isfile(target): # if a new keyfile key repository is created, ensure that an existing keyfile of another @@ -771,7 +826,7 @@ class AuthenticatedKeyBase(AESKeyBase, FlexiKey): # It's only authenticated, not encrypted. logically_encrypted = False - def _load(self, key_data, passphrase): + def _load(self, key_data, passphrase, args): if AUTHENTICATED_NO_KEY: # fake _load if we have no key or passphrase NOPE = bytes(32) # 256 bit all-zero @@ -781,15 +836,15 @@ def _load(self, key_data, passphrase): self.id_key = NOPE self.chunk_seed = 0 return True - return super()._load(key_data, passphrase) + return super()._load(key_data, passphrase, args) - def load(self, target, passphrase): + def load(self, target, passphrase, args): success = super().load(target, passphrase) self.logically_encrypted = False return success - def save(self, target, passphrase, algorithm, create=False): - super().save(target, passphrase, algorithm, create=create) + def save(self, target, passphrase, algorithm, args, create=False): + super().save(target, passphrase, algorithm, args, create=create) self.logically_encrypted = False def init_ciphers(self, manifest_data=None): diff --git a/src/borg/helpers/passphrase.py b/src/borg/helpers/passphrase.py index 59b5f65c40..3156c497dd 100644 --- a/src/borg/helpers/passphrase.py +++ b/src/borg/helpers/passphrase.py @@ -47,21 +47,27 @@ def _env_passphrase(cls, env_var, default=None): return cls(passphrase) @classmethod - def env_passphrase(cls, default=None, other=False): + def env_passphrase(cls, default=None, other=False, new=False): + if other and new: + raise ValueError("Only one of 'other' and 'new' may be true") env_var = "BORG_OTHER_PASSPHRASE" if other else "BORG_PASSPHRASE" + env_var = "BORG_NEW_PASSPHRASE" if new else env_var passphrase = cls._env_passphrase(env_var, default) if passphrase is not None: return passphrase - passphrase = cls.env_passcommand(other=other) + passphrase = cls.env_passcommand(other=other, new=new) if passphrase is not None: return passphrase - passphrase = cls.fd_passphrase(other=other) + passphrase = cls.fd_passphrase(other=other, new=new) if passphrase is not None: return passphrase @classmethod - def env_passcommand(cls, default=None, other=False): + def env_passcommand(cls, default=None, other=False, new=False): + if other and new: + raise ValueError("Only one of 'other' and 'new' may be true") env_var = "BORG_OTHER_PASSCOMMAND" if other else "BORG_PASSCOMMAND" + env_var = "BORG_NEW_PASSCOMMAND" if other else env_var passcommand = os.environ.get(env_var, None) if passcommand is not None: # passcommand is a system command (not inside pyinstaller env) @@ -73,8 +79,11 @@ def env_passcommand(cls, default=None, other=False): return cls(passphrase.rstrip("\n")) @classmethod - def fd_passphrase(cls, other=False): + def fd_passphrase(cls, other=False, new=False): + if other and new: + raise ValueError("Only one of 'other' and 'new' may be true") env_var = "BORG_OTHER_PASSPHRASE_FD" if other else "BORG_PASSPHRASE_FD" + env_var = "BORG_NEW_PASSPHRASE_FD" if new else env_var try: fd = int(os.environ.get(env_var)) except (ValueError, TypeError): @@ -83,10 +92,6 @@ def fd_passphrase(cls, other=False): passphrase = f.read() return cls(passphrase.rstrip("\n")) - @classmethod - def env_new_passphrase(cls, default=None): - return cls._env_passphrase("BORG_NEW_PASSPHRASE", default) - @classmethod def getpass(cls, prompt): try: @@ -143,6 +148,9 @@ def fmt_var(env_var): {fmt_var("BORG_PASSPHRASE")} {fmt_var("BORG_PASSCOMMAND")} {fmt_var("BORG_PASSPHRASE_FD")} + {fmt_var("BORG_NEW_PASSPHRASE")} + {fmt_var("BORG_NEW_PASSCOMMAND")} + {fmt_var("BORG_NEW_PASSPHRASE_FD")} {fmt_var("BORG_OTHER_PASSPHRASE")} {fmt_var("BORG_OTHER_PASSCOMMAND")} {fmt_var("BORG_OTHER_PASSPHRASE_FD")} @@ -151,13 +159,21 @@ def fmt_var(env_var): print(passphrase_info, file=sys.stderr) @classmethod - def new(cls, allow_empty=False): - passphrase = cls.env_new_passphrase() - if passphrase is not None: - return passphrase - passphrase = cls.env_passphrase() + def new(cls, allow_empty=False, only_new=False, pin_prompt=None): + passphrase = cls.env_passphrase(new=True) if passphrase is not None: return passphrase + if not only_new: + passphrase = cls.env_passphrase() + if passphrase is not None: + return passphrase + if pin_prompt: + passphrase = cls.getpass(pin_prompt) + if passphrase is not None: + return passphrase + else: + print("PIN must not be blank.", file=sys.stderr) + raise PasswordRetriesExceeded for retry in range(1, 11): passphrase = cls.getpass("Enter new passphrase: ") if allow_empty or passphrase: diff --git a/src/borg/item.pyx b/src/borg/item.pyx index b139f55e26..cb9020eed3 100644 --- a/src/borg/item.pyx +++ b/src/borg/item.pyx @@ -424,7 +424,8 @@ cdef class EncryptedKey(PropDict): """ VALID_KEYS = {'version', 'algorithm', 'iterations', 'salt', 'hash', 'data', - 'argon2_time_cost', 'argon2_memory_cost', 'argon2_parallelism', 'argon2_type'} + 'argon2_time_cost', 'argon2_memory_cost', 'argon2_parallelism', + 'argon2_type', 'fido2_credential_id'} version = PropDictProperty(int) algorithm = PropDictProperty(str) @@ -436,6 +437,7 @@ cdef class EncryptedKey(PropDict): argon2_memory_cost = PropDictProperty(int) argon2_parallelism = PropDictProperty(int) argon2_type = PropDictProperty(str) + fido2_credential_id = PropDictProperty(bytes) def update_internal(self, d): # legacy support for migration (data from old msgpacks comes in as bytes always, but sometimes we want str) diff --git a/src/borg/manifest.py b/src/borg/manifest.py index 58029b625d..2c50439df9 100644 --- a/src/borg/manifest.py +++ b/src/borg/manifest.py @@ -491,13 +491,13 @@ def last_timestamp(self): return parse_timestamp(self.timestamp) @classmethod - def load(cls, repository, operations, key=None, *, other=False, ro_cls=RepoObj): + def load(cls, repository, operations, args, key=None, *, other=False, ro_cls=RepoObj): from .item import ManifestItem from .crypto.key import key_factory cdata = repository.get_manifest() if not key: - key = key_factory(repository, cdata, other=other, ro_cls=ro_cls) + key = key_factory(repository, cdata, args, other=other, ro_cls=ro_cls) manifest = cls(key, repository, ro_cls=ro_cls) _, data = manifest.repo_objs.parse(cls.MANIFEST_ID, cdata, ro_type=ROBJ_MANIFEST) manifest_dict = key.unpack_manifest(data)