Files

514 lines
20 KiB
Python

"""Narrow privileged host-credential backend for systemd 255.
Run only through the root-owned installed launcher. Administrative protect and
revoke operations use ordinary sudo authorization. Each protected credential
gets one literal, owner-specific sudoers decrypt command; there is no general
sudo permission for systemd-creds. No owner-controlled paths are opened here.
"""
from __future__ import annotations
import base64
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import resource
import secrets
import select
import stat
import subprocess
import sys
import time
ROOT = Path("/var/lib/framebridge-expanded/credentials")
SUDOERS = Path("/etc/sudoers.d")
LAUNCHER = Path("/usr/lib/framebridge-expanded/bin/framebridge-credential-helper")
CODE = Path("/usr/lib/framebridge-expanded/expanded/framebridge_expanded/credential_helper.py")
CREDS = "/usr/bin/systemd-creds"
VISUDO = "/usr/sbin/visudo"
HEX = re.compile(r"[0-9a-f]{64}\Z")
USER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}\$?\Z")
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
MAX_KEY = 16384
MAX_CIPHER = 131072
INPUT_TIMEOUT = 5
def _fail():
raise RuntimeError("The protected credential operation could not be verified.")
def _root_path(path, *, directory=False, private=False):
"""Check every path component; root-owned parents cannot be swapped by users."""
path = Path(path)
if not path.is_absolute():
_fail()
for item in reversed((path, *path.parents)):
info = item.lstat()
last = item == path
if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
_fail()
if not last or directory:
if not stat.S_ISDIR(info.st_mode):
_fail()
elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
_fail()
if last and private and info.st_mode & 0o077:
_fail()
return path
def _installation():
if os.geteuid() != 0 or not sys.flags.isolated or not sys.flags.no_site:
_fail()
if Path(__file__).absolute() != CODE:
_fail()
_root_path(CODE)
_root_path(LAUNCHER)
_root_path(Path(CREDS))
_root_path(Path(VISUDO))
def _caller():
raw = os.environ.get("SUDO_UID", "")
if not raw.isascii() or not raw.isdecimal() or str(int(raw)) != raw or int(raw) <= 0:
_fail()
owner = pwd.getpwuid(int(raw))
if not USER.fullmatch(owner.pw_name) or os.environ.get("SUDO_USER") != owner.pw_name:
_fail()
# sudo sets these identity variables, and generated rules explicitly deny
# SETENV. There is deliberately no caller-selected UID command argument.
if os.environ.get("SUDO_GID") != str(owner.pw_gid):
_fail()
return owner
def _run(args, data=None):
result = subprocess.run(args, input=data, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=ENV, cwd="/",
close_fds=True, timeout=30)
if result.returncode:
_fail()
return result.stdout
def _sync(directory):
fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def _read_key():
# The frontend has already collected the key before launching this helper.
# A caller holding stdin open must not retain the shared registry lock.
deadline = time.monotonic() + INPUT_TIMEOUT
fd = sys.stdin.fileno()
value = bytearray()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0 or not select.select([fd], [], [], remaining)[0]:
_fail()
chunk = os.read(fd, MAX_KEY + 1 - len(value))
if not chunk:
return bytes(value)
value.extend(chunk)
if len(value) > MAX_KEY:
_fail()
def _prepare_root():
# Only this fixed root-owned tree is created. No profile or HOME is opened.
for directory in (ROOT.parent, ROOT):
_root_path(directory.parent, directory=True)
try:
directory.mkdir(mode=0o700)
_sync(directory.parent)
except FileExistsError:
pass
_root_path(directory, directory=True, private=directory == ROOT)
_root_path(SUDOERS, directory=True)
def _read(path, maximum=200000, *, policy=False):
_root_path(path, private=not policy)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1
or info.st_mode & (0o027 if policy else 0o077)):
_fail()
data = os.read(fd, maximum + 1)
if len(data) > maximum:
_fail()
return data
finally:
os.close(fd)
def _install(path, data, *, policy=False):
_root_path(path.parent, directory=True)
temporary = path.with_name("." + path.name + "." + secrets.token_hex(12))
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if policy:
temporary.chmod(0o440)
_run([VISUDO, "-c", "-f", str(temporary)])
# link is atomic and never overwrites a pre-existing/symlink target.
os.link(temporary, path, follow_symlinks=False)
_sync(path.parent)
finally:
temporary.unlink(missing_ok=True)
_sync(path.parent)
def _cleanup_temporaries():
# Recover only this installer's root-owned, fixed-format staging files.
patterns = ((ROOT, r"\.[1-9][0-9]*-[0-9a-f]{64}-[0-9a-f]{64}\.json\.[0-9a-f]{24}", False),
(SUDOERS, r"\.framebridge-credential-[1-9][0-9]*-[0-9a-f]{64}-[0-9a-f]{64}\.[0-9a-f]{24}", True),
(SUDOERS, r"\.framebridge-credential-authorize-[1-9][0-9]*-[0-9a-f]{64}-(?:host|user)\.[0-9a-f]{24}", True))
for directory, pattern, policy in patterns:
removed = False
for path in directory.iterdir():
if re.fullmatch(pattern, path.name):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0
or info.st_mode & (0o027 if policy else 0o077)
or info.st_nlink not in {1, 2}
or info.st_size > (4096 if policy else 200000)):
_fail()
if info.st_nlink == 2:
# A crash after publish/link but before staging unlink.
published = directory / path.name[1:].rsplit(".", 1)[0]
other = published.lstat()
if (not stat.S_ISREG(other.st_mode) or other.st_uid != 0
or (other.st_dev, other.st_ino) != (info.st_dev, info.st_ino)):
_fail()
finally:
os.close(fd)
path.unlink(); removed = True
if removed:
_sync(directory)
def _ids(profile, digest=None):
if not isinstance(profile, str) or not HEX.fullmatch(profile):
_fail()
if digest is not None and (not isinstance(digest, str) or not HEX.fullmatch(digest)):
_fail()
def _paths(uid, profile, digest):
stem = str(uid) + "-" + profile + "-" + digest
return ROOT / (stem + ".json"), SUDOERS / ("framebridge-credential-" + stem)
def _policy(owner, profile, digest):
# All fields are allowlisted tokens; no glob, free path, flags or shell.
return (owner.pw_name + " ALL=(root) NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD: " + str(LAUNCHER)
+ " decrypt " + profile + " " + digest + "\n").encode("ascii")
def _authorization(owner, profile, kind):
_ids(profile)
if kind not in {"host", "user"}:
_fail()
path = SUDOERS / ("framebridge-credential-authorize-" + str(owner.pw_uid) + "-" + profile + "-" + kind)
if kind == "host":
command = str(LAUNCHER) + " protect " + profile
else:
command = CREDS + " encrypt --uid=" + str(owner.pw_uid) + " --with-key=host --name=chatgpt-runtime-key - -"
prefix = owner.pw_name + " ALL=(root) NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD: "
cancel = str(LAUNCHER) + " unauthorize " + profile + " " + kind
return path, (prefix + command + "\n" + prefix + cancel + "\n").encode("ascii")
def authorize(owner, profile, kind):
"""Administrative, no secret input: exact no-I/O-log permission only."""
path, expected = _authorization(owner, profile, kind)
if path.exists() or path.is_symlink():
if _read(path, 4096, policy=True) != expected:
_fail()
return
_install(path, expected, policy=True)
def unauthorize(owner, profile, kind, *, required=False):
path, expected = _authorization(owner, profile, kind)
if not path.exists() and not path.is_symlink():
if required:
_fail()
return
if _read(path, 4096, policy=True) != expected:
_fail()
path.unlink()
_sync(SUDOERS)
def _record(owner, profile, digest, *, require_policy=True):
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = json.loads(_read(record_path))
if (type(value) is not dict or value.get("schema") != 1
or value.get("uid") != owner.pw_uid or value.get("username") != owner.pw_name
or value.get("home") != owner.pw_dir or value.get("profile") != profile
or value.get("sha256") != digest):
_fail()
name = value.get("name", "")
if not re.fullmatch(r"framebridge-" + str(owner.pw_uid) + "-" + profile + r"-[0-9a-f]{32}", name):
_fail()
try:
cipher = base64.b64decode(value["cipher"], validate=True)
except (KeyError, TypeError, ValueError):
_fail()
if not 80 <= len(cipher) <= MAX_CIPHER or hashlib.sha256(cipher).hexdigest() != digest:
_fail()
if require_policy and _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
return value, cipher, record_path, policy_path
def protect(owner, profile, key):
_ids(profile)
if (not key or len(key) > MAX_KEY or any(c < 33 or c > 126 for c in key)):
_fail()
name = "framebridge-" + str(owner.pw_uid) + "-" + profile + "-" + secrets.token_hex(16)
try:
cipher = _run([CREDS, "encrypt", "--with-key=host", "--name=" + name, "-", "-"], key)
finally:
key = b""
if not 80 <= len(cipher) <= MAX_CIPHER:
_fail()
digest = hashlib.sha256(cipher).hexdigest()
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = {"schema": 1, "uid": owner.pw_uid, "username": owner.pw_name,
"home": owner.pw_dir, "profile": profile, "name": name,
"sha256": digest, "cipher": base64.b64encode(cipher).decode("ascii")}
data = (json.dumps(value, sort_keys=True) + "\n").encode("ascii")
_install(record_path, data)
try:
_install(policy_path, _policy(owner, profile, digest), policy=True)
except BaseException:
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
policy_path.unlink()
_sync(SUDOERS)
record_path.unlink()
_sync(ROOT)
raise
return cipher
def decrypt(owner, profile, digest):
_ids(profile, digest)
value, cipher, _, _ = _record(owner, profile, digest)
key = _run([CREDS, "decrypt", "--name=" + value["name"], "-", "-"], cipher)
if not key or len(key) > MAX_KEY or any(c < 33 or c > 126 for c in key):
_fail()
return key
def revoke(owner, profile, digest):
_ids(profile, digest)
record_path, policy_path = _paths(owner.pw_uid, profile, digest)
if record_path.exists() or record_path.is_symlink():
_record(owner, profile, digest, require_policy=False)
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
# Remove authorization first. A failure cannot leave a decryption grant to
# a missing/replaced record. The lock excludes concurrent decrypt/rotation.
policy_path.unlink(missing_ok=True)
_sync(SUDOERS)
record_path.unlink(missing_ok=True)
_sync(ROOT)
def _keep_digests(value):
values = [] if value == "-" else value.split(",")
if len(values) > 128 or any(not HEX.fullmatch(v) for v in values):
_fail()
return set(values)
def prune(owner, profile, keep):
"""Administrative crash recovery: remove only this owner's profile grants."""
_ids(profile)
authorizations = []
for kind in ("host", "user"):
path, expected = _authorization(owner, profile, kind)
if path.exists() or path.is_symlink():
if _read(path, 4096, policy=True) != expected:
_fail()
authorizations.append(path)
digests = set()
prefix = str(owner.pw_uid) + "-" + profile + "-"
for path in ROOT.glob(prefix + "*"):
match = re.fullmatch(re.escape(prefix) + r"([0-9a-f]{64})\.json", path.name)
if not match:
_fail()
digest = match.group(1)
_record(owner, profile, digest, require_policy=False)
digests.add(digest)
for path in SUDOERS.glob("framebridge-credential-" + prefix + "*"):
digest = path.name.removeprefix("framebridge-credential-" + prefix)
_ids(profile, digest)
if _read(path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
digests.add(digest)
# Prevalidation above completes before any grant or encrypted record moves.
for digest in sorted(digests - keep):
revoke(owner, profile, digest)
for path in authorizations:
path.unlink()
if authorizations:
_sync(SUDOERS)
def purge_all():
"""Administrative uninstall cleanup, not permitted by decrypt sudo rules."""
_cleanup_temporaries()
entries = []
for path in sorted(ROOT.iterdir()):
if path.name == ".lock":
continue
match = re.fullmatch(r"([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})\.json", path.name)
if not match:
_fail()
uid, profile, digest = match.groups()
# A removed/reused account must not prevent revocation. Validate the
# original principal from the root-owned record without trusting passwd.
value = json.loads(_read(path))
if type(value) is not dict or not USER.fullmatch(value.get("username", "")):
_fail()
owner = type("Owner", (), {"pw_uid": int(uid), "pw_name": value["username"],
"pw_dir": value.get("home")})()
_, _, record_path, policy_path = _record(owner, profile, digest, require_policy=False)
if policy_path.exists() or policy_path.is_symlink():
if _read(policy_path, 4096, policy=True) != _policy(owner, profile, digest):
_fail()
entries.append((record_path, policy_path))
policies = []
for policy_path in sorted(SUDOERS.glob("framebridge-credential-*")):
auth = re.fullmatch(r"framebridge-credential-authorize-([1-9][0-9]*)-([0-9a-f]{64})-(host|user)", policy_path.name)
if auth:
uid, profile, kind = auth.groups()
data = _read(policy_path, 4096, policy=True)
username = data.split(b" ", 1)[0].decode("ascii")
if not USER.fullmatch(username):
_fail()
owner = type("Owner", (), {"pw_uid": int(uid), "pw_name": username})()
if data != _authorization(owner, profile, kind)[1]:
_fail()
policies.append(policy_path)
continue
match = re.fullmatch(r"framebridge-credential-([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})", policy_path.name)
if not match:
_fail()
uid, profile, digest = match.groups()
data = _read(policy_path, 4096, policy=True)
username = data.split(b" ", 1)[0].decode("ascii")
if not USER.fullmatch(username):
_fail()
owner = type("Owner", (), {"pw_name": username})()
if data != _policy(owner, profile, digest):
_fail()
policies.append(policy_path)
# Prevalidate every record and policy before removing any of them.
for policy_path in policies:
policy_path.unlink()
_sync(SUDOERS)
for record_path, _ in entries:
record_path.unlink()
_sync(ROOT)
@contextmanager
def _registry_lock():
fd = os.open(ROOT / ".lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_nlink != 1 or info.st_mode & 0o077:
_fail()
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def main(argv=None):
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
args = list(sys.argv[1:] if argv is None else argv)
try:
_installation()
if not args or args[0] not in {"protect", "decrypt", "revoke", "prune", "authorize", "unauthorize", "purge-all"}:
_fail()
operation = args[0]
if len(args) != {"protect": 2, "decrypt": 3, "revoke": 3, "prune": 3, "authorize": 3, "unauthorize": 3, "purge-all": 1}[operation]:
_fail()
owner = _caller() if operation != "purge-all" else None
if operation in {"authorize", "unauthorize"}:
_ids(args[1])
if args[2] not in {"host", "user"}:
_fail()
elif operation == "prune":
_ids(args[1]); keep = _keep_digests(args[2])
elif operation != "purge-all":
_ids(*args[1:])
_prepare_root()
result = b""
with _registry_lock():
_cleanup_temporaries()
if operation == "protect":
# sudo already selected this exact NOLOG policy. Consume its
# encryption permission before accepting any key bytes.
unauthorize(owner, args[1], "host", required=True)
key = _read_key()
result = protect(owner, args[1], key)
key = b""
elif operation == "decrypt":
result = decrypt(owner, args[1], args[2])
elif operation == "revoke":
revoke(owner, args[1], args[2])
elif operation == "prune":
prune(owner, args[1], keep)
elif operation == "authorize":
authorize(owner, args[1], args[2])
elif operation == "unauthorize":
unauthorize(owner, args[1], args[2])
else:
purge_all()
# stdout belongs to an unprivileged caller and can block indefinitely.
# Never hold the shared registry lock while writing or flushing it.
try:
if result:
sys.stdout.buffer.write(result)
sys.stdout.buffer.flush()
except BaseException:
if operation == "protect" and result:
# A broken receipt pipe must not orphan an authorization. The
# lock is reacquired only for this bounded administrative undo.
with _registry_lock():
revoke(owner, args[1], hashlib.sha256(result).hexdigest())
raise
return 0
except (Exception, KeyboardInterrupt):
# Never export subprocess output, arguments, record contents or traces.
sys.stderr.write("FrameBridge protected credential operation failed.\n")
return 1
if __name__ == "__main__":
raise SystemExit(main())