Skip to content

Commit bf9bddb

Browse files
committed
feat: issue FreeRADIUS a dedicated EAP-TLS cert from the wireless CA
FreeRADIUS now uses two certs: - tls.crt (RadSec CA): outer RadSec TLS, verified by routers - eap.crt (wireless CA): EAP-TLS inner auth, verified by iOS devices iOS devices anchor EAP-TLS server trust to the wireless CA via the mobileconfig PayloadCertificateAnchorUUID — so the EAP cert must be issued by the wireless CA, not the RadSec CA. This was the root cause of the "certificate unknown" alert (sent by the device toward FreeRADIUS when the presented cert couldn't be verified). PINT manages the eap.crt lifecycle (load/renew on startup, 24h watcher) identically to the existing RadSec cert. Both certs are stored in the same radsec-server-certificates secret. The EAP Dogtag profile defaults to pint_radsec_server and is overridable via PINT_IPA_EAP_CERT_PROFILE.
1 parent 34c1fe2 commit bf9bddb

7 files changed

Lines changed: 105 additions & 12 deletions

File tree

chart/templates/deployment.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ spec:
5858
- name: PINT_IPA_RADSEC_SERVER_CERT_PROFILE
5959
value: {{ .Values.config.ipaRadSecServerCertProfile | quote }}
6060
{{- end }}
61+
{{- if .Values.config.ipaEAPCertProfile }}
62+
- name: PINT_IPA_EAP_CERT_PROFILE
63+
value: {{ .Values.config.ipaEAPCertProfile | quote }}
64+
{{- end }}
6165
{{- if .Values.config.ipaSkipTLSVerify }}
6266
- name: PINT_IPA_SKIP_TLS_VERIFY
6367
value: "true"

chart/values.schema.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@
7373
},
7474
"ipaRadSecServerCertProfile": {
7575
"type": "string",
76-
"description": "Dogtag certificate profile for the FreeRADIUS server cert.",
76+
"description": "Dogtag certificate profile for the FreeRADIUS outer RadSec TLS cert (RadSec CA-issued; verified by routers).",
77+
"default": "pint_radsec_server"
78+
},
79+
"ipaEAPCertProfile": {
80+
"type": "string",
81+
"description": "Dogtag certificate profile for the FreeRADIUS EAP-TLS server cert (wireless CA-issued; verified by iOS devices via mobileconfig anchor).",
7782
"default": "pint_radsec_server"
7883
},
7984
"ipaSkipTLSVerify": {

cmd/pint/main.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,19 @@ func main() {
149149
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCACertDER})...,
150150
)
151151

152-
// Load or renew FreeRADIUS server TLS cert
152+
// Load or renew FreeRADIUS outer RadSec TLS cert (RadSec CA-issued; verified by routers)
153153
if _, _, _, err := loadOrRenewRadSecServerCert(context.Background(), log, k8sClient, ipaClient, cfg, []byte(radSecCAChainPEM), wifiCAPEM); err != nil {
154154
log.Fatal("radsec server cert failed", zap.Error(err))
155155
}
156156

157-
// Background watcher: renew cert before expiry and reload FreeRADIUS
157+
// Load or renew FreeRADIUS EAP-TLS server cert (wireless CA-issued; verified by iOS devices)
158+
if err := loadOrRenewEAPServerCert(context.Background(), log, k8sClient, ipaClient, cfg); err != nil {
159+
log.Fatal("eap server cert failed", zap.Error(err))
160+
}
161+
162+
// Background watchers: renew certs before expiry and reload FreeRADIUS
158163
go watchRadSecServerCert(log, k8sClient, ipaClient, cfg, []byte(radSecCAChainPEM), wifiCAPEM)
164+
go watchEAPServerCert(log, k8sClient, ipaClient, cfg)
159165

160166
// Load or generate the SCEP RA cert (self-signed RSA, stored in K8s secret).
161167
scepRACert, scepRAKey, scepRACertDER, err := loadOrGenerateSCEPRACert(context.Background(), log, k8sClient, cfg)
@@ -371,6 +377,62 @@ func watchRadSecServerCert(log *zap.Logger, k8sClient kubernetes.Interface, ipaC
371377
}
372378
}
373379

