Files
orca/internal/security/csr.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

77 lines
2.4 KiB
Go

package security
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"net"
)
// GenerateCSR mints a new RSA private key, builds a CSR with the given
// commonName and SANs (DNS or IP entries), and returns the key + CSR in
// PEM form. The private key is RSA 3072 (matches CAKeySize).
//
// REQ-036: server certs MUST have at least one DNS or IP SAN. This function
// enforces that constraint — calling with empty sans returns an error.
//
// Validation: dns entries must be syntactically valid hostnames; ip entries
// must be parseable by net.ParseIP. Bad inputs are rejected up-front so
// the operator gets a clear error before signing.
func GenerateCSR(commonName string, sans []string) (keyPEM, csrPEM []byte, err error) {
if commonName == "" {
return nil, nil, errors.New("GenerateCSR: commonName is required")
}
if len(sans) == 0 {
return nil, nil, errors.New("GenerateCSR: at least one DNS or IP SAN is required (REQ-036)")
}
dnsNames := make([]string, 0, len(sans))
ipAddrs := make([]net.IP, 0, len(sans))
for _, s := range sans {
if s == "" {
return nil, nil, errors.New("GenerateCSR: empty SAN entry")
}
if ip := net.ParseIP(s); ip != nil {
ipAddrs = append(ipAddrs, ip)
continue
}
// Treat as a DNS name. Validate it parses and is not a host:port form.
if _, _, err := net.SplitHostPort(s); err == nil {
return nil, nil, fmt.Errorf("GenerateCSR: SAN %q looks like host:port; use a bare hostname or IP", s)
}
dnsNames = append(dnsNames, s)
}
if len(dnsNames) == 0 && len(ipAddrs) == 0 {
return nil, nil, errors.New("GenerateCSR: at least one valid DNS or IP SAN is required (REQ-036)")
}
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
if err != nil {
return nil, nil, fmt.Errorf("GenerateCSR: generate key: %w", err)
}
csr := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
Organization: []string{"orca"},
},
DNSNames: dnsNames,
IPAddresses: ipAddrs,
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
if err != nil {
return nil, nil, fmt.Errorf("GenerateCSR: create CSR: %w", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, nil, fmt.Errorf("GenerateCSR: marshal key: %w", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
csrPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
return keyPEM, csrPEM, nil
}