From 7486b7a3638c150997c0e0a63f655f165f80127a Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 9 Apr 2024 10:53:38 +0200 Subject: [PATCH] Add support for SASL2 and BIND2 (#187) * Add basic support for SASL2 (XEP-0388) and Bind2 (XEP-0386). --- xmpp.go | 256 ++++++++++++++++++++++++++++++-------- xmpp_information_query.go | 2 +- 2 files changed, 205 insertions(+), 53 deletions(-) diff --git a/xmpp.go b/xmpp.go index ac84e11..c326130 100644 --- a/xmpp.go +++ b/xmpp.go @@ -49,7 +49,9 @@ const ( nsStream = "http://etherx.jabber.org/streams" nsTLS = "urn:ietf:params:xml:ns:xmpp-tls" nsSASL = "urn:ietf:params:xml:ns:xmpp-sasl" + nsSASL2 = "urn:xmpp:sasl:2" nsBind = "urn:ietf:params:xml:ns:xmpp-bind" + nsBind2 = "urn:xmpp:bind:0" nsSASLCB = "urn:xmpp:sasl-cb:0" nsClient = "jabber:client" nsSession = "urn:ietf:params:xml:ns:xmpp-session" @@ -237,6 +239,19 @@ type Options struct { // XEP-0474: SASL SCRAM Downgrade Protection SSDP bool + + // XEP-0388: XEP-0388: Extensible SASL Profile + // Value for software + UserAgentSW string + + // XEP-0388: XEP-0388: Extensible SASL Profile + // Value for device + UserAgentDev string + + // XEP-0388: XEP-0388: Extensible SASL Profile + // Unique stable identifier for the client installation + // MUST be a valid UUIDv4 + UserAgentID string } // NewClient establishes a new Client connection based on a set of Options. @@ -416,13 +431,25 @@ func (c *Client) init(o *Options) error { return err } var mechanism, channelBinding, clientFirstMessage, clientFinalMessageBare, authMessage string + var bind2Data, resource, userAgentSW, userAgentDev, userAgentID string var serverSignature, keyingMaterial []byte - var scramPlus, ok, tlsConnOK, tls13, serverEndPoint bool - var cbsSlice []string + var scramPlus, ok, tlsConnOK, tls13, serverEndPoint, sasl2, bind2 bool + var cbsSlice, mechSlice []string var tlsConn *tls.Conn + // Use SASL2 if available + if f.Authentication.Mechanism != nil && c.IsEncrypted() { + sasl2 = true + mechSlice = f.Authentication.Mechanism + // Detect whether bind2 is available + if f.Authentication.Inline.Bind.Xmlns != "" { + bind2 = true + } + } else { + mechSlice = f.Mechanisms.Mechanism + } if o.User == "" && o.Password == "" { foundAnonymous := false - for _, m := range f.Mechanisms.Mechanism { + for _, m := range mechSlice { if m == "ANONYMOUS" { fmt.Fprintf(c.stanzaWriter, "\n", nsSASL) foundAnonymous = true @@ -446,26 +473,26 @@ func (c *Client) init(o *Options) error { } mechanism = "" if o.Mechanism != "" { - if slices.Contains(f.Mechanisms.Mechanism, o.Mechanism) { + if slices.Contains(mechSlice, o.Mechanism) { mechanism = o.Mechanism } } else { switch { - case slices.Contains(f.Mechanisms.Mechanism, "SCRAM-SHA-512-PLUS") && tlsConnOK: + case slices.Contains(mechSlice, "SCRAM-SHA-512-PLUS") && tlsConnOK: mechanism = "SCRAM-SHA-512-PLUS" - case slices.Contains(f.Mechanisms.Mechanism, "SCRAM-SHA-256-PLUS") && tlsConnOK: + case slices.Contains(mechSlice, "SCRAM-SHA-256-PLUS") && tlsConnOK: mechanism = "SCRAM-SHA-256-PLUS" - case slices.Contains(f.Mechanisms.Mechanism, "SCRAM-SHA-1-PLUS") && tlsConnOK: + case slices.Contains(mechSlice, "SCRAM-SHA-1-PLUS") && tlsConnOK: mechanism = "SCRAM-SHA-1-PLUS" - case slices.Contains(f.Mechanisms.Mechanism, "SCRAM-SHA-512"): + case slices.Contains(mechSlice, "SCRAM-SHA-512"): mechanism = "SCRAM-SHA-512" - case slices.Contains(f.Mechanisms.Mechanism, "SCRAM-SHA-256"): + case slices.Contains(mechSlice, "SCRAM-SHA-256"): mechanism = "SCRAM-SHA-256" - case slices.Contains(f.Mechanisms.Mechanism, "SCRAM-SHA-1"): + case slices.Contains(mechSlice, "SCRAM-SHA-1"): mechanism = "SCRAM-SHA-1" - case slices.Contains(f.Mechanisms.Mechanism, "X-OAUTH2"): + case slices.Contains(mechSlice, "X-OAUTH2"): mechanism = "X-OAUTH2" - case slices.Contains(f.Mechanisms.Mechanism, "PLAIN") && tlsConnOK: + case slices.Contains(mechSlice, "PLAIN") && tlsConnOK: mechanism = "PLAIN" } } @@ -556,14 +583,48 @@ func (c *Client) init(o *Options) error { } else { clientFirstMessage = "n,,n=" + user + ",r=" + clientNonce } - fmt.Fprintf(c.stanzaWriter, "%s\n", - nsSASL, mechanism, base64.StdEncoding.EncodeToString([]byte(clientFirstMessage))) + if sasl2 { + if bind2 { + if o.UserAgentSW != "" { + resource = o.UserAgentSW + } else { + resource = "go-xmpp" + } + bind2Data = fmt.Sprintf("%s", + nsBind2, resource) + } + if o.UserAgentSW != "" { + userAgentSW = fmt.Sprintf("%s", o.UserAgentSW) + } else { + userAgentSW = "go-xmpp" + } + if o.UserAgentDev != "" { + userAgentDev = fmt.Sprintf("%s", o.UserAgentDev) + } + if o.UserAgentID != "" { + userAgentID = fmt.Sprintf(" id='%s'", o.UserAgentID) + } + fmt.Fprintf(c.stanzaWriter, + "%s%s%s%s\n", + nsSASL2, mechanism, base64.StdEncoding.EncodeToString([]byte(clientFirstMessage)), userAgentID, userAgentSW, userAgentDev, bind2Data) + } else { + fmt.Fprintf(c.stanzaWriter, "%s\n", + nsSASL, mechanism, base64.StdEncoding.EncodeToString([]byte(clientFirstMessage))) + } var sfm string _, val, err := c.next() if err != nil { return err } switch v := val.(type) { + case *sasl2Failure: + errorMessage := v.Text + if errorMessage == "" { + // v.Any is type of sub-element in failure, + // which gives a description of what failed if there was no text element + errorMessage = v.Any.Local + } + return errors.New("auth failure: " + errorMessage) case *saslFailure: errorMessage := v.Text if errorMessage == "" { @@ -572,6 +633,8 @@ func (c *Client) init(o *Options) error { errorMessage = v.Any.Local } return errors.New("auth failure: " + errorMessage) + case *sasl2Challenge: + sfm = v.Text case *saslChallenge: sfm = v.Text } @@ -702,23 +765,37 @@ func (c *Client) init(o *Options) error { } clientFinalMessage := base64.StdEncoding.EncodeToString([]byte(clientFinalMessageBare + ",p=" + base64.StdEncoding.EncodeToString(clientProof))) - fmt.Fprintf(c.stanzaWriter, "%s\n", nsSASL, - clientFinalMessage) + if sasl2 { + fmt.Fprintf(c.stanzaWriter, "%s\n", nsSASL2, + clientFinalMessage) + } else { + fmt.Fprintf(c.stanzaWriter, "%s\n", nsSASL, + clientFinalMessage) + } } if mechanism == "X-OAUTH2" && o.OAuthToken != "" && o.OAuthScope != "" { // Oauth authentication: send base64-encoded \x00 user \x00 token. raw := "\x00" + user + "\x00" + o.OAuthToken enc := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) base64.StdEncoding.Encode(enc, []byte(raw)) - fmt.Fprintf(c.stanzaWriter, "%s\n", nsSASL, o.OAuthXmlNs, enc) + if sasl2 { + fmt.Fprintf(c.stanzaWriter, "%s\n", nsSASL2, o.OAuthXmlNs, enc) + } else { + fmt.Fprintf(c.stanzaWriter, "%s\n", nsSASL, o.OAuthXmlNs, enc) + } } if mechanism == "PLAIN" { // Plain authentication: send base64-encoded \x00 user \x00 password. raw := "\x00" + user + "\x00" + o.Password enc := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) base64.StdEncoding.Encode(enc, []byte(raw)) - fmt.Fprintf(c.conn, "%s\n", nsSASL, enc) + if sasl2 { + fmt.Fprintf(c.conn, "%s\n", nsSASL2, enc) + } else { + fmt.Fprintf(c.conn, "%s\n", nsSASL, enc) + } } } if mechanism == "" { @@ -730,6 +807,28 @@ func (c *Client) init(o *Options) error { return err } switch v := val.(type) { + case *sasl2Success: + if strings.HasPrefix(mechanism, "SCRAM-SHA") { + successMsg, err := base64.StdEncoding.DecodeString(v.AdditionalData) + if err != nil { + return err + } + if !strings.HasPrefix(string(successMsg), "v=") { + return errors.New("server sent unexpected content in SCRAM success message") + } + serverSignatureReply := strings.SplitN(string(successMsg), "v=", 2)[1] + serverSignatureRemote, err := base64.StdEncoding.DecodeString(serverSignatureReply) + if err != nil { + return err + } + if string(serverSignature) != string(serverSignatureRemote) { + return errors.New("SCRAM: server signature mismatch") + } + c.Mechanism = mechanism + } + if bind2 { + c.jid = v.AuthorizationIdentifier + } case *saslSuccess: if strings.HasPrefix(mechanism, "SCRAM-SHA") { successMsg, err := base64.StdEncoding.DecodeString(v.Text) @@ -749,6 +848,14 @@ func (c *Client) init(o *Options) error { } c.Mechanism = mechanism } + case *sasl2Failure: + errorMessage := v.Text + if errorMessage == "" { + // v.Any is type of sub-element in failure, + // which gives a description of what failed if there was no text element + errorMessage = v.Any.Local + } + return errors.New("auth failure: " + errorMessage) case *saslFailure: errorMessage := v.Text if errorMessage == "" { @@ -761,10 +868,12 @@ func (c *Client) init(o *Options) error { return errors.New("expected or , got <" + name.Local + "> in " + name.Space) } - // Now that we're authenticated, we're supposed to start the stream over again. - // Declare intent to be a jabber client. - if f, err = c.startStream(o, domain); err != nil { - return err + if !sasl2 { + // Now that we're authenticated, we're supposed to start the stream over again. + // Declare intent to be a jabber client. + if f, err = c.startStream(o, domain); err != nil { + return err + } } // Make the max. stanza size limit available. if f.Limits.MaxBytes != "" { @@ -781,39 +890,41 @@ func (c *Client) init(o *Options) error { } } - // Generate a unique cookie - cookie := getCookie() + if !bind2 { + // Generate a unique cookie + cookie := getCookie() - // Send IQ message asking to bind to the local user name. - if o.Resource == "" { - fmt.Fprintf(c.stanzaWriter, "\n", cookie, nsBind) - } else { - fmt.Fprintf(c.stanzaWriter, "%s\n", cookie, nsBind, o.Resource) - } - _, val, err = c.next() - if err != nil { - return err - } - switch v := val.(type) { - case *streamError: - errorMessage := v.Text.Text - if errorMessage == "" { - // v.Any is type of sub-element in failure, - // which gives a description of what failed if there was no text element - errorMessage = v.Any.Space - } - return errors.New("stream error: " + errorMessage) - case *clientIQ: - if v.Bind.XMLName.Space == nsBind { - c.jid = v.Bind.Jid // our local id - c.domain = domain + // Send IQ message asking to bind to the local user name. + if o.Resource == "" { + fmt.Fprintf(c.stanzaWriter, "\n", cookie, nsBind) } else { - return errors.New("bind: unexpected reply to xmpp-bind IQ") + fmt.Fprintf(c.stanzaWriter, "%s\n", cookie, nsBind, o.Resource) + } + _, val, err = c.next() + if err != nil { + return err + } + switch v := val.(type) { + case *streamError: + errorMessage := v.Text.Text + if errorMessage == "" { + // v.Any is type of sub-element in failure, + // which gives a description of what failed if there was no text element + errorMessage = v.Any.Space + } + return errors.New("stream error: " + errorMessage) + case *clientIQ: + if v.Bind.XMLName.Space == nsBind { + c.jid = v.Bind.Jid // our local id + c.domain = domain + } else { + return errors.New("bind: unexpected reply to xmpp-bind IQ") + } } } if o.Session { // if server support session, open it - cookie = getCookie() // generate new id value for session + cookie := getCookie() // generate new id value for session fmt.Fprintf(c.stanzaWriter, "\n", xmlEscape(domain), cookie, nsSession) } @@ -880,9 +991,9 @@ func (c *Client) startStream(o *Options, domain string) (*streamFeatures, error) } _, err := fmt.Fprintf(c.stanzaWriter, ""+ - "\n", - xmlEscape(domain), nsClient, nsStream) + xmlEscape(o.User), xmlEscape(domain), nsClient, nsStream) if err != nil { return nil, err } @@ -1338,6 +1449,7 @@ func (c *Client) Roster() error { // RFC 3920 C.1 Streams name space type streamFeatures struct { XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"` + Authentication sasl2Authentication StartTLS *tlsStartTLS Mechanisms saslMechanisms ChannelBindings saslChannelBindings @@ -1370,6 +1482,18 @@ type tlsFailure struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls failure"` } +type sasl2Authentication struct { + XMLName xml.Name `xml:"urn:xmpp:sasl:2 authentication"` + Mechanism []string `xml:"mechanism"` + Inline struct { + Text string `xml:",chardata"` + Bind struct { + Text string `xml:",chardata"` + Xmlns string `xml:"xmlns,attr"` + } `xml:"bind"` + } `xml:"inline"` +} + // RFC 3920 C.4 SASL name space type saslMechanisms struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl mechanisms"` @@ -1390,17 +1514,39 @@ type saslAbort struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl abort"` } +type sasl2Success struct { + XMLName xml.Name `xml:"urn:xmpp:sasl:2 success"` + Text string `xml:",chardata"` + AdditionalData string `xml:"additional-data"` + AuthorizationIdentifier string `xml:"authorization-identifier"` + Bound struct { + Text string `xml:",chardata"` + Xmlns string `xml:"xmlns,attr"` + } `xml:"bound"` +} + type saslSuccess struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl success"` Text string `xml:",chardata"` } +type sasl2Failure struct { + XMLName xml.Name `xml:"urn:xmpp:sasl:2 failure"` + Any xml.Name `xml:",any"` + Text string `xml:"text"` +} + type saslFailure struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl failure"` Any xml.Name `xml:",any"` Text string `xml:"text"` } +type sasl2Challenge struct { + XMLName xml.Name `xml:"urn:xmpp:sasl:2 challenge"` + Text string `xml:",chardata"` +} + type saslChallenge struct { XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl challenge"` Text string `xml:",chardata"` @@ -1607,14 +1753,20 @@ func (c *Client) next() (xml.Name, interface{}, error) { nv = &tlsFailure{} case nsSASL + " mechanisms": nv = &saslMechanisms{} + case nsSASL2 + " challenge": + nv = &sasl2Challenge{} case nsSASL + " challenge": nv = &saslChallenge{} case nsSASL + " response": nv = "" case nsSASL + " abort": nv = &saslAbort{} + case nsSASL2 + " success": + nv = &sasl2Success{} case nsSASL + " success": nv = &saslSuccess{} + case nsSASL2 + " failure": + nv = &sasl2Failure{} case nsSASL + " failure": nv = &saslFailure{} case nsSASLCB + " sasl-channel-binding": diff --git a/xmpp_information_query.go b/xmpp_information_query.go index 8e30a13..3bab3bb 100644 --- a/xmpp_information_query.go +++ b/xmpp_information_query.go @@ -12,7 +12,7 @@ const ( ) func (c *Client) Discovery() (string, error) { - // use getCookie for a pseudo random id. + // use UUIDv4 for a pseudo random id. reqID := strconv.FormatUint(uint64(getCookie()), 10) return c.RawInformationQuery(c.jid, c.domain, reqID, IQTypeGet, XMPPNS_DISCO_ITEMS, "") }