Skip to content

Commit

Permalink
Merge pull request #103 from ergochat/sasl.1
Browse files Browse the repository at this point in the history
fix #102
  • Loading branch information
slingamn authored Feb 7, 2024
2 parents 5474a63 + 5c25eee commit 46f819a
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 7 deletions.
11 changes: 10 additions & 1 deletion ircevent/examples/simple.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,19 @@ func main() {
UseTLS: true,
TLSConfig: &tls.Config{InsecureSkipVerify: true},
RequestCaps: []string{"server-time", "message-tags"},
SASLLogin: saslLogin, // SASL will be enabled automatically if these are set
SASLLogin: saslLogin, // SASL PLAIN will be enabled automatically if these are set
SASLPassword: saslPassword,
}

if certKeyFile := os.Getenv("IRCEVENT_SASL_CLIENTCERT"); certKeyFile != "" {
clientCert, err := tls.LoadX509KeyPair(certKeyFile, certKeyFile)
if err != nil {
log.Fatalf("could not load client certificate: %v", err)
}
irc.TLSConfig.Certificates = []tls.Certificate{clientCert}
irc.SASLMech = "EXTERNAL" // overrides automatic SASL PLAIN
}

irc.AddConnectCallback(func(e ircmsg.Message) {
// attempt to set the BOT mode on ourself:
if botMode := irc.ISupport()["BOT"]; botMode != "" {
Expand Down
4 changes: 2 additions & 2 deletions ircevent/irc.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,8 @@ func (irc *Connection) Connect() (err error) {
if irc.SASLMech == "" {
irc.SASLMech = "PLAIN"
}
if irc.SASLMech != "PLAIN" {
return errors.New("only SASL PLAIN is supported")
if !(irc.SASLMech == "PLAIN" || irc.SASLMech == "EXTERNAL") {
return fmt.Errorf("unsupported SASL mechanism %s", irc.SASLMech)
}
if irc.MaxLineLen == 0 {
irc.MaxLineLen = 512
Expand Down
26 changes: 22 additions & 4 deletions ircevent/irc_sasl.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package ircevent

import (
"encoding/base64"
"bytes"
"errors"
"fmt"

"github.com/ergochat/irc-go/ircmsg"
"github.com/ergochat/irc-go/ircutils"
)

type saslResult struct {
Expand All @@ -29,10 +29,28 @@ func (irc *Connection) submitSASLResult(r saslResult) {
}
}

func (irc *Connection) composeSaslPlainResponse() []byte {
var buf bytes.Buffer
buf.WriteString(irc.SASLLogin) // optional authzid, included for compatibility
buf.WriteByte('\x00')
buf.WriteString(irc.SASLLogin) // authcid
buf.WriteByte('\x00')
buf.WriteString(irc.SASLPassword) // passwd
return buf.Bytes()
}

func (irc *Connection) setupSASLCallbacks() {
irc.AddCallback("AUTHENTICATE", func(e ircmsg.Message) {
str := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s\x00%s\x00%s", irc.SASLLogin, irc.SASLLogin, irc.SASLPassword)))
irc.Send("AUTHENTICATE", str)
switch irc.SASLMech {
case "PLAIN":
for _, resp := range ircutils.EncodeSASLResponse(irc.composeSaslPlainResponse()) {
irc.Send("AUTHENTICATE", resp)
}
case "EXTERNAL":
irc.Send("AUTHENTICATE", "+")
default:
// impossible, nothing to do
}
})

irc.AddCallback(RPL_LOGGEDOUT, func(e ircmsg.Message) {
Expand Down
105 changes: 105 additions & 0 deletions ircutils/sasl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package ircutils

import (
"encoding/base64"
"errors"
"strings"
)

var (
ErrSASLLimitExceeded = errors.New("SASL total response size exceeded configured limit")
ErrSASLTooLong = errors.New("SASL response chunk exceeded 400-byte limit")
)

// EncodeSASLResponse encodes a raw SASL response as parameters to successive
// AUTHENTICATE commands, as described in the IRCv3 SASL specification.
func EncodeSASLResponse(raw []byte) (result []string) {
// https://ircv3.net/specs/extensions/sasl-3.1#the-authenticate-command
// "The response is encoded in Base64 (RFC 4648), then split to 400-byte chunks,
// and each chunk is sent as a separate AUTHENTICATE command. Empty (zero-length)
// responses are sent as AUTHENTICATE +. If the last chunk was exactly 400 bytes
// long, it must also be followed by AUTHENTICATE + to signal end of response."

if len(raw) == 0 {
return []string{"+"}
}

response := base64.StdEncoding.EncodeToString(raw)
lastLen := 0
for len(response) > 0 {
// TODO once we require go 1.21, this can be: lastLen = min(len(response), 400)
lastLen = len(response)
if lastLen > 400 {
lastLen = 400
}
result = append(result, response[:lastLen])
response = response[lastLen:]
}

if lastLen == 400 {
result = append(result, "+")
}

return result
}

// SASLBuffer handles buffering and decoding SASL responses sent as parameters
// to AUTHENTICATE commands, as described in the IRCv3 SASL specification.
// Do not copy a SASLBuffer after first use.
type SASLBuffer struct {
maxLength int
buffer strings.Builder
}

// NewSASLBuffer returns a new SASLBuffer. maxLength is the maximum amount of
// base64'ed data to buffer (0 for no limit).
func NewSASLBuffer(maxLength int) *SASLBuffer {
result := new(SASLBuffer)
result.Initialize(maxLength)
return result
}

// Initialize initializes a SASLBuffer in place.
func (b *SASLBuffer) Initialize(maxLength int) {
b.maxLength = maxLength
}

// Add processes an additional SASL response chunk sent via AUTHENTICATE.
// If the response is complete, it returns the decoded response along with
// any decoding or protocol errors detected.
func (b *SASLBuffer) Add(value string) (done bool, output []byte, err error) {
if value == "+" {
output, err = b.getAndReset()
return true, output, err
}

if len(value) > 400 {
b.buffer.Reset()
return true, nil, ErrSASLTooLong
}

if b.maxLength != 0 && (b.buffer.Len()+len(value)) > b.maxLength {
b.buffer.Reset()
return true, nil, ErrSASLLimitExceeded
}

b.buffer.WriteString(value)
if len(value) < 400 {
output, err = b.getAndReset()
return true, output, err
} else {
// 400 bytes, wait for continuation line or +
return false, nil, nil
}
}

// Clear resets the buffer state.
func (b *SASLBuffer) Clear() {
b.buffer.Reset()
}

func (b *SASLBuffer) getAndReset() (output []byte, err error) {
output, err = base64.StdEncoding.DecodeString(b.buffer.String())
b.buffer.Reset()
return
}
93 changes: 93 additions & 0 deletions ircutils/sasl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package ircutils

import (
"testing"
)

func TestSplitResponse(t *testing.T) {
assertEqual(EncodeSASLResponse([]byte{}), []string{"+"})
assertEqual(EncodeSASLResponse(
[]byte("shivaram\x00shivaram\x00shivarampassphrase")),
[]string{"c2hpdmFyYW0Ac2hpdmFyYW0Ac2hpdmFyYW1wYXNzcGhyYXNl"},
)

// from the examples in the spec:
assertEqual(
EncodeSASLResponse([]byte("\x00emersion\x00Est ut beatae omnis ipsam. Quis fugiat deleniti totam qui. Ipsum quam a dolorum tempora velit laborum odit. Et saepe voluptate sed cumque vel. Voluptas sint ab pariatur libero veritatis corrupti. Vero iure omnis ullam. Vero beatae dolores facere fugiat ipsam. Ea est pariatur minima nobis sunt aut ut. Dolores ut laudantium maiores temporibus voluptates. Reiciendis impedit omnis et unde delectus quas ab. Quae eligendi necessitatibus doloribus molestias tempora magnam assumenda.")),
[]string{
"AGVtZXJzaW9uAEVzdCB1dCBiZWF0YWUgb21uaXMgaXBzYW0uIFF1aXMgZnVnaWF0IGRlbGVuaXRpIHRvdGFtIHF1aS4gSXBzdW0gcXVhbSBhIGRvbG9ydW0gdGVtcG9yYSB2ZWxpdCBsYWJvcnVtIG9kaXQuIEV0IHNhZXBlIHZvbHVwdGF0ZSBzZWQgY3VtcXVlIHZlbC4gVm9sdXB0YXMgc2ludCBhYiBwYXJpYXR1ciBsaWJlcm8gdmVyaXRhdGlzIGNvcnJ1cHRpLiBWZXJvIGl1cmUgb21uaXMgdWxsYW0uIFZlcm8gYmVhdGFlIGRvbG9yZXMgZmFjZXJlIGZ1Z2lhdCBpcHNhbS4gRWEgZXN0IHBhcmlhdHVyIG1pbmltYSBub2JpcyBz",
"dW50IGF1dCB1dC4gRG9sb3JlcyB1dCBsYXVkYW50aXVtIG1haW9yZXMgdGVtcG9yaWJ1cyB2b2x1cHRhdGVzLiBSZWljaWVuZGlzIGltcGVkaXQgb21uaXMgZXQgdW5kZSBkZWxlY3R1cyBxdWFzIGFiLiBRdWFlIGVsaWdlbmRpIG5lY2Vzc2l0YXRpYnVzIGRvbG9yaWJ1cyBtb2xlc3RpYXMgdGVtcG9yYSBtYWduYW0gYXNzdW1lbmRhLg==",
},
)

// 400 byte line must be followed by +:
assertEqual(
EncodeSASLResponse([]byte("slingamn\x00slingamn\x001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111")),
[]string{
"c2xpbmdhbW4Ac2xpbmdhbW4AMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMQ==",
"+",
},
)
}

func TestBuffer(t *testing.T) {
b := NewSASLBuffer(1600)

// less than 400 bytes
done, output, err := b.Add("c2hpdmFyYW0Ac2hpdmFyYW0Ac2hpdmFyYW1wYXNzcGhyYXNl")
assertEqual(done, true)
assertEqual(output, []byte("shivaram\x00shivaram\x00shivarampassphrase"))
assertEqual(err, nil)

// 400 bytes exactly plus a continuation +:
done, output, err = b.Add("c2xpbmdhbW4Ac2xpbmdhbW4AMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMTExMQ==")
assertEqual(done, false)
assertEqual(output, []byte(nil))
assertEqual(err, nil)
done, output, err = b.Add("+")
assertEqual(done, true)
assertEqual(output, []byte("slingamn\x00slingamn\x001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"))
assertEqual(err, nil)

// over 400 bytes
done, output, err = b.Add("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==")
assertEqual(done, true)
assertEqual(output, []byte(nil))
assertEqual(err, ErrSASLTooLong)

// a single +
done, output, err = b.Add("+")
assertEqual(done, true)
assertEqual(len(output), 0)
assertEqual(err, nil)

// length limit
for i := 0; i < 4; i++ {
done, output, err = b.Add("AGVtZXJzaW9uAEVzdCB1dCBiZWF0YWUgb21uaXMgaXBzYW0uIFF1aXMgZnVnaWF0IGRlbGVuaXRpIHRvdGFtIHF1aS4gSXBzdW0gcXVhbSBhIGRvbG9ydW0gdGVtcG9yYSB2ZWxpdCBsYWJvcnVtIG9kaXQuIEV0IHNhZXBlIHZvbHVwdGF0ZSBzZWQgY3VtcXVlIHZlbC4gVm9sdXB0YXMgc2ludCBhYiBwYXJpYXR1ciBsaWJlcm8gdmVyaXRhdGlzIGNvcnJ1cHRpLiBWZXJvIGl1cmUgb21uaXMgdWxsYW0uIFZlcm8gYmVhdGFlIGRvbG9yZXMgZmFjZXJlIGZ1Z2lhdCBpcHNhbS4gRWEgZXN0IHBhcmlhdHVyIG1pbmltYSBub2JpcyBz")
assertEqual(done, false)
assertEqual(output, []byte(nil))
assertEqual(err, nil)
}
done, output, err = b.Add("AA==")
assertEqual(done, true)
assertEqual(output, []byte(nil))
assertEqual(err, ErrSASLLimitExceeded)

// invalid base64
done, output, err = b.Add("!!!")
assertEqual(done, true)
assertEqual(len(output), 0)
if err == nil {
t.Errorf("expected non-nil error from invalid base64")
}

// two lines
done, output, err = b.Add("AGVtZXJzaW9uAEVzdCB1dCBiZWF0YWUgb21uaXMgaXBzYW0uIFF1aXMgZnVnaWF0IGRlbGVuaXRpIHRvdGFtIHF1aS4gSXBzdW0gcXVhbSBhIGRvbG9ydW0gdGVtcG9yYSB2ZWxpdCBsYWJvcnVtIG9kaXQuIEV0IHNhZXBlIHZvbHVwdGF0ZSBzZWQgY3VtcXVlIHZlbC4gVm9sdXB0YXMgc2ludCBhYiBwYXJpYXR1ciBsaWJlcm8gdmVyaXRhdGlzIGNvcnJ1cHRpLiBWZXJvIGl1cmUgb21uaXMgdWxsYW0uIFZlcm8gYmVhdGFlIGRvbG9yZXMgZmFjZXJlIGZ1Z2lhdCBpcHNhbS4gRWEgZXN0IHBhcmlhdHVyIG1pbmltYSBub2JpcyBz")
assertEqual(done, false)
assertEqual(output, []byte(nil))
assertEqual(err, nil)
done, output, err = b.Add("dW50IGF1dCB1dC4gRG9sb3JlcyB1dCBsYXVkYW50aXVtIG1haW9yZXMgdGVtcG9yaWJ1cyB2b2x1cHRhdGVzLiBSZWljaWVuZGlzIGltcGVkaXQgb21uaXMgZXQgdW5kZSBkZWxlY3R1cyBxdWFzIGFiLiBRdWFlIGVsaWdlbmRpIG5lY2Vzc2l0YXRpYnVzIGRvbG9yaWJ1cyBtb2xlc3RpYXMgdGVtcG9yYSBtYWduYW0gYXNzdW1lbmRhLg==")
assertEqual(done, true)
assertEqual(output, []byte("\x00emersion\x00Est ut beatae omnis ipsam. Quis fugiat deleniti totam qui. Ipsum quam a dolorum tempora velit laborum odit. Et saepe voluptate sed cumque vel. Voluptas sint ab pariatur libero veritatis corrupti. Vero iure omnis ullam. Vero beatae dolores facere fugiat ipsam. Ea est pariatur minima nobis sunt aut ut. Dolores ut laudantium maiores temporibus voluptates. Reiciendis impedit omnis et unde delectus quas ab. Quae eligendi necessitatibus doloribus molestias tempora magnam assumenda."))
assertEqual(err, nil)
}

0 comments on commit 46f819a

Please sign in to comment.