fix(release): validate service-only systemd credential ACLs

This commit is contained in:
2026-10-14 03:54:07 +08:00
parent be22dd9c12
commit 5ff88047ff
5 changed files with 174 additions and 30 deletions
+92
View File
@@ -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("<I", 2) + b"".join(
struct.pack("<HHI", *row)
for row in [
(1, owner, 0xFFFFFFFF),
(2, user, uid),
(4, group, 0xFFFFFFFF),
(16, mask, 0xFFFFFFFF),
(32, other, 0xFFFFFFFF),
]
)
@pytest.mark.parametrize("uid", [1201, 1202, 1203])
def test_credential_acl_has_exactly_one_service_reader(uid):
assert credentials.validate_acl(credential_acl(uid), uid) is None
@pytest.mark.parametrize(
"raw",
[
b"",
credential_acl()[:-1],
b"\x01" + credential_acl()[1:],
credential_acl(1202),
credential_acl(owner=6),
credential_acl(user=6),
credential_acl(group=4),
credential_acl(mask=7),
credential_acl(other=4),
credential_acl() + struct.pack("<HHI", 2, 4, 9999),
],
)
def test_credential_acl_refuses_extra_readers_writers_or_noncanonical_acl(raw):
with pytest.raises(policy.DeliveryError, match="credential ACL differs"):
credentials.validate_acl(raw, 1203)
def credential_metadata(monkeypatch, **overrides):
values = dict(st_mode=stat.S_IFREG | 0o440, st_nlink=1, st_uid=0, st_gid=0, st_size=65)
values.update(overrides)
monkeypatch.setattr(Path, "lstat", lambda _: SimpleNamespace(**values))
monkeypatch.setattr(credentials.os, "statvfs", lambda _: SimpleNamespace(f_flag=os.ST_RDONLY))
monkeypatch.setattr(credentials.os, "getxattr", lambda *args: credential_acl())
monkeypatch.setattr(credentials.preflight, "protected", lambda *args, **kwargs: None)
def test_acl_credential_is_limited_to_fixed_readonly_systemd_binding(monkeypatch):
credential_metadata(monkeypatch)
path = Path("/run/credentials/kf-postgres-initialize.service/bootstrap")
assert credentials.validate_metadata(path, 1203, 65).st_size == 65
monkeypatch.setattr(credentials.os, "statvfs", lambda _: SimpleNamespace(f_flag=0))
with pytest.raises(policy.DeliveryError, match="bounded systemd credential"):
credentials.validate_metadata(path, 1203, 65)
@pytest.mark.parametrize(
"path,uid",
[
("/tmp/kf-postgres-initialize.service/bootstrap", 1203),
("/run/credentials/foreign.service/bootstrap", 1203),
("/run/credentials/kf-postgres-initialize.service/foreign", 1203),
("/run/credentials/kf-postgres-initialize.service/bootstrap", 1202),
],
)
def test_acl_credential_refuses_wrong_directory_member_or_role(monkeypatch, path, uid):
credential_metadata(monkeypatch)
with pytest.raises(policy.DeliveryError, match="bounded systemd credential"):
credentials.validate_metadata(Path(path), uid, 65)
@pytest.mark.parametrize(
"overrides",
[
{"st_uid": 1203},
{"st_gid": 1203},
{"st_nlink": 2},
{"st_size": 66},
{"st_mode": stat.S_IFREG | 0o444},
{"st_mode": stat.S_IFLNK | 0o440},
],
)
def test_acl_credential_refuses_unknown_file_identity_or_bounds(monkeypatch, overrides):
credential_metadata(monkeypatch, **overrides)
with pytest.raises(policy.DeliveryError, match="bounded systemd credential"):
credentials.validate_metadata(
Path("/run/credentials/kf-postgres-initialize.service/bootstrap"), 1203, 65
)
def test_database_initialization_receipt_does_not_prove_migration_or_authentication():
receipt = {
"schema_version": 1,
+61
View File
@@ -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
+17 -10
View File
@@ -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):
+2 -10
View File
@@ -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)
+2 -10
View File
@@ -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")