From 7ff751f8a223f68b9578fa85d74ba344393b771f Mon Sep 17 00:00:00 2001 From: bbedward Date: Wed, 31 Dec 2025 10:03:49 -0500 Subject: [PATCH] vpn: attempt to support pkcs11 prompts --- .../network/backend_networkmanager_vpn.go | 303 ++++++++++++------ quickshell/Modals/PolkitAuthModal.qml | 3 +- quickshell/Modals/WifiPasswordModal.qml | 72 +++-- quickshell/translations/en.json | 12 + 4 files changed, 275 insertions(+), 115 deletions(-) diff --git a/core/internal/server/network/backend_networkmanager_vpn.go b/core/internal/server/network/backend_networkmanager_vpn.go index 335fe5aa..67f34b28 100644 --- a/core/internal/server/network/backend_networkmanager_vpn.go +++ b/core/internal/server/network/backend_networkmanager_vpn.go @@ -282,111 +282,33 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) } } - needsUsernamePrePrompt := false var vpnServiceType string + var vpnData map[string]string if vpnSettings, ok := targetSettings["vpn"]; ok { if svc, ok := vpnSettings["service-type"].(string); ok { vpnServiceType = svc } if data, ok := vpnSettings["data"].(map[string]string); ok { - connType := data["connection-type"] - username := data["username"] - // OpenVPN password auth needs username in vpn.data - if strings.Contains(vpnServiceType, "openvpn") && - (connType == "password" || connType == "password-tls") && - username == "" { - needsUsernamePrePrompt = true - } + vpnData = data } } - // If username is needed but missing, prompt for it before activating - if needsUsernamePrePrompt && b.promptBroker != nil { - log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation") + authAction := detectVPNAuthAction(vpnServiceType, vpnData) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - token, err := b.promptBroker.Ask(ctx, PromptRequest{ - Name: connName, - ConnType: "vpn", - VpnService: vpnServiceType, - SettingName: "vpn", - Fields: []string{"username", "password"}, - FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}}, - Reason: "required", - ConnectionId: connName, - ConnectionUuid: targetUUID, - ConnectionPath: string(targetConn.GetPath()), - }) - if err != nil { - return fmt.Errorf("failed to request credentials: %w", err) + switch authAction { + case "pkcs11_pin": + if b.promptBroker == nil { + return fmt.Errorf("PKCS11 authentication requires interactive prompt") } - - reply, err := b.promptBroker.Wait(ctx, token) - if err != nil { - return fmt.Errorf("credentials prompt failed: %w", err) + if err := b.handlePKCS11Auth(targetConn, connName, targetUUID, vpnServiceType); err != nil { + return err } - - username := reply.Secrets["username"] - password := reply.Secrets["password"] - if username != "" { - connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath()) - var existingSettings map[string]map[string]dbus.Variant - if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil { - return fmt.Errorf("failed to get settings for username save: %w", err) - } - - settings := make(map[string]map[string]dbus.Variant) - if connSection, ok := existingSettings["connection"]; ok { - settings["connection"] = connSection - } - vpn := existingSettings["vpn"] - var data map[string]string - if dataVariant, ok := vpn["data"]; ok { - if dm, ok := dataVariant.Value().(map[string]string); ok { - data = make(map[string]string) - for k, v := range dm { - data[k] = v - } - } else { - data = make(map[string]string) - } - } else { - data = make(map[string]string) - } - data["username"] = username - - if reply.Save && password != "" { - data["password-flags"] = "0" - secs := make(map[string]string) - secs["password"] = password - vpn["secrets"] = dbus.MakeVariant(secs) - log.Infof("[ConnectVPN] Saving username and password to vpn.data") - } else { - log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)") - } - - vpn["data"] = dbus.MakeVariant(data) - settings["vpn"] = vpn - - var result map[string]dbus.Variant - if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0, - settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil { - return fmt.Errorf("failed to save username: %w", err) - } - log.Infof("[ConnectVPN] Username saved to connection, now activating") - - if password != "" && !reply.Save { - b.cachedVPNCredsMu.Lock() - b.cachedVPNCreds = &cachedVPNCredentials{ - ConnectionUUID: targetUUID, - Password: password, - SavePassword: reply.Save, - } - b.cachedVPNCredsMu.Unlock() - log.Infof("[ConnectVPN] Cached password for GetSecrets") - } + case "openvpn_username": + if b.promptBroker == nil { + return fmt.Errorf("OpenVPN password authentication requires interactive prompt") + } + if err := b.handleOpenVPNUsernameAuth(targetConn, connName, targetUUID, vpnServiceType); err != nil { + return err } } @@ -417,6 +339,201 @@ func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) return nil } +func detectVPNAuthAction(serviceType string, data map[string]string) string { + if data == nil { + return "" + } + + switch { + case strings.Contains(serviceType, "openconnect"): + authType := data["authtype"] + userCert := data["usercert"] + if authType == "cert" && strings.HasPrefix(userCert, "pkcs11:") { + return "pkcs11_pin" + } + case strings.Contains(serviceType, "openvpn"): + connType := data["connection-type"] + username := data["username"] + if (connType == "password" || connType == "password-tls") && username == "" { + return "openvpn_username" + } + } + return "" +} + +func (b *NetworkManagerBackend) handlePKCS11Auth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error { + log.Infof("[ConnectVPN] PKCS11 authentication detected - prompting for PIN") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := b.promptBroker.Ask(ctx, PromptRequest{ + Name: connName, + ConnType: "vpn", + VpnService: vpnServiceType, + SettingName: "vpn", + Fields: []string{"key_pass"}, + FieldsInfo: []FieldInfo{{Name: "key_pass", Label: "PIN", IsSecret: true}}, + Reason: "pkcs11", + ConnectionId: connName, + ConnectionUuid: targetUUID, + ConnectionPath: string(targetConn.GetPath()), + }) + if err != nil { + return fmt.Errorf("failed to request PIN: %w", err) + } + + reply, err := b.promptBroker.Wait(ctx, token) + if err != nil { + return fmt.Errorf("PIN prompt failed: %w", err) + } + + if reply.Cancel { + return fmt.Errorf("user cancelled PIN entry") + } + + pin := reply.Secrets["key_pass"] + if pin == "" { + return fmt.Errorf("PIN required for PKCS11 authentication") + } + + connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath()) + var existingSettings map[string]map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + + settings := make(map[string]map[string]dbus.Variant) + if connSection, ok := existingSettings["connection"]; ok { + settings["connection"] = connSection + } + + vpn := existingSettings["vpn"] + var data map[string]string + if dataVariant, ok := vpn["data"]; ok { + if dm, ok := dataVariant.Value().(map[string]string); ok { + data = make(map[string]string) + for k, v := range dm { + data[k] = v + } + } else { + data = make(map[string]string) + } + } else { + data = make(map[string]string) + } + data["key_pass"] = pin + + vpn["data"] = dbus.MakeVariant(data) + settings["vpn"] = vpn + + var result map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0, + settings, uint32(0x2), map[string]dbus.Variant{}).Store(&result); err != nil { + return fmt.Errorf("failed to set PIN: %w", err) + } + + log.Infof("[ConnectVPN] PIN set (in-memory only)") + return nil +} + +func (b *NetworkManagerBackend) handleOpenVPNUsernameAuth(targetConn gonetworkmanager.Connection, connName, targetUUID, vpnServiceType string) error { + log.Infof("[ConnectVPN] OpenVPN requires username in vpn.data - prompting before activation") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := b.promptBroker.Ask(ctx, PromptRequest{ + Name: connName, + ConnType: "vpn", + VpnService: vpnServiceType, + SettingName: "vpn", + Fields: []string{"username", "password"}, + FieldsInfo: []FieldInfo{{Name: "username", Label: "Username", IsSecret: false}, {Name: "password", Label: "Password", IsSecret: true}}, + Reason: "required", + ConnectionId: connName, + ConnectionUuid: targetUUID, + ConnectionPath: string(targetConn.GetPath()), + }) + if err != nil { + return fmt.Errorf("failed to request credentials: %w", err) + } + + reply, err := b.promptBroker.Wait(ctx, token) + if err != nil { + return fmt.Errorf("credentials prompt failed: %w", err) + } + + if reply.Cancel { + return fmt.Errorf("user cancelled authentication") + } + + username := reply.Secrets["username"] + password := reply.Secrets["password"] + if username == "" { + return nil + } + + connObj := b.dbusConn.Object("org.freedesktop.NetworkManager", targetConn.GetPath()) + var existingSettings map[string]map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0).Store(&existingSettings); err != nil { + return fmt.Errorf("failed to get settings for username save: %w", err) + } + + settings := make(map[string]map[string]dbus.Variant) + if connSection, ok := existingSettings["connection"]; ok { + settings["connection"] = connSection + } + vpn := existingSettings["vpn"] + var data map[string]string + if dataVariant, ok := vpn["data"]; ok { + if dm, ok := dataVariant.Value().(map[string]string); ok { + data = make(map[string]string) + for k, v := range dm { + data[k] = v + } + } else { + data = make(map[string]string) + } + } else { + data = make(map[string]string) + } + data["username"] = username + + if reply.Save && password != "" { + data["password-flags"] = "0" + secs := make(map[string]string) + secs["password"] = password + vpn["secrets"] = dbus.MakeVariant(secs) + log.Infof("[ConnectVPN] Saving username and password to vpn.data") + } else { + log.Infof("[ConnectVPN] Saving username to vpn.data (password will be prompted)") + } + + vpn["data"] = dbus.MakeVariant(data) + settings["vpn"] = vpn + + var result map[string]dbus.Variant + if err := connObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Update2", 0, + settings, uint32(0x1), map[string]dbus.Variant{}).Store(&result); err != nil { + return fmt.Errorf("failed to save username: %w", err) + } + log.Infof("[ConnectVPN] Username saved to connection") + + if password != "" && !reply.Save { + b.cachedVPNCredsMu.Lock() + b.cachedVPNCreds = &cachedVPNCredentials{ + ConnectionUUID: targetUUID, + Password: password, + SavePassword: reply.Save, + } + b.cachedVPNCredsMu.Unlock() + log.Infof("[ConnectVPN] Cached password for GetSecrets") + } + + return nil +} + func (b *NetworkManagerBackend) DisconnectVPN(uuidOrName string) error { nm := b.nmConn.(gonetworkmanager.NetworkManager) diff --git a/quickshell/Modals/PolkitAuthModal.qml b/quickshell/Modals/PolkitAuthModal.qml index 467aa7fd..68bd63c7 100644 --- a/quickshell/Modals/PolkitAuthModal.qml +++ b/quickshell/Modals/PolkitAuthModal.qml @@ -10,6 +10,7 @@ FloatingWindow { property string passwordInput: "" property var currentFlow: PolkitService.agent?.flow property bool isLoading: false + readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2 property int calculatedHeight: Math.max(240, headerRow.implicitHeight + mainColumn.implicitHeight + Theme.spacingM * 3) function focusPasswordField() { @@ -202,7 +203,7 @@ FloatingWindow { Rectangle { width: parent.width - height: 50 + height: inputFieldHeight radius: Theme.cornerRadius color: Theme.surfaceHover border.color: passwordField.activeFocus ? Theme.primary : Theme.outlineStrong diff --git a/quickshell/Modals/WifiPasswordModal.qml b/quickshell/Modals/WifiPasswordModal.qml index a2c46c8e..95fa91c1 100644 --- a/quickshell/Modals/WifiPasswordModal.qml +++ b/quickshell/Modals/WifiPasswordModal.qml @@ -28,14 +28,29 @@ FloatingWindow { property var fieldsInfo: [] property var secretValues: ({}) + readonly property bool showUsernameField: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0 + readonly property bool showPasswordField: fieldsInfo.length === 0 + readonly property bool showAnonField: requiresEnterprise && !isVpnPrompt + readonly property bool showDomainField: requiresEnterprise && !isVpnPrompt + readonly property bool showShowPasswordCheckbox: fieldsInfo.length === 0 + readonly property bool showSavePasswordCheckbox: (isVpnPrompt || fieldsInfo.length > 0) && promptReason !== "pkcs11" + + readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2 + readonly property int inputFieldWithSpacing: inputFieldHeight + Theme.spacingM + readonly property int checkboxRowHeight: Theme.fontSizeMedium + Theme.spacingS + readonly property int headerHeight: Theme.fontSizeLarge + Theme.fontSizeMedium + Theme.spacingM * 2 + readonly property int buttonRowHeight: 36 + Theme.spacingM + property int calculatedHeight: { - if (fieldsInfo.length > 0) - return 180 + (fieldsInfo.length * 60); - if (requiresEnterprise) - return 430; - if (isVpnPrompt) - return 260; - return 230; + let h = headerHeight + buttonRowHeight + Theme.spacingL * 2; + h += fieldsInfo.length * inputFieldWithSpacing; + if (showUsernameField) h += inputFieldWithSpacing; + if (showPasswordField) h += inputFieldWithSpacing; + if (showAnonField) h += inputFieldWithSpacing; + if (showDomainField) h += inputFieldWithSpacing; + if (showShowPasswordCheckbox) h += checkboxRowHeight; + if (showSavePasswordCheckbox) h += checkboxRowHeight; + return h; } function focusFirstField() { @@ -127,6 +142,7 @@ FloatingWindow { case "private-key-password": return I18n.tr("Private Key Password"); case "pin": + case "key_pass": return I18n.tr("PIN"); case "psk": return I18n.tr("Password"); @@ -188,7 +204,13 @@ FloatingWindow { } objectName: "wifiPasswordModal" - title: isVpnPrompt ? I18n.tr("VPN Password") : I18n.tr("Wi-Fi Password") + title: { + if (promptReason === "pkcs11") + return I18n.tr("Smartcard PIN"); + if (isVpnPrompt) + return I18n.tr("VPN Password"); + return I18n.tr("Wi-Fi Password"); + } minimumSize: Qt.size(420, calculatedHeight) maximumSize: Qt.size(420, calculatedHeight) color: Theme.surfaceContainer @@ -242,7 +264,7 @@ FloatingWindow { Column { id: contentCol anchors.centerIn: parent - width: parent.width - Theme.spacingM * 2 + width: parent.width - Theme.spacingL * 2 spacing: Theme.spacingM Row { @@ -260,7 +282,13 @@ FloatingWindow { spacing: Theme.spacingXS StyledText { - text: isVpnPrompt ? I18n.tr("Connect to VPN") : I18n.tr("Connect to Wi-Fi") + text: { + if (promptReason === "pkcs11") + return I18n.tr("Smartcard Authentication"); + if (isVpnPrompt) + return I18n.tr("Connect to VPN"); + return I18n.tr("Connect to Wi-Fi"); + } font.pixelSize: Theme.fontSizeLarge color: Theme.surfaceText font.weight: Font.Medium @@ -272,6 +300,8 @@ FloatingWindow { StyledText { text: { + if (promptReason === "pkcs11") + return I18n.tr("Enter PIN for ") + wifiPasswordSSID; if (fieldsInfo.length > 0) return I18n.tr("Enter credentials for ") + wifiPasswordSSID; if (isVpnPrompt) @@ -325,7 +355,7 @@ FloatingWindow { required property int index width: contentCol.width - height: 50 + height: inputFieldHeight radius: Theme.cornerRadius color: Theme.surfaceHover border.color: fieldInput.activeFocus ? Theme.primary : Theme.outlineStrong @@ -388,12 +418,12 @@ FloatingWindow { Rectangle { width: parent.width - height: 50 + height: inputFieldHeight radius: Theme.cornerRadius color: Theme.surfaceHover border.color: usernameInput.activeFocus ? Theme.primary : Theme.outlineStrong border.width: usernameInput.activeFocus ? 2 : 1 - visible: requiresEnterprise && !isVpnPrompt && fieldsInfo.length === 0 + visible: showUsernameField MouseArea { anchors.fill: parent @@ -419,12 +449,12 @@ FloatingWindow { Rectangle { width: parent.width - height: 50 + height: inputFieldHeight radius: Theme.cornerRadius color: Theme.surfaceHover border.color: passwordInput.activeFocus ? Theme.primary : Theme.outlineStrong border.width: passwordInput.activeFocus ? 2 : 1 - visible: fieldsInfo.length === 0 + visible: showPasswordField MouseArea { anchors.fill: parent @@ -456,9 +486,9 @@ FloatingWindow { } Rectangle { - visible: requiresEnterprise && !isVpnPrompt + visible: showAnonField width: parent.width - height: 50 + height: inputFieldHeight radius: Theme.cornerRadius color: Theme.surfaceHover border.color: anonInput.activeFocus ? Theme.primary : Theme.outlineStrong @@ -487,9 +517,9 @@ FloatingWindow { } Rectangle { - visible: requiresEnterprise && !isVpnPrompt + visible: showDomainField width: parent.width - height: 50 + height: inputFieldHeight radius: Theme.cornerRadius color: Theme.surfaceHover border.color: domainMatchInput.activeFocus ? Theme.primary : Theme.outlineStrong @@ -523,7 +553,7 @@ FloatingWindow { Row { spacing: Theme.spacingS - visible: fieldsInfo.length === 0 + visible: showShowPasswordCheckbox Rectangle { id: showPasswordCheckbox @@ -563,7 +593,7 @@ FloatingWindow { Row { spacing: Theme.spacingS - visible: isVpnPrompt || fieldsInfo.length > 0 + visible: showSavePasswordCheckbox Rectangle { id: savePasswordCheckbox diff --git a/quickshell/translations/en.json b/quickshell/translations/en.json index 55a1c2b1..44881455 100644 --- a/quickshell/translations/en.json +++ b/quickshell/translations/en.json @@ -6677,6 +6677,18 @@ "reference": "Modules/Settings/DockTab.qml:144", "comment": "" }, + { + "term": "Smartcard Authentication", + "context": "Smartcard Authentication", + "reference": "Modals/WifiPasswordModal.qml:272", + "comment": "" + }, + { + "term": "Smartcard PIN", + "context": "Smartcard PIN", + "reference": "Modals/WifiPasswordModal.qml:194", + "comment": "" + }, { "term": "Some plugins require a newer version of DMS:", "context": "Some plugins require a newer version of DMS:",