Explorar o código

Merge pull request #1374 from textshell/issue/1039-aes-iv

Fix AES-CTR nonce reusage
enkore %!s(int64=8) %!d(string=hai) anos
pai
achega
69c51f268d

+ 11 - 0
src/borg/helpers.py

@@ -269,6 +269,17 @@ def get_keys_dir():
     return keys_dir
     return keys_dir
 
 
 
 
+def get_nonces_dir():
+    """Determine where to store the local nonce high watermark"""
+
+    xdg_config = os.environ.get('XDG_CONFIG_HOME', os.path.join(get_home_dir(), '.config'))
+    nonces_dir = os.environ.get('BORG_NONCES_DIR', os.path.join(xdg_config, 'borg', 'key-nonces'))
+    if not os.path.exists(nonces_dir):
+        os.makedirs(nonces_dir)
+        os.chmod(nonces_dir, stat.S_IRWXU)
+    return nonces_dir
+
+
 def get_cache_dir():
 def get_cache_dir():
     """Determine where to repository keys and cache"""
     """Determine where to repository keys and cache"""
     xdg_cache = os.environ.get('XDG_CACHE_HOME', os.path.join(get_home_dir(), '.cache'))
     xdg_cache = os.environ.get('XDG_CACHE_HOME', os.path.join(get_home_dir(), '.cache'))

+ 8 - 5
src/borg/key.py

@@ -3,7 +3,7 @@ import getpass
 import os
 import os
 import sys
 import sys
 import textwrap
 import textwrap
-from binascii import a2b_base64, b2a_base64, hexlify
+from binascii import a2b_base64, b2a_base64, hexlify, unhexlify
 from hashlib import sha256, pbkdf2_hmac
 from hashlib import sha256, pbkdf2_hmac
 from hmac import compare_digest
 from hmac import compare_digest
 
 
@@ -23,6 +23,7 @@ from .helpers import bin_to_hex
 from .helpers import CompressionDecider2, CompressionSpec
 from .helpers import CompressionDecider2, CompressionSpec
 from .item import Key, EncryptedKey
 from .item import Key, EncryptedKey
 from .platform import SaveFile
 from .platform import SaveFile
+from .nonces import NonceManager
 
 
 
 
 PREFIX = b'\0' * 8
 PREFIX = b'\0' * 8
@@ -169,6 +170,7 @@ class AESKeyBase(KeyBase):
 
 
     def encrypt(self, chunk):
     def encrypt(self, chunk):
         chunk = self.compress(chunk)
         chunk = self.compress(chunk)
+        self.nonce_manager.ensure_reservation(num_aes_blocks(len(chunk.data)))
         self.enc_cipher.reset()
         self.enc_cipher.reset()
         data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(chunk.data)))
         data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(chunk.data)))
         hmac = hmac_sha256(self.enc_hmac_key, data)
         hmac = hmac_sha256(self.enc_hmac_key, data)
@@ -207,8 +209,9 @@ class AESKeyBase(KeyBase):
         if self.chunk_seed & 0x80000000:
         if self.chunk_seed & 0x80000000:
             self.chunk_seed = self.chunk_seed - 0xffffffff - 1
             self.chunk_seed = self.chunk_seed - 0xffffffff - 1
 
 
-    def init_ciphers(self, enc_iv=b''):
-        self.enc_cipher = AES(is_encrypt=True, key=self.enc_key, iv=enc_iv)
+    def init_ciphers(self, manifest_nonce=0):
+        self.enc_cipher = AES(is_encrypt=True, key=self.enc_key, iv=manifest_nonce.to_bytes(16, byteorder='big'))
+        self.nonce_manager = NonceManager(self.repository, self.enc_cipher, manifest_nonce)
         self.dec_cipher = AES(is_encrypt=False, key=self.enc_key)
         self.dec_cipher = AES(is_encrypt=False, key=self.enc_key)
 
 
 
 
@@ -299,7 +302,7 @@ class PassphraseKey(AESKeyBase):
             try:
             try:
                 key.decrypt(None, manifest_data)
                 key.decrypt(None, manifest_data)
                 num_blocks = num_aes_blocks(len(manifest_data) - 41)
                 num_blocks = num_aes_blocks(len(manifest_data) - 41)
-                key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
+                key.init_ciphers(key.extract_nonce(manifest_data) + num_blocks)
                 return key
                 return key
             except IntegrityError:
             except IntegrityError:
                 passphrase = Passphrase.getpass(prompt)
                 passphrase = Passphrase.getpass(prompt)
@@ -337,7 +340,7 @@ class KeyfileKeyBase(AESKeyBase):
             if not key.load(target, passphrase):
             if not key.load(target, passphrase):
                 raise PassphraseWrong
                 raise PassphraseWrong
         num_blocks = num_aes_blocks(len(manifest_data) - 41)
         num_blocks = num_aes_blocks(len(manifest_data) - 41)
-        key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
+        key.init_ciphers(key.extract_nonce(manifest_data) + num_blocks)
         return key
         return key
 
 
     def find_key(self):
     def find_key(self):

+ 87 - 0
src/borg/nonces.py

@@ -0,0 +1,87 @@
+import os
+import sys
+from binascii import unhexlify
+
+from .crypto import bytes_to_long, long_to_bytes
+from .helpers import get_nonces_dir
+from .helpers import bin_to_hex
+from .platform import SaveFile
+from .remote import InvalidRPCMethod
+
+
+MAX_REPRESENTABLE_NONCE = 2**64 - 1
+NONCE_SPACE_RESERVATION = 2**28  # This in units of AES blocksize (16 bytes)
+
+
+class NonceManager:
+    def __init__(self, repository, enc_cipher, manifest_nonce):
+        self.repository = repository
+        self.enc_cipher = enc_cipher
+        self.end_of_nonce_reservation = None
+        self.manifest_nonce = manifest_nonce
+        self.nonce_file = os.path.join(get_nonces_dir(), self.repository.id_str)
+
+    def get_local_free_nonce(self):
+        try:
+            with open(self.nonce_file, 'r') as fd:
+                return bytes_to_long(unhexlify(fd.read()))
+        except FileNotFoundError:
+            return None
+
+    def commit_local_nonce_reservation(self, next_unreserved, start_nonce):
+        if self.get_local_free_nonce() != start_nonce:
+            raise Exception("nonce space reservation with mismatched previous state")
+        with SaveFile(self.nonce_file, binary=False) as fd:
+            fd.write(bin_to_hex(long_to_bytes(next_unreserved)))
+
+    def get_repo_free_nonce(self):
+        try:
+            return self.repository.get_free_nonce()
+        except InvalidRPCMethod as error:
+            # old server version, suppress further calls
+            sys.stderr.write("Please upgrade to borg version 1.1+ on the server for safer AES-CTR nonce handling.\n")
+            self.get_repo_free_nonce = lambda: None
+            self.commit_repo_nonce_reservation = lambda next_unreserved, start_nonce: None
+            return None
+
+    def commit_repo_nonce_reservation(self, next_unreserved, start_nonce):
+        self.repository.commit_nonce_reservation(next_unreserved, start_nonce)
+
+    def ensure_reservation(self, nonce_space_needed):
+        # Nonces may never repeat, even if a transaction aborts or the system crashes.
+        # Therefore a part of the nonce space is reserved before any nonce is used for encryption.
+        # As these reservations are commited to permanent storage before any nonce is used, this protects
+        # against nonce reuse in crashes and transaction aborts. In that case the reservation still
+        # persists and the whole reserved space is never reused.
+        #
+        # Local storage on the client is used to protect against an attacker that is able to rollback the
+        # state of the server or can do arbitrary modifications to the repository.
+        # Storage on the server is used for the multi client use case where a transaction on client A is
+        # aborted and later client B writes to the repository.
+        #
+        # This scheme does not protect against attacker who is able to rollback the state of the server
+        # or can do arbitrary modifications to the repository in the multi client usecase.
+
+        if self.end_of_nonce_reservation:
+            # we already got a reservation, if nonce_space_needed still fits everything is ok
+            next_nonce = int.from_bytes(self.enc_cipher.iv, byteorder='big')
+            assert next_nonce <= self.end_of_nonce_reservation
+            if next_nonce + nonce_space_needed <= self.end_of_nonce_reservation:
+                return
+
+        repo_free_nonce = self.get_repo_free_nonce()
+        local_free_nonce = self.get_local_free_nonce()
+        free_nonce_space = max(x for x in (repo_free_nonce, local_free_nonce, self.manifest_nonce, self.end_of_nonce_reservation) if x is not None)
+        reservation_end = free_nonce_space + nonce_space_needed + NONCE_SPACE_RESERVATION
+        assert reservation_end < MAX_REPRESENTABLE_NONCE
+        if self.end_of_nonce_reservation is None:
+            # initialization, reset the encryption cipher to the start of the reservation
+            self.enc_cipher.reset(None, free_nonce_space.to_bytes(16, byteorder='big'))
+        else:
+            # expand existing reservation if possible
+            if free_nonce_space != self.end_of_nonce_reservation:
+                # some other client got an interleaved reservation, skip partial space in old reservation to avoid overlap
+                self.enc_cipher.reset(None, free_nonce_space.to_bytes(16, byteorder='big'))
+        self.commit_repo_nonce_reservation(reservation_end, repo_free_nonce)
+        self.commit_local_nonce_reservation(reservation_end, local_free_nonce)
+        self.end_of_nonce_reservation = reservation_end

