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

Argon2 the second part: implement key encryption / decryption #6469

Merged
merged 28 commits into from
Apr 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
46243e1
Argon2 the second part: implement decryption of v2 keys
hexagonrecursion Mar 19, 2022
5dd38c0
add a roundtrip test
hexagonrecursion Mar 27, 2022
c092bf2
Make algorithm mandatory
hexagonrecursion Mar 27, 2022
40dc9be
Implement encryption
hexagonrecursion Mar 27, 2022
32796fe
borg init - defalt to argon2
hexagonrecursion Mar 28, 2022
86cf05a
add test_passphrase_retry
hexagonrecursion Mar 28, 2022
3cdbc5d
add a docstring
hexagonrecursion Mar 28, 2022
339d50e
Fix integrity errors in key.detect()
hexagonrecursion Mar 28, 2022
40d6472
Fix AttributeError: 'MockArgs' object has no attribute 'key_algorithm'
hexagonrecursion Mar 28, 2022
6f6e003
key change-passphrase: keep key algorithm the same
hexagonrecursion Mar 28, 2022
3c69823
key change-location: keep key algorithm the same
hexagonrecursion Mar 28, 2022
02eb0f9
simplify a test
hexagonrecursion Mar 28, 2022
699d063
typo
hexagonrecursion Mar 30, 2022
bf7c196
triple-doublequotes
hexagonrecursion Mar 30, 2022
a23843f
Extract ARGON2_ARGS
hexagonrecursion Mar 30, 2022
58c6de2
--key-algorithm: use shorter names
hexagonrecursion Mar 30, 2022
28ae76d
Fix the testsuite/key.py again
hexagonrecursion Mar 31, 2022
01cd10c
Change names per review comment
hexagonrecursion Mar 31, 2022
1769290
Use KEY_ALGORITHMS.items()
hexagonrecursion Mar 31, 2022
db93243
Speed up tests: skip key derivation
hexagonrecursion Apr 3, 2022
40c5f4d
Remove redundant PBKDF2_ITERATIONS monkeypatch
hexagonrecursion Apr 4, 2022
24010a6
Tests: weaken the KDF instead of skipping it
hexagonrecursion Apr 5, 2022
b5b3d43
typo
hexagonrecursion Apr 6, 2022
d6474e9
Only weaken KDF if env var == "1"
hexagonrecursion Apr 6, 2022
3c5c197
Clarify the purpose of the test
hexagonrecursion Apr 7, 2022
2e1de16
Use consistent argon2 paramaters in a test
hexagonrecursion Apr 7, 2022
721edc9
Test that pbkdf2 is not autoupgraded
hexagonrecursion Apr 7, 2022
1aa652e
Pass arguments consistently in tests
hexagonrecursion Apr 7, 2022
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
10 changes: 2 additions & 8 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,6 @@

import pytest

# IMPORTANT keep this above all other borg imports to avoid inconsistent values
# for `from borg.constants import PBKDF2_ITERATIONS` (or star import) usages before
# this is executed
from borg import constants
# no fixture-based monkey-patching since star-imports are used for the constants module
constants.PBKDF2_ITERATIONS = 1


# needed to get pretty assertion failures in unit tests:
if hasattr(pytest, 'register_assert_rewrite'):
pytest.register_assert_rewrite('borg.testsuite')
Expand All @@ -36,6 +28,8 @@ def clean_env(tmpdir_factory, monkeypatch):
if key.startswith('BORG_') and key not in ('BORG_FUSE_IMPL', )]
for key in keys:
monkeypatch.delenv(key, raising=False)
# Speed up tests
monkeypatch.setenv("BORG_TESTONLY_WEAKEN_KDF", "1")


def pytest_report_header(config, startdir):
Expand Down
4 changes: 3 additions & 1 deletion src/borg/archiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,8 @@ def do_change_location(self, args, repository, manifest, key, cache):
setattr(key_new, name, value)

key_new.target = key_new.get_new_target(args)
key_new.save(key_new.target, key._passphrase, create=True) # save with same passphrase
# save with same passphrase and algorithm
key_new.save(key_new.target, key._passphrase, create=True, algorithm=key._encrypted_key_algorithm)

# rewrite the manifest with the new key, so that the key-type byte of the manifest changes
manifest.key = key_new
Expand Down Expand Up @@ -4310,6 +4311,7 @@ def define_borg_mount(parser):
help='Set storage quota of the new repository (e.g. 5G, 1.5T). Default: no quota.')
subparser.add_argument('--make-parent-dirs', dest='make_parent_dirs', action='store_true',
help='create the parent directories of the repository directory, if they are missing.')
subparser.add_argument('--key-algorithm', dest='key_algorithm', default='argon2', choices=list(KEY_ALGORITHMS))

