mirror of
https://github.com/minio/minio.git
synced 2024-12-27 07:35:55 -05:00
7b239ae154
sftp sends local requests to the S3 port while passing the session token header when the account corresponds to a service account. However, this is not permitted and will throw an error: "The security token included in the request is invalid" This commit will avoid passing the session token to the upper layer that initializes MinIO client to avoid this error.
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")
|
|
}
|
|
}
|