Files
Classroom_Desktop/tests/test-classroom-network.sh
ekklesiaandClaude Opus 5 bf7b959893 feat: the room may use the church Wi-Fi and may not read its password
These are shared computers in rooms full of people, and in a stock Plasma
desktop "use the network" and "read the network's password" are much
closer together than they look.

Measured on Debian 13 with NetworkManager 1.52 and Plasma 6.3:

  * org.freedesktop.NetworkManager.settings.modify.own ships as "yes" for
    an active session — no prompt, no administrator. It covers any
    connection whose connection.permissions names that user, and a Wi-Fi
    network added from the desktop is created exactly that way. So a
    password a teacher types in is readable by the next person to sit
    down.
  * the applet offers "Show Network's QR Code" on every saved wireless
    connection, and the QR code contains the pre-shared key.
  * that action, and the "Configure…" beside it, are contextual actions in
    ConnectionItem.qml and do NOT consult
    authorizeControlModule("kcm_networkmanagement"). The Kiosk key gates
    the toolbar button only. Hiding buttons was never going to be the
    answer.

Every one of those paths ends at NetworkManager's GetSecrets, so that is
where this is answered. A polkit rule refuses settings.modify.own,
settings.modify.system, both wifi.share actions and the machine-wide
settings, for the classroom user by name.

Deliberately NOT refused: network-control, wifi.scan and the
enable/disable actions. None returns a secret, and they are what
connecting and seeing a network are made of. Automatic reconnection does
not go through polkit at all — NetworkManager does it as root — so nothing
here can stop the room coming back on the network after a reboot, which is
the property that matters most on a Sunday.

An administrator's account and root are untouched, and the override route
is the same one the sleep rule has.

classroom-wifi is the other half, and it is a requirement rather than a
convenience: it provisions the church Wi-Fi as a SYSTEM connection, with
no connection.permissions, psk-flags=0 and a 0600 root:root file. A
connection the machine owns is one nobody in the room can read. Verified
against real NetworkManager in the lab — the classroom account gets
"Permission denied" on the file, "--" from nmcli and "Insufficient
privileges" from GetSecrets, while autoconnect stays on.

There is deliberately no teacher-facing join dialog. Anything that lets
the room add a connection lets the room own it, and an owned connection is
a readable one; docs/networking.md says so rather than leaving it to be
rediscovered.

The rule is plain JavaScript, so the test evaluates it with polkit's
objects stubbed and asserts both halves: which actions come back NO, and
which are left alone. What it cannot test is the last mile — polkit
resolves a subject through logind and a container has no graphical seat
session, so every check there is denied for reasons unrelated to this
rule. That one is a physical check, and it is written down as one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 21:04:07 -05:00

134 lines
6.1 KiB
Bash
Executable File

