mirror of
https://github.com/borgbackup/borg.git
synced 2026-03-25 20:04:52 -04:00
Reusing the nonce totally breaks AES-CTR confidentiality. This code uses a reservation of nonce space and stores the next nonce available for a future reservation on the client and in the repository. Local storage is needed to protect against evil repositories that try to gain access to encrypted data by not saving nonce reservations and aborting the connection or otherwise forcing a rollback. Storage in the repository is needed to protect against another client writing to the repository after a transaction was aborted and thus not seeing the last used nonce from the manifest. With a real counter mode cipher protection for the multiple client case with an actively evil repository is not possible. But this still protects against cases where the attacker can not arbitrarily change the repository but can read everything stored and abort connections or crash the server. Fixes #22
87 lines
4.4 KiB
Python
87 lines
4.4 KiB
Python
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
|