From e86bae79c2f535132531aed07a1e393a7a1cc71e Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 6 Jun 2025 19:56:10 +0200 Subject: [PATCH] key: add derive_key to derive new keys from existing key material. Just a slight refactor of existing code to make it more useful for other key-generation purposes. --- src/borg/crypto/key.py | 28 +++++++++++++-- src/borg/testsuite/crypto_test.py | 58 ++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 8c0df8a93..454f6449a 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -160,6 +160,12 @@ class KeyBase: # type is int chunk_seed: int = None + # crypt_key dummy, needs to be overwritten by subclass + crypt_key: bytes = None + + # id_key dummy, needs to be overwritten by subclass + id_key: bytes = None + # Whether this *particular instance* is encrypted from a practical point of view, # i.e. when it's using encryption with a empty passphrase, then # that may be *technically* called encryption, but for all intents and purposes @@ -196,6 +202,21 @@ class KeyBase: id_str = bin_to_hex(id) if id is not None else "(unknown)" raise IntegrityError(f"Chunk {id_str}: Invalid encryption envelope") + def derive_key(self, *, salt, domain, size, from_id_key=False): + """ + create a new crypto key ( bytes long) from existing key material, a given salt and domain. + from_id_key == False: derive from self.crypt_key (default) + from_id_key == True: derive from self.id_key (note: related repos have same ID key) + """ + from_key = self.id_key if from_id_key else self.crypt_key + assert isinstance(from_key, bytes) + assert isinstance(salt, bytes) + assert isinstance(domain, bytes) + assert size <= 32 # sha256 gives us 32 bytes + # Because crypt_key is already a PRK, we do not need KDF security here, PRF security is good enough. + # See https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Cr2.pdf section 4 "one-step KDF". + return sha256(from_key + salt + domain).digest()[:size] + def pack_metadata(self, metadata_dict): metadata_dict = StableDict(metadata_dict) return msgpack.packb(metadata_dict) @@ -229,6 +250,9 @@ class PlaintextKey(KeyBase): ARG_NAME = "none" chunk_seed = 0 + crypt_key = b"" # makes .derive_key() work, nothing secret here + id_key = b"" # makes .derive_key() work, nothing secret here + logically_encrypted = False @classmethod @@ -891,9 +915,7 @@ class AEADKeyBase(KeyBase): assert len(sessionid) == 24 # 192bit if domain is None: domain = b"borg-session-key-" + self.CIPHERSUITE.__name__.encode() - # Because crypt_key is already a PRK, we do not need KDF security here, PRF security is good enough. - # See https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Cr2.pdf section 4 "one-step KDF". - return sha256(self.crypt_key + sessionid + domain).digest() + return self.derive_key(salt=sessionid, domain=domain, size=32) # 256bit def _get_cipher(self, sessionid, iv): assert isinstance(iv, int) diff --git a/src/borg/testsuite/crypto_test.py b/src/borg/testsuite/crypto_test.py index 5f75089fd..7c6ba0b22 100644 --- a/src/borg/testsuite/crypto_test.py +++ b/src/borg/testsuite/crypto_test.py @@ -7,7 +7,8 @@ import unittest from ..crypto.low_level import AES256_CTR_HMAC_SHA256, AES256_OCB, CHACHA20_POLY1305, UNENCRYPTED, IntegrityError from ..crypto.low_level import bytes_to_long, bytes_to_int, long_to_bytes from ..crypto.low_level import AES, hmac_sha256 -from ..crypto.key import CHPOKeyfileKey, AESOCBRepoKey, FlexiKey +from hashlib import sha256 +from ..crypto.key import CHPOKeyfileKey, AESOCBRepoKey, FlexiKey, KeyBase, PlaintextKey from ..helpers import msgpack, bin_to_hex from . import BaseTestCase @@ -276,3 +277,58 @@ def test_repo_key_detect_does_not_raise_integrity_error(getpass, monkeypatch): repository.load_key.return_value = repository.save_key.call_args.args[0] AESOCBRepoKey.detect(repository, manifest_data=None) + + +class TestDeriveKey(BaseTestCase): + # Create a simple KeyBase subclass with a non-empty crypt_key + class CustomKey(KeyBase): + def __init__(self, crypt_key, id_key): + self.crypt_key = crypt_key + self.id_key = id_key + + def test_derive_key_with_plaintext_key(self): + """Test derive_key with PlaintextKey (empty crypt_key)""" + key = PlaintextKey(None) + salt, domain, size = b"salt", b"domain", 16 + + # PlaintextKey has an empty crypt_key, so the derived key should be based on salt and domain only + derived_key = key.derive_key(salt=salt, domain=domain, size=size) + expected = sha256(b"" + salt + domain).digest()[:size] + self.assert_equal(derived_key, expected) + + def test_derive_key_with_custom_key(self): + """Test derive_key with a custom KeyBase subclass (non-empty crypt_key)""" + crypt_key, id_key = b"test_crypt_key", b"test_id_key" + key = self.CustomKey(crypt_key, id_key) + salt, domain, size = b"salt", b"domain", 32 + + # derived key size and value as expected + expected = sha256(crypt_key + salt + domain).digest()[:size] + derived_key = key.derive_key(salt=salt, domain=domain, size=size) + self.assert_equal(derived_key, expected) + + # domain separation + derived_key = key.derive_key(salt=salt, domain=b"other_domain", size=size) + assert derived_key != expected + assert len(derived_key) == size + + # salt separation + derived_key = key.derive_key(salt=b"other salt", domain=domain, size=size) + assert derived_key != expected + assert len(derived_key) == size + + def test_derive_key_from_different_keys(self): + """Test derive_key with different key material""" + crypt_key, id_key = b"test_crypt_key", b"test_id_key" + key = self.CustomKey(crypt_key, id_key) + salt, domain, size = b"salt", b"domain", 32 + + # derived key size and value as expected (using the ID key) + expected = sha256(id_key + salt + domain).digest()[:size] + derived_key = key.derive_key(salt=salt, domain=domain, size=size, from_id_key=True) + self.assert_equal(derived_key, expected) + + # generating different keys from crypt_key and id_key + derived_key_from_id = key.derive_key(salt=salt, domain=domain, size=size, from_id_key=True) + derived_key_from_crypt = key.derive_key(salt=salt, domain=domain, size=size, from_id_key=False) + assert derived_key_from_id != derived_key_from_crypt