diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index adf94d3..6900f74 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -715,9 +715,13 @@ func newVoWiFiOrchestrator( return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err) } imsProvider, err := ims.NewProvider(adapter, ims.Config{ - // The userspace SWu data plane currently carries the protected P-CSCF - // signalling path over TCP. + // The userspace SWu data plane carries protected P-CSCF signalling over + // TCP by default. UK PLMN 234-10 exposes its P-CSCF over UDP/5060 on SWu. Transport: "tcp", + TransportByPLMN: map[string]string{ + "23410": "udp", + "234010": "udp", + }, // Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes // this service-centre number for manual SMS setup. SMSCenter: "+447785016005", diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go index c93b22c..82a6cdf 100644 --- a/internal/vowifi/ims/provider.go +++ b/internal/vowifi/ims/provider.go @@ -38,6 +38,7 @@ type Config struct { PCSCF string LocalAddress string Transport string + TransportByPLMN map[string]string Port int RegistrationExpiry time.Duration TransactionTimeout time.Duration @@ -106,6 +107,19 @@ func normalizeConfig(config Config) (Config, error) { if config.Transport != "" && config.Transport != "udp" && config.Transport != "tcp" { return Config{}, fmt.Errorf("ims: unsupported SIP transport %q", config.Transport) } + transportByPLMN := make(map[string]string, len(config.TransportByPLMN)) + for plmn, transport := range config.TransportByPLMN { + plmn = strings.TrimSpace(plmn) + transport = strings.ToLower(strings.TrimSpace(transport)) + if !digitsBetween(plmn, 5, 6) { + return Config{}, fmt.Errorf("ims: invalid transport override PLMN %q", plmn) + } + if transport != "udp" && transport != "tcp" { + return Config{}, fmt.Errorf("ims: unsupported SIP transport %q for PLMN %s", transport, plmn) + } + transportByPLMN[plmn] = transport + } + config.TransportByPLMN = transportByPLMN if strings.TrimSpace(config.UserAgent) == "" { config.UserAgent = "vocat/1" } @@ -185,7 +199,7 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) 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 + transport := transportForIdentity(provider.config, request.Identity) if transport == "" { transport = transportHint } @@ -227,6 +241,15 @@ func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) return session, nil } +func transportForIdentity(config Config, identity vowifi.SIMIdentity) string { + mcc := strings.TrimSpace(identity.HomeMCC) + mnc := strings.TrimSpace(identity.HomeMNC) + if transport := config.TransportByPLMN[mcc+mnc]; transport != "" { + return transport + } + return config.Transport +} + type identitySet struct { domain string private string diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go index 1f71a48..5cb3dc2 100644 --- a/internal/vowifi/ims/provider_test.go +++ b/internal/vowifi/ims/provider_test.go @@ -26,6 +26,53 @@ func (evidenceTunnel) Close(context.Context) error { return nil } +func TestTransportForIdentityUsesPLMNOverride(t *testing.T) { + t.Parallel() + config := Config{ + Transport: "tcp", + TransportByPLMN: map[string]string{ + "23410": "udp", + "234010": "udp", + }, + } + + if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "10"}); got != "udp" { + t.Fatalf("PLMN 234-10 transport = %q, want udp", got) + } + if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "010"}); got != "udp" { + t.Fatalf("zero-padded PLMN 234-010 transport = %q, want udp", got) + } + if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "234", HomeMNC: "15"}); got != "tcp" { + t.Fatalf("non-overridden transport = %q, want tcp", got) + } +} + +func TestTransportForIdentityPreservesLeadingZeroMNCs(t *testing.T) { + t.Parallel() + config := Config{ + TransportByPLMN: map[string]string{ + "31001": "udp", + "310001": "tcp", + "31000": "udp", + "310000": "tcp", + }, + } + + for _, test := range []struct { + mnc string + want string + }{ + {mnc: "01", want: "udp"}, + {mnc: "001", want: "tcp"}, + {mnc: "00", want: "udp"}, + {mnc: "000", want: "tcp"}, + } { + if got := transportForIdentity(config, vowifi.SIMIdentity{HomeMCC: "310", HomeMNC: test.mnc}); got != test.want { + t.Errorf("PLMN 310-%s transport = %q, want %q", test.mnc, got, test.want) + } + } +} + func TestProviderRegisterAKAParseEvidenceAndClose(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/vowifi/ims/security.go b/internal/vowifi/ims/security.go index 9a8caaa..600c1fc 100644 --- a/internal/vowifi/ims/security.go +++ b/internal/vowifi/ims/security.go @@ -28,8 +28,9 @@ const ( ) var ( - ErrIPSecAgreementRequired = errors.New("ims: a supported ipsec-3gpp security agreement is required") - ErrIPSecInstall = errors.New("ims: install ipsec-3gpp security associations") + ErrIPSecAgreementRequired = errors.New("ims: a supported ipsec-3gpp security agreement is required") + ErrIPSecInstall = errors.New("ims: install ipsec-3gpp security associations") + errIncompleteSecurityOffer = errors.New("ims: incomplete ipsec-3gpp security offer") ) // IPSecSAConfig is the complete, evidence-derived 3GPP transport-mode SA set. @@ -186,6 +187,9 @@ func parseSecurityAgreement(values []string, proposal securityProposal) (securit if err != nil { name := strings.ToLower(strings.TrimSpace(strings.SplitN(item, ";", 2)[0])) if name == "ipsec-3gpp" { + if errors.Is(err, errIncompleteSecurityOffer) { + continue + } return securityAgreement{}, fmt.Errorf( "ims: malformed ipsec-3gpp Security-Server: %w", err, @@ -263,6 +267,19 @@ func parseSecurityMechanism(value string) (securityMechanism, error) { if value := parameters["ealg"]; value != "" { mechanism.encryption = strings.ToLower(value) } + saParameterKeys := []string{"spi-c", "spi-s", "port-c", "port-s"} + presentSAParameters := 0 + for _, key := range saParameterKeys { + if parameters[key] != "" { + presentSAParameters++ + } + } + if presentSAParameters == 0 { + return securityMechanism{}, errIncompleteSecurityOffer + } + if presentSAParameters != len(saParameterKeys) { + return securityMechanism{}, errors.New("ims: partially specified Security-Server SA parameters") + } var err error if mechanism.spiClient, err = decimalUint32(parameters["spi-c"]); err != nil { return securityMechanism{}, err diff --git a/internal/vowifi/ims/security_test.go b/internal/vowifi/ims/security_test.go index 7993f84..cc99109 100644 --- a/internal/vowifi/ims/security_test.go +++ b/internal/vowifi/ims/security_test.go @@ -36,6 +36,31 @@ func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) { } } +func TestParseSecurityAgreementSkipsIncompleteCarrierAlternatives(t *testing.T) { + proposal := securityProposal{ + spiClient: 1001, + spiServer: 1002, + portClient: 40666, + portServer: 55610, + } + incomplete := []string{ + "ipsec-3gpp;q=0.100;alg=hmac-md5-96;mod=trans", + "ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;ealg=des-ede3-cbc;mod=trans", + "ipsec-3gpp;q=0.300;alg=hmac-sha-1-96;ealg=aes-cbc;mod=trans", + } + selected := "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;" + + "ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600" + values := []string{strings.Join(append(incomplete, selected), ", ")} + + agreement, err := parseSecurityAgreement(values, proposal) + if err != nil { + t.Fatalf("parseSecurityAgreement() error = %v", err) + } + if agreement.selected.spiClient != 2001 || agreement.selected.spiServer != 2002 { + t.Fatalf("selected mechanism = %#v", agreement.selected) + } +} + func TestParseSecurityAgreementFailsClosed(t *testing.T) { proposal := securityProposal{ spiClient: 1001, @@ -73,6 +98,12 @@ func TestParseSecurityAgreementFailsClosed(t *testing.T) { valid + ", ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;alg=hmac-sha-1-96", }, }, + { + name: "partially specified SA parameters poison otherwise valid list", + values: []string{ + valid + ", ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;spi-c=3001", + }, + }, { name: "no offer", values: nil,