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]>
165 lines
4.9 KiB
Python
Executable File
165 lines
4.9 KiB
Python
Executable File
#!/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())
|