From f697c418a55cff19cd6bec6d8974b2f47c2766c1 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:37:40 +0800 Subject: [PATCH] FIX #68 #28 --- go.mod | 3 +- go.sum | 2 + internal/device/sms_pdu.go | 272 ++++++++++++++++++++--- internal/device/sms_pdu_test.go | 114 ++++++++++ internal/device/types.go | 3 + internal/vowifi/carrier_ipcc.go | 8 +- internal/vowifi/carrier_ipcc_test.go | 1 - internal/vowifi/ims/call_runtime.go | 12 +- internal/vowifi/ims/call_runtime_test.go | 2 +- internal/vowifi/ims/provider.go | 73 +++++- internal/vowifi/ims/provider_test.go | 65 ++++-- internal/vowifi/ims/sms_runtime.go | 4 + internal/vowifi/ims/sms_runtime_test.go | 29 +++ 13 files changed, 516 insertions(+), 72 deletions(-) diff --git a/go.mod b/go.mod index 4648a79..8d014db 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.25.0 require ( github.com/coder/websocket v1.8.15 github.com/iniwex5/quectel-qmi-go v0.6.0 + github.com/warthog618/sms v0.3.0 go.bug.st/serial v1.6.4 golang.org/x/crypto v0.52.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.43.0 + golang.org/x/text v0.41.0 howett.net/plist v1.0.1 modernc.org/sqlite v1.38.2 ) @@ -21,7 +23,6 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/testify v1.10.0 // indirect - github.com/warthog618/sms v0.3.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 04865c1..998d475 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/device/sms_pdu.go b/internal/device/sms_pdu.go index c6ec186..b63d119 100644 --- a/internal/device/sms_pdu.go +++ b/internal/device/sms_pdu.go @@ -8,7 +8,13 @@ import ( "strconv" "strings" "time" + "unicode" "unicode/utf16" + "unicode/utf8" + + "github.com/warthog618/sms/encoding/gsm7" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" ) var gsm7DefaultAlphabet = [128]rune{ @@ -770,14 +776,19 @@ func readTPAddress(cursor *pduCursor) (string, error) { var septetCount int if toa&0x70 == 0x50 { // 3GPP TS 23.040 §9.1.2.5: For alphanumeric addresses, the length field - // indicates the number of useful semi-octets (i.e. characters * 7 / 4, rounded up). - // The number of characters is (length * 4) / 7 and byte count is (length + 1) / 2. - // However, some non-standard sources specify length as the direct count of septets - // (e.g. length=4 for 4 chars, which needs 4 bytes instead of (4+1)/2=2 bytes). - if length >= 7 { - byteCount = (int(length) + 1) / 2 - septetCount = int(length) * 4 / 7 - } else { + // is a count of useful semi-octets, not a character count. In particular, + // a three-character sender such as "OKX" has length 6. Treating every + // short length as a septet count consumes PID/DCS bytes as part of the + // address and shifts the entire TPDU, producing plausible-looking GSM-7 + // garbage instead of the message body. + byteCount = (int(length) + 1) / 2 + septetCount = int(length) * 4 / 7 + + // A few legacy/non-standard sources do put the character count in this + // field. Retain compatibility only when the standard-sized value cannot + // be a valid zero-padded GSM-7 address; do not guess based on its length. + if byteCount == 0 || cursor.index+byteCount > len(cursor.data) || + !hasZeroGSM7Padding(cursor.data[cursor.index:cursor.index+byteCount], septetCount) { byteCount = (int(length)*7 + 7) / 8 septetCount = int(length) } @@ -798,6 +809,18 @@ func readTPAddress(cursor *pduCursor) (string, error) { return decodeNumericAddress(value, int(length), toa), nil } +func hasZeroGSM7Padding(data []byte, septetCount int) bool { + if septetCount <= 0 || septetCount*7 > len(data)*8 { + return false + } + for bit := septetCount * 7; bit < len(data)*8; bit++ { + if data[bit/8]&(byte(1)< len(data) { @@ -848,8 +871,12 @@ func decodeUserData( message.Concat = parseConcatHeader(data[1:headerBytes]) } + var header []byte + if headerBytes > 0 { + header = data[1:headerBytes] + } switch alphabet { - case 0: + case smsAlphabetGSM7: message.Encoding = SMSEncodingGSM7PDU headerSeptets := 0 if headerBytes > 0 { @@ -860,33 +887,228 @@ func decodeUserData( if err != nil { return err } - text, err := decodeGSM7(septets) + text, err := decodeGSM7WithHeader(septets, header) message.Text = text return err - case 8: + case smsAlphabetUCS2: message.Encoding = SMSEncodingUCS2PDU payload := data[headerBytes:] - if len(payload)%2 != 0 { - return errors.New("UCS2 SMS has an odd byte count") + text, ok := decodeUTF16Bytes(payload) + if ok { + message.Text = text + return nil } - units := make([]uint16, 0, len(payload)/2) - for index := 0; index < len(payload); index += 2 { - units = append(units, uint16(payload[index])<<8|uint16(payload[index+1])) + // Some gateways label UTF-8 or a local 8-bit character set as UCS-2. + // Only accept a fallback when it is unambiguously readable text. + if text, encoding, detected := decodeTextBytes(payload, header); detected { + message.Text = text + message.Encoding = encoding + return nil } - message.Text = string(utf16.Decode(units)) - return nil + return errors.New("UCS2 SMS has invalid UTF-16 data") default: - // 8-bit (binary) user data has no portable text representation, so the - // raw payload bytes are rendered as uppercase hexadecimal after the user - // data header is stripped. This keeps the bubble non-empty and gives a - // faithful rendering of the delivered content rather than a blank "". - message.Encoding = SMSEncoding8BitPDU payload := data[headerBytes:] + if text, encoding, detected := decodeTextBytes(payload, header); detected { + message.Text = text + message.Encoding = encoding + return nil + } + // Port-addressed or non-text 8-bit data remains hexadecimal, preserving + // binary SMS (WAP push, provisioning, SIM data) without lossy guessing. + message.Encoding = SMSEncoding8BitPDU message.Text = strings.ToUpper(hex.EncodeToString(payload)) return nil } } +type smsAlphabet byte + +const ( + smsAlphabetGSM7 smsAlphabet = iota + smsAlphabet8Bit + smsAlphabetUCS2 + smsAlphabetUnknown +) + +// decodeSMSAlphabet applies the complete 3GPP TS 23.038 DCS grouping rules. +// A plain dcs&0x0c check is incorrect for message-waiting groups Cx/Dx/Ex and +// reserved coding groups, and can silently select the wrong decoder. +func decodeSMSAlphabet(dcs byte) smsAlphabet { + switch { + case dcs&0x80 == 0: + if dcs&0x20 != 0 { // GSM compression is not safely decodable here. + return smsAlphabetUnknown + } + switch (dcs >> 2) & 0x03 { + case 0: + return smsAlphabetGSM7 + case 1: + return smsAlphabet8Bit + case 2: + return smsAlphabetUCS2 + default: + return smsAlphabetUnknown + } + case dcs&0xe0 == 0xc0: // Cx and Dx message-waiting groups use GSM-7. + return smsAlphabetGSM7 + case dcs&0xf0 == 0xe0: // Ex message-waiting group uses UCS-2. + return smsAlphabetUCS2 + case dcs&0xf0 == 0xf0: + if dcs&0x04 != 0 { + return smsAlphabet8Bit + } + return smsAlphabetGSM7 + default: + return smsAlphabetUnknown + } +} + +func decodeGSM7WithHeader(septets, header []byte) (string, error) { + locking, hasLocking := userDataHeaderLanguage(header, 0x25) + shift, hasShift := userDataHeaderLanguage(header, 0x24) + if !hasLocking && !hasShift { + return decodeGSM7(septets) + } + options := make([]gsm7.DecoderOption, 0, 2) + if hasLocking { + options = append(options, gsm7.WithCharset(locking)) + } + if hasShift { + options = append(options, gsm7.WithExtCharset(shift)) + } + decoded, err := gsm7.Decode(septets, options...) + return string(decoded), err +} + +func userDataHeaderLanguage(header []byte, identifier byte) (int, bool) { + for index := 0; index+1 < len(header); { + id := header[index] + length := int(header[index+1]) + index += 2 + if index+length > len(header) { + return 0, false + } + if id == identifier && length == 1 { + return int(header[index]), true + } + index += length + } + return 0, false +} + +func decodeUTF16Bytes(payload []byte) (string, bool) { + if len(payload) == 0 { + return "", true + } + if len(payload)%2 != 0 { + return "", false + } + littleEndian := len(payload) >= 2 && payload[0] == 0xff && payload[1] == 0xfe + if (payload[0] == 0xfe && payload[1] == 0xff) || littleEndian { + payload = payload[2:] + } + units := make([]uint16, 0, len(payload)/2) + for index := 0; index < len(payload); index += 2 { + unit := uint16(payload[index])<<8 | uint16(payload[index+1]) + if littleEndian { + unit = uint16(payload[index+1])<<8 | uint16(payload[index]) + } + units = append(units, unit) + } + text := string(utf16.Decode(units)) + return text, !strings.ContainsRune(text, unicode.ReplacementChar) && readableText(text) +} + +func decodeTextBytes(payload, header []byte) (string, SMSEncoding, bool) { + if hasApplicationPortAddressing(header) || len(payload) == 0 { + return "", SMSEncoding8BitPDU, false + } + if len(payload) >= 2 && ((payload[0] == 0xfe && payload[1] == 0xff) || + (payload[0] == 0xff && payload[1] == 0xfe)) { + if text, ok := decodeUTF16Bytes(payload); ok { + return text, SMSEncodingUCS2PDU, true + } + } + if utf8.Valid(payload) { + text := string(payload) + if readableText(text) { + return text, SMSEncodingUTF8PDU, true + } + } + if containsNonASCII(payload) { + decoded, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), payload) + text := string(decoded) + if err == nil && strings.ContainsFunc(text, func(character rune) bool { + return unicode.Is(unicode.Han, character) + }) && readableText(text) { + return text, SMSEncodingGB18030, true + } + } + if text, ok := decodeLatin1Text(payload); ok { + return text, SMSEncodingLatin1, true + } + return "", SMSEncoding8BitPDU, false +} + +func readableText(text string) bool { + if text == "" { + return true + } + printable, total := 0, 0 + for _, character := range text { + total++ + if unicode.IsPrint(character) || character == '\n' || character == '\r' || character == '\t' { + printable++ + } + } + return printable*100 >= total*90 +} + +func containsNonASCII(data []byte) bool { + for _, value := range data { + if value >= utf8.RuneSelf { + return true + } + } + return false +} + +func decodeLatin1Text(payload []byte) (string, bool) { + characters := make([]rune, 0, len(payload)) + ascii := 0 + for _, value := range payload { + switch { + case value == '\n' || value == '\r' || value == '\t' || value >= 0x20 && value <= 0x7e: + ascii++ + case value >= 0xa0: + default: + return "", false + } + characters = append(characters, rune(value)) + } + if ascii == 0 || ascii*2 < len(payload) { + return "", false + } + text := string(characters) + return text, readableText(text) +} + +func hasApplicationPortAddressing(header []byte) bool { + for index := 0; index+1 < len(header); { + identifier := header[index] + length := int(header[index+1]) + index += 2 + if index+length > len(header) { + return true + } + if (identifier == 0x04 && length == 2) || (identifier == 0x05 && length == 4) { + return true + } + index += length + } + return false +} + func parseConcatHeader(header []byte) *SMSConcatInfo { for index := 0; index+1 < len(header); { identifier := header[index] diff --git a/internal/device/sms_pdu_test.go b/internal/device/sms_pdu_test.go index 5610300..8677503 100644 --- a/internal/device/sms_pdu_test.go +++ b/internal/device/sms_pdu_test.go @@ -1,6 +1,7 @@ package device import ( + "encoding/hex" "errors" "strings" "testing" @@ -323,6 +324,35 @@ func TestDecodeDeliverPDUWithAlphanumericSender(t *testing.T) { } } +func TestDecodeDeliverPDUWithShortStandardAlphanumericSender(t *testing.T) { + // TP-OA length is expressed in useful semi-octets. The three-character + // sender "OKX" therefore has length 6, even though it contains 3 septets. + // A previous short-address heuristic interpreted 6 as the character count + // and swallowed PID, DCS, and timestamp bytes into the sender address. + text := "Your OKX verification code is: 123456" + textSeptets, ok := encodeGSM7(text) + if !ok { + t.Fatal("test text is not GSM-7 encodable") + } + pdu := []byte{0x00, 0x04, 0x06, 0xd0} + pdu = append(pdu, packSeptets([]byte{'O', 'K', 'X'}, 0)...) + pdu = append(pdu, + 0x00, 0x00, // PID and GSM-7 DCS. + 0x62, 0x80, 0x20, 0x91, 0x40, 0x95, 0x00, // 2026-08-02 19:04:59 UTC. + byte(len(textSeptets)), + ) + pdu = append(pdu, packSeptets(textSeptets, 0)...) + + message, err := decodeSMSPDU(hex.EncodeToString(pdu)) + if err != nil { + t.Fatalf("decode short alphanumeric sender: %v", err) + } + if message.From != "OKX" || message.Text != text || + message.Encoding != SMSEncodingGSM7PDU || message.DataCodingScheme != 0 { + t.Fatalf("message = %#v", message) + } +} + func TestDecode8BitPDUShowsHexPayload(t *testing.T) { // SMS-DELIVER with no SMSC, from +12345, DCS=0xF5 (8-bit data, // alphabet bits 0x0c), UDL=3. User data bytes are 0xAA 0xBB 0xCC. @@ -340,3 +370,87 @@ func TestDecode8BitPDUShowsHexPayload(t *testing.T) { t.Fatalf("8-bit message = %#v", message) } } + +func TestDecodeUserDataUnderstandsDCSGroups(t *testing.T) { + septets, ok := encodeGSM7("HELLO") + if !ok { + t.Fatal("encode GSM-7 test text") + } + packed := packSeptets(septets, 0) + for _, dcs := range []byte{0x00, 0xc8, 0xd0, 0xf0} { + message := SMSMessage{} + if err := decodeUserData(packed, 0, dcs, len(septets), &message); err != nil { + t.Fatalf("decode DCS 0x%02X: %v", dcs, err) + } + if message.Text != "HELLO" || message.Encoding != SMSEncodingGSM7PDU { + t.Fatalf("DCS 0x%02X message = %#v", dcs, message) + } + } + + ucs2 := []byte{0x4f, 0x60, 0x59, 0x7d} + for _, dcs := range []byte{0x08, 0xe0} { + message := SMSMessage{} + if err := decodeUserData(ucs2, 0, dcs, len(ucs2), &message); err != nil { + t.Fatalf("decode DCS 0x%02X: %v", dcs, err) + } + if message.Text != "你好" || message.Encoding != SMSEncodingUCS2PDU { + t.Fatalf("DCS 0x%02X message = %#v", dcs, message) + } + } +} + +func TestDecodeGSM7NationalLanguageTables(t *testing.T) { + // National language locking shift IEI 0x25, Turkish table 1. In that + // locking table septet 0x07 is the dotless i (ı), rather than default ì. + header := []byte{0x03, 0x25, 0x01, 0x01} + headerSeptets := (len(header)*8 + 6) / 7 + data := packSeptets([]byte{0x07}, headerSeptets*7) + copy(data, header) + + message := SMSMessage{} + if err := decodeUserData(data, 0x40, 0x00, headerSeptets+1, &message); err != nil { + t.Fatalf("decode Turkish locking table: %v", err) + } + if message.Text != "ı" || message.Encoding != SMSEncodingGSM7PDU { + t.Fatalf("message = %#v", message) + } +} + +func TestDecode8BitTextEncodingsAndPreservesBinary(t *testing.T) { + tests := []struct { + name string + payload []byte + wantText string + encoding SMSEncoding + }{ + {name: "UTF-8", payload: []byte("验证码 123456"), wantText: "验证码 123456", encoding: SMSEncodingUTF8PDU}, + {name: "GB18030", payload: []byte{0xd1, 0xe9, 0xd6, 0xa4, 0xc2, 0xeb}, wantText: "验证码", encoding: SMSEncodingGB18030}, + {name: "Latin-1", payload: []byte{'C', 'a', 'f', 0xe9}, wantText: "Café", encoding: SMSEncodingLatin1}, + {name: "binary", payload: []byte{0xaa, 0xbb, 0xcc}, wantText: "AABBCC", encoding: SMSEncoding8BitPDU}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + message := SMSMessage{} + if err := decodeUserData(test.payload, 0, 0x04, len(test.payload), &message); err != nil { + t.Fatalf("decode: %v", err) + } + if message.Text != test.wantText || message.Encoding != test.encoding { + t.Fatalf("message = %#v", message) + } + }) + } +} + +func TestDecodePortAddressed8BitSMSRemainsBinary(t *testing.T) { + header := []byte{0x04, 0x04, 0x02, 0x0b, 0x84} + data := append(append([]byte(nil), header...), []byte("plain-looking payload")...) + message := SMSMessage{} + if err := decodeUserData(data, 0x40, 0x04, len(data), &message); err != nil { + t.Fatalf("decode: %v", err) + } + payload := data[len(header):] + if message.Text != strings.ToUpper(hex.EncodeToString(payload)) || + message.Encoding != SMSEncoding8BitPDU { + t.Fatalf("message = %#v", message) + } +} diff --git a/internal/device/types.go b/internal/device/types.go index 8bf7e72..8f7be4d 100644 --- a/internal/device/types.go +++ b/internal/device/types.go @@ -146,6 +146,9 @@ const ( SMSEncodingGSM7Text SMSEncoding = "gsm7_text" SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu" SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu" + SMSEncodingUTF8PDU SMSEncoding = "utf8_pdu" + SMSEncodingGB18030 SMSEncoding = "gb18030_pdu" + SMSEncodingLatin1 SMSEncoding = "latin1_pdu" SMSEncoding8BitPDU SMSEncoding = "8bit_pdu" SMSEncodingUnknown SMSEncoding = "unknown" ) diff --git a/internal/vowifi/carrier_ipcc.go b/internal/vowifi/carrier_ipcc.go index a84c026..f2b39fd 100644 --- a/internal/vowifi/carrier_ipcc.go +++ b/internal/vowifi/carrier_ipcc.go @@ -662,11 +662,9 @@ func inspectIgnoredCarrierFields(plists []ipccPlist, warnings *ipccWarningSet) { case key == "media" && strings.Contains(strings.ToLower(strings.Join(keyPath, ".")), "imsconfig"): warnings.add("device_media_overrides_ignored", "device-family media and codec overrides require hardware validation and were not imported", fullPath) case key == "countryoforiginationformat": - warnings.add( - "country_of_origination_format_not_imported", - "CountryOfOriginationFormat was not imported because VoCat has no trusted runtime country source; a P-Access-Network-Info value must not be fabricated", - fullPath, - ) + // PANI is access/session metadata, not a carrier location constant. + // The IMS runtime provides one globally and consistently across + // REGISTER, MESSAGE, RP-ACK and dialogs, so no profile field is needed. case strings.Contains(key, "emergency") || strings.Contains(key, "e911"): warnings.add("emergency_settings_ignored", "emergency-service settings are never imported", fullPath) } diff --git a/internal/vowifi/carrier_ipcc_test.go b/internal/vowifi/carrier_ipcc_test.go index a9a4a9e..3634fdc 100644 --- a/internal/vowifi/carrier_ipcc_test.go +++ b/internal/vowifi/carrier_ipcc_test.go @@ -82,7 +82,6 @@ func TestImportCarrierIPCCConvertsBinaryAndXMLPlistsSafely(t *testing.T) { "entitlement_bypass_ignored", "apn_settings_ignored", "device_media_overrides_ignored", - "country_of_origination_format_not_imported", "emergency_settings_ignored", } { if !hasIPCCWarning(result.Warnings, code) { diff --git a/internal/vowifi/ims/call_runtime.go b/internal/vowifi/ims/call_runtime.go index 4291a76..af086d9 100644 --- a/internal/vowifi/ims/call_runtime.go +++ b/internal/vowifi/ims/call_runtime.go @@ -799,16 +799,10 @@ func (session *Session) callOriginatingIdentitiesLocked(profile vowifi.CarrierPr } func (session *Session) pAccessNetworkInfo() string { - profile := vowifi.ResolveCarrierProfile(session.request.Identity) - node := strings.TrimSpace(profile.PANINode) - if node == "" { - node = "000000000000" + if session.paniResolved { + return ueProvidedPANI(session.pani) } - value := "IEEE-802.11;i-wlan-node-id=" + node - if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" { - value += ";country=" + country - } - return value + ";network-provided" + return sessionPAccessNetworkInfo(session.instanceID) } func (session *Session) callUserAgent() string { diff --git a/internal/vowifi/ims/call_runtime_test.go b/internal/vowifi/ims/call_runtime_test.go index 142a170..18f7c37 100644 --- a/internal/vowifi/ims/call_runtime_test.go +++ b/internal/vowifi/ims/call_runtime_test.go @@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) { "P-Preferred-Identity: \r\n", "P-Preferred-Service: " + mmtelServiceURN + "\r\n", `Accept-Contact: *;+g.3gpp.icsi-ref="` + mmtelFeatureTag + `"` + "\r\n", - "P-Access-Network-Info: IEEE-802.11;i-wlan-node-id=000000000000;network-provided\r\n", + "P-Access-Network-Info: " + sessionPAccessNetworkInfo(session.instanceID) + "\r\n", "User-Agent: VoCat Test\r\n", "Accept: application/sdp\r\n", } { diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index 1237c44..ce72c02 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/hex" "errors" @@ -594,6 +595,8 @@ type Session struct { callID string fromTag string instanceID string + pani string + paniResolved bool cseq uint32 auth *authenticationState securityProposal securityProposal @@ -658,6 +661,8 @@ func newSession( callID: callToken + "@" + addressHost(connection.LocalAddr()), fromTag: fromTag, instanceID: "urn:uuid:" + instanceID, + pani: resolveSessionPAccessNetworkInfo(request.Identity, "urn:uuid:"+instanceID), + paniResolved: true, cseq: 1, refreshContext: refreshContext, refreshCancel: refreshCancel, @@ -997,11 +1002,10 @@ func (session *Session) buildRegister( if value := strings.TrimSpace(registerOptions.PVisitedNetworkID); value != "" { lines = append(lines, `P-Visited-Network-ID: "`+value+`"`) } - // PANI carries access/location information and must not be fabricated. - // In particular, "network-provided" identifies a value inserted by a - // trusted network proxy, not one generated by this UE. Send the header - // only when a carrier profile explicitly supplies a reviewed value. - if pani := optionalRegisterHeader(registerOptions.PAccessNetworkInfo); pani != "" { + // PANI describes this UE's access and is stable for the complete IMS + // session. The same UE-provided value is used by REGISTER, MESSAGE, + // RP-ACK and dialog requests; it never claims to be network-provided. + if pani := session.pAccessNetworkInfo(); pani != "" { lines = append(lines, "P-Access-Network-Info: "+pani) } if value := strings.TrimSpace(registerOptions.CellularNetworkInfo); value != "" { @@ -1045,13 +1049,6 @@ func (session *Session) buildRegister( return []byte(strings.Join(lines, "\r\n")), nil } -func optionalRegisterHeader(value *string) string { - if value == nil { - return "" - } - return strings.TrimSpace(*value) -} - func (session *Session) buildContact(contactAddress string, registerOptions vowifi.IMSRegisterOptions) string { base := fmt.Sprintf("", session.identity.user, contactAddress, session.transport) instanceID := session.instanceID @@ -1079,6 +1076,58 @@ func (session *Session) buildContact(contactAddress string, registerOptions vowi } } +// sessionPAccessNetworkInfo creates a syntactically valid, locally +// administered unicast WLAN node identifier from the already-random SIP +// instance ID. It discloses neither a real BSSID nor subscriber identity, but +// remains stable for every transaction belonging to this IMS registration. +func sessionPAccessNetworkInfo(instanceID string) string { + instanceID = strings.TrimSpace(instanceID) + if instanceID == "" { + return "" + } + digest := sha256.Sum256([]byte(instanceID)) + digest[0] = (digest[0] | 0x02) & 0xfe // locally administered, unicast + return "IEEE-802.11;i-wlan-node-id=" + hex.EncodeToString(digest[:6]) +} + +// resolveSessionPAccessNetworkInfo freezes the selected value when the IMS +// session is created. This prevents a carrier-profile reload from changing +// access identity between REGISTER, SMS MESSAGE and its RP-ACK. +func resolveSessionPAccessNetworkInfo(identity vowifi.SIMIdentity, instanceID string) string { + profile := vowifi.ResolveCarrierProfile(identity) + if configured := profile.IMSRegisterOptions.PAccessNetworkInfo; configured != nil { + return ueProvidedPANI(*configured) + } + + node := strings.ToLower(strings.TrimSpace(profile.PANINode)) + if decoded, err := hex.DecodeString(node); err != nil || len(decoded) != 6 { + node = strings.TrimPrefix(sessionPAccessNetworkInfo(instanceID), "IEEE-802.11;i-wlan-node-id=") + } + if node == "" { + return "" + } + value := "IEEE-802.11;i-wlan-node-id=" + node + if country := strings.ToUpper(strings.TrimSpace(profile.PANICountry)); country != "" { + value += ";country=" + country + } + return value +} + +// ueProvidedPANI removes the network-provided marker from a profile override. +// RFC 7315 reserves that marker for a trusted proxy; a UE must not assert it. +func ueProvidedPANI(value string) string { + parts := strings.Split(strings.TrimSpace(value), ";") + filtered := parts[:0] + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || strings.EqualFold(part, "network-provided") { + continue + } + filtered = append(filtered, part) + } + return strings.Join(filtered, ";") +} + func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) { if err := ctx.Err(); err != nil { return nil, err diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index 4792dff..714c968 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -3,6 +3,7 @@ package ims import ( "context" "encoding/base64" + "encoding/hex" "errors" "fmt" "io" @@ -361,6 +362,7 @@ func TestRefreshFailureRevokesRegistrationEvidence(t *testing.T) { func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) error { var callID string + var pani string for step := 0; step < 4; step++ { packet := make([]byte, 65535) count, remote, err := listener.ReadFromUDP(packet) @@ -377,7 +379,6 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err for _, forbidden := range []string{ "p-visited-network-id", "p-preferred-identity", - "p-access-network-info", } { if headers[forbidden] != "" { return fmt.Errorf( @@ -387,6 +388,15 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err ) } } + currentPANI := headers["p-access-network-info"] + if err := validateTestPANI(currentPANI); err != nil { + return fmt.Errorf("REGISTER PANI: %w", err) + } + if step == 0 { + pani = currentPANI + } else if currentPANI != pani { + return fmt.Errorf("REGISTER PANI changed from %q to %q", pani, currentPANI) + } if !strings.Contains(headers["allow"], "MESSAGE") || !strings.Contains(string(packet[:count]), "Accept-Contact: *;+g.3gpp.smsip") { return fmt.Errorf("REGISTER omitted SMS-over-IMS capability: Allow=%q", headers["allow"]) @@ -495,24 +505,43 @@ func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) err return nil } -func TestOptionalRegisterHeaderRequiresExplicitNonemptyValue(t *testing.T) { - explicit := " IEEE-802.11;i-wlan-node-id=aabbccddeeff " - empty := " " - for _, test := range []struct { - name string - value *string - want string - }{ - {name: "unspecified", value: nil, want: ""}, - {name: "explicit omission", value: &empty, want: ""}, - {name: "explicit value", value: &explicit, want: "IEEE-802.11;i-wlan-node-id=aabbccddeeff"}, - } { - t.Run(test.name, func(t *testing.T) { - if got := optionalRegisterHeader(test.value); got != test.want { - t.Fatalf("optionalRegisterHeader() = %q, want %q", got, test.want) - } - }) +func TestSessionPAccessNetworkInfoIsStableAndUEProvided(t *testing.T) { + instanceID := "urn:uuid:00000000-0000-4000-8000-000000000001" + first := sessionPAccessNetworkInfo(instanceID) + second := sessionPAccessNetworkInfo(instanceID) + if first != second { + t.Fatalf("PANI changed for one SIP instance: %q != %q", first, second) } + if err := validateTestPANI(first); err != nil { + t.Fatal(err) + } + if got := ueProvidedPANI(" IEEE-802.11;i-wlan-node-id=aabbccddeeff;network-provided "); got != "IEEE-802.11;i-wlan-node-id=aabbccddeeff" { + t.Fatalf("ueProvidedPANI() = %q", got) + } + if got := ueProvidedPANI("network-provided"); got != "" { + t.Fatalf("marker-only PANI = %q, want empty", got) + } + if got := (&Session{paniResolved: true}).pAccessNetworkInfo(); got != "" { + t.Fatalf("explicitly omitted session PANI = %q, want empty", got) + } +} + +func validateTestPANI(value string) error { + const prefix = "IEEE-802.11;i-wlan-node-id=" + if !strings.HasPrefix(value, prefix) { + return fmt.Errorf("value %q does not start with %q", value, prefix) + } + if strings.Contains(strings.ToLower(value), "network-provided") { + return fmt.Errorf("UE PANI incorrectly claims network-provided provenance: %q", value) + } + node, err := hex.DecodeString(strings.TrimPrefix(value, prefix)) + if err != nil || len(node) != 6 { + return fmt.Errorf("i-wlan-node-id must be 12 hexadecimal digits: %q", value) + } + if node[0]&0x03 != 0x02 { + return fmt.Errorf("i-wlan-node-id must be a locally administered unicast identifier: %q", value) + } + return nil } func serveRefreshFailure(listener *net.UDPConn, nonce string) error { diff --git a/internal/vowifi/ims/sms_runtime.go b/internal/vowifi/ims/sms_runtime.go index e2b6f31..ff19191 100644 --- a/internal/vowifi/ims/sms_runtime.go +++ b/internal/vowifi/ims/sms_runtime.go @@ -56,6 +56,7 @@ type ReceivedSMS struct { RawTPDU string DecodeError string } + // ReceivedSMSStatus is network delivery evidence for one submitted SMS part. type ReceivedSMSStatus struct { DeviceID string @@ -1165,6 +1166,9 @@ func (session *Session) sendSIPMessageWith( fmt.Sprintf("CSeq: %d MESSAGE", cseq), "P-Preferred-Identity: <"+session.identity.public+">", ) + if pani := session.pAccessNetworkInfo(); pani != "" { + lines = append(lines, "P-Access-Network-Info: "+pani) + } if acceptContactTag != "" { lines = append(lines, "Accept-Contact: *;+g.3gpp."+acceptContactTag) } diff --git a/internal/vowifi/ims/sms_runtime_test.go b/internal/vowifi/ims/sms_runtime_test.go index ffec397..7582ed8 100644 --- a/internal/vowifi/ims/sms_runtime_test.go +++ b/internal/vowifi/ims/sms_runtime_test.go @@ -352,6 +352,10 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s if err != nil { return err } + registerPANI := headers["p-access-network-info"] + if err := validateTestPANI(registerPANI); err != nil { + return fmt.Errorf("initial REGISTER PANI: %w", err) + } callID := headers["call-id"] if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{ `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, @@ -366,6 +370,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s if err != nil { return err } + if headers["p-access-network-info"] != registerPANI { + return errors.New("authenticated REGISTER changed PANI") + } if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{ "Contact: " + headers["contact"] + ";expires=600", }), remote); err != nil { @@ -433,6 +440,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s len(report.Request.Body) != 2 || report.Request.Body[0] != 0x02 || report.Request.Body[1] != 0x2a { return fmt.Errorf("unexpected delivery report %#v", report.Request) } + if report.Request.value("P-Access-Network-Info") != registerPANI { + return errors.New("inbound SMS RP-ACK did not reuse REGISTER PANI") + } if _, err = listener.WriteToUDP(testResponse(200, "OK", report.Request.value("Call-ID"), report.Request.value("CSeq"), nil), remote); err != nil { return err } @@ -449,6 +459,9 @@ func serveInboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- s if headers["expires"] != "0" { return errors.New("expected deregistration") } + if headers["p-access-network-info"] != registerPANI { + return errors.New("deregistration changed PANI") + } _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote) return err } @@ -463,6 +476,10 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- if err != nil { return err } + registerPANI := headers["p-access-network-info"] + if err := validateTestPANI(registerPANI); err != nil { + return fmt.Errorf("initial REGISTER PANI: %w", err) + } registerCallID := headers["call-id"] if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{ `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, @@ -477,6 +494,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- if err != nil { return err } + if headers["p-access-network-info"] != registerPANI { + return errors.New("authenticated REGISTER changed PANI") + } if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{ "Contact: " + headers["contact"] + ";expires=600", }), remote); err != nil { @@ -508,6 +528,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- message.Request.value("Allow") != "MESSAGE" { return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request) } + if message.Request.value("P-Access-Network-Info") != registerPANI { + return errors.New("outbound SMS MESSAGE did not reuse REGISTER PANI") + } rpdu, err := parseRPDU(message.Request.Body) if err != nil || rpdu.messageType != 0 || len(rpdu.tpdu) != 0 { // parseRPDU intentionally decodes only network-to-MS RP-DATA; inspect @@ -573,6 +596,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- len(statusACK.Request.Body) != 2 || statusACK.Request.Body[0] != 0x02 || statusACK.Request.Body[1] != 0x2b { return fmt.Errorf("unexpected status RP-ACK %#v (%v)", statusACK.Request, err) } + if statusACK.Request.value("P-Access-Network-Info") != registerPANI { + return errors.New("status-report RP-ACK did not reuse REGISTER PANI") + } if _, err = listener.WriteToUDP(testResponse(200, "OK", statusACK.Request.value("Call-ID"), statusACK.Request.value("CSeq"), nil), remote); err != nil { return err } @@ -589,6 +615,9 @@ func serveOutboundSMS(listener *net.UDPConn, nonce string, readyForClose chan<- if headers["expires"] != "0" { return errors.New("expected deregistration") } + if headers["p-access-network-info"] != registerPANI { + return errors.New("deregistration changed PANI") + } _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote) return err }