diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index 88a871c..0f479c2 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -5,6 +5,7 @@ import json import os import secrets import stat +import struct import subprocess import sys from contextlib import nullcontext @@ -29,6 +30,7 @@ factory_service = importlib.import_module("scripts.release.factory_service") dependencies = importlib.import_module("scripts.release.dependencies") dependency_install = importlib.import_module("scripts.release.dependency_install") dependency_initialize = importlib.import_module("scripts.release.dependency_initialize") +credentials = importlib.import_module("scripts.release.credentials") node_health = importlib.import_module("scripts.release.node_health") broker_health = importlib.import_module("scripts.release.broker_health") lifecycle = importlib.import_module("scripts.release.lifecycle") @@ -1240,6 +1242,96 @@ def test_database_initializer_refuses_empty_multiline_or_noncanonical_password(c dependency_initialize.validate_password(contents) +def credential_acl(uid=1203, *, owner=4, user=4, group=0, mask=4, other=0): + return struct.pack(" 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(" 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 diff --git a/scripts/release/dependency_initialize.py b/scripts/release/dependency_initialize.py index 8896019..0ae8f1d 100644 --- a/scripts/release/dependency_initialize.py +++ b/scripts/release/dependency_initialize.py @@ -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): diff --git a/scripts/release/recovery_identity.py b/scripts/release/recovery_identity.py index 616a0e0..df40295 100644 --- a/scripts/release/recovery_identity.py +++ b/scripts/release/recovery_identity.py @@ -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) diff --git a/scripts/release/service_bootstrap.py b/scripts/release/service_bootstrap.py index 8b35be6..805928e 100644 --- a/scripts/release/service_bootstrap.py +++ b/scripts/release/service_bootstrap.py @@ -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")