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---
77 lines
2.4 KiB
Go
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
|
|
}
|