Files

333 lines
8.9 KiB
Go

package ims
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"vocat/internal/vowifi"
)
type digestChallenge struct {
Realm string
Nonce string
Opaque string
Algorithm string
QOP string
Stale bool
Proxy bool
}
type digestCredentials struct {
Username string
AKAResponse []byte
AUTS string
URI string
Method string
CNonce string
NC uint32
}
func parseDigestChallenge(value string, proxy bool) (digestChallenge, error) {
scheme, parameters, found := strings.Cut(strings.TrimSpace(value), " ")
if !found || !strings.EqualFold(scheme, "Digest") {
return digestChallenge{}, errors.New("ims: unsupported SIP authentication scheme")
}
directives, err := parseAuthDirectives(parameters)
if err != nil {
return digestChallenge{}, err
}
challenge := digestChallenge{
Realm: directives["realm"],
Nonce: directives["nonce"],
Opaque: directives["opaque"],
Algorithm: directives["algorithm"],
Proxy: proxy,
Stale: strings.EqualFold(directives["stale"], "true"),
}
if challenge.Realm == "" || challenge.Nonce == "" {
return digestChallenge{}, errors.New("ims: incomplete SIP digest challenge")
}
if challenge.Algorithm == "" {
// RFC 3310 inherits the HTTP Digest default: an omitted algorithm is
// plain MD5, not AKA. This provider has no subscriber password and
// must not misinterpret an ordinary nonce as RAND || AUTN.
return digestChallenge{}, errors.New("ims: digest challenge omitted the AKA algorithm")
}
if !strings.EqualFold(challenge.Algorithm, "AKAv1-MD5") {
return digestChallenge{}, fmt.Errorf("ims: unsupported digest algorithm %q", challenge.Algorithm)
}
if qop := directives["qop"]; qop != "" {
for _, candidate := range strings.Split(qop, ",") {
if strings.EqualFold(strings.TrimSpace(candidate), "auth") {
challenge.QOP = "auth"
break
}
}
if challenge.QOP == "" {
return digestChallenge{}, errors.New("ims: digest challenge does not offer qop=auth")
}
}
return challenge, nil
}
func parseAuthDirectives(value string) (map[string]string, error) {
directives := make(map[string]string)
for index := 0; index < len(value); {
for index < len(value) && (value[index] == ' ' || value[index] == '\t' || value[index] == ',') {
index++
}
if index == len(value) {
break
}
keyStart := index
for index < len(value) && value[index] != '=' && value[index] != ',' {
index++
}
if index == len(value) || value[index] != '=' {
return nil, errors.New("ims: malformed digest directive")
}
key := strings.ToLower(strings.TrimSpace(value[keyStart:index]))
index++
for index < len(value) && (value[index] == ' ' || value[index] == '\t') {
index++
}
var directiveValue strings.Builder
if index < len(value) && value[index] == '"' {
index++
closed := false
for index < len(value) {
switch value[index] {
case '\\':
index++
if index == len(value) {
return nil, errors.New("ims: malformed quoted digest directive")
}
directiveValue.WriteByte(value[index])
index++
case '"':
index++
closed = true
default:
directiveValue.WriteByte(value[index])
index++
}
if closed {
break
}
}
if !closed {
return nil, errors.New("ims: unterminated quoted digest directive")
}
} else {
start := index
for index < len(value) && value[index] != ',' {
index++
}
directiveValue.WriteString(strings.TrimSpace(value[start:index]))
}
if key == "" {
return nil, errors.New("ims: empty digest directive name")
}
directives[key] = directiveValue.String()
for index < len(value) && value[index] != ',' {
if value[index] != ' ' && value[index] != '\t' {
return nil, errors.New("ims: malformed digest directive separator")
}
index++
}
}
return directives, nil
}
type akaMaterial struct {
response []byte
auts []byte
ck []byte
ik []byte
}
func clearAKAMaterial(material *akaMaterial) {
if material == nil {
return
}
zeroBytes(material.response)
zeroBytes(material.auts)
zeroBytes(material.ck)
zeroBytes(material.ik)
*material = akaMaterial{}
}
func authenticateAKA(
ctx context.Context,
provider vowifi.AKAProvider,
identity vowifi.SIMIdentity,
challenge digestChallenge,
preference string,
) (akaMaterial, error) {
nonce, err := decodeAKANonce(challenge.Nonce)
if err != nil {
return akaMaterial{}, err
}
// 3GPP HTTP Digest AKA encodes RAND || AUTN as the first 32 nonce octets.
// Following server data remains in the digest nonce and never enters USIM.
var akaChallenge vowifi.AKAChallenge
copy(akaChallenge.RAND[:], nonce[:16])
copy(akaChallenge.AUTN[:], nonce[16:32])
var result vowifi.AKAResult
if preferred, ok := provider.(vowifi.PreferredAKAProvider); ok && strings.TrimSpace(preference) != "" {
result, err = preferred.AuthenticateWithPreference(ctx, identity, akaChallenge, preference)
} else {
result, err = provider.Authenticate(ctx, identity, akaChallenge)
}
if err != nil {
application := "USIM"
if strings.EqualFold(strings.TrimSpace(preference), "isim_strict") {
application = "ISIM"
}
return akaMaterial{}, fmt.Errorf("ims: %s AKA authentication failed: %w", application, err)
}
if result.SynchronizationFailure || len(result.AUTS) > 0 {
if !result.SynchronizationFailure || len(result.AUTS) != 14 {
return akaMaterial{}, errors.New("ims: USIM returned malformed AKA synchronization evidence")
}
return akaMaterial{auts: append([]byte(nil), result.AUTS...)}, nil
}
res, err := extractRES(result)
if err != nil {
return akaMaterial{}, err
}
return akaMaterial{
response: res,
ck: append([]byte(nil), result.CK...),
ik: append([]byte(nil), result.IK...),
}, nil
}
func decodeAKANonce(value string) ([]byte, error) {
var decoded []byte
var err error
for _, encoding := range []*base64.Encoding{
base64.StdEncoding,
base64.RawStdEncoding,
base64.URLEncoding,
base64.RawURLEncoding,
} {
decoded, err = encoding.DecodeString(strings.TrimSpace(value))
if err == nil {
break
}
}
if err != nil || len(decoded) < 32 {
return nil, errors.New("ims: invalid AKA nonce")
}
return decoded, nil
}
func extractRES(result vowifi.AKAResult) ([]byte, error) {
if len(result.RES) == 0 {
return nil, errors.New("ims: USIM returned an empty AKA result")
}
if len(result.RES) < 4 || len(result.RES) > 16 {
return nil, errors.New("ims: USIM returned an invalid RES length")
}
return append([]byte(nil), result.RES...), nil
}
func newDigestCredentials(
username string,
akaResponse []byte,
uri string,
method string,
nc uint32,
) (digestCredentials, error) {
cnonceBytes := make([]byte, 16)
if _, err := rand.Read(cnonceBytes); err != nil {
return digestCredentials{}, fmt.Errorf("ims: create digest cnonce: %w", err)
}
return digestCredentials{
Username: username,
AKAResponse: akaResponse,
URI: uri,
Method: method,
CNonce: hex.EncodeToString(cnonceBytes),
NC: nc,
}, nil
}
func buildDigestAuthorization(challenge digestChallenge, credentials digestCredentials) string {
nc := fmt.Sprintf("%08x", credentials.NC)
response := digestResponse(
credentials.Username,
challenge.Realm,
credentials.AKAResponse,
credentials.Method,
credentials.URI,
challenge.Nonce,
nc,
credentials.CNonce,
challenge.QOP,
)
parts := []string{
`username="` + quoteDigest(credentials.Username) + `"`,
`realm="` + quoteDigest(challenge.Realm) + `"`,
`nonce="` + quoteDigest(challenge.Nonce) + `"`,
`uri="` + quoteDigest(credentials.URI) + `"`,
`response="` + response + `"`,
"algorithm=AKAv1-MD5",
}
if challenge.Opaque != "" {
parts = append(parts, `opaque="`+quoteDigest(challenge.Opaque)+`"`)
}
if challenge.QOP != "" {
parts = append(parts,
"qop="+challenge.QOP,
"nc="+nc,
`cnonce="`+quoteDigest(credentials.CNonce)+`"`,
)
}
if credentials.AUTS != "" {
parts = append(parts, `auts="`+quoteDigest(credentials.AUTS)+`"`)
}
return "Digest " + strings.Join(parts, ", ")
}
func digestResponse(
username string,
realm string,
akaResponse []byte,
method string,
uri string,
nonce string,
nc string,
cnonce string,
qop string,
) string {
ha1Hash := md5.New()
_, _ = ha1Hash.Write([]byte(username + ":" + realm + ":"))
// AKAv1-MD5 is mandated by the IMS server challenge (3GPP TS 33.203).
// akaResponse is the short-lived USIM RES value, not a stored password.
_, _ = ha1Hash.Write(akaResponse)
ha1 := hex.EncodeToString(ha1Hash.Sum(nil))
ha2 := md5Hex(method + ":" + uri)
if qop == "" {
return md5Hex(ha1 + ":" + nonce + ":" + ha2)
}
return md5Hex(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2)
}
func md5Hex(value string) string {
sum := md5.Sum([]byte(value))
return hex.EncodeToString(sum[:])
}
func quoteDigest(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
return strings.ReplaceAll(value, `"`, `\"`)
}