You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
258 lines
8.1 KiB
258 lines
8.1 KiB
|
2 months ago
|
package externalauth
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/md5"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/binary"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Server struct {
|
||
|
|
Host string
|
||
|
|
Port int
|
||
|
|
Secret string
|
||
|
|
}
|
||
|
|
|
||
|
|
type Authenticator interface {
|
||
|
|
Authenticate(context.Context, string, Server, string, string, time.Duration) (bool, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type Client struct{ Dialer net.Dialer }
|
||
|
|
|
||
|
|
func (c Client) Authenticate(ctx context.Context, method string, server Server, username, password string, timeout time.Duration) (bool, error) {
|
||
|
|
if timeout <= 0 {
|
||
|
|
timeout = 5 * time.Second
|
||
|
|
}
|
||
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||
|
|
defer cancel()
|
||
|
|
switch strings.ToLower(strings.TrimSpace(method)) {
|
||
|
|
case "radius":
|
||
|
|
return c.radius(ctx, server, username, password)
|
||
|
|
case "tacacs", "tacacs+":
|
||
|
|
return c.tacacs(ctx, server, username, password)
|
||
|
|
default:
|
||
|
|
return false, fmt.Errorf("unsupported external authentication method %q", method)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c Client) radius(ctx context.Context, server Server, username, password string) (bool, error) {
|
||
|
|
if err := validateInput(server, username, password); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
address := net.JoinHostPort(server.Host, strconv.Itoa(server.Port))
|
||
|
|
conn, err := c.Dialer.DialContext(ctx, "udp", address)
|
||
|
|
if err != nil {
|
||
|
|
return false, fmt.Errorf("dial RADIUS server: %w", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
if deadline, ok := ctx.Deadline(); ok {
|
||
|
|
_ = conn.SetDeadline(deadline)
|
||
|
|
}
|
||
|
|
var requestAuth [16]byte
|
||
|
|
if _, err := rand.Read(requestAuth[:]); err != nil {
|
||
|
|
return false, fmt.Errorf("generate RADIUS authenticator: %w", err)
|
||
|
|
}
|
||
|
|
var identifier [1]byte
|
||
|
|
if _, err := rand.Read(identifier[:]); err != nil {
|
||
|
|
return false, fmt.Errorf("generate RADIUS identifier: %w", err)
|
||
|
|
}
|
||
|
|
attrs := append(radiusAttribute(1, []byte(username)), radiusAttribute(2, hideRadiusPassword([]byte(server.Secret), requestAuth[:], []byte(password)))...)
|
||
|
|
attrs = append(attrs, radiusAttribute(32, []byte("tera-console-server"))...)
|
||
|
|
packet := make([]byte, 20+len(attrs))
|
||
|
|
packet[0], packet[1] = 1, identifier[0]
|
||
|
|
binary.BigEndian.PutUint16(packet[2:4], uint16(len(packet)))
|
||
|
|
copy(packet[4:20], requestAuth[:])
|
||
|
|
copy(packet[20:], attrs)
|
||
|
|
if _, err := conn.Write(packet); err != nil {
|
||
|
|
return false, fmt.Errorf("write RADIUS request: %w", err)
|
||
|
|
}
|
||
|
|
response := make([]byte, 4096)
|
||
|
|
n, err := conn.Read(response)
|
||
|
|
if err != nil {
|
||
|
|
return false, fmt.Errorf("read RADIUS response: %w", err)
|
||
|
|
}
|
||
|
|
response = response[:n]
|
||
|
|
if len(response) < 20 || response[1] != identifier[0] {
|
||
|
|
return false, errors.New("invalid RADIUS response header")
|
||
|
|
}
|
||
|
|
length := int(binary.BigEndian.Uint16(response[2:4]))
|
||
|
|
if length != len(response) {
|
||
|
|
return false, errors.New("invalid RADIUS response length")
|
||
|
|
}
|
||
|
|
authInput := append([]byte(nil), response...)
|
||
|
|
copy(authInput[4:20], requestAuth[:])
|
||
|
|
authInput = append(authInput, []byte(server.Secret)...)
|
||
|
|
digest := md5.Sum(authInput)
|
||
|
|
if subtle.ConstantTimeCompare(digest[:], response[4:20]) != 1 {
|
||
|
|
return false, errors.New("invalid RADIUS response authenticator")
|
||
|
|
}
|
||
|
|
switch response[0] {
|
||
|
|
case 2:
|
||
|
|
return true, nil
|
||
|
|
case 3:
|
||
|
|
return false, nil
|
||
|
|
case 11:
|
||
|
|
return false, errors.New("RADIUS Access-Challenge is not supported")
|
||
|
|
default:
|
||
|
|
return false, fmt.Errorf("unexpected RADIUS response code %d", response[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func radiusAttribute(kind byte, value []byte) []byte {
|
||
|
|
result := make([]byte, 2+len(value))
|
||
|
|
result[0], result[1] = kind, byte(len(result))
|
||
|
|
copy(result[2:], value)
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func hideRadiusPassword(secret, requestAuth, password []byte) []byte {
|
||
|
|
length := ((len(password) + 15) / 16) * 16
|
||
|
|
if length == 0 {
|
||
|
|
length = 16
|
||
|
|
}
|
||
|
|
padded := make([]byte, length)
|
||
|
|
copy(padded, password)
|
||
|
|
result := make([]byte, length)
|
||
|
|
previous := requestAuth
|
||
|
|
for offset := 0; offset < length; offset += 16 {
|
||
|
|
input := append(append([]byte(nil), secret...), previous...)
|
||
|
|
digest := md5.Sum(input)
|
||
|
|
for index := 0; index < 16; index++ {
|
||
|
|
result[offset+index] = padded[offset+index] ^ digest[index]
|
||
|
|
}
|
||
|
|
previous = result[offset : offset+16]
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c Client) tacacs(ctx context.Context, server Server, username, password string) (bool, error) {
|
||
|
|
if err := validateInput(server, username, password); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
address := net.JoinHostPort(server.Host, strconv.Itoa(server.Port))
|
||
|
|
conn, err := c.Dialer.DialContext(ctx, "tcp", address)
|
||
|
|
if err != nil {
|
||
|
|
return false, fmt.Errorf("dial TACACS+ server: %w", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
if deadline, ok := ctx.Deadline(); ok {
|
||
|
|
_ = conn.SetDeadline(deadline)
|
||
|
|
}
|
||
|
|
var sessionBytes [4]byte
|
||
|
|
if _, err := rand.Read(sessionBytes[:]); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
sessionID := binary.BigEndian.Uint32(sessionBytes[:])
|
||
|
|
start := []byte{1, 1, 1, 1, byte(len(username)), 0, 0, 0}
|
||
|
|
start = append(start, []byte(username)...)
|
||
|
|
if err := writeTacacsPacket(conn, sessionID, 1, []byte(server.Secret), start); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
sequence := byte(2)
|
||
|
|
for attempts := 0; attempts < 6; attempts++ {
|
||
|
|
body, err := readTacacsPacket(conn, sessionID, sequence, []byte(server.Secret))
|
||
|
|
if err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
if len(body) < 6 {
|
||
|
|
return false, errors.New("invalid TACACS+ authentication reply")
|
||
|
|
}
|
||
|
|
status := body[0]
|
||
|
|
switch status {
|
||
|
|
case 1:
|
||
|
|
return true, nil
|
||
|
|
case 2:
|
||
|
|
return false, nil
|
||
|
|
case 4, 5, 3:
|
||
|
|
value := password
|
||
|
|
if status == 4 {
|
||
|
|
value = username
|
||
|
|
}
|
||
|
|
continueBody := make([]byte, 5+len(value))
|
||
|
|
binary.BigEndian.PutUint16(continueBody[0:2], uint16(len(value)))
|
||
|
|
copy(continueBody[5:], []byte(value))
|
||
|
|
sequence++
|
||
|
|
if err := writeTacacsPacket(conn, sessionID, sequence, []byte(server.Secret), continueBody); err != nil {
|
||
|
|
return false, err
|
||
|
|
}
|
||
|
|
sequence++
|
||
|
|
case 6:
|
||
|
|
return false, errors.New("TACACS+ server requested authentication restart")
|
||
|
|
default:
|
||
|
|
return false, fmt.Errorf("TACACS+ authentication error status %d", status)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false, errors.New("TACACS+ authentication exchange limit exceeded")
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeTacacsPacket(writer io.Writer, sessionID uint32, sequence byte, secret, body []byte) error {
|
||
|
|
header := make([]byte, 12)
|
||
|
|
header[0], header[1], header[2] = 0xc0, 1, sequence
|
||
|
|
binary.BigEndian.PutUint32(header[4:8], sessionID)
|
||
|
|
binary.BigEndian.PutUint32(header[8:12], uint32(len(body)))
|
||
|
|
encoded := tacacsXOR(header, secret, body)
|
||
|
|
if _, err := writer.Write(append(header, encoded...)); err != nil {
|
||
|
|
return fmt.Errorf("write TACACS+ packet: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func readTacacsPacket(reader io.Reader, sessionID uint32, sequence byte, secret []byte) ([]byte, error) {
|
||
|
|
header := make([]byte, 12)
|
||
|
|
if _, err := io.ReadFull(reader, header); err != nil {
|
||
|
|
return nil, fmt.Errorf("read TACACS+ header: %w", err)
|
||
|
|
}
|
||
|
|
if header[0] != 0xc0 || header[1] != 1 || header[2] != sequence || binary.BigEndian.Uint32(header[4:8]) != sessionID || header[3]&1 != 0 {
|
||
|
|
return nil, errors.New("invalid TACACS+ response header")
|
||
|
|
}
|
||
|
|
length := binary.BigEndian.Uint32(header[8:12])
|
||
|
|
if length > 64*1024 {
|
||
|
|
return nil, errors.New("TACACS+ response exceeds limit")
|
||
|
|
}
|
||
|
|
body := make([]byte, length)
|
||
|
|
if _, err := io.ReadFull(reader, body); err != nil {
|
||
|
|
return nil, fmt.Errorf("read TACACS+ body: %w", err)
|
||
|
|
}
|
||
|
|
return tacacsXOR(header, secret, body), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func tacacsXOR(header, secret, body []byte) []byte {
|
||
|
|
result := make([]byte, len(body))
|
||
|
|
pad := make([]byte, 0, len(body))
|
||
|
|
var previous []byte
|
||
|
|
for len(pad) < len(body) {
|
||
|
|
input := append([]byte(nil), header[4:8]...)
|
||
|
|
input = append(input, secret...)
|
||
|
|
input = append(input, header[0], header[2])
|
||
|
|
input = append(input, previous...)
|
||
|
|
digest := md5.Sum(input)
|
||
|
|
previous = digest[:]
|
||
|
|
pad = append(pad, previous...)
|
||
|
|
}
|
||
|
|
for index := range body {
|
||
|
|
result[index] = body[index] ^ pad[index]
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateInput(server Server, username, password string) error {
|
||
|
|
if strings.TrimSpace(server.Host) == "" || server.Port < 1 || server.Port > 65535 {
|
||
|
|
return errors.New("external authentication server address is invalid")
|
||
|
|
}
|
||
|
|
if server.Secret == "" {
|
||
|
|
return errors.New("external authentication shared secret is required")
|
||
|
|
}
|
||
|
|
if username == "" || len(username) > 253 || password == "" || len(password) > 253 {
|
||
|
|
return errors.New("external authentication credentials are invalid")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|