2024-06-05 03:51:13 -04:00
// Copyright (c) 2015-2024 MinIO, Inc.
2023-11-08 12:47:05 -05:00
//
// 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"
2023-12-06 07:31:35 -05:00
"errors"
2023-11-08 12:47:05 -05:00
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
2024-06-05 03:51:13 -04:00
"github.com/minio/madmin-go/v3"
"github.com/minio/minio/internal/auth"
2023-11-08 12:47:05 -05:00
"github.com/minio/minio/internal/logger"
2024-06-05 03:51:13 -04:00
xldap "github.com/minio/pkg/v3/ldap"
2024-05-24 19:05:23 -04:00
xsftp "github.com/minio/pkg/v3/sftp"
2023-11-08 12:47:05 -05:00
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
2024-04-30 11:15:45 -04:00
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"
)
2024-06-05 03:51:13 -04:00
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
2024-04-30 11:15:45 -04:00
// 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" ,
}
2024-06-05 03:51:13 -04:00
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 {
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
}
}
return & ssh . Permissions {
CriticalOptions : map [ string ] string {
"AccessKey" : ui . Credentials . AccessKey ,
"SecretKey" : ui . Credentials . SecretKey ,
"SessionToken" : ui . Credentials . SessionToken ,
} ,
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 ,
"SessionToken" : sa . Credentials . SessionToken ,
} ,
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 )
}
2023-11-08 12:47:05 -05:00
func ( s * sftpLogger ) Error ( tag xsftp . LogType , err error ) {
switch tag {
case xsftp . AcceptNetworkError :
2024-04-04 08:04:40 -04:00
sftpLogOnceIf ( context . Background ( ) , err , "accept-limit-sftp" )
2023-11-08 12:47:05 -05:00
case xsftp . AcceptChannelError :
2024-04-04 08:04:40 -04:00
sftpLogOnceIf ( context . Background ( ) , err , "accept-channel-sftp" )
2023-11-08 12:47:05 -05:00
case xsftp . SSHKeyExchangeError :
2024-04-04 08:04:40 -04:00
sftpLogOnceIf ( context . Background ( ) , err , "key-exchange-sftp" )
2023-11-08 12:47:05 -05:00
default :
2024-04-04 08:04:40 -04:00
sftpLogOnceIf ( context . Background ( ) , err , "unknown-error-sftp" )
2023-11-08 12:47:05 -05:00
}
}
2024-04-30 11:15:45 -04:00
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
}
2023-12-07 04:33:56 -05:00
func startSFTPServer ( args [ ] string ) {
2023-11-08 12:47:05 -05:00
var (
2024-06-05 03:51:13 -04:00
port int
publicIP string
sshPrivateKey string
userCaKeyFile string
disablePassAuth bool
2023-11-08 12:47:05 -05:00
)
2024-06-05 03:51:13 -04:00
2024-04-30 11:15:45 -04:00
allowPubKeys := supportedPubKeyAuthAlgos
allowKexAlgos := preferredKexAlgos
allowCiphers := preferredCiphers
allowMACs := supportedMACs
2023-11-08 12:47:05 -05:00
var err error
2024-06-05 03:51:13 -04:00
2023-11-08 12:47:05 -05:00
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 ]
2024-04-30 11:15:45 -04:00
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" :
2024-05-01 07:07:40 -04:00
allowMACs = filterAlgos ( arg , strings . Split ( tokens [ 1 ] , "," ) , supportedMACs )
2024-05-07 02:41:25 -04:00
case "trusted-user-ca-key" :
userCaKeyFile = tokens [ 1 ]
2024-06-05 03:51:13 -04:00
case "password-auth" :
disablePassAuth , _ = strconv . ParseBool ( tokens [ 1 ] )
2023-11-08 12:47:05 -05:00
}
}
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" )
}
2024-05-07 02:41:25 -04:00
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" )
}
2024-06-05 03:51:13 -04:00
caPublicKey , _ , _ , _ , err = ssh . ParseAuthorizedKey ( keyBytes )
2024-05-07 02:41:25 -04:00
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" )
}
2024-06-05 03:51:13 -04:00
}
2024-05-07 02:41:25 -04:00
2024-06-05 03:51:13 -04:00
// 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 ,
}
2024-05-07 02:41:25 -04:00
2024-06-05 03:51:13 -04:00
if ! disablePassAuth {
sshConfig . PasswordCallback = sshPasswordAuth
} else {
sshConfig . PasswordCallback = nil
2024-05-07 02:41:25 -04:00
}
2023-11-08 12:47:05 -05:00
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" )
}
}