mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
333 lines
8.9 KiB
Go
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, `"`, `\"`)
|
|
}
|