diff --git a/README.md b/README.md index 8b6b62ce..ae5ec66c 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ ---- ## Description -PacketRusher is a tool, based upon [my5G-RANTester](https://github.com/my5G/my5G-RANTester), dedicated to the performance testing and automatic validation of 5G Core Networks using simulated UE (user equipment) and gNodeB (5G base station). +#### Now with SUCI Concealing/Deconcealment (Null-Scheme, Profile A (X25519), Profile B (P-256))! -If you have questions or comments, feel free to open an issue. +PacketRusher is a tool dedicated to the performance testing and automatic validation of 5G Core Networks using simulated UE (user equipment) and gNodeB (5G base station). + +If you have questions or comments, feel free to open an issue after **a careful** review of existing closed issues. PacketRusher borrows libraries and data structures from the [free5gc project](https://github.com/free5gc/free5gc). @@ -16,6 +18,7 @@ PacketRusher borrows libraries and data structures from the [free5gc project](ht * Supports both N2 (NGAP) and N1 (NAS) interfaces for stress testing * --pcap parameter to capture pcap of N1/N2 traffic * Implements main control plane procedures: + * SUCI Concealing/Deconcealment (Null-Scheme, Profile A (X25519), Profile B (P-256)) * UE attach/detach (registration/identity request/authentification/security mode) procedures * Create/Delete PDU Sessions, up to 15 PDU Sessions per UE * Xn handover: UE handover between simulated gNodeB (PathSwitchRequest) @@ -110,7 +113,7 @@ If you use this software, you may cite it as below: ## License © Copyright 2023 Hewlett Packard Enterprise Development LP -© Copyright 2024 Valentin D'Emmanuele +© Copyright 2024-2025 Valentin D'Emmanuele This project is under the [Apache 2.0 License](LICENSE) license. diff --git a/config/config.go b/config/config.go index 1b37aeab..d9a7c165 100644 --- a/config/config.go +++ b/config/config.go @@ -5,15 +5,19 @@ package config import ( + "crypto/ecdh" + "encoding/hex" + "my5G-RANTester/internal/common/sidf" "net" "os" "path" "path/filepath" + "strconv" "github.com/free5gc/nas/nasMessage" "github.com/free5gc/nas/nasType" + "github.com/goccy/go-yaml" log "github.com/sirupsen/logrus" - "gopkg.in/yaml.v2" ) // TunnelMode indicates how to create a GTP-U tunnel interface in an UE. @@ -33,7 +37,7 @@ var config *Config type Config struct { GNodeB GNodeB `yaml:"gnodeb"` Ue Ue `yaml:"ue"` - AMFs []*AMF `yaml:"amfif"` + AMFs []*AMF `yaml:"amfif"` Logs Logs `yaml:"logs"` } @@ -64,18 +68,21 @@ type SliceSupportList struct { } type Ue struct { - Msin string `yaml:"msin"` - Key string `yaml:"key"` - Opc string `yaml:"opc"` - Amf string `yaml:"amf"` - Sqn string `yaml:"sqn"` - Dnn string `yaml:"dnn"` - RoutingIndicator string `yaml:"routingindicator"` - Hplmn Hplmn `yaml:"hplmn"` - Snssai Snssai `yaml:"snssai"` - Integrity Integrity `yaml:"integrity"` - Ciphering Ciphering `yaml:"ciphering"` - TunnelMode TunnelMode `yaml:"-"` + Msin string `yaml:"msin"` + Key string `yaml:"key"` + Opc string `yaml:"opc"` + Amf string `yaml:"amf"` + Sqn string `yaml:"sqn"` + Dnn string `yaml:"dnn"` + ProtectionScheme int `yaml:"protectionScheme"` + HomeNetworkPublicKey string `yaml:"homeNetworkPublicKey"` + HomeNetworkPublicKeyID uint8 `yaml:"homeNetworkPublicKeyID"` + RoutingIndicator string `yaml:"routingindicator"` + Hplmn Hplmn `yaml:"hplmn"` + Snssai Snssai `yaml:"snssai"` + Integrity Integrity `yaml:"integrity"` + Ciphering Ciphering `yaml:"ciphering"` + TunnelMode TunnelMode `yaml:"-"` } type Hplmn struct { @@ -136,7 +143,7 @@ func readConfig(configPath string) Config { } defer f.Close() - decoder := yaml.NewDecoder(f) + decoder := yaml.NewDecoder(f, yaml.Strict()) err = decoder.Decode(&cfg) if err != nil { log.Fatal("Could not unmarshal yaml config at \"", configPath, "\". ", err.Error()) @@ -217,6 +224,54 @@ func (config *Config) GetUESecurityCapability() *nasType.UESecurityCapability { return UESecurityCapability } +func (config *Config) GetHomeNetworkPublicKey() sidf.HomeNetworkPublicKey { + switch config.Ue.ProtectionScheme { + case 0: + config.Ue.HomeNetworkPublicKey = "" + config.Ue.HomeNetworkPublicKeyID = 0 + case 1: + key, err := hex.DecodeString(config.Ue.HomeNetworkPublicKey) + if err != nil { + log.Fatalf("Invalid Home Network Public Key in configuration for Profile A: %v", err) + } + + publicKey, err := ecdh.X25519().NewPublicKey(key) + if err != nil { + log.Fatalf("Invalid Home Network Public Key in configuration for Profile A: %v", err) + } + + return sidf.HomeNetworkPublicKey{ + ProtectionScheme: strconv.Itoa(config.Ue.ProtectionScheme), + PublicKey: publicKey, + PublicKeyID: strconv.Itoa(int(config.Ue.HomeNetworkPublicKeyID)), + } + case 2: + key, err := hex.DecodeString(config.Ue.HomeNetworkPublicKey) + if err != nil { + log.Fatalf("Invalid Home Network Public Key in configuration for Profile B: %v", err) + } + + publicKey, err := ecdh.P256().NewPublicKey(key) + if err != nil { + log.Fatalf("Invalid Home Network Public Key in configuration for Profile B: %v", err) + } + + return sidf.HomeNetworkPublicKey{ + ProtectionScheme: strconv.Itoa(config.Ue.ProtectionScheme), + PublicKey: publicKey, + PublicKeyID: strconv.Itoa(int(config.Ue.HomeNetworkPublicKeyID)), + } + default: + log.Fatal("Invalid Protection Scheme for SUCI. Valid values are 0, 1 and 2") + } + + return sidf.HomeNetworkPublicKey{ + ProtectionScheme: "0", + PublicKey: nil, + PublicKeyID: "0", + } +} + func boolToUint8(boolean bool) uint8 { if boolean { return 1 diff --git a/config/config.yml b/config/config.yml index 9c1e2cf0..75aec0d9 100644 --- a/config/config.yml +++ b/config/config.yml @@ -1,44 +1,87 @@ +# PacketRusher Simulated gNodeB Configuration gnodeb: + # IP Address on the N2 Interface (e.g. used between the gNodeB and the AMF) controlif: - ip: "192.168.11.13" + ip: "127.0.0.1" port: 9487 + + # IP Address on the N3 Interface (e.g. used between the gNodeB and the UPF) dataif: - ip: "192.168.11.13" + ip: "127.0.0.1" port: 2152 + + # gNodeB's Identity plmnlist: - mcc: "999" - mnc: "70" + mcc: "208" + mnc: "93" tac: "000001" gnbid: "000008" + + # gNodeB's Supported Slices slicesupportlist: sst: "01" sd: "000001" # optional, can be removed if not used + +# PacketRusher Simulated UE Configuration ue: + # UE's Identity, frequently called IMSI in 4G and before + # IMSI format is "" + # In 5G, the SUPI of the UE will be "imsi-"" + # With default configuration, SUPI will be imsi-208930000000120 + hplmn: + mcc: "208" + mnc: "93" msin: "0000000120" + + # In 5G, the UE's identity to the AMF as a SUCI (Subscription Concealed Identifier) + # + # SUCI format is suci------- + # With default configuration, SUCI sent to AMF will be suci-0-999-70-0000-0-0-0000000120 + # + # SUCI Routing Indicator allows the AMF to route the UE to the correct UDM + routingindicator: "0000" + # + # SUCI Protection Scheme: 0 for Null-scheme, 1 for Profile A and 2 for Profile B + protectionScheme: 0 + # + # Home Network Public Key + # Ignored with default Null-Scheme configuration + homeNetworkPublicKey: "5a8d38864820197c3394b92613b20b91633cbd897119273bf8e4a6f4eec0a650" + # + # Home Network Public Key ID + # Ignored ith default Null-Scheme configuration + homeNetworkPublicKeyID: 1 + + # UE's SIM credentials key: "00112233445566778899AABBCCDDEEFF" opc: "00112233445566778899AABBCCDDEEFF" amf: "8000" sqn: "00000000" + + # UE will request to establish a data session in this DNN (APN) dnn: "internet" - routingindicator: "0000" - hplmn: - mcc: "999" - mnc: "70" + # in the following slice snssai: - sst: 01 + sst: "01" sd: "000001" # optional, can be removed if not used + + # The UE's security capabilities that will be advertised to the AMF integrity: nia0: false nia1: false nia2: true nia3: false ciphering: + # For debugging Wireshark traces, NEA0 is recommended, as the NAS messages + # will be sent in cleartext, and be decipherable in Wireshark. nea0: true nea1: false nea2: true nea3: false + +# List of AMF that PacketRusher will try to connect to amfif: - - ip: "192.168.11.30" + - ip: "127.0.0.1" port: 38412 logs: level: 4 diff --git a/go.mod b/go.mod index a0d1973e..7311df5a 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1 // indirect github.com/antonfisher/nested-logrus-formatter v1.3.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/goccy/go-yaml v1.15.23 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/khirono/go-genl v1.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect diff --git a/go.sum b/go.sum index 8245e7f4..87b90acf 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/free5gc/aper v1.0.5 h1:sUYFFmOXDLjyL4rU6zFnq81M4YluqP90Pso5e/J4UhA= github.com/free5gc/aper v1.0.5/go.mod h1:ybHxhYnRqQ9wD4yB9r/3MZdbCYCjtqUyfLpSnJpwWd4= -github.com/free5gc/go-gtp5gnl v1.4.6 h1:xqwyGjrRNRGwo3/HyfXMh/fQ56QnCUzQKP2XR5/i1cE= -github.com/free5gc/go-gtp5gnl v1.4.6/go.mod h1:TT5aXB90NuSPMehuIK9lV2yJFnq6Qjw37ZqNB1QAKh0= github.com/free5gc/go-gtp5gnl v1.4.7-0.20241008130314-a3088e4cb7fa h1:D5OzFSttS6WY2XRspxtPKoHyCVkRLH9kqteQ1bGfOg0= github.com/free5gc/go-gtp5gnl v1.4.7-0.20241008130314-a3088e4cb7fa/go.mod h1:TT5aXB90NuSPMehuIK9lV2yJFnq6Qjw37ZqNB1QAKh0= github.com/free5gc/nas v1.1.3 h1:eYkvT8GGieD06MExw3JLeIPA88Yg89DFjptVBnadIyQ= @@ -39,6 +37,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.15.23 h1:WS0GAX1uNPDLUvLkNU2vXq6oTnsmfVFocjQ/4qA48qo= +github.com/goccy/go-yaml v1.15.23/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= diff --git a/internal/common/sidf/suci_concealment.go b/internal/common/sidf/suci_concealment.go new file mode 100644 index 00000000..c383ed31 --- /dev/null +++ b/internal/common/sidf/suci_concealment.go @@ -0,0 +1,153 @@ +/** + * SPDX-License-Identifier: Apache-2.0 + * © Copyright 2025 Free Mobile SAS + */ +package sidf + +import ( + "crypto/ecdh" + "crypto/elliptic" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" +) + +type HomeNetworkPublicKey struct { + ProtectionScheme string + PublicKey *ecdh.PublicKey + PublicKeyID string +} + +func profileAEncrypt(msin string, hnPubkey *ecdh.PublicKey) (string, error) { + // Profile A curve + x25519Curve := ecdh.X25519() + + // The UE generates an ephemeral key to transmit its SUPI to network + ephemeralPriv, err := x25519Curve.GenerateKey(rand.Reader) + if err != nil { + return "", fmt.Errorf("failed to generate ephemeral X25519 key: %w", err) + } + ephemeralPub := ephemeralPriv.PublicKey().Bytes() + + // ECDH between UE's ephemeral key and Home Network Public Key + sharedKey, err := ephemeralPriv.ECDH(hnPubkey) + if err != nil { + return "", fmt.Errorf("failed to compute ECDH: %w", err) + } + + plainBCD, err := hex.DecodeString(Tbcd(msin)) + if err != nil { + return "", err + } + + kdfKey := AnsiX963KDF(sharedKey, ephemeralPub, ProfileAEncKeyLen, ProfileAMacKeyLen, ProfileAHashLen) + encKey := kdfKey[:ProfileAEncKeyLen] + iv := kdfKey[ProfileAEncKeyLen : ProfileAEncKeyLen+ProfileAIcbLen] + macKey := kdfKey[len(kdfKey)-ProfileAMacKeyLen:] + + cipherText, err := Aes128ctr(plainBCD, encKey, iv) + if err != nil { + return "", err + } + + mac, err := HmacSha256(cipherText, macKey, ProfileAMacLen) + if err != nil { + return "", err + } + + // UE's ephemeral public key || ciphered(MSIN || iv) || MAC. + out := append(ephemeralPub, cipherText...) + out = append(out, mac...) + + return hex.EncodeToString(out), nil +} + +func profileBEncrypt(msin string, hnPubkey *ecdh.PublicKey) (string, error) { + // Profile B curve + p256Curve := ecdh.P256() + + // The UE generates an ephemeral key to transmit its SUPI to network + ephemeralPriv, err := p256Curve.GenerateKey(rand.Reader) + if err != nil { + return "", fmt.Errorf("failed to generate ephemeral P256 key: %w", err) + } + + // ECDH between UE's ephemeral key and Home Network Public Key + sharedKey, err := ephemeralPriv.ECDH(hnPubkey) + if err != nil { + return "", fmt.Errorf("failed to compute ECDH: %w", err) + } + + // For the KDF we need the ephemeral public key in compressed form + x, y := elliptic.Unmarshal(elliptic.P256(), ephemeralPriv.PublicKey().Bytes()) + if x == nil || y == nil { + return "", errors.New("failed to unmarshal ephemeral public key") + } + ephemeralPubCompressed := elliptic.MarshalCompressed(elliptic.P256(), x, y) + + plainBCD, err := hex.DecodeString(Tbcd(msin)) + if err != nil { + return "", err + } + + kdfKey := AnsiX963KDF(sharedKey, ephemeralPubCompressed, ProfileBEncKeyLen, ProfileBMacKeyLen, ProfileBHashLen) + encKey := kdfKey[:ProfileBEncKeyLen] + iv := kdfKey[ProfileBEncKeyLen : ProfileBEncKeyLen+ProfileBIcbLen] + macKey := kdfKey[len(kdfKey)-ProfileBMacKeyLen:] + + cipherText, err := Aes128ctr(plainBCD, encKey, iv) + if err != nil { + return "", err + } + + mac, err := HmacSha256(cipherText, macKey, ProfileBMacLen) + if err != nil { + return "", err + } + + // ephemeral public key || ciphertext || MAC + out := append(ephemeralPubCompressed, cipherText...) + out = append(out, mac...) + + return hex.EncodeToString(out), nil +} + +func CipherSuci(msin, mcc, mnc string, routingIndicator string, profile HomeNetworkPublicKey) (*Suci, error) { + if len(msin)+len(mcc)+len(mnc) < 14 { + return nil, errors.New("supi length must be 15") + } + + var schemeOutput string + var err error + + switch profile.ProtectionScheme { + case NullScheme: + schemeOutput = msin + case ProfileAScheme: + schemeOutput, err = profileAEncrypt(msin, profile.PublicKey) + if err != nil { + return nil, fmt.Errorf("profile A encryption failed: %w", err) + } + case ProfileBScheme: + schemeOutput, err = profileBEncrypt(msin, profile.PublicKey) + if err != nil { + return nil, fmt.Errorf("profile B encryption failed: %w", err) + } + default: + return nil, fmt.Errorf("unsupported protection scheme: %s", profile.ProtectionScheme) + } + + // suci------- + suci := fmt.Sprintf("%s-%s-%s-%s-%s-%s-%s-%s", + PrefixSUCI, + SupiTypeIMSI, + mcc, + mnc, + routingIndicator, + profile.ProtectionScheme, + profile.PublicKeyID, + schemeOutput, + ) + return ParseSuci(suci), nil +} diff --git a/internal/common/sidf/suci_deconcealing.go b/internal/common/sidf/suci_deconcealing.go new file mode 100644 index 00000000..169b36ce --- /dev/null +++ b/internal/common/sidf/suci_deconcealing.go @@ -0,0 +1,380 @@ +/** + * SPDX-License-Identifier: Apache-2.0 + * © Copyright 2019 The Free5GC Authors + * © Copyright 2025 Free Mobile SAS + */ +package sidf + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/elliptic" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +// suci-0(SUPI type: IMSI)-mcc-mnc-routingIndicator-protectionScheme-homeNetworkPublicKeyID-schemeOutput. +// TODO: suci-1(SUPI type: NAI)-homeNetworkID-routingIndicator-protectionScheme-homeNetworkPublicKeyID-schemeOutput. + +const ( + PrefixIMSI = "imsi-" + PrefixSUCI = "suci" + SupiTypeIMSI = "0" + NullScheme = "0" + ProfileAScheme = "1" + ProfileBScheme = "2" +) + +var ( + // Network and identification patterns. + // Mobile Country Code; 3 digits + mccRegex = `(?P\d{3})` + // Mobile Network Code; 2 or 3 digits + mncRegex = `(?P\d{2,3})` + + // MCC-MNC + imsiTypeRegex = fmt.Sprintf("(?P0-%s-%s)", mccRegex, mncRegex) + + // The Home Network Identifier consists of a string of + // characters with a variable length representing a domain name + // as specified in Section 2.2 of RFC 7542 + naiTypeRegex = "(?P1-.*)" + + // SUPI type; 0 = IMSI, 1 = NAI (for n3gpp) + supiTypeRegex = fmt.Sprintf("(?P%s|%s)", + imsiTypeRegex, + naiTypeRegex) + + // Routing Indicator, used by the AUSF to find the appropriate UDM when SUCI is encrypted 1-4 digits + routingIndicatorRegex = `(?P\d{1,4})` + // Protection Scheme ID; 0 = NULL Scheme (unencrypted), 1 = Profile A, 2 = Profile B + protectionSchemeRegex = `(?P(?:[0-2]))` + // Public Key ID; 1-255 + publicKeyIDRegex = `(?P(?:\d{1,2}|1\d{2}|2[0-4]\d|25[0-5]))` + // Scheme Output; unbounded hex string (safe from ReDoS due to bounded length of SUCI) + schemeOutputRegex = `(?P[A-Fa-f0-9]+)` + // Subscription Concealed Identifier (SUCI) Encrypted SUPI as sent by the UE to the AMF; 3GPP TS 29.503 - Annex C + suciRegex = regexp.MustCompile(fmt.Sprintf("^suci-%s-%s-%s-%s-%s$", + supiTypeRegex, + routingIndicatorRegex, + protectionSchemeRegex, + publicKeyIDRegex, + schemeOutputRegex, + )) +) + +type Suci struct { + SupiType string // 0 for IMSI, 1 for NAI + Mcc string // 3 digits + Mnc string // 2-3 digits + HomeNetworkId string // variable-length string + RoutingIndicator string // 1-4 digits + ProtectionScheme string // 0-2 + PublicKeyID string // 1-255 + SchemeOutput string // hex string + + Raw string // raw SUCI string +} + +func ParseSuci(input string) *Suci { + matches := suciRegex.FindStringSubmatch(input) + if matches == nil { + return nil + } + + // The indices correspond to the order of the regex groups in the pattern + return &Suci{ + SupiType: matches[1], // First capture group + Mcc: matches[3], // Third capture group + Mnc: matches[4], // Fourth capture group + HomeNetworkId: matches[5], // Fifth capture group + RoutingIndicator: matches[6], // Sixth capture group + ProtectionScheme: matches[7], // Seventh capture group + PublicKeyID: matches[8], // Eighth capture group + SchemeOutput: matches[9], // Ninth capture group + + Raw: input, + } +} + +type HomeNetworkPrivateKey struct { + ProtectionScheme string `yaml:"ProtectionScheme,omitempty"` + PrivateKey *ecdh.PrivateKey `yaml:"PrivateKey,omitempty"` + PublicKey *ecdh.PublicKey `yaml:"PublicKey,omitempty"` +} + +// profile A. +const ( + ProfileAMacKeyLen = 32 // octets + ProfileAEncKeyLen = 16 // octets + ProfileAIcbLen = 16 // octets + ProfileAMacLen = 8 // octets + ProfileAHashLen = 32 // octets +) + +// profile B. +const ( + ProfileBMacKeyLen = 32 // octets + ProfileBEncKeyLen = 16 // octets + ProfileBIcbLen = 16 // octets + ProfileBMacLen = 8 // octets + ProfileBHashLen = 32 // octets +) + +func HmacSha256(input, macKey []byte, macLen int) ([]byte, error) { + h := hmac.New(sha256.New, macKey) + if _, err := h.Write(input); err != nil { + return nil, fmt.Errorf("HMAC SHA256 error: %w", err) + } + macVal := h.Sum(nil) + return macVal[:macLen], nil +} + +func Aes128ctr(input, encKey, icb []byte) ([]byte, error) { + output := make([]byte, len(input)) + block, err := aes.NewCipher(encKey) + if err != nil { + return nil, fmt.Errorf("AES128 CTR error: %w", err) + } + stream := cipher.NewCTR(block, icb) + stream.XORKeyStream(output, input) + return output, nil +} + +func AnsiX963KDF(sharedKey, publicKey []byte, encKeyLen, macKeyLen, hashLen int) []byte { + var counter uint32 = 1 + var kdfKey []byte + kdfRounds := int(math.Ceil(float64(encKeyLen+macKeyLen) / float64(hashLen))) + for i := 0; i < kdfRounds; i++ { + counterBytes := make([]byte, 4) + binary.BigEndian.PutUint32(counterBytes, counter) + tmpK := sha256.Sum256(append(append(sharedKey, counterBytes...), publicKey...)) + kdfKey = append(kdfKey, tmpK[:]...) + counter++ + } + return kdfKey +} + +func decryptWithKdf(sharedKey, kdfPubKey, cipherText, providedMac []byte, + encKeyLen, macKeyLen, hashLen, icbLen, macLen int, +) ([]byte, error) { + kdfKey := AnsiX963KDF(sharedKey, kdfPubKey, encKeyLen, macKeyLen, hashLen) + encKey := kdfKey[:encKeyLen] + icb := kdfKey[encKeyLen : encKeyLen+icbLen] + macKey := kdfKey[len(kdfKey)-macKeyLen:] + + computedMac, err := HmacSha256(cipherText, macKey, macLen) + if err != nil { + return nil, err + } + if !hmac.Equal(computedMac, providedMac) { + return nil, fmt.Errorf("decryption MAC failed") + } + + return Aes128ctr(cipherText, encKey, icb) +} + +func ecdhX25519(privateKey *ecdh.PrivateKey, peerPubKey []byte) ([]byte, error) { + if privateKey == nil { + return nil, errors.New("private key is nil") + } + x25519Curve := ecdh.X25519() + pub, err := x25519Curve.NewPublicKey(peerPubKey) + if err != nil { + return nil, fmt.Errorf("failed to parse X25519 public key: %w", err) + } + return privateKey.ECDH(pub) +} + +var ErrorPublicKeyUnmarshalling = fmt.Errorf("failed to unmarshal uncompressed public key") + +func ecdhP256(privateKey *ecdh.PrivateKey, transmittedPubKey []byte) (sharedKey, kdfPubKey []byte, err error) { + if privateKey == nil { + return nil, nil, errors.New("private key is nil") + } + + p256Curve := ecdh.P256() + + var pubKeyForECDH []byte + switch transmittedPubKey[0] { + case 0x02, 0x03: + // Compressed format + x, y := elliptic.UnmarshalCompressed(elliptic.P256(), transmittedPubKey) + if x == nil || y == nil { + return nil, nil, fmt.Errorf("failed to uncompress public key") + } + pubKeyForECDH = elliptic.Marshal(elliptic.P256(), x, y) + kdfPubKey = transmittedPubKey + + case 0x04: + // Uncompressed format. + pubKeyForECDH = transmittedPubKey + + // For KDF, we need the compressed form. + x, y := elliptic.Unmarshal(elliptic.P256(), transmittedPubKey) + if x == nil || y == nil { + return nil, nil, ErrorPublicKeyUnmarshalling + } + kdfPubKey = elliptic.MarshalCompressed(elliptic.P256(), x, y) + default: + return nil, nil, fmt.Errorf("unknown public key format") + } + + pub, err := p256Curve.NewPublicKey(pubKeyForECDH) + if err != nil { + return nil, nil, fmt.Errorf("failed to create P-256 public key: %w", err) + } + + sharedKey, err = privateKey.ECDH(pub) + if err != nil { + return nil, nil, fmt.Errorf("failed to compute ECDH: %w", err) + } + + return sharedKey, kdfPubKey, nil +} + +func profileADecrypt(input, supiType string, privateKey *ecdh.PrivateKey) (string, error) { + s, err := hex.DecodeString(input) + if err != nil { + return "", err + } + + const ProfileAPubKeyLen = 32 + if len(s) < ProfileAPubKeyLen+ProfileAMacLen { + return "", fmt.Errorf("suci input too short") + } + + peerPubKey := s[:ProfileAPubKeyLen] + cipherText := s[ProfileAPubKeyLen : len(s)-ProfileAMacLen] + providedMac := s[len(s)-ProfileAMacLen:] + + sharedKey, err := ecdhX25519(privateKey, peerPubKey) + if err != nil { + return "", err + } + + plainText, err := decryptWithKdf(sharedKey, peerPubKey, cipherText, providedMac, + ProfileAEncKeyLen, ProfileAMacKeyLen, ProfileAHashLen, ProfileAIcbLen, ProfileAMacLen) + if err != nil { + return "", err + } + + return Tbcd(hex.EncodeToString(plainText)), nil +} + +func profileBDecrypt(input, supiType string, privateKey *ecdh.PrivateKey) (string, error) { + s, err := hex.DecodeString(input) + if err != nil || len(s) < 1 { + return "", fmt.Errorf("hex DecodeString error: %w", err) + } + + var ProfileBPubKeyLen int + switch s[0] { + case 0x02, 0x03: + ProfileBPubKeyLen = 33 + case 0x04: + ProfileBPubKeyLen = 65 + default: + return "", fmt.Errorf("suci input error: unknown public key format") + } + + if len(s) < ProfileBPubKeyLen+ProfileBMacLen { + return "", fmt.Errorf("suci input too short") + } + + transmittedPubKey := s[:ProfileBPubKeyLen] + cipherText := s[ProfileBPubKeyLen : len(s)-ProfileBMacLen] + providedMac := s[len(s)-ProfileBMacLen:] + + sharedKey, kdfPubKey, err := ecdhP256(privateKey, transmittedPubKey) + if err != nil { + return "", err + } + + plainText, err := decryptWithKdf(sharedKey, kdfPubKey, cipherText, providedMac, + ProfileBEncKeyLen, ProfileBMacKeyLen, ProfileBHashLen, ProfileBIcbLen, ProfileBMacLen) + if err != nil { + return "", err + } + return Tbcd(hex.EncodeToString(plainText)), nil +} + +func ToSupi(suci string, suciProfiles []HomeNetworkPrivateKey) (string, error) { + parsedSuci := ParseSuci(suci) + if parsedSuci == nil { + if strings.HasPrefix(suci, "imsi-") || strings.HasPrefix(suci, "nai-") { + return suci, nil + } + return "", fmt.Errorf("unknown suci [%s]", suci) + } + + scheme := parsedSuci.ProtectionScheme + mccMnc := parsedSuci.Mcc + parsedSuci.Mnc + supiPrefix := PrefixIMSI + + if !strings.HasPrefix(parsedSuci.SupiType, SupiTypeIMSI) { + return "", fmt.Errorf("unsupported suciType NAI") + } + + if scheme == NullScheme { + return supiPrefix + mccMnc + parsedSuci.SchemeOutput, nil + } + + keyIndex, err := strconv.Atoi(parsedSuci.PublicKeyID) + if err != nil { + return "", fmt.Errorf("parse HNPublicKeyID error: %w", err) + } + if keyIndex < 1 || keyIndex > len(suciProfiles) { + return "", fmt.Errorf("keyIndex (%d) out of range (%d)", keyIndex, len(suciProfiles)) + } + + profile := suciProfiles[keyIndex-1] + if scheme != profile.ProtectionScheme { + return "", fmt.Errorf("protect Scheme mismatch [%s:%s]", scheme, profile.ProtectionScheme) + } + + switch scheme { + case ProfileAScheme: + result, err := profileADecrypt(parsedSuci.SchemeOutput, SupiTypeIMSI, profile.PrivateKey) + if err != nil { + return "", err + } + return supiPrefix + mccMnc + result, nil + case ProfileBScheme: + result, err := profileBDecrypt(parsedSuci.SchemeOutput, SupiTypeIMSI, profile.PrivateKey) + if err != nil { + return "", err + } + return supiPrefix + mccMnc + result, nil + default: + return "", fmt.Errorf("protect Scheme (%s) is not supported", scheme) + } +} + +func Tbcd(value string) string { + valueBytes := []byte(value) + for (len(valueBytes) % 2) != 0 { + valueBytes = append(valueBytes, 'F') + } + + // Reverse the bytes in group of two + for i := 1; i < len(valueBytes); i += 2 { + valueBytes[i-1], valueBytes[i] = valueBytes[i], valueBytes[i-1] + } + + i := len(valueBytes) - 1 + if valueBytes[i] == 'F' || valueBytes[i] == 'f' { + valueBytes = valueBytes[:i] + } + + return string(valueBytes) +} diff --git a/internal/common/sidf/suci_test.go b/internal/common/sidf/suci_test.go new file mode 100644 index 00000000..13b4f1e4 --- /dev/null +++ b/internal/common/sidf/suci_test.go @@ -0,0 +1,160 @@ +/** + * SPDX-License-Identifier: Apache-2.0 + * © Copyright 2019 The Free5GC Authors + * © Copyright 2025 Free Mobile SAS + */ +package sidf + +import ( + "crypto/ecdh" + "encoding/hex" + "errors" + "fmt" + "math/rand/v2" + "testing" +) + +var testHomeNetworkPrivateKeys = []HomeNetworkPrivateKey{ + { + ProtectionScheme: "1", // Protect Scheme: Profile A + PrivateKey: Must(ecdh.X25519().NewPrivateKey(Must(hex.DecodeString("c53c22208b61860b06c62e5406a7b330c2b577aa5558981510d128247d38bd1d")))), + PublicKey: Must(ecdh.X25519().NewPublicKey(Must(hex.DecodeString("5a8d38864820197c3394b92613b20b91633cbd897119273bf8e4a6f4eec0a650")))), + }, + { + ProtectionScheme: "2", // Protect Scheme: Profile B + PrivateKey: Must(ecdh.P256().NewPrivateKey(Must(hex.DecodeString("F1AB1074477EBCC7F554EA1C5FC368B1616730155E0041AC447D6301975FECDA")))), + PublicKey: Must(ecdh.P256().NewPublicKey(Must(hex.DecodeString("0472DA71976234CE833A6907425867B82E074D44EF907DFB4B3E21C1C2256EBCD15A7DED52FCBB097A4ED250E036C7B9C8C7004C4EEDC4F068CD7BF8D3F900E3B4")))), + }, + { + ProtectionScheme: "2", // Protect Scheme: Profile B + PrivateKey: Must(ecdh.P256().NewPrivateKey(Must(hex.DecodeString("F1AB1074477EBCC7F554EA1C5FC368B1616730155E0041AC447D6301975FECDA")))), + PublicKey: Must(ecdh.P256().NewPublicKey(Must(hex.DecodeString("0472DA71976234CE833A6907425867B82E074D44EF907DFB4B3E21C1C2256EBCD15A7DED52FCBB097A4ED250E036C7B9C8C7004C4EEDC4F068CD7BF8D3F900E3B4")))), + }, +} + +var testHomeNetworkPublicKeys = []HomeNetworkPublicKey{ + { + ProtectionScheme: "1", + PublicKeyID: "1", + PublicKey: Must(ecdh.X25519().NewPublicKey(Must(hex.DecodeString("5a8d38864820197c3394b92613b20b91633cbd897119273bf8e4a6f4eec0a650")))), + }, + { + ProtectionScheme: "2", + PublicKeyID: "2", + PublicKey: Must(ecdh.P256().NewPublicKey(Must(hex.DecodeString("0472DA71976234CE833A6907425867B82E074D44EF907DFB4B3E21C1C2256EBCD15A7DED52FCBB097A4ED250E036C7B9C8C7004C4EEDC4F068CD7BF8D3F900E3B4")))), + }, + { + ProtectionScheme: "2", + PublicKeyID: "3", + PublicKey: Must(ecdh.P256().NewPublicKey(Must(hex.DecodeString("0472DA71976234CE833A6907425867B82E074D44EF907DFB4B3E21C1C2256EBCD15A7DED52FCBB097A4ED250E036C7B9C8C7004C4EEDC4F068CD7BF8D3F900E3B4")))), + }, +} + +func TestToSupi(t *testing.T) { + testCases := []struct { + suci string + expectedSupi string + expectedErr error + }{ + { + suci: "suci-0-208-93-0-0-0-00007487", + expectedSupi: "imsi-2089300007487", + expectedErr: nil, + }, + { + suci: "suci-0-208-93-0-1-1-b2e92f836055a255837debf850b528997ce0201cb82a" + + "dfe4be1f587d07d8457dcb02352410cddd9e730ef3fa87", + expectedSupi: "imsi-20893001002086", + expectedErr: nil, + }, + { + suci: "suci-0-208-93-0-2-2-039aab8376597021e855679a9778ea0b67396e68c66d" + + "f32c0f41e9acca2da9b9d146a33fc2716ac7dae96aa30a4d", + expectedSupi: "imsi-20893001002086", + expectedErr: nil, + }, + { + suci: "suci-0-208-93-0-2-2-0434a66778799d52fedd9326db4b690d092e05c9ba0ace5b413da" + + "fc0a40aa28ee00a79f790fa4da6a2ece892423adb130dc1b30e270b7d0088bdd716b93894891d5221a74c810d6b9350cc067c76", + expectedSupi: "", + expectedErr: ErrorPublicKeyUnmarshalling, + }, + { + suci: "suci-0-001-01-0-2-2-03a7b1db2a9db9d44112b59d03d8243dc6089fd91d2ecb" + + "78f5d16298634682e94373888b22bdc9293d1681922e17", + expectedSupi: "imsi-001010123456789", + expectedErr: nil, + }, + { + // Uncompressed Ephemeral Public Key + Compressed Home Public Key + suci: "suci-0-001-01-0-2-2-049AAB8376597021E855679A9778EA0B67396E68C66DF32C0F41E9ACCA2DA9B9D1D1F44EA1C" + + "87AA7478B954537BDE79951E748A43294A4F4CF86EAFF1789C9C81F46A33FC2716AC7DAE96AA30A4D", + expectedSupi: "imsi-00101001002086", + expectedErr: nil, + }, + { + suci: "suci-0-208-93-0-2-3-039aab8376597021e855679a9778ea0b67396e68c66d" + + "f32c0f41e9acca2da9b9d146a33fc2716ac7dae96aa30a4d", + expectedSupi: "imsi-20893001002086", + expectedErr: nil, + }, + { + suci: "suci-0-208-93-0-2-3-0434a66778799d52fedd9326db4b690d092e05c9ba0ace5b413da" + + "fc0a40aa28ee00a79f790fa4da6a2ece892423adb130dc1b30e270b7d0088bdd716b93894891d5221a74c810d6b9350cc067c76", + expectedSupi: "", + expectedErr: ErrorPublicKeyUnmarshalling, + }, + { + suci: "suci-0-001-01-0-2-3-03a7b1db2a9db9d44112b59d03d8243dc6089fd91d2ecb" + + "78f5d16298634682e94373888b22bdc9293d1681922e17", + expectedSupi: "imsi-001010123456789", + expectedErr: nil, + }, + { + // Uncompressed Ephemeral Public Key + Uncompressed Home Public Key + suci: "suci-0-001-01-0-2-3-049AAB8376597021E855679A9778EA0B67396E68C66DF32C0F41E9ACCA2DA9B9D1D1F44EA1C" + + "87AA7478B954537BDE79951E748A43294A4F4CF86EAFF1789C9C81F46A33FC2716AC7DAE96AA30A4D", + expectedSupi: "imsi-00101001002086", + expectedErr: nil, + }, + } + for i, tc := range testCases { + supi, err := ToSupi(tc.suci, testHomeNetworkPrivateKeys) + if err != nil { + if !errors.Is(err, tc.expectedErr) { + t.Errorf("TC%d fail: err[%v], expected[%v]\n", i, err, tc.expectedErr) + } + } else if supi != tc.expectedSupi { + t.Errorf("TC%d fail: supi[%s], expected[%s]\n", i, supi, tc.expectedSupi) + } + } +} + +func TestSupiToSuciToSupi(t *testing.T) { + testCases := []string{"0010020862"} + + for _ = range 100 { + testCases = append(testCases, fmt.Sprintf("%010d", rand.Int64N(10000000000))) + } + + for i, tc := range testCases { + suci, err := CipherSuci(tc, "208", "15", "0", testHomeNetworkPublicKeys[2]) + if err != nil { + t.Errorf("TC%d fail: %v", i, tc) + } + decipheredSupi, err := ToSupi(suci.Raw, testHomeNetworkPrivateKeys) + if err != nil { + t.Errorf("TC%d fail: unable to deciphber ciphered suci: %v", i, err) + } + if decipheredSupi != "imsi-20815"+tc { + t.Errorf("TC%d fail: decipheredSupi[%s] != supi[20815%s]", i, decipheredSupi, tc) + } + } +} + +func Must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} diff --git a/internal/control_test_engine/ue/context/context.go b/internal/control_test_engine/ue/context/context.go index 19702c28..57315bf6 100644 --- a/internal/control_test_engine/ue/context/context.go +++ b/internal/control_test_engine/ue/context/context.go @@ -1,6 +1,7 @@ /** * SPDX-License-Identifier: Apache-2.0 * © Copyright 2023 Hewlett Packard Enterprise Development LP + * © Copyright 2025 Valentin D'Emmanuele */ package context @@ -16,10 +17,10 @@ import ( "net" "reflect" "regexp" + "strconv" "sync" "time" - "github.com/free5gc/nas/nasMessage" "github.com/free5gc/nas/nasType" "github.com/free5gc/nas/security" @@ -27,6 +28,7 @@ import ( "github.com/free5gc/util/ueauth" "my5G-RANTester/internal/common/auth" + "my5G-RANTester/internal/common/sidf" "github.com/free5gc/openapi/models" log "github.com/sirupsen/logrus" @@ -109,13 +111,14 @@ type SECURITY struct { Kamf []uint8 AuthenticationSubs models.AuthenticationSubscription Suci nasType.MobileIdentity5GS + suciPublicKey sidf.HomeNetworkPublicKey RoutingIndicator string Guti *nasType.GUTI5G } func (ue *UEContext) NewRanUeContext(msin string, ueSecurityCapability *nasType.UESecurityCapability, - k, opc, op, amf, sqn, mcc, mnc, routingIndicator, dnn string, + k, opc, op, amf, sqn, mcc, mnc string, homeNetworkPublicKey sidf.HomeNetworkPublicKey, routingIndicator, dnn string, sst int32, sd string, tunnelMode config.TunnelMode, scenarioChan chan scenario.ScenarioMessage, gnbInboundChannel chan context.UEMessage, id int) { @@ -145,6 +148,7 @@ func (ue *UEContext) NewRanUeContext(msin string, // added routing indidcator ue.UeSecurity.RoutingIndicator = routingIndicator + ue.UeSecurity.suciPublicKey = homeNetworkPublicKey // added supi ue.UeSecurity.Supi = fmt.Sprintf("imsi-%s%s%s", mcc, mnc, msin) @@ -478,31 +482,38 @@ func (ue *UEContext) GetRoutingIndicatorInOctets() []byte { } func (ue *UEContext) EncodeSuci() nasType.MobileIdentity5GS { - msin := ue.GetMsin() - suci := nasType.MobileIdentity5GS{ - Buffer: []uint8{nasMessage.SupiFormatImsi<<4 | - nasMessage.MobileIdentity5GSTypeSuci, 0x0, 0x0, 0x0, 0xf0, 0xff, 0x00, 0x00}, - } + protScheme, _ := strconv.ParseUint(ue.UeSecurity.suciPublicKey.ProtectionScheme, 10, 8) + buf6 := byte(protScheme) - //mcc & mnc - mccmnc := ue.GetMccAndMncInOctets() - copy(suci.Buffer[1:], mccmnc) + hnPubKeyId, _ := strconv.ParseUint(ue.UeSecurity.suciPublicKey.PublicKeyID, 10, 8) + buf7 := byte(hnPubKeyId) - routingIndicator := ue.GetRoutingIndicatorInOctets() - suci.Buffer[4] = routingIndicator[0] - suci.Buffer[5] = routingIndicator[1] + var schemeOutput []byte - for i := 0; i < len(msin); i += 2 { - suci.Buffer = append(suci.Buffer, 0x0) - j := len(suci.Buffer) - 1 - if i+1 == len(msin) { - suci.Buffer[j] = 0xf<<4 | hexCharToByte(msin[i]) - } else { - suci.Buffer[j] = hexCharToByte(msin[i+1])<<4 | hexCharToByte(msin[i]) + if protScheme == 0 { + schemeOutput, _ = hex.DecodeString(sidf.Tbcd(ue.UeSecurity.Msin)) + } else { + suci, err := sidf.CipherSuci(ue.UeSecurity.Msin, ue.UeSecurity.mcc, ue.UeSecurity.mnc, ue.UeSecurity.RoutingIndicator, ue.UeSecurity.suciPublicKey) + if err != nil { + log.Fatalf("Unable to cipher SUCI: %v", err) } + schemeOutput, _ = hex.DecodeString(suci.SchemeOutput) + } + + buffer := make([]byte, 8+len(schemeOutput)) + + buffer[0] = 1 + copy(buffer[1:], ue.GetMccAndMncInOctets()) + copy(buffer[4:], ue.GetRoutingIndicatorInOctets()) + buffer[6] = buf6 + buffer[7] = buf7 + copy(buffer[8:], schemeOutput) + + suci := nasType.MobileIdentity5GS{ + Buffer: buffer, + Len: uint16(len(buffer)), } - suci.Len = uint16(len(suci.Buffer)) return suci } diff --git a/internal/control_test_engine/ue/ue.go b/internal/control_test_engine/ue/ue.go index 482b825a..476e797a 100644 --- a/internal/control_test_engine/ue/ue.go +++ b/internal/control_test_engine/ue/ue.go @@ -38,6 +38,7 @@ func NewUE(conf config.Config, id int, ueMgrChannel chan procedures.UeTesterMess conf.Ue.Sqn, conf.Ue.Hplmn.Mcc, conf.Ue.Hplmn.Mnc, + conf.GetHomeNetworkPublicKey(), conf.Ue.RoutingIndicator, conf.Ue.Dnn, int32(conf.Ue.Snssai.Sst),