Fix passwordless OWE Wi-Fi handling (#7238)

This commit is contained in:
David Heinemeier Hansson
2026-08-17 03:36:59 -04:00
committed by GitHub
parent 30f7a06090
commit 1c1116b626
3 changed files with 127 additions and 45 deletions
+24 -13
View File
@@ -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
+28 -26
View File
@@ -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)
+75 -6
View File
@@ -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')