mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-14 20:03:43 +08:00
fix: support O2 Germany certificate-authenticated EAP
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user