diff --git a/README.md b/README.md index ac8181f..a40f7f5 100644 --- a/README.md +++ b/README.md @@ -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 **Bring your ChatGPT conversations to Linux computers you control.** diff --git a/source/docs/CREDENTIAL-BACKENDS.md b/source/docs/CREDENTIAL-BACKENDS.md index 670fd76..e17d69d 100644 --- a/source/docs/CREDENTIAL-BACKENDS.md +++ b/source/docs/CREDENTIAL-BACKENDS.md @@ -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 service and uses a separately installed, privileged credential helper. Node 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 @@ -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 installing the package does not enable a connection or install a sudoers grant. -On systemd 255, connection setup requires ordinary administrative sudo -authorization. It writes host-encrypted ciphertext and principal metadata under +Connection setup first requires ordinary administrative sudo authorization. +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. 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 -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, provisioning, revocation or unrestricted root execution without authentication. 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. Uninstall uses administrative `purge-all` after the user workers have stopped. 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): * `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 pipe; this is the only operation granted passwordless sudo. * `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 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 Python ABI, architecture, libc, PAM, user-systemd, cgroup, sudo or X11 capability requirements. Synthetic tests validate interfaces and failure handling; a diff --git a/source/expanded/framebridge_expanded/autostart.py b/source/expanded/framebridge_expanded/autostart.py index fbcbf9f..add27b0 100644 --- a/source/expanded/framebridge_expanded/autostart.py +++ b/source/expanded/framebridge_expanded/autostart.py @@ -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: diff --git a/source/expanded/framebridge_expanded/cli.py b/source/expanded/framebridge_expanded/cli.py index 371fa08..3f87322 100644 --- a/source/expanded/framebridge_expanded/cli.py +++ b/source/expanded/framebridge_expanded/cli.py @@ -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)) diff --git a/source/expanded/framebridge_expanded/credential_helper.py b/source/expanded/framebridge_expanded/credential_helper.py index 6e69164..c7f583a 100644 --- a/source/expanded/framebridge_expanded/credential_helper.py +++ b/source/expanded/framebridge_expanded/credential_helper.py @@ -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. diff --git a/source/tests/test_autostart.py b/source/tests/test_autostart.py index 8b31dda..16c3bf1 100644 --- a/source/tests/test_autostart.py +++ b/source/tests/test_autostart.py @@ -34,6 +34,7 @@ class AutostartTests(unittest.TestCase): mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start() mock.patch.object(autostart, "_linger", return_value=False).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.resource, "setrlimit").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): 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"]: 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"", b"") @@ -59,7 +64,7 @@ class AutostartTests(unittest.TestCase): state, directory, unit = autostart._paths(self.state) self.assertNotEqual(before, (self.state / "config.json").read_bytes()) 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)) args, options = encryption[0] 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 failing(args, **kwargs): - if "encrypt" in args: + if "encrypt" in args and "-ll" not in args: raise RuntimeError("Credential encryption failed.") return self.fake_command(args, **kwargs) self.command.side_effect = failing @@ -187,7 +192,7 @@ class AutostartTests(unittest.TestCase): self._durable_connect() self.inputs.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(MARKER.encode(), encryptions[0][1]["data"]) 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): original = (self.state / "config.json").read_bytes() def failed(args, **kwargs): - if "encrypt" in args: + if "encrypt" in args and "-ll" not in args: raise RuntimeError("Protection failed.") return self.fake_command(args, **kwargs) self.command.side_effect = failed @@ -238,7 +243,7 @@ class AutostartTests(unittest.TestCase): events = [] original_command = self.fake_command def command(args, **kwargs): - if "encrypt" in args: + if "encrypt" in args and "-ll" not in args: events.append("encrypt") return subprocess.CompletedProcess(args, 0, b"REPLACEMENT-CIPHERTEXT-" * 20, b"") return original_command(args, **kwargs) diff --git a/source/tests/test_autostart_host_helper.py b/source/tests/test_autostart_host_helper.py index 0227c2c..570bb39 100644 --- a/source/tests/test_autostart_host_helper.py +++ b/source/tests/test_autostart_host_helper.py @@ -44,6 +44,9 @@ class HostCredentialAutostartTests(unittest.TestCase): self.calls.append((args, kwargs)) output = b"" 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: self.generation += 1 output = ("SYNTHETIC-HOST-CIPHER-%03d-" % self.generation).encode() * 8 @@ -55,7 +58,7 @@ class HostCredentialAutostartTests(unittest.TestCase): return subprocess.CompletedProcess(args, 0, b"", b"") 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): autostart.configure_connection(self.state, self.settings, KEY) @@ -233,6 +236,52 @@ class HostCredentialAutostartTests(unittest.TestCase): autostart._credential(self.state) 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__": unittest.main() diff --git a/source/tests/test_credential_helper.py b/source/tests/test_credential_helper.py index db9bf2f..04a36bd 100644 --- a/source/tests/test_credential_helper.py +++ b/source/tests/test_credential_helper.py @@ -61,7 +61,7 @@ class CredentialHelperTests(unittest.TestCase): self.assertEqual(0o600, stat.S_IMODE(record.stat().st_mode)) self.assertEqual(0o440, stat.S_IMODE(policy.stat().st_mode)) 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.assertNotIn("*", text) self.assertNotIn("systemd-creds", text) @@ -135,6 +135,29 @@ class CredentialHelperTests(unittest.TestCase): self.assertEqual([], list(self.registry.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): _, first = self.protect(); _, second = self.protect() 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)) 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") 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])) diff --git a/source/tests/test_doctor_platform_output.py b/source/tests/test_doctor_platform_output.py new file mode 100644 index 0000000..a83b979 --- /dev/null +++ b/source/tests/test_doctor_platform_output.py @@ -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()