You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
goocsp/pkg/ocspsource/opensslcertdb.go

344 lines
8.2 KiB
Go

/*
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"
"git.cacert.org/cacert-goocsp/pkg/ocsp"
"github.com/fsnotify/fsnotify"
"github.com/sirupsen/logrus"
)
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
issuer *CertificateIssuer
}
func (o *OpenSSLCertDB) UpdateCertificate(update *CertificateUpdate) {
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)
certDb.WatchChanges(watcherCtx)
<-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 += 1
}
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 {
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) *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 := &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 *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 *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
}