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 2f4ddf0796
commit 3d3055dc7f
36 changed files with 2014 additions and 1118 deletions
+11 -1
View File
@@ -39,12 +39,22 @@ var vncAgentCmd = &cobra.Command{
if token == "" { if token == "" {
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service") return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
} }
// Drop the token from our process environment so any child the
// agent spawns does not inherit it, and casual debugging tools
// that dump /proc/<pid>/environ (or the Windows equivalent) on a
// running agent don't surface the loopback shared secret.
if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil {
log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err)
}
capturer, injector, err := newAgentResources() capturer, injector, err := newAgentResources()
if err != nil { if err != nil {
return err return err
} }
srv := vncserver.New(capturer, injector) // The per-user agent listens only on loopback and is gated by an
// agent token shared with the daemon, so no X25519 identity key
// is needed; auth is disabled at the RFB layer.
srv := vncserver.New(capturer, injector, nil)
srv.SetDisableAuth(true) srv.SetDisableAuth(true)
srv.SetAgentToken(token) srv.SetAgentToken(token)
+1 -1
View File
@@ -1064,7 +1064,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
} }
} }
if err := e.updateVNC(conf.GetSshConfig()); err != nil { if err := e.updateVNC(); err != nil {
log.Warnf("failed handling VNC server setup: %v", err) log.Warnf("failed handling VNC server setup: %v", err)
} }
+23 -51
View File
@@ -66,8 +66,7 @@ func (e *Engine) cleanupVNCPortRedirection() error {
} }
// updateVNC handles starting/stopping the VNC server based on the config flag. // updateVNC handles starting/stopping the VNC server based on the config flag.
// sshConf provides the JWT identity provider config (shared with SSH). func (e *Engine) updateVNC() error {
func (e *Engine) updateVNC(sshConf *mgmProto.SSHConfig) error {
if !e.config.ServerVNCAllowed { if !e.config.ServerVNCAllowed {
if e.vncSrv != nil { if e.vncSrv != nil {
log.Info("VNC server disabled, stopping") log.Info("VNC server disabled, stopping")
@@ -81,15 +80,13 @@ func (e *Engine) updateVNC(sshConf *mgmProto.SSHConfig) error {
} }
if e.vncSrv != nil { if e.vncSrv != nil {
// Update JWT config on existing server in case management sent new config.
e.updateVNCServerJWT(sshConf)
return nil return nil
} }
return e.startVNCServer(sshConf) return e.startVNCServer()
} }
func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error { func (e *Engine) startVNCServer() error {
if e.wgInterface == nil { if e.wgInterface == nil {
return errors.New("wg interface not initialized") return errors.New("wg interface not initialized")
} }
@@ -102,7 +99,7 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error {
netbirdIP := e.wgInterface.Address().IP netbirdIP := e.wgInterface.Address().IP
srv := vncserver.New(capturer, injector) srv := vncserver.New(capturer, injector, e.config.WgPrivateKey[:])
if e.clientMetrics != nil { if e.clientMetrics != nil {
srv.SetSessionRecorder(func(t vncserver.SessionTick) { srv.SetSessionRecorder(func(t vncserver.SessionTick) {
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{ e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
@@ -122,20 +119,6 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error {
srv.SetServiceMode(true) srv.SetServiceMode(true)
} }
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
audiences := protoJWT.GetAudiences()
if len(audiences) == 0 && protoJWT.GetAudience() != "" {
audiences = []string{protoJWT.GetAudience()}
}
srv.SetJWTConfig(&vncserver.JWTConfig{
Issuer: protoJWT.GetIssuer(),
Audiences: audiences,
KeysLocation: protoJWT.GetKeysLocation(),
MaxTokenAge: protoJWT.GetMaxTokenAge(),
})
log.Debugf("VNC: JWT authentication configured (issuer=%s)", protoJWT.GetIssuer())
}
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
srv.SetNetstackNet(netstackNet) srv.SetNetstackNet(netstackNet)
} }
@@ -165,35 +148,6 @@ func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error {
return nil return nil
} }
// updateVNCServerJWT configures the JWT validation for the VNC server using
// the same JWT config as SSH (same identity provider).
func (e *Engine) updateVNCServerJWT(sshConf *mgmProto.SSHConfig) {
if e.vncSrv == nil {
return
}
vncSrv, ok := e.vncSrv.(*vncserver.Server)
if !ok {
return
}
protoJWT := sshConf.GetJwtConfig()
if protoJWT == nil {
return
}
audiences := protoJWT.GetAudiences()
if len(audiences) == 0 && protoJWT.GetAudience() != "" {
audiences = []string{protoJWT.GetAudience()}
}
vncSrv.SetJWTConfig(&vncserver.JWTConfig{
Issuer: protoJWT.GetIssuer(),
Audiences: audiences,
KeysLocation: protoJWT.GetKeysLocation(),
MaxTokenAge: protoJWT.GetMaxTokenAge(),
})
}
// updateVNCServerAuth updates VNC fine-grained access control from management. // updateVNCServerAuth updates VNC fine-grained access control from management.
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) { func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
@@ -221,10 +175,28 @@ func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
machineUsers[osUser] = indexes.GetIndexes() machineUsers[osUser] = indexes.GetIndexes()
} }
sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys()))
for _, e := range vncAuth.GetSessionPubKeys() {
pub := e.GetPubKey()
if len(pub) != 32 {
log.Warnf("VNC session pubkey wrong length %d", len(pub))
continue
}
hash := e.GetUserIdHash()
if len(hash) != 16 {
log.Warnf("VNC session user id hash wrong length %d", len(hash))
continue
}
sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{
PubKey: pub,
UserIDHash: sshuserhash.UserIDHash(hash),
})
}
vncSrv.UpdateVNCAuth(&sshauth.Config{ vncSrv.UpdateVNCAuth(&sshauth.Config{
UserIDClaim: vncAuth.GetUserIDClaim(),
AuthorizedUsers: authorizedUsers, AuthorizedUsers: authorizedUsers,
MachineUsers: machineUsers, MachineUsers: machineUsers,
SessionPubKeys: sessionPubKeys,
}) })
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import (
type vncServer interface{} type vncServer interface{}
func (e *Engine) updateVNC(_ *mgmProto.SSHConfig) error { return nil } func (e *Engine) updateVNC() error { return nil }
func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) { func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) {
// no-op on platforms without a VNC server // no-op on platforms without a VNC server
+8 -6
View File
@@ -2107,7 +2107,9 @@ type VNCSessionInfo struct {
RemoteAddress string `protobuf:"bytes,1,opt,name=remoteAddress,proto3" json:"remoteAddress,omitempty"` RemoteAddress string `protobuf:"bytes,1,opt,name=remoteAddress,proto3" json:"remoteAddress,omitempty"`
Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"` Mode string `protobuf:"bytes,2,opt,name=mode,proto3" json:"mode,omitempty"`
Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"`
JwtUsername string `protobuf:"bytes,4,opt,name=jwtUsername,proto3" json:"jwtUsername,omitempty"` // userID is the Noise-verified session identity (hashed user ID from
// the ACL session-key entry), empty when auth is disabled.
UserID string `protobuf:"bytes,4,opt,name=userID,proto3" json:"userID,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -2163,9 +2165,9 @@ func (x *VNCSessionInfo) GetUsername() string {
return "" return ""
} }
func (x *VNCSessionInfo) GetJwtUsername() string { func (x *VNCSessionInfo) GetUserID() string {
if x != nil { if x != nil {
return x.JwtUsername return x.UserID
} }
return "" return ""
} }
@@ -6582,12 +6584,12 @@ const file_daemon_proto_rawDesc = "" +
"\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" + "\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" +
"\x0eSSHServerState\x12\x18\n" + "\x0eSSHServerState\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" +
"\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\x88\x01\n" + "\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"~\n" +
"\x0eVNCSessionInfo\x12$\n" + "\x0eVNCSessionInfo\x12$\n" +
"\rremoteAddress\x18\x01 \x01(\tR\rremoteAddress\x12\x12\n" + "\rremoteAddress\x18\x01 \x01(\tR\rremoteAddress\x12\x12\n" +
"\x04mode\x18\x02 \x01(\tR\x04mode\x12\x1a\n" + "\x04mode\x18\x02 \x01(\tR\x04mode\x12\x1a\n" +
"\busername\x18\x03 \x01(\tR\busername\x12 \n" + "\busername\x18\x03 \x01(\tR\busername\x12\x16\n" +
"\vjwtUsername\x18\x04 \x01(\tR\vjwtUsername\"^\n" + "\x06userID\x18\x04 \x01(\tR\x06userID\"^\n" +
"\x0eVNCServerState\x12\x18\n" + "\x0eVNCServerState\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" +
"\bsessions\x18\x02 \x03(\v2\x16.daemon.VNCSessionInfoR\bsessions\"\xef\x04\n" + "\bsessions\x18\x02 \x03(\v2\x16.daemon.VNCSessionInfoR\bsessions\"\xef\x04\n" +
+3 -1
View File
@@ -403,7 +403,9 @@ message VNCSessionInfo {
string remoteAddress = 1; string remoteAddress = 1;
string mode = 2; string mode = 2;
string username = 3; string username = 3;
string jwtUsername = 4; // userID is the Noise-verified session identity (hashed user ID from
// the ACL session-key entry), empty when auth is disabled.
string userID = 4;
} }
// VNCServerState contains the latest state of the VNC server // VNCServerState contains the latest state of the VNC server
+1 -1
View File
@@ -1199,7 +1199,7 @@ func (s *Server) getVNCServerState() *proto.VNCServerState {
RemoteAddress: sess.RemoteAddress, RemoteAddress: sess.RemoteAddress,
Mode: sess.Mode, Mode: sess.Mode,
Username: sess.Username, Username: sess.Username,
JwtUsername: sess.JWTUsername, UserID: sess.UserID,
}) })
} }
return &proto.VNCServerState{ return &proto.VNCServerState{
+73 -8
View File
@@ -15,13 +15,16 @@ const (
DefaultUserIDClaim = "sub" DefaultUserIDClaim = "sub"
// Wildcard is a special user ID that matches all users // Wildcard is a special user ID that matches all users
Wildcard = "*" Wildcard = "*"
// sessionPubKeyLen is the size of an X25519 static public key in bytes.
sessionPubKeyLen = 32
) )
var ( var (
ErrEmptyUserID = errors.New("JWT user ID is empty") ErrEmptyUserID = errors.New("JWT user ID is empty")
ErrUserNotAuthorized = errors.New("user is not authorized to access this peer") ErrUserNotAuthorized = errors.New("user is not authorized to access this peer")
ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user") ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user")
ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user") ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user")
ErrSessionKeyNotKnown = errors.New("session pubkey not registered")
) )
// Authorizer handles SSH fine-grained access control authorization // Authorizer handles SSH fine-grained access control authorization
@@ -35,6 +38,12 @@ type Authorizer struct {
// machineUsers maps OS login usernames to lists of authorized user indexes // machineUsers maps OS login usernames to lists of authorized user indexes
machineUsers map[string][]uint32 machineUsers map[string][]uint32
// sessionPubKeys maps an X25519 static public key (as map-safe
// array) to the hashed user identity that key authenticates as.
// Populated from management's temporary-access flow; used by VNC to
// authenticate via the Noise_IK handshake.
sessionPubKeys map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash
// mu protects the list of users // mu protects the list of users
mu sync.RWMutex mu sync.RWMutex
} }
@@ -50,13 +59,25 @@ type Config struct {
// MachineUsers maps OS login usernames to indexes in AuthorizedUsers // MachineUsers maps OS login usernames to indexes in AuthorizedUsers
// If a user wants to login as a specific OS user, their index must be in the corresponding list // If a user wants to login as a specific OS user, their index must be in the corresponding list
MachineUsers map[string][]uint32 MachineUsers map[string][]uint32
// SessionPubKeys binds ephemeral X25519 static public keys to hashed
// user identities. Populated for VNC; ignored on the SSH side.
SessionPubKeys []SessionPubKey
}
// SessionPubKey is a single ephemeral-key entry: the 32-byte X25519
// static public key plus the hashed user identity it authenticates as.
type SessionPubKey struct {
PubKey []byte
UserIDHash sshuserhash.UserIDHash
} }
// NewAuthorizer creates a new SSH authorizer with empty configuration // NewAuthorizer creates a new SSH authorizer with empty configuration
func NewAuthorizer() *Authorizer { func NewAuthorizer() *Authorizer {
a := &Authorizer{ a := &Authorizer{
userIDClaim: DefaultUserIDClaim, userIDClaim: DefaultUserIDClaim,
machineUsers: make(map[string][]uint32), machineUsers: make(map[string][]uint32),
sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash),
} }
return a return a
@@ -72,6 +93,7 @@ func (a *Authorizer) Update(config *Config) {
a.userIDClaim = DefaultUserIDClaim a.userIDClaim = DefaultUserIDClaim
a.authorizedUsers = []sshuserhash.UserIDHash{} a.authorizedUsers = []sshuserhash.UserIDHash{}
a.machineUsers = make(map[string][]uint32) a.machineUsers = make(map[string][]uint32)
a.sessionPubKeys = make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash)
log.Info("SSH authorization cleared") log.Info("SSH authorization cleared")
return return
} }
@@ -94,8 +116,19 @@ func (a *Authorizer) Update(config *Config) {
} }
a.machineUsers = machineUsers a.machineUsers = machineUsers
log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings", sessionPubKeys := make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash, len(config.SessionPubKeys))
len(config.AuthorizedUsers), len(machineUsers)) for _, e := range config.SessionPubKeys {
if len(e.PubKey) != sessionPubKeyLen {
continue
}
var key [sessionPubKeyLen]byte
copy(key[:], e.PubKey)
sessionPubKeys[key] = e.UserIDHash
}
a.sessionPubKeys = sessionPubKeys
log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings, %d session pubkeys",
len(config.AuthorizedUsers), len(machineUsers), len(sessionPubKeys))
} }
// Authorize validates if a user is authorized to login as the specified OS user. // Authorize validates if a user is authorized to login as the specified OS user.
@@ -155,6 +188,38 @@ func (a *Authorizer) GetUserIDClaim() string {
return a.userIDClaim return a.userIDClaim
} }
// LookupSessionKey resolves a Noise-verified static public key to the
// hashed user identity registered with it. Fails closed when the key is
// unknown.
func (a *Authorizer) LookupSessionKey(pubKey []byte) (sshuserhash.UserIDHash, error) {
var zero sshuserhash.UserIDHash
if len(pubKey) != sessionPubKeyLen {
return zero, fmt.Errorf("session pubkey wrong length: %d", len(pubKey))
}
var key [sessionPubKeyLen]byte
copy(key[:], pubKey)
a.mu.RLock()
hash, ok := a.sessionPubKeys[key]
a.mu.RUnlock()
if !ok {
return zero, ErrSessionKeyNotKnown
}
return hash, nil
}
// AuthorizeOSUserBySessionKey resolves the OS-user mapping for a session
// key. Mirrors Authorize but skips the JWT-hash step since the key has
// already been verified and the user identity hash is in hand.
func (a *Authorizer) AuthorizeOSUserBySessionKey(userIDHash sshuserhash.UserIDHash, osUsername string) (string, error) {
a.mu.RLock()
defer a.mu.RUnlock()
userIndex, found := a.findUserIndex(userIDHash)
if !found {
return "", fmt.Errorf("session user (hash: %s) not in authorized list for OS user %q: %w", userIDHash, osUsername, ErrUserNotAuthorized)
}
return a.checkMachineUserMapping("session", osUsername, userIndex)
}
// findUserIndex finds the index of a hashed user ID in the authorized users list // findUserIndex finds the index of a hashed user ID in the authorized users list
// Returns the index and true if found, 0 and false if not found // Returns the index and true if found, 0 and false if not found
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) { func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
+59
View File
@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"errors"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -610,3 +611,61 @@ func TestAuthorizer_Wildcard_WithPartialIndexes_AllowsAllUsers(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied") assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied")
} }
func TestAuthorizer_LookupSessionKey_Valid(t *testing.T) {
pub := bytesRepeat(0x11, sessionPubKeyLen)
userHash, err := sshauth.HashUserID("alice")
require.NoError(t, err)
a := NewAuthorizer()
a.Update(&Config{
AuthorizedUsers: []sshauth.UserIDHash{userHash},
MachineUsers: map[string][]uint32{Wildcard: {0}},
SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}},
})
got, err := a.LookupSessionKey(pub)
require.NoError(t, err)
assert.Equal(t, userHash, got)
if _, err := a.AuthorizeOSUserBySessionKey(got, "alice"); err != nil {
t.Fatalf("AuthorizeOSUserBySessionKey: %v", err)
}
}
func TestAuthorizer_LookupSessionKey_UnknownPub(t *testing.T) {
a := NewAuthorizer()
a.Update(&Config{})
_, err := a.LookupSessionKey(bytesRepeat(0x22, sessionPubKeyLen))
require.ErrorIs(t, err, ErrSessionKeyNotKnown)
}
func TestAuthorizer_LookupSessionKey_WrongLength(t *testing.T) {
a := NewAuthorizer()
_, err := a.LookupSessionKey([]byte("short"))
require.Error(t, err)
}
func TestAuthorizer_LookupSessionKey_UpdateClears(t *testing.T) {
pub := bytesRepeat(0x33, sessionPubKeyLen)
userHash, err := sshauth.HashUserID("alice")
require.NoError(t, err)
a := NewAuthorizer()
a.Update(&Config{SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}}})
if _, err := a.LookupSessionKey(pub); err != nil {
t.Fatalf("setup lookup: %v", err)
}
a.Update(&Config{})
if _, err := a.LookupSessionKey(pub); !errors.Is(err, ErrSessionKeyNotKnown) {
t.Fatalf("expected ErrSessionKeyNotKnown, got %v", err)
}
}
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}
+2 -2
View File
@@ -200,8 +200,8 @@ func newLsaString(s string) lsaString {
} }
} }
// generateS4UUserToken creates a Windows token using S4U authentication. // generateS4UUserToken creates a Windows token using S4U authentication
// This is the same approach OpenSSH for Windows uses for public key authentication. // This is the exact approach OpenSSH for Windows uses for public key authentication
func generateS4UUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) { func generateS4UUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) {
userCpn := buildUserCpn(username, domain) userCpn := buildUserCpn(username, domain)
+42 -2
View File
@@ -551,7 +551,27 @@ func (s *Server) checkTokenAge(token *gojwt.Token, jwtConfig *JWTConfig) error {
maxTokenAge = DefaultJWTMaxTokenAge maxTokenAge = DefaultJWTMaxTokenAge
} }
return jwt.CheckTokenAge(token, time.Duration(maxTokenAge)*time.Second) claims, ok := token.Claims.(gojwt.MapClaims)
if !ok {
userID := extractUserID(token)
return fmt.Errorf("token has invalid claims format (user=%s)", userID)
}
iat, ok := claims["iat"].(float64)
if !ok {
userID := extractUserID(token)
return fmt.Errorf("token missing iat claim (user=%s)", userID)
}
issuedAt := time.Unix(int64(iat), 0)
tokenAge := time.Since(issuedAt)
maxAge := time.Duration(maxTokenAge) * time.Second
if tokenAge > maxAge {
userID := getUserIDFromClaims(claims)
return fmt.Errorf("token expired for user=%s: age=%v, max=%v", userID, tokenAge, maxAge)
}
return nil
} }
func (s *Server) extractAndValidateUser(token *gojwt.Token) (*auth.UserAuth, error) { func (s *Server) extractAndValidateUser(token *gojwt.Token) (*auth.UserAuth, error) {
@@ -582,7 +602,27 @@ func (s *Server) hasSSHAccess(userAuth *auth.UserAuth) bool {
} }
func extractUserID(token *gojwt.Token) string { func extractUserID(token *gojwt.Token) string {
return jwt.UserIDFromToken(token) if token == nil {
return "unknown"
}
claims, ok := token.Claims.(gojwt.MapClaims)
if !ok {
return "unknown"
}
return getUserIDFromClaims(claims)
}
func getUserIDFromClaims(claims gojwt.MapClaims) string {
if sub, ok := claims["sub"].(string); ok && sub != "" {
return sub
}
if userID, ok := claims["user_id"].(string); ok && userID != "" {
return userID
}
if email, ok := claims["email"].(string); ok && email != "" {
return email
}
return "unknown"
} }
func (s *Server) parseTokenWithoutValidation(tokenString string) (map[string]interface{}, error) { func (s *Server) parseTokenWithoutValidation(tokenString string) (map[string]interface{}, error) {
+4 -4
View File
@@ -135,7 +135,7 @@ type VNCSessionOutput struct {
RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"` RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"`
Mode string `json:"mode" yaml:"mode"` Mode string `json:"mode" yaml:"mode"`
Username string `json:"username,omitempty" yaml:"username,omitempty"` Username string `json:"username,omitempty" yaml:"username,omitempty"`
JWTUsername string `json:"jwtUsername,omitempty" yaml:"jwtUsername,omitempty"` UserID string `json:"userID,omitempty" yaml:"userID,omitempty"`
} }
type VNCServerStateOutput struct { type VNCServerStateOutput struct {
@@ -296,7 +296,7 @@ func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput {
RemoteAddress: sess.GetRemoteAddress(), RemoteAddress: sess.GetRemoteAddress(),
Mode: sess.GetMode(), Mode: sess.GetMode(),
Username: sess.GetUsername(), Username: sess.GetUsername(),
JWTUsername: sess.GetJwtUsername(), UserID: sess.GetUserID(),
}) })
} }
return VNCServerStateOutput{ return VNCServerStateOutput{
@@ -583,9 +583,9 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
if showSSHSessions && vncSessionCount > 0 { if showSSHSessions && vncSessionCount > 0 {
for _, sess := range o.VNCServerState.Sessions { for _, sess := range o.VNCServerState.Sessions {
var line string var line string
if sess.JWTUsername != "" { if sess.UserID != "" {
line = fmt.Sprintf("[%s@%s -> %s] mode=%s", line = fmt.Sprintf("[%s@%s -> %s] mode=%s",
sess.JWTUsername, sess.RemoteAddress, sess.Username, sess.Mode) sess.UserID, sess.RemoteAddress, sess.Username, sess.Mode)
} else { } else {
line = fmt.Sprintf("[%s] mode=%s user=%s", line = fmt.Sprintf("[%s] mode=%s user=%s",
sess.RemoteAddress, sess.Mode, sess.Username) sess.RemoteAddress, sess.Mode, sess.Username)
+431
View File
@@ -0,0 +1,431 @@
//go:build !js && !ios && !android
package server
import (
"encoding/binary"
"io"
"net"
"net/netip"
"testing"
"time"
"github.com/flynn/noise"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/curve25519"
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
// noiseTestServer starts a VNC server with a freshly generated identity
// key and returns the listener address, the server, and the server's
// static public key for client-side handshake setup.
func noiseTestServer(t *testing.T) (net.Addr, *Server, []byte) {
t.Helper()
kp, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
srv := New(&testCapturer{}, &StubInputInjector{}, kp.Private)
srv.SetDisableAuth(false)
addr := netip.MustParseAddrPort("127.0.0.1:0")
network := netip.MustParsePrefix("127.0.0.0/8")
require.NoError(t, srv.Start(t.Context(), addr, network))
srv.localAddr = netip.MustParseAddr("10.99.99.1")
t.Cleanup(func() { _ = srv.Stop() })
return srv.listener.Addr(), srv, kp.Public
}
// registerSessionKey enrolls a fresh X25519 keypair under the given user
// ID into the server's authorizer with the requested OS-user wildcard
// mapping. Returns the keypair so the test can drive the handshake.
func registerSessionKey(t *testing.T, srv *Server, userID string) noise.DHKey {
t.Helper()
kp, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
userHash, err := sshuserhash.HashUserID(userID)
require.NoError(t, err)
srv.UpdateVNCAuth(&sshauth.Config{
AuthorizedUsers: []sshuserhash.UserIDHash{userHash},
MachineUsers: map[string][]uint32{sshauth.Wildcard: {0}},
SessionPubKeys: []sshauth.SessionPubKey{
{PubKey: kp.Public, UserIDHash: userHash},
},
})
return kp
}
// writeHeaderPrefix writes the mode + zero-length-username prefix that
// precedes the optional Noise handshake in the NetBird VNC header.
func writeHeaderPrefix(t *testing.T, conn net.Conn, mode byte) {
t.Helper()
prefix := []byte{mode, 0, 0}
_, err := conn.Write(prefix)
require.NoError(t, err)
}
// writeHeaderTail writes the sessionID/width/height fields that follow
// either the Noise msg2 (auth path) or the prefix alone (no-auth path).
func writeHeaderTail(t *testing.T, conn net.Conn) {
t.Helper()
tail := make([]byte, 8)
_, err := conn.Write(tail)
require.NoError(t, err)
}
// performInitiator drives the initiator side of Noise_IK against the
// server's identity public key, returns the resulting state. The Noise
// msg2 produced by the server is read and consumed.
func performInitiator(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte) {
t.Helper()
state, err := noise.NewHandshakeState(noise.Config{
CipherSuite: vncNoiseSuite,
Pattern: noise.HandshakeIK,
Initiator: true,
StaticKeypair: clientKey,
PeerStatic: serverPub,
})
require.NoError(t, err)
msg1, _, _, err := state.WriteMessage(nil, nil)
require.NoError(t, err)
require.Equal(t, noiseInitiatorMsgLen, len(msg1))
_, err = conn.Write(append([]byte("NBV3"), msg1...))
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
msg2 := make([]byte, noiseResponderMsgLen)
_, err = io.ReadFull(conn, msg2)
require.NoError(t, err)
_, _, _, err = state.ReadMessage(nil, msg2)
require.NoError(t, err, "server responder message must decrypt with the correct peer static")
}
// readRFBFailure consumes the RFB version exchange and returns the
// security-failure reason string. Fails the test if the server did not
// send a failure (i.e. produced a non-zero security-types list).
func readRFBFailure(t *testing.T, conn net.Conn) string {
t.Helper()
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
var ver [12]byte
_, err := io.ReadFull(conn, ver[:])
require.NoError(t, err)
require.Equal(t, "RFB 003.008\n", string(ver[:]))
_, err = conn.Write(ver[:])
require.NoError(t, err)
var n [1]byte
_, err = io.ReadFull(conn, n[:])
require.NoError(t, err)
require.Equal(t, byte(0), n[0], "expected security-failure (0 types)")
var rl [4]byte
_, err = io.ReadFull(conn, rl[:])
require.NoError(t, err)
reason := make([]byte, binary.BigEndian.Uint32(rl[:]))
_, err = io.ReadFull(conn, reason)
require.NoError(t, err)
return string(reason)
}
// readRFBGreetingNoFailure asserts the server proceeded past auth: it
// must offer at least one security type rather than a 0 failure.
func readRFBGreetingNoFailure(t *testing.T, conn net.Conn) {
t.Helper()
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
var ver [12]byte
_, err := io.ReadFull(conn, ver[:])
require.NoError(t, err)
require.Equal(t, "RFB 003.008\n", string(ver[:]))
_, err = conn.Write(ver[:])
require.NoError(t, err)
var n [1]byte
_, err = io.ReadFull(conn, n[:])
require.NoError(t, err)
require.NotEqual(t, byte(0), n[0], "server must offer security types after a valid handshake")
}
// TestNoise_RegisteredKey_AccessGranted exercises the happy path: a
// session key enrolled in the authorizer completes a Noise_IK handshake
// and the server proceeds to the RFB greeting.
func TestNoise_RegisteredKey_AccessGranted(t *testing.T) {
addr, srv, serverPub := noiseTestServer(t)
clientKey := registerSessionKey(t, srv, "alice@example")
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
writeHeaderPrefix(t, conn, ModeAttach)
performInitiator(t, conn, clientKey, serverPub)
writeHeaderTail(t, conn)
readRFBGreetingNoFailure(t, conn)
}
// TestNoise_UnregisteredClientStatic_Rejected proves the authorizer is
// consulted: a syntactically-valid handshake from a key the server has
// never been told about must be rejected fail-closed.
func TestNoise_UnregisteredClientStatic_Rejected(t *testing.T) {
addr, _, serverPub := noiseTestServer(t)
// Auth is enabled but the authorizer was not updated, so the lookup
// path returns ErrSessionKeyNotKnown.
attackerKey, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
writeHeaderPrefix(t, conn, ModeAttach)
performInitiator(t, conn, attackerKey, serverPub)
writeHeaderTail(t, conn)
reason := readRFBFailure(t, conn)
assert.Contains(t, reason, RejectCodeAuthForbidden)
assert.Contains(t, reason, "session pubkey not registered")
}
// TestNoise_WrongServerStatic_HandshakeFails proves the server's
// identity is bound into the handshake: an initiator using the wrong
// peer static encrypts msg1 under keys the real server can't derive, so
// the server fails the handshake and closes without RFB output.
func TestNoise_WrongServerStatic_HandshakeFails(t *testing.T) {
addr, srv, _ := noiseTestServer(t)
clientKey := registerSessionKey(t, srv, "alice@example")
bogusServerKey, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
writeHeaderPrefix(t, conn, ModeAttach)
state, err := noise.NewHandshakeState(noise.Config{
CipherSuite: vncNoiseSuite,
Pattern: noise.HandshakeIK,
Initiator: true,
StaticKeypair: clientKey,
PeerStatic: bogusServerKey.Public,
})
require.NoError(t, err)
msg1, _, _, err := state.WriteMessage(nil, nil)
require.NoError(t, err)
_, err = conn.Write(append([]byte("NBV3"), msg1...))
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
var b [1]byte
_, err = io.ReadFull(conn, b[:])
require.Error(t, err, "server must close without RFB greeting when msg1 is sealed for a different server identity")
}
// TestNoise_MalformedMsg1_ClosesConnection covers the case where the
// magic prefix is correct but the following 96 bytes are random: the
// noise library fails ReadMessage and the server closes silently.
func TestNoise_MalformedMsg1_ClosesConnection(t *testing.T) {
addr, _, _ := noiseTestServer(t)
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
writeHeaderPrefix(t, conn, ModeAttach)
junk := make([]byte, noiseInitiatorMsgLen)
for i := range junk {
junk[i] = byte(i)
}
_, err = conn.Write(append([]byte("NBV3"), junk...))
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
var b [1]byte
_, err = io.ReadFull(conn, b[:])
require.Error(t, err, "garbage msg1 must terminate the connection before any RFB output")
}
// TestNoise_TruncatedMsg1_ClosesConnection sends fewer than the 96
// bytes a Noise_IK msg1 must contain. The server's io.ReadFull short-
// reads and closes; no RFB greeting must leak.
func TestNoise_TruncatedMsg1_ClosesConnection(t *testing.T) {
addr, _, _ := noiseTestServer(t)
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
writeHeaderPrefix(t, conn, ModeAttach)
_, err = conn.Write([]byte("NBV3"))
require.NoError(t, err)
_, err = conn.Write(make([]byte, 8))
require.NoError(t, err)
require.NoError(t, conn.Close())
// Re-dial just to confirm the listener is alive (the previous
// connection terminated server-side without affecting the listener).
probe, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
require.NoError(t, probe.Close())
}
// TestNoise_AuthEnabled_NoHandshake_Rejected proves that with auth on,
// a connection that skips the Noise prefix (older client / VNC client)
// is rejected with AUTH_FORBIDDEN: identity proof missing.
func TestNoise_AuthEnabled_NoHandshake_Rejected(t *testing.T) {
addr, _, _ := noiseTestServer(t)
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
writeHeaderPrefix(t, conn, ModeAttach)
writeHeaderTail(t, conn)
reason := readRFBFailure(t, conn)
assert.Contains(t, reason, RejectCodeAuthForbidden)
assert.Contains(t, reason, "identity proof missing")
}
// TestNoise_RevokedKey_RejectedAfterAuthUpdate verifies the authorizer
// honors revocations: a key that worked before a UpdateVNCAuth call
// must stop working as soon as the new config omits it.
func TestNoise_RevokedKey_RejectedAfterAuthUpdate(t *testing.T) {
addr, srv, serverPub := noiseTestServer(t)
clientKey := registerSessionKey(t, srv, "alice@example")
// First connection succeeds.
conn1, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn1.Close()
writeHeaderPrefix(t, conn1, ModeAttach)
performInitiator(t, conn1, clientKey, serverPub)
writeHeaderTail(t, conn1)
readRFBGreetingNoFailure(t, conn1)
// Revoke by pushing a fresh config that drops the pubkey entry.
srv.UpdateVNCAuth(&sshauth.Config{})
// Same client, same Noise key, should now be denied.
conn2, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn2.Close()
writeHeaderPrefix(t, conn2, ModeAttach)
performInitiator(t, conn2, clientKey, serverPub)
writeHeaderTail(t, conn2)
reason := readRFBFailure(t, conn2)
assert.Contains(t, reason, RejectCodeAuthForbidden)
assert.Contains(t, reason, "session pubkey not registered")
}
// TestNoise_NoIdentityKey_FailsClosed ensures a server constructed
// without a static private key still rejects authenticated connections
// fail-closed; it must not silently accept the client.
func TestNoise_NoIdentityKey_FailsClosed(t *testing.T) {
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.SetDisableAuth(false)
addr := netip.MustParseAddrPort("127.0.0.1:0")
network := netip.MustParsePrefix("127.0.0.0/8")
require.NoError(t, srv.Start(t.Context(), addr, network))
srv.localAddr = netip.MustParseAddr("10.99.99.1")
t.Cleanup(func() { _ = srv.Stop() })
clientKey, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
fakeServerKey, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
conn, err := net.Dial("tcp", srv.listener.Addr().String())
require.NoError(t, err)
defer conn.Close()
writeHeaderPrefix(t, conn, ModeAttach)
state, err := noise.NewHandshakeState(noise.Config{
CipherSuite: vncNoiseSuite,
Pattern: noise.HandshakeIK,
Initiator: true,
StaticKeypair: clientKey,
PeerStatic: fakeServerKey.Public,
})
require.NoError(t, err)
msg1, _, _, err := state.WriteMessage(nil, nil)
require.NoError(t, err)
_, err = conn.Write(append([]byte("NBV3"), msg1...))
require.NoError(t, err)
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
var b [1]byte
_, err = io.ReadFull(conn, b[:])
require.Error(t, err, "server without identity key must not write the RFB greeting")
}
// TestNoise_DerivedIdentityPublicMatchesPrivate sanity-checks the
// derivation done in New(): the identityPublic must be Curve25519.
// Basepoint multiplied with identityKey.
func TestNoise_DerivedIdentityPublicMatchesPrivate(t *testing.T) {
priv := make([]byte, 32)
for i := range priv {
priv[i] = byte(i + 1)
}
srv := New(&testCapturer{}, &StubInputInjector{}, priv)
expected, err := curve25519.X25519(priv, curve25519.Basepoint)
require.NoError(t, err)
assert.Equal(t, expected, srv.identityPublic)
}
// TestNoise_SessionMode_OSUserCheckRunsAfterHandshake verifies that a
// successful Noise handshake doesn't bypass OS-user authorization: an
// authenticated key whose user index isn't mapped to the requested OS
// user must be rejected.
func TestNoise_SessionMode_OSUserCheckRunsAfterHandshake(t *testing.T) {
addr, srv, serverPub := noiseTestServer(t)
clientKey, err := noise.DH25519.GenerateKeypair(nil)
require.NoError(t, err)
userHash, err := sshuserhash.HashUserID("alice@example")
require.NoError(t, err)
// Map Alice only to "alice" OS user, not the wildcard.
srv.UpdateVNCAuth(&sshauth.Config{
AuthorizedUsers: []sshuserhash.UserIDHash{userHash},
MachineUsers: map[string][]uint32{"alice": {0}},
SessionPubKeys: []sshauth.SessionPubKey{
{PubKey: clientKey.Public, UserIDHash: userHash},
},
})
// Request session for "bob" — Noise succeeds, OS-user check denies.
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
bob := []byte("bob")
prefix := []byte{ModeSession, 0, byte(len(bob))}
prefix = append(prefix, bob...)
_, err = conn.Write(prefix)
require.NoError(t, err)
performInitiator(t, conn, clientKey, serverPub)
writeHeaderTail(t, conn)
reason := readRFBFailure(t, conn)
assert.Contains(t, reason, RejectCodeAuthForbidden)
assert.Contains(t, reason, "authorize OS user")
}
+292 -190
View File
@@ -3,6 +3,8 @@
package server package server
import ( import (
"bufio"
"bytes"
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/binary" "encoding/binary"
@@ -13,16 +15,15 @@ import (
"io" "io"
"net" "net"
"net/netip" "net/netip"
"strings"
"sync" "sync"
"time" "time"
gojwt "github.com/golang-jwt/jwt/v5" "github.com/flynn/noise"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/crypto/curve25519"
"golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/tun/netstack"
sshauth "github.com/netbirdio/netbird/client/ssh/auth" 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. // 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. // stable so clients can branch on them without parsing free text.
// Format: "CODE: human message". // Format: "CODE: human message".
const ( const (
RejectCodeJWTMissing = "AUTH_JWT_MISSING"
RejectCodeJWTExpired = "AUTH_JWT_EXPIRED"
RejectCodeJWTInvalid = "AUTH_JWT_INVALID"
RejectCodeAuthForbidden = "AUTH_FORBIDDEN" RejectCodeAuthForbidden = "AUTH_FORBIDDEN"
RejectCodeAuthConfig = "AUTH_CONFIG"
RejectCodeSessionError = "SESSION_ERROR" RejectCodeSessionError = "SESSION_ERROR"
RejectCodeCapturerError = "CAPTURER_ERROR" RejectCodeCapturerError = "CAPTURER_ERROR"
RejectCodeUnsupportedOS = "UNSUPPORTED" RejectCodeUnsupportedOS = "UNSUPPORTED"
@@ -56,6 +53,21 @@ const EnvVNCDisableDownscale = "NB_VNC_DISABLE_DOWNSCALE"
// enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps. // enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps.
const freshWindow = 16 * time.Millisecond 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. // ScreenCapturer grabs desktop frames for the VNC server.
type ScreenCapturer interface { type ScreenCapturer interface {
// Width returns the current screen width in pixels. // Width returns the current screen width in pixels.
@@ -120,26 +132,22 @@ type InputInjector interface {
TypeText(text string) 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 // connectionHeader is sent by the client before the RFB handshake to specify
// the VNC session mode and authenticate. // the VNC session mode and authenticate.
type connectionHeader struct { type connectionHeader struct {
mode byte mode byte
username string 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 is the Windows session ID; 0 selects the console session.
sessionID uint32 sessionID uint32
// width and height request the virtual display geometry for session mode. // width and height request the virtual display geometry for session mode.
// Zero means use the default. // Zero means use the default.
width uint16 width uint16
height 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. // Server is the embedded VNC server that listens on the WireGuard interface.
@@ -170,13 +178,16 @@ type Server struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
vmgr virtualSessionManager vmgr virtualSessionManager
jwtConfig *JWTConfig authorizer *sshauth.Authorizer
jwtValidator *nbjwt.Validator netstackNet *netstack.Net
jwtExtractor *nbjwt.ClaimsExtractor
authorizer *sshauth.Authorizer
netstackNet *netstack.Net
// agentToken holds the raw token bytes for agent-mode auth. // agentToken holds the raw token bytes for agent-mode auth.
agentToken []byte 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 sessionsMu sync.Mutex
sessionSeq uint64 sessionSeq uint64
@@ -188,6 +199,17 @@ type Server struct {
// closeActiveSessions iterates this set so Stop() can interrupt // closeActiveSessions iterates this set so Stop() can interrupt
// handshaking peers, not just post-handshake sessions. // handshaking peers, not just post-handshake sessions.
acceptedConns map[net.Conn]struct{} 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 // sessionRecorder, when non-nil, receives a SessionTick periodically
// during each VNC session and on session close. The engine wires // during each VNC session and on session close. The engine wires
@@ -195,12 +217,24 @@ type Server struct {
sessionRecorder func(SessionTick) 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. // ActiveSessionInfo describes a currently connected VNC client.
type ActiveSessionInfo struct { type ActiveSessionInfo struct {
RemoteAddress string RemoteAddress string
Mode string Mode string
Username 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. // vncSession provides capturer and injector for a virtual display session.
@@ -220,19 +254,31 @@ type virtualSessionManager interface {
StopAll() StopAll()
} }
// New creates a VNC server with the given screen capturer and input injector. // New creates a VNC server. identityKey is the 32-byte X25519 private
// Authentication uses a JWT supplied by the client in the connection // key used by the daemon in the Noise_IK handshake; nil disables auth.
// header; the protocol-level VNC password scheme is not supported. // The protocol-level VNC password scheme is not supported.
func New(capturer ScreenCapturer, injector InputInjector) *Server { func New(capturer ScreenCapturer, injector InputInjector, identityKey []byte) *Server {
return &Server{ s := &Server{
capturer: capturer, capturer: capturer,
injector: injector, injector: injector,
identityKey: identityKey,
authorizer: sshauth.NewAuthorizer(), authorizer: sshauth.NewAuthorizer(),
log: log.WithField("component", "vnc-server"), log: log.WithField("component", "vnc-server"),
sessions: make(map[uint64]ActiveSessionInfo), sessions: make(map[uint64]ActiveSessionInfo),
sessionConns: make(map[uint64]net.Conn), sessionConns: make(map[uint64]net.Conn),
acceptedConns: make(map[net.Conn]struct{}), 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. // 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) { func (s *Server) untrackConn(c net.Conn) {
s.sessionsMu.Lock() s.sessionsMu.Lock()
delete(s.acceptedConns, c) delete(s.acceptedConns, c)
delete(s.connAuth, c)
s.sessionsMu.Unlock() 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. // SetServiceMode enables proxy-to-agent mode for Windows service operation.
func (s *Server) SetServiceMode(enabled bool) { func (s *Server) SetServiceMode(enabled bool) {
s.serviceMode = enabled s.serviceMode = enabled
@@ -308,16 +420,6 @@ func (s *Server) SetSessionRecorder(recorder func(SessionTick)) {
s.sessionRecorder = recorder 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. // SetDisableAuth disables authentication entirely.
func (s *Server) SetDisableAuth(disable bool) { func (s *Server) SetDisableAuth(disable bool) {
s.disableAuth = disable s.disableAuth = disable
@@ -346,13 +448,14 @@ func (s *Server) SetNetstackNet(n *netstack.Net) {
s.netstackNet = n 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) { 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.authorizer.Update(config)
s.revokeUnauthorizedSessions()
} }
// Start begins listening for VNC connections on the given address. // Start begins listening for VNC connections on the given address.
@@ -463,9 +566,15 @@ func (s *Server) acceptLoop() {
continue 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) enableTCPKeepAlive(conn, s.log)
s.trackConn(conn) s.trackConn(conn)
go func(c net.Conn) { go func(c net.Conn) {
defer s.releaseConnSlot()
defer s.untrackConn(c) defer s.untrackConn(c)
s.handleConnection(c) s.handleConnection(c)
}(conn) }(conn)
@@ -565,16 +674,17 @@ func (s *Server) handleConnection(conn net.Conn) {
if !s.verifyAgentToken(conn, connLog) { if !s.verifyAgentToken(conn, connLog) {
return return
} }
header, err := readConnectionHeader(conn) header, err := s.readConnectionHeader(conn)
if err != nil { if err != nil {
connLog.Warnf("read connection header: %v", err) connLog.Warnf("read connection header: %v", err)
conn.Close() conn.Close()
return return
} }
connLog, jwtUserID, ok := s.authorizeJWT(conn, header, connLog) connLog, sessionUserID, ok := s.authorizeSession(conn, header, connLog)
if !ok { if !ok {
return return
} }
s.registerConnAuth(conn, header)
capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog) capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog)
if !ok { if !ok {
@@ -586,7 +696,7 @@ func (s *Server) handleConnection(conn net.Conn) {
RemoteAddress: conn.RemoteAddr().String(), RemoteAddress: conn.RemoteAddr().String(),
Mode: modeString(header.mode), Mode: modeString(header.mode),
Username: header.username, Username: header.username,
JWTUsername: jwtUserID, UserID: sessionUserID,
}, conn) }, conn)
defer s.removeSession(sessionID) defer s.removeSession(sessionID)
@@ -596,13 +706,20 @@ func (s *Server) handleConnection(conn net.Conn) {
return 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) conn = newMetricsConn(conn, s.sessionRecorder)
sess := &session{ sess := &session{
conn: conn, conn: conn,
capturer: capturer, capturer: capturer,
injector: injector, injector: injector,
serverW: capturer.Width(), serverW: w,
serverH: capturer.Height(), serverH: h,
log: connLog, log: connLog,
} }
sess.serve() sess.serve()
@@ -615,25 +732,6 @@ func codeMessage(code, msg string) string {
return code + ": " + msg 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 // rejectConnection sends a minimal RFB handshake with a security failure
// reason, so VNC clients display the error message instead of a generic // reason, so VNC clients display the error message instead of a generic
// "unexpected disconnect." // "unexpected disconnect."
@@ -658,105 +756,57 @@ func rejectConnection(conn net.Conn, reason string) {
_, _ = conn.Write(buf) _, _ = conn.Write(buf)
} }
const defaultJWTMaxTokenAge = 10 * 60 // 10 minutes // authenticateSession resolves the Noise-verified client static public
// key to a hashed user identity via the authorizer, and checks OS-user
// authenticateJWT validates the JWT from the connection header and checks // mapping for session mode. Returns the hashed user identity on success.
// authorization. For attach mode, just checks membership in the authorized func (s *Server) authenticateSession(header *connectionHeader) (string, error) {
// user list. For session mode, additionally validates the OS user mapping. if !header.identityVerified {
func (s *Server) authenticateJWT(header *connectionHeader) (string, error) { return "", fmt.Errorf("identity proof missing")
if header.jwt == "" { }
return "", fmt.Errorf("JWT required but not provided") if len(header.clientStatic) != 32 {
return "", fmt.Errorf("client static key missing")
} }
s.mu.Lock() userIDHash, err := s.authorizer.LookupSessionKey(header.clientStatic)
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)
if err != nil { 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 { osUser := "*"
return "", err if header.mode == ModeSession {
osUser = header.username
} }
if _, err := s.authorizer.AuthorizeOSUserBySessionKey(userIDHash, osUser); err != nil {
userAuth, err := extractor.ToUserAuth(token) return "", fmt.Errorf("authorize OS user %q: %w", osUser, err)
if err != nil {
return "", fmt.Errorf("extract user from JWT: %w", err)
} }
if userAuth.UserId == "" { return userIDHash.String(), nil
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
} }
// ensureJWTValidator lazily initializes the JWT validator. Must be called with mu held. var vncIdentityMagic = []byte("NBV3")
func (s *Server) ensureJWTValidator() error {
if s.jwtValidator != nil && s.jwtExtractor != nil {
return nil
}
if s.jwtConfig == nil {
return fmt.Errorf("no JWT config")
}
// Enable IdP key refresh so JWKS rotations don't latch the validator // Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads).
// off until daemon restart. // msg1 = e(32) + s_AEAD(32+16) + payload_AEAD(0+16) = 96 bytes
s.jwtValidator = nbjwt.NewValidator( // msg2 = e(32) + payload_AEAD(0+16) = 48 bytes
s.jwtConfig.Issuer, const (
s.jwtConfig.Audiences, noiseInitiatorMsgLen = 96
s.jwtConfig.KeysLocation, noiseResponderMsgLen = 48
true, )
)
var opts []nbjwt.ClaimsExtractorOption // vncNoiseSuite pins the cipher suite for the VNC handshake. Changing
if len(s.jwtConfig.Audiences) > 0 { // it requires bumping vncIdentityMagic so old clients fail closed.
opts = append(opts, nbjwt.WithAudience(s.jwtConfig.Audiences[0])) var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256)
}
if claim := s.authorizer.GetUserIDClaim(); claim != "" {
opts = append(opts, nbjwt.WithUserIDClaim(claim))
}
s.jwtExtractor = nbjwt.NewClaimsExtractor(opts...)
return nil // readConnectionHeader reads the NetBird VNC session header. Format:
}
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]
// //
// [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 // Standard VNC clients don't speak first, so they time out on the first
// connecting. Standard VNC clients don't send anything first (server speaks // read and fall through to attach mode (which auth still rejects when
// first in RFB), so they time out and get the default attach mode. // no Noise handshake completed).
func readConnectionHeader(conn net.Conn) (*connectionHeader, error) { func (s *Server) readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
return nil, fmt.Errorf("set deadline: %w", err) return nil, fmt.Errorf("set deadline: %w", err)
} }
@@ -764,11 +814,9 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
var hdr [3]byte var hdr [3]byte
if _, err := io.ReadFull(conn, hdr[:]); err != nil { if _, err := io.ReadFull(conn, hdr[:]); err != nil {
// Timeout or error: assume no header, use attach mode.
return &connectionHeader{mode: ModeAttach}, nil 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 { if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return nil, fmt.Errorf("set deadline: %w", err) return nil, fmt.Errorf("set deadline: %w", err)
} }
@@ -788,48 +836,93 @@ func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
username = string(buf) username = string(buf)
} }
// Read JWT token length and data. br := bufio.NewReader(conn)
var jwtLenBuf [2]byte clientStatic, identityVerified, err := s.maybeRunNoiseHandshake(conn, br)
var jwtToken string if err != nil {
if _, err := io.ReadFull(conn, jwtLenBuf[:]); err == nil { return nil, err
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)
}
} }
// Read optional Windows session ID (4 bytes BE). Missing = 0 (console/auto).
var sessionID uint32 var sessionID uint32
var sidBuf [4]byte 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[:]) sessionID = binary.BigEndian.Uint32(sidBuf[:])
} }
// Read optional requested viewport size (2x uint16 BE). Missing = 0 (default).
var width, height uint16 var width, height uint16
var geomBuf [4]byte 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]) width = binary.BigEndian.Uint16(geomBuf[0:2])
height = binary.BigEndian.Uint16(geomBuf[2:4]) height = binary.BigEndian.Uint16(geomBuf[2:4])
} }
return &connectionHeader{ return &connectionHeader{
mode: mode, mode: mode,
username: username, username: username,
jwt: jwtToken, clientStatic: clientStatic,
sessionID: sessionID, sessionID: sessionID,
width: width, width: width,
height: height, height: height,
identityVerified: identityVerified,
}, nil }, 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 // verifyAgentToken validates the agent token prefix when configured. Returns
// false when the token is invalid or unreadable; the connection is closed. // false when the token is invalid or unreadable; the connection is closed.
func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool { 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 return true
} }
// authorizeJWT performs JWT validation when auth is enabled. Returns the // authorizeSession runs the Noise_IK handshake when auth is enabled.
// enriched log entry, jwt user ID (empty when auth disabled), and ok=false // Returns the enriched log entry, user identity hash (empty when auth
// if the connection was rejected. // 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) { func (s *Server) authorizeSession(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) {
if s.disableAuth { if s.disableAuth {
return connLog, "", true return connLog, "", true
} }
if s.jwtConfig == nil { userID, err := s.authenticateSession(header)
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)
if err != nil { if err != nil {
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error())) rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
connLog.Warnf("auth rejected: %v", err) connLog.Warnf("auth rejected: %v", err)
return connLog, "", false 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 // acquireSessionResources returns the capturer/injector to use for this
+10 -8
View File
@@ -47,10 +47,16 @@ func (s *Server) serviceAcceptLoop() {
continue 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) enableTCPKeepAlive(conn, s.log)
conn = newMetricsConn(conn, s.sessionRecorder) conn = newMetricsConn(conn, s.sessionRecorder)
s.trackConn(conn) s.trackConn(conn)
go func(c net.Conn) { go func(c net.Conn) {
defer s.releaseConnSlot()
defer s.untrackConn(c) defer s.untrackConn(c)
s.handleServiceConnectionDarwin(c, mgr) s.handleServiceConnectionDarwin(c, mgr)
}(conn) }(conn)
@@ -69,7 +75,7 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa
tee := io.TeeReader(conn, &headerBuf) tee := io.TeeReader(conn, &headerBuf)
teeConn := &darwinPrefixConn{Reader: tee, Conn: conn} teeConn := &darwinPrefixConn{Reader: tee, Conn: conn}
header, err := readConnectionHeader(teeConn) header, err := s.readConnectionHeader(teeConn)
if err != nil { if err != nil {
connLog.Debugf("read connection header: %v", err) connLog.Debugf("read connection header: %v", err)
conn.Close() conn.Close()
@@ -77,17 +83,13 @@ func (s *Server) handleServiceConnectionDarwin(conn net.Conn, mgr *darwinAgentMa
} }
if !s.disableAuth { if !s.disableAuth {
if s.jwtConfig == nil { if _, err := s.authenticateSession(header); err != nil {
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
connLog.Warn("auth rejected: no identity provider configured")
return
}
if _, err := s.authenticateJWT(header); err != nil {
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
connLog.Warnf("auth rejected: %v", err) connLog.Warnf("auth rejected: %v", err)
return return
} }
} }
s.registerConnAuth(conn, header)
token, err := mgr.ensure(s.ctx) token, err := mgr.ensure(s.ctx)
if err != nil { if err != nil {
+18 -114
View File
@@ -9,7 +9,6 @@ import (
"io" "io"
"net" "net"
"net/netip" "net/netip"
"strings"
"testing" "testing"
"time" "time"
@@ -26,14 +25,11 @@ func (t *testCapturer) Capture() (*image.RGBA, error) {
return image.NewRGBA(image.Rect(0, 0, 100, 100)), nil return image.NewRGBA(image.Rect(0, 0, 100, 100)), nil
} }
func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.Addr, *Server) { func startTestServer(t *testing.T, disableAuth bool) (net.Addr, *Server) {
t.Helper() t.Helper()
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.SetDisableAuth(disableAuth) srv.SetDisableAuth(disableAuth)
if jwtConfig != nil {
srv.SetJWTConfig(jwtConfig)
}
addr := netip.MustParseAddrPort("127.0.0.1:0") addr := netip.MustParseAddrPort("127.0.0.1:0")
network := netip.MustParsePrefix("127.0.0.0/8") network := netip.MustParsePrefix("127.0.0.0/8")
@@ -45,30 +41,28 @@ func startTestServer(t *testing.T, disableAuth bool, jwtConfig *JWTConfig) (net.
return srv.listener.Addr(), srv return srv.listener.Addr(), srv
} }
func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) { func TestAuthEnabled_NoSessionAuth_RejectsConnection(t *testing.T) {
addr, _ := startTestServer(t, false, nil) addr, _ := startTestServer(t, false)
conn, err := net.Dial("tcp", addr.String()) conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err) require.NoError(t, err)
defer conn.Close() defer conn.Close()
// Send session header: attach mode, no username, no JWT. // Header with no Noise handshake. Auth-required servers must reject
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 // because no client static was authenticated.
header := make([]byte, 11) // mode + usernameLen + sessionID + w + h
header[0] = ModeAttach header[0] = ModeAttach
_, err = conn.Write(header) _, err = conn.Write(header)
require.NoError(t, err) require.NoError(t, err)
// Server should send RFB version then security failure.
var version [12]byte var version [12]byte
_, err = io.ReadFull(conn, version[:]) _, err = io.ReadFull(conn, version[:])
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "RFB 003.008\n", string(version[:])) assert.Equal(t, "RFB 003.008\n", string(version[:]))
// Write client version to proceed through handshake.
_, err = conn.Write(version[:]) _, err = conn.Write(version[:])
require.NoError(t, err) require.NoError(t, err)
// Read security types: 0 means failure, followed by reason.
var numTypes [1]byte var numTypes [1]byte
_, err = io.ReadFull(conn, numTypes[:]) _, err = io.ReadFull(conn, numTypes[:])
require.NoError(t, err) require.NoError(t, err)
@@ -81,18 +75,17 @@ func TestAuthEnabled_NoJWTConfig_RejectsConnection(t *testing.T) {
reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:])) reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:]))
_, err = io.ReadFull(conn, reason) _, err = io.ReadFull(conn, reason)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, string(reason), "identity provider", "rejection reason should mention missing IdP config") assert.Contains(t, string(reason), "identity proof missing", "rejection reason should mention missing identity proof")
} }
func TestAuthDisabled_AllowsConnection(t *testing.T) { func TestAuthDisabled_AllowsConnection(t *testing.T) {
addr, _ := startTestServer(t, true, nil) addr, _ := startTestServer(t, true)
conn, err := net.Dial("tcp", addr.String()) conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err) require.NoError(t, err)
defer conn.Close() defer conn.Close()
// Send session header: attach mode, no username, no JWT. header := make([]byte, 11) // mode + usernameLen + sessionID + w + h
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0
header[0] = ModeAttach header[0] = ModeAttach
_, err = conn.Write(header) _, err = conn.Write(header)
require.NoError(t, err) require.NoError(t, err)
@@ -114,70 +107,12 @@ func TestAuthDisabled_AllowsConnection(t *testing.T) {
assert.NotEqual(t, byte(0), numTypes[0], "should have at least one security type (auth disabled)") assert.NotEqual(t, byte(0), numTypes[0], "should have at least one security type (auth disabled)")
} }
// TestAuthEnabled_InvalidJWT_RejectedBeforeRFB confirms the VNC server itself
// (not just the JWT library) wires authentication into handleConnection. A
// well-formed JWT-shaped token must hit the server's validation path and be
// rejected with an AUTH_JWT_* reason, never reaching the RFB handshake.
func TestAuthEnabled_InvalidJWT_RejectedBeforeRFB(t *testing.T) {
addr, _ := startTestServer(t, false, &JWTConfig{
Issuer: "https://example.invalid",
KeysLocation: "https://example.invalid/.well-known/jwks.json",
Audiences: []string{"test"},
})
// Three-segment "JWT" with bogus base64. The server's authenticateJWT path
// must catch this regardless of the IdP being unreachable.
bogusJWT := "abc.def.ghi"
header := make([]byte, 3+2+len(bogusJWT)+4+4)
header[0] = ModeAttach
binary.BigEndian.PutUint16(header[1:3], 0) // username len
binary.BigEndian.PutUint16(header[3:5], uint16(len(bogusJWT)))
copy(header[5:5+len(bogusJWT)], bogusJWT)
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
_, err = conn.Write(header)
require.NoError(t, err)
var version [12]byte
_, err = io.ReadFull(conn, version[:])
require.NoError(t, err)
_, err = conn.Write(version[:])
require.NoError(t, err)
var numTypes [1]byte
_, err = io.ReadFull(conn, numTypes[:])
require.NoError(t, err)
require.Equal(t, byte(0), numTypes[0], "must fail security negotiation")
var reasonLen [4]byte
_, err = io.ReadFull(conn, reasonLen[:])
require.NoError(t, err)
reason := make([]byte, binary.BigEndian.Uint32(reasonLen[:]))
_, err = io.ReadFull(conn, reason)
require.NoError(t, err)
// The reason must carry one of the server's AUTH_JWT_* codes, proving
// the rejection came from authenticateJWT in handleConnection.
r := string(reason)
hasJWTReject := false
for _, code := range []string{RejectCodeJWTInvalid, RejectCodeJWTExpired, RejectCodeAuthForbidden} {
if strings.Contains(r, code) {
hasJWTReject = true
break
}
}
assert.True(t, hasJWTReject, "reason %q must include an AUTH_JWT_* code", r)
}
// TestAuth_NoUnauthBytesPastHeader proves the server does not send any RFB // TestAuth_NoUnauthBytesPastHeader proves the server does not send any RFB
// content to a connection that fails source validation. Specifically, the // content to a connection that fails source validation. Specifically, the
// server must close immediately and the client must see EOF before any RFB // server must close immediately and the client must see EOF before any RFB
// version greeting is written. // version greeting is written.
func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { func TestAuth_NoUnauthBytesPastHeader(t *testing.T) {
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.SetDisableAuth(true) srv.SetDisableAuth(true)
addr := netip.MustParseAddrPort("127.0.0.1:0") addr := netip.MustParseAddrPort("127.0.0.1:0")
// Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so // Tight overlay that excludes 127.0.0.0/8 and a non-loopback local IP, so
@@ -198,37 +133,6 @@ func TestAuth_NoUnauthBytesPastHeader(t *testing.T) {
require.Error(t, err, "non-overlay client must see EOF, not an RFB greeting") require.Error(t, err, "non-overlay client must see EOF, not an RFB greeting")
} }
func TestAuthEnabled_EmptyJWT_Rejected(t *testing.T) {
// Auth enabled with a (bogus) JWT config: connections without JWT should be rejected.
addr, _ := startTestServer(t, false, &JWTConfig{
Issuer: "https://example.com",
KeysLocation: "https://example.com/.well-known/jwks.json",
Audiences: []string{"test"},
})
conn, err := net.Dial("tcp", addr.String())
require.NoError(t, err)
defer conn.Close()
// Send session header with empty JWT.
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0
header[0] = ModeAttach
_, err = conn.Write(header)
require.NoError(t, err)
var version [12]byte
_, err = io.ReadFull(conn, version[:])
require.NoError(t, err)
_, err = conn.Write(version[:])
require.NoError(t, err)
var numTypes [1]byte
_, err = io.ReadFull(conn, numTypes[:])
require.NoError(t, err)
assert.Equal(t, byte(0), numTypes[0], "should reject with 0 security types")
}
func TestIsAllowedSource(t *testing.T) { func TestIsAllowedSource(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -289,7 +193,7 @@ func TestIsAllowedSource(t *testing.T) {
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.localAddr = tc.localAddr srv.localAddr = tc.localAddr
srv.network = tc.network srv.network = tc.network
assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote))
@@ -298,7 +202,7 @@ func TestIsAllowedSource(t *testing.T) {
} }
func TestStart_InvalidNetworkRejected(t *testing.T) { func TestStart_InvalidNetworkRejected(t *testing.T) {
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
addr := netip.MustParseAddrPort("127.0.0.1:0") addr := netip.MustParseAddrPort("127.0.0.1:0")
err := srv.Start(t.Context(), addr, netip.Prefix{}) err := srv.Start(t.Context(), addr, netip.Prefix{})
require.Error(t, err, "Start must refuse an invalid overlay prefix") require.Error(t, err, "Start must refuse an invalid overlay prefix")
@@ -306,7 +210,7 @@ func TestStart_InvalidNetworkRejected(t *testing.T) {
} }
func TestAgentToken_MismatchClosesConnection(t *testing.T) { func TestAgentToken_MismatchClosesConnection(t *testing.T) {
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.SetDisableAuth(true) srv.SetDisableAuth(true)
srv.SetAgentToken("deadbeefcafebabe") srv.SetAgentToken("deadbeefcafebabe")
@@ -334,7 +238,7 @@ func TestAgentToken_MismatchClosesConnection(t *testing.T) {
} }
func TestAgentToken_MatchAllowsHandshake(t *testing.T) { func TestAgentToken_MatchAllowsHandshake(t *testing.T) {
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.SetDisableAuth(true) srv.SetDisableAuth(true)
const tokenHex = "deadbeefcafebabe" const tokenHex = "deadbeefcafebabe"
srv.SetAgentToken(tokenHex) srv.SetAgentToken(tokenHex)
@@ -356,7 +260,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Send session header so handleConnection can proceed past readConnectionHeader. // Send session header so handleConnection can proceed past readConnectionHeader.
header := make([]byte, 13) // ModeAttach + usernameLen=0 + jwtLen=0 + sessionID=0 + width=0 + height=0 header := make([]byte, 11) // ModeAttach + usernameLen=0 + sessionID=0 + width=0 + height=0
header[0] = ModeAttach header[0] = ModeAttach
_, err = conn.Write(header) _, err = conn.Write(header)
require.NoError(t, err) require.NoError(t, err)
@@ -371,7 +275,7 @@ func TestAgentToken_MatchAllowsHandshake(t *testing.T) {
func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) { func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) {
// Default platformSessionManager() on non-Linux returns nil, so ModeSession // Default platformSessionManager() on non-Linux returns nil, so ModeSession
// must be rejected with the UNSUPPORTED reason rather than crashing. // must be rejected with the UNSUPPORTED reason rather than crashing.
srv := New(&testCapturer{}, &StubInputInjector{}) srv := New(&testCapturer{}, &StubInputInjector{}, nil)
srv.SetDisableAuth(true) srv.SetDisableAuth(true)
addr := netip.MustParseAddrPort("127.0.0.1:0") addr := netip.MustParseAddrPort("127.0.0.1:0")
@@ -387,7 +291,7 @@ func TestSessionMode_RejectedWhenNoVMGR(t *testing.T) {
defer conn.Close() defer conn.Close()
require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second)))
// ModeSession with no username/JWT, so we exit on the vmgr==nil branch // ModeSession with no username, so we exit on the vmgr==nil branch
// before username validation runs. // before username validation runs.
header := []byte{ModeSession, 0, 0, 0, 0} header := []byte{ModeSession, 0, 0, 0, 0}
_, err = conn.Write(header) _, err = conn.Write(header)
+16 -12
View File
@@ -233,8 +233,9 @@ func (s *Server) platformInit() {
startSASListener(s.ctx) startSASListener(s.ctx)
} }
// serviceAcceptLoop runs in Session 0. It validates source IP and // serviceAcceptLoop runs in Session 0. It validates the source IP and
// authenticates via JWT before proxying connections to the user-session agent. // hands accepted connections to handleServiceConnection, which runs the
// Noise_IK handshake before proxying to the user-session agent.
func (s *Server) serviceAcceptLoop() { func (s *Server) serviceAcceptLoop() {
sm := newSessionManager(agentPort) sm := newSessionManager(agentPort)
@@ -255,18 +256,25 @@ func (s *Server) serviceAcceptLoop() {
continue 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) enableTCPKeepAlive(conn, s.log)
conn = newMetricsConn(conn, s.sessionRecorder) conn = newMetricsConn(conn, s.sessionRecorder)
s.trackConn(conn) s.trackConn(conn)
go func(c net.Conn) { go func(c net.Conn) {
defer s.releaseConnSlot()
defer s.untrackConn(c) defer s.untrackConn(c)
s.handleServiceConnection(c, sm) s.handleServiceConnection(c, sm)
}(conn) }(conn)
} }
} }
// handleServiceConnection validates the source IP and JWT, then proxies // handleServiceConnection runs the connection-header handshake (including
// the connection (with header bytes replayed) to the agent. // Noise_IK), then proxies the connection (with header bytes replayed) to
// the agent listening on loopback.
func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) { func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) {
connLog := s.log.WithField("remote", conn.RemoteAddr().String()) connLog := s.log.WithField("remote", conn.RemoteAddr().String())
@@ -279,7 +287,7 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) {
tee := io.TeeReader(conn, &headerBuf) tee := io.TeeReader(conn, &headerBuf)
teeConn := &prefixConn{Reader: tee, Conn: conn} teeConn := &prefixConn{Reader: tee, Conn: conn}
header, err := readConnectionHeader(teeConn) header, err := s.readConnectionHeader(teeConn)
if err != nil { if err != nil {
connLog.Debugf("read connection header: %v", err) connLog.Debugf("read connection header: %v", err)
conn.Close() conn.Close()
@@ -287,17 +295,13 @@ func (s *Server) handleServiceConnection(conn net.Conn, sm *sessionManager) {
} }
if !s.disableAuth { if !s.disableAuth {
if s.jwtConfig == nil { if _, err := s.authenticateSession(header); err != nil {
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured")) rejectConnection(conn, codeMessage(RejectCodeAuthForbidden, err.Error()))
connLog.Warn("auth rejected: no identity provider configured")
return
}
if _, err := s.authenticateJWT(header); err != nil {
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
connLog.Warnf("auth rejected: %v", err) connLog.Warnf("auth rejected: %v", err)
return return
} }
} }
s.registerConnAuth(conn, header)
// Replay buffered header bytes + remaining stream to the agent. // Replay buffered header bytes + remaining stream to the agent.
replayConn := &prefixConn{ replayConn := &prefixConn{
+3 -3
View File
@@ -222,9 +222,9 @@ func (s *session) handshake() error {
} }
// sendSecurityTypes advertises only secNone. Authentication and access // sendSecurityTypes advertises only secNone. Authentication and access
// control happen in the NetBird connection header (JWT, mode, username) // control happen in the NetBird connection header (Noise_IK handshake,
// that precedes the RFB handshake, not via the protocol-level password // mode, username) that precedes the RFB handshake; the protocol-level
// scheme. // password scheme is not supported.
func (s *session) sendSecurityTypes() error { func (s *session) sendSecurityTypes() error {
_, err := s.conn.Write([]byte{1, secNone}) _, err := s.conn.Write([]byte{1, secNone})
return err return err
+4
View File
@@ -225,6 +225,10 @@ func (s *session) handleResize() error {
if w <= 0 || h <= 0 { if w <= 0 || h <= 0 {
return nil return nil
} }
if w > maxFramebufferDim || h > maxFramebufferDim {
s.log.Warnf("ignoring resize: %dx%d exceeds cap %d", w, h, maxFramebufferDim)
return nil
}
if w == s.serverW && h == s.serverH { if w == s.serverW && h == s.serverH {
return nil return nil
} }
+42 -17
View File
@@ -4,6 +4,7 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"net" "net"
"strconv" "strconv"
@@ -39,6 +40,7 @@ const (
func main() { func main() {
js.Global().Set("NetBirdClient", js.FuncOf(netBirdClientConstructor)) js.Global().Set("NetBirdClient", js.FuncOf(netBirdClientConstructor))
js.Global().Set("netbirdGenerateVNCSessionKey", createGenerateVNCSessionKeyMethod())
select {} select {}
} }
@@ -388,13 +390,31 @@ func createRDPProxyMethod(client *netbird.Client) js.Func {
}) })
} }
// createGenerateVNCSessionKeyMethod returns a JS func that mints a fresh
// X25519 keypair, stashes the private half inside wasm under a random
// session id, and returns { publicKey, sessionId } to JS. The private
// key never leaves the wasm heap.
func createGenerateVNCSessionKeyMethod() js.Func {
return js.FuncOf(func(_ js.Value, _ []js.Value) any {
id, pub, err := vnc.NewSessionKey()
if err != nil {
return js.ValueOf(err.Error())
}
out := js.Global().Get("Object").New()
out.Set("sessionId", id)
out.Set("publicKey", base64.StdEncoding.EncodeToString(pub))
return out
})
}
// createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. // createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging.
// JS signature: createVNCProxy(hostname, port, mode?, username?, jwt?, sessionID?, width?, height?) // JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?)
// mode: "attach" (default) or "session" // mode: "attach" (default) or "session"
// username: required when mode is "session" // username: required when mode is "session"
// jwt: authentication token (from OIDC session) // keySessionID: handle for the wasm-resident session keypair minted by netbirdGenerateVNCSessionKey
// sessionID: Windows session ID (0 = console/auto) // sessionID: Windows session ID (0 = console/auto)
// width/height: requested viewport size for session mode (0 = server default) // width/height: requested viewport size for session mode (0 = server default)
// peerPublicKey: base64 X25519 static pubkey of the destination peer (required for auth)
func createVNCProxyMethod(client *netbird.Client) js.Func { func createVNCProxyMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(_ js.Value, args []js.Value) any { return js.FuncOf(func(_ js.Value, args []js.Value) any {
params, err := parseVNCProxyArgs(args) params, err := parseVNCProxyArgs(args)
@@ -408,14 +428,15 @@ func createVNCProxyMethod(client *netbird.Client) js.Func {
} }
proxy := vnc.NewVNCProxy(client) proxy := vnc.NewVNCProxy(client)
return proxy.CreateProxy(vnc.ProxyRequest{ return proxy.CreateProxy(vnc.ProxyRequest{
Hostname: params.hostname, Hostname: params.hostname,
Port: params.port, Port: params.port,
Mode: params.mode, Mode: params.mode,
Username: params.username, Username: params.username,
JWT: params.jwt, SessionID: params.sessionID,
SessionID: params.sessionID, Width: params.width,
Width: params.width, Height: params.height,
Height: params.height, PeerPublicKey: params.peerPublicKey,
KeySessionID: params.keySessionID,
}) })
}) })
} }
@@ -425,11 +446,12 @@ type vncProxyParams struct {
port string port string
mode string mode string
username string username string
jwt string keySessionID string
sessionID uint32 sessionID uint32
width uint16 width uint16
height uint16 height uint16
rejectViaPromise bool // true when the JS caller expects a rejected Promise instead of a plain string return peerPublicKey string
rejectViaPromise bool
} }
// parseVNCProxyArgs validates JS args for createVNCProxyMethod and returns // parseVNCProxyArgs validates JS args for createVNCProxyMethod and returns
@@ -480,7 +502,7 @@ func parseVNCProxyOptionalStrings(args []js.Value, p *vncProxyParams) error {
p.username = args[3].String() p.username = args[3].String()
} }
if len(args) > 4 && args[4].Type() == js.TypeString { if len(args) > 4 && args[4].Type() == js.TypeString {
p.jwt = args[4].String() p.keySessionID = args[4].String()
} }
return nil return nil
} }
@@ -512,6 +534,9 @@ func parseVNCProxyOptionalNumbers(args []js.Value, p *vncProxyParams) error {
} }
p.height = uint16(v) p.height = uint16(v)
} }
if len(args) > 8 && args[8].Type() == js.TypeString {
p.peerPublicKey = args[8].String()
}
return nil return nil
} }
+200 -48
View File
@@ -4,6 +4,8 @@ package vnc
import ( import (
"context" "context"
crand "crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -13,9 +15,65 @@ import (
"syscall/js" "syscall/js"
"time" "time"
"github.com/flynn/noise"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var cryptoRandRead = crand.Read
// vncIdentityMagic mirrors the server side in client/vnc/server/server.go.
var vncIdentityMagic = []byte("NBV3")
// Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads).
const (
noiseInitiatorMsgLen = 96
noiseResponderMsgLen = 48
)
var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256)
// sessionKeyStore retains per-session X25519 keypairs so the JS layer
// only sees an opaque session id + the public key; the private key never
// leaves wasm.
var sessionKeyStore = struct {
mu sync.Mutex
keys map[string]noise.DHKey
}{keys: map[string]noise.DHKey{}}
// NewSessionKey mints an X25519 keypair, stores the private half under a
// fresh random session id, and returns (id, pubkey).
func NewSessionKey() (string, []byte, error) {
kp, err := noise.DH25519.GenerateKeypair(nil)
if err != nil {
return "", nil, fmt.Errorf("generate keypair: %w", err)
}
idBytes := make([]byte, 16)
if _, err := cryptoRandRead(idBytes); err != nil {
return "", nil, fmt.Errorf("session id randomness: %w", err)
}
id := base64.RawURLEncoding.EncodeToString(idBytes)
sessionKeyStore.mu.Lock()
sessionKeyStore.keys[id] = kp
sessionKeyStore.mu.Unlock()
return id, kp.Public, nil
}
// lookupSessionKey returns the keypair for id, or false if unknown.
func lookupSessionKey(id string) (noise.DHKey, bool) {
sessionKeyStore.mu.Lock()
defer sessionKeyStore.mu.Unlock()
kp, ok := sessionKeyStore.keys[id]
return kp, ok
}
// dropSessionKey removes the keypair for id. Called after the VNC
// connection closes (or after a connect attempt fails terminally).
func dropSessionKey(id string) {
sessionKeyStore.mu.Lock()
delete(sessionKeyStore.keys, id)
sessionKeyStore.mu.Unlock()
}
const ( const (
vncProxyHost = "vnc.proxy.local" vncProxyHost = "vnc.proxy.local"
vncProxyScheme = "ws" vncProxyScheme = "ws"
@@ -37,10 +95,12 @@ const (
// VNCProxy bridges WebSocket connections from noVNC in the browser // VNCProxy bridges WebSocket connections from noVNC in the browser
// to TCP VNC server connections through the NetBird tunnel. // to TCP VNC server connections through the NetBird tunnel.
type vncNBClient interface {
Dial(ctx context.Context, network, address string) (net.Conn, error)
}
type VNCProxy struct { type VNCProxy struct {
nbClient interface { nbClient vncNBClient
Dial(ctx context.Context, network, address string) (net.Conn, error)
}
activeConnections map[string]*vncConnection activeConnections map[string]*vncConnection
destinations map[string]vncDestination destinations map[string]vncDestination
// pendingHandlers holds the js.Func for handleVNCWebSocket_<id> between // pendingHandlers holds the js.Func for handleVNCWebSocket_<id> between
@@ -52,13 +112,15 @@ type VNCProxy struct {
} }
type vncDestination struct { type vncDestination struct {
address string address string
mode byte mode byte
username string username string
jwt string sessionPriv []byte
sessionID uint32 // Windows session ID (0 = auto/console) sessionPub []byte
width uint16 // Requested viewport width for session mode (0 = default) sessionID uint32
height uint16 // Requested viewport height for session mode (0 = default) width uint16
height uint16
peerPubKey []byte
} }
type vncConnection struct { type vncConnection struct {
@@ -78,9 +140,7 @@ type vncConnection struct {
} }
// NewVNCProxy creates a new VNC proxy. // NewVNCProxy creates a new VNC proxy.
func NewVNCProxy(client interface { func NewVNCProxy(client vncNBClient) *VNCProxy {
Dial(ctx context.Context, network, address string) (net.Conn, error)
}) *VNCProxy {
return &VNCProxy{ return &VNCProxy{
nbClient: client, nbClient: client,
activeConnections: make(map[string]*vncConnection), activeConnections: make(map[string]*vncConnection),
@@ -94,10 +154,16 @@ type ProxyRequest struct {
Port string Port string
Mode string Mode string
Username string Username string
JWT string
SessionID uint32 SessionID uint32
Width uint16 Width uint16
Height uint16 Height uint16
// PeerPublicKey is the destination peer's base64 X25519 public key,
// used as the responder static in the Noise_IK handshake.
PeerPublicKey string
// KeySessionID is the handle returned by generateVNCSessionKey. The
// matching private key is looked up inside wasm and never crosses
// the JS boundary.
KeySessionID string
} }
// CreateProxy creates a new proxy endpoint for the given VNC destination. // CreateProxy creates a new proxy endpoint for the given VNC destination.
@@ -106,7 +172,7 @@ type ProxyRequest struct {
// virtual display geometry for session mode; 0 means use the server default. // virtual display geometry for session mode; 0 means use the server default.
// Returns a JS Promise that resolves to the WebSocket proxy URL. // Returns a JS Promise that resolves to the WebSocket proxy URL.
func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value {
hostname, port, mode, username, jwt := req.Hostname, req.Port, req.Mode, req.Username, req.JWT hostname, port, mode, username := req.Hostname, req.Port, req.Mode, req.Username
sessionID, width, height := req.SessionID, req.Width, req.Height sessionID, width, height := req.SessionID, req.Width, req.Height
address := net.JoinHostPort(hostname, port) address := net.JoinHostPort(hostname, port)
@@ -119,14 +185,51 @@ func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value {
address: address, address: address,
mode: m, mode: m,
username: username, username: username,
jwt: jwt,
sessionID: sessionID, sessionID: sessionID,
width: width, width: width,
height: height, height: height,
} }
if req.KeySessionID != "" {
kp, ok := lookupSessionKey(req.KeySessionID)
if !ok {
return rejectedPromise("unknown VNC session id")
}
// A session handle is single-use; drop it before the destination
// holds the private bytes so a leaked handle can't be replayed.
dropSessionKey(req.KeySessionID)
dest.sessionPriv = kp.Private
dest.sessionPub = kp.Public
pub, err := decodePeerPubKey(req.PeerPublicKey)
if err != nil {
return rejectedPromise(fmt.Sprintf("invalid peer public key: %v", err))
}
dest.peerPubKey = pub
}
return p.newProxyPromise(address, mode, username, dest) return p.newProxyPromise(address, mode, username, dest)
} }
// decodePeerPubKey parses a base64-encoded 32-byte X25519 public key.
func decodePeerPubKey(b64 string) ([]byte, error) {
if b64 == "" {
return nil, errors.New("peer public key missing")
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
if len(raw) != 32 {
return nil, fmt.Errorf("expected 32 bytes, got %d", len(raw))
}
return raw, nil
}
// rejectedPromise returns a resolved Promise carrying msg as an error
// string, mirroring how CreateProxy reports earlier validation failures.
func rejectedPromise(msg string) js.Value {
promise := js.Global().Get("Promise")
return promise.Call("resolve", js.ValueOf(msg))
}
// newProxyPromise wraps the JS Promise creation + executor lifecycle so // newProxyPromise wraps the JS Promise creation + executor lifecycle so
// CreateProxy stays a thin parameter-bundling entrypoint. // CreateProxy stays a thin parameter-bundling entrypoint.
func (p *VNCProxy) newProxyPromise(address, mode, username string, dest vncDestination) js.Value { func (p *VNCProxy) newProxyPromise(address, mode, username string, dest vncDestination) js.Value {
@@ -288,46 +391,95 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) {
p.cleanupConnection(conn) p.cleanupConnection(conn)
} }
// sendSessionHeader writes mode, username, JWT, Windows session ID, and the // sendSessionHeader writes the NetBird VNC connection header: mode +
// requested viewport size to the VNC server. // username prefix, an optional Noise_IK handshake that authenticates the
// Format: [mode:1] [username_len:2] [username:N] [jwt_len:2] [jwt:N] // client and the server, then the trailing sessionID / width / height
// // fields the daemon needs once auth is settled.
// [session_id:4] [width:2] [height:2]
func (p *VNCProxy) sendSessionHeader(conn net.Conn, dest vncDestination) error { func (p *VNCProxy) sendSessionHeader(conn net.Conn, dest vncDestination) error {
usernameBytes := []byte(dest.username) usernameBytes := []byte(dest.username)
jwtBytes := []byte(dest.jwt)
if len(usernameBytes) > 0xFFFF { if len(usernameBytes) > 0xFFFF {
return fmt.Errorf("username too long: %d bytes (max %d)", len(usernameBytes), 0xFFFF) return fmt.Errorf("username too long: %d bytes (max %d)", len(usernameBytes), 0xFFFF)
} }
if len(jwtBytes) > 0xFFFF { prefix := make([]byte, 3+len(usernameBytes))
return fmt.Errorf("jwt too long: %d bytes (max %d)", len(jwtBytes), 0xFFFF) prefix[0] = dest.mode
prefix[1] = byte(len(usernameBytes) >> 8)
prefix[2] = byte(len(usernameBytes))
copy(prefix[3:], usernameBytes)
if err := writeAll(conn, prefix); err != nil {
return fmt.Errorf("write header prefix: %w", err)
} }
hdr := make([]byte, 3+len(usernameBytes)+2+len(jwtBytes)+4+4)
hdr[0] = dest.mode
hdr[1] = byte(len(usernameBytes) >> 8)
hdr[2] = byte(len(usernameBytes))
off := 3
copy(hdr[off:], usernameBytes)
off += len(usernameBytes)
hdr[off] = byte(len(jwtBytes) >> 8)
hdr[off+1] = byte(len(jwtBytes))
off += 2
copy(hdr[off:], jwtBytes)
off += len(jwtBytes)
hdr[off] = byte(dest.sessionID >> 24)
hdr[off+1] = byte(dest.sessionID >> 16)
hdr[off+2] = byte(dest.sessionID >> 8)
hdr[off+3] = byte(dest.sessionID)
off += 4
hdr[off] = byte(dest.width >> 8)
hdr[off+1] = byte(dest.width)
hdr[off+2] = byte(dest.height >> 8)
hdr[off+3] = byte(dest.height)
for off := 0; off < len(hdr); { if dest.sessionPriv == nil {
n, err := conn.Write(hdr[off:]) return p.writeHeaderTail(conn, dest)
}
if err := p.runNoiseHandshake(conn, dest); err != nil {
return fmt.Errorf("noise handshake: %w", err)
}
return p.writeHeaderTail(conn, dest)
}
// writeHeaderTail writes the post-auth trailing fields (sessionID,
// width, height) the daemon reads regardless of whether the Noise
// handshake was performed.
func (p *VNCProxy) writeHeaderTail(conn net.Conn, dest vncDestination) error {
tail := make([]byte, 4+4)
tail[0] = byte(dest.sessionID >> 24)
tail[1] = byte(dest.sessionID >> 16)
tail[2] = byte(dest.sessionID >> 8)
tail[3] = byte(dest.sessionID)
tail[4] = byte(dest.width >> 8)
tail[5] = byte(dest.width)
tail[6] = byte(dest.height >> 8)
tail[7] = byte(dest.height)
if err := writeAll(conn, tail); err != nil {
return fmt.Errorf("write header tail: %w", err)
}
return nil
}
// runNoiseHandshake performs the initiator side of a Noise_IK handshake
// against the destination daemon. The session keypair authenticates the
// client; the daemon's pre-known peer pubkey authenticates the server.
func (p *VNCProxy) runNoiseHandshake(conn net.Conn, dest vncDestination) error {
state, err := noise.NewHandshakeState(noise.Config{
CipherSuite: vncNoiseSuite,
Pattern: noise.HandshakeIK,
Initiator: true,
StaticKeypair: noise.DHKey{Private: dest.sessionPriv, Public: dest.sessionPub},
PeerStatic: dest.peerPubKey,
})
if err != nil {
return fmt.Errorf("noise initiator init: %w", err)
}
msg1, _, _, err := state.WriteMessage(nil, nil)
if err != nil {
return fmt.Errorf("noise write msg1: %w", err)
}
out := make([]byte, 0, len(vncIdentityMagic)+len(msg1))
out = append(out, vncIdentityMagic...)
out = append(out, msg1...)
if err := writeAll(conn, out); err != nil {
return fmt.Errorf("send noise msg1: %w", err)
}
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
return fmt.Errorf("set noise deadline: %w", err)
}
defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck
msg2 := make([]byte, noiseResponderMsgLen)
if _, err := io.ReadFull(conn, msg2); err != nil {
return fmt.Errorf("read noise msg2: %w", err)
}
if _, _, _, err := state.ReadMessage(nil, msg2); err != nil {
return fmt.Errorf("noise read msg2: %w", err)
}
return nil
}
func writeAll(conn net.Conn, buf []byte) error {
for off := 0; off < len(buf); {
n, err := conn.Write(buf[off:])
if err != nil { if err != nil {
return fmt.Errorf("write session header: %w", err) return err
} }
off += n off += n
} }
+1
View File
@@ -185,6 +185,7 @@ require (
github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fredbi/uri v1.1.1 // indirect github.com/fredbi/uri v1.1.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect
+4
View File
@@ -162,6 +162,8 @@ github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg=
github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -412,6 +414,7 @@ github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoK
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -756,6 +759,7 @@ goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
+37 -1
View File
@@ -2,6 +2,7 @@ package grpc
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"net/netip" "net/netip"
"net/url" "net/url"
@@ -184,12 +185,47 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
if networkMap.VNCAuthorizedUsers != nil { if networkMap.VNCAuthorizedUsers != nil {
hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.VNCAuthorizedUsers) hashedUsers, machineUsers := buildAuthorizedUsersProto(ctx, networkMap.VNCAuthorizedUsers)
response.NetworkMap.VncAuth = &proto.VNCAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} response.NetworkMap.VncAuth = &proto.VNCAuth{
AuthorizedUsers: hashedUsers,
MachineUsers: machineUsers,
SessionPubKeys: buildSessionPubKeysProto(ctx, networkMap.VNCSessionPubKeys),
}
} }
return response return response
} }
// buildSessionPubKeysProto decodes base64 X25519 session pubkeys and
// hashes the user IDs they belong to, emitting the proto entries the
// daemon's authorizer indexes by pubkey.
func buildSessionPubKeysProto(ctx context.Context, in []types.VNCSessionPubKey) []*proto.SessionPubKey {
if len(in) == 0 {
return nil
}
out := make([]*proto.SessionPubKey, 0, len(in))
for _, e := range in {
pub, err := base64.StdEncoding.DecodeString(e.PubKey)
if err != nil {
log.WithContext(ctx).Warnf("decode VNC session pubkey: %v", err)
continue
}
if len(pub) != 32 {
log.WithContext(ctx).Warnf("VNC session pubkey wrong length: %d", len(pub))
continue
}
hash, err := sshauth.HashUserID(e.UserID)
if err != nil {
log.WithContext(ctx).Warnf("hash VNC session user id: %v", err)
continue
}
out = append(out, &proto.SessionPubKey{
PubKey: pub,
UserIdHash: hash[:],
})
}
return out
}
func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) { func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]map[string]struct{}) ([][]byte, map[string]*proto.MachineUserIndexes) {
userIDToIndex := make(map[string]uint32) userIDToIndex := make(map[string]uint32)
var hashedUsers [][]byte var hashedUsers [][]byte
@@ -517,6 +517,9 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
if protocol == types.PolicyRuleProtocolNetbirdSSH || protocol == types.PolicyRuleProtocolNetbirdVNC { if protocol == types.PolicyRuleProtocolNetbirdSSH || protocol == types.PolicyRuleProtocolNetbirdVNC {
policy.Rules[0].AuthorizedUser = userAuth.UserId policy.Rules[0].AuthorizedUser = userAuth.UserId
} }
if protocol == types.PolicyRuleProtocolNetbirdVNC && req.SessionPubKey != nil {
policy.Rules[0].SessionPubKey = *req.SessionPubKey
}
_, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true) _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true)
if err != nil { if err != nil {
@@ -526,9 +529,10 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
} }
resp := &api.PeerTemporaryAccessResponse{ resp := &api.PeerTemporaryAccessResponse{
Id: peer.ID, Id: peer.ID,
Name: peer.Name, Name: peer.Name,
Rules: req.Rules, Rules: req.Rules,
TargetPubKey: targetPeer.Key,
} }
util.WriteJSONObject(r.Context(), w, resp) util.WriteJSONObject(r.Context(), w, resp)
+16 -16
View File
@@ -246,14 +246,14 @@ func TestAccount_getPeersByPolicy(t *testing.T) {
t.Run("check that all peers get map", func(t *testing.T) { t.Run("check that all peers get map", func(t *testing.T) {
for _, p := range account.Peers { for _, p := range account.Peers {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers())
assert.GreaterOrEqual(t, len(peers), 1, "minimum number peers should present") assert.GreaterOrEqual(t, len(peers), 1, "minimum number peers should present")
assert.GreaterOrEqual(t, len(firewallRules), 1, "minimum number of firewall rules should present") assert.GreaterOrEqual(t, len(firewallRules), 1, "minimum number of firewall rules should present")
} }
}) })
t.Run("check first peer map details", func(t *testing.T) { t.Run("check first peer map details", func(t *testing.T) {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 8) assert.Len(t, peers, 8)
assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerA"])
assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerC"])
@@ -509,7 +509,7 @@ func TestAccount_getPeersByPolicy(t *testing.T) {
}) })
t.Run("check port ranges support for older peers", func(t *testing.T) { t.Run("check port ranges support for older peers", func(t *testing.T) {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 1) assert.Len(t, peers, 1)
assert.Contains(t, peers, account.Peers["peerI"]) assert.Contains(t, peers, account.Peers["peerI"])
@@ -635,7 +635,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
} }
t.Run("check first peer map", func(t *testing.T) { t.Run("check first peer map", func(t *testing.T) {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerC"])
expectedFirewallRules := []*types.FirewallRule{ expectedFirewallRules := []*types.FirewallRule{
@@ -665,7 +665,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
}) })
t.Run("check second peer map", func(t *testing.T) { t.Run("check second peer map", func(t *testing.T) {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerB"]) assert.Contains(t, peers, account.Peers["peerB"])
expectedFirewallRules := []*types.FirewallRule{ expectedFirewallRules := []*types.FirewallRule{
@@ -697,7 +697,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
account.Policies[1].Rules[0].Bidirectional = false account.Policies[1].Rules[0].Bidirectional = false
t.Run("check first peer map directional only", func(t *testing.T) { t.Run("check first peer map directional only", func(t *testing.T) {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerC"])
expectedFirewallRules := []*types.FirewallRule{ expectedFirewallRules := []*types.FirewallRule{
@@ -719,7 +719,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) {
}) })
t.Run("check second peer map directional only", func(t *testing.T) { t.Run("check second peer map directional only", func(t *testing.T) {
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Contains(t, peers, account.Peers["peerB"]) assert.Contains(t, peers, account.Peers["peerB"])
expectedFirewallRules := []*types.FirewallRule{ expectedFirewallRules := []*types.FirewallRule{
@@ -917,7 +917,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
t.Run("verify peer's network map with default group peer list", func(t *testing.T) { t.Run("verify peer's network map with default group peer list", func(t *testing.T) {
// peerB doesn't fulfill the NB posture check but is included in the destination group Swarm, // peerB doesn't fulfill the NB posture check but is included in the destination group Swarm,
// will establish a connection with all source peers satisfying the NB posture check. // will establish a connection with all source peers satisfying the NB posture check.
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 4) assert.Len(t, peers, 4)
assert.Len(t, firewallRules, 4) assert.Len(t, firewallRules, 4)
assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerA"])
@@ -927,7 +927,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerC satisfy the NB posture check, should establish connection to all destination group peer's // peerC satisfy the NB posture check, should establish connection to all destination group peer's
// We expect a single permissive firewall rule which all outgoing connections // We expect a single permissive firewall rule which all outgoing connections
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers))
assert.Len(t, firewallRules, 7) assert.Len(t, firewallRules, 7)
expectedFirewallRules := []*types.FirewallRule{ expectedFirewallRules := []*types.FirewallRule{
@@ -992,7 +992,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm,
// all source group peers satisfying the NB posture check should establish connection // all source group peers satisfying the NB posture check should establish connection
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 4) assert.Len(t, peers, 4)
assert.Len(t, firewallRules, 4) assert.Len(t, firewallRules, 4)
assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerA"])
@@ -1002,7 +1002,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerI doesn't fulfill the OS version posture check and exists in only destination group Swarm, // peerI doesn't fulfill the OS version posture check and exists in only destination group Swarm,
// all source group peers satisfying the NB posture check should establish connection // all source group peers satisfying the NB posture check should establish connection
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 4) assert.Len(t, peers, 4)
assert.Len(t, firewallRules, 4) assert.Len(t, firewallRules, 4)
assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerA"])
@@ -1017,19 +1017,19 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerB doesn't satisfy the NB posture check, and doesn't exist in destination group peer's // peerB doesn't satisfy the NB posture check, and doesn't exist in destination group peer's
// no connection should be established to any peer of destination group // no connection should be established to any peer of destination group
peers, firewallRules, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 0) assert.Len(t, peers, 0)
assert.Len(t, firewallRules, 0) assert.Len(t, firewallRules, 0)
// peerI doesn't satisfy the OS version posture check, and doesn't exist in destination group peer's // peerI doesn't satisfy the OS version posture check, and doesn't exist in destination group peer's
// no connection should be established to any peer of destination group // no connection should be established to any peer of destination group
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 0) assert.Len(t, peers, 0)
assert.Len(t, firewallRules, 0) assert.Len(t, firewallRules, 0)
// peerC satisfy the NB posture check, should establish connection to all destination group peer's // peerC satisfy the NB posture check, should establish connection to all destination group peer's
// We expect a single permissive firewall rule which all outgoing connections // We expect a single permissive firewall rule which all outgoing connections
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers))
assert.Len(t, firewallRules, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, len(account.Groups["GroupSwarm"].Peers))
@@ -1044,14 +1044,14 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) {
// peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm,
// all source group peers satisfying the NB posture check should establish connection // all source group peers satisfying the NB posture check should establish connection
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 3) assert.Len(t, peers, 3)
assert.Len(t, firewallRules, 3) assert.Len(t, firewallRules, 3)
assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerA"])
assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerC"])
assert.Contains(t, peers, account.Peers["peerD"]) assert.Contains(t, peers, account.Peers["peerD"])
peers, firewallRules, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers()) peers, firewallRules, _, _, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers())
assert.Len(t, peers, 5) assert.Len(t, peers, 5)
// assert peers from Group Swarm // assert peers from Group Swarm
assert.Contains(t, peers, account.Peers["peerD"]) assert.Contains(t, peers, account.Peers["peerD"])
+2 -2
View File
@@ -849,7 +849,7 @@ func (a *Account) UserGroupsRemoveFromPeers(userID string, groups ...string) map
// GetPeerConnectionResources for a given peer // GetPeerConnectionResources for a given peer
// //
// This function returns the list of peers and firewall rules that are applicable to a given peer. // This function returns the list of peers and firewall rules that are applicable to a given peer.
func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, map[string]map[string]struct{}, bool) { func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, map[string]map[string]struct{}, []VNCSessionPubKey, bool) {
generateResources, getAccumulatedResources := a.connResourcesGenerator(ctx, peer) generateResources, getAccumulatedResources := a.connResourcesGenerator(ctx, peer)
ctxState := &peerConnResolveState{ ctxState := &peerConnResolveState{
authorizedUsers: make(map[string]map[string]struct{}), authorizedUsers: make(map[string]map[string]struct{}),
@@ -869,7 +869,7 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
} }
peers, fwRules := getAccumulatedResources() peers, fwRules := getAccumulatedResources()
return peers, fwRules, ctxState.authorizedUsers, ctxState.vncAuthorizedUsers, ctxState.sshEnabled return peers, fwRules, ctxState.authorizedUsers, ctxState.vncAuthorizedUsers, ctxState.vncSessionPubKeys, ctxState.sshEnabled
} }
func (a *Account) applyPolicyRule( func (a *Account) applyPolicyRule(
+1
View File
@@ -49,6 +49,7 @@ type NetworkMap struct {
ForwardingRules []*ForwardingRule ForwardingRules []*ForwardingRule
AuthorizedUsers map[string]map[string]struct{} AuthorizedUsers map[string]map[string]struct{}
VNCAuthorizedUsers map[string]map[string]struct{} VNCAuthorizedUsers map[string]map[string]struct{}
VNCSessionPubKeys []VNCSessionPubKey
EnableSSH bool EnableSSH bool
} }
@@ -167,6 +167,7 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap {
RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...), RoutesFirewallRules: append(networkResourcesFirewallRules, routesFirewallRules...),
AuthorizedUsers: connRes.authorizedUsers, AuthorizedUsers: connRes.authorizedUsers,
VNCAuthorizedUsers: connRes.vncAuthorizedUsers, VNCAuthorizedUsers: connRes.vncAuthorizedUsers,
VNCSessionPubKeys: connRes.vncSessionPubKeys,
EnableSSH: connRes.sshEnabled, EnableSSH: connRes.sshEnabled,
} }
} }
@@ -177,6 +178,7 @@ type peerConnectionResult struct {
firewallRules []*FirewallRule firewallRules []*FirewallRule
authorizedUsers map[string]map[string]struct{} authorizedUsers map[string]map[string]struct{}
vncAuthorizedUsers map[string]map[string]struct{} vncAuthorizedUsers map[string]map[string]struct{}
vncSessionPubKeys []VNCSessionPubKey
sshEnabled bool sshEnabled bool
} }
@@ -210,6 +212,7 @@ func (c *NetworkMapComponents) getPeerConnectionResources(targetPeerID string) p
firewallRules: fwRules, firewallRules: fwRules,
authorizedUsers: state.authorizedUsers, authorizedUsers: state.authorizedUsers,
vncAuthorizedUsers: state.vncAuthorizedUsers, vncAuthorizedUsers: state.vncAuthorizedUsers,
vncSessionPubKeys: state.vncSessionPubKeys,
sshEnabled: state.sshEnabled, sshEnabled: state.sshEnabled,
} }
} }
@@ -15,9 +15,21 @@ import (
type peerConnResolveState struct { type peerConnResolveState struct {
authorizedUsers map[string]map[string]struct{} authorizedUsers map[string]map[string]struct{}
vncAuthorizedUsers map[string]map[string]struct{} vncAuthorizedUsers map[string]map[string]struct{}
vncSessionPubKeys []VNCSessionPubKey
sshEnabled bool sshEnabled bool
} }
// VNCSessionPubKey carries an ephemeral X25519 static public key the
// dashboard registered via temporary-access. The daemon uses it as the
// allowed-client side of a Noise_IK handshake; a successful handshake
// authenticates the connection as UserID.
type VNCSessionPubKey struct {
// PubKey is the base64-encoded 32-byte X25519 public key.
PubKey string
// UserID is the unhashed user identity the pubkey authenticates as.
UserID string
}
// ruleAuthCallbacks lets Account and NetworkMapComponents share the per-rule // ruleAuthCallbacks lets Account and NetworkMapComponents share the per-rule
// direction-and-auth logic while keeping their own context/state plumbing for // direction-and-auth logic while keeping their own context/state plumbing for
// authorized-user collection and allowed-user lookups. // authorized-user collection and allowed-user lookups.
@@ -57,6 +69,12 @@ func applyResolvedRuleToState(
return return
} }
cb.collectVNCUsers(rule, state.vncAuthorizedUsers) cb.collectVNCUsers(rule, state.vncAuthorizedUsers)
if rule.SessionPubKey != "" && rule.AuthorizedUser != "" {
state.vncSessionPubKeys = append(state.vncSessionPubKeys, VNCSessionPubKey{
PubKey: rule.SessionPubKey,
UserID: rule.AuthorizedUser,
})
}
case policyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled: case policyRuleImpliesLegacySSH(rule) && targetPeerSSHEnabled:
if !peerInDestinations { if !peerInDestinations {
return return
+9 -1
View File
@@ -88,6 +88,12 @@ type PolicyRule struct {
// AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh // AuthorizedUser is a list of userIDs that are authorized to access local resources via ssh
AuthorizedUser string AuthorizedUser string
// SessionPubKey is the base64 Ed25519 public key the AuthorizedUser
// will sign session-binding challenges with. Set together with
// AuthorizedUser when the rule was created via temporary-access for
// a VNC scope; empty otherwise.
SessionPubKey string
} }
// Copy returns a copy of a policy rule // Copy returns a copy of a policy rule
@@ -109,6 +115,7 @@ func (pm *PolicyRule) Copy() *PolicyRule {
PortRanges: make([]RulePortRange, len(pm.PortRanges)), PortRanges: make([]RulePortRange, len(pm.PortRanges)),
AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)), AuthorizedGroups: make(map[string][]string, len(pm.AuthorizedGroups)),
AuthorizedUser: pm.AuthorizedUser, AuthorizedUser: pm.AuthorizedUser,
SessionPubKey: pm.SessionPubKey,
} }
copy(rule.Destinations, pm.Destinations) copy(rule.Destinations, pm.Destinations)
copy(rule.Sources, pm.Sources) copy(rule.Sources, pm.Sources)
@@ -136,7 +143,8 @@ func (pm *PolicyRule) Equal(other *PolicyRule) bool {
pm.Protocol != other.Protocol || pm.Protocol != other.Protocol ||
pm.SourceResource != other.SourceResource || pm.SourceResource != other.SourceResource ||
pm.DestinationResource != other.DestinationResource || pm.DestinationResource != other.DestinationResource ||
pm.AuthorizedUser != other.AuthorizedUser { pm.AuthorizedUser != other.AuthorizedUser ||
pm.SessionPubKey != other.SessionPubKey {
return false return false
} }
-68
View File
@@ -1,68 +0,0 @@
package jwt
import (
"errors"
"fmt"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
)
// ErrTokenExpired signals that the iat-based token age check failed. Callers
// use errors.Is to branch on it when they want to surface a stable machine-
// readable reason (e.g. so a dashboard can prompt for re-login).
var ErrTokenExpired = errors.New("token expired")
// CheckTokenAge validates that a JWT token's iat claim is within the given
// maxAge duration. Returns an error if the claims are unparsable, the iat
// claim is missing, or the token is too old.
func CheckTokenAge(token *gojwt.Token, maxAge time.Duration) error {
if token == nil {
return fmt.Errorf("token is nil")
}
claims, ok := token.Claims.(gojwt.MapClaims)
if !ok {
return fmt.Errorf("token has invalid claims format (user=%s)", UserIDFromToken(token))
}
iat, ok := claims["iat"].(float64)
if !ok {
return fmt.Errorf("token missing iat claim (user=%s)", UserIDFromToken(token))
}
issuedAt := time.Unix(int64(iat), 0)
tokenAge := time.Since(issuedAt)
if tokenAge > maxAge {
return fmt.Errorf("%w for user=%s: age=%v, max=%v", ErrTokenExpired, userIDFromClaims(claims), tokenAge, maxAge)
}
return nil
}
// UserIDFromToken extracts a human-readable user identifier from a JWT token
// for use in error messages. Returns "unknown" if the token or claims are nil.
func UserIDFromToken(token *gojwt.Token) string {
if token == nil {
return "unknown"
}
claims, ok := token.Claims.(gojwt.MapClaims)
if !ok {
return "unknown"
}
return userIDFromClaims(claims)
}
// userIDFromClaims extracts a user identifier from JWT claims, trying sub,
// user_id, and email in order.
func userIDFromClaims(claims gojwt.MapClaims) string {
if sub, ok := claims["sub"].(string); ok && sub != "" {
return sub
}
if userID, ok := claims["user_id"].(string); ok && userID != "" {
return userID
}
if email, ok := claims["email"].(string); ok && email != "" {
return email
}
return "unknown"
}
+9
View File
@@ -1007,6 +1007,10 @@ components:
items: items:
type: string type: string
example: "tcp/80" example: "tcp/80"
session_pub_key:
description: Ephemeral Ed25519 public key the requester will sign session-binding challenges with. Required for VNC rules; ignored for SSH and L4.
type: string
example: "n0r3pL4c3h0ld3rK3y=="
required: required:
- name - name
- wg_pub_key - wg_pub_key
@@ -1028,10 +1032,15 @@ components:
items: items:
type: string type: string
example: "tcp/80" example: "tcp/80"
target_pub_key:
description: Identity public key of the destination peer the temporary access was requested for. Used by the requester to verify the destination daemon's identity before transmitting credentials.
type: string
example: "n0r3pL4c3h0ld3rK3y=="
required: required:
- name - name
- id - id
- rules - rules
- target_pub_key
AccessiblePeer: AccessiblePeer:
allOf: allOf:
- $ref: '#/components/schemas/PeerMinimum' - $ref: '#/components/schemas/PeerMinimum'
+6
View File
@@ -3391,6 +3391,9 @@ type PeerTemporaryAccessRequest struct {
// Rules List of temporary access rules // Rules List of temporary access rules
Rules []string `json:"rules"` Rules []string `json:"rules"`
// SessionPubKey Ephemeral Ed25519 public key the requester will sign session-binding challenges with. Required for VNC rules; ignored for SSH and L4.
SessionPubKey *string `json:"session_pub_key,omitempty"`
// WgPubKey Peer's WireGuard public key // WgPubKey Peer's WireGuard public key
WgPubKey string `json:"wg_pub_key"` WgPubKey string `json:"wg_pub_key"`
} }
@@ -3405,6 +3408,9 @@ type PeerTemporaryAccessResponse struct {
// Rules List of temporary access rules // Rules List of temporary access rules
Rules []string `json:"rules"` Rules []string `json:"rules"`
// TargetPubKey Identity public key of the destination peer the temporary access was requested for. Used by the requester to verify the destination daemon's identity before transmitting credentials.
TargetPubKey string `json:"target_pub_key"`
} }
// PersonalAccessToken defines model for PersonalAccessToken. // PersonalAccessToken defines model for PersonalAccessToken.
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -428,9 +428,6 @@ message MachineUserIndexes {
// VNCAuth represents VNC authorization configuration for a peer. // VNCAuth represents VNC authorization configuration for a peer.
message VNCAuth { message VNCAuth {
// UserIDClaim is the JWT claim to be used to get the users ID
string UserIDClaim = 1;
// AuthorizedUsers is a list of hashed user IDs authorized to access this peer via VNC // AuthorizedUsers is a list of hashed user IDs authorized to access this peer via VNC
repeated bytes AuthorizedUsers = 2; repeated bytes AuthorizedUsers = 2;
@@ -438,6 +435,24 @@ message VNCAuth {
// Used in session mode to determine which OS user to create the virtual session as. // Used in session mode to determine which OS user to create the virtual session as.
// The wildcard "*" allows any OS user. // The wildcard "*" allows any OS user.
map<string, MachineUserIndexes> machine_users = 3; map<string, MachineUserIndexes> machine_users = 3;
// SessionPubKeys are short-lived X25519 static keypairs the dashboard
// (or other temporary-access clients) registers per session. The
// daemon runs a Noise_IK handshake against the matching pubkey to
// authenticate the connection and resolve the pubkey back to a user.
repeated SessionPubKey session_pub_keys = 4;
}
// SessionPubKey binds an ephemeral X25519 static public key to a hashed
// user identity so the daemon can authorize VNC connections that
// complete a Noise_IK handshake with the matching private key.
message SessionPubKey {
// PubKey is the 32-byte X25519 static public key.
bytes pub_key = 1;
// UserIDHash is the BLAKE2b-128 hash of the user ID this session
// belongs to, matching the entries in VNCAuth.AuthorizedUsers.
bytes user_id_hash = 2;
} }
// RemotePeerConfig represents a configuration of a remote peer. // RemotePeerConfig represents a configuration of a remote peer.