diff --git a/config/config.go b/config/config.go index 4913eca1..bcf621c8 100644 --- a/config/config.go +++ b/config/config.go @@ -195,6 +195,7 @@ func Init() { MaxAuthTries: 0, Banner: defaultSFTPDBanner, HostKeys: []string{}, + HostCertificates: []string{}, KexAlgorithms: []string{}, Ciphers: []string{}, MACs: []string{}, @@ -1539,6 +1540,7 @@ func setViperDefaults() { viper.SetDefault("sftpd.max_auth_tries", globalConf.SFTPD.MaxAuthTries) viper.SetDefault("sftpd.banner", globalConf.SFTPD.Banner) viper.SetDefault("sftpd.host_keys", globalConf.SFTPD.HostKeys) + viper.SetDefault("sftpd.host_certificates", globalConf.SFTPD.HostCertificates) viper.SetDefault("sftpd.kex_algorithms", globalConf.SFTPD.KexAlgorithms) viper.SetDefault("sftpd.ciphers", globalConf.SFTPD.Ciphers) viper.SetDefault("sftpd.macs", globalConf.SFTPD.MACs) diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 5d9b7159..70ec340d 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -108,6 +108,7 @@ The configuration file contains the following sections: - `max_auth_tries` integer. Maximum number of authentication attempts permitted per connection. If set to a negative number, the number of attempts is unlimited. If set to zero, the number of attempts is limited to 6. - `banner`, string. Identification string used by the server. Leave empty to use the default banner. Default `SFTPGo_`, for example `SSH-2.0-SFTPGo_0.9.5` - `host_keys`, list of strings. It contains the daemon's private host keys. Each host key can be defined as a path relative to the configuration directory or an absolute one. If empty, the daemon will search or try to generate `id_rsa`, `id_ecdsa` and `id_ed25519` keys inside the configuration directory. If you configure absolute paths to files named `id_rsa`, `id_ecdsa` and/or `id_ed25519` then SFTPGo will try to generate these keys using the default settings. + - `host_certificates`, list of strings. Public host certificates. Each certificate can be defined as a path relative to the configuration directory or an absolute one. Certificate's public key must match a private host key otherwise it will be silently ignored. Default: empty. - `kex_algorithms`, list of strings. Available KEX (Key Exchange) algorithms in preference order. Leave empty to use default values. The supported values are: `curve25519-sha256`, `curve25519-sha256@libssh.org`, `ecdh-sha2-nistp256`, `ecdh-sha2-nistp384`, `ecdh-sha2-nistp521`, `diffie-hellman-group14-sha256`, `diffie-hellman-group16-sha512`, `diffie-hellman-group18-sha512`, `diffie-hellman-group14-sha1`, `diffie-hellman-group1-sha1`. Default values: `curve25519-sha256`, `curve25519-sha256@libssh.org`, `ecdh-sha2-nistp256`, `ecdh-sha2-nistp384`, `ecdh-sha2-nistp521`, `diffie-hellman-group14-sha256`. Sha512 based KEXs are disabled by default because they are slow. - `ciphers`, list of strings. Allowed ciphers in preference order. Leave empty to use default values. The supported values are: `aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`, `chacha20-poly1305@openssh.com`, `aes128-ctr`, `aes192-ctr`, `aes256-ctr`, `aes128-cbc`, `aes192-cbc`, `aes256-cbc`, `3des-cbc`, `arcfour256`, `arcfour128`, `arcfour`. Default values: `aes128-gcm@openssh.com`, `aes256-gcm@openssh.com`, `chacha20-poly1305@openssh.com`, `aes128-ctr`, `aes192-ctr`, `aes256-ctr`. Please note that the ciphers disabled by default are insecure, you should expect that an active attacker can recover plaintext if you enable them. - `macs`, list of strings. Available MAC (message authentication code) algorithms in preference order. Leave empty to use default values. The supported values are: `hmac-sha2-256-etm@openssh.com`, `hmac-sha2-256`, `hmac-sha2-512-etm@openssh.com`, `hmac-sha2-512`, `hmac-sha1`, `hmac-sha1-96`. Default values: `hmac-sha2-256-etm@openssh.com`, `hmac-sha2-256`. diff --git a/sftpd/server.go b/sftpd/server.go index 09232796..1aae0964 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -102,6 +102,10 @@ type Configuration struct { // If empty or missing, the daemon will search or try to generate "id_rsa" and "id_ecdsa" host keys // inside the configuration directory. HostKeys []string `json:"host_keys" mapstructure:"host_keys"` + // HostCertificates defines public host certificates. + // Each certificate can be defined as a path relative to the configuration directory or an absolute one. + // Certificate's public key must match a private host key otherwise it will be silently ignored. + HostCertificates []string `json:"host_certificates" mapstructure:"host_certificates"` // KexAlgorithms specifies the available KEX (Key Exchange) algorithms in // preference order. KexAlgorithms []string `json:"kex_algorithms" mapstructure:"kex_algorithms"` @@ -790,6 +794,10 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh if err := c.checkHostKeyAutoGeneration(configDir); err != nil { return err } + hostCertificates, err := c.loadHostCertificates(configDir) + if err != nil { + return err + } serviceStatus.HostKeys = nil for _, hostKey := range c.HostKeys { if !util.IsFileInputValid(hostKey) { @@ -821,6 +829,14 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh // Add private key to the server configuration. serverConfig.AddHostKey(private) + for _, cert := range hostCertificates { + signer, err := ssh.NewCertSigner(cert, private) + if err == nil { + serverConfig.AddHostKey(signer) + logger.Info(logSender, "", "Host certificate loaded for host key %#v, fingerprint %#v", + hostKey, ssh.FingerprintSHA256(signer.PublicKey())) + } + } } var fp []string for idx := range serviceStatus.HostKeys { @@ -831,11 +847,42 @@ func (c *Configuration) checkAndLoadHostKeys(configDir string, serverConfig *ssh return nil } +func (c *Configuration) loadHostCertificates(configDir string) ([]*ssh.Certificate, error) { + var certs []*ssh.Certificate + for _, certPath := range c.HostCertificates { + if !util.IsFileInputValid(certPath) { + logger.Warn(logSender, "", "unable to load invalid host certificate %#v", certPath) + logger.WarnToConsole("unable to load invalid host certificate %#v", certPath) + continue + } + if !filepath.IsAbs(certPath) { + certPath = filepath.Join(configDir, certPath) + } + certBytes, err := os.ReadFile(certPath) + if err != nil { + return certs, fmt.Errorf("unable to load host certificate %#v: %w", certPath, err) + } + parsed, _, _, _, err := ssh.ParseAuthorizedKey(certBytes) + if err != nil { + return nil, fmt.Errorf("unable to parse host certificate %#v: %w", certPath, err) + } + cert, ok := parsed.(*ssh.Certificate) + if !ok { + return nil, fmt.Errorf("the file %#v is not an SSH certificate", certPath) + } + if cert.CertType != ssh.HostCert { + return nil, fmt.Errorf("the file %#v is not an host certificate", certPath) + } + certs = append(certs, cert) + } + return certs, nil +} + func (c *Configuration) initializeCertChecker(configDir string) error { for _, keyPath := range c.TrustedUserCAKeys { if !util.IsFileInputValid(keyPath) { - logger.Warn(logSender, "", "unable to load invalid trusted user CA key: %#v", keyPath) - logger.WarnToConsole("unable to load invalid trusted user CA key: %#v", keyPath) + logger.Warn(logSender, "", "unable to load invalid trusted user CA key %#v", keyPath) + logger.WarnToConsole("unable to load invalid trusted user CA key %#v", keyPath) continue } if !filepath.IsAbs(keyPath) { diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 62faf54b..08e23dd4 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -396,6 +396,44 @@ func TestInitialization(t *testing.T) { if assert.Error(t, err) { assert.Contains(t, err.Error(), "unsupported key-exchange algorithm") } + sftpdConf.HostCertificates = []string{"missing file"} + err = sftpdConf.Initialize(configDir) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "unable to load host certificate") + } + sftpdConf.HostCertificates = []string{"."} + err = sftpdConf.Initialize(configDir) + assert.Error(t, err) + hostCertPath := filepath.Join(os.TempDir(), "host_cert.pub") + err = os.WriteFile(hostCertPath, []byte(testCertValid), 0600) + assert.NoError(t, err) + sftpdConf.HostKeys = []string{privateKeyPath} + sftpdConf.HostCertificates = []string{hostCertPath} + err = sftpdConf.Initialize(configDir) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "is not an host certificate") + } + err = os.WriteFile(hostCertPath, []byte(testPubKey), 0600) + assert.NoError(t, err) + err = sftpdConf.Initialize(configDir) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "is not an SSH certificate") + } + err = os.WriteFile(hostCertPath, []byte("abc"), 0600) + assert.NoError(t, err) + err = sftpdConf.Initialize(configDir) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "unable to parse host certificate") + } + err = os.WriteFile(hostCertPath, []byte(testHostCert), 0600) + assert.NoError(t, err) + err = sftpdConf.Initialize(configDir) + assert.Error(t, err) + + err = os.Remove(hostCertPath) + assert.NoError(t, err) + sftpdConf.HostKeys = nil + sftpdConf.HostCertificates = nil sftpdConf.RevokedUserCertsFile = "." err = sftpdConf.Initialize(configDir) assert.Error(t, err) diff --git a/sftpgo.json b/sftpgo.json index fafa81f0..1a4a9901 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -63,6 +63,7 @@ "max_auth_tries": 0, "banner": "", "host_keys": [], + "host_certificates": [], "kex_algorithms": [], "ciphers": [], "macs": [],