Files
netbird/client/vnc/server/noise_auth_test.go
2026-05-24 18:52:57 +02:00

453 lines
15 KiB
Go

//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/shared/sessionauth"
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(Config{
Capturer: &testCapturer{},
Injector: &StubInputInjector{},
IdentityKey: kp.Private,
})
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 + (optional) 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()
writeHeaderPrefixWithUser(t, conn, mode, "")
}
// writeHeaderPrefixWithUser is the username-aware variant used by tests
// that need to verify the Noise prologue binds to the cleartext header.
func writeHeaderPrefixWithUser(t *testing.T, conn net.Conn, mode byte, username string) {
t.Helper()
if len(username) > 0xFFFF {
t.Fatalf("test username too long: %d", len(username))
}
prefix := []byte{mode, byte(len(username) >> 8), byte(len(username))}
prefix = append(prefix, []byte(username)...)
_, 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. headerMode and
// headerUsername are mixed into the prologue and MUST match what the
// caller already wrote in the cleartext header prefix.
func performInitiator(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte) {
t.Helper()
performInitiatorWithHeader(t, conn, clientKey, serverPub, ModeAttach, "")
}
func performInitiatorWithHeader(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte, headerMode byte, headerUsername string) {
t.Helper()
state, err := noise.NewHandshakeState(noise.Config{
CipherSuite: vncNoiseSuite,
Pattern: noise.HandshakeIK,
Initiator: true,
Prologue: BuildVNCNoisePrologue(headerMode, headerUsername),
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,
Prologue: BuildVNCNoisePrologue(ModeAttach, ""),
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)
defer conn.Close()
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.(*net.TCPConn).CloseWrite())
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
buf := make([]byte, 64)
n, err := conn.Read(buf)
require.Equal(t, 0, n, "server must not emit RFB bytes after a truncated handshake")
require.ErrorIs(t, err, io.EOF, "server must close the connection on truncated msg1")
}
// 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(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}})
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,
Prologue: BuildVNCNoisePrologue(ModeAttach, ""),
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(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}, IdentityKey: 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 := "bob"
writeHeaderPrefixWithUser(t, conn, ModeSession, bob)
performInitiatorWithHeader(t, conn, clientKey, serverPub, ModeSession, bob)
writeHeaderTail(t, conn)
reason := readRFBFailure(t, conn)
assert.Contains(t, reason, RejectCodeAuthForbidden)
assert.Contains(t, reason, "authorize OS user")
}