Files
orca/internal/security/tls_config.go
T
Jon Chery 181cc769e6 feat(P08): CA, CSR, fingerprint, rotation, redact, TLS config + cert repo
Internal CA with CSR join, mTLS 1.3 config builders, rotation alarm,
PEM redaction, and cert inventory schema (REQ-033/034/035/036).

- internal/security/ca.go: CAInit/LoadCA/SignCSR, file mode enforcement
  (ca.crt 0644, ca.key 0600) per REQ-033
- internal/security/csr.go: GenerateCSR with DNS + IP SANs (REQ-036)
- internal/security/fingerprint.go: SHA-256 hex of cert DER
- internal/security/rotation.go: 30d pre-expiry alarm, history pruning
- internal/security/redact.go: PEM private key block stripping (REQ-035)
- internal/security/tls_config.go: TLS 1.3 with AEAD allowlist
- internal/security/certgen_test.go: round-trip + mode + rotation + redact
- internal/store/migrations/0004_certs.sql: cert inventory table
- internal/store/cert_repo.go: CRUD + PruneOlderThan (REQ-025)

---ci---
project: orca
phase: 8
milestone: v0.2
status: execute
---/ci---
2026-06-03 21:18:50 +00:00

132 lines
4.4 KiB
Go

package security
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"os"
)
// allowedSuites is the AEAD cipher allowlist required by D-015. We only
// support TLS 1.3, so the Go cipher suite names below are TLS 1.3 cipher
// suites. In Go 1.22+, the CipherSuites field still works for TLS 1.2
// negotiation, but with MinVersion=tls.VersionTLS13 only the TLS 1.3
// suites apply.
//
// We pin the three NIST/CHACHA AEAD suites:
// - TLS_AES_256_GCM_SHA384
// - TLS_CHACHA20_POLY1305_SHA256
// - TLS_AES_128_GCM_SHA256
//
// No TLS 1.2 fallback. No CBC modes. No NULL/integrity-only modes.
var allowedSuites = []uint16{
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_AES_128_GCM_SHA256,
}
// AllowedCipherSuites returns a copy of the cipher allowlist. Exposed for
// tests and for callers that want to construct their own tls.Config with
// the same policy.
func AllowedCipherSuites() []uint16 {
out := make([]uint16, len(allowedSuites))
copy(out, allowedSuites)
return out
}
// loadKeyPair is a small helper: load cert + key from disk, return
// tls.Certificate. Errors are wrapped with the path that failed.
func loadKeyPair(certPath, keyPath string) (tls.Certificate, error) {
if certPath == "" || keyPath == "" {
return tls.Certificate{}, errors.New("loadKeyPair: certPath and keyPath are required")
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return tls.Certificate{}, fmt.Errorf("load cert/key pair (%s, %s): %w", certPath, keyPath, err)
}
return cert, nil
}
// loadCAPool reads a PEM CA cert file and returns a CertPool containing
// that cert. We use the subject as the trust anchor — clients verify
// server certs against this single CA.
func loadCAPool(caPath string) (*x509.CertPool, error) {
if caPath == "" {
return nil, errors.New("loadCAPool: caPath is required")
}
caPEM, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("parse CA cert PEM from %s", caPath)
}
return pool, nil
}
// ServerTLSConfig returns a *tls.Config suitable for an mTLS server. The
// server presents certPath/keyPath and requires client certs signed by
// the CA at caPath. The cipher allowlist + MinVersion=1.3 are enforced.
//
// ClientCAs is the same pool as the trust store — peers present certs
// signed by the same CA, and we verify them. GetCertificate is left nil;
// callers (the daemon) populate it to enable hot-swap on cert renewal.
//
// Returns an error if any path is missing or any file cannot be read.
func ServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
if _, err := loadKeyPair(certPath, keyPath); err != nil {
return nil, err
}
pool, err := loadCAPool(caPath)
if err != nil {
return nil, err
}
return &tls.Config{
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
CipherSuites: AllowedCipherSuites(),
Certificates: []tls.Certificate{{Certificate: nil}}, // placeholder; daemon fills via GetCertificate
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
NextProtos: []string{"h2", "http/1.1"},
}, nil
}
// ClientTLSConfig returns a *tls.Config suitable for an mTLS client. The
// client verifies the server cert against the CA at caPath. If certPath
// and keyPath are both non-empty, the client also presents a cert (for
// mutual auth). If only one is set, the call fails — both-or-neither.
//
// serverName is the expected server identity (SNI / cert SAN match). It
// MUST match a SAN on the server cert; the standard tls.Config will then
// validate it during the handshake. For extra safety, callers should also
// use VerifyPeerCertificate to enforce a pinned peer identity.
func ClientTLSConfig(caPath, serverName string, certPath, keyPath string) (*tls.Config, error) {
pool, err := loadCAPool(caPath)
if err != nil {
return nil, err
}
cfg := &tls.Config{
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
CipherSuites: AllowedCipherSuites(),
RootCAs: pool,
ServerName: serverName,
NextProtos: []string{"h2", "http/1.1"},
}
hasCert, hasKey := certPath != "", keyPath != ""
if hasCert != hasKey {
return nil, errors.New("ClientTLSConfig: certPath and keyPath must be both set or both empty")
}
if hasCert && hasKey {
cert, err := loadKeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
cfg.Certificates = []tls.Certificate{cert}
}
return cfg, nil
}