network: answer openvpn dynamic challenge prompts with the challenge-response secret

fixes #3952
port 2.0

(cherry picked from commit 19f3e58dcb)
This commit is contained in:
bbedward authored and dux-ci[bot] committed 2026-10-28 18:49:45 +00:00
1 parent b528af8469
commit 5fea906c9f
3 files changed
+99 -11

No files matched your search

@@ -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]:<secret>
// and NM appends the plugin's prompt text as x-vpn-message:<text>, 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":
@@ -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"})
+8 -1
View File
@@ -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)