diff --git a/browsergateway/browsergateway.go b/browsergateway/browsergateway.go index 60fecb5..df54b29 100644 --- a/browsergateway/browsergateway.go +++ b/browsergateway/browsergateway.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "sync" + + "github.com/fosrl/newt/nativessh" ) // Forwarding buffer size. RDP graphics traffic is bursty and TLS records cap @@ -36,6 +38,9 @@ type Config struct { // to match against). For all proxy targets (RDP/SSH/VNC), auth tokens are // stored per-Target and validated by isAllowed. AuthToken string + // SSHCredentials are used by native SSH browser sessions for certificate + // validation against the in-memory CA/principal store. + SSHCredentials *nativessh.CredentialStore } // Gateway is a browser-based RDP/SSH/VNC WebSocket proxy. @@ -43,6 +48,7 @@ type Config struct { // HandleRDP / HandleSSH / HandleVNC http.HandlerFunc methods. type Gateway struct { authToken string + sshCreds *nativessh.CredentialStore mu sync.RWMutex targets map[int]Target // keyed by Target.ID @@ -54,6 +60,7 @@ type Gateway struct { func New(cfg Config) *Gateway { return &Gateway{ authToken: cfg.AuthToken, + sshCreds: cfg.SSHCredentials, targets: make(map[int]Target), } } diff --git a/browsergateway/ssh.go b/browsergateway/ssh.go index bf5a83a..06ca9a0 100644 --- a/browsergateway/ssh.go +++ b/browsergateway/ssh.go @@ -21,6 +21,7 @@ type sshClientMsg struct { Type string `json:"type"` Password string `json:"password,omitempty"` // used when type="auth" PrivateKey string `json:"privateKey,omitempty"` // used when type="auth" + Certificate string `json:"certificate,omitempty"` // used when type="auth" Data string `json:"data,omitempty"` // used when type="data" Cols uint32 `json:"cols,omitempty"` // used when type="resize" Rows uint32 `json:"rows,omitempty"` // used when type="resize" @@ -89,7 +90,7 @@ func (g *Gateway) HandleSSH(w http.ResponseWriter, r *http.Request) { defer ws.CloseNow() //nolint:errcheck if nativeSSH { - if err := serveNativeSSHSession(ctx, ws, username); err != nil { + if err := serveNativeSSHSession(ctx, ws, username, g.sshCreds); err != nil { log.Printf("SSH native session error: %v", err) } } else { diff --git a/browsergateway/ssh_native.go b/browsergateway/ssh_native.go index d81e762..992f875 100644 --- a/browsergateway/ssh_native.go +++ b/browsergateway/ssh_native.go @@ -17,7 +17,7 @@ import ( // The auth frame from the browser must be a JSON sshClientMsg with type="auth" // carrying the same password/privateKey fields used by the proxy SSH path. // The target username is passed in from the HTTP layer (query param). -func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string) error { +func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username string, creds *nativessh.CredentialStore) error { // Read the auth frame. _, authBytes, err := ws.Read(ctx) if err != nil { @@ -29,7 +29,7 @@ func serveNativeSSHSession(ctx context.Context, ws *websocket.Conn, username str } // Authenticate using host authorized_keys or PAM password. - if err := nativessh.Authenticate(username, authMsg.Password, authMsg.PrivateKey); err != nil { + if err := nativessh.AuthenticateWithCertificate(creds, username, authMsg.Password, authMsg.PrivateKey, authMsg.Certificate); err != nil { sendSSHError(ctx, ws, "Authentication failed") return fmt.Errorf("auth for user %q: %w", username, err) } diff --git a/main.go b/main.go index 25d4102..cf4cf7f 100644 --- a/main.go +++ b/main.go @@ -1044,7 +1044,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( }) } - browserGateway = browsergateway.New(browsergateway.Config{}) + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) browserGateway.SetTargets(bgTargets) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) @@ -2024,7 +2024,7 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( // If the gateway doesn't exist yet but we have a tunnel, start it if browserGateway == nil && tnet != nil { - browserGateway = browsergateway.New(browsergateway.Config{}) + browserGateway = browsergateway.New(browsergateway.Config{SSHCredentials: sshCredStore}) ln, bgErr := tnet.ListenTCP(&net.TCPAddr{Port: browsergateway.ListenPort}) if bgErr != nil { logger.Error("Failed to start browser gateway listener: %v", bgErr) @@ -2198,16 +2198,16 @@ persistent_keepalive_interval=5`, util.FixKey(privateKey.String()), util.FixKey( if newCfg.Blocked != connectionBlocked.Load() { connectionBlocked.Store(newCfg.Blocked) if newCfg.Blocked { - logger.Info("Config reload: connection blocking enabled") + logger.Debug("Config reload: connection blocking enabled") } else { - logger.Info("Config reload: connection blocking disabled") + logger.Debug("Config reload: connection blocking disabled") } if p := currentPM.Load(); p != nil { p.SetBlocked(newCfg.Blocked) } setClientsBlocked(newCfg.Blocked) } else { - logger.Info("Config reload: no relevant changes detected") + logger.Debug("Config reload: no relevant changes detected") } case <-ctx.Done(): return diff --git a/nativessh/auth.go b/nativessh/auth.go index 2de7a21..9f9289b 100644 --- a/nativessh/auth.go +++ b/nativessh/auth.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "log" + "net" "os" "os/user" "path/filepath" @@ -12,6 +13,17 @@ import ( "golang.org/x/crypto/ssh" ) +type staticConnMeta struct { + user string +} + +func (m staticConnMeta) User() string { return m.user } +func (m staticConnMeta) SessionID() []byte { return nil } +func (m staticConnMeta) ClientVersion() []byte { return nil } +func (m staticConnMeta) ServerVersion() []byte { return nil } +func (m staticConnMeta) RemoteAddr() net.Addr { return nil } +func (m staticConnMeta) LocalAddr() net.Addr { return nil } + // 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. @@ -59,22 +71,79 @@ func SystemUserExists(username string) bool { // // Returns nil on the first method that succeeds, or an error if all fail. func Authenticate(username, password, privateKeyPEM string) error { + return AuthenticateWithCertificate(nil, username, password, privateKeyPEM, "") +} + +// AuthenticateWithCertificate authenticates a user for a browser-based native +// SSH session using the same method ordering as the native SSH server: +// 1. Private key in host ~/.ssh/authorized_keys. +// 2. SSH certificate signed by the configured CA (when provided). +// 3. Password via host PAM stack. +func AuthenticateWithCertificate(store *CredentialStore, username, password, privateKeyPEM, certificate string) error { log.Printf("nativessh: authenticating user %q (hasPassword=%v, hasPrivateKey=%v)", username, password != "", privateKeyPEM != "") if !SystemUserExists(username) { log.Printf("nativessh: user %q not found on system", username) return fmt.Errorf("user %q does not exist", username) } + + var signer ssh.Signer if privateKeyPEM != "" { - signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) + parsedSigner, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { log.Printf("nativessh: failed to parse private key for %q: %v", username, err) - } else if CheckAuthorizedKeys(username, signer.PublicKey()) { + } else if CheckAuthorizedKeys(username, parsedSigner.PublicKey()) { log.Printf("nativessh: private key auth succeeded for %q", username) return nil } else { + signer = parsedSigner log.Printf("nativessh: private key not in authorized_keys for %q", username) } } + + if store != nil && certificate != "" { + if signer == nil { + log.Printf("nativessh: certificate provided for %q but no matching private key was provided", username) + } else { + pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certificate)) + if err != nil { + log.Printf("nativessh: failed to parse certificate for %q: %v", username, err) + } else { + cert, ok := pub.(*ssh.Certificate) + if !ok { + log.Printf("nativessh: provided cert data for %q is not an SSH certificate", username) + } else if ssh.FingerprintSHA256(cert.Key) != ssh.FingerprintSHA256(signer.PublicKey()) { + log.Printf("nativessh: certificate key mismatch for %q", username) + } else { + caKey, userPrincipals := store.get(username) + if caKey == nil { + log.Printf("nativessh: CA key is not set for certificate auth user %q", username) + } else if len(userPrincipals) == 0 { + log.Printf("nativessh: no allowed principals found for certificate auth user %q", username) + } else { + checker := &ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return ssh.FingerprintSHA256(auth) == ssh.FingerprintSHA256(caKey) + }, + } + + var lastErr error + for principal := range userPrincipals { + _, authErr := checker.Authenticate(staticConnMeta{user: principal}, cert) + if authErr == nil { + log.Printf("nativessh: certificate auth succeeded for %q (principal=%q)", username, principal) + return nil + } + lastErr = authErr + } + if lastErr != nil { + log.Printf("nativessh: certificate auth failed for %q: %v", username, lastErr) + } + } + } + } + } + } + if password != "" { if err := VerifySystemPassword(username, password); err != nil { log.Printf("nativessh: password auth failed for %q: %v", username, err) diff --git a/netstack2/proxy.go b/netstack2/proxy.go index 2df5c83..00d6763 100644 --- a/netstack2/proxy.go +++ b/netstack2/proxy.go @@ -250,9 +250,9 @@ func (p *ProxyHandler) SetBlocked(v bool) { } p.blocked.Store(v) if v { - logger.Info("ProxyHandler: connection blocking enabled") + logger.Debug("ProxyHandler: connection blocking enabled") } else { - logger.Info("ProxyHandler: connection blocking disabled") + logger.Debug("ProxyHandler: connection blocking disabled") } } diff --git a/proxy/manager.go b/proxy/manager.go index b04be26..9203f18 100644 --- a/proxy/manager.go +++ b/proxy/manager.go @@ -253,9 +253,9 @@ func (pm *ProxyManager) SetTNet(tnet *netstack.Net) { func (pm *ProxyManager) SetBlocked(v bool) { pm.blocked.Store(v) if v { - logger.Info("ProxyManager: connection blocking enabled, new connections will be dropped") + logger.Debug("ProxyManager: connection blocking enabled, new connections will be dropped") } else { - logger.Info("ProxyManager: connection blocking disabled, accepting connections") + logger.Debug("ProxyManager: connection blocking disabled, accepting connections") } }