fix: support O2 Germany certificate-authenticated EAP

This commit is contained in:
MengMengCode
2026-08-13 21:28:38 +08:00
parent 69b1c3eec6
commit 2917378da0
7 changed files with 181 additions and 38 deletions
+11 -3
View File
@@ -285,6 +285,7 @@ type akaClient struct {
simIdentity vowifi.SIMIdentity
provider vowifi.AKAProvider
keys akaKeys
lastResponseStage string
challengeComplete bool
resultIndication bool
protectedSuccess bool
@@ -298,7 +299,12 @@ func newAKAClient(identity vowifi.SIMIdentity, provider vowifi.AKAProvider) (*ak
if err != nil {
return nil, err
}
return &akaClient{identity: nai, simIdentity: identity, provider: provider}, nil
return &akaClient{
identity: nai,
simIdentity: identity,
provider: provider,
lastResponseStage: "in the initial IKE_AUTH identity exchange",
}, nil
}
func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction, error) {
@@ -308,9 +314,9 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
}
switch packet.Code {
case eapFailure:
stage := "before the SIM AKA challenge (identity or subscription rejected)"
stage := client.lastResponseStage
if client.challengeComplete {
stage = "after the SIM AKA response (AKA result or subscription rejected)"
stage = "after the SIM AKA challenge response"
}
return eapAction{}, fmt.Errorf("%w %s", vowifi.ErrEAPAuthenticationRejected, stage)
case eapSuccess:
@@ -333,6 +339,7 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
Type: eapTypeIdentity,
Data: client.identity,
})
client.lastResponseStage = "after EAP-Response/Identity"
return eapAction{Response: response}, err
case eapTypeAKA:
return client.handleAKARequest(ctx, packet)
@@ -405,6 +412,7 @@ func (client *akaClient) respondAKAIdentity(identifier uint8, attributes []akaAt
Type: eapTypeAKA,
Data: data,
})
client.lastResponseStage = "after EAP-Response/AKA-Identity"
return eapAction{Response: response}, err
}
+20 -2
View File
@@ -106,16 +106,34 @@ func TestEAPFailureReportsAuthenticationStage(t *testing.T) {
t.Fatal(err)
}
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "before the SIM AKA challenge") {
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "initial IKE_AUTH identity exchange") {
t.Fatalf("pre-challenge failure = %v", err)
}
client.challengeComplete = true
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA response") {
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA challenge response") {
t.Fatalf("post-challenge failure = %v", err)
}
}
func TestEAPFailureReportsIdentityResponseStage(t *testing.T) {
client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{})
if err != nil {
t.Fatal(err)
}
identityRequest, _ := marshalEAPPacket(eapPacket{
Code: eapRequest, Identifier: 4, Type: eapTypeIdentity,
})
if _, err := client.handle(context.Background(), identityRequest); err != nil {
t.Fatal(err)
}
failure, _ := marshalEAPPacket(eapPacket{Code: eapFailure, Identifier: 5})
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after EAP-Response/Identity") {
t.Fatalf("identity-stage failure = %v", err)
}
}
func TestEAPAKAChallengeTypedSIMAndMAC(t *testing.T) {
result := vowifi.AKAResult{
RES: bytes.Repeat([]byte{0x91}, 8),
+36 -13
View File
@@ -111,6 +111,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
group := uint16(dhMODP2048)
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
eapOnly := eapOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
if legacyFirst {
group = dhMODP1024
}
@@ -259,7 +260,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
requestedIDr := payload{Type: payloadIDr, Body: append([]byte{2, 0, 0, 0}, []byte(provider.config.APN)...)}
tsi := dualStackTrafficSelectors(payloadTSi)
tsr := dualStackTrafficSelectors(payloadTSr)
firstAuthPayloads := buildInitialEAPOnlyAuth(idi, requestedIDr, childOfferBody, tsi, tsr)
firstAuthPayloads := buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, eapOnly)
authHeader := ikeHeader{
InitiatorSPI: initiatorSPI,
ResponderSPI: responseHeader.ResponderSPI,
@@ -296,7 +297,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
serverName,
provider.config.RootCAs,
provider.config.ResponderPublicKey,
true, // RFC 5998 EAP-only authentication defers responder AUTH.
eapOnly, // RFC 5998 EAP-only authentication defers responder AUTH.
)
if err != nil {
return nil, err
@@ -383,10 +384,10 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
}
finalAUTHs := payloadsOfType(finalPayloads, payloadAuth)
if len(finalAUTHs) != 1 {
return nil, fmt.Errorf("%w: final EAP-only response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
return nil, fmt.Errorf("%w: final EAP response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired)
}
if len(responderID.Body) == 0 {
return nil, errors.New("ike: EAP-only exchange has no initial ePDG IDr for the responder AUTH transcript")
return nil, errors.New("ike: EAP exchange has no responder IDr for the AUTH transcript")
}
finalIDs := payloadsOfType(finalPayloads, payloadIDr)
if len(finalIDs) > 1 {
@@ -553,6 +554,36 @@ func legacyIKEProfile(mcc, mnc string) bool {
return plmn == "23415" || plmn == "2044"
}
func eapOnlyAuthentication(mcc, mnc string) bool {
// Android exposes the ePDG authentication method as carrier policy rather
// than unconditionally requesting RFC 5998 EAP-only authentication. O2
// Germany's 262-03 ePDG rejects an EAP-only initial IKE_AUTH before it sends
// an EAP-AKA identity or challenge. Use the certificate-authenticated EAP
// flow for that PLMN while preserving the established behavior elsewhere.
plmn := strings.TrimSpace(mcc) + strings.TrimLeft(strings.TrimSpace(mnc), "0")
return plmn != "2623"
}
func buildInitialEAPAuth(
idi payload,
requestedIDr payload,
childOfferBody []byte,
tsi payload,
tsr payload,
eapOnly bool,
) []payload {
payloads := []payload{idi, requestedIDr}
if eapOnly {
payloads = append(payloads, makeNotify(notifyEAPOnlyAuth, nil))
}
return append(payloads,
payload{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
tsi,
tsr,
configurationRequest(),
)
}
func buildInitialEAPOnlyAuth(
idi payload,
requestedIDr payload,
@@ -560,15 +591,7 @@ func buildInitialEAPOnlyAuth(
tsi payload,
tsr payload,
) []payload {
return []payload{
idi,
requestedIDr,
makeNotify(notifyEAPOnlyAuth, nil),
{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
tsi,
tsr,
configurationRequest(),
}
return buildInitialEAPAuth(idi, requestedIDr, childOfferBody, tsi, tsr, true)
}
func ikeOffer(group uint16, legacyFirst bool) proposal {
+75 -17
View File
@@ -42,15 +42,17 @@ func (reader constantReader) Read(destination []byte) (int, error) {
}
type firstAuthCaptureTransport struct {
t *testing.T
calls int
suite negotiatedSuite
keys ikeKeys
spii [8]byte
spir [8]byte
nonceI []byte
nonceR []byte
floated bool
t *testing.T
wantEAPOnly bool
wantGroup uint16
calls int
suite negotiatedSuite
keys ikeKeys
spii [8]byte
spir [8]byte
nonceI []byte
nonceR []byte
floated bool
}
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
@@ -96,8 +98,16 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
return nil, err
}
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
if group != dhMODP1024 || len(ke.Body[4:]) != 128 {
transport.t.Fatalf("Vodafone init KE = group %d length %d", group, len(ke.Body[4:]))
wantGroup := transport.wantGroup
if wantGroup == 0 {
wantGroup = dhMODP1024
}
wantKELength := 128
if wantGroup == dhMODP2048 {
wantKELength = 256
}
if group != wantGroup || len(ke.Body[4:]) != wantKELength {
transport.t.Fatalf("init KE = group %d length %d, want group %d length %d", group, len(ke.Body[4:]), wantGroup, wantKELength)
}
serverDH, err := newDHExchange(group, constantReader{value: 0x77})
if err != nil {
@@ -108,6 +118,15 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
return nil, err
}
transport.suite = legacyTestSuite()
if group == dhMODP2048 {
transport.suite = negotiatedSuite{
EncryptionID: encryptionAESCBC,
EncryptionBits: 128,
PRFID: prfHMACSHA256,
IntegrityID: integrityHMACSHA256_128,
DHID: dhMODP2048,
}
}
transport.spii = header.InitiatorSPI
transport.spir = [8]byte{0x80, 1, 2, 3, 4, 5, 6, 7}
transport.nonceI = append([]byte(nil), nonce.Body...)
@@ -128,9 +147,9 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
Protocol: protocolIKE,
Transforms: []transform{
{Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128},
{Type: transformPRF, ID: prfHMACSHA1},
{Type: transformIntegrity, ID: integrityHMACSHA1_96},
{Type: transformDH, ID: dhMODP1024},
{Type: transformPRF, ID: transport.suite.PRFID},
{Type: transformIntegrity, ID: transport.suite.IntegrityID},
{Type: transformDH, ID: group},
},
}})
keBody := make([]byte, 4+len(serverDH.Public))
@@ -180,8 +199,8 @@ func (transport *firstAuthCaptureTransport) observeFirstAuth(packet []byte) erro
foundEAPOnly = true
}
}
if !foundEAPOnly {
transport.t.Fatal("first IKE_AUTH omitted EAP_ONLY_AUTHENTICATION")
if foundEAPOnly != transport.wantEAPOnly {
transport.t.Fatalf("first IKE_AUTH EAP_ONLY_AUTHENTICATION present=%v, want %v", foundEAPOnly, transport.wantEAPOnly)
}
for _, kind := range []uint8{payloadIDi, payloadSA, payloadTSi, payloadTSr, payloadCP} {
if _, err := onePayload(payloads, kind); err != nil {
@@ -212,7 +231,7 @@ func (unusedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, e
}
func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t}
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: true}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
@@ -250,5 +269,44 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
}
}
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
Installer: unusedInstaller{},
APN: "ims",
})
if err != nil {
t.Fatal(err)
}
provider.transportFactory = func(
context.Context,
transportConfig,
vowifi.ProxyRoute,
string,
) (datagramTransport, error) {
return capture, nil
}
aka := &testAKAProvider{}
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
DeviceID: "ec20-o2",
Identity: vowifi.SIMIdentity{
ICCID: "8949200000000000000",
IMSI: "262030123456789",
HomeMCC: "262",
HomeMNC: "03",
},
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
AKA: aka,
})
if !errors.Is(err, errFirstAuthObserved) {
t.Fatalf("Start() error = %v, want capture sentinel", err)
}
if capture.calls != 2 || capture.floated || aka.calls != 0 {
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
}
}
var _ io.Reader = constantReader{}
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
+36
View File
@@ -88,6 +88,42 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
}
}
func TestInitialStandardEAPAuthOmitsEAPOnlyNotify(t *testing.T) {
idi := payload{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u'}}
idr := payload{Type: payloadIDr, Body: []byte{2, 0, 0, 0, 'i', 'm', 's'}}
payloads := buildInitialEAPAuth(
idi,
idr,
[]byte{1, 2, 3},
dualStackTrafficSelectors(payloadTSi),
dualStackTrafficSelectors(payloadTSr),
false,
)
if len(payloads) != 6 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
t.Fatalf("initial standard EAP payload order = %#v", payloads)
}
for _, item := range payloadsOfType(payloads, payloadNotify) {
kind, _, err := parseNotify(item)
if err != nil {
t.Fatal(err)
}
if kind == notifyEAPOnlyAuth {
t.Fatal("standard EAP initial request contains EAP_ONLY_AUTHENTICATION")
}
}
}
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
for _, mnc := range []string{"03", "003"} {
if eapOnlyAuthentication("262", mnc) {
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
}
}
if !eapOnlyAuthentication("262", "02") || !eapOnlyAuthentication("234", "15") {
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
}
}
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
epdg := payload{
Type: payloadIDr,
@@ -94,7 +94,7 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {
<div className="space-y-1.5 border-t border-gray-100 px-3 pb-2 pt-2 text-sm text-gray-700 dark:border-white/5 dark:text-gray-200">
{eapRejected ? (
<div className="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-200">
{t("已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。")}
{t("已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。")}
</div>
) : null}
<FieldRow label={t("数据平面")} value={rt?.dataplaneMode || "--"} monospace />
+2 -2
View File
@@ -754,8 +754,8 @@ export const EN_DICT: Record<string, string> = {
"扫描中...": "Scanning...",
"扫描可用网络": "Scan Available Networks",
"不可注册": "Unavailable",
"已连接到运营商 ePDG,但运营商拒绝了此 SIM 的 EAP-AKA 鉴权。通常表示该 Profile 未开通 IMS/WiFi Calling;重复重连不会解决,需要换用支持 VoWiFi 的运营商 Profile。":
"The carrier ePDG was reached, but it rejected EAP-AKA authentication for this SIM. The profile usually has no IMS/Wi-Fi Calling entitlement; reconnecting will not fix it, so use a carrier profile that supports VoWiFi.",
"已连接到运营商 ePDG,但 EAP-AKA 流程被拒绝。可能是初始身份、运营商 IKE/EAP 兼容性或订阅策略问题;请根据错误详情确认失败阶段。":
"The carrier ePDG was reached, but the EAP-AKA flow was rejected. This can be caused by the initial identity, carrier IKE/EAP interoperability, or subscription policy; check the error details for the failing stage.",
"扫描结果只代表模组在当前位置实际收到的运营商信号,不代表模组支持的全部运营商;禁用网络表示当前 SIM 不允许注册。":
"Scan results show only networks the modem can currently receive, not every operator the hardware supports. A forbidden network cannot be used by the current SIM.",
"扫描网络失败": "Network scan failed",