This commit is contained in:
MengMengCode
2026-08-20 23:37:40 +08:00
parent 8d06231494
commit f697c418a5
13 changed files with 516 additions and 72 deletions
+2 -1
View File
@@ -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
+2
View File
@@ -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=
+247 -25
View File
@@ -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)<<uint(bit%8)) != 0 {
return false
}
}
return true
}
func decodeNumericAddress(value []byte, digits int, toa byte) string {
var result strings.Builder
if toa&0x70 == 0x10 {
@@ -825,9 +848,9 @@ func decodeUserData(
udl int,
message *SMSMessage,
) error {
alphabet := dcs & 0x0c
alphabet := decodeSMSAlphabet(dcs)
expectedBytes := udl
if alphabet == 0 {
if alphabet == smsAlphabetGSM7 {
expectedBytes = (udl*7 + 7) / 8
}
if expectedBytes > 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]
+114
View File
@@ -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)
}
}
+3
View File
@@ -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"
)
+3 -5
View File
@@ -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)
}
-1
View File
@@ -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) {
+3 -9
View File
@@ -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 {
+1 -1
View File
@@ -229,7 +229,7 @@ func TestOutgoingLocalNumberUsesIMSPhoneContextAndMMTelHeaders(t *testing.T) {
"P-Preferred-Identity: <tel:+447700900123>\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",
} {
+61 -12
View File
@@ -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("<sip:%s@%s;transport=%s>", 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
+47 -18
View File
@@ -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 {
+4
View File
@@ -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)
}
+29
View File
@@ -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
}