# borg key
subparser = subparsers.add_parser('key', parents=[mid_common_parser], add_help=False,
Expand Down
12 changes: 12 additions & 0 deletions src/borg/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@

PBKDF2_ITERATIONS = 100000

# https://www.rfc-editor.org/rfc/rfc9106.html#section-4-6.2
ARGON2_ARGS = {'time_cost': 3, 'memory_cost': 2**16, 'parallelism': 4, 'type': 'id'}
ARGON2_SALT_BYTES = 16

# Maps the CLI argument to our internal identifier for the format
KEY_ALGORITHMS = {
# encrypt-and-MAC, kdf: PBKDF2(HMAC−SHA256), encryption: AES256-CTR, authentication: HMAC-SHA256
'pbkdf2': 'sha256',
# encrypt-then-MAC, kdf: argon2, encryption: AES256-CTR, authentication: HMAC-SHA256
'argon2': 'argon2 aes256-ctr hmac-sha256',
}


class KeyBlobStorage:
NO_STORAGE = 'no_storage'
Expand Down
96 changes: 81 additions & 15 deletions src/borg/crypto/key.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from .nonces import NonceManager
from .low_level import AES, bytes_to_long, long_to_bytes, bytes_to_int, num_cipher_blocks, hmac_sha256, blake2b_256, hkdf_hmac_sha512
from .low_level import AES256_CTR_HMAC_SHA256, AES256_CTR_BLAKE2b, AES256_OCB, CHACHA20_POLY1305
from . import low_level


class UnsupportedPayloadError(Error):
Expand All @@ -51,6 +52,10 @@ class RepoKeyNotFoundError(Error):
"""No key entry found in the config of repository {}."""


class UnsupportedKeyFormatError(Error):
"""Your borg key is stored in an unsupported format. Try using a newer version of borg."""


class TAMRequiredError(IntegrityError):
__doc__ = textwrap.dedent("""
Manifest is unauthenticated, but it is required for this repository.
Expand Down Expand Up @@ -430,15 +435,54 @@ def decrypt_key_file(self, data, passphrase):
unpacker = get_limited_unpacker('key')
unpacker.feed(data)
data = unpacker.unpack()
enc_key = EncryptedKey(internal_dict=data)
assert enc_key.version == 1
assert enc_key.algorithm == 'sha256'
key = passphrase.kdf(enc_key.salt, enc_key.iterations, 32)
data = AES(key, b'\0'*16).decrypt(enc_key.data)
if hmac.compare_digest(hmac_sha256(key, data), enc_key.hash):
encrypted_key = EncryptedKey(internal_dict=data)
if encrypted_key.version != 1:
raise UnsupportedKeyFormatError()
else:
self._encrypted_key_algorithm = encrypted_key.algorithm
if encrypted_key.algorithm == 'sha256':
return self.decrypt_key_file_pbkdf2(encrypted_key, passphrase)
elif encrypted_key.algorithm == 'argon2 aes256-ctr hmac-sha256':
return self.decrypt_key_file_argon2(encrypted_key, passphrase)
else:
raise UnsupportedKeyFormatError()

def decrypt_key_file_pbkdf2(self, encrypted_key, passphrase):
key = passphrase.kdf(encrypted_key.salt, encrypted_key.iterations, 32)
data = AES(key, b'\0'*16).decrypt(encrypted_key.data)
if hmac.compare_digest(hmac_sha256(key, data), encrypted_key.hash):
return data
return None

def decrypt_key_file_argon2(self, encrypted_key, passphrase):
key = passphrase.argon2(
output_len_in_bytes=64,
salt=encrypted_key.salt,
time_cost=encrypted_key.argon2_time_cost,
memory_cost=encrypted_key.argon2_memory_cost,
parallelism=encrypted_key.argon2_parallelism,
type=encrypted_key.argon2_type,
)
enc_key, mac_key = key[:32], key[32:]
ae_cipher = AES256_CTR_HMAC_SHA256(
iv=0, header_len=0, aad_offset=0,
enc_key=enc_key,
mac_key=mac_key,
)
try:
return ae_cipher.decrypt(encrypted_key.data)
except low_level.IntegrityError:
return None
ThomasWaldmann marked this conversation as resolved.
Show resolved Hide resolved

def encrypt_key_file(self, data, passphrase, algorithm):
if algorithm == 'sha256':
return self.encrypt_key_file_pbkdf2(data, passphrase)
elif algorithm == 'argon2 aes256-ctr hmac-sha256':
return self.encrypt_key_file_argon2(data, passphrase)
else:
raise ValueError(f'Unexpected algorithm: {algorithm}')

def encrypt_key_file(self, data, passphrase):
def encrypt_key_file_pbkdf2(self, data, passphrase):
salt = os.urandom(32)
iterations = PBKDF2_ITERATIONS
key = passphrase.kdf(salt, iterations, 32)
Expand All @@ -454,7 +498,29 @@ def encrypt_key_file(self, data, passphrase):
)
return msgpack.packb(enc_key.as_dict())

def _save(self, passphrase):
def encrypt_key_file_argon2(self, data, passphrase):
salt = os.urandom(ARGON2_SALT_BYTES)
key = passphrase.argon2(
output_len_in_bytes=64,
salt=salt,
**ARGON2_ARGS,
)
enc_key, mac_key = key[:32], key[32:]
ae_cipher = AES256_CTR_HMAC_SHA256(
iv=0, header_len=0, aad_offset=0,
enc_key=enc_key,
mac_key=mac_key,
)
encrypted_key = EncryptedKey(
version=1,
algorithm='argon2 aes256-ctr hmac-sha256',
salt=salt,
data=ae_cipher.encrypt(data),
**{'argon2_' + k: v for k, v in ARGON2_ARGS.items()},
)
return msgpack.packb(encrypted_key.as_dict())

def _save(self, passphrase, algorithm):
key = Key(
version=1,
repository_id=self.repository_id,
Expand All @@ -464,14 +530,14 @@ def _save(self, passphrase):
chunk_seed=self.chunk_seed,
tam_required=self.tam_required,
)
data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase)
data = self.encrypt_key_file(msgpack.packb(key.as_dict()), passphrase, algorithm)
key_data = '\n'.join(textwrap.wrap(b2a_base64(data).decode('ascii')))
return key_data

def change_passphrase(self, passphrase=None):
if passphrase is None:
passphrase = Passphrase.new(allow_empty=True)
self.save(self.target, passphrase)
self.save(self.target, passphrase, algorithm=self._encrypted_key_algorithm)

@classmethod
def create(cls, repository, args):
Expand All @@ -481,7 +547,7 @@ def create(cls, repository, args):
key.init_from_random_data()
key.init_ciphers()
target = key.get_new_target(args)
key.save(target, passphrase, create=True)
key.save(target, passphrase, create=True, algorithm=KEY_ALGORITHMS[args.key_algorithm])
logger.info('Key in "%s" created.' % target)
logger.info('Keep this key safe. Your data will be inaccessible without it.')
return key
Expand Down Expand Up @@ -581,8 +647,8 @@ def load(self, target, passphrase):
self.target = target
return success

def save(self, target, passphrase, create=False):
key_data = self._save(passphrase)
def save(self, target, passphrase, algorithm, create=False):
key_data = self._save(passphrase, algorithm)
if self.STORAGE == KeyBlobStorage.KEYFILE:
if create and os.path.isfile(target):
# if a new keyfile key repository is created, ensure that an existing keyfile of another
Expand Down Expand Up @@ -657,8 +723,8 @@ def load(self, target, passphrase):
self.logically_encrypted = False
return success

def save(self, target, passphrase, create=False):
super().save(target, passphrase, create=create)
def save(self, target, passphrase, algorithm, create=False):
super().save(target, passphrase, algorithm, create=create)
self.logically_encrypted = False

def init_ciphers(self, manifest_data=None):
Expand Down
7 changes: 7 additions & 0 deletions src/borg/helpers/passphrase.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ def __repr__(self):
return '<Passphrase "***hidden***">'

def kdf(self, salt, iterations, length):
if os.environ.get("BORG_TESTONLY_WEAKEN_KDF") == "1":
iterations = 1
return pbkdf2_hmac('sha256', self.encode('utf-8'), salt, iterations, length)

def argon2(
Expand All @@ -152,6 +154,11 @@ def argon2(
parallelism,
type: Literal['i', 'd', 'id']
) -> bytes:
if os.environ.get("BORG_TESTONLY_WEAKEN_KDF") == "1":
time_cost = 1
parallelism = 1
# 8 is the smallest value that avoids the "Memory cost is too small" exception
memory_cost = 8
type_map = {
'i': argon2.low_level.Type.I,
'd': argon2.low_level.Type.D,
Expand Down
8 changes: 7 additions & 1 deletion src/borg/item.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ from .helpers import bigint_to_int, int_to_bigint
from .helpers import StableDict
from .helpers import format_file_size


cdef extern from "_item.c":
object _object_to_optr(object obj)
object _optr_to_object(object bytes)
Expand Down Expand Up @@ -294,7 +295,8 @@ class EncryptedKey(PropDict):
If a EncryptedKey shall be serialized, give as_dict() method output to msgpack packer.
"""

VALID_KEYS = {'version', 'algorithm', 'iterations', 'salt', 'hash', 'data'} # str-typed keys
VALID_KEYS = { 'version', 'algorithm', 'iterations', 'salt', 'hash', 'data',
'argon2_time_cost', 'argon2_memory_cost', 'argon2_parallelism', 'argon2_type' }

__slots__ = ("_dict", ) # avoid setting attributes not supported by properties

Expand All @@ -304,6 +306,10 @@ class EncryptedKey(PropDict):
salt = PropDict._make_property('salt', bytes)
hash = PropDict._make_property('hash', bytes)
data = PropDict._make_property('data', bytes)
argon2_time_cost = PropDict._make_property('argon2_time_cost', int)
argon2_memory_cost = PropDict._make_property('argon2_memory_cost', int)
argon2_parallelism = PropDict._make_property('argon2_parallelism', int)
argon2_type = PropDict._make_property('argon2_type', str, encode=str.encode, decode=bytes.decode)
ThomasWaldmann marked this conversation as resolved.
Show resolved Hide resolved


class Key(PropDict):
Expand Down
47 changes: 46 additions & 1 deletion src/borg/testsuite/archiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import tempfile
import time
import unittest
from binascii import unhexlify, b2a_base64
from binascii import unhexlify, b2a_base64, a2b_base64
from configparser import ConfigParser
from datetime import datetime
from datetime import timezone
Expand Down Expand Up @@ -3603,6 +3603,51 @@ def test_recovery_from_deleted_repo_nonce(self):
self.cmd('create', self.repository_location + '::test2', 'input')
assert os.path.exists(nonce)

def test_init_defaults_to_argon2(self):
ThomasWaldmann marked this conversation as resolved.
Show resolved Hide resolved
"""https://github.com/borgbackup/borg/issues/747#issuecomment-1076160401"""
self.cmd('init', '--encryption=repokey', self.repository_location)
with Repository(self.repository_path) as repository:
key = msgpack.unpackb(a2b_base64(repository.load_key()))
assert key[b'algorithm'] == b'argon2 aes256-ctr hmac-sha256'

def test_init_with_explicit_key_algorithm(self):
"""https://github.com/borgbackup/borg/issues/747#issuecomment-1076160401"""
self.cmd('init', '--encryption=repokey', '--key-algorithm=pbkdf2', self.repository_location)
with Repository(self.repository_path) as repository:
key = msgpack.unpackb(a2b_base64(repository.load_key()))
assert key[b'algorithm'] == b'sha256'

def verify_change_passphrase_does_not_change_algorithm(self, given_algorithm, expected_algorithm):
self.cmd('init', '--encryption=repokey', '--key-algorithm', given_algorithm, self.repository_location)
os.environ['BORG_NEW_PASSPHRASE'] = 'newpassphrase'

self.cmd('key', 'change-passphrase', self.repository_location)

with Repository(self.repository_path) as repository:
key = msgpack.unpackb(a2b_base64(repository.load_key()))
assert key[b'algorithm'] == expected_algorithm

def test_change_passphrase_does_not_change_algorithm_argon2(self):
self.verify_change_passphrase_does_not_change_algorithm('argon2', b'argon2 aes256-ctr hmac-sha256')

def test_change_passphrase_does_not_change_algorithm_pbkdf2(self):
self.verify_change_passphrase_does_not_change_algorithm('pbkdf2', b'sha256')

def verify_change_location_does_not_change_algorithm(self, given_algorithm, expected_algorithm):
self.cmd('init', '--encryption=keyfile', '--key-algorithm', given_algorithm, self.repository_location)

self.cmd('key', 'change-location', self.repository_location, 'repokey')

with Repository(self.repository_path) as repository:
key = msgpack.unpackb(a2b_base64(repository.load_key()))
assert key[b'algorithm'] == expected_algorithm

def test_change_location_does_not_change_algorithm_argon2(self):
self.verify_change_location_does_not_change_algorithm('argon2', b'argon2 aes256-ctr hmac-sha256')

def test_change_location_does_not_change_algorithm_pbkdf2(self):
self.verify_change_location_does_not_change_algorithm('pbkdf2', b'sha256')


@unittest.skipUnless('binary' in BORG_EXES, 'no borg.exe available')
class ArchiverTestCaseBinary(ArchiverTestCase):
Expand Down
Loading