diff --git a/internal/vowifi/ike/eap.go b/internal/vowifi/ike/eap.go index 63fa00b..b1c037f 100644 --- a/internal/vowifi/ike/eap.go +++ b/internal/vowifi/ike/eap.go @@ -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 } diff --git a/internal/vowifi/ike/eap_test.go b/internal/vowifi/ike/eap_test.go index 781f7fd..507f1aa 100644 --- a/internal/vowifi/ike/eap_test.go +++ b/internal/vowifi/ike/eap_test.go @@ -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), diff --git a/internal/vowifi/ike/provider.go b/internal/vowifi/ike/provider.go index 03a77a7..c192617 100644 --- a/internal/vowifi/ike/provider.go +++ b/internal/vowifi/ike/provider.go @@ -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 { diff --git a/internal/vowifi/ike/provider_test.go b/internal/vowifi/ike/provider_test.go index 224118a..f04b8c9 100644 --- a/internal/vowifi/ike/provider_test.go +++ b/internal/vowifi/ike/provider_test.go @@ -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) diff --git a/internal/vowifi/ike/wire_auth_test.go b/internal/vowifi/ike/wire_auth_test.go index d07b4bb..b2abec5 100644 --- a/internal/vowifi/ike/wire_auth_test.go +++ b/internal/vowifi/ike/wire_auth_test.go @@ -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, diff --git a/web/src/components/devices/OverviewVowifiCard.tsx b/web/src/components/devices/OverviewVowifiCard.tsx index 0efed17..2c2659e 100644 --- a/web/src/components/devices/OverviewVowifiCard.tsx +++ b/web/src/components/devices/OverviewVowifiCard.tsx @@ -94,7 +94,7 @@ export function OverviewVowifiCard({ device }: { device: DeviceDetail }) {