+ 8 - 0
src/borg/remote.py

@@ -66,6 +66,8 @@ class RepositoryServer:  # pragma: no cover
         'save_key',
         'save_key',
         'load_key',
         'load_key',
         'break_lock',
         'break_lock',
+        'get_free_nonce',
+        'commit_nonce_reservation'
     )
     )
 
 
     def __init__(self, restrict_to_paths, append_only):
     def __init__(self, restrict_to_paths, append_only):
@@ -450,6 +452,12 @@ This problem will go away as soon as the server has been upgraded to 1.0.7+.
     def load_key(self):
     def load_key(self):
         return self.call('load_key')
         return self.call('load_key')
 
 
+    def get_free_nonce(self):
+        return self.call('get_free_nonce')
+
+    def commit_nonce_reservation(self, next_unreserved, start_nonce):
+        return self.call('commit_nonce_reservation', next_unreserved, start_nonce)
+
     def break_lock(self):
     def break_lock(self):
         return self.call('break_lock')
         return self.call('break_lock')
 
 

+ 21 - 0
src/borg/repository.py

@@ -189,6 +189,27 @@ class Repository:
         keydata = self.config.get('repository', 'key')
         keydata = self.config.get('repository', 'key')
         return keydata.encode('utf-8')  # remote repo: msgpack issue #99, returning bytes
         return keydata.encode('utf-8')  # remote repo: msgpack issue #99, returning bytes
 
 
+    def get_free_nonce(self):
+        if not self.lock.got_exclusive_lock():
+            raise AssertionError("bug in code, exclusive lock should exist here")
+
+        nonce_path = os.path.join(self.path, 'nonce')
+        try:
+            with open(nonce_path, 'r') as fd:
+                return int.from_bytes(unhexlify(fd.read()), byteorder='big')
+        except FileNotFoundError:
+            return None
+
+    def commit_nonce_reservation(self, next_unreserved, start_nonce):
+        if not self.lock.got_exclusive_lock():
+            raise AssertionError("bug in code, exclusive lock should exist here")
+
+        if self.get_free_nonce() != start_nonce:
+            raise Exception("nonce space reservation with mismatched previous state")
+        nonce_path = os.path.join(self.path, 'nonce')
+        with SaveFile(nonce_path, binary=False) as fd:
+            fd.write(bin_to_hex(next_unreserved.to_bytes(8, byteorder='big')))
+
     def destroy(self):
     def destroy(self):
         """Destroy the repository at `self.path`
         """Destroy the repository at `self.path`
         """
         """

