"""Explicit, persistent, read-only fb0 access for one local account. No permission changes occur on import, status, or package installation. The administrator explicitly opts in with sudo fb screen-access enable --user NAME. The selected UID can read framebuffer pixels even after logout or a VT switch. Disable restores only this operation's exact unchanged ACL and retains backups. """ from __future__ import annotations import argparse import base64 from contextlib import contextmanager import errno import fcntl import json import os from pathlib import Path import pwd import stat import struct import subprocess import sys import time import uuid DEVICE = Path("/dev/fb0") POLICY = Path("/etc/framebridge-expanded/screen-access.json") STATE = Path("/var/lib/framebridge-expanded/screen-access") RULE = Path("/usr/lib/udev/rules.d/70-framebridge-screen.rules") XATTR = "system.posix_acl_access" ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C"} RISK = ("The selected account can read real /dev/fb0 pixels through its processes " "after logout or a VT switch. No framebuffer write permission is granted.") UNDEFINED = 0xffffffff def require(value, code): if not value: raise RuntimeError(code) def protected_stat(info, *, directory=False, private=False): require(stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode), "PROTECTED_PATH_TYPE_INVALID") require(info.st_uid == 0 and not stat.S_IMODE(info.st_mode) & 0o022, "PROTECTED_PATH_OWNERSHIP_INVALID") if private: require(not stat.S_IMODE(info.st_mode) & 0o077, "PRIVATE_PATH_MODE_INVALID") def sync_directory(path): fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) try: protected_stat(os.fstat(fd), directory=True) os.fsync(fd) finally: os.close(fd) def secure_directory(path, *, create=False, private=False): path = Path(path) require(path.is_absolute() and ".." not in path.parts, "PROTECTED_PATH_INVALID") current = Path("/") for part in path.parts[1:]: current /= part try: info = current.lstat() except FileNotFoundError: require(create, "PROTECTED_DIRECTORY_MISSING") os.mkdir(current, 0o700 if current == path and private else 0o755) sync_directory(current.parent) info = current.lstat() protected_stat(info, directory=True, private=private and current == path) return path def read_json(path, *, private=False): secure_directory(path.parent) fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) try: protected_stat(os.fstat(fd), private=private) data = os.read(fd, 65537) require(len(data) <= 65536, "PROTECTED_RECORD_TOO_LARGE") value = json.loads(data) require(isinstance(value, dict), "PROTECTED_RECORD_INVALID") return value finally: os.close(fd) def write_json(path, value, *, private=True, exclusive=False): secure_directory(path.parent, create=True, private=path.parent == STATE) if path.exists(): fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) try: protected_stat(os.fstat(fd), private=private) finally: os.close(fd) require(not exclusive, "PROTECTED_RECORD_EXISTS") temporary = path.parent / ("." + path.name + "." + uuid.uuid4().hex) fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600 if private else 0o644) try: payload = (json.dumps(value, sort_keys=True) + "\n").encode() os.fchmod(fd, 0o600 if private else 0o644) with os.fdopen(fd, "wb") as stream: stream.write(payload) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) sync_directory(path.parent) finally: try: temporary.unlink() except FileNotFoundError: pass @contextmanager def locked(): require(os.geteuid() == 0, "ROOT_REQUIRED_USE_SUDO") secure_directory(STATE, create=True, private=True) fd = os.open(STATE / "lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) try: protected_stat(os.fstat(fd), private=True) fcntl.flock(fd, fcntl.LOCK_EX) yield finally: os.close(fd) def account(name, expected_uid=None): require(isinstance(name, str) and 0 < len(name) <= 256 and not any(c.isspace() or ord(c) < 32 for c in name), "ACCOUNT_INVALID") try: user = pwd.getpwnam(name) except KeyError: raise RuntimeError("ACCOUNT_NOT_FOUND") from None require(user.pw_uid > 0, "ROOT_ACCOUNT_NOT_ALLOWED") require(expected_uid is None or user.pw_uid == expected_uid, "ACCOUNT_IDENTITY_CHANGED") return {"user": user.pw_name, "uid": user.pw_uid} def validate_policy(value): require(value.get("schema") == 1 and type(value.get("enabled")) is bool and value.get("device") == str(DEVICE) and type(value.get("uid")) is int and value["uid"] > 0 and isinstance(value.get("user"), str) and 0 < len(value["user"]) <= 256 and not any(c.isspace() or ord(c) < 32 for c in value["user"]), "SCREEN_ACCESS_POLICY_INVALID") # Status/revocation must still work after an account is removed or its name # is reused. The live name/UID identity is checked before every grant. return value def load_policy(): try: return validate_policy(read_json(POLICY)) except FileNotFoundError: return None except RuntimeError as exc: if str(exc) == "PROTECTED_DIRECTORY_MISSING" and not POLICY.parent.exists(): return None raise def acl_entries(raw, mode): if raw is None: return [(1, (mode >> 6) & 7, UNDEFINED), (4, (mode >> 3) & 7, UNDEFINED), (32, mode & 7, UNDEFINED)] require(len(raw) >= 28 and (len(raw) - 4) % 8 == 0 and struct.unpack(" 0, "ACCESS_BACKUP_INVALID") meta = value["meta"] require(set(meta) == {"dev", "inode", "rdev", "uid", "gid", "mode", "boot"} and all(type(meta[k]) is int and meta[k] >= 0 for k in ("dev", "inode", "rdev", "uid", "gid", "mode")) and meta["rdev"] == os.makedev(29, 0) and meta["uid"] == 0 and meta["mode"] <= 0o777 and isinstance(meta["boot"], str) and 0 < len(meta["boot"]) <= 64, "ACCESS_BACKUP_IDENTITY_INVALID") require("before_acl" in value and "expected_acl" in value, "ACCESS_BACKUP_INVALID") decode(value["before_acl"]) decode(value["expected_acl"]) return value def same_device(left, right): return all(left.get(key) == right.get(key) for key in ("dev", "inode", "rdev", "boot")) def restore_owned(fd, record): meta, raw = device_snapshot(fd) if not same_device(meta, record["meta"]): return "DEVICE_RECREATED_NO_OLD_GRANT" require(meta == record["meta"], "FRAMEBUFFER_METADATA_CHANGED_REVIEW_REQUIRED") before, expected = decode(record["before_acl"]), decode(record["expected_acl"]) if raw == before: return "ORIGINAL_ACL_ALREADY_RESTORED" require(raw == expected, "FRAMEBUFFER_ACL_CHANGED_REVIEW_REQUIRED") if raw != before: if before is None: os.removexattr(fd, XATTR) else: os.setxattr(fd, XATTR, before, os.XATTR_REPLACE) require(device_snapshot(fd) == (meta, before), "FRAMEBUFFER_RESTORE_VERIFY_FAILED") return "ORIGINAL_ACL_RESTORED" def apply_locked(policy): account(policy["user"], policy["uid"]) try: fd = os.open(DEVICE, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) except FileNotFoundError: return {"status": "ENABLED_WAITING_FOR_FB0", "changed": False} try: meta, before = device_snapshot(fd) previous = load_record() if previous and same_device(previous["meta"], meta): require(previous["meta"] == meta, "FRAMEBUFFER_METADATA_CHANGED_REVIEW_REQUIRED") require(previous["uid"] == policy["uid"], "ACCESS_BACKUP_IDENTITY_CHANGED") require(before == decode(previous["expected_acl"]), "FRAMEBUFFER_ACL_CHANGED_REVIEW_REQUIRED") probe_user(policy) return {"status": "ENABLED_READ_ONLY", "changed": False} expected = read_only_acl(before, meta["mode"], policy["uid"]) record = {"schema": 1, "uid": policy["uid"], "user": policy["user"], "meta": meta, "before_acl": encode(before), "expected_acl": encode(expected)} backup = STATE / ("backup-" + str(time.time_ns()) + "-" + uuid.uuid4().hex + ".json") write_json(backup, record, exclusive=True) write_json(STATE / "active.json", record) require(device_snapshot(fd) == (meta, before), "FRAMEBUFFER_CHANGED_BEFORE_APPLY") if before != expected: os.setxattr(fd, XATTR, expected, os.XATTR_CREATE if before is None else os.XATTR_REPLACE) try: require(device_snapshot(fd) == (meta, expected), "FRAMEBUFFER_APPLY_VERIFY_FAILED") probe_user(policy) except Exception: restore_owned(fd, record) raise return {"status": "ENABLED_READ_ONLY", "changed": before != expected} finally: os.close(fd) def reload_rules(): result = subprocess.run(["/usr/bin/udevadm", "control", "--reload-rules"], env=ENV, capture_output=True, timeout=8) require(result.returncode == 0, "UDEV_RULE_RELOAD_FAILED") def status(): policy = load_policy() info = {"enabled": bool(policy and policy["enabled"]), "device": str(DEVICE), "persistent": bool(policy and policy["enabled"]), "risk": RISK, "status": "DISABLED" if not policy else ("ENABLED" if policy["enabled"] else "DISABLED_REVOKE_PENDING")} if policy: info.update({"user": policy["user"], "uid": policy["uid"]}) try: info["current_account_can_read"] = os.access(DEVICE, os.R_OK) info["current_account_can_write"] = os.access(DEVICE, os.W_OK) info["device_present"] = DEVICE.exists() info["udev_rule_present"] = RULE.is_file() and not RULE.is_symlink() except OSError: info["device_present"] = False return info def enable(user): chosen = account(user) with locked(): secure_directory(RULE.parent) fd = os.open(RULE, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) try: protected_stat(os.fstat(fd)) finally: os.close(fd) old = load_policy() if old: require(old["enabled"] and all(old[k] == chosen[k] for k in ("user", "uid")), "DISABLE_EXISTING_ACCESS_POLICY_FIRST") policy = old else: policy = {"schema": 1, "enabled": True, "device": str(DEVICE), **chosen} write_json(POLICY, policy, private=False, exclusive=True) try: reload_rules() answer = apply_locked(policy) except Exception: # Stop later udev grants. Keep the tombstone and protected backup # for an explicit disable/recovery instead of concealing a failure. write_json(POLICY, dict(policy, enabled=False), private=False) raise return dict(answer, persistent=True, user=chosen["user"], uid=chosen["uid"], risk=RISK) def disable(): with locked(): policy = load_policy() if policy is None: return {"status": "DISABLED", "changed": False, "persistent": False} write_json(POLICY, dict(policy, enabled=False), private=False) reload_rules() record = load_record() result = "NO_CURRENT_OWNED_GRANT" if record: require(record["uid"] == policy["uid"], "ACCESS_BACKUP_IDENTITY_CHANGED") try: fd = os.open(DEVICE, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) except FileNotFoundError: result = "DEVICE_ABSENT_NO_OLD_GRANT" else: try: result = restore_owned(fd, record) finally: os.close(fd) # Successful revocation must not leave an active record belonging # to an earlier enable, even when the device has been recreated. (STATE / "active.json").unlink() sync_directory(STATE) POLICY.unlink() sync_directory(POLICY.parent) return {"status": "DISABLED", "changed": True, "persistent": False, "restore": result} def apply_device(): with locked(): policy = load_policy() if policy is None or not policy["enabled"]: return {"status": "DISABLED", "changed": False} return apply_locked(policy) def command(args): if args.screen_access_command == "status": return status() if args.screen_access_command == "enable": return enable(args.user) if args.screen_access_command == "disable": return disable() raise ValueError("SCREEN_ACCESS_COMMAND_INVALID") def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="action", required=True) item = sub.add_parser("enable") item.add_argument("--user", required=True) sub.add_parser("disable") sub.add_parser("status") sub.add_parser("apply-device") args = parser.parse_args(argv) try: result = (enable(args.user) if args.action == "enable" else disable() if args.action == "disable" else status() if args.action == "status" else apply_device()) print(json.dumps(result, sort_keys=True)) return 0 except (OSError, ValueError, RuntimeError, KeyError) as exc: # No passwords, environment, process argv, or screen pixels are read. print("Error: " + (str(exc) if isinstance(exc, RuntimeError) else "SCREEN_ACCESS_OPERATION_FAILED"), file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())