103 lines
3.9 KiB
Python
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
|