6 Commits
Author SHA1 Message Date
MengMengCode d37a191011 feat: add home carrier information to ModemSummary interface 2026-08-14 00:22:39 +08:00
MengMengCode ba28c7f79a Rollback 2026-08-13 23:38:45 +08:00
MengMengCode 773b44aad6 Implement 3GPP Device Identity Handling in IKE
- Added support for DEVICE_IDENTITY request as per TS 24.302.
- Implemented deviceIdentityRequested function to check for DEVICE_IDENTITY notifications.
- Created deviceIdentityNotify function to construct DEVICE_IDENTITY notifications with IMEI/IMEISV.
- Enhanced security proposal to accommodate O2 Germany's integrity-only ESP profile.
- Updated tests to validate DEVICE_IDENTITY encoding and request handling.
- Refactored security proposal to include fallback encryption and integrity algorithms.
- Improved session management to ensure proper cleanup of IPSec resources after caller deadline.
- Added comprehensive tests for security agreement parsing and algorithm support.

FIX #11
2026-08-13 23:30:00 +08:00
MengMengCode 2917378da0 fix: support O2 Germany certificate-authenticated EAP 2026-08-13 21:28:38 +08:00
MengMengCode 69b1c3eec6 fix: stabilize EM7430 hotplug support 2026-08-13 20:58:24 +08:00
MengMengCode 01e25ff12c feat: support Sierra EM7430 MBIM modems 2026-08-13 20:05:07 +08:00
44 changed files with 6902 additions and 243 deletions
+153 -1
View File
@@ -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
}
+9 -1
View File
@@ -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 = &copy
}
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
}
+14 -1
View File
@@ -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
+12 -2
View File
@@ -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
}
+3
View File
@@ -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)
+37
View File
@@ -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)
}
}
+105
View File
@@ -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)
+4
View File
@@ -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)
+64 -54
View File
@@ -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,
}
}
+41 -7
View File
@@ -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)
+21
View File
@@ -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) {
+1 -1
View File
@@ -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()
+22 -12
View File
@@ -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 + ")"}, " "))
}
+5 -2
View File
@@ -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 != "--(飞行模式)" {
+4
View File
@@ -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 {
+11 -3
View File
@@ -285,6 +285,7 @@ type akaClient struct {
simIdentity vowifi.SIMIdentity
provider vowifi.AKAProvider
keys akaKeys
lastResponseStage string
challengeComplete bool
resultIndication bool
protectedSuccess bool
@@ -298,7 +299,12 @@ func newAKAClient(identity vowifi.SIMIdentity, provider vowifi.AKAProvider) (*ak
if err != nil {
return nil, err
}
return &akaClient{identity: nai, simIdentity: identity, provider: provider}, nil
return &akaClient{
identity: nai,
simIdentity: identity,
provider: provider,
lastResponseStage: "in the initial IKE_AUTH identity exchange",
}, nil
}
func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction, error) {
@@ -308,9 +314,9 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
}
switch packet.Code {
case eapFailure:
stage := "before the SIM AKA challenge (identity or subscription rejected)"
stage := client.lastResponseStage
if client.challengeComplete {
stage = "after the SIM AKA response (AKA result or subscription rejected)"
stage = "after the SIM AKA challenge response"
}
return eapAction{}, fmt.Errorf("%w %s", vowifi.ErrEAPAuthenticationRejected, stage)
case eapSuccess:
@@ -333,6 +339,7 @@ func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction,
Type: eapTypeIdentity,
Data: client.identity,
})
client.lastResponseStage = "after EAP-Response/Identity"
return eapAction{Response: response}, err
case eapTypeAKA:
return client.handleAKARequest(ctx, packet)
@@ -405,6 +412,7 @@ func (client *akaClient) respondAKAIdentity(identifier uint8, attributes []akaAt
Type: eapTypeAKA,
Data: data,
})
client.lastResponseStage = "after EAP-Response/AKA-Identity"
return eapAction{Response: response}, err
}
+20 -2
View File
@@ -106,16 +106,34 @@ func TestEAPFailureReportsAuthenticationStage(t *testing.T) {
t.Fatal(err)
}
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "before the SIM AKA challenge") {
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "initial IKE_AUTH identity exchange") {
t.Fatalf("pre-challenge failure = %v", err)
}
client.challengeComplete = true
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA response") {
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA challenge response") {
t.Fatalf("post-challenge failure = %v", err)
}
}
func TestEAPFailureReportsIdentityResponseStage(t *testing.T) {
client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{})
if err != nil {
t.Fatal(err)
}
identityRequest, _ := marshalEAPPacket(eapPacket{
Code: eapRequest, Identifier: 4, Type: eapTypeIdentity,
})
if _, err := client.handle(context.Background(), identityRequest); err != nil {
t.Fatal(err)
}
failure, _ := marshalEAPPacket(eapPacket{Code: eapFailure, Identifier: 5})
_, err = client.handle(context.Background(), failure)
if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after EAP-Response/Identity") {
t.Fatalf("identity-stage failure = %v", err)
}
}
func TestEAPAKAChallengeTypedSIMAndMAC(t *testing.T) {
result := vowifi.AKAResult{
RES: bytes.Repeat([]byte{0x91}, 8),
+57
View File
@@ -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 != ""
}
+39
View File
@@ -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)
}
}
+90 -19
View File
@@ -111,6 +111,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelReques
group := uint16(dhMODP2048)
legacyFirst := legacyIKEProfile(request.Identity.HomeMCC, request.Identity.HomeMNC)
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))
}
}
+75 -17
View File
@@ -42,15 +42,17 @@ func (reader constantReader) Read(destination []byte) (int, error) {
}
type firstAuthCaptureTransport struct {
t *testing.T
calls int
suite negotiatedSuite
keys ikeKeys
spii [8]byte
spir [8]byte
nonceI []byte
nonceR []byte
floated bool
t *testing.T
wantEAPOnly bool
wantGroup uint16
calls int
suite negotiatedSuite
keys ikeKeys
spii [8]byte
spir [8]byte
nonceI []byte
nonceR []byte
floated bool
}
func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr {
@@ -96,8 +98,16 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
return nil, err
}
group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1])
if group != dhMODP1024 || len(ke.Body[4:]) != 128 {
transport.t.Fatalf("Vodafone init KE = group %d length %d", group, len(ke.Body[4:]))
wantGroup := transport.wantGroup
if wantGroup == 0 {
wantGroup = dhMODP1024
}
wantKELength := 128
if wantGroup == dhMODP2048 {
wantKELength = 256
}
if group != wantGroup || len(ke.Body[4:]) != wantKELength {
transport.t.Fatalf("init KE = group %d length %d, want group %d length %d", group, len(ke.Body[4:]), wantGroup, wantKELength)
}
serverDH, err := newDHExchange(group, constantReader{value: 0x77})
if err != nil {
@@ -108,6 +118,15 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
return nil, err
}
transport.suite = legacyTestSuite()
if group == dhMODP2048 {
transport.suite = negotiatedSuite{
EncryptionID: encryptionAESCBC,
EncryptionBits: 128,
PRFID: prfHMACSHA256,
IntegrityID: integrityHMACSHA256_128,
DHID: dhMODP2048,
}
}
transport.spii = header.InitiatorSPI
transport.spir = [8]byte{0x80, 1, 2, 3, 4, 5, 6, 7}
transport.nonceI = append([]byte(nil), nonce.Body...)
@@ -128,9 +147,9 @@ func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte
Protocol: protocolIKE,
Transforms: []transform{
{Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128},
{Type: transformPRF, ID: prfHMACSHA1},
{Type: transformIntegrity, ID: integrityHMACSHA1_96},
{Type: transformDH, ID: dhMODP1024},
{Type: transformPRF, ID: transport.suite.PRFID},
{Type: transformIntegrity, ID: transport.suite.IntegrityID},
{Type: transformDH, ID: group},
},
}})
keBody := make([]byte, 4+len(serverDH.Public))
@@ -180,8 +199,8 @@ func (transport *firstAuthCaptureTransport) observeFirstAuth(packet []byte) erro
foundEAPOnly = true
}
}
if !foundEAPOnly {
transport.t.Fatal("first IKE_AUTH omitted EAP_ONLY_AUTHENTICATION")
if foundEAPOnly != transport.wantEAPOnly {
transport.t.Fatalf("first IKE_AUTH EAP_ONLY_AUTHENTICATION present=%v, want %v", foundEAPOnly, transport.wantEAPOnly)
}
for _, kind := range []uint8{payloadIDi, payloadSA, payloadTSi, payloadTSr, payloadCP} {
if _, err := onePayload(payloads, kind); err != nil {
@@ -212,7 +231,7 @@ func (unusedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, e
}
func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t}
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: true}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
@@ -250,5 +269,44 @@ func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) {
}
}
func TestProviderO2GermanyFirstAuthUsesStandardEAPAndRequestsIMSAPN(t *testing.T) {
capture := &firstAuthCaptureTransport{t: t, wantEAPOnly: false, wantGroup: dhMODP2048}
provider, err := NewProvider(Config{
Random: constantReader{value: 0x42},
Timeout: time.Second,
Installer: unusedInstaller{},
APN: "ims",
})
if err != nil {
t.Fatal(err)
}
provider.transportFactory = func(
context.Context,
transportConfig,
vowifi.ProxyRoute,
string,
) (datagramTransport, error) {
return capture, nil
}
aka := &testAKAProvider{}
_, err = provider.Start(context.Background(), vowifi.TunnelRequest{
DeviceID: "ec20-o2",
Identity: vowifi.SIMIdentity{
ICCID: "8949200000000000000",
IMSI: "262030123456789",
HomeMCC: "262",
HomeMNC: "03",
},
EPDG: "epdg.epc.mnc003.mcc262.pub.3gppnetwork.org",
AKA: aka,
})
if !errors.Is(err, errFirstAuthObserved) {
t.Fatalf("Start() error = %v, want capture sentinel", err)
}
if capture.calls != 2 || capture.floated || aka.calls != 0 {
t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls)
}
}
var _ io.Reader = constantReader{}
var _ datagramTransport = (*firstAuthCaptureTransport)(nil)
+50
View File
@@ -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()
+45 -1
View File
@@ -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,
)
+9 -5
View File
@@ -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 (
+94 -2
View File
@@ -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,
+61 -7
View File
@@ -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)
}
}
+63
View File
@@ -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
View File
@@ -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 {
+43 -4
View File
@@ -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)
+153 -1
View File
@@ -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
+24 -8
View File
@@ -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,
)
+37 -3
View File
@@ -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)
}
}
+63 -8
View File
@@ -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 />
+10 -12
View File
@@ -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">
-10
View File
@@ -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);
}
+4 -4
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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;