mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 23:29:08 +02:00
243 lines
8.3 KiB
Go
243 lines
8.3 KiB
Go
//go:build !js && !ios && !android
|
|
|
|
package server
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"syscall"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// The daemon and its per-session agent authenticate each other with the
|
|
// per-spawn token, over a challenge-response rather than by sending the token
|
|
// itself.
|
|
//
|
|
// Sending it was enough to prove the daemon's side, but it also handed the
|
|
// secret to whatever was listening. The socket lives in a directory only the
|
|
// console user and root may write to, so an impostor has to already be running
|
|
// as that user — but such a process could then take the token, answer as the
|
|
// agent, and sit between an authorized remote peer and the desktop, watching
|
|
// what they see and type. Neither end reveals the token now, and each refuses to
|
|
// continue until the other has proved it holds the same one.
|
|
//
|
|
// Both ends are the same binary: the daemon spawns the agent from its own
|
|
// executable, so there is no version skew between them to keep compatible.
|
|
const (
|
|
// agentTokenLen is the size of the random per-spawn token in bytes. It is
|
|
// the HMAC key both halves below are keyed on.
|
|
agentTokenLen = 32
|
|
|
|
// agentNonceLen is the size of each side's challenge.
|
|
agentNonceLen = 32
|
|
// agentMACLen is the size of an HMAC-SHA256 tag.
|
|
agentMACLen = sha256.Size
|
|
// agentHandshakeTimeout bounds the whole exchange. Both ends are local
|
|
// processes, so this only has to cover scheduling, never a network.
|
|
agentHandshakeTimeout = 5 * time.Second
|
|
|
|
// maxAgentPeerAddrLen is the longest peer address the grant can carry; its
|
|
// length travels as one byte.
|
|
maxAgentPeerAddrLen = 255
|
|
)
|
|
|
|
// Domain separation, so a tag one side produces can never be replayed as the
|
|
// other side's answer.
|
|
var (
|
|
agentDaemonLabel = []byte("netbird-vnc-daemon")
|
|
agentAgentLabel = []byte("netbird-vnc-agent")
|
|
)
|
|
|
|
// agentMAC tags the label and the parts under a token.
|
|
func agentMAC(token, label []byte, parts ...[]byte) []byte {
|
|
mac := hmac.New(sha256.New, token)
|
|
mac.Write(label)
|
|
for _, p := range parts {
|
|
mac.Write(p)
|
|
}
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
// agentGrant is what the daemon authenticates to the agent for one proxied
|
|
// connection: whether it is view-only, and the address of the remote peer the
|
|
// daemon accepted, which the agent otherwise only sees as the local socket.
|
|
type agentGrant struct {
|
|
viewOnly bool
|
|
peerAddr string
|
|
}
|
|
|
|
// encode renders the grant as the bytes the daemon's tag covers: the view-only
|
|
// byte, a one-byte address length, then the address.
|
|
func (g agentGrant) encode() ([]byte, error) {
|
|
if len(g.peerAddr) > maxAgentPeerAddrLen {
|
|
return nil, fmt.Errorf("peer address of %d bytes exceeds %d", len(g.peerAddr), maxAgentPeerAddrLen)
|
|
}
|
|
b := make([]byte, 0, 2+len(g.peerAddr))
|
|
b = append(b, viewOnlyByte(g.viewOnly)...)
|
|
b = append(b, byte(len(g.peerAddr)))
|
|
return append(b, g.peerAddr...), nil
|
|
}
|
|
|
|
// agentClientHandshake runs the daemon's half against a freshly dialled agent
|
|
// connection: read the agent's challenge, answer it, then challenge the agent
|
|
// back and check its answer before any session bytes are proxied.
|
|
//
|
|
// The grant travels inside the daemon's tag, so an impostor cannot flip a
|
|
// read-only session into a controlling one, or change the reported peer, by
|
|
// rewriting bytes in flight.
|
|
func agentClientHandshake(conn net.Conn, token []byte, grant agentGrant) error {
|
|
payload, err := grant.encode()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := conn.SetDeadline(time.Now().Add(agentHandshakeTimeout)); err != nil {
|
|
return fmt.Errorf("set handshake deadline: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := conn.SetDeadline(time.Time{}); err != nil {
|
|
log.Debugf("clear agent handshake deadline: %v", err)
|
|
}
|
|
}()
|
|
|
|
agentNonce := make([]byte, agentNonceLen)
|
|
if _, err := io.ReadFull(conn, agentNonce); err != nil {
|
|
return fmt.Errorf("read agent challenge: %w", err)
|
|
}
|
|
|
|
daemonNonce := make([]byte, agentNonceLen)
|
|
if _, err := rand.Read(daemonNonce); err != nil {
|
|
return fmt.Errorf("read random: %w", err)
|
|
}
|
|
|
|
reply := make([]byte, 0, agentMACLen+agentNonceLen+len(payload))
|
|
reply = append(reply, agentMAC(token, agentDaemonLabel, agentNonce, payload)...)
|
|
reply = append(reply, daemonNonce...)
|
|
reply = append(reply, payload...)
|
|
if _, err := conn.Write(reply); err != nil {
|
|
return fmt.Errorf("send handshake response: %w", err)
|
|
}
|
|
|
|
agentTag := make([]byte, agentMACLen)
|
|
if _, err := io.ReadFull(conn, agentTag); err != nil {
|
|
return fmt.Errorf("read agent response: %w", err)
|
|
}
|
|
want := agentMAC(token, agentAgentLabel, daemonNonce)
|
|
if subtle.ConstantTimeCompare(agentTag, want) != 1 {
|
|
return fmt.Errorf("agent did not prove it holds the session token")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// agentServerHandshake runs the agent's half against an accepted connection,
|
|
// returning the grant the daemon authenticated.
|
|
func agentServerHandshake(conn net.Conn, token []byte) (agentGrant, error) {
|
|
var none agentGrant
|
|
if err := conn.SetDeadline(time.Now().Add(agentHandshakeTimeout)); err != nil {
|
|
return none, fmt.Errorf("set handshake deadline: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := conn.SetDeadline(time.Time{}); err != nil {
|
|
log.Debugf("clear agent handshake deadline: %v", err)
|
|
}
|
|
}()
|
|
|
|
agentNonce := make([]byte, agentNonceLen)
|
|
if _, err := rand.Read(agentNonce); err != nil {
|
|
return none, fmt.Errorf("read random: %w", err)
|
|
}
|
|
if _, err := conn.Write(agentNonce); err != nil {
|
|
return none, fmt.Errorf("send challenge: %w", err)
|
|
}
|
|
|
|
head := make([]byte, agentMACLen+agentNonceLen+2)
|
|
if _, err := io.ReadFull(conn, head); err != nil {
|
|
return none, fmt.Errorf("read daemon response: %w", err)
|
|
}
|
|
daemonTag := head[:agentMACLen]
|
|
daemonNonce := head[agentMACLen : agentMACLen+agentNonceLen]
|
|
addr := make([]byte, head[len(head)-1])
|
|
if _, err := io.ReadFull(conn, addr); err != nil {
|
|
return none, fmt.Errorf("read daemon response: %w", err)
|
|
}
|
|
payload := slices.Concat(head[agentMACLen+agentNonceLen:], addr)
|
|
|
|
want := agentMAC(token, agentDaemonLabel, agentNonce, payload)
|
|
if subtle.ConstantTimeCompare(daemonTag, want) != 1 {
|
|
return none, fmt.Errorf("caller did not prove it holds the session token")
|
|
}
|
|
|
|
if _, err := conn.Write(agentMAC(token, agentAgentLabel, daemonNonce)); err != nil {
|
|
return none, fmt.Errorf("send response: %w", err)
|
|
}
|
|
return agentGrant{viewOnly: payload[0] != 0, peerAddr: string(addr)}, nil
|
|
}
|
|
|
|
// isProbeDisconnect reports whether err is a peer that connected and left
|
|
// without speaking.
|
|
//
|
|
// The daemon's own readiness check dials the agent socket and closes it
|
|
// immediately, and it is not alone: anything probing the socket for liveness
|
|
// does the same. Since the agent now writes its challenge first, such a probe
|
|
// surfaces as a failed write (a reset or broken pipe) as often as a failed
|
|
// read, and logging either at warning level would fill the daemon log with
|
|
// entries for something entirely expected.
|
|
//
|
|
// io.ErrUnexpectedEOF is deliberately not here: that is a peer that sent part
|
|
// of a handshake and then went away, which is an aborted or malformed
|
|
// authentication attempt rather than a probe, and has to stay visible.
|
|
func isProbeDisconnect(err error) bool {
|
|
switch {
|
|
case errors.Is(err, io.EOF):
|
|
return true
|
|
case errors.Is(err, net.ErrClosed):
|
|
return true
|
|
case errors.Is(err, syscall.EPIPE), errors.Is(err, syscall.ECONNRESET):
|
|
return true
|
|
default:
|
|
return isPipeDisconnect(err)
|
|
}
|
|
}
|
|
|
|
// viewOnlyByte renders the flag as the single byte both tags cover.
|
|
func viewOnlyByte(viewOnly bool) []byte {
|
|
if viewOnly {
|
|
return []byte{1}
|
|
}
|
|
return []byte{0}
|
|
}
|
|
|
|
// peerAddrConn reports the remote peer the daemon authenticated in the grant
|
|
// as the connection's remote address, in place of the local socket the agent
|
|
// actually accepted on.
|
|
type peerAddrConn struct {
|
|
net.Conn
|
|
remote net.Addr
|
|
}
|
|
|
|
func (c *peerAddrConn) RemoteAddr() net.Addr { return c.remote }
|
|
|
|
// withGrantPeer wraps conn so RemoteAddr returns the grant's peer address. A
|
|
// grant without a parseable address leaves conn as it is.
|
|
func withGrantPeer(conn net.Conn, grant agentGrant) net.Conn {
|
|
if grant.peerAddr == "" {
|
|
return conn
|
|
}
|
|
ap, err := netip.ParseAddrPort(grant.peerAddr)
|
|
if err != nil {
|
|
log.Debugf("agent grant peer address %q: %v", grant.peerAddr, err)
|
|
return conn
|
|
}
|
|
ap = netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port())
|
|
return &peerAddrConn{Conn: conn, remote: net.TCPAddrFromAddrPort(ap)}
|
|
}
|