mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
e855f5786d
Add a new betterdesk-server Go codebase (server, api, auth, db, relay, signal, metrics, audit, ratelimit, proto, tools, tests) and related deployment/migration scripts. Add a comprehensive SECURITY_AUDIT_2026-03-01 report and .gitattributes; update copilot-instructions (ALL-IN-ONE v2.4.0), README, VERSION, Dockerfiles, scripts, docker-compose and entrypoint. Large updates to web-nodejs (translations, routes, services, frontend assets and middleware) and numerous new utilities; remove legacy Flask web files and archive hbbs-patch-v2 artifacts. Prepares repository for PostgreSQL support, DB migration tooling and the new Go server as the production backend.
358 lines
12 KiB
Go
358 lines
12 KiB
Go
// Package crypto — secure TCP handshake for the BetterDesk signal server.
|
|
//
|
|
// Implements the RustDesk "secure TCP" protocol used by newer clients (≥1.2.x):
|
|
//
|
|
// Protocol flow:
|
|
// 1. Server accepts TCP connection
|
|
// 2. Server sends KeyExchange{keys: [ed25519_sign(server_curve25519_pubkey)]}
|
|
// The signed payload is 96 bytes: [64-byte Ed25519 signature][32-byte Curve25519 pubkey]
|
|
// (matches libsodium crypto_sign combined mode)
|
|
// 3. Client verifies Ed25519 signature, extracts server's Curve25519 pubkey
|
|
// 4. Client sends KeyExchange{keys: [nacl_box_encrypted_sym_key, client_curve25519_pubkey]}
|
|
// 5. Server decrypts symmetric key using NaCl box
|
|
// 6. All subsequent messages encrypted with NaCl secretbox
|
|
//
|
|
// Backward compatible: if client sends a non-KeyExchange message (old client),
|
|
// the server processes it directly without encryption.
|
|
package crypto
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha512"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/curve25519"
|
|
"golang.org/x/crypto/nacl/box"
|
|
"golang.org/x/crypto/nacl/secretbox"
|
|
|
|
"github.com/unitronix/betterdesk-server/codec"
|
|
pb "github.com/unitronix/betterdesk-server/proto"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// Ed25519ToCurve25519PrivateKey converts an Ed25519 private key to a
|
|
// Curve25519 private key suitable for X25519 key exchange.
|
|
// This mirrors libsodium's crypto_sign_ed25519_sk_to_curve25519.
|
|
func Ed25519ToCurve25519PrivateKey(edPriv ed25519.PrivateKey) [32]byte {
|
|
// Ed25519 private key is 64 bytes: [32-byte seed][32-byte public key].
|
|
// The Curve25519 scalar is derived by hashing the seed with SHA-512 and
|
|
// clamping the first 32 bytes.
|
|
seed := edPriv[:32]
|
|
h := sha512.Sum512(seed)
|
|
h[0] &= 248
|
|
h[31] &= 127
|
|
h[31] |= 64
|
|
var curvePriv [32]byte
|
|
copy(curvePriv[:], h[:32])
|
|
return curvePriv
|
|
}
|
|
|
|
// Ed25519ToCurve25519PublicKey converts an Ed25519 public key to a
|
|
// Curve25519 public key. Uses the scalar multiplication identity:
|
|
//
|
|
// curve25519_pubkey = scalar_mult(curve25519_privkey, basepoint)
|
|
//
|
|
// Since we derive the scalar from the private key, we can also derive
|
|
// the public key from the private key directly.
|
|
//
|
|
// Alternatively, we compute it from the Ed25519 private key to ensure
|
|
// consistency: curvePriv * basepoint = curvePub.
|
|
func Ed25519ToCurve25519PublicKey(edPriv ed25519.PrivateKey) ([32]byte, error) {
|
|
curvePriv := Ed25519ToCurve25519PrivateKey(edPriv)
|
|
curvePub, err := curve25519.X25519(curvePriv[:], curve25519.Basepoint)
|
|
if err != nil {
|
|
return [32]byte{}, fmt.Errorf("secure: curve25519 scalar mult: %w", err)
|
|
}
|
|
var pub [32]byte
|
|
copy(pub[:], curvePub)
|
|
return pub, nil
|
|
}
|
|
|
|
// SecureTCPConn wraps a net.Conn with NaCl secretbox encryption/decryption.
|
|
// After the key exchange, all reads and writes go through this wrapper.
|
|
type SecureTCPConn struct {
|
|
conn net.Conn
|
|
key [32]byte // symmetric NaCl secretbox key
|
|
sendNonce uint64
|
|
recvNonce uint64
|
|
sendMu sync.Mutex
|
|
recvMu sync.Mutex
|
|
}
|
|
|
|
// makeNonce builds a 24-byte NaCl nonce from a sequential counter.
|
|
// Format: [u64 LE counter][16 zero bytes] — matches RustDesk convention.
|
|
func makeNonce(counter uint64) [24]byte {
|
|
var nonce [24]byte
|
|
binary.LittleEndian.PutUint64(nonce[:8], counter)
|
|
return nonce
|
|
}
|
|
|
|
// Read reads and decrypts data from the underlying connection.
|
|
// This implements the io.Reader interface at the raw level, but for
|
|
// the RustDesk protocol we use ReadMessage instead.
|
|
func (sc *SecureTCPConn) Read(p []byte) (int, error) {
|
|
return sc.conn.Read(p)
|
|
}
|
|
|
|
// Write writes data to the underlying connection.
|
|
func (sc *SecureTCPConn) Write(p []byte) (int, error) {
|
|
return sc.conn.Write(p)
|
|
}
|
|
|
|
// Close closes the underlying connection.
|
|
func (sc *SecureTCPConn) Close() error {
|
|
return sc.conn.Close()
|
|
}
|
|
|
|
// RemoteAddr returns the remote address.
|
|
func (sc *SecureTCPConn) RemoteAddr() net.Addr {
|
|
return sc.conn.RemoteAddr()
|
|
}
|
|
|
|
// LocalAddr returns the local address.
|
|
func (sc *SecureTCPConn) LocalAddr() net.Addr {
|
|
return sc.conn.LocalAddr()
|
|
}
|
|
|
|
// SetDeadline sets the read and write deadlines.
|
|
func (sc *SecureTCPConn) SetDeadline(t time.Time) error {
|
|
return sc.conn.SetDeadline(t)
|
|
}
|
|
|
|
// SetReadDeadline sets the read deadline.
|
|
func (sc *SecureTCPConn) SetReadDeadline(t time.Time) error {
|
|
return sc.conn.SetReadDeadline(t)
|
|
}
|
|
|
|
// SetWriteDeadline sets the write deadline.
|
|
func (sc *SecureTCPConn) SetWriteDeadline(t time.Time) error {
|
|
return sc.conn.SetWriteDeadline(t)
|
|
}
|
|
|
|
// ReadMessage reads a framed message, decrypts it, and unmarshals the protobuf.
|
|
func (sc *SecureTCPConn) ReadMessage(timeout time.Duration) (*pb.RendezvousMessage, error) {
|
|
if timeout > 0 {
|
|
if err := sc.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
|
return nil, fmt.Errorf("secure: set deadline: %w", err)
|
|
}
|
|
defer sc.conn.SetReadDeadline(time.Time{})
|
|
}
|
|
|
|
// Read the framed ciphertext
|
|
ciphertext, err := codec.ReadRawBytes(sc.conn, 0) // deadline already set above
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Decrypt — RustDesk uses pre-increment: first message uses nonce=1
|
|
sc.recvMu.Lock()
|
|
sc.recvNonce++
|
|
nonce := makeNonce(sc.recvNonce)
|
|
sc.recvMu.Unlock()
|
|
|
|
plaintext, ok := secretbox.Open(nil, ciphertext, &nonce, &sc.key)
|
|
if !ok {
|
|
return nil, fmt.Errorf("secure: decryption failed (nonce=%d)", sc.recvNonce)
|
|
}
|
|
|
|
// Unmarshal protobuf
|
|
msg := &pb.RendezvousMessage{}
|
|
if err := proto.Unmarshal(plaintext, msg); err != nil {
|
|
return nil, fmt.Errorf("secure: unmarshal: %w", err)
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// WriteMessage marshals a protobuf, encrypts it, and writes a framed message.
|
|
func (sc *SecureTCPConn) WriteMessage(msg *pb.RendezvousMessage) error {
|
|
data, err := proto.Marshal(msg)
|
|
if err != nil {
|
|
return fmt.Errorf("secure: marshal: %w", err)
|
|
}
|
|
|
|
// RustDesk uses pre-increment: first message uses nonce=1
|
|
sc.sendMu.Lock()
|
|
sc.sendNonce++
|
|
nonce := makeNonce(sc.sendNonce)
|
|
sc.sendMu.Unlock()
|
|
|
|
ciphertext := secretbox.Seal(nil, data, &nonce, &sc.key)
|
|
return codec.WriteRawBytes(sc.conn, ciphertext)
|
|
}
|
|
|
|
// ReadRawDecrypted reads a framed encrypted message and decrypts it to raw bytes.
|
|
// Useful for relay scenarios where we decrypt, then re-encrypt for another connection.
|
|
func (sc *SecureTCPConn) ReadRawDecrypted(timeout time.Duration) ([]byte, error) {
|
|
if timeout > 0 {
|
|
if err := sc.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil {
|
|
return nil, fmt.Errorf("secure: set deadline: %w", err)
|
|
}
|
|
defer sc.conn.SetReadDeadline(time.Time{})
|
|
}
|
|
|
|
ciphertext, err := codec.ReadRawBytes(sc.conn, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sc.recvMu.Lock()
|
|
sc.recvNonce++
|
|
nonce := makeNonce(sc.recvNonce)
|
|
sc.recvMu.Unlock()
|
|
|
|
plaintext, ok := secretbox.Open(nil, ciphertext, &nonce, &sc.key)
|
|
if !ok {
|
|
return nil, fmt.Errorf("secure: decryption failed (nonce=%d)", sc.recvNonce)
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// WriteRawEncrypted encrypts raw bytes and writes a framed message.
|
|
// Useful for relay scenarios where we received decrypted data from another connection.
|
|
func (sc *SecureTCPConn) WriteRawEncrypted(data []byte) error {
|
|
sc.sendMu.Lock()
|
|
sc.sendNonce++
|
|
nonce := makeNonce(sc.sendNonce)
|
|
sc.sendMu.Unlock()
|
|
|
|
ciphertext := secretbox.Seal(nil, data, &nonce, &sc.key)
|
|
return codec.WriteRawBytes(sc.conn, ciphertext)
|
|
}
|
|
|
|
// HandshakeResult holds the outcome of the TCP security handshake.
|
|
type HandshakeResult struct {
|
|
// Secure is true if encryption was negotiated.
|
|
Secure bool
|
|
|
|
// SecureConn is set when Secure is true — use for encrypted I/O.
|
|
SecureConn *SecureTCPConn
|
|
|
|
// FirstMsg is set when Secure is false — the first (unencrypted) message
|
|
// already read from the client (old client that doesn't do key exchange).
|
|
FirstMsg *pb.RendezvousMessage
|
|
}
|
|
|
|
// NegotiateSecureTCP performs the secure TCP handshake on a newly accepted
|
|
// signal connection.
|
|
//
|
|
// Flow:
|
|
// 1. Send server's Curve25519 public key as KeyExchange
|
|
// 2. Read the client's first response
|
|
// 3. If KeyExchange → complete handshake, return SecureConn
|
|
// 4. If other message → old client, return that message for direct handling
|
|
//
|
|
// The edPriv parameter is the server's Ed25519 private key (64 bytes).
|
|
func NegotiateSecureTCP(conn net.Conn, edPriv ed25519.PrivateKey) (*HandshakeResult, error) {
|
|
// Derive Curve25519 keys from Ed25519 identity key
|
|
curvePriv := Ed25519ToCurve25519PrivateKey(edPriv)
|
|
curvePub, err := Ed25519ToCurve25519PublicKey(edPriv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secure: derive curve25519 key: %w", err)
|
|
}
|
|
|
|
// Step 1: Send Ed25519-signed Curve25519 public key as KeyExchange.
|
|
// RustDesk clients expect libsodium crypto_sign combined format:
|
|
// [64-byte Ed25519 signature][32-byte Curve25519 pubkey] = 96 bytes.
|
|
signature := ed25519.Sign(edPriv, curvePub[:]) // 64 bytes (detached)
|
|
signedPubKey := make([]byte, 0, 96)
|
|
signedPubKey = append(signedPubKey, signature...) // first 64 bytes: signature
|
|
signedPubKey = append(signedPubKey, curvePub[:]...) // last 32 bytes: pubkey
|
|
|
|
keMsg := &pb.RendezvousMessage{
|
|
Union: &pb.RendezvousMessage_KeyExchange{
|
|
KeyExchange: &pb.KeyExchange{
|
|
Keys: [][]byte{signedPubKey},
|
|
},
|
|
},
|
|
}
|
|
if err := codec.WriteRawProto(conn, keMsg); err != nil {
|
|
return nil, fmt.Errorf("secure: send KeyExchange: %w", err)
|
|
}
|
|
log.Printf("[signal] Sent signed KeyExchange to %s (sig=%d + pubkey=%d = %d bytes)",
|
|
conn.RemoteAddr(), len(signature), len(curvePub), len(signedPubKey))
|
|
|
|
// Step 2: Read client's first response (30s timeout)
|
|
resp, err := codec.ReadRawProto(conn, 30*time.Second)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("secure: read client response: %w", err)
|
|
}
|
|
|
|
// Step 3: Check if it's a KeyExchange
|
|
if ke := resp.GetKeyExchange(); ke != nil {
|
|
// New client completing secure handshake
|
|
keys := ke.GetKeys()
|
|
if len(keys) < 2 {
|
|
return nil, fmt.Errorf("secure: KeyExchange from client has %d keys (expected ≥2)", len(keys))
|
|
}
|
|
|
|
// Client sends: keys[0] = ephemeral Curve25519 public key (32 bytes)
|
|
// keys[1] = NaCl box-encrypted symmetric key (48 bytes = 32 + 16 MAC)
|
|
clientPubKeyBytes := keys[0]
|
|
encryptedSymKey := keys[1]
|
|
|
|
if len(clientPubKeyBytes) != 32 {
|
|
return nil, fmt.Errorf("secure: client public key is %d bytes (expected 32)", len(clientPubKeyBytes))
|
|
}
|
|
if len(encryptedSymKey) != 48 {
|
|
log.Printf("[signal] Warning: encrypted sym key is %d bytes (expected 48)", len(encryptedSymKey))
|
|
}
|
|
|
|
var clientPub [32]byte
|
|
copy(clientPub[:], clientPubKeyBytes)
|
|
|
|
// Decrypt the symmetric key using NaCl box
|
|
// box.Open(out, box, nonce, senderPublicKey, recipientPrivateKey)
|
|
var zeroNonce [24]byte
|
|
symKeyBytes, ok := box.Open(nil, encryptedSymKey, &zeroNonce, &clientPub, &curvePriv)
|
|
if !ok {
|
|
return nil, fmt.Errorf("secure: failed to decrypt symmetric key from client")
|
|
}
|
|
if len(symKeyBytes) != 32 {
|
|
return nil, fmt.Errorf("secure: symmetric key is %d bytes (expected 32)", len(symKeyBytes))
|
|
}
|
|
|
|
var symKey [32]byte
|
|
copy(symKey[:], symKeyBytes)
|
|
|
|
log.Printf("[signal] Secure TCP established with %s (sym_key_len=%d)", conn.RemoteAddr(), len(symKeyBytes))
|
|
|
|
return &HandshakeResult{
|
|
Secure: true,
|
|
SecureConn: &SecureTCPConn{
|
|
conn: conn,
|
|
key: symKey,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Step 4: Not a KeyExchange — old client, return the message for direct handling
|
|
log.Printf("[signal] Non-secure TCP from %s (old client, no KeyExchange)", conn.RemoteAddr())
|
|
return &HandshakeResult{
|
|
Secure: false,
|
|
FirstMsg: resp,
|
|
}, nil
|
|
}
|
|
|
|
// GenerateEphemeralX25519 generates a random X25519 keypair for one-shot use.
|
|
// Not currently needed (we derive from Ed25519), but available for future use.
|
|
func GenerateEphemeralX25519() (publicKey, privateKey [32]byte, err error) {
|
|
_, err = io.ReadFull(rand.Reader, privateKey[:])
|
|
if err != nil {
|
|
return
|
|
}
|
|
pub, err := curve25519.X25519(privateKey[:], curve25519.Basepoint)
|
|
if err != nil {
|
|
return
|
|
}
|
|
copy(publicKey[:], pub)
|
|
return
|
|
}
|