Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ JWT_SECRET=change-me-in-production
# a duration (87600h), between 2 and 30 years (default: 10y). Applied only
# when the CA is first generated, so set it before the first start.
# BOR_CA_LIFETIME=10y
# CA certificates agents receive at enrollment when the CA is a subordinate of
# an external authority: signing (default, the Bor signing certificate only)
# or chain (also the external intermediates and root). With chain, every CA
# under that root can impersonate this server to agents.
# BOR_CA_AGENT_TRUST=signing

# ── PKCS#11 HSM for CA private key (optional; requires make server-pkcs11) ────
# Store the CA private key in a hardware security module rather than a file.
Expand Down
18 changes: 15 additions & 3 deletions agent/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,8 @@ To follow the agent logs:
}
}

agentCreds = &agentCredentials{serverAddr: agentAddr, paths: paths}

// ─── Connect with mTLS credentials ────────────────────────────────
client, err := policyclient.New(
agentAddr,
Expand Down Expand Up @@ -2113,10 +2115,20 @@ func sendHeartbeat(ctx context.Context, client *policyclient.Client) {
LSMMode: lsmMode,
}

if err := client.Heartbeat(ctx, info); err != nil {
res, err := client.Heartbeat(ctx, info, agentCreds.trustFingerprint())
if err != nil {
log.Printf("Heartbeat failed: %v", err)
} else {
log.Printf("Heartbeat sent (os=%s %s, de=%v)", info.OSName, info.OSVersion, info.DesktopEnvs)
return
}
log.Printf("Heartbeat sent (os=%s %s, de=%v)", info.OSName, info.OSVersion, info.DesktopEnvs)
if agentCreds.apply(res) {
// Tell the server that the new CA certificates are in place; it
// waits for every agent before it starts to use a new CA.
if confirm, confirmErr := client.Heartbeat(ctx, info, agentCreds.trustFingerprint()); confirmErr != nil {
log.Printf("Heartbeat failed: %v", confirmErr)
} else {
agentCreds.apply(confirm)
}
}
}

Expand Down
76 changes: 76 additions & 0 deletions agent/cmd/agent/trust.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vute Tech LTD
// Copyright (C) 2026 Bor contributors

package main

import (
"log"
"sync"
"time"

"github.com/VuteTech/Bor/agent/internal/policyclient"
)

// renewRetryInterval is the least time between two certificate renewals the
// server asks for, so a server that keeps asking cannot make the agent
// renew in a loop.
const renewRetryInterval = time.Hour

// agentCredentials is where the enrolled agent keeps its CA certificates and
// its own certificate, and how it reacts when the server replaces its CA.
type agentCredentials struct {
serverAddr string
paths policyclient.EnrollmentPaths

mu sync.Mutex
lastRenewal time.Time
}

// agentCreds is set once the agent is enrolled, before the first
// heartbeat. Nil while enrolling.
var agentCreds *agentCredentials

// trustFingerprint is what a heartbeat reports about the CA certificate
// file ("" before enrollment).
func (c *agentCredentials) trustFingerprint() string {
if c == nil {
return ""
}
return policyclient.TrustBundleSHA256(c.paths.CACert)
}

// apply acts on a heartbeat answer: it installs the CA certificates the
// server sent and renews the agent certificate when asked to. Both take
// effect on the next connection; the current one stays up. It reports
// whether new CA certificates were installed, which the caller confirms to
// the server with another heartbeat.
func (c *agentCredentials) apply(res *policyclient.HeartbeatResult) (trustChanged bool) {
if c == nil || res == nil {
return false
}
if len(res.TrustBundlePEM) > 0 {
if err := policyclient.WriteTrustBundle(c.paths.CACert, res.TrustBundlePEM); err != nil {
log.Printf("Server sent new CA certificates, but they were not installed: %v", err)
} else {
log.Printf("Installed new CA certificates from the server in %s", c.paths.CACert)
trustChanged = true
}
}
if !res.RenewCertificate {
return trustChanged
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.lastRenewal.IsZero() && time.Since(c.lastRenewal) < renewRetryInterval {
return trustChanged
}
c.lastRenewal = time.Now()
log.Println("Server asked for a certificate renewal (its CA is being replaced), renewing...")
if err := policyclient.RenewCertificate(c.serverAddr, c.paths.CACert, c.paths.CertFile, c.paths.KeyFile); err != nil {
log.Printf("Warning: certificate renewal failed: %v - will retry later", err)
return trustChanged
}
log.Println("Certificate renewed")
return trustChanged
}
59 changes: 41 additions & 18 deletions agent/internal/policyclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@ package policyclient
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"math"
"os"
"time"

pb "github.com/VuteTech/Bor/server/pkg/grpc/policy"
Expand Down Expand Up @@ -51,15 +49,13 @@ func New(serverAddr, clientID, caCertPath, clientCertPath, clientKeyPath string,

switch {
case caCertPath != "":
caPEM, err := os.ReadFile(caCertPath) //nolint:gosec // G304: path comes from trusted config
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate %s: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("failed to parse CA certificate from %s", caCertPath)
// Fail early on an unusable file. The roots themselves are read
// again for every handshake (see trustFileCredentials), because
// the server can replace the agent's CA certificates while the
// agent runs.
if _, err := loadTrustPool(caCertPath); err != nil {
return nil, err
}
tlsCfg.RootCAs = pool
case insecureSkipVerify:
tlsCfg.InsecureSkipVerify = true //nolint:gosec // G402: controlled by admin config, only during enrollment bootstrap
log.Println("WARNING: server certificate verification is disabled (insecure_skip_verify)")
Expand All @@ -69,15 +65,26 @@ func New(serverAddr, clientID, caCertPath, clientCertPath, clientKeyPath string,

// Load client certificate for mTLS if present
if clientCertPath != "" && clientKeyPath != "" {
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
if err != nil {
if _, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath); err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsCfg.Certificates = []tls.Certificate{cert}
// Read the certificate for every handshake, so a renewed
// certificate is used from the next connection on.
tlsCfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
return &cert, nil
}
}

creds := credentials.NewTLS(tlsCfg)
if caCertPath != "" {
creds = &trustFileCredentials{TransportCredentials: creds, config: tlsCfg, caCertPath: caCertPath}
}
conn, err := grpc.NewClient(serverAddr,
grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)),
grpc.WithTransportCredentials(creds),
// HTTP/2 pings so a dead connection is noticed within about a
// minute. After a suspend, a NAT reset or a server restart the TCP
// socket can stay established on the node while the server has
Expand Down Expand Up @@ -217,8 +224,20 @@ type NodeInfo struct {
LSMMode string
}

// HeartbeatResult is what the server answered to a heartbeat.
type HeartbeatResult struct {
// TrustBundlePEM, when not empty, is the CA certificates the agent
// must trust from now on.
TrustBundlePEM []byte
// RenewCertificate asks the agent to renew its certificate now.
RenewCertificate bool
}

// Heartbeat sends a heartbeat with node metadata to the server.
func (c *Client) Heartbeat(ctx context.Context, info *NodeInfo) error {
// trustBundleSHA256 is the fingerprint of the agent's CA certificate file
// (see TrustBundleSHA256); the server answers with a new bundle when the
// agent's is out of date.
func (c *Client) Heartbeat(ctx context.Context, info *NodeInfo, trustBundleSHA256 string) (*HeartbeatResult, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()

Expand All @@ -235,16 +254,20 @@ func (c *Client) Heartbeat(ctx context.Context, info *NodeInfo) error {
Lsm: info.LSM,
LsmMode: info.LSMMode,
},
TrustBundleSha256: trustBundleSHA256,
}

resp, err := c.client.Heartbeat(ctx, req)
if err != nil {
return fmt.Errorf("Heartbeat RPC failed: %w", err)
return nil, fmt.Errorf("Heartbeat RPC failed: %w", err)
}
if !resp.GetAccepted() {
return fmt.Errorf("server rejected heartbeat for client %s", c.clientID)
return nil, fmt.Errorf("server rejected heartbeat for client %s", c.clientID)
}
return nil
return &HeartbeatResult{
TrustBundlePEM: resp.GetCaBundlePem(),
RenewCertificate: resp.GetRenewCertificate(),
}, nil
}

// TamperProcess describes a process found holding a tampered file open.
Expand Down
135 changes: 135 additions & 0 deletions agent/internal/policyclient/trust.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Vute Tech LTD
// Copyright (C) 2026 Bor contributors

package policyclient

import (
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"net"
"os"
"path/filepath"

"google.golang.org/grpc/credentials"
)

// loadTrustPool reads the CA certificates the agent verifies the server
// against.
func loadTrustPool(caCertPath string) (*x509.CertPool, error) {
caPEM, err := os.ReadFile(caCertPath) //nolint:gosec // G304: path comes from trusted config
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate %s: %w", caCertPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("failed to parse CA certificate from %s", caCertPath)
}
return pool, nil
}

// trustFileCredentials are TLS transport credentials whose root CAs are
// read from a file for every handshake, so the agent follows a replacement
// of its CA certificates without a restart. Each handshake is the standard
// crypto/tls one, with full verification of the server's certificate chain
// and name against the certificates in the file at that moment.
type trustFileCredentials struct {
// TransportCredentials answers everything but the client handshake.
credentials.TransportCredentials
// config is the TLS configuration without root CAs.
config *tls.Config
caCertPath string
}

// ClientHandshake implements credentials.TransportCredentials.
func (c *trustFileCredentials) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
roots, err := loadTrustPool(c.caCertPath)
if err != nil {
return nil, nil, err
}
cfg := c.config.Clone()
cfg.RootCAs = roots
return credentials.NewTLS(cfg).ClientHandshake(ctx, authority, rawConn)
}

// Clone implements credentials.TransportCredentials.
func (c *trustFileCredentials) Clone() credentials.TransportCredentials {
return &trustFileCredentials{
TransportCredentials: c.TransportCredentials.Clone(),
config: c.config.Clone(),
caCertPath: c.caCertPath,
}
}

// TrustBundleSHA256 returns the hex SHA-256 of the CA certificate file, the
// fingerprint the agent reports to the server. It is empty when the file
// cannot be read.
func TrustBundleSHA256(caCertPath string) string {
data, err := os.ReadFile(caCertPath) //nolint:gosec // G304: path comes from trusted config
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

// WriteTrustBundle replaces the CA certificate file with bundlePEM, the CA
// certificates the server says the agent must trust. The bundle is accepted
// only when it consists of parseable CA certificates; the file is replaced
// atomically, so a crash leaves either the old or the new content.
//
// The bundle comes from a server the agent has authenticated against its
// current CA certificates, over the mutually authenticated policy channel.
func WriteTrustBundle(caCertPath string, bundlePEM []byte) error {
count := 0
for rest := bundlePEM; ; {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
return fmt.Errorf("trust bundle holds a %q block, only certificates are allowed", block.Type)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("trust bundle certificate %d cannot be parsed: %w", count+1, err)
}
if !cert.IsCA {
return fmt.Errorf("trust bundle certificate %d (%s) is not a CA certificate", count+1, cert.Subject)
}
count++
}
if count == 0 {
return errors.New("trust bundle holds no certificate")
}

tmp, err := os.CreateTemp(filepath.Dir(caCertPath), ".ca-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary CA file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op after the rename
if _, err = tmp.Write(bundlePEM); err == nil {
err = tmp.Sync()
}
if closeErr := tmp.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Chmod(tmpName, 0o644) //nolint:gosec // G302: CA certificates are public
}
if err != nil {
return fmt.Errorf("failed to write CA certificates: %w", err)
}
if err = os.Rename(tmpName, caCertPath); err != nil {
return fmt.Errorf("failed to replace %s: %w", caCertPath, err)
}
return nil
}
Loading
Loading