Files
backup-tool/backend/src/backup_tool/security/repository_crypto.py
T

103 lines
3.9 KiB
Python

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