Files
crossdesk/apps/desktop/src/platform/linux/privacy/privacy_guard.cpp
T

722 lines
25 KiB
C++

/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_guard.h"
#include <X11/XKBlib.h>
#include <X11/Xatom.h>
#include <X11/extensions/XInput.h>
#include <X11/extensions/XInput2.h>
#include <X11/extensions/XTest.h>
#include <X11/extensions/Xfixes.h>
#include <X11/extensions/Xrandr.h>
#include <X11/keysym.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <map>
#include <memory>
#include <string>
#include <tuple>
#include <vector>
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
using Gamma = std::unique_ptr<XRRCrtcGamma, decltype(&XRRFreeGamma)>;
using Resources =
std::unique_ptr<XRRScreenResources, decltype(&XRRFreeScreenResources)>;
using CrtcInfo = std::unique_ptr<XRRCrtcInfo, decltype(&XRRFreeCrtcInfo)>;
volatile std::sig_atomic_t stopping = 0;
int x_error = 0;
void StopGuard(int) { stopping = 1; }
int OnXError(Display*, XErrorEvent* event) {
x_error = event->error_code;
return 0;
}
template <size_t N>
void SetText(char (&dest)[N], const std::string& text) {
std::strncpy(dest, text.c_str(), N - 1);
dest[N - 1] = '\0';
}
struct Output {
Window root = 0;
RRCrtc crtc = 0;
RRMode mode = 0;
int x = 0, y = 0;
unsigned width = 0, height = 0;
Rotation rotation = 0;
std::vector<RROutput> connectors;
Gamma saved{nullptr, XRRFreeGamma};
bool SameLayout(const Output& other) const {
return std::tie(root, crtc, mode, x, y, width, height, rotation,
connectors) == std::tie(other.root, other.crtc, other.mode,
other.x, other.y, other.width,
other.height, other.rotation,
other.connectors);
}
};
struct Device {
int id = 0, use = 0, attachment = 0;
bool keyboard = false, touch = false;
std::string name;
bool operator==(const Device& other) const {
return std::tie(id, use, attachment, keyboard, touch, name) ==
std::tie(other.id, other.use, other.attachment, other.keyboard,
other.touch, other.name);
}
};
bool IsBlack(const XRRCrtcGamma* gamma) {
if (!gamma || gamma->size <= 0) return false;
for (int i = 0; i < gamma->size; ++i)
if (gamma->red[i] || gamma->green[i] || gamma->blue[i]) return false;
return true;
}
// This class lives only in the exec'd guard process. Its Xlib error handler is
// isolated from the GUI and capture threads' process-global error handlers.
class NativePrivacy {
public:
~NativePrivacy() {
Recover();
if (display_) XCloseDisplay(display_);
}
void Query() {
reply_.supported = reply_.input_supported = false;
std::string error;
if (!Initialize(error)) {
SetText(reply_.capability_reason, error);
return;
}
std::vector<Output> outputs;
std::vector<Device> devices;
if (!ReadOutputs(outputs, true, error) || !ReadDevices(devices, error)) {
SetText(reply_.capability_reason, error);
return;
}
const bool keyboard = std::any_of(devices.begin(), devices.end(),
[](const auto& d) { return d.keyboard; });
if (!keyboard) {
SetText(reply_.capability_reason,
"No local keyboard available for privacy recovery");
return;
}
reply_.supported = true;
// XIGrabDevice does not suppress XI 2.2 touch delivery. Do not advertise
// input protection for touchscreens without a separate touch grab.
reply_.input_supported = std::none_of(
devices.begin(), devices.end(), [](const auto& d) { return d.touch; });
SetText(reply_.capability_reason,
reply_.input_supported
? "X11 privacy black screen available; local recovery: "
"Ctrl+Alt+Shift+F12"
: "X11 privacy black screen available; touchscreen input "
"blocking is unavailable");
}
void Enable(bool block_input) {
++reply_.generation;
reply_.failure[0] = '\0';
reply_.emergency = false;
if (!reply_.recovered) {
Fail("Linux privacy resources are still in use");
return;
}
Query();
if (!reply_.supported || (block_input && !reply_.input_supported)) {
Fail(reply_.capability_reason);
return;
}
std::string error;
keys_.clear();
if (!Acquire(error) || !ReadOutputs(outputs_, true, error) ||
!ReadDevices(devices_, error) || !ProtectInput(block_input, error)) {
Fail(error);
return;
}
// No display ramp is changed until every original ramp has been saved.
// Gamma is applied at scanout, after XGetImage/DRM framebuffer capture.
// There is deliberately no cover window to contaminate remote frames.
x_error = 0;
for (const auto& output : outputs_) {
Gamma black(XRRAllocGamma(output.saved->size), XRRFreeGamma);
if (!black) {
Fail("Cannot allocate Linux privacy display ramp");
return;
}
std::fill_n(black->red, black->size, 0);
std::fill_n(black->green, black->size, 0);
std::fill_n(black->blue, black->size, 0);
XRRSetCrtcGamma(display_, output.crtc, black.get());
}
for (int i = 0; i < ScreenCount(display_); ++i) {
XFixesHideCursor(display_, RootWindow(display_, i));
hidden_roots_.push_back(RootWindow(display_, i));
}
XSync(display_, False);
if (x_error) {
Fail("Cannot blank Linux displays or hide the local cursor");
return;
}
// Active slave grabs temporarily detach devices. Snapshot after grabbing,
// and monitor subsequent hardware/hierarchy changes during protection.
if (!ReadDevices(devices_, error)) {
Fail(error);
return;
}
reply_.active = true;
Check();
}
void Recover() {
reply_.active = false;
if (!display_) {
reply_.recovered = true;
return;
}
for (int id : grabbed_) XIUngrabDevice(display_, id, CurrentTime);
grabbed_.clear();
for (Window root : hidden_roots_) XFixesShowCursor(display_, root);
hidden_roots_.clear();
// Keep failed restorations for retry. Removed CRTCs no longer own a local
// output; all still-present CRTCs must acknowledge the original ramp.
for (auto it = outputs_.begin(); it != outputs_.end();) {
x_error = 0;
Resources resources(XRRGetScreenResourcesCurrent(display_, it->root),
XRRFreeScreenResources);
if (!resources) {
++it;
continue;
}
const bool exists =
std::find(resources->crtcs, resources->crtcs + resources->ncrtc,
it->crtc) != resources->crtcs + resources->ncrtc;
if (exists && it->saved) {
XRRSetCrtcGamma(display_, it->crtc, it->saved.get());
XSync(display_, False);
if (x_error) {
++it;
continue;
}
}
it = outputs_.erase(it);
}
keys_.clear();
if (outputs_.empty() && owner_) {
XDestroyWindow(display_, owner_);
owner_ = 0;
}
XSync(display_, False);
reply_.recovered = outputs_.empty() && !owner_;
}
void Fail(const std::string& reason) {
SetText(reply_.failure, reason);
Recover();
}
void Poll() {
if (!display_) return;
while (XPending(display_)) {
XEvent event{};
XNextEvent(display_, &event);
if (event.type != GenericEvent || event.xcookie.extension != xi_opcode_ ||
!XGetEventData(display_, &event.xcookie))
continue;
if (reply_.active) {
const int type = event.xcookie.evtype;
if (type == XI_KeyPress || type == XI_KeyRelease) {
const auto* key = static_cast<XIDeviceEvent*>(event.xcookie.data);
Key(key->sourceid, key->detail, type == XI_KeyPress);
} else if (!blocking_ &&
(type == XI_RawKeyPress || type == XI_RawKeyRelease)) {
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
Key(key->sourceid, key->detail, type == XI_RawKeyPress);
}
}
XFreeEventData(display_, &event.xcookie);
}
if (reply_.active &&
Clock::now() - checked_ >= std::chrono::milliseconds(100))
Check();
if (!reply_.active && !reply_.recovered) Recover();
}
const LinuxPrivacyReply& Reply() const { return reply_; }
int Connection() const { return display_ ? ConnectionNumber(display_) : -1; }
void AcknowledgeEmergency() { reply_.emergency = false; }
private:
bool Initialize(std::string& error) {
if (initialized_) return true;
const char* session = std::getenv("XDG_SESSION_TYPE");
const char* wayland = std::getenv("WAYLAND_DISPLAY");
if ((session && std::strcmp(session, "wayland") == 0) ||
(wayland && *wayland)) {
error =
"Linux privacy screen requires an X11 session; Wayland is "
"unsupported";
return false;
}
const char* address = std::getenv("DISPLAY");
if (!address ||
(*address != ':' && std::strncmp(address, "unix:", 5) != 0)) {
error = "Linux privacy screen requires a local X11 display";
return false;
}
if (!display_) display_ = XOpenDisplay(nullptr);
if (!display_) {
error = "Cannot open the local X11 display";
return false;
}
int event = 0, code = 0, opcode = 0;
int major = 1, minor = 3;
if (XQueryExtension(display_, "XWAYLAND", &opcode, &event, &code)) {
error = "XWayland cannot protect native Wayland displays";
return false;
}
if (!XRRQueryExtension(display_, &event, &code) ||
!XRRQueryVersion(display_, &major, &minor) || major < 1 ||
(major == 1 && minor < 3)) {
error = "Linux privacy screen requires RandR 1.3 display gamma control";
return false;
}
major = 4;
minor = 0;
if (!XFixesQueryExtension(display_, &event, &code) ||
!XFixesQueryVersion(display_, &major, &minor) || major < 4) {
error = "Linux privacy screen requires XFixes 4 cursor hiding";
return false;
}
major = 2;
minor = 2;
if (!XQueryExtension(display_, "XInputExtension", &xi_opcode_, &event,
&code) ||
XIQueryVersion(display_, &major, &minor) != Success || major < 2) {
error = "Linux privacy screen requires XInput 2 local input monitoring";
return false;
}
xtest_property_ = XInternAtom(display_, "XTEST Device", True);
if (!xtest_property_ ||
!XTestQueryExtension(display_, &event, &code, &major, &minor)) {
error = "Cannot identify X11 remote input devices";
return false;
}
selection_ = XInternAtom(display_, "_CROSSDESK_PRIVACY_GUARD", False);
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
XISetMask(bits.data(), XI_RawKeyPress);
XISetMask(bits.data(), XI_RawKeyRelease);
XIEventMask mask{XIAllDevices, static_cast<int>(bits.size()), bits.data()};
x_error = 0;
for (int i = 0; i < ScreenCount(display_); ++i)
XISelectEvents(display_, RootWindow(display_, i), &mask, 1);
XSync(display_, False);
if (x_error) {
error = "Cannot monitor the local privacy recovery shortcut";
return false;
}
initialized_ = true;
return true;
}
bool ReadOutputs(std::vector<Output>& outputs, bool save,
std::string& error) {
outputs.clear();
x_error = 0;
for (int screen = 0; screen < ScreenCount(display_); ++screen) {
const Window root = RootWindow(display_, screen);
Resources resources(XRRGetScreenResourcesCurrent(display_, root),
XRRFreeScreenResources);
if (!resources) {
error = "Cannot enumerate Linux display outputs";
return false;
}
for (int i = 0; i < resources->ncrtc; ++i) {
CrtcInfo info(
XRRGetCrtcInfo(display_, resources.get(), resources->crtcs[i]),
XRRFreeCrtcInfo);
if (!info) {
error = "Cannot inspect Linux display controller";
return false;
}
if (!info->mode || !info->noutput) continue;
Output output;
output.root = root;
output.crtc = resources->crtcs[i];
output.mode = info->mode;
output.x = info->x;
output.y = info->y;
output.width = info->width;
output.height = info->height;
output.rotation = info->rotation;
output.connectors.assign(info->outputs, info->outputs + info->noutput);
std::sort(output.connectors.begin(), output.connectors.end());
if (save) {
output.saved.reset(XRRGetCrtcGamma(display_, output.crtc));
if (!output.saved || output.saved->size <= 0 ||
IsBlack(output.saved.get())) {
error =
"Display gamma control is unavailable or the display is "
"already blank";
return false;
}
}
outputs.push_back(std::move(output));
}
}
XSync(display_, False);
if (x_error || outputs.empty()) {
error = "No controllable Linux display outputs";
return false;
}
return true;
}
bool ReadDevices(std::vector<Device>& devices, std::string& error) {
devices.clear();
x_error = 0;
int count = 0;
XIDeviceInfo* info = XIQueryDevice(display_, XIAllDevices, &count);
if (!info) {
error = "Cannot enumerate local X11 input devices";
return false;
}
for (int i = 0; i < count; ++i) {
const auto& d = info[i];
if (!d.enabled || d.use == XIMasterPointer || d.use == XIMasterKeyboard)
continue;
Atom type = None;
int format = 0;
unsigned long items = 0, remaining = 0;
unsigned char* value = nullptr;
const int result =
XIGetProperty(display_, d.deviceid, xtest_property_, 0, 1, False,
XA_INTEGER, &type, &format, &items, &remaining, &value);
const bool injected = result == Success && type == XA_INTEGER &&
format == 8 && items == 1 && value && value[0] == 1;
if (value) XFree(value);
if (injected) continue;
Device device;
device.id = d.deviceid;
device.use = d.use;
device.attachment = d.attachment;
device.name = d.name ? d.name : "";
for (int c = 0; c < d.num_classes; ++c) {
device.keyboard |= d.classes[c]->type == XIKeyClass;
device.touch |= d.classes[c]->type == XITouchClass;
}
devices.push_back(std::move(device));
}
XIFreeDeviceInfo(info);
XSync(display_, False);
if (x_error) {
error = "Cannot verify local X11 input devices";
return false;
}
return true;
}
bool Acquire(std::string& error) {
// A second instance must not save the first instance's black ramps as its
// originals. Selection ownership and creation are atomic on this server.
XGrabServer(display_);
if (XGetSelectionOwner(display_, selection_) == None) {
owner_ = XCreateSimpleWindow(display_, DefaultRootWindow(display_), 0, 0,
1, 1, 0, 0, 0);
XSetSelectionOwner(display_, selection_, owner_, CurrentTime);
}
XUngrabServer(display_);
XSync(display_, False);
if (!owner_ || XGetSelectionOwner(display_, selection_) != owner_) {
error = "Another CrossDesk instance owns Linux privacy protection";
return false;
}
reply_.recovered = false;
return true;
}
bool ProtectInput(bool block_input, std::string& error) {
blocking_ = block_input;
if (!block_input) return true;
for (const auto& device : devices_) {
if (device.touch) {
error = "Touchscreen privacy input blocking is unavailable";
return false;
}
if (device.keyboard) {
// Preserve held local modifiers for the emergency chord independently
// of the releases sent to the master keyboard below.
XDevice* keyboard = XOpenDevice(display_, device.id);
XDeviceState* state =
keyboard ? XQueryDeviceState(display_, keyboard) : nullptr;
if (!state) {
if (keyboard) XCloseDevice(display_, keyboard);
error = "Cannot inspect held local keyboard input";
return false;
}
XInputClass* input = state->data;
for (int i = 0; i < state->num_classes; ++i) {
if (input->c_class == KeyClass) {
const auto* keys = reinterpret_cast<XKeyState*>(input);
for (int key = 0; key < 256; ++key)
keys_[device.id][key] = keys->keys[key / 8] & (1 << (key % 8));
}
input = reinterpret_cast<XInputClass*>(
reinterpret_cast<char*>(input) + input->length);
}
XFreeDeviceState(state);
XCloseDevice(display_, keyboard);
}
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
for (int type : {XI_KeyPress, XI_KeyRelease, XI_ButtonPress,
XI_ButtonRelease, XI_Motion})
XISetMask(bits.data(), type);
XIEventMask mask{device.id, static_cast<int>(bits.size()), bits.data()};
const int result = XIGrabDevice(
display_, device.id, DefaultRootWindow(display_), CurrentTime, None,
GrabModeAsync, GrabModeAsync, False, &mask);
if (result != GrabSuccess) {
error = "Cannot grab every local X11 input device";
return false;
}
grabbed_.push_back(device.id);
}
return ReleaseHeldInput(error);
}
bool ReleaseHeldInput(std::string& error) {
// Slave grabs float physical devices, but their previous presses can
// remain on the master. Release them after all grabs are installed so a
// held local Ctrl/button cannot alter the remote controller's next input.
int original_pointer = 0, count = 0;
XIGetClientPointer(display_, None, &original_pointer);
XIDeviceInfo* masters = XIQueryDevice(display_, XIAllMasterDevices, &count);
if (!masters) {
error = "Cannot inspect held X11 input";
return false;
}
x_error = 0;
bool ok = true;
for (int i = 0; i < count; ++i) {
if (masters[i].use != XIMasterPointer || !masters[i].enabled) continue;
XISetClientPointer(display_, None, masters[i].deviceid);
char keys[32]{};
XQueryKeymap(display_, keys);
for (int key = 8; key < 256; ++key)
if (keys[key / 8] & (1 << (key % 8)))
ok &= XTestFakeKeyEvent(display_, key, False, CurrentTime) != 0;
Window root = None, child = None;
double root_x = 0, root_y = 0, win_x = 0, win_y = 0;
XIButtonState buttons{};
XIModifierState modifiers{};
XIGroupState group{};
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
&modifiers, &group);
for (int button = 1; button < buttons.mask_len * 8; ++button)
if (XIMaskIsSet(buttons.mask, button))
ok &= XTestFakeButtonEvent(display_, button, False, CurrentTime) != 0;
if (buttons.mask) XFree(buttons.mask);
XSync(display_, False);
XQueryKeymap(display_, keys);
for (char pressed : keys) ok &= pressed == 0;
buttons = {};
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
&modifiers, &group);
for (int byte = 0; byte < buttons.mask_len; ++byte)
ok &= buttons.mask[byte] == 0;
if (buttons.mask) XFree(buttons.mask);
}
XIFreeDeviceInfo(masters);
if (original_pointer) XISetClientPointer(display_, None, original_pointer);
XSync(display_, False);
if (!ok || x_error) {
error = "Cannot release held X11 input for privacy";
return false;
}
return true;
}
void Key(int source, int keycode, bool down) {
if (keycode < 0 || keycode >= 256 ||
std::none_of(devices_.begin(), devices_.end(), [source](const auto& d) {
return d.id == source && d.keyboard;
}))
return;
keys_[source][keycode] = down;
bool ctrl = false, alt = false, shift = false;
for (const auto& [id, keys] : keys_) {
for (int code = 0; code < 256; ++code) {
if (!keys[code]) continue;
const KeySym symbol = XkbKeycodeToKeysym(display_, code, 0, 0);
ctrl |= symbol == XK_Control_L || symbol == XK_Control_R;
alt |= symbol == XK_Alt_L || symbol == XK_Alt_R;
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
}
}
if (down && ctrl && alt && shift &&
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
reply_.emergency = true;
Recover();
}
}
void Check() {
checked_ = Clock::now();
std::string error;
std::vector<Output> current;
std::vector<Device> devices;
if (!ReadOutputs(current, false, error) || !ReadDevices(devices, error)) {
Fail(error);
return;
}
if (current.size() != outputs_.size() ||
!std::equal(
current.begin(), current.end(), outputs_.begin(),
[](const auto& a, const auto& b) { return a.SameLayout(b); })) {
Fail("Display topology changed; Linux privacy has been turned off");
return;
}
if (devices != devices_) {
Fail("Local input devices changed; Linux privacy has been turned off");
return;
}
x_error = 0;
for (const auto& output : outputs_) {
Gamma ramp(XRRGetCrtcGamma(display_, output.crtc), XRRFreeGamma);
if (!IsBlack(ramp.get())) {
Fail(
"Display gamma protection was changed; Linux privacy is "
"unavailable");
return;
}
}
XSync(display_, False);
if (x_error || XGetSelectionOwner(display_, selection_) != owner_)
Fail("Cannot verify Linux privacy protection");
}
Display* display_ = nullptr;
bool initialized_ = false;
bool blocking_ = false;
int xi_opcode_ = 0;
Atom xtest_property_ = None, selection_ = None;
Window owner_ = 0;
std::vector<Output> outputs_;
std::vector<Device> devices_;
std::vector<int> grabbed_;
std::vector<Window> hidden_roots_;
std::map<int, std::array<bool, 256>> keys_;
LinuxPrivacyReply reply_;
Clock::time_point checked_{};
};
} // namespace
int RunLinuxPrivacyGuard() {
const int socket = kLinuxPrivacyGuardSocket;
int type = 0;
socklen_t length = sizeof(type);
if (getsockopt(socket, SOL_SOCKET, SO_TYPE, &type, &length) != 0 ||
type != SOCK_SEQPACKET)
return 1;
fcntl(socket, F_SETFD, FD_CLOEXEC);
fcntl(socket, F_SETFL, O_NONBLOCK);
struct sigaction action {};
action.sa_handler = StopGuard;
sigemptyset(&action.sa_mask);
sigaction(SIGTERM, &action, nullptr);
sigaction(SIGINT, &action, nullptr);
sigaction(SIGHUP, &action, nullptr);
XSetErrorHandler(OnXError);
NativePrivacy privacy;
auto heartbeat = Clock::now();
bool connected = true;
bool dirty = false;
while (connected && !stopping) {
pollfd fds[] = {{socket, POLLIN, 0}, {privacy.Connection(), POLLIN, 0}};
poll(fds, 2, privacy.Reply().active ? 25 : 250);
if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) break;
for (int i = 0; i < 64; ++i) {
LinuxPrivacyCommand command;
const auto bytes = recv(socket, &command, sizeof(command), 0);
if (bytes < 0 &&
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
break;
if (bytes != sizeof(command)) {
connected = false;
break;
}
heartbeat = Clock::now();
switch (command) {
case LinuxPrivacyCommand::query:
if (!privacy.Reply().active) privacy.Query();
break;
case LinuxPrivacyCommand::enable:
privacy.Enable(false);
break;
case LinuxPrivacyCommand::enable_blocking:
privacy.Enable(true);
break;
case LinuxPrivacyCommand::recover:
privacy.Recover();
break;
case LinuxPrivacyCommand::heartbeat:
break;
default:
connected = false;
break;
}
dirty = true;
}
if (!connected || stopping) break;
const auto before = privacy.Reply();
privacy.Poll();
if (privacy.Reply().active &&
Clock::now() - heartbeat > std::chrono::seconds(3))
privacy.Fail(
"Privacy controller stopped responding; local desktop and input "
"restored");
const auto& after = privacy.Reply();
dirty |= before.active != after.active ||
before.recovered != after.recovered ||
before.emergency != after.emergency ||
std::strcmp(before.failure, after.failure) != 0;
if (dirty) {
const auto& reply = privacy.Reply();
if (send(socket, &reply, sizeof(reply), MSG_NOSIGNAL) == sizeof(reply)) {
dirty = false;
privacy.AcknowledgeEmergency();
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
break;
}
}
// Keep trying if the X server temporarily rejected restoration, even after
// the parent exited. Disconnecting releases XI grabs and cursor hiding too.
do {
privacy.Recover();
if (!privacy.Reply().recovered) poll(nullptr, 0, 250);
} while (!privacy.Reply().recovered);
const auto& reply = privacy.Reply();
const auto ignored = send(socket, &reply, sizeof(reply), MSG_NOSIGNAL);
(void)ignored;
close(socket);
return 0;
}
} // namespace crossdesk