package ims import ( "bufio" "context" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" "net" "strconv" "strings" "sync" "time" "vocat/internal/vowifi" ) const ( defaultSIPPort = 5060 defaultRegistrationExpiry = 3600 * time.Second defaultTransactionTimeout = 12 * time.Second maxAuthenticationChallenges = 3 ) var ( ErrRegistrationRejected = errors.New("ims: SIP registration was rejected") ErrSessionClosed = errors.New("ims: session is closed") ErrRegistrationExpired = errors.New("ims: registration has expired") ErrSMSCapabilityNotConfirmed = errors.New("ims: registrar did not confirm the +g.3gpp.smsip contact") ) // Config defines only deployment-specific SIP details. If PCSCF or // LocalAddress is empty, Provider uses the corresponding value proven by the // TunnelSession. The default transport is TCP and the default port is 5060. type Config struct { PCSCF string LocalAddress string Transport string Port int RegistrationExpiry time.Duration TransactionTimeout time.Duration PrivateIdentity string PublicIdentity string UserAgent string SecurityMode SecurityMode IPSecInstaller IPSecSAInstaller ProtectedClientPort int ProtectedServerPort int // SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP // and AT+CSCA empty. It must be an international or national digit string. SMSCenter string // OnSMS is invoked after a valid inbound RP-DATA/SMS-DELIVER has been // decoded. Returning an error causes an RP-ERROR delivery report. OnSMS func(context.Context, ReceivedSMS) error // OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a // submission that requested a delivery report. OnSMSStatus func(context.Context, ReceivedSMSStatus) error } // Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER // transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or // runtime dependency outside the Go standard library. type Provider struct { aka vowifi.AKAProvider config Config installer IPSecSAInstaller } func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) { if aka == nil { return nil, errors.New("ims: AKA provider is required") } normalized, err := normalizeConfig(config) if err != nil { return nil, err } installer := normalized.IPSecInstaller if installer == nil { installer = defaultIPSecInstaller() } return &Provider{aka: aka, config: normalized, installer: installer}, nil } func normalizeConfig(config Config) (Config, error) { if config.Port == 0 { config.Port = defaultSIPPort } if config.Port < 1 || config.Port > 65535 { return Config{}, errors.New("ims: SIP port is out of range") } if config.RegistrationExpiry == 0 { config.RegistrationExpiry = defaultRegistrationExpiry } if config.RegistrationExpiry < time.Minute || config.RegistrationExpiry > 24*time.Hour { return Config{}, errors.New("ims: registration expiry must be between one minute and 24 hours") } if config.TransactionTimeout == 0 { config.TransactionTimeout = defaultTransactionTimeout } if config.TransactionTimeout < time.Second || config.TransactionTimeout > time.Minute { return Config{}, errors.New("ims: transaction timeout must be between one second and one minute") } config.Transport = strings.ToLower(strings.TrimSpace(config.Transport)) if config.Transport != "" && config.Transport != "udp" && config.Transport != "tcp" { return Config{}, fmt.Errorf("ims: unsupported SIP transport %q", config.Transport) } if strings.TrimSpace(config.UserAgent) == "" { config.UserAgent = "vocat/1" } if config.SecurityMode == "" { config.SecurityMode = SecurityRequired } switch config.SecurityMode { case SecurityRequired, SecurityOptional, SecurityDisabled: default: return Config{}, fmt.Errorf("ims: unsupported security mode %q", config.SecurityMode) } if config.ProtectedClientPort != 0 && !validProtectedPort(config.ProtectedClientPort) { return Config{}, errors.New("ims: protected client port is invalid") } if config.ProtectedServerPort != 0 && !validProtectedPort(config.ProtectedServerPort) { return Config{}, errors.New("ims: protected server port is invalid") } if config.ProtectedClientPort != 0 && config.ProtectedClientPort == config.ProtectedServerPort { return Config{}, errors.New("ims: protected client and server ports must differ") } for name, value := range map[string]string{ "PCSCF": config.PCSCF, "local address": config.LocalAddress, "private identity": config.PrivateIdentity, "public identity": config.PublicIdentity, "user agent": config.UserAgent, } { if strings.ContainsAny(value, "\r\n") { return Config{}, fmt.Errorf("ims: %s contains a line break", name) } } config.PCSCF = strings.TrimSpace(config.PCSCF) config.LocalAddress = strings.TrimSpace(config.LocalAddress) config.PrivateIdentity = strings.TrimSpace(config.PrivateIdentity) config.PublicIdentity = strings.TrimSpace(config.PublicIdentity) config.UserAgent = strings.TrimSpace(config.UserAgent) config.SMSCenter = strings.TrimSpace(config.SMSCenter) if config.SMSCenter != "" { digits := strings.TrimPrefix(config.SMSCenter, "+") if !digitsBetween(digits, 3, 20) { return Config{}, errors.New("ims: configured SMS service-centre address is invalid") } } return config, nil } func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) (vowifi.IMSSession, error) { if ctx == nil { ctx = context.Background() } if request.Tunnel == nil { return nil, errors.New("ims: tunnel session is required") } tunnel := request.Tunnel.Evidence() if !tunnel.Established { return nil, vowifi.ErrTunnelNotEstablished } identities, err := deriveIdentities(request.Identity, provider.config) if err != nil { return nil, err } pcscf := provider.config.PCSCF if pcscf == "" { for _, candidate := range tunnel.PCSCF { if strings.TrimSpace(candidate) != "" { pcscf = candidate break } } } if pcscf == "" { return nil, errors.New("ims: tunnel did not provide a P-CSCF") } endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port) if err != nil { return nil, err } if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) { return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel") } transport := provider.config.Transport if transport == "" { transport = transportHint } if transport == "" { transport = "tcp" } localAddress := provider.config.LocalAddress if localAddress == "" { if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil { localAddress = tunnel.LocalIPv6 } else { localAddress = tunnel.LocalIPv4 if strings.TrimSpace(localAddress) == "" { localAddress = tunnel.LocalIPv6 } } } localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0]) if localAddress == "" { return nil, errors.New("ims: tunnel did not provide a local address") } if !localAddressProvenByTunnel(localAddress, tunnel) { return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel") } connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address()) if err != nil { return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err) } session, err := newSession(provider, request, identities, endpoint, transport, connection) if err != nil { _ = connection.Close() return nil, err } if err := session.establish(ctx); err != nil { session.abort() return nil, err } return session, nil } type identitySet struct { domain string private string public string user string } func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet, error) { imsi := strings.TrimSpace(identity.IMSI) if !digitsBetween(imsi, 5, 16) { return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid") } mcc := strings.TrimSpace(identity.HomeMCC) mnc := strings.TrimSpace(identity.HomeMNC) if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) { return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid") } for len(mnc) < 3 { mnc = "0" + mnc } domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc) privateIdentity := config.PrivateIdentity if privateIdentity == "" { privateIdentity = imsi + "@" + domain } publicIdentity := config.PublicIdentity if publicIdentity == "" { publicIdentity = "sip:" + imsi + "@" + domain } if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") || !strings.Contains(privateIdentity, "@") || (!strings.HasPrefix(strings.ToLower(publicIdentity), "sip:") && !strings.HasPrefix(strings.ToLower(publicIdentity), "sips:")) { return identitySet{}, errors.New("ims: configured IMS identity is invalid") } user := strings.TrimPrefix(strings.TrimPrefix(publicIdentity, "sip:"), "sips:") if at := strings.IndexByte(user, '@'); at >= 0 { user = user[:at] } if user == "" || strings.ContainsAny(user, "<>\" \t;") { return identitySet{}, errors.New("ims: public identity user is invalid") } return identitySet{domain: domain, private: privateIdentity, public: publicIdentity, user: user}, nil } type pcscfEndpoint struct { host string port int } func (endpoint pcscfEndpoint) address() string { return net.JoinHostPort(endpoint.host, strconv.Itoa(endpoint.port)) } func parsePCSCF(raw string, defaultPort int) (pcscfEndpoint, string, error) { value := strings.Trim(strings.TrimSpace(raw), "<>") lower := strings.ToLower(value) if strings.HasPrefix(lower, "sip:") { value = value[4:] } else if strings.HasPrefix(lower, "sips:") { value = value[5:] } transport := "" if separator := strings.IndexAny(value, ";?"); separator >= 0 { parameters := value[separator+1:] value = value[:separator] for _, parameter := range strings.FieldsFunc(parameters, func(character rune) bool { return character == ';' || character == '&' }) { key, parameterValue, found := strings.Cut(parameter, "=") if found && strings.EqualFold(strings.TrimSpace(key), "transport") { transport = strings.ToLower(strings.TrimSpace(parameterValue)) } } } if at := strings.LastIndexByte(value, '@'); at >= 0 { value = value[at+1:] } value = strings.TrimSpace(value) if value == "" || strings.ContainsAny(value, "\r\n/") { return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF address") } host := value port := defaultPort if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil { host = parsedHost numericPort, parseErr := strconv.Atoi(parsedPort) if parseErr != nil || numericPort < 1 || numericPort > 65535 { return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF port") } port = numericPort } else if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { host = strings.Trim(value, "[]") } else if strings.Count(value, ":") == 1 { candidateHost, candidatePort, found := strings.Cut(value, ":") if found { numericPort, parseErr := strconv.Atoi(candidatePort) if parseErr != nil || numericPort < 1 || numericPort > 65535 { return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF port") } host = candidateHost port = numericPort } } host = strings.Trim(strings.TrimSpace(host), "[]") if host == "" || strings.ContainsAny(host, " \t<>\"") { return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF host") } if transport != "" && transport != "udp" && transport != "tcp" { return pcscfEndpoint{}, "", fmt.Errorf("ims: unsupported P-CSCF transport %q", transport) } return pcscfEndpoint{host: host, port: port}, transport, nil } func pcscfProvenByTunnel(endpoint pcscfEndpoint, candidates []string, defaultPort int) bool { for _, candidate := range candidates { proven, _, err := parsePCSCF(candidate, defaultPort) if err != nil { continue } if proven.port == endpoint.port && equalHost(proven.host, endpoint.host) { return true } } return false } func equalHost(left string, right string) bool { leftIP := net.ParseIP(strings.Trim(left, "[]")) rightIP := net.ParseIP(strings.Trim(right, "[]")) if leftIP != nil || rightIP != nil { return leftIP != nil && rightIP != nil && leftIP.Equal(rightIP) } return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) } func localAddressProvenByTunnel(localAddress string, tunnel vowifi.TunnelEvidence) bool { localIP := net.ParseIP(strings.Trim(localAddress, "[]")) if localIP == nil { return false } for _, candidate := range []string{tunnel.LocalIPv4, tunnel.LocalIPv6} { candidate = strings.TrimSpace(strings.Split(candidate, "/")[0]) candidateIP := net.ParseIP(strings.Trim(candidate, "[]")) if candidateIP != nil && localIP.Equal(candidateIP) { return true } } return false } func dialSIP( ctx context.Context, transport string, localAddress string, localPort int, remoteAddress string, ) (net.Conn, error) { var local net.Addr var err error switch transport { case "udp": local, err = net.ResolveUDPAddr("udp", net.JoinHostPort(localAddress, strconv.Itoa(localPort))) case "tcp": local, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(localAddress, strconv.Itoa(localPort))) default: return nil, fmt.Errorf("ims: unsupported SIP transport %q", transport) } if err != nil { return nil, fmt.Errorf("resolve tunnel local address: %w", err) } dialer := net.Dialer{LocalAddr: local} return dialer.DialContext(ctx, transport, remoteAddress) } type authenticationState struct { challenge digestChallenge password []byte auts string cnonce string nc uint32 } type Session struct { provider *Provider request vowifi.IMSRequest identity identitySet endpoint pcscfEndpoint transport string conn net.Conn reader *bufio.Reader initialEndpoint pcscfEndpoint securityDeclined bool callID string fromTag string instanceID string cseq uint32 auth *authenticationState securityProposal securityProposal securityAgreement securityAgreement securityActive bool ipsecHandle IPSecSAHandle protectedTCP *net.TCPListener protectedUDP *net.UDPConn failures chan error failureOnce sync.Once writeMu sync.Mutex transactionsMu sync.Mutex transactions map[sipTransactionKey]chan *sipResponse runtimeStarted bool receiveDone sync.WaitGroup inboundMu sync.Mutex inboundConnections map[net.Conn]struct{} smsMu sync.Mutex nextRPReference byte mu sync.Mutex closed bool evidence vowifi.IMSEvidence smsContactConfirmed bool expiresAt time.Time refreshContext context.Context refreshCancel context.CancelFunc refreshDone chan struct{} } func newSession( provider *Provider, request vowifi.IMSRequest, identity identitySet, endpoint pcscfEndpoint, transport string, connection net.Conn, ) (*Session, error) { callToken, err := randomHex(18) if err != nil { return nil, err } fromTag, err := randomHex(8) if err != nil { return nil, err } instanceID, err := randomUUID() if err != nil { return nil, err } refreshContext, refreshCancel := context.WithCancel(context.Background()) session := &Session{ provider: provider, request: request, identity: identity, endpoint: endpoint, initialEndpoint: endpoint, transport: transport, conn: connection, callID: callToken + "@" + addressHost(connection.LocalAddr()), fromTag: fromTag, instanceID: "urn:uuid:" + instanceID, cseq: 1, refreshContext: refreshContext, refreshCancel: refreshCancel, refreshDone: make(chan struct{}), failures: make(chan error, 1), transactions: make(map[sipTransactionKey]chan *sipResponse), inboundConnections: make(map[net.Conn]struct{}), evidence: vowifi.IMSEvidence{ RegistrationState: "registering", Transport: transport, }, } if transport == "tcp" { session.reader = bufio.NewReader(connection) } if provider.config.SecurityMode != SecurityDisabled { localIP := addressIP(connection.LocalAddr()) if localIP == nil { refreshCancel() return nil, errors.New("ims: protected local IP address is unavailable") } proposal, err := newSecurityProposal( localIP, provider.config.ProtectedClientPort, provider.config.ProtectedServerPort, ) if err != nil { refreshCancel() return nil, err } session.securityProposal = proposal protectedTCP, err := net.ListenTCP( "tcp", &net.TCPAddr{IP: append(net.IP(nil), localIP...), Port: proposal.portServer}, ) if err != nil { refreshCancel() return nil, fmt.Errorf("ims: reserve protected TCP server port: %w", err) } protectedUDP, err := net.ListenUDP( "udp", &net.UDPAddr{IP: append(net.IP(nil), localIP...), Port: proposal.portServer}, ) if err != nil { _ = protectedTCP.Close() refreshCancel() return nil, fmt.Errorf("ims: reserve protected UDP server port: %w", err) } session.protectedTCP = protectedTCP session.protectedUDP = protectedUDP } return session, nil } func (session *Session) abort() { session.refreshCancel() _ = session.conn.Close() if session.protectedTCP != nil { _ = session.protectedTCP.Close() } if session.protectedUDP != nil { _ = session.protectedUDP.Close() } if session.ipsecHandle != nil { _ = session.ipsecHandle.Close(context.Background()) } session.clearAuthentication() } func (session *Session) establish(ctx context.Context) error { session.mu.Lock() defer session.mu.Unlock() response, err := session.register(ctx, int(session.provider.config.RegistrationExpiry/time.Second)) if err != nil { session.evidence.RegistrationState = "failed" return err } if response.StatusCode != 200 { session.evidence.RegistrationState = "rejected" phase := "initial" if session.auth != nil { phase = "authenticated" } return registrationRejectionError(response, phase) } if err := session.applyRegistrationEvidence(response); err != nil { return err } if err := session.startRuntimeReceivers(); err != nil { return err } go session.refreshLoop() return nil } // registrationRejectionError preserves the registrar's safe diagnostic text. // Operators commonly use the SIP reason phrase, Reason, or Warning header to // distinguish an unprovisioned subscriber from a malformed REGISTER. Do not // include authentication headers here because they contain AKA material. func registrationRejectionError(response *sipResponse, phase string) error { if response == nil { return fmt.Errorf("%w: empty SIP response", ErrRegistrationRejected) } phase = strings.TrimSpace(phase) if phase == "" { phase = "unknown" } message := fmt.Sprintf( "%s: SIP %d", phase+" REGISTER was rejected", response.StatusCode, ) if reason := safeSIPDiagnostic(response.Reason); reason != "" { message += " " + reason } for _, header := range []string{"Reason", "Warning"} { for _, value := range response.values(header) { if value = safeSIPDiagnostic(value); value != "" { message += fmt.Sprintf("; %s: %s", header, value) } } } return fmt.Errorf("%w: %s", ErrRegistrationRejected, message) } func safeSIPDiagnostic(value string) string { value = strings.Join(strings.Fields(value), " ") const maximum = 256 if len(value) > maximum { value = value[:maximum] + "..." } return value } func (session *Session) register(ctx context.Context, expires int) (*sipResponse, error) { for challenges := 0; challenges <= maxAuthenticationChallenges; challenges++ { cseq := session.cseq session.cseq++ authorization := "" authorizationHeader := "" if session.auth != nil { session.auth.nc++ credentials := digestCredentials{ Username: session.identity.private, Password: session.auth.password, AUTS: session.auth.auts, URI: "sip:" + session.identity.domain, Method: "REGISTER", CNonce: session.auth.cnonce, NC: session.auth.nc, } authorization = buildDigestAuthorization(session.auth.challenge, credentials) if session.auth.challenge.Proxy { authorizationHeader = "Proxy-Authorization" } else { authorizationHeader = "Authorization" } } request, err := session.buildRegister(cseq, expires, authorizationHeader, authorization) if err != nil { return nil, err } response, err := session.exchange(ctx, request, cseq) if err != nil { return nil, err } session.evidence.LastSIPCode = response.StatusCode if response.StatusCode != 401 && response.StatusCode != 407 { return response, nil } if challenges == maxAuthenticationChallenges { break } if session.securityActive { return nil, errors.New("ims: protected registration was challenged again") } challenge, err := challengeFromResponse(response) if err != nil { return nil, err } agreement, useSecurity, err := session.securityFromResponse(response) if err != nil { return nil, err } material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge) if err != nil { return nil, err } if len(material.auts) == 0 && useSecurity { if err := session.activateIPSec(ctx, agreement, material.ck, material.ik); err != nil { clearAKAMaterial(&material) return nil, err } } credentials, err := newDigestCredentials( session.identity.private, material.password, "sip:"+session.identity.domain, "REGISTER", 1, ) if err != nil { clearAKAMaterial(&material) return nil, err } auts := base64.StdEncoding.EncodeToString(material.auts) session.auth = &authenticationState{ challenge: challenge, password: append([]byte(nil), material.password...), auts: auts, cnonce: credentials.CNonce, } clearAKAMaterial(&material) } return nil, errors.New("ims: too many SIP authentication challenges") } func challengeFromResponse(response *sipResponse) (digestChallenge, error) { header := "WWW-Authenticate" proxy := false if response.StatusCode == 407 { header = "Proxy-Authenticate" proxy = true } var lastError error for _, value := range response.values(header) { challenge, err := parseDigestChallenge(value, proxy) if err == nil { return challenge, nil } lastError = err } if lastError == nil { lastError = errors.New("ims: authentication response omitted a digest challenge") } return digestChallenge{}, lastError } func (session *Session) buildRegister( cseq uint32, expires int, authorizationHeader string, authorization string, ) ([]byte, error) { branch, err := randomHex(12) if err != nil { return nil, err } local := session.conn.LocalAddr().String() contactAddress := session.contactAddress() transportUpper := strings.ToUpper(session.transport) requestURI := "sip:" + session.identity.domain routeURI := "sip:" + session.endpoint.address() + ";transport=" + session.transport + ";lr" contact := fmt.Sprintf( ";+sip.instance=\"<%s>\";+g.3gpp.smsip", session.identity.user, contactAddress, session.transport, session.instanceID, ) lines := []string{ "REGISTER " + requestURI + " SIP/2.0", fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch), "Max-Forwards: 70", "Route: <" + routeURI + ">", "From: <" + session.identity.public + ">;tag=" + session.fromTag, "To: <" + session.identity.public + ">", "Call-ID: " + session.callID, fmt.Sprintf("CSeq: %d REGISTER", cseq), "Contact: " + contact, fmt.Sprintf("Expires: %d", expires), "Supported: path, gruu", "Allow: REGISTER, OPTIONS", "User-Agent: " + session.provider.config.UserAgent, } if session.securityOffered() { lines = append( lines, "Security-Client: "+session.securityProposal.headerValue(), "Require: sec-agree", "Proxy-Require: sec-agree", ) if session.securityActive { lines = append(lines, "Security-Verify: "+session.securityAgreement.verifyValue) } } if authorization != "" { if session.securityOffered() { integrity := "no" if session.securityActive { integrity = "yes" } authorization += ", integrity-protected=" + integrity } lines = append(lines, authorizationHeader+": "+authorization) } else if cseq == 1 && session.securityOffered() { lines = append(lines, "Authorization: "+session.emptyDigestAuthorization()) } lines = append(lines, "Content-Length: 0", "", "") return []byte(strings.Join(lines, "\r\n")), nil } func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) { if err := ctx.Err(); err != nil { return nil, err } if session.runtimeStarted { return session.exchangeRuntime(ctx, request, sipTransactionKey{ callID: session.callID, cseq: cseq, method: "REGISTER", }) } deadline := time.Now().Add(session.provider.config.TransactionTimeout) if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { deadline = contextDeadline } readUDP := session.protectedUDP protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil if err := session.conn.SetDeadline(deadline); err != nil { return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err) } if protectedUDP { if err := readUDP.SetReadDeadline(deadline); err != nil { return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err) } } stopCancellation := context.AfterFunc(ctx, func() { _ = session.conn.SetDeadline(time.Now()) if protectedUDP { _ = readUDP.SetReadDeadline(time.Now()) } }) defer stopCancellation() if _, err := session.conn.Write(request); err != nil { return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err) } for { var response *sipResponse var err error if session.transport == "tcp" { response, err = readSIPResponse(session.reader) } else if protectedUDP { packet := make([]byte, 65535) var count int var remote *net.UDPAddr count, remote, err = readUDP.ReadFromUDP(packet) if err == nil && !session.validProtectedUDPSource(remote) { continue } if err == nil { response, err = parseSIPResponse(packet[:count]) } } else { packet := make([]byte, 65535) var count int count, err = session.conn.Read(packet) if err == nil { response, err = parseSIPResponse(packet[:count]) } } if err != nil { if contextErr := ctx.Err(); contextErr != nil { return nil, contextErr } return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err) } if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) { continue } responseCSeq, method, err := cseqNumber(response.value("CSeq")) if err != nil || responseCSeq != cseq || method != "REGISTER" { continue } if response.StatusCode >= 100 && response.StatusCode < 200 { continue } return response, nil } } func (session *Session) applyRegistrationEvidence(response *sipResponse) error { if session.provider.config.SecurityMode == SecurityRequired && !session.securityActive { session.evidence.Registered = false session.evidence.RegistrationState = "security_failed" return ErrIPSecAgreementRequired } associated := splitHeaderValues(response.values("P-Associated-URI")) contacts := splitHeaderValues(response.values("Contact")) serviceRoutes := splitHeaderValues(response.values("Service-Route")) registeredContact := "" smsConfirmed := false instanceLower := strings.ToLower(session.instanceID) contactURILower := strings.ToLower(fmt.Sprintf( "sip:%s@%s;transport=%s", session.identity.user, session.contactAddress(), session.transport, )) for _, contact := range contacts { lower := strings.ToLower(contact) matchesThisSession := strings.Contains(lower, instanceLower) || strings.Contains(lower, contactURILower) if matchesThisSession { registeredContact = contact smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip") if strings.Contains(lower, instanceLower) { break } } } expiry := registrationExpiry(response, contacts, session.provider.config.RegistrationExpiry) if expiry <= 0 { session.evidence.Registered = false session.evidence.RegistrationState = "rejected_zero_expiry" session.evidence.LastSIPCode = response.StatusCode session.clearAuthentication() return fmt.Errorf("%w: registrar granted zero expiry", ErrRegistrationRejected) } session.expiresAt = time.Now().Add(expiry) session.smsContactConfirmed = smsConfirmed session.evidence = vowifi.IMSEvidence{ Registered: true, RegistrationState: "registered", PAssociatedURI: append([]string(nil), associated...), AssociatedIdentities: append([]string(nil), associated...), RegisteredContact: registeredContact, ServiceRoute: append([]string(nil), serviceRoutes...), Transport: session.transport, LastSIPCode: response.StatusCode, SecurityMode: session.effectiveSecurityMode(), SecurityVerified: session.securityActive, } session.clearAuthentication() return nil } func (session *Session) refreshLoop() { defer close(session.refreshDone) for { session.mu.Lock() if session.closed || !session.evidence.Registered { session.mu.Unlock() return } delay := refreshDelay(time.Until(session.expiresAt)) session.mu.Unlock() timer := time.NewTimer(delay) select { case <-session.refreshContext.Done(): if !timer.Stop() { <-timer.C } return case <-timer.C: } if err := session.refreshOnce(session.refreshContext); err != nil { session.publishFailure(err) return } } } func refreshDelay(remaining time.Duration) time.Duration { if remaining <= 0 { return 0 } delay := remaining * 4 / 5 if remaining > time.Minute && delay > remaining-30*time.Second { delay = remaining - 30*time.Second } if delay < 100*time.Millisecond { delay = 100 * time.Millisecond } return delay } func (session *Session) refreshOnce(ctx context.Context) error { session.mu.Lock() defer session.mu.Unlock() switch { case session.closed: return ErrSessionClosed case !session.evidence.Registered: return vowifi.ErrIMSNotRegistered } response, err := session.register(ctx, int(session.provider.config.RegistrationExpiry/time.Second)) if err != nil { session.failRefresh() return fmt.Errorf("ims: refresh registration: %w", err) } if response.StatusCode != 200 { session.failRefresh() return fmt.Errorf("ims: refresh registration returned SIP %d", response.StatusCode) } if err := session.applyRegistrationEvidence(response); err != nil { session.failRefresh() return err } return nil } func (session *Session) failRefresh() { session.evidence.Registered = false session.evidence.RegistrationState = "refresh_failed" session.smsContactConfirmed = false session.expiresAt = time.Time{} session.clearAuthentication() } func (session *Session) publishFailure(err error) { if err == nil { return } session.failureOnce.Do(func() { session.failures <- err }) } func (session *Session) Failures() <-chan error { return session.failures } func (session *Session) clearAuthentication() { if session.auth == nil { return } for index := range session.auth.password { session.auth.password[index] = 0 } session.auth = nil } func registrationExpiry(response *sipResponse, contacts []string, fallback time.Duration) time.Duration { for _, contact := range contacts { if seconds, ok := parameterSeconds(contact, "expires"); ok { return boundedExpiry(seconds) } } if seconds, err := strconv.Atoi(strings.TrimSpace(response.value("Expires"))); err == nil { return boundedExpiry(seconds) } return fallback } func parameterSeconds(value string, name string) (int, bool) { lower := strings.ToLower(value) needle := strings.ToLower(name) + "=" index := strings.Index(lower, needle) if index < 0 { return 0, false } start := index + len(needle) end := start for end < len(value) && value[end] >= '0' && value[end] <= '9' { end++ } if end == start { return 0, false } seconds, err := strconv.Atoi(value[start:end]) return seconds, err == nil } func boundedExpiry(seconds int) time.Duration { if seconds <= 0 { return 0 } expiry := time.Duration(seconds) * time.Second if expiry > 24*time.Hour { return 24 * time.Hour } return expiry } func (session *Session) Evidence() vowifi.IMSEvidence { session.mu.Lock() defer session.mu.Unlock() evidence := cloneEvidence(session.evidence) if session.closed { evidence.Registered = false evidence.RegistrationState = "closed" } else if evidence.Registered && !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt) { evidence.Registered = false evidence.RegistrationState = "expired" } return evidence } func (session *Session) EnableSMS(ctx context.Context) (vowifi.SMSEvidence, error) { if ctx == nil { ctx = context.Background() } if err := ctx.Err(); err != nil { return vowifi.SMSEvidence{}, err } session.mu.Lock() defer session.mu.Unlock() // This method is deliberately evidence-only. It never sends SIP MESSAGE, // modem CMGS commands, or any dial request. A registrar-confirmed feature // tag on this session's own Contact is the minimum readiness proof. switch { case session.closed: return vowifi.SMSEvidence{}, ErrSessionClosed case !session.evidence.Registered: return vowifi.SMSEvidence{}, vowifi.ErrIMSNotRegistered case !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt): return vowifi.SMSEvidence{}, ErrRegistrationExpired case !session.smsContactConfirmed: return vowifi.SMSEvidence{Ready: false}, ErrSMSCapabilityNotConfirmed default: return vowifi.SMSEvidence{Ready: true}, nil } } func (session *Session) Close(ctx context.Context) error { if ctx == nil { ctx = context.Background() } session.refreshCancel() select { case <-session.refreshDone: case <-ctx.Done(): _ = session.conn.Close() <-session.refreshDone } session.mu.Lock() if session.closed { session.mu.Unlock() return nil } var unregisterErr error if session.evidence.Registered && ctx.Err() == nil { response, err := session.register(ctx, 0) if err != nil { unregisterErr = err } else if response.StatusCode != 200 { unregisterErr = fmt.Errorf("ims: SIP deregistration returned %d", response.StatusCode) } } session.closed = true session.evidence.Registered = false session.evidence.RegistrationState = "closed" session.smsContactConfirmed = false session.clearAuthentication() session.mu.Unlock() var cleanupErrors []error if unregisterErr != nil { cleanupErrors = append(cleanupErrors, unregisterErr) } if err := session.conn.Close(); err != nil { cleanupErrors = append(cleanupErrors, err) } if session.protectedTCP != nil { if err := session.protectedTCP.Close(); err != nil && !errors.Is(err, net.ErrClosed) { cleanupErrors = append(cleanupErrors, err) } } if session.protectedUDP != nil { if err := session.protectedUDP.Close(); err != nil && !errors.Is(err, net.ErrClosed) { cleanupErrors = append(cleanupErrors, err) } } session.closeInboundConnections() session.receiveDone.Wait() if session.ipsecHandle != nil { if err := session.ipsecHandle.Close(ctx); err != nil { cleanupErrors = append(cleanupErrors, err) } } return errors.Join(cleanupErrors...) } func cloneEvidence(evidence vowifi.IMSEvidence) vowifi.IMSEvidence { evidence.PAssociatedURI = append([]string(nil), evidence.PAssociatedURI...) evidence.AssociatedIdentities = append([]string(nil), evidence.AssociatedIdentities...) evidence.ServiceRoute = append([]string(nil), evidence.ServiceRoute...) return evidence } func randomHex(size int) (string, error) { value := make([]byte, size) if _, err := rand.Read(value); err != nil { return "", fmt.Errorf("ims: create random SIP identifier: %w", err) } return hex.EncodeToString(value), nil } func randomUUID() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return "", fmt.Errorf("ims: create SIP instance identifier: %w", err) } value[6] = (value[6] & 0x0f) | 0x40 value[8] = (value[8] & 0x3f) | 0x80 return fmt.Sprintf( "%x-%x-%x-%x-%x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16], ), nil } func addressHost(address net.Addr) string { if address == nil { return "localhost" } host, _, err := net.SplitHostPort(address.String()) if err == nil { return strings.Trim(host, "[]") } return strings.Trim(address.String(), "[]") } func addressIP(address net.Addr) net.IP { host := addressHost(address) ip := net.ParseIP(strings.Trim(host, "[]")) if ip == nil { return nil } return append(net.IP(nil), ip...) } func digitsBetween(value string, minimum int, maximum int) bool { if len(value) < minimum || len(value) > maximum { return false } for _, character := range value { if character < '0' || character > '9' { return false } } return true } var _ vowifi.IMSProvider = (*Provider)(nil) var _ vowifi.IMSSession = (*Session)(nil) var _ vowifi.RuntimeFailureNotifier = (*Session)(nil)