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/pkg/x509/revoking" ) const TimeSpec = "060102030405Z" type indexStatus string const ( certificateValid indexStatus = "V" certificateRevoked indexStatus = "R" certificateExpired indexStatus = "E" ) // 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 lock sync.Locker entries []indexEntry } 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 { entries := make([]indexEntry, 0, 100) 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 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) { var err error fields := strings.Split(text, "\t") const expectedFieldNumber = 6 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[1]) if err != nil { return nil, err } var revocationTimeParsed time.Time var revocationReason revoking.CRLReason if fields[2] != "" { var timeString string if strings.Contains(fields[2], ",") { parts := strings.SplitN(fields[2], ",", 2) timeString = parts[0] revocationReason = revoking.ParseReason(parts[1]) } else { timeString = fields[2] } revocationTimeParsed, err = time.Parse(TimeSpec, timeString) if err != nil { return nil, err } } serialParsed := new(big.Int) if _, ok := serialParsed.SetString(fields[3], 16); !ok { return nil, fmt.Errorf("could not parse serial number %s", fields[3]) } fileNameParsed := "unknown" if fields[4] != "" { _, err = os.Stat(path.Join(path.Dir(r.indexFileName), fields[4])) if err != nil && !os.IsNotExist(err) { return nil, err } fileNameParsed = fields[4] } return &indexEntry{ statusFlag: indexStatus(fields[0]), expiresAt: expirationParsed, revokedAt: &revocationTimeParsed, revocationReason: revocationReason, serialNumber: serialParsed, fileName: fileNameParsed, certificateSubjectDN: fields[5], }, nil } func NewFileRepository(baseDirectory string) (*Repository, error) { err := os.Chdir(baseDirectory) if err != nil { return nil, err } return &Repository{ indexFileName: path.Join(baseDirectory, "index.txt"), lock: &sync.Mutex{}, }, nil }