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
@@ -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.**
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user