+ 0 - 1
src/borg/testsuite/archiver.py

@@ -1493,7 +1493,6 @@ class ArchiverTestCase(ArchiverTestCaseBase):
         verify_uniqueness()
         verify_uniqueness()
         self.cmd('delete', self.repository_location + '::test.2')
         self.cmd('delete', self.repository_location + '::test.2')
         verify_uniqueness()
         verify_uniqueness()
-        self.assert_equal(used, set(range(len(used))))
 
 
     def test_aes_counter_uniqueness_keyfile(self):
     def test_aes_counter_uniqueness_keyfile(self):
         self.verify_aes_counter_uniqueness('keyfile')
         self.verify_aes_counter_uniqueness('keyfile')

+ 12 - 1
src/borg/testsuite/helpers.py

@@ -14,7 +14,7 @@ from ..helpers import Buffer
 from ..helpers import partial_format, format_file_size, parse_file_size, format_timedelta, format_line, PlaceholderError
 from ..helpers import partial_format, format_file_size, parse_file_size, format_timedelta, format_line, PlaceholderError
 from ..helpers import make_path_safe, clean_lines
 from ..helpers import make_path_safe, clean_lines
 from ..helpers import prune_within, prune_split
 from ..helpers import prune_within, prune_split
-from ..helpers import get_cache_dir, get_keys_dir
+from ..helpers import get_cache_dir, get_keys_dir, get_nonces_dir
 from ..helpers import is_slow_msgpack
 from ..helpers import is_slow_msgpack
 from ..helpers import yes, TRUISH, FALSISH, DEFAULTISH
 from ..helpers import yes, TRUISH, FALSISH, DEFAULTISH
 from ..helpers import StableDict, int_to_bigint, bigint_to_int, bin_to_hex
 from ..helpers import StableDict, int_to_bigint, bigint_to_int, bin_to_hex
@@ -636,6 +636,17 @@ def test_get_keys_dir():
         os.environ['BORG_KEYS_DIR'] = old_env
         os.environ['BORG_KEYS_DIR'] = old_env
 
 
 
 
