diff --git a/crls.go b/crls.go new file mode 100644 index 0000000..cc60fc3 --- /dev/null +++ b/crls.go @@ -0,0 +1,216 @@ +// crls.go - Go implementation of CRL (Certificate Revocation List) management for APS. +// Copyright (c) 2025 - Aroosha Pervaiz +// +// This file implements logic to load, parse, and periodically refresh CRLs to validate x509 certificates/ +// +// The core workflow is as follows: +// +// 1. CRL Loading: +// - The server reads all CRL files from configured directories. +// - File patterns (e.g. "*.crl", "*.[rR][0-9]") are configurable via the CRLGlobs option. +// - Each CRL is parsed using Go's crypto/x509 package, and revoked certificate serial numbers +// are extracted and stored in memory. +// +// 2. CRL Refreshing: +// - A background goroutine runs at a configurable interval (CRLInterval). +// - It reloads all CRLs from the configured directories and updates the in-memory map of revoked serials. +// - Optionally, the refresh can skip or quarantine corrupted CRL files based on the CRLQuarantine flag. +// +// 3. Manual Refresh via HTTP: +// - The `/refresh-crls` endpoint allows manual refresh of CRLs using an HTTP PUT request. +// - This endpoint triggers an immediate reload of all configured CRL directories. +// - It responds with either “CRLs refreshed successfully” or an error message. +// +// 4. Certificate Verification: +// - During client TLS handshake, the VerifyPeerCertificate callback checks +// whether the presented certificate’s serial number exists in the revoked list. +// - If the serial is found, the connection is rejected as “certificate revoked”. +// +// 5. Testing: +// - The CRL logic includes unit tests(in crls_test.go) that verify correct handling of valid and invalid CRLs, +// the periodic refresher, and the manual HTTP refresh endpoint. + +package main + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" +) + +// revoked cert serials (swap with atomic.Value) +var revoked atomic.Value + +// collectCRLFiles builds the file list once (O(n)) across dirs and patterns. +func collectCRLFiles(dirs, globs []string) []string { + var files []string + patts := globs + if len(patts) == 0 { + // default patterns if none provided + patts = []string{"*.[rR][0-9]", "*.crl"} + } + for _, dir := range dirs { + for _, pat := range patts { + m, _ := filepath.Glob(filepath.Join(dir, pat)) + if len(m) > 0 { + files = append(files, m...) + } + } + } + return files +} + +// quarantineFile renames a bad CRL to .bad; if rename fails, remove it. +func quarantineFile(path string) { + bad := path + ".bad" + if err := os.Rename(path, bad); err != nil { + log.Printf("failed to quarantine %s: %v; removing", path, err) + if rmErr := os.Remove(path); rmErr != nil { + log.Printf("failed to remove %s: %v", path, rmErr) + } + } else { + log.Printf("quarantined bad CRL %s -> %s", path, bad) + } +} + +// parseCRLBytes decodes DER/PEM, validates freshness, and accumulates serials. +func parseCRLBytes(data []byte, path string, out *map[string]bool, now time.Time) error { + // accept PEM-wrapped or raw DER + if strings.Contains(string(data), "-----BEGIN") { + for { + block, rest := pem.Decode(data) + if block == nil { + break + } + data = rest + if !strings.Contains(block.Type, "CRL") { + continue + } + crl, err := x509.ParseCRL(block.Bytes) + if err != nil { + return err + } + if crl.HasExpired(now) { + log.Printf("WARNING expired CRL %s (ignored)", path) + continue + } + for _, rc := range crl.TBSCertList.RevokedCertificates { + (*out)[rc.SerialNumber.String()] = true + } + } + return nil + } + + // raw DER + crl, err := x509.ParseCRL(data) + if err != nil { + return err + } + if crl.HasExpired(now) { + log.Printf("WARNING expired CRL %s (ignored)", path) + return nil + } + for _, rc := range crl.TBSCertList.RevokedCertificates { + (*out)[rc.SerialNumber.String()] = true + } + return nil +} + +// loadLocalCRLs builds revoked set from dirs+globs; skips or quarantines bad files per config. +func loadLocalCRLs(dirs, globs []string, quarantine bool) map[string]bool { + out := make(map[string]bool) + now := time.Now() + + files := collectCRLFiles(dirs, globs) + if len(files) == 0 && Config.Verbose > 0 { + log.Printf("No CRL files found in %v with patterns %v", dirs, globs) + } + + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + log.Printf("Failed to read CRL %s: %v", f, err) + continue + } + if err := parseCRLBytes(data, f, &out, now); err != nil { + log.Printf("Failed to parse CRL %s: %v", f, err) + if quarantine { + quarantineFile(f) + } + continue + } + if Config.Verbose > 0 { + log.Printf("Loaded CRL %s", f) + } + } + log.Printf("[CRL-DEBUG] TOTAL revoked certs loaded: %d", len(out)) + return out +} + +// startCRLRefresher periodically reloads CRLs and swaps the map atomically. +func startCRLRefresher(dirs, globs []string, interval time.Duration, quarantine bool) { + go func() { + for { + m := loadLocalCRLs(dirs, globs, quarantine) + revoked.Store(m) + if Config.Verbose > 0 { + log.Printf("CRLs refreshed, %d revoked certs loaded", len(m)) + } + time.Sleep(interval) + } + }() +} + +// RefreshCRLsHandler handles PUT /refresh-crls to reload CRLs manually. +// It updates the revoked certificates map used for TLS verification. +func RefreshCRLsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + w.Header().Set("Allow", http.MethodPut) + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + if err := refreshCRLsNow(Config.CRLDirs, Config.CRLGlobs, Config.CRLQuarantine); err != nil { + log.Printf("[CRL] manual refresh failed: %v", err) + http.Error(w, fmt.Sprintf("Failed to refresh CRLs: %v\n", err), http.StatusInternalServerError) + return + } + + log.Printf("[CRL] manual refresh successful") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("CRLs refreshed successfully\n")) +} + +// refreshCRLsNow forces an immediate reload. +func refreshCRLsNow(dirs, globs []string, quarantine bool) error { + m := loadLocalCRLs(dirs, globs, quarantine) + if len(m) == 0 { + return fmt.Errorf("no CRLs loaded from %v", dirs) + } + revoked.Store(m) + log.Printf("[CRL] Refreshed %d revoked certs", len(m)) + return nil +} + +// verifyPeerCertificate enforces CRL-based revocation for the leaf cert. +func verifyPeerCertificateWithCRL(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 { + return fmt.Errorf("no verified chains") + } + leaf := verifiedChains[0][0] + crls, _ := revoked.Load().(map[string]bool) + if crls == nil { + return nil // no CRLs loaded yet -> do not block + } + if _, ok := crls[leaf.SerialNumber.String()]; ok { + return fmt.Errorf("certificate revoked: %s", leaf.SerialNumber) + } + return nil +} diff --git a/crls_test.go b/crls_test.go new file mode 100644 index 0000000..ce992d0 --- /dev/null +++ b/crls_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" +) + +// helper to make a dummy certificate +func makeDummyCert(t *testing.T) *x509.Certificate { + serial, err := rand.Int(rand.Reader, big.NewInt(1000000)) + if err != nil { + t.Fatal(err) + } + return &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "test-cert"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } +} + +// makeDummyCRL writes a minimal valid CRL PEM file into dir. +func makeDummyCRL(t *testing.T, dir string) string { + t.Helper() + + // Create a dummy CA + caKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate CA key: %v", err) + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test Root CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatalf("failed to create CA cert: %v", err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatalf("failed to parse CA cert: %v", err) + } + + // Optionally include one revoked cert (for test coverage) + revokedCert := makeDummyCert(t) + revokedList := []pkix.RevokedCertificate{ + { + SerialNumber: revokedCert.SerialNumber, + RevocationTime: time.Now().Add(-time.Hour), + }, + } + + // Generate CRL signed by our CA + now := time.Now() + next := now.Add(12 * time.Hour) + crlDER, err := caCert.CreateCRL(rand.Reader, caKey, revokedList, now, next) + if err != nil { + t.Fatalf("failed to create CRL: %v", err) + } + + // Write PEM file to disk + crlPath := filepath.Join(dir, "test.crl") + crlPEM := pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: crlDER}) + if err := os.WriteFile(crlPath, crlPEM, 0644); err != nil { + t.Fatalf("failed to write CRL: %v", err) + } + + return crlPath +} + +// tests empty CRL directory +func TestCollectCRLFilesEmpty(t *testing.T) { + tmpDir := t.TempDir() + files := collectCRLFiles([]string{tmpDir}, []string{"*.crl"}) + if len(files) != 0 { + t.Errorf("expected 0 files, got %d", len(files)) + } +} + +// tests loading invalid CRL file +func TestLoadLocalCRLsSkipBad(t *testing.T) { + tmpDir := t.TempDir() + badFile := filepath.Join(tmpDir, "bad.crl") + if err := os.WriteFile(badFile, []byte("not a crl"), 0644); err != nil { + t.Fatal(err) + } + + out := loadLocalCRLs([]string{tmpDir}, []string{"*.crl"}, false) + if len(out) != 0 { + t.Errorf("expected 0 revoked certs, got %d", len(out)) + } +} + +// tests that the CRL refresher goroutine starts and runs +func TestStartRefresher(t *testing.T) { + tmpDir := t.TempDir() + done := make(chan struct{}) + go func() { + startCRLRefresher([]string{tmpDir}, []string{"*.crl"}, 100*time.Millisecond, false) + time.Sleep(300 * time.Millisecond) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("timeout waiting for refresher") + } +} + +// tests VerifyPeerCertificateWithCRL behavior +func TestVerifyPeerCertificateWithCRL(t *testing.T) { + cert := makeDummyCert(t) + chain := [][]*x509.Certificate{{cert}} + + mockRevoked := map[string]bool{} + revoked.Store(mockRevoked) + + // not revoked + err := verifyPeerCertificateWithCRL(nil, chain) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // revoked + mockRevoked[cert.SerialNumber.String()] = true + revoked.Store(mockRevoked) + err = verifyPeerCertificateWithCRL(nil, chain) + if err == nil { + t.Errorf("expected revocation error, got nil") + } +} + +// tests atomic safety of revoked map +func TestRevokedMapAtomicity(t *testing.T) { + m := map[string]bool{"1234": true} + revoked.Store(m) + + val := revoked.Load().(map[string]bool) + if _, ok := val["1234"]; !ok { + t.Error("expected to find revoked serial 1234") + } +} + +// minimal test for CRL Handler: method not allowed +func TestRefreshCRLsHandler_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/refresh-crls", nil) + w := httptest.NewRecorder() + + RefreshCRLsHandler(w, req) + res := w.Result() + defer res.Body.Close() + + if res.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", res.StatusCode) + } +} + +// TestRefreshCRLsHandler_Success verifies that a valid CRL causes a successful refresh. +func TestRefreshCRLsHandler_Success(t *testing.T) { + tmpDir := t.TempDir() + makeDummyCRL(t, tmpDir) + + Config.CRLDirs = []string{tmpDir} + Config.CRLGlobs = []string{"*.crl"} + Config.CRLQuarantine = false + + req := httptest.NewRequest(http.MethodPut, "/refresh-crls", nil) + w := httptest.NewRecorder() + + RefreshCRLsHandler(w, req) + res := w.Result() + defer res.Body.Close() + + body, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + t.Fatalf("unexpected result: %d %s", res.StatusCode, string(body)) + } + if string(body) != "CRLs refreshed successfully\n" { + t.Fatalf("unexpected body: %s", string(body)) + } +} diff --git a/data.go b/data.go index 208fca2..dacfe65 100644 --- a/data.go +++ b/data.go @@ -8,7 +8,7 @@ package main import ( "encoding/json" "log" - + "time" "github.com/shirou/gopsutil/load" "github.com/shirou/gopsutil/net" "github.com/shirou/gopsutil/process" @@ -87,6 +87,13 @@ type Configuration struct { // debug server info DebugAllowedIPs []string `json:"debug_allowed_ips"` // list of allowed IPs to view debug/profile info + //configuration for crls.go + CRLDirs []string `json:"crl_dirs"` // directories containing CRL files + CRLGlobs []string `json:"crl_globs"` // filename patterns (optional, defaults provided) + CRLInterval time.Duration `json:"crl_interval"` // refresh interval, e.g. "6h" + CRLQuarantine bool `json:"crl_quarantine"` // true = quarantine bad CRLs, false = skip them + CRLPort int `json:"crl_port"` // separate HTTP port for /refresh-crls + // Monit pieces MonitType string `json:"monit_type"` // monit record type MonitProducer string `json:"monit_producer"` // monit record producer diff --git a/server.go b/server.go index 6ff0837..b0c05b9 100644 --- a/server.go +++ b/server.go @@ -1,3 +1,4 @@ +// server.go package main import ( @@ -88,6 +89,21 @@ func Server(config string, port, metricsPort int, logFile string, useX509, useX5 // read RootCAs once _rootCAs = RootCAs() + + // load CRLs once and start refresher + revoked.Store(loadLocalCRLs(Config.CRLDirs, Config.CRLGlobs, Config.CRLQuarantine)) + startCRLRefresher(Config.CRLDirs, Config.CRLGlobs, Config.CRLInterval, Config.CRLQuarantine) + + // manual refresh endpoint on a dedicated port(CRLPort) + mux := http.NewServeMux() + mux.HandleFunc("/refresh-crls", RefreshCRLsHandler) + go func() { + addr := fmt.Sprintf(":%d", Config.CRLPort) + log.Printf("CRL refresh endpoint running at http://localhost%s/refresh-crls", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Printf("[CRL] refresh server error: %v", err) + } + }() // initialize ingress rules only once _ingressMap, _ingressRules = readIngressRules() @@ -106,7 +122,7 @@ func Server(config string, port, metricsPort int, logFile string, useX509, useX5 NumLogicalCores = 0 } - // initialize all particiapted providers + // initialize all participated providers auth.Init(Config.Providers, Config.Verbose) // initialize cmsauth module @@ -122,6 +138,7 @@ func Server(config string, port, metricsPort int, logFile string, useX509, useX5 Transport: &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: _rootCAs, + VerifyPeerCertificate: verifyPeerCertificateWithCRL, }, }, } @@ -168,8 +185,9 @@ func Server(config string, port, metricsPort int, logFile string, useX509, useX5 // Get CRIC records go cric.UpdateCricRecords("id", Config.CricFile, Config.CricURL, Config.UpdateCricInterval, Config.CricVerbose) } - // Get AIM records + // Get IAM records go getIAMInfo() // start OAuth server oauthProxyServer() } +