diff --git a/go.mod b/go.mod index 1508e71..32b24c5 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/msteinert/pam/v2 v2.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect diff --git a/go.sum b/go.sum index c79a283..77e8085 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/msteinert/pam/v2 v2.1.0 h1:er5F9TKV5nGFuTt12ubtqPHEUdeBwReP7vd3wovidGY= +github.com/msteinert/pam/v2 v2.1.0/go.mod h1:KT28NNIcDFf3PcBmNI2mIGO4zZJ+9RSs/At2PB3IDVc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= diff --git a/main.go b/main.go index cc03bc2..75a3f61 100644 --- a/main.go +++ b/main.go @@ -1715,14 +1715,14 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // Define the structure of the incoming message type SSHCertData struct { - MessageId int `json:"messageId"` - AgentPort int `json:"agentPort"` - AgentHost string `json:"agentHost"` - ExternalAuthDaemon bool `json:"externalAuthDaemon"` - CACert string `json:"caCert"` - Username string `json:"username"` - NiceID string `json:"niceId"` - Metadata struct { + MessageId int `json:"messageId"` + AgentPort int `json:"agentPort"` + AgentHost string `json:"agentHost"` + AuthDaemonMode string `json:"authDaemonMode"` // site, remote, native + CACert string `json:"caCert"` + Username string `json:"username"` + NiceID string `json:"niceId"` + Metadata struct { SudoMode string `json:"sudoMode"` SudoCommands []string `json:"sudoCommands"` Homedir bool `json:"homedir"` @@ -1745,28 +1745,8 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( return } - var useNativeSSH = true - - if useNativeSSH { - // Update in-memory credentials used by the native SSH server. - if err := sshCredStore.SetCAKey(certData.CACert); err != nil { - logger.Error("nativessh: failed to set CA key: %v", err) - } - sshCredStore.AddPrincipals(certData.Username, certData.NiceID) - logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) - - // Acknowledge the PAM connection to the cloud. - if err := client.SendMessage("ws/round-trip/complete", map[string]interface{}{ - "messageId": certData.MessageId, - "complete": true, - }); err != nil { - logger.Error("nativessh: failed to send round-trip complete: %v", err) - } - return - } - // Check if we're running the auth daemon internally - if authDaemonServer != nil && !certData.ExternalAuthDaemon { // if the auth daemon is running internally and the external auth daemon is not enabled + if authDaemonServer != nil && certData.AuthDaemonMode == "site" { // if the auth daemon is running internally and the external auth daemon is not enabled // Call ProcessConnection directly when running internally logger.Debug("Calling internal auth daemon ProcessConnection for user %s", certData.Username) @@ -1789,7 +1769,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }) logger.Info("Successfully processed connection via internal auth daemon for user %s", certData.Username) - } else { + } else if certData.AuthDaemonMode == "remote" { // External auth daemon mode - make HTTP request // Check if auth daemon key is configured if authDaemonKey == "" { @@ -1886,6 +1866,15 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( } logger.Info("Successfully registered SSH certificate with external auth daemon for user %s", certData.Username) + } else if certData.AuthDaemonMode == "native" { + // Update in-memory credentials used by the native SSH server. + if err := sshCredStore.SetCAKey(certData.CACert); err != nil { + logger.Error("nativessh: failed to set CA key: %v", err) + } + sshCredStore.AddPrincipals(certData.Username, certData.NiceID) + logger.Info("nativessh: updated credentials for user %s (niceId=%s)", certData.Username, certData.NiceID) + } else { + logger.Error("Unknown auth daemon mode: %s", certData.AuthDaemonMode) } // Send success response back to cloud diff --git a/nativessh/auth.go b/nativessh/auth.go new file mode 100644 index 0000000..54d0902 --- /dev/null +++ b/nativessh/auth.go @@ -0,0 +1,50 @@ +package nativessh + +import ( + "bufio" + "os" + "os/user" + "path/filepath" + "strings" + + "golang.org/x/crypto/ssh" +) + +// checkAuthorizedKeys reports whether key matches any entry in the system +// user's ~/.ssh/authorized_keys file. Returns false (not an error) when the +// user or file does not exist. +func checkAuthorizedKeys(username string, key ssh.PublicKey) bool { + u, err := user.Lookup(username) + if err != nil { + return false + } + f, err := os.Open(filepath.Join(u.HomeDir, ".ssh", "authorized_keys")) + if err != nil { + return false + } + defer f.Close() + + want := ssh.FingerprintSHA256(key) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) + if err != nil { + continue + } + if ssh.FingerprintSHA256(parsed) == want { + return true + } + } + return false +} + +// systemUserExists reports whether a user account with the given name exists +// on the host OS. +func systemUserExists(username string) bool { + _, err := user.Lookup(username) + return err == nil +} diff --git a/nativessh/pam_linux.go b/nativessh/pam_linux.go new file mode 100644 index 0000000..c41a046 --- /dev/null +++ b/nativessh/pam_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package nativessh + +import ( + "fmt" + + "github.com/msteinert/pam/v2" +) + +// verifySystemPassword authenticates username/password via PAM using the +// "sshd" service stack. It returns nil on success and an error on failure. +// The caller must not reveal the error detail to the client. +func verifySystemPassword(username, password string) error { + tx, err := pam.StartFunc("sshd", username, func(s pam.Style, msg string) (string, error) { + switch s { + case pam.PromptEchoOff, pam.PromptEchoOn: + return password, nil + default: + return "", nil + } + }) + if err != nil { + return fmt.Errorf("PAM start: %w", err) + } + + if err := tx.Authenticate(0); err != nil { + return fmt.Errorf("PAM authenticate: %w", err) + } + if err := tx.AcctMgmt(0); err != nil { + return fmt.Errorf("PAM acct_mgmt: %w", err) + } + return nil +} diff --git a/nativessh/pam_other.go b/nativessh/pam_other.go new file mode 100644 index 0000000..267400a --- /dev/null +++ b/nativessh/pam_other.go @@ -0,0 +1,11 @@ +//go:build !linux + +package nativessh + +import "errors" + +// verifySystemPassword is not supported on non-Linux platforms; it always +// returns an error so that password authentication is never accepted. +func verifySystemPassword(username, password string) error { + return errors.New("password authentication not supported on this platform") +} diff --git a/nativessh/server.go b/nativessh/server.go index 46eb804..f4aa2c9 100644 --- a/nativessh/server.go +++ b/nativessh/server.go @@ -93,14 +93,17 @@ func NewServer(cfg ServerConfig) *Server { return &Server{cfg: cfg} } -// buildSSHConfig builds the ssh.ServerConfig backed by the in-memory CredentialStore. +// buildSSHConfig builds the ssh.ServerConfig with multi-method authentication: +// 1. Public key: host ~/.ssh/authorized_keys, then CA certificate. +// 2. Password: system PAM stack (Linux only). func (s *Server) buildSSHConfig() (*ssh.ServerConfig, error) { hostSigner, err := generateHostKey() if err != nil { return nil, fmt.Errorf("host key: %w", err) } cfg := &ssh.ServerConfig{ - PublicKeyCallback: makeCredentialStoreCallback(s.cfg.Credentials), + PublicKeyCallback: makePublicKeyCallback(s.cfg.Credentials), + PasswordCallback: makePasswordCallback(), } cfg.AddHostKey(hostSigner) return cfg, nil @@ -240,28 +243,57 @@ func (s *Server) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { } } -// makeCredentialStoreCallback returns an ssh.PublicKeyCallback that reads -// the CA key and per-user principals from store on every auth attempt, so -// credentials updated via AddPrincipals/SetCAKey are applied immediately. -func makeCredentialStoreCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { +// makePublicKeyCallback returns a PublicKeyCallback that tries, in order: +// 1. Host authorized_keys – matches any key in the OS user's +// ~/.ssh/authorized_keys file. +// 2. CA certificate – validates an SSH certificate signed by the +// configured CA and checks that the user appears in the principals map. +// +// store may be nil or empty; those paths are simply skipped. +func makePublicKeyCallback(store *CredentialStore) func(ssh.ConnMetadata, ssh.PublicKey) (*ssh.Permissions, error) { return func(meta ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - caKey, userPrincipals := store.get(meta.User()) - if caKey == nil { - return nil, fmt.Errorf("no CA key configured") + // 1. Host authorized_keys. + if checkAuthorizedKeys(meta.User(), key) { + log.Printf("nativessh: authorized_keys auth for user %q", meta.User()) + return &ssh.Permissions{}, nil } - checker := &ssh.CertChecker{ - IsUserAuthority: func(auth ssh.PublicKey) bool { - return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) - }, + + // 2. CA certificate. + if store != nil { + caKey, userPrincipals := store.get(meta.User()) + if caKey != nil { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + perms, err := checker.Authenticate(meta, key) + if err == nil { + if len(userPrincipals) == 0 { + return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) + } + log.Printf("nativessh: CA cert auth for user %q", meta.User()) + return perms, nil + } + } } - perms, err := checker.Authenticate(meta, key) - if err != nil { - return nil, err + + return nil, fmt.Errorf("public key not authorized for user %q", meta.User()) + } +} + +// makePasswordCallback returns a PasswordCallback that validates the supplied +// password via the host OS PAM stack. On non-Linux platforms this always +// fails (see pam_other.go). +func makePasswordCallback() func(ssh.ConnMetadata, []byte) (*ssh.Permissions, error) { + return func(meta ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if err := verifySystemPassword(meta.User(), string(password)); err != nil { + // Return a generic message to the client; log the real reason. + log.Printf("nativessh: password auth failed for user %q: %v", meta.User(), err) + return nil, fmt.Errorf("permission denied") } - if len(userPrincipals) == 0 { - return nil, fmt.Errorf("user %q not in allowed principals list", meta.User()) - } - return perms, nil + log.Printf("nativessh: password auth for user %q", meta.User()) + return &ssh.Permissions{}, nil } }