// Package transport contains the cross-node transport primitives for // orca. mTLS is the v0.2 baseline (D-011..D-015); clients and servers // use stdlib crypto/tls with TLS 1.3 only and an AEAD cipher allowlist. // // The transport layer deliberately depends on the stdlib only — no // gRPC, no ConnectRPC, no third-party transport libraries. This keeps // the binary lean (matches the minimalist pillar) and the trust chain // auditable (one library: the Go stdlib). // // Deprecated: v0.9 re-architecture replaces this with // internal/sshpush (REQ-073). The daemon-to-daemon mTLS transport is // removed because servers no longer run the orca binary (R-001); the // CLI pushes config via SSH instead. Scheduled for deletion in // v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006. package transport import ( "context" "crypto/tls" "crypto/x509" "errors" "fmt" "net" "net/http" "time" "git.cloudinit.dev/coreci/orca/internal/security" ) // MTLSClient wraps an http.Client configured for mTLS. The client // verifies the server cert against the pinned CA and the expected // server name (typically the SAN on the server cert). type MTLSClient struct { caPath string serverName string clientCert string clientKey string http *http.Client } // NewMTLSClient constructs an mTLS client. // // caPath is the path to the CA cert (PEM). The client's RootCAs is set // to this single CA, so the server cert MUST be signed by it (REQ-011). // serverName is the expected DNS name on the server cert's SAN list // (REQ-036). // // certPath and keyPath are optional; if both are non-empty, the client // presents them during the handshake. Pass empty strings for clients // that don't authenticate themselves. func NewMTLSClient(caPath, serverName, certPath, keyPath string) (*MTLSClient, error) { if caPath == "" { return nil, errors.New("NewMTLSClient: caPath is required") } if serverName == "" { return nil, errors.New("NewMTLSClient: serverName is required (must match server cert SAN)") } tlsCfg, err := security.ClientTLSConfig(caPath, serverName, certPath, keyPath) if err != nil { return nil, fmt.Errorf("NewMTLSClient: %w", err) } // Tighten the http.Client transport. The defaults (DefaultTransport) // would reuse connections too aggressively for our needs; we want // per-request timeout and a fresh dial per request to ensure cert // rotation is picked up promptly. tr := &http.Transport{ TLSClientConfig: tlsCfg, MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second, DisableCompression: true, } return &MTLSClient{ caPath: caPath, serverName: serverName, clientCert: certPath, clientKey: keyPath, http: &http.Client{Transport: tr, Timeout: 30 * time.Second}, }, nil } // Do executes an HTTP request over mTLS. Returns the response or an // error. On TLS handshake failure, wraps the error with structured // context for the audit/handshake_log package. func (c *MTLSClient) Do(req *http.Request) (*http.Response, error) { if c == nil || c.http == nil { return nil, errors.New("MTLSClient: nil receiver") } return c.http.Do(req) } // VerifyPeerCertificate is a tls.Config.VerifyPeerCertificate callback // that enforces a pinned peer identity. Use it on the client side to // reject certs that match the CA but are not the expected server. // // expectedFingerprint is the SHA-256 hex of the server cert DER. If it // matches, the connection is allowed. If not, the handshake is // aborted with a clear error. func VerifyPeerCertificate(expectedFingerprint string) func([][]byte, [][]*x509.Certificate) error { return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { if len(rawCerts) == 0 { return errors.New("VerifyPeerCertificate: no peer certs presented") } leaf, err := x509.ParseCertificate(rawCerts[0]) if err != nil { return fmt.Errorf("VerifyPeerCertificate: parse leaf: %w", err) } got := security.FingerprintOf(leaf.Raw) if got != expectedFingerprint { return fmt.Errorf("VerifyPeerCertificate: peer fingerprint mismatch: got %s, want %s", got, expectedFingerprint) } return nil } } // DialContext dials a TCP address over raw TLS (no HTTP). Returns a // tls.Conn. Used for low-level handshake tests; the mTLS client above // is what production code uses. func DialContext(ctx context.Context, network, addr, caPath, serverName string) (net.Conn, error) { if caPath == "" { return nil, errors.New("DialContext: caPath is required") } tlsCfg, err := security.ClientTLSConfig(caPath, serverName, "", "") if err != nil { return nil, fmt.Errorf("DialContext: %w", err) } d := &net.Dialer{Timeout: 5 * time.Second} return tls.DialWithDialer(d, network, addr, tlsCfg) }