mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-20 06:07:05 +00:00
* sftpd: support SSH user certificates signed by a trusted CA Adds a new "certificate" auth method to weed sftp. When enabled, the server loads trusted CA public keys from -trustedUserCAKeysFile (OpenSSH authorized_keys format, one or more keys) and accepts only ssh.Certificate blobs of type UserCert on the public-key channel. Validation uses ssh.CertChecker: CA signature, ValidAfter/ValidBefore, non-empty ValidPrincipals and SSH login user must appear in ValidPrincipals. The authenticated user must exist in the user store; home dir and permissions resolve as before. Behaviour mirrors MinIO's --sftp=trusted-user-ca-key and OpenSSH's TrustedUserCAKeys: when certificate auth is active, plain (non-cert) public keys are rejected even if "publickey" is also listed. Default authMethods remain "password,publickey", so existing deployments are unaffected. * Update weed/sftpd/auth/certificate.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * sftpd: address review feedback on certificate auth - Pre-marshal trusted CA public keys in IsUserAuthority instead of re-marshaling on every authentication attempt (gemini-code-assist). - Differentiate user-not-found from underlying store errors via errors.As(*user.UserNotFoundError) so backend/read failures are no longer reported as bad credentials (coderabbitai). - Fix the corresponding sanity check in the missing-file test to use errors.As instead of errors.Is (UserNotFoundError has no Is method, so the previous check never matched) (coderabbitai). * sftpd: register trustedUserCAKeysFile flag in filer and server commands The new field on SftpOptions is dereferenced unconditionally in resolvePaths(), but only the standalone `weed sftp` command was wiring its flag. `weed filer` and `weed server` both embed an SftpOptions value and call resolvePaths() on it, so they hit a nil pointer dereference at startup. Register `-sftp.trustedUserCAKeysFile` in both commands and update the -sftp.authMethods help text to mention the new "certificate" method. Fixes the SFTP Integration Tests CI failure on this PR. * helm: expose SFTP certificate auth in the SeaweedFS chart Adds Helm-chart support for the new SSH user-certificate auth method: - values.yaml (sftp:) gains `trustedUserCAKeys` (inline OpenSSH authorized_keys-format CA public keys) and `existingCAKeysSecret` (reference an externally managed Secret). Same pair added under allInOne.sftp with a null default that falls back to the top-level sftp.* setting. - New template templates/sftp/sftp-ca-secret.yaml renders a chart-managed Secret <release>-sftp-ca-secret with `ca_user.pub`, but only when SFTP is enabled, "certificate" is in authMethods, inline keys are provided, and no existingCAKeysSecret is set. - templates/sftp/sftp-deployment.yaml and the all-in-one deployment template add `-trustedUserCAKeysFile=/etc/sw/sftp_ca/ca_user.pub` to the weed sftp command, mount the CA secret at /etc/sw/sftp_ca and add the corresponding volume. All cert-auth bits are guarded by `contains "certificate" authMethods` so existing users see no change. - authMethods help text updated to mention "certificate". Verified end-to-end on a local k3d cluster: cert login succeeds, plain-pubkey login is rejected with "public key without certificate not allowed". * helm: fail render when SFTP certificate auth lacks CA keys When certificate is in authMethods but neither trustedUserCAKeys nor existingCAKeysSecret is set, the deployment mounted a secret that the chart never renders, leaving the pod stuck on a missing volume. Fail at template time with a clear message instead. * sftpd: fix stale auth-method list in SFTPServiceOptions comment keyboard-interactive was never implemented; certificate is the new supported method. Match the CLI help text. * sftpd: test Manager wiring of certificate vs public-key channel Cover the channel takeover at the Manager level: certificate auth displaces plain public-key auth when both are enabled, public-key auth stays put otherwise, and enabling certificate without a CA file errors. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Chris Lu <chris.lu@gmail.com>
151 lines
4.3 KiB
Go
151 lines
4.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/sftpd/user"
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// CertificateAuthenticator authenticates clients that present an OpenSSH
|
|
// user certificate signed by one of the configured trusted CA public keys.
|
|
//
|
|
// Behaviour mirrors MinIO's --sftp=trusted-user-ca-key option and OpenSSH's
|
|
// TrustedUserCAKeys directive: when enabled, only key blobs of type
|
|
// *ssh.Certificate are accepted on the public-key channel. Plain public
|
|
// keys are rejected. The SSH login username must appear in the cert's
|
|
// ValidPrincipals list and must resolve to an existing user in the store.
|
|
type CertificateAuthenticator struct {
|
|
userStore user.Store
|
|
enabled bool
|
|
trustedCAs []ssh.PublicKey
|
|
checker *ssh.CertChecker
|
|
}
|
|
|
|
// NewCertificateAuthenticator constructs a CertificateAuthenticator.
|
|
// When enabled is true, caKeysFile must point to a file containing one or
|
|
// more CA public keys in OpenSSH authorized_keys format (one per line).
|
|
func NewCertificateAuthenticator(userStore user.Store, enabled bool, caKeysFile string) (*CertificateAuthenticator, error) {
|
|
a := &CertificateAuthenticator{
|
|
userStore: userStore,
|
|
enabled: enabled,
|
|
}
|
|
|
|
if !enabled {
|
|
return a, nil
|
|
}
|
|
|
|
if caKeysFile == "" {
|
|
return nil, fmt.Errorf("certificate auth enabled but no trustedUserCAKeysFile provided")
|
|
}
|
|
|
|
cas, err := loadAuthorizedKeysFile(caKeysFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load trusted user CA keys from %s: %w", caKeysFile, err)
|
|
}
|
|
if len(cas) == 0 {
|
|
return nil, fmt.Errorf("no trusted user CA keys found in %s", caKeysFile)
|
|
}
|
|
a.trustedCAs = cas
|
|
|
|
// Pre-marshal trusted CA keys once. IsUserAuthority runs on every
|
|
// authentication attempt, so caching the marshaled form avoids
|
|
// repeated allocations on the hot path.
|
|
trustedCAsMarshaled := make([][]byte, len(cas))
|
|
for i, ca := range cas {
|
|
trustedCAsMarshaled[i] = ca.Marshal()
|
|
}
|
|
|
|
a.checker = &ssh.CertChecker{
|
|
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
|
marshaled := auth.Marshal()
|
|
for _, caBytes := range trustedCAsMarshaled {
|
|
if subtle.ConstantTimeCompare(marshaled, caBytes) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
}
|
|
|
|
return a, nil
|
|
}
|
|
|
|
// Enabled reports whether certificate authentication is active.
|
|
func (a *CertificateAuthenticator) Enabled() bool {
|
|
return a.enabled
|
|
}
|
|
|
|
// Authenticate implements ssh.ServerConfig.PublicKeyCallback.
|
|
func (a *CertificateAuthenticator) Authenticate(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
|
if !a.enabled {
|
|
return nil, fmt.Errorf("certificate authentication disabled")
|
|
}
|
|
|
|
cert, ok := key.(*ssh.Certificate)
|
|
if !ok {
|
|
return nil, fmt.Errorf("public key without certificate not allowed")
|
|
}
|
|
if cert.CertType != ssh.UserCert {
|
|
return nil, fmt.Errorf("certificate is not a user certificate")
|
|
}
|
|
if len(cert.ValidPrincipals) == 0 {
|
|
return nil, fmt.Errorf("certificate has no valid principals")
|
|
}
|
|
|
|
username := conn.User()
|
|
|
|
// CertChecker.Authenticate verifies the CA signature (via IsUserAuthority),
|
|
// the ValidAfter/ValidBefore window, and that username is in ValidPrincipals.
|
|
perms, err := a.checker.Authenticate(conn, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("certificate validation failed: %w", err)
|
|
}
|
|
|
|
// The SSH login user must exist in the SeaweedFS user store.
|
|
if _, err := a.userStore.GetUser(username); err != nil {
|
|
var notFound *user.UserNotFoundError
|
|
if errors.As(err, ¬Found) {
|
|
return nil, fmt.Errorf("user %q not found", username)
|
|
}
|
|
return nil, fmt.Errorf("lookup user %q: %w", username, err)
|
|
}
|
|
|
|
if perms == nil {
|
|
perms = &ssh.Permissions{}
|
|
}
|
|
if perms.Extensions == nil {
|
|
perms.Extensions = map[string]string{}
|
|
}
|
|
perms.Extensions["username"] = username
|
|
return perms, nil
|
|
}
|
|
|
|
// loadAuthorizedKeysFile parses an authorized_keys-style file and returns
|
|
// all public keys found in it. Blank lines and comment lines starting with
|
|
// '#' are skipped.
|
|
func loadAuthorizedKeysFile(path string) ([]ssh.PublicKey, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var keys []ssh.PublicKey
|
|
for _, line := range bytes.Split(data, []byte("\n")) {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 || line[0] == '#' {
|
|
continue
|
|
}
|
|
pub, _, _, _, err := ssh.ParseAuthorizedKey(line)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys = append(keys, pub)
|
|
}
|
|
return keys, nil
|
|
}
|