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 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 `<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),
}, []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() {

452
main.go
View File

@@ -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)