From 636d4e8a693dd447d5888e5662032ccc0fa74d17 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:15:34 +0800 Subject: [PATCH] feat: add XeSIM CTE and RedPocket VoWiFi compatibility --- internal/vowifi/carrier_compat.go | 60 ++++++++++ internal/vowifi/carrier_compat_test.go | 56 ++++++++++ internal/vowifi/ec20_adapter.go | 145 +++++++++++++++++++++++-- internal/vowifi/ec20_adapter_test.go | 94 ++++++++++++++++ internal/vowifi/ims/digest.go | 14 ++- internal/vowifi/ims/digest_test.go | 37 +++++++ internal/vowifi/ims/provider.go | 61 ++++++++++- internal/vowifi/ims/provider_test.go | 69 ++++++++++++ internal/vowifi/ims/security.go | 15 +++ internal/vowifi/orchestrator.go | 13 +-- internal/vowifi/pcsc_adapter.go | 6 +- internal/vowifi/phone_test.go | 10 ++ internal/vowifi/types.go | 8 ++ 13 files changed, 559 insertions(+), 29 deletions(-) create mode 100644 internal/vowifi/carrier_compat.go create mode 100644 internal/vowifi/carrier_compat_test.go diff --git a/internal/vowifi/carrier_compat.go b/internal/vowifi/carrier_compat.go new file mode 100644 index 0000000..8283389 --- /dev/null +++ b/internal/vowifi/carrier_compat.go @@ -0,0 +1,60 @@ +package vowifi + +import ( + "fmt" + "strings" +) + +const att310280EPDG = "epdg.epc.att.net" + +// AssignedRoutePLMN returns a narrowly matched ePDG route PLMN without +// changing the subscription PLMN used for AKA identities. Some multi-profile +// and MVNO SIMs authenticate against their own HPLMN but use a host network's +// VoWiFi access gateway. +func AssignedRoutePLMN(iccid, imsi string) (string, string, bool) { + iccid = strings.TrimSpace(iccid) + imsi = strings.TrimSpace(imsi) + switch { + case strings.HasPrefix(iccid, "894416") && strings.HasPrefix(imsi, "204047"): + // XeSIM/Lebara: keep 204/04 for AKA and use Vodafone UK's ePDG. + return "234", "15", true + case strings.HasPrefix(iccid, "894430") && strings.HasPrefix(imsi, "23433"): + // CTExcel UK: keep 234/33 for AKA and use the EE UK ePDG used by + // the initial VoWiFi provisioning path. + return "234", "30", true + default: + return "", "", false + } +} + +// IsATT310280 reports whether the live subscription is on AT&T's three-digit +// 310/280 PLMN. It is shared by SWu and IMS so the carrier exception cannot +// drift between protocol layers. +func IsATT310280(identity SIMIdentity) bool { + mcc := strings.TrimSpace(identity.HomeMCC) + mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0") + imsi := strings.TrimSpace(identity.IMSI) + return mcc == "310" && mnc == "280" && strings.HasPrefix(imsi, "310280") +} + +func applyAssignedCarrierRoute(identity SIMIdentity) SIMIdentity { + if strings.TrimSpace(identity.EPDG) != "" { + return identity + } + if routeMCC, routeMNC, ok := AssignedRoutePLMN(identity.ICCID, identity.IMSI); ok { + identity.EPDG = standardEPDGHostname(routeMCC, routeMNC) + } + return identity +} + +func standardEPDGHostname(mcc, mnc string) string { + mnc = strings.TrimSpace(mnc) + for len(mnc) < 3 { + mnc = "0" + mnc + } + return fmt.Sprintf( + "epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org", + mnc, + strings.TrimSpace(mcc), + ) +} diff --git a/internal/vowifi/carrier_compat_test.go b/internal/vowifi/carrier_compat_test.go new file mode 100644 index 0000000..51ee4c5 --- /dev/null +++ b/internal/vowifi/carrier_compat_test.go @@ -0,0 +1,56 @@ +package vowifi + +import "testing" + +func TestAssignedRoutePLMNUsesNarrowCardAndSubscriptionMatches(t *testing.T) { + tests := []struct { + name string + iccid string + imsi string + wantMCC string + wantMNC string + wantAssigned bool + }{ + {name: "XeSIM Lebara route", iccid: "89441600001001576265", imsi: "204047666157626", wantMCC: "234", wantMNC: "15", wantAssigned: true}, + {name: "CTExcel initial route", iccid: "8944303773524055208", imsi: "234336570712415", wantMCC: "234", wantMNC: "30", wantAssigned: true}, + {name: "XeSIM ICCID without matching subscription", iccid: "89441600001001576265", imsi: "204041666157626"}, + {name: "similar ICCID must not match", iccid: "89441000001001576265", imsi: "204047666157626"}, + {name: "generic EE SIM must not match CTExcel", iccid: "8944110000000000000", imsi: "234336570712415"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mcc, mnc, assigned := AssignedRoutePLMN(test.iccid, test.imsi) + if mcc != test.wantMCC || mnc != test.wantMNC || assigned != test.wantAssigned { + t.Fatalf("AssignedRoutePLMN() = %q/%q,%v, want %q/%q,%v", mcc, mnc, assigned, test.wantMCC, test.wantMNC, test.wantAssigned) + } + }) + } +} + +func TestApplyAssignedCarrierRoutePreservesAuthenticationPLMN(t *testing.T) { + identity := applyAssignedCarrierRoute(SIMIdentity{ + ICCID: "8944303773524055208", IMSI: "234336570712415", + HomeMCC: "234", HomeMNC: "33", + }) + if identity.HomeMCC != "234" || identity.HomeMNC != "33" { + t.Fatalf("authentication PLMN = %s/%s, want 234/33", identity.HomeMCC, identity.HomeMNC) + } + if identity.EPDG != "epdg.epc.mnc030.mcc234.pub.3gppnetwork.org" { + t.Fatalf("route ePDG = %q", identity.EPDG) + } +} + +func TestIsATT310280RequiresMatchingPLMNAndIMSI(t *testing.T) { + if !IsATT310280(SIMIdentity{IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280"}) { + t.Fatal("AT&T 310/280 identity was not recognized") + } + for _, identity := range []SIMIdentity{ + {IMSI: "310410229187733", HomeMCC: "310", HomeMNC: "280"}, + {IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "28"}, + {IMSI: "310280229187733", HomeMCC: "311", HomeMNC: "280"}, + } { + if IsATT310280(identity) { + t.Fatalf("unrelated identity matched AT&T 310/280: %#v", identity) + } + } +} diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go index 19ce5ea..8614dee 100644 --- a/internal/vowifi/ec20_adapter.go +++ b/internal/vowifi/ec20_adapter.go @@ -97,9 +97,10 @@ type ec20RadioCheckpoint struct { } var ( - _ SIMIdentityReader = (*EC20Adapter)(nil) - _ AKAProvider = (*EC20Adapter)(nil) - _ RadioController = (*EC20Adapter)(nil) + _ SIMIdentityReader = (*EC20Adapter)(nil) + _ AKAProvider = (*EC20Adapter)(nil) + _ PreferredAKAProvider = (*EC20Adapter)(nil) + _ RadioController = (*EC20Adapter)(nil) ) func NewEC20Adapter( @@ -172,6 +173,7 @@ func (adapter *EC20Adapter) ReadIdentity( HomeMCC: homeMCC, HomeMNC: homeMNC, } + identity = applyAssignedCarrierRoute(identity) adapter.mu.Lock() adapter.bindings[iccid] = ec20SIMBinding{ deviceID: deviceID, @@ -208,6 +210,11 @@ func (adapter *EC20Adapter) readHomePLMN( iccid string, imsi string, ) (string, string, error) { + // AT&T 310/280 is a three-digit MNC. Prefer the assigned subscription + // prefix when EF_AD is stale or ambiguous after a profile switch. + if strings.HasPrefix(strings.TrimSpace(imsi), "310280") { + return "310", "280", nil + } mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID) if efErr == nil { if len(imsi) < 3+mncLength { @@ -238,9 +245,10 @@ func assignedHomePLMN(imsi string) (mcc, mnc string, ok bool) { prefix string mncLength int }{ - {prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions. - {prefix: "23415", mncLength: 2}, // Vodafone UK. - {prefix: "23487", mncLength: 2}, // Lebara Mobile UK. + {prefix: "20404", mncLength: 2}, // Vodafone NL core; some Lebara subscriptions. + {prefix: "23415", mncLength: 2}, // Vodafone UK. + {prefix: "23487", mncLength: 2}, // Lebara Mobile UK. + {prefix: "310280", mncLength: 3}, // AT&T / RedPocket GSMA. } for _, assignment := range assignments { if strings.HasPrefix(imsi, assignment.prefix) { @@ -391,11 +399,46 @@ func (adapter *EC20Adapter) Authenticate( ctx context.Context, identity SIMIdentity, challenge AKAChallenge, +) (AKAResult, error) { + return adapter.authenticateWithApplication(ctx, identity, challenge, "") +} + +func (adapter *EC20Adapter) AuthenticateWithPreference( + ctx context.Context, + identity SIMIdentity, + challenge AKAChallenge, + preference string, +) (AKAResult, error) { + return adapter.authenticateWithApplication(ctx, identity, challenge, preference) +} + +func (adapter *EC20Adapter) authenticateWithApplication( + ctx context.Context, + identity SIMIdentity, + challenge AKAChallenge, + preference string, ) (AKAResult, error) { binding, err := adapter.bindingFor(identity) if err != nil { return AKAResult{}, err } + if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" { + aid, application, err := adapter.discoverPreferredAKAApplication( + ctx, + binding.deviceID, + isimAIDPrefix, + "ISIM", + ) + if err != nil { + return AKAResult{}, err + } + binding.aid = aid + binding.application = application + binding.basicChannel = false + adapter.mu.Lock() + adapter.bindings[binding.iccid] = binding + adapter.mu.Unlock() + } if binding.aid == "" { if _, err := adapter.CheckReady(ctx, identity); err != nil { return AKAResult{}, err @@ -405,6 +448,14 @@ func (adapter *EC20Adapter) Authenticate( return AKAResult{}, err } } + if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") && binding.application != "ISIM" { + return AKAResult{}, fmt.Errorf( + "%w: ISIM strict requested, selected %s (%s)", + ErrEC20ApplicationAbsent, + binding.application, + binding.aid, + ) + } if err := adapter.verifyLiveICCID(ctx, binding); err != nil { return AKAResult{}, err } @@ -907,6 +958,28 @@ func (adapter *EC20Adapter) discoverAKAApplication( return usimAIDPrefix, "USIM", nil } +func (adapter *EC20Adapter) discoverPreferredAKAApplication( + ctx context.Context, + deviceID string, + aidPrefix string, + application string, +) (string, string, error) { + response, err := adapter.execute(ctx, deviceID, "AT+CUAD") + if err == nil { + data, parseErr := parseCUADData(response) + if parseErr == nil { + for _, candidate := range collectApplicationAIDs(data) { + if strings.HasPrefix(candidate, aidPrefix) { + return candidate, application, nil + } + } + } + } + // AT+CUAD is optional. Returning the standard AID prefix still lets CCHO + // perform the authoritative application probe on older EC20 firmware. + return aidPrefix, application, nil +} + func (adapter *EC20Adapter) openLogicalChannel( ctx context.Context, deviceID string, @@ -1146,12 +1219,27 @@ func parseCRSMData(response modem.Response) ([]byte, error) { } func parseCUADData(response modem.Response) ([]byte, error) { - fields := parseCSV(valueAfterATPrefix(response, "+CUAD:")) - if len(fields) == 0 { + // EC20 firmware may split the BER-TLV stream across adjacent quoted chunks + // and continuation lines. Concatenating every hex fragment prevents an ISIM + // AID after a USIM entry from being silently discarded. + var encoded strings.Builder + collect := false + for _, line := range response.Lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToUpper(line), "+CUAD:") { + collect = true + line = strings.TrimSpace(line[len("+CUAD:"):]) + } else if !collect { + continue + } + for _, fragment := range quotedHexFragments(line) { + encoded.WriteString(fragment) + } + } + if encoded.Len() == 0 { return nil, errors.New("CUAD response has no data") } - value := fields[len(fields)-1] - data, err := hex.DecodeString(strings.Trim(value, `"`)) + data, err := hex.DecodeString(encoded.String()) if err != nil || len(data) == 0 { return nil, errors.New("CUAD response data is invalid") } @@ -1161,11 +1249,48 @@ func parseCUADData(response modem.Response) ([]byte, error) { return data, nil } +func quotedHexFragments(line string) []string { + var fragments []string + for { + start := strings.IndexByte(line, '"') + if start < 0 { + break + } + line = line[start+1:] + end := strings.IndexByte(line, '"') + if end < 0 { + break + } + fragment := strings.ToUpper(strings.TrimSpace(line[:end])) + line = line[end+1:] + if fragment == "" || len(fragment)%2 != 0 { + continue + } + valid := true + for _, character := range fragment { + if (character < '0' || character > '9') && (character < 'A' || character > 'F') { + valid = false + break + } + } + if valid { + fragments = append(fragments, fragment) + } + } + return fragments +} + func collectApplicationAIDs(data []byte) []string { var result []string var walk func([]byte) walk = func(value []byte) { for len(value) > 0 { + for len(value) > 0 && value[0] == 0xff { + value = value[1:] + } + if len(value) == 0 { + return + } tag, constructed, body, consumed, err := decodeBERTLV(value) if err != nil || consumed == 0 { return diff --git a/internal/vowifi/ec20_adapter_test.go b/internal/vowifi/ec20_adapter_test.go index 6d28cdf..9551e84 100644 --- a/internal/vowifi/ec20_adapter_test.go +++ b/internal/vowifi/ec20_adapter_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "reflect" "strings" "sync" "testing" @@ -421,6 +422,7 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) { "204040123456789": "204/04", "234150123456789": "234/15", "234870123456789": "234/87", + "310280229187733": "310/280", } for imsi, want := range tests { mcc, mnc, ok := assignedHomePLMN(imsi) @@ -430,6 +432,23 @@ func TestAssignedHomePLMNIncludesLebaraUKCores(t *testing.T) { } } +func TestEC20AdapterTreatsATT310280AsThreeDigitMNC(t *testing.T) { + t.Parallel() + transcript := &ec20Transcript{t: t, steps: identityTranscriptStepsWithoutEFAD("310280229187733")} + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + identity, err := adapter.ReadIdentity(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("ReadIdentity: %v", err) + } + if identity.HomeMCC != "310" || identity.HomeMNC != "280" { + t.Fatalf("home PLMN = %s/%s, want 310/280", identity.HomeMCC, identity.HomeMNC) + } + transcript.assertDone() +} + func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts( t *testing.T, ) { @@ -598,3 +617,78 @@ func synchronizationFailureUSIMResponse() []byte { raw = append(raw, auts...) return append(raw, 0x90, 0x00) } + +func TestCollectApplicationAIDsSkipsCUADPadding(t *testing.T) { + t.Parallel() + response := modem.Response{Lines: []string{ + `+CUAD: "61184F10A0000000871002FFFFFFFF890302000050045553494DFFFFFFFFFFFFFFFFFFFFFFFF""61184F10A0000000871004FFFFFFFF890302000050044953494DFFFFFFFFFFFFFFFFFFFFFFFF"`, + `"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"`, + }} + data, err := parseCUADData(response) + if err != nil { + t.Fatal(err) + } + aids := collectApplicationAIDs(data) + want := []string{ + "A0000000871002FFFFFFFF8903020000", + "A0000000871004FFFFFFFF8903020000", + } + if !reflect.DeepEqual(aids, want) { + t.Fatalf("AIDs = %v, want %v", aids, want) + } +} + +func TestEC20AdapterISIMStrictUsesCUADFullAID(t *testing.T) { + var challenge AKAChallenge + for index := range challenge.RAND { + challenge.RAND[index] = byte(index) + challenge.AUTN[index] = byte(0xf0 + index) + } + authAPDU := buildUSIMAuthenticateAPDU(challenge) + authCommand := fmt.Sprintf( + `AT+CGLA=1,%d,"%s"`, + len(authAPDU)*2, + strings.ToUpper(hex.EncodeToString(authAPDU)), + ) + encodedResponse := strings.ToUpper(hex.EncodeToString(successfulUSIMResponse())) + fullISIM := "A0000000871004FFFFFFFF8903020000" + cuad := `61184F10A0000000871002FFFFFFFF890302000050045553494D61184F10A0000000871004FFFFFFFF890302000050044953494D` + transcript := &ec20Transcript{ + t: t, + steps: []ec20TranscriptStep{ + {command: "AT+CPIN?", lines: []string{"+CPIN: READY"}}, + {command: "AT+CIMI", lines: []string{"310280229187733"}}, + {command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}}, + {command: "AT+CGSN", lines: []string{"863212060022487"}}, + {command: "AT+CUAD", lines: []string{`+CUAD: "` + cuad + `"`}}, + {command: "AT+CCID", lines: []string{"+CCID: 89012804332291663965"}}, + {command: `AT+CCHO="` + fullISIM + `"`, lines: []string{"+CCHO: 1"}}, + { + command: authCommand, + sensitive: true, + lines: []string{fmt.Sprintf( + `+CGLA: %d,"%s"`, + len(encodedResponse), + encodedResponse, + )}, + }, + {command: "AT+CCHC=1"}, + }, + } + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + identity, err := adapter.ReadIdentity(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("ReadIdentity: %v", err) + } + result, err := adapter.AuthenticateWithPreference(context.Background(), identity, challenge, "isim_strict") + if err != nil { + t.Fatalf("AuthenticateWithPreference: %v", err) + } + if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) { + t.Fatalf("RES = %x", result.RES) + } + transcript.assertDone() +} diff --git a/internal/vowifi/ims/digest.go b/internal/vowifi/ims/digest.go index e19f6f9..bdfa15c 100644 --- a/internal/vowifi/ims/digest.go +++ b/internal/vowifi/ims/digest.go @@ -168,6 +168,7 @@ func authenticateAKA( provider vowifi.AKAProvider, identity vowifi.SIMIdentity, challenge digestChallenge, + preference string, ) (akaMaterial, error) { nonce, err := decodeAKANonce(challenge.Nonce) if err != nil { @@ -178,9 +179,18 @@ func authenticateAKA( var akaChallenge vowifi.AKAChallenge copy(akaChallenge.RAND[:], nonce[:16]) copy(akaChallenge.AUTN[:], nonce[16:32]) - result, err := provider.Authenticate(ctx, identity, akaChallenge) + var result vowifi.AKAResult + if preferred, ok := provider.(vowifi.PreferredAKAProvider); ok && strings.TrimSpace(preference) != "" { + result, err = preferred.AuthenticateWithPreference(ctx, identity, akaChallenge, preference) + } else { + result, err = provider.Authenticate(ctx, identity, akaChallenge) + } if err != nil { - return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err) + application := "USIM" + if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") { + application = "ISIM" + } + return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err) } if result.SynchronizationFailure || len(result.AUTS) > 0 { if !result.SynchronizationFailure || len(result.AUTS) != 14 { diff --git a/internal/vowifi/ims/digest_test.go b/internal/vowifi/ims/digest_test.go index c1f953b..e80ced3 100644 --- a/internal/vowifi/ims/digest_test.go +++ b/internal/vowifi/ims/digest_test.go @@ -16,6 +16,21 @@ type recordingAKA struct { challenges []vowifi.AKAChallenge } +type recordingPreferredAKA struct { + recordingAKA + preference string +} + +func (aka *recordingPreferredAKA) AuthenticateWithPreference( + ctx context.Context, + identity vowifi.SIMIdentity, + challenge vowifi.AKAChallenge, + preference string, +) (vowifi.AKAResult, error) { + aka.preference = preference + return aka.Authenticate(ctx, identity, challenge) +} + func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) { return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil } @@ -60,6 +75,7 @@ func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) { aka, vowifi.SIMIdentity{IMSI: "001010123456789"}, digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)}, + "", ) if err != nil { t.Fatalf("authenticateAKA() error = %v", err) @@ -93,6 +109,7 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) { aka, vowifi.SIMIdentity{}, digestChallenge{Nonce: nonce}, + "", ) if err != nil { t.Fatalf("authenticateAKA() error = %v", err) @@ -102,6 +119,26 @@ func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) { } } +func TestAuthenticateAKAUsesPreferredApplicationWhenSupported(t *testing.T) { + nonce := base64.StdEncoding.EncodeToString(make([]byte, 32)) + aka := &recordingPreferredAKA{recordingAKA: recordingAKA{ + result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}, + }} + _, err := authenticateAKA( + context.Background(), + aka, + vowifi.SIMIdentity{IMSI: "310280229187733"}, + digestChallenge{Nonce: nonce}, + "isim_strict", + ) + if err != nil { + t.Fatalf("authenticateAKA() error = %v", err) + } + if aka.preference != "isim_strict" { + t.Fatalf("preference = %q, want isim_strict", aka.preference) + } +} + func TestBuildDigestAuthorizationCarriesAUTSWithEmptyResponse(t *testing.T) { authorization := buildDigestAuthorization( digestChallenge{ diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 44ce987..a8dffc5 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -271,13 +271,22 @@ func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet, mnc = "0" + mnc } domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc) + privateDomain := domain + publicDomain := domain + if vowifi.IsATT310280(identity) { + // AT&T provisions the IMPI and IMPU in its ISIM domains rather than + // the generic 3GPP PLMN IMS domain. + domain = "one.att.net" + privateDomain = "private.att.net" + publicDomain = "one.att.net" + } privateIdentity := config.PrivateIdentity if privateIdentity == "" { - privateIdentity = imsi + "@" + domain + privateIdentity = imsi + "@" + privateDomain } publicIdentity := config.PublicIdentity if publicIdentity == "" { - publicIdentity = "sip:" + imsi + "@" + domain + publicIdentity = "sip:" + imsi + "@" + publicDomain } if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") || !strings.Contains(privateIdentity, "@") || @@ -536,6 +545,9 @@ func newSession( } protectedClientPort := provider.config.ProtectedClientPort protectedServerPort := provider.config.ProtectedServerPort + if vowifi.IsATT310280(request.Identity) && protectedServerPort == 0 { + protectedServerPort = 6000 + } if securityEncryptionForIdentity(request.Identity) == "null" { if protectedClientPort == 0 { protectedClientPort = 5062 @@ -554,6 +566,10 @@ func newSession( return nil, err } proposal.encryption = securityEncryptionForIdentity(request.Identity) + if vowifi.IsATT310280(request.Identity) { + proposal.integrityAlgorithms = []string{"hmac-sha-1-96"} + proposal.encryptionAlgorithmsList = []string{"aes-cbc"} + } session.securityProposal = proposal protectedTCP, err := net.ListenTCP( "tcp", @@ -732,7 +748,11 @@ func (session *Session) register(ctx context.Context, expires int) (*sipResponse if err != nil { return nil, err } - material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge) + preference := "" + if vowifi.IsATT310280(session.request.Identity) { + preference = "isim_strict" + } + material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge, preference) if err != nil { return nil, err } @@ -792,6 +812,10 @@ func (session *Session) buildRegister( authorizationHeader string, authorization string, ) ([]byte, error) { + att310280 := vowifi.IsATT310280(session.request.Identity) + if att310280 { + expires = 18400 + } branch, err := randomHex(12) if err != nil { return nil, err @@ -810,6 +834,17 @@ func (session *Session) buildRegister( session.instanceID, "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel", ) + if att310280 { + contact = fmt.Sprintf( + `;+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip;`+ + `+g.3gpp.icsi-ref="%s";+sip.instance="<%s>"`, + session.identity.user, + contactAddress, + session.transport, + "urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel", + session.instanceID, + ) + } o2Germany := usesO2GermanyIMSProfile(session.request.Identity) supported := "path, gruu" allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS" @@ -820,6 +855,13 @@ func (session *Session) buildRegister( supported = "path, gruu, outbound, sec-agree, 100rel, timer" allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS" } + if att310280 { + supported = "path,sec-agree,gruu" + } + userAgent := strings.TrimSpace(session.provider.config.UserAgent) + if att310280 && (userAgent == "" || userAgent == "vocat/1") { + userAgent = "SimAdmin VoWiFi" + } lines := []string{ "REGISTER " + requestURI + " SIP/2.0", fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch), @@ -833,14 +875,23 @@ func (session *Session) buildRegister( fmt.Sprintf("Expires: %d", expires), "Supported: " + supported, "Allow: " + allow, - "User-Agent: " + session.provider.config.UserAgent, + "User-Agent: " + userAgent, } if o2Germany { lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">") + } else if att310280 { + lines = append(lines, + "P-Preferred-Identity: <"+session.identity.public+">", + `P-Visited-Network-ID: "one.att.net"`, + "P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided", + "Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0", + "Accept-Contact: *;+g.3gpp.smsip", + `Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"`, + ) } if session.securityOffered() { lines = append(lines, - "Security-Client: "+session.securityProposal.headerValue(), + "Security-Client: "+session.securityClientValue(), "Require: sec-agree", "Proxy-Require: sec-agree", ) diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index ed1fda2..4c9631d 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -442,6 +442,75 @@ func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) { } } +func TestATT310280DeriveIdentitiesUsesISIMDomains(t *testing.T) { + identities, err := deriveIdentities(vowifi.SIMIdentity{ + IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280", + }, Config{}) + if err != nil { + t.Fatalf("deriveIdentities() error = %v", err) + } + if identities.domain != "one.att.net" || + identities.private != "310280229187733@private.att.net" || + identities.public != "sip:310280229187733@one.att.net" { + t.Fatalf("AT&T identities = %#v", identities) + } +} + +func TestATT310280InitialRegisterMatchesProvisionedProfile(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + identity := vowifi.SIMIdentity{ + IMSI: "310280229187733", HomeMCC: "310", HomeMNC: "280", + } + identities, err := deriveIdentities(identity, Config{}) + if err != nil { + t.Fatal(err) + } + session := &Session{ + provider: &Provider{config: Config{SecurityMode: SecurityRequired, UserAgent: "vocat/1"}}, + request: vowifi.IMSRequest{Identity: identity}, + identity: identities, + endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060}, + transport: "tcp", + conn: client, + callID: "att-test", + fromTag: "tag", + instanceID: "urn:uuid:test", + securityProposal: securityProposal{ + spiClient: 1546543, spiServer: 1546542, + portClient: 32773, portServer: 6000, + integrityAlgorithms: []string{"hmac-sha-1-96"}, + encryptionAlgorithmsList: []string{"aes-cbc"}, + }, + } + packet, err := session.buildRegister(1, 3600, "", "") + if err != nil { + t.Fatalf("buildRegister() error = %v", err) + } + request := string(packet) + for _, want := range []string{ + "REGISTER sip:one.att.net SIP/2.0", + "Expires: 18400", + "Supported: path,sec-agree,gruu", + "User-Agent: SimAdmin VoWiFi", + `+g.3gpp.accesstype="wlan1";audio;+g.3gpp.smsip`, + "P-Preferred-Identity: ", + `P-Visited-Network-ID: "one.att.net"`, + "P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided", + "Cellular-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=3102800000000;cell-info-age=0", + "Accept-Contact: *;+g.3gpp.smsip", + "Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=1546543; spi-s=1546542; port-c=32773; port-s=6000", + `username="310280229187733@private.att.net"`, + `uri="sip:one.att.net"`, + } { + if !strings.Contains(request, want) { + t.Fatalf("AT&T REGISTER omits %q:\n%s", want, request) + } + } +} + func serveRefreshFailure(listener *net.UDPConn, nonce string) error { var callID string for step := 0; step < 3; step++ { diff --git a/internal/vowifi/ims/security.go b/internal/vowifi/ims/security.go index 563acc4..ab1e91e 100644 --- a/internal/vowifi/ims/security.go +++ b/internal/vowifi/ims/security.go @@ -12,6 +12,8 @@ import ( "sort" "strconv" "strings" + + "vocat/internal/vowifi" ) type SecurityMode string @@ -143,6 +145,19 @@ func (proposal securityProposal) headerValue() string { return strings.Join(values, ", ") } +func (session *Session) securityClientValue() string { + if vowifi.IsATT310280(session.request.Identity) { + return fmt.Sprintf( + "ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; prot=esp; mod=trans; spi-c=%d; spi-s=%d; port-c=%d; port-s=%d", + session.securityProposal.spiClient, + session.securityProposal.spiServer, + session.securityProposal.portClient, + session.securityProposal.portServer, + ) + } + return session.securityProposal.headerValue() +} + func (proposal securityProposal) encryptionAlgorithm() string { if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") { return "null" diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go index 3c29661..ed5f665 100644 --- a/internal/vowifi/orchestrator.go +++ b/internal/vowifi/orchestrator.go @@ -645,18 +645,13 @@ func DeriveEPDG(identity SIMIdentity) (string, error) { } return strings.ToLower(configured), nil } + if IsATT310280(identity) { + return att310280EPDG, nil + } if err := identity.validate(); err != nil { return "", err } - mnc := strings.TrimSpace(identity.HomeMNC) - for len(mnc) < 3 { - mnc = "0" + mnc - } - return fmt.Sprintf( - "epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org", - mnc, - strings.TrimSpace(identity.HomeMCC), - ), nil + return standardEPDGHostname(identity.HomeMCC, identity.HomeMNC), nil } func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) { diff --git a/internal/vowifi/pcsc_adapter.go b/internal/vowifi/pcsc_adapter.go index 4709109..0b3551e 100644 --- a/internal/vowifi/pcsc_adapter.go +++ b/internal/vowifi/pcsc_adapter.go @@ -53,18 +53,18 @@ func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) ( mncLength := identity.MNCLength if mncLength != 2 && mncLength != 3 { if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok { - return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil + return applyAssignedCarrierRoute(SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}), nil } return SIMIdentity{}, ErrEC20MNCUnavailable } if len(identity.IMSI) < 3+mncLength { return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN") } - return SIMIdentity{ + return applyAssignedCarrierRoute(SIMIdentity{ ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength], SMSC: identity.SMSC, - }, nil + }), nil } func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) { diff --git a/internal/vowifi/phone_test.go b/internal/vowifi/phone_test.go index 9b5e3f4..055bc4e 100644 --- a/internal/vowifi/phone_test.go +++ b/internal/vowifi/phone_test.go @@ -125,6 +125,16 @@ func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) { }, want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org", }, + { + name: "AT&T 310280 uses carrier endpoint", + identity: SIMIdentity{ + ICCID: "89012804332291663965", + IMSI: "310280229187733", + HomeMCC: "310", + HomeMNC: "280", + }, + want: "epdg.epc.att.net", + }, { name: "explicit endpoint", identity: SIMIdentity{ diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go index cfe09ad..c5a8d11 100644 --- a/internal/vowifi/types.go +++ b/internal/vowifi/types.go @@ -317,6 +317,14 @@ type AKAProvider interface { Authenticate(context.Context, SIMIdentity, AKAChallenge) (AKAResult, error) } +// PreferredAKAProvider optionally lets an AKA provider select a carrier- +// provisioned application such as ISIM. Providers that only expose USIM keep +// implementing AKAProvider unchanged. +type PreferredAKAProvider interface { + AKAProvider + AuthenticateWithPreference(context.Context, SIMIdentity, AKAChallenge, string) (AKAResult, error) +} + // RadioController owns the host/modem radio projection. EnterVoWiFiRFOff must // not toggle the independent pure-airplane policy; Restore must return to the // captured pre-transaction state.