diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 766d379..f387e07 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -19,13 +19,18 @@ import qs.services SettingsPage { id: root + title: "Network & Devices" lede: Connectivity.activeNetwork ? "Connected to " + Connectivity.activeNetwork.name : "Wi-Fi, Bluetooth, and the things Fedora owns." // Drive the scanners only while this page is the one being shown. - Component.onCompleted: Connectivity.active = true + Component.onCompleted: { + Connectivity.active = true; + if (!WifiShare.scanned) + WifiShare.refresh(); + } Component.onDestruction: Connectivity.active = false SettingsCard { @@ -68,6 +73,69 @@ SettingsPage { } } + // Sharing a network by QR, the way GNOME's Wi-Fi panel does. The + // alternative is reading a passphrase out loud. + // + // The image holds the password in machine-readable form, so it is generated + // on demand rather than up front, and the helper writes it to tmpfs under + // XDG_RUNTIME_DIR instead of anywhere persistent. + SettingsCard { + visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0 + title: "Share a network" + subtitle: WifiShare.sharing !== "" + ? "Point a phone's camera at the code to join " + WifiShare.sharing + "." + : "Shows a QR code a phone can scan to join, without reading the password out." + + Repeater { + model: WifiShare.shareable + + ActionRow { + id: shareRow + required property var modelData + required property int index + + label: shareRow.modelData.ssid + detail: WifiShare.sharing === shareRow.modelData.name + ? "Showing a code below — anyone who can see the screen can join" + : "Saved network" + action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code" + divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== "" + onTriggered: WifiShare.sharing === shareRow.modelData.name + ? WifiShare.stopSharing() + : WifiShare.share(shareRow.modelData.name) + } + } + + // Drawn at its natural size on a white plate: a QR code inverted or + // tinted to match a dark theme is unreliable to scan, and this one has + // exactly one job. + Item { + width: parent.width + visible: WifiShare.sharing !== "" && WifiShare.imagePath !== "" + implicitHeight: visible ? plate.height + 20 : 0 + + Rectangle { + id: plate + anchors.horizontalCenter: parent.horizontalCenter + y: 10 + width: 208 + height: 208 + radius: 10 + color: "white" + + Image { + anchors.centerIn: parent + width: 184 + height: 184 + smooth: false + fillMode: Image.PreserveAspectFit + cache: false + source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : "" + } + } + } + } + SettingsCard { title: "Bluetooth" visible: Connectivity.adapter !== null diff --git a/config/dot/quickshell/scripts/panama-wifi-qr b/config/dot/quickshell/scripts/panama-wifi-qr new file mode 100755 index 0000000..6532bb5 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-wifi-qr @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +# A QR code for a saved Wi-Fi network, so a guest can join by pointing a camera. +# +# GNOME's Wi-Fi panel has this and it is the single most-used thing in it. +# The payload is the de-facto WIFI: URI that Android and iOS both scan: +# +# WIFI:T:WPA;S:;P:;H:;; +# +# HANDLING THE PASSPHRASE +# +# This image contains the network password in machine-readable form. Anyone who +# can read the file can read the password, so: +# +# * it is written under XDG_RUNTIME_DIR, which is 0700 and on tmpfs, so it +# never reaches disk and disappears at logout -- not /tmp, which is shared; +# * it is created with umask 077; +# * the passphrase is never printed, never passed as an argument (argv is +# world-readable via /proc), and never appears in an error message. +# +# It is piped to qrencode on stdin for that last reason. +# +# Usage: +# panama-wifi-qr list -> {"networks":[{"name","ssid","shareable"}]} +# panama-wifi-qr qr -> {"path":"/run/user/…/….png"} + +set -uo pipefail + +emit_error() { + printf '{"networks":[],"path":"","error":%s}\n' "$(jq -Rn --arg e "$1" '$e')" + exit 0 +} + +command -v nmcli >/dev/null 2>&1 || emit_error 'NetworkManager is not available' +command -v qrencode >/dev/null 2>&1 || emit_error 'qrencode is not installed, so a Wi-Fi QR code cannot be drawn' + +cmd_list() { + local rows=() name ssid psk + while IFS= read -r name; do + [[ -n "$name" ]] || continue + ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)" + [[ -n "$ssid" ]] || ssid="$name" + + # Only networks whose passphrase this user can actually read are + # shareable. An enterprise network has no passphrase to share at all, + # and a QR code for one would simply not work. + psk="$(nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null)" + + rows+=("$(jq -cn --arg name "$name" --arg ssid "$ssid" \ + --argjson shareable "$([[ -n "$psk" ]] && echo true || echo false)" \ + '{name: $name, ssid: $ssid, shareable: $shareable}')") + done < <(nmcli -t -f NAME,TYPE connection show 2>/dev/null \ + | awk -F: '$2 == "802-11-wireless" { print $1 }') + + if [[ ${#rows[@]} -eq 0 ]]; then + printf '{"networks":[],"path":"","error":"No saved Wi-Fi networks."}\n' + return 0 + fi + printf '{"networks":[%s],"path":"","error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")" +} + +# The WIFI: URI reserves \ ; , : and ", each escaped with a backslash. An SSID +# containing a semicolon would otherwise terminate the field early and produce a +# QR code for a different network entirely. +# +# Trailing newlines are stripped as well. nmcli terminates every value with one, +# and left in place it lands INSIDE the payload -- the code still decodes here, +# but a newline in the middle of a WIFI: URI is not something every phone's +# scanner tolerates, and the failure would look like "the QR code just does not +# work on my phone". +escape_field() { + sed -e 's/\\/\\\\/g' -e 's/;/\\;/g' -e 's/,/\\,/g' -e 's/:/\\:/g' -e 's/"/\\"/g' \ + | tr -d '\n' +} + +cmd_qr() { + local name="${1:-}" + [[ -n "$name" ]] || emit_error 'no network named' + + local ssid hidden psk_file payload_file out_dir out_file + ssid="$(nmcli -g 802-11-wireless.ssid connection show "$name" 2>/dev/null)" + [[ -n "$ssid" ]] || emit_error "There is no saved network called \"$name\"." + + hidden="$(nmcli -g 802-11-wireless.hidden connection show "$name" 2>/dev/null)" + [[ "$hidden" == "yes" ]] && hidden=true || hidden=false + + out_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama" + umask 077 + mkdir -p "$out_dir" 2>/dev/null || emit_error 'could not create the runtime directory' + chmod 700 "$out_dir" 2>/dev/null || true + + # Named after the connection, hashed, so repeated shares reuse one file + # instead of accumulating images of the password. + out_file="$out_dir/wifi-$(printf '%s' "$name" | sha256sum | cut -c1-16).png" + + # Built in a file rather than a variable that could be echoed, and piped to + # qrencode on stdin so the passphrase never appears in argv. + payload_file="$(mktemp "$out_dir/payload.XXXXXX")" || emit_error 'could not create a temporary file' + trap 'rm -f "$payload_file"' RETURN + + { + printf 'WIFI:T:WPA;S:' + printf '%s' "$ssid" | escape_field + printf ';P:' + nmcli -s -g 802-11-wireless-security.psk connection show "$name" 2>/dev/null | escape_field + printf ';H:%s;;' "$hidden" + } >"$payload_file" + + if ! qrencode -o "$out_file" -s 8 -m 2 -l M <"$payload_file" 2>/dev/null; then + emit_error "Could not generate a QR code for \"$name\"." + fi + chmod 600 "$out_file" 2>/dev/null || true + + jq -cn --arg path "$out_file" '{networks: [], path: $path, error: ""}' +} + +case "${1:-list}" in + list) cmd_list ;; + qr) shift; cmd_qr "${1:-}" ;; + *) printf 'usage: panama-wifi-qr [list|qr ]\n' >&2; exit 2 ;; +esac diff --git a/config/dot/quickshell/services/WifiShare.qml b/config/dot/quickshell/services/WifiShare.qml new file mode 100644 index 0000000..edb7aea --- /dev/null +++ b/config/dot/quickshell/services/WifiShare.qml @@ -0,0 +1,99 @@ +pragma Singleton + +// A QR code for a saved Wi-Fi network, so a guest can join by pointing a phone +// at the screen. GNOME's Wi-Fi panel has this and it is the most-used thing in +// it; reading a passphrase aloud is the alternative. +// +// The generated image contains the network password in machine-readable form, +// so the helper writes it under XDG_RUNTIME_DIR -- 0700, on tmpfs, gone at +// logout -- rather than anywhere persistent. Nothing here ever holds the +// passphrase itself; this service only ever sees a file path. +// +// Generated on demand. Producing a QR for every saved network up front would +// mean writing images of passwords nobody asked to see. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-wifi-qr" + + // [{ name, ssid, shareable }] + property var networks: [] + property bool scanned: false + property string lastError: "" + + // The network whose code is on screen, and where its image is. Empty when + // nothing is being shared. + property string sharing: "" + property string imagePath: "" + + readonly property var shareable: root.networks.filter(n => n.shareable) + + function refresh(): void { + if (!list.running) + list.running = true; + } + + function share(name: string): void { + if (generate.running) + return; + // Cache-bust: the helper reuses one file per network, so a QML Image + // pointed at the same path would keep showing the previous render. + root.imagePath = ""; + root.sharing = name; + generate.command = [root.helperPath, "qr", name]; + generate.running = true; + } + + function stopSharing(): void { + root.sharing = ""; + root.imagePath = ""; + } + + Process { + id: list + command: [root.helperPath, "list"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.networks = Array.isArray(parsed.networks) ? parsed.networks : []; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.networks = []; + root.lastError = "Could not read the Wi-Fi helper's output."; + console.warn("WifiShare: could not parse helper output:", error); + } + root.scanned = true; + } + } + } + + Process { + id: generate + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + const path = String(parsed.path ?? ""); + const error = String(parsed.error ?? ""); + if (error !== "" || path === "") { + root.lastError = error !== "" ? error : "No QR code was produced."; + root.sharing = ""; + return; + } + root.lastError = ""; + root.imagePath = path; + } catch (error) { + root.lastError = "Could not read the generated QR code's path."; + root.sharing = ""; + console.warn("WifiShare: could not parse helper output:", error); + } + } + } + } +} diff --git a/tests/quickshell/wifi-qr-contract.sh b/tests/quickshell/wifi-qr-contract.sh new file mode 100755 index 0000000..66b866f --- /dev/null +++ b/tests/quickshell/wifi-qr-contract.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash + +# panama-wifi-qr renders a saved network as a QR code a phone can scan. +# +# The QR contains the network PASSWORD in machine-readable form, so most of what +# is worth testing here is about handling that safely rather than about QR +# codes. Both nmcli and qrencode are stubbed: the real ones would read this +# machine's actual passphrases, and a test that writes the daily driver's Wi-Fi +# password into a fixture directory is not one worth having. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-wifi-qr" + +fail() { + printf 'wifi qr contract: %s\n' "$1" >&2 + exit 1 +} + +work="$(mktemp -d /tmp/panama-wifiqr.XXXXXX)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/run" + +readonly SECRET='hunter2-secret' + +cat >"$work/bin/nmcli" <"$work/bin/qrencode" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$QRENCODE_ARGV_LOG" +out="" +prev="" +for arg in "$@"; do + [[ "$prev" == "-o" ]] && out="$arg" + prev="$arg" +done +cat >"$QRENCODE_STDIN_LOG" +printf 'fake-png' >"$out" +exit 0 +STUB +chmod +x "$work/bin/qrencode" + +export QRENCODE_ARGV_LOG="$work/argv.log" +export QRENCODE_STDIN_LOG="$work/stdin.log" +: >"$QRENCODE_ARGV_LOG" +: >"$QRENCODE_STDIN_LOG" + +run() { PATH="$work/bin:$PATH" XDG_RUNTIME_DIR="$work/run" "$helper" "$@"; } + +# ── Listing distinguishes shareable from not ──────────────────────────────── +out="$(run list)" +jq -e . >/dev/null 2>&1 <<<"$out" || fail "list did not emit JSON: $out" +[[ "$(jq -r '.networks | length' <<<"$out")" == "2" ]] \ + || fail "only wireless connections belong in the list: $out" +jq -e '.networks[] | select(.name == "home net") | .shareable == true' >/dev/null <<<"$out" \ + || fail "a network with a passphrase must be shareable: $out" +jq -e '.networks[] | select(.name == "work-eap") | .shareable == false' >/dev/null <<<"$out" \ + || fail "an enterprise network has no passphrase, so a QR code for it cannot work: $out" + +# ── The payload ───────────────────────────────────────────────────────────── +path="$(run qr 'home net' | jq -r .path)" +[[ -n "$path" && -e "$path" ]] || fail 'no image was produced' + +payload="$(cat "$QRENCODE_STDIN_LOG")" +grep -q "P:$SECRET;" <<<"$payload" \ + || fail 'the passphrase did not reach the payload intact' + +# The SSID is "home;net": unescaped, the semicolon ends the S: field early and +# the code describes a different network. +grep -qF 'S:home\;net;' <<<"$payload" \ + || fail "a reserved character in the SSID was not escaped: $payload" + +[[ "$(wc -l <"$QRENCODE_STDIN_LOG")" == "0" ]] \ + || fail "the payload contains a newline; nmcli's trailing newline must be stripped: $(cat -A "$QRENCODE_STDIN_LOG")" + +grep -q ';;$' <<<"$payload" || fail "the WIFI: URI must be terminated with ;;: $payload" + +# ── The secret must never appear in argv ──────────────────────────────────── +grep -q "$SECRET" "$QRENCODE_ARGV_LOG" \ + && fail 'the passphrase was passed as a command-line argument, where /proc exposes it to every process on the machine' + +# ── The image and its directory must not be readable by others ────────────── +[[ "$(stat -c '%a' "$path")" == "600" ]] \ + || fail "the QR image is mode $(stat -c '%a' "$path"); it contains a password" +[[ "$(stat -c '%a' "$(dirname "$path")")" == "700" ]] \ + || fail "the directory holding QR images is mode $(stat -c '%a' "$(dirname "$path")")" + +# ── No temporary payload files may survive ────────────────────────────────── +leftovers="$(find "$work/run" -name 'payload.*' | wc -l)" +[[ "$leftovers" == "0" ]] \ + || fail "$leftovers temporary payload file(s) containing the passphrase were left behind" + +# ── An unknown network is an error, not an empty image ────────────────────── +out="$(run qr 'no-such-network')" +jq -e '.path == "" and .error != ""' >/dev/null <<<"$out" \ + || fail "an unknown network must be reported: $out" + +printf 'wifi qr contract: PASS\n'