fix(release): validate service-only systemd credential ACLs
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Strict systemd credential metadata, including service-only POSIX ACLs."""
|
||||
|
||||
import os
|
||||
import stat
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from . import preflight
|
||||
from .policy import DeliveryError
|
||||
|
||||
ACL_INPUTS = {
|
||||
"kf-coordinator.service": (1201, {"coordinator.json"}),
|
||||
"kf-worker.service": (1202, {"worker.json"}),
|
||||
"kf-postgres-initialize.service": (1203, {"bootstrap"}),
|
||||
"kf-postgres-recover.service": (1203, {"bootstrap", "connections"}),
|
||||
}
|
||||
|
||||
|
||||
def validate_acl(contents: bytes, uid: int) -> None:
|
||||
# Linux POSIX ACL xattr v2: owner and exactly this user may read;
|
||||
# the owning group and other users have no rights. The group mode bits
|
||||
# describe the ACL mask, rather than an owning-group permission grant.
|
||||
undefined = 0xFFFFFFFF
|
||||
entries = [
|
||||
(1, 4, undefined),
|
||||
(2, 4, uid),
|
||||
(4, 0, undefined),
|
||||
(16, 4, undefined),
|
||||
(32, 0, undefined),
|
||||
]
|
||||
expected = struct.pack("<I", 2) + b"".join(struct.pack("<HHI", *row) for row in entries)
|
||||
if contents != expected:
|
||||
raise DeliveryError("service-only credential ACL differs")
|
||||
|
||||
|
||||
def validate_metadata(path: Path, uid: int, maximum: int) -> os.stat_result:
|
||||
"""Call after the fixed service identity/cgroup/budgets have been verified."""
|
||||
info = path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
or info.st_uid not in {0, uid}
|
||||
or not 1 <= info.st_size <= maximum
|
||||
):
|
||||
raise DeliveryError("private bounded systemd credential required")
|
||||
mode = stat.S_IMODE(info.st_mode)
|
||||
if mode in {0o400, 0o600}:
|
||||
return info
|
||||
bound_uid, members = ACL_INPUTS.get(path.parent.name, (None, set()))
|
||||
if (
|
||||
mode != 0o440
|
||||
or (info.st_uid, info.st_gid) != (0, 0)
|
||||
or path.parent.parent != Path("/run/credentials")
|
||||
or uid != bound_uid
|
||||
or path.name not in members
|
||||
or not os.statvfs(path).f_flag & os.ST_RDONLY
|
||||
):
|
||||
raise DeliveryError("private bounded systemd credential required")
|
||||
preflight.protected(path.parent, directory=True)
|
||||
validate_acl(os.getxattr(path, "system.posix_acl_access"), uid)
|
||||
return info
|
||||
@@ -9,7 +9,15 @@ import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import configuration, dependencies, dependency_install, install, preflight, service_install
|
||||
from . import (
|
||||
configuration,
|
||||
credentials,
|
||||
dependencies,
|
||||
dependency_install,
|
||||
install,
|
||||
preflight,
|
||||
service_install,
|
||||
)
|
||||
from .policy import DeliveryError, canonical, digest, fields, read_json, sha256
|
||||
from .services import python_environment
|
||||
|
||||
@@ -93,14 +101,8 @@ def run_in_unit() -> dict:
|
||||
interfaces = Path("/proc/net/dev").read_text().splitlines()[2:]
|
||||
if {line.split(":", 1)[0].strip() for line in interfaces} != {"lo"}:
|
||||
raise DeliveryError("network-isolated database initializer required")
|
||||
credential = CREDENTIAL.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(credential.st_mode)
|
||||
or credential.st_nlink != 1
|
||||
or credential.st_uid not in {0, 1203}
|
||||
or stat.S_IMODE(credential.st_mode) not in {0o400, 0o600}
|
||||
or credential.st_size != 65
|
||||
):
|
||||
credential = credentials.validate_metadata(CREDENTIAL, 1203, 65)
|
||||
if credential.st_size != 65:
|
||||
raise DeliveryError("private database initialization credential required")
|
||||
validate_password(CREDENTIAL.read_bytes())
|
||||
if DATA.exists() or DATA.is_symlink():
|
||||
@@ -173,7 +175,12 @@ def initialize(password_file: Path, password_sha: str) -> dict:
|
||||
validate_password(password_file.read_bytes())
|
||||
selected, original = service_install.current_receipt()
|
||||
payload = install.RELEASES / selected["release_id"] / "payload"
|
||||
for name in ("dependencies.py", "dependency_install.py", "dependency_initialize.py"):
|
||||
for name in (
|
||||
"credentials.py",
|
||||
"dependencies.py",
|
||||
"dependency_install.py",
|
||||
"dependency_initialize.py",
|
||||
):
|
||||
shipped = payload / "delivery/scripts/release" / name
|
||||
preflight.protected(shipped)
|
||||
if digest(shipped) != digest(Path(__file__).parent / name):
|
||||
|
||||
@@ -11,7 +11,7 @@ import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from . import configuration, database, dependencies, dependency_initialize, preflight
|
||||
from . import configuration, credentials, database, dependencies, dependency_initialize, preflight
|
||||
from .policy import DeliveryError, canonical, read_json
|
||||
|
||||
UNIT = "kf-postgres-recover.service"
|
||||
@@ -300,15 +300,7 @@ def run_in_unit() -> dict:
|
||||
raise DeliveryError("identity recovery refuses standby/replay signal files")
|
||||
dependency_initialize.cluster_identity()
|
||||
for name, maximum in (("connections", 16384), ("bootstrap", 65)):
|
||||
info = (CREDENTIALS / name).lstat()
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
or info.st_uid not in {0, 1203}
|
||||
or stat.S_IMODE(info.st_mode) not in {0o400, 0o600}
|
||||
or info.st_size > maximum
|
||||
):
|
||||
raise DeliveryError("private recovery credential identity differs")
|
||||
credentials.validate_metadata(CREDENTIALS / name, 1203, maximum)
|
||||
endpoints = configuration.validate_connections(read_json(CREDENTIALS / "connections", 16384))
|
||||
raw = (CREDENTIALS / "bootstrap").read_bytes()
|
||||
dependency_initialize.validate_password(raw)
|
||||
|
||||
@@ -8,7 +8,7 @@ import resource
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from . import preflight
|
||||
from . import credentials, preflight
|
||||
from .policy import DeliveryError, sha256
|
||||
from .services import PROFILES
|
||||
|
||||
@@ -193,15 +193,7 @@ def python_command(role: str, release: str) -> tuple[list[str], dict]:
|
||||
|
||||
|
||||
def read_credential(path: Path, expected: str, uid: int) -> bytes:
|
||||
info = path.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
or info.st_uid not in {0, uid}
|
||||
or stat.S_IMODE(info.st_mode) not in {0o400, 0o600}
|
||||
or not 1 <= info.st_size <= 32768
|
||||
):
|
||||
raise DeliveryError("private bounded systemd credential required")
|
||||
info = credentials.validate_metadata(path, uid, 32768)
|
||||
content = path.read_bytes()
|
||||
if len(content) != info.st_size or hashlib.sha256(content).hexdigest() != sha256(expected):
|
||||
raise DeliveryError("service credential fingerprint mismatch")
|
||||
|
||||
Reference in New Issue
Block a user