cacert-gosigner/internal/x509/openssl/repository.go

434 lines
10 KiB
Go

/*
Copyright 2021-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 openssl
import (
"bufio"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"errors"
"fmt"
"math/big"
"os"
"path"
"strings"
"sync"
"time"
"git.cacert.org/cacert-gosigner/internal/x509/revoking"
)
const TimeSpec = "060102030405Z"
type indexStatus string
const (
certificateValid indexStatus = "V"
certificateRevoked indexStatus = "R"
certificateExpired indexStatus = "E"
)
const (
posStatus int = iota
posExpiry
posRevocation
posSerial
posFilename
posSubject
)
// An indexEntry represents a line in an openssl ca compatible index.txt file
// a format specification is available at https://pki-tutorial.readthedocs.io/en/latest/cadb.html
type indexEntry struct {
statusFlag indexStatus
expiresAt time.Time
revokedAt *time.Time
revocationReason revoking.CRLReason
serialNumber *big.Int
fileName string
certificateSubjectDN string
}
func (ie *indexEntry) String() string {
var revoked, fileName string
if ie.revokedAt != nil {
revoked = fmt.Sprintf("%s,%s", ie.revokedAt.Format(TimeSpec), ie.revocationReason)
}
if ie.fileName == "" {
fileName = "unknown"
}
return strings.Join([]string{
string(ie.statusFlag),
ie.expiresAt.Format(TimeSpec),
revoked,
strings.ToUpper(ie.serialNumber.Text(16)),
fileName,
ie.certificateSubjectDN, // this is not 100% compatible with openssl that uses a non-RFC syntax
}, "\t")
}
// The Repository stores information about signed and revoked certificates
// in an openssl index.txt compatible file.
//
// A reference for the file format can be found at
// https://pki-tutorial.readthedocs.io/en/latest/cadb.html.
type Repository struct {
indexFileName string
crlNumberFileName string
lock sync.Locker
entries []indexEntry
}
func (r *Repository) NextCRLNumber() (*big.Int, error) {
r.lock.Lock()
defer r.lock.Unlock()
number := big.NewInt(0)
data, err := os.ReadFile(r.crlNumberFileName)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf(
"could not read next CRL number from file %s: %w",
r.crlNumberFileName,
err,
)
}
} else {
if _, ok := number.SetString(string(data), 16); !ok {
return nil, fmt.Errorf("could not parse %s as CRL serial number", data)
}
}
number.Add(number, big.NewInt(1))
err = os.WriteFile(r.crlNumberFileName, []byte(number.Text(16)), 0600) //nolint:gomnd
if err != nil {
return nil, fmt.Errorf(
"could not write next CRL serial number to file %s: %w",
r.crlNumberFileName,
err,
)
}
return number, nil
}
func (ie *indexEntry) markRevoked(revocationTime time.Time, reason revoking.CRLReason) {
if ie.statusFlag == certificateValid {
ie.statusFlag = certificateRevoked
ie.revokedAt = &revocationTime
ie.revocationReason = reason
}
}
func (r *Repository) findEntry(number *big.Int) (*indexEntry, error) {
if number == nil {
return nil, errors.New("serial number must not be nil")
}
for _, entry := range r.entries {
if entry.serialNumber.Cmp(number) == 0 {
return &entry, nil
}
}
return nil, nil
}
type CannotRevokeUnknown struct {
Serial *big.Int
}
func (c CannotRevokeUnknown) Error() string {
return fmt.Sprintf("cannot revoke unknown certificate with serial number %s", c.Serial)
}
// StoreRevocation records information about a revoked certificate.
func (r *Repository) StoreRevocation(revoked *pkix.RevokedCertificate) error {
r.lock.Lock()
defer r.lock.Unlock()
err := r.loadIndex()
if err != nil {
return err
}
entry, err := r.findEntry(revoked.SerialNumber)
if err != nil {
return err
}
if entry == nil {
return CannotRevokeUnknown{Serial: revoked.SerialNumber}
}
reason := revoking.CRLReasonUnspecified
for _, ext := range revoked.Extensions {
if ext.Id.Equal(revoking.OidCRLReason) {
_, err := asn1.Unmarshal(ext.Value, &reason)
if err != nil {
return fmt.Errorf("could not unmarshal ")
}
}
}
entry.markRevoked(revoked.RevocationTime, reason)
err = r.writeIndex()
return err
}
// StoreCertificate records information about a signed certificate.
func (r *Repository) StoreCertificate(signed *x509.Certificate) error {
var err error
r.lock.Lock()
defer r.lock.Unlock()
err = r.loadIndex()
if err != nil {
return err
}
entry, err := r.findEntry(signed.SerialNumber)
if err != nil {
return err
}
if entry != nil {
return fmt.Errorf("certificate with serial %s is already in the index", signed.SerialNumber)
}
status := certificateValid
if signed.NotAfter.Before(time.Now().UTC()) {
status = certificateExpired
}
err = r.addIndexEntry(&indexEntry{
statusFlag: status,
expiresAt: signed.NotAfter,
serialNumber: signed.SerialNumber,
certificateSubjectDN: signed.Subject.String(), // not openssl compatible
})
if err != nil {
return err
}
return nil
}
func (r *Repository) RevokedCertificates() ([]pkix.RevokedCertificate, error) {
var err error
r.lock.Lock()
defer r.lock.Unlock()
err = r.loadIndex()
if err != nil {
return nil, err
}
result := make([]pkix.RevokedCertificate, 0)
for _, entry := range r.entries {
if entry.revokedAt != nil {
result = append(result, pkix.RevokedCertificate{
SerialNumber: entry.serialNumber,
RevocationTime: *entry.revokedAt,
Extensions: []pkix.Extension{entry.revocationReason.BuildExtension()},
})
}
}
return result, nil
}
func (r *Repository) loadIndex() error {
const reservedEntries = 100 // use 100 as base capacity to avoid unnecessary array resizing
entries := make([]indexEntry, 0, reservedEntries)
f, err := os.Open(r.indexFileName)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("could not load index from %s: %w", r.indexFileName, err)
}
if f == nil {
r.entries = entries
return nil
}
defer func() { _ = f.Close() }()
indexScanner := bufio.NewScanner(f)
for indexScanner.Scan() {
indexEntry, err := r.newIndexEntryFromLine(indexScanner.Text())
if err != nil {
return err
}
entries = append(entries, *indexEntry)
}
if err := indexScanner.Err(); err != nil {
return fmt.Errorf("could not scan index file: %w", err)
}
r.entries = entries
return nil
}
func (r *Repository) writeIndex() error {
f, err := os.OpenFile(r.indexFileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("could not create index file %s: %w", r.indexFileName, err)
}
defer func(f *os.File) {
_ = f.Close()
}(f)
w := bufio.NewWriter(f)
for i, entry := range r.entries {
_, err = w.WriteString(entry.String())
if err != nil {
return fmt.Errorf("could not write entry for serial %s: %w", entry.serialNumber, err)
}
if i < len(r.entries)-1 {
_, err = w.WriteString("\n")
if err != nil {
return fmt.Errorf("could not write linebreak: %w", err)
}
}
}
err = w.Flush()
if err != nil {
return fmt.Errorf("could not write to %s: %w", r.indexFileName, err)
}
return nil
}
func (r *Repository) addIndexEntry(ie *indexEntry) error {
r.entries = append(r.entries, *ie)
err := r.writeIndex()
if err != nil {
return err
}
return nil
}
func (r *Repository) newIndexEntryFromLine(text string) (*indexEntry, error) {
const expectedFieldNumber = 6
var err error
fields := strings.SplitN(text, "\t", expectedFieldNumber)
if len(fields) != expectedFieldNumber {
return nil, fmt.Errorf(
"unexpected number of fields %d instead of %d",
len(fields),
expectedFieldNumber,
)
}
expirationParsed, err := time.Parse(TimeSpec, fields[posExpiry])
if err != nil {
return nil, fmt.Errorf("could not parse expiration time %s: %w", fields[posExpiry], err)
}
var (
revocationTimeParsed time.Time
revocationReason revoking.CRLReason
)
if fields[posRevocation] != "" {
timeString, reasonText, found := strings.Cut(fields[revocationReason], ",")
if found {
revocationReason = revoking.ParseReason(reasonText)
}
revocationTimeParsed, err = time.Parse(TimeSpec, timeString)
if err != nil {
return nil, fmt.Errorf("could not parse revocation time %s: %w", timeString, err)
}
}
serialParsed := new(big.Int)
if _, ok := serialParsed.SetString(fields[posSerial], 16); !ok {
return nil, fmt.Errorf("could not parse serial number %s", fields[3])
}
fileNameParsed := "unknown"
if fields[posFilename] != "" {
certificateFile := path.Join(path.Dir(r.indexFileName), fields[posFilename])
_, err = os.Stat(certificateFile)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("could not check certificate file %s: %w", certificateFile, err)
}
fileNameParsed = fields[posFilename]
}
return &indexEntry{
statusFlag: indexStatus(fields[posStatus]),
expiresAt: expirationParsed,
revokedAt: &revocationTimeParsed,
revocationReason: revocationReason,
serialNumber: serialParsed,
fileName: fileNameParsed,
certificateSubjectDN: fields[posSubject],
}, nil
}
func NewFileRepository(baseDirectory string) (*Repository, error) {
_, err := os.ReadDir(baseDirectory)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("could not change to base directory %s: %w", baseDirectory, err)
}
err = os.MkdirAll(baseDirectory, 0750) //nolint:gomnd
if err != nil {
return nil, fmt.Errorf("could not create base directory %s: %w", baseDirectory, err)
}
}
return &Repository{
indexFileName: path.Join(baseDirectory, "index.txt"),
crlNumberFileName: path.Join(baseDirectory, "crlnumber"),
lock: &sync.Mutex{},
}, nil
}