Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions crls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// 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 (
"fmt"
"crypto/x509"
"encoding/pem"
"log"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"net/http"
)

// 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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function should use pointer to out map which will be passed to it from loadLocalCRLs. Currently the map you passed around is copied by value, i.e. it is not accummulated as it is always a new copy. Instead, you should use pointer to a map, which will points to a single map and you'll fill it out with CRL serial numbers.

Or, if you globally define map of CRLs to keep then you don't need to use local map definition. Look at cmsRecords in https://github.com/dmwm/auth-proxy-server/blob/master/cric/cric.go#L22 and how it is used through the code. If you will define global map you better put mutex locks around its read/write operations to avoid potential (and very dangerous but rare) racing conditions as maps are not safe concurrency objects. Moreover, you may define an entire struct which will keep your map, e.g.

type CRLMap struct {
   Map map[string[bool
   mutex sync.RWMutex
}
func (c *CRLMap) Update(entry string) {
   mutex.Lock()
   c.Map[entry] = true
   mutex.Unlock()
}
func (c *CRLMap) Get(entry string) bool {
   mutex.RLock()
   r, ok := cmsRecords[sortedDN]
   mutex.RUnlock()
   if ok { 
     return r
   }
   return false
}

With such implementation you map even add periodict refresh of the internal map if you want, i.e. define duration of validity of the map.

// 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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you change parseCRLBytes function to accept pointer to a map, then you should change here its call like this: parseCRLBytes(data, f, &out, now)

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
}

Loading