181cc769e6
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---
132 lines
4.4 KiB
Go
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
|
|
}
|