Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

crypto: use a one-step kdf for session keys, fixes #7953 #7955

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/internals/encryption-aead.odg
Binary file not shown.
Binary file modified docs/internals/encryption-aead.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 7 additions & 10 deletions docs/internals/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,17 +124,17 @@ The chunk ID is derived via a MAC over the plaintext (mac key taken from borg ke
For each borg invocation, a new session id is generated by `os.urandom`_.

From that session id, the initial key material (ikm, taken from the borg key)
and an application and cipher specific salt, borg derives a session key via HKDF.
and an application and cipher specific salt, borg derives a session key using a
"one-step KDF" based on just sha256.

For each session key, IVs (nonces) are generated by a counter which increments for
each encrypted message.

Session::

sessionid = os.urandom(24)
ikm = crypt_key
salt = "borg-session-key-CIPHERNAME"
sessionkey = HKDF(ikm, sessionid, salt)
domain = "borg-session-key-CIPHERNAME"
sessionkey = sha256(crypt_key + sessionid + domain)
message_iv = 0

Encryption::
Expand All @@ -155,7 +155,9 @@ Decryption::

ASSERT(type-byte is correct)

past_key = HKDF(ikm, past_sessionid, salt)
domain = "borg-session-key-CIPHERNAME"
past_key = sha256(crypt_key + past_sessionid + domain)

decrypted = AEAD_decrypt(past_key, past_message_iv, authenticated)

decompressed = decompress(decrypted)
Expand Down Expand Up @@ -229,12 +231,7 @@ on widely used libraries providing them:
- HMAC and a constant-time comparison from Python's hmac_ standard library module are used.
- argon2 is used via argon2-cffi.

Implemented cryptographic constructions are:

- HKDF_-SHA-512 (using ``hmac.digest`` from Python's hmac_ standard library module)

.. _Horton principle: https://en.wikipedia.org/wiki/Horton_Principle
.. _HKDF: https://tools.ietf.org/html/rfc5869
.. _length extension: https://en.wikipedia.org/wiki/Length_extension_attack
.. _hashlib: https://docs.python.org/3/library/hashlib.html
.. _hmac: https://docs.python.org/3/library/hmac.html
Expand Down
27 changes: 17 additions & 10 deletions src/borg/crypto/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from ..repoobj import RepoObj


from .low_level import AES, bytes_to_int, num_cipher_blocks, hmac_sha256, blake2b_256, hkdf_hmac_sha512
from .low_level import AES, bytes_to_int, num_cipher_blocks, hmac_sha256, blake2b_256
from .low_level import AES256_CTR_HMAC_SHA256, AES256_CTR_BLAKE2b, AES256_OCB, CHACHA20_POLY1305
from . import low_level

Expand Down Expand Up @@ -833,7 +833,7 @@ def decrypt(self, id, data):
# to decrypt existing data, we need to get a cipher configured for the sessionid and iv from header
self.assert_type(data[0], id)
iv_48bit = data[2:8]
sessionid = data[8:32]
sessionid = bytes(data[8:32])
iv = int.from_bytes(iv_48bit, "big")
cipher = self._get_cipher(sessionid, iv)
try:
Expand All @@ -857,15 +857,22 @@ def init_from_random_data(self):
chunk_seed = chunk_seed - 0xFFFFFFFF - 1
self.init_from_given_data(crypt_key=data[0:64], id_key=data[64:96], chunk_seed=chunk_seed)

def _get_session_key(self, sessionid):
def _get_session_key(self, sessionid, domain=None):
"""
Derive a session key from the secret long-term static crypt_key (which is a fully random PRK)
and the session id (which is fully random also).
Optionally, a domain can be given for domain separation (defaults to a different binary string
per cipher suite).
"""
# Performance note:
# While this is only invoked once per session to generate a new key for encrypting new data, it is invoked
# frequently (per encrypted repo object) to compute the corresponding key for decrypting existing data.
assert len(sessionid) == 24 # 192bit
key = hkdf_hmac_sha512(
ikm=self.crypt_key,
salt=sessionid,
info=b"borg-session-key-" + self.CIPHERSUITE.__name__.encode(),
output_length=32,
)
return key
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()

def _get_cipher(self, sessionid, iv):
assert isinstance(iv, int)
Expand Down
27 changes: 0 additions & 27 deletions src/borg/crypto/low_level.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -714,30 +714,3 @@ def blake2b_256(key, data):

def blake2b_128(data):
return hashlib.blake2b(data, digest_size=16).digest()


def hkdf_hmac_sha512(ikm, salt, info, output_length):
"""
Compute HKDF-HMAC-SHA512 with input key material *ikm*, *salt* and *info* to produce *output_length* bytes.

This is the "HMAC-based Extract-and-Expand Key Derivation Function (HKDF)" (RFC 5869)
instantiated with HMAC-SHA512.

*output_length* must not be greater than 64 * 255 bytes.
"""
digest_length = 64
assert output_length <= (255 * digest_length), 'output_length must be <= 255 * 64 bytes'
# Step 1. HKDF-Extract (ikm, salt) -> prk
if salt is None:
salt = bytes(64)
prk = hmac.digest(salt, ikm, 'sha512')

# Step 2. HKDF-Expand (prk, info, output_length) -> output key
n = ceil(output_length / digest_length)
t_n = b''
output = b''
for i in range(n):
msg = t_n + info + (i + 1).to_bytes(1, 'little')
t_n = hmac.digest(prk, msg, 'sha512')
output += t_n
return output[:output_length]
2 changes: 1 addition & 1 deletion src/borg/selftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
ChunkerTestCase,
]

SELFTEST_COUNT = 38
SELFTEST_COUNT = 33


class SelfTestResult(TestResult):
Expand Down
69 changes: 0 additions & 69 deletions src/borg/testsuite/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

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 hkdf_hmac_sha512
from ..crypto.low_level import AES, hmac_sha256
from ..crypto.key import CHPOKeyfileKey, AESOCBRepoKey, FlexiKey
from ..helpers import msgpack
Expand Down Expand Up @@ -195,74 +194,6 @@ def test_AEAD_with_more_AAD(self):
cs = cs_cls(key, iv_int, header_len=len(header), aad_offset=0)
self.assert_raises(IntegrityError, lambda: cs.decrypt(hdr_mac_iv_cdata, aad=b"incorrect_chunkid"))

# These test vectors come from https://www.kullo.net/blog/hkdf-sha-512-test-vectors/
# who claims to have verified these against independent Python and C++ implementations.

def test_hkdf_hmac_sha512(self):
ikm = b"\x0b" * 22
salt = bytes.fromhex("000102030405060708090a0b0c")
info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
length = 42

okm = hkdf_hmac_sha512(ikm, salt, info, length)
assert okm == bytes.fromhex(
"832390086cda71fb47625bb5ceb168e4c8e26a1a16ed34d9fc7fe92c1481579338da362cb8d9f925d7cb"
)

def test_hkdf_hmac_sha512_2(self):
ikm = bytes.fromhex(
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f2021222324252627"
"28292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f"
)
salt = bytes.fromhex(
"606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868"
"788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf"
)
info = bytes.fromhex(
"b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7"
"d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"
)
length = 82

okm = hkdf_hmac_sha512(ikm, salt, info, length)
assert okm == bytes.fromhex(
"ce6c97192805b346e6161e821ed165673b84f400a2b514b2fe23d84cd189ddf1b695b48cbd1c838844"
"1137b3ce28f16aa64ba33ba466b24df6cfcb021ecff235f6a2056ce3af1de44d572097a8505d9e7a93"
)

def test_hkdf_hmac_sha512_3(self):
ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
salt = None
info = b""
length = 42

okm = hkdf_hmac_sha512(ikm, salt, info, length)
assert okm == bytes.fromhex(
"f5fa02b18298a72a8c23898a8703472c6eb179dc204c03425c970e3b164bf90fff22d04836d0e2343bac"
)

def test_hkdf_hmac_sha512_4(self):
ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b")
salt = bytes.fromhex("000102030405060708090a0b0c")
info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
length = 42

okm = hkdf_hmac_sha512(ikm, salt, info, length)
assert okm == bytes.fromhex(
"7413e8997e020610fbf6823f2ce14bff01875db1ca55f68cfcf3954dc8aff53559bd5e3028b080f7c068"
)

def test_hkdf_hmac_sha512_5(self):
ikm = bytes.fromhex("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c")
salt = None
info = b""
length = 42

okm = hkdf_hmac_sha512(ikm, salt, info, length)
assert okm == bytes.fromhex(
"1407d46013d98bc6decefcfee55f0f90b0c7f63d68eb1a80eaf07e953cfc0a3a5240a155d6e4daa965bb"
)


def test_decrypt_key_file_argon2_chacha20_poly1305():
plain = b"hello"
Expand Down