diff --git a/wlan-setup/.gitignore b/wlan-setup/.gitignore new file mode 100644 index 00000000..730cb84d --- /dev/null +++ b/wlan-setup/.gitignore @@ -0,0 +1 @@ +/wlan-setup diff --git a/wlan-setup/go.mod b/wlan-setup/go.mod index e77fe179..aff02599 100644 --- a/wlan-setup/go.mod +++ b/wlan-setup/go.mod @@ -5,6 +5,7 @@ go 1.27 require ( github.com/beevik/etree v1.7.0 github.com/russellhaering/goxmldsig v1.6.1 + howett.net/plist v1.0.1 ) require github.com/jonboulle/clockwork v0.5.0 // indirect diff --git a/wlan-setup/go.sum b/wlan-setup/go.sum index 5611047e..0792e47d 100644 --- a/wlan-setup/go.sum +++ b/wlan-setup/go.sum @@ -2,6 +2,7 @@ github.com/beevik/etree v1.7.0 h1:xjBk9O4p4x7D1YajePjfLzdaFC4/uYUENA7P0pv6gXA= github.com/beevik/etree v1.7.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -10,5 +11,9 @@ github.com/russellhaering/goxmldsig v1.6.1 h1:SB7R5ttvrGIDB2juJAK/i7DQ2Ivr7agG+o github.com/russellhaering/goxmldsig v1.6.1/go.mod h1:haZkRcLs9W/Xp989fIjP3BrTdbFQveRF0QNZSYoH09w= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/wlan-setup/profiles.go b/wlan-setup/profiles.go index 1171a604..a78a6dc8 100644 --- a/wlan-setup/profiles.go +++ b/wlan-setup/profiles.go @@ -1,8 +1,16 @@ package main import ( + "bytes" "encoding/base64" - "strings" + "encoding/xml" + "fmt" + "mime" + "mime/multipart" + "net/textproto" + + "github.com/beevik/etree" + "howett.net/plist" ) // isrgRootX1 is the trust anchor of the radius-tls chain (DER, base64). @@ -11,113 +19,238 @@ const isrgRootX1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQEL // carrierID identifies the Windows provisioning document; a reinstall replaces the profile. const carrierID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}" -func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } +// mimeBoundary separates the parts of the Android profile. +const mimeBoundary = "renner-passpoint-profile" -func node(name, inner string) string { - return "" + name + "" + inner + "" +// ppsNode is a node of the PPS MO management tree: a leaf carries a Value, an interior node +// further nodes. +type ppsNode struct { + XMLName xml.Name `xml:"Node"` + NodeName string `xml:"NodeName"` + RTProperties *ppsRTProperties `xml:"RTProperties,omitempty"` + Value *string `xml:"Value,omitempty"` + Nodes []ppsNode `xml:"Node"` } -func leaf(name, value string) string { return node(name, ""+value+"") } +type ppsRTProperties struct { + DDFName string `xml:"Type>DDFName"` +} -var xmlEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">") +type ppsTree struct { + XMLName xml.Name `xml:"syncml:dmddf1.2 MgmtTree"` + VerDTD string `xml:"VerDTD"` + Nodes []ppsNode `xml:"Node"` +} -func xmlText(s string) string { return xmlEscaper.Replace(s) } +func ppsLeaf(name, value string) ppsNode { return ppsNode{NodeName: name, Value: &value} } + +func ppsInterior(name string, nodes ...ppsNode) ppsNode { + return ppsNode{NodeName: name, Nodes: nodes} +} // androidProfile is the application/x-wifi-config body: base64 of a MIME multipart with the -// Passpoint R1 PPS MO and the trust anchor. -func androidProfile(user, password string) string { - pps := `1.2` + - node("PerProviderSubscription", - "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0"+ - node("i001", - node("HomeSP", leaf("FriendlyName", "Renner")+leaf("FQDN", "renner.uno"))+ - node("Credential", - leaf("Realm", "renner.uno")+ - node("UsernamePassword", - leaf("Username", user)+ - leaf("Password", b64(password))+ - node("EAPMethod", leaf("EAPType", "21")+leaf("InnerMethod", "PAP"))))+ - node("Extension", node("Android", - node("AAAServerTrustedNames", leaf("FQDN", "radius.renner.uno")))))) + - "" - mime := "Content-Type: multipart/mixed; boundary={boundary}\r\n" + - "Content-Transfer-Encoding: base64\r\n\r\n" + - "--{boundary}\r\n" + - "Content-Type: application/x-passpoint-profile\r\n" + - "Content-Transfer-Encoding: base64\r\n\r\n" + - b64(pps) + "\r\n" + - "--{boundary}\r\n" + - "Content-Type: application/x-x509-ca-cert\r\n" + - "Content-Transfer-Encoding: base64\r\n\r\n" + - isrgRootX1 + "\r\n" + - "--{boundary}--\r\n" - return b64(mime) +// Passpoint R1 PPS MO and the trust anchor, each part base64. +func androidProfile(user, password string) ([]byte, error) { + subscription := ppsInterior("PerProviderSubscription", + ppsInterior("i001", + ppsInterior("HomeSP", ppsLeaf("FriendlyName", "Renner"), ppsLeaf("FQDN", "renner.uno")), + ppsInterior("Credential", + ppsLeaf("Realm", "renner.uno"), + ppsInterior("UsernamePassword", + ppsLeaf("Username", user), + ppsLeaf("Password", base64.StdEncoding.EncodeToString([]byte(password))), + ppsInterior("EAPMethod", ppsLeaf("EAPType", "21"), ppsLeaf("InnerMethod", "PAP")))), + ppsInterior("Extension", ppsInterior("Android", + ppsInterior("AAAServerTrustedNames", ppsLeaf("FQDN", "radius.renner.uno")))))) + subscription.RTProperties = &ppsRTProperties{DDFName: "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0"} + pps, err := xml.Marshal(ppsTree{VerDTD: "1.2", Nodes: []ppsNode{subscription}}) + if err != nil { + return nil, fmt.Errorf("pps: %w", err) + } + + var parts bytes.Buffer + mw := multipart.NewWriter(&parts) + if err := mw.SetBoundary(mimeBoundary); err != nil { + return nil, err + } + for _, p := range []struct{ contentType, body string }{ + {"application/x-passpoint-profile", base64.StdEncoding.EncodeToString(pps)}, + {"application/x-x509-ca-cert", isrgRootX1}, + } { + w, err := mw.CreatePart(textproto.MIMEHeader{ + "Content-Type": {p.contentType}, + "Content-Transfer-Encoding": {"base64"}, + }) + if err != nil { + return nil, err + } + if _, err := w.Write([]byte(p.body)); err != nil { + return nil, err + } + } + if err := mw.Close(); err != nil { + return nil, err + } + + var message bytes.Buffer + fmt.Fprintf(&message, "Content-Type: %s\r\nContent-Transfer-Encoding: base64\r\n\r\n", + mime.FormatMediaType("multipart/mixed", map[string]string{"boundary": mw.Boundary()})) + message.Write(parts.Bytes()) + return []byte(base64.StdEncoding.EncodeToString(message.Bytes())), nil +} + +type appleEAP struct { + UserName string + UserPassword string + OuterIdentity string + AcceptEAPTypes []int + TTLSInnerAuthentication string + TLSTrustedServerNames []string +} + +type appleWiFi struct { + PayloadType string + PayloadVersion int + PayloadIdentifier string + PayloadUUID string + PayloadDisplayName string + PayloadOrganization string + IsHotspot bool + DomainName string + NAIRealmNames []string + DisplayedOperatorName string + ServiceProviderRoamingEnabled bool + AutoJoin bool + EncryptionType string + EAPClientConfiguration appleEAP +} + +type appleConfiguration struct { + PayloadContent []appleWiFi + PayloadType string + PayloadVersion int + PayloadIdentifier string + PayloadUUID string + PayloadDisplayName string + PayloadOrganization string } // appleProfile is the .mobileconfig: com.apple.wifi.managed with Passpoint matching. Fixed // identifiers and UUIDs, so a reinstall replaces the previous profile. -func appleProfile(user, password string) string { - return `` + "\n" + - `` + "\n" + - `` + - `PayloadContent` + - `PayloadTypecom.apple.wifi.managed` + - `PayloadVersion1` + - `PayloadIdentifieruno.renner.wifi.renner-unifi.network` + - `PayloadUUID824959bf-c55d-4ed2-b569-c33f3759c2b6` + - `PayloadDisplayNameRenner UniFi` + - `PayloadOrganizationRenner` + - `IsHotspot` + - `DomainNamerenner.uno` + - `NAIRealmNamesrenner.uno` + - `DisplayedOperatorNameRenner` + - `ServiceProviderRoamingEnabled` + - `AutoJoin` + - `EncryptionTypeAny` + - `EAPClientConfiguration` + - `UserName` + xmlText(user) + `` + - `UserPassword` + xmlText(password) + `` + - `OuterIdentityanonymous` + - `AcceptEAPTypes21` + - `TTLSInnerAuthenticationPAP` + - `TLSTrustedServerNamesradius.renner.uno` + - `` + - `PayloadTypeConfiguration` + - `PayloadVersion1` + - `PayloadIdentifieruno.renner.wifi.renner-unifi` + - `PayloadUUID7175a2a3-ddd3-42ea-b4ad-b7e5501892f3` + - `PayloadDisplayNameWLAN Renner UniFi` + - `PayloadOrganizationRenner` + - `` + "\n" +func appleProfile(user, password string) ([]byte, error) { + return plist.MarshalIndent(appleConfiguration{ + PayloadContent: []appleWiFi{{ + PayloadType: "com.apple.wifi.managed", + PayloadVersion: 1, + PayloadIdentifier: "uno.renner.wifi.renner-unifi.network", + PayloadUUID: "824959bf-c55d-4ed2-b569-c33f3759c2b6", + PayloadDisplayName: "Renner UniFi", + PayloadOrganization: "Renner", + IsHotspot: true, + DomainName: "renner.uno", + NAIRealmNames: []string{"renner.uno"}, + DisplayedOperatorName: "Renner", + ServiceProviderRoamingEnabled: false, + AutoJoin: true, + EncryptionType: "Any", + EAPClientConfiguration: appleEAP{ + UserName: user, + UserPassword: password, + OuterIdentity: "anonymous", + AcceptEAPTypes: []int{21}, + TTLSInnerAuthentication: "PAP", + TLSTrustedServerNames: []string{"radius.renner.uno"}, + }, + }}, + PayloadType: "Configuration", + PayloadVersion: 1, + PayloadIdentifier: "uno.renner.wifi.renner-unifi", + PayloadUUID: "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3", + PayloadDisplayName: "WLAN Renner UniFi", + PayloadOrganization: "Renner", + }, plist.XMLFormat, "\t") } -// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile, -// WPA3 Enterprise, EAP-TTLS with inner PAP, and the user's credentials. -func windowsProfile(user, password string) string { - const common = ` xmlns="http://www.microsoft.com/provisioning/EapCommon"` - return `` + - `` + carrierID + `renner-unifi` + - `` + - `Renner UniFi` + - `Renner UniFi` + - `renner.unorenner.uno` + - `` + - `WPA3ENTAEStrue` + - `user` + - `` + - `210` + - `0311` + - `` + - `radius.renner.uno` + - `ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8` + - `true` + - `` + - `trueanonymous` + - `` + - `` + - `21311` + - `` + - `` + xmlText(user) + `` + xmlText(password) + `` + - `` + - `` +// Namespaces of the CarrierProvisioning document. +const ( + nsCarrierControl = "http://www.microsoft.com/networking/CarrierControl/v1" + nsWLAN = "http://www.microsoft.com/networking/CarrierControl/WLAN/v1" + nsOneX = "http://www.microsoft.com/networking/OneX/v1" + nsEapHostConfig = "http://www.microsoft.com/provisioning/EapHostConfig" + nsEapCommon = "http://www.microsoft.com/provisioning/EapCommon" + nsEapTtlsConn = "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1" + nsEapHostUserCred = "http://www.microsoft.com/provisioning/EapHostUserCredentials" + nsEapTtlsUser = "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1" +) + +// add appends an element named tag to parent. ns, when set, becomes its default namespace. +func add(parent *etree.Element, tag, ns string) *etree.Element { + el := parent.CreateElement(tag) + if ns != "" { + el.CreateAttr("xmlns", ns) + } + return el +} + +// addText appends an element named tag with text content to parent. +func addText(parent *etree.Element, tag, ns, text string) *etree.Element { + el := add(parent, tag, ns) + el.SetText(text) + return el +} + +// windowsProfile is the unsigned CarrierProvisioning document: one Hotspot 2.0 WLAN profile +// matched by domain name, EAP-TTLS with inner PAP, and the user's credentials. The +// CarrierControl WLAN schema allows authentication up to WPA2 (Enterprise); Windows adds the +// PMF that the WPA3-Enterprise network requires. Elements follow Microsoft's signed Passpoint +// provisioning samples. +func windowsProfile(user, password string) *etree.Document { + doc := etree.NewDocument() + doc.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true} + doc.CreateProcInst("xml", `version="1.0"`) + doc.CreateText("\n") + root := add(&doc.Element, "CarrierProvisioning", nsCarrierControl) + + global := add(root, "Global", "") + addText(global, "CarrierId", "", carrierID) + addText(global, "SubscriberId", "", "renner-unifi") + + wlan := add(add(root, "WLANProfiles", ""), "WLANProfile", nsWLAN) + addText(wlan, "name", "", "Renner UniFi") + addText(add(add(wlan, "SSIDConfig", ""), "SSID", ""), "name", "", "Renner UniFi") + addText(add(wlan, "Hotspot2", ""), "DomainName", "", "renner.uno") + + security := add(add(wlan, "MSM", ""), "security", "") + auth := add(security, "authEncryption", "") + addText(auth, "authentication", "", "WPA2") + addText(auth, "encryption", "", "AES") + addText(auth, "useOneX", "", "true") + + oneX := add(security, "OneX", nsOneX) + addText(oneX, "authMode", "", "user") + eapHost := add(add(oneX, "EAPConfig", ""), "EapHostConfig", nsEapHostConfig) + method := add(eapHost, "EapMethod", "") + addText(method, "Type", nsEapCommon, "21") + addText(method, "VendorId", nsEapCommon, "0") + addText(method, "VendorType", nsEapCommon, "0") + addText(method, "AuthorId", nsEapCommon, "311") + ttls := add(add(eapHost, "Config", ""), "EapTtls", nsEapTtlsConn) + validation := add(ttls, "ServerValidation", "") + addText(validation, "ServerNames", "", "radius.renner.uno") + addText(validation, "TrustedRootCAHash", "", "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8") + addText(validation, "DisablePrompt", "", "true") + add(add(ttls, "Phase2Authentication", ""), "PAPAuthentication", "") + identity := add(ttls, "Phase1Identity", "") + addText(identity, "IdentityPrivacy", "", "true") + addText(identity, "AnonymousIdentity", "", "anonymous") + + userCred := add(security, "EapHostUserCredentials", nsEapHostUserCred) + userCred.CreateAttr("xmlns:eapCommon", nsEapCommon) + userMethod := add(userCred, "EapMethod", "") + addText(userMethod, "eapCommon:Type", "", "21") + addText(userMethod, "eapCommon:AuthorId", "", "311") + credentials := add(add(userCred, "Credentials", ""), "EapTtls", nsEapTtlsUser) + addText(credentials, "Username", "", user) + addText(credentials, "Password", "", password) + return doc } diff --git a/wlan-setup/server.go b/wlan-setup/server.go index f75c009a..91e4a705 100644 --- a/wlan-setup/server.go +++ b/wlan-setup/server.go @@ -192,15 +192,25 @@ func (s *Server) callback(w http.ResponseWriter, r *http.Request, name string) { switch name { case "android": + body, err := androidProfile(user, key) + if err != nil { + fail("building the profile", "err", err) + return + } setStateCookie(w, name, "", 0) w.Header().Set("Content-Type", "application/x-wifi-config") w.Header().Set("Content-Transfer-Encoding", "base64") - _, _ = io.WriteString(w, androidProfile(user, key)) + _, _ = w.Write(body) case "apple": + body, err := appleProfile(user, key) + if err != nil { + fail("building the profile", "err", err) + return + } setStateCookie(w, name, "", 0) w.Header().Set("Content-Type", "application/x-apple-aspen-config") w.Header().Set("Content-Disposition", `attachment; filename="renner-unifi.mobileconfig"`) - _, _ = io.WriteString(w, appleProfile(user, key)) + _, _ = w.Write(body) case "windows": body, err := signer.Sign(windowsProfile(user, key)) if err != nil { diff --git a/wlan-setup/server_test.go b/wlan-setup/server_test.go index b6a546d1..370bc141 100644 --- a/wlan-setup/server_test.go +++ b/wlan-setup/server_test.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "crypto/rand" "crypto/rsa" @@ -14,11 +15,15 @@ import ( "io" "log/slog" "math/big" + "mime" + "mime/multipart" "net/http" "net/http/httptest" + "net/textproto" "net/url" "os" "path/filepath" + "reflect" "regexp" "strings" "sync" @@ -27,6 +32,7 @@ import ( "github.com/beevik/etree" dsig "github.com/russellhaering/goxmldsig" + "howett.net/plist" ) const ( @@ -368,99 +374,104 @@ func TestCallbackAndroid(t *testing.T) { rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" { t.Errorf("headers %v", rec.Header()) } - mime, err := base64.StdEncoding.DecodeString(rec.Body.String()) + raw, err := base64.StdEncoding.DecodeString(rec.Body.String()) if err != nil { t.Fatal(err) } - parts := strings.Split(string(mime), "--{boundary}\r\n") - var pps string - for _, p := range parts { - if body, ok := strings.CutPrefix(p, "Content-Type: application/x-passpoint-profile\r\nContent-Transfer-Encoding: base64\r\n\r\n"); ok { - raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body)) - if err != nil { - t.Fatal(err) - } - pps = string(raw) - } + msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(raw))).ReadMIMEHeader() + if err != nil { + t.Fatal(err) } - if !strings.Contains(pps, "UsernameAlice.Smith") || - !strings.Contains(pps, "Password"+base64.StdEncoding.EncodeToString([]byte(testKey))+"") { - t.Errorf("pps %s", pps) + mediaType, params, err := mime.ParseMediaType(msg.Get("Content-Type")) + if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" || + msg.Get("Content-Transfer-Encoding") != "base64" { + t.Fatalf("message header %v", msg) } - if !strings.Contains(string(mime), "Content-Type: application/x-x509-ca-cert\r\nContent-Transfer-Encoding: base64\r\n\r\n"+isrgRootX1+"\r\n--{boundary}--\r\n") { - t.Error("trust anchor part missing") - } -} - -// plistValue decodes one plist value: dict, array, string, integer, true, false. -func plistValue(d *xml.Decoder, start xml.StartElement) (any, error) { - switch start.Name.Local { - case "dict": - m := map[string]any{} - var key string - for { - tok, err := d.Token() - if err != nil { - return nil, err - } - switch el := tok.(type) { - case xml.StartElement: - if el.Name.Local == "key" { - if err := d.DecodeElement(&key, &el); err != nil { - return nil, err - } - continue - } - v, err := plistValue(d, el) - if err != nil { - return nil, err - } - m[key] = v - case xml.EndElement: - return m, nil - } - } - case "array": - var a []any - for { - tok, err := d.Token() - if err != nil { - return nil, err - } - switch el := tok.(type) { - case xml.StartElement: - v, err := plistValue(d, el) - if err != nil { - return nil, err - } - a = append(a, v) - case xml.EndElement: - return a, nil - } - } - case "true", "false": - return start.Name.Local == "true", d.Skip() - default: - var s string - err := d.DecodeElement(&s, &start) - return s, err - } -} - -func parsePlist(t *testing.T, body []byte) map[string]any { - t.Helper() - d := xml.NewDecoder(bytes.NewReader(body)) + body := raw[bytes.Index(raw, []byte("\r\n\r\n"))+4:] + mr := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + parts := map[string][]byte{} + var order []string for { - tok, err := d.Token() + p, err := mr.NextRawPart() + if err == io.EOF { + break + } if err != nil { t.Fatal(err) } - if el, ok := tok.(xml.StartElement); ok && el.Name.Local == "dict" { - v, err := plistValue(d, el) - if err != nil { - t.Fatal(err) + if p.Header.Get("Content-Transfer-Encoding") != "base64" { + t.Errorf("part %v is not base64", p.Header) + } + b, err := io.ReadAll(p) + if err != nil { + t.Fatal(err) + } + ct := p.Header.Get("Content-Type") + order = append(order, ct) + parts[ct] = b + } + if strings.Join(order, ",") != "application/x-passpoint-profile,application/x-x509-ca-cert" { + t.Errorf("parts %v", order) + } + if string(parts["application/x-x509-ca-cert"]) != isrgRootX1 { + t.Error("the trust anchor part is not ISRG Root X1") + } + pps, err := base64.StdEncoding.DecodeString(string(parts["application/x-passpoint-profile"])) + if err != nil { + t.Fatal(err) + } + type node struct { + Name string `xml:"NodeName"` + DDF string `xml:"RTProperties>Type>DDFName"` + Value *string `xml:"Value"` + Nodes []node `xml:"Node"` + } + var tree struct { + XMLName xml.Name + VerDTD string `xml:"VerDTD"` + Nodes []node `xml:"Node"` + } + if err := xml.Unmarshal(pps, &tree); err != nil { + t.Fatalf("pps: %v\n%s", err, pps) + } + if tree.XMLName != (xml.Name{Space: "syncml:dmddf1.2", Local: "MgmtTree"}) || tree.VerDTD != "1.2" || + len(tree.Nodes) != 1 || tree.Nodes[0].Name != "PerProviderSubscription" || + tree.Nodes[0].DDF != "urn:wfa:mo:hotspot2dot0-perprovidersubscription:1.0" { + t.Fatalf("pps %s", pps) + } + // value follows NodeNames below PerProviderSubscription/i001. + value := func(path string) string { + nodes := tree.Nodes[0].Nodes + var cur *node + for _, name := range strings.Split("i001/"+path, "/") { + cur = nil + for i := range nodes { + if nodes[i].Name == name { + cur = &nodes[i] + } } - return v.(map[string]any) + if cur == nil { + return "" + } + nodes = cur.Nodes + } + if cur.Value == nil { + return "" + } + return *cur.Value + } + for path, want := range map[string]string{ + "HomeSP/FriendlyName": "Renner", + "HomeSP/FQDN": "renner.uno", + "Credential/Realm": "renner.uno", + "Credential/UsernamePassword/Username": "Alice.Smith", + "Credential/UsernamePassword/Password": base64.StdEncoding.EncodeToString([]byte(testKey)), + "Credential/UsernamePassword/EAPMethod/EAPType": "21", + "Credential/UsernamePassword/EAPMethod/InnerMethod": "PAP", + "Extension/Android/AAAServerTrustedNames/FQDN": "radius.renner.uno", + } { + if got := value(path); got != want { + t.Errorf("%s = %q, want %q", path, got, want) } } } @@ -477,20 +488,47 @@ func TestCallbackApple(t *testing.T) { rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` { t.Errorf("headers %v", rec.Header()) } - top := parsePlist(t, rec.Body.Bytes()) - if top["PayloadType"] != "Configuration" || top["PayloadUUID"] != "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3" { - t.Errorf("top %v", top) + var top map[string]any + format, err := plist.Unmarshal(rec.Body.Bytes(), &top) + if err != nil || format != plist.XMLFormat { + t.Fatalf("plist (format %d): %v\n%s", format, err, rec.Body.Bytes()) } - wifi := top["PayloadContent"].([]any)[0].(map[string]any) - if wifi["IsHotspot"] != true || wifi["DomainName"] != "renner.uno" || - len(wifi["NAIRealmNames"].([]any)) != 1 || wifi["NAIRealmNames"].([]any)[0] != "renner.uno" || - wifi["PayloadUUID"] != "824959bf-c55d-4ed2-b569-c33f3759c2b6" { - t.Errorf("wifi %v", wifi) + want := map[string]any{ + "PayloadContent": []any{map[string]any{ + "PayloadType": "com.apple.wifi.managed", + "PayloadVersion": uint64(1), + "PayloadIdentifier": "uno.renner.wifi.renner-unifi.network", + "PayloadUUID": "824959bf-c55d-4ed2-b569-c33f3759c2b6", + "PayloadDisplayName": "Renner UniFi", + "PayloadOrganization": "Renner", + "IsHotspot": true, + "DomainName": "renner.uno", + "NAIRealmNames": []any{"renner.uno"}, + "DisplayedOperatorName": "Renner", + "ServiceProviderRoamingEnabled": false, + "AutoJoin": true, + "EncryptionType": "Any", + "EAPClientConfiguration": map[string]any{ + "UserName": "Alice.Smith", + "UserPassword": testKey, + "OuterIdentity": "anonymous", + "AcceptEAPTypes": []any{uint64(21)}, + "TTLSInnerAuthentication": "PAP", + "TLSTrustedServerNames": []any{"radius.renner.uno"}, + }, + }}, + "PayloadType": "Configuration", + "PayloadVersion": uint64(1), + "PayloadIdentifier": "uno.renner.wifi.renner-unifi", + "PayloadUUID": "7175a2a3-ddd3-42ea-b4ad-b7e5501892f3", + "PayloadDisplayName": "WLAN Renner UniFi", + "PayloadOrganization": "Renner", } - eap := wifi["EAPClientConfiguration"].(map[string]any) - if eap["UserName"] != "Alice.Smith" || eap["UserPassword"] != testKey || eap["OuterIdentity"] != "anonymous" || - eap["TTLSInnerAuthentication"] != "PAP" { - t.Errorf("eap %v", eap) + if !reflect.DeepEqual(top, want) { + t.Errorf("profile\n got %#v\nwant %#v", top, want) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("app-password-<&>-456")) { + t.Error("the password is not escaped as XML text") } } @@ -556,13 +594,37 @@ func TestCallbackWindows(t *testing.T) { t.Errorf("missing %s", want) } } - // The unsigned document is already canonical: its SHA-1 is the reference digest. - sum := sha1.Sum([]byte(windowsProfile("Alice.Smith", testKey))) + // The reference digest is the SHA-1 of the canonical unsigned document; written with + // canonical settings, the unsigned root is already in that form. + unsigned := etree.NewDocument() + unsigned.SetRoot(windowsProfile("Alice.Smith", testKey).Root()) + unsigned.WriteSettings = etree.WriteSettings{CanonicalEndTags: true, CanonicalText: true, CanonicalAttrVal: true} + unsignedBytes, err := unsigned.WriteToBytes() + if err != nil { + t.Fatal(err) + } + canonical, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(unsigned.Root()) + if err != nil || !bytes.Equal(canonical, unsignedBytes) { + t.Fatalf("the unsigned document is not canonical: %v\n%s\n%s", err, canonical, unsignedBytes) + } + sum := sha1.Sum(canonical) if !strings.Contains(s, ""+base64.StdEncoding.EncodeToString(sum[:])+"") { t.Error("the digest is not the SHA-1 of the unsigned document") } - if !strings.HasPrefix(s, strings.TrimSuffix(windowsProfile("Alice.Smith", testKey), "")+"` + - `` + - `` + - `` + - `` + base64.StdEncoding.EncodeToString(digest[:]) + `` + - `` - // Canonical SignedInfo carries the namespace it inherits from Signature. - hashed := sha1.Sum([]byte(`` + signedInfo)) + c14n := dsig.MakeC14N10RecCanonicalizer() + canonical, err := c14n.Canonicalize(root) + if err != nil { + return nil, fmt.Errorf("signing: %w", err) + } + digest := sha1.Sum(canonical) + + signature := root.CreateElement(dsig.SignatureTag) + signature.CreateAttr("xmlns", dsigNS) + signedInfo := signature.CreateElement(dsig.SignedInfoTag) + signedInfo.CreateElement(dsig.CanonicalizationMethodTag). + CreateAttr(dsig.AlgorithmAttr, c14n.Algorithm().String()) + signedInfo.CreateElement(dsig.SignatureMethodTag).CreateAttr(dsig.AlgorithmAttr, dsig.RSASHA1SignatureMethod) + reference := signedInfo.CreateElement(dsig.ReferenceTag) + reference.CreateAttr(dsig.URIAttr, "") + reference.CreateElement(dsig.TransformsTag).CreateElement(dsig.TransformTag). + CreateAttr(dsig.AlgorithmAttr, dsig.EnvelopedSignatureAltorithmId.String()) + reference.CreateElement(dsig.DigestMethodTag).CreateAttr(dsig.AlgorithmAttr, dsigNS+"sha1") + reference.CreateElement(dsig.DigestValueTag).SetText(base64.StdEncoding.EncodeToString(digest[:])) + + // In place, the canonicalizer adds the namespace SignedInfo inherits from Signature. + canonical, err = c14n.Canonicalize(signedInfo) + if err != nil { + return nil, fmt.Errorf("signing: %w", err) + } + hashed := sha1.Sum(canonical) value, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA1, hashed[:]) if err != nil { return nil, fmt.Errorf("signing: %w", err) } - var certs strings.Builder + signature.CreateElement(dsig.SignatureValueTag).SetText(base64.StdEncoding.EncodeToString(value)) + x509Data := signature.CreateElement(dsig.KeyInfoTag).CreateElement(dsig.X509DataTag) for _, c := range s.chain { - certs.WriteString("" + base64.StdEncoding.EncodeToString(c) + "") + x509Data.CreateElement(dsig.X509CertificateTag).SetText(base64.StdEncoding.EncodeToString(c)) } - signature := `` + signedInfo + - `` + base64.StdEncoding.EncodeToString(value) + `` + - `` + certs.String() + `` - return []byte(strings.TrimSuffix(doc, end) + signature + end), nil + return doc.WriteToBytes() }