Windows Settings rejected the provisioning XML: Global/SubscriberId is base:SubscriberType (\w+, at most 20 characters) in the schemas Windows ships in %SYSTEMROOT%\schemas\provisioning, and "renner-unifi" contains a hyphen. It is now "rennerunifi". Those schemas also allow WPA3ENT and Hotspot2/NAIRealm, which the online schema reference lacks: the profile uses WPA3ENT again (the network is WPA3-Enterprise) and carries NAI realm renner.uno next to the domain. TestWindowsProfileSchema validates the signed document with xmllint against a copy of that schema folder (WLAN_SETUP_PROVISIONING_XSD); without it the test is skipped, the schemas are Microsoft's files and not in the repo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
881 lines
32 KiB
Go
881 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha1"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"encoding/xml"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/textproto"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/beevik/etree"
|
|
dsig "github.com/russellhaering/goxmldsig"
|
|
"howett.net/plist"
|
|
)
|
|
|
|
const (
|
|
testSecret = "client-secret"
|
|
testAccess = "access-token-123"
|
|
testKey = "app-password-<&>-456"
|
|
testCode = "code-789"
|
|
)
|
|
|
|
// fakeAuthentik answers the four calls of a callback and records what it received.
|
|
type fakeAuthentik struct {
|
|
t *testing.T
|
|
username string
|
|
fail string // path that answers 500
|
|
|
|
mu sync.Mutex
|
|
requests []string
|
|
created tokenRequest
|
|
}
|
|
|
|
func (f *fakeAuthentik) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
f.requests = append(f.requests, r.Method+" "+r.URL.Path)
|
|
f.mu.Unlock()
|
|
t := f.t
|
|
if r.Host != "auth.renner.uno" {
|
|
t.Errorf("%s: Host %q", r.URL.Path, r.Host)
|
|
}
|
|
if r.Header.Get("Cookie") != "" {
|
|
t.Errorf("%s: a cookie reached authentik", r.URL.Path)
|
|
}
|
|
if r.URL.Path == f.fail {
|
|
http.Error(w, `{"detail":"broken"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
bearer := func() bool {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+testAccess {
|
|
t.Errorf("%s: Authorization %q", r.URL.Path, got)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
switch {
|
|
case r.Method == http.MethodPost && r.URL.Path == "/application/o/token/":
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok || user != "wlan-setup" || pass != testSecret {
|
|
t.Errorf("token: Basic auth %q %v", user, ok)
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
|
t.Errorf("token: Content-Type %q", ct)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.PostForm.Get("grant_type") != "authorization_code" || r.PostForm.Get("code") != testCode {
|
|
t.Errorf("token: form %v", r.PostForm)
|
|
}
|
|
platform := strings.Split(r.PostForm.Get("redirect_uri"), "/")[3]
|
|
if want := "https://wlan.renner.uno/" + platform + "/callback"; r.PostForm.Get("redirect_uri") != want {
|
|
t.Errorf("token: redirect_uri %q", r.PostForm.Get("redirect_uri"))
|
|
}
|
|
_, _ = io.WriteString(w, `{"access_token":"`+testAccess+`","token_type":"Bearer"}`)
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/core/users/me/":
|
|
if bearer() {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"user": map[string]any{"username": f.username}})
|
|
}
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v3/core/tokens/":
|
|
if !bearer() {
|
|
return
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("tokens: Content-Type %q", ct)
|
|
}
|
|
var tr tokenRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&tr); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.mu.Lock()
|
|
f.created = tr
|
|
f.mu.Unlock()
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = io.WriteString(w, `{"identifier":"`+tr.Identifier+`"}`)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v3/core/tokens/") &&
|
|
strings.HasSuffix(r.URL.Path, "/view_key/"):
|
|
f.mu.Lock()
|
|
want := "/api/v3/core/tokens/" + f.created.Identifier + "/view_key/"
|
|
f.mu.Unlock()
|
|
if r.URL.Path != want {
|
|
t.Errorf("view_key: path %q, want %q", r.URL.Path, want)
|
|
}
|
|
if bearer() {
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"key": testKey})
|
|
}
|
|
default:
|
|
t.Errorf("unexpected call %s %s", r.Method, r.URL.Path)
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
type testCert struct {
|
|
cert *x509.Certificate
|
|
keyFile string
|
|
certFile string
|
|
}
|
|
|
|
// writeSigningFiles writes a fresh RSA key (PKCS#8) and a chain of a self-signed leaf plus a
|
|
// second certificate, as cert-manager would mount them.
|
|
func writeSigningFiles(t *testing.T) testCert {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mk := func(cn string, k *rsa.PrivateKey) []byte {
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
|
Subject: pkix.Name{CommonName: cn},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &k.PublicKey, k)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return der
|
|
}
|
|
leaf := mk("wlan.renner.uno", key)
|
|
other, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
intermediate := mk("intermediate", other)
|
|
pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tc := testCert{keyFile: filepath.Join(dir, "tls.key"), certFile: filepath.Join(dir, "tls.crt")}
|
|
if err := os.WriteFile(tc.keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chain := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf}),
|
|
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: intermediate})...)
|
|
if err := os.WriteFile(tc.certFile, chain, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tc.cert, _ = x509.ParseCertificate(leaf)
|
|
return tc
|
|
}
|
|
|
|
type harness struct {
|
|
srv *Server
|
|
handler http.Handler
|
|
fake *fakeAuthentik
|
|
signing testCert
|
|
clock *fakeClock
|
|
}
|
|
|
|
type fakeClock struct {
|
|
mu sync.Mutex
|
|
now time.Time
|
|
}
|
|
|
|
func (c *fakeClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now }
|
|
func (c *fakeClock) Add(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.now = c.now.Add(d)
|
|
}
|
|
|
|
func newHarness(t *testing.T) *harness {
|
|
t.Helper()
|
|
fake := &fakeAuthentik{t: t, username: "Alice.Smith"}
|
|
upstream := httptest.NewServer(fake)
|
|
t.Cleanup(upstream.Close)
|
|
signing := writeSigningFiles(t)
|
|
cfg, err := loadConfig(env(map[string]string{
|
|
"WLAN_SETUP_CLIENT_SECRET": testSecret,
|
|
"AUTHENTIK_INTERNAL_URL": upstream.URL,
|
|
"SIGNING_KEY": signing.keyFile,
|
|
"SIGNING_CERT": signing.certFile,
|
|
}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := newServer(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
clock := &fakeClock{now: time.Date(2026, 11, 5, 9, 8, 7, 0, time.UTC)}
|
|
srv.now = clock.Now
|
|
srv.profiles.now = clock.Now
|
|
return &harness{srv: srv, handler: srv.routes(), fake: fake, signing: signing, clock: clock}
|
|
}
|
|
|
|
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
|
|
|
func (h *harness) do(req *http.Request) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
h.handler.ServeHTTP(rec, req)
|
|
if rec.Header().Get("Cache-Control") != "no-store" {
|
|
panic("no Cache-Control: no-store on " + req.URL.Path)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func (h *harness) callback(platform, cookieState, queryState, user string) *httptest.ResponseRecorder {
|
|
q := url.Values{"state": {queryState}, "code": {testCode}}
|
|
req := httptest.NewRequest(http.MethodGet, "/"+platform+"/callback?"+q.Encode(), nil)
|
|
if cookieState != "" {
|
|
req.Header.Set("Cookie", "other=1; wlan_oauth_state="+cookieState)
|
|
}
|
|
req.Header.Set("X-authentik-username", user)
|
|
return h.do(req)
|
|
}
|
|
|
|
func TestInstall(t *testing.T) {
|
|
h := newHarness(t)
|
|
for _, platform := range []string{"android", "apple", "windows"} {
|
|
rec := h.do(httptest.NewRequest(http.MethodGet, "/"+platform+"/install", nil))
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("%s: status %d", platform, rec.Code)
|
|
}
|
|
cookie := rec.Header().Get("Set-Cookie")
|
|
m := regexp.MustCompile(`^wlan_oauth_state=([A-Za-z0-9_-]{32}); Path=/` + platform +
|
|
`/; Max-Age=300; HttpOnly; Secure; SameSite=Lax$`).FindStringSubmatch(cookie)
|
|
if m == nil {
|
|
t.Fatalf("%s: cookie %q", platform, cookie)
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, "https://auth.renner.uno/application/o/authorize/?response_type=code&") {
|
|
t.Errorf("%s: location %q", platform, loc)
|
|
}
|
|
if !strings.Contains(loc, "scope=openid%20profile%20goauthentik.io%2Fapi") {
|
|
t.Errorf("%s: scope not encoded like encodeURIComponent: %q", platform, loc)
|
|
}
|
|
u, err := url.Parse(loc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := u.Query()
|
|
if q.Get("client_id") != "wlan-setup" || q.Get("redirect_uri") != "https://wlan.renner.uno/"+platform+"/callback" ||
|
|
q.Get("scope") != "openid profile goauthentik.io/api" || q.Get("state") != m[1] || q.Get("response_type") != "code" {
|
|
t.Errorf("%s: query %v", platform, q)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallbackStateMismatch(t *testing.T) {
|
|
h := newHarness(t)
|
|
for _, c := range []struct{ cookie, query string }{{"abc", "abd"}, {"", "abc"}, {"abc", ""}} {
|
|
rec := h.callback("android", c.cookie, c.query, "Alice.Smith")
|
|
if rec.Code != http.StatusBadRequest || rec.Body.String() != "Bitte die Einrichtung neu starten.\n" {
|
|
t.Errorf("%+v: %d %q", c, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "/apple/callback?state=abc", nil)
|
|
req.Header.Set("Cookie", "wlan_oauth_state=abc")
|
|
if rec := h.do(req); rec.Code != http.StatusBadRequest {
|
|
t.Errorf("no code: %d", rec.Code)
|
|
}
|
|
if len(h.fake.requests) != 0 {
|
|
t.Errorf("authentik was called: %v", h.fake.requests)
|
|
}
|
|
}
|
|
|
|
func TestCallbackUserMismatch(t *testing.T) {
|
|
h := newHarness(t)
|
|
if rec := h.callback("android", "s", "s", "bob"); rec.Code != http.StatusForbidden {
|
|
t.Errorf("other user: %d", rec.Code)
|
|
}
|
|
h.fake.username = "bad user"
|
|
if rec := h.callback("apple", "s", "s", "bad user"); rec.Code != http.StatusForbidden {
|
|
t.Errorf("unsafe username: %d", rec.Code)
|
|
}
|
|
for _, r := range h.fake.requests {
|
|
if strings.HasPrefix(r, "POST /api/v3/core/tokens/") {
|
|
t.Errorf("an app password was created for a mismatched user")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallbackUpstreamError(t *testing.T) {
|
|
for _, path := range []string{"/application/o/token/", "/api/v3/core/users/me/", "/api/v3/core/tokens/"} {
|
|
h := newHarness(t)
|
|
h.fake.fail = path
|
|
rec := h.callback("android", "s", "s", "Alice.Smith")
|
|
if rec.Code != http.StatusBadGateway || rec.Body.String() != "Die Einrichtung ist fehlgeschlagen.\n" {
|
|
t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String())
|
|
}
|
|
if strings.Contains(rec.Header().Get("Set-Cookie"), "Max-Age=0") {
|
|
t.Errorf("%s: the state cookie was cleared on failure", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallbackWindowsWithoutSigningKey(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.srv.cfg.SigningKey = filepath.Join(t.TempDir(), "missing.key")
|
|
if rec := h.callback("windows", "s", "s", "Alice.Smith"); rec.Code != http.StatusBadGateway {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
if len(h.fake.requests) != 0 {
|
|
t.Errorf("authentik was called without a signing key: %v", h.fake.requests)
|
|
}
|
|
}
|
|
|
|
func checkCreated(t *testing.T, h *harness, platform, label string) {
|
|
t.Helper()
|
|
got := h.fake.created
|
|
if !regexp.MustCompile(`^wlan-` + platform + `-alice-smith-20261105T090807-[A-Za-z0-9]{6}$`).MatchString(got.Identifier) {
|
|
t.Errorf("identifier %q", got.Identifier)
|
|
}
|
|
if got.Intent != "app_password" || !got.Expiring || got.Expires != "2026-12-04T09:08:07.000Z" ||
|
|
got.Description != "WLAN "+label+", 2026-11-05 (wlan.renner.uno)" {
|
|
t.Errorf("token request %+v", got)
|
|
}
|
|
want := []string{"POST /application/o/token/", "GET /api/v3/core/users/me/", "POST /api/v3/core/tokens/",
|
|
"GET /api/v3/core/tokens/" + got.Identifier + "/view_key/"}
|
|
if strings.Join(h.fake.requests, "|") != strings.Join(want, "|") {
|
|
t.Errorf("calls %v", h.fake.requests)
|
|
}
|
|
}
|
|
|
|
func checkCleared(t *testing.T, rec *httptest.ResponseRecorder, platform string) {
|
|
t.Helper()
|
|
if got := rec.Header().Get("Set-Cookie"); got != "wlan_oauth_state=; Path=/"+platform+"/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" {
|
|
t.Errorf("cookie %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCallbackAndroid(t *testing.T) {
|
|
h := newHarness(t)
|
|
rec := h.callback("android", "s", "s", "Alice.Smith")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
checkCreated(t, h, "android", "Android")
|
|
checkCleared(t, rec, "android")
|
|
if rec.Header().Get("Content-Type") != "application/x-wifi-config" ||
|
|
rec.Header().Get("Content-Transfer-Encoding") != "base64" || rec.Header().Get("Content-Disposition") != "" {
|
|
t.Errorf("headers %v", rec.Header())
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(rec.Body.String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg, err := textproto.NewReader(bufio.NewReader(bytes.NewReader(raw))).ReadMIMEHeader()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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)
|
|
}
|
|
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 {
|
|
p, err := mr.NextRawPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
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]
|
|
}
|
|
}
|
|
if cur == nil {
|
|
return "<no node>"
|
|
}
|
|
nodes = cur.Nodes
|
|
}
|
|
if cur.Value == nil {
|
|
return "<no value>"
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCallbackApple(t *testing.T) {
|
|
h := newHarness(t)
|
|
rec := h.callback("apple", "s", "s", "Alice.Smith")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
checkCreated(t, h, "apple", "Apple")
|
|
checkCleared(t, rec, "apple")
|
|
if rec.Header().Get("Content-Type") != "application/x-apple-aspen-config" ||
|
|
rec.Header().Get("Content-Disposition") != `attachment; filename="renner-unifi.mobileconfig"` {
|
|
t.Errorf("headers %v", rec.Header())
|
|
}
|
|
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())
|
|
}
|
|
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",
|
|
}
|
|
if !reflect.DeepEqual(top, want) {
|
|
t.Errorf("profile\n got %#v\nwant %#v", top, want)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte("<string>app-password-<&>-456</string>")) {
|
|
t.Error("the password is not escaped as XML text")
|
|
}
|
|
}
|
|
|
|
func (h *harness) windowsToken(t *testing.T) string {
|
|
t.Helper()
|
|
rec := h.callback("windows", "s", "s", "Alice.Smith")
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
checkCleared(t, rec, "windows")
|
|
m := regexp.MustCompile(`^ms-settings:wifi-provisioning\?uri=https://wlan\.renner\.uno/windows/profile/([A-Za-z0-9_-]{43})$`).
|
|
FindStringSubmatch(rec.Header().Get("Location"))
|
|
if m == nil {
|
|
t.Fatalf("location %q", rec.Header().Get("Location"))
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
func validate(cert *x509.Certificate, body []byte) error {
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromBytes(body); err != nil {
|
|
return err
|
|
}
|
|
ctx := dsig.NewDefaultValidationContext(&dsig.MemoryX509CertificateStore{Roots: []*x509.Certificate{cert}})
|
|
_, err := ctx.Validate(doc.Root())
|
|
return err
|
|
}
|
|
|
|
func TestCallbackWindows(t *testing.T) {
|
|
h := newHarness(t)
|
|
token := h.windowsToken(t)
|
|
checkCreated(t, h, "windows", "Windows")
|
|
|
|
// No header or cookie: Windows Settings fetches it outside the authentik session.
|
|
head := h.do(httptest.NewRequest(http.MethodHead, "/windows/profile/"+token, nil))
|
|
if head.Code != http.StatusOK {
|
|
t.Errorf("HEAD: %d", head.Code)
|
|
}
|
|
rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil))
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/xml" {
|
|
t.Fatalf("first fetch: %d %v", rec.Code, rec.Header())
|
|
}
|
|
body := rec.Body.Bytes()
|
|
if again := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); again.Code != http.StatusNotFound {
|
|
t.Errorf("second fetch: %d", again.Code)
|
|
}
|
|
|
|
if err := validate(h.signing.cert, body); err != nil {
|
|
t.Fatalf("signature does not verify: %v\n%s", err, body)
|
|
}
|
|
s := string(body)
|
|
for _, want := range []string{
|
|
`<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">`,
|
|
`<Username>Alice.Smith</Username><Password>app-password-<&>-456</Password>`,
|
|
`<Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo>`,
|
|
`<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>`,
|
|
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"></SignatureMethod>`,
|
|
`<Reference URI=""><Transforms><Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></Transform>`,
|
|
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"></DigestMethod>`,
|
|
`</Signature></CarrierProvisioning>`,
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("missing %s", want)
|
|
}
|
|
}
|
|
// 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, "<DigestValue>"+base64.StdEncoding.EncodeToString(sum[:])+"</DigestValue>") {
|
|
t.Error("the digest is not the SHA-1 of the unsigned document")
|
|
}
|
|
signed := etree.NewDocument()
|
|
if err := signed.ReadFromBytes(body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pi, ok := signed.Child[0].(*etree.ProcInst); !ok || pi.Target != "xml" || pi.Inst != `version="1.0"` {
|
|
t.Errorf("the document does not start with the XML declaration: %q", s[:min(len(s), 40)])
|
|
}
|
|
root := signed.Root()
|
|
if last := root.ChildElements()[len(root.ChildElements())-1]; last.Tag != "Signature" {
|
|
t.Errorf("the last child of the root is %s", last.Tag)
|
|
}
|
|
root.RemoveChild(root.SelectElement("Signature"))
|
|
if stripped, err := dsig.MakeC14N10RecCanonicalizer().Canonicalize(root); err != nil || !bytes.Equal(stripped, canonical) {
|
|
t.Errorf("without the signature, the document differs from the unsigned one: %v", err)
|
|
}
|
|
// Only the enveloped-signature transform, as in Microsoft's signed samples.
|
|
if n := strings.Count(s, "<Transform "); n != 1 {
|
|
t.Errorf("%d transforms, want 1", n)
|
|
}
|
|
if n := strings.Count(s, "<X509Certificate>"); n != 2 {
|
|
t.Errorf("%d certificates in KeyInfo, want the whole chain (2)", n)
|
|
}
|
|
leaf := base64.StdEncoding.EncodeToString(h.signing.cert.Raw)
|
|
if !strings.Contains(s, "<X509Data><X509Certificate>"+leaf+"</X509Certificate>") {
|
|
t.Error("the leaf is not the first certificate")
|
|
}
|
|
|
|
tampered := bytes.Replace(body, []byte("Alice.Smith"), []byte("Alice.Smitx"), 1)
|
|
if err := validate(h.signing.cert, tampered); err == nil {
|
|
t.Error("a tampered profile verifies")
|
|
}
|
|
}
|
|
|
|
// TestWindowsProfileSchema validates the signed document with xmllint against the schemas
|
|
// Windows ships in %SYSTEMROOT%\schemas\provisioning. They are Microsoft's files and not in the
|
|
// repo: point WLAN_SETUP_PROVISIONING_XSD at a copy of that folder to run it.
|
|
func TestWindowsProfileSchema(t *testing.T) {
|
|
dir := os.Getenv("WLAN_SETUP_PROVISIONING_XSD")
|
|
if dir == "" {
|
|
t.Skip("WLAN_SETUP_PROVISIONING_XSD not set")
|
|
}
|
|
xmllint, err := exec.LookPath("xmllint")
|
|
if err != nil {
|
|
t.Skip("xmllint not installed")
|
|
}
|
|
tc := writeSigningFiles(t)
|
|
signer, err := loadSigner(tc.keyFile, tc.certFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The schemas import each other by namespace only; a wrapper schema gives libxml2 the
|
|
// locations. Dependencies first, so the later imports find their namespaces loaded.
|
|
wrapper := etree.NewDocument()
|
|
schema := wrapper.CreateElement("xs:schema")
|
|
schema.CreateAttr("xmlns:xs", "http://www.w3.org/2001/XMLSchema")
|
|
schema.CreateAttr("targetNamespace", "urn:wlan-setup:schema-test")
|
|
for _, s := range []struct{ ns, file string }{
|
|
{"http://www.microsoft.com/networking/CarrierControl/Base/v1", "provisioning_base_v1.xsd"},
|
|
{"http://www.microsoft.com/networking/CarrierControl/Plans/v1", "provisioning_plans_v1.xsd"},
|
|
{"http://www.microsoft.com/networking/CarrierControl/WWAN/v1", "provisioning_wwan_v1.xsd"},
|
|
{"http://www.microsoft.com/networking/CarrierControl/WLAN/v1", "provisioning_wlan_v1.xsd"},
|
|
{dsig.Namespace, "provisioning_sig_v1.xsd"},
|
|
{nsCarrierControl, "provisioning_v1.xsd"},
|
|
} {
|
|
imp := schema.CreateElement("xs:import")
|
|
imp.CreateAttr("namespace", s.ns)
|
|
imp.CreateAttr("schemaLocation", "file://"+filepath.Join(dir, s.file))
|
|
}
|
|
tmp := t.TempDir()
|
|
wrapperFile := filepath.Join(tmp, "wrapper.xsd")
|
|
if err := wrapper.WriteToFile(wrapperFile); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
docFile := filepath.Join(tmp, "profile.xml")
|
|
if err := os.WriteFile(docFile, body, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := exec.Command(xmllint, "--noout", "--schema", wrapperFile, docFile).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("xmllint: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
func TestWindowsProfileDocument(t *testing.T) {
|
|
tc := writeSigningFiles(t)
|
|
signer, err := loadSigner(tc.keyFile, tc.certFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := signer.Sign(windowsProfile("Alice.Smith", testKey))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := validate(tc.cert, body); err != nil {
|
|
t.Fatalf("signature does not verify: %v", err)
|
|
}
|
|
var wellFormed struct{}
|
|
if err := xml.Unmarshal(body, &wellFormed); err != nil {
|
|
t.Fatalf("not well-formed: %v", err)
|
|
}
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromBytes(body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root := doc.Root()
|
|
const (
|
|
wlan = "WLANProfiles/WLANProfile/"
|
|
sec = wlan + "MSM/security/"
|
|
eap = sec + "OneX/EAPConfig/EapHostConfig/"
|
|
ttls = eap + "Config/EapTtls/"
|
|
creds = sec + "EapHostUserCredentials/"
|
|
sig = "Signature/"
|
|
)
|
|
for path, want := range map[string]string{
|
|
"Global/CarrierId": carrierID,
|
|
"Global/SubscriberId": "rennerunifi",
|
|
wlan + "name": "Renner UniFi",
|
|
wlan + "SSIDConfig/SSID/name": "Renner UniFi",
|
|
wlan + "Hotspot2/DomainName": "renner.uno",
|
|
wlan + "Hotspot2/NAIRealm/name": "renner.uno",
|
|
sec + "authEncryption/authentication": "WPA3ENT",
|
|
sec + "authEncryption/encryption": "AES",
|
|
sec + "authEncryption/useOneX": "true",
|
|
sec + "OneX/authMode": "user",
|
|
eap + "EapMethod/Type": "21",
|
|
eap + "EapMethod/VendorId": "0",
|
|
eap + "EapMethod/VendorType": "0",
|
|
eap + "EapMethod/AuthorId": "311",
|
|
ttls + "ServerValidation/ServerNames": "radius.renner.uno",
|
|
ttls + "ServerValidation/TrustedRootCAHash": "ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8",
|
|
ttls + "ServerValidation/DisablePrompt": "true",
|
|
ttls + "Phase2Authentication/PAPAuthentication": "",
|
|
ttls + "Phase1Identity/IdentityPrivacy": "true",
|
|
ttls + "Phase1Identity/AnonymousIdentity": "anonymous",
|
|
creds + "EapMethod/eapCommon:Type": "21",
|
|
creds + "EapMethod/eapCommon:AuthorId": "311",
|
|
creds + "Credentials/EapTtls/Username": "Alice.Smith",
|
|
creds + "Credentials/EapTtls/Password": testKey,
|
|
sig + "SignedInfo/Reference/Transforms/Transform": "",
|
|
} {
|
|
el := root.FindElement(path)
|
|
if el == nil {
|
|
t.Errorf("no element %s", path)
|
|
continue
|
|
}
|
|
if el.Text() != want {
|
|
t.Errorf("%s = %q, want %q", path, el.Text(), want)
|
|
}
|
|
}
|
|
for path, want := range map[string]string{
|
|
".": "http://www.microsoft.com/networking/CarrierControl/v1",
|
|
"Global/CarrierId": "http://www.microsoft.com/networking/CarrierControl/v1",
|
|
"WLANProfiles": "http://www.microsoft.com/networking/CarrierControl/v1",
|
|
wlan + "Hotspot2/DomainName": "http://www.microsoft.com/networking/CarrierControl/WLAN/v1",
|
|
sec + "OneX/authMode": "http://www.microsoft.com/networking/OneX/v1",
|
|
eap + "EapMethod": "http://www.microsoft.com/provisioning/EapHostConfig",
|
|
eap + "EapMethod/Type": "http://www.microsoft.com/provisioning/EapCommon",
|
|
eap + "EapMethod/AuthorId": "http://www.microsoft.com/provisioning/EapCommon",
|
|
ttls + "ServerValidation": "http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1",
|
|
creds + "EapMethod": "http://www.microsoft.com/provisioning/EapHostUserCredentials",
|
|
creds + "EapMethod/eapCommon:Type": "http://www.microsoft.com/provisioning/EapCommon",
|
|
creds + "Credentials/EapTtls/Password": "http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1",
|
|
"Signature": dsig.Namespace,
|
|
sig + "SignedInfo/Reference": dsig.Namespace,
|
|
} {
|
|
if el := root.FindElement(path); el == nil || el.NamespaceURI() != want {
|
|
t.Errorf("%s: namespace not %s", path, want)
|
|
}
|
|
}
|
|
if si := root.FindElement(sig + "SignedInfo"); si == nil || len(si.Attr) != 0 {
|
|
t.Error("SignedInfo carries its own attributes")
|
|
}
|
|
for path, want := range map[string]string{
|
|
sig + "SignedInfo/CanonicalizationMethod": "http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
|
|
sig + "SignedInfo/SignatureMethod": "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
sig + "SignedInfo/Reference/Transforms/Transform": "http://www.w3.org/2000/09/xmldsig#enveloped-signature",
|
|
sig + "SignedInfo/Reference/DigestMethod": "http://www.w3.org/2000/09/xmldsig#sha1",
|
|
} {
|
|
if el := root.FindElement(path); el == nil || el.SelectAttrValue("Algorithm", "") != want {
|
|
t.Errorf("%s: Algorithm not %s", path, want)
|
|
}
|
|
}
|
|
if ref := root.FindElement(sig + "SignedInfo/Reference"); ref == nil || ref.SelectAttrValue("URI", "-") != "" {
|
|
t.Error(`Reference URI is not ""`)
|
|
}
|
|
if n := len(root.FindElements(sig + "SignedInfo/Reference/Transforms/Transform")); n != 1 {
|
|
t.Errorf("%d transforms, want 1", n)
|
|
}
|
|
certs := root.FindElements(sig + "KeyInfo/X509Data/X509Certificate")
|
|
if len(certs) != 2 || certs[0].Text() != base64.StdEncoding.EncodeToString(tc.cert.Raw) {
|
|
t.Errorf("KeyInfo holds %d certificates, want the chain with the leaf first", len(certs))
|
|
}
|
|
if _, err := signer.Sign(doc); err == nil {
|
|
t.Error("an already signed document was signed again")
|
|
}
|
|
}
|
|
|
|
func TestWindowsProfileExpires(t *testing.T) {
|
|
h := newHarness(t)
|
|
token := h.windowsToken(t)
|
|
h.clock.Add(profileTTL)
|
|
if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/"+token, nil)); rec.Code != http.StatusNotFound {
|
|
t.Errorf("expired: %d", rec.Code)
|
|
}
|
|
if len(h.srv.profiles.profiles) != 0 {
|
|
t.Error("the expired profile was not purged")
|
|
}
|
|
if rec := h.do(httptest.NewRequest(http.MethodGet, "/windows/profile/unknown", nil)); rec.Code != http.StatusNotFound {
|
|
t.Errorf("unknown: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestWhoamiHealthzIndexNotFound(t *testing.T) {
|
|
h := newHarness(t)
|
|
for _, c := range []struct{ user, want string }{{"Alice.Smith", "Alice.Smith"}, {"a b", ""}, {"", ""},
|
|
{strings.Repeat("a", 65), ""}} {
|
|
req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
|
|
req.Header.Set("X-authentik-username", c.user)
|
|
rec := h.do(req)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != c.want || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
|
|
t.Errorf("whoami %q: %d %q", c.user, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
if rec := h.do(httptest.NewRequest(http.MethodGet, "/healthz", nil)); rec.Code != http.StatusOK || rec.Body.String() != "ok\n" {
|
|
t.Errorf("healthz: %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
rec := h.do(httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `href="/windows/install"`) ||
|
|
strings.Contains(rec.Body.String(), ".cmd") {
|
|
t.Errorf("index: %d", rec.Code)
|
|
}
|
|
for _, req := range []*http.Request{
|
|
httptest.NewRequest(http.MethodGet, "/renner-unifi-wlan.cmd", nil),
|
|
httptest.NewRequest(http.MethodGet, "/linux/install", nil),
|
|
httptest.NewRequest(http.MethodPost, "/", nil),
|
|
httptest.NewRequest(http.MethodPost, "/android/install", nil),
|
|
httptest.NewRequest(http.MethodGet, "/index.html", nil),
|
|
} {
|
|
if rec := h.do(req); rec.Code != http.StatusNotFound {
|
|
t.Errorf("%s %s: %d", req.Method, req.URL.Path, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig(t *testing.T) {
|
|
if _, err := loadConfig(env(nil)); err == nil || !strings.Contains(err.Error(), "WLAN_SETUP_CLIENT_SECRET is not set") {
|
|
t.Errorf("missing secret: %v", err)
|
|
}
|
|
c, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x"}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.AuthentikURL != "http://authentik-server.authentik.svc.cluster.local" ||
|
|
c.AuthorizeURL != "https://auth.renner.uno/application/o/authorize/" || c.PublicURL != "https://wlan.renner.uno" ||
|
|
c.SigningKey != "/etc/wlan-signing/tls.key" || c.SigningCert != "/etc/wlan-signing/tls.crt" || c.Listen != ":8080" {
|
|
t.Errorf("defaults %+v", c)
|
|
}
|
|
if _, err := loadConfig(env(map[string]string{"WLAN_SETUP_CLIENT_SECRET": "x", "PUBLIC_URL": "wlan"})); err == nil {
|
|
t.Error("relative PUBLIC_URL accepted")
|
|
}
|
|
}
|