#!/bin/bash
# What the classroom account may and may not do to the network.
#
# The rule is plain JavaScript that polkitd evaluates, so it is evaluated
# here too, with polkit's own objects stubbed. That tests the part this
# package actually wrote: which action ids come back NO, for which user,
# and — just as important — which ones are deliberately left alone,
# because a rule that refuses too much takes the room off the network.
#
# What this canNOT test is the last mile: that a teacher sitting in a real
# Plasma session cannot reveal a saved password through the applet. polkit
# resolves a subject through logind, and a container has no graphical seat
# session for it to resolve, so every check here comes back denied for
# reasons that have nothing to do with this rule. docs/testing.md carries
# the physical procedure for that.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v node >/dev/null 2>&1; then
echo " SKIP node is not installed; the polkit rule was not evaluated"
exit 0
fi
RULES="$ROOT/config/polkit/org.ekklesia.classroom.network.rules"
SLEEP_RULES="$ROOT/config/polkit/org.ekklesia.classroom.sleep.rules"
KDEGLOBALS="$ROOT/config/plasma/xdg/kdeglobals"
WIFI="$ROOT/scripts/classroom-wifi"
node - "$RULES" "$SLEEP_RULES" "$KDEGLOBALS" "$WIFI" <<'JS'
const fs = require("fs");
const [rulesPath, sleepPath, kdeglobalsPath, wifiPath] = process.argv.slice(2);
let passed = 0, failed = 0;
function check(label, condition, detail) {
if (condition) { console.log(" ok " + label); passed++; }
else { console.log(" FAIL " + label); if (detail) console.log(" " + detail); failed++; }
}
// polkit's own surface, as far as these rules use it.
function loadRules(path) {
const handlers = [];
const polkit = {
Result: {NO: "NO", YES: "YES", NOT_HANDLED: "NOT_HANDLED",
AUTH_ADMIN: "AUTH_ADMIN"},
addRule: (fn) => handlers.push(fn),
log: () => {},
};
const source = fs.readFileSync(path, "utf8");
new Function("polkit", source)(polkit);
return (actionId, user) => {
for (const handler of handlers) {
const result = handler({id: actionId}, {user: user});
if (result !== polkit.Result.NOT_HANDLED) return result;
}
return "NOT_HANDLED";
};
}
const decide = loadRules(rulesPath);
console.log("1. the classroom account cannot be shown a saved password");
// settings.modify.own is the one that matters. Debian ships it as "yes"
// for an active session: no prompt, no administrator, just the password.
// A network added from the desktop is user-owned, so this is the path a
// teacher would actually have.
check("a connection this user owns is refused",
decide("org.freedesktop.NetworkManager.settings.modify.own", "classroom") === "NO");
check("a connection the system owns is refused",
decide("org.freedesktop.NetworkManager.settings.modify.system", "classroom") === "NO");
check("the QR code that contains the pre-shared key is refused",
decide("org.freedesktop.NetworkManager.wifi.share.protected", "classroom") === "NO");
check("and its open-network twin",
decide("org.freedesktop.NetworkManager.wifi.share.open", "classroom") === "NO");
console.log("2. the room stays on the network");
// Refusing these would be a rule that protects a password by taking the
// classroom off the internet, which is not a trade this room can make.
for (const action of ["network-control", "wifi.scan",
"enable-disable-network", "enable-disable-wifi"]) {
check(action + " is left alone",
decide("org.freedesktop.NetworkManager." + action, "classroom") === "NOT_HANDLED");
}
console.log("3. an administrator keeps every one of them");
for (const user of ["tech", "root"]) {
check(user + " is not touched by this rule",
["settings.modify.own", "settings.modify.system",
"wifi.share.protected"].every(
a => decide("org.freedesktop.NetworkManager." + a, user) === "NOT_HANDLED"));
}
console.log("4. machine-wide settings a room has no business changing");
for (const action of ["settings.modify.hostname", "settings.modify.global-dns",
"reload", "checkpoint-rollback"]) {
check(action + " is refused",
decide("org.freedesktop.NetworkManager." + action, "classroom") === "NO");
}
console.log("5. the sleep rule is unchanged by any of this");
const sleepDecide = loadRules(sleepPath);
check("the classroom still cannot suspend the room",
sleepDecide("org.freedesktop.login1.suspend", "classroom") === "NO");
check("and a technician still can",
sleepDecide("org.freedesktop.login1.suspend", "tech") === "NOT_HANDLED");
check("the network rule does not answer sleep",
decide("org.freedesktop.login1.suspend", "classroom") === "NOT_HANDLED");
console.log("6. the panel is told not to offer doors that will not open");
const kdeglobals = fs.readFileSync(kdeglobalsPath, "utf8");
check("the network settings module is refused by Kiosk policy",
/\[KDE Control Module Restrictions\]\[\$i\][\s\S]*?kcm_networkmanagement=false/.test(kdeglobals));
check("and the restriction is immutable, like the rest of the policy",
/\[KDE Control Module Restrictions\]\[\$i\]/.test(kdeglobals));
console.log("7. the Wi-Fi an administrator provisions belongs to the machine");
const wifi = fs.readFileSync(wifiPath, "utf8");
// As a quoted nmcli argument, which is the only form that would actually
// set it. The prose above it in the script says the word on purpose.
check("no connection.permissions is passed to nmcli, so it is the machine's",
!/"connection\.permissions"/.test(wifi),
"a permissions argument would make the secrets readable by that user");
check("secrets are stored in the connection file, not a login keyring",
/psk-flags[\s\S]{0,40}"0"/.test(wifi));
check("the password is prompted for, never taken from the command line",
/getpass\.getpass/.test(wifi) && !/--psk/.test(wifi));
check("and it refuses to run as anybody but root",
/geteuid\(\) != 0/.test(wifi));
console.log();
console.log(passed + " passed, " + failed + " failed");
process.exit(failed ? 1 : 0);
JS