Replace VNC JWT auth with a Noise_IK handshake bound to ACL-pushed pubkeys

This commit is contained in:
Viktor Liu
2026-05-21 17:36:15 +02:00
parent 9fd977c000
commit c0a3a2ee6d
36 changed files with 2014 additions and 1118 deletions
+292 -190
View File
@@ -3,6 +3,8 @@
package server
import (
"bufio"
"bytes"
"context"
"crypto/subtle"
"encoding/binary"
@@ -13,16 +15,15 @@ import (
"io"
"net"
"net/netip"
"strings"
"sync"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/flynn/noise"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/curve25519"
"golang.zx2c4.com/wireguard/tun/netstack"
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
)
// Connection modes sent by the client in the session header.
@@ -35,11 +36,7 @@ const (
// stable so clients can branch on them without parsing free text.
// Format: "CODE: human message".
const (
RejectCodeJWTMissing = "AUTH_JWT_MISSING"
RejectCodeJWTExpired = "AUTH_JWT_EXPIRED"
RejectCodeJWTInvalid = "AUTH_JWT_INVALID"
RejectCodeAuthForbidden = "AUTH_FORBIDDEN"
RejectCodeAuthConfig = "AUTH_CONFIG"
RejectCodeSessionError = "SESSION_ERROR"
RejectCodeCapturerError = "CAPTURER_ERROR"
RejectCodeUnsupportedOS = "UNSUPPORTED"
@@ -56,6 +53,21 @@ const EnvVNCDisableDownscale = "NB_VNC_DISABLE_DOWNSCALE"
// enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps.
const freshWindow = 16 * time.Millisecond
// maxConcurrentVNCConns caps in-flight VNC connections. Each accepted
// connection consumes a handler goroutine, a tracking entry, and (after
// handshake) capturer/encoder resources, so an unauthenticated peer that
// dials in a tight loop could otherwise grow memory without bound. The
// limit covers the entire accept→handshake→session window; a slot is
// released only when the handler returns.
const maxConcurrentVNCConns = 64
// maxFramebufferDim caps the screen dimensions accepted from a capturer.
// RFB serialises width/height as u16, and the encoder allocates per-frame
// buffers proportional to width*height*4. 8192 keeps width*height*4 well
// under 2^31 so int math doesn't overflow on 32-bit builds, and is large
// enough to cover real-world multi-monitor desktops.
const maxFramebufferDim = 8192
// ScreenCapturer grabs desktop frames for the VNC server.
type ScreenCapturer interface {
// Width returns the current screen width in pixels.
@@ -120,26 +132,22 @@ type InputInjector interface {
TypeText(text string)
}
// JWTConfig holds JWT validation configuration for VNC auth.
type JWTConfig struct {
Issuer string
KeysLocation string
MaxTokenAge int64
Audiences []string
}
// connectionHeader is sent by the client before the RFB handshake to specify
// the VNC session mode and authenticate.
type connectionHeader struct {
mode byte
username string
jwt string
// clientStatic is the client's static X25519 public key learned from
// the Noise handshake. Populated when identityVerified is true.
clientStatic []byte
// sessionID is the Windows session ID; 0 selects the console session.
sessionID uint32
// width and height request the virtual display geometry for session mode.
// Zero means use the default.
width uint16
height uint16
// identityVerified is true when the Noise_IK handshake completed.
identityVerified bool
}
// Server is the embedded VNC server that listens on the WireGuard interface.
@@ -170,13 +178,16 @@ type Server struct {
ctx context.Context
cancel context.CancelFunc
vmgr virtualSessionManager
jwtConfig *JWTConfig
jwtValidator *nbjwt.Validator
jwtExtractor *nbjwt.ClaimsExtractor
authorizer *sshauth.Authorizer
netstackNet *netstack.Net
authorizer *sshauth.Authorizer
netstackNet *netstack.Net
// agentToken holds the raw token bytes for agent-mode auth.
agentToken []byte
// identityKey is the daemon's static X25519 private key used in the
// Noise_IK handshake. Nil disables the handshake.
identityKey []byte
// identityPublic is the matching X25519 public key, derived once at
// construction to avoid recomputing per handshake.
identityPublic []byte
sessionsMu sync.Mutex
sessionSeq uint64
@@ -188,6 +199,17 @@ type Server struct {
// closeActiveSessions iterates this set so Stop() can interrupt
// handshaking peers, not just post-handshake sessions.
acceptedConns map[net.Conn]struct{}
// connAuth holds the verified Noise_IK identity tied to each accepted
// connection so a later UpdateVNCAuth call can revoke live sessions
// whose authorization no longer holds. Populated by registerConnAuth
// once authenticateSession succeeds; absent entries (e.g. disableAuth
// or pre-handshake conns) are skipped at revocation time.
connAuth map[net.Conn]connAuthInfo
// connSem caps concurrent accepted connections (handshake + session).
// Buffered with maxConcurrentVNCConns slots; accept loops try-acquire
// before spawning a handler and release on handler return.
connSem chan struct{}
// sessionRecorder, when non-nil, receives a SessionTick periodically
// during each VNC session and on session close. The engine wires
@@ -195,12 +217,24 @@ type Server struct {
sessionRecorder func(SessionTick)
}
// connAuthInfo captures the Noise_IK-verified identity bound to a live
// connection so policy updates can re-check it and close sessions whose
// authorization was revoked. clientStatic is empty when auth was disabled
// for this connection, which signals that revocation does not apply.
type connAuthInfo struct {
clientStatic []byte
mode byte
username string
}
// ActiveSessionInfo describes a currently connected VNC client.
type ActiveSessionInfo struct {
RemoteAddress string
Mode string
Username string
JWTUsername string
// UserID is the authenticated session identity (hashed user ID from
// the Noise_IK static-key registration), empty when auth is disabled.
UserID string
}
// vncSession provides capturer and injector for a virtual display session.
@@ -220,19 +254,31 @@ type virtualSessionManager interface {
StopAll()
}
// New creates a VNC server with the given screen capturer and input injector.
// Authentication uses a JWT supplied by the client in the connection
// header; the protocol-level VNC password scheme is not supported.
func New(capturer ScreenCapturer, injector InputInjector) *Server {
return &Server{
// New creates a VNC server. identityKey is the 32-byte X25519 private
// key used by the daemon in the Noise_IK handshake; nil disables auth.
// The protocol-level VNC password scheme is not supported.
func New(capturer ScreenCapturer, injector InputInjector, identityKey []byte) *Server {
s := &Server{
capturer: capturer,
injector: injector,
identityKey: identityKey,
authorizer: sshauth.NewAuthorizer(),
log: log.WithField("component", "vnc-server"),
sessions: make(map[uint64]ActiveSessionInfo),
sessionConns: make(map[uint64]net.Conn),
acceptedConns: make(map[net.Conn]struct{}),
connAuth: make(map[net.Conn]connAuthInfo),
connSem: make(chan struct{}, maxConcurrentVNCConns),
}
if len(identityKey) == 32 {
pub, err := curve25519.X25519(identityKey, curve25519.Basepoint)
if err == nil {
s.identityPublic = pub
} else {
s.log.Warnf("derive identity public key: %v", err)
}
}
return s
}
// ActiveSessions returns a snapshot of currently connected VNC clients.
@@ -292,9 +338,75 @@ func (s *Server) trackConn(c net.Conn) {
func (s *Server) untrackConn(c net.Conn) {
s.sessionsMu.Lock()
delete(s.acceptedConns, c)
delete(s.connAuth, c)
s.sessionsMu.Unlock()
}
// registerConnAuth records the verified Noise_IK identity for a live
// connection so UpdateVNCAuth can later revoke it if policy changes.
// No-op when auth is disabled (e.g. agent-mode loopback connections).
func (s *Server) registerConnAuth(c net.Conn, header *connectionHeader) {
if s.disableAuth || header == nil || len(header.clientStatic) != 32 {
return
}
s.sessionsMu.Lock()
s.connAuth[c] = connAuthInfo{
clientStatic: append([]byte(nil), header.clientStatic...),
mode: header.mode,
username: header.username,
}
s.sessionsMu.Unlock()
}
// tryAcquireConnSlot returns true when a connection slot was successfully
// reserved. Releases must pair with releaseConnSlot. Returns false when
// the cap is already saturated; callers must close the connection.
func (s *Server) tryAcquireConnSlot() bool {
select {
case s.connSem <- struct{}{}:
return true
default:
return false
}
}
func (s *Server) releaseConnSlot() {
select {
case <-s.connSem:
default:
}
}
// revokeUnauthorizedSessions closes every live connection whose Noise-
// verified identity no longer authenticates under the current authorizer
// configuration. Called by UpdateVNCAuth after the new policy is applied.
func (s *Server) revokeUnauthorizedSessions() {
if s.disableAuth {
return
}
s.sessionsMu.Lock()
victims := make([]net.Conn, 0)
for c, info := range s.connAuth {
if len(info.clientStatic) != 32 {
continue
}
hdr := &connectionHeader{
identityVerified: true,
clientStatic: info.clientStatic,
mode: info.mode,
username: info.username,
}
if _, err := s.authenticateSession(hdr); err != nil {
victims = append(victims, c)
s.log.Infof("revoking VNC session from %s: %v", c.RemoteAddr(), err)
}
}
s.sessionsMu.Unlock()
for _, c := range victims {
_ = c.Close()
}
}
// SetServiceMode enables proxy-to-agent mode for Windows service operation.
func (s *Server) SetServiceMode(enabled bool) {
s.serviceMode = enabled
@@ -308,16 +420,6 @@ func (s *Server) SetSessionRecorder(recorder func(SessionTick)) {
s.sessionRecorder = recorder
}
// SetJWTConfig configures JWT authentication for VNC connections.
// Pass nil to disable JWT (public mode).
func (s *Server) SetJWTConfig(config *JWTConfig) {
s.mu.Lock()
defer s.mu.Unlock()
s.jwtConfig = config
s.jwtValidator = nil
s.jwtExtractor = nil
}
// SetDisableAuth disables authentication entirely.
func (s *Server) SetDisableAuth(disable bool) {
s.disableAuth = disable
@@ -346,13 +448,14 @@ func (s *Server) SetNetstackNet(n *netstack.Net) {
s.netstackNet = n
}
// UpdateVNCAuth updates the fine-grained authorization configuration.
// UpdateVNCAuth updates the fine-grained authorization configuration and
// closes any live session whose identity no longer authenticates under
// the new policy. Revocation is event-driven: there is no periodic
// re-check, so a session stays open until either the next UpdateVNCAuth
// call or normal disconnect.
func (s *Server) UpdateVNCAuth(config *sshauth.Config) {
s.mu.Lock()
defer s.mu.Unlock()
s.jwtValidator = nil
s.jwtExtractor = nil
s.authorizer.Update(config)
s.revokeUnauthorizedSessions()
}
// Start begins listening for VNC connections on the given address.
@@ -463,9 +566,15 @@ func (s *Server) acceptLoop() {
continue
}
if !s.tryAcquireConnSlot() {
s.log.Warnf("rejecting VNC connection from %s: %d concurrent connections in flight", conn.RemoteAddr(), maxConcurrentVNCConns)
_ = conn.Close()
continue
}
enableTCPKeepAlive(conn, s.log)
s.trackConn(conn)
go func(c net.Conn) {
defer s.releaseConnSlot()
defer s.untrackConn(c)
s.handleConnection(c)
}(conn)
@@ -565,16 +674,17 @@ func (s *Server) handleConnection(conn net.Conn) {
if !s.verifyAgentToken(conn, connLog) {
return
}
header, err := readConnectionHeader(conn)
header, err := s.readConnectionHeader(conn)
if err != nil {
connLog.Warnf("read connection header: %v", err)
conn.Close()
return
}
connLog, jwtUserID, ok := s.authorizeJWT(conn, header, connLog)
connLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog)
if !ok {
return
}
s.registerConnAuth(conn, header)
capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog)
if !ok {
@@ -586,7 +696,7 @@ func (s *Server) handleConnection(conn net.Conn) {
RemoteAddress: conn.RemoteAddr().String(),
Mode: modeString(header.mode),
Username: header.username,
JWTUsername: jwtUserID,
UserID: sessionUserID,
}, conn)
defer s.removeSession(sessionID)
@@ -596,13 +706,20 @@ func (s *Server) handleConnection(conn net.Conn) {
return
}
w, h := capturer.Width(), capturer.Height()
if w <= 0 || h <= 0 || w > maxFramebufferDim || h > maxFramebufferDim {
rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("framebuffer dimensions out of range: %dx%d", w, h)))
connLog.Warnf("rejecting session: framebuffer %dx%d outside [1, %d]", w, h, maxFramebufferDim)
return
}
conn = newMetricsConn(conn, s.sessionRecorder)
sess := &session{
conn: conn,
capturer: capturer,
injector: injector,
serverW: capturer.Width(),
serverH: capturer.Height(),
serverW: w,
serverH: h,
log: connLog,
}
sess.serve()
@@ -615,25 +732,6 @@ func codeMessage(code, msg string) string {
return code + ": " + msg
}
// jwtErrorCode maps a JWT auth error to a stable reject code.
func jwtErrorCode(err error) string {
if err == nil {
return RejectCodeJWTInvalid
}
if errors.Is(err, nbjwt.ErrTokenExpired) {
return RejectCodeJWTExpired
}
msg := err.Error()
switch {
case strings.Contains(msg, "JWT required but not provided"):
return RejectCodeJWTMissing
case strings.Contains(msg, "authorize") || strings.Contains(msg, "not authorized"):
return RejectCodeAuthForbidden
default:
return RejectCodeJWTInvalid
}
}
// rejectConnection sends a minimal RFB handshake with a security failure
// reason, so VNC clients display the error message instead of a generic
// "unexpected disconnect."
@@ -658,105 +756,57 @@ func rejectConnection(conn net.Conn, reason string) {
_, _ = conn.Write(buf)
}
const defaultJWTMaxTokenAge = 10 * 60 // 10 minutes
// authenticateJWT validates the JWT from the connection header and checks
// authorization. For attach mode, just checks membership in the authorized
// user list. For session mode, additionally validates the OS user mapping.
func (s *Server) authenticateJWT(header *connectionHeader) (string, error) {
if header.jwt == "" {
return "", fmt.Errorf("JWT required but not provided")
// authenticateSession resolves the Noise-verified client static public
// key to a hashed user identity via the authorizer, and checks OS-user
// mapping for session mode. Returns the hashed user identity on success.
func (s *Server) authenticateSession(header *connectionHeader) (string, error) {
if !header.identityVerified {
return "", fmt.Errorf("identity proof missing")
}
if len(header.clientStatic) != 32 {
return "", fmt.Errorf("client static key missing")
}
s.mu.Lock()
if err := s.ensureJWTValidator(); err != nil {
s.mu.Unlock()
return "", fmt.Errorf("initialize JWT validator: %w", err)
}
validator := s.jwtValidator
extractor := s.jwtExtractor
s.mu.Unlock()
token, err := validator.ValidateAndParse(context.Background(), header.jwt)
userIDHash, err := s.authorizer.LookupSessionKey(header.clientStatic)
if err != nil {
return "", fmt.Errorf("validate JWT: %w", err)
return "", fmt.Errorf("lookup session pubkey: %w", err)
}
if err := s.checkTokenAge(token); err != nil {
return "", err
osUser := "*"
if header.mode == ModeSession {
osUser = header.username
}
userAuth, err := extractor.ToUserAuth(token)
if err != nil {
return "", fmt.Errorf("extract user from JWT: %w", err)
if _, err := s.authorizer.AuthorizeOSUserBySessionKey(userIDHash, osUser); err != nil {
return "", fmt.Errorf("authorize OS user %q: %w", osUser, err)
}
if userAuth.UserId == "" {
return "", fmt.Errorf("JWT has no user ID")
}
switch header.mode {
case ModeSession:
// Session mode: check user + OS username mapping.
if _, err := s.authorizer.Authorize(userAuth.UserId, header.username); err != nil {
return "", fmt.Errorf("authorize session for %s: %w", header.username, err)
}
default:
// Attach mode: just check user is in the authorized list (wildcard OS user).
if _, err := s.authorizer.Authorize(userAuth.UserId, "*"); err != nil {
return "", fmt.Errorf("user not authorized for VNC: %w", err)
}
}
return userAuth.UserId, nil
return userIDHash.String(), nil
}
// ensureJWTValidator lazily initializes the JWT validator. Must be called with mu held.
func (s *Server) ensureJWTValidator() error {
if s.jwtValidator != nil && s.jwtExtractor != nil {
return nil
}
if s.jwtConfig == nil {
return fmt.Errorf("no JWT config")
}
var vncIdentityMagic = []byte("NBV3")
// Enable IdP key refresh so JWKS rotations don't latch the validator
// off until daemon restart.
s.jwtValidator = nbjwt.NewValidator(
s.jwtConfig.Issuer,
s.jwtConfig.Audiences,
s.jwtConfig.KeysLocation,
true,
)
// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads).
// msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes
// msg2 = e(32) + payload_AEAD(0+16) = 48 bytes
const (
noiseInitiatorMsgLen = 96
noiseResponderMsgLen = 48
)
var opts []nbjwt.ClaimsExtractorOption
if len(s.jwtConfig.Audiences) > 0 {
opts = append(opts, nbjwt.WithAudience(s.jwtConfig.Audiences[0]))
}
if claim := s.authorizer.GetUserIDClaim(); claim != "" {
opts = append(opts, nbjwt.WithUserIDClaim(claim))
}
s.jwtExtractor = nbjwt.NewClaimsExtractor(opts...)
// vncNoiseSuite pins the cipher suite for the VNC handshake. Changing
// it requires bumping vncIdentityMagic so old clients fail closed.
var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256)
return nil
}
func (s *Server) checkTokenAge(token *gojwt.Token) error {
maxAge := defaultJWTMaxTokenAge
if s.jwtConfig != nil && s.jwtConfig.MaxTokenAge > 0 {
maxAge = int(s.jwtConfig.MaxTokenAge)
}
return nbjwt.CheckTokenAge(token, time.Duration(maxAge)*time.Second)
}
// readConnectionHeader reads the NetBird VNC session header from the connection.
// Format: [mode: 1 byte] [username_len: 2 bytes BE] [username: N bytes]
// readConnectionHeader reads the NetBird VNC session header. Format:
//
// [jwt_len: 2 bytes BE] [jwt: N bytes]
// [mode: 1] [username_len: 2 BE] [username: N]
// [opt magic "NBV3": 4] [noise_msg1: 96]
// (server writes [noise_msg2: 48] here when the magic is present)
// [session_id: 4 BE] [width: 2 BE] [height: 2 BE]
//
// Uses a short timeout: our WASM proxy sends the header immediately after
// connecting. Standard VNC clients don't send anything first (server speaks
// first in RFB), so they time out and get the default attach mode.
func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
// Standard VNC clients don't speak first, so they time out on the first
// read and fall through to attach mode (which auth still rejects when
// no Noise handshake completed).
func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
return nil, fmt.Errorf("set deadline: %w", err)
}
@@ -764,11 +814,9 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
var hdr [3]byte
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
// Timeout or error: assume no header, use attach mode.
return &connectionHeader{mode: ModeAttach}, nil
}
// Restore a longer deadline for reading variable-length fields.
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return nil, fmt.Errorf("set deadline: %w", err)
}
@@ -788,48 +836,93 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
username = string(buf)
}
// Read JWT token length and data.
var jwtLenBuf [2]byte
var jwtToken string
if _, err := io.ReadFull(conn, jwtLenBuf[:]); err == nil {
jwtLen := binary.BigEndian.Uint16(jwtLenBuf[:])
if jwtLen >= 8192 {
return nil, fmt.Errorf("jwt too long: %d (max 8191)", jwtLen)
}
if jwtLen > 0 {
buf := make([]byte, jwtLen)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, fmt.Errorf("read JWT: %w", err)
}
jwtToken = string(buf)
}
br := bufio.NewReader(conn)
clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br)
if err != nil {
return nil, err
}
// Read optional Windows session ID (4 bytes BE). Missing = 0 (console/auto).
var sessionID uint32
var sidBuf [4]byte
if _, err := io.ReadFull(conn, sidBuf[:]); err == nil {
if _, err := io.ReadFull(br, sidBuf[:]); err == nil {
sessionID = binary.BigEndian.Uint32(sidBuf[:])
}
// Read optional requested viewport size (2x uint16 BE). Missing = 0 (default).
var width, height uint16
var geomBuf [4]byte
if _, err := io.ReadFull(conn, geomBuf[:]); err == nil {
if _, err := io.ReadFull(br, geomBuf[:]); err == nil {
width = binary.BigEndian.Uint16(geomBuf[0:2])
height = binary.BigEndian.Uint16(geomBuf[2:4])
}
return &connectionHeader{
mode: mode,
username: username,
jwt: jwtToken,
sessionID: sessionID,
width: width,
height: height,
mode: mode,
username: username,
clientStatic: clientStatic,
sessionID: sessionID,
width: width,
height: height,
identityVerified: identityVerified,
}, nil
}
// maybeRunNoiseHandshake performs the responder side of a Noise_IK
// handshake when the client sends the v3 magic. Returns the client static
// public key learned from the handshake. Any handshake failure is fatal
// (fail closed).
func (s *Server) maybeRunNoiseHandshake(conn net.Conn, br *bufio.Reader) ([]byte, bool, error) {
peek, err := br.Peek(len(vncIdentityMagic))
if err != nil || !bytes.Equal(peek, vncIdentityMagic) {
return nil, false, nil
}
if _, err := br.Discard(len(vncIdentityMagic)); err != nil {
return nil, false, fmt.Errorf("discard identity magic: %w", err)
}
msg1 := make([]byte, noiseInitiatorMsgLen)
if _, err := io.ReadFull(br, msg1); err != nil {
return nil, false, fmt.Errorf("read noise msg1: %w", err)
}
// Agents on loopback authenticate via the agent token, not this
// handshake. Consume the replayed bytes and skip the response.
if s.disableAuth {
return nil, true, nil
}
if len(s.identityKey) != 32 || len(s.identityPublic) != 32 {
return nil, false, errors.New("identity key not configured")
}
state, err := noise.NewHandshakeState(noise.Config{
CipherSuite: vncNoiseSuite,
Pattern: noise.HandshakeIK,
Initiator: false,
StaticKeypair: noise.DHKey{Private: s.identityKey, Public: s.identityPublic},
})
if err != nil {
return nil, false, fmt.Errorf("noise responder init: %w", err)
}
if _, _, _, err := state.ReadMessage(nil, msg1); err != nil {
return nil, false, fmt.Errorf("noise read msg1: %w", err)
}
msg2, _, _, err := state.WriteMessage(nil, nil)
if err != nil {
return nil, false, fmt.Errorf("noise write msg2: %w", err)
}
if len(msg2) != noiseResponderMsgLen {
return nil, false, fmt.Errorf("noise responder produced %d bytes, expected %d", len(msg2), noiseResponderMsgLen)
}
if _, err := conn.Write(msg2); err != nil {
return nil, false, fmt.Errorf("write noise msg2: %w", err)
}
clientStatic := state.PeerStatic()
if len(clientStatic) != 32 {
return nil, false, errors.New("noise peer static missing")
}
return clientStatic, true, nil
}
// verifyAgentToken validates the agent token prefix when configured. Returns
// false when the token is invalid or unreadable; the connection is closed.
func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool {
@@ -865,25 +958,34 @@ func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool {
return true
}
// authorizeJWT performs JWT validation when auth is enabled. Returns the
// enriched log entry, jwt user ID (empty when auth disabled), and ok=false
// if the connection was rejected.
func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) {
// authorizeSession runs the Noise_IK handshake when auth is enabled.
// Returns the enriched log entry, user identity hash (empty when auth
// disabled), and ok=false if the connection was rejected.
func (s *Server) authorizeSession(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) {
if s.disableAuth {
return connLog, "", true
}
if s.jwtConfig == nil {
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured"))
connLog.Warn("auth rejected: no identity provider configured")
return connLog, "", false
}
jwtUserID, err := s.authenticateJWT(header)
userID, err := s.authenticateSession(header)
if err != nil {
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
connLog.Warnf("auth rejected: %v", err)
return connLog, "", false
}
return connLog.WithField("jwt_user", jwtUserID), jwtUserID, true
return connLog.WithFields(log.Fields{
"session_user": userID,
"session_key": sessionKeyFingerprint(header.clientStatic),
}), userID, true
}
// sessionKeyFingerprint returns a short hex fingerprint of a client
// static key for log correlation. Distinct VNC sessions of the same
// user end up with distinct fingerprints because each session mints a
// fresh keypair, so this lets an operator tell parallel sessions apart.
func sessionKeyFingerprint(clientStatic []byte) string {
if len(clientStatic) < 4 {
return ""
}
return hex.EncodeToString(clientStatic[:4])
}
// acquireSessionResources returns the capturer/injector to use for this