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

74 lines
2.2 KiB
Go

package security
import (
"context"
"crypto/x509"
"errors"
"fmt"
"time"
"git.cloudinit.dev/coreci/orca/internal/store"
)
// RotationWindow is the lead-time before expiry at which we start warning
// the operator. REQ-034 says 30 days.
const RotationWindow = 30 * 24 * time.Hour
// RotationAlarm checks cert's remaining validity. Returns nil if the cert
// has more than RotationWindow of life left. If remaining <= RotationWindow,
// returns a non-nil error wrapping the days-remaining message so callers
// can log it. Callers MUST treat a non-nil result as a warning, not a fatal
// error — the cert is still usable; we want to alert the operator ahead
// of time.
func RotationAlarm(cert *x509.Certificate) error {
if cert == nil {
return errors.New("RotationAlarm: nil cert")
}
now := time.Now()
remaining := cert.NotAfter.Sub(now)
if remaining > RotationWindow {
return nil
}
days := int(remaining.Hours() / 24)
if days < 0 {
days = 0
}
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
days, cert.NotAfter.UTC().Format(time.RFC3339))
}
// RotationAlarmAt is identical to RotationAlarm but takes an explicit "now"
// for deterministic testing.
func RotationAlarmAt(cert *x509.Certificate, now time.Time) error {
if cert == nil {
return errors.New("RotationAlarmAt: nil cert")
}
remaining := cert.NotAfter.Sub(now)
if remaining > RotationWindow {
return nil
}
days := int(remaining.Hours() / 24)
if days < 0 {
days = 0
}
return fmt.Errorf("cert rotates in %d days (NotAfter=%s) — renew soon (REQ-034)",
days, cert.NotAfter.UTC().Format(time.RFC3339))
}
// PruneOldCerts deletes all certs for (nodeID, kind) beyond the most recent
// `keep` rows, ordered by created_at DESC. Per REQ-025, the rotation
// history is bounded at 10 generations per cert kind. Returns the number
// of rows deleted.
//
// `keep` is a positive integer; values <= 0 are treated as 10 (the
// documented max).
func PruneOldCerts(ctx context.Context, repo *store.CertRepo, nodeID, kind string, keep int) (int64, error) {
if repo == nil {
return 0, errors.New("PruneOldCerts: nil repo")
}
if keep <= 0 {
keep = 10
}
return repo.PruneOlderThan(ctx, nodeID, kind, keep)
}