fix(release): validate service-only systemd credential ACLs
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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