361 lines
12 KiB
Go
361 lines
12 KiB
Go
// Copyright 2024 Dolthub, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
"github.com/cockroachdb/errors"
|
|
"github.com/dolthub/go-mysql-server/sql"
|
|
"github.com/jackc/pgx/v5/pgproto3"
|
|
|
|
"github.com/dolthub/doltgresql/server/auth"
|
|
"github.com/dolthub/doltgresql/server/auth/rfc5802"
|
|
)
|
|
|
|
// SCRAM authentication is defined in RFC-5802:
|
|
// https://datatracker.ietf.org/doc/html/rfc5802
|
|
|
|
// These are mechanisms that are used for SASL authentication.
|
|
const (
|
|
SASLMechanism_SCRAM_SHA_256 = "SCRAM-SHA-256"
|
|
SASLMechanism_SCRAM_SHA_256_PLUS = "SCRAM-SHA-256-PLUS"
|
|
)
|
|
|
|
// EnableAuthentication handles whether authentication is enabled. If enabled, it verifies that the given user exists,
|
|
// and checks that the encrypted password is derivable from the stored encrypted password.
|
|
var EnableAuthentication = true
|
|
|
|
// SASLBindingFlag are the flags for gs2-cbind-flag, used in SASL authentication.
|
|
type SASLBindingFlag string
|
|
|
|
const (
|
|
SASLBindingFlag_NoClientSupport SASLBindingFlag = "n"
|
|
SASLBindingFlag_AssumedNoServerSupport SASLBindingFlag = "y"
|
|
SASLBindingFlag_Used SASLBindingFlag = "p"
|
|
)
|
|
|
|
// SASLInitial is the structured form of the input given by *pgproto3.SASLInitialResponse.
|
|
type SASLInitial struct {
|
|
Flag SASLBindingFlag
|
|
BindName string // Only set when Flag is SASLBindingFlag_Used
|
|
Binding string // Base64 encoding of cbind-input
|
|
Authzid string // Authorization ID, currently ignored in favor of the startup message's username
|
|
Username string // Prepared using SASLprep, currently ignored in favor of the startup message's username
|
|
Nonce string
|
|
RawData []byte // The bytes that were received in the message
|
|
}
|
|
|
|
// SASLContinue is the structured form of the output for *pgproto3.SASLInitialResponse.
|
|
type SASLContinue struct {
|
|
Nonce string
|
|
Salt string // Base64 encoded salt
|
|
Iterations uint32
|
|
}
|
|
|
|
// SASLResponse is the structured form of the input given by *pgproto3.SASLResponse.
|
|
type SASLResponse struct {
|
|
GS2Header string
|
|
Nonce string
|
|
ClientProof string // Base64 encoded
|
|
RawData []byte // The bytes that were received in the message
|
|
}
|
|
|
|
// handleAuthentication handles authentication for the given user
|
|
func (h *ConnectionHandler) handleAuthentication(startupMessage *pgproto3.StartupMessage) error {
|
|
var username string
|
|
var host string
|
|
var ok bool
|
|
if username, ok = startupMessage.Parameters["user"]; ok && len(username) > 0 {
|
|
if h.Conn().RemoteAddr().Network() == "unix" {
|
|
host = "localhost"
|
|
} else {
|
|
host, _, _ = net.SplitHostPort(h.Conn().RemoteAddr().String())
|
|
if len(host) == 0 {
|
|
host = "localhost"
|
|
}
|
|
}
|
|
} else {
|
|
username = "postgres"
|
|
host = "localhost"
|
|
}
|
|
h.mysqlConn.User = username
|
|
h.mysqlConn.UserData = sql.MysqlConnectionUser{
|
|
User: username,
|
|
Host: host,
|
|
}
|
|
// Currently, regression tests disable authentication, since we can't just replay the messages due to nonces.
|
|
if !EnableAuthentication {
|
|
return h.send(&pgproto3.AuthenticationOk{})
|
|
}
|
|
// We only support one mechanism for now.
|
|
if err := h.send(&pgproto3.AuthenticationSASL{
|
|
AuthMechanisms: []string{
|
|
SASLMechanism_SCRAM_SHA_256,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := h.backend.SetAuthType(pgproto3.AuthTypeSASL); err != nil {
|
|
return err
|
|
}
|
|
// Even though we can determine whether the role exists at this point, we delay the actual error for additional security.
|
|
role := auth.GetRole(username)
|
|
var saslInitial SASLInitial
|
|
var saslContinue SASLContinue
|
|
var saslResponse SASLResponse
|
|
for {
|
|
initialResponse, err := h.backend.Receive()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch response := initialResponse.(type) {
|
|
case *pgproto3.SASLInitialResponse:
|
|
saslInitial, err = readSASLInitial(response)
|
|
if err != nil {
|
|
_ = h.send(&pgproto3.ErrorResponse{
|
|
Severity: "FATAL",
|
|
Code: "XX000",
|
|
Message: err.Error(),
|
|
})
|
|
return err
|
|
}
|
|
var salt string
|
|
if role.Password != nil {
|
|
salt = role.Password.Salt.ToBase64()
|
|
} else {
|
|
// We do this to get a stable salt. An unstable salt could be used to determine whether a username exists.
|
|
salt = rfc5802.H(rfc5802.OctetString(username))[:16].ToBase64()
|
|
}
|
|
saslContinue = SASLContinue{
|
|
Nonce: saslInitial.Nonce + auth.GenerateRandomOctetString(16).ToBase64(),
|
|
Salt: salt,
|
|
Iterations: 4096,
|
|
}
|
|
if err = h.send(saslContinue.Encode()); err != nil {
|
|
return err
|
|
}
|
|
if err = h.backend.SetAuthType(pgproto3.AuthTypeSASLContinue); err != nil {
|
|
return err
|
|
}
|
|
case *pgproto3.SASLResponse:
|
|
saslResponse, err = readSASLResponse(saslInitial.Base64Header(), saslContinue.Nonce, response)
|
|
if err != nil {
|
|
_ = h.send(&pgproto3.ErrorResponse{
|
|
Severity: "FATAL",
|
|
Code: "XX000",
|
|
Message: err.Error(),
|
|
})
|
|
return err
|
|
}
|
|
serverSignature, err := verifySASLClientProof(role, saslInitial, saslContinue, saslResponse)
|
|
if err != nil {
|
|
_ = h.send(&pgproto3.ErrorResponse{
|
|
Severity: "FATAL",
|
|
Code: "28P01",
|
|
Message: err.Error(),
|
|
})
|
|
return err
|
|
}
|
|
if err = h.send(&pgproto3.AuthenticationSASLFinal{
|
|
Data: []byte("v=" + serverSignature),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return h.send(&pgproto3.AuthenticationOk{})
|
|
default:
|
|
return errors.Errorf("unknown message type encountered during SASL authentication: %T", response)
|
|
}
|
|
}
|
|
}
|
|
|
|
// readSASLInitial reads the initial SASL response from the client.
|
|
func readSASLInitial(r *pgproto3.SASLInitialResponse) (SASLInitial, error) {
|
|
if r.AuthMechanism != SASLMechanism_SCRAM_SHA_256 {
|
|
return SASLInitial{}, errors.Errorf("SASL mechanism not supported: %s", r.AuthMechanism)
|
|
}
|
|
saslInitial := SASLInitial{}
|
|
sections := strings.Split(string(r.Data), ",")
|
|
if len(sections) < 3 {
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: too few sections")
|
|
}
|
|
|
|
// gs2-cbind-flag is the first section
|
|
gs2CbindFlag := sections[0]
|
|
if len(gs2CbindFlag) == 0 {
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: malformed gs2-cbind-flag")
|
|
}
|
|
switch gs2CbindFlag[0] {
|
|
case 'n':
|
|
saslInitial.Flag = SASLBindingFlag_NoClientSupport
|
|
case 'p':
|
|
if len(gs2CbindFlag) < 3 {
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: malformed gs2-cbind-flag channel binding")
|
|
}
|
|
saslInitial.Flag = SASLBindingFlag_Used
|
|
saslInitial.BindName = gs2CbindFlag[2:]
|
|
case 'y':
|
|
saslInitial.Flag = SASLBindingFlag_AssumedNoServerSupport
|
|
default:
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: malformed gs2-cbind-flag options (%c)", gs2CbindFlag[0])
|
|
}
|
|
|
|
// authzid is the second section
|
|
authzid := sections[1]
|
|
if len(authzid) > 0 {
|
|
if len(authzid) < 3 {
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: malformed authzid")
|
|
}
|
|
saslInitial.Authzid = authzid[2:]
|
|
}
|
|
|
|
// Read the gs2-header
|
|
for i := 2; i < len(sections); i++ {
|
|
if len(sections[i]) < 2 {
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: malformed gs2-header")
|
|
}
|
|
switch sections[i][0] {
|
|
case 'c':
|
|
saslInitial.Binding = sections[i][2:]
|
|
case 'n':
|
|
saslInitial.Username = sections[i][2:]
|
|
case 'r':
|
|
saslInitial.Nonce = sections[i][2:]
|
|
default:
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: unknown gs2-header option (%c)", sections[i][0])
|
|
}
|
|
}
|
|
|
|
// Validate that all required options have been read
|
|
if len(saslInitial.Nonce) == 0 {
|
|
return SASLInitial{}, errors.Errorf("invalid SASLInitialResponse: missing nonce")
|
|
}
|
|
// Copy the message bytes, since the backend may re-use the slice for future responses
|
|
saslInitial.RawData = make([]byte, len(r.Data))
|
|
copy(saslInitial.RawData, r.Data)
|
|
return saslInitial, nil
|
|
}
|
|
|
|
// readSASLResponse reads the second SASL response from the client.
|
|
func readSASLResponse(gs2EncodedHeader string, nonce string, r *pgproto3.SASLResponse) (SASLResponse, error) {
|
|
saslResponse := SASLResponse{}
|
|
for _, section := range strings.Split(string(r.Data), ",") {
|
|
if len(section) < 3 {
|
|
return SASLResponse{}, errors.Errorf("invalid SASLResponse: attribute too small")
|
|
}
|
|
switch section[0] {
|
|
case 'c':
|
|
saslResponse.GS2Header = section[2:]
|
|
if saslResponse.GS2Header != gs2EncodedHeader {
|
|
return SASLResponse{}, errors.Errorf("invalid SASLResponse: inconsistent GS2 header")
|
|
}
|
|
case 'p':
|
|
saslResponse.ClientProof = section[2:]
|
|
case 'r':
|
|
saslResponse.Nonce = section[2:]
|
|
if saslResponse.Nonce != nonce {
|
|
return SASLResponse{}, errors.Errorf("invalid SASLResponse: nonce does not match authentication session")
|
|
}
|
|
default:
|
|
return SASLResponse{}, errors.Errorf("invalid SASLResponse: unknown attribute (%c)", section[0])
|
|
}
|
|
}
|
|
|
|
// Validate that all required options have been read
|
|
if len(saslResponse.Nonce) == 0 {
|
|
return SASLResponse{}, errors.Errorf("invalid SASLResponse: missing nonce")
|
|
}
|
|
if len(saslResponse.ClientProof) == 0 {
|
|
return SASLResponse{}, errors.Errorf("invalid SASLResponse: missing nonce")
|
|
}
|
|
// Copy the message bytes, since the backend may re-use the slice for future responses
|
|
saslResponse.RawData = make([]byte, len(r.Data))
|
|
copy(saslResponse.RawData, r.Data)
|
|
return saslResponse, nil
|
|
}
|
|
|
|
// verifySASLClientProof verifies that the proof given by the client in valid. Returns the base64-encoded
|
|
// ServerSignature, which verifies (to the client) that the server has proper access to the client's authentication
|
|
// information.
|
|
func verifySASLClientProof(user auth.Role, saslInitial SASLInitial, saslContinue SASLContinue, saslResponse SASLResponse) (string, error) {
|
|
if !user.CanLogin || user.Password == nil {
|
|
return "", errors.Errorf(`password authentication failed for user "%s"`, user.Name)
|
|
}
|
|
// TODO: check the "valid until" time
|
|
clientProof := rfc5802.Base64ToOctetString(saslResponse.ClientProof)
|
|
authMessage := fmt.Sprintf("%s,%s,%s", saslInitial.MessageBare(), saslContinue.Encode().Data, saslResponse.MessageWithoutProof())
|
|
clientSignature := rfc5802.ClientSignature(user.Password.StoredKey, authMessage)
|
|
if len(clientProof) != len(clientSignature) {
|
|
return "", errors.Errorf(`password authentication failed for user "%s"`, user.Name)
|
|
}
|
|
clientKey := clientSignature.Xor(clientProof)
|
|
storedKey := rfc5802.StoredKey(clientKey)
|
|
if !storedKey.Equals(user.Password.StoredKey) {
|
|
return "", errors.Errorf(`password authentication failed for user "%s"`, user.Name)
|
|
}
|
|
serverSignature := rfc5802.ServerSignature(user.Password.ServerKey, authMessage)
|
|
return serverSignature.ToBase64(), nil
|
|
}
|
|
|
|
// Base64Header returns the base64-encoded GS2 header and channel binding data.
|
|
func (si SASLInitial) Base64Header() string {
|
|
return base64.StdEncoding.EncodeToString(si.base64HeaderBytes())
|
|
}
|
|
|
|
// MessageBare returns the message without the GS2 header.
|
|
func (si SASLInitial) MessageBare() []byte {
|
|
return bytes.TrimPrefix(si.RawData, si.base64HeaderBytes())
|
|
}
|
|
|
|
// base64HeaderBytes returns the GS2 header encoded as bytes.
|
|
func (si SASLInitial) base64HeaderBytes() []byte {
|
|
bb := bytes.Buffer{}
|
|
switch si.Flag {
|
|
case SASLBindingFlag_NoClientSupport:
|
|
bb.WriteString("n,")
|
|
case SASLBindingFlag_AssumedNoServerSupport:
|
|
bb.WriteString("y,")
|
|
case SASLBindingFlag_Used:
|
|
bb.WriteString(fmt.Sprintf("p=%s,", si.BindName))
|
|
}
|
|
bb.WriteString(si.Authzid)
|
|
bb.WriteRune(',')
|
|
return bb.Bytes()
|
|
}
|
|
|
|
// Encode returns the struct as an AuthenticationSASLContinue message.
|
|
func (sc SASLContinue) Encode() *pgproto3.AuthenticationSASLContinue {
|
|
return &pgproto3.AuthenticationSASLContinue{
|
|
Data: []byte(fmt.Sprintf("r=%s,s=%s,i=%d", sc.Nonce, sc.Salt, sc.Iterations)),
|
|
}
|
|
}
|
|
|
|
// MessageWithoutProof returns the client-final-message-without-proof.
|
|
func (sr SASLResponse) MessageWithoutProof() []byte {
|
|
// client-final-message is defined as:
|
|
// client-final-message-without-proof "," proof
|
|
// So we can simply search for ",p=" and exclude everything after that for well-conforming messages.
|
|
// If the message does not conform, then an error will happen later in the pipeline.
|
|
index := strings.LastIndex(string(sr.RawData), ",p=")
|
|
if index == -1 {
|
|
return sr.RawData
|
|
}
|
|
return sr.RawData[:index]
|
|
}
|