/* 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 opensslcertdb import ( "bufio" "context" "errors" "fmt" "io" "math/big" "os" "path/filepath" "strings" "sync" "time" "code.cacert.org/cacert/goocsp/pkg/filewatcher" "github.com/fsnotify/fsnotify" "github.com/sirupsen/logrus" "code.cacert.org/cacert/goocsp/pkg/ocspsource" "code.cacert.org/cacert/goocsp/pkg/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 lock sync.Mutex } func (o *OpenSSLCertDB) UpdateCertificate(update *ocspsource.CertificateUpdate) { o.lock.Lock() defer o.lock.Unlock() o.content[update.Serial.Text(hexBase)] = &ocsp.Response{ Status: update.Status, SerialNumber: update.Serial, RevokedAt: update.RevokedAt, RevocationReason: update.RevocationReason, } } // NewCertDB creates a new certificate database for the given index.txt file func NewCertDB(ctx context.Context, 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, content: make(map[string]*ocsp.Response)} err = certDb.update() if err != nil { return nil, err } go func(ctx context.Context) { watcherCtx, cancel := context.WithCancel(ctx) filewatcher.Watch(watcherCtx, certDb.fileName, certDb.watchIndexFile) <-ctx.Done() cancel() }(ctx) 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) b := bufio.NewReader(f) lastLine := false count := 0 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 } update := parseLine(strings.TrimSpace(line)) if update != nil { o.UpdateCertificate(update) count++ } if lastLine { break } } logrus.Infof("parsed certificate database '%s', found information for %d certificates", o.fileName, count) return nil } // LookupResponseTemplate retrieves an OCSP response template for the given certificate serial number. func (o *OpenSSLCertDB) LookupResponseTemplate(number *big.Int) *ocsp.Response { o.lock.Lock() defer o.lock.Unlock() 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 } 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) *ocspsource.CertificateUpdate { 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 } update := &ocspsource.CertificateUpdate{ Serial: serialNumber, } mapStatusField(update, parts) if logrus.IsLevelEnabled(logrus.TraceLevel) { traceParsedCertificateLine(parts, serial) } if update.Status == ocsp.Revoked { err = handleRevoked(update, parts, serial) if err != nil { logrus.Warn(err) return nil } } return update } 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(update *ocspsource.CertificateUpdate, parts []string) { switch parts[idxStatus] { case "V": update.Status = ocsp.Good case "R": update.Status = ocsp.Revoked update.RevocationReason = ocsp.Unspecified default: update.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 *ocspsource.CertificateUpdate, 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 }