Files
VoCat/internal/vowifi/ims/security.go
T
2026-08-09 05:33:21 +08:00

748 lines
22 KiB
Go

package ims
import (
"bufio"
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
)
type SecurityMode string
const (
// SecurityRequired is the production default. A 401 response without a
// supported ipsec-3gpp Security-Server offer fails closed.
SecurityRequired SecurityMode = "required"
// SecurityOptional advertises ipsec-3gpp but permits a carrier that
// explicitly omits Security-Server to continue on the tunnel in plain SIP.
SecurityOptional SecurityMode = "optional"
// SecurityDisabled is intended for controlled interoperability testing.
SecurityDisabled SecurityMode = "disabled"
)
var (
ErrIPSecAgreementRequired = errors.New("ims: a supported ipsec-3gpp security agreement is required")
ErrIPSecInstall = errors.New("ims: install ipsec-3gpp security associations")
)
// IPSecSAConfig is the complete, evidence-derived 3GPP transport-mode SA set.
// The two UE SPIs identify inbound SAs; the two P-CSCF SPIs identify outbound
// SAs. EncryptionKey and IntegrityKey must be discarded after Install returns.
type IPSecSAConfig struct {
LocalIP net.IP
RemoteIP net.IP
UEClientSPI uint32
UEServerSPI uint32
PCSCFClientSPI uint32
PCSCFServerSPI uint32
UEClientPort int
UEServerPort int
PCSCFClientPort int
PCSCFServerPort int
EncryptionKey []byte
IntegrityKey []byte
}
type IPSecSAHandle interface {
Close(context.Context) error
}
type IPSecSAInstaller interface {
Install(context.Context, IPSecSAConfig) (IPSecSAHandle, error)
}
type securityProposal struct {
spiClient uint32
spiServer uint32
portClient int
portServer int
}
func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredServerPort int) (securityProposal, error) {
spiClient, err := randomSPI(0)
if err != nil {
return securityProposal{}, err
}
spiServer, err := randomSPI(spiClient)
if err != nil {
return securityProposal{}, err
}
portClient := configuredClientPort
if portClient == 0 {
portClient, err = availableProtectedPort(localIP, 0)
if err != nil {
return securityProposal{}, err
}
}
portServer := configuredServerPort
if portServer == 0 {
portServer, err = availableProtectedPort(localIP, portClient)
if err != nil {
return securityProposal{}, err
}
}
if !validProtectedPort(portClient) || !validProtectedPort(portServer) || portClient == portServer {
return securityProposal{}, errors.New("ims: protected UE ports must be distinct non-standard SIP ports")
}
return securityProposal{
spiClient: spiClient,
spiServer: spiServer,
portClient: portClient,
portServer: portServer,
}, nil
}
func (proposal securityProposal) headerValue() string {
return fmt.Sprintf(
"ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d",
proposal.spiClient,
proposal.spiServer,
proposal.portClient,
proposal.portServer,
)
}
func randomSPI(exclude uint32) (uint32, error) {
for attempts := 0; attempts < 16; attempts++ {
var value [4]byte
if _, err := rand.Read(value[:]); err != nil {
return 0, fmt.Errorf("ims: create protected SPI: %w", err)
}
spi := binary.BigEndian.Uint32(value[:])
if spi >= 256 && spi != exclude {
return spi, nil
}
}
return 0, errors.New("ims: could not allocate a protected SPI")
}
func availableProtectedPort(localIP net.IP, exclude int) (int, error) {
for attempts := 0; attempts < 32; attempts++ {
var value [2]byte
if _, err := rand.Read(value[:]); err != nil {
return 0, fmt.Errorf("ims: create protected port: %w", err)
}
port := 20000 + int(binary.BigEndian.Uint16(value[:]))%44000
if port == exclude || !validProtectedPort(port) {
continue
}
address := &net.TCPAddr{IP: append(net.IP(nil), localIP...), Port: port}
listener, err := net.ListenTCP("tcp", address)
if err != nil {
continue
}
_ = listener.Close()
packet, err := net.ListenUDP("udp", &net.UDPAddr{IP: append(net.IP(nil), localIP...), Port: port})
if err != nil {
continue
}
_ = packet.Close()
return port, nil
}
return 0, errors.New("ims: no protected local port is available")
}
func validProtectedPort(port int) bool {
return port > 1024 && port <= 65535 && port != 5060 && port != 5061
}
type securityMechanism struct {
raw string
name string
algorithm string
protocol string
mode string
encryption string
spiClient uint32
spiServer uint32
portClient int
portServer int
preference int
}
type securityAgreement struct {
selected securityMechanism
verifyValue string
}
func parseSecurityAgreement(values []string, proposal securityProposal) (securityAgreement, error) {
items := splitHeaderValues(values)
if len(items) == 0 {
return securityAgreement{}, ErrIPSecAgreementRequired
}
candidates := make([]securityMechanism, 0, len(items))
for _, item := range items {
mechanism, err := parseSecurityMechanism(item)
if err != nil {
name := strings.ToLower(strings.TrimSpace(strings.SplitN(item, ";", 2)[0]))
if name == "ipsec-3gpp" {
return securityAgreement{}, fmt.Errorf(
"ims: malformed ipsec-3gpp Security-Server: %w",
err,
)
}
continue
}
if !strings.EqualFold(mechanism.name, "ipsec-3gpp") ||
!strings.EqualFold(mechanism.algorithm, "hmac-sha-1-96") ||
!strings.EqualFold(mechanism.protocol, "esp") ||
!strings.EqualFold(mechanism.mode, "trans") ||
!strings.EqualFold(mechanism.encryption, "aes-cbc") {
continue
}
if mechanism.spiClient == 0 || mechanism.spiServer == 0 ||
mechanism.spiClient == mechanism.spiServer ||
mechanism.spiClient == proposal.spiClient ||
mechanism.spiClient == proposal.spiServer ||
mechanism.spiServer == proposal.spiClient ||
mechanism.spiServer == proposal.spiServer ||
!validProtectedPort(mechanism.portClient) ||
!validProtectedPort(mechanism.portServer) ||
mechanism.portClient == mechanism.portServer {
continue
}
candidates = append(candidates, mechanism)
}
if len(candidates) == 0 {
return securityAgreement{}, ErrIPSecAgreementRequired
}
sort.SliceStable(candidates, func(left int, right int) bool {
return candidates[left].preference > candidates[right].preference
})
return securityAgreement{
selected: candidates[0],
verifyValue: strings.Join(items, ", "),
}, nil
}
func parseSecurityMechanism(value string) (securityMechanism, error) {
parts := strings.Split(value, ";")
if len(parts) == 0 {
return securityMechanism{}, errors.New("ims: empty Security-Server mechanism")
}
mechanism := securityMechanism{
raw: strings.TrimSpace(value),
name: strings.ToLower(strings.TrimSpace(parts[0])),
protocol: "esp",
mode: "trans",
encryption: "null",
}
parameters := make(map[string]string)
for _, raw := range parts[1:] {
key, parameterValue, found := strings.Cut(strings.TrimSpace(raw), "=")
if !found {
return securityMechanism{}, errors.New("ims: malformed Security-Server parameter")
}
key = strings.ToLower(strings.TrimSpace(key))
parameterValue = strings.Trim(strings.TrimSpace(parameterValue), `"`)
if key == "" || parameterValue == "" {
return securityMechanism{}, errors.New("ims: empty Security-Server parameter")
}
if _, duplicate := parameters[key]; duplicate {
return securityMechanism{}, errors.New("ims: duplicate Security-Server parameter")
}
parameters[key] = parameterValue
}
mechanism.algorithm = strings.ToLower(parameters["alg"])
if value := parameters["prot"]; value != "" {
mechanism.protocol = strings.ToLower(value)
}
if value := parameters["mod"]; value != "" {
mechanism.mode = strings.ToLower(value)
}
if value := parameters["ealg"]; value != "" {
mechanism.encryption = strings.ToLower(value)
}
var err error
if mechanism.spiClient, err = decimalUint32(parameters["spi-c"]); err != nil {
return securityMechanism{}, err
}
if mechanism.spiServer, err = decimalUint32(parameters["spi-s"]); err != nil {
return securityMechanism{}, err
}
if mechanism.portClient, err = decimalPort(parameters["port-c"]); err != nil {
return securityMechanism{}, err
}
if mechanism.portServer, err = decimalPort(parameters["port-s"]); err != nil {
return securityMechanism{}, err
}
mechanism.preference, err = preferenceValue(parameters["q"])
if err != nil {
return securityMechanism{}, err
}
return mechanism, nil
}
func decimalUint32(value string) (uint32, error) {
if value == "" || len(value) > 10 {
return 0, errors.New("ims: invalid Security-Server SPI")
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return 0, errors.New("ims: invalid Security-Server SPI")
}
return uint32(parsed), nil
}
func decimalPort(value string) (int, error) {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 65535 {
return 0, errors.New("ims: invalid Security-Server port")
}
return parsed, nil
}
func preferenceValue(value string) (int, error) {
if value == "" {
return 0, nil
}
whole, fraction, found := strings.Cut(value, ".")
if whole != "0" && whole != "1" {
return 0, errors.New("ims: invalid Security-Server preference")
}
if !found {
if whole == "1" {
return 1000, nil
}
return 0, nil
}
if len(fraction) > 3 {
return 0, errors.New("ims: invalid Security-Server preference")
}
for len(fraction) < 3 {
fraction += "0"
}
numeric, err := strconv.Atoi(fraction)
if err != nil || (whole == "1" && numeric != 0) {
return 0, errors.New("ims: invalid Security-Server preference")
}
if whole == "1" {
return 1000, nil
}
return numeric, nil
}
func expandIPSecKeys(ck []byte, ik []byte) (encryption []byte, integrity []byte, err error) {
if len(ck) != 16 || len(ik) != 16 {
return nil, nil, errors.New("ims: AKA did not return 16-byte CK and IK")
}
encryption = append([]byte(nil), ck...)
integrity = make([]byte, 20)
copy(integrity, ik)
return encryption, integrity, nil
}
type xfrmOperation struct {
description string
arguments []string
}
func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) {
if err := validateIPSecSAConfig(config); err != nil {
return nil, err
}
var operations []xfrmOperation
states := []struct {
description string
source net.IP
destination net.IP
spi uint32
reqid uint32
}{
{"outbound UE-client to P-CSCF-server state", config.LocalIP, config.RemoteIP, config.PCSCFServerSPI, clientPairReqID(config)},
{"inbound P-CSCF-server to UE-client state", config.RemoteIP, config.LocalIP, config.UEClientSPI, clientPairReqID(config)},
{"inbound P-CSCF-client to UE-server state", config.RemoteIP, config.LocalIP, config.UEServerSPI, serverPairReqID(config)},
{"outbound UE-server to P-CSCF-client state", config.LocalIP, config.RemoteIP, config.PCSCFClientSPI, serverPairReqID(config)},
}
for _, state := range states {
operations = append(operations, xfrmOperation{
description: state.description,
arguments: []string{
"xfrm", "state", "add",
"src", state.source.String(),
"dst", state.destination.String(),
"proto", "esp",
"spi", fmt.Sprintf("0x%08x", state.spi),
"reqid", strconv.FormatUint(uint64(state.reqid), 10),
"mode", "transport",
"replay-window", "32",
"auth-trunc", "hmac(sha1)", "0x" + hex.EncodeToString(config.IntegrityKey), "96",
"enc", "cbc(aes)", "0x" + hex.EncodeToString(config.EncryptionKey),
},
})
}
for _, flow := range xfrmFlows(config) {
for _, protocol := range flow.protocols {
operations = append(operations, xfrmOperation{
description: flow.description + " " + protocol + " policy",
arguments: []string{
flow.family,
"xfrm", "policy", "add",
"src", flow.sourcePrefix,
"dst", flow.destinationPrefix,
"proto", protocol,
"sport", strconv.Itoa(flow.sourcePort),
"dport", strconv.Itoa(flow.destinationPort),
"dir", flow.direction,
"priority", "100",
"tmpl",
"src", flow.templateSource.String(),
"dst", flow.templateDestination.String(),
"proto", "esp",
"spi", fmt.Sprintf("0x%08x", flow.spi),
"reqid", strconv.FormatUint(uint64(flow.reqid), 10),
"mode", "transport",
"level", "required",
},
})
}
}
return operations, nil
}
func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation {
var operations []xfrmOperation
flows := xfrmFlows(config)
for flowIndex := len(flows) - 1; flowIndex >= 0; flowIndex-- {
flow := flows[flowIndex]
for protocolIndex := len(flow.protocols) - 1; protocolIndex >= 0; protocolIndex-- {
protocol := flow.protocols[protocolIndex]
operations = append(operations, xfrmOperation{
description: "delete " + flow.description + " " + protocol + " policy",
arguments: []string{
flow.family,
"xfrm", "policy", "delete",
"src", flow.sourcePrefix,
"dst", flow.destinationPrefix,
"proto", protocol,
"sport", strconv.Itoa(flow.sourcePort),
"dport", strconv.Itoa(flow.destinationPort),
"dir", flow.direction,
},
})
}
}
states := []struct {
source net.IP
destination net.IP
spi uint32
}{
{config.LocalIP, config.RemoteIP, config.PCSCFClientSPI},
{config.RemoteIP, config.LocalIP, config.UEServerSPI},
{config.RemoteIP, config.LocalIP, config.UEClientSPI},
{config.LocalIP, config.RemoteIP, config.PCSCFServerSPI},
}
for _, state := range states {
operations = append(operations, xfrmOperation{
description: "delete ipsec-3gpp state",
arguments: []string{
"xfrm", "state", "delete",
"src", state.source.String(),
"dst", state.destination.String(),
"proto", "esp",
"spi", fmt.Sprintf("0x%08x", state.spi),
},
})
}
return operations
}
type xfrmFlow struct {
description string
family string
sourcePrefix string
destinationPrefix string
sourcePort int
destinationPort int
direction string
templateSource net.IP
templateDestination net.IP
spi uint32
reqid uint32
protocols []string
}
func xfrmFlows(config IPSecSAConfig) []xfrmFlow {
family := "-4"
prefix := "/32"
if config.LocalIP.To4() == nil {
family = "-6"
prefix = "/128"
}
localPrefix := config.LocalIP.String() + prefix
remotePrefix := config.RemoteIP.String() + prefix
return []xfrmFlow{
{
description: "UE-client to P-CSCF-server", family: family,
sourcePrefix: localPrefix, destinationPrefix: remotePrefix,
sourcePort: config.UEClientPort, destinationPort: config.PCSCFServerPort,
direction: "out", templateSource: config.LocalIP, templateDestination: config.RemoteIP,
spi: config.PCSCFServerSPI, reqid: clientPairReqID(config),
protocols: []string{"tcp", "udp"},
},
{
description: "P-CSCF-server to UE-client", family: family,
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
sourcePort: config.PCSCFServerPort, destinationPort: config.UEClientPort,
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
spi: config.UEClientSPI, reqid: clientPairReqID(config),
protocols: []string{"tcp"},
},
{
description: "P-CSCF-client to UE-server", family: family,
sourcePrefix: remotePrefix, destinationPrefix: localPrefix,
sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort,
direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP,
spi: config.UEServerSPI, reqid: serverPairReqID(config),
protocols: []string{"tcp", "udp"},
},
{
description: "UE-server to P-CSCF-client", family: family,
sourcePrefix: localPrefix, destinationPrefix: remotePrefix,
sourcePort: config.UEServerPort, destinationPort: config.PCSCFClientPort,
direction: "out", templateSource: config.LocalIP, templateDestination: config.RemoteIP,
spi: config.PCSCFClientSPI, reqid: serverPairReqID(config),
protocols: []string{"tcp"},
},
}
}
func clientPairReqID(config IPSecSAConfig) uint32 {
reqid := (config.UEClientSPI ^ config.PCSCFServerSPI) & 0x7fffffff
if reqid == 0 {
return 1
}
return reqid
}
func serverPairReqID(config IPSecSAConfig) uint32 {
reqid := (config.UEServerSPI ^ config.PCSCFClientSPI) & 0x7fffffff
if reqid == 0 {
reqid = 2
}
if reqid == clientPairReqID(config) {
reqid ^= 0x40000000
if reqid == 0 {
reqid = 2
}
}
return reqid
}
func validateIPSecSAConfig(config IPSecSAConfig) error {
local := config.LocalIP
remote := config.RemoteIP
if local == nil || remote == nil || local.IsUnspecified() || remote.IsUnspecified() ||
(local.To4() == nil) != (remote.To4() == nil) {
return errors.New("ims: ipsec-3gpp endpoints are invalid or use different IP families")
}
spis := []uint32{
config.UEClientSPI, config.UEServerSPI, config.PCSCFClientSPI, config.PCSCFServerSPI,
}
seen := make(map[uint32]struct{}, len(spis))
for _, spi := range spis {
if spi == 0 {
return errors.New("ims: ipsec-3gpp SPI is zero")
}
if _, duplicate := seen[spi]; duplicate {
return errors.New("ims: ipsec-3gpp SPIs must be unique")
}
seen[spi] = struct{}{}
}
ports := []int{
config.UEClientPort, config.UEServerPort, config.PCSCFClientPort, config.PCSCFServerPort,
}
for _, port := range ports {
if !validProtectedPort(port) {
return errors.New("ims: ipsec-3gpp protected port is invalid")
}
}
if config.UEClientPort == config.UEServerPort ||
config.PCSCFClientPort == config.PCSCFServerPort {
return errors.New("ims: client and server protected ports must differ")
}
if len(config.EncryptionKey) != 16 || len(config.IntegrityKey) != 20 {
return errors.New("ims: ipsec-3gpp key length is invalid")
}
return nil
}
func cloneIPSecSAConfig(config IPSecSAConfig) IPSecSAConfig {
config.LocalIP = append(net.IP(nil), config.LocalIP...)
config.RemoteIP = append(net.IP(nil), config.RemoteIP...)
config.EncryptionKey = append([]byte(nil), config.EncryptionKey...)
config.IntegrityKey = append([]byte(nil), config.IntegrityKey...)
return config
}
func zeroBytes(value []byte) {
for index := range value {
value[index] = 0
}
}
func (session *Session) securityOffered() bool {
return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined
}
func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) {
if !session.securityOffered() {
return securityAgreement{}, false, nil
}
values := response.values("Security-Server")
if len(splitHeaderValues(values)) == 0 {
if session.provider.config.SecurityMode == SecurityRequired {
return securityAgreement{}, false, ErrIPSecAgreementRequired
}
session.declineSecurity()
return securityAgreement{}, false, nil
}
agreement, err := parseSecurityAgreement(values, session.securityProposal)
if err != nil {
return securityAgreement{}, false, err
}
return agreement, true, nil
}
func (session *Session) declineSecurity() {
session.securityDeclined = true
session.endpoint = session.initialEndpoint
if session.protectedTCP != nil {
_ = session.protectedTCP.Close()
session.protectedTCP = nil
}
if session.protectedUDP != nil {
_ = session.protectedUDP.Close()
session.protectedUDP = nil
}
session.securityProposal = securityProposal{}
}
func (session *Session) activateIPSec(
ctx context.Context,
agreement securityAgreement,
ck []byte,
ik []byte,
) error {
if session.securityActive {
return errors.New("ims: ipsec-3gpp is already active")
}
if !session.securityOffered() {
return ErrIPSecAgreementRequired
}
encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik)
if err != nil {
return err
}
defer zeroBytes(encryptionKey)
defer zeroBytes(integrityKey)
localIP := addressIP(session.conn.LocalAddr())
remoteIP := addressIP(session.conn.RemoteAddr())
if localIP == nil || remoteIP == nil {
return errors.New("ims: protected SIP endpoints are unavailable")
}
selected := agreement.selected
config := IPSecSAConfig{
LocalIP: localIP,
RemoteIP: remoteIP,
UEClientSPI: session.securityProposal.spiClient,
UEServerSPI: session.securityProposal.spiServer,
PCSCFClientSPI: selected.spiClient,
PCSCFServerSPI: selected.spiServer,
UEClientPort: session.securityProposal.portClient,
UEServerPort: session.securityProposal.portServer,
PCSCFClientPort: selected.portClient,
PCSCFServerPort: selected.portServer,
EncryptionKey: encryptionKey,
IntegrityKey: integrityKey,
}
handle, err := session.provider.installer.Install(ctx, config)
if err != nil {
return fmt.Errorf("%w: %v", ErrIPSecInstall, err)
}
if handle == nil {
return fmt.Errorf("%w: installer returned no handle", ErrIPSecInstall)
}
remoteAddress := net.JoinHostPort(remoteIP.String(), strconv.Itoa(selected.portServer))
_ = session.conn.Close()
connection, dialErr := dialSIP(
ctx,
session.transport,
localIP.String(),
session.securityProposal.portClient,
remoteAddress,
)
if dialErr != nil {
cleanupErr := handle.Close(context.Background())
if cleanupErr != nil {
return errors.Join(
fmt.Errorf("ims: connect protected P-CSCF: %w", dialErr),
fmt.Errorf("ims: roll back ipsec-3gpp: %w", cleanupErr),
)
}
return fmt.Errorf("ims: connect protected P-CSCF: %w", dialErr)
}
session.conn = connection
if session.transport == "tcp" {
session.reader = bufio.NewReader(connection)
} else {
session.reader = nil
}
session.endpoint.port = selected.portServer
session.securityAgreement = agreement
session.securityActive = true
session.ipsecHandle = handle
return nil
}
func (session *Session) contactAddress() string {
if session.securityOffered() {
host := addressHost(session.conn.LocalAddr())
return net.JoinHostPort(host, strconv.Itoa(session.securityProposal.portServer))
}
return session.conn.LocalAddr().String()
}
func (session *Session) emptyDigestAuthorization() string {
uri := "sip:" + session.identity.domain
return "Digest " + strings.Join([]string{
`username="` + quoteDigest(session.identity.private) + `"`,
`realm="` + quoteDigest(session.identity.domain) + `"`,
`nonce=""`,
`uri="` + quoteDigest(uri) + `"`,
`response=""`,
"algorithm=AKAv1-MD5",
"integrity-protected=no",
}, ", ")
}
func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool {
if remote == nil || !session.securityActive {
return false
}
expectedIP := addressIP(session.conn.RemoteAddr())
return expectedIP != nil &&
expectedIP.Equal(remote.IP) &&
remote.Port == session.securityAgreement.selected.portClient
}
func (session *Session) effectiveSecurityMode() string {
if session.securityActive {
return "ipsec-3gpp"
}
return "none"
}