Jan Dittberner
b0a16bb85c
needed to integrate code from cfssl and golang.org/x/crypto/ocsp into new pkg/ocsp to be able to add response extensions.
343 lines
8.2 KiB
Go
343 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
|
|
}
|