// Copyright (C) 2023 Opsmate, Inc. // // This Source Code Form is subject to the terms of the Mozilla // Public License, v. 2.0. If a copy of the MPL was not distributed // with this file, You can obtain one at http://mozilla.org/MPL/2.0/. // // This software is distributed WITHOUT A WARRANTY OF ANY KIND. // See the Mozilla Public License for details. package monitor import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" "software.sslmate.com/src/certspotter" "software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/merkletree" ) type logEntry struct { Log *loglist.Log Index uint64 LeafInput []byte ExtraData []byte LeafHash merkletree.Hash } func processLogEntry(ctx context.Context, config *Config, entry *logEntry) error { leaf, err := ct.ReadMerkleTreeLeaf(bytes.NewReader(entry.LeafInput)) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing Merkle Tree Leaf: %w", err)) } switch leaf.TimestampedEntry.EntryType { case ct.X509LogEntryType: return processX509LogEntry(ctx, config, entry, leaf.TimestampedEntry.X509Entry) case ct.PrecertLogEntryType: return processPrecertLogEntry(ctx, config, entry, leaf.TimestampedEntry.PrecertEntry) default: return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("unknown log entry type %d", leaf.TimestampedEntry.EntryType)) } } func processX509LogEntry(ctx context.Context, config *Config, entry *logEntry, cert ct.ASN1Cert) error { certInfo, err := certspotter.MakeCertInfoFromRawCert(cert) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing X.509 certificate: %w", err)) } chain, err := ct.UnmarshalX509ChainArray(entry.ExtraData) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing extra_data for X.509 entry: %w", err)) } chain = append([]ct.ASN1Cert{cert}, chain...) if precertTBS, err := certspotter.ReconstructPrecertTBS(certInfo.TBS); err == nil { certInfo.TBS = precertTBS } else { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error reconstructing precertificate TBSCertificate: %w", err)) } return processCertificate(ctx, config, entry, certInfo, chain) } func processPrecertLogEntry(ctx context.Context, config *Config, entry *logEntry, precert ct.PreCert) error { certInfo, err := certspotter.MakeCertInfoFromRawTBS(precert.TBSCertificate) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing precert TBSCertificate: %w", err)) } chain, err := ct.UnmarshalPrecertChainArray(entry.ExtraData) if err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing extra_data for precert entry: %w", err)) } if _, err := certspotter.ValidatePrecert(chain[0], precert.TBSCertificate); err != nil { return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("precertificate in extra_data does not match TBSCertificate in leaf_input: %w", err)) } return processCertificate(ctx, config, entry, certInfo, chain) } func processCertificate(ctx context.Context, config *Config, entry *logEntry, certInfo *certspotter.CertInfo, chain []ct.ASN1Cert) error { identifiers, err := certInfo.ParseIdentifiers() if err != nil { return processMalformedLogEntry(ctx, config, entry, err) } matched, watchItem := config.WatchList.Matches(identifiers) if !matched { return nil } cert := &discoveredCert{ WatchItem: watchItem, LogEntry: entry, Info: certInfo, Chain: chain, TBSSHA256: sha256.Sum256(certInfo.TBS.Raw), SHA256: sha256.Sum256(chain[0]), PubkeySHA256: sha256.Sum256(certInfo.TBS.PublicKey.FullBytes), Identifiers: identifiers, } var notifiedPath string if config.SaveCerts { hexFingerprint := hex.EncodeToString(cert.SHA256[:]) prefixPath := filepath.Join(config.StateDir, "certs", hexFingerprint[0:2]) var ( notifiedFilename = "." + hexFingerprint + ".notified" certFilename = hexFingerprint + ".pem" jsonFilename = hexFingerprint + ".v1.json" textFilename = hexFingerprint + ".txt" legacyCertFilename = hexFingerprint + ".cert.pem" legacyPrecertFilename = hexFingerprint + ".precert.pem" ) for _, filename := range []string{notifiedFilename, legacyCertFilename, legacyPrecertFilename} { if fileExists(filepath.Join(prefixPath, filename)) { return nil } } if err := os.Mkdir(prefixPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) { return fmt.Errorf("error creating directory in which to save certificate %x: %w", cert.SHA256, err) } notifiedPath = filepath.Join(prefixPath, notifiedFilename) cert.CertPath = filepath.Join(prefixPath, certFilename) cert.JSONPath = filepath.Join(prefixPath, jsonFilename) cert.TextPath = filepath.Join(prefixPath, textFilename) if err := cert.save(); err != nil { return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err) } } else { // TODO-4: save cert to temporary files, and defer their unlinking } if err := notify(ctx, config, cert); err != nil { return fmt.Errorf("error notifying about discovered certificate for %s (%x): %w", cert.WatchItem, cert.SHA256, err) } if notifiedPath != "" { if err := os.WriteFile(notifiedPath, nil, 0666); err != nil { return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err) } } return nil } func processMalformedLogEntry(ctx context.Context, config *Config, entry *logEntry, parseError error) error { dirPath := filepath.Join(config.StateDir, "logs", entry.Log.LogID.Base64URLString(), "malformed_entries") malformed := &malformedLogEntry{ Entry: entry, Error: parseError.Error(), EntryPath: filepath.Join(dirPath, fmt.Sprintf("%d.json", entry.Index)), TextPath: filepath.Join(dirPath, fmt.Sprintf("%d.txt", entry.Index)), } if err := malformed.save(); err != nil { return fmt.Errorf("error saving malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err) } if err := notify(ctx, config, malformed); err != nil { return fmt.Errorf("error notifying about malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err) } return nil }