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.
This commit is contained in:
Thomas Waldmann 2025-06-06 19:56:10 +02:00
parent fb527051cb
commit e86bae79c2
No known key found for this signature in database
GPG key ID: 243ACFA951F78E01
2 changed files with 82 additions and 4 deletions

View file

@ -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 (<size> 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)

View file

@ -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