| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- 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
- Password []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 {
- password []byte
- auts []byte
- ck []byte
- ik []byte
- }
- func clearAKAMaterial(material *akaMaterial) {
- if material == nil {
- return
- }
- zeroBytes(material.password)
- zeroBytes(material.auts)
- zeroBytes(material.ck)
- zeroBytes(material.ik)
- *material = akaMaterial{}
- }
- func authenticateAKA(
- ctx context.Context,
- provider vowifi.AKAProvider,
- identity vowifi.SIMIdentity,
- challenge digestChallenge,
- ) (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])
- result, err := provider.Authenticate(ctx, identity, akaChallenge)
- if err != nil {
- return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", 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{
- password: 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,
- password []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,
- Password: password,
- 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.Password,
- 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,
- password []byte,
- method string,
- uri string,
- nonce string,
- nc string,
- cnonce string,
- qop string,
- ) string {
- ha1Hash := md5.New()
- _, _ = ha1Hash.Write([]byte(username + ":" + realm + ":"))
- _, _ = ha1Hash.Write(password)
- 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, `"`, `\"`)
- }
|