From 4e4dd2eaaeb66e532d9c64fffa39640362d14581 Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Thu, 30 Apr 2026 02:08:56 +0200 Subject: [PATCH] feat: implement multi CA serving --- README.md | 68 +++++- internal/metrics/metrics.go | 4 +- main.go | 452 ++++++++++++++++++++++++------------ 3 files changed, 356 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 500ec87..81908fa 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,61 @@ # OCSP Server -OCSP Server is a minimal implementation of both a OCSP and CRL server in Golang, using a single CRL as the source for both interfaces. -Originally created by Florian Bauer and now adapted for Spacebar. It provides the following http endpoints: +OCSP Server is a minimal implementation of both an OCSP and CRL server in Golang, using a CRL as the source for both interfaces. +Originally created by Florian Bauer and now adapted for Spacebar. A single instance can serve any number of CAs (e.g. a root CA and several intermediates). -| Endpoint | Description | -|------------|----------------------------------------------------------| -| `/ocsp` | OCSP responder supporting both `GET` and `POST` requests | -| `/crl` | CRL responder in DER format | -| `/crl.pem` | CRL responder in PEM format | -| `/ca` | Issuer CA certificate in DER format | -| `/ca.pem` | Issuer CA certificate in PEM format | +## Configuration -You need to provide a CRL file, the root certificate and cert/key with extendedKeyUsage `OCSPSigning` to allow the OCSP server to sign the OCSP responses. -When using OCSP, the certificate is checked against the CRL for validity. +Point the server at a single directory that contains one subdirectory per CA. The subdirectory name is used as the route prefix. -Synchronization of the CAs CRL is out of scope of this project. You can use any mechanism to update the CRL file. Just notify the ocspcrl server process via `SIGHUP` signal to reload the CRL file. +``` +cas/ +├── root/ +│ ├── ca.crt +│ ├── responder.crt +│ ├── key.pem +│ └── crl.pem +├── hr/ +│ ├── ca.crt +│ ├── responder.crt +│ ├── key.pem +│ └── crl.pem +└── it/ + ├── ca.crt + ├── responder.crt + ├── key.pem + └── crl.pem +``` + +Each CA subdirectory must contain: + +| File | Description | +|-----------------|--------------------------------------------------------------------------| +| `ca.crt` | The CA certificate (PEM) | +| `responder.crt` | The OCSP responder certificate with `extendedKeyUsage = OCSPSigning` | +| `key.pem` | The private key for the responder certificate (PEM) | +| `crl.pem` | The current CRL issued by the CA (PEM or DER) | + +Run the server with: + +``` +ocspcrl --cas-directory /path/to/cas +``` + +## Endpoints + +For every CA subdirectory ``, the following endpoints are exposed: + +| Endpoint | Content-Type | Description | +|-----------------------------|-----------------------------|----------------------------------------------------------| +| `/ocsp` | (OCSP) | OCSP responder supporting both `GET` and `POST` requests | +| `/ocsp/` | (OCSP) | OCSP responder supporting both `GET` and `POST` requests | +| `/crl` | `application/pkix-cert` | CRL in DER form | +| `/crl.pem` | `application/pkix-crl` | CRL in PEM form | +| `/ca` | `application/pkix-cert` | CA certificate in DER form | +| `/ca.pem` | `application/x-x509-ca-cert`| CA certificate in PEM form | + +For example, with the layout above, the OCSP endpoint for the `hr` intermediate is `https:///hr/ocsp` and its CRL is at `https:///hr/crl.pem`. + +## Reloading the CRLs + +Synchronization of the CAs' CRLs is out of scope of this project. You can use any mechanism to update the CRL files. Notify the `ocspcrl` server process via `SIGHUP` to reload every CA's CRL from disk. diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 4d604f4..571e961 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -26,11 +26,11 @@ var ( Buckets: prometheus.ExponentialBuckets(0.0001, 2, 10), }, []string{labelPath}) - CrlEntries = prometheus.NewGauge(prometheus.GaugeOpts{ + CrlEntries = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "ocspcrl", Name: "crl_entries_total", Help: "Number of entries in the CRL", - }) + }, []string{"ca"}) ) func init() { diff --git a/main.go b/main.go index ba60dac..f422602 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,10 @@ import ( "net/http" "os" "os/signal" + "path/filepath" + "sort" + "strings" + "sync" "syscall" "github.com/alecthomas/kingpin/v2" @@ -21,180 +25,320 @@ import ( "ocspcrl/internal/ocsp_source" ) -type loadCrlFunction func() error - -func loadCrlFromFile(path string) (*x509.RevocationList, error) { - crlContent, openCrlError := os.ReadFile(path) - if openCrlError != nil { - return nil, openCrlError - } - - // if the file contains a pem block, decode it - // otherwise, assume it is a DER encoded CRL - crlBlock := &pem.Block{} - if bytes.Contains(crlContent, []byte("BEGIN")) { - block, rest := pem.Decode(crlContent) - if len(rest) > 0 { - return nil, fmt.Errorf("failed to decode crl") - } - crlBlock = block - } else { - crlBlock = &pem.Block{Type: "X509 CRL", Bytes: crlContent} - } - - crl, parseCrlError := x509.ParseRevocationList(crlBlock.Bytes) - if parseCrlError != nil { - return nil, parseCrlError - } - return crl, nil -} - -func reloadCrlWorker(signal chan os.Signal, loadCrlFunc loadCrlFunction) { - defer log.Println("reload crl worker stopped") - for { - select { - case _, ok := <-signal: - if !ok { - return - } - loadCrlError := loadCrlFunc() - if loadCrlError != nil { - log.Printf("failed to reload crl: %v", loadCrlError) - } else { - log.Println("reloaded crl") - } - } - } -} - -type responder struct { - certificatePath string - keyPath string -} - -type crlSourceFile struct { - path string -} +const ( + crlFileName = "crl.pem" + keyFileName = "key.pem" + responderFileName = "responder.crt" + caFileName = "ca.crt" +) type configuration struct { - responder *responder - caCrtPath string - crlSourceType string - crlSourceFile *crlSourceFile + casDirectory string applicationListenAddress string metricsListenAddress string } +func parseConfiguration(args []string) *configuration { + config := &configuration{} + app := kingpin.New("ocspcrl", "OCSP responder / CRL server (multi-CA)") + app.HelpFlag.Short('h') + app.Flag("cas-directory", "Path to a directory containing one subdirectory per CA. Each subdirectory must contain ca.crt, responder.crt, key.pem and crl.pem. The subdirectory name is used as the route prefix."). + Envar("CAS_DIRECTORY").Required().ExistingDirVar(&config.casDirectory) + app.Flag("web.listen-address", "Address for application endpoint"). + Envar("WEB_LISTEN_ADDRESS").Default(":8080").StringVar(&config.applicationListenAddress) + app.Flag("metrics.listen-address", "Address for metrics endpoint"). + Envar("METRICS_LISTEN_ADDRESS").Default("[::1]:8081").StringVar(&config.metricsListenAddress) + kingpin.MustParse(app.Parse(args)) + return config +} + +func decodeCrlBytes(content []byte) ([]byte, error) { + if !bytes.Contains(content, []byte("BEGIN")) { + return content, nil + } + block, rest := pem.Decode(content) + if block == nil { + return nil, fmt.Errorf("crl pem block could not be decoded") + } + if len(bytes.TrimSpace(rest)) > 0 { + return nil, fmt.Errorf("crl file contains trailing data") + } + return block.Bytes, nil +} + +func loadCrlFromFile(path string) (*x509.RevocationList, error) { + content, readError := os.ReadFile(path) + if readError != nil { + return nil, readError + } + derBytes, decodeError := decodeCrlBytes(content) + if decodeError != nil { + return nil, fmt.Errorf("%s: %w", path, decodeError) + } + return x509.ParseRevocationList(derBytes) +} + +func loadCertificateFromFile(path string) (*x509.Certificate, error) { + content, readError := os.ReadFile(path) + if readError != nil { + return nil, readError + } + block, rest := pem.Decode(content) + if block == nil { + return nil, fmt.Errorf("%s: certificate pem block could not be decoded", path) + } + if len(bytes.TrimSpace(rest)) > 0 { + return nil, fmt.Errorf("%s: certificate file contains trailing data", path) + } + return x509.ParseCertificate(block.Bytes) +} + +type caInstance struct { + name string + crlPath string + caCertificate *x509.Certificate + source *ocsp_source.CrlSource + + crlMutex sync.RWMutex + crl *x509.RevocationList +} + +func (c *caInstance) reloadCrl() error { + crl, loadError := loadCrlFromFile(c.crlPath) + if loadError != nil { + return loadError + } + c.crlMutex.Lock() + c.crl = crl + c.crlMutex.Unlock() + metrics.CrlEntries.WithLabelValues(c.name).Set(float64(len(crl.RevokedCertificateEntries))) + c.source.UseCrl(*crl) + return nil +} + +func (c *caInstance) currentCrl() *x509.RevocationList { + c.crlMutex.RLock() + defer c.crlMutex.RUnlock() + return c.crl +} + +type caFiles struct { + key string + responder string + ca string + crl string +} + +func newCaFiles(directory string) caFiles { + return caFiles{ + key: filepath.Join(directory, keyFileName), + responder: filepath.Join(directory, responderFileName), + ca: filepath.Join(directory, caFileName), + crl: filepath.Join(directory, crlFileName), + } +} + +func (f caFiles) ensureExist(caName string) error { + for _, path := range []string{f.key, f.responder, f.ca, f.crl} { + if _, statError := os.Stat(path); statError != nil { + return fmt.Errorf("ca %q: %w", caName, statError) + } + } + return nil +} + +func verifyResponderIssuedByCa(responder tls.Certificate, ca *x509.Certificate) error { + if responder.Leaf == nil { + return fmt.Errorf("responder leaf certificate could not be parsed") + } + if !bytes.Equal(ca.RawSubject, responder.Leaf.RawIssuer) { + return fmt.Errorf("responder certificate issuer does not match ca certificate subject; %+q != %+q", + ca.Subject.String(), responder.Leaf.Issuer.String()) + } + return nil +} + +func loadCa(name, directory string) (*caInstance, error) { + files := newCaFiles(directory) + if existsError := files.ensureExist(name); existsError != nil { + return nil, existsError + } + + responderKeyPair, loadResponderError := tls.LoadX509KeyPair(files.responder, files.key) + if loadResponderError != nil { + return nil, fmt.Errorf("ca %q: failed to load responder key pair: %w", name, loadResponderError) + } + + caCertificate, loadCaError := loadCertificateFromFile(files.ca) + if loadCaError != nil { + return nil, fmt.Errorf("ca %q: failed to load ca certificate: %w", name, loadCaError) + } + + if verifyError := verifyResponderIssuedByCa(responderKeyPair, caCertificate); verifyError != nil { + return nil, fmt.Errorf("ca %q: %w", name, verifyError) + } + + instance := &caInstance{ + name: name, + crlPath: files.crl, + caCertificate: caCertificate, + source: ocsp_source.NewCrlSource(caCertificate, responderKeyPair), + } + + if reloadError := instance.reloadCrl(); reloadError != nil { + return nil, fmt.Errorf("ca %q: failed to load crl: %w", name, reloadError) + } + + return instance, nil +} + +func listCaSubdirectories(rootDir string) ([]string, error) { + entries, readDirError := os.ReadDir(rootDir) + if readDirError != nil { + return nil, fmt.Errorf("failed to read cas directory: %w", readDirError) + } + names := []string{} + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if strings.HasPrefix(entry.Name(), ".") { + continue + } + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func discoverCas(rootDir string) ([]*caInstance, error) { + names, listError := listCaSubdirectories(rootDir) + if listError != nil { + return nil, listError + } + if len(names) == 0 { + return nil, fmt.Errorf("no ca subdirectories found in %s", rootDir) + } + + cas := make([]*caInstance, 0, len(names)) + for _, name := range names { + instance, loadError := loadCa(name, filepath.Join(rootDir, name)) + if loadError != nil { + return nil, loadError + } + cas = append(cas, instance) + } + return cas, nil +} + +func writeBinary(w http.ResponseWriter, contentType string, body []byte) { + w.Header().Set("Content-Type", contentType) + w.Write(body) +} + +func writePem(w http.ResponseWriter, contentType, blockType string, body []byte) { + w.Header().Set("Content-Type", contentType) + pem.Encode(w, &pem.Block{Type: blockType, Bytes: body}) +} + +func registerOcspRoutes(router *http.ServeMux, prefix string, ca *caInstance) { + responder := cfocsp.NewResponder(ca.source, nil) + router.Handle(prefix+"/ocsp", responder) + router.Handle(prefix+"/ocsp/", http.StripPrefix(prefix+"/ocsp/", responder)) +} + +func registerCrlRoutes(router *http.ServeMux, prefix string, ca *caInstance) { + router.HandleFunc(prefix+"/crl", func(w http.ResponseWriter, r *http.Request) { + writeBinary(w, "application/pkix-cert", ca.currentCrl().Raw) + }) + router.HandleFunc(prefix+"/crl.pem", func(w http.ResponseWriter, r *http.Request) { + writePem(w, "application/pkix-crl", "X509 CRL", ca.currentCrl().Raw) + }) +} + +func registerCaCertificateRoutes(router *http.ServeMux, prefix string, ca *caInstance) { + router.HandleFunc(prefix+"/ca", func(w http.ResponseWriter, r *http.Request) { + writeBinary(w, "application/pkix-cert", ca.caCertificate.Raw) + }) + router.HandleFunc(prefix+"/ca.pem", func(w http.ResponseWriter, r *http.Request) { + writePem(w, "application/x-x509-ca-cert", "CERTIFICATE", ca.caCertificate.Raw) + }) +} + +func registerCaRoutes(router *http.ServeMux, ca *caInstance) { + prefix := "/" + ca.name + registerOcspRoutes(router, prefix, ca) + registerCrlRoutes(router, prefix, ca) + registerCaCertificateRoutes(router, prefix, ca) +} + +func buildApplicationRouter(cas []*caInstance) *http.ServeMux { + router := http.NewServeMux() + for _, ca := range cas { + registerCaRoutes(router, ca) + log.Printf("registered ca %q with routes under /%s/", ca.name, ca.name) + } + return router +} + +func reloadAllCrls(cas []*caInstance) { + for _, ca := range cas { + if reloadError := ca.reloadCrl(); reloadError != nil { + log.Printf("failed to reload crl for ca %q: %v", ca.name, reloadError) + } else { + log.Printf("reloaded crl for ca %q", ca.name) + } + } +} + +func runReloadWorker(signalChan <-chan os.Signal, cas []*caInstance) { + defer log.Println("reload crl worker stopped") + for { + _, ok := <-signalChan + if !ok { + return + } + reloadAllCrls(cas) + } +} + +func startServer(server *http.Server, label string) <-chan struct{} { + closed := make(chan struct{}) + go func() { + log.Printf("starting %s server on %+q", label, server.Addr) + if listenError := server.ListenAndServe(); !errors.Is(listenError, http.ErrServerClosed) { + log.Printf("%s server error: %v", label, listenError) + } + close(closed) + }() + return closed +} + func main() { log.SetFlags(log.Lshortfile) - config := &configuration{ - responder: &responder{}, - crlSourceFile: &crlSourceFile{}, - } - app := kingpin.New("ocspcrl", "OCSP responder / CRL server") - app.HelpFlag.Short('h') - app.Flag("responder.certificate-path", "Path to the responder certificate").Envar("RESPONDER_CERTIFICATE_PATH").Required().ExistingFileVar(&config.responder.certificatePath) - app.Flag("responder.key-path", "Path to the responder key").Envar("RESPONDER_KEY_PATH").Required().ExistingFileVar(&config.responder.keyPath) - app.Flag("ca-crt-path", "Path to the CA certificate").Envar("CA_CRT_PATH").Required().ExistingFileVar(&config.caCrtPath) - app.Flag("crl-source-type", "Type of CRL source").Envar("CRL_SOURCE").Default("file").EnumVar(&config.crlSourceType, "file") - app.Flag("source.file.path", "Path to the CRL file").Envar("SOURCE_FILE_PATH").ExistingFileVar(&config.crlSourceFile.path) - app.Flag("web.listen-address", "Address for application endpoint").Envar("WEB_LISTEN_ADDRESS").Default(":8080").StringVar(&config.applicationListenAddress) - app.Flag("metrics.listen-address", "Address for metrics endpoint").Envar("METRICS_LISTEN_ADDRESS").Default("[::1]:8081").StringVar(&config.metricsListenAddress) - kingpin.MustParse(app.Parse(os.Args[1:])) + config := parseConfiguration(os.Args[1:]) - responderKeyPair, loadResponderKeyPairError := tls.LoadX509KeyPair(config.responder.certificatePath, config.responder.keyPath) - if loadResponderKeyPairError != nil { - log.Fatalf("failed to load responder key pair: %v", loadResponderKeyPairError) + cas, discoverError := discoverCas(config.casDirectory) + if discoverError != nil { + log.Fatalf("failed to load cas: %v", discoverError) } - caCrtContent, openCaCrtError := os.ReadFile(config.caCrtPath) - if openCaCrtError != nil { - log.Fatalf("failed to open ca certificate: %v", openCaCrtError) - } - block, rest := pem.Decode(caCrtContent) - if len(rest) > 0 { - log.Fatalln("failed to decode ca certificate") - } - caCertificate, loadCaCertificateError := x509.ParseCertificate(block.Bytes) - if loadCaCertificateError != nil { - log.Fatalf("failed to parse ca certificate: %v", loadCaCertificateError) - } + applicationRouter := buildApplicationRouter(cas) - if !bytes.Equal(caCertificate.RawSubject, responderKeyPair.Leaf.RawIssuer) { - log.Fatalf("responder certificate issuer does not match ca certificate subject; %+q != %+q", caCertificate.Subject.String(), responderKeyPair.Leaf.Issuer.String()) - } + terminationChan := make(chan os.Signal, 1) + signal.Notify(terminationChan, syscall.SIGINT, syscall.SIGTERM) - source := ocsp_source.NewCrlSource(caCertificate, responderKeyPair) - - crl := &x509.RevocationList{} - - loadCrl := func() error { - curlCandidate, loadCrlError := loadCrlFromFile(config.crlSourceFile.path) - if loadCrlError != nil { - return loadCrlError - } - metrics.CrlEntries.Set(float64(len(curlCandidate.RevokedCertificateEntries))) - source.UseCrl(*curlCandidate) - crl = curlCandidate - return nil - } - - signalChan := make(chan os.Signal, 1) - signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) - - // initial load of the CRL - if loadCrlError := loadCrl(); loadCrlError != nil { - log.Fatalf("failed to load crl: %v", loadCrlError) - } - - // on HUP reload the CRL hupChan := make(chan os.Signal, 1) signal.Notify(hupChan, syscall.SIGHUP) - go reloadCrlWorker(hupChan, loadCrl) - - responder := cfocsp.NewResponder(source, nil) - - applicationRouter := http.NewServeMux() - applicationRouter.Handle("/ocsp", responder) - applicationRouter.Handle("/ocsp/", http.StripPrefix("/ocsp/", responder)) - applicationRouter.HandleFunc("/crl", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/pkix-crl") - w.Write(crl.Raw) - }) - applicationRouter.HandleFunc("/crl.pem", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/pkix-crl") - pem.Encode(w, &pem.Block{Type: "X509 CRL", Bytes: crl.Raw}) - }) - applicationRouter.HandleFunc("/ca", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/pkix-cert") - w.Write(caCertificate.Raw) - }) - applicationRouter.HandleFunc("/ca.pem", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/x-x509-ca-cert") - pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: caCertificate.Raw}) - }) + go runReloadWorker(hupChan, cas) applicationServer := &http.Server{Addr: config.applicationListenAddress, Handler: metrics.Middleware(applicationRouter)} metricsServer := &http.Server{Addr: config.metricsListenAddress, Handler: promhttp.Handler()} - applicationServerClosed := make(chan any) - metricsServerClosed := make(chan any) - go func() { - log.Printf("starting application server on %+q", config.applicationListenAddress) - if listenError := applicationServer.ListenAndServe(); !errors.Is(listenError, http.ErrServerClosed) { - log.Printf("application error: %v", listenError) - } - close(applicationServerClosed) - }() - go func() { - log.Printf("starting metrics server on %+q", config.metricsListenAddress) - if listenError := metricsServer.ListenAndServe(); !errors.Is(listenError, http.ErrServerClosed) { - log.Printf("metrics server error: %v", listenError) - } - close(metricsServerClosed) - }() + applicationServerClosed := startServer(applicationServer, "application") + metricsServerClosed := startServer(metricsServer, "metrics") - <-signalChan + <-terminationChan close(hupChan) applicationServer.Shutdown(nil) metricsServer.Shutdown(nil)