diff --git a/docs/faq.rst b/docs/faq.rst index 7995a587d..ef9d35656 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -710,6 +710,32 @@ Send a private email to the :ref:`security contact ` if you think you have discovered a security issue. Please disclose security issues responsibly. +How important are the nonce files? +------------------------------------ + +Borg uses :ref:`AES-CTR encryption `. An +essential part of AES-CTR is a sequential counter that must **never** +repeat. If the same value of the counter is used twice in the same repository, +an attacker can decrypt the data. The counter is stored in the home directory +of each user ($HOME/.config/borg/security/$REPO_ID/nonce) as well as +in the repository (/path/to/repo/nonce). When creating a new archive borg uses +the highest of the two values. The value of the counter in the repository may be +higher than your local value if another user has created an archive more recently +than you did. + +Since the nonce is not necessary to read the data that is already encrypted, +``borg info``, ``borg list``, ``borg extract`` and ``borg mount`` should work +just fine without it. + +If the the nonce file stored in the repo is lost, but you still have your local copy, +borg will recreate the repository nonce file the next time you run ``borg create``. +This should be safe for repositories that are only used from one user account +on one machine. + +For repositories that are used by multiple users and/or from multiple machines +it is safest to avoid running *any* commands that modify the repository after +the nonce is deleted or if you suspect it may have been tampered with. See :ref:`attack_model`. + Common issues ############# diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index bad0c38f1..eb9f715f8 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -384,6 +384,10 @@ class ArchiverTestCaseBase(BaseTestCase): class ArchiverTestCase(ArchiverTestCaseBase): requires_hardlinks = pytest.mark.skipif(not are_hardlinks_supported(), reason='hardlinks not supported') + def get_security_dir(self): + repository_id = bin_to_hex(self._extract_repository_id(self.repository_path)) + return get_security_dir(repository_id) + def test_basic_functionality(self): have_root = self.create_test_files() # fork required to test show-rc output @@ -718,8 +722,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): self.cmd('init', '--encryption=repokey', self.repository_location) # Delete cache & security database, AKA switch to user perspective self.cmd('delete', '--cache-only', self.repository_location) - repository_id = bin_to_hex(self._extract_repository_id(self.repository_path)) - shutil.rmtree(get_security_dir(repository_id)) + shutil.rmtree(self.get_security_dir()) with environment_variable(BORG_PASSPHRASE=None): # This is the part were the user would be tricked, e.g. she assumes that BORG_PASSPHRASE # is set, while it isn't. Previously this raised no warning, @@ -732,11 +735,10 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_repository_move(self): self.cmd('init', '--encryption=repokey', self.repository_location) - repository_id = bin_to_hex(self._extract_repository_id(self.repository_path)) + security_dir = self.get_security_dir() os.rename(self.repository_path, self.repository_path + '_new') with environment_variable(BORG_RELOCATED_REPO_ACCESS_IS_OK='yes'): self.cmd('info', self.repository_location + '_new') - security_dir = get_security_dir(repository_id) with open(os.path.join(security_dir, 'location')) as fd: location = fd.read() assert location == Location(self.repository_location + '_new').canonical_path() @@ -751,9 +753,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_security_dir_compat(self): self.cmd('init', '--encryption=repokey', self.repository_location) - repository_id = bin_to_hex(self._extract_repository_id(self.repository_path)) - security_dir = get_security_dir(repository_id) - with open(os.path.join(security_dir, 'location'), 'w') as fd: + with open(os.path.join(self.get_security_dir(), 'location'), 'w') as fd: fd.write('something outdated') # This is fine, because the cache still has the correct information. security_dir and cache can disagree # if older versions are used to confirm a renamed repository. @@ -761,8 +761,6 @@ class ArchiverTestCase(ArchiverTestCaseBase): def test_unknown_unencrypted(self): self.cmd('init', '--encryption=none', self.repository_location) - repository_id = bin_to_hex(self._extract_repository_id(self.repository_path)) - security_dir = get_security_dir(repository_id) # Ok: repository is known self.cmd('info', self.repository_location) @@ -772,7 +770,7 @@ class ArchiverTestCase(ArchiverTestCaseBase): # Needs confirmation: cache and security dir both gone (eg. another host or rm -rf ~) shutil.rmtree(self.cache_path) - shutil.rmtree(security_dir) + shutil.rmtree(self.get_security_dir()) if self.FORK_DEFAULT: self.cmd('info', self.repository_location, exit_code=EXIT_ERROR) else: @@ -3506,6 +3504,53 @@ id: 2 / e29442 3506da 4e1ea7 / 25f62a 5a3d41 - 02 self.assert_in('this-repository-does-not-exist', output) self.assert_not_in('this-repository-does-not-exist::test', output) + def test_can_read_repo_even_if_nonce_is_deleted(self): + """Nonce is only used for encrypting new data. + + It should be possible to retrieve the data from an archive even if + both the client and the server forget the nonce""" + self.create_regular_file('file1', contents=b'Hello, borg') + self.cmd('init', '--encryption=repokey', self.repository_location) + self.cmd('create', self.repository_location + '::test', 'input') + # Oops! We have removed the repo-side memory of the nonce! + # See https://github.com/borgbackup/borg/issues/5858 + os.remove(os.path.join(self.repository_path, 'nonce')) + # Oops! The client has lost the nonce too! + os.remove(os.path.join(self.get_security_dir(), 'nonce')) + + # The repo should still be readable + repo_info = self.cmd('info', self.repository_location) + assert 'All archives:' in repo_info + repo_list = self.cmd('list', self.repository_location) + assert 'test' in repo_list + # The archive should still be readable + archive_info = self.cmd('info', self.repository_location + '::test') + assert 'Archive name: test\n' in archive_info + archive_list = self.cmd('list', self.repository_location + '::test') + assert 'file1' in archive_list + # Extracting the archive should work + with changedir('output'): + self.cmd('extract', self.repository_location + '::test') + self.assert_dirs_equal('input', 'output/input') + + def test_recovery_from_deleted_repo_nonce(self): + """We should be able to recover if path/to/repo/nonce is deleted. + + The nonce is stored in two places: in the repo and in $HOME. + The nonce in the repo is only needed when multiple clients use the same + repo. Otherwise we can just use our own copy of the nonce. + """ + self.create_regular_file('file1', contents=b'Hello, borg') + self.cmd('init', '--encryption=repokey', self.repository_location) + self.cmd('create', self.repository_location + '::test', 'input') + # Oops! We have removed the repo-side memory of the nonce! + # See https://github.com/borgbackup/borg/issues/5858 + nonce = os.path.join(self.repository_path, 'nonce') + os.remove(nonce) + + self.cmd('create', self.repository_location + '::test2', 'input') + assert os.path.exists(nonce) + @unittest.skipUnless('binary' in BORG_EXES, 'no borg.exe available') class ArchiverTestCaseBinary(ArchiverTestCase):