feat: implement multi CA serving

This commit is contained in:
T. R. Bernstein
2026-04-30 02:08:56 +02:00
parent 00ac7628d4
commit 4e4dd2eaae
3 changed files with 356 additions and 168 deletions

View File

@@ -1,17 +1,61 @@
# OCSP Server # 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. 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. It provides the following http endpoints: 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 | ## Configuration
|------------|----------------------------------------------------------|
| `/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 |
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. Point the server at a single directory that contains one subdirectory per CA. The subdirectory name is used as the route prefix.
When using OCSP, the certificate is checked against the CRL for validity.
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 `<route-prefix>`, the following endpoints are exposed:
| Endpoint | Content-Type | Description |
|-----------------------------|-----------------------------|----------------------------------------------------------|
| `<route-prefix>/ocsp` | (OCSP) | OCSP responder supporting both `GET` and `POST` requests |
| `<route-prefix>/ocsp/` | (OCSP) | OCSP responder supporting both `GET` and `POST` requests |
| `<route-prefix>/crl` | `application/pkix-cert` | CRL in DER form |
| `<route-prefix>/crl.pem` | `application/pkix-crl` | CRL in PEM form |
| `<route-prefix>/ca` | `application/pkix-cert` | CA certificate in DER form |
| `<route-prefix>/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://<host>/hr/ocsp` and its CRL is at `https://<host>/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.

View File

@@ -26,11 +26,11 @@ var (
Buckets: prometheus.ExponentialBuckets(0.0001, 2, 10), Buckets: prometheus.ExponentialBuckets(0.0001, 2, 10),
}, []string{labelPath}) }, []string{labelPath})
CrlEntries = prometheus.NewGauge(prometheus.GaugeOpts{ CrlEntries = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "ocspcrl", Namespace: "ocspcrl",
Name: "crl_entries_total", Name: "crl_entries_total",
Help: "Number of entries in the CRL", Help: "Number of entries in the CRL",
}) }, []string{"ca"})
) )
func init() { func init() {

436
main.go
View File

@@ -11,6 +11,10 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall" "syscall"
"github.com/alecthomas/kingpin/v2" "github.com/alecthomas/kingpin/v2"
@@ -21,180 +25,320 @@ import (
"ocspcrl/internal/ocsp_source" "ocspcrl/internal/ocsp_source"
) )
type loadCrlFunction func() error const (
crlFileName = "crl.pem"
func loadCrlFromFile(path string) (*x509.RevocationList, error) { keyFileName = "key.pem"
crlContent, openCrlError := os.ReadFile(path) responderFileName = "responder.crt"
if openCrlError != nil { caFileName = "ca.crt"
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
}
type configuration struct { type configuration struct {
responder *responder casDirectory string
caCrtPath string
crlSourceType string
crlSourceFile *crlSourceFile
applicationListenAddress string applicationListenAddress string
metricsListenAddress string metricsListenAddress string
} }
func main() { func parseConfiguration(args []string) *configuration {
log.SetFlags(log.Lshortfile) config := &configuration{}
config := &configuration{ app := kingpin.New("ocspcrl", "OCSP responder / CRL server (multi-CA)")
responder: &responder{},
crlSourceFile: &crlSourceFile{},
}
app := kingpin.New("ocspcrl", "OCSP responder / CRL server")
app.HelpFlag.Short('h') 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("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.").
app.Flag("responder.key-path", "Path to the responder key").Envar("RESPONDER_KEY_PATH").Required().ExistingFileVar(&config.responder.keyPath) Envar("CAS_DIRECTORY").Required().ExistingDirVar(&config.casDirectory)
app.Flag("ca-crt-path", "Path to the CA certificate").Envar("CA_CRT_PATH").Required().ExistingFileVar(&config.caCrtPath) app.Flag("web.listen-address", "Address for application endpoint").
app.Flag("crl-source-type", "Type of CRL source").Envar("CRL_SOURCE").Default("file").EnumVar(&config.crlSourceType, "file") Envar("WEB_LISTEN_ADDRESS").Default(":8080").StringVar(&config.applicationListenAddress)
app.Flag("source.file.path", "Path to the CRL file").Envar("SOURCE_FILE_PATH").ExistingFileVar(&config.crlSourceFile.path) app.Flag("metrics.listen-address", "Address for metrics endpoint").
app.Flag("web.listen-address", "Address for application endpoint").Envar("WEB_LISTEN_ADDRESS").Default(":8080").StringVar(&config.applicationListenAddress) Envar("METRICS_LISTEN_ADDRESS").Default("[::1]:8081").StringVar(&config.metricsListenAddress)
app.Flag("metrics.listen-address", "Address for metrics endpoint").Envar("METRICS_LISTEN_ADDRESS").Default("[::1]:8081").StringVar(&config.metricsListenAddress) kingpin.MustParse(app.Parse(args))
kingpin.MustParse(app.Parse(os.Args[1:])) return config
responderKeyPair, loadResponderKeyPairError := tls.LoadX509KeyPair(config.responder.certificatePath, config.responder.keyPath)
if loadResponderKeyPairError != nil {
log.Fatalf("failed to load responder key pair: %v", loadResponderKeyPairError)
} }
caCrtContent, openCaCrtError := os.ReadFile(config.caCrtPath) func decodeCrlBytes(content []byte) ([]byte, error) {
if openCaCrtError != nil { if !bytes.Contains(content, []byte("BEGIN")) {
log.Fatalf("failed to open ca certificate: %v", openCaCrtError) return content, nil
} }
block, rest := pem.Decode(caCrtContent) block, rest := pem.Decode(content)
if len(rest) > 0 { if block == nil {
log.Fatalln("failed to decode ca certificate") return nil, fmt.Errorf("crl pem block could not be decoded")
} }
caCertificate, loadCaCertificateError := x509.ParseCertificate(block.Bytes) if len(bytes.TrimSpace(rest)) > 0 {
if loadCaCertificateError != nil { return nil, fmt.Errorf("crl file contains trailing data")
log.Fatalf("failed to parse ca certificate: %v", loadCaCertificateError) }
return block.Bytes, nil
} }
if !bytes.Equal(caCertificate.RawSubject, responderKeyPair.Leaf.RawIssuer) { func loadCrlFromFile(path string) (*x509.RevocationList, error) {
log.Fatalf("responder certificate issuer does not match ca certificate subject; %+q != %+q", caCertificate.Subject.String(), responderKeyPair.Leaf.Issuer.String()) 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)
} }
source := ocsp_source.NewCrlSource(caCertificate, responderKeyPair) func loadCertificateFromFile(path string) (*x509.Certificate, error) {
content, readError := os.ReadFile(path)
crl := &x509.RevocationList{} if readError != nil {
return nil, readError
loadCrl := func() error {
curlCandidate, loadCrlError := loadCrlFromFile(config.crlSourceFile.path)
if loadCrlError != nil {
return loadCrlError
} }
metrics.CrlEntries.Set(float64(len(curlCandidate.RevokedCertificateEntries))) block, rest := pem.Decode(content)
source.UseCrl(*curlCandidate) if block == nil {
crl = curlCandidate 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 return nil
} }
signalChan := make(chan os.Signal, 1) func (c *caInstance) currentCrl() *x509.RevocationList {
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) c.crlMutex.RLock()
defer c.crlMutex.RUnlock()
// initial load of the CRL return c.crl
if loadCrlError := loadCrl(); loadCrlError != nil {
log.Fatalf("failed to load crl: %v", loadCrlError)
} }
// on HUP reload the 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 := parseConfiguration(os.Args[1:])
cas, discoverError := discoverCas(config.casDirectory)
if discoverError != nil {
log.Fatalf("failed to load cas: %v", discoverError)
}
applicationRouter := buildApplicationRouter(cas)
terminationChan := make(chan os.Signal, 1)
signal.Notify(terminationChan, syscall.SIGINT, syscall.SIGTERM)
hupChan := make(chan os.Signal, 1) hupChan := make(chan os.Signal, 1)
signal.Notify(hupChan, syscall.SIGHUP) signal.Notify(hupChan, syscall.SIGHUP)
go reloadCrlWorker(hupChan, loadCrl) go runReloadWorker(hupChan, cas)
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})
})
applicationServer := &http.Server{Addr: config.applicationListenAddress, Handler: metrics.Middleware(applicationRouter)} applicationServer := &http.Server{Addr: config.applicationListenAddress, Handler: metrics.Middleware(applicationRouter)}
metricsServer := &http.Server{Addr: config.metricsListenAddress, Handler: promhttp.Handler()} metricsServer := &http.Server{Addr: config.metricsListenAddress, Handler: promhttp.Handler()}
applicationServerClosed := make(chan any) applicationServerClosed := startServer(applicationServer, "application")
metricsServerClosed := make(chan any) metricsServerClosed := startServer(metricsServer, "metrics")
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)
}()
<-signalChan <-terminationChan
close(hupChan) close(hupChan)
applicationServer.Shutdown(nil) applicationServer.Shutdown(nil)
metricsServer.Shutdown(nil) metricsServer.Shutdown(nil)