+def test_get_nonces_dir(monkeypatch):
+    """test that get_nonces_dir respects environment"""
+    monkeypatch.delenv('XDG_CONFIG_HOME', raising=False)
+    monkeypatch.delenv('BORG_NONCES_DIR', raising=False)
+    assert get_nonces_dir() == os.path.join(os.path.expanduser('~'), '.config', 'borg', 'key-nonces')
+    monkeypatch.setenv('XDG_CONFIG_HOME', '/var/tmp/.config')
+    assert get_nonces_dir() == os.path.join('/var/tmp/.config', 'borg', 'key-nonces')
+    monkeypatch.setenv('BORG_NONCES_DIR', '/var/tmp')
+    assert get_nonces_dir() == '/var/tmp'
+
+
 def test_file_size():
 def test_file_size():
     """test the size formatting routines"""
     """test the size formatting routines"""
     si_size_map = {
     si_size_map = {

+ 24 - 1
src/borg/testsuite/key.py

@@ -1,6 +1,7 @@
 import getpass
 import getpass
 import re
 import re
 import tempfile
 import tempfile
+import os.path
 from binascii import hexlify, unhexlify
 from binascii import hexlify, unhexlify
 
 
 import pytest
 import pytest
@@ -9,6 +10,7 @@ from ..crypto import bytes_to_long, num_aes_blocks
 from ..helpers import Location
 from ..helpers import Location
 from ..helpers import Chunk
 from ..helpers import Chunk
 from ..helpers import IntegrityError
 from ..helpers import IntegrityError
+from ..helpers import get_nonces_dir
 from ..key import PlaintextKey, PassphraseKey, KeyfileKey, Passphrase, PasswordRetriesExceeded, bin_to_hex
 from ..key import PlaintextKey, PassphraseKey, KeyfileKey, Passphrase, PasswordRetriesExceeded, bin_to_hex
 
 
 
 
@@ -18,6 +20,11 @@ def clean_env(monkeypatch):
     monkeypatch.delenv('BORG_PASSPHRASE', False)
     monkeypatch.delenv('BORG_PASSPHRASE', False)
 
 
 
 
+@pytest.fixture(autouse=True)
+def nonce_dir(tmpdir_factory, monkeypatch):
+    monkeypatch.setenv('XDG_CONFIG_HOME', tmpdir_factory.mktemp('xdg-config-home'))
+
+
 class TestKey:
 class TestKey:
     class MockArgs:
     class MockArgs:
         location = Location(tempfile.mkstemp()[1])
         location = Location(tempfile.mkstemp()[1])
@@ -59,6 +66,12 @@ class TestKey:
         id = bytes(32)
         id = bytes(32)
         id_str = bin_to_hex(id)
         id_str = bin_to_hex(id)
 
 
+        def get_free_nonce(self):
+            return None
+
+        def commit_nonce_reservation(self, next_unreserved, start_nonce):
+            pass
+
     def test_plaintext(self):
     def test_plaintext(self):
         key = PlaintextKey.create(None, None)
         key = PlaintextKey.create(None, None)
         chunk = Chunk(b'foo')
         chunk = Chunk(b'foo')
@@ -77,13 +90,23 @@ class TestKey:
         assert key.extract_nonce(manifest2) == 1
         assert key.extract_nonce(manifest2) == 1
         iv = key.extract_nonce(manifest)
         iv = key.extract_nonce(manifest)
         key2 = KeyfileKey.detect(self.MockRepository(), manifest)
         key2 = KeyfileKey.detect(self.MockRepository(), manifest)
-        assert bytes_to_long(key2.enc_cipher.iv, 8) == iv + num_aes_blocks(len(manifest) - KeyfileKey.PAYLOAD_OVERHEAD)
+        assert bytes_to_long(key2.enc_cipher.iv, 8) >= iv + num_aes_blocks(len(manifest) - KeyfileKey.PAYLOAD_OVERHEAD)
         # Key data sanity check
         # Key data sanity check
         assert len({key2.id_key, key2.enc_key, key2.enc_hmac_key}) == 3
         assert len({key2.id_key, key2.enc_key, key2.enc_hmac_key}) == 3
         assert key2.chunk_seed != 0
         assert key2.chunk_seed != 0
         chunk = Chunk(b'foo')
         chunk = Chunk(b'foo')
         assert chunk == key2.decrypt(key.id_hash(chunk.data), key.encrypt(chunk))
         assert chunk == key2.decrypt(key.id_hash(chunk.data), key.encrypt(chunk))
 
 
+    def test_keyfile_nonce_rollback_protection(self, monkeypatch, keys_dir):
+        monkeypatch.setenv('BORG_PASSPHRASE', 'test')
+        repository = self.MockRepository()
+        with open(os.path.join(get_nonces_dir(), repository.id_str), "w") as fd:
+            fd.write("0000000000002000")
+        key = KeyfileKey.create(repository, self.MockArgs())
+        data = key.encrypt(Chunk(b'ABC'))
+        assert key.extract_nonce(data) == 0x2000
+        assert key.decrypt(None, data).data == b'ABC'
+
     def test_keyfile_kfenv(self, tmpdir, monkeypatch):
     def test_keyfile_kfenv(self, tmpdir, monkeypatch):
         keyfile = tmpdir.join('keyfile')
         keyfile = tmpdir.join('keyfile')
         monkeypatch.setenv('BORG_KEY_FILE', str(keyfile))
         monkeypatch.setenv('BORG_KEY_FILE', str(keyfile))

+ 242 - 0
src/borg/testsuite/nonces.py

@@ -0,0 +1,242 @@
+import os.path
+
+import pytest
+
+from ..helpers import get_nonces_dir
+from ..key import bin_to_hex
+from ..nonces import NonceManager
+from ..remote import InvalidRPCMethod
+
+from .. import nonces  # for monkey patching NONCE_SPACE_RESERVATION
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    # Workaround for some tests (testsuite/archiver) polluting the environment
+    monkeypatch.delenv('BORG_PASSPHRASE', False)
+
+
+@pytest.fixture(autouse=True)
+def nonce_dir(tmpdir_factory, monkeypatch):
+    monkeypatch.setenv('XDG_CONFIG_HOME', tmpdir_factory.mktemp('xdg-config-home'))
+
+
+class TestNonceManager:
+
+    class MockRepository:
+        class _Location:
+            orig = '/some/place'
+
+        _location = _Location()
+        id = bytes(32)
+        id_str = bin_to_hex(id)
+
+        def get_free_nonce(self):
+            return self.next_free
+
+        def commit_nonce_reservation(self, next_unreserved, start_nonce):
+            assert start_nonce == self.next_free
+            self.next_free = next_unreserved
+
+    class MockOldRepository(MockRepository):
+        def get_free_nonce(self):
+            raise InvalidRPCMethod("")
+
+        def commit_nonce_reservation(self, next_unreserved, start_nonce):
+            pytest.fail("commit_nonce_reservation should never be called on an old repository")
+
+    class MockEncCipher:
+        def __init__(self, iv):
+            self.iv_set = False  # placeholder, this is never a valid iv
+            self.iv = iv
+
+        def reset(self, key, iv):
+            assert key is None
+            assert iv is not False
+            self.iv_set = iv
+            self.iv = iv
+
+        def expect_iv_and_advance(self, expected_iv, advance):
+            expected_iv = expected_iv.to_bytes(16, byteorder='big')
+            iv_set = self.iv_set
+            assert iv_set == expected_iv
+            self.iv_set = False
+            self.iv = advance.to_bytes(16, byteorder='big')
+
+        def expect_no_reset_and_advance(self, advance):
+            iv_set = self.iv_set
+            assert iv_set is False
+            self.iv = advance.to_bytes(16, byteorder='big')
+
+    def setUp(self):
+        self.repository = None
+
+    def cache_nonce(self):
+        with open(os.path.join(get_nonces_dir(), self.repository.id_str), "r") as fd:
+            return fd.read()
+
+    def set_cache_nonce(self, nonce):
+        with open(os.path.join(get_nonces_dir(), self.repository.id_str), "w") as fd:
+            assert fd.write(nonce)
+
+    def test_empty_cache_and_old_server(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x2000)
+        self.repository = self.MockOldRepository()
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2013)
+
+        assert self.cache_nonce() == "0000000000002033"
+
+    def test_empty_cache(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x2000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = 0x2000
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2013)
+
+        assert self.cache_nonce() == "0000000000002033"
+
+    def test_empty_nonce(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x2000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = None
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+        # enough space in reservation
+        manager.ensure_reservation(13)
+        enc_cipher.expect_no_reset_and_advance(0x2000 + 19 + 13)
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+        # just barely enough space in reservation
+        manager.ensure_reservation(19)
+        enc_cipher.expect_no_reset_and_advance(0x2000 + 19 + 13 + 19)
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+        # no space in reservation
+        manager.ensure_reservation(16)
+        enc_cipher.expect_no_reset_and_advance(0x2000 + 19 + 13 + 19 + 16)
+        assert self.cache_nonce() == "0000000000002063"
+        assert self.repository.next_free == 0x2063
+
+        # spans reservation boundary
+        manager.ensure_reservation(64)
+        enc_cipher.expect_no_reset_and_advance(0x2000 + 19 + 13 + 19 + 16 + 64)
+        assert self.cache_nonce() == "00000000000020c3"
+        assert self.repository.next_free == 0x20c3
+
+    def test_sync_nonce(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x2000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = 0x2000
+        self.set_cache_nonce("0000000000002000")
+
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+    def test_server_just_upgraded(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x2000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = None
+        self.set_cache_nonce("0000000000002000")
+
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+    def test_transaction_abort_no_cache(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x1000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = 0x2000
+
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+    def test_transaction_abort_old_server(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x1000)
+        self.repository = self.MockOldRepository()
+        self.set_cache_nonce("0000000000002000")
+
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+
+    def test_transaction_abort_on_other_client(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x1000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = 0x2000
+        self.set_cache_nonce("0000000000001000")
+
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+    def test_interleaved(self, monkeypatch):
+        monkeypatch.setattr(nonces, 'NONCE_SPACE_RESERVATION', 0x20)
+
+        enc_cipher = self.MockEncCipher(0x2000)
+        self.repository = self.MockRepository()
+        self.repository.next_free = 0x2000
+        self.set_cache_nonce("0000000000002000")
+
+        manager = NonceManager(self.repository, enc_cipher, 0x2000)
+        manager.ensure_reservation(19)
+        enc_cipher.expect_iv_and_advance(0x2000, 0x2000 + 19)
+
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x2033
+
+        # somehow the clients unlocks, another client reserves and this client relocks
+        self.repository.next_free = 0x4000
+
+        # enough space in reservation
+        manager.ensure_reservation(12)
+        enc_cipher.expect_no_reset_and_advance(0x2000 + 19 + 12)
+        assert self.cache_nonce() == "0000000000002033"
+        assert self.repository.next_free == 0x4000
+
+        # spans reservation boundary
+        manager.ensure_reservation(21)
+        enc_cipher.expect_iv_and_advance(0x4000, 0x4000 + 21)
+        assert self.cache_nonce() == "0000000000004035"
+        assert self.repository.next_free == 0x4035

+ 42 - 0
src/borg/testsuite/repository.py

@@ -390,6 +390,48 @@ class RepositoryFreeSpaceTestCase(RepositoryTestCaseBase):
                 self.repository.commit()
                 self.repository.commit()
 
 
 
 
+class NonceReservation(RepositoryTestCaseBase):
+    def test_get_free_nonce_asserts(self):
+        self.reopen(exclusive=False)
+        with pytest.raises(AssertionError):
+            with self.repository:
+                self.repository.get_free_nonce()
+
+    def test_get_free_nonce(self):
+        with self.repository:
+            assert self.repository.get_free_nonce() is None
+
+            with open(os.path.join(self.repository.path, "nonce"), "w") as fd:
+                fd.write("0000000000000000")
+            assert self.repository.get_free_nonce() == 0
+
+            with open(os.path.join(self.repository.path, "nonce"), "w") as fd:
+                fd.write("5000000000000000")
+            assert self.repository.get_free_nonce() == 0x5000000000000000
+
+    def test_commit_nonce_reservation_asserts(self):
+        self.reopen(exclusive=False)
+        with pytest.raises(AssertionError):
+            with self.repository:
+                self.repository.commit_nonce_reservation(0x200, 0x100)
+
+    def test_commit_nonce_reservation(self):
+        with self.repository:
+            with pytest.raises(Exception):
+                self.repository.commit_nonce_reservation(0x200, 15)
+
+            self.repository.commit_nonce_reservation(0x200, None)
+            with open(os.path.join(self.repository.path, "nonce"), "r") as fd:
+                assert fd.read() == "0000000000000200"
+
+            with pytest.raises(Exception):
+                self.repository.commit_nonce_reservation(0x200, 15)
+
+            self.repository.commit_nonce_reservation(0x400, 0x200)
+            with open(os.path.join(self.repository.path, "nonce"), "r") as fd:
+                assert fd.read() == "0000000000000400"
+
+
 class RepositoryAuxiliaryCorruptionTestCase(RepositoryTestCaseBase):
 class RepositoryAuxiliaryCorruptionTestCase(RepositoryTestCaseBase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()