Files
orca/internal/security/ca.go
T
ciagent 31ccb52114 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---
2026-06-03 21:33:41 +00:00

336 lines
11 KiB
Go

package security
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
)
// CAValidity is how long a CA cert is valid. Per D-013, the CA is long-lived
// (10 years) because manual rotation is expensive.
const CAValidity = 10 * 365 * 24 * time.Hour
// ServerCertValidity is the default validity window for server certs. D-013
// says server certs are short-lived (90 days) to limit the compromise window.
const ServerCertValidity = 90 * 24 * time.Hour
// CAKeySize is the RSA key size used for both CA and server certs. 3072 is
// the minimum we accept for v0.2 — matches REQ-033 spirit and Go's stdlib
// defaults for new RSA keys are typically 2048 or 4096. 3072 is the
// sweet spot for balance of safety and key-gen latency.
const CAKeySize = 3072
// CAMode is the file mode used when persisting the CA private key. REQ-033
// requires 0600.
const CAMode os.FileMode = 0o600
// CACPEMMode is the file mode used when persisting the CA public cert.
// REQ-033 requires 0644 (public, but still mode-pinned).
const CACPEMMode os.FileMode = 0o644
// File names used inside the CA directory.
const (
CACertFile = "ca.crt"
CAKeyFile = "ca.key"
)
// CA wraps a loaded CA. Use CAInit to mint a new one, LoadCA to read an
// existing one from disk.
type CA struct {
Cert *x509.Certificate
Key *rsa.PrivateKey
CertPEM []byte
Dir string
NotBefore time.Time
NotAfter time.Time
}
// CAInit creates a fresh self-signed CA and persists it to dir/ca.crt and
// dir/ca.key with the required file modes (REQ-033). If the CA files already
// exist with valid content, the existing CA is returned — idempotent.
//
// commonName is the CA's CommonName (typically an org/cluster identifier).
// Returns a *CA wrapping the loaded cert + key. The CA is valid for
// CAValidity from now.
func CAInit(dir, commonName string) (*CA, error) {
if dir == "" {
return nil, errors.New("CAInit: dir is required")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("CAInit: mkdir: %w", err)
}
certPath := filepath.Join(dir, CACertFile)
keyPath := filepath.Join(dir, CAKeyFile)
// Fast path: existing CA — load and return.
if ok, err := bothExist(certPath, keyPath); err != nil {
return nil, err
} else if ok {
// Verify file modes on the existing CA (REQ-033).
if err := EnforceFileModes(dir); err != nil {
return nil, err
}
return LoadCA(dir)
}
// Generate key.
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
if err != nil {
return nil, fmt.Errorf("CAInit: generate key: %w", err)
}
// Self-signed cert. We use x509.Certificate directly to set the CA
// extensions. Serial number is random 128 bits.
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("CAInit: serial: %w", err)
}
now := time.Now().UTC()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: commonName,
Organization: []string{"orca-internal-ca"},
},
NotBefore: now.Add(-1 * time.Hour),
NotAfter: now.Add(CAValidity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
MaxPathLenZero: false,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("CAInit: create cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("CAInit: marshal key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
// Atomic write: temp file + rename. This avoids leaving a half-written
// ca.key on disk if the process crashes mid-write.
if err := writeAtomic(certPath, CACPEMMode, certPEM); err != nil {
return nil, err
}
if err := writeAtomic(keyPath, CAMode, keyPEM); err != nil {
return nil, err
}
return LoadCA(dir)
}
// LoadCA reads a previously-initialized CA from disk. Returns a *CA or an
// error. Verifies file modes (REQ-033).
func LoadCA(dir string) (*CA, error) {
if dir == "" {
return nil, errors.New("LoadCA: dir is required")
}
certPath := filepath.Join(dir, CACertFile)
keyPath := filepath.Join(dir, CAKeyFile)
if err := EnforceFileModes(dir); err != nil {
return nil, err
}
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("LoadCA: read cert: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("LoadCA: read key: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, fmt.Errorf("LoadCA: cert PEM decode failed")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("LoadCA: parse cert: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, fmt.Errorf("LoadCA: key PEM decode failed")
}
keyAny, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("LoadCA: parse key: %w", err)
}
key, ok := keyAny.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("LoadCA: key is %T, not *rsa.PrivateKey", keyAny)
}
return &CA{
Cert: cert,
Key: key,
CertPEM: certPEM,
Dir: dir,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
}, nil
}
// EnforceFileModes refuses to operate if ca.crt / ca.key do not have the
// required modes (REQ-033). Returns nil on success. Callers (daemon start,
// CA loaders) MUST call this and abort on error.
func EnforceFileModes(dir string) error {
certPath := filepath.Join(dir, CACertFile)
keyPath := filepath.Join(dir, CAKeyFile)
certInfo, err := os.Stat(certPath)
if err != nil {
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
}
keyInfo, err := os.Stat(keyPath)
if err != nil {
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
}
if certInfo.Mode().Perm() != CACPEMMode {
return fmt.Errorf(
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
)
}
if keyInfo.Mode().Perm() != CAMode {
return fmt.Errorf(
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
)
}
return nil
}
// SignCSR signs a PEM-encoded CSR with the CA and returns the issued cert
// in PEM form. The resulting cert is valid for ServerCertValidity and
// inherits the SANs from the CSR (DNS, IP). If the CSR has no SANs, the
// call fails — REQ-036 requires server certs to have identifying SANs.
func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) {
if c == nil || c.Cert == nil || c.Key == nil {
return nil, errors.New("SignCSR: nil CA")
}
block, _ := pem.Decode(csrPEM)
if block == nil {
return nil, errors.New("SignCSR: CSR PEM decode failed")
}
if block.Type != "CERTIFICATE REQUEST" && block.Type != "NEW CERTIFICATE REQUEST" {
return nil, fmt.Errorf("SignCSR: unexpected PEM type %q", block.Type)
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, fmt.Errorf("SignCSR: parse CSR: %w", err)
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("SignCSR: CSR signature invalid: %w", err)
}
// REQ-036: refuse CSRs without SANs. A server cert needs at least
// one DNS or IP SAN so the peer can verify it against a pinned identity.
if len(csr.DNSNames) == 0 && len(csr.IPAddresses) == 0 {
return nil, errors.New("SignCSR: CSR has no DNS or IP SANs (REQ-036) — must include at least one")
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("SignCSR: serial: %w", err)
}
now := time.Now().UTC()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: csr.Subject,
NotBefore: now.Add(-1 * time.Hour),
NotAfter: now.Add(ServerCertValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
DNSNames: csr.DNSNames,
IPAddresses: csr.IPAddresses,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, csr.PublicKey, c.Key)
if err != nil {
return nil, fmt.Errorf("SignCSR: create cert: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
}
// Fingerprint returns the SHA-256 hex fingerprint of the CA cert. Useful
// for the operator to communicate to peers out-of-band; peers then pin
// this value at `orca node join --ca-fingerprint <sha>`.
func (c *CA) Fingerprint() string {
return FingerprintOf(c.Cert.Raw)
}
// bothExist returns true if both paths exist (regular files).
func bothExist(paths ...string) (bool, error) {
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !info.Mode().IsRegular() {
return false, fmt.Errorf("not a regular file: %s", p)
}
}
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 {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("writeAtomic: create temp: %w", err)
}
tmpName := tmp.Name()
// Best-effort cleanup if we fail before rename.
defer func() {
_ = os.Remove(tmpName)
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: write: %w", err)
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: chmod: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("writeAtomic: close: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("writeAtomic: rename: %w", err)
}
return nil
}