Add pam to native ssh server

Former-commit-id: e6267cc1fc
This commit is contained in:
Owen
2026-05-22 11:20:21 -07:00
parent b9536597d2
commit 6bf6b47d18
7 changed files with 169 additions and 50 deletions

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

49
main.go
View File

@@ -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

50
nativessh/auth.go Normal file
View File

@@ -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
}

34
nativessh/pam_linux.go Normal file
View File

@@ -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
}

11
nativessh/pam_other.go Normal file
View File

@@ -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")
}

View File

@@ -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
}
}