feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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
|
||||
Reference in New Issue
Block a user