Protect credential transport from sudo I/O logging and report matched platforms

This commit is contained in:
FrameBridge compatibility build committed 2026-10-27 11:06:05 -04:00
1 parent a48a20c936
commit 12a03a1f97
9 files changed
+285 -25

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
> Development candidate: **0.3.2~compat.20261027.1** for Debian, Ubuntu and Linux Mint on amd64. See [candidate scope and validation](docs/LINUX-COMPATIBILITY-CANDIDATE.md). Build profiles are not proof of live production compatibility. The official signed 0.3.1 release described below remains a separate artifact.
# FrameBridge # FrameBridge
**Bring your ChatGPT conversations to Linux computers you control.** **Bring your ChatGPT conversations to Linux computers you control.**
+29 -4
View File
@@ -5,6 +5,8 @@ The existing systemd 256+ backend uses owner-scoped encrypted credentials and
field retain that behavior. The systemd 255 backend preserves the same user field retain that behavior. The systemd 255 backend preserves the same user
service and uses a separately installed, privileged credential helper. Node service and uses a separately installed, privileged credential helper. Node
startup without a ChatGPT connection does not need a runtime connection key. startup without a ChatGPT connection does not need a runtime connection key.
New or rotated keys on both backends use an administrative, secret-free sudo
policy bootstrap so that key input cannot enter sudo I/O logs.
## Installation and authorization ## Installation and authorization
@@ -17,12 +19,26 @@ The launcher executes the fixed system interpreter with `-I -S`; the helper uses
only the standard library and absolute systemd-creds/visudo paths. Merely only the standard library and absolute systemd-creds/visudo paths. Merely
installing the package does not enable a connection or install a sudoers grant. installing the package does not enable a connection or install a sudoers grant.
On systemd 255, connection setup requires ordinary administrative sudo Connection setup first requires ordinary administrative sudo authorization.
authorization. It writes host-encrypted ciphertext and principal metadata under The helper's `authorize` operation accepts no key and installs only literal,
owner/profile-specific commands tagged `NOLOG_INPUT`, `NOLOG_OUTPUT` and
`NOSETENV`. The frontend then queries the effective policy for the exact command
using command-specific `sudo -n -ll` in the C locale. It requires one effective
matching root rule, the exact command/arguments and explicit disabled I/O logging
and environment overrides before sending any key. Generic Defaults and absence
of logging flags are not accepted as evidence. Unknown formats fail closed.
On systemd 255, setup writes host-encrypted ciphertext and principal metadata under
`/var/lib/framebridge-expanded/credentials`, owned by root with private access. `/var/lib/framebridge-expanded/credentials`, owned by root with private access.
It also installs one validated file in `/etc/sudoers.d` per credential version. It also installs one validated file in `/etc/sudoers.d` per credential version.
That file permits exactly the recorded owner's literal helper `decrypt` command That file permits exactly the recorded owner's literal helper `decrypt` command
and fixed profile/cipher digest arguments, with `NOSETENV`. It does not permit and fixed profile/cipher digest arguments, with `NOSETENV` and disabled I/O
logging. The same effective-policy check precedes runtime sudo decryption.
The one-use encryption bootstrap is consumed before the helper reads key input.
On systemd 256+, the bootstrap permits only the existing native encrypt command
with UID derived from the sudo caller and the fixed credential name; the
frontend revokes that temporary permission in `finally`. Owner-scoped ciphertext
and unprivileged `systemd-creds decrypt --user` remain unchanged. It does not permit
arbitrary systemd-creds commands, paths, shell execution, account selection, arbitrary systemd-creds commands, paths, shell execution, account selection,
provisioning, revocation or unrestricted root execution without authentication. provisioning, revocation or unrestricted root execution without authentication.
This narrow permission is necessary for unattended reconnection after reboot. This narrow permission is necessary for unattended reconnection after reboot.
@@ -50,11 +66,16 @@ all versions for its owner/profile, including when no manifest was committed.
This reconciliation also runs after an upgrade to the systemd 256+ backend. This reconciliation also runs after an upgrade to the systemd 256+ backend.
Uninstall uses administrative `purge-all` after the user workers have stopped. Uninstall uses administrative `purge-all` after the user workers have stopped.
Recognized root-owned staging files from interrupted publication are recovered. Recognized root-owned staging files from interrupted publication are recovered.
Prune and purge also remove authorization bootstrap files left by interruption.
Administrative interface (run the installed launcher, never a user checkout): Administrative interface (run the installed launcher, never a user checkout):
* `protect PROFILE_SHA256`: read a bounded printable synthetic/runtime key from * `protect PROFILE_SHA256`: read a bounded printable synthetic/runtime key from
stdin and return only encrypted ciphertext on stdout. stdin and return only encrypted ciphertext on stdout. Requires and consumes
its matching host authorization before reading input.
* `authorize PROFILE_SHA256 host|user`: secret-free administrative bootstrap for
literal NOLOG encryption and cleanup commands, bound to the actual caller UID.
* `unauthorize PROFILE_SHA256 host|user`: remove a bootstrap authorization.
* `decrypt PROFILE_SHA256 CIPHER_SHA256`: return the registered owner's key on a * `decrypt PROFILE_SHA256 CIPHER_SHA256`: return the registered owner's key on a
pipe; this is the only operation granted passwordless sudo. pipe; this is the only operation granted passwordless sudo.
* `revoke PROFILE_SHA256 CIPHER_SHA256`: idempotently revoke one owner version. * `revoke PROFILE_SHA256 CIPHER_SHA256`: idempotently revoke one owner version.
@@ -76,6 +97,10 @@ purge the grants before deleting/recycling an account, and before changing its
identity/home in a way that would prevent ordinary owner cleanup. A randomized identity/home in a way that would prevent ordinary owner cleanup. A randomized
credential name is not proof of an account's generation. credential name is not proof of an account's generation.
The effective-policy parser is validated against sudo 1.9's command-specific
verbose listing. A different sudo implementation or ambiguous policy output
fails before key input and needs its own compatibility work and evidence.
The helper backend is not a Wayland capture/input backend and does not remove The helper backend is not a Wayland capture/input backend and does not remove
Python ABI, architecture, libc, PAM, user-systemd, cgroup, sudo or X11 capability Python ABI, architecture, libc, PAM, user-systemd, cgroup, sudo or X11 capability
requirements. Synthetic tests validate interfaces and failure handling; a requirements. Synthetic tests validate interfaces and failure handling; a
@@ -67,14 +67,54 @@ def _profile_id(state):
def _encrypt_credential(state, key, backend): def _encrypt_credential(state, key, backend):
if backend == HOST_BACKEND:
_helper_ready() _helper_ready()
args = ["sudo", "-n", "--", str(HELPER), "protect", _profile_id(state)] if backend == HOST_BACKEND:
kind = "host"
command = [str(HELPER), "protect", _profile_id(state)]
elif backend == USER_BACKEND: 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: else:
raise RuntimeError("Unsupported protected-credential backend.") 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): 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) _write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace)
def _command(args, *, data=None, timeout=35, check=True): 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) result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, env=env)
if check and result.returncode: if check and result.returncode:
raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.") raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.")
return result return result
@@ -752,7 +792,9 @@ def _credential(state):
resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
if _credential_backend(record) == HOST_BACKEND: if _credential_backend(record) == HOST_BACKEND:
_helper_ready() _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: else:
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15) result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15)
try: try:
+6 -1
View File
@@ -430,7 +430,12 @@ def _doctor_command(args, state):
_show(value) _show(value)
else: else:
print("Diagnostics: " + ("passed" if value.get("ok") else "needs attention")) 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"))) print("Desktop: " + str(value.get("display", "unavailable")))
for item in value.get("missing_commands", []): for item in value.get("missing_commands", []):
print("Missing command: " + str(item)) print("Missing command: " + str(item))
@@ -152,7 +152,8 @@ def _install(path, data, *, policy=False):
def _cleanup_temporaries(): def _cleanup_temporaries():
# Recover only this installer's root-owned, fixed-format staging files. # 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), 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: for directory, pattern, policy in patterns:
removed = False removed = False
for path in directory.iterdir(): for path in directory.iterdir():
@@ -193,10 +194,46 @@ def _paths(uid, profile, digest):
def _policy(owner, profile, digest): def _policy(owner, profile, digest):
# All fields are allowlisted tokens; no glob, free path, flags or shell. # 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") + " 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): def _record(owner, profile, digest, *, require_policy=True):
record_path, policy_path = _paths(owner.pw_uid, profile, digest) record_path, policy_path = _paths(owner.pw_uid, profile, digest)
value = json.loads(_read(record_path)) value = json.loads(_read(record_path))
@@ -286,6 +323,13 @@ def _keep_digests(value):
def prune(owner, profile, keep): def prune(owner, profile, keep):
"""Administrative crash recovery: remove only this owner's profile grants.""" """Administrative crash recovery: remove only this owner's profile grants."""
_ids(profile) _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() digests = set()
prefix = str(owner.pw_uid) + "-" + profile + "-" prefix = str(owner.pw_uid) + "-" + profile + "-"
for path in ROOT.glob(prefix + "*"): 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. # Prevalidation above completes before any grant or encrypted record moves.
for digest in sorted(digests - keep): for digest in sorted(digests - keep):
revoke(owner, profile, digest) revoke(owner, profile, digest)
for path in authorizations:
path.unlink()
if authorizations:
_sync(SUDOERS)
def purge_all(): def purge_all():
@@ -331,6 +379,18 @@ def purge_all():
entries.append((record_path, policy_path)) entries.append((record_path, policy_path))
policies = [] policies = []
for policy_path in sorted(SUDOERS.glob("framebridge-credential-*")): 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) match = re.fullmatch(r"framebridge-credential-([1-9][0-9]*)-([0-9a-f]{64})-([0-9a-f]{64})", policy_path.name)
if not match: if not match:
_fail() _fail()
@@ -371,13 +431,17 @@ def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv) args = list(sys.argv[1:] if argv is None else argv)
try: try:
_installation() _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() _fail()
operation = args[0] 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() _fail()
owner = _caller() if operation != "purge-all" else None 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]) _ids(args[1]); keep = _keep_digests(args[2])
elif operation != "purge-all": elif operation != "purge-all":
_ids(*args[1:]) _ids(*args[1:])
@@ -386,6 +450,9 @@ def main(argv=None):
with _registry_lock(): with _registry_lock():
_cleanup_temporaries() _cleanup_temporaries()
if operation == "protect": 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) key = sys.stdin.buffer.read(MAX_KEY + 1)
result = protect(owner, args[1], key) result = protect(owner, args[1], key)
key = b"" key = b""
@@ -395,6 +462,10 @@ def main(argv=None):
revoke(owner, args[1], args[2]) revoke(owner, args[1], args[2])
elif operation == "prune": elif operation == "prune":
prune(owner, args[1], keep) prune(owner, args[1], keep)
elif operation == "authorize":
authorize(owner, args[1], args[2])
elif operation == "unauthorize":
unauthorize(owner, args[1], args[2])
else: else:
purge_all() purge_all()
# stdout belongs to an unprivileged caller and can block indefinitely. # stdout belongs to an unprivileged caller and can block indefinitely.
+11 -6
View File
@@ -34,6 +34,7 @@ class AutostartTests(unittest.TestCase):
mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start() mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start()
mock.patch.object(autostart, "_linger", return_value=False).start() mock.patch.object(autostart, "_linger", return_value=False).start()
mock.patch.object(autostart, "_enabled_units", return_value=[]).start() mock.patch.object(autostart, "_enabled_units", return_value=[]).start()
mock.patch.object(autostart, "_helper_ready").start()
mock.patch.object(autostart.os, "geteuid", return_value=1000).start() mock.patch.object(autostart.os, "geteuid", return_value=1000).start()
mock.patch.object(autostart.resource, "setrlimit").start() mock.patch.object(autostart.resource, "setrlimit").start()
self.inputs = mock.patch("builtins.input", side_effect=["y"]).start() self.inputs = mock.patch("builtins.input", side_effect=["y"]).start()
@@ -43,9 +44,13 @@ class AutostartTests(unittest.TestCase):
def fake_command(self, args, **kwargs): def fake_command(self, args, **kwargs):
self.calls.append((args, kwargs)) self.calls.append((args, kwargs))
if "-ll" in args:
command = " ".join(args[args.index("--") + 1:])
output = "Sudoers entry: /etc/sudoers.d/synthetic-test\n RunAsUsers: root\n Options: !authenticate, !log_input, !log_output, !setenv\n Commands:\n " + command + "\n Matched: " + command + "\n"
return subprocess.CompletedProcess(args, 0, output.encode(), b"")
if args[:2] == ["systemd-creds", "--version"]: if args[:2] == ["systemd-creds", "--version"]:
return subprocess.CompletedProcess(args, 0, b"systemd 257\n", b"") return subprocess.CompletedProcess(args, 0, b"systemd 257\n", b"")
if "encrypt" in args: if "encrypt" in args and "-ll" not in args:
return subprocess.CompletedProcess(args, 0, b"CIPHERTEXT-ONLY-" * 20, b"") return subprocess.CompletedProcess(args, 0, b"CIPHERTEXT-ONLY-" * 20, b"")
return subprocess.CompletedProcess(args, 0, b"", b"") return subprocess.CompletedProcess(args, 0, b"", b"")
@@ -59,7 +64,7 @@ class AutostartTests(unittest.TestCase):
state, directory, unit = autostart._paths(self.state) state, directory, unit = autostart._paths(self.state)
self.assertNotEqual(before, (self.state / "config.json").read_bytes()) self.assertNotEqual(before, (self.state / "config.json").read_bytes())
self.assertEqual("auto", config.load_config(self.state)["headless"]) self.assertEqual("auto", config.load_config(self.state)["headless"])
encryption = [(a, kw) for a, kw in self.calls if "encrypt" in a] encryption = [(a, kw) for a, kw in self.calls if "encrypt" in a and "-ll" not in a]
self.assertEqual(1, len(encryption)) self.assertEqual(1, len(encryption))
args, options = encryption[0] args, options = encryption[0]
self.assertEqual(MARKER.encode(), options["data"]) self.assertEqual(MARKER.encode(), options["data"])
@@ -111,7 +116,7 @@ class AutostartTests(unittest.TestCase):
def test_encryption_failure_never_creates_unit_or_disables_an_existing_service(self): def test_encryption_failure_never_creates_unit_or_disables_an_existing_service(self):
def failing(args, **kwargs): def failing(args, **kwargs):
if "encrypt" in args: if "encrypt" in args and "-ll" not in args:
raise RuntimeError("Credential encryption failed.") raise RuntimeError("Credential encryption failed.")
return self.fake_command(args, **kwargs) return self.fake_command(args, **kwargs)
self.command.side_effect = failing self.command.side_effect = failing
@@ -187,7 +192,7 @@ class AutostartTests(unittest.TestCase):
self._durable_connect() self._durable_connect()
self.inputs.assert_not_called() self.inputs.assert_not_called()
self.key_prompt.assert_not_called() self.key_prompt.assert_not_called()
encryptions = [(args, kw) for args, kw in self.calls if "encrypt" in args] encryptions = [(args, kw) for args, kw in self.calls if "encrypt" in args and "-ll" not in args]
self.assertEqual(1, len(encryptions)) self.assertEqual(1, len(encryptions))
self.assertEqual(MARKER.encode(), encryptions[0][1]["data"]) self.assertEqual(MARKER.encode(), encryptions[0][1]["data"])
self.assertNotIn(MARKER, " ".join(encryptions[0][0])) self.assertNotIn(MARKER, " ".join(encryptions[0][0]))
@@ -220,7 +225,7 @@ class AutostartTests(unittest.TestCase):
def test_durable_encryption_failure_leaves_current_profile_and_service_alone(self): def test_durable_encryption_failure_leaves_current_profile_and_service_alone(self):
original = (self.state / "config.json").read_bytes() original = (self.state / "config.json").read_bytes()
def failed(args, **kwargs): def failed(args, **kwargs):
if "encrypt" in args: if "encrypt" in args and "-ll" not in args:
raise RuntimeError("Protection failed.") raise RuntimeError("Protection failed.")
return self.fake_command(args, **kwargs) return self.fake_command(args, **kwargs)
self.command.side_effect = failed self.command.side_effect = failed
@@ -238,7 +243,7 @@ class AutostartTests(unittest.TestCase):
events = [] events = []
original_command = self.fake_command original_command = self.fake_command
def command(args, **kwargs): def command(args, **kwargs):
if "encrypt" in args: if "encrypt" in args and "-ll" not in args:
events.append("encrypt") events.append("encrypt")
return subprocess.CompletedProcess(args, 0, b"REPLACEMENT-CIPHERTEXT-" * 20, b"") return subprocess.CompletedProcess(args, 0, b"REPLACEMENT-CIPHERTEXT-" * 20, b"")
return original_command(args, **kwargs) return original_command(args, **kwargs)
+50 -1
View File
@@ -44,6 +44,9 @@ class HostCredentialAutostartTests(unittest.TestCase):
self.calls.append((args, kwargs)) self.calls.append((args, kwargs))
output = b"" output = b""
if args[:2] == ["systemd-creds", "--version"]: output = b"systemd 255\n" if args[:2] == ["systemd-creds", "--version"]: output = b"systemd 255\n"
elif "-ll" in args:
command = " ".join(args[args.index("--") + 1:])
output = ("Sudoers entry: /etc/sudoers.d/synthetic-test\n RunAsUsers: root\n Options: !authenticate, !log_input, !log_output, !setenv\n Commands:\n " + command + "\n Matched: " + command + "\n").encode()
elif "protect" in args or "encrypt" in args: elif "protect" in args or "encrypt" in args:
self.generation += 1 self.generation += 1
output = ("SYNTHETIC-HOST-CIPHER-%03d-" % self.generation).encode() * 8 output = ("SYNTHETIC-HOST-CIPHER-%03d-" % self.generation).encode() * 8
@@ -55,7 +58,7 @@ class HostCredentialAutostartTests(unittest.TestCase):
return subprocess.CompletedProcess(args, 0, b"", b"") return subprocess.CompletedProcess(args, 0, b"", b"")
def operations(self, name): def operations(self, name):
return [(args, options) for args, options in self.calls if name in args] return [(args, options) for args, options in self.calls if name in args and "-ll" not in args]
def test_255_setup_uses_helper_stdin_and_preserves_user_unit(self): def test_255_setup_uses_helper_stdin_and_preserves_user_unit(self):
autostart.configure_connection(self.state, self.settings, KEY) autostart.configure_connection(self.state, self.settings, KEY)
@@ -233,6 +236,52 @@ class HostCredentialAutostartTests(unittest.TestCase):
autostart._credential(self.state) autostart._credential(self.state)
self.assertEqual([], self.calls) self.assertEqual([], self.calls)
def test_unsafe_or_ambiguous_policy_never_receives_secret_input(self):
for backend in (autostart.HOST_BACKEND, autostart.USER_BACKEND):
for kind in ("logging", "missing", "ambiguous", "different-command", "query-failed", "specific-stream-logging"):
self.calls.clear()
def policy(args, **kwargs):
result = self.fake_command(args, **kwargs)
if "-ll" not in args:
return result
text = result.stdout.decode()
if kind == "logging": text = text.replace("!log_input", "log_input")
elif kind == "missing": text = text.replace("!log_output, ", "")
elif kind == "ambiguous": text += "Matched: /usr/bin/another\n"
elif kind == "different-command": text = text.replace(" Matched: ", " Matched: /different ")
elif kind == "specific-stream-logging": text = text.replace("!setenv", "!setenv, log_stdin")
return subprocess.CompletedProcess(args, 1 if kind == "query-failed" else 0, text.encode(), b"")
self.command.side_effect = policy
with self.subTest(backend=backend, kind=kind), self.assertRaises(RuntimeError):
autostart._encrypt_credential(self.state, KEY, backend)
self.assertFalse(any(options.get("data") == KEY.encode() for _, options in self.calls))
self.assertTrue(self.operations("authorize"))
self.assertTrue(self.operations("unauthorize"))
def test_policy_query_exception_precedes_secret_and_cancels_authorization(self):
def unavailable(args, **kwargs):
if "-ll" in args:
raise RuntimeError("synthetic policy lookup failure")
return self.fake_command(args, **kwargs)
self.command.side_effect = unavailable
with self.assertRaises(RuntimeError):
autostart._encrypt_credential(self.state, KEY, autostart.HOST_BACKEND)
self.assertFalse(any(options.get("data") == KEY.encode() for _, options in self.calls))
self.assertTrue(self.operations("unauthorize"))
def test_decrypt_checks_effective_nolog_policy_before_running_privileged_command(self):
autostart.configure_connection(self.state, self.settings, KEY)
self.calls.clear()
def logging(args, **kwargs):
result = self.fake_command(args, **kwargs)
if "-ll" in args:
return subprocess.CompletedProcess(args, 0, result.stdout.replace(b"!log_output", b"log_output"), b"")
return result
self.command.side_effect = logging
with self.assertRaisesRegex(RuntimeError, "explicitly disable"):
autostart._credential(self.state)
self.assertFalse(self.operations("decrypt"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+25 -1
View File
@@ -61,7 +61,7 @@ class CredentialHelperTests(unittest.TestCase):
self.assertEqual(0o600, stat.S_IMODE(record.stat().st_mode)) self.assertEqual(0o600, stat.S_IMODE(record.stat().st_mode))
self.assertEqual(0o440, stat.S_IMODE(policy.stat().st_mode)) self.assertEqual(0o440, stat.S_IMODE(policy.stat().st_mode))
text = policy.read_text() text = policy.read_text()
self.assertIn("testowner ALL=(root) NOSETENV: NOPASSWD:", text) self.assertIn("testowner ALL=(root) NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD:", text)
self.assertTrue(text.endswith(" decrypt " + PROFILE + " " + digest + "\n")) self.assertTrue(text.endswith(" decrypt " + PROFILE + " " + digest + "\n"))
self.assertNotIn("*", text) self.assertNotIn("*", text)
self.assertNotIn("systemd-creds", text) self.assertNotIn("systemd-creds", text)
@@ -135,6 +135,29 @@ class CredentialHelperTests(unittest.TestCase):
self.assertEqual([], list(self.registry.iterdir())) self.assertEqual([], list(self.registry.iterdir()))
self.assertEqual([], list(self.policies.iterdir())) self.assertEqual([], list(self.policies.iterdir()))
def test_bootstrap_has_only_literal_nolog_commands_and_host_grant_is_one_use(self):
for kind in ("host", "user"):
helper.authorize(self.owner, PROFILE, kind)
path, expected = helper._authorization(self.owner, PROFILE, kind)
self.assertEqual(expected, path.read_bytes())
self.assertIn(b"NOLOG_INPUT: NOLOG_OUTPUT: NOSETENV: NOPASSWD:", expected)
self.assertNotIn(b"*", expected)
self.assertNotIn(KEY, expected)
if kind == "user":
self.assertIn(b"encrypt --uid=1234 --with-key=host --name=chatgpt-runtime-key - -", expected)
helper.unauthorize(self.owner, PROFILE, kind, required=True)
with self.assertRaises(RuntimeError):
helper.unauthorize(self.owner, PROFILE, kind, required=True)
def test_prune_and_purge_remove_interrupted_authorizations(self):
helper.authorize(self.owner, PROFILE, "host")
helper.authorize(self.owner, PROFILE, "user")
helper.prune(self.owner, PROFILE, set())
self.assertEqual([], list(self.policies.iterdir()))
helper.authorize(self.owner, PROFILE, "user")
helper.purge_all()
self.assertEqual([], list(self.policies.iterdir()))
def test_purge_prevalidates_all_policies_before_deleting_any(self): def test_purge_prevalidates_all_policies_before_deleting_any(self):
_, first = self.protect(); _, second = self.protect() _, first = self.protect(); _, second = self.protect()
record, policy = helper._paths(self.owner.pw_uid, PROFILE, second) record, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
@@ -191,6 +214,7 @@ class CredentialHelperTests(unittest.TestCase):
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest)) self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
def test_broken_receipt_pipe_rolls_back_published_protect_grant(self): def test_broken_receipt_pipe_rolls_back_published_protect_grant(self):
helper.authorize(self.owner, PROFILE, "host")
stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe") stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe")
with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdin", SimpleNamespace(buffer=io.BytesIO(KEY))), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdin", SimpleNamespace(buffer=io.BytesIO(KEY))), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
self.assertEqual(1, helper.main(["protect", PROFILE])) self.assertEqual(1, helper.main(["protect", PROFILE]))
@@ -0,0 +1,37 @@
import argparse
import contextlib
import io
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "expanded"))
from framebridge_expanded import cli
class DoctorPlatformOutputTests(unittest.TestCase):
def render(self, compatibility, ok):
result = {"ok": ok, "compatible_debian_13_amd64": False,
"platform_compatibility": compatibility, "display": "unavailable"}
runtime = mock.Mock()
runtime.doctor.return_value = result
output = io.StringIO()
with mock.patch.object(cli, "_module", return_value=runtime), contextlib.redirect_stdout(output):
code = cli._doctor_command(argparse.Namespace(json=False), Path("/unused-test-state"))
return code, output.getvalue()
def test_supported_ubuntu_uses_current_profile_not_legacy_debian_flag(self):
code, text = self.render({"ok": True, "profile_id": "ubuntu24.04-amd64-py312", "issues": []}, True)
self.assertEqual(code, 0)
self.assertIn("Platform: compatible", text)
self.assertIn("ubuntu24.04-amd64-py312", text)
self.assertNotIn("Debian 13 amd64:", text)
def test_incompatible_abi_is_explained_in_human_diagnostics(self):
code, text = self.render({"ok": False, "profile_id": "mint22-amd64-py312",
"issues": ["Installed artifact profile/ABI does not match this operating system and interpreter."]}, False)
self.assertNotEqual(code, 0)
self.assertIn("not verified compatible", text)
self.assertIn("profile/ABI does not match", text)
if __name__ == "__main__":
unittest.main()