380+
// loadOrRenewEAPServerCert reads the EAP-TLS server cert (eap.crt/eap.key) from the RadSec
381+
// cert secret. If the cert is missing or within radSecRenewBefore of expiry, a new one is
382+
// issued from the wireless CA so that iOS devices can verify it via their mobileconfig anchor.
383+
func loadOrRenewEAPServerCert(ctx context.Context, log *zap.Logger, k8sClient kubernetes.Interface, ipaClient *freeipa.Client, cfg *config.Config) error {
384+
secret, err := k8sClient.CoreV1().Secrets(cfg.Namespace).Get(ctx, cfg.RadSecCertSecret, metav1.GetOptions{})
385+
if err == nil {
386+
existing := secret.Data["eap.crt"]
387+
key := secret.Data["eap.key"]
388+
if len(existing) > 0 && len(key) > 0 {
389+
block, _ := pem.Decode(existing)
390+
if block != nil {
391+
cert, parseErr := x509.ParseCertificate(block.Bytes)
392+
if parseErr == nil && time.Until(cert.NotAfter) > radSecRenewBefore {
393+
log.Info("reusing existing EAP server cert", zap.String("expires", cert.NotAfter.Format(time.RFC3339)))
394+
return nil
395+
}
396+
}
397+
}
398+
}
399+
400+
privKey, csrPEM, genErr := profile.GenerateKeyAndCSR(cfg.IPAServiceHostname)
401+
if genErr != nil {
402+
return fmt.Errorf("generate eap key/csr: %w", genErr)
403+
}
404+
405+
certDER, certErr := ipaClient.CertRequest(cfg.IPAPrincipal, string(csrPEM), cfg.IPAWirelessCAName, cfg.EAPCertProfile)
406+
if certErr != nil {
407+
return fmt.Errorf("cert_request eap: %w", certErr)
408+
}
409+
410+
newCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
411+
ecKeyBytes, err := x509.MarshalECPrivateKey(privKey)
412+
if err != nil {
413+
return fmt.Errorf("marshal eap ec key: %w", err)
414+
}
415+
newKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecKeyBytes})
416+
417+
if writeErr := radius.WriteEAPServerCert(ctx, k8sClient, cfg.Namespace, cfg.RadSecCertSecret, cfg.FreeRADIUSDeployment, newCertPEM, newKeyPEM); writeErr != nil {
418+
return fmt.Errorf("write eap cert: %w", writeErr)
419+
}
420+
log.Info("issued and stored new EAP server cert")
421+
return nil
422+
}
423+
424+
// watchEAPServerCert runs forever, checking every 24 hours whether the EAP server cert
425+
// needs renewal. On renewal it reloads FreeRADIUS so the new cert is picked up.
426+
func watchEAPServerCert(log *zap.Logger, k8sClient kubernetes.Interface, ipaClient *freeipa.Client, cfg *config.Config) {
427+
ticker := time.NewTicker(24 * time.Hour)
428+
defer ticker.Stop()
429+
for range ticker.C {
430+
if err := loadOrRenewEAPServerCert(context.Background(), log, k8sClient, ipaClient, cfg); err != nil {
431+
log.Error("eap cert renewal failed", zap.Error(err))
432+
}
433+
}
434+
}
435+
374436
const profileSigningRenewBefore = 30 * 24 * time.Hour
375437

376438
// loadOrRenewProfileSigningCert loads the profile signing cert from the K8s Secret when

dev/freeradius/eap

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# EAP-TLS module — used for WiFi user certificate authentication.
2-
# The server cert is shared with the RadSec listener (same FreeIPA-issued cert).
3-
# wifi-ca.pem is the WiFi intermediate CA; FreeRADIUS uses it to verify that
4-
# connecting users' EAP-TLS client certs were issued by PINT.
2+
# eap.crt is a separate cert issued by the wireless CA (not the RadSec CA). iOS devices
3+
# verify this cert using the wireless CA anchor embedded in their mobileconfig profile.
4+
# The outer RadSec TLS connection (tls.crt) uses a RadSec CA-issued cert verified by routers.
5+
# wifi-ca.pem is the WiFi CA chain; FreeRADIUS uses it to verify EAP-TLS client certs.
56
eap {
67
default_eap_type = tls
78
timer_expire = 60
89

910
tls-config tls-csh {
10-
private_key_file = /etc/pint/radsec/tls.key
11-
certificate_file = /etc/pint/radsec/tls.crt
11+
private_key_file = /etc/pint/radsec/eap.key
12+
certificate_file = /etc/pint/radsec/eap.crt
1213
ca_file = /etc/pint/radsec/wifi-ca.pem
1314
tls_min_version = "1.2"
1415
cipher_list = "ECDHE+AESGCM:DHE+AESGCM"

internal/config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ type Config struct {
2323
RootCAName string // PINT_IPA_ROOT_CA_NAME:signing root CA; defaults to "ipa"
2424
IPACertProfile string // PINT_IPA_CERT_PROFILE:FreeIPA profile for WiFi client certs (default: pint_wifi)
2525
RadSecClientCertProfile string // PINT_IPA_RADSEC_CLIENT_CERT_PROFILE:FreeIPA profile for RadSec router client certs (default: pint_radsec_client)
26-
RadSecServerCertProfile string // PINT_IPA_RADSEC_SERVER_CERT_PROFILE:FreeIPA profile for FreeRADIUS server cert (default: pint_radsec_server)
26+
RadSecServerCertProfile string // PINT_IPA_RADSEC_SERVER_CERT_PROFILE:FreeIPA profile for FreeRADIUS outer RadSec TLS cert (default: pint_radsec_server)
27+
EAPCertProfile string // PINT_IPA_EAP_CERT_PROFILE:FreeIPA profile for FreeRADIUS EAP-TLS server cert, issued by the wireless CA (default: pint_radsec_server)
2728
IPAPrincipal string // derived: full principal, e.g. pint/host@REALM
2829
IPAServiceHostname string // derived: hostname portion of principal, e.g. host
2930
IPASkipTLSVerify bool
@@ -114,6 +115,7 @@ func Load() (*Config, error) {
114115
cfg.IPACertProfile = optional("PINT_IPA_CERT_PROFILE", "pint_wifi")
115116
cfg.RadSecClientCertProfile = optional("PINT_IPA_RADSEC_CLIENT_CERT_PROFILE", "pint_radsec_client")
116117
cfg.RadSecServerCertProfile = optional("PINT_IPA_RADSEC_SERVER_CERT_PROFILE", "pint_radsec_server")
118+
cfg.EAPCertProfile = optional("PINT_IPA_EAP_CERT_PROFILE", "pint_radsec_server")
117119
cfg.CodeSigningCAName = require("PINT_IPA_CODE_SIGNING_CA_NAME")
118120
cfg.CodeSigningCertProfile = optional("PINT_IPA_CODE_SIGNING_CERT_PROFILE", "pint_profile_signing")
119121
cfg.ProfileSigningCertSecret = optional("PINT_PROFILE_SIGNING_CERT_SECRET", "pint-profile-signing-cert")

internal/profile/mobileconfig.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import (
1313
type MobileconfigParams struct {
1414
SSID string
1515
RadiusHost string // hostname only, no port
16-
CACertDER []byte // wireless intermediate CA — anchors EAP-TLS server verification
16+
CACertDER []byte // wireless intermediate CA — anchors EAP-TLS server verification.
17+
// FreeRADIUS presents a cert signed by the wireless CA for EAP-TLS (eap.crt in the
18+
// radsec secret), so this CA is the correct anchor. The outer RadSec TLS cert (tls.crt)
19+
// is signed by the RadSec CA and is verified by routers, not end devices.
20+
// See: dev/freeradius/eap — certificate_file = /etc/pint/radsec/eap.crt.
1721
RootCACertDER []byte // root CA — always embed for full chain trust
1822
CodeSigningCACertDER []byte // code-signing intermediate CA — embed when profile signing is enabled
1923
Username string
@@ -72,7 +76,7 @@ func BuildMobileconfig(p MobileconfigParams) ([]byte, error) {
7276
"PayloadCertificateUUID": scepUUID,
7377
}
7478

75-
// Wireless intermediate CA — anchors EAP-TLS server verification.
79+
// Wireless intermediate CA — anchors EAP-TLS server verification and embedded for chain reference.
7680
caPayload := map[string]interface{}{
7781
"PayloadType": "com.apple.security.root",
7882
"PayloadUUID": caUUID,

internal/radius/reload.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,24 @@ func WriteRadSecTLS(ctx context.Context, k8s kubernetes.Interface, namespace, se
4646
return Reload(ctx, k8s, namespace, deployment)
4747
}
4848

49+
// WriteEAPServerCert writes the FreeRADIUS EAP-TLS server cert and key to the named K8s
50+
// Secret (eap.crt / eap.key) and triggers a FreeRADIUS rollout restart.
51+
// The EAP cert is issued by the wireless CA so that iOS devices can verify it using the
52+
// CA anchor embedded in their mobileconfig profile. This is separate from tls.crt / tls.key,
53+
// which is the outer RadSec TLS cert issued by the RadSec CA and verified by routers.
54+
func WriteEAPServerCert(ctx context.Context, k8s kubernetes.Interface, namespace, secretName, deployment string, certPEM, keyPEM []byte) error {
55+
if err := patchSecretKey(ctx, k8s, namespace, secretName, "eap.crt", certPEM); err != nil {
56+
return err
57+
}
58+
if err := patchSecretKey(ctx, k8s, namespace, secretName, "eap.key", keyPEM); err != nil {
59+
return err
60+
}
61+
return Reload(ctx, k8s, namespace, deployment)
62+
}
63+
4964
// WriteRadSecServerCert writes all FreeRADIUS TLS material to the named K8s Secret
5065
// and triggers a FreeRADIUS rollout restart:
51-
// - tls.crt / tls.key: server cert presented to RadSec clients and EAP supplicants
66+
// - tls.crt / tls.key: outer RadSec TLS cert presented to router clients (RadSec CA-issued)
5267
// - ca.pem: RadSec CA chain; verifies connecting router client certificates
5368
// - wifi-ca.pem: WiFi CA chain; verifies EAP-TLS user client certificates
5469
func WriteRadSecServerCert(ctx context.Context, k8s kubernetes.Interface, namespace, secretName, deployment string, certPEM, keyPEM, caPEM, wifiCAPEM []byte) error {

0 commit comments

Comments
 (0)