from __future__ import annotations import os import stat from pathlib import Path from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM from backup_tool.config import Settings from backup_tool.ids import new_uuid7 class RepositoryKeyError(ValueError): pass def _directory(settings: Settings) -> Path: directory = settings.data_dir / "repository-data-keys" directory.mkdir(mode=0o700, exist_ok=True) if ( directory.is_symlink() or not directory.is_dir() or stat.S_IMODE(directory.stat().st_mode) != 0o700 ): raise RepositoryKeyError("repository data key directory is unsafe") return directory def create_data_key(settings: Settings, repository_id: str) -> tuple[str, Path]: key_id = str(new_uuid7()) directory = _directory(settings) path = directory / f"{repository_id}.{key_id}.key" try: descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(descriptor, "wb") as handle: handle.write(os.urandom(32)) handle.flush() os.fsync(handle.fileno()) except OSError as error: raise RepositoryKeyError("repository data key cannot be created") from error if stat.S_IMODE(path.stat().st_mode) != 0o600 or path.is_symlink(): path.unlink(missing_ok=True) raise RepositoryKeyError("repository data key is unsafe") return key_id, path def install_data_key(settings: Settings, repository_id: str, key_id: str, key: bytes) -> Path: """Install recovered key material once; never replace an existing key file.""" if len(key) != 32 or "/" in repository_id or "/" in key_id: raise RepositoryKeyError("repository data key is invalid") path = _directory(settings) / f"{repository_id}.{key_id}.key" flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW try: descriptor = os.open(path, flags, 0o600) with os.fdopen(descriptor, "wb") as handle: handle.write(key) handle.flush() os.fsync(handle.fileno()) if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600: path.unlink(missing_ok=True) raise RepositoryKeyError("repository data key is unsafe") except OSError as error: raise RepositoryKeyError("repository data key cannot be installed") from error return path def object_aad(repository_id: str, key_id: str, kind: str, identity: str) -> bytes: if kind not in {"blob", "manifest"} or not all((repository_id, key_id, identity)): raise RepositoryKeyError("encrypted object metadata is invalid") return f"BTENC:1:{repository_id}:{key_id}:{kind}:{identity}".encode() def encrypt_object(key: bytes, aad: bytes, plaintext: bytes) -> bytes: if len(key) != 32: raise RepositoryKeyError("repository data key is unavailable") nonce = os.urandom(12) return b"BTENC\x01" + nonce + AESGCM(key).encrypt(nonce, plaintext, aad) def decrypt_object(key: bytes, aad: bytes, stored: bytes) -> bytes: if len(key) != 32 or not stored.startswith(b"BTENC\x01") or len(stored) < 35: raise RepositoryKeyError("encrypted object is invalid") try: return AESGCM(key).decrypt(stored[6:18], stored[18:], aad) except InvalidTag as error: raise RepositoryKeyError("encrypted object is invalid") from error def load_data_key(settings: Settings, repository_id: str, key_id: str) -> bytes: path = _directory(settings) / f"{repository_id}.{key_id}.key" try: if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o600: raise RepositoryKeyError("repository data key is unsafe") key = path.read_bytes() except OSError as error: raise RepositoryKeyError("repository data key is unavailable") from error if len(key) != 32: raise RepositoryKeyError("repository data key is unavailable") return key