a73x

internal/transport/tlsconf.go

Ref:   Size: 5.4 KiB   History

package transport

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/hex"
	"encoding/pem"
	"fmt"
	"math/big"
	"time"

	"github.com/quic-go/quic-go"
)

// ALPN is the QUIC application-layer protocol token. Both ends MUST set it; the
// handshake fails without a match.
const ALPN = "eitri-sync/1"

// Application error codes sent via CloseWithError so the agent can classify why
// the server dropped it.
const (
	CodeAuthRejected = 1 // permanent: bad/expired host credential — do not tight-loop
	// CodeSuperseded closes the older of two sessions claiming one host: the
	// newest Hello wins and the displaced agent is told so, rather than being
	// left holding a connection nobody reads. It is transient by design — the
	// displaced agent reconnects on its normal backoff, and only the auth code
	// above earns the long one.
	CodeSuperseded = 2
)

// Sync QUIC keepalive/idle timings. The agent dialer and the server listener
// MUST build their quic.Config from SyncQUICConfig: the effective idle timeout
// is the min of the two peers, so tuning one side alone silently shortens it.
// 10s keepalive under a 60s idle gives ~5 keepalive attempts before teardown,
// so a single missed PING or a brief GC/stall on either side does not kill a
// healthy control-plane session (the old 15s/30s allowed only one attempt and
// caused reconnect churn).
const (
	SyncKeepAlivePeriod = 10 * time.Second
	SyncMaxIdleTimeout  = 60 * time.Second
)

// SyncQUICConfig returns the QUIC transport config shared by the eitri-agent
// dialer and the eitri-server listener. Colocating it with ALPN and the TLS
// builders is what keeps the two ends from drifting.
func SyncQUICConfig() *quic.Config {
	return &quic.Config{
		KeepAlivePeriod: SyncKeepAlivePeriod,
		MaxIdleTimeout:  SyncMaxIdleTimeout,
	}
}

// GenerateServerCert returns a fresh self-signed ECDSA cert+key as PEM.
func GenerateServerCert() (certPEM, keyPEM []byte, err error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, nil, err
	}
	tmpl := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{CommonName: "eitri-server"},
		NotBefore:    time.Now().Add(-time.Hour),
		// 2 years, not 10: the agent pin ignores expiry (an expired cert never
		// breaks the fleet), so long validity only widens the forgery window
		// if server.key leaks. CertRenewalDue warns 90 days out; rotation
		// runbook: docs/cert-rotation.md.
		NotAfter:    time.Now().AddDate(2, 0, 0),
		KeyUsage:    x509.KeyUsageDigitalSignature,
		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return nil, nil, err
	}
	certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
	keyDER, err := x509.MarshalECPrivateKey(key)
	if err != nil {
		return nil, nil, err
	}
	keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
	return certPEM, keyPEM, nil
}

// CertFingerprint returns the hex SHA-256 of the cert's DER bytes.
func CertFingerprint(certPEM []byte) (string, error) {
	block, _ := pem.Decode(certPEM)
	if block == nil {
		return "", fmt.Errorf("no PEM block in cert")
	}
	sum := sha256.Sum256(block.Bytes)
	return hex.EncodeToString(sum[:]), nil
}

// renewalWindow is how long before NotAfter CertRenewalDue starts reporting
// true. 90 days gives the operator a comfortable rotation runway.
const renewalWindow = 90 * 24 * time.Hour

// CertRenewalDue reports the cert's NotAfter and whether rotation is due (now
// is within renewalWindow of expiry, or past it). An unparseable cert reports
// due — fail loud so the operator looks at it. Note: the agent pin
// (VerifyConnection) ignores expiry, so an expired cert never breaks the
// running fleet; this only drives the operator-facing warning cadence.
func CertRenewalDue(certPEM []byte, now time.Time) (notAfter time.Time, due bool) {
	block, _ := pem.Decode(certPEM)
	if block == nil {
		return time.Time{}, true
	}
	cert, err := x509.ParseCertificate(block.Bytes)
	if err != nil {
		return time.Time{}, true
	}
	return cert.NotAfter, now.After(cert.NotAfter.Add(-renewalWindow))
}

// ServerTLS builds the server's tls.Config from PEM cert+key with ALPN set.
func ServerTLS(certPEM, keyPEM []byte) (*tls.Config, error) {
	cert, err := tls.X509KeyPair(certPEM, keyPEM)
	if err != nil {
		return nil, err
	}
	return &tls.Config{
		Certificates: []tls.Certificate{cert},
		NextProtos:   []string{ALPN},
		MinVersion:   tls.VersionTLS13,
	}, nil
}

// ClientTLS builds the agent's tls.Config pinned to wantFP (hex sha256 of the
// server cert DER). Uses VerifyConnection (called on resumed connections too,
// unlike VerifyPeerCertificate) and disables default CA verification since the
// cert is self-signed and pinned instead.
func ClientTLS(wantFP string) *tls.Config {
	return &tls.Config{
		NextProtos:         []string{ALPN},
		MinVersion:         tls.VersionTLS13,
		InsecureSkipVerify: true, // CA path disabled; pinning is the trust root
		VerifyConnection: func(cs tls.ConnectionState) error {
			if len(cs.PeerCertificates) == 0 {
				return fmt.Errorf("server presented no certificate")
			}
			sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
			if got := hex.EncodeToString(sum[:]); got != wantFP {
				return fmt.Errorf("server cert fingerprint mismatch: got %s want %s", got, wantFP)
			}
			return nil
		},
	}
}