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

310 lines
9.0 KiB
Go

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
}