#!/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