From 181cc769e65ed7e04f1541f38a0f284d5191cbc8 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Wed, 3 Jun 2026 21:18:50 +0000 Subject: [PATCH] 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--- --- internal/security/ca.go | 322 +++++++++++++++++++++++ internal/security/certgen_test.go | 309 ++++++++++++++++++++++ internal/security/csr.go | 76 ++++++ internal/security/doc.go | 17 ++ internal/security/fingerprint.go | 58 ++++ internal/security/redact.go | 103 ++++++++ internal/security/rotation.go | 73 +++++ internal/security/tls_config.go | 131 +++++++++ internal/store/cert_repo.go | 179 +++++++++++++ internal/store/migrations/0004_certs.sql | 30 +++ 10 files changed, 1298 insertions(+) create mode 100644 internal/security/ca.go create mode 100644 internal/security/certgen_test.go create mode 100644 internal/security/csr.go create mode 100644 internal/security/doc.go create mode 100644 internal/security/fingerprint.go create mode 100644 internal/security/redact.go create mode 100644 internal/security/rotation.go create mode 100644 internal/security/tls_config.go create mode 100644 internal/store/cert_repo.go create mode 100644 internal/store/migrations/0004_certs.sql diff --git a/internal/security/ca.go b/internal/security/ca.go new file mode 100644 index 0000000..dc5fcfe --- /dev/null +++ b/internal/security/ca.go @@ -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 `. +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 +} diff --git a/internal/security/certgen_test.go b/internal/security/certgen_test.go new file mode 100644 index 0000000..a1eda3f --- /dev/null +++ b/internal/security/certgen_test.go @@ -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 +} diff --git a/internal/security/csr.go b/internal/security/csr.go new file mode 100644 index 0000000..2aea764 --- /dev/null +++ b/internal/security/csr.go @@ -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 +} diff --git a/internal/security/doc.go b/internal/security/doc.go new file mode 100644 index 0000000..4dd10c7 --- /dev/null +++ b/internal/security/doc.go @@ -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 diff --git a/internal/security/fingerprint.go b/internal/security/fingerprint.go new file mode 100644 index 0000000..be1d9c3 --- /dev/null +++ b/internal/security/fingerprint.go @@ -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 ` 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 +} diff --git a/internal/security/redact.go b/internal/security/redact.go new file mode 100644 index 0000000..3efdbbf --- /dev/null +++ b/internal/security/redact.go @@ -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 diff --git a/internal/security/rotation.go b/internal/security/rotation.go new file mode 100644 index 0000000..628ea0d --- /dev/null +++ b/internal/security/rotation.go @@ -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) +} diff --git a/internal/security/tls_config.go b/internal/security/tls_config.go new file mode 100644 index 0000000..46b75fe --- /dev/null +++ b/internal/security/tls_config.go @@ -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 +} diff --git a/internal/store/cert_repo.go b/internal/store/cert_repo.go new file mode 100644 index 0000000..39b71c0 --- /dev/null +++ b/internal/store/cert_repo.go @@ -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 +} diff --git a/internal/store/migrations/0004_certs.sql b/internal/store/migrations/0004_certs.sql new file mode 100644 index 0000000..fe4df52 --- /dev/null +++ b/internal/store/migrations/0004_certs.sql @@ -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);