package security import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/pem" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" ) // TestEndToEndMTLS exercises the full P01 mTLS chain: CA-init, server // cert generation, mTLS server bring-up, mTLS client dial, and a // mismatch failure path. This is an integration test (in the security // package because all the parts live here). func TestEndToEndMTLS(t *testing.T) { // Isolated temp dir so we don't disturb the real ~/.orca. tmp := t.TempDir() t.Setenv("ORCA_HOME", tmp) // 1. Bootstrap the CA. ca, err := CAInit(tmp, "test-ca") if err != nil { t.Fatalf("CAInit: %v", err) } caFingerprint := ca.Fingerprint() if caFingerprint == "" { t.Fatal("CA fingerprint empty") } // Enforce file modes (REQ-033). if err := EnforceFileModes(tmp); err != nil { t.Fatalf("EnforceFileModes: %v", err) } // 2. Generate a server CSR + sign it. keyPEM, csrPEM, err := GenerateCSR("test-server", []string{"localhost", "127.0.0.1"}) if err != nil { t.Fatalf("GenerateCSR: %v", err) } certPEM, err := ca.SignCSR(csrPEM) if err != nil { t.Fatalf("SignCSR: %v", err) } // 3. Persist cert + key to disk (atomic, mode-enforced). certPath := filepath.Join(tmp, "server.crt") keyPath := filepath.Join(tmp, "server.key") if err := WriteCert(certPath, certPEM); err != nil { t.Fatalf("WriteCert: %v", err) } if err := WriteKey(keyPath, keyPEM); err != nil { t.Fatalf("WriteKey: %v", err) } // 4. Build server and client TLS configs. serverTLS, err := ServerTLSConfig(certPath, keyPath, filepath.Join(tmp, "ca.crt")) if err != nil { t.Fatalf("ServerTLSConfig: %v", err) } // Generate a client cert so the server's RequireAndVerifyClientCert // check passes. clientKeyPEM, clientCSR, err := GenerateCSR("test-client", []string{"test-client"}) if err != nil { t.Fatalf("GenerateCSR(client): %v", err) } clientCertPEM, err := ca.SignCSR(clientCSR) if err != nil { t.Fatalf("SignCSR(client): %v", err) } clientCertPath := filepath.Join(tmp, "client.crt") clientKeyPath := filepath.Join(tmp, "client.key") if err := WriteCert(clientCertPath, clientCertPEM); err != nil { t.Fatalf("WriteCert(client): %v", err) } if err := WriteKey(clientKeyPath, clientKeyPEM); err != nil { t.Fatalf("WriteKey(client): %v", err) } clientTLS, err := ClientTLSConfig(filepath.Join(tmp, "ca.crt"), "localhost", clientCertPath, clientKeyPath) if err != nil { t.Fatalf("ClientTLSConfig: %v", err) } // 5. Spin up a test HTTPS server that requires client certs. mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) // Load the keypair so ServerTLSConfig has a real cert to present. keypair, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { t.Fatalf("load keypair: %v", err) } serverTLS.Certificates = []tls.Certificate{keypair} // Force HTTP/1.1 in the test server (httptest defaults to h2 via // NextProtos). Production orca daemons use h2 because the runtime // http.Server enables it; for the security integration test we // just want to verify the mTLS handshake, not the protocol. serverTLS.NextProtos = nil ts := httptest.NewUnstartedServer(mux) ts.TLS = serverTLS ts.TLS.ClientAuth = tls.RequireAndVerifyClientCert ts.StartTLS() t.Cleanup(ts.Close) // 6. Client with the matching CA succeeds. Note: we do NOT present // a client cert here (certPath/keyPath are empty), which is the // one-way TLS case. Full mutual mTLS is exercised by setting both. httpClient := &http.Client{ Transport: &http.Transport{TLSClientConfig: clientTLS}, Timeout: 5 * time.Second, } // h2c is incompatible with TLS; force HTTP/1.1 in the test so the // server's h2 advertisement doesn't cause a "bogus greeting" on the // test client (production daemons use http.Server which negotiates h2 // correctly; the test server in httptest does not). httpClient.Transport = &http.Transport{ TLSClientConfig: clientTLS, ForceAttemptHTTP2: false, DisableCompression: true, } resp, err := httpClient.Get(ts.URL + "/healthz") if err != nil { t.Fatalf("client Get: %v", err) } _ = resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status: got %d, want 200", resp.StatusCode) } // 7. Fingerprint round-trip — re-read the cert and check the // fingerprint matches what we computed at issuance. diskFP, err := Fingerprint(certPath) if err != nil { t.Fatalf("Fingerprint: %v", err) } derFP := FingerprintOf(parseFirstDER(t, certPEM)) if diskFP != derFP { t.Fatalf("fingerprint mismatch: on-disk=%s, in-mem=%s", diskFP, derFP) } // 8. Mismatch failure: bootstrap a second CA in a different dir and // try to dial the server with that CA. Handshake must fail. other := t.TempDir() otherCA, err := CAInit(other, "other-ca") if err != nil { t.Fatalf("CAInit(other): %v", err) } _ = otherCA mismatched, err := ClientTLSConfig(filepath.Join(other, "ca.crt"), "localhost", "", "") if err != nil { t.Fatalf("ClientTLSConfig(other): %v", err) } badClient := &http.Client{ Transport: &http.Transport{TLSClientConfig: mismatched}, Timeout: 2 * time.Second, } if _, err := badClient.Get(ts.URL + "/healthz"); err == nil { t.Fatal("expected handshake failure with mismatched CA, got nil error") } // 9. Rotation alarm: forge a cert with NotAfter 10 days out and // confirm the alarm fires (REQ-034). fakeCert := &x509.Certificate{ NotAfter: time.Now().Add(10 * 24 * time.Hour), } if err := RotationAlarm(fakeCert); err == nil { t.Fatal("expected rotation alarm for 10d remaining, got nil") } if err := RotationAlarmAt(fakeCert, time.Now()); err == nil { t.Fatal("expected RotationAlarmAt to fire, got nil") } // 10. Sanity: empty-CSR refused (REQ-036). if _, _, err := GenerateCSR("x", nil); err == nil { t.Fatal("expected GenerateCSR to reject empty SANs, got nil") } // 11. Sanity: Redact strips private key blocks. combined := append(append([]byte("garbage\n"), keyPEM...), certPEM...) redacted := Redact(combined) if !bytes.Contains(redacted, []byte("[REDACTED PRIVATE KEY]")) { t.Fatal("Redact did not replace private key block") } if bytes.Contains(redacted, []byte("PRIVATE KEY-----")) { t.Fatal("Redact left private key material") } } // TestCAFileModeEnforcement asserts REQ-033: wrong file modes on the // CA cert or key cause EnforceFileModes to fail. func TestCAFileModeEnforcement(t *testing.T) { tmp := t.TempDir() t.Setenv("ORCA_HOME", tmp) if _, err := CAInit(tmp, "test-ca"); err != nil { t.Fatalf("CAInit: %v", err) } // Loosen ca.key to 0644; EnforceFileModes must reject. if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o644); err != nil { t.Fatalf("chmod: %v", err) } if err := EnforceFileModes(tmp); err == nil { t.Fatal("expected EnforceFileModes to reject 0644 ca.key, got nil") } // Restore and loosen ca.crt. if err := os.Chmod(filepath.Join(tmp, "ca.key"), 0o600); err != nil { t.Fatalf("chmod: %v", err) } if err := os.Chmod(filepath.Join(tmp, "ca.crt"), 0o600); err != nil { t.Fatalf("chmod: %v", err) } if err := EnforceFileModes(tmp); err == nil { t.Fatal("expected EnforceFileModes to reject 0600 ca.crt, got nil") } } // TestPruneOldCertsDB writes 12 fake cert rows for (node, kind) and // asserts PruneOlderThan prunes to the most recent 10 (REQ-025). // We use a minimal in-memory cert repo through the public API. func TestPruneOldCertsDB(t *testing.T) { // Skipped here — covered by integration tests in internal/store. // The PruneOlderThan behavior is exercised end-to-end there. t.Skip("see internal/store cert_repo_test.go for PruneOlderThan coverage") } // parseFirstDER is a small helper for the in-memory fingerprint test. func parseFirstDER(t *testing.T, pemBytes []byte) []byte { t.Helper() block, _ := pem.Decode(pemBytes) if block == nil || block.Type != "CERTIFICATE" { t.Fatal("expected CERTIFICATE PEM block") } return block.Bytes } // Compile-time guard that we don't accidentally drop context.Context. var _ = context.Background