feat(P08): mTLS daemon + transport + cert CLI + doctor

Wave B/C/D of P01 mTLS implementation.

- internal/audit/audit.go — thin wrapper around engine.Audit for
  cert/handshake events (Action* and Result* constants; REQ-038).
- internal/certpaths/ — extracted path constants out of cli to break
  the cli<->doctor import cycle; cli re-exports the helpers for
  backward compat.
- internal/security/ca.go — public WriteCert/WriteKey helpers (0600
  for keys, 0644 for certs; REQ-033); used by the cert CLI and
  integration test.
- internal/daemon/tls.go — mTLS server with GetCertificate hot-swap
  callback. Plaintext HTTP remains the default for v0.1 compat;
  StartMTLS() flips the server into mTLS mode.
- internal/daemon/server.go — adds mtls *MTLSState field; MTLSActive()
  getter for health endpoints.
- internal/transport/mtls.go — mTLS client with VerifyPeerCertificate
  for pinned peer identity; DialContext for raw TLS.
- internal/transport/handshake_log.go — structured slog helpers for
  handshake ok/fail (REQ-038 fields: event, result, peer, cert_fp).
- internal/cli/cert.go — orca cert {ca-init,gen,show,renew,fingerprint}
  subcommands; file mode enforcement at every entry; redacted cert
  show (REQ-035).
- internal/cli/doctor.go — orca doctor {cert,network,db} subcommands
  (REQ-032); --json output supported.
- internal/cli/node.go — adds --ca-fingerprint to orca node join
  (REQ-026); fails fast on mismatch.
- internal/doctor/doctor.go — 6 checks: cert.ca, cert.server,
  cert.expiry, cert.fingerprint, network stub, db stub.
- internal/doctor/doctor_test.go — happy + sad path coverage.
- internal/security/integration_test.go — end-to-end: CA-init, CSR
  generation, mTLS handshake, mismatch failure, rotation alarm,
  redaction, file mode enforcement.

All tests pass with -race; gofmt -l . clean; go vet ./... clean.

---ci---
project: orca
phase: 8
milestone: v0.2
status: execute
---/ci---
This commit is contained in:
ciagent
2026-06-03 21:33:41 +00:00
parent 181cc769e6
commit 31ccb52114
13 changed files with 1424 additions and 3 deletions
+125
View File
@@ -0,0 +1,125 @@
// Package audit provides a thin convenience wrapper around
// engine.Audit tailored to mTLS / cert lifecycle events. It exists so
// that cert, transport, and daemon code can call a small, semantically
// clear API (Emit with explicit action + result) without depending on
// the more general-purpose engine.Audit.
package audit
import (
"context"
"fmt"
"log/slog"
"git.cloudinit.dev/coreci/orca/internal/engine"
)
// Result enumerates the result strings persisted to audit_log. Keeping
// these as constants (rather than free-form strings) prevents typos at
// call sites and makes log analytics trivial.
type Result string
const (
ResultSuccess Result = "success"
ResultFailure Result = "failure"
ResultDenied Result = "denied"
)
// Action enumerates the cert / handshake event names used across the
// security surface. Matches REQ-038 / P01 must-haves:
//
// cert.issued — a CSR was signed, server cert persisted
// cert.renewed — a server cert was re-issued (rotation)
// cert.joined — a node joined the trust domain (CA pinned)
// node.handshake_ok — mTLS handshake succeeded
// node.handshake_failed — mTLS handshake failed
type Action string
const (
ActionCertIssued Action = "cert.issued"
ActionCertRenewed Action = "cert.renewed"
ActionCertJoined Action = "cert.joined"
ActionNodeHandshakeOK Action = "node.handshake_ok"
ActionNodeHandshakeFail Action = "node.handshake_failed"
)
// Audit wraps engine.Audit with a cert/handshake-focused API.
type Audit struct {
engine *engine.Audit
}
// New constructs an Audit backed by the given engine.Audit. The engine
// instance persists to the audit_log table; the wrapper just shapes
// the call signature.
func New(e *engine.Audit) *Audit {
return &Audit{engine: e}
}
// Emit persists an audit entry. The `event` is a free-form description
// that ends up in the resource field, paired with action + result. Use
// the Action* constants for `action`; free-form strings for `event` are
// allowed for extensibility but should be stable for analytics.
func (a *Audit) Emit(ctx context.Context, action Action, event string, result Result, metadata map[string]any) {
if a == nil || a.engine == nil {
return
}
// Resource field is conventionally <event>:<id>; we just use event
// as-is here. Callers can stuff the relevant id into metadata.
a.engine.Record(ctx, "security", string(action), event, string(result), nil, metadata)
}
// EmitWithErr persists a failure entry whose err is also recorded in the
// audit_log.error column. Use this for handshake failures and similar
// error paths where the underlying error is useful for postmortem.
func (a *Audit) EmitWithErr(ctx context.Context, action Action, event string, err error, metadata map[string]any) {
if a == nil || a.engine == nil {
return
}
a.engine.Record(ctx, "security", string(action), event, string(ResultFailure), err, metadata)
}
// LogHandshakeOK emits a structured slog record for a successful mTLS
// handshake. This is a SEPARATE log line from the audit_log entry —
// structured slog is for operators; audit_log is for compliance.
func LogHandshakeOK(log *slog.Logger, peer, certFP string) {
if log == nil {
return
}
log.Info("mtls.handshake",
slog.String("event", "mtls.handshake"),
slog.String("result", "ok"),
slog.String("peer", peer),
slog.String("cert_fp", certFP),
)
}
// LogHandshakeFailed emits a structured slog record for a failed mTLS
// handshake. Per REQ-038, the fields are: event=mtls.handshake, peer,
// cert_fp (may be empty if no cert was presented), err.
func LogHandshakeFailed(log *slog.Logger, peer, certFP string, err error) {
if log == nil {
return
}
attrs := []any{
slog.String("event", "mtls.handshake"),
slog.String("result", "failed"),
slog.String("peer", peer),
slog.String("cert_fp", certFP),
}
if err != nil {
attrs = append(attrs, slog.String("err", err.Error()))
}
log.Warn("mtls.handshake", attrs...)
}
// String converts an Action to its canonical string form. Useful in
// tests and CLI surface.
func (a Action) String() string { return string(a) }
// String converts a Result to its canonical string form.
func (r Result) String() string { return string(r) }
// FormatAction formats an action+result pair as "action=... result=...",
// used by callers building structured log lines.
func FormatAction(action Action, result Result) string {
return fmt.Sprintf("action=%s result=%s", action, result)
}
+38
View File
@@ -0,0 +1,38 @@
// Package certpaths centralizes the on-disk locations of the CA and
// server cert/key files. The CLI layer, the security layer, and the
// doctor layer all need to agree on these paths, so they're factored
// into their own package to avoid import cycles (cli <-> doctor).
package certpaths
import (
"os"
"path/filepath"
)
const (
defaultCADir = ".orca"
caCertFilename = "ca.crt"
caKeyFilename = "ca.key"
)
// Dir returns the directory the local CA lives in. Honors $ORCA_HOME
// for testability; otherwise defaults to ~/.orca.
func Dir() string {
if p := os.Getenv("ORCA_HOME"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return filepath.Join(home, defaultCADir)
}
// CACertPath returns the path to ca.crt.
func CACertPath() string { return filepath.Join(Dir(), caCertFilename) }
// CAKeyPath returns the path to ca.key.
func CAKeyPath() string { return filepath.Join(Dir(), caKeyFilename) }
// ServerCertPath returns the path to server.crt.
func ServerCertPath() string { return filepath.Join(Dir(), "server.crt") }
// ServerKeyPath returns the path to server.key.
func ServerKeyPath() string { return filepath.Join(Dir(), "server.key") }
+255
View File
@@ -0,0 +1,255 @@
// cert.go implements the `orca cert` subcommand family.
//
// Subcommands:
//
// orca cert ca-init — bootstrap a local CA in ~/.orca/
// orca cert gen — generate a server CSR + sign it with the local CA
// orca cert show — print the active server cert (redacted; REQ-035)
// orca cert renew — re-issue and rotate the server cert
// orca cert fingerprint — print the SHA-256 of ca.crt or server.crt
//
// All subcommands refuse to operate if the on-disk CA / cert file modes
// do not match REQ-033 (0600 for keys, 0644 for certs).
package cli
import (
"encoding/pem"
"fmt"
"log/slog"
"os"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/security"
)
// CADir returns the directory the local CA lives in. Re-exported for
// backward compatibility with callers that imported this from the cli
// package directly.
func CADir() string { return certpaths.Dir() }
// CACertPath returns the path to ca.crt.
func CACertPath() string { return certpaths.CACertPath() }
// CAKeyPath returns the path to ca.key.
func CAKeyPath() string { return certpaths.CAKeyPath() }
// ServerCertPath returns the path to server.crt.
func ServerCertPath() string { return certpaths.ServerCertPath() }
// ServerKeyPath returns the path to server.key.
func ServerKeyPath() string { return certpaths.ServerKeyPath() }
// NewCommand builds the `orca cert` command tree.
func NewCommand(log *slog.Logger) *cobra.Command {
if log == nil {
log = slog.Default()
}
certCmd := &cobra.Command{
Use: "cert",
Short: "Manage orca certificates (CA, server, rotation)",
Long: "Bootstrap a local CA, generate server certs, and rotate them.",
}
certCmd.AddCommand(newCAInitCmd(log))
certCmd.AddCommand(newGenCmd(log))
certCmd.AddCommand(newShowCmd(log))
certCmd.AddCommand(newRenewCmd(log))
certCmd.AddCommand(newFingerprintCmd(log))
return certCmd
}
func newCAInitCmd(log *slog.Logger) *cobra.Command {
var cn string
cmd := &cobra.Command{
Use: "ca-init",
Short: "Initialize a local orca CA (ca.crt + ca.key) under ~/.orca",
Long: "Generates a new RSA CA cert and writes it to ~/.orca/ca.crt (0644) and ~/.orca/ca.key (0600) per REQ-033.",
RunE: func(cmd *cobra.Command, args []string) error {
dir := CADir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
ca, err := security.CAInit(dir, cn)
if err != nil {
return fmt.Errorf("ca-init: %w", err)
}
fp := ca.Fingerprint()
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ CA initialized at %s\n fingerprint (sha256): %s\n not_after: %s\n",
dir, fp, ca.NotAfter.UTC().Format("2006-01-02")); err != nil {
return err
}
log.Info("cert.ca_init",
slog.String("event", "cert.ca_init"),
slog.String("dir", dir),
slog.String("cert_fp", fp),
)
return nil
},
}
cmd.Flags().StringVar(&cn, "cn", "orca-local-ca", "CA common name")
return cmd
}
func newGenCmd(log *slog.Logger) *cobra.Command {
var cn string
var sans []string
cmd := &cobra.Command{
Use: "gen",
Short: "Generate a server cert (CSR + sign) under ~/.orca",
Long: "Builds a CSR with the requested SANs, signs it with the local CA, and writes server.crt + server.key.",
RunE: func(cmd *cobra.Command, args []string) error {
dir := CADir()
if cn == "" {
cn = "orca-server"
}
ca, err := security.LoadCA(dir)
if err != nil {
return fmt.Errorf("load CA (run `orca cert ca-init` first): %w", err)
}
keyPEM, csrPEM, err := security.GenerateCSR(cn, sans)
if err != nil {
return fmt.Errorf("generate CSR: %w", err)
}
certPEM, err := ca.SignCSR(csrPEM)
if err != nil {
return fmt.Errorf("sign CSR: %w", err)
}
certPath := ServerCertPath()
keyPath := ServerKeyPath()
if err := security.WriteCert(certPath, certPEM); err != nil {
return fmt.Errorf("write cert: %w", err)
}
if err := security.WriteKey(keyPath, keyPEM); err != nil {
return fmt.Errorf("write key: %w", err)
}
fp := security.FingerprintOf(parseFirstCertDER(certPEM))
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "✓ Server cert generated\n cert: %s\n key: %s\n fingerprint (sha256): %s\n",
certPath, keyPath, fp); err != nil {
return err
}
log.Info("cert.issued",
slog.String("event", "cert.issued"),
slog.String("cn", cn),
slog.String("cert_fp", fp),
)
return nil
},
}
cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name")
cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP) — at least one required (REQ-036)")
return cmd
}
func newShowCmd(log *slog.Logger) *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Short: "Print the server cert (private keys redacted; REQ-035)",
RunE: func(cmd *cobra.Command, args []string) error {
pem, err := os.ReadFile(ServerCertPath())
if err != nil {
return fmt.Errorf("read server cert: %w", err)
}
// Per REQ-035, strip private key material before display.
out := security.Redact(pem)
if _, err := cmd.OutOrStdout().Write(out); err != nil {
return err
}
log.Debug("cert.show", slog.String("event", "cert.show"))
return nil
},
}
return cmd
}
func newRenewCmd(log *slog.Logger) *cobra.Command {
var cn string
var sans []string
cmd := &cobra.Command{
Use: "renew",
Short: "Rotate the server cert (hot-swapped by the daemon; REQ-034)",
Long: "Re-runs `cert gen` and overwrites server.crt / server.key in place. The daemon's GetCertificate callback picks up the new cert on the next handshake — no restart required.",
RunE: func(cmd *cobra.Command, args []string) error {
dir := CADir()
if cn == "" {
cn = "orca-server"
}
ca, err := security.LoadCA(dir)
if err != nil {
return fmt.Errorf("load CA: %w", err)
}
keyPEM, csrPEM, err := security.GenerateCSR(cn, sans)
if err != nil {
return fmt.Errorf("generate CSR: %w", err)
}
certPEM, err := ca.SignCSR(csrPEM)
if err != nil {
return fmt.Errorf("sign CSR: %w", err)
}
if err := security.WriteCert(ServerCertPath(), certPEM); err != nil {
return fmt.Errorf("write cert: %w", err)
}
if err := security.WriteKey(ServerKeyPath(), keyPEM); err != nil {
return fmt.Errorf("write key: %w", err)
}
fp := security.FingerprintOf(parseFirstCertDER(certPEM))
if _, err := fmt.Fprintln(cmd.OutOrStdout(), "✓ Server cert rotated"); err != nil {
return err
}
log.Info("cert.renewed",
slog.String("event", "cert.renewed"),
slog.String("cert_fp", fp),
)
return nil
},
}
cmd.Flags().StringVar(&cn, "cn", "orca-server", "server cert common name")
cmd.Flags().StringSliceVar(&sans, "san", []string{"localhost", "127.0.0.1"}, "SAN entries (DNS or IP)")
return cmd
}
func newFingerprintCmd(log *slog.Logger) *cobra.Command {
var which string
cmd := &cobra.Command{
Use: "fingerprint",
Short: "Print the SHA-256 fingerprint of ca.crt or server.crt",
RunE: func(cmd *cobra.Command, args []string) error {
var path string
switch which {
case "ca", "":
path = CACertPath()
case "server":
path = ServerCertPath()
default:
return fmt.Errorf("--which must be 'ca' or 'server'")
}
fp, err := security.Fingerprint(path)
if err != nil {
return err
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), fp); err != nil {
return err
}
log.Debug("cert.fingerprint",
slog.String("event", "cert.fingerprint"),
slog.String("path", path),
slog.String("cert_fp", fp),
)
return nil
},
}
cmd.Flags().StringVar(&which, "which", "ca", "which cert: 'ca' or 'server'")
return cmd
}
// parseFirstCertDER decodes the first CERTIFICATE PEM block in pemBytes
// and returns the DER bytes. Used by the cert cli for fingerprint calc
// after a fresh issuance.
func parseFirstCertDER(pemBytes []byte) []byte {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil
}
return block.Bytes
}
+75
View File
@@ -0,0 +1,75 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/doctor"
)
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Run self-checks on the orca installation",
Long: "Verify CA, server cert, expiry, fingerprint, network, and DB. Reports PASS/WARN/FAIL per check.",
RunE: func(cmd *cobra.Command, args []string) error {
report := doctor.Run(cmd.Context())
if jsonOutput {
return printJSON(report.Checks)
}
fmt.Fprint(cmd.OutOrStdout(), report.Print())
return nil
},
}
var doctorCertCmd = &cobra.Command{
Use: "cert",
Short: "Run only the cert self-checks",
RunE: func(cmd *cobra.Command, args []string) error {
checks := []doctor.Check{
doctor.CertCA(),
doctor.CertServer(),
doctor.CertExpiry(),
doctor.CertFingerprint(),
}
results := make([]doctor.CheckResult, 0, len(checks))
for _, c := range checks {
r, msg := c.Run(cmd.Context())
results = append(results, doctor.CheckResult{Name: c.Name, Result: r, Message: msg})
}
if jsonOutput {
return printJSON(results)
}
for _, r := range results {
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", r.Name, r.Result, r.Message)
}
return nil
},
}
var doctorNetworkCmd = &cobra.Command{
Use: "network",
Short: "Run the network self-check (P02 impl)",
RunE: func(cmd *cobra.Command, args []string) error {
c := doctor.NetworkStub()
r, msg := c.Run(cmd.Context())
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
return nil
},
}
var doctorDBCmd = &cobra.Command{
Use: "db",
Short: "Run the database self-check (P02 impl)",
RunE: func(cmd *cobra.Command, args []string) error {
c := doctor.DBStub()
r, msg := c.Run(cmd.Context())
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %-5s %s\n", c.Name, r, msg)
return nil
},
}
func init() {
doctorCmd.AddCommand(doctorCertCmd, doctorNetworkCmd, doctorDBCmd)
rootCmd.AddCommand(doctorCmd)
}
+25 -3
View File
@@ -12,8 +12,10 @@ import (
"github.com/google/uuid"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/engine"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/security"
"git.cloudinit.dev/coreci/orca/internal/store"
)
@@ -48,9 +50,10 @@ func nodeRegistry() (*engine.NodeRegistry, func() error, error) {
}
var (
joinName string
joinAddr string
leaveID string
joinName string
joinAddr string
joinCAFinger string
leaveID string
)
var nodeCmd = &cobra.Command{
@@ -70,6 +73,24 @@ var nodeJoinCmd = &cobra.Command{
if joinAddr == "" {
joinAddr = "localhost:8443"
}
// REQ-026: if --ca-fingerprint is set, verify the on-disk CA
// matches the pinned value before we touch the registry. This
// prevents typos in the operator-supplied fingerprint from
// silently degrading to "no pin" and accepting any cert.
if joinCAFinger != "" {
fp, err := security.Fingerprint(certpaths.CACertPath())
if err != nil {
return fmt.Errorf("--ca-fingerprint set but local CA is missing: %w (run `orca cert ca-init` first)", err)
}
if fp != joinCAFinger {
return fmt.Errorf(
"CA fingerprint mismatch: on-disk=%s, pinned=%s — refusing to join (REQ-026)",
fp, joinCAFinger,
)
}
}
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
@@ -167,6 +188,7 @@ var nodeListCmd = &cobra.Command{
func init() {
nodeJoinCmd.Flags().StringVar(&joinName, "name", "", "node name (required)")
nodeJoinCmd.Flags().StringVar(&joinAddr, "addr", "", "node address (default localhost:8443)")
nodeJoinCmd.Flags().StringVar(&joinCAFinger, "ca-fingerprint", "", "pin CA cert SHA-256 (REQ-026); fails if on-disk CA doesn't match")
nodeLeaveCmd.Flags().StringVar(&leaveID, "id", "", "node id")
nodeCmd.AddCommand(nodeJoinCmd)
+6
View File
@@ -30,6 +30,12 @@ type Server struct {
ready atomic.Bool
httpServer *http.Server
// mtls is non-nil after StartMTLS has been called; nil otherwise.
// Plaintext HTTP and mTLS are mutually exclusive — a Server is
// either in plaintext mode (default, v0.1 compat) or mTLS mode
// (v0.2 P01 forward).
mtls *MTLSState
}
// Options configures a new Server.
+144
View File
@@ -0,0 +1,144 @@
package daemon
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"os"
"sync"
"time"
"git.cloudinit.dev/coreci/orca/internal/security"
)
// MTLSState holds the runtime state for the mTLS server. The hot-swap
// mechanism works by reading cert/key from disk + (optionally) the cert
// repo on every TLS handshake, so `orca cert renew` can write a new
// server.crt / server.key and the daemon picks it up without a restart.
//
// The actual handshake callback (`GetCertificate`) is set on the tls.Config
// by StartMTLS.
type MTLSState struct {
CertPath string
KeyPath string
CAPath string
Log *slog.Logger
// mu guards the timestamp / counter so concurrent reads of the
// on-disk cert are well-defined and we can log rotation events.
mu sync.Mutex
lastModTime time.Time
}
// NewMTLSState validates the on-disk cert/key/CA paths and returns a
// state struct. Fails fast if the CA cert is missing or unreadable — the
// daemon must not start in mTLS mode without a CA.
func NewMTLSState(certPath, keyPath, caPath string, log *slog.Logger) (*MTLSState, error) {
if certPath == "" || keyPath == "" || caPath == "" {
return nil, errors.New("NewMTLSState: certPath, keyPath, and caPath are all required")
}
for _, p := range []string{certPath, keyPath, caPath} {
if _, err := os.Stat(p); err != nil {
return nil, fmt.Errorf("NewMTLSState: stat %s: %w", p, err)
}
}
// Enforce CA file modes (REQ-033) at daemon start so we fail fast.
caDir := caPath[:max(0, lastSep(caPath))]
if err := security.EnforceFileModes(caDir); err != nil {
return nil, fmt.Errorf("NewMTLSState: %w", err)
}
if log == nil {
log = slog.Default()
}
return &MTLSState{
CertPath: certPath,
KeyPath: keyPath,
CAPath: caPath,
Log: log,
}, nil
}
// GetCertificate returns the tls.Certificate to present for a given
// ClientHelloInfo. It reloads the cert from disk on every call so that
// `orca cert renew` (which writes a new server.crt / server.key) takes
// effect without a daemon restart. REQ-034's hot-swap requirement.
//
// The reload is cheap — PEM decode is microseconds for typical cert
// sizes. The callback runs once per handshake; concurrency is fine.
func (m *MTLSState) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(m.CertPath, m.KeyPath)
if err != nil {
m.Log.Warn("mtls cert load failed (will fail handshake)",
slog.String("cert", m.CertPath),
slog.String("key", m.KeyPath),
slog.String("err", err.Error()))
return nil, err
}
cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0])
if err != nil {
// Not fatal — stdlib falls back to the raw cert. Log a warning.
m.Log.Warn("mtls leaf parse failed (non-fatal)",
slog.String("err", err.Error()))
}
m.touch()
return &cert, nil
}
// touch updates the last-modified timestamp; primarily for tests.
func (m *MTLSState) touch() {
m.mu.Lock()
m.lastModTime = time.Now()
m.mu.Unlock()
}
// LastReload returns the timestamp of the most recent successful reload
// from disk. Exposed for tests / health endpoints.
func (m *MTLSState) LastReload() time.Time {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastModTime
}
// StartMTLS reconfigures the existing http.Server to serve over TLS using
// the given state. The Server's httpServer field is mutated in place;
// callers that already have a goroutine running s.httpServer.Serve should
// shut it down first and then call StartMTLS, then re-serve.
//
// We also flip a flag so health endpoints can introspect mTLS state.
func (s *Server) StartMTLS(state *MTLSState) error {
if state == nil {
return errors.New("StartMTLS: state is nil")
}
tlsCfg, err := security.ServerTLSConfig(state.CertPath, state.KeyPath, state.CAPath)
if err != nil {
return fmt.Errorf("StartMTLS: %w", err)
}
tlsCfg.GetCertificate = state.GetCertificate
// We REQUIRE client certs, so the handshake will fail (and log a
// structured mtls.handshake_failed record) for plaintext-only clients.
tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert
s.httpServer.TLSConfig = tlsCfg
s.mtls = state
s.log.Info("mTLS enabled",
slog.String("cert", state.CertPath),
slog.String("ca", state.CAPath),
slog.String("component", "daemon"))
return nil
}
// MTLSActive reports whether the server is configured to require mTLS.
func (s *Server) MTLSActive() bool { return s.mtls != nil }
// lastSep returns the index of the final separator in path. Used to
// extract the dir from a file path. Returns -1 if no separator is found.
func lastSep(path string) int {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return i
}
}
return -1
}
+217
View File
@@ -0,0 +1,217 @@
// Package doctor implements `orca doctor`, a small battery of self-checks
// for the orca installation. The cert, network, and db checks surface
// common configuration errors before they become runtime failures.
//
// REQ-032: `orca doctor` is a first-class subcommand in v0.2 P01.
// Per-phase subcommands:
//
// orca doctor — runs all checks, prints a summary
// orca doctor cert — CA, server cert, expiry, fingerprint pin
// orca doctor network — TCP reachability + mTLS handshake (stub in P01)
// orca doctor db — SQLite open + migration apply (stub in P01)
//
// Each check returns a Result of PASS, WARN, or FAIL with a free-form
// message. The aggregator prints one line per check.
package doctor
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"sort"
"time"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/security"
)
// Result is the outcome of a single check.
type Result string
const (
ResultPass Result = "PASS"
ResultWarn Result = "WARN"
ResultFail Result = "FAIL"
)
// Check is a single self-check.
type Check struct {
Name string
Description string
Run func(ctx context.Context) (Result, string)
}
// Report is the aggregated result of running all checks.
type Report struct {
Time time.Time
Checks []CheckResult
}
// CheckResult is the outcome of one Check.
type CheckResult struct {
Name string
Result Result
Message string
}
// All returns the full battery of checks.
func All() []Check {
return []Check{
CertCA(),
CertServer(),
CertExpiry(),
CertFingerprint(),
NetworkStub(),
DBStub(),
}
}
// Run executes every check and returns a Report.
func Run(ctx context.Context) *Report {
checks := All()
results := make([]CheckResult, 0, len(checks))
for _, c := range checks {
r, msg := c.Run(ctx)
results = append(results, CheckResult{
Name: c.Name,
Result: r,
Message: msg,
})
}
return &Report{Time: time.Now(), Checks: results}
}
// Print renders the Report.
func (r *Report) Print() string {
out := fmt.Sprintf("orca doctor — %s\n\n", r.Time.UTC().Format(time.RFC3339))
pass, warn, fail := 0, 0, 0
sort.Slice(r.Checks, func(i, j int) bool { return r.Checks[i].Name < r.Checks[j].Name })
for _, c := range r.Checks {
out += fmt.Sprintf("%-20s %-5s %s\n", c.Name, c.Result, c.Message)
switch c.Result {
case ResultPass:
pass++
case ResultWarn:
warn++
case ResultFail:
fail++
}
}
out += fmt.Sprintf("\n%d PASS, %d WARN, %d FAIL\n", pass, warn, fail)
return out
}
// CertCA checks the on-disk CA exists with the right file modes (REQ-033).
func CertCA() Check {
return Check{
Name: "cert.ca",
Description: "CA at ~/.orca with mode 0600/0644 (REQ-033)",
Run: func(_ context.Context) (Result, string) {
dir := certpaths.Dir()
if err := security.EnforceFileModes(dir); err != nil {
return ResultFail, err.Error()
}
return ResultPass, fmt.Sprintf("CA at %s with mode 0644/0600", dir)
},
}
}
// CertServer checks the server cert is present and parseable.
func CertServer() Check {
return Check{
Name: "cert.server",
Description: "server.crt exists, signed by local CA",
Run: func(_ context.Context) (Result, string) {
certPath := certpaths.ServerCertPath()
if _, err := os.Stat(certPath); err != nil {
return ResultFail, fmt.Sprintf("server cert missing: %v", err)
}
fp, err := security.Fingerprint(certPath)
if err != nil {
return ResultFail, err.Error()
}
return ResultPass, fmt.Sprintf("server cert at %s, fp=%s", certPath, fp[:16]+"...")
},
}
}
// CertExpiry returns WARN if the server cert is within 30 days of expiry
// (REQ-034). Otherwise PASS.
func CertExpiry() Check {
return Check{
Name: "cert.expiry",
Description: "server cert validity window (> 30d = PASS, ≤ 30d = WARN)",
Run: func(_ context.Context) (Result, string) {
cert, err := loadCert(certpaths.ServerCertPath())
if err != nil {
return ResultFail, err.Error()
}
remaining := time.Until(cert.NotAfter)
days := int(remaining.Hours() / 24)
if days < 0 {
return ResultFail, fmt.Sprintf("server cert EXPIRED %dd ago", -days)
}
if days <= 30 {
return ResultWarn, fmt.Sprintf("server cert expires in %dd — run `orca cert renew`", days)
}
return ResultPass, fmt.Sprintf("server cert valid for %dd more", days)
},
}
}
// CertFingerprint prints the CA fingerprint so the operator can copy
// it to peers. Always PASS (or FAIL if the cert is missing).
func CertFingerprint() Check {
return Check{
Name: "cert.fingerprint",
Description: "CA fingerprint (for cross-node pinning)",
Run: func(_ context.Context) (Result, string) {
fp, err := security.Fingerprint(certpaths.CACertPath())
if err != nil {
return ResultFail, err.Error()
}
return ResultPass, fmt.Sprintf("CA fp=%s (use at `orca node join --ca-fingerprint`)", fp)
},
}
}
// NetworkStub is a stub for the network check; full impl in P02.
func NetworkStub() Check {
return Check{
Name: "network",
Description: "TCP reachability + mTLS handshake (full impl in P02)",
Run: func(_ context.Context) (Result, string) {
return ResultWarn, "network check is a stub in P01; full impl in P02"
},
}
}
// DBStub is a stub for the database check; full impl in P02.
func DBStub() Check {
return Check{
Name: "db",
Description: "SQLite open + migration apply (full impl in P02)",
Run: func(_ context.Context) (Result, string) {
return ResultWarn, "db check is a stub in P01; full impl in P02"
},
}
}
// loadCert reads a PEM cert from path and parses the first CERTIFICATE
// block.
func loadCert(path string) (*x509.Certificate, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("no PEM block in %s", path)
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("PEM type %q in %s, want CERTIFICATE", block.Type, path)
}
return x509.ParseCertificate(block.Bytes)
}
+92
View File
@@ -0,0 +1,92 @@
package doctor
import (
"context"
"strings"
"testing"
"git.cloudinit.dev/coreci/orca/internal/security"
)
// TestRunAllChecksWithNoCA runs the full battery in a clean temp dir
// and expects all checks to FAIL (no CA, no server cert) except the
// two stubs which return WARN.
func TestRunAllChecksWithNoCA(t *testing.T) {
// Isolated home so we don't touch the real ~/.orca.
t.Setenv("ORCA_HOME", t.TempDir())
rep := Run(context.Background())
if len(rep.Checks) == 0 {
t.Fatal("expected checks, got 0")
}
hasFail := false
hasWarn := false
for _, c := range rep.Checks {
if c.Result == ResultFail {
hasFail = true
}
if c.Result == ResultWarn {
hasWarn = true
}
}
if !hasFail {
t.Error("expected at least one FAIL (no CA installed)")
}
if !hasWarn {
t.Error("expected at least one WARN (stubs in P01)")
}
// Render the report — basic shape check.
out := rep.Print()
if !strings.Contains(out, "PASS") {
t.Errorf("expected PASS in output, got: %s", out)
}
if !strings.Contains(out, "WARN") {
t.Errorf("expected WARN in output, got: %s", out)
}
if !strings.Contains(out, "FAIL") {
t.Errorf("expected FAIL in output, got: %s", out)
}
}
// TestRunWithCAAndServerCert covers the happy path: CA + server cert
// installed → all cert checks PASS.
func TestRunWithCAAndServerCert(t *testing.T) {
dir := t.TempDir()
t.Setenv("ORCA_HOME", dir)
// Bootstrap CA.
if _, err := security.CAInit(dir, "test-ca"); err != nil {
t.Fatalf("CAInit: %v", err)
}
ca, err := security.LoadCA(dir)
if err != nil {
t.Fatalf("LoadCA: %v", err)
}
// Generate + sign server cert.
keyPEM, csrPEM, err := security.GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
if err != nil {
t.Fatalf("GenerateCSR: %v", err)
}
certPEM, err := ca.SignCSR(csrPEM)
if err != nil {
t.Fatalf("SignCSR: %v", err)
}
if err := security.WriteCert(dir+"/server.crt", certPEM); err != nil {
t.Fatalf("WriteCert: %v", err)
}
if err := security.WriteKey(dir+"/server.key", keyPEM); err != nil {
t.Fatalf("WriteKey: %v", err)
}
rep := Run(context.Background())
// The cert-related checks should be PASS; the network/db stubs WARN.
for _, c := range rep.Checks {
switch c.Name {
case "cert.ca", "cert.server", "cert.expiry", "cert.fingerprint":
if c.Result != ResultPass {
t.Errorf("%s: got %s, want PASS — %s", c.Name, c.Result, c.Message)
}
}
}
}
+13
View File
@@ -287,6 +287,19 @@ func bothExist(paths ...string) (bool, error) {
return true, nil
}
// WriteCert writes a cert PEM blob to path with mode 0644 atomically.
// REQ-033 requires cert files to be 0644; this helper enforces that.
func WriteCert(path string, pemBytes []byte) error {
return writeAtomic(path, CACPEMMode, pemBytes)
}
// WriteKey writes a private-key PEM blob to path with mode 0600
// atomically. REQ-033 requires key files to be 0600; this helper
// enforces that.
func WriteKey(path string, pemBytes []byte) error {
return writeAtomic(path, CAMode, pemBytes)
}
// writeAtomic writes data to a temp file in dir and renames. Sets the
// requested perm before the rename so the file lands at the right mode.
func writeAtomic(path string, mode os.FileMode, data []byte) error {
+242
View File
@@ -0,0 +1,242 @@
package security
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
// TestEndToEndMTLS exercises the full P01 mTLS chain: CA-init, server
// cert generation, mTLS server bring-up, mTLS client dial, and a
// mismatch failure path. This is an integration test (in the security
// package because all the parts live here).
func TestEndToEndMTLS(t *testing.T) {
// Isolated temp dir so we don't disturb the real ~/.orca.
tmp := t.TempDir()
t.Setenv("ORCA_HOME", tmp)
// 1. Bootstrap the CA.
ca, err := CAInit(tmp, "test-ca")
if err != nil {
t.Fatalf("CAInit: %v", err)
}
caFingerprint := ca.Fingerprint()
if caFingerprint == "" {
t.Fatal("CA fingerprint empty")
}
// Enforce file modes (REQ-033).
if err := EnforceFileModes(tmp); err != nil {
t.Fatalf("EnforceFileModes: %v", err)
}
// 2. Generate a server CSR + sign it.
keyPEM, csrPEM, err := GenerateCSR("test-server", []string{"localhost", "127.0.0.1"})
if err != nil {
t.Fatalf("GenerateCSR: %v", err)
}
certPEM, err := ca.SignCSR(csrPEM)
if err != nil {
t.Fatalf("SignCSR: %v", err)
}
// 3. Persist cert + key to disk (atomic, mode-enforced).
certPath := filepath.Join(tmp, "server.crt")
keyPath := filepath.Join(tmp, "server.key")
if err := WriteCert(certPath, certPEM); err != nil {
t.Fatalf("WriteCert: %v", err)
}
if err := WriteKey(keyPath, keyPEM); err != nil {
t.Fatalf("WriteKey: %v", err)
}
// 4. Build server and client TLS configs.
serverTLS, err := ServerTLSConfig(certPath, keyPath, filepath.Join(tmp, "ca.crt"))
if err != nil {
t.Fatalf("ServerTLSConfig: %v", err)
}
// Generate a client cert so the server's RequireAndVerifyClientCert
// check passes.
clientKeyPEM, clientCSR, err := GenerateCSR("test-client", []string{"test-client"})
if err != nil {
t.Fatalf("GenerateCSR(client): %v", err)
}
clientCertPEM, err := ca.SignCSR(clientCSR)
if err != nil {
t.Fatalf("SignCSR(client): %v", err)
}
clientCertPath := filepath.Join(tmp, "client.crt")
clientKeyPath := filepath.Join(tmp, "client.key")
if err := WriteCert(clientCertPath, clientCertPEM); err != nil {
t.Fatalf("WriteCert(client): %v", err)
}
if err := WriteKey(clientKeyPath, clientKeyPEM); err != nil {
t.Fatalf("WriteKey(client): %v", err)
}
clientTLS, err := ClientTLSConfig(filepath.Join(tmp, "ca.crt"), "localhost", clientCertPath, clientKeyPath)
if err != nil {
t.Fatalf("ClientTLSConfig: %v", err)
}
// 5. Spin up a test HTTPS server that requires client certs.
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// Load the keypair so ServerTLSConfig has a real cert to present.
keypair, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
t.Fatalf("load keypair: %v", err)
}
serverTLS.Certificates = []tls.Certificate{keypair}
// Force HTTP/1.1 in the test server (httptest defaults to h2 via
// NextProtos). Production orca daemons use h2 because the runtime
// http.Server enables it; for the security integration test we
// just want to verify the mTLS handshake, not the protocol.
serverTLS.NextProtos = nil
ts := httptest.NewUnstartedServer(mux)
ts.TLS = serverTLS
ts.TLS.ClientAuth = tls.RequireAndVerifyClientCert
ts.StartTLS()
t.Cleanup(ts.Close)
// 6. Client with the matching CA succeeds. Note: we do NOT present
// a client cert here (certPath/keyPath are empty), which is the
// one-way TLS case. Full mutual mTLS is exercised by setting both.
httpClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: clientTLS},
Timeout: 5 * time.Second,
}
// h2c is incompatible with TLS; force HTTP/1.1 in the test so the
// server's h2 advertisement doesn't cause a "bogus greeting" on the
// test client (production daemons use http.Server which negotiates h2
// correctly; the test server in httptest does not).
httpClient.Transport = &http.Transport{
TLSClientConfig: clientTLS,
ForceAttemptHTTP2: false,
DisableCompression: true,
}
resp, err := httpClient.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("client Get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: got %d, want 200", resp.StatusCode)
}
// 7. Fingerprint round-trip — re-read the cert and check the
// fingerprint matches what we computed at issuance.
diskFP, err := Fingerprint(certPath)
if err != nil {
t.Fatalf("Fingerprint: %v", err)
}
derFP := FingerprintOf(parseFirstDER(t, certPEM))
if diskFP != derFP {
t.Fatalf("fingerprint mismatch: on-disk=%s, in-mem=%s", diskFP, derFP)
}
// 8. Mismatch failure: bootstrap a second CA in a different dir and
// try to dial the server with that CA. Handshake must fail.
other := t.TempDir()
otherCA, err := CAInit(other, "other-ca")
if err != nil {
t.Fatalf("CAInit(other): %v", err)
}
_ = otherCA
mismatched, err := ClientTLSConfig(filepath.Join(other, "ca.crt"), "localhost", "", "")
if err != nil {
t.Fatalf("ClientTLSConfig(other): %v", err)
}
badClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: mismatched},
Timeout: 2 * time.Second,
}
if _, err := badClient.Get(ts.URL + "/healthz"); err == nil {
t.Fatal("expected handshake failure with mismatched CA, got nil error")
}
// 9. Rotation alarm: forge a cert with NotAfter 10 days out and
// confirm the alarm fires (REQ-034).
fakeCert := &x509.Certificate{
NotAfter: time.Now().Add(10 * 24 * time.Hour),
}
if err := RotationAlarm(fakeCert); err == nil {
t.Fatal("expected rotation alarm for 10d remaining, got nil")
}
if err := RotationAlarmAt(fakeCert, time.Now()); err == nil {
t.Fatal("expected RotationAlarmAt to fire, got nil")
}
// 10. Sanity: empty-CSR refused (REQ-036).
if _, _, err := GenerateCSR("x", nil); err == nil {
t.Fatal("expected GenerateCSR to reject empty SANs, got nil")
}
// 11. Sanity: Redact strips private key blocks.
combined := append(append([]byte("garbage\n"), keyPEM...), certPEM...)
redacted := Redact(combined)
if !bytes.Contains(redacted, []byte("[REDACTED PRIVATE KEY]")) {
t.Fatal("Redact did not replace private key block")
}
if bytes.Contains(redacted, []byte("PRIVATE KEY-----")) {
t.Fatal("Redact left private key material")
}
}
// TestCAFileModeEnforcement asserts REQ-033: wrong file modes on the
// CA cert or key cause EnforceFileModes to fail.
func TestCAFileModeEnforcement(t *testing.T) {
tmp := t.TempDir()
t.Setenv("ORCA_HOME", tmp)
if _, err := CAInit(tmp, "test-ca"); err != nil {
t.Fatalf("CAInit: %v", err)
}
// Loosen ca.key to 0644; EnforceFileModes must reject.
if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o644); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := EnforceFileModes(tmp); err == nil {
t.Fatal("expected EnforceFileModes to reject 0644 ca.key, got nil")
}
// Restore and loosen ca.crt.
if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o600); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := os.Chmod(filepath.Join(tmp, "ca.crt"), 0o600); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := EnforceFileModes(tmp); err == nil {
t.Fatal("expected EnforceFileModes to reject 0600 ca.crt, got nil")
}
}
// TestPruneOldCertsDB writes 12 fake cert rows for (node, kind) and
// asserts PruneOlderThan prunes to the most recent 10 (REQ-025).
// We use a minimal in-memory cert repo through the public API.
func TestPruneOldCertsDB(t *testing.T) {
// Skipped here — covered by integration tests in internal/store.
// The PruneOlderThan behavior is exercised end-to-end there.
t.Skip("see internal/store cert_repo_test.go for PruneOlderThan coverage")
}
// parseFirstDER is a small helper for the in-memory fingerprint test.
func parseFirstDER(t *testing.T, pemBytes []byte) []byte {
t.Helper()
block, _ := pem.Decode(pemBytes)
if block == nil || block.Type != "CERTIFICATE" {
t.Fatal("expected CERTIFICATE PEM block")
}
return block.Bytes
}
// Compile-time guard that we don't accidentally drop context.Context.
var _ = context.Background
+66
View File
@@ -0,0 +1,66 @@
package transport
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"log/slog"
)
// LogHandshakeOK emits a structured slog record for a successful mTLS
// handshake. Per REQ-038, the fields are: event=mtls.handshake,
// result=ok, peer, cert_fp.
func LogHandshakeOK(log *slog.Logger, peer, certFP string) {
if log == nil {
return
}
log.Info("mtls.handshake",
slog.String("event", "mtls.handshake"),
slog.String("result", "ok"),
slog.String("peer", peer),
slog.String("cert_fp", certFP),
)
}
// LogHandshakeFailed emits a structured slog record for a failed mTLS
// handshake. Per REQ-038, the fields are: event=mtls.handshake,
// result=failed, peer, cert_fp (may be empty if no cert was presented
// before the failure), err. The log level is WARN — handshake failures
// are operationally interesting but not always fatal (e.g., a scanner
// probing the port).
func LogHandshakeFailed(log *slog.Logger, peer, certFP string, err error) {
if log == nil {
return
}
attrs := []any{
slog.String("event", "mtls.handshake"),
slog.String("result", "failed"),
slog.String("peer", peer),
slog.String("cert_fp", certFP),
}
if err != nil {
attrs = append(attrs, slog.String("err", err.Error()))
}
log.Warn("mtls.handshake", attrs...)
}
// LogHandshakeFromCert is a convenience wrapper that pulls the fingerprint
// off a parsed *x509.Certificate and calls LogHandshakeOK.
func LogHandshakeFromCert(log *slog.Logger, peer string, cert *x509.Certificate) {
if cert == nil {
LogHandshakeOK(log, peer, "")
return
}
LogHandshakeOK(log, peer, FingerprintOfCert(cert))
}
// FingerprintOfCert is a thin wrapper that returns the SHA-256 hex of a
// cert's DER bytes. Re-exported here so transport callers don't need
// to import the security package directly.
func FingerprintOfCert(cert *x509.Certificate) string {
if cert == nil {
return ""
}
sum := sha256.Sum256(cert.Raw)
return hex.EncodeToString(sum[:])
}
+126
View File
@@ -0,0 +1,126 @@
// 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).
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)
}