Browse Source

Merge pull request #6261 from hexagonrecursion/bp-nonce

Backport: Doc: impact of deleting path/to/repo/nonce
TW 3 years ago
parent
commit
16219f3f52
2 changed files with 81 additions and 10 deletions
  1. 26 0
      docs/faq.rst
  2. 55 10
      src/borg/testsuite/archiver.py

+ 26 - 0
docs/faq.rst

@@ -684,6 +684,32 @@ Send a private email to the :ref:`security contact <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 <borg_security_critique>`. 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
 #############
 

+ 55 - 10
src/borg/testsuite/archiver.py

@@ -386,6 +386,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
@@ -720,8 +724,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,
@@ -734,11 +737,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()
@@ -753,9 +755,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.
@@ -763,8 +763,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)
 
@@ -774,7 +772,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:
@@ -3168,6 +3166,53 @@ id: 2 / e29442 3506da 4e1ea7 / 25f62a 5a3d41 - 02
             with patch.object(xattr, 'setxattr', patched_setxattr_EACCES):
                 self.cmd('extract', self.repository_location + '::test', exit_code=EXIT_WARNING)
 
+    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):