mirror of https://github.com/minio/minio.git
514 lines
16 KiB
Go
514 lines
16 KiB
Go
// Copyright (c) 2015-2024 MinIO, Inc.
|
|
//
|
|
// This file is part of MinIO Object Storage stack
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/minio/madmin-go/v3"
|
|
"github.com/minio/minio/internal/auth"
|
|
"github.com/minio/minio/internal/logger"
|
|
xldap "github.com/minio/pkg/v3/ldap"
|
|
xsftp "github.com/minio/pkg/v3/sftp"
|
|
"github.com/pkg/sftp"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
const (
|
|
kexAlgoDH1SHA1 = "diffie-hellman-group1-sha1"
|
|
kexAlgoDH14SHA1 = "diffie-hellman-group14-sha1"
|
|
kexAlgoDH14SHA256 = "diffie-hellman-group14-sha256"
|
|
kexAlgoDH16SHA512 = "diffie-hellman-group16-sha512"
|
|
kexAlgoECDH256 = "ecdh-sha2-nistp256"
|
|
kexAlgoECDH384 = "ecdh-sha2-nistp384"
|
|
kexAlgoECDH521 = "ecdh-sha2-nistp521"
|
|
kexAlgoCurve25519SHA256LibSSH = "curve25519-sha256@libssh.org"
|
|
kexAlgoCurve25519SHA256 = "curve25519-sha256"
|
|
|
|
chacha20Poly1305ID = "chacha20-poly1305@openssh.com"
|
|
gcm256CipherID = "aes256-gcm@openssh.com"
|
|
aes128cbcID = "aes128-cbc"
|
|
tripledescbcID = "3des-cbc"
|
|
)
|
|
|
|
var (
|
|
errSFTPPublicKeyBadFormat = errors.New("the public key provided could not be parsed")
|
|
errSFTPUserHasNoPolicies = errors.New("no policies present on this account")
|
|
errSFTPLDAPNotEnabled = errors.New("ldap authentication is not enabled")
|
|
)
|
|
|
|
// if the sftp parameter --trusted-user-ca-key is set, then
|
|
// the final form of the key file will be set as this variable.
|
|
var caPublicKey ssh.PublicKey
|
|
|
|
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=46
|
|
// preferredKexAlgos specifies the default preference for key-exchange
|
|
// algorithms in preference order. The diffie-hellman-group16-sha512 algorithm
|
|
// is disabled by default because it is a bit slower than the others.
|
|
var preferredKexAlgos = []string{
|
|
kexAlgoCurve25519SHA256, kexAlgoCurve25519SHA256LibSSH,
|
|
kexAlgoECDH256, kexAlgoECDH384, kexAlgoECDH521,
|
|
kexAlgoDH14SHA256, kexAlgoDH14SHA1,
|
|
}
|
|
|
|
// supportedKexAlgos specifies the supported key-exchange algorithms in
|
|
// preference order.
|
|
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=44
|
|
var supportedKexAlgos = []string{
|
|
kexAlgoCurve25519SHA256, kexAlgoCurve25519SHA256LibSSH,
|
|
// P384 and P521 are not constant-time yet, but since we don't
|
|
// reuse ephemeral keys, using them for ECDH should be OK.
|
|
kexAlgoECDH256, kexAlgoECDH384, kexAlgoECDH521,
|
|
kexAlgoDH14SHA256, kexAlgoDH16SHA512, kexAlgoDH14SHA1,
|
|
kexAlgoDH1SHA1,
|
|
}
|
|
|
|
// supportedPubKeyAuthAlgos specifies the supported client public key
|
|
// authentication algorithms. Note that this doesn't include certificate types
|
|
// since those use the underlying algorithm. This list is sent to the client if
|
|
// it supports the server-sig-algs extension. Order is irrelevant.
|
|
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=142
|
|
var supportedPubKeyAuthAlgos = []string{
|
|
ssh.KeyAlgoED25519,
|
|
ssh.KeyAlgoSKED25519, ssh.KeyAlgoSKECDSA256,
|
|
ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521,
|
|
ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSA,
|
|
ssh.KeyAlgoDSA,
|
|
}
|
|
|
|
// supportedCiphers lists ciphers we support but might not recommend.
|
|
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=28
|
|
var supportedCiphers = []string{
|
|
"aes128-ctr", "aes192-ctr", "aes256-ctr",
|
|
"aes128-gcm@openssh.com", gcm256CipherID,
|
|
chacha20Poly1305ID,
|
|
"arcfour256", "arcfour128", "arcfour",
|
|
aes128cbcID,
|
|
tripledescbcID,
|
|
}
|
|
|
|
// preferredCiphers specifies the default preference for ciphers.
|
|
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=37
|
|
var preferredCiphers = []string{
|
|
"aes128-gcm@openssh.com", gcm256CipherID,
|
|
chacha20Poly1305ID,
|
|
"aes128-ctr", "aes192-ctr", "aes256-ctr",
|
|
}
|
|
|
|
// supportedMACs specifies a default set of MAC algorithms in preference order.
|
|
// This is based on RFC 4253, section 6.4, but with hmac-md5 variants removed
|
|
// because they have reached the end of their useful life.
|
|
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.22.0:ssh/common.go;l=85
|
|
var supportedMACs = []string{
|
|
"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-sha1-96",
|
|
}
|
|
|
|
func sshPubKeyAuth(c ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
|
return authenticateSSHConnection(c, key, nil)
|
|
}
|
|
|
|
func sshPasswordAuth(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
|
return authenticateSSHConnection(c, nil, pass)
|
|
}
|
|
|
|
func authenticateSSHConnection(c ssh.ConnMetadata, key ssh.PublicKey, pass []byte) (*ssh.Permissions, error) {
|
|
user, found := strings.CutSuffix(c.User(), "=ldap")
|
|
if found {
|
|
if !globalIAMSys.LDAPConfig.Enabled() {
|
|
return nil, errSFTPLDAPNotEnabled
|
|
}
|
|
return processLDAPAuthentication(key, pass, user)
|
|
}
|
|
|
|
user, found = strings.CutSuffix(c.User(), "=svc")
|
|
if found {
|
|
goto internalAuth
|
|
}
|
|
|
|
if globalIAMSys.LDAPConfig.Enabled() {
|
|
perms, _ := processLDAPAuthentication(key, pass, user)
|
|
if perms != nil {
|
|
return perms, nil
|
|
}
|
|
}
|
|
|
|
internalAuth:
|
|
ui, ok := globalIAMSys.GetUser(context.Background(), user)
|
|
if !ok {
|
|
return nil, errNoSuchUser
|
|
}
|
|
|
|
if caPublicKey != nil && pass == nil {
|
|
err := validateKey(c, key)
|
|
if err != nil {
|
|
return nil, errAuthentication
|
|
}
|
|
} else {
|
|
// Temporary credentials are not allowed.
|
|
if ui.Credentials.IsTemp() {
|
|
return nil, errAuthentication
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(ui.Credentials.SecretKey), pass) != 1 {
|
|
return nil, errAuthentication
|
|
}
|
|
|
|
}
|
|
|
|
copts := map[string]string{
|
|
"AccessKey": ui.Credentials.AccessKey,
|
|
"SecretKey": ui.Credentials.SecretKey,
|
|
}
|
|
if ui.Credentials.IsTemp() {
|
|
copts["SessionToken"] = ui.Credentials.SessionToken
|
|
}
|
|
|
|
return &ssh.Permissions{
|
|
CriticalOptions: copts,
|
|
Extensions: make(map[string]string),
|
|
}, nil
|
|
}
|
|
|
|
func processLDAPAuthentication(key ssh.PublicKey, pass []byte, user string) (perms *ssh.Permissions, err error) {
|
|
var lookupResult *xldap.DNSearchResult
|
|
var targetGroups []string
|
|
|
|
if pass == nil && key == nil {
|
|
return nil, errAuthentication
|
|
}
|
|
|
|
if pass != nil {
|
|
sa, _, err := globalIAMSys.getServiceAccount(context.Background(), user)
|
|
if err == nil {
|
|
if subtle.ConstantTimeCompare([]byte(sa.Credentials.SecretKey), pass) != 1 {
|
|
return nil, errAuthentication
|
|
}
|
|
|
|
return &ssh.Permissions{
|
|
CriticalOptions: map[string]string{
|
|
"AccessKey": sa.Credentials.AccessKey,
|
|
"SecretKey": sa.Credentials.SecretKey,
|
|
},
|
|
Extensions: make(map[string]string),
|
|
}, nil
|
|
}
|
|
|
|
if !errors.Is(err, errNoSuchServiceAccount) {
|
|
return nil, err
|
|
}
|
|
|
|
lookupResult, targetGroups, err = globalIAMSys.LDAPConfig.Bind(user, string(pass))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
} else if key != nil {
|
|
|
|
lookupResult, targetGroups, err = globalIAMSys.LDAPConfig.LookupUserDN(user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
}
|
|
|
|
if lookupResult == nil {
|
|
return nil, errNoSuchUser
|
|
}
|
|
|
|
ldapPolicies, _ := globalIAMSys.PolicyDBGet(lookupResult.NormDN, targetGroups...)
|
|
if len(ldapPolicies) == 0 {
|
|
return nil, errSFTPUserHasNoPolicies
|
|
}
|
|
|
|
claims := make(map[string]interface{})
|
|
for attribKey, attribValue := range lookupResult.Attributes {
|
|
// we skip multi-value attributes here, as they cannot
|
|
// be stored in the critical options.
|
|
if len(attribValue) != 1 {
|
|
continue
|
|
}
|
|
|
|
if attribKey == "sshPublicKey" && key != nil {
|
|
key2, _, _, _, err := ssh.ParseAuthorizedKey([]byte(attribValue[0]))
|
|
if err != nil {
|
|
return nil, errSFTPPublicKeyBadFormat
|
|
}
|
|
|
|
if subtle.ConstantTimeCompare(key2.Marshal(), key.Marshal()) != 1 {
|
|
return nil, errAuthentication
|
|
}
|
|
}
|
|
claims[ldapAttribPrefix+attribKey] = attribValue[0]
|
|
}
|
|
|
|
expiryDur, err := globalIAMSys.LDAPConfig.GetExpiryDuration("")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
claims[expClaim] = UTCNow().Add(expiryDur).Unix()
|
|
claims[ldapUserN] = user
|
|
claims[ldapUser] = lookupResult.NormDN
|
|
|
|
cred, err := auth.GetNewCredentialsWithMetadata(claims, globalActiveCred.SecretKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set the parent of the temporary access key, this is useful
|
|
// in obtaining service accounts by this cred.
|
|
cred.ParentUser = lookupResult.NormDN
|
|
|
|
// Set this value to LDAP groups, LDAP user can be part
|
|
// of large number of groups
|
|
cred.Groups = targetGroups
|
|
|
|
// Set the newly generated credentials, policyName is empty on purpose
|
|
// LDAP policies are applied automatically using their ldapUser, ldapGroups
|
|
// mapping.
|
|
updatedAt, err := globalIAMSys.SetTempUser(context.Background(), cred.AccessKey, cred, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
replLogIf(context.Background(), globalSiteReplicationSys.IAMChangeHook(context.Background(), madmin.SRIAMItem{
|
|
Type: madmin.SRIAMItemSTSAcc,
|
|
STSCredential: &madmin.SRSTSCredential{
|
|
AccessKey: cred.AccessKey,
|
|
SecretKey: cred.SecretKey,
|
|
SessionToken: cred.SessionToken,
|
|
ParentUser: cred.ParentUser,
|
|
},
|
|
UpdatedAt: updatedAt,
|
|
}))
|
|
|
|
return &ssh.Permissions{
|
|
CriticalOptions: map[string]string{
|
|
"AccessKey": cred.AccessKey,
|
|
"SecretKey": cred.SecretKey,
|
|
"SessionToken": cred.SessionToken,
|
|
},
|
|
Extensions: make(map[string]string),
|
|
}, nil
|
|
}
|
|
|
|
func validateKey(c ssh.ConnMetadata, clientKey ssh.PublicKey) (err error) {
|
|
if caPublicKey == nil {
|
|
return errors.New("public key authority validation requested but no ca public key specified.")
|
|
}
|
|
|
|
cert, ok := clientKey.(*ssh.Certificate)
|
|
if !ok {
|
|
return errSftpPublicKeyWithoutCert
|
|
}
|
|
|
|
// ssh.CheckCert called by ssh.Authenticate accepts certificates
|
|
// with empty principles list so we block those in here.
|
|
if len(cert.ValidPrincipals) == 0 {
|
|
return errSftpCertWithoutPrincipals
|
|
}
|
|
|
|
// Verify that certificate provided by user is issued by trusted CA,
|
|
// username in authentication request matches to identities in certificate
|
|
// and that certificate type is correct.
|
|
checker := ssh.CertChecker{}
|
|
checker.IsUserAuthority = func(k ssh.PublicKey) bool {
|
|
return subtle.ConstantTimeCompare(caPublicKey.Marshal(), k.Marshal()) == 1
|
|
}
|
|
|
|
_, err = checker.Authenticate(c, clientKey)
|
|
return
|
|
}
|
|
|
|
type sftpLogger struct{}
|
|
|
|
func (s *sftpLogger) Info(tag xsftp.LogType, msg string) {
|
|
logger.Info(msg)
|
|
}
|
|
|
|
func (s *sftpLogger) Error(tag xsftp.LogType, err error) {
|
|
switch tag {
|
|
case xsftp.AcceptNetworkError:
|
|
sftpLogOnceIf(context.Background(), err, "accept-limit-sftp")
|
|
case xsftp.AcceptChannelError:
|
|
sftpLogOnceIf(context.Background(), err, "accept-channel-sftp")
|
|
case xsftp.SSHKeyExchangeError:
|
|
sftpLogOnceIf(context.Background(), err, "key-exchange-sftp")
|
|
default:
|
|
sftpLogOnceIf(context.Background(), err, "unknown-error-sftp")
|
|
}
|
|
}
|
|
|
|
func filterAlgos(arg string, want []string, allowed []string) []string {
|
|
var filteredAlgos []string
|
|
found := false
|
|
for _, algo := range want {
|
|
if len(algo) == 0 {
|
|
continue
|
|
}
|
|
for _, allowedAlgo := range allowed {
|
|
algo := strings.ToLower(strings.TrimSpace(algo))
|
|
if algo == allowedAlgo {
|
|
filteredAlgos = append(filteredAlgos, algo)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
logger.Fatal(fmt.Errorf("unknown algorithm %q passed to --sftp=%s\nValid algorithms: %v", algo, arg, strings.Join(allowed, ", ")), "unable to start SFTP server")
|
|
}
|
|
}
|
|
if len(filteredAlgos) == 0 {
|
|
logger.Fatal(fmt.Errorf("no valid algorithms passed to --sftp=%s\nValid algorithms: %v", arg, strings.Join(allowed, ", ")), "unable to start SFTP server")
|
|
}
|
|
return filteredAlgos
|
|
}
|
|
|
|
func startSFTPServer(args []string) {
|
|
var (
|
|
port int
|
|
publicIP string
|
|
sshPrivateKey string
|
|
userCaKeyFile string
|
|
disablePassAuth bool
|
|
)
|
|
|
|
allowPubKeys := supportedPubKeyAuthAlgos
|
|
allowKexAlgos := preferredKexAlgos
|
|
allowCiphers := preferredCiphers
|
|
allowMACs := supportedMACs
|
|
var err error
|
|
|
|
for _, arg := range args {
|
|
tokens := strings.SplitN(arg, "=", 2)
|
|
if len(tokens) != 2 {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s", arg), "unable to start SFTP server")
|
|
}
|
|
switch tokens[0] {
|
|
case "address":
|
|
host, portStr, err := net.SplitHostPort(tokens[1])
|
|
if err != nil {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s (%v)", arg, err), "unable to start SFTP server")
|
|
}
|
|
port, err = strconv.Atoi(portStr)
|
|
if err != nil {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s (%v)", arg, err), "unable to start SFTP server")
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed to --sftp=%s, (port number must be between 1 to 65535)", arg), "unable to start SFTP server")
|
|
}
|
|
publicIP = host
|
|
case "ssh-private-key":
|
|
sshPrivateKey = tokens[1]
|
|
case "pub-key-algos":
|
|
allowPubKeys = filterAlgos(arg, strings.Split(tokens[1], ","), supportedPubKeyAuthAlgos)
|
|
case "kex-algos":
|
|
allowKexAlgos = filterAlgos(arg, strings.Split(tokens[1], ","), supportedKexAlgos)
|
|
case "cipher-algos":
|
|
allowCiphers = filterAlgos(arg, strings.Split(tokens[1], ","), supportedCiphers)
|
|
case "mac-algos":
|
|
allowMACs = filterAlgos(arg, strings.Split(tokens[1], ","), supportedMACs)
|
|
case "trusted-user-ca-key":
|
|
userCaKeyFile = tokens[1]
|
|
case "password-auth":
|
|
disablePassAuth, _ = strconv.ParseBool(tokens[1])
|
|
}
|
|
}
|
|
|
|
if port == 0 {
|
|
port = 8022 // Default SFTP port, since no port was given.
|
|
}
|
|
|
|
if sshPrivateKey == "" {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is mandatory for --sftp='ssh-private-key=path/to/id_ecdsa'"), "unable to start SFTP server")
|
|
}
|
|
|
|
privateBytes, err := os.ReadFile(sshPrivateKey)
|
|
if err != nil {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is not accessible: %v", err), "unable to start SFTP server")
|
|
}
|
|
|
|
private, err := ssh.ParsePrivateKey(privateBytes)
|
|
if err != nil {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed, private key file is not parseable: %v", err), "unable to start SFTP server")
|
|
}
|
|
|
|
if userCaKeyFile != "" {
|
|
keyBytes, err := os.ReadFile(userCaKeyFile)
|
|
if err != nil {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed, trusted user certificate authority public key file is not accessible: %v", err), "unable to start SFTP server")
|
|
}
|
|
|
|
caPublicKey, _, _, _, err = ssh.ParseAuthorizedKey(keyBytes)
|
|
if err != nil {
|
|
logger.Fatal(fmt.Errorf("invalid arguments passed, trusted user certificate authority public key file is not parseable: %v", err), "unable to start SFTP server")
|
|
}
|
|
}
|
|
|
|
// An SSH server is represented by a ServerConfig, which holds
|
|
// certificate details and handles authentication of ServerConns.
|
|
sshConfig := &ssh.ServerConfig{
|
|
Config: ssh.Config{
|
|
KeyExchanges: allowKexAlgos,
|
|
Ciphers: allowCiphers,
|
|
MACs: allowMACs,
|
|
},
|
|
PublicKeyAuthAlgorithms: allowPubKeys,
|
|
PublicKeyCallback: sshPubKeyAuth,
|
|
}
|
|
|
|
if !disablePassAuth {
|
|
sshConfig.PasswordCallback = sshPasswordAuth
|
|
} else {
|
|
sshConfig.PasswordCallback = nil
|
|
}
|
|
|
|
sshConfig.AddHostKey(private)
|
|
|
|
handleSFTPSession := func(channel ssh.Channel, sconn *ssh.ServerConn) {
|
|
server := sftp.NewRequestServer(channel, NewSFTPDriver(sconn.Permissions), sftp.WithRSAllocator())
|
|
defer server.Close()
|
|
server.Serve()
|
|
}
|
|
|
|
sftpServer, err := xsftp.NewServer(&xsftp.Options{
|
|
PublicIP: publicIP,
|
|
Port: port,
|
|
// OpensSSH default handshake timeout is 2 minutes.
|
|
SSHHandshakeDeadline: 2 * time.Minute,
|
|
Logger: new(sftpLogger),
|
|
SSHConfig: sshConfig,
|
|
HandleSFTPSession: handleSFTPSession,
|
|
})
|
|
if err != nil {
|
|
logger.Fatal(err, "Unable to start SFTP Server")
|
|
}
|
|
|
|
err = sftpServer.Listen()
|
|
if err != nil {
|
|
logger.Fatal(err, "SFTP Server had an unrecoverable error while accepting connections")
|
|
}
|
|
}
|