From d2bc45f56d33d56d70a944653e622acef5bdc657 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 12 Jun 2026 23:07:57 +0200 Subject: [PATCH] key: unify keyfile/repokey classes, locate key independent of type byte (#9743) Borg used to read the manifest's key-type byte and then look for the key in exactly one place (keyfile or repokey) depending on the key class that byte selected. As a result every crypto suite was duplicated into a keyfile class and a repokey class that differed only in TYPE, NAME, ARG_NAME and STORAGE. Now key *location* is independent of the type byte: detection tries keyfiles first and repokeys afterwards until a passphrase unlocks a key. The type byte still selects the crypto suite (id hash, MAC, cipher) to instantiate. Where a key is stored (keyfile vs repokey) is therefore a per-key property (self.storage), not a separate class, so a repository may even hold a mix of keyfile- and repo-stored borg keys. With storage decoupled from class identity, the keyfile/repokey class pairs collapse into one class per crypto suite: - modern AEAD: AESOCBKey, CHPOKey, Blake3AESOCBKey, Blake3CHPOKey - legacy borg 1.x (read-only): AESCTRKey, Blake2AESCTRKey There is now exactly one type byte per modern crypto suite (the old separate repokey type bytes 0x11/0x21/0x31/0x41 were removed; borg2 is beta and only needs to read repos it created). identify_key() matches on TYPES_ACCEPTABLE. CLI: --encryption selects only the crypto suite (aes-ocb, chacha20-poly1305, blake3-aes-ocb, blake3-chacha20-poly1305, authenticated*, none); the storage location is chosen with the new --key-location=repokey|keyfile (default repokey). The old combined modes (repokey-aes-ocb etc.) were removed. borg key import also gained --key-location. borg key change-location no longer swaps key classes or rewrites the manifest; it just re-saves the unlocked key at the new location. Keyfile removal (key remove, change-location) now overwrites the keyfile with random data via secure_erase() before unlinking, consistent with save(). borg 1.x legacy read compatibility is preserved (the legacy class merge is a behavior-preserving rename; the legacy type bytes incl. PASSPHRASE stay in TYPES_ACCEPTABLE). Co-Authored-By: Claude Opus 4.8 --- docs/changes.rst | 9 + docs/quickstart_example.rst.inc | 2 +- docs/usage/key.rst | 4 +- docs/usage/repo-create.rst | 22 +- docs/usage/transfer.rst | 12 +- src/borg/archiver/key_cmds.py | 60 ++--- src/borg/archiver/repo_create_cmd.py | 40 ++- src/borg/archiver/repo_info_cmd.py | 10 +- src/borg/constants.py | 18 +- src/borg/crypto/key.py | 240 ++++++++---------- src/borg/crypto/keymanager.py | 12 +- src/borg/legacy/crypto/key.py | 44 ++-- src/borg/testsuite/archiver/__init__.py | 8 +- src/borg/testsuite/archiver/check_cmd_test.py | 4 +- src/borg/testsuite/archiver/key_cmds_test.py | 100 +++++--- .../archiver/repo_create_cmd_test.py | 6 +- src/borg/testsuite/benchmark_test.py | 2 +- src/borg/testsuite/cache_test.py | 4 +- src/borg/testsuite/crypto/crypto_test.py | 14 +- src/borg/testsuite/crypto/key_test.py | 73 +++--- src/borg/testsuite/crypto/legacy_key_test.py | 8 +- 21 files changed, 349 insertions(+), 343 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 7b4751c9c..39ea315d7 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -168,6 +168,15 @@ above. New features: +- key: unify keyfile/repokey key classes and locate the key independently of the + manifest key-type byte. Borg now tries keyfiles first and repokeys afterwards until + a passphrase unlocks a key, so where a key is stored (keyfile vs repokey) is a + per-key property rather than a separate key class. The key-type byte still selects + the crypto suite (id hash, MAC, cipher). ``borg repo-create --encryption`` now takes + only the crypto suite (e.g. ``aes-ocb``, ``chacha20-poly1305``, ``blake3-aes-ocb``); + choose the storage location with the new ``--key-location=repokey|keyfile`` option + (default: ``repokey``). The old combined modes (``repokey-aes-ocb`` etc.) were + removed. ``borg key import`` also gained ``--key-location``. #9743 - WIP packs project, major repo format changes, you must create new repos! #8572 - rest:// repository URLs - connect via ssh to remote borgstore REST server, talking http via stdio, #9593 diff --git a/docs/quickstart_example.rst.inc b/docs/quickstart_example.rst.inc index b6a8368ea..abfa70a76 100644 --- a/docs/quickstart_example.rst.inc +++ b/docs/quickstart_example.rst.inc @@ -1,6 +1,6 @@ 1. Before a backup can be made, a repository has to be initialized:: - $ borg -r /path/to/repo repo-create --encryption=repokey-aes-ocb + $ borg -r /path/to/repo repo-create --encryption=aes-ocb 2. Back up the ``~/src`` and ``~/Documents`` directories into an archive called *docs*:: diff --git a/docs/usage/key.rst b/docs/usage/key.rst index e9c47b6d2..a323629c7 100644 --- a/docs/usage/key.rst +++ b/docs/usage/key.rst @@ -9,7 +9,7 @@ Examples :: # Create a key file protected repository - $ borg repo-create --encryption=keyfile-aes-ocb -v + $ borg repo-create --encryption=aes-ocb --key-location=keyfile -v Initializing repository at "/path/to/repo" Enter new passphrase: Enter same passphrase again: @@ -46,7 +46,7 @@ Fully automated using environment variables: :: - $ BORG_NEW_PASSPHRASE=old borg repo-create --encryption=repokey-aes-ocb + $ BORG_NEW_PASSPHRASE=old borg repo-create --encryption=aes-ocb # now "old" is the current passphrase. $ BORG_PASSPHRASE=old BORG_NEW_PASSPHRASE=new borg key change-passphrase # now "new" is the current passphrase. diff --git a/docs/usage/repo-create.rst b/docs/usage/repo-create.rst index 89e145851..80ccba40f 100644 --- a/docs/usage/repo-create.rst +++ b/docs/usage/repo-create.rst @@ -8,21 +8,21 @@ Examples # Local repository $ export BORG_REPO=/path/to/repo - # Recommended repokey AEAD cryptographic modes - $ borg repo-create --encryption=repokey-aes-ocb - $ borg repo-create --encryption=repokey-chacha20-poly1305 - $ borg repo-create --encryption=repokey-blake2-aes-ocb - $ borg repo-create --encryption=repokey-blake2-chacha20-poly1305 + # Recommended AEAD cryptographic modes (key stored in the repository by default) + $ borg repo-create --encryption=aes-ocb + $ borg repo-create --encryption=chacha20-poly1305 + $ borg repo-create --encryption=blake3-aes-ocb + $ borg repo-create --encryption=blake3-chacha20-poly1305 # No encryption (not recommended) $ borg repo-create --encryption=authenticated - $ borg repo-create --encryption=authenticated-blake2 + $ borg repo-create --encryption=authenticated-blake3 $ borg repo-create --encryption=none - # Remote repository (accesses a remote Borg via SSH) - $ export BORG_REPO=ssh://user@hostname/~/backup - # repokey: stores the encrypted key in /config - $ borg repo-create --encryption=repokey-aes-ocb + # The crypto suite (--encryption) and where the key is stored (--key-location) are + # chosen independently. --key-location defaults to repokey. + # repokey: stores the encrypted key inside the repository + $ borg repo-create --encryption=aes-ocb --key-location=repokey # keyfile: stores the encrypted key in the config dir's keys/ subdir # (e.g. ~/.config/borg/keys/ on Linux, ~/Library/Application Support/borg/keys/ on macOS) - $ borg repo-create --encryption=keyfile-aes-ocb + $ borg repo-create --encryption=aes-ocb --key-location=keyfile diff --git a/docs/usage/transfer.rst b/docs/usage/transfer.rst index 5588a5779..9a85d3608 100644 --- a/docs/usage/transfer.rst +++ b/docs/usage/transfer.rst @@ -21,13 +21,13 @@ locations and passphrases first: # 1. Create a new "related" repository: # Here, the existing Borg 1.x repository used repokey (and AES-CTR mode), - # thus we use repokey-aes-ocb for the new Borg 2.0 repository. + # thus we use aes-ocb for the new Borg 2.0 repository. # Staying with the same chunk ID algorithm (hmac-sha256) and with the same # key material (via BORG_OTHER_REPO) will make deduplication work # between old archives (copied with borg transfer) and future ones. # The AEAD cipher does not matter (everything must be re-encrypted and - # re-authenticated anyway); you could also choose repokey-chacha20-poly1305. - $ borg repo-create -e repokey-aes-ocb + # re-authenticated anyway); you could also choose chacha20-poly1305. + $ borg repo-create -e aes-ocb # 2. Check what and how much it would transfer: $ borg transfer --from-borg1 --dry-run @@ -46,15 +46,15 @@ locations and passphrases first: # 1. Create a new "related" repository: # Here, the existing Borg 1.x repository used repokey-blake2 (and AES-CTR mode), - # thus we use repokey-blake3-aes-ocb for the new Borg 2.0 repository. + # thus we use blake3-aes-ocb for the new Borg 2.0 repository. # We need to change from blake2 to blake3, because blake2 is not supported # for borg2 repos (blake3 is much faster). Because we change how chunk IDs are # computed, we need to re-chunk everything while doing the transfer. # The chunker parameters you provide here should be the same as you will # use for all future Borg 2.0 archives. # The AEAD cipher does not matter (everything must be re-encrypted and - # re-authenticated anyway); you could also choose repokey-blake3-chacha20-poly1305. - $ borg repo-create -e repokey-blake3-aes-ocb + # re-authenticated anyway); you could also choose blake3-chacha20-poly1305. + $ borg repo-create -e blake3-aes-ocb $ export CHUNKER_PARAMS="buzhash64,19,23,21,4095" # 2. Check what and how much it would transfer: diff --git a/src/borg/archiver/key_cmds.py b/src/borg/archiver/key_cmds.py index b944e7892..0f7e08959 100644 --- a/src/borg/archiver/key_cmds.py +++ b/src/borg/archiver/key_cmds.py @@ -1,8 +1,7 @@ import os from ..constants import * # NOQA -from ..crypto.key import AESOCBRepoKey, CHPORepoKey, Blake3AESOCBRepoKey, Blake3CHPORepoKey -from ..crypto.key import AESOCBKeyfileKey, CHPOKeyfileKey, Blake3AESOCBKeyfileKey, Blake3CHPOKeyfileKey +from ..crypto.key import KEY_LOCATIONS from ..crypto.keymanager import KeyManager from ..helpers import PathSpec, CommandError from ..helpers.argparsing import ArgumentParser @@ -62,40 +61,24 @@ class KeysMixIn: @with_repository(exclusive=True, manifest=True, cache=True, compatibility=(Manifest.Operation.CHECK,)) def do_key_change_location(self, args, repository, manifest, cache): - """Changes the repository key location.""" + """Changes the location of the borg key used to unlock this repository.""" key = manifest.key if not hasattr(key, "change_passphrase"): raise CommandError("This repository is not encrypted, cannot change the key location.") + if not getattr(key, "LOCATION_CONFIGURABLE", False): + raise CommandError("This key's location cannot be changed (it has no keyfile/repokey storage).") - if args.key_mode == "keyfile": - if isinstance(key, AESOCBRepoKey): - key_new = AESOCBKeyfileKey(repository) - elif isinstance(key, CHPORepoKey): - key_new = CHPOKeyfileKey(repository) - elif isinstance(key, Blake3AESOCBRepoKey): - key_new = Blake3AESOCBKeyfileKey(repository) - elif isinstance(key, Blake3CHPORepoKey): - key_new = Blake3CHPOKeyfileKey(repository) - else: - print("Change not needed or not supported.") - return - if args.key_mode == "repokey": - if isinstance(key, AESOCBKeyfileKey): - key_new = AESOCBRepoKey(repository) - elif isinstance(key, CHPOKeyfileKey): - key_new = CHPORepoKey(repository) - elif isinstance(key, Blake3AESOCBKeyfileKey): - key_new = Blake3AESOCBRepoKey(repository) - elif isinstance(key, Blake3CHPOKeyfileKey): - key_new = Blake3CHPORepoKey(repository) - else: - print("Change not needed or not supported.") - return + new_storage = KEY_LOCATIONS[args.key_mode] + if key.storage == new_storage: + print(f"The borg key is already stored as {args.key_mode}, nothing to do.") + return + # the crypto class / manifest key-type byte does not change - only the storage location does. + # build a same-class key with the same key material and store it at the new location. + key_new = type(key)(repository) for name in ("repository_id", "crypt_key", "id_key", "chunk_seed", "sessionid", "cipher"): - value = getattr(key, name) - setattr(key_new, name, value) - + setattr(key_new, name, getattr(key, name)) + key_new.storage = new_storage key_new.target = key_new.get_new_target(args) # save with same passphrase, algorithm and label (keep the unlocked borg key's label) key_new.save( @@ -106,18 +89,16 @@ class KeysMixIn: label=key._loaded_label, ) - # rewrite the manifest with the new key, so that the key-type byte of the manifest changes + # the new key (same crypto material, new storage) is the canonical key going forward manifest.key = key_new manifest.repo_objs.key = key_new - manifest.write() - cache.key = key_new - loc = key_new.find_key() if hasattr(key_new, "find_key") else None + loc = key_new.find_key() if args.keep: logger.info(f"Key copied to {loc}") else: - key.remove(key.target) # remove key from current location + key.remove(key.target) # remove the borg key from its previous location only logger.info(f"Key moved to {loc}") @with_repository(lock=False, manifest=False, cache=False) @@ -256,6 +237,15 @@ class KeysMixIn: action="store_true", help="interactively import from a backup done with ``--paper``", ) + subparser.add_argument( + "--key-location", + metavar="LOCATION", + dest="key_location", + choices=("repokey", "keyfile"), + default="repokey", + help="where to store the imported key: 'repokey' (in the repository, default) or " + "'keyfile' (in the local keys directory)", + ) change_passphrase_epilog = process_epilog( """ diff --git a/src/borg/archiver/repo_create_cmd.py b/src/borg/archiver/repo_create_cmd.py index 2dfcc0a17..aa5cb7289 100644 --- a/src/borg/archiver/repo_create_cmd.py +++ b/src/borg/archiver/repo_create_cmd.py @@ -77,7 +77,7 @@ class RepoCreateMixIn: :: - borg repo-create --encryption repokey-aes-ocb + borg repo-create --encryption aes-ocb --key-location repokey Borg will: @@ -125,11 +125,17 @@ class RepoCreateMixIn: Depending on your hardware, hashing and crypto performance may vary widely. The easiest way to find out what is fastest is to run ``borg benchmark cpu``. - `repokey` modes: if you want ease-of-use and "passphrase" security is good enough - - the key will be stored in the repository (in ``repo_dir/config``). + The encryption mode (``--encryption``) only selects the crypto suite (id hash, encryption + and authentication). Where the key is stored is chosen separately with ``--key-location``: - `keyfile` modes: if you want "passphrase and having-the-key" security - - the key will be stored in your home directory (in ``~/.config/borg/keys``). + - ``repokey`` (default): the key is stored in the repository (under ``keys/``). Pick this + if you want ease-of-use and "passphrase" security is good enough. + - ``keyfile``: the key is stored in your home directory (in ``~/.config/borg/keys``). Pick + this if you want "passphrase and having-the-key" security. + + You can move the key between these locations later with ``borg key change-location``. + ``--key-location`` is ignored for ``none`` and ``authenticated*`` modes (those have no + separate keyfile/repokey storage). The following table is roughly sorted in order of preference, the better ones are in the upper part of the table, in the lower part is the old and/or unsafe(r) stuff: @@ -137,17 +143,17 @@ class RepoCreateMixIn: .. nanorst: inline-fill +-----------------------------------+--------------+----------------+--------------------+ - | Mode (K = keyfile or repokey) | ID-Hash | Encryption | Authentication | + | Encryption mode | ID-Hash | Encryption | Authentication | +-----------------------------------+--------------+----------------+--------------------+ - | K-blake2-chacha20-poly1305 | BLAKE2b | CHACHA20 | POLY1305 | + | blake3-chacha20-poly1305 | BLAKE3 | CHACHA20 | POLY1305 | +-----------------------------------+--------------+----------------+--------------------+ - | K-chacha20-poly1305 | HMAC-SHA-256 | CHACHA20 | POLY1305 | + | chacha20-poly1305 | HMAC-SHA-256 | CHACHA20 | POLY1305 | +-----------------------------------+--------------+----------------+--------------------+ - | K-blake2-aes-ocb | BLAKE2b | AES256-OCB | AES256-OCB | + | blake3-aes-ocb | BLAKE3 | AES256-OCB | AES256-OCB | +-----------------------------------+--------------+----------------+--------------------+ - | K-aes-ocb | HMAC-SHA-256 | AES256-OCB | AES256-OCB | + | aes-ocb | HMAC-SHA-256 | AES256-OCB | AES256-OCB | +-----------------------------------+--------------+----------------+--------------------+ - | authenticated-blake2 | BLAKE2b | none | BLAKE2b | + | authenticated-blake3 | BLAKE3 | none | BLAKE3 | +-----------------------------------+--------------+----------------+--------------------+ | authenticated | HMAC-SHA-256 | none | HMAC-SHA256 | +-----------------------------------+--------------+----------------+--------------------+ @@ -214,7 +220,17 @@ class RepoCreateMixIn: required=True, choices=key_argument_names(), action=Highlander, - help="select encryption key mode **(required)**", + help="select encryption crypto suite **(required)**", + ) + subparser.add_argument( + "--key-location", + metavar="LOCATION", + dest="key_location", + choices=("repokey", "keyfile"), + default="repokey", + action=Highlander, + help="where to store the key: 'repokey' (in the repository, default) or 'keyfile' " + "(in the local keys directory). Ignored for 'none' and 'authenticated*' modes.", ) subparser.add_argument( "--copy-crypt-key", diff --git a/src/borg/archiver/repo_info_cmd.py b/src/borg/archiver/repo_info_cmd.py index d8feeaf8c..44765c918 100644 --- a/src/borg/archiver/repo_info_cmd.py +++ b/src/borg/archiver/repo_info_cmd.py @@ -25,9 +25,13 @@ class RepoInfoMixIn: if key.NAME in ("plaintext", "authenticated"): encryption += "No" else: - encryption += "Yes (%s)" % key.NAME - if key.NAME.startswith("key file"): - encryption += "\nKey file: %s" % key.find_key() + # storage (keyfile/repokey) is a per-key property now; the crypto suite is key.NAME. + mode = {KeyBlobStorage.KEYFILE: "keyfile", KeyBlobStorage.REPO: "repokey"}.get( + getattr(key, "storage", None) + ) + encryption += "Yes (%s, %s)" % (mode, key.NAME) if mode else "Yes (%s)" % key.NAME + if getattr(key, "storage", None) == KeyBlobStorage.KEYFILE: + encryption += "\nKey file: %s" % key.find_key() info["encryption"] = encryption output = ( diff --git a/src/borg/constants.py b/src/borg/constants.py index 262ac4d27..5c88b6b89 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -182,7 +182,7 @@ class KeyType: # repos with PASSPHRASE mode could not be created any more since borg 1.0, see #97. # in borg 2. all of its code and also the "borg key migrate-to-repokey" command was removed. # if you still need to, you can use "borg key migrate-to-repokey" with borg 1.0, 1.1 and 1.2. - # Nowadays, we just dispatch this to RepoKey and assume the passphrase was migrated to a repokey. + # Nowadays, we just dispatch this to the legacy AES-CTR key and assume the passphrase was migrated. PASSPHRASE = 0x01 # legacy, borg < 1.0 PLAINTEXT = 0x02 REPO = 0x03 @@ -191,15 +191,13 @@ class KeyType: BLAKE2AUTHENTICATED = 0x06 AUTHENTICATED = 0x07 # new crypto - # upper 4 bits are ciphersuite, lower 4 bits are keytype - AESOCBKEYFILE = 0x10 - AESOCBREPO = 0x11 - CHPOKEYFILE = 0x20 - CHPOREPO = 0x21 - BLAKE3AESOCBKEYFILE = 0x30 - BLAKE3AESOCBREPO = 0x31 - BLAKE3CHPOKEYFILE = 0x40 - BLAKE3CHPOREPO = 0x41 + # upper 4 bits are ciphersuite, lower 4 bits are reserved (0). + # the type byte only identifies the crypto suite; where the key is stored (keyfile vs + # repokey) is not encoded here any more, so there is only one type byte per suite. + AESOCB = 0x10 + CHPO = 0x20 + BLAKE3AESOCB = 0x30 + BLAKE3CHPO = 0x40 BLAKE3AUTHENTICATED = 0x50 diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 049fcdc6b..6caa89aae 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -121,6 +121,11 @@ class UnsupportedKeyFormatError(Error): exit_mcode = 49 +# map the user-facing key location names ("borg repo-create --key-location", "borg key change-location") +# to the internal KeyBlobStorage values. Note "repokey" != KeyBlobStorage.REPO's string value. +KEY_LOCATIONS = {"keyfile": KeyBlobStorage.KEYFILE, "repokey": KeyBlobStorage.REPO} + + def key_creator(repository, args, *, other_key=None): for key in AVAILABLE_KEY_TYPES: if key.ARG_NAME == args.encryption: @@ -135,15 +140,14 @@ def key_argument_names(): def identify_key(manifest_data): + # the key-type byte only identifies the crypto suite (id hash, MAC, cipher), NOT where the key is + # stored: keyfile and repokey share one class now and accept both historic type bytes. The legacy + # PASSPHRASE byte (0x01) is part of AESCTRKey.TYPES_ACCEPTABLE. All TYPES_ACCEPTABLE sets are disjoint. key_type = manifest_data[0] - if key_type == KeyType.PASSPHRASE: # legacy, see comment in KeyType class. - return RepoKey - for key in LEGACY_KEY_TYPES + AVAILABLE_KEY_TYPES: - if key.TYPE == key_type: + if key_type in key.TYPES_ACCEPTABLE: return key - else: - raise UnsupportedPayloadError(key_type) + raise UnsupportedPayloadError(key_type) def key_factory(repository, manifest_chunk, *, other=False, ro_cls=RepoObj): @@ -164,16 +168,10 @@ def uses_same_id_hash(other_key, key): # avoid breaking the deduplication by changing the id hash old_sha256_ids = (PlaintextKey,) new_sha256_ids = (PlaintextKey,) - old_hmac_sha256_ids = (RepoKey, KeyfileKey, AuthenticatedKey) - new_hmac_sha256_ids = (AESOCBRepoKey, AESOCBKeyfileKey, CHPORepoKey, CHPOKeyfileKey, AuthenticatedKey) + old_hmac_sha256_ids = (AESCTRKey, AuthenticatedKey) + new_hmac_sha256_ids = (AESOCBKey, CHPOKey, AuthenticatedKey) # note: we do not support blake2b for new repos, see #8867 - new_blake3_ids = ( - Blake3AESOCBRepoKey, - Blake3AESOCBKeyfileKey, - Blake3CHPORepoKey, - Blake3CHPOKeyfileKey, - Blake3AuthenticatedKey, - ) + new_blake3_ids = (Blake3AESOCBKey, Blake3CHPOKey, Blake3AuthenticatedKey) same_ids = ( isinstance(other_key, old_hmac_sha256_ids + new_hmac_sha256_ids) and isinstance(key, new_hmac_sha256_ids) @@ -197,9 +195,14 @@ class KeyBase: # Name used in command line / API (e.g. borg init --encryption=...) ARG_NAME = "UNDEFINED" - # Storage type (no key blob storage / keyfile / repo) + # Storage type (no key blob storage / keyfile / repo). This is only a default seed for the + # per-instance self.storage; keyfile vs repokey is a property of an individual key, not the class. STORAGE: ClassVar[str] = KeyBlobStorage.NO_STORAGE + # Whether a key of this class may be stored as a keyfile or as a repokey (configurable at + # repo creation via --key-location and changeable later via "borg key change-location"). + LOCATION_CONFIGURABLE = False + # Seed for the buzhash chunker (borg.algorithms.chunker.Chunker) # type is int chunk_seed: int = None @@ -223,6 +226,9 @@ class KeyBase: self.TYPE_STR = bytes([self.TYPE]) self.repository = repository self.target = None # key location file path / repo obj + # where this particular key is/will be stored (keyfile or repo); seeded from the class default, + # overwritten when a key is loaded (see FlexiKey._try_key) or created (see --key-location). + self.storage = self.STORAGE self.copy_crypt_key = False def id_hash(self, data): @@ -546,7 +552,7 @@ class FlexiKey: # (for repokey) delete the previously-loaded borg key (keyfile mode auto-erases it in save()). old_id = self._loaded_key_id self.save(self.target, passphrase, algorithm=self._encrypted_key_algorithm, label=self._loaded_label) - if self.STORAGE == KeyBlobStorage.REPO and old_id and hasattr(self.repository, "delete_key"): + if self.storage == KeyBlobStorage.REPO and old_id and hasattr(self.repository, "delete_key"): if self._loaded_key_id != old_id: self.repository.delete_key(old_id) @@ -554,6 +560,9 @@ class FlexiKey: def create(cls, repository, args, *, other_key=None): key = cls(repository) key.repository_id = repository.id + if cls.LOCATION_CONFIGURABLE: + # choose initial storage (keyfile or repokey) from --key-location (default: repokey). + key.storage = KEY_LOCATIONS.get(getattr(args, "key_location", None), cls.STORAGE) if other_key is not None: if isinstance(other_key, PlaintextKey): raise Error("Copying key material from an unencrypted repository is not possible.") @@ -605,23 +614,18 @@ class FlexiKey: return filename def find_key(self): - if self.STORAGE == KeyBlobStorage.KEYFILE: - keyfile = self._find_key_file_from_environment() - if keyfile is not None: - return self.sanity_check(keyfile, self.repository.id) - keyfile = self._find_key_in_keys_dir() - if keyfile is not None: - return keyfile - raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir()) - elif self.STORAGE == KeyBlobStorage.REPO: - loc = self.repository._location.canonical_path() - key = self.repository.load_key() - if not key: - # if we got an empty key, it means there is no key. - raise RepoKeyNotFoundError(loc) from None + # storage-agnostic: report the location of any existing borg key for this repo, preferring a + # keyfile (checked first) over a repokey. Used for the passphrase prompt and for logging. + env_keyfile = self._find_key_file_from_environment() + if env_keyfile is not None: + return self.sanity_check(env_keyfile, self.repository.id) + keyfile = self._find_key_in_keys_dir() + if keyfile is not None: + return keyfile + loc = self.repository._location.canonical_path() + if self.repository.load_key(): return loc - else: - raise TypeError("Unsupported borg key storage type") + raise RepoKeyNotFoundError(loc) from None def get_existing_or_new_target(self, args): keyfile = self._find_key_file_from_environment() @@ -659,12 +663,12 @@ class FlexiKey: return found def get_new_target(self, args): - if self.STORAGE == KeyBlobStorage.KEYFILE: + if self.storage == KeyBlobStorage.KEYFILE: keyfile = self._find_key_file_from_environment() if keyfile is not None: return keyfile return get_keys_dir() - elif self.STORAGE == KeyBlobStorage.REPO: + elif self.storage == KeyBlobStorage.REPO: return self.repository else: raise TypeError("Unsupported borg key storage type") @@ -703,12 +707,9 @@ class FlexiKey: def _iter_keys(self): # return [(key_id, blob_text, keyfile_path_or_None)] for all borg keys of this repo. - if self.STORAGE == KeyBlobStorage.KEYFILE: - return self._keyfile_candidates() - elif self.STORAGE == KeyBlobStorage.REPO: - return self._repo_candidates() - else: - raise TypeError("Unsupported borg key storage type") + # storage-agnostic: we look at keyfiles first and repokeys afterwards, regardless of the + # manifest key-type byte. The first key a passphrase unlocks wins (see load_any). + return self._keyfile_candidates() + self._repo_candidates() def _key_envelope(self, blob_text): # decode the (unencrypted) EncryptedKey envelope of a borg key without decrypting it. @@ -738,7 +739,14 @@ class FlexiKey: logger.debug("Borg key %s could not be loaded (corrupted?), skipping it: %s", key_id[:12], exc) return False if loaded: - self.target = keyfile_path if self.STORAGE == KeyBlobStorage.KEYFILE else self.repository + # remember where this particular key actually lives (keyfile vs repokey), independent of + # the manifest key-type byte, so save/remove/list operate on the right storage afterwards. + self.storage = KeyBlobStorage.KEYFILE if keyfile_path is not None else KeyBlobStorage.REPO + self.target = keyfile_path if self.storage == KeyBlobStorage.KEYFILE else self.repository + if self.storage == KeyBlobStorage.REPO: + # While the repository is encrypted, we consider a repokey repository with a blank + # passphrase an unencrypted repository. + self.logically_encrypted = passphrase != "" # nosec B105 self._loaded_key_id = key_id self._loaded_label = self._encrypted_key_label return True @@ -746,10 +754,6 @@ class FlexiKey: def load_any(self, passphrase): """Try the passphrase against every borg key of this repository.""" - if self.STORAGE == KeyBlobStorage.REPO: - # While the repository is encrypted, we consider a repokey repository with a blank - # passphrase an unencrypted repository. - self.logically_encrypted = passphrase != "" # nosec B105 for key_id, blob_text, keyfile_path in self._iter_keys(): if self._try_key(key_id, blob_text, keyfile_path, passphrase): return True @@ -758,23 +762,21 @@ class FlexiKey: def load(self, target, passphrase): # load a specific borg key: for keyfiles, the explicit file given as target; for repokey, # any of the repository's borg keys (which are addressed by passphrase, not by target). - if self.STORAGE == KeyBlobStorage.KEYFILE: + if self.storage == KeyBlobStorage.KEYFILE: try: with open(target, "rb") as fd: blob = fd.read() except OSError: return False return self._try_key(sha256(blob).hexdigest(), blob.decode("utf-8"), str(target), passphrase) - elif self.STORAGE == KeyBlobStorage.REPO: - return self.load_any(passphrase) else: - raise TypeError("Unsupported borg key storage type") + return self.load_any(passphrase) def save(self, target, passphrase, algorithm, create=False, label=None, replace=True): # replace=True replaces the previously-loaded borg key (change-passphrase semantics); # replace=False adds an additional borg key, keeping the existing ones (key add). key_data = self._save(passphrase, algorithm, label=label) - if self.STORAGE == KeyBlobStorage.KEYFILE: + if self.storage == KeyBlobStorage.KEYFILE: old_target = getattr(self, "target", None) keys_dir = get_keys_dir() keyfile_data = keyfile_format(bin_to_hex(self.repository_id), key_data) @@ -801,7 +803,7 @@ class FlexiKey: except OSError as exc: logger.debug('Could not remove previous keyfile "%s": %s', old_target, exc) self._loaded_key_id = sha256(keyfile_data.encode()).hexdigest() - elif self.STORAGE == KeyBlobStorage.REPO: + elif self.storage == KeyBlobStorage.REPO: self.logically_encrypted = passphrase != "" # nosec B105 key_data = keyfile_format(bin_to_hex(self.repository_id), key_data) key_data = key_data.encode("utf-8") # remote repo: msgpack issue #99, giving bytes @@ -814,13 +816,15 @@ class FlexiKey: self._loaded_key_id = sha256(key_data).hexdigest() else: raise TypeError("Unsupported borg key storage type") - self.target = target if self.STORAGE != KeyBlobStorage.REPO else self.repository + self.target = target if self.storage != KeyBlobStorage.REPO else self.repository self._loaded_label = label def remove(self, target): - if self.STORAGE == KeyBlobStorage.KEYFILE: - os.remove(target) # the keyfile of the borg key we unlocked; other borg keys are separate files - elif self.STORAGE == KeyBlobStorage.REPO: + if self.storage == KeyBlobStorage.KEYFILE: + # the keyfile of the borg key we unlocked; other borg keys are separate files. + # overwrite it with random data before unlinking (same as save() does for old keyfiles). + secure_erase(target, avoid_collateral_damage=True) + elif self.storage == KeyBlobStorage.REPO: # remove only the borg key we unlocked, leaving the repository's other borg keys alone. if hasattr(target, "delete_key") and self._loaded_key_id: target.delete_key(self._loaded_key_id) @@ -831,9 +835,11 @@ class FlexiKey: def list_keys(self): """Return metadata for all borg keys of this repository (no decryption).""" - mode = "keyfile" if self.STORAGE == KeyBlobStorage.KEYFILE else "repokey" result = [] for key_id, blob_text, keyfile_path in self._iter_keys(): + # storage is a per-key property now, so report each key's actual mode (a repository may + # hold a mix of keyfile- and repo-stored borg keys). + mode = "keyfile" if keyfile_path is not None else "repokey" try: env = self._key_envelope(blob_text) label, algorithm = env.get("label"), env.get("algorithm") @@ -854,9 +860,9 @@ class FlexiKey: def add_key(self, passphrase=None, label=None): """Add an additional borg key protecting the same key material with a new passphrase.""" - if self.STORAGE == KeyBlobStorage.REPO and not hasattr(self.repository, "store_key"): + if self.storage == KeyBlobStorage.REPO and not hasattr(self.repository, "store_key"): raise Error("This repository type does not support multiple borg keys.") - if self.STORAGE == KeyBlobStorage.KEYFILE and os.environ.get("BORG_KEY_FILE"): + if self.storage == KeyBlobStorage.KEYFILE and os.environ.get("BORG_KEY_FILE"): raise Error( "Cannot add a borg key while BORG_KEY_FILE points to a single keyfile; " "unset it so the new keyfile can be stored in the keys directory." @@ -889,12 +895,11 @@ class FlexiKey: victim = matches[0] if victim["label"] == ADMIN_LABEL: raise Error('The "%s" borg key is protected and cannot be removed.' % ADMIN_LABEL) - if self.STORAGE == KeyBlobStorage.REPO: + # remove from the victim's own storage (which may differ from the unlocked key's storage) + if victim["mode"] == "repokey": self.repository.delete_key(victim["id"]) - elif self.STORAGE == KeyBlobStorage.KEYFILE: - os.remove(victim["path"]) else: - raise TypeError("Unsupported borg key storage type") + secure_erase(victim["path"], avoid_collateral_damage=True) # overwrite the keyfile before unlinking return victim @@ -944,8 +949,8 @@ class AuthenticatedKeyBase(AESKeyBase, FlexiKey): # legacy imports placed after FlexiKey/AESKeyBase/KeyBase/AuthenticatedKeyBase so those names are already # in the partial module when legacy/crypto/key.py imports them back during circular load -from ..legacy.crypto.key import KeyfileKey, RepoKey -from ..legacy.crypto.key import Blake2KeyfileKey, Blake2RepoKey, Blake2AuthenticatedKey # noqa: F401 +from ..legacy.crypto.key import AESCTRKey, Blake2AESCTRKey # noqa: F401 +from ..legacy.crypto.key import Blake2AuthenticatedKey # noqa: F401 from ..legacy.crypto.key import LEGACY_KEY_TYPES # noqa: E402 from ..legacy.crypto.key import ID_BLAKE2b_256 # noqa: F401 @@ -987,7 +992,7 @@ class AEADKeyBase(KeyBase): Offsets:0 1 2 8 32 48 [bytes] suite: 1010b for new AEAD crypto, 0000b is old crypto - keytype: see constants.KeyType (suite+keytype) + keytype: always 0 for new crypto (the key storage location is no longer encoded in the type byte) reserved: all-zero, for future use messageIV: a counter starting from 0 for all new encrypted messages of one session sessionID: 192bit random, computed once per session (the session key is derived from this) @@ -1003,6 +1008,11 @@ class AEADKeyBase(KeyBase): MAX_IV = 2**48 - 1 + # default storage; an individual key's actual storage is tracked per-instance in self.storage. + STORAGE = KeyBlobStorage.REPO + # an AEAD key may be stored as a keyfile or inside the repository (see borg key change-location). + LOCATION_CONFIGURABLE = True + def assert_id(self, id, data): # Comparing the id hash here would not be needed any more for the new AEAD crypto **IF** we # could be sure that chunks were created by normal (not tampered, not evil) borg code: @@ -1087,75 +1097,41 @@ class AEADKeyBase(KeyBase): self.cipher = self._get_cipher(self.sessionid, iv=0) -class AESOCBKeyfileKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.AESOCBKEYFILE, KeyType.AESOCBREPO} - TYPE = KeyType.AESOCBKEYFILE - NAME = "key file AES-OCB" - ARG_NAME = "keyfile-aes-ocb" - STORAGE = KeyBlobStorage.KEYFILE +# Each of these is one unified key class per crypto suite. A key of this class may be stored either as +# a keyfile or inside the repository (repokey) - that is a per-key storage property (self.storage), not +# a class distinction. The class is selected from the manifest's key-type byte (see identify_key), which +# only encodes the crypto suite (there is exactly one type byte per suite now). + + +class AESOCBKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): + TYPE = KeyType.AESOCB + TYPES_ACCEPTABLE = {TYPE} + NAME = "AES-OCB" + ARG_NAME = "aes-ocb" CIPHERSUITE = AES256_OCB -class AESOCBRepoKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.AESOCBKEYFILE, KeyType.AESOCBREPO} - TYPE = KeyType.AESOCBREPO - NAME = "repokey AES-OCB" - ARG_NAME = "repokey-aes-ocb" - STORAGE = KeyBlobStorage.REPO - CIPHERSUITE = AES256_OCB - - -class CHPOKeyfileKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.CHPOKEYFILE, KeyType.CHPOREPO} - TYPE = KeyType.CHPOKEYFILE - NAME = "key file ChaCha20-Poly1305" - ARG_NAME = "keyfile-chacha20-poly1305" - STORAGE = KeyBlobStorage.KEYFILE +class CHPOKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): + TYPE = KeyType.CHPO + TYPES_ACCEPTABLE = {TYPE} + NAME = "ChaCha20-Poly1305" + ARG_NAME = "chacha20-poly1305" CIPHERSUITE = CHACHA20_POLY1305 -class CHPORepoKey(ID_HMAC_SHA_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.CHPOKEYFILE, KeyType.CHPOREPO} - TYPE = KeyType.CHPOREPO - NAME = "repokey ChaCha20-Poly1305" - ARG_NAME = "repokey-chacha20-poly1305" - STORAGE = KeyBlobStorage.REPO - CIPHERSUITE = CHACHA20_POLY1305 - - -class Blake3AESOCBKeyfileKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.BLAKE3AESOCBKEYFILE, KeyType.BLAKE3AESOCBREPO} - TYPE = KeyType.BLAKE3AESOCBKEYFILE - NAME = "key file BLAKE3 AES-OCB" - ARG_NAME = "keyfile-blake3-aes-ocb" - STORAGE = KeyBlobStorage.KEYFILE +class Blake3AESOCBKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): + TYPE = KeyType.BLAKE3AESOCB + TYPES_ACCEPTABLE = {TYPE} + NAME = "BLAKE3 AES-OCB" + ARG_NAME = "blake3-aes-ocb" CIPHERSUITE = AES256_OCB -class Blake3AESOCBRepoKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.BLAKE3AESOCBKEYFILE, KeyType.BLAKE3AESOCBREPO} - TYPE = KeyType.BLAKE3AESOCBREPO - NAME = "repokey BLAKE3 AES-OCB" - ARG_NAME = "repokey-blake3-aes-ocb" - STORAGE = KeyBlobStorage.REPO - CIPHERSUITE = AES256_OCB - - -class Blake3CHPOKeyfileKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.BLAKE3CHPOKEYFILE, KeyType.BLAKE3CHPOREPO} - TYPE = KeyType.BLAKE3CHPOKEYFILE - NAME = "key file BLAKE3 ChaCha20-Poly1305" - ARG_NAME = "keyfile-blake3-chacha20-poly1305" - STORAGE = KeyBlobStorage.KEYFILE - CIPHERSUITE = CHACHA20_POLY1305 - - -class Blake3CHPORepoKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): - TYPES_ACCEPTABLE = {KeyType.BLAKE3CHPOKEYFILE, KeyType.BLAKE3CHPOREPO} - TYPE = KeyType.BLAKE3CHPOREPO - NAME = "repokey BLAKE3 ChaCha20-Poly1305" - ARG_NAME = "repokey-blake3-chacha20-poly1305" - STORAGE = KeyBlobStorage.REPO +class Blake3CHPOKey(ID_BLAKE3_256, AEADKeyBase, FlexiKey): + TYPE = KeyType.BLAKE3CHPO + TYPES_ACCEPTABLE = {TYPE} + NAME = "BLAKE3 ChaCha20-Poly1305" + ARG_NAME = "blake3-chacha20-poly1305" CIPHERSUITE = CHACHA20_POLY1305 @@ -1166,12 +1142,8 @@ AVAILABLE_KEY_TYPES = ( AuthenticatedKey, # new crypto Blake3AuthenticatedKey, - AESOCBKeyfileKey, - AESOCBRepoKey, - CHPOKeyfileKey, - CHPORepoKey, - Blake3AESOCBKeyfileKey, - Blake3AESOCBRepoKey, - Blake3CHPOKeyfileKey, - Blake3CHPORepoKey, + AESOCBKey, + CHPOKey, + Blake3AESOCBKey, + Blake3CHPOKey, ) diff --git a/src/borg/crypto/keymanager.py b/src/borg/crypto/keymanager.py index 033375f93..58e7d50b0 100644 --- a/src/borg/crypto/keymanager.py +++ b/src/borg/crypto/keymanager.py @@ -9,7 +9,7 @@ from ..repoobj import RepoObj from .key import keyfile_format, keyfile_parse, is_keyfile -from .key import RepoKeyNotFoundError, KeyBlobStorage, identify_key, keyfile_name_for +from .key import RepoKeyNotFoundError, KeyBlobStorage, KEY_LOCATIONS, identify_key, keyfile_name_for class NotABorgKeyFile(Error): @@ -103,17 +103,19 @@ class KeyManager: self.loaded_label = selected["label"] def store_keyblob(self, args): - if self.keyblob_storage == KeyBlobStorage.KEYFILE: - from .key import CHPOKeyfileKey + # storage location for the imported key: --key-location wins, else the class default. + storage = KEY_LOCATIONS.get(getattr(args, "key_location", None), self.keyblob_storage) + if storage == KeyBlobStorage.KEYFILE: + from .key import CHPOKey - k = CHPOKeyfileKey(self.repository) + k = CHPOKey(self.repository) target = k.get_existing_or_new_target(args) keyfile_data = self.get_keyfile_data() if not os.environ.get("BORG_KEY_FILE") and os.path.samefile(target, get_keys_dir()): target = os.path.join(target, keyfile_name_for(keyfile_data.encode())) with dash_open(target, "w") as fd: fd.write(keyfile_data) - elif self.keyblob_storage == KeyBlobStorage.REPO: + elif storage == KeyBlobStorage.REPO: key_data = keyfile_format(bin_to_hex(self.repository.id), self.keyblob.strip()) self.repository.save_key(key_data.encode("utf-8")) diff --git a/src/borg/legacy/crypto/key.py b/src/borg/legacy/crypto/key.py index 7918381e6..f6e708a0d 100644 --- a/src/borg/legacy/crypto/key.py +++ b/src/borg/legacy/crypto/key.py @@ -92,40 +92,30 @@ class Blake2AuthenticatedKey(ID_BLAKE2b_256, AuthenticatedKeyBase): # type: ign ARG_NAME = "authenticated-blake2" -class KeyfileKey(Pbkdf2FileMixin, ID_HMAC_SHA_256, AESKeyBase, FlexiKey): # type: ignore[misc] +# borg 1.x AES-CTR keys. keyfile and repokey are no longer separate classes - storage is a per-key +# property (self.storage), tracked when the key is loaded. These classes are read-only (borg 2 only +# reads borg 1.x repos, e.g. via borg transfer; it never creates them), so the canonical TYPE byte is +# never written - only TYPES_ACCEPTABLE matters, and it keeps the historic keyfile/repokey/passphrase bytes. + + +class AESCTRKey(Pbkdf2FileMixin, ID_HMAC_SHA_256, AESKeyBase, FlexiKey): # type: ignore[misc] TYPES_ACCEPTABLE = {KeyType.KEYFILE, KeyType.REPO, KeyType.PASSPHRASE} TYPE = KeyType.KEYFILE - NAME = "key file" - ARG_NAME = "keyfile" - STORAGE = KeyBlobStorage.KEYFILE + NAME = "AES-CTR HMAC-SHA256" + ARG_NAME = None # not creatable: borg 1.x compatibility (read-only) + STORAGE = KeyBlobStorage.REPO # seed default; actual per-key storage is tracked in self.storage on load + LOCATION_CONFIGURABLE = True # borg 1.x had keyfile and repokey variants CIPHERSUITE = AES256_CTR_HMAC_SHA256 -class RepoKey(Pbkdf2FileMixin, ID_HMAC_SHA_256, AESKeyBase, FlexiKey): # type: ignore[misc] - TYPES_ACCEPTABLE = {KeyType.KEYFILE, KeyType.REPO, KeyType.PASSPHRASE} - TYPE = KeyType.REPO - NAME = "repokey" - ARG_NAME = "repokey" - STORAGE = KeyBlobStorage.REPO - CIPHERSUITE = AES256_CTR_HMAC_SHA256 - - -class Blake2KeyfileKey(Pbkdf2FileMixin, ID_BLAKE2b_256, AESKeyBase, FlexiKey): # type: ignore[misc] +class Blake2AESCTRKey(Pbkdf2FileMixin, ID_BLAKE2b_256, AESKeyBase, FlexiKey): # type: ignore[misc] TYPES_ACCEPTABLE = {KeyType.BLAKE2KEYFILE, KeyType.BLAKE2REPO} TYPE = KeyType.BLAKE2KEYFILE - NAME = "key file BLAKE2b" - ARG_NAME = "keyfile-blake2" - STORAGE = KeyBlobStorage.KEYFILE + NAME = "AES-CTR BLAKE2b" + ARG_NAME = None # not creatable: borg 1.x compatibility (read-only) + STORAGE = KeyBlobStorage.REPO # seed default; actual per-key storage is tracked in self.storage on load + LOCATION_CONFIGURABLE = True # borg 1.x had keyfile and repokey variants CIPHERSUITE = AES256_CTR_BLAKE2b -class Blake2RepoKey(Pbkdf2FileMixin, ID_BLAKE2b_256, AESKeyBase, FlexiKey): # type: ignore[misc] - TYPES_ACCEPTABLE = {KeyType.BLAKE2KEYFILE, KeyType.BLAKE2REPO} - TYPE = KeyType.BLAKE2REPO - NAME = "repokey BLAKE2b" - ARG_NAME = "repokey-blake2" - STORAGE = KeyBlobStorage.REPO - CIPHERSUITE = AES256_CTR_BLAKE2b - - -LEGACY_KEY_TYPES = (KeyfileKey, RepoKey, Blake2KeyfileKey, Blake2RepoKey, Blake2AuthenticatedKey) +LEGACY_KEY_TYPES = (AESCTRKey, Blake2AESCTRKey, Blake2AuthenticatedKey) diff --git a/src/borg/testsuite/archiver/__init__.py b/src/borg/testsuite/archiver/__init__.py index 8b4738c2b..de59c4119 100644 --- a/src/borg/testsuite/archiver/__init__.py +++ b/src/borg/testsuite/archiver/__init__.py @@ -31,8 +31,12 @@ from .. import are_symlinks_supported, are_hardlinks_supported, are_fifos_suppor from ..platform.platform_test import is_win32 from ...xattr import get_all -RK_ENCRYPTION = "--encryption=repokey-aes-ocb" -KF_ENCRYPTION = "--encryption=keyfile-chacha20-poly1305" +# --encryption now selects only the crypto suite; key storage is chosen with --key-location +# (default: repokey). RK_* stays a single token (repokey is the default); for keyfile storage, +# pass KF_ENCRYPTION together with KF_LOCATION. +RK_ENCRYPTION = "--encryption=aes-ocb" +KF_ENCRYPTION = "--encryption=chacha20-poly1305" +KF_LOCATION = "--key-location=keyfile" # This points to the ``src/borg/archiver`` directory (small, with only a few files). # There are quite a lot of files in there, because there is a __pycache__ subdirectory. diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index c9292687c..929b96f17 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -356,7 +356,7 @@ def test_extra_chunks(archivers, request): cmd(archiver, "check", "-v", exit_code=0) # check does not deal with orphans anymore -@pytest.mark.parametrize("init_args", [["--encryption=repokey-aes-ocb"], ["--encryption", "none"]]) +@pytest.mark.parametrize("init_args", [["--encryption=aes-ocb"], ["--encryption", "none"]]) def test_verify_data(archivers, request, init_args): archiver = request.getfixturevalue(archivers) if archiver.get_kind() != "local": @@ -392,7 +392,7 @@ def test_verify_data(archivers, request, init_args): assert f"{src_file}: Missing file chunk detected" in output -@pytest.mark.parametrize("init_args", [["--encryption=repokey-aes-ocb"], ["--encryption", "none"]]) +@pytest.mark.parametrize("init_args", [["--encryption=aes-ocb"], ["--encryption", "none"]]) def test_corrupted_file_chunk(archivers, request, init_args): ## similar to test_verify_data, but here we let the low level repository-only checks discover the issue. diff --git a/src/borg/testsuite/archiver/key_cmds_test.py b/src/borg/testsuite/archiver/key_cmds_test.py index 040b7d255..cbc34a561 100644 --- a/src/borg/testsuite/archiver/key_cmds_test.py +++ b/src/borg/testsuite/archiver/key_cmds_test.py @@ -5,14 +5,23 @@ from hashlib import sha256 import pytest from ...constants import * # NOQA -from ...crypto.key import AESOCBRepoKey, AESOCBKeyfileKey, CHPOKeyfileKey, Passphrase, is_keyfile, keyfile_parse +from ...constants import KeyBlobStorage +from ...crypto.key import AESOCBKey, CHPOKey, Passphrase, is_keyfile, keyfile_parse from ...crypto.keymanager import RepoIdMismatch, NotABorgKeyFile from ...helpers import CommandError from ...helpers import bin_to_hex, hex_to_bin from ...helpers import msgpack from ...repository import Repository from ..crypto.key_test import TestKey -from . import RK_ENCRYPTION, KF_ENCRYPTION, cmd, _extract_repository_id, _set_repository_id, generate_archiver_tests +from . import ( + RK_ENCRYPTION, + KF_ENCRYPTION, + KF_LOCATION, + cmd, + _extract_repository_id, + _set_repository_id, + generate_archiver_tests, +) pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary") # NOQA @@ -34,24 +43,24 @@ def test_change_location_to_keyfile(archivers, request): assert "(repokey" in log cmd(archiver, "key", "change-location", "keyfile") log = cmd(archiver, "repo-info") - assert "(key file" in log + assert "(keyfile" in log def test_change_location_to_b3keyfile(archivers, request): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", "--encryption=repokey-blake3-aes-ocb") + cmd(archiver, "repo-create", "--encryption=blake3-aes-ocb") log = cmd(archiver, "repo-info") - assert "(repokey BLAKE3" in log + assert "(repokey, BLAKE3" in log cmd(archiver, "key", "change-location", "keyfile") log = cmd(archiver, "repo-info") - assert "(key file BLAKE3" in log + assert "(keyfile, BLAKE3" in log def test_change_location_to_repokey(archivers, request): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) log = cmd(archiver, "repo-info") - assert "(key file" in log + assert "(keyfile" in log cmd(archiver, "key", "change-location", "repokey") log = cmd(archiver, "repo-info") assert "(repokey" in log @@ -59,17 +68,17 @@ def test_change_location_to_repokey(archivers, request): def test_change_location_to_b3repokey(archivers, request): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", "--encryption=keyfile-blake3-aes-ocb") + cmd(archiver, "repo-create", "--encryption=blake3-aes-ocb", KF_LOCATION) log = cmd(archiver, "repo-info") - assert "(key file BLAKE3" in log + assert "(keyfile, BLAKE3" in log cmd(archiver, "key", "change-location", "repokey") log = cmd(archiver, "repo-info") - assert "(repokey BLAKE3" in log + assert "(repokey, BLAKE3" in log def test_keyfile_name_is_content_sha256(archivers, request): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) [key_filename] = os.listdir(archiver.keys_path) key_path = os.path.join(archiver.keys_path, key_filename) with open(key_path, "rb") as fd: @@ -79,7 +88,7 @@ def test_keyfile_name_is_content_sha256(archivers, request): def test_change_passphrase_renames_keyfile_to_new_sha256(archivers, request): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) [old_key_filename] = os.listdir(archiver.keys_path) old_key_path = os.path.join(archiver.keys_path, old_key_filename) os.environ["BORG_NEW_PASSPHRASE"] = "newpassphrase" @@ -99,7 +108,7 @@ def test_borg_key_file_env_keeps_explicit_path(archivers, request, monkeypatch): archiver = request.getfixturevalue(archivers) explicit_key_path = os.path.join(archiver.output_path, "explicit-key") monkeypatch.setenv("BORG_KEY_FILE", explicit_key_path) - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) assert os.path.isfile(explicit_key_path) assert os.listdir(archiver.keys_path) == [] @@ -107,7 +116,7 @@ def test_borg_key_file_env_keeps_explicit_path(archivers, request, monkeypatch): def test_key_export_keyfile(archivers, request): archiver = request.getfixturevalue(archivers) export_file = archiver.output_path + "/exported" - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) repo_id = _extract_repository_id(archiver.repository_path) cmd(archiver, "key", "export", export_file) @@ -125,7 +134,7 @@ def test_key_export_keyfile(archivers, request): os.unlink(key_file) - cmd(archiver, "key", "import", export_file) + cmd(archiver, "key", "import", export_file, "--key-location=keyfile") with open(key_file) as fd: key_contents2 = fd.read() @@ -135,7 +144,7 @@ def test_key_export_keyfile(archivers, request): def test_key_import_keyfile_with_borg_key_file(archivers, request, monkeypatch): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) exported_key_file = os.path.join(archiver.output_path, "exported") cmd(archiver, "key", "export", exported_key_file) @@ -147,7 +156,7 @@ def test_key_import_keyfile_with_borg_key_file(archivers, request, monkeypatch): imported_key_file = os.path.join(archiver.output_path, "imported") monkeypatch.setenv("BORG_KEY_FILE", imported_key_file) - cmd(archiver, "key", "import", exported_key_file) + cmd(archiver, "key", "import", exported_key_file, "--key-location=keyfile") assert not os.path.isfile(key_file), '"borg key import" should respect BORG_KEY_FILE' with open(imported_key_file) as fd: @@ -168,10 +177,11 @@ def test_key_export_repokey(archivers, request): assert is_keyfile(export_contents, bin_to_hex(repo_id)) with Repository(archiver.repository_path) as repository: - repo_key = AESOCBRepoKey(repository) + repo_key = AESOCBKey(repository) # default storage (repokey): load_any finds the repo's key repo_key.load(None, Passphrase.env_passphrase()) - backup_key = AESOCBKeyfileKey(TestKey.MockRepository(id=repo_id)) + backup_key = AESOCBKey(TestKey.MockRepository(id=repo_id)) + backup_key.storage = KeyBlobStorage.KEYFILE # load explicitly from the exported keyfile backup_key.load(export_file, Passphrase.env_passphrase()) assert repo_key.crypt_key == backup_key.crypt_key @@ -182,7 +192,7 @@ def test_key_export_repokey(archivers, request): cmd(archiver, "key", "import", export_file) with Repository(archiver.repository_path) as repository: - repo_key2 = AESOCBRepoKey(repository) + repo_key2 = AESOCBKey(repository) repo_key2.load(None, Passphrase.env_passphrase()) assert repo_key2.crypt_key == repo_key.crypt_key @@ -232,7 +242,7 @@ def test_key_export_qr_directory(archivers, request): def test_key_import_errors(archivers, request): archiver = request.getfixturevalue(archivers) export_file = archiver.output_path + "/exported" - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) if archiver.FORK_DEFAULT: expected_ec = CommandError().exit_code cmd(archiver, "key", "import", export_file, exit_code=expected_ec) @@ -265,12 +275,12 @@ def test_key_export_paperkey(archivers, request): archiver = request.getfixturevalue(archivers) repo_id = "e294423506da4e1ea76e8dcdf1a3919624ae3ae496fddf905610c351d3f09239" export_file = archiver.output_path + "/exported" - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) _set_repository_id(archiver.repository_path, hex_to_bin(repo_id)) key_file = archiver.keys_path + "/" + os.listdir(archiver.keys_path)[0] with open(key_file, "w") as fd: - fd.write(CHPOKeyfileKey.FILE_ID + " " + repo_id + "\n") + fd.write(CHPOKey.FILE_ID + " " + repo_id + "\n") fd.write(binascii.b2a_base64(b"abcdefghijklmnopqrstu").decode()) cmd(archiver, "key", "export", "--paper", export_file) @@ -293,12 +303,12 @@ id: 2 / e29442 3506da 4e1ea7 / 25f62a 5a3d41 - 02 def test_key_import_paperkey(archivers, request): archiver = request.getfixturevalue(archivers) repo_id = "e294423506da4e1ea76e8dcdf1a3919624ae3ae496fddf905610c351d3f09239" - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) _set_repository_id(archiver.repository_path, hex_to_bin(repo_id)) key_file = archiver.keys_path + "/" + os.listdir(archiver.keys_path)[0] with open(key_file, "w") as fd: - fd.write(AESOCBKeyfileKey.FILE_ID + " " + repo_id + "\n") + fd.write(AESOCBKey.FILE_ID + " " + repo_id + "\n") fd.write(binascii.b2a_base64(b"abcdefghijklmnopqrstu").decode()) typed_input = ( @@ -362,7 +372,7 @@ def test_change_passphrase_does_not_change_algorithm_argon2(archivers, request): def test_change_location_does_not_change_algorithm_argon2(archivers, request): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) cmd(archiver, "key", "change-location", "repokey") with Repository(archiver.repository_path) as repository: @@ -408,23 +418,27 @@ def _key_id_for_label(archiver, label): raise AssertionError(f"label {label!r} not found in:\n{out}") -@pytest.mark.parametrize("encryption", [RK_ENCRYPTION, KF_ENCRYPTION]) -def test_key_first_key_is_admin(archivers, request, encryption): +# crypto suite + key storage combinations used to parametrize the multi-key tests below. +ENC_ARGS_AND_MODE = [((RK_ENCRYPTION,), "repokey"), ((KF_ENCRYPTION, KF_LOCATION), "keyfile")] +ENC_ARGS = [args for args, _mode in ENC_ARGS_AND_MODE] + + +@pytest.mark.parametrize("enc_args, mode", ENC_ARGS_AND_MODE) +def test_key_first_key_is_admin(archivers, request, enc_args, mode): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", encryption) + cmd(archiver, "repo-create", *enc_args) out = cmd(archiver, "key", "list") assert "admin" in out - mode = "keyfile" if encryption == KF_ENCRYPTION else "repokey" rows = [ln for ln in out.splitlines() if "argon2" in ln] assert len(rows) == 1 assert rows[0].lstrip().startswith("*") assert mode in rows[0] -@pytest.mark.parametrize("encryption", [RK_ENCRYPTION, KF_ENCRYPTION]) -def test_key_add(archivers, request, encryption): +@pytest.mark.parametrize("enc_args", ENC_ARGS) +def test_key_add(archivers, request, enc_args): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", encryption) # admin = DEFAULT_PASSPHRASE + cmd(archiver, "repo-create", *enc_args) # admin = DEFAULT_PASSPHRASE os.environ["BORG_NEW_PASSPHRASE"] = "alicepass" cmd(archiver, "key", "add", "--label", "alice") @@ -450,10 +464,10 @@ def test_key_add_rejects_duplicate_and_reserved(archivers, request): _expect_error(archiver, "key", "add", "--label", "admin") # reserved -@pytest.mark.parametrize("encryption", [RK_ENCRYPTION, KF_ENCRYPTION]) -def test_key_remove_by_label(archivers, request, encryption): +@pytest.mark.parametrize("enc_args", ENC_ARGS) +def test_key_remove_by_label(archivers, request, enc_args): archiver = request.getfixturevalue(archivers) - cmd(archiver, "repo-create", encryption) + cmd(archiver, "repo-create", *enc_args) os.environ["BORG_NEW_PASSPHRASE"] = "alicepass" cmd(archiver, "key", "add", "--label", "alice") @@ -577,10 +591,12 @@ def test_key_change_location_keeps_label(archivers, request): cmd(archiver, "key", "change-location", "keyfile") out = cmd(archiver, "key", "list") # still unlocked as xxx - rows = [ln for ln in out.splitlines() if "argon2" in ln] - assert len(rows) == 1 - assert "keyfile" in rows[0] - assert "xxx" in rows[0] # label preserved, not lost + # the migrated xxx key now lives in a keyfile; admin stays a repokey (mixed storage is allowed now) + xxx_rows = [ln for ln in out.splitlines() if "xxx" in ln] + assert len(xxx_rows) == 1 + assert "keyfile" in xxx_rows[0] # storage changed + assert "xxx" in xxx_rows[0] # label preserved, not lost + assert "admin" in out # the other borg key is untouched def _exported_label(path, repo_id): @@ -649,7 +665,7 @@ def test_key_export_rejects_ambiguous_key_selector(archivers, request): def _store_corrupted_borg_key(repository_path, repo_id): """Store a borg key whose envelope is unparseable but which still passes the keyfile header / repo-id check (so it is enumerated). Returns its key id.""" - header = CHPOKeyfileKey.FILE_ID + " " + bin_to_hex(repo_id) + "\n" + header = CHPOKey.FILE_ID + " " + bin_to_hex(repo_id) + "\n" body = binascii.b2a_base64(b"this is not a valid key envelope").decode() # valid base64, not msgpack blob = (header + body).encode("utf-8") with Repository(repository_path) as repository: diff --git a/src/borg/testsuite/archiver/repo_create_cmd_test.py b/src/borg/testsuite/archiver/repo_create_cmd_test.py index a0d67b358..04446b678 100644 --- a/src/borg/testsuite/archiver/repo_create_cmd_test.py +++ b/src/borg/testsuite/archiver/repo_create_cmd_test.py @@ -6,7 +6,7 @@ import pytest from ...helpers.errors import Error, CancelledByUser from ...constants import * # NOQA from ...crypto.key import FlexiKey -from . import cmd, generate_archiver_tests, RK_ENCRYPTION, KF_ENCRYPTION +from . import cmd, generate_archiver_tests, RK_ENCRYPTION, KF_ENCRYPTION, KF_LOCATION pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary") # NOQA @@ -42,11 +42,11 @@ def test_repo_create_refuse_to_overwrite_keyfile(archivers, request, monkeypatch monkeypatch.setenv("BORG_KEY_FILE", keyfile) original_location = archiver.repository_location archiver.repository_location = original_location + "0" - cmd(archiver, "repo-create", KF_ENCRYPTION) + cmd(archiver, "repo-create", KF_ENCRYPTION, KF_LOCATION) with open(keyfile) as file: before = file.read() archiver.repository_location = original_location + "1" - arg = ("repo-create", KF_ENCRYPTION) + arg = ("repo-create", KF_ENCRYPTION, KF_LOCATION) if archiver.FORK_DEFAULT: cmd(archiver, *arg, exit_code=2) else: diff --git a/src/borg/testsuite/benchmark_test.py b/src/borg/testsuite/benchmark_test.py index 5d49d1e75..c0423c62b 100644 --- a/src/borg/testsuite/benchmark_test.py +++ b/src/borg/testsuite/benchmark_test.py @@ -27,7 +27,7 @@ def repo_url(request, tmpdir, monkeypatch): tmpdir.remove(rec=1) -@pytest.fixture(params=["none", "repokey-aes-ocb"]) +@pytest.fixture(params=["none", "aes-ocb"]) def repo(request, cmd_fixture, repo_url): cmd_fixture(f"--repo={repo_url}", "repo-create", "--encryption", request.param) return repo_url diff --git a/src/borg/testsuite/cache_test.py b/src/borg/testsuite/cache_test.py index b50516f73..1fdbfad83 100644 --- a/src/borg/testsuite/cache_test.py +++ b/src/borg/testsuite/cache_test.py @@ -7,7 +7,7 @@ from .hashindex_test import H from .crypto.key_test import TestKey from ..archive import Statistics from ..cache import AdHocWithFilesCache, FileCacheEntry, delete_chunkindex_cache, read_chunkindex_from_repo_cache -from ..crypto.key import AESOCBRepoKey +from ..crypto.key import AESOCBKey from ..helpers import safe_ns from ..helpers.msgpack import int_to_timestamp from ..manifest import Manifest @@ -25,7 +25,7 @@ class TestAdHocWithFilesCache: @pytest.fixture def key(self, repository, monkeypatch): monkeypatch.setenv("BORG_PASSPHRASE", "test") - key = AESOCBRepoKey.create(repository, TestKey.MockArgs()) + key = AESOCBKey.create(repository, TestKey.MockArgs()) return key @pytest.fixture diff --git a/src/borg/testsuite/crypto/crypto_test.py b/src/borg/testsuite/crypto/crypto_test.py index 2cf054a2c..c25a60eda 100644 --- a/src/borg/testsuite/crypto/crypto_test.py +++ b/src/borg/testsuite/crypto/crypto_test.py @@ -9,8 +9,8 @@ from ...crypto.low_level import bytes_to_long, bytes_to_int, long_to_bytes from ...crypto.low_level import hmac_sha256 from ...legacy.crypto.low_level import AES from hashlib import sha256 -from ...crypto.key import CHPOKeyfileKey, AESOCBRepoKey, KeyBase, PlaintextKey -from ...legacy.crypto.key import KeyfileKey as LegacyKeyfileKey +from ...crypto.key import CHPOKey, AESOCBKey, KeyBase, PlaintextKey +from ...legacy.crypto.key import AESCTRKey as LegacyAESCTRKey from ...helpers import msgpack, bin_to_hex from .. import BaseTestCase @@ -217,7 +217,7 @@ def test_decrypt_key_file_argon2_chacha20_poly1305(): "data": envelope, } ) - key = CHPOKeyfileKey(None) + key = CHPOKey(None) decrypted = key.decrypt_key_file(encrypted, "hello, pass phrase") @@ -228,13 +228,13 @@ def test_decrypt_key_file_pbkdf2_sha256_aes256_ctr_hmac_sha256(): plain = b"hello" salt = b"salt" * 4 passphrase = "hello, pass phrase" - key = LegacyKeyfileKey.pbkdf2(passphrase, salt, 1, 32) + key = LegacyAESCTRKey.pbkdf2(passphrase, salt, 1, 32) hash = hmac_sha256(key, plain) data = AES(key, b"\0" * 16).encrypt(plain) encrypted = msgpack.packb( {"version": 1, "algorithm": "sha256", "iterations": 1, "salt": salt, "data": data, "hash": hash} ) - key = LegacyKeyfileKey(None) + key = LegacyAESCTRKey(None) decrypted = key.decrypt_key_file(encrypted, passphrase) @@ -275,11 +275,11 @@ def test_repo_key_detect_does_not_raise_integrity_error(getpass, monkeypatch): repository = MagicMock(id=b"repository_id") getpass.return_value = "hello, pass phrase" monkeypatch.setenv("BORG_DISPLAY_PASSPHRASE", "no") - AESOCBRepoKey.create(repository, args=MagicMock(key_algorithm="argon2")) + AESOCBKey.create(repository, args=MagicMock(key_algorithm="argon2")) saved = repository.store_key.call_args.args[0] repository.load_keys.return_value = [("key0", saved)] - AESOCBRepoKey.detect(repository, manifest_data=None) + AESOCBKey.detect(repository, manifest_data=None) class TestDeriveKey(BaseTestCase): diff --git a/src/borg/testsuite/crypto/key_test.py b/src/borg/testsuite/crypto/key_test.py index 86bf96400..4eb742b29 100644 --- a/src/borg/testsuite/crypto/key_test.py +++ b/src/borg/testsuite/crypto/key_test.py @@ -6,10 +6,9 @@ from unittest.mock import MagicMock import pytest from ...crypto.key import PlaintextKey, AuthenticatedKey, Blake2AuthenticatedKey, keyfile_parse -from ...crypto.key import RepoKey, KeyfileKey, Blake2RepoKey, Blake2KeyfileKey +from ...crypto.key import AESCTRKey, Blake2AESCTRKey from ...crypto.key import AEADKeyBase -from ...crypto.key import AESOCBRepoKey, AESOCBKeyfileKey, CHPORepoKey, CHPOKeyfileKey -from ...crypto.key import Blake3AESOCBRepoKey, Blake3AESOCBKeyfileKey, Blake3CHPORepoKey, Blake3CHPOKeyfileKey +from ...crypto.key import AESOCBKey, CHPOKey, Blake3AESOCBKey, Blake3CHPOKey from ...crypto.key import Blake3AuthenticatedKey from ...crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256, ID_BLAKE3_256 from ...crypto.key import UnsupportedManifestError, UnsupportedKeyFormatError @@ -26,6 +25,14 @@ class TestKey: class MockArgs: location = Location(tempfile.mkstemp()[1]) key_algorithm = "argon2" + key_location = "repokey" # default storage; tests that want a keyfile use kf_args() below + + @classmethod + def kf_args(cls): + # like MockArgs(), but selects keyfile storage (the unified key classes default to repokey). + args = cls.MockArgs() + args.key_location = "keyfile" + return args keyfile2_key_file = """ BORG_KEY 0000000000000000000000000000000000000000000000000000000000000000 @@ -75,25 +82,21 @@ class TestKey: @pytest.fixture( params=( + # keyfile and repokey are no longer separate classes (storage is a per-key property), + # so each crypto suite appears once here. # not encrypted PlaintextKey, AuthenticatedKey, Blake3AuthenticatedKey, # legacy crypto - KeyfileKey, - Blake2KeyfileKey, - RepoKey, - Blake2RepoKey, + AESCTRKey, + Blake2AESCTRKey, Blake2AuthenticatedKey, # new crypto - AESOCBKeyfileKey, - AESOCBRepoKey, - Blake3AESOCBKeyfileKey, - Blake3AESOCBRepoKey, - CHPOKeyfileKey, - CHPORepoKey, - Blake3CHPOKeyfileKey, - Blake3CHPORepoKey, + AESOCBKey, + Blake3AESOCBKey, + CHPOKey, + Blake3CHPOKey, ) ) def key(self, request, monkeypatch): @@ -118,7 +121,9 @@ class TestKey: self.key_data = data def load_key(self): - return self.key_data + # mirror a real repository: no repokey stored yet -> empty bytes (not an error). Detection + # is storage-agnostic now and always probes repo candidates, even for keyfile keys. + return getattr(self, "key_data", b"") def test_plaintext(self): key = PlaintextKey.create(None, None) @@ -129,7 +134,7 @@ class TestKey: def test_keyfile(self, monkeypatch, keys_dir): monkeypatch.setenv("BORG_PASSPHRASE", "test") - key = KeyfileKey.create(self.MockRepository(), self.MockArgs()) + key = AESCTRKey.create(self.MockRepository(), self.kf_args()) assert key.cipher.next_iv() == 0 chunk = b"ABC" id = key.id_hash(chunk) @@ -140,8 +145,8 @@ class TestKey: assert key.decrypt(id, manifest) == key.decrypt(id, manifest2) assert key.cipher.extract_iv(manifest2) == 1 iv = key.cipher.extract_iv(manifest) - key2 = KeyfileKey.detect(self.MockRepository(), manifest) - assert key2.cipher.next_iv() >= iv + key2.cipher.block_count(len(manifest) - KeyfileKey.PAYLOAD_OVERHEAD) + key2 = AESCTRKey.detect(self.MockRepository(), manifest) + assert key2.cipher.next_iv() >= iv + key2.cipher.block_count(len(manifest) - AESCTRKey.PAYLOAD_OVERHEAD) # Key data sanity check assert len({key2.id_key, key2.crypt_key}) == 2 assert key2.chunk_seed != 0 @@ -154,22 +159,22 @@ class TestKey: monkeypatch.setenv("BORG_KEY_FILE", str(keyfile)) monkeypatch.setenv("BORG_PASSPHRASE", "testkf") assert not keyfile.exists() - key = CHPOKeyfileKey.create(self.MockRepository(), self.MockArgs()) + key = CHPOKey.create(self.MockRepository(), self.kf_args()) assert keyfile.exists() chunk = b"ABC" chunk_id = key.id_hash(chunk) chunk_cdata = key.encrypt(chunk_id, chunk) - key = CHPOKeyfileKey.detect(self.MockRepository(), chunk_cdata) + key = CHPOKey.detect(self.MockRepository(), chunk_cdata) assert chunk == key.decrypt(chunk_id, chunk_cdata) keyfile.remove() with pytest.raises(FileNotFoundError): - CHPOKeyfileKey.detect(self.MockRepository(), chunk_cdata) + CHPOKey.detect(self.MockRepository(), chunk_cdata) def test_keyfile2(self, monkeypatch, keys_dir): with keys_dir.join("keyfile").open("w") as fd: fd.write(self.keyfile2_key_file) monkeypatch.setenv("BORG_PASSPHRASE", "passphrase") - key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata) + key = AESCTRKey.detect(self.MockRepository(), self.keyfile2_cdata) assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata) == b"payload" def test_keyfile2_kfenv(self, tmpdir, monkeypatch): @@ -178,23 +183,23 @@ class TestKey: fd.write(self.keyfile2_key_file) monkeypatch.setenv("BORG_KEY_FILE", str(keyfile)) monkeypatch.setenv("BORG_PASSPHRASE", "passphrase") - key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata) + key = AESCTRKey.detect(self.MockRepository(), self.keyfile2_cdata) assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata) == b"payload" def test_keyfile_blake2(self, monkeypatch, keys_dir): with keys_dir.join("keyfile").open("w") as fd: fd.write(self.keyfile_blake2_key_file) monkeypatch.setenv("BORG_PASSPHRASE", "passphrase") - key = Blake2KeyfileKey.detect(self.MockRepository(), self.keyfile_blake2_cdata) + key = Blake2AESCTRKey.detect(self.MockRepository(), self.keyfile_blake2_cdata) assert key.decrypt(self.keyfile_blake2_id, self.keyfile_blake2_cdata) == b"payload" def test_legacy_named_keyfile_still_loads(self, monkeypatch, keys_dir): monkeypatch.setenv("BORG_PASSPHRASE", "test") - key = CHPOKeyfileKey.create(self.MockRepository(), self.MockArgs()) + key = CHPOKey.create(self.MockRepository(), self.kf_args()) hashed_keyfile = key.target legacy_keyfile = str(keys_dir.join("legacy-name")) os.replace(hashed_keyfile, legacy_keyfile) - key2 = CHPOKeyfileKey.detect(self.MockRepository(), key.encrypt(b"", b"payload")) + key2 = CHPOKey.detect(self.MockRepository(), key.encrypt(b"", b"payload")) assert key2.target == legacy_keyfile def _corrupt_byte(self, key, data, offset): @@ -209,7 +214,7 @@ class TestKey: with keys_dir.join("keyfile").open("w") as fd: fd.write(self.keyfile2_key_file) monkeypatch.setenv("BORG_PASSPHRASE", "passphrase") - key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata) + key = AESCTRKey.detect(self.MockRepository(), self.keyfile2_cdata) data = self.keyfile2_cdata for i in range(len(data)): @@ -284,7 +289,7 @@ class TestTAM: @pytest.fixture def key(self, monkeypatch): monkeypatch.setenv("BORG_PASSPHRASE", "test") - return CHPOKeyfileKey.create(TestKey.MockRepository(), TestKey.MockArgs()) + return CHPOKey.create(TestKey.MockRepository(), TestKey.MockArgs()) def test_unpack_future(self, key): blob = b"\xc1\xc1\xc1\xc1foobar" @@ -312,7 +317,7 @@ class TestTAM: def test_decrypt_key_file_unsupported_algorithm(): """We will add more algorithms in the future. We should raise a helpful error.""" - key = CHPOKeyfileKey(None) + key = CHPOKey(None) encrypted = msgpack.packb({"algorithm": "THIS ALGORITHM IS NOT SUPPORTED", "version": 1}) with pytest.raises(UnsupportedKeyFormatError): @@ -321,7 +326,7 @@ def test_decrypt_key_file_unsupported_algorithm(): def test_decrypt_key_file_v2_is_unsupported(): """There may eventually be a version 2 of the format. For now we should raise a helpful error.""" - key = CHPOKeyfileKey(None) + key = CHPOKey(None) encrypted = msgpack.packb({"version": 2}) with pytest.raises(UnsupportedKeyFormatError): @@ -336,10 +341,10 @@ def test_key_file_roundtrip(monkeypatch): repository = MagicMock(id=b"repository_id") monkeypatch.setenv("BORG_PASSPHRASE", "hello, pass phrase") - save_me = AESOCBRepoKey.create(repository, args=MagicMock(key_algorithm="argon2")) + save_me = AESOCBKey.create(repository, args=MagicMock(key_algorithm="argon2")) saved = repository.store_key.call_args.args[0] repository.load_keys.return_value = [("key0", saved)] - load_me = AESOCBRepoKey.detect(repository, manifest_data=None) + load_me = AESOCBKey.detect(repository, manifest_data=None) assert to_dict(load_me) == to_dict(save_me) _, saved_b64 = keyfile_parse(saved) @@ -351,7 +356,7 @@ def test_argon2_wrong_passphrase_returns_none(monkeypatch): # decrypt_key_file signals this by returning None, not by raising (refs #8036) repository = MagicMock(id=b"repository_id") monkeypatch.setenv("BORG_PASSPHRASE", "correct passphrase") - key = AESOCBRepoKey.create(repository, args=MagicMock(key_algorithm="argon2")) + key = AESOCBKey.create(repository, args=MagicMock(key_algorithm="argon2")) saved = repository.store_key.call_args.args[0] _, saved_b64 = keyfile_parse(saved) assert key.decrypt_key_file(a2b_base64(saved_b64), "wrong passphrase") is None diff --git a/src/borg/testsuite/crypto/legacy_key_test.py b/src/borg/testsuite/crypto/legacy_key_test.py index 199be06b1..f970890de 100644 --- a/src/borg/testsuite/crypto/legacy_key_test.py +++ b/src/borg/testsuite/crypto/legacy_key_test.py @@ -4,7 +4,7 @@ import pytest from ...crypto.key import UnsupportedKeyFormatError from ...helpers import msgpack -from ...legacy.crypto.key import KeyfileKey as LegacyKeyfileKey +from ...legacy.crypto.key import AESCTRKey as LegacyAESCTRKey # ── Pbkdf2FileMixin ─────────────────────────────────────────────────────────── @@ -13,7 +13,7 @@ from ...legacy.crypto.key import KeyfileKey as LegacyKeyfileKey def test_pbkdf2_encrypt_decrypt_roundtrip(): # encrypt_key_file dispatches to encrypt_key_file_pbkdf2; decrypt_key_file # dispatches back — the round-trip must recover the original plaintext - key_obj = LegacyKeyfileKey(None) + key_obj = LegacyAESCTRKey(None) plaintext = b"secret key material" blob = key_obj.encrypt_key_file(plaintext, "correct passphrase", "sha256") assert key_obj.decrypt_key_file(blob, "correct passphrase") == plaintext @@ -22,7 +22,7 @@ def test_pbkdf2_encrypt_decrypt_roundtrip(): def test_pbkdf2_wrong_passphrase_returns_none(): # a wrong passphrase derives a different key, so the HMAC check fails; # decrypt_key_file signals this by returning None, not by raising - key_obj = LegacyKeyfileKey(None) + key_obj = LegacyAESCTRKey(None) blob = key_obj.encrypt_key_file(b"secret key material", "correct passphrase", "sha256") assert key_obj.decrypt_key_file(blob, "wrong passphrase") is None @@ -31,4 +31,4 @@ def test_pbkdf2_unsupported_version_raises(): # only version 1 is defined in the borg 1.x format; anything else must raise blob = msgpack.packb({"version": 99}) with pytest.raises(UnsupportedKeyFormatError): - LegacyKeyfileKey(None).decrypt_key_file(blob, "pass") + LegacyAESCTRKey(None).decrypt_key_file(blob, "pass")