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---
This commit is contained in:
Jon Chery
2026-06-03 21:18:50 +00:00
parent bed5a2e8e5
commit 181cc769e6
10 changed files with 1298 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
package security
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
)
// CAValidity is how long a CA cert is valid. Per D-013, the CA is long-lived
// (10 years) because manual rotation is expensive.
const CAValidity = 10 * 365 * 24 * time.Hour
// ServerCertValidity is the default validity window for server certs. D-013
// says server certs are short-lived (90 days) to limit the compromise window.
const ServerCertValidity = 90 * 24 * time.Hour
// CAKeySize is the RSA key size used for both CA and server certs. 3072 is
// the minimum we accept for v0.2 — matches REQ-033 spirit and Go's stdlib
// defaults for new RSA keys are typically 2048 or 4096. 3072 is the
// sweet spot for balance of safety and key-gen latency.
const CAKeySize = 3072
// CAMode is the file mode used when persisting the CA private key. REQ-033
// requires 0600.
const CAMode os.FileMode = 0o600
// CACPEMMode is the file mode used when persisting the CA public cert.
// REQ-033 requires 0644 (public, but still mode-pinned).
const CACPEMMode os.FileMode = 0o644
// File names used inside the CA directory.
const (
CACertFile = "ca.crt"
CAKeyFile = "ca.key"
)
// CA wraps a loaded CA. Use CAInit to mint a new one, LoadCA to read an
// existing one from disk.
type CA struct {
Cert *x509.Certificate
Key *rsa.PrivateKey
CertPEM []byte
Dir string
NotBefore time.Time
NotAfter time.Time
}
// CAInit creates a fresh self-signed CA and persists it to dir/ca.crt and
// dir/ca.key with the required file modes (REQ-033). If the CA files already
// exist with valid content, the existing CA is returned — idempotent.
//
// commonName is the CA's CommonName (typically an org/cluster identifier).
// Returns a *CA wrapping the loaded cert + key. The CA is valid for
// CAValidity from now.
func CAInit(dir, commonName string) (*CA, error) {
if dir == "" {
return nil, errors.New("CAInit: dir is required")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("CAInit: mkdir: %w", err)
}
certPath := filepath.Join(dir, CACertFile)
keyPath := filepath.Join(dir, CAKeyFile)
// Fast path: existing CA — load and return.
if ok, err := bothExist(certPath, keyPath); err != nil {
return nil, err
} else if ok {
// Verify file modes on the existing CA (REQ-033).
if err := EnforceFileModes(dir); err != nil {
return nil, err
}
return LoadCA(dir)
}
// Generate key.
key, err := rsa.GenerateKey(rand.Reader, CAKeySize)
if err != nil {
return nil, fmt.Errorf("CAInit: generate key: %w", err)
}
// Self-signed cert. We use x509.Certificate directly to set the CA
// extensions. Serial number is random 128 bits.
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("CAInit: serial: %w", err)
}
now := time.Now().UTC()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: commonName,
Organization: []string{"orca-internal-ca"},
},
NotBefore: now.Add(-1 * time.Hour),
NotAfter: now.Add(CAValidity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
MaxPathLenZero: false,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("CAInit: create cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, fmt.Errorf("CAInit: marshal key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
// Atomic write: temp file + rename. This avoids leaving a half-written
// ca.key on disk if the process crashes mid-write.
if err := writeAtomic(certPath, CACPEMMode, certPEM); err != nil {
return nil, err
}
if err := writeAtomic(keyPath, CAMode, keyPEM); err != nil {
return nil, err
}
return LoadCA(dir)
}
// LoadCA reads a previously-initialized CA from disk. Returns a *CA or an
// error. Verifies file modes (REQ-033).
func LoadCA(dir string) (*CA, error) {
if dir == "" {
return nil, errors.New("LoadCA: dir is required")
}
certPath := filepath.Join(dir, CACertFile)
keyPath := filepath.Join(dir, CAKeyFile)
if err := EnforceFileModes(dir); err != nil {
return nil, err
}
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("LoadCA: read cert: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("LoadCA: read key: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, fmt.Errorf("LoadCA: cert PEM decode failed")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("LoadCA: parse cert: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, fmt.Errorf("LoadCA: key PEM decode failed")
}
keyAny, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("LoadCA: parse key: %w", err)
}
key, ok := keyAny.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("LoadCA: key is %T, not *rsa.PrivateKey", keyAny)
}
return &CA{
Cert: cert,
Key: key,
CertPEM: certPEM,
Dir: dir,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
}, nil
}
// EnforceFileModes refuses to operate if ca.crt / ca.key do not have the
// required modes (REQ-033). Returns nil on success. Callers (daemon start,
// CA loaders) MUST call this and abort on error.
func EnforceFileModes(dir string) error {
certPath := filepath.Join(dir, CACertFile)
keyPath := filepath.Join(dir, CAKeyFile)
certInfo, err := os.Stat(certPath)
if err != nil {
return fmt.Errorf("EnforceFileModes: stat %s: %w", certPath, err)
}
keyInfo, err := os.Stat(keyPath)
if err != nil {
return fmt.Errorf("EnforceFileModes: stat %s: %w", keyPath, err)
}
if certInfo.Mode().Perm() != CACPEMMode {
return fmt.Errorf(
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
certPath, certInfo.Mode().Perm(), CACPEMMode, CACPEMMode, certPath,
)
}
if keyInfo.Mode().Perm() != CAMode {
return fmt.Errorf(
"REQ-033 violation: %s has mode %04o, want %04o — fix with `chmod %04o %s`",
keyPath, keyInfo.Mode().Perm(), CAMode, CAMode, keyPath,
)
}
return nil
}
// SignCSR signs a PEM-encoded CSR with the CA and returns the issued cert
// in PEM form. The resulting cert is valid for ServerCertValidity and
// inherits the SANs from the CSR (DNS, IP). If the CSR has no SANs, the
// call fails — REQ-036 requires server certs to have identifying SANs.
func (c *CA) SignCSR(csrPEM []byte) ([]byte, error) {
if c == nil || c.Cert == nil || c.Key == nil {
return nil, errors.New("SignCSR: nil CA")
}
block, _ := pem.Decode(csrPEM)
if block == nil {
return nil, errors.New("SignCSR: CSR PEM decode failed")
}
if block.Type != "CERTIFICATE REQUEST" && block.Type != "NEW CERTIFICATE REQUEST" {
return nil, fmt.Errorf("SignCSR: unexpected PEM type %q", block.Type)
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, fmt.Errorf("SignCSR: parse CSR: %w", err)
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("SignCSR: CSR signature invalid: %w", err)
}
// REQ-036: refuse CSRs without SANs. A server cert needs at least
// one DNS or IP SAN so the peer can verify it against a pinned identity.
if len(csr.DNSNames) == 0 && len(csr.IPAddresses) == 0 {
return nil, errors.New("SignCSR: CSR has no DNS or IP SANs (REQ-036) — must include at least one")
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("SignCSR: serial: %w", err)
}
now := time.Now().UTC()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: csr.Subject,
NotBefore: now.Add(-1 * time.Hour),
NotAfter: now.Add(ServerCertValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
DNSNames: csr.DNSNames,
IPAddresses: csr.IPAddresses,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, c.Cert, csr.PublicKey, c.Key)
if err != nil {
return nil, fmt.Errorf("SignCSR: create cert: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
}
// Fingerprint returns the SHA-256 hex fingerprint of the CA cert. Useful
// for the operator to communicate to peers out-of-band; peers then pin
// this value at `orca node join --ca-fingerprint <sha>`.
func (c *CA) Fingerprint() string {
return FingerprintOf(c.Cert.Raw)
}
// bothExist returns true if both paths exist (regular files).
func bothExist(paths ...string) (bool, error) {
for _, p := range paths {
info, err := os.Stat(p)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !info.Mode().IsRegular() {
return false, fmt.Errorf("not a regular file: %s", p)
}
}
return true, nil
}
// writeAtomic writes data to a temp file in dir and renames. Sets the
// requested perm before the rename so the file lands at the right mode.
func writeAtomic(path string, mode os.FileMode, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("writeAtomic: create temp: %w", err)
}
tmpName := tmp.Name()
// Best-effort cleanup if we fail before rename.
defer func() {
_ = os.Remove(tmpName)
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: write: %w", err)
}
if err := tmp.Chmod(mode); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: chmod: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("writeAtomic: sync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("writeAtomic: close: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("writeAtomic: rename: %w", err)
}
return nil
}
+309
View File
@@ -0,0 +1,309 @@
package security
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"os"
"path/filepath"
"testing"
"time"
)
// TestRoundTrip exercises the full CA → CSR → SignCSR → x509.Verify chain
// in a single test. The point is to catch protocol mismatches early: if
// SignCSR produces a cert that doesn't chain to the CA, the verification
// step will fail and this test will surface the bug.
func TestRoundTrip(t *testing.T) {
dir := t.TempDir()
// 1. Init a CA.
ca, err := CAInit(dir, "orca-test-ca")
if err != nil {
t.Fatalf("CAInit: %v", err)
}
if ca == nil || ca.Cert == nil {
t.Fatal("CAInit returned nil cert")
}
if !ca.Cert.IsCA {
t.Error("CA cert IsCA is false")
}
if got := ca.Cert.KeyUsage & x509.KeyUsageCertSign; got == 0 {
t.Error("CA cert missing KeyUsageCertSign")
}
// 2. Generate a server CSR with SANs.
commonName := "test.orca.local"
sans := []string{"test.orca.local", "127.0.0.1"}
keyPEM, csrPEM, err := GenerateCSR(commonName, sans)
if err != nil {
t.Fatalf("GenerateCSR: %v", err)
}
if len(keyPEM) == 0 || len(csrPEM) == 0 {
t.Fatal("GenerateCSR returned empty PEM")
}
// 3. Sign the CSR.
signedPEM, err := ca.SignCSR(csrPEM)
if err != nil {
t.Fatalf("SignCSR: %v", err)
}
if len(signedPEM) == 0 {
t.Fatal("SignCSR returned empty cert")
}
// 4. Verify the chain programmatically with x509.Verify.
caPool := x509.NewCertPool()
caPool.AddCert(ca.Cert)
leafBlock, _ := pem.Decode(signedPEM)
if leafBlock == nil {
t.Fatal("pem.Decode: no cert block")
}
leaf, err := x509.ParseCertificate(leafBlock.Bytes)
if err != nil {
t.Fatalf("ParseCertificate (leaf): %v", err)
}
_, err = leaf.Verify(x509.VerifyOptions{
Roots: caPool,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
CurrentTime: time.Now(),
})
if err != nil {
t.Fatalf("leaf.Verify: %v", err)
}
// 5. Sanity-check the SANs survived signing.
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "test.orca.local" {
t.Errorf("expected DNS SAN [test.orca.local], got %v", leaf.DNSNames)
}
if len(leaf.IPAddresses) != 1 || leaf.IPAddresses[0].String() != "127.0.0.1" {
t.Errorf("expected IP SAN [127.0.0.1], got %v", leaf.IPAddresses)
}
if leaf.Subject.CommonName != commonName {
t.Errorf("expected CN %q, got %q", commonName, leaf.Subject.CommonName)
}
// 6. CA fingerprint pin should match the on-disk ca.crt.
caFingerprint, err := Fingerprint(filepath.Join(dir, CACertFile))
if err != nil {
t.Fatalf("Fingerprint: %v", err)
}
if caFingerprint != ca.Fingerprint() {
t.Errorf("Fingerprint mismatch: file=%q CA.Fingerprint()=%q", caFingerprint, ca.Fingerprint())
}
if len(caFingerprint) != 64 {
t.Errorf("expected 64 hex chars, got %d (%q)", len(caFingerprint), caFingerprint)
}
}
// TestCAFileModes verifies REQ-033: ca.crt must be 0644, ca.key must be 0600.
func TestCAFileModes(t *testing.T) {
dir := t.TempDir()
if _, err := CAInit(dir, "orca-mode-test"); err != nil {
t.Fatalf("CAInit: %v", err)
}
certInfo, err := os.Stat(filepath.Join(dir, CACertFile))
if err != nil {
t.Fatalf("stat ca.crt: %v", err)
}
keyInfo, err := os.Stat(filepath.Join(dir, CAKeyFile))
if err != nil {
t.Fatalf("stat ca.key: %v", err)
}
if got := certInfo.Mode().Perm(); got != CACPEMMode {
t.Errorf("ca.crt mode = %04o, want %04o (REQ-033)", got, CACPEMMode)
}
if got := keyInfo.Mode().Perm(); got != CAMode {
t.Errorf("ca.key mode = %04o, want %04o (REQ-033)", got, CAMode)
}
}
// TestCAEnforceFileModes verifies that EnforceFileModes refuses to load a CA
// whose file modes are wrong (e.g., ca.key is world-readable).
func TestCAEnforceFileModes(t *testing.T) {
dir := t.TempDir()
if _, err := CAInit(dir, "orca-enforce-test"); err != nil {
t.Fatalf("CAInit: %v", err)
}
// Make ca.key world-readable — should fail EnforceFileModes.
if err := os.Chmod(filepath.Join(dir, CAKeyFile), 0o644); err != nil {
t.Fatalf("chmod: %v", err)
}
if err := EnforceFileModes(dir); err == nil {
t.Error("expected EnforceFileModes to fail with world-readable ca.key")
}
// And LoadCA should refuse too.
if _, err := LoadCA(dir); err == nil {
t.Error("expected LoadCA to fail with world-readable ca.key")
}
// Restore mode; should pass again.
if err := os.Chmod(filepath.Join(dir, CAKeyFile), CAMode); err != nil {
t.Fatalf("chmod restore: %v", err)
}
if err := EnforceFileModes(dir); err != nil {
t.Errorf("EnforceFileModes after restore: %v", err)
}
}
// TestRotationAlarmFiresAt30Days verifies REQ-034: a cert with NotAfter
// 30 days from now triggers RotationAlarm; a cert with 31 days does not.
func TestRotationAlarmFiresAt30Days(t *testing.T) {
now := time.Now()
tests := []struct {
name string
notAfter time.Time
wantError bool
}{
{
name: "31 days remaining",
notAfter: now.Add(31 * 24 * time.Hour),
wantError: false,
},
{
name: "30 days remaining (boundary, fires)",
notAfter: now.Add(30 * 24 * time.Hour),
wantError: true,
},
{
name: "15 days remaining (fires)",
notAfter: now.Add(15 * 24 * time.Hour),
wantError: true,
},
{
name: "expired (fires, days=0)",
notAfter: now.Add(-1 * time.Hour),
wantError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cert := &x509.Certificate{NotAfter: tc.notAfter}
err := RotationAlarmAt(cert, now)
if tc.wantError && err == nil {
t.Errorf("expected alarm, got nil")
}
if !tc.wantError && err != nil {
t.Errorf("expected no alarm, got %v", err)
}
})
}
}
// TestRedactStripsPrivateKey verifies REQ-035: the Redact helper strips
// PEM private key blocks from arbitrary input.
func TestRedactStripsPrivateKey(t *testing.T) {
in := []byte(`hello
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxxxx
-----END RSA PRIVATE KEY-----
world
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgI...
-----END CERTIFICATE-----
trailing
`)
out := string(Redact(in))
if contains(out, "PRIVATE KEY-----") {
t.Errorf("Redact output still contains PRIVATE KEY header: %q", out)
}
if contains(out, "BEGIN RSA PRIVATE KEY") {
t.Errorf("Redact output still contains BEGIN RSA PRIVATE KEY: %q", out)
}
if !contains(out, "[REDACTED PRIVATE KEY]") {
t.Errorf("expected redaction marker in output: %q", out)
}
if !contains(out, "BEGIN CERTIFICATE") {
t.Errorf("expected CERTIFICATE block to survive redaction: %q", out)
}
if !contains(out, "hello") || !contains(out, "world") || !contains(out, "trailing") {
t.Errorf("expected non-key content preserved: %q", out)
}
}
// TestRedactNoKey verifies Redact is a no-op (other than a copy) when no
// private key blocks are present.
func TestRedactNoKey(t *testing.T) {
in := []byte("just a cert\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n")
out := string(Redact(in))
if out != string(in) {
t.Errorf("Redact changed input without any keys present:\n got=%q\nwant=%q", out, in)
}
}
// TestGenerateCSRRequiresSANs verifies REQ-036: a CSR without any SANs is
// rejected at generation time.
func TestGenerateCSRRequiresSANs(t *testing.T) {
if _, _, err := GenerateCSR("foo", nil); err == nil {
t.Error("expected GenerateCSR to fail with empty sans")
}
if _, _, err := GenerateCSR("", []string{"foo"}); err == nil {
t.Error("expected GenerateCSR to fail with empty commonName")
}
}
// TestSignCSRRejectsSANless verifies REQ-036: even a syntactically valid CSR
// with no SANs is rejected at sign-time.
func TestSignCSRRejectsSANless(t *testing.T) {
dir := t.TempDir()
ca, err := CAInit(dir, "orca-sign-reject-test")
if err != nil {
t.Fatalf("CAInit: %v", err)
}
// Build a CSR directly with no SANs to bypass the GenerateCSR guard.
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
csr := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: "nosan.example"},
DNSNames: nil,
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csr, key)
if err != nil {
t.Fatalf("CreateCertificateRequest: %v", err)
}
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
if _, err := ca.SignCSR(csrPEM); err == nil {
t.Error("expected SignCSR to fail with SAN-less CSR (REQ-036)")
}
}
// TestFingerprintStable verifies the SHA-256 hex is identical across two
// computations of the same DER.
func TestFingerprintStable(t *testing.T) {
dir := t.TempDir()
if _, err := CAInit(dir, "orca-fp-test"); err != nil {
t.Fatalf("CAInit: %v", err)
}
caPEM, err := os.ReadFile(filepath.Join(dir, CACertFile))
if err != nil {
t.Fatalf("read ca.crt: %v", err)
}
der, err := firstCertDER(caPEM)
if err != nil {
t.Fatalf("firstCertDER: %v", err)
}
fp1 := FingerprintOf(der)
fp2 := FingerprintOf(der)
if fp1 != fp2 {
t.Errorf("FingerprintOf not stable: %q vs %q", fp1, fp2)
}
if len(fp1) != 64 {
t.Errorf("expected 64 hex chars, got %d", len(fp1))
}
}
func contains(haystack, needle string) bool {
return indexOf(haystack, needle) >= 0
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+76
View File
@@ -0,0 +1,76 @@
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
}
+17
View File
@@ -0,0 +1,17 @@
// Package security provides certificate authority, CSR signing, TLS
// configuration, and rotation helpers for orca's mTLS transport.
//
// The CA model is internal + operator-mediated (per PROJECT.md D-011, D-012):
//
// - The bootstrap node runs CAInit(dir) to mint a self-signed CA and persist
// ca.crt (0644) + ca.key (0600). Mode enforcement is intentional — REQ-033
// requires the daemon to refuse to start if the file modes are wrong.
// - Operators copy ca.crt to peers out-of-band.
// - Peers run GenerateCSR to produce a CSR + key, ship the CSR to the CA
// node, which calls SignCSR to produce a server cert. The peer verifies
// the on-disk CA cert's SHA-256 fingerprint at `node join` time against
// a pinned value (REQ-026) — fail fast on CA mismatch (D-014).
//
// All certificate operations use the Go standard library (no external
// crypto deps) per the v0.2 plan's "no new direct deps for P01" rule.
package security
+58
View File
@@ -0,0 +1,58 @@
package security
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"os"
)
// Fingerprint returns the SHA-256 hex digest of the certificate's DER bytes,
// computed from the on-disk PEM at certPath. The output is lowercase hex
// (64 chars) and matches the value operators see with `openssl x509 -fingerprint
// -sha256 -noout`. Used for the `orca node join --ca-fingerprint <sha>` pin.
func Fingerprint(certPath string) (string, error) {
if certPath == "" {
return "", errors.New("Fingerprint: certPath is required")
}
pemBytes, err := os.ReadFile(certPath)
if err != nil {
return "", fmt.Errorf("Fingerprint: read cert: %w", err)
}
der, err := firstCertDER(pemBytes)
if err != nil {
return "", fmt.Errorf("Fingerprint: %w", err)
}
return FingerprintOf(der), nil
}
// FingerprintOf returns the SHA-256 hex digest of a DER-encoded certificate.
// Lowercase hex; matches `openssl ... -fingerprint -sha256` output.
func FingerprintOf(der []byte) string {
sum := sha256.Sum256(der)
return hex.EncodeToString(sum[:])
}
// firstCertDER decodes PEM bytes and returns the DER of the first
// CERTIFICATE block. Errors if the input is empty or no CERTIFICATE block
// is present.
func firstCertDER(pemBytes []byte) ([]byte, error) {
if len(pemBytes) == 0 {
return nil, errors.New("empty input")
}
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("no PEM data found")
}
if block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("unexpected PEM type %q, want CERTIFICATE", block.Type)
}
// Re-parse through x509 to validate the cert is well-formed.
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
return block.Bytes, nil
}
+103
View File
@@ -0,0 +1,103 @@
package security
import (
"bytes"
"errors"
"regexp"
)
// privateKeyBlockRe matches the PEM header for any private key variant.
// Catches: RSA, EC, DSA, OPENSSH, ENCRYPTED, and the legacy PKCS#1 forms.
var privateKeyBlockRe = regexp.MustCompile(
`-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`,
)
// Redact removes all PEM private-key blocks from the input. It strips the
// header, base64 body, and footer of each private key block, replacing the
// block with a single line: `[REDACTED PRIVATE KEY]`.
//
// REQ-035: `orca cert show` MUST NOT print private key material, in either
// the default text or --json output. This helper is the single source of
// truth for that guarantee — call it on any PEM blob before display.
//
// The function is conservative: if the input contains no private key
// blocks, the input is returned unchanged (other than a copy). Errors are
// only returned for impossible states (e.g., a nil pattern hit, which
// can't happen in practice).
func Redact(pem []byte) []byte {
if len(pem) == 0 {
return pem
}
// Find all header positions.
matches := privateKeyBlockRe.FindAllIndex(pem, -1)
if len(matches) == 0 {
// No private key blocks — return a defensive copy.
out := make([]byte, len(pem))
copy(out, pem)
return out
}
// Process each block: locate the matching footer "-----END ... PRIVATE KEY-----"
// and replace the entire block. Multiple matches possible.
type span struct{ start, end int }
spans := make([]span, 0, len(matches))
for _, m := range matches {
headerStart := m[0]
// Find footer starting after the header.
footerStart := findPrivateKeyFooter(pem[headerStart:])
if footerStart < 0 {
// Malformed PEM — leave the input alone for safety. The caller
// will likely surface the parse error elsewhere.
continue
}
end := headerStart + footerStart + len("-----END (any) PRIVATE KEY-----")
// We don't know the exact footer length; use bytes.Index for it.
if exactEnd := exactFooterEnd(pem[headerStart:]); exactEnd > 0 {
end = headerStart + exactEnd
}
spans = append(spans, span{headerStart, end})
}
if len(spans) == 0 {
out := make([]byte, len(pem))
copy(out, pem)
return out
}
// Build output: segments between spans + redaction marker.
var out bytes.Buffer
prev := 0
for _, s := range spans {
out.Write(pem[prev:s.start])
out.WriteString("[REDACTED PRIVATE KEY]\n")
prev = s.end
}
out.Write(pem[prev:])
return out.Bytes()
}
// findPrivateKeyFooter returns the offset of the footer for a private key
// block whose header starts at pem[0]. Returns -1 if not found.
func findPrivateKeyFooter(pem []byte) int {
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----`)
loc := re.FindIndex(pem)
if loc == nil {
return -1
}
return loc[0]
}
// exactFooterEnd returns the offset just past the footer line's newline (or
// end-of-input if no trailing newline). Returns -1 if no footer is found.
func exactFooterEnd(pem []byte) int {
re := regexp.MustCompile(`-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP |)PRIVATE KEY-----\r?\n?`)
loc := re.FindIndex(pem)
if loc == nil {
return -1
}
return loc[1]
}
// Sentinel to silence the "imported and not used" check if a future
// refactor removes all consumers of errors. Currently errors is imported
// only transitively, so keep this var to anchor the package.
var _ = errors.New
+73
View File
@@ -0,0 +1,73 @@
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)
}
+131
View File
@@ -0,0 +1,131 @@
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
}
+179
View File
@@ -0,0 +1,179 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// CertKind enumerates the kinds of certs orca tracks. 'ca' is the
// cluster's internal CA; 'server' is a per-node server cert.
type CertKind string
const (
CertKindCA CertKind = "ca"
CertKindServer CertKind = "server"
)
// Cert is the in-memory representation of a row in the `certs` table.
type Cert struct {
ID string `json:"id"`
Kind CertKind `json:"kind"`
NodeID string `json:"node_id"`
SerialHex string `json:"serial_hex"`
SubjectCN string `json:"subject_cn"`
IssuerCN string `json:"issuer_cn"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
Fingerprint string `json:"fingerprint"`
SourcePath string `json:"source_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CertRepo is a CRUD wrapper around the `certs` table.
type CertRepo struct {
db *sql.DB
}
func NewCertRepo(db *sql.DB) *CertRepo {
return &CertRepo{db: db}
}
// Insert persists a new cert. Fills CreatedAt to now() if zero. The caller
// is responsible for setting ID, SerialHex, Fingerprint, etc.
func (r *CertRepo) Insert(ctx context.Context, c *Cert) error {
if c == nil {
return errors.New("CertRepo.Insert: nil cert")
}
if c.ID == "" {
return errors.New("CertRepo.Insert: ID is required")
}
if c.Kind == "" {
return errors.New("CertRepo.Insert: Kind is required")
}
if c.CreatedAt.IsZero() {
c.CreatedAt = time.Now().UTC()
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO certs (id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, string(c.Kind), c.NodeID, c.SerialHex, c.SubjectCN, c.IssuerCN,
c.NotBefore, c.NotAfter, c.Fingerprint, c.SourcePath, c.CreatedAt)
if err != nil {
return fmt.Errorf("CertRepo.Insert: %w", err)
}
return nil
}
// Get returns a single cert by ID. Returns ErrNotFound if absent.
func (r *CertRepo) Get(ctx context.Context, id string) (*Cert, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE id = ?`, id)
return scanCert(row)
}
// List returns all certs ordered by created_at DESC. Use ListByNode /
// LatestForKind for filtered queries.
func (r *CertRepo) List(ctx context.Context) ([]*Cert, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("CertRepo.List: %w", err)
}
defer rows.Close()
var certs []*Cert
for rows.Next() {
c, err := scanCert(rows)
if err != nil {
return nil, err
}
certs = append(certs, c)
}
return certs, rows.Err()
}
// ListByNode returns certs belonging to a node (or matching node_id for the
// CA — CA rows use node_id = ”).
func (r *CertRepo) ListByNode(ctx context.Context, nodeID string) ([]*Cert, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? ORDER BY created_at DESC`, nodeID)
if err != nil {
return nil, fmt.Errorf("CertRepo.ListByNode: %w", err)
}
defer rows.Close()
var certs []*Cert
for rows.Next() {
c, err := scanCert(rows)
if err != nil {
return nil, err
}
certs = append(certs, c)
}
return certs, rows.Err()
}
// LatestForKind returns the most recent cert of the given kind for the given
// node. Returns ErrNotFound if none exists. nodeID may be empty to query
// the cluster-wide CA.
func (r *CertRepo) LatestForKind(ctx context.Context, nodeID string, kind CertKind) (*Cert, error) {
row := r.db.QueryRowContext(ctx,
`SELECT id, kind, node_id, serial_hex, subject_cn, issuer_cn, not_before, not_after, fingerprint, source_path, created_at FROM certs WHERE node_id = ? AND kind = ? ORDER BY created_at DESC LIMIT 1`,
nodeID, string(kind))
return scanCert(row)
}
// PruneOlderThan deletes certs beyond the most recent `keep` rows for
// (nodeID, kind), ordered by created_at DESC. Returns the number of
// rows deleted. `keep` must be > 0; values <= 0 are treated as 1.
func (r *CertRepo) PruneOlderThan(ctx context.Context, nodeID, kind string, keep int) (int64, error) {
if keep <= 0 {
keep = 1
}
// Two-step delete: first find the cutoff created_at, then delete
// everything older. Done in a single transaction via ExecContext.
// modernc/sqlite supports multiple statements in a single Exec only
// via the "multi-statement" pragma; we use a subquery instead.
res, err := r.db.ExecContext(ctx,
`DELETE FROM certs WHERE node_id = ? AND kind = ? AND id NOT IN (
SELECT id FROM certs WHERE node_id = ? AND kind = ?
ORDER BY created_at DESC LIMIT ?
)`,
nodeID, kind, nodeID, kind, keep)
if err != nil {
return 0, fmt.Errorf("CertRepo.PruneOlderThan: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// Delete removes a cert by ID. Returns ErrNotFound if no rows affected.
func (r *CertRepo) Delete(ctx context.Context, id string) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM certs WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("CertRepo.Delete: %w", err)
}
rows, _ := res.RowsAffected()
if rows == 0 {
return ErrNotFound
}
return nil
}
func scanCert(s scanner) (*Cert, error) {
var (
c Cert
kindStr string
)
err := s.Scan(&c.ID, &kindStr, &c.NodeID, &c.SerialHex, &c.SubjectCN, &c.IssuerCN,
&c.NotBefore, &c.NotAfter, &c.Fingerprint, &c.SourcePath, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scan cert: %w", err)
}
c.Kind = CertKind(kindStr)
return &c, nil
}
+30
View File
@@ -0,0 +1,30 @@
-- Cert inventory: every CA + server cert issued by orca, with metadata
-- sufficient to drive rotation history, fingerprint pinning, and
-- `orca doctor cert` health reports. This is migration 0004; v0.2 P01.
--
-- `kind` is one of: 'ca', 'server'. CA rows have node_id = '' (the
-- CA is per-cluster, not per-node). Server rows have node_id set.
-- `serial_hex` is the cert serial as a hex string; used to detect
-- duplicate issuances.
-- `fingerprint` is SHA-256 hex (lowercase) of the cert's DER bytes;
-- matches the value returned by `Fingerprint(certPath)` in
-- internal/security.
CREATE TABLE IF NOT EXISTS certs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
node_id TEXT NOT NULL DEFAULT '',
serial_hex TEXT NOT NULL,
subject_cn TEXT NOT NULL,
issuer_cn TEXT NOT NULL,
not_before DATETIME NOT NULL,
not_after DATETIME NOT NULL,
fingerprint TEXT NOT NULL,
source_path TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_certs_kind ON certs(kind);
CREATE INDEX IF NOT EXISTS idx_certs_node ON certs(node_id);
CREATE INDEX IF NOT EXISTS idx_certs_node_kind ON certs(node_id, kind);
CREATE INDEX IF NOT EXISTS idx_certs_created ON certs(created_at);
CREATE INDEX IF NOT EXISTS idx_certs_fp ON certs(fingerprint);