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()
}