diff --git a/config/plasma/xdg/kdeglobals b/config/plasma/xdg/kdeglobals index b1bb5f3..aef73dd 100644 --- a/config/plasma/xdg/kdeglobals +++ b/config/plasma/xdg/kdeglobals @@ -32,6 +32,22 @@ action/help_report_bug=false action/help_about_kde=false action/help_contents=false +# The network applet reads this one before it draws its own settings +# button and its "Configure Network Connections…" entry — see +# main.qml, which calls +# KAuthorized.authorizeControlModule("kcm_networkmanagement"). +# +# This is only the first of two layers, and on its own it would be +# security theatre. The applet ALSO puts "Show Network's QR Code" and +# "Configure…" on every saved wireless connection as contextual actions, +# and neither of those consults this key — measured on Plasma 6.3. The QR +# code contains the pre-shared key. What actually refuses them is the +# polkit rule in org.ekklesia.classroom.network.rules, which answers at +# NetworkManager rather than in a menu. This key is here so that a teacher +# is not shown doors that will not open. +[KDE Control Module Restrictions][$i] +kcm_networkmanagement=false + # Not immutable. Marking this group [$i] froze the colour scheme so hard # that `classroom-theme light` changed the running session and then lost # the change at the next start — measured. These are defaults for a room diff --git a/config/polkit/org.ekklesia.classroom.network.rules b/config/polkit/org.ekklesia.classroom.network.rules new file mode 100644 index 0000000..d6fd60d --- /dev/null +++ b/config/polkit/org.ekklesia.classroom.network.rules @@ -0,0 +1,86 @@ +// The classroom user cannot be shown a saved Wi-Fi password. +// +// These are shared computers in rooms full of people. The account a +// teacher uses must be able to USE the church's Wi-Fi and must never be +// able to READ its password, and those two are much closer together in a +// stock desktop than they look. +// +// WHY THIS IS NOT DONE IN THE PANEL. +// +// Plasma's network applet offers "Show Network's QR Code" on every saved +// wireless connection — a contextual action in ConnectionItem.qml — and +// that QR code contains the pre-shared key in plain text. Next to it is +// "Configure…", which opens the connection editor. Neither is covered by +// the Kiosk key that hides the applet's own settings button: measured, +// authorizeControlModule("kcm_networkmanagement") gates the toolbar entry +// and not those two. +// +// So hiding buttons cannot be the answer. Every one of those paths ends +// at the same place — NetworkManager's GetSecrets over D-Bus — and polkit +// is where that is answered once, for the applet, the editor, nmcli, a +// future Plasma version and anything else that asks. +// +// WHAT IS REFUSED, AND WHAT IS DELIBERATELY NOT. +// +// Refused: reading or changing stored connections, and the two sharing +// actions that exist to hand the password to somebody. +// +// settings.modify.own is the one that matters most and is the easiest to +// miss. Debian ships it as "yes" — no authentication at all — and it +// applies to any connection whose connection.permissions names this user. +// A Wi-Fi network added from the desktop is created that way by default, +// so a password typed in by a teacher would be readable by the next +// person to sit down. This is why the church's Wi-Fi must be provisioned +// as a SYSTEM connection by an administrator; see classroom-wifi. +// +// Not refused: network-control, wifi.scan and the enable/disable actions. +// Those are what connecting to a network and seeing that it is there are +// made of, and none of them returns a secret. 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. +// +// Scoped to the classroom user by name. A technician's account and root +// are untouched, which is what keeps the machine serviceable: an +// administrator on SSH can still read, edit and add connections normally. +// +// To lift this for maintenance, drop a file of the same name into +// /etc/polkit-1/rules.d/, which polkit reads ahead of this one. + +polkit.addRule(function (action, subject) { + if (subject.user !== "classroom") { + return polkit.Result.NOT_HANDLED; + } + + var refused = [ + // Reading or writing the secrets of a connection this user owns. + // Debian's default for this is "yes". + "org.freedesktop.NetworkManager.settings.modify.own", + // The same for a connection belonging to the system. Debian asks + // for an administrator password here; the classroom has no + // administrator standing at it, and a prompt a teacher cannot + // answer is worse than no menu entry. + "org.freedesktop.NetworkManager.settings.modify.system", + // Handing the password to somebody: the QR code, and the open + // network equivalent. + "org.freedesktop.NetworkManager.wifi.share.protected", + "org.freedesktop.NetworkManager.wifi.share.open", + // Machine-wide settings a classroom has no business changing. + "org.freedesktop.NetworkManager.settings.modify.hostname", + "org.freedesktop.NetworkManager.settings.modify.global-dns", + "org.freedesktop.NetworkManager.reload", + "org.freedesktop.NetworkManager.checkpoint-rollback", + ]; + + for (var i = 0; i < refused.length; i++) { + if (action.id === refused[i]) { + polkit.log( + "ekklesia-classroom: refusing " + action.id + + " for the classroom user" + ); + return polkit.Result.NO; + } + } + + return polkit.Result.NOT_HANDLED; +}); diff --git a/docs/networking.md b/docs/networking.md index 9d8b462..dc27343 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -40,3 +40,69 @@ This preserves an existing static or ifupdown Ethernet setup. The package disables `NetworkManager-wait-online.service`; no classroom feature is allowed to hold boot waiting for Wi-Fi or Internet access. Saved connections still reconnect automatically. + +## Wi-Fi passwords, and who may see them + +These are shared computers in rooms full of people. The classroom account +has to be able to USE the church Wi-Fi and must never be able to READ its +password. In a stock Plasma desktop those two are much closer together +than they look. + +**Provision Wi-Fi with `classroom-wifi`, as root.** Not from the desktop. + +``` +classroom-wifi "Church Wi-Fi" # prompts, never echoes, never in ps +classroom-wifi --list +classroom-wifi --forget "Old Network" +``` + +This is a requirement, not a convenience. A wireless network added from +the desktop becomes a **user** connection: NetworkManager records +`connection.permissions` naming whoever added it, and Debian allows that +user to read its secrets with **no authentication at all** — +`org.freedesktop.NetworkManager.settings.modify.own` ships as `yes` for an +active session. Plasma's applet then offers *Show Network's QR Code* on +every saved wireless connection, and that QR code contains the pre-shared +key. So a password typed in at the desktop is readable by the next person +to sit down. + +`classroom-wifi` creates a **system** connection instead: no +`connection.permissions`, secrets in a root-only file +(`/etc/NetworkManager/system-connections/*.nmconnection`, 0600 root:root), +`psk-flags=0` so nothing lands in a login keyring. Every account can use +it, and NetworkManager reconnects at boot on its own — no teacher ever +re-enters it. + +### What is refused, and what is deliberately not + +`49-ekklesia-classroom-network.rules` answers at NetworkManager rather +than in a menu, because hiding buttons is not a control: the applet's +*Show Network's QR Code* and *Configure…* are contextual actions that do +**not** consult the Kiosk key which hides its settings button. + +Refused for `classroom`: `settings.modify.own`, `settings.modify.system`, +`wifi.share.protected`, `wifi.share.open`, `settings.modify.hostname`, +`settings.modify.global-dns`, `reload`, `checkpoint-rollback`. + +Left alone on purpose: `network-control`, `wifi.scan`, +`enable-disable-network`, `enable-disable-wifi`. None of them returns a +secret, and they are what connecting and seeing a network are made of. A +rule that protected a password by taking the room off the internet would +not be a trade this room can make. Automatic reconnection does not go +through polkit at all — NetworkManager does it as root — so nothing here +can stop the room coming back after a reboot. + +An administrator's own account and root are untouched. To lift any of it +for maintenance, drop a file of the same name into +`/etc/polkit-1/rules.d/`, which polkit reads first. + +### Recovery when there is no network at all + +There is deliberately no teacher-facing "join a network" dialog in this +release. Anything that lets the room add a connection also lets the room +own it, and an owned connection is a readable one. Recovery is an +administrator running `classroom-wifi` over SSH, or at the machine +through the maintenance terminal chord. If a room ever needs to join a +network unattended, that workflow has to be designed so the connection it +creates still belongs to the machine — not bolted on by relaxing the rule +above. diff --git a/scripts/classroom-wifi b/scripts/classroom-wifi new file mode 100755 index 0000000..6a43b6f --- /dev/null +++ b/scripts/classroom-wifi @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 + +"""Put the church's Wi-Fi on this machine, so the room can use it and +nobody in the room can read it. + +Run by an administrator, as root, over SSH or from the maintenance +terminal: + + classroom-wifi "Church Wi-Fi" # prompts for the password + classroom-wifi --list + classroom-wifi --forget "Old Network" + +WHY THIS EXISTS RATHER THAN LETTING A TEACHER ADD ONE. + +A wireless network added from the desktop is created as a USER connection: +NetworkManager writes connection.permissions naming whoever added it. +Debian then allows that user to read its secrets with no authentication at +all — org.freedesktop.NetworkManager.settings.modify.own is "yes" — so a +password typed in by one teacher is readable by the next person to sit +down, through the applet's own "Show Network's QR Code". + +This creates a SYSTEM connection instead: no connection.permissions, so it +belongs to the machine rather than to a person, its secrets are stored in +a root-only file, and reading them needs an administrator. Every account +on the machine can still USE it, and NetworkManager reconnects to it at +boot on its own without anybody logging in. + +That is the whole reason this is an administrator's command. The classroom +account is refused the NetworkManager actions that return secrets — see +org.ekklesia.classroom.network.rules — and that refusal would be pointless +if the room could create a connection it was allowed to read. +""" + +import argparse +import getpass +import os +import subprocess +import sys + +CONNECTIONS = "/etc/NetworkManager/system-connections" + + +def say(message): + print(f"classroom-wifi: {message}") + + +def nmcli(*args, check=True): + result = subprocess.run( + ["nmcli"] + list(args), capture_output=True, text=True, timeout=60, + check=False, + ) + + if check and result.returncode != 0: + say(result.stderr.strip() or "nmcli failed") + raise SystemExit(1) + + return result + + +def listing(): + result = nmcli("-t", "-f", "NAME,TYPE,UUID", "connection", "show") + + for line in result.stdout.splitlines(): + parts = line.split(":") + + if len(parts) >= 2 and "wireless" in parts[1]: + print(f" {parts[0]}") + + return 0 + + +def forget(name): + nmcli("connection", "delete", name) + say(f"removed {name}") + + return 0 + + +def add(ssid, password, name=None): + name = name or ssid + + # Deliberately no connection.permissions. That single omission is the + # difference between a connection the machine owns and one a teacher + # owns and may therefore read. + arguments = [ + "connection", "add", + "type", "wifi", + "con-name", name, + "ifname", "*", + "ssid", ssid, + "connection.autoconnect", "yes", + # Stored in the connection file, which is root-only, rather than + # in a login keyring that belongs to whoever is signed in. + "802-11-wireless-security.psk-flags", "0", + ] + + if password: + arguments += [ + "wifi-sec.key-mgmt", "wpa-psk", + "wifi-sec.psk", password, + ] + + nmcli(*arguments) + + path = os.path.join(CONNECTIONS, f"{name}.nmconnection") + + if os.path.exists(path): + # NetworkManager already writes 0600 root:root. Said out loud and + # enforced anyway, because this is the file the whole arrangement + # rests on. + os.chmod(path, 0o600) + os.chown(path, 0, 0) + mode = oct(os.stat(path).st_mode & 0o777) + say(f"{path} is {mode} root:root") + + say(f"added {name} as a system connection") + say("the room can use it; the classroom account cannot read it") + + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Provision the church Wi-Fi as a system connection.") + parser.add_argument("ssid", nargs="?", help="the network name") + parser.add_argument("--name", help="connection name, if not the SSID") + parser.add_argument("--list", action="store_true", + help="list the wireless connections on this machine") + parser.add_argument("--forget", metavar="NAME", + help="remove a saved connection") + parser.add_argument("--open", action="store_true", + help="the network has no password") + arguments = parser.parse_args() + + if arguments.list: + return listing() + + if os.geteuid() != 0: + say("run this as root; it creates a connection the machine owns") + return 1 + + if arguments.forget: + return forget(arguments.forget) + + if not arguments.ssid: + parser.print_help() + return 2 + + password = "" + + if not arguments.open: + # Never echoed, and never read from the command line, where it + # would sit in a shell history and in ps. + password = getpass.getpass("Wi-Fi password (not shown): ") + + if not password: + say("no password given") + return 1 + + return add(arguments.ssid, password, arguments.name) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-classroom-network.sh b/tests/test-classroom-network.sh new file mode 100755 index 0000000..d325ec8 --- /dev/null +++ b/tests/test-classroom-network.sh @@ -0,0 +1,133 @@ +#!/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 diff --git a/tools/build-release.sh b/tools/build-release.sh index 43f730f..ee4b747 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -128,6 +128,12 @@ install -Dm755 \ "$ROOT/scripts/classroom-printer-setup" \ "$STAGE/usr/local/bin/classroom-printer-setup" +# An administrator's command. The church Wi-Fi has to be provisioned as a +# system connection or the classroom account can read its password. +install -Dm755 \ + "$ROOT/scripts/classroom-wifi" \ + "$STAGE/usr/local/bin/classroom-wifi" + # Shared helpers imported by the classroom commands. install -Dm644 \ "$ROOT/lib/classroom_config.py" \ @@ -411,6 +417,12 @@ install -Dm644 \ "$ROOT/config/polkit/org.ekklesia.classroom.sleep.rules" \ "$STAGE/usr/share/polkit-1/rules.d/49-ekklesia-classroom-sleep.rules" +# The classroom user cannot be shown a saved Wi-Fi password. Same place +# and the same override route as the sleep rule above. +install -Dm644 \ + "$ROOT/config/polkit/org.ekklesia.classroom.network.rules" \ + "$STAGE/usr/share/polkit-1/rules.d/49-ekklesia-classroom-network.rules" + install -Dm644 \ "$ROOT/config/plasma/autostart/ekklesia-classroom-scale.desktop" \ "$STAGE/etc/xdg/autostart/ekklesia-classroom-scale.desktop" diff --git a/tools/validate-trixie-release.sh b/tools/validate-trixie-release.sh index 82b42a8..e7ef885 100755 --- a/tools/validate-trixie-release.sh +++ b/tools/validate-trixie-release.sh @@ -118,6 +118,20 @@ grep -q 'pipewire-pulse' "$audio_unit" command -v classroom-audio >/dev/null command -v pactl >/dev/null +# The classroom account cannot be shown a saved Wi-Fi password. The rule +# has to be installed, owned by root — polkit silently ignores a rules +# file that is not — and it has to refuse the action Debian ships as +# "yes", which is the one a teacher would actually reach. +network_rules=/usr/share/polkit-1/rules.d/49-ekklesia-classroom-network.rules +[[ -f "$network_rules" ]] +[[ "$(stat -c %U:%G "$network_rules")" == "root:root" ]] +grep -q "settings.modify.own" "$network_rules" +grep -q "wifi.share.protected" "$network_rules" +# And it must NOT refuse the actions that keep the room on the network. +! grep -qE '"org\.freedesktop\.NetworkManager\.network-control"' "$network_rules" +grep -q "kcm_networkmanagement=false" /etc/ekklesia-classroom/xdg/kdeglobals +command -v classroom-wifi >/dev/null + # The policy module and the daemon agree on what they import, on a machine # rather than in a checkout. PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \