From 1c1116b6260f1b19da7081c90fa2f4ef62f25799 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 17 Aug 2026 02:36:59 -0500 Subject: [PATCH] Fix passwordless OWE Wi-Fi handling (#7238) --- shell/plugins/panels/network/Model.js | 37 +++++++----- shell/plugins/panels/network/Panel.qml | 54 ++++++++--------- test/shell.d/network-test.sh | 81 ++++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 45 deletions(-) diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index 01d0f34a..b4c84c68 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -299,8 +299,17 @@ function wifiSectionTitle(wifiNetworks, index) { return "" } -function isProtected(security, openSecurity) { - return security !== openSecurity +// OWE (Enhanced Open) encrypts traffic without authenticating the user, so it +// has no credentials to collect. The panel's lock is a credentials-required +// affordance, so OWE should neither show it nor open its attached prompt. +function requiresCredentials(security, openSecurity, oweSecurity) { + // Only explicit passwordless types bypass the prompt. Unknown security + // stays credentialed as the conservative fallback. + return security !== openSecurity && security !== oweSecurity +} + +function canForgetNetwork(network) { + return !!(network && network.known && !network.connected) } // The password arrives on stdin and reaches nmcli through the scriptable @@ -316,10 +325,10 @@ var enterpriseConnectScript = " && nmcli connection up uuid \"$u\"" + " || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }" -function networkFailureReason(reason, reasons) { +function networkFailureReason(reason, needsCredentials, reasons) { var r = reasons || {} - if (reason === r.NoSecrets) return "Passphrase required" - if (reason === r.WifiAuthTimeout) return "Wrong password" + if (needsCredentials && reason === r.NoSecrets) return "Passphrase required" + if (needsCredentials && reason === r.WifiAuthTimeout) return "Wrong password" if (reason === r.WifiNetworkLost) return "Network lost" if (reason === r.WifiClientDisconnected) return "Disconnected" if (reason === r.WifiClientFailed) return "Connection failed" @@ -327,14 +336,15 @@ function networkFailureReason(reason, reasons) { } // Whether a failed connect should reopen the passphrase prompt. NoSecrets -// always means credentials are missing. An auth timeout on a protected -// network means the saved passphrase is wrong (the same profile a first -// failed attempt leaves behind as "known"), so the user needs a chance to -// re-enter it -- connectWithPsk overwrites the stored PSK on submit. -function shouldRepromptPassphrase(reason, isProtected, reasons) { +// means credentials are missing only for a network that actually uses them. +// An auth timeout on such a network means the saved passphrase is wrong (the +// same profile a first failed attempt leaves behind as "known"), so the user +// needs a chance to re-enter it -- connectWithPsk overwrites the stored PSK on +// submit. +function shouldRepromptPassphrase(reason, needsCredentials, reasons) { var r = reasons || {} - if (reason === r.NoSecrets) return true - return !!isProtected && reason === r.WifiAuthTimeout + if (!needsCredentials) return false + return reason === r.NoSecrets || reason === r.WifiAuthTimeout } if (typeof module !== "undefined") { @@ -361,7 +371,8 @@ if (typeof module !== "undefined") { wifiRow: wifiRow, sortWifiRows: sortWifiRows, wifiSectionTitle: wifiSectionTitle, - isProtected: isProtected, + requiresCredentials: requiresCredentials, + canForgetNetwork: canForgetNetwork, enterpriseConnectScript: enterpriseConnectScript, networkFailureReason: networkFailureReason, shouldRepromptPassphrase: shouldRepromptPassphrase diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 9cb737e6..dea1d280 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -398,7 +398,7 @@ Panel { } function canForgetNetwork(net) { - return !!(net && net.known && isProtected(net.security) && !net.connected) + return Model.canForgetNetwork(net) } function canShareNetwork(net) { @@ -417,8 +417,8 @@ Panel { } // Enter/Space on the highlighted row. Mirrors row-click semantics: - // connected → disconnect, protected-unknown → password prompt, - // open/known → connect. + // connected → disconnect, credentials-required/unknown → prompt, + // passwordless/known → connect. function activateSelected() { if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return var net = wifiNetworks[selectedIndex] @@ -428,8 +428,8 @@ Panel { // connectedWifiNetwork when handed null, so a row left stale by scan churn // would otherwise tear down whatever is connected now instead. if (net.connected) { disconnectRow(net.ssid); return } - if (isProtected(net.security) && !net.known) { openPasswordPrompt(net.ssid); return } - connectKnown(net.ssid) + if (requiresCredentials(net.security) && !net.known) { openPasswordPrompt(net.ssid); return } + connectDirectly(net.ssid) } // Bar pill state, derived from the native NetworkManager service so the @@ -680,8 +680,8 @@ Panel { root.close() } - function isProtected(security) { - return Model.isProtected(security, WifiSecurityType.Open) + function requiresCredentials(security) { + return Model.requiresCredentials(security, WifiSecurityType.Open, WifiSecurityType.Owe) } function openPasswordPrompt(ssid) { @@ -735,18 +735,18 @@ Panel { if (!network || actionKind === "" || actionSsid !== (network.name || "")) return actionTimeout.stop() failureSsid = actionSsid - failureReason = networkFailureReason(reason) + failureReason = networkFailureReason(reason, requiresCredentials(network.security)) actionSsid = "" actionKind = "" refresh() } - function networkFailureReason(reason) { - return Model.networkFailureReason(reason, connectionFailReasons) + function networkFailureReason(reason, needsCredentials) { + return Model.networkFailureReason(reason, needsCredentials, connectionFailReasons) } - function shouldRepromptPassphrase(reason, isProtected) { - return Model.shouldRepromptPassphrase(reason, isProtected, connectionFailReasons) + function shouldRepromptPassphrase(reason, needsCredentials) { + return Model.shouldRepromptPassphrase(reason, needsCredentials, connectionFailReasons) } function checkActionCompletion(network) { @@ -756,7 +756,7 @@ Panel { else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction() } - function connectKnown(ssid) { + function connectDirectly(ssid) { runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() }) } @@ -1587,8 +1587,8 @@ Panel { } // A single Wi-Fi network entry. Collapses to a one-line pill normally; - // expands inline to a passphrase prompt when the user picks a protected - // network we don't have credentials for. Clicking a connected row + // expands inline to a passphrase prompt when the user picks a network that + // requires credentials we do not have. Clicking a connected row // disconnects. component NetworkRow: CursorSurface { id: row @@ -1597,14 +1597,14 @@ Panel { readonly property bool isConnected: net && net.connected readonly property bool isKnown: !!(net && net.known) - readonly property bool isProtected: net ? root.isProtected(net.security) : false + readonly property bool requiresCredentials: net ? root.requiresCredentials(net.security) : false readonly property bool isEnterprise: net ? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap) : false - readonly property bool canForgetFromLock: isKnown && isProtected && !isConnected + readonly property bool canForget: root.canForgetNetwork(net) readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index - readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForgetFromLock - readonly property bool forgetVisible: canForgetFromLock && (forgetFocused || rightMouse.containsMouse) + readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForget + readonly property bool forgetVisible: canForget && (!requiresCredentials || forgetFocused || rightMouse.containsMouse) hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused current: isConnected @@ -1631,7 +1631,7 @@ Panel { // failNetworkAction, which clears the action state. var ours = root.actionKind === "connect" && root.actionSsid === (row.net.ssid || "") root.failNetworkAction(root.networkForSsid(row.net.ssid), reason) - if (ours && root.shouldRepromptPassphrase(reason, row.isProtected)) root.openPasswordPrompt(row.net.ssid) + if (ours && root.shouldRepromptPassphrase(reason, row.requiresCredentials)) root.openPasswordPrompt(row.net.ssid) } function onConnectedChanged() { if (row.net) root.checkActionCompletion(root.networkForSsid(row.net.ssid)) @@ -1692,11 +1692,11 @@ Panel { root.disconnectRow(row.net.ssid) return } - if (row.isProtected && !row.isKnown) { + if (row.requiresCredentials && !row.isKnown) { root.openPasswordPrompt(row.net.ssid) return } - root.connectKnown(row.net.ssid) + root.connectDirectly(row.net.ssid) } } @@ -1719,11 +1719,12 @@ Panel { anchors.verticalCenter: parent.verticalCenter } - // Shows a lock glyph for protected networks. Known disconnected - // networks reveal the forget action when hovering that right edge. + // The right edge shows a lock for networks that require credentials and + // reveals Forget on hover. Known passwordless networks show Forget + // directly rather than reserving an invisible or misleading target. Item { id: rightAction - visible: row.isProtected + visible: row.requiresCredentials || row.canForget width: Style.space(22) implicitHeight: lockIndicator.implicitHeight anchors.right: parent.right @@ -1731,6 +1732,7 @@ Panel { Text { id: lockIndicator + visible: row.requiresCredentials || row.forgetVisible width: parent.width anchors.verticalCenter: parent.verticalCenter horizontalAlignment: Text.AlignHCenter @@ -1754,7 +1756,7 @@ Panel { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton - enabled: row.canForgetFromLock && !root.busy + enabled: row.canForget && !root.busy cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true } onClicked: if (row.net) root.forget(row.net) diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh index 920ee9d1..11fdf9dd 100644 --- a/test/shell.d/network-test.sh +++ b/test/shell.d/network-test.sh @@ -189,13 +189,82 @@ assertDeepEqual( 'network wifi rows project exactly the primitive fields, so each delegate stores no live QObject' ) -const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 } -assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures') -assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures') -assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures') +const security = { + Wpa3SuiteB192: 0, + Sae: 1, + Wpa2Eap: 2, + Wpa2Psk: 3, + WpaEap: 4, + WpaPsk: 5, + StaticWep: 6, + DynamicWep: 7, + Leap: 8, + Owe: 9, + Open: 10, + Unknown: 11 +} +for (const name of ['Wpa3SuiteB192', 'Sae', 'Wpa2Eap', 'Wpa2Psk', 'WpaEap', 'WpaPsk', 'StaticWep', 'DynamicWep', 'Leap', 'Unknown']) { + assertEqual(network.requiresCredentials(security[name], security.Open, security.Owe), true, 'network asks for ' + name + ' credentials') +} +assertEqual(network.requiresCredentials(security.Owe, security.Open, security.Owe), false, 'network does not ask for OWE credentials') +assertEqual(network.requiresCredentials(security.Open, security.Open, security.Owe), false, 'network does not ask for open-network credentials') -assertEqual(network.shouldRepromptPassphrase(reasons.NoSecrets, false, reasons), true, 'network reprompts when secrets are missing') -assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, true, reasons), true, 'network reprompts a protected network after a wrong password') +assert( + /Model\.requiresCredentials\(security, WifiSecurityType\.Open, WifiSecurityType\.Owe\)/.test(panelSource), + 'network wires the Quickshell OWE enum into credential detection' +) +assert( + /if \(requiresCredentials\(net\.security\) && !net\.known\)/.test(panelSource), + 'network keyboard activation gates unknown-network prompts on credential requirements' +) +assert( + /if \(row\.requiresCredentials && !row\.isKnown\)/.test(panelSource), + 'network row clicks gate unknown-network prompts on credential requirements' +) +assert( + /shouldRepromptPassphrase\(reason, row\.requiresCredentials\)/.test(panelSource), + 'network failure reprompts use the row credential requirement' +) +assert( + /networkFailureReason\(reason, requiresCredentials\(network\.security\)\)/.test(panelSource), + 'network failure copy uses the live network credential requirement' +) +assert( + /readonly property bool canForget: root\.canForgetNetwork\(net\)/.test(panelSource), + 'network rows derive forget eligibility from the tested model helper' +) +const rightAction = panelSource.match(/Item \{\s*id: rightAction\b[\s\S]*?\n {6}\}/) +assert(rightAction, 'network has a right-edge action target') +assert( + /visible: row\.requiresCredentials \|\| row\.canForget/.test(rightAction[0]), + 'network keeps a forget target for known passwordless networks' +) +const lockIndicator = panelSource.match(/Text \{\s*id: lockIndicator\b[\s\S]*?\n {8}\}/) +assert(lockIndicator, 'network has a lock/forget indicator') +assert( + /visible: row\.requiresCredentials \|\| row\.forgetVisible/.test(lockIndicator[0]), + 'network hides the lock on passwordless networks until showing their forget action' +) +assert( + /forgetVisible: canForget && \(!requiresCredentials \|\| forgetFocused \|\| rightMouse\.containsMouse\)/.test(panelSource), + 'network shows the forget action directly for known passwordless networks' +) + +const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 } +assertEqual(network.networkFailureReason(reasons.NoSecrets, true, reasons), 'Passphrase required', 'network maps missing credential failures') +assertEqual(network.networkFailureReason(reasons.WifiAuthTimeout, true, reasons), 'Wrong password', 'network maps credentialed auth timeouts') +assertEqual(network.networkFailureReason(reasons.NoSecrets, false, reasons), 'Failed to connect', 'network gives passwordless missing-secret failures generic copy') +assertEqual(network.networkFailureReason(reasons.WifiAuthTimeout, false, reasons), 'Failed to connect', 'network gives passwordless auth timeouts generic copy') +assertEqual(network.networkFailureReason(99, true, reasons), 'Failed to connect', 'network maps unknown failures') + +assertEqual(network.canForgetNetwork({ known: true, connected: false, security: security.Owe }), true, 'network can forget known disconnected OWE networks') +assertEqual(network.canForgetNetwork({ known: true, connected: false, security: security.Open }), true, 'network can forget known disconnected open networks') +assertEqual(network.canForgetNetwork({ known: false, connected: false, security: security.Owe }), false, 'network cannot forget unknown networks') +assertEqual(network.canForgetNetwork({ known: true, connected: true, security: security.Owe }), false, 'network cannot forget the connected network') + +assertEqual(network.shouldRepromptPassphrase(reasons.NoSecrets, true, reasons), true, 'network reprompts when required credentials are missing') +assertEqual(network.shouldRepromptPassphrase(reasons.NoSecrets, false, reasons), false, 'network does not ask a passwordless network for missing secrets') +assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, true, reasons), true, 'network reprompts a credentialed network after a wrong password') assertEqual(network.shouldRepromptPassphrase(reasons.WifiAuthTimeout, false, reasons), false, 'network does not reprompt an open network on auth timeout') assertEqual(network.shouldRepromptPassphrase(reasons.WifiClientFailed, true, reasons), false, 'network does not reprompt on generic connection failures')