From 5fea906c9f488abdb45d3c461533b75ea79f1f20 Mon Sep 17 00:00:00 2001 From: bbedward Date: Wed, 28 Oct 2026 14:48:58 -0400 Subject: [PATCH] network: answer openvpn dynamic challenge prompts with the challenge-response secret fixes #3952 port 2.0 (cherry picked from commit 19f3e58dcb8ece18b579b7bd668309c55b61bd8a) --- .../server/network/agent_networkmanager.go | 60 +++++++++++++++---- .../network/agent_networkmanager_test.go | 41 +++++++++++++ quickshell/Modals/WifiPasswordModal.qml | 9 ++- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/core/internal/server/network/agent_networkmanager.go b/core/internal/server/network/agent_networkmanager.go index 741389a10..c86f91819 100644 --- a/core/internal/server/network/agent_networkmanager.go +++ b/core/internal/server/network/agent_networkmanager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strconv" "strings" "time" @@ -278,7 +279,7 @@ func (a *SecretAgent) GetSecrets( a.backend.cachedVPNCredsMu.Lock() cached := a.backend.cachedVPNCreds - if cached != nil && cached.ConnectionUUID == connUuid { + if cached != nil && cached.ConnectionUUID == connUuid && slices.Contains(fields, "password") { a.backend.cachedVPNCreds = nil a.backend.cachedVPNCredsMu.Unlock() @@ -451,7 +452,7 @@ func (a *SecretAgent) GetSecrets( VpnService: vpnSvc, SettingName: settingName, Fields: fields, - FieldsInfo: buildFieldsInfo(settingName, fields, vpnSvc), + FieldsInfo: buildFieldsInfo(settingName, fields, hints, vpnSvc), Hints: hints, Reason: reason, ConnectionId: connId, @@ -577,13 +578,13 @@ func (a *SecretAgent) GetSecrets( default: out[settingName] = sec } - if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || reply.Save) { - secrets := make(map[string]string) - for k, v := range reply.Secrets { - if k != "username" { - secrets[k] = v - } + secrets := make(map[string]string) + for k, v := range reply.Secrets { + if k != "username" && k != "challenge-response" { + secrets[k] = v } + } + if settingName == "vpn" && a.backend != nil && !isPKCS11 && (vpnUsername != "" || (reply.Save && len(secrets) > 0)) { a.backend.pendingVPNSaveMu.Lock() a.backend.pendingVPNSave = &pendingVPNCredentials{ ConnectionPath: string(path), @@ -835,13 +836,46 @@ func fieldsNeeded(setting string, hints []string, conn map[string]nmVariantMap) return hints } return infer8021xFields(conn) - case "vpn", "wireguard": + case "vpn": + return parseVPNHints(hints).fields + case "wireguard": return hints default: return []string{} } } +const ( + vpnHintMessagePrefix = "x-vpn-message:" + vpnHintChallengePrefix = "x-dynamic-challenge:" + vpnHintChallengeEchoPrefix = "x-dynamic-challenge-echo:" +) + +type vpnPromptHints struct { + fields []string + echo map[string]bool +} + +// NM-openvpn asks for a CRV1 challenge answer as x-dynamic-challenge[-echo]: +// and NM appends the plugin's prompt text as x-vpn-message:, which the modal shows. +func parseVPNHints(hints []string) vpnPromptHints { + parsed := vpnPromptHints{echo: map[string]bool{}} + for _, hint := range hints { + switch { + case strings.HasPrefix(hint, vpnHintMessagePrefix): + case strings.HasPrefix(hint, vpnHintChallengeEchoPrefix): + field := strings.TrimPrefix(hint, vpnHintChallengeEchoPrefix) + parsed.echo[field] = true + parsed.fields = append(parsed.fields, field) + case strings.HasPrefix(hint, vpnHintChallengePrefix): + parsed.fields = append(parsed.fields, strings.TrimPrefix(hint, vpnHintChallengePrefix)) + default: + parsed.fields = append(parsed.fields, hint) + } + } + return parsed +} + func infer8021xFields(conn map[string]nmVariantMap) []string { dot1x, ok := conn["802-1x"] if !ok { @@ -877,7 +911,8 @@ func infer8021xFields(conn map[string]nmVariantMap) []string { return fields } -func buildFieldsInfo(setting string, fields []string, vpnService string) []FieldInfo { +func buildFieldsInfo(setting string, fields []string, hints []string, vpnService string) []FieldInfo { + echo := parseVPNHints(hints).echo result := make([]FieldInfo, 0, len(fields)) for _, f := range fields { info := FieldInfo{Name: f} @@ -899,6 +934,9 @@ func buildFieldsInfo(setting string, fields []string, vpnService string) []Field } case "vpn": info.Label, info.IsSecret = vpnFieldMeta(f, vpnService) + if echo[f] { + info.IsSecret = false + } default: info.Label = f info.IsSecret = true @@ -1052,6 +1090,8 @@ func vpnFieldMeta(field, _ string) (label string, isSecret bool) { return "IPSec Pre-Shared Key", true case "cert-pass": return "Certificate Password", true + case "challenge-response": + return "Challenge Response", true case "http-proxy-password": return "HTTP Proxy Password", true case "username": diff --git a/core/internal/server/network/agent_networkmanager_test.go b/core/internal/server/network/agent_networkmanager_test.go index 3a16bb47f..63f776969 100644 --- a/core/internal/server/network/agent_networkmanager_test.go +++ b/core/internal/server/network/agent_networkmanager_test.go @@ -308,6 +308,47 @@ func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) { assert.Contains(t, err.Error(), "NoSecrets") } +func TestSecretAgent_GetSecrets_OpenVPNDynamicChallenge(t *testing.T) { + conn := map[string]nmVariantMap{ + "connection": { + "id": dbus.MakeVariant("Work VPN"), + "type": dbus.MakeVariant("vpn"), + }, + "vpn": { + "service-type": dbus.MakeVariant("org.freedesktop.NetworkManager.openvpn"), + }, + } + + tests := []struct { + name string + hint string + isSecret bool + }{ + {"noecho", "x-dynamic-challenge:challenge-response", true}, + {"echo", "x-dynamic-challenge-echo:challenge-response", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + broker := &fakePromptBroker{ + asked: make(chan PromptRequest, 1), + reply: PromptReply{Secrets: map[string]string{"challenge-response": "123456"}}, + } + agent := &SecretAgent{prompts: broker} + hints := []string{tt.hint, "x-vpn-message:Enter your OTP"} + + out, err := agent.GetSecrets(conn, "/test/path", "vpn", hints, nmSecretAgentFlagAllowInteraction) + assert.Nil(t, err) + + req := <-broker.asked + assert.Equal(t, []string{"challenge-response"}, req.Fields) + assert.Equal(t, []FieldInfo{{Name: "challenge-response", Label: "Challenge Response", IsSecret: tt.isSecret}}, req.FieldsInfo) + + secrets, _ := out["vpn"]["secrets"].Value().(map[string]string) + assert.Equal(t, map[string]string{"challenge-response": "123456"}, secrets) + }) + } +} + func TestBuildWiFiSecretsResponse(t *testing.T) { t.Run("wpa-psk returns psk", func(t *testing.T) { out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"}) diff --git a/quickshell/Modals/WifiPasswordModal.qml b/quickshell/Modals/WifiPasswordModal.qml index e7e6db434..9f0fb726f 100644 --- a/quickshell/Modals/WifiPasswordModal.qml +++ b/quickshell/Modals/WifiPasswordModal.qml @@ -47,13 +47,18 @@ DModal { readonly property bool isCertificateChangedPrompt: promptReason === "server-certificate-changed" readonly property bool isCertificatePrompt: promptReason === "server-certificate" || isCertificateChangedPrompt readonly property string serverCertificateFingerprint: promptHints.length > 0 ? promptHints[0] : "" + readonly property string vpnPromptMessage: { + const hint = promptHints.find(h => typeof h === "string" && h.startsWith("x-vpn-message:")); + return hint ? hint.slice("x-vpn-message:".length) : ""; + } + readonly property bool isChallengePrompt: promptHints.some(h => typeof h === "string" && h.startsWith("x-dynamic-challenge")) readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0 readonly property bool showPasswordField: fieldsInfo.length === 0 && !isCertificatePrompt readonly property bool showEapFields: requiresEnterprise && !isVpnPrompt && !isPromptMode readonly property bool showPhase2Dropdown: eapMethodValue === "peap" || eapMethodValue === "ttls" readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt && !isPromptMode && eapMethodValue !== "pwd" readonly property bool showDomainField: showAnonField - readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" && !isCertificatePrompt + readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" && !isCertificatePrompt && !isChallengePrompt readonly property int certificateWarningHeight: certificateWarningColumn.implicitHeight + Theme.spacingM * 2 @@ -318,6 +323,8 @@ DModal { return I18n.tr("Enter PIN for %1", "%1 is the device or network name").arg(wifiPasswordSSID); if (isCertificatePrompt) return wifiPasswordSSID; + if (vpnPromptMessage) + return vpnPromptMessage; if (fieldsInfo.length > 0) return I18n.tr("Enter credentials for %1", "%1 is the network name").arg(wifiPasswordSSID); if (isVpnPrompt)