diff --git a/cmd/certspotter/main.go b/cmd/certspotter/main.go index 7779457..e0633f0 100644 --- a/cmd/certspotter/main.go +++ b/cmd/certspotter/main.go @@ -99,6 +99,16 @@ func defaultWatchListPathIfExists() string { return "" } } +func defaultKeyListPath() string { + return filepath.Join(defaultConfigDir(), "keylist") +} +func defaultKeyListPathIfExists() string { + if fileExists(defaultKeyListPath()) { + return defaultKeyListPath() + } else { + return "" + } +} func defaultScriptDir() string { return filepath.Join(defaultConfigDir(), "hooks.d") } @@ -124,6 +134,15 @@ func readWatchListFile(filename string) (monitor.WatchList, error) { return monitor.ReadWatchList(file) } +func readKeyListFile(filename string) (monitor.KeyList, error) { + file, err := os.Open(filename) + if err != nil { + return nil, simplifyError(err) + } + defer file.Close() + return monitor.ReadKeyList(file) +} + func readEmailFile(filename string) ([]string, error) { file, err := os.Open(filename) if err != nil { @@ -166,6 +185,7 @@ func main() { verbose bool version bool watchlist string + keylist string } flag.IntVar(&flags.batchSize, "batch_size", 1000, "Max number of entries to request per call to get-entries (advanced)") flag.Func("email", "Email address to contact when matching certificate is discovered (repeatable)", appendFunc(&flags.email)) @@ -179,6 +199,7 @@ func main() { flag.BoolVar(&flags.verbose, "verbose", false, "Be verbose") flag.BoolVar(&flags.version, "version", false, "Print version and exit") flag.StringVar(&flags.watchlist, "watchlist", defaultWatchListPathIfExists(), "File containing domain names to watch") + flag.StringVar(&flags.keylist, "keylist", defaultKeyListPathIfExists(), "File containing known key information") flag.Parse() if flags.version { @@ -242,6 +263,15 @@ func main() { config.WatchList = watchlist } + if flags.keylist != "" { + keylist, err := readKeyListFile(flags.keylist) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: error reading keylist from %q: %s\n", programName, flags.keylist, err) + os.Exit(1) + } + config.KeyList = keylist + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/man/certspotter.md b/man/certspotter.md index 880ebfa..d687989 100644 --- a/man/certspotter.md +++ b/man/certspotter.md @@ -115,6 +115,11 @@ You can use Cert Spotter to detect: certspotter reads the watch list only when starting up, so you must restart certspotter if you change it. +-keylist *PATH* + +: File containing known key information, one per line. A line consist of a + identifier and the sha256 of the public key separated by semi colon + # NOTIFICATIONS When certspotter detects a certificate matching your watchlist, or encounters diff --git a/monitor/config.go b/monitor/config.go index 378e3d5..5ec12a7 100644 --- a/monitor/config.go +++ b/monitor/config.go @@ -18,6 +18,7 @@ type Config struct { State StateProvider StartAtEnd bool WatchList WatchList + KeyList KeyList Verbose bool HealthCheckInterval time.Duration } diff --git a/monitor/discoveredcert.go b/monitor/discoveredcert.go index 46fc7ba..f61a7ae 100644 --- a/monitor/discoveredcert.go +++ b/monitor/discoveredcert.go @@ -23,6 +23,7 @@ import ( type DiscoveredCert struct { WatchItem WatchItem + KeyItem KeyItem LogEntry *LogEntry Info *certspotter.CertInfo Chain []ct.ASN1Cert // first entry is the leaf certificate or precertificate @@ -172,5 +173,9 @@ func certNotificationText(cert *DiscoveredCert, paths *certPaths) string { } func certNotificationSummary(cert *DiscoveredCert) string { - return fmt.Sprintf("Certificate Discovered for %s", cert.WatchItem) + keytext := "" + if cert.KeyItem.String() != "" { + keytext = fmt.Sprintf(" (known as %s)", cert.KeyItem.String()) + } + return fmt.Sprintf("Certificate Discovered for %s%s", cert.WatchItem, keytext) } diff --git a/monitor/keylist.go b/monitor/keylist.go new file mode 100644 index 0000000..34004f2 --- /dev/null +++ b/monitor/keylist.go @@ -0,0 +1,77 @@ +// Copyright (C) 2016, 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 ( + "bufio" + "encoding/hex" + "crypto/sha256" + "fmt" + "io" + "software.sslmate.com/src/certspotter" + "strings" +) + +type KeyItem struct { + domain string + keyinfo string +} + +type KeyList []KeyItem + +func ParseKeyItem(str string) (KeyItem, error) { + fields := strings.Split(str, ";") + if len(fields) == 0 { + return KeyItem{}, fmt.Errorf("empty domain") + } + if len(fields) == 1 { + return KeyItem{}, fmt.Errorf("empty key info") + } + return KeyItem{ + domain: fields[0], + keyinfo: fields[1], + }, nil +} + +func ReadKeyList(reader io.Reader) (KeyList, error) { + items := make(KeyList, 0, 50) + scanner := bufio.NewScanner(reader) + lineNo := 0 + for scanner.Scan() { + line := scanner.Text() + lineNo++ + if line == "" || strings.HasPrefix(line, "#") { + continue + } + item, err := ParseKeyItem(line) + if err != nil { + return nil, fmt.Errorf("%w on line %d", err, lineNo) + } + items = append(items, item) + } + return items, scanner.Err() +} + +func (item KeyItem) String() string { + return item.domain +} + +func (list KeyList) Matches(certInfo *certspotter.CertInfo) (bool, KeyItem) { + + for _, item := range list { + // better would be to convert the input and compare the bytes (no uppercase/lower-case issue) + pub := sha256.Sum256(certInfo.TBS.PublicKey.FullBytes) + sum := hex.EncodeToString(pub[:]) + if sum == item.keyinfo { + return true, item + } + } + return false, KeyItem{} +} diff --git a/monitor/process.go b/monitor/process.go index c416d55..3bd822d 100644 --- a/monitor/process.go +++ b/monitor/process.go @@ -91,11 +91,13 @@ func processCertificate(ctx context.Context, config *Config, entry *LogEntry, ce if !matched { return nil } + matched, keyItem := config.KeyList.Matches(certInfo) cert := &DiscoveredCert{ WatchItem: watchItem, LogEntry: entry, Info: certInfo, + KeyItem: keyItem, Chain: chain, TBSSHA256: sha256.Sum256(certInfo.TBS.Raw), SHA256: sha256.Sum256(chain[0]),