// Package wlanxml handles WLAN Profiles for Microsoft MDM server. // See: https://learn.microsoft.com/en-us/windows/win32/nativewifi/wlan-profileschema-schema // for samples: https://learn.microsoft.com/en-us/windows/win32/nativewifi/wireless-profile-samples // finally for multi-SSID uses: https://learn.microsoft.com/en-us/windows-hardware/drivers/mobilebroadband/handling-large-numbers-of-ssids package wlanxml import ( "encoding/xml" "fmt" "slices" "strings" ) func IsWLANXML(text string) bool { // We try to unmarshal the string to see if it looks like a valid WLAN XML profile _, err := unmarshal(text) return err == nil } func Equal(a, b string) (bool, error) { aProfile, err := unmarshal(a) if err != nil { return false, fmt.Errorf("unmarshalling WLAN XML profile a: %w", err) } bProfile, err := unmarshal(b) if err != nil { return false, fmt.Errorf("unmarshalling WLAN XML profile b: %w", err) } return aProfile.Equal(bProfile), nil } func unmarshal(w string) (WlanXmlProfile, error) { // This whole thing will be XML Encoded so step 1 is just to decode it var unescaped string err := xml.Unmarshal([]byte(""+w+""), &unescaped) if err != nil { return WlanXmlProfile{}, fmt.Errorf("unmarshalling WLAN XML profile to string: %w", err) } var profile WlanXmlProfile err = xml.Unmarshal([]byte(unescaped), &profile) if err != nil { return WlanXmlProfile{}, fmt.Errorf("unmarshalling WLAN XML profile: %w", err) } if profile.XMLName.Local != "WLANProfile" { return WlanXmlProfile{}, fmt.Errorf("unmarshalling WLAN XML profile: expected tag, got <%s>", profile.XMLName.Local) } for i := 0; i < len(profile.SSIDConfig.SSID); i++ { profile.SSIDConfig.SSID[i].normalize() } profile.SSIDConfig.SSIDPrefix.normalize() return profile, nil } type WlanXmlProfile struct { XMLName xml.Name `xml:"WLANProfile"` Name string `xml:"name"` SSIDConfig WlanXmlProfileSSIDConfig `xml:"SSIDConfig"` } type WlanXmlProfileSSIDConfig struct { SSID []WlanXmlProfileSSID `xml:"SSID"` SSIDPrefix WlanXmlProfileSSID `xml:"SSIDPrefix"` NonBroadcast bool `xml:"nonBroadcast"` } type WlanXmlProfileSSID struct { Hex string `xml:"hex,omitempty"` Name string `xml:"name,omitempty"` } func (s *WlanXmlProfileSSID) normalize() { // Microsoft's documentation says that the hex representation overrides the Name when both are // present. In testing, if a profile is provided with only the Name and not the hex // representation, Microsoft generates Hex and it is present in the profile returned. As such we // will convert name to hex on the way in for use in comparisons if s.Hex == "" && s.Name != "" { s.Hex = fmt.Sprintf("%x", s.Name) } // Most of the profile settings are case sensitive however the hex representation of the SSID is not and // in some cases Windows converts it to uppercase when writing a profile to the system which was provided // with uppercase alpha characters. s.Hex = strings.ToUpper(s.Hex) } func (s WlanXmlProfileSSID) Equal(b WlanXmlProfileSSID) bool { return s.Hex == b.Hex } // We have seen cases where Windows will "upgrade" a profile based on what it sees when it actually // connects to a network, for instance if a profile specifies WPA2 but the interface and network // support WPA3 it will "upgrade" the profile to WPA3 and that is what gets returned when querying // the device. This behavior is undocumented but precludes comparing profiles too strictly. // Because of this we have opted for a simple comparison that ensures a profile matching // basic fields like name, SSID and (non-)broadcast status is considered equal. func (a WlanXmlProfile) Equal(b WlanXmlProfile) bool { if a.Name != b.Name { return false } if a.SSIDConfig.NonBroadcast != b.SSIDConfig.NonBroadcast { return false } if !a.SSIDConfig.SSIDPrefix.Equal(b.SSIDConfig.SSIDPrefix) { return false } if len(a.SSIDConfig.SSID) != len(b.SSIDConfig.SSID) { return false } a.sortSSIDs() b.sortSSIDs() return slices.EqualFunc(a.SSIDConfig.SSID, b.SSIDConfig.SSID, func(i, j WlanXmlProfileSSID) bool { return i.Equal(j) }) } // a profile may have multiple SSIDs. func (a *WlanXmlProfile) sortSSIDs() { slices.SortFunc(a.SSIDConfig.SSID, func(i, j WlanXmlProfileSSID) int { return strings.Compare(i.Hex, j.Hex) }) } // Generates a WLAN XML profile with the given SSID Config and name for use in our tests both. This // is exported so it can be used by tests outside this package. func GenerateWLANXMLProfileForTests(name string, ssidConfig WlanXmlProfileSSIDConfig) (string, error) { type wlanXmlProfileForTests struct { WlanXmlProfile MSM string `xml:",innerxml"` ConnectionMode string `xml:"connectionMode"` ConnectionType string `xml:"connectionType"` } profile := wlanXmlProfileForTests{ WlanXmlProfile: WlanXmlProfile{ XMLName: xml.Name{Local: "WLANProfile", Space: "http://www.microsoft.com/networking/WLAN/profile/v1"}, Name: name, SSIDConfig: ssidConfig, }, ConnectionType: "ESS", ConnectionMode: "auto", MSM: ` WPA2PSK AES false passPhrase false sup3rs3cr3t `, } xmlBytes, err := xml.Marshal(profile) if err != nil { return "", fmt.Errorf("Error marshaling WLAN XML profile: %w", err) } var buffer strings.Builder err = xml.EscapeText(&buffer, xmlBytes) if err != nil { return "", fmt.Errorf("Error escaping marshalled WLAN XML profile: %w", err) } return buffer.String(), nil }