181cc769e6
Internal CA with CSR join, mTLS 1.3 config builders, rotation alarm, PEM redaction, and cert inventory schema (REQ-033/034/035/036). - internal/security/ca.go: CAInit/LoadCA/SignCSR, file mode enforcement (ca.crt 0644, ca.key 0600) per REQ-033 - internal/security/csr.go: GenerateCSR with DNS + IP SANs (REQ-036) - internal/security/fingerprint.go: SHA-256 hex of cert DER - internal/security/rotation.go: 30d pre-expiry alarm, history pruning - internal/security/redact.go: PEM private key block stripping (REQ-035) - internal/security/tls_config.go: TLS 1.3 with AEAD allowlist - internal/security/certgen_test.go: round-trip + mode + rotation + redact - internal/store/migrations/0004_certs.sql: cert inventory table - internal/store/cert_repo.go: CRUD + PruneOlderThan (REQ-025) ---ci--- project: orca phase: 8 milestone: v0.2 status: execute ---/ci---
310 lines
9.0 KiB
Go
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
|
|
}
|