Skip to content

Hardening

codingncaffeine edited this page Sep 8, 2026 · 4 revisions

Hardening

What has actually been done to reduce what this application can be made to do, what was found when it was reviewed, and what has deliberately not been done.

The short version is in SECURITY.md. This page is the reasoning and the measurements.

The shape of the risk

Nexus Manager is not a network service. It has no listener on any port, no update checker and no telemetry. What makes it worth reviewing at all is a narrower set of capabilities:

Capability Why it exists What it means
Writes to a hidraw device It draws on the panel Access is an ACL for the logged-in user, not root
Creates a virtual keyboard on /dev/uinput A touch button presses a real key; Wayland has no XTEST While running, it can type into whatever has focus
Runs a configured command through /bin/sh A touch button launches things Whoever can write your config runs commands as you
Spawns parec, pactl, wpctl, gdbus Audio capture and media control Fixed argument arrays, never a shell
Binds a Unix socket A second launch raises the first one's window Accepts exactly one word, show

Everything after the first two rows is ordinary desktop behaviour. The first two are the ones worth understanding before you install it.

No privilege at any point

There is no setuid binary, no capability, no polkit action and no system service. The bundled systemd unit is a user unit. After installation nothing belonging to this project runs as root.

Device access comes from a udev rule that tags the panel with uaccess, which grants an ACL to the active local seat's user and revokes it at logout — rather than MODE="0666", which would hand the device to every process on the machine permanently. See Device Access on Linux.

Sandboxing the daemon

systemd-analyze security rates the shipped unit 1.3 OK. It rated 7.0 MEDIUM before this pass. Check it yourself:

systemd-analyze security --user nexus-manager.service

What was added: SystemCallFilter=@system-service, SystemCallArchitectures=native, PrivateNetwork=yes and IPAddressDeny=any, RestrictAddressFamilies narrowed to what the app actually uses, DevicePolicy=closed with only the panel and the virtual keyboard allowed through, an empty capability bounding set, LockPersonality, RestrictSUIDSGID, ProtectHostname, ProtectClock, ProtectProc=invisible and UMask=0077.

⛔ Three directives are deliberately absent

A hardening score measures the policy. It says nothing about whether the program still runs under it. Each of these improved the score and had to be reverted because the service would not start:

Directive What happens
MemoryDenyWriteExecute=true The .NET runtime JITs and needs pages that are writable and then executable. The service does not start.
SystemCallFilter=~@resources Failed to create CoreCLR, HRESULT: 0x8007054F. The runtime needs syscalls in that set.
RestrictAddressFamilies=AF_UNIX alone Device enumeration goes through udev, which needs AF_NETLINK. Without it the panel is simply never found.

They are written down here and in the unit file so that the next person to look at the score does not re-add them.

How to test a directive rather than guess:

systemd-run --user -p ProtectHome=read-only --wait --pipe nexus-manager daemon

One property per run names the culprit in minutes. Two things to watch for: systemctl is-active reports activating during a restart loop, so it is not proof of anything; and a test unit with Restart=always will grab the panel lock between probes and quietly invalidate every result after it.

What the review found

Five faults, all in 0.0.3, all fixed in 0.0.4. They are listed because the interesting part is the pattern rather than the individual bugs.

  • The bundled systemd unit could never start the daemon. ProtectHome= covers /run/user as well as /home and /root, so the directory holding the single-instance lock was read-only. The daemon then reported "the panel is already being driven by another instance" — naming a process that did not exist. Fixed with ReadWritePaths=%t.
  • A corrupt configuration crashed the application with a core dump. One interrupted autosave left it unstartable. It now reports the parse error, moves the unreadable file aside rather than letting the next save overwrite it, and starts from a discovered configuration.
  • The /tmp fallback directory was created with default permissions. That path is predictable and /tmp is world-writable, so another local user could create it first and own the directory the lock, owner record and socket are made in — and File.WriteAllText follows symlinks. Now created 0700, with the mode checked afterwards, because CreateDirectory does not alter a directory that already exists.
  • Lock failures were reported as contention. Three unrelated causes — a permissions fault, a sandbox denial and genuine contention — all produced the same message. A failure path is a diagnostic; giving one sentinel three meanings makes the program confidently wrong.
  • A launched program could deadlock or pin a thread. The launcher read one pipe to the end before the other, so a child filling stderr hung both, and since the read only returns when the child exits, launching anything long-lived held a worker for its lifetime.

⛔ Every one of those passed every other check. The package inventory, the dependency sweep, the launch tests and the hardening score were all green while the service could not boot. That is why packaging/test-deb.sh now starts the packaged unit and requires it to reach its ready state, verified against the broken unit as a negative control.

Supply chain

  • Release packages bundle the .NET runtime, so they depend only on system libraries.
  • Dependency lists in the .deb and the PKGBUILD are derived from a sweep of the published binaries — objdump -p for NEEDED entries plus a strings sweep for anything loaded by dlopen — rather than written from memory.
  • dotnet list package --vulnerable --include-transitive reports nothing across all seven projects, and nothing deprecated.
  • packaging/test-deb.sh asserts the package contents against the tree it was built from and then launches the binaries out of the extracted package with DOTNET_ROOT scrubbed, so a bundle that silently fell back to a developer toolchain would be caught.

Not done, and worth doing

Stated plainly rather than left for someone to discover:

  • Releases are not signed. No GPG signature on tags or artifacts, and no build provenance attestation. The AUR package pins a SHA-256 of the release tarball, which protects against a corrupted download but not against a compromised release.
  • There is no CI. Everything is built and tested on one machine. No automated dependency scanning, no static analysis on push.
  • Builds are not reproducible. A .NET publish embeds timestamps, so two builds of the same commit differ. Nobody can independently confirm that a published binary came from the published source.
  • No portal or Flatpak confinement. The application needs raw hidraw and uinput access, which sandboxed packaging formats do not grant well. This is a real limitation rather than an oversight.
  • A Launch action is not confirmed before it runs. The configuration is trusted the way a shell profile is trusted. If configuration ever becomes importable — .cuescreens import is not implemented — that assumption has to be revisited before it ships, because a file from someone else could then carry an action.

Clone this wiki locally