Compare commits

...
Author SHA1 Message Date
claude-botandClaude Opus 5.5 045b68f3ec feat(radius): signed Windows Wi-Fi provisioning test file
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
wlan.renner.uno/windows/test-profile.xml serves a CarrierProvisioning
document with a Passpoint profile for "Renner UniFi" (domain and NAI realm
renner.uno, WPA3ENT, EAP-TTLS/PAP, credentials that exist nowhere), signed
with XML-DSig (enveloped, C14N, rsa-sha1) by a Let's Encrypt certificate for
wlan.renner.uno. It shows whether Windows Settings
(ms-settings:wifi-provisioning) accepts that signature.

- Certificate wlan-setup-signing (PKCS#8 for njs WebCrypto), mounted
  read-only into wlan-setup (fsGroup 101, mode 0440), reloader on renewal.
- Ingress wlan-setup-provisioning: exact path, without the authentik gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:19:39 +01:00
4 changed files with 151 additions and 2 deletions
@@ -17,6 +17,7 @@ resources:
- freeradius.yaml
- freeradius-cnp.yaml
- wlan-setup-oauth.yaml
- wlan-setup-signing-cert.yaml
- wlan-setup-config.yaml
- wlan-setup.yaml
# ak-pg CNPG Postgres backend (moved here from the retired seed-authentik KS;
@@ -94,6 +94,12 @@ data:
js_content passpoint.install;
}
# Signed Windows provisioning test file (dummy credentials), reached
# without the gate through the Ingress wlan-setup-provisioning.
location = /windows/test-profile.xml {
js_content passpoint.windowsTest;
}
location ~ ^/(android|apple)/callback$ {
# The query carries the one-time authorization code.
access_log off;
@@ -153,7 +159,14 @@ data:
# identity anonymous, trusted server name radius.renner.uno, username and
# app password. Fixed PayloadIdentifier/UUIDs so a reinstall replaces the
# previous profile.
# Windows: provisioning XML (CarrierProvisioning) for
# ms-settings:wifi-provisioning, signed with XML-DSig (enveloped, C14N,
# rsa-sha1, as Microsoft's samples) by the wlan-setup-signing key. The
# document is written in canonical form, so the digest covers it as sent.
passpoint.js: |
import fs from "fs";
import cr from "crypto";
const ISRG_ROOT_X1 = "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=";
const USER = /^[A-Za-z0-9._@+-]{1,64}$/;
@@ -229,6 +242,79 @@ data:
'</dict></plist>\n';
}
const SIGNING = "/etc/wlan-signing/";
const DSIG = "http://www.w3.org/2000/09/xmldsig#";
const CARRIER_ID = "{5423c37a-7530-4ce9-8f87-a2574ea006bc}";
function pem(text, label) {
const re = new RegExp("-----BEGIN " + label + "-----([^-]+)-----END " + label + "-----", "g");
const out = [];
let m;
while ((m = re.exec(text)) !== null) out.push(m[1].replace(/\s+/g, ""));
return out;
}
// Canonical form (C14N 1.0): no self-closing tags, namespaces declared
// only where they change.
function windowsProfile(user, password) {
const common = ' xmlns="http://www.microsoft.com/provisioning/EapCommon"';
return '<CarrierProvisioning xmlns="http://www.microsoft.com/networking/CarrierControl/v1">' +
'<Global><CarrierId>' + CARRIER_ID + '</CarrierId><SubscriberId>renner-unifi</SubscriberId></Global>' +
'<WLANProfiles><WLANProfile xmlns="http://www.microsoft.com/networking/CarrierControl/WLAN/v1">' +
'<name>Renner UniFi</name>' +
'<SSIDConfig><SSID><name>Renner UniFi</name></SSID></SSIDConfig>' +
'<Hotspot2><DomainName>renner.uno</DomainName><NAIRealm><name>renner.uno</name></NAIRealm></Hotspot2>' +
'<MSM><security>' +
'<authEncryption><authentication>WPA3ENT</authentication><encryption>AES</encryption><useOneX>true</useOneX></authEncryption>' +
'<OneX xmlns="http://www.microsoft.com/networking/OneX/v1"><authMode>user</authMode><EAPConfig>' +
'<EapHostConfig xmlns="http://www.microsoft.com/provisioning/EapHostConfig">' +
'<EapMethod><Type' + common + '>21</Type><VendorId' + common + '>0</VendorId>' +
'<VendorType' + common + '>0</VendorType><AuthorId' + common + '>311</AuthorId></EapMethod>' +
'<Config><EapTtls xmlns="http://www.microsoft.com/provisioning/EapTtlsConnectionPropertiesV1">' +
'<ServerValidation><ServerNames>radius.renner.uno</ServerNames>' +
'<TrustedRootCAHash>ca bd 2a 79 a1 07 6a 31 f2 1d 25 36 35 cb 03 9d 43 29 a5 e8</TrustedRootCAHash>' +
'<DisablePrompt>true</DisablePrompt></ServerValidation>' +
'<Phase2Authentication><PAPAuthentication></PAPAuthentication></Phase2Authentication>' +
'<Phase1Identity><IdentityPrivacy>true</IdentityPrivacy><AnonymousIdentity>anonymous</AnonymousIdentity></Phase1Identity>' +
'</EapTtls></Config></EapHostConfig></EAPConfig></OneX>' +
'<EapHostUserCredentials xmlns="http://www.microsoft.com/provisioning/EapHostUserCredentials" xmlns:eapCommon="http://www.microsoft.com/provisioning/EapCommon">' +
'<EapMethod><eapCommon:Type>21</eapCommon:Type><eapCommon:AuthorId>311</eapCommon:AuthorId></EapMethod>' +
'<Credentials><EapTtls xmlns="http://www.microsoft.com/provisioning/EapTtlsUserPropertiesV1">' +
'<Username>' + xml(user) + '</Username><Password>' + xml(password) + '</Password>' +
'</EapTtls></Credentials></EapHostUserCredentials>' +
'</security></MSM></WLANProfile></WLANProfiles></CarrierProvisioning>';
}
async function signed(doc) {
const digest = cr.createHash("sha1").update(doc).digest("base64");
const signedInfo = '<SignedInfo xmlns="' + DSIG + '">' +
'<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"></CanonicalizationMethod>' +
'<SignatureMethod Algorithm="' + DSIG + 'rsa-sha1"></SignatureMethod>' +
'<Reference URI=""><Transforms><Transform Algorithm="' + DSIG + 'enveloped-signature"></Transform></Transforms>' +
'<DigestMethod Algorithm="' + DSIG + 'sha1"></DigestMethod><DigestValue>' + digest + '</DigestValue></Reference>' +
'</SignedInfo>';
const der = Buffer.from(pem(fs.readFileSync(SIGNING + "tls.key", "utf8"), "PRIVATE KEY")[0], "base64");
const key = await crypto.subtle.importKey("pkcs8", der, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-1" }, false, ["sign"]);
const value = Buffer.from(await crypto.subtle.sign({ name: "RSASSA-PKCS1-v1_5" }, key, signedInfo)).toString("base64");
const certs = pem(fs.readFileSync(SIGNING + "tls.crt", "utf8"), "CERTIFICATE")
.map(c => "<X509Certificate>" + c + "</X509Certificate>").join("");
const signature = '<Signature xmlns="' + DSIG + '">' + signedInfo +
"<SignatureValue>" + value + "</SignatureValue><KeyInfo><X509Data>" + certs + "</X509Data></KeyInfo></Signature>";
return doc.replace(/<\/CarrierProvisioning>$/, signature + "</CarrierProvisioning>");
}
// Test of the signature only: fixed profile with credentials that exist
// nowhere.
async function windowsTest(r) {
try {
r.headersOut["Content-Type"] = "text/xml";
r.return(200, await signed(windowsProfile("wlan-signature-test", "not-a-real-password")));
} catch (e) {
r.error("wlan-setup windows test: " + e.message);
r.return(500, "signing failed\n");
}
}
const PLATFORMS = {
android: { label: "Android" },
apple: { label: "Apple" },
@@ -347,7 +433,7 @@ data:
}
}
export default { install, callback };
export default { install, callback, windowsTest };
index.html: |
<!doctype html>
@@ -0,0 +1,21 @@
---
# Signs the Windows Wi-Fi provisioning XML of wlan.renner.uno (XML-DSig,
# passpoint.js). PKCS#8 because njs WebCrypto imports only that format.
# Issued via the cluster-wide Cloudflare DNS-01 ClusterIssuer.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wlan-setup-signing
namespace: authentik
spec:
secretName: wlan-setup-signing
issuerRef:
name: letsencrypt-cluster
kind: ClusterIssuer
dnsNames:
- wlan.renner.uno
privateKey:
algorithm: RSA
size: 2048
encoding: PKCS8
rotationPolicy: Always
+42 -1
View File
@@ -13,7 +13,7 @@ metadata:
app.kubernetes.io/name: wlan-setup
annotations:
configmap.reloader.stakater.com/reload: wlan-setup
secret.reloader.stakater.com/reload: wlan-setup-oauth
secret.reloader.stakater.com/reload: wlan-setup-oauth,wlan-setup-signing
spec:
replicas: 1
selector:
@@ -29,6 +29,8 @@ spec:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
# Group of the signing key files (defaultMode 0440).
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
@@ -80,10 +82,23 @@ spec:
- name: html
mountPath: /usr/share/nginx/html
readOnly: true
# Key and chain that sign the Windows provisioning XML.
- name: signing
mountPath: /etc/wlan-signing
readOnly: true
# pid file and temp paths of the unprivileged image.
- name: tmp
mountPath: /tmp
volumes:
- name: signing
secret:
secretName: wlan-setup-signing
defaultMode: 0440
items:
- key: tls.key
path: tls.key
- key: tls.crt
path: tls.crt
- name: main
configMap:
name: wlan-setup
@@ -161,3 +176,29 @@ spec:
name: wlan-setup
port:
number: 80
---
# Without the gate: Windows Settings fetches provisioning XML without the
# browser's session. Traefik reaches the pods through the CNP wlan-setup-gate
# that gate-sync creates for the Ingress above.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wlan-setup-provisioning
namespace: authentik
spec:
ingressClassName: traefik
tls:
- hosts:
- wlan.renner.uno
secretName: wlan-setup-tls
rules:
- host: wlan.renner.uno
http:
paths:
- path: /windows/test-profile.xml
pathType: Exact
backend:
service:
name: wlan-setup
port:
number: 80