diff --git a/cmd/cacertocsp/main.go b/cmd/cacertocsp/main.go index b6667e2..3238e03 100644 --- a/cmd/cacertocsp/main.go +++ b/cmd/cacertocsp/main.go @@ -1,17 +1,18 @@ /* - Copyright 2022 CAcert Inc. +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. */ package main @@ -31,12 +32,13 @@ import ( "syscall" "time" - "git.cacert.org/cacert-goocsp/internal/ocspsource" "github.com/cloudflare/cfssl/ocsp" "github.com/knadh/koanf" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/file" "github.com/sirupsen/logrus" + + "git.cacert.org/cacert-goocsp/pkg/ocspsource" ) /* constants for configuration keys */ @@ -50,52 +52,22 @@ const ( ) func main() { - var serverAddr = flag.String("serverAddr", ":8080", "Server ip addr and port") - var config = koanf.New(".") + var ( + serverAddr = flag.String("serverAddr", ":8080", "Server ip addr and port") + config = koanf.New(".") + opts []ocspsource.Option + ) err := config.Load(file.Provider("config.yaml"), yaml.Parser()) if err != nil { logrus.Panicf("could not load configuration: %v", err) } + logrus.SetLevel(logrus.DebugLevel) - var opts []ocspsource.Option issuerConfigs := config.Slices(coIssuers) - for number, issuerConfig := range issuerConfigs { - hasErrors := false - for _, item := range []string{issuerCaCert, issuerReCert, issuerReKey, issuerCertList} { - if v := issuerConfig.String(item); v == "" { - logrus.Warnf("%s parameter for issuers entry %d is missing", item, number) - hasErrors = true - } - } - if hasErrors { - logrus.Warnf("configuration for issuers entry %d had errors and has been skipped", number) - continue - } - caCertificate, err := parseCertificate(issuerConfig.String(issuerCaCert)) - if err != nil { - logrus.Errorf("could not parse CA certificate for issuer %d: %v", number, err) - continue - } - responderCertificate, err := parseCertificate(issuerConfig.String(issuerReCert)) - if err != nil { - logrus.Errorf("could not parse OCSP responder certificate for issuer %d: %v", number, err) - continue - } - responderKey, err := parsePrivateKey(issuerConfig.String(issuerReKey)) - if err != nil { - logrus.Errorf("could not parse OCSP responder key for issuer %d: %v", number, err) - continue - } - issuer, err := ocspsource.NewIssuer(caCertificate, responderCertificate, responderKey, issuerConfig.String(issuerCertList)) - if err != nil { - logrus.Errorf("could not create issuer %d: %v", number, err) - continue - } - opts = append(opts, ocspsource.WithIssuer(issuer)) - } + opts = configureIssuers(issuerConfigs, opts) cacertSource, err := ocspsource.NewSource(opts...) if err != nil { @@ -121,17 +93,78 @@ func main() { if !errors.Is(err, http.ErrServerClosed) { logrus.Panicf("could not start the server process: %v", err) } + logrus.Infof("server shutdown") } } +func configureIssuers(issuerConfigs []*koanf.Koanf, opts []ocspsource.Option) []ocspsource.Option { + for number, issuerConfig := range issuerConfigs { + hasErrors := false + + for _, item := range []string{issuerCaCert, issuerReCert, issuerReKey, issuerCertList} { + if v := issuerConfig.String(item); v == "" { + logrus.Warnf("%s parameter for issuers entry %d is missing", item, number) + + hasErrors = true + } + } + + if hasErrors { + logrus.Warnf("configuration for issuers entry %d had errors and has been skipped", number) + + continue + } + + caCertificate, err := parseCertificate(issuerConfig.String(issuerCaCert)) + if err != nil { + logrus.Errorf("could not parse CA certificate for issuer %d: %v", number, err) + + continue + } + + responderCertificate, err := parseCertificate(issuerConfig.String(issuerReCert)) + if err != nil { + logrus.Errorf("could not parse OCSP responder certificate for issuer %d: %v", number, err) + + continue + } + + responderKey, err := parsePrivateKey(issuerConfig.String(issuerReKey)) + if err != nil { + logrus.Errorf("could not parse OCSP responder key for issuer %d: %v", number, err) + + continue + } + + issuer, err := ocspsource.NewIssuer( + caCertificate, + responderCertificate, + responderKey, + issuerConfig.String(issuerCertList), + ) + if err != nil { + logrus.Errorf("could not create issuer %d: %v", number, err) + + continue + } + + opts = append(opts, ocspsource.WithIssuer(issuer)) + } + + return opts +} + // The setupCloseHandler takes care of OS signal handling func setupCloseHandler(ctx context.Context, server *http.Server) { - c := make(chan os.Signal, 0) + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func(ctx context.Context) { <-c logrus.Infof("program interrupted") + err := server.Shutdown(ctx) if err != nil { logrus.Errorf("could not close server: %v", err) @@ -143,6 +176,7 @@ func setupCloseHandler(ctx context.Context, server *http.Server) { func withLogging(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { start := time.Now() + next.ServeHTTP(w, r) logrus.Infof("GET %s FROM %s in %dms", r.URL.Path, r.RemoteAddr, time.Since(start).Milliseconds()) } @@ -159,6 +193,7 @@ func parseCertificate(certificateFile string) (*x509.Certificate, error) { if block == nil { return nil, fmt.Errorf("could not find PEM data in %s", certificateFile) } + certificate, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("could not parse certificate in %s: %w", certificateFile, err) @@ -185,12 +220,19 @@ func parsePrivateKey(keyFile string) (crypto.Signer, error) { if err != nil { return nil, fmt.Errorf("no usable private key found in %s: %w", keyFile, err) } - return key.(crypto.Signer), nil + + signer, ok := key.(crypto.Signer) + if !ok { + return nil, errors.New("key cannot be used as signer") + } + + return signer, nil case "RSA PRIVATE KEY": rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("no usable private key found in %s: %w", keyFile, err) } + return rsaKey, nil default: return nil, fmt.Errorf("unsupported PEM block type %s in %s", block.Type, keyFile) diff --git a/internal/ocspsource/opensslcertdb.go b/internal/ocspsource/opensslcertdb.go deleted file mode 100644 index ee147f7..0000000 --- a/internal/ocspsource/opensslcertdb.go +++ /dev/null @@ -1,247 +0,0 @@ -/* - Copyright 2022 CAcert Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package ocspsource - -import ( - "bufio" - "context" - "errors" - "fmt" - "io" - "math/big" - "os" - "path" - "path/filepath" - "strings" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/sirupsen/logrus" - "golang.org/x/crypto/ocsp" -) - -// OpenSSLCertDB implements the needed glue code to interface with the index.txt format of openssl ca -type OpenSSLCertDB struct { - fileName string - content map[string]*ocsp.Response -} - -// NewCertDB creates a new certificate database for the given index.txt file -func NewCertDB(fileName string) (*OpenSSLCertDB, error) { - absFile, err := filepath.Abs(fileName) - if err != nil { - return nil, fmt.Errorf("could not determine absolute file name of %s: %w", fileName, err) - } - - certDb := &OpenSSLCertDB{fileName: absFile} - - err = certDb.update() - if err != nil { - return nil, err - } - - return certDb, nil -} - -// The update method reads the content of index.txt and replaces the current content of the OpenSSLCertDB -func (o *OpenSSLCertDB) update() error { - f, err := os.Open(o.fileName) - if err != nil { - return fmt.Errorf("could not open %s: %w", o.fileName, err) - } - defer func(f *os.File) { - _ = f.Close() - }(f) - - newContent := make(map[string]*ocsp.Response) - b := bufio.NewReader(f) - lastLine := false - for { - line, err := b.ReadString('\n') - if err != nil { - if !errors.Is(err, io.EOF) { - return fmt.Errorf("could not read next line: %w", err) - } - lastLine = true - } - - serial, response := parseLine(strings.TrimSpace(line)) - if serial != "" { - newContent[serial] = response - } - - if lastLine { - break - } - } - - logrus.Infof("parsed certificate database '%s', found information for %d certificates", o.fileName, len(newContent)) - - o.content = newContent - - return nil -} - -// LookupResponseTemplate retrieves an OCSP response template for the given certificate serial number. -func (o *OpenSSLCertDB) LookupResponseTemplate(number *big.Int) *ocsp.Response { - serial := number.Text(16) - if response, ok := o.content[serial]; ok { - return response - } - // RFC https://datatracker.ietf.org/doc/html/rfc6960#section-2.2 states that certificates we cannot answer for - // should be marked as good. This would allow a CRL as sole certificate source. - // - // We might change this to an unknown response when we have a more accurate certificate source like an API or an - // up-to-date openssl index.txt. - logrus.Debugf("received request for certificate %s that is not in our data source", serial) - response := &ocsp.Response{ - Status: ocsp.Good, - SerialNumber: number, - } - return response -} - -// WatchChanges creates file system monitoring for the index.txt file of the OpenSSLCertDB -func (o *OpenSSLCertDB) WatchChanges(ctx context.Context) { - watcher, err := fsnotify.NewWatcher() - if err != nil { - logrus.Fatalf("could not create file watcher: %v", err) - } - defer func(watcher *fsnotify.Watcher) { - _ = watcher.Close() - logrus.Infof("stopped watching for %s changes", o.fileName) - }(watcher) - - go func(watcher *fsnotify.Watcher, filename string) { - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return - } - if event.Op&fsnotify.Write == fsnotify.Write { - if event.Name == filename { - logrus.Infof("modified: %s", event.Name) - err := o.update() - if err != nil { - logrus.Error(err) - } - } - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - logrus.Errorf("error from watcher: %v", err) - } - } - }(watcher, o.fileName) - - err = watcher.Add(path.Dir(o.fileName)) - if err != nil { - logrus.Fatalf("could not watch %s: %v", o.fileName, err) - } - logrus.Infof("watching for changes on %s", o.fileName) - - <-ctx.Done() - logrus.Infof("ending background tasks for %s", o.fileName) -} - -// The parseLine function parses a line of index.txt. -func parseLine(line string) (string, *ocsp.Response) { - if line == "" { - return "", nil - } - - parts := strings.Split(line, "\t") - if len(parts) != 6 { - logrus.Warnf("found invalid line '%s'", line) - return "", nil - } - - serial := parts[3] - serialNumber := new(big.Int) - _, ok := serialNumber.SetString(serial, 16) - if !ok { - logrus.Warnf("could not parse %s as serial number", serial) - return "", nil - } - - response := ocsp.Response{ - SerialNumber: serialNumber, - } - - switch parts[0] { - case "V": - response.Status = ocsp.Good - case "R": - response.Status = ocsp.Revoked - response.RevocationReason = ocsp.Unspecified - default: - response.Status = ocsp.Unknown - } - - if response.Status == ocsp.Revoked && parts[2] == "" { - logrus.Warnf("inconsistency detected certificate with serial %s is marked as revoked but has no revocation timestamp", serial) - return "", nil - } - - if response.Status == ocsp.Revoked { - revocation := []string{parts[2]} - if strings.Contains(revocation[0], ",") { - revocation = strings.SplitN(revocation[0], ",", 2) - } - if len(revocation) == 2 { - response.RevocationReason = mapRevocationReason(revocation[1]) - } - revocationTimeStamp, err := time.Parse("20060102150405Z", revocation[0]) - if err != nil { - logrus.Warnf("could not parse %s as revocation timestamp for serial %s: %v", revocation[0], serial, err) - return "", nil - } - response.RevokedAt = revocationTimeStamp.UTC() - } - - return serialNumber.Text(16), &response -} - -// mapRevocationReason takes a OCSP revocation reason string and converts it to the numerical representation required -// for OCSP responses. -func mapRevocationReason(reason string) int { - switch reason { - case "keyCompromise": - return ocsp.KeyCompromise - case "CACompromise": - return ocsp.CACompromise - case "affiliationChanged": - return ocsp.AffiliationChanged - case "superseded": - return ocsp.Superseded - case "cessationOfOperation": - return ocsp.CessationOfOperation - case "certificateHold": - return ocsp.CertificateHold - case "removeFromCRL": - return ocsp.RemoveFromCRL - case "privilegeWithdrawn": - return ocsp.PrivilegeWithdrawn - case "AACompromise": - return ocsp.AACompromise - default: - return ocsp.Unspecified - } -} diff --git a/internal/ocspsource/ocspsource.go b/pkg/ocspsource/ocspsource.go similarity index 82% rename from internal/ocspsource/ocspsource.go rename to pkg/ocspsource/ocspsource.go index e6664d3..3173f08 100644 --- a/internal/ocspsource/ocspsource.go +++ b/pkg/ocspsource/ocspsource.go @@ -1,17 +1,18 @@ /* - Copyright 2022 CAcert Inc. +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. */ package ocspsource @@ -35,7 +36,7 @@ import ( "golang.org/x/crypto/ocsp" ) -var unknownIssuer = errors.New("issuer key hash does not match any of the known issuers") +var errUnknownIssuer = errors.New("issuer key hash does not match any of the known issuers") // CertificateIssuer is an abstraction for the OCSP responder that knows certificates of a specific CA type CertificateIssuer struct { @@ -46,7 +47,11 @@ type CertificateIssuer struct { } // NewIssuer is used to construct a new CertificateIssuer instance -func NewIssuer(caCertificate, responderCertificate *x509.Certificate, responderKey crypto.Signer, certificateFile string) (*CertificateIssuer, error) { +func NewIssuer( + caCertificate, responderCertificate *x509.Certificate, + responderKey crypto.Signer, + certificateFile string, +) (*CertificateIssuer, error) { certDb, err := NewCertDB(certificateFile) if err != nil { return nil, fmt.Errorf("could not initialize certificate database: %w", err) @@ -73,6 +78,7 @@ func (i *CertificateIssuer) publicKeyMatches(requestHash []byte, algorithm crypt if _, err := h.Write(publicKeyInfo.PublicKey.RightAlign()); err != nil { return false, fmt.Errorf("could not update digest instance: %w", err) } + issuerHash := h.Sum(nil) return bytes.Equal(issuerHash, requestHash), nil @@ -84,6 +90,7 @@ func (i *CertificateIssuer) buildUnknownResponse(number *big.Int) ([]byte, error SerialNumber: number, Status: ocsp.Unknown, } + return i.buildResponse(template) } @@ -93,7 +100,13 @@ func (i *CertificateIssuer) buildResponse(template *ocsp.Response) ([]byte, erro template.ThisUpdate = time.Now() template.NextUpdate = time.Now().Add(time.Hour) template.Certificate = i.responderCertificate - return ocsp.CreateResponse(i.caCertificate, i.responderCertificate, *template, i.responderKey) + + response, err := ocsp.CreateResponse(i.caCertificate, i.responderCertificate, *template, i.responderKey) + if err != nil { + return nil, fmt.Errorf("could not create final OCSP response: %w", err) + } + + return response, nil } // String returns the name of the CertificateIssuer @@ -107,12 +120,14 @@ func (i *CertificateIssuer) LookupResponse(serialNumber *big.Int) ([]byte, error if response == nil { return i.buildUnknownResponse(serialNumber) } + return i.buildResponse(response) } // The backgroundTasks of the issuer are started func (i *CertificateIssuer) backgroundTasks(ctx context.Context) { logrus.Infof("starting background tasks for ceritificate issuer %s", i) + watcherCtx, cancel := context.WithCancel(ctx) go i.certDb.WatchChanges(watcherCtx) @@ -150,7 +165,9 @@ func NewSource(options ...Option) (*OcspSource, error) { func WithIssuer(issuer *CertificateIssuer) Option { return func(o *OcspSource) error { o.issuers = append(o.issuers, issuer) + logrus.Infof("add issuer %s as known issuer", issuer) + return nil } } @@ -159,22 +176,27 @@ func WithIssuer(issuer *CertificateIssuer) Option { func (o *OcspSource) Response(r *ocsp.Request) ([]byte, http.Header, error) { issuer, err := o.getIssuer(r.IssuerKeyHash, r.HashAlgorithm) if err != nil { - if errors.Is(err, unknownIssuer) { + if errors.Is(err, errUnknownIssuer) { logrus.Infof("received request for unknown issuer key hash %s", hex.EncodeToString(r.IssuerKeyHash)) + issuer = o.getDefaultIssuer() + response, err := issuer.buildUnknownResponse(r.SerialNumber) if err != nil { - return nil, nil, fmt.Errorf("could not build OCSP response: %v", err) + return nil, nil, fmt.Errorf("could not build OCSP response: %w", err) } + return response, nil, nil } + return nil, nil, fmt.Errorf("cannot answer request: %w", err) } response, err := issuer.LookupResponse(r.SerialNumber) if err != nil { - return nil, nil, fmt.Errorf("could not build OCSP response: %v", err) + return nil, nil, fmt.Errorf("could not build OCSP response: %w", err) } + return response, nil, nil } @@ -185,11 +207,13 @@ func (o *OcspSource) getIssuer(keyHash []byte, algorithm crypto.Hash) (*Certific if err != nil { logrus.Errorf("error for issuer: %v", err) } + if matched { return issuer, nil } } - return nil, unknownIssuer + + return nil, errUnknownIssuer } // BackgroundTasks delegates background task handling to the CertificateIssuer instances of the OcspSource @@ -197,13 +221,16 @@ func (o *OcspSource) BackgroundTasks(ctx context.Context) { wg := sync.WaitGroup{} wg.Add(len(o.issuers)) logrus.Info("starting OCSP source background tasks") + for _, issuer := range o.issuers { issuer := issuer + go func() { issuer.backgroundTasks(ctx) wg.Done() }() } + wg.Wait() logrus.Info("stopped OCSP source background tasks") } diff --git a/pkg/ocspsource/opensslcertdb.go b/pkg/ocspsource/opensslcertdb.go new file mode 100644 index 0000000..5852288 --- /dev/null +++ b/pkg/ocspsource/opensslcertdb.go @@ -0,0 +1,326 @@ +/* +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ocspsource + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math/big" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ocsp" +) + +const ( + hexBase = 16 + opensslUtcFormat = "20060102150405Z" +) + +// field positions in index.txt + +const ( + idxStatus int = iota + idxExpiration + idxRevocation + idxSerial + idxFilename + idxSubjectDN +) + +// OpenSSLCertDB implements the needed glue code to interface with the index.txt format of openssl ca +type OpenSSLCertDB struct { + fileName string + content map[string]*ocsp.Response +} + +// NewCertDB creates a new certificate database for the given index.txt file +func NewCertDB(fileName string) (*OpenSSLCertDB, error) { + absFile, err := filepath.Abs(fileName) + if err != nil { + return nil, fmt.Errorf("could not determine absolute file name of %s: %w", fileName, err) + } + + certDb := &OpenSSLCertDB{fileName: absFile} + + err = certDb.update() + if err != nil { + return nil, err + } + + return certDb, nil +} + +// The update method reads the content of index.txt and replaces the current content of the OpenSSLCertDB +func (o *OpenSSLCertDB) update() error { + f, err := os.Open(o.fileName) + if err != nil { + return fmt.Errorf("could not open %s: %w", o.fileName, err) + } + + defer func(f *os.File) { + _ = f.Close() + }(f) + + newContent := make(map[string]*ocsp.Response) + b := bufio.NewReader(f) + lastLine := false + + for { + line, err := b.ReadString('\n') + if err != nil { + if !errors.Is(err, io.EOF) { + return fmt.Errorf("could not read next line: %w", err) + } + + lastLine = true + } + + serial, response := parseLine(strings.TrimSpace(line)) + if serial != "" { + newContent[serial] = response + } + + if lastLine { + break + } + } + + logrus.Infof("parsed certificate database '%s', found information for %d certificates", o.fileName, len(newContent)) + + o.content = newContent + + return nil +} + +// LookupResponseTemplate retrieves an OCSP response template for the given certificate serial number. +func (o *OpenSSLCertDB) LookupResponseTemplate(number *big.Int) *ocsp.Response { + serial := number.Text(hexBase) + if response, ok := o.content[serial]; ok { + return response + } + + logrus.Debugf("received request for certificate %s that is not in our data source", serial) + + // RFC https://datatracker.ietf.org/doc/html/rfc6960#section-2.2 states that certificates we cannot answer for + // should be marked as good. This would allow a CRL as sole certificate source. + // + // We might change this to an unknown response when we have a more accurate certificate source like an API or an + // up-to-date openssl index.txt. + response := &ocsp.Response{ + Status: ocsp.Good, + SerialNumber: number, + } + + return response +} + +// WatchChanges creates file system monitoring for the index.txt file of the OpenSSLCertDB +func (o *OpenSSLCertDB) WatchChanges(ctx context.Context) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + logrus.Fatalf("could not create file watcher: %v", err) + } + + defer func(watcher *fsnotify.Watcher) { + _ = watcher.Close() + + logrus.Infof("stopped watching for %s changes", o.fileName) + }(watcher) + + go o.watchIndexFile(watcher) + + err = watcher.Add(path.Dir(o.fileName)) + if err != nil { + logrus.Fatalf("could not watch %s: %v", o.fileName, err) + } + + logrus.Infof("watching for changes on %s", o.fileName) + + <-ctx.Done() + logrus.Infof("ending background tasks for %s", o.fileName) +} + +func (o *OpenSSLCertDB) watchIndexFile(watcher *fsnotify.Watcher) { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + if event.Op&fsnotify.Write == fsnotify.Write { + if event.Name == o.fileName { + logrus.Infof("modified: %s", event.Name) + + err := o.update() + if err != nil { + logrus.Error(err) + } + } + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + + logrus.Errorf("error from watcher: %v", err) + } + } +} + +// The parseLine function parses a line of index.txt. +func parseLine(line string) (string, *ocsp.Response) { + const ( + fieldSeparator = "\t" + ) + + if line == "" { + return "", nil + } + + parts := strings.Split(line, fieldSeparator) + if len(parts) != idxSubjectDN+1 { + logrus.Warnf("found invalid line '%s'", line) + + return "", nil + } + + serial, serialNumber, err := parseSerialNumber(parts) + if err != nil { + logrus.Warn(err) + + return "", nil + } + + response := ocsp.Response{ + SerialNumber: serialNumber, + } + + mapStatusField(&response, parts) + + if logrus.IsLevelEnabled(logrus.TraceLevel) { + traceParsedCertificateLine(parts, serial) + } + + if response.Status == ocsp.Revoked { + err = handleRevoked(&response, parts, serial) + if err != nil { + logrus.Warn(err) + + return "", nil + } + } + + return serialNumber.Text(hexBase), &response +} + +func parseSerialNumber(parts []string) (string, *big.Int, error) { + serial := parts[idxSerial] + serialNumber := new(big.Int) + + _, ok := serialNumber.SetString(serial, hexBase) + if !ok { + return "", nil, fmt.Errorf("could not parse %s as serial number", serial) + } + + return serial, serialNumber, nil +} + +func mapStatusField(response *ocsp.Response, parts []string) { + switch parts[idxStatus] { + case "V": + response.Status = ocsp.Good + case "R": + response.Status = ocsp.Revoked + response.RevocationReason = ocsp.Unspecified + default: + response.Status = ocsp.Unknown + } +} + +func traceParsedCertificateLine(parts []string, serial string) { + expirationTimeStamp, err := time.Parse(opensslUtcFormat, parts[idxExpiration]) + if err != nil { + logrus.Warnf("could not parse %s as expiration timestamp for serial %s: %v", parts[idxExpiration], serial, err) + } + + logrus.Tracef( + "found certificate with serial number %s, expiration timestamp %s and filename '%s' for subject DN '%s'", + serial, expirationTimeStamp, parts[idxFilename], parts[idxSubjectDN], + ) +} + +func handleRevoked(response *ocsp.Response, parts []string, serial string) error { + const lenWithReason = 2 + + if parts[idxRevocation] == "" { + return fmt.Errorf( + "inconsistency detected certificate with serial %s is marked as revoked but has no revocation timestamp", + serial, + ) + } + + revocation := []string{parts[idxRevocation]} + if strings.Contains(revocation[0], ",") { + revocation = strings.SplitN(revocation[0], ",", lenWithReason) + } + + if len(revocation) == lenWithReason { + response.RevocationReason = mapRevocationReason(revocation[1]) + } + + revocationTimeStamp, err := time.Parse(opensslUtcFormat, revocation[0]) + if err != nil { + return fmt.Errorf("could not parse %s as revocation timestamp for serial %s: %w", revocation[0], serial, err) + } + + response.RevokedAt = revocationTimeStamp.UTC() + + return nil +} + +// mapRevocationReason takes a OCSP revocation reason string and converts it to the numerical representation required +// for OCSP responses. +func mapRevocationReason(reason string) int { + var revocationReasons = map[string]int{ + "keyCompromise": ocsp.KeyCompromise, + "CACompromise": ocsp.CACompromise, + "affiliationChanged": ocsp.AffiliationChanged, + "superseded": ocsp.Superseded, + "cessationOfOperation": ocsp.CessationOfOperation, + "certificateHold": ocsp.CertificateHold, + "removeFromCRL": ocsp.RemoveFromCRL, + "privilegeWithdrawn": ocsp.PrivilegeWithdrawn, + "AACompromise": ocsp.AACompromise, + } + + if reasonCode, exist := revocationReasons[reason]; exist { + return reasonCode + } + + return ocsp.Unspecified +}