Protect credential transport from sudo I/O logging and report matched platforms
This commit is contained in:
1 parent
a48a20c936
commit
12a03a1f97
9 files changed
+285
-25
No files matched your search
@@ -67,14 +67,54 @@ def _profile_id(state):
|
||||
|
||||
|
||||
def _encrypt_credential(state, key, backend):
|
||||
_helper_ready()
|
||||
if backend == HOST_BACKEND:
|
||||
_helper_ready()
|
||||
args = ["sudo", "-n", "--", str(HELPER), "protect", _profile_id(state)]
|
||||
kind = "host"
|
||||
command = [str(HELPER), "protect", _profile_id(state)]
|
||||
elif backend == USER_BACKEND:
|
||||
args = ["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"]
|
||||
kind = "user"
|
||||
command = ["/usr/bin/systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"]
|
||||
else:
|
||||
raise RuntimeError("Unsupported protected-credential backend.")
|
||||
return _command(args, data=key.encode()).stdout
|
||||
# Bootstrap is administratively authorized and carries no runtime key.
|
||||
# A command-specific effective-policy check precedes ALL secret input.
|
||||
_command(["sudo", "-n", "--", str(HELPER), "authorize", _profile_id(state), kind])
|
||||
try:
|
||||
_require_no_sudo_io_logging(command)
|
||||
return _command(["sudo", "-n", "--", *command], data=key.encode()).stdout
|
||||
finally:
|
||||
# Host protect consumes its grant before reading input. A missing
|
||||
# cancel grant after that successful consume is harmless. Native user
|
||||
# encryption cannot consume the grant itself, so cancellation is strict.
|
||||
_command(["sudo", "-n", "--", str(HELPER), "unauthorize", _profile_id(state), kind], check=kind == "user")
|
||||
|
||||
|
||||
def _require_no_sudo_io_logging(command):
|
||||
environment = dict(os.environ, LANG="C", LC_ALL="C")
|
||||
result = _command(["sudo", "-n", "-ll", "--", *command], env=environment)
|
||||
error = "The effective sudo policy must explicitly disable input/output logging for this exact credential command. No runtime key was sent."
|
||||
if result.returncode or len(result.stdout) > 131072:
|
||||
raise RuntimeError(error)
|
||||
try:
|
||||
text = result.stdout.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise RuntimeError(error) from None
|
||||
# sudo 1.9's command-specific verbose list reports the effective matching
|
||||
# rule. Unknown/ambiguous formats (including other policy plugins) fail
|
||||
# closed; absence of log_* in generic Defaults is never accepted as proof.
|
||||
if text.count("Sudoers entry:") != 1 or len(re.findall(r"^\s*Matched:", text, re.M)) != 1:
|
||||
raise RuntimeError(error)
|
||||
options = re.search(r"^\s*Options:\s*(.*?)^\s*Commands:\s*$", text, re.M | re.S)
|
||||
matched = re.search(r"^\s*Matched:\s*(.*?)\s*\Z", text, re.M | re.S)
|
||||
run_as = re.findall(r"^\s*RunAsUsers:\s*(.*?)\s*$", text, re.M)
|
||||
if not options or not matched or run_as != ["root"]:
|
||||
raise RuntimeError(error)
|
||||
flags = {value.strip() for value in options[1].replace("\n", " ").split(",")}
|
||||
logs = {"log_input", "log_output", "log_stdin", "log_stdout", "log_stderr", "log_ttyin", "log_ttyout"}
|
||||
if not {"!log_input", "!log_output", "!setenv"}.issubset(flags) or logs.intersection(flags):
|
||||
raise RuntimeError(error)
|
||||
if " ".join(matched[1].split()) != " ".join(command):
|
||||
raise RuntimeError(error)
|
||||
|
||||
|
||||
def _revoke_helper(state, digest):
|
||||
@@ -176,8 +216,8 @@ def _json(path, value, replace=False):
|
||||
_write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace)
|
||||
|
||||
|
||||
def _command(args, *, data=None, timeout=35, check=True):
|
||||
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
def _command(args, *, data=None, timeout=35, check=True, env=None):
|
||||
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, env=env)
|
||||
if check and result.returncode:
|
||||
raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.")
|
||||
return result
|
||||
@@ -752,7 +792,9 @@ def _credential(state):
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
if _credential_backend(record) == HOST_BACKEND:
|
||||
_helper_ready()
|
||||
result = _command(["sudo", "-n", "--", str(HELPER), "decrypt", _profile_id(state), record["credential_sha256"]], timeout=15)
|
||||
command = [str(HELPER), "decrypt", _profile_id(state), record["credential_sha256"]]
|
||||
_require_no_sudo_io_logging(command)
|
||||
result = _command(["sudo", "-n", "--", *command], timeout=15)
|
||||
else:
|
||||
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15)
|
||||
try:
|
||||
|
||||
@@ -430,7 +430,12 @@ def _doctor_command(args, state):
|
||||
_show(value)
|
||||
else:
|
||||
print("Diagnostics: " + ("passed" if value.get("ok") else "needs attention"))
|
||||
print("Debian 13 amd64: " + ("compatible" if value.get("compatible_debian_13_amd64") else "not verified compatible"))
|
||||
compatibility = value.get("platform_compatibility") or {}
|
||||
print("Platform: " + ("compatible" if compatibility.get("ok") else "not verified compatible"))
|
||||
if compatibility.get("profile_id"):
|
||||
print("Build profile: " + str(compatibility["profile_id"]))
|
||||
for issue in compatibility.get("issues", []):
|
||||
print("Platform issue: " + str(issue))
|
||||
print("Desktop: " + str(value.get("display", "unavailable")))
|
||||
for item in value.get("missing_commands", []):
|
||||
print("Missing command: " + str(item))
|
||||
|
||||
@@ -152,7 +152,8 @@ def _install(path, data, *, policy=False):
|
||||
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-[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():
|
||||
@@ -193,10 +194,46 @@ def _paths(uid, profile, digest):
|
||||
|
||||
def _policy(owner, profile, digest):
|
||||
# All fields are allowlisted tokens; no glob, free path, flags or shell.
|
||||
return (owner.pw_name + " ALL=(root) NOSETENV: NOPASSWD: " + str(LAUNCHER)
|
||||
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))
|
||||
@@ -286,6 +323,13 @@ def _keep_digests(value):
|
||||
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 + "*"):
|
||||
@@ -304,6 +348,10 @@ def prune(owner, profile, keep):
|
||||
# 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():
|
||||
@@ -331,6 +379,18 @@ def purge_all():
|
||||
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()
|
||||
@@ -371,13 +431,17 @@ def main(argv=None):
|
||||
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", "purge-all"}:
|
||||
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, "purge-all": 1}[operation]:
|
||||
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 == "prune":
|
||||
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:])
|
||||
@@ -386,6 +450,9 @@ def main(argv=None):
|
||||
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 = sys.stdin.buffer.read(MAX_KEY + 1)
|
||||
result = protect(owner, args[1], key)
|
||||
key = b""
|
||||
@@ -395,6 +462,10 @@ def main(argv=None):
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user