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---
74 lines
2.2 KiB
Go
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)
|
|
}
|