206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Crash-safe, metadata-preserving host account database I/O."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import os
|
|
import stat
|
|
import time
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
|
|
MAX_XATTRS = 64
|
|
MAX_XATTR_NAME = 255
|
|
MAX_XATTR_VALUE = 256 * 1024
|
|
MAX_XATTR_TOTAL = 1024 * 1024
|
|
LOCK_TIMEOUT_SECONDS = 15.0
|
|
|
|
|
|
class HardeningError(RuntimeError):
|
|
"""Raised before an unsafe or ambiguous host-account change."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FileSnapshot:
|
|
"""Bounded file contents and all security-relevant inode metadata."""
|
|
|
|
value: bytes
|
|
device: int
|
|
inode: int
|
|
mode: int
|
|
uid: int
|
|
gid: int
|
|
size: int
|
|
mtime_ns: int
|
|
ctime_ns: int
|
|
xattrs: tuple[tuple[str, bytes], ...]
|
|
|
|
|
|
def _stat_identity(item: os.stat_result) -> tuple[int, ...]:
|
|
return (
|
|
item.st_dev,
|
|
item.st_ino,
|
|
item.st_mode,
|
|
item.st_uid,
|
|
item.st_gid,
|
|
item.st_size,
|
|
item.st_mtime_ns,
|
|
item.st_ctime_ns,
|
|
)
|
|
|
|
|
|
def _xattrs(path_or_fd: Path | int) -> tuple[tuple[str, bytes], ...]:
|
|
try:
|
|
names = sorted(os.listxattr(path_or_fd, follow_symlinks=False))
|
|
except (TypeError, ValueError):
|
|
names = sorted(os.listxattr(path_or_fd))
|
|
if len(names) > MAX_XATTRS:
|
|
raise HardeningError("host account file has too many extended attributes")
|
|
values: list[tuple[str, bytes]] = []
|
|
total = 0
|
|
for name in names:
|
|
if len(name.encode("utf-8")) > MAX_XATTR_NAME:
|
|
raise HardeningError("host account file xattr name is too long")
|
|
try:
|
|
value = os.getxattr(path_or_fd, name, follow_symlinks=False)
|
|
except (TypeError, ValueError):
|
|
value = os.getxattr(path_or_fd, name)
|
|
total += len(value)
|
|
if len(value) > MAX_XATTR_VALUE or total > MAX_XATTR_TOTAL:
|
|
raise HardeningError("host account file xattrs exceed the safe limit")
|
|
values.append((name, value))
|
|
return tuple(values)
|
|
|
|
|
|
def _restore_xattrs(descriptor: int, expected: tuple[tuple[str, bytes], ...]) -> None:
|
|
expected_names = {name for name, _value in expected}
|
|
for name, _value in _xattrs(descriptor):
|
|
if name not in expected_names:
|
|
os.removexattr(descriptor, name)
|
|
for name, value in expected:
|
|
os.setxattr(descriptor, name, value)
|
|
|
|
|
|
def read_regular(path: Path, maximum: int) -> FileSnapshot:
|
|
"""Read one bounded, non-symlink regular file and capture its metadata."""
|
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
descriptor = os.open(path, flags)
|
|
try:
|
|
metadata = os.fstat(descriptor)
|
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
|
|
raise HardeningError(f"unsafe regular file: {path.name}")
|
|
value = os.read(descriptor, maximum + 1)
|
|
attrs = _xattrs(descriptor)
|
|
after = os.fstat(descriptor)
|
|
finally:
|
|
os.close(descriptor)
|
|
if len(value) != metadata.st_size or _stat_identity(metadata) != _stat_identity(after):
|
|
raise HardeningError(f"short file read: {path.name}")
|
|
return FileSnapshot(
|
|
value=value,
|
|
device=metadata.st_dev,
|
|
inode=metadata.st_ino,
|
|
mode=stat.S_IMODE(metadata.st_mode),
|
|
uid=metadata.st_uid,
|
|
gid=metadata.st_gid,
|
|
size=metadata.st_size,
|
|
mtime_ns=metadata.st_mtime_ns,
|
|
ctime_ns=metadata.st_ctime_ns,
|
|
xattrs=attrs,
|
|
)
|
|
|
|
|
|
def assert_unchanged(path: Path, expected: FileSnapshot, maximum: int) -> None:
|
|
"""Fail if an account file changed since planning began."""
|
|
current = read_regular(path, maximum)
|
|
if current != expected:
|
|
raise HardeningError(f"concurrent host account change detected: {path.name}")
|
|
|
|
|
|
def atomic_write(path: Path, value: bytes, metadata: FileSnapshot) -> None:
|
|
"""Atomically replace a file while retaining ACLs, labels, and xattrs."""
|
|
temporary = path.with_name(f".{path.name}.hermes-{os.getpid()}")
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
descriptor = os.open(temporary, flags, metadata.mode)
|
|
try:
|
|
if os.write(descriptor, value) != len(value):
|
|
raise HardeningError(f"short atomic write: {path.name}")
|
|
os.fchmod(descriptor, metadata.mode)
|
|
os.fchown(descriptor, metadata.uid, metadata.gid)
|
|
_restore_xattrs(descriptor, metadata.xattrs)
|
|
os.fsync(descriptor)
|
|
except Exception:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
finally:
|
|
os.close(descriptor)
|
|
os.replace(temporary, path)
|
|
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
|
|
|
|
def backup_once(path: Path, snapshot: FileSnapshot, maximum: int) -> Path:
|
|
"""Create the first durable recovery copy without replacing an older one."""
|
|
backup = path.with_name(path.name + ".hermes-boundary-backup")
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
try:
|
|
descriptor = os.open(backup, flags, snapshot.mode)
|
|
except FileExistsError:
|
|
read_regular(backup, maximum)
|
|
return backup
|
|
try:
|
|
if os.write(descriptor, snapshot.value) != len(snapshot.value):
|
|
raise HardeningError(f"short backup write: {path.name}")
|
|
os.fchmod(descriptor, snapshot.mode)
|
|
os.fchown(descriptor, snapshot.uid, snapshot.gid)
|
|
_restore_xattrs(descriptor, snapshot.xattrs)
|
|
os.fsync(descriptor)
|
|
except Exception:
|
|
backup.unlink(missing_ok=True)
|
|
raise
|
|
finally:
|
|
os.close(descriptor)
|
|
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
return backup
|
|
|
|
|
|
@contextmanager
|
|
def account_lock(host_etc: Path, *, expected_uid: int = 0) -> Iterator[None]:
|
|
"""Hold the standard shadow-utils account lock for the full transaction."""
|
|
path = host_etc / ".pwd.lock"
|
|
flags = os.O_WRONLY | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
descriptor = os.open(path, flags, 0o600)
|
|
try:
|
|
metadata = os.fstat(descriptor)
|
|
if (
|
|
not stat.S_ISREG(metadata.st_mode)
|
|
or metadata.st_uid != expected_uid
|
|
or stat.S_IMODE(metadata.st_mode) != 0o600
|
|
):
|
|
raise HardeningError("unsafe standard account lock")
|
|
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
|
|
while True:
|
|
try:
|
|
fcntl.lockf(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
break
|
|
except BlockingIOError as exc:
|
|
if time.monotonic() >= deadline:
|
|
raise HardeningError("standard account lock is busy") from exc
|
|
time.sleep(0.05)
|
|
yield
|
|
finally:
|
|
try:
|
|
fcntl.lockf(descriptor, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(descriptor)
|