mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-14 11:53:42 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37a191011 | ||
|
|
ba28c7f79a | ||
|
|
773b44aad6 | ||
|
|
2917378da0 | ||
|
|
69b1c3eec6 | ||
|
|
01e25ff12c |
@@ -14,7 +14,41 @@ import (
|
||||
var carrierDatabaseJSON []byte
|
||||
|
||||
type carrierDatabase struct {
|
||||
Carriers map[string][]string `json:"c"`
|
||||
Carriers map[string][]string `json:"c"`
|
||||
Countries map[string]string `json:"i"`
|
||||
Rules []carrierRule `json:"r"`
|
||||
}
|
||||
|
||||
type carrierRule struct {
|
||||
Name string `json:"n"`
|
||||
PLMNs []string `json:"m"`
|
||||
IMSIPatterns []string `json:"x"`
|
||||
SPNs []string `json:"s"`
|
||||
GID1Prefixes []string `json:"g1"`
|
||||
GID2Prefixes []string `json:"g2"`
|
||||
ICCIDPrefixes []string `json:"i"`
|
||||
}
|
||||
|
||||
// CarrierIdentity contains the SIM-issued values used by Android's carrier
|
||||
// resolver. MNC length comes from EF_AD; GID values come from EF_GID1/2.
|
||||
type CarrierIdentity struct {
|
||||
IMSI string
|
||||
ICCID string
|
||||
SPN string
|
||||
GID1 string
|
||||
GID2 string
|
||||
MNCLength int
|
||||
}
|
||||
|
||||
// CountryForMCC returns the ISO alpha-2 country/territory code associated with
|
||||
// a three-digit mobile country code in the embedded Android carrier database.
|
||||
func CountryForMCC(mcc string) (string, bool) {
|
||||
mcc = strings.TrimSpace(mcc)
|
||||
if len(mcc) != 3 {
|
||||
return "", false
|
||||
}
|
||||
country := strings.ToUpper(strings.TrimSpace(globalCarrierDatabase.Countries[mcc]))
|
||||
return country, len(country) == 2
|
||||
}
|
||||
|
||||
var globalCarrierDatabase = func() carrierDatabase {
|
||||
@@ -64,3 +98,121 @@ func CarrierForIMSI(imsi string) (plmn, name, countryCode string, ok bool) {
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
// CarrierForSIM applies the constrained Android carrier-ID rules before the
|
||||
// MCC/MNC fallback. This is important for MVNO and travel eSIM profiles where
|
||||
// several customer-facing carriers authenticate through the same home PLMN.
|
||||
func CarrierForSIM(identity CarrierIdentity) (plmn, name, countryCode string, ok bool) {
|
||||
imsi := strings.TrimSpace(identity.IMSI)
|
||||
if !decimalDigits(imsi, 5, 20) {
|
||||
return "", "", "", false
|
||||
}
|
||||
plmns := carrierPLMNCandidates(imsi, identity.MNCLength)
|
||||
bestScore := -1
|
||||
for _, rule := range globalCarrierDatabase.Rules {
|
||||
matchedPLMN := firstMatchingValue(rule.PLMNs, func(value string) bool {
|
||||
return containsString(plmns, value)
|
||||
})
|
||||
if matchedPLMN == "" {
|
||||
continue
|
||||
}
|
||||
score := 1 << 8
|
||||
if len(rule.IMSIPatterns) > 0 {
|
||||
if firstMatchingValue(rule.IMSIPatterns, func(pattern string) bool { return imsiPatternMatch(imsi, pattern) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 7
|
||||
}
|
||||
if len(rule.ICCIDPrefixes) > 0 {
|
||||
if firstMatchingValue(rule.ICCIDPrefixes, func(prefix string) bool { return strings.HasPrefix(identity.ICCID, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 6
|
||||
}
|
||||
if len(rule.GID1Prefixes) > 0 {
|
||||
if firstMatchingValue(rule.GID1Prefixes, func(prefix string) bool { return prefixFold(identity.GID1, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 5
|
||||
}
|
||||
if len(rule.GID2Prefixes) > 0 {
|
||||
if firstMatchingValue(rule.GID2Prefixes, func(prefix string) bool { return prefixFold(identity.GID2, prefix) }) == "" {
|
||||
continue
|
||||
}
|
||||
score += 1 << 4
|
||||
}
|
||||
if len(rule.SPNs) > 0 {
|
||||
if !containsFold(rule.SPNs, identity.SPN) {
|
||||
continue
|
||||
}
|
||||
score += 1 << 1
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
plmn = matchedPLMN
|
||||
name = strings.TrimSpace(rule.Name)
|
||||
}
|
||||
}
|
||||
if bestScore >= 0 && name != "" {
|
||||
countryCode, _ = CountryForMCC(plmn[:3])
|
||||
return plmn, name, countryCode, true
|
||||
}
|
||||
return CarrierForIMSI(imsi)
|
||||
}
|
||||
|
||||
func carrierPLMNCandidates(imsi string, mncLength int) []string {
|
||||
if (mncLength == 2 || mncLength == 3) && len(imsi) >= 3+mncLength {
|
||||
return []string{imsi[:3+mncLength]}
|
||||
}
|
||||
result := make([]string, 0, 2)
|
||||
for _, length := range []int{6, 5} {
|
||||
if len(imsi) >= length {
|
||||
result = append(result, imsi[:length])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstMatchingValue(values []string, match func(string) bool) string {
|
||||
for _, value := range values {
|
||||
if match(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsString(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsFold(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prefixFold(value, prefix string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), strings.ToLower(strings.TrimSpace(prefix)))
|
||||
}
|
||||
|
||||
func imsiPatternMatch(imsi, pattern string) bool {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if len(imsi) < len(pattern) {
|
||||
return false
|
||||
}
|
||||
for index, value := range pattern {
|
||||
if value != 'x' && value != 'X' && byte(value) != imsi[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -411,7 +411,14 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
|
||||
return Snapshot{}, err
|
||||
}
|
||||
previousICCID := state.lastICCID
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, client)
|
||||
var previousSnapshot *Snapshot
|
||||
manager.mu.RLock()
|
||||
if state.snapshot != nil {
|
||||
copy := *state.snapshot
|
||||
previousSnapshot = ©
|
||||
}
|
||||
manager.mu.RUnlock()
|
||||
snapshot, err := manager.readSnapshot(ctx, id, candidate, backend, previousICCID, previousSnapshot, client)
|
||||
if err == nil && strings.TrimSpace(snapshot.ICCID) != "" {
|
||||
state.lastICCID = strings.TrimSpace(snapshot.ICCID)
|
||||
}
|
||||
@@ -447,6 +454,7 @@ func (manager *Manager) refreshCardReader(ctx context.Context, id string, state
|
||||
result.ICCID = card.Identity.ICCID
|
||||
result.IMSI = card.Identity.IMSI
|
||||
result.SPN = card.Identity.SPN
|
||||
result.MNCLength = card.Identity.MNCLength
|
||||
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
|
||||
state.lastICCID = result.ICCID
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
{command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")},
|
||||
{command: "AT+CIMI", response: okResponse("460001234567890")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"00434D4343FFFFFFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
|
||||
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020002FFFF"`)},
|
||||
{command: "AT+CRSM=176,28478,0,0,2", response: okResponse(`+CRSM: 144,0,"0102"`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 20,99")},
|
||||
{
|
||||
command: `AT+QENG="servingcell"`,
|
||||
@@ -112,7 +118,8 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
|
||||
}
|
||||
if snapshot.IMEI != "867123456789012" ||
|
||||
snapshot.ICCID != "8986001234567890123" ||
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" {
|
||||
snapshot.IMSI != "460001234567890" || snapshot.SPN != "CMCC" ||
|
||||
snapshot.MNCLength != 2 || snapshot.GID1 != "0102" || snapshot.GID2 != "" {
|
||||
t.Fatalf("subscriber identifiers = %#v", snapshot)
|
||||
}
|
||||
if !snapshot.ModeKnown || snapshot.OperatingMode != 1 ||
|
||||
@@ -198,6 +205,12 @@ func TestManagerForcesRFOffBeforeInspectingChangedSIMNetwork(t *testing.T) {
|
||||
{command: "AT+CFUN=4", response: okResponse()},
|
||||
{command: "AT+CIMI", response: okResponse("234150000000002")},
|
||||
{command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)},
|
||||
{command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020004FFFF"`)},
|
||||
{command: "AT+CRSM=176,28589,0,0,4", response: okResponse(`+CRSM: 144,0,"00000002"`)},
|
||||
{command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28478,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 144,0,"620680020001FFFF"`)},
|
||||
{command: "AT+CRSM=176,28479,0,0,1", response: okResponse(`+CRSM: 144,0,"FF"`)},
|
||||
{command: "AT+CSQ", response: okResponse("+CSQ: 99,99")},
|
||||
{command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)},
|
||||
{command: "AT+COPS?", response: okResponse("+COPS: 0")},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,6 +22,13 @@ var BlockedMCCs = map[string]string{
|
||||
// code. The MCC is the leading three digits and the MNC the following two or
|
||||
// three. Empty strings are returned for an unusable IMSI.
|
||||
func CardMCCMNC(imsi string) (mcc string, mnc string) {
|
||||
return CardMCCMNCWithLength(imsi, 0)
|
||||
}
|
||||
|
||||
// CardMCCMNCWithLength uses the MNC length advertised by EF_AD when available.
|
||||
// Without it the historical three-digit behavior is retained for callers that
|
||||
// have only an IMSI.
|
||||
func CardMCCMNCWithLength(imsi string, mncLength int) (mcc string, mnc string) {
|
||||
digits := strings.TrimSpace(imsi)
|
||||
if len(digits) < 5 ||
|
||||
strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||
@@ -29,8 +36,11 @@ func CardMCCMNC(imsi string) (mcc string, mnc string) {
|
||||
}
|
||||
mcc = digits[:3]
|
||||
mnc = digits[3:]
|
||||
if len(mnc) > 3 {
|
||||
mnc = mnc[:3]
|
||||
if mncLength != 2 && mncLength != 3 {
|
||||
mncLength = 3
|
||||
}
|
||||
if len(mnc) > mncLength {
|
||||
mnc = mnc[:mncLength]
|
||||
}
|
||||
return mcc, mnc
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestCardMCCMNC(t *testing.T) {
|
||||
if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" {
|
||||
t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc)
|
||||
}
|
||||
if mcc, mnc := CardMCCMNCWithLength("454006395879502", 2); mcc != "454" || mnc != "00" {
|
||||
t.Fatalf("CardMCCMNCWithLength = (%q, %q), want (454, 00)", mcc, mnc)
|
||||
}
|
||||
for _, bad := range []string{"", "4600", "4600X1234"} {
|
||||
if mcc, _ := CardMCCMNC(bad); mcc != "" {
|
||||
t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc)
|
||||
|
||||
@@ -55,6 +55,25 @@ func TestCarrierForPLMNReturnsCountryCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryForMCCUsesEmbeddedCountryIndex(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"234": "GB",
|
||||
"262": "DE",
|
||||
"310": "US",
|
||||
"460": "CN",
|
||||
}
|
||||
for mcc, want := range tests {
|
||||
if got, ok := CountryForMCC(mcc); !ok || got != want {
|
||||
t.Errorf("CountryForMCC(%q) = (%q, %v), want %q", mcc, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"", "23", "999", "abcd"} {
|
||||
if got, ok := CountryForMCC(invalid); ok || got != "" {
|
||||
t.Errorf("CountryForMCC(%q) = (%q, %v), want unknown", invalid, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
tests := []struct {
|
||||
imsi string
|
||||
@@ -64,6 +83,7 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
{imsi: "234336570710174", wantPLMN: "23433", wantCountry: "GB"},
|
||||
{imsi: "234159609054263", wantPLMN: "23415", wantCountry: "GB"},
|
||||
{imsi: "234870123456789", wantPLMN: "23487", wantCountry: "GB"},
|
||||
{imsi: "454006395879502", wantPLMN: "45400", wantCountry: "HK"},
|
||||
{imsi: "310260123456789", wantPLMN: "310260", wantCountry: "US"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
@@ -73,3 +93,20 @@ func TestCarrierForIMSIHandlesTwoAndThreeDigitMNCs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCarrierForSIMUsesAndroidGIDRuleBeforePLMNFallback(t *testing.T) {
|
||||
plmn, name, country, ok := CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "454006395879502", ICCID: "89852350126077295027",
|
||||
SPN: "Saily", GID1: "536E617065", GID2: "536E617065000012", MNCLength: 2,
|
||||
})
|
||||
if !ok || plmn != "45400" || name != "Webbing" || country != "HK" {
|
||||
t.Fatalf("CarrierForSIM exact rule = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
|
||||
plmn, name, country, ok = CarrierForSIM(CarrierIdentity{
|
||||
IMSI: "454006395879502", SPN: "Saily", MNCLength: 2,
|
||||
})
|
||||
if !ok || plmn != "45400" || name != "1O1O / csl / Club Sim" || country != "HK" {
|
||||
t.Fatalf("CarrierForSIM generic fallback = (%q, %q, %q, %v)", plmn, name, country, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func (manager *Manager) readSnapshot(
|
||||
candidate modem.Candidate,
|
||||
backend string,
|
||||
previousICCID string,
|
||||
previousSnapshot *Snapshot,
|
||||
client modem.Client,
|
||||
) (Snapshot, error) {
|
||||
snapshot := Snapshot{
|
||||
@@ -81,6 +82,25 @@ func (manager *Manager) readSnapshot(
|
||||
if response, spnErr := manager.command(ctx, client, "AT+CRSM=176,28486,0,0,17"); spnErr == nil {
|
||||
snapshot.SPN = parseSPN(response)
|
||||
}
|
||||
if previousSnapshot != nil && previousSnapshot.IdentityFilesRead &&
|
||||
strings.EqualFold(strings.TrimSpace(previousSnapshot.ICCID), strings.TrimSpace(snapshot.ICCID)) {
|
||||
snapshot.MNCLength = previousSnapshot.MNCLength
|
||||
snapshot.GID1 = previousSnapshot.GID1
|
||||
snapshot.GID2 = previousSnapshot.GID2
|
||||
snapshot.IdentityFilesRead = true
|
||||
} else {
|
||||
// Android's carrier resolver does not identify MVNOs from MCC/MNC alone.
|
||||
// Read these files once per inserted ICCID and cache even an empty result;
|
||||
// repeatedly probing unsupported EFs would add avoidable modem traffic.
|
||||
if efAD := manager.readTransparentSIMFile(ctx, client, 28589); len(efAD) >= 4 {
|
||||
if length := int(efAD[3] & 0x0f); length == 2 || length == 3 {
|
||||
snapshot.MNCLength = length
|
||||
}
|
||||
}
|
||||
snapshot.GID1 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28478))
|
||||
snapshot.GID2 = encodeSIMGroupID(manager.readTransparentSIMFile(ctx, client, 28479))
|
||||
snapshot.IdentityFilesRead = true
|
||||
}
|
||||
if response, ok := optional("AT+CSQ"); ok {
|
||||
snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response)
|
||||
}
|
||||
@@ -164,6 +184,91 @@ func (manager *Manager) readSnapshot(
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) readTransparentSIMFile(ctx context.Context, client modem.Client, fileID int) []byte {
|
||||
response, err := manager.command(ctx, client, fmt.Sprintf("AT+CRSM=192,%d,0,0,0", fileID))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
size := transparentSIMFileSize(crsmPayload(response))
|
||||
if size <= 0 || size > 64 {
|
||||
return nil
|
||||
}
|
||||
response, err = manager.command(ctx, client, fmt.Sprintf("AT+CRSM=176,%d,0,0,%d", fileID, size))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return crsmPayload(response)
|
||||
}
|
||||
|
||||
func transparentSIMFileSize(payload []byte) int {
|
||||
// USIM FCP templates contain file size in tag 0x80. Skip the outer 0x62
|
||||
// template and walk its immediate TLVs.
|
||||
content := payload
|
||||
if len(content) >= 2 && content[0] == 0x62 {
|
||||
length, header, ok := berLength(content[1:])
|
||||
if !ok || 1+header+length > len(content) {
|
||||
return 0
|
||||
}
|
||||
content = content[1+header : 1+header+length]
|
||||
}
|
||||
for offset := 0; offset+2 <= len(content); {
|
||||
tag := content[offset]
|
||||
length, header, ok := berLength(content[offset+1:])
|
||||
start := offset + 1 + header
|
||||
end := start + length
|
||||
if !ok || end > len(content) {
|
||||
break
|
||||
}
|
||||
if tag == 0x80 && (length == 1 || length == 2) {
|
||||
size := 0
|
||||
for _, value := range content[start:end] {
|
||||
size = size<<8 | int(value)
|
||||
}
|
||||
return size
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
// Legacy GSM GET RESPONSE data stores file size in bytes 2 and 3.
|
||||
if len(payload) >= 4 && payload[0] != 0x62 {
|
||||
return int(payload[2])<<8 | int(payload[3])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func berLength(value []byte) (length, header int, ok bool) {
|
||||
if len(value) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
if value[0] < 0x80 {
|
||||
return int(value[0]), 1, true
|
||||
}
|
||||
count := int(value[0] & 0x7f)
|
||||
if count == 0 || count > 2 || len(value) < count+1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, item := range value[1 : count+1] {
|
||||
length = length<<8 | int(item)
|
||||
}
|
||||
return length, count + 1, true
|
||||
}
|
||||
|
||||
func encodeSIMGroupID(value []byte) string {
|
||||
if len(value) == 0 {
|
||||
return ""
|
||||
}
|
||||
allPadding := true
|
||||
for _, item := range value {
|
||||
if item != 0xff {
|
||||
allPadding = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allPadding {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(value))
|
||||
}
|
||||
|
||||
func parseSPN(response modem.Response) string {
|
||||
value := valueAfterPrefix(response, "+CRSM:")
|
||||
fields := csvValues(value)
|
||||
|
||||
@@ -105,6 +105,10 @@ type Snapshot struct {
|
||||
ICCID string `json:"iccid"`
|
||||
IMSI string `json:"imsi"`
|
||||
SPN string `json:"spn,omitempty"`
|
||||
MNCLength int `json:"mncLength,omitempty"`
|
||||
GID1 string `json:"gid1,omitempty"`
|
||||
GID2 string `json:"gid2,omitempty"`
|
||||
IdentityFilesRead bool `json:"-"`
|
||||
OperatingMode int `json:"operatingMode"`
|
||||
ModeKnown bool `json:"modeKnown"`
|
||||
FlightMode bool `json:"flightMode"`
|
||||
|
||||
@@ -31,6 +31,7 @@ type automaticTaskNotification struct {
|
||||
}
|
||||
|
||||
func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTask, run store.AutomaticTaskRun) {
|
||||
ctx = s.notificationDestinationContext(ctx)
|
||||
deviceLabel := task.DeviceID
|
||||
if configured, err := s.store.Device(ctx, task.DeviceID); err == nil {
|
||||
deviceLabel = firstNonEmpty(configured.Name, configured.ID)
|
||||
|
||||
@@ -1904,66 +1904,76 @@ func fillConfigFromPhysical(config *store.Device, entry device.Device) {
|
||||
func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any {
|
||||
if snapshot == nil {
|
||||
return map[string]any{
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
"card_country": "",
|
||||
"service_blocked": false,
|
||||
"blocked_reason": "",
|
||||
"network_mode": "",
|
||||
"radio_band": "",
|
||||
"radio_channel": 0,
|
||||
"signal_dbm": 0,
|
||||
"signal_sinr": 0,
|
||||
"imei": "",
|
||||
"iccid": "",
|
||||
"reg_status": 0,
|
||||
"reg_status_text": "not refreshed",
|
||||
"sim_inserted": false,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"model": "",
|
||||
"operator": "",
|
||||
"native_mcc": "",
|
||||
"native_mnc": "",
|
||||
"native_spn": "",
|
||||
"operator_country_code": "",
|
||||
"card_mcc": "",
|
||||
"card_mnc": "",
|
||||
"card_country": "",
|
||||
"home_carrier_name": "",
|
||||
"home_carrier_plmn": "",
|
||||
"home_carrier_country_code": "",
|
||||
"service_blocked": false,
|
||||
"blocked_reason": "",
|
||||
"network_mode": "",
|
||||
"radio_band": "",
|
||||
"radio_channel": 0,
|
||||
"signal_dbm": 0,
|
||||
"signal_sinr": 0,
|
||||
"imei": "",
|
||||
"iccid": "",
|
||||
"reg_status": 0,
|
||||
"reg_status_text": "not refreshed",
|
||||
"sim_inserted": false,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"model": "",
|
||||
}
|
||||
}
|
||||
mcc, mnc := splitPLMN(snapshot.OperatorCode)
|
||||
_, operatorCountryCode, _ := device.CarrierForPLMN(snapshot.OperatorCode)
|
||||
cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI)
|
||||
cardMCC, cardMNC := device.CardMCCMNCWithLength(snapshot.IMSI, snapshot.MNCLength)
|
||||
homePLMN, homeCarrier, homeCountry, _ := device.CarrierForSIM(device.CarrierIdentity{
|
||||
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
|
||||
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
|
||||
})
|
||||
blockedReason := device.RegionBlockReason(snapshot.IMSI)
|
||||
return map[string]any{
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
"card_country": countryNameForMCC(cardMCC),
|
||||
"service_blocked": blockedReason != "",
|
||||
"blocked_reason": blockedReason,
|
||||
"network_mode": snapshot.AccessTech,
|
||||
"network_duplex": "",
|
||||
"radio_band": snapshot.Band,
|
||||
"radio_channel": parseDecimal(snapshot.Channel),
|
||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||
"signal_sinr": pointerInt(snapshot.SINR),
|
||||
"imei": snapshot.IMEI,
|
||||
"iccid": snapshot.ICCID,
|
||||
"imsi": snapshot.IMSI,
|
||||
"firmware": snapshot.Firmware,
|
||||
"model": snapshot.Model,
|
||||
"reg_status": snapshot.RegistrationStatus,
|
||||
"reg_status_text": registrationText(snapshot),
|
||||
"ps_attached": snapshot.PSAttached,
|
||||
"sim_inserted": snapshotHasSIM(snapshot),
|
||||
"operating_mode": snapshot.OperatingMode,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
"operator": snapshot.OperatorName,
|
||||
"native_mcc": mcc,
|
||||
"native_mnc": mnc,
|
||||
"native_spn": snapshot.SPN,
|
||||
"operator_country_code": operatorCountryCode,
|
||||
"card_mcc": cardMCC,
|
||||
"card_mnc": cardMNC,
|
||||
"card_country": countryNameForMCC(cardMCC),
|
||||
"home_carrier_name": homeCarrier,
|
||||
"home_carrier_plmn": homePLMN,
|
||||
"home_carrier_country_code": homeCountry,
|
||||
"service_blocked": blockedReason != "",
|
||||
"blocked_reason": blockedReason,
|
||||
"network_mode": snapshot.AccessTech,
|
||||
"network_duplex": "",
|
||||
"radio_band": snapshot.Band,
|
||||
"radio_channel": parseDecimal(snapshot.Channel),
|
||||
"signal_dbm": pointerInt(snapshot.RSSIDBm),
|
||||
"signal_rsrp": pointerInt(snapshot.RSRP),
|
||||
"signal_rsrq": pointerInt(snapshot.RSRQ),
|
||||
"signal_sinr": pointerInt(snapshot.SINR),
|
||||
"imei": snapshot.IMEI,
|
||||
"iccid": snapshot.ICCID,
|
||||
"imsi": snapshot.IMSI,
|
||||
"firmware": snapshot.Firmware,
|
||||
"model": snapshot.Model,
|
||||
"reg_status": snapshot.RegistrationStatus,
|
||||
"reg_status_text": registrationText(snapshot),
|
||||
"ps_attached": snapshot.PSAttached,
|
||||
"sim_inserted": snapshotHasSIM(snapshot),
|
||||
"operating_mode": snapshot.OperatingMode,
|
||||
"phone_number": phone,
|
||||
"phone_number_source": phoneSource,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -429,17 +429,18 @@ func (s *Server) handleNotificationTest(
|
||||
return
|
||||
}
|
||||
|
||||
notificationContext := s.notificationDestinationContext(r.Context())
|
||||
switch channel {
|
||||
case "webhook":
|
||||
err = sendWebhookNotificationTest(r.Context(), resolved)
|
||||
err = sendWebhookNotificationTest(notificationContext, resolved)
|
||||
case "telegram":
|
||||
err = sendTelegramNotificationTest(r.Context(), resolved)
|
||||
err = sendTelegramNotificationTest(notificationContext, resolved)
|
||||
case "email":
|
||||
err = sendEmailNotificationTest(r.Context(), resolved)
|
||||
err = sendEmailNotificationTest(notificationContext, resolved)
|
||||
case "bark":
|
||||
err = sendBarkNotificationTest(r.Context(), resolved)
|
||||
err = sendBarkNotificationTest(notificationContext, resolved)
|
||||
case "wecom":
|
||||
err = sendWecomNotificationTest(r.Context(), resolved)
|
||||
err = sendWecomNotificationTest(notificationContext, resolved)
|
||||
}
|
||||
if err != nil {
|
||||
redacted := store.RedactText(err.Error(), provider)
|
||||
@@ -1073,6 +1074,39 @@ func dialRestricted(
|
||||
return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
type notificationAllowedNetworksKey struct{}
|
||||
|
||||
func (s *Server) notificationDestinationContext(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
access := s.currentAccessConfig()
|
||||
return context.WithValue(ctx, notificationAllowedNetworksKey{}, append([]netip.Prefix(nil), access.cidrs...))
|
||||
}
|
||||
|
||||
func notificationAddressAllowed(ctx context.Context, address netip.Addr) bool {
|
||||
address = address.Unmap()
|
||||
// Even an administrator-provided exception must never turn a notification
|
||||
// endpoint into a loopback or cloud-metadata request. Private/LAN and
|
||||
// benchmark ranges may be explicitly allowed for local push gateways and
|
||||
// DNS Fake-IP deployments, but these process-local destinations stay closed.
|
||||
if !address.IsValid() || address.IsUnspecified() || address.IsLoopback() ||
|
||||
address.IsMulticast() || address.IsLinkLocalUnicast() ||
|
||||
address == netip.MustParseAddr("100.100.100.200") {
|
||||
return false
|
||||
}
|
||||
if publicNotificationAddress(address) {
|
||||
return true
|
||||
}
|
||||
prefixes, _ := ctx.Value(notificationAllowedNetworksKey{}).([]netip.Prefix)
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.Contains(address) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
if normalized == "" || normalized == "localhost" ||
|
||||
@@ -1084,7 +1118,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
}
|
||||
if literal, err := netip.ParseAddr(normalized); err == nil {
|
||||
literal = literal.Unmap()
|
||||
if !publicNotificationAddress(literal) {
|
||||
if !notificationAddressAllowed(ctx, literal) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
|
||||
}
|
||||
return []netip.Addr{literal}, nil
|
||||
@@ -1099,7 +1133,7 @@ func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, err
|
||||
result := make([]netip.Addr, 0, len(addresses))
|
||||
for _, address := range addresses {
|
||||
address = address.Unmap()
|
||||
if !publicNotificationAddress(address) {
|
||||
if !notificationAddressAllowed(ctx, address) {
|
||||
return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
|
||||
}
|
||||
result = append(result, address)
|
||||
|
||||
@@ -789,6 +789,27 @@ func TestNotificationDestinationAddressPolicy(t *testing.T) {
|
||||
); err == nil {
|
||||
t.Fatal("metadata IP was not blocked")
|
||||
}
|
||||
allowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("198.18.0.0/15")},
|
||||
)
|
||||
if addresses, err := resolvePublicAddresses(allowedContext, "198.18.0.1"); err != nil || len(addresses) != 1 {
|
||||
t.Fatalf("explicit Fake-IP notification allowlist = %v, %v", addresses, err)
|
||||
}
|
||||
if _, err := resolvePublicAddresses(allowedContext, "169.254.169.254"); err == nil {
|
||||
t.Fatal("unlisted metadata IP was allowed")
|
||||
}
|
||||
wideAllowedContext := context.WithValue(
|
||||
context.Background(),
|
||||
notificationAllowedNetworksKey{},
|
||||
[]netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
|
||||
)
|
||||
for _, address := range []string{"127.0.0.1", "169.254.169.254", "100.100.100.200"} {
|
||||
if _, err := resolvePublicAddresses(wideAllowedContext, address); err == nil {
|
||||
t.Fatalf("non-overridable destination %s was allowed", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) {
|
||||
|
||||
@@ -105,7 +105,7 @@ func (s *Server) runSMSNotificationChannel(ctx context.Context, channel string)
|
||||
} else {
|
||||
for _, message := range messages {
|
||||
notification := s.newSMSNotification(ctx, message)
|
||||
if sendErr := sendSMSNotification(ctx, channel, config, notification); sendErr != nil {
|
||||
if sendErr := sendSMSNotification(s.notificationDestinationContext(ctx), channel, config, notification); sendErr != nil {
|
||||
if sendErr.Error() != lastError || time.Since(lastErrorAt) >= time.Minute {
|
||||
s.logSMSNotificationError(channel, sendErr)
|
||||
lastError, lastErrorAt = sendErr.Error(), time.Now()
|
||||
|
||||
@@ -850,7 +850,7 @@ func (bot *telegramBot) sendDeviceStatus(ctx context.Context, config telegramRun
|
||||
"ICCID:"+firstNonEmpty(snapshot.ICCID, "--"),
|
||||
"IMSI:"+firstNonEmpty(snapshot.IMSI, "--"),
|
||||
"号码:"+resolveTelegramPhoneNumber(associationNumber, wfcState, snapshot),
|
||||
"原运营商:"+telegramHomeCarrier(snapshot.IMSI, snapshot.SPN),
|
||||
"原运营商:"+telegramSnapshotHomeCarrier(snapshot),
|
||||
"当前网络:"+telegramCurrentNetwork(snapshot),
|
||||
"蜂窝模式:"+map[bool]string{true: "飞行模式", false: "开启"}[snapshot.FlightMode],
|
||||
)
|
||||
@@ -929,20 +929,30 @@ func usableTelegramPhoneNumber(value string) bool {
|
||||
}
|
||||
|
||||
func telegramHomeCarrier(imsi string, spn ...string) string {
|
||||
plmn, name, country, ok := device.CarrierForIMSI(imsi)
|
||||
if !ok {
|
||||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||||
return strings.TrimSpace(spn[0])
|
||||
}
|
||||
identity := device.CarrierIdentity{IMSI: imsi}
|
||||
if len(spn) > 0 {
|
||||
identity.SPN = spn[0]
|
||||
}
|
||||
return telegramResolvedHomeCarrier(identity)
|
||||
}
|
||||
|
||||
func telegramSnapshotHomeCarrier(snapshot *device.Snapshot) string {
|
||||
if snapshot == nil {
|
||||
return "--"
|
||||
}
|
||||
if len(spn) > 0 && strings.TrimSpace(spn[0]) != "" {
|
||||
brand := strings.TrimSpace(spn[0])
|
||||
brandCountry := country
|
||||
if strings.Contains(strings.ToLower(brand), "lebara") && strings.HasPrefix(strings.TrimSpace(imsi), "20404") {
|
||||
brandCountry = "GB"
|
||||
return telegramResolvedHomeCarrier(device.CarrierIdentity{
|
||||
IMSI: snapshot.IMSI, ICCID: snapshot.ICCID, SPN: snapshot.SPN,
|
||||
GID1: snapshot.GID1, GID2: snapshot.GID2, MNCLength: snapshot.MNCLength,
|
||||
})
|
||||
}
|
||||
|
||||
func telegramResolvedHomeCarrier(identity device.CarrierIdentity) string {
|
||||
plmn, name, country, ok := device.CarrierForSIM(identity)
|
||||
if !ok {
|
||||
if strings.TrimSpace(identity.SPN) != "" {
|
||||
return strings.TrimSpace(identity.SPN)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(brandCountry), brand, "(认证核心 " + plmn + ")"}, " "))
|
||||
return "--"
|
||||
}
|
||||
return strings.TrimSpace(strings.Join([]string{telegramCountryFlag(country), name, "(" + plmn + ")"}, " "))
|
||||
}
|
||||
|
||||
@@ -127,8 +127,11 @@ func TestTelegramCarrierPresentationSeparatesHomeAndServingNetworks(t *testing.T
|
||||
if got := telegramHomeCarrier("234336570710174"); !strings.Contains(got, "🇬🇧") || !strings.Contains(got, "23433") {
|
||||
t.Fatalf("home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("204040123456789", "Lebara"); !strings.Contains(got, "Lebara") || !strings.Contains(got, "20404") || !strings.Contains(got, "🇬🇧") || strings.Contains(got, "🇳🇱") {
|
||||
t.Fatalf("branded foreign-core carrier = %q", got)
|
||||
if got := telegramHomeCarrier("454006395879502", "Saily"); !strings.Contains(got, "1O1O / csl / Club Sim") || !strings.Contains(got, "45400") || !strings.Contains(got, "🇭🇰") || strings.Contains(got, "Saily") {
|
||||
t.Fatalf("profile brand overrode home carrier = %q", got)
|
||||
}
|
||||
if got := telegramHomeCarrier("999991234567890", "Unknown Brand"); got != "Unknown Brand" {
|
||||
t.Fatalf("unknown home carrier did not fall back to SPN: %q", got)
|
||||
}
|
||||
flight := &device.Snapshot{FlightMode: true, OperatorName: "stale network", RegistrationStatus: 1}
|
||||
if got := telegramCurrentNetwork(flight); got != "--(飞行模式)" {
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
|
||||
configInternalIPv4Address = 1
|
||||
configInternalIPv4DNS = 3
|
||||
configApplicationVersion = 7
|
||||
configInternalIPv6Address = 8
|
||||
configInternalIPv6DNS = 10
|
||||
configPCSCFIPv4Address = 20
|
||||
@@ -186,6 +187,9 @@ func configurationRequest() payload {
|
||||
configInternalIPv6DNS,
|
||||
configPCSCFIPv4Address,
|
||||
configPCSCFIPv6Address,
|
||||
// Android's IKE library always appends APPLICATION_VERSION to the
|
||||
// initial configuration request, even when the value is empty.
|
||||
configApplicationVersion,
|
||||
}
|
||||
body := []byte{configRequest, 0, 0, 0}
|
||||
for _, attribute := range attributes {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package ike
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// deviceIdentityRequested reports the 3GPP DEVICE_IDENTITY request defined by
|
||||
// TS 24.302. Android remembers this request and answers it in a later EAP
|
||||
// IKE_AUTH request, but only after authenticating the ePDG.
|
||||
func deviceIdentityRequested(payloads []payload) (bool, error) {
|
||||
for _, item := range payloadsOfType(payloads, payloadNotify) {
|
||||
kind, _, err := parseNotify(item)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if kind == notifyDeviceIdentity {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func deviceIdentityNotify(identity string) (payload, error) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if (len(identity) != 15 && len(identity) != 16) || !decimalDigits(identity) {
|
||||
return payload{}, errors.New("ike: device identity must contain 15 or 16 digits")
|
||||
}
|
||||
identityType := byte(1) // IMEI
|
||||
if len(identity) == 16 {
|
||||
identityType = 2 // IMEISV
|
||||
}
|
||||
data := make([]byte, 11)
|
||||
// TS 24.302 Figure 8.2.9.2: this inner length excludes its own two
|
||||
// octets, and is therefore 9 for an IMEI/IMEISV value.
|
||||
binary.BigEndian.PutUint16(data[:2], 9)
|
||||
data[2] = identityType
|
||||
for index := 0; index < 8; index++ {
|
||||
low := identity[index*2] - '0'
|
||||
high := byte(0x0f)
|
||||
if index*2+1 < len(identity) {
|
||||
high = identity[index*2+1] - '0'
|
||||
}
|
||||
data[index+3] = high<<4 | low
|
||||
}
|
||||
return makeNotify(notifyDeviceIdentity, data), nil
|
||||
}
|
||||
|
||||
func decimalDigits(value string) bool {
|
||||
for _, digit := range value {
|
||||
if digit < '0' || digit > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ike
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeviceIdentityNotifyMatchesAndroidEncoding(t *testing.T) {
|
||||
item, err := deviceIdentityNotify("123456789012345")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kind, data, err := parseNotify(item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []byte{0, 9, 1, 0x21, 0x43, 0x65, 0x87, 0x09, 0x21, 0x43, 0xf5}
|
||||
if kind != notifyDeviceIdentity || !bytes.Equal(data, want) {
|
||||
t.Fatalf("DEVICE_IDENTITY = %d/%x, want %d/%x", kind, data, notifyDeviceIdentity, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityNotifyAcceptsIMEISV(t *testing.T) {
|
||||
item, err := deviceIdentityNotify("1234567890123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, data, _ := parseNotify(item)
|
||||
if data[2] != 2 || data[10] != 0x65 {
|
||||
t.Fatalf("IMEISV data = %x", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceIdentityRequested(t *testing.T) {
|
||||
requested, err := deviceIdentityRequested([]payload{makeNotify(notifyDeviceIdentity, nil)})
|
||||
if err != nil || !requested {
|
||||
t.Fatalf("deviceIdentityRequested() = %v, %v", requested, err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
advertiseEAPOnly := advertiseEAPOnlyAuthentication(request.Identity.HomeMCC, request.Identity.HomeMNC)
|
||||
if legacyFirst {
|
||||
group = dhMODP1024
|
||||
}
|
||||
@@ -245,6 +246,23 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cleanupPendingIKE := true
|
||||
cleanupMessageID := uint32(2)
|
||||
defer func() {
|
||||
if cleanupPendingIKE {
|
||||
cleanupContext, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
_ = sendIKESADelete(
|
||||
cleanupContext,
|
||||
transport,
|
||||
ikeSuite,
|
||||
keys,
|
||||
initiatorSPI,
|
||||
responseHeader.ResponderSPI,
|
||||
cleanupMessageID,
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
var childInboundSPIBytes [4]byte
|
||||
if err := fillNonzero(provider.config.Random, childInboundSPIBytes[:]); err != nil {
|
||||
@@ -259,7 +277,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, advertiseEAPOnly)
|
||||
authHeader := ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
@@ -292,15 +310,19 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
initiatorNonce,
|
||||
ikeSuite,
|
||||
keys.SKpr,
|
||||
serverName,
|
||||
"", // Android Iwlan enables IKE_OPTION_ACCEPT_ANY_REMOTE_ID.
|
||||
serverName,
|
||||
provider.config.RootCAs,
|
||||
provider.config.ResponderPublicKey,
|
||||
true, // RFC 5998 EAP-only authentication defers responder AUTH.
|
||||
true, // Some ePDGs, including O2 Germany, implicitly defer AUTH without accepting the RFC 5998 notify.
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceIdentityPending, err := deviceIdentityRequested(authResponsePayloads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageID := uint32(1)
|
||||
currentPayloads := authResponsePayloads
|
||||
for round := 0; round < 10; round++ {
|
||||
@@ -319,16 +341,29 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, errors.New("ike: EAP state machine produced no response")
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
requestPayloads := []payload{{Type: payloadEAP, Body: action.Response}}
|
||||
if deviceIdentityPending && responderAUTH == vowifi.ResponderAUTHVerified {
|
||||
deviceIdentity, identityErr := deviceIdentityNotify(request.Identity.IMEI)
|
||||
if identityErr == nil {
|
||||
requestPayloads = append(requestPayloads, deviceIdentity)
|
||||
}
|
||||
}
|
||||
eapRequest, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
Exchange: exchangeIKEAuth,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{{Type: payloadEAP, Body: action.Response}}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
}, requestPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requested, notifyErr := deviceIdentityRequested(currentPayloads); notifyErr != nil {
|
||||
return nil, notifyErr
|
||||
} else if requested {
|
||||
deviceIdentityPending = true
|
||||
}
|
||||
eapResponse, err := transport.RoundTrip(ctx, eapRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -358,6 +393,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
return nil, err
|
||||
}
|
||||
messageID++
|
||||
cleanupMessageID = messageID + 1
|
||||
finalRequest, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responseHeader.ResponderSPI,
|
||||
@@ -383,17 +419,17 @@ 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 {
|
||||
return nil, errors.New("ike: duplicate final responder IDr payload")
|
||||
}
|
||||
if len(finalIDs) == 1 {
|
||||
if err := validateFQDNIDr(finalIDs[0], provider.config.APN, "final APN"); err != nil {
|
||||
if err := validateFQDNIDr(finalIDs[0], "", "final responder"); err != nil {
|
||||
return nil, fmt.Errorf("ike: final APN IDr: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -472,9 +508,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
keys,
|
||||
initiatorSPI,
|
||||
responseHeader.ResponderSPI,
|
||||
messageID+1,
|
||||
natDetected,
|
||||
provider.config.KeepaliveInterval,
|
||||
)
|
||||
cleanupPendingIKE = false
|
||||
installed, err := provider.config.Installer.Install(ctx, ChildSAConfig{
|
||||
Name: name,
|
||||
OuterLocal: append(net.IP(nil), transport.LocalAddr().IP...),
|
||||
@@ -499,11 +537,11 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
|
||||
Relay: relay,
|
||||
})
|
||||
if err != nil {
|
||||
_ = relay.Close()
|
||||
_ = relay.CloseWithDelete(ctx)
|
||||
return nil, fmt.Errorf("ike: install CHILD_SA: %w", err)
|
||||
}
|
||||
if installed == nil {
|
||||
_ = relay.Close()
|
||||
_ = relay.CloseWithDelete(ctx)
|
||||
return nil, errors.New("ike: CHILD_SA installer returned a nil handle")
|
||||
}
|
||||
dataplaneMode := "unknown"
|
||||
@@ -553,6 +591,47 @@ func legacyIKEProfile(mcc, mnc string) bool {
|
||||
return plmn == "23415" || plmn == "2044"
|
||||
}
|
||||
|
||||
func advertiseEAPOnlyAuthentication(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 initial IKE_AUTH that explicitly carries
|
||||
// EAP_ONLY_AUTHENTICATION, but then implicitly defers responder AUTH when the
|
||||
// notify is omitted. Do not advertise RFC 5998 for that PLMN; the final
|
||||
// responder AUTH derived from the EAP-AKA MSK remains mandatory.
|
||||
return !o2GermanyIKECompatibility(mcc, mnc)
|
||||
}
|
||||
|
||||
func o2GermanyIKECompatibility(mcc, mnc string) bool {
|
||||
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 {
|
||||
// Match Android's IkeSessionStateMachine.buildIkeAuthReq ordering. Some
|
||||
// carrier ePDGs inspect this first encrypted exchange before starting EAP.
|
||||
payloads := []payload{idi, requestedIDr}
|
||||
if eapOnly {
|
||||
payloads = append(payloads, makeNotify(notifyEAPOnlyAuth, nil))
|
||||
}
|
||||
payloads = append(payloads,
|
||||
makeNotify(notifyMOBIKESupported, nil),
|
||||
makeNotify(notifyInitialContact, nil),
|
||||
)
|
||||
return append(payloads,
|
||||
payload{Type: payloadSA, Body: append([]byte(nil), childOfferBody...)},
|
||||
tsi,
|
||||
tsr,
|
||||
configurationRequest(),
|
||||
)
|
||||
}
|
||||
|
||||
func buildInitialEAPOnlyAuth(
|
||||
idi payload,
|
||||
requestedIDr payload,
|
||||
@@ -560,15 +639,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 {
|
||||
@@ -877,7 +948,7 @@ func (session *Session) Close(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
if relay != nil {
|
||||
if err := relay.Close(); err != nil {
|
||||
if err := relay.CloseWithDelete(ctx); err != nil {
|
||||
errs = append(errs, fmt.Errorf("close session relay: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@ type sessionRelay struct {
|
||||
keys ikeKeys
|
||||
spii [8]byte
|
||||
spir [8]byte
|
||||
deleteID uint32
|
||||
natt bool
|
||||
keepalive time.Duration
|
||||
|
||||
@@ -33,6 +34,7 @@ func newSessionRelay(
|
||||
keys ikeKeys,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
deleteMessageID uint32,
|
||||
natt bool,
|
||||
keepalive time.Duration,
|
||||
) *sessionRelay {
|
||||
@@ -46,6 +48,7 @@ func newSessionRelay(
|
||||
keys: keys,
|
||||
spii: initiatorSPI,
|
||||
spir: responderSPI,
|
||||
deleteID: deleteMessageID,
|
||||
natt: natt,
|
||||
keepalive: keepalive,
|
||||
ctx: ctx,
|
||||
@@ -214,6 +217,53 @@ func (relay *sessionRelay) Close() error {
|
||||
return errors.Join(relay.terminalErrorIfFailure(), transportErr)
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) CloseWithDelete(ctx context.Context) error {
|
||||
deleteErr := relay.sendIKEDelete(ctx)
|
||||
return errors.Join(deleteErr, relay.Close())
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) sendIKEDelete(ctx context.Context) error {
|
||||
return sendIKESADelete(ctx, relay.transport, relay.suite, relay.keys, relay.spii, relay.spir, relay.deleteID)
|
||||
}
|
||||
|
||||
func sendIKESADelete(
|
||||
ctx context.Context,
|
||||
transport datagramTransport,
|
||||
suite negotiatedSuite,
|
||||
keys ikeKeys,
|
||||
initiatorSPI [8]byte,
|
||||
responderSPI [8]byte,
|
||||
messageID uint32,
|
||||
) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
// Teardown is often called with the operation context already canceled.
|
||||
// Give the protocol-level release a short independent chance to leave.
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
request, err := encryptPayloads(ikeHeader{
|
||||
InitiatorSPI: initiatorSPI,
|
||||
ResponderSPI: responderSPI,
|
||||
Exchange: exchangeInformational,
|
||||
Flags: flagInitiator,
|
||||
MessageID: messageID,
|
||||
}, []payload{{
|
||||
Type: payloadDelete,
|
||||
Body: []byte{protocolIKE, 0, 0, 0},
|
||||
}}, suite, keys.SKei, keys.SKai, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ike: build IKE SA delete: %w", err)
|
||||
}
|
||||
if err := transport.SendSessionPacket(ctx, request, true); err != nil {
|
||||
return fmt.Errorf("ike: send IKE SA delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (relay *sessionRelay) terminalErrorIfFailure() error {
|
||||
relay.mu.Lock()
|
||||
defer relay.mu.Unlock()
|
||||
|
||||
@@ -119,6 +119,7 @@ func TestSessionRelayCloseInterruptsStuckTransportRead(t *testing.T) {
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
9,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
@@ -156,7 +157,7 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) {
|
||||
}
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, true, time.Hour)
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour)
|
||||
defer relay.Close()
|
||||
|
||||
esp := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa}
|
||||
@@ -201,6 +202,47 @@ func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaySendsEncryptedIKESADelete(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
suite := legacyTestSuite()
|
||||
keys := ikeKeys{
|
||||
SKai: bytes.Repeat([]byte{0x11}, 20),
|
||||
SKar: bytes.Repeat([]byte{0x12}, 20),
|
||||
SKei: bytes.Repeat([]byte{0x13}, 16),
|
||||
SKer: bytes.Repeat([]byte{0x14}, 16),
|
||||
}
|
||||
spii := [8]byte{1}
|
||||
spir := [8]byte{2}
|
||||
relay := newSessionRelay(transport, suite, keys, spii, spir, 9, true, time.Hour)
|
||||
defer relay.Close()
|
||||
|
||||
if err := relay.sendIKEDelete(context.Background()); err != nil {
|
||||
t.Fatalf("sendIKEDelete() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case sent := <-transport.sent:
|
||||
if !sent.ike {
|
||||
t.Fatal("IKE SA delete was sent as ESP")
|
||||
}
|
||||
header, payloads, err := decryptPayloads(sent.data, suite, keys.SKei, keys.SKai)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt IKE SA delete: %v", err)
|
||||
}
|
||||
if header.Exchange != exchangeInformational || header.MessageID != 9 || header.Flags != flagInitiator {
|
||||
t.Fatalf("IKE SA delete header = %#v", header)
|
||||
}
|
||||
item, err := onePayload(payloads, payloadDelete)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(item.Body, []byte{protocolIKE, 0, 0, 0}) {
|
||||
t.Fatalf("IKE SA delete body = %x", item.Body)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("relay did not send IKE SA delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
transport := newFakeSessionTransport()
|
||||
relay := newSessionRelay(
|
||||
@@ -209,6 +251,7 @@ func TestSessionRelaySendsNATKeepalive(t *testing.T) {
|
||||
ikeKeys{},
|
||||
[8]byte{1},
|
||||
[8]byte{2},
|
||||
9,
|
||||
true,
|
||||
10*time.Millisecond,
|
||||
)
|
||||
@@ -233,6 +276,7 @@ func TestSessionRelayDropsDelayedIKEPacketFromPreviousSA(t *testing.T) {
|
||||
ikeKeys{},
|
||||
spii,
|
||||
spir,
|
||||
9,
|
||||
true,
|
||||
time.Hour,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ const (
|
||||
payloadAuth = 39
|
||||
payloadNonce = 40
|
||||
payloadNotify = 41
|
||||
payloadDelete = 42
|
||||
payloadTSi = 44
|
||||
payloadTSr = 45
|
||||
payloadEncrypted = 46
|
||||
@@ -54,11 +55,14 @@ const (
|
||||
dhMODP2048 = 14
|
||||
transformAttributeKeyLen = 14
|
||||
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
notifyInitialContact = 16384
|
||||
notifyMOBIKESupported = 16396
|
||||
notifyNATSource = 16388
|
||||
notifyNATDestination = 16389
|
||||
notifyEAPOnlyAuth = 16417
|
||||
notifyDeviceIdentity = 41101
|
||||
notifyInvalidKE = 17
|
||||
notifyNoProposal = 14
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -3,6 +3,7 @@ package ike
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
@@ -60,7 +61,7 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
dualStackTrafficSelectors(payloadTSi),
|
||||
dualStackTrafficSelectors(payloadTSr),
|
||||
)
|
||||
if len(payloads) != 7 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
if len(payloads) != 9 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
t.Fatalf("initial auth payload order = %#v", payloads)
|
||||
}
|
||||
if got := string(payloads[1].Body[4:]); got != "ims" || payloads[1].Body[0] != 2 {
|
||||
@@ -68,11 +69,25 @@ func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) {
|
||||
}
|
||||
kind, data, err := parseNotify(payloads[2])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
t.Fatalf("parseNotify(EAP_ONLY_AUTHENTICATION) error = %v", err)
|
||||
}
|
||||
if kind != notifyEAPOnlyAuth || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want EAP_ONLY_AUTHENTICATION", kind, data)
|
||||
}
|
||||
kind, data, err = parseNotify(payloads[3])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
}
|
||||
if kind != notifyMOBIKESupported || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want MOBIKE_SUPPORTED", kind, data)
|
||||
}
|
||||
kind, data, err = parseNotify(payloads[4])
|
||||
if err != nil {
|
||||
t.Fatalf("parseNotify() error = %v", err)
|
||||
}
|
||||
if kind != notifyInitialContact || len(data) != 0 {
|
||||
t.Fatalf("notify = %d/%x, want INITIAL_CONTACT", kind, data)
|
||||
}
|
||||
for _, kind := range []uint8{payloadTSi, payloadTSr} {
|
||||
item, err := onePayload(payloads, kind)
|
||||
if err != nil {
|
||||
@@ -88,6 +103,83 @@ 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) != 8 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr {
|
||||
t.Fatalf("initial standard EAP payload order = %#v", payloads)
|
||||
}
|
||||
initialContact := 0
|
||||
mobikeSupported := 0
|
||||
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")
|
||||
}
|
||||
if kind == notifyInitialContact {
|
||||
initialContact++
|
||||
}
|
||||
if kind == notifyMOBIKESupported {
|
||||
mobikeSupported++
|
||||
}
|
||||
}
|
||||
if initialContact != 1 {
|
||||
t.Fatalf("standard EAP initial request INITIAL_CONTACT count = %d, want 1", initialContact)
|
||||
}
|
||||
if mobikeSupported != 1 {
|
||||
t.Fatalf("standard EAP initial request MOBIKE_SUPPORTED count = %d, want 1", mobikeSupported)
|
||||
}
|
||||
if payloads[2].Type != payloadNotify || payloads[3].Type != payloadNotify {
|
||||
t.Fatalf("standard EAP Android notify order = %#v", payloads[:4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationRequestMatchesAndroidAttributes(t *testing.T) {
|
||||
cp := configurationRequest()
|
||||
if cp.Type != payloadCP || len(cp.Body) < 4 || cp.Body[0] != configRequest {
|
||||
t.Fatalf("configuration request = %#v", cp)
|
||||
}
|
||||
var attributes []uint16
|
||||
for offset := 4; offset < len(cp.Body); {
|
||||
if offset+4 > len(cp.Body) {
|
||||
t.Fatalf("truncated attribute at %d", offset)
|
||||
}
|
||||
kind := uint16(cp.Body[offset])<<8 | uint16(cp.Body[offset+1])
|
||||
length := int(cp.Body[offset+2])<<8 | int(cp.Body[offset+3])
|
||||
if offset+4+length > len(cp.Body) {
|
||||
t.Fatalf("attribute %d exceeds payload", kind)
|
||||
}
|
||||
attributes = append(attributes, kind)
|
||||
offset += 4 + length
|
||||
}
|
||||
want := []uint16{1, 8, 3, 10, 20, 21, configApplicationVersion}
|
||||
if !slices.Equal(attributes, want) {
|
||||
t.Fatalf("configuration attributes = %v, want %v", attributes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanyUsesStandardEAPAuthentication(t *testing.T) {
|
||||
for _, mnc := range []string{"03", "003"} {
|
||||
if advertiseEAPOnlyAuthentication("262", mnc) {
|
||||
t.Fatalf("O2 Germany 262-%s unexpectedly uses EAP-only", mnc)
|
||||
}
|
||||
}
|
||||
if !advertiseEAPOnlyAuthentication("262", "02") || !advertiseEAPOnlyAuthentication("234", "15") {
|
||||
t.Fatal("non-O2 PLMN lost the existing EAP-only policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) {
|
||||
epdg := payload{
|
||||
Type: payloadIDr,
|
||||
|
||||
@@ -534,15 +534,26 @@ func newSession(
|
||||
refreshCancel()
|
||||
return nil, errors.New("ims: protected local IP address is unavailable")
|
||||
}
|
||||
protectedClientPort := provider.config.ProtectedClientPort
|
||||
protectedServerPort := provider.config.ProtectedServerPort
|
||||
if securityEncryptionForIdentity(request.Identity) == "null" {
|
||||
if protectedClientPort == 0 {
|
||||
protectedClientPort = 5062
|
||||
}
|
||||
if protectedServerPort == 0 {
|
||||
protectedServerPort = 5063
|
||||
}
|
||||
}
|
||||
proposal, err := newSecurityProposal(
|
||||
localIP,
|
||||
provider.config.ProtectedClientPort,
|
||||
provider.config.ProtectedServerPort,
|
||||
protectedClientPort,
|
||||
protectedServerPort,
|
||||
)
|
||||
if err != nil {
|
||||
refreshCancel()
|
||||
return nil, err
|
||||
}
|
||||
proposal.encryption = securityEncryptionForIdentity(request.Identity)
|
||||
session.securityProposal = proposal
|
||||
protectedTCP, err := net.ListenTCP(
|
||||
"tcp",
|
||||
@@ -567,6 +578,21 @@ func newSession(
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func securityEncryptionForIdentity(identity vowifi.SIMIdentity) string {
|
||||
if usesO2GermanyIMSProfile(identity) {
|
||||
// O2 Germany's P-CSCF advertises the 3GPP integrity-only ESP profile.
|
||||
// Proposing aes-cbc is rejected before the AKA challenge is issued.
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
}
|
||||
|
||||
func usesO2GermanyIMSProfile(identity vowifi.SIMIdentity) bool {
|
||||
mcc := strings.TrimSpace(identity.HomeMCC)
|
||||
mnc := strings.TrimLeft(strings.TrimSpace(identity.HomeMNC), "0")
|
||||
return mcc+mnc == "2623"
|
||||
}
|
||||
|
||||
func (session *Session) abort() {
|
||||
session.refreshCancel()
|
||||
_ = session.conn.Close()
|
||||
@@ -635,6 +661,15 @@ func registrationRejectionError(response *sipResponse, phase string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// P-Debug-Info is carrier-generated but can contain subscriber identifiers.
|
||||
// Surface only a fixed classification for the O2 security-agreement error;
|
||||
// never copy the raw header into logs or API responses.
|
||||
for _, value := range response.values("P-Debug-Info") {
|
||||
if strings.Contains(strings.ToLower(value), "no matched security item") {
|
||||
message += "; carrier detail: no matched IMS security item"
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", ErrRegistrationRejected, message)
|
||||
}
|
||||
|
||||
@@ -775,6 +810,16 @@ func (session *Session) buildRegister(
|
||||
session.instanceID,
|
||||
"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel",
|
||||
)
|
||||
o2Germany := usesO2GermanyIMSProfile(session.request.Identity)
|
||||
supported := "path, gruu"
|
||||
allow := "REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS"
|
||||
if o2Germany {
|
||||
// Match the complete IMS capability set used by the previously working
|
||||
// VoHive client. O2 validates more of the initial UE security profile
|
||||
// than the other tested carriers do.
|
||||
supported = "path, gruu, outbound, sec-agree, 100rel, timer"
|
||||
allow = "INVITE, ACK, CANCEL, BYE, PRACK, UPDATE, INFO, MESSAGE, OPTIONS"
|
||||
}
|
||||
lines := []string{
|
||||
"REGISTER " + requestURI + " SIP/2.0",
|
||||
fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch),
|
||||
@@ -786,13 +831,15 @@ func (session *Session) buildRegister(
|
||||
fmt.Sprintf("CSeq: %d REGISTER", cseq),
|
||||
"Contact: " + contact,
|
||||
fmt.Sprintf("Expires: %d", expires),
|
||||
"Supported: path, gruu",
|
||||
"Allow: REGISTER, INVITE, ACK, CANCEL, BYE, OPTIONS",
|
||||
"Supported: " + supported,
|
||||
"Allow: " + allow,
|
||||
"User-Agent: " + session.provider.config.UserAgent,
|
||||
}
|
||||
if o2Germany {
|
||||
lines = append(lines, "P-Preferred-Identity: <"+session.identity.public+">")
|
||||
}
|
||||
if session.securityOffered() {
|
||||
lines = append(
|
||||
lines,
|
||||
lines = append(lines,
|
||||
"Security-Client: "+session.securityProposal.headerValue(),
|
||||
"Require: sec-agree",
|
||||
"Proxy-Require: sec-agree",
|
||||
@@ -1194,7 +1241,14 @@ func (session *Session) Close(ctx context.Context) error {
|
||||
session.closeInboundConnections()
|
||||
session.receiveDone.Wait()
|
||||
if session.ipsecHandle != nil {
|
||||
if err := session.ipsecHandle.Close(ctx); err != nil {
|
||||
// XFRM teardown is local and must still run when SIP deregistration has
|
||||
// consumed the caller's deadline. Use a fresh bounded context so a
|
||||
// service restart or Profile switch cannot strand the previous SIM's
|
||||
// transport-mode policies in the kernel.
|
||||
cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
err := session.ipsecHandle.Close(cleanupContext)
|
||||
cleanupCancel()
|
||||
if err != nil {
|
||||
cleanupErrors = append(cleanupErrors, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,69 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestO2GermanyInitialRegisterMatchesSupportedIMSProfile(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
identity := vowifi.SIMIdentity{
|
||||
IMSI: "262030123456789",
|
||||
HomeMCC: "262",
|
||||
HomeMNC: "03",
|
||||
}
|
||||
identities, err := deriveIdentities(identity, Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("deriveIdentities() error = %v", err)
|
||||
}
|
||||
session := &Session{
|
||||
provider: &Provider{config: Config{
|
||||
SecurityMode: SecurityRequired,
|
||||
UserAgent: "vocat-test",
|
||||
}},
|
||||
request: vowifi.IMSRequest{Identity: identity},
|
||||
identity: identities,
|
||||
endpoint: pcscfEndpoint{host: "pcscf.example", port: 5060},
|
||||
transport: "tcp",
|
||||
conn: client,
|
||||
callID: "o2-test",
|
||||
fromTag: "tag",
|
||||
instanceID: "urn:uuid:test",
|
||||
securityProposal: securityProposal{
|
||||
spiClient: 101,
|
||||
spiServer: 102,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
},
|
||||
}
|
||||
|
||||
packet, err := session.buildRegister(1, 3600, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRegister() error = %v", err)
|
||||
}
|
||||
_, headers, err := parseTestRequest(packet)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTestRequest() error = %v", err)
|
||||
}
|
||||
if got, want := headers["security-client"], "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000000101;spi-s=0000000102;port-c=5062;port-s=5063"; got != want {
|
||||
t.Fatalf("Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
if headers["proxy-require"] != "sec-agree" || !strings.Contains(headers["authorization"], "integrity-protected=no") {
|
||||
t.Fatalf("initial O2 headers omitted standardized sec-agree/IMS-AKA fields: %#v", headers)
|
||||
}
|
||||
if got, want := headers["p-preferred-identity"], "<"+identities.public+">"; got != want {
|
||||
t.Fatalf("P-Preferred-Identity = %q, want %q", got, want)
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"supported": "sec-agree",
|
||||
"allow": "MESSAGE",
|
||||
} {
|
||||
if !strings.Contains(headers[name], token) {
|
||||
t.Fatalf("%s = %q, want token %q", name, headers[name], token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveRefreshFailure(listener *net.UDPConn, nonce string) error {
|
||||
var callID string
|
||||
for step := 0; step < 3; step++ {
|
||||
|
||||
+197
-47
@@ -39,6 +39,12 @@ var (
|
||||
type IPSecSAConfig struct {
|
||||
LocalIP net.IP
|
||||
RemoteIP net.IP
|
||||
// IntegrityAlgorithm is the negotiated 3GPP Security-Server alg value.
|
||||
// Supported values are hmac-md5-96 and hmac-sha-1-96.
|
||||
IntegrityAlgorithm string
|
||||
// EncryptionAlgorithm is the negotiated 3GPP Security-Server ealg value.
|
||||
// Supported values are null, des-ede3-cbc, and aes-cbc.
|
||||
EncryptionAlgorithm string
|
||||
|
||||
UEClientSPI uint32
|
||||
UEServerSPI uint32
|
||||
@@ -63,10 +69,14 @@ type IPSecSAInstaller interface {
|
||||
}
|
||||
|
||||
type securityProposal struct {
|
||||
spiClient uint32
|
||||
spiServer uint32
|
||||
portClient int
|
||||
portServer int
|
||||
spiClient uint32
|
||||
spiServer uint32
|
||||
portClient int
|
||||
portServer int
|
||||
encryption string
|
||||
fallbackEncryption string
|
||||
integrityAlgorithms []string
|
||||
encryptionAlgorithmsList []string
|
||||
}
|
||||
|
||||
func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredServerPort int) (securityProposal, error) {
|
||||
@@ -96,21 +106,85 @@ func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredSer
|
||||
return securityProposal{}, errors.New("ims: protected UE ports must be distinct non-standard SIP ports")
|
||||
}
|
||||
return securityProposal{
|
||||
spiClient: spiClient,
|
||||
spiServer: spiServer,
|
||||
portClient: portClient,
|
||||
portServer: portServer,
|
||||
spiClient: spiClient,
|
||||
spiServer: spiServer,
|
||||
portClient: portClient,
|
||||
portServer: portServer,
|
||||
integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (proposal securityProposal) headerValue() string {
|
||||
return fmt.Sprintf(
|
||||
"ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
|
||||
proposal.spiClient,
|
||||
proposal.spiServer,
|
||||
proposal.portClient,
|
||||
proposal.portServer,
|
||||
)
|
||||
// TS 33.203 defines spi-c and spi-s as exactly 10 decimal digits. Keep the
|
||||
// complete mechanism explicit even where ESP/null defaults would permit a
|
||||
// shorter form; this is the interoperable handset/IMS profile.
|
||||
values := make([]string, 0, len(proposal.integrities())*len(proposal.encryptionAlgorithms()))
|
||||
index := 0
|
||||
for _, integrity := range proposal.integrities() {
|
||||
for _, encryption := range proposal.encryptionAlgorithms() {
|
||||
preference := fmt.Sprintf("0.%03d", 999-index)
|
||||
if index == 0 {
|
||||
preference = "1.000"
|
||||
}
|
||||
values = append(values, fmt.Sprintf(
|
||||
"ipsec-3gpp;q=%s;alg=%s;prot=esp;mod=trans;ealg=%s;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
|
||||
preference,
|
||||
integrity,
|
||||
encryption,
|
||||
proposal.spiClient,
|
||||
proposal.spiServer,
|
||||
proposal.portClient,
|
||||
proposal.portServer,
|
||||
))
|
||||
index++
|
||||
}
|
||||
}
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithm() string {
|
||||
if strings.EqualFold(strings.TrimSpace(proposal.encryption), "null") {
|
||||
return "null"
|
||||
}
|
||||
return "aes-cbc"
|
||||
}
|
||||
|
||||
func (proposal securityProposal) encryptionAlgorithms() []string {
|
||||
if len(proposal.encryptionAlgorithmsList) > 0 {
|
||||
return append([]string(nil), proposal.encryptionAlgorithmsList...)
|
||||
}
|
||||
algorithms := []string{proposal.encryptionAlgorithm()}
|
||||
fallback := strings.ToLower(strings.TrimSpace(proposal.fallbackEncryption))
|
||||
if (fallback == "aes-cbc" || fallback == "null") && fallback != algorithms[0] {
|
||||
algorithms = append(algorithms, fallback)
|
||||
}
|
||||
return algorithms
|
||||
}
|
||||
|
||||
func (proposal securityProposal) integrities() []string {
|
||||
if len(proposal.integrityAlgorithms) > 0 {
|
||||
return append([]string(nil), proposal.integrityAlgorithms...)
|
||||
}
|
||||
return []string{"hmac-sha-1-96"}
|
||||
}
|
||||
|
||||
func (proposal securityProposal) supportsIntegrity(integrity string) bool {
|
||||
for _, offered := range proposal.integrities() {
|
||||
if strings.EqualFold(integrity, offered) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (proposal securityProposal) supportsEncryption(encryption string) bool {
|
||||
for _, offered := range proposal.encryptionAlgorithms() {
|
||||
if strings.EqualFold(encryption, offered) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func randomSPI(exclude uint32) (uint32, error) {
|
||||
@@ -198,10 +272,10 @@ func parseSecurityAgreement(values []string, proposal securityProposal) (securit
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(mechanism.name, "ipsec-3gpp") ||
|
||||
!strings.EqualFold(mechanism.algorithm, "hmac-sha-1-96") ||
|
||||
!proposal.supportsIntegrity(mechanism.algorithm) ||
|
||||
!strings.EqualFold(mechanism.protocol, "esp") ||
|
||||
!strings.EqualFold(mechanism.mode, "trans") ||
|
||||
!strings.EqualFold(mechanism.encryption, "aes-cbc") {
|
||||
!proposal.supportsEncryption(mechanism.encryption) {
|
||||
continue
|
||||
}
|
||||
if mechanism.spiClient == 0 || mechanism.spiServer == 0 ||
|
||||
@@ -349,16 +423,48 @@ func preferenceValue(value string) (int, error) {
|
||||
return numeric, nil
|
||||
}
|
||||
|
||||
func expandIPSecKeys(ck []byte, ik []byte) (encryption []byte, integrity []byte, err error) {
|
||||
func expandIPSecKeys(ck []byte, ik []byte, encryptionAlgorithm, integrityAlgorithm string) (encryption []byte, integrity []byte, err error) {
|
||||
if len(ck) != 16 || len(ik) != 16 {
|
||||
return nil, nil, errors.New("ims: AKA did not return 16-byte CK and IK")
|
||||
}
|
||||
encryption = append([]byte(nil), ck...)
|
||||
integrity = make([]byte, 20)
|
||||
copy(integrity, ik)
|
||||
switch strings.ToLower(strings.TrimSpace(encryptionAlgorithm)) {
|
||||
case "null":
|
||||
encryption = nil
|
||||
case "aes-cbc", "":
|
||||
encryption = append([]byte(nil), ck...)
|
||||
case "des-ede3-cbc":
|
||||
encryption = append(encryption, ck...)
|
||||
encryption = append(encryption, ck[:8]...)
|
||||
for index, value := range encryption {
|
||||
encryption[index] = withOddDESParity(value)
|
||||
}
|
||||
default:
|
||||
return nil, nil, errors.New("ims: unsupported ipsec-3gpp encryption algorithm")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(integrityAlgorithm)) {
|
||||
case "hmac-md5-96":
|
||||
integrity = append([]byte(nil), ik...)
|
||||
case "hmac-sha-1-96", "":
|
||||
integrity = make([]byte, 20)
|
||||
copy(integrity, ik)
|
||||
default:
|
||||
return nil, nil, errors.New("ims: unsupported ipsec-3gpp integrity algorithm")
|
||||
}
|
||||
return encryption, integrity, nil
|
||||
}
|
||||
|
||||
func withOddDESParity(value byte) byte {
|
||||
value &^= 1
|
||||
ones := 0
|
||||
for bits := value; bits != 0; bits >>= 1 {
|
||||
ones += int(bits & 1)
|
||||
}
|
||||
if ones%2 == 0 {
|
||||
value |= 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type xfrmOperation struct {
|
||||
description string
|
||||
arguments []string
|
||||
@@ -382,20 +488,31 @@ func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
|
||||
{"outbound UE-server to P-CSCF-client state", config.LocalIP, config.RemoteIP, config.PCSCFClientSPI, serverPairReqID(config)},
|
||||
}
|
||||
for _, state := range states {
|
||||
arguments := []string{
|
||||
"xfrm", "state", "add",
|
||||
"src", state.source.String(),
|
||||
"dst", state.destination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", state.spi),
|
||||
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
|
||||
"mode", "transport",
|
||||
"replay-window", "32",
|
||||
"auth-trunc", xfrmIntegrityAlgorithm(config), "0x" + hex.EncodeToString(config.IntegrityKey), "96",
|
||||
}
|
||||
switch ipsecEncryptionAlgorithm(config) {
|
||||
case "aes-cbc":
|
||||
arguments = append(arguments, "enc", "cbc(aes)", "0x"+hex.EncodeToString(config.EncryptionKey))
|
||||
case "des-ede3-cbc":
|
||||
arguments = append(arguments, "enc", "cbc(des3_ede)", "0x"+hex.EncodeToString(config.EncryptionKey))
|
||||
default:
|
||||
// Linux requires an explicit encryption transform for ESP even when
|
||||
// 3GPP negotiates ealg=null. iproute2 must receive a genuinely empty
|
||||
// key argument; the textual value "0x" is rejected by XFRM as EINVAL.
|
||||
arguments = append(arguments, "enc", "cipher_null", "")
|
||||
}
|
||||
operations = append(operations, xfrmOperation{
|
||||
description: state.description,
|
||||
arguments: []string{
|
||||
"xfrm", "state", "add",
|
||||
"src", state.source.String(),
|
||||
"dst", state.destination.String(),
|
||||
"proto", "esp",
|
||||
"spi", fmt.Sprintf("0x%08x", state.spi),
|
||||
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
|
||||
"mode", "transport",
|
||||
"replay-window", "32",
|
||||
"auth-trunc", "hmac(sha1)", "0x" + hex.EncodeToString(config.IntegrityKey), "96",
|
||||
"enc", "cbc(aes)", "0x" + hex.EncodeToString(config.EncryptionKey),
|
||||
},
|
||||
arguments: arguments,
|
||||
})
|
||||
}
|
||||
for _, flow := range xfrmFlows(config) {
|
||||
@@ -588,12 +705,43 @@ func validateIPSecSAConfig(config IPSecSAConfig) error {
|
||||
config.PCSCFClientPort == config.PCSCFServerPort {
|
||||
return errors.New("ims: client and server protected ports must differ")
|
||||
}
|
||||
if len(config.EncryptionKey) != 16 || len(config.IntegrityKey) != 20 {
|
||||
if ipsecEncryptionAlgorithm(config) != "null" && ipsecEncryptionAlgorithm(config) != "aes-cbc" && ipsecEncryptionAlgorithm(config) != "des-ede3-cbc" {
|
||||
return errors.New("ims: unsupported ipsec-3gpp encryption algorithm")
|
||||
}
|
||||
if ipsecIntegrityAlgorithm(config) != "hmac-md5-96" && ipsecIntegrityAlgorithm(config) != "hmac-sha-1-96" {
|
||||
return errors.New("ims: unsupported ipsec-3gpp integrity algorithm")
|
||||
}
|
||||
wantEncryptionKey := map[string]int{"null": 0, "aes-cbc": 16, "des-ede3-cbc": 24}[ipsecEncryptionAlgorithm(config)]
|
||||
wantIntegrityKey := map[string]int{"hmac-md5-96": 16, "hmac-sha-1-96": 20}[ipsecIntegrityAlgorithm(config)]
|
||||
if len(config.EncryptionKey) != wantEncryptionKey || len(config.IntegrityKey) != wantIntegrityKey {
|
||||
return errors.New("ims: ipsec-3gpp key length is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ipsecIntegrityAlgorithm(config IPSecSAConfig) string {
|
||||
algorithm := strings.ToLower(strings.TrimSpace(config.IntegrityAlgorithm))
|
||||
if algorithm == "" {
|
||||
return "hmac-sha-1-96"
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
|
||||
func xfrmIntegrityAlgorithm(config IPSecSAConfig) string {
|
||||
if ipsecIntegrityAlgorithm(config) == "hmac-md5-96" {
|
||||
return "hmac(md5)"
|
||||
}
|
||||
return "hmac(sha1)"
|
||||
}
|
||||
|
||||
func ipsecEncryptionAlgorithm(config IPSecSAConfig) string {
|
||||
algorithm := strings.ToLower(strings.TrimSpace(config.EncryptionAlgorithm))
|
||||
if algorithm == "" {
|
||||
return "aes-cbc"
|
||||
}
|
||||
return algorithm
|
||||
}
|
||||
|
||||
func cloneIPSecSAConfig(config IPSecSAConfig) IPSecSAConfig {
|
||||
config.LocalIP = append(net.IP(nil), config.LocalIP...)
|
||||
config.RemoteIP = append(net.IP(nil), config.RemoteIP...)
|
||||
@@ -657,7 +805,7 @@ func (session *Session) activateIPSec(
|
||||
if !session.securityOffered() {
|
||||
return ErrIPSecAgreementRequired
|
||||
}
|
||||
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik)
|
||||
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik, agreement.selected.encryption, agreement.selected.algorithm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -671,18 +819,20 @@ func (session *Session) activateIPSec(
|
||||
}
|
||||
selected := agreement.selected
|
||||
config := IPSecSAConfig{
|
||||
LocalIP: localIP,
|
||||
RemoteIP: remoteIP,
|
||||
UEClientSPI: session.securityProposal.spiClient,
|
||||
UEServerSPI: session.securityProposal.spiServer,
|
||||
PCSCFClientSPI: selected.spiClient,
|
||||
PCSCFServerSPI: selected.spiServer,
|
||||
UEClientPort: session.securityProposal.portClient,
|
||||
UEServerPort: session.securityProposal.portServer,
|
||||
PCSCFClientPort: selected.portClient,
|
||||
PCSCFServerPort: selected.portServer,
|
||||
EncryptionKey: encryptionKey,
|
||||
IntegrityKey: integrityKey,
|
||||
LocalIP: localIP,
|
||||
RemoteIP: remoteIP,
|
||||
IntegrityAlgorithm: selected.algorithm,
|
||||
EncryptionAlgorithm: selected.encryption,
|
||||
UEClientSPI: session.securityProposal.spiClient,
|
||||
UEServerSPI: session.securityProposal.spiServer,
|
||||
PCSCFClientSPI: selected.spiClient,
|
||||
PCSCFServerSPI: selected.spiServer,
|
||||
UEClientPort: session.securityProposal.portClient,
|
||||
UEServerPort: session.securityProposal.portServer,
|
||||
PCSCFClientPort: selected.portClient,
|
||||
PCSCFServerPort: selected.portServer,
|
||||
EncryptionKey: encryptionKey,
|
||||
IntegrityKey: integrityKey,
|
||||
}
|
||||
handle, err := session.provider.installer.Install(ctx, config)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,8 +23,9 @@ type fakeIPSecInstaller struct {
|
||||
}
|
||||
|
||||
type fakeIPSecHandle struct {
|
||||
mu sync.Mutex
|
||||
closeCount int
|
||||
mu sync.Mutex
|
||||
closeCount int
|
||||
closeContextErr error
|
||||
}
|
||||
|
||||
func (installer *fakeIPSecInstaller) Install(
|
||||
@@ -53,10 +54,11 @@ func (installer *fakeIPSecInstaller) installed() []IPSecSAConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
func (handle *fakeIPSecHandle) Close(context.Context) error {
|
||||
func (handle *fakeIPSecHandle) Close(ctx context.Context) error {
|
||||
handle.mu.Lock()
|
||||
defer handle.mu.Unlock()
|
||||
handle.closeCount++
|
||||
handle.closeContextErr = ctx.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,6 +68,38 @@ func (handle *fakeIPSecHandle) closes() int {
|
||||
return handle.closeCount
|
||||
}
|
||||
|
||||
func (handle *fakeIPSecHandle) contextError() error {
|
||||
handle.mu.Lock()
|
||||
defer handle.mu.Unlock()
|
||||
return handle.closeContextErr
|
||||
}
|
||||
|
||||
func TestSessionCloseCleansIPSecAfterCallerDeadline(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer server.Close()
|
||||
refreshDone := make(chan struct{})
|
||||
close(refreshDone)
|
||||
handle := &fakeIPSecHandle{}
|
||||
session := &Session{
|
||||
conn: client,
|
||||
refreshCancel: func() {},
|
||||
refreshDone: refreshDone,
|
||||
ipsecHandle: handle,
|
||||
calls: make(map[string]*imsCall),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := session.Close(ctx); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if handle.closes() != 1 {
|
||||
t.Fatalf("IPsec close count = %d, want 1", handle.closes())
|
||||
}
|
||||
if err := handle.contextError(); err != nil {
|
||||
t.Fatalf("IPsec cleanup inherited expired caller context: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderNegotiatesIPSecAndRegistersOverProtectedTCP(t *testing.T) {
|
||||
localIP := net.ParseIP("127.0.0.1")
|
||||
remoteIP := net.ParseIP("127.0.0.2")
|
||||
@@ -386,7 +420,12 @@ func serveProtectedRegistrar(
|
||||
return result, fmt.Errorf("initial sec-agree headers = %#v", headers)
|
||||
}
|
||||
result.securityClient = headers["security-client"]
|
||||
proposal, err := parseSecurityMechanism(result.securityClient)
|
||||
offers := splitHeaderValues([]string{result.securityClient})
|
||||
if len(offers) != 6 {
|
||||
_ = initialConnection.Close()
|
||||
return result, fmt.Errorf("initial Security-Client offers = %d, want 6", len(offers))
|
||||
}
|
||||
proposal, err := parseSecurityMechanism(offers[0])
|
||||
if err != nil {
|
||||
_ = initialConnection.Close()
|
||||
return result, fmt.Errorf("parse initial Security-Client: %w", err)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vocat/internal/vowifi"
|
||||
)
|
||||
|
||||
func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
@@ -36,6 +38,51 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestO2GermanySecurityProposalUsesIntegrityOnlyESP(t *testing.T) {
|
||||
identity := vowifi.SIMIdentity{HomeMCC: "262", HomeMNC: "03"}
|
||||
if got := securityEncryptionForIdentity(identity); got != "null" {
|
||||
t.Fatalf("O2 security encryption = %q, want null", got)
|
||||
}
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002,
|
||||
portClient: 40666, portServer: 55610,
|
||||
encryption: securityEncryptionForIdentity(identity),
|
||||
}
|
||||
if got, want := proposal.headerValue(), "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null;spi-c=0000001001;spi-s=0000001002;port-c=40666;port-s=55610"; got != want {
|
||||
t.Fatalf("O2 Security-Client = %q, want %q", got, want)
|
||||
}
|
||||
selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=null;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
if _, err := parseSecurityAgreement([]string{selected}, proposal); err != nil {
|
||||
t.Fatalf("O2 null Security-Server rejected: %v", err)
|
||||
}
|
||||
|
||||
identity.HomeMNC = "02"
|
||||
if got := securityEncryptionForIdentity(identity); got != "aes-cbc" {
|
||||
t.Fatalf("non-O2 security encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityAgreementAcceptsO2FallbackEncryption(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
spiServer: 1002,
|
||||
portClient: 5062,
|
||||
portServer: 5063,
|
||||
encryption: "null",
|
||||
fallbackEncryption: "aes-cbc",
|
||||
}
|
||||
value := "ipsec-3gpp;q=0.5;alg=hmac-sha-1-96;prot=esp;mod=trans;" +
|
||||
"ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600"
|
||||
agreement, err := parseSecurityAgreement([]string{value}, proposal)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSecurityAgreement(aes-cbc fallback) error = %v", err)
|
||||
}
|
||||
if got := agreement.selected.encryption; got != "aes-cbc" {
|
||||
t.Fatalf("selected encryption = %q, want aes-cbc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSecurityAgreementSkipsIncompleteCarrierAlternatives(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001,
|
||||
@@ -121,7 +168,7 @@ func TestParseSecurityAgreementFailsClosed(t *testing.T) {
|
||||
func TestExpandIPSecKeys(t *testing.T) {
|
||||
ck := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
ik := []byte{16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}
|
||||
encryption, integrity, err := expandIPSecKeys(ck, ik)
|
||||
encryption, integrity, err := expandIPSecKeys(ck, ik, "aes-cbc", "hmac-sha-1-96")
|
||||
if err != nil {
|
||||
t.Fatalf("expandIPSecKeys() error = %v", err)
|
||||
}
|
||||
@@ -139,6 +186,71 @@ func TestExpandIPSecKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandIPSecKeysForAndroidAlgorithmSet(t *testing.T) {
|
||||
ck := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}
|
||||
ik := []byte{0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38}
|
||||
tripleDES, md5Key, err := expandIPSecKeys(ck, ik, "des-ede3-cbc", "hmac-md5-96")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tripleDES) != 24 || len(md5Key) != 16 {
|
||||
t.Fatalf("3DES/MD5 key lengths = %d/%d", len(tripleDES), len(md5Key))
|
||||
}
|
||||
for _, value := range tripleDES {
|
||||
ones := 0
|
||||
for bits := value; bits != 0; bits >>= 1 {
|
||||
ones += int(bits & 1)
|
||||
}
|
||||
if ones%2 != 1 {
|
||||
t.Fatalf("3DES byte %02x does not have odd parity", value)
|
||||
}
|
||||
}
|
||||
nullKey, sha1Key, err := expandIPSecKeys(ck, ik, "null", "hmac-sha-1-96")
|
||||
if err != nil || len(nullKey) != 0 || len(sha1Key) != 20 {
|
||||
t.Fatalf("null/SHA1 keys = %d/%d, %v", len(nullKey), len(sha1Key), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidIMSProposalOffersAllDefaultAlgorithms(t *testing.T) {
|
||||
proposal := securityProposal{
|
||||
spiClient: 1001, spiServer: 1002, portClient: 5062, portServer: 5063,
|
||||
integrityAlgorithms: []string{"hmac-md5-96", "hmac-sha-1-96"},
|
||||
encryptionAlgorithmsList: []string{"null", "des-ede3-cbc", "aes-cbc"},
|
||||
}
|
||||
header := proposal.headerValue()
|
||||
for _, combination := range []string{
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=null",
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=des-ede3-cbc",
|
||||
"alg=hmac-md5-96;prot=esp;mod=trans;ealg=aes-cbc",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=null",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=des-ede3-cbc",
|
||||
"alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc",
|
||||
} {
|
||||
if !strings.Contains(header, combination) {
|
||||
t.Fatalf("Android IMS Security-Client omitted %q: %s", combination, header)
|
||||
}
|
||||
}
|
||||
if got := len(splitHeaderValues([]string{header})); got != 6 {
|
||||
t.Fatalf("Security-Client mechanism count = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSecurityProposalUsesAndroidDefaultsForEveryCarrier(t *testing.T) {
|
||||
proposal, err := newSecurityProposal(net.ParseIP("127.0.0.1"), 45062, 45063)
|
||||
if err != nil {
|
||||
t.Fatalf("newSecurityProposal() error = %v", err)
|
||||
}
|
||||
if got, want := proposal.integrities(), []string{"hmac-md5-96", "hmac-sha-1-96"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("integrity algorithms = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := proposal.encryptionAlgorithms(), []string{"null", "des-ede3-cbc", "aes-cbc"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("encryption algorithms = %v, want %v", got, want)
|
||||
}
|
||||
if got := len(splitHeaderValues([]string{proposal.headerValue()})); got != 6 {
|
||||
t.Fatalf("Security-Client mechanism count = %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
@@ -209,6 +321,46 @@ func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanSupportsIntegrityOnlyESP(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.EncryptionAlgorithm = "null"
|
||||
config.EncryptionKey = nil
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXFRMInstallPlan(null) error = %v", err)
|
||||
}
|
||||
for index, operation := range install[:4] {
|
||||
joined := strings.Join(operation.arguments, " ")
|
||||
if !strings.Contains(joined, "auth-trunc hmac(sha1)") {
|
||||
t.Fatalf("null state %d omitted integrity: %v", index, operation.arguments)
|
||||
}
|
||||
if !containsArguments(operation.arguments, "enc", "cipher_null", "") {
|
||||
t.Fatalf("null state %d omitted Linux NULL cipher: %v", index, operation.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXFRMPlanSupportsAndroidMD5AndTripleDES(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.IntegrityAlgorithm = "hmac-md5-96"
|
||||
config.EncryptionAlgorithm = "des-ede3-cbc"
|
||||
config.IntegrityKey = []byte(strings.Repeat("\x22", 16))
|
||||
config.EncryptionKey = []byte(strings.Repeat("\x11", 24))
|
||||
|
||||
install, err := buildXFRMInstallPlan(config)
|
||||
if err != nil {
|
||||
t.Fatalf("buildXFRMInstallPlan() error = %v", err)
|
||||
}
|
||||
for index, operation := range install[:4] {
|
||||
if !containsArguments(operation.arguments, "auth-trunc", "hmac(md5)", "0x"+strings.Repeat("22", 16), "96") {
|
||||
t.Fatalf("state %d omitted HMAC-MD5-96 transform: %v", index, operation.arguments)
|
||||
}
|
||||
if !containsArguments(operation.arguments, "enc", "cbc(des3_ede)", "0x"+strings.Repeat("11", 24)) {
|
||||
t.Fatalf("state %d omitted 3DES-CBC transform: %v", index, operation.arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIPSecSAConfigRejectsDuplicateSPI(t *testing.T) {
|
||||
config := testIPSecSAConfig()
|
||||
config.PCSCFServerSPI = config.UEClientSPI
|
||||
|
||||
@@ -28,21 +28,37 @@ func (resolver ProxyResolver) Resolve(
|
||||
}
|
||||
deviceID := strings.TrimSpace(request.DeviceID)
|
||||
iccid := strings.TrimSpace(request.ICCID)
|
||||
if deviceID == "" || iccid == "" {
|
||||
if deviceID == "" {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
var upstreamID string
|
||||
if iccid != "" {
|
||||
binding, err := resolver.Store.DeviceProxyBinding(ctx, iccid)
|
||||
if err == nil {
|
||||
upstreamID = binding.UpstreamProxyID
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for ICCID %s: %w", iccid, err)
|
||||
if upstreamID == "" {
|
||||
country, found := device.CountryForMCC(strings.TrimSpace(request.HomeMCC))
|
||||
if !found {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
rule, ruleErr := resolver.Store.CountryRule(ctx, country)
|
||||
if errors.Is(ruleErr, store.ErrNotFound) || (ruleErr == nil && !rule.Enabled) {
|
||||
return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil
|
||||
}
|
||||
if ruleErr != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy country rule for MCC %s: %w", request.HomeMCC, ruleErr)
|
||||
}
|
||||
upstreamID = rule.UpstreamProxyID
|
||||
}
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID)
|
||||
upstream, err := resolver.Store.UpstreamProxy(ctx, upstreamID)
|
||||
if err != nil {
|
||||
return vowifi.ProxyRoute{}, fmt.Errorf(
|
||||
"load upstream proxy %q for device %s: %w",
|
||||
binding.UpstreamProxyID,
|
||||
upstreamID,
|
||||
deviceID,
|
||||
err,
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestProxyResolverDoesNotLeakBindingToAnotherProfileOnSameDevice(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
|
||||
func TestProxyResolverUsesCountryRuleWithoutICCIDBinding(t *testing.T) {
|
||||
database := testStore(t)
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{
|
||||
ID: "legacy", Name: "Legacy", Addr: "127.0.0.1:1080", Enabled: true,
|
||||
@@ -98,8 +98,42 @@ func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.Mode != vowifi.ProxyModeDirect {
|
||||
t.Fatalf("route = %#v, want direct", route)
|
||||
if route.Mode != vowifi.ProxyModeSOCKS5 || route.ID != "legacy" {
|
||||
t.Fatalf("route = %#v, want MCC country fallback", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyResolverPrefersICCIDBindingOverCountryRule(t *testing.T) {
|
||||
database := testStore(t)
|
||||
for _, proxy := range []store.UpstreamProxy{
|
||||
{ID: "profile", Name: "Profile", Addr: "127.0.0.1:1080", Enabled: true},
|
||||
{ID: "country", Name: "Country", Addr: "127.0.0.1:1081", Enabled: true},
|
||||
} {
|
||||
if err := database.UpsertUpstreamProxy(context.Background(), proxy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := database.UpsertCountryRule(context.Background(), store.CountryRule{
|
||||
CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "country", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", ProfileName: "Physical SIM", UpstreamProxyID: "profile",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route, err := (ProxyResolver{Store: database}).Resolve(context.Background(), vowifi.ProxyRequest{
|
||||
DeviceID: "ec20", ICCID: "89441000400128014257", HomeMCC: "234",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if route.ID != "profile" {
|
||||
t.Fatalf("route = %#v, want ICCID binding", route)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Refresh VoCat's offline PLMN name table from Android's carrier database.
|
||||
"""Refresh VoCat's offline carrier table from Android's carrier database.
|
||||
|
||||
The AOSP carrier ID table is maintained for Android's own carrier recognition.
|
||||
Only unconstrained MCC/MNC records are used here: MVNO matches that also require
|
||||
an SPN, IMSI prefix, GID or ICCID prefix must not rename the serving MNO.
|
||||
The compact ``c`` map remains the MCC/MNC-only fallback. The ``r`` list keeps
|
||||
Android's SIM-identity rules (IMSI, ICCID, SPN and GID) so travel eSIMs and
|
||||
MVNOs sharing an MNO's PLMN can be identified without hard-coded exceptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -125,10 +125,61 @@ def aosp_carriers(text: str) -> dict[str, str]:
|
||||
return carriers
|
||||
|
||||
|
||||
RULE_FIELDS = {
|
||||
"mccmnc_tuple": "m",
|
||||
"imsi_prefix_xpattern": "x",
|
||||
"spn": "s",
|
||||
"gid1": "g1",
|
||||
"gid2": "g2",
|
||||
"iccid_prefix": "i",
|
||||
}
|
||||
|
||||
|
||||
def textproto_strings(block: str, field: str) -> list[str]:
|
||||
return [
|
||||
json.loads(value)
|
||||
for value in re.findall(
|
||||
rf"^\s*{re.escape(field)}:\s*(\"(?:\\.|[^\"\\])*\")",
|
||||
block,
|
||||
re.M,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def aosp_identity_rules(text: str) -> list[dict[str, object]]:
|
||||
"""Return rules VoCat can evaluate using values read directly from a SIM.
|
||||
|
||||
Android combines different fields with AND and repeated values of one field
|
||||
with OR. Rules that also require APN, PNN/PLMN or carrier certificates are
|
||||
omitted until those inputs are available; treating an unavailable input as
|
||||
a wildcard would incorrectly identify subscriptions.
|
||||
"""
|
||||
|
||||
supported = set(RULE_FIELDS)
|
||||
rules: list[dict[str, object]] = []
|
||||
for carrier in braced_blocks(text, "carrier_id"):
|
||||
name = textproto_string(carrier, "carrier_name").strip()
|
||||
if not name:
|
||||
continue
|
||||
for attribute in braced_blocks(carrier, "carrier_attribute"):
|
||||
fields = set(re.findall(r"^\s*([a-zA-Z0-9_]+)\s*:", attribute, re.M))
|
||||
if not fields.issubset(supported) or fields == {"mccmnc_tuple"}:
|
||||
continue
|
||||
rule: dict[str, object] = {"n": name}
|
||||
for source_field, output_field in RULE_FIELDS.items():
|
||||
values = textproto_strings(attribute, source_field)
|
||||
if values:
|
||||
rule[output_field] = values
|
||||
if rule.get("m"):
|
||||
rules.append(rule)
|
||||
return rules
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with urllib.request.urlopen(SOURCE_URL, timeout=30) as response:
|
||||
source = base64.b64decode(response.read()).decode("utf-8")
|
||||
names = aosp_carriers(source)
|
||||
identity_rules = aosp_identity_rules(source)
|
||||
table = json.loads(FRONTEND_TABLE.read_text(encoding="utf-8"))
|
||||
countries: dict[str, str] = table["i"]
|
||||
countries.update({str(mcc): "us" for mcc in range(310, 317)})
|
||||
@@ -149,19 +200,23 @@ def main() -> None:
|
||||
"c": dict(sorted(carriers.items())),
|
||||
"i": dict(sorted(countries.items())),
|
||||
"t": sorted(set(table["t"])),
|
||||
"r": identity_rules,
|
||||
"meta": {
|
||||
"source": "Android Open Source Project carrier_list.textpb",
|
||||
"source_url": SOURCE_URL.removesuffix("?format=TEXT"),
|
||||
"aosp_version": version_match.group(1) if version_match else "unknown",
|
||||
"aosp_generic_records": len(names),
|
||||
"aosp_identity_rules": len(identity_rules),
|
||||
},
|
||||
}
|
||||
encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
FRONTEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
BACKEND_TABLE.write_text(encoded, encoding="utf-8", newline="\n")
|
||||
frontend_encoded = json.dumps(output, ensure_ascii=False, indent=4) + "\n"
|
||||
backend_encoded = json.dumps(output, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
FRONTEND_TABLE.write_text(frontend_encoded, encoding="utf-8", newline="\n")
|
||||
BACKEND_TABLE.write_text(backend_encoded, encoding="utf-8", newline="\n")
|
||||
print(
|
||||
f"updated {len(carriers)} PLMN records "
|
||||
f"({len(names)} generic AOSP records, version {output['meta']['aosp_version']})"
|
||||
f"({len(names)} generic records, {len(identity_rules)} identity rules, "
|
||||
f"version {output['meta']['aosp_version']})"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FieldRow } from "./FieldRow";
|
||||
import { useShowSensitive } from "./shared";
|
||||
import type { DeviceDetail } from "./types";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { carrierBrandIso } from "../../lib/carrier";
|
||||
import { carrierIso } from "../../lib/carrier";
|
||||
import { CountryFlag } from "../CountryFlag";
|
||||
|
||||
export interface OverviewSimPanelProps {
|
||||
@@ -22,7 +22,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber
|
||||
const sensitive = !showSensitive;
|
||||
const activeEsim = (device.activeEsimProfileName || "").trim();
|
||||
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
|
||||
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
|
||||
const carrierCountryCode = String(modem?.homeCarrierCountryCode ?? "").trim() || carrierIso(modem?.imsi);
|
||||
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
|
||||
const backendLabel =
|
||||
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -259,20 +259,18 @@ export function simOperatorDisplay(device?: DeviceDetail | null): string {
|
||||
const spn = String(modem?.nativeSpn ?? "").trim();
|
||||
const name = oplPnnName(modem) || firstPnnName(modem?.pnn);
|
||||
const plmn = plmnOf(modem);
|
||||
// EF_SPN is the SIM's customer-facing brand. Do not append the currently
|
||||
// visited PLMN: a roaming Lebara UK SIM on a Chinese network would otherwise
|
||||
// be mislabeled as "Lebara (460xx)". Append the home/authentication PLMN
|
||||
// resolved from IMSI instead, so GigSky on 222-01 renders as
|
||||
// "GigSky (22201)" even while roaming.
|
||||
if (spn) {
|
||||
const home = lookupCarrier(modem?.imsi);
|
||||
return withPlmn(spn, home ? home.mcc + home.mnc : cardPlmnOf(modem));
|
||||
}
|
||||
if (name) return withPlmn(name, plmn);
|
||||
// Home ("original") carrier resolved from the SIM's IMSI via the MCC/MNC table.
|
||||
// Readable even when the modem isn't camped (VoWiFi RF-off / flight mode).
|
||||
// "Original Carrier" means the IMSI home/authentication network. EF_SPN is
|
||||
// only a profile-supplied display brand (travel eSIMs and MVNOs may put their
|
||||
// storefront name there), so it must not override a known home PLMN.
|
||||
const resolvedName = String(modem?.homeCarrierName ?? "").trim();
|
||||
const resolvedPLMN = String(modem?.homeCarrierPlmn ?? "").trim();
|
||||
if (resolvedName) return withPlmn(resolvedName, resolvedPLMN);
|
||||
const carrier = lookupCarrier(modem?.imsi);
|
||||
if (carrier) return withPlmn(carrier.name, carrier.mcc + carrier.mnc);
|
||||
// If the bundled carrier database cannot resolve the home PLMN, retain the
|
||||
// card's own labels as graceful, data-driven fallbacks.
|
||||
if (spn) return withPlmn(spn, cardPlmnOf(modem));
|
||||
if (name) return withPlmn(name, plmn);
|
||||
if (plmn) return plmn;
|
||||
const cardPlmn = cardPlmnOf(modem);
|
||||
if (cardPlmn) return cardPlmn;
|
||||
|
||||
@@ -21,6 +21,10 @@ function profileLabel(profile: { name?: string; serviceProviderName?: string; ic
|
||||
return String(profile.name || profile.serviceProviderName || profile.iccid).trim();
|
||||
}
|
||||
|
||||
function currentDeviceICCID(device: DeviceListItem) {
|
||||
return String(device.modem?.iccid || device.vowifiRuntime?.iccid || "").trim();
|
||||
}
|
||||
|
||||
export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { open, proxy, proxies, devices, bindings, busy, onAdd, onDelete, onClose } = props;
|
||||
@@ -30,7 +34,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const proxyName = proxy?.name || proxy?.id || "";
|
||||
const deviceKey = devices
|
||||
.map((device) => `${device.id}:${String(device.modem?.iccid || "").trim()}`)
|
||||
.map((device) => `${device.id}:${currentDeviceICCID(device)}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const current = useMemo(
|
||||
@@ -53,7 +57,7 @@ export function DeviceBindingsDialog(props: DeviceBindingsDialogProps) {
|
||||
let active = true;
|
||||
setLoadingProfiles(true);
|
||||
Promise.allSettled(devices.map(async (device) => {
|
||||
const currentICCID = String(device.modem?.iccid || "").trim();
|
||||
const currentICCID = currentDeviceICCID(device);
|
||||
let installed: ProfileProxyCandidate[] = [];
|
||||
try {
|
||||
const data = await api<EsimOverview>(`/devices/${encodeURIComponent(device.id)}/esim`);
|
||||
|
||||
@@ -101,7 +101,7 @@ export function NetworkAccessCard({
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。")}
|
||||
{t("在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。")}
|
||||
</p>
|
||||
{cidrs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/30 py-2 text-center text-xs text-gray-400 dark:border-white/10 dark:bg-white/5">
|
||||
|
||||
@@ -47,13 +47,3 @@ export function carrierIso(imsi?: string): string {
|
||||
if (hit) return hit.iso;
|
||||
return data.i[imsiDigits(imsi).slice(0, 3)] ?? "";
|
||||
}
|
||||
|
||||
// carrierBrandIso keeps the normal IMSI country flag for branded/MVNO SIMs.
|
||||
// Lebara UK's Vodafone-NL-hosted 204-04 eSIM is the one known exception: its
|
||||
// customer-facing country is GB even though AKA must continue using 204-04.
|
||||
export function carrierBrandIso(spn?: string, imsi?: string): string {
|
||||
const brand = String(spn ?? "").trim().toLowerCase();
|
||||
const digits = imsiDigits(imsi);
|
||||
if (brand.includes("lebara") && digits.startsWith("20404")) return "gb";
|
||||
return carrierIso(imsi);
|
||||
}
|
||||
|
||||
@@ -352,8 +352,8 @@ export const EN_DICT: Record<string, string> = {
|
||||
"Opening to the public internet greatly expands the attack surface. Use a strong password and switch back to Internal Only as soon as possible.",
|
||||
额外放行网段: "Additional Allowed Ranges",
|
||||
添加网段: "Add Range",
|
||||
"在内置内网网段之外始终放行的 CIDR 或单个 IP(例如 203.0.113.0/24)。":
|
||||
"CIDRs or single IPs always allowed in addition to the built-in internal ranges (e.g. 203.0.113.0/24).",
|
||||
"在内置内网网段之外始终放行的 CIDR 或单个 IP;也允许通知推送访问这些目标地址(例如 198.18.0.0/15)。":
|
||||
"CIDRs or single IPs always allowed in addition to the built-in internal ranges; notification delivery may also access these destinations (e.g. 198.18.0.0/15).",
|
||||
暂无额外放行网段: "No additional allowed ranges",
|
||||
信任代理请求头: "Trust Proxy Headers",
|
||||
"仅在系统位于可信反向代理之后时开启,按 X-Forwarded-For 判定来源;否则客户端可伪造该头绕过内网限制。":
|
||||
@@ -753,8 +753,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",
|
||||
|
||||
+5247
-2
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,9 @@ export interface ModemSummary {
|
||||
cardMcc?: string;
|
||||
cardMnc?: string;
|
||||
cardCountry?: string;
|
||||
homeCarrierName?: string;
|
||||
homeCarrierPlmn?: string;
|
||||
homeCarrierCountryCode?: string;
|
||||
serviceBlocked?: boolean;
|
||||
blockedReason?: string;
|
||||
networkMode: string;
|
||||
|
||||
Reference in New Issue
Block a user