From b4334334c82ca0a64ed4a6acce7a8e0417706ec5 Mon Sep 17 00:00:00 2001 From: Andrew Ayer Date: Wed, 7 Jan 2026 23:33:32 +0000 Subject: [PATCH] Add certspotter-authorize command Closes: #13 --- README.md | 22 +++ cmd/certspotter-authorize/.gitignore | 1 + cmd/certspotter-authorize/main.go | 182 +++++++++++++++++++++++ cmd/certspotter-authorize/main_test.go | 190 +++++++++++++++++++++++++ man/Makefile | 2 +- man/certspotter-authorize.md | 90 ++++++++++++ 6 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 cmd/certspotter-authorize/.gitignore create mode 100644 cmd/certspotter-authorize/main.go create mode 100644 cmd/certspotter-authorize/main_test.go create mode 100644 man/certspotter-authorize.md diff --git a/README.md b/README.md index 0ea4c28..e248624 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,30 @@ The following instructions require you to have [Go version 1.21 or higher](https * Command line options and operational details: [certspotter(8) man page](man/certspotter.md) * The script interface: [certspotter-script(8) man page](man/certspotter-script.md) +* Authorizing known certificates: [certspotter-authorize(8) man page](man/certspotter-authorize.md) * [Change Log](CHANGELOG.md) +## Authorizing Known Certificates to Prevent False Alarms + +You can use the **certspotter-authorize** command to tell certspotter +about legitimate certificates issued by your certificate authority. +certspotter won't notify you when it discovers an authorized certificate +(or its corresponding precertificate) in Certificate Transparency logs. + +To install certspotter-authorize, run: + +``` +go install software.sslmate.com/src/certspotter/cmd/certspotter-authorize@latest +``` + +To authorize a certificate, run: + +``` +certspotter-authorize -cert /path/to/cert.pem +``` + +For more details, see the [certspotter-authorize(8) man page](man/certspotter-authorize.md). + ## What certificates are detected by Cert Spotter? In the default configuration, any certificate that is logged to a diff --git a/cmd/certspotter-authorize/.gitignore b/cmd/certspotter-authorize/.gitignore new file mode 100644 index 0000000..ce28fbd --- /dev/null +++ b/cmd/certspotter-authorize/.gitignore @@ -0,0 +1 @@ +/certspotter-authorize diff --git a/cmd/certspotter-authorize/main.go b/cmd/certspotter-authorize/main.go new file mode 100644 index 0000000..4302c76 --- /dev/null +++ b/cmd/certspotter-authorize/main.go @@ -0,0 +1,182 @@ +// Copyright (C) 2026 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 main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "runtime/debug" + "strconv" + "strings" + + "software.sslmate.com/src/certspotter" +) + +var programName = os.Args[0] +var Version = "unknown" +var Source = "unknown" + +func certspotterVersion() (string, string) { + if buildinfo, ok := debug.ReadBuildInfo(); ok && strings.HasPrefix(buildinfo.Main.Version, "v") { + return strings.TrimPrefix(buildinfo.Main.Version, "v"), buildinfo.Main.Path + } else { + return Version, Source + } +} + +func homedir() string { + homedir, err := os.UserHomeDir() + if err != nil { + panic(fmt.Errorf("unable to determine home directory: %w", err)) + } + return homedir +} + +func startedBySupervisor() bool { + return os.Getenv("SYSTEMD_EXEC_PID") == strconv.Itoa(os.Getpid()) +} + +func defaultStateDir() string { + if envVar := os.Getenv("CERTSPOTTER_STATE_DIR"); envVar != "" { + return envVar + } else if envVar := os.Getenv("STATE_DIRECTORY"); envVar != "" && startedBySupervisor() { + return envVar + } else { + return filepath.Join(homedir(), ".certspotter") + } +} + +func fileExists(filename string) bool { + _, err := os.Lstat(filename) + return err == nil +} + +func readCertFile(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(os.Stdin) + } else { + return os.ReadFile(path) + } +} + +func parseCertificate(certBytes []byte) ([]byte, error) { + block, _ := pem.Decode(certBytes) + if block != nil { + if block.Type == "CERTIFICATE" { + return block.Bytes, nil + } + return nil, fmt.Errorf("PEM block type is %q, expected CERTIFICATE", block.Type) + } + return nil, fmt.Errorf("no PEM data found") +} + +func computeTBSHash(certDER []byte) ([32]byte, error) { + certInfo, err := certspotter.MakeCertInfoFromRawCert(certDER) + if err != nil { + return [32]byte{}, fmt.Errorf("error parsing certificate: %w", err) + } + precertTBS, err := certspotter.ReconstructPrecertTBS(certInfo.TBS) + if err != nil { + return [32]byte{}, fmt.Errorf("error reconstructing precertificate TBSCertificate: %w", err) + } + return sha256.Sum256(precertTBS.Raw), nil +} + +func createNotifiedMarker(stateDir string, tbsHash [32]byte) (string, error) { + tbsHex := hex.EncodeToString(tbsHash[:]) + + certsDir := filepath.Join(stateDir, "certs") + tbsDir := filepath.Join(certsDir, tbsHex[0:2]) + notifiedPath := filepath.Join(tbsDir, "."+tbsHex+".notified") + + // Check if already notified + if fileExists(notifiedPath) { + return notifiedPath, nil + } + + // Create certs directory if needed + if err := os.Mkdir(certsDir, 0777); err != nil && !errors.Is(err, fs.ErrExist) { + return "", fmt.Errorf("error creating certs directory: %w", err) + } + + // Create TBS-specific subdirectory if needed + if err := os.Mkdir(tbsDir, 0777); err != nil && !errors.Is(err, fs.ErrExist) { + return "", fmt.Errorf("error creating directory: %w", err) + } + + // Create marker file + if err := os.WriteFile(notifiedPath, nil, 0666); err != nil { + return "", fmt.Errorf("error creating marker file: %w", err) + } + + return notifiedPath, nil +} + +func main() { + version, source := certspotterVersion() + + var flags struct { + cert string + stateDir string + version bool + } + + flag.StringVar(&flags.cert, "cert", "", "Path to a PEM-encoded certificate (- to read from stdin)") + flag.StringVar(&flags.stateDir, "state_dir", defaultStateDir(), "State directory used by certspotter") + flag.BoolVar(&flags.version, "version", false, "Print version and exit") + flag.Parse() + + if flags.version { + fmt.Fprintf(os.Stdout, "certspotter-authorize version %s (%s)\n", version, source) + os.Exit(0) + } + + if flags.cert == "" { + fmt.Fprintf(os.Stderr, "Usage: %s -cert PATH [-state_dir PATH]\n", programName) + fmt.Fprintf(os.Stderr, "Purpose: suppress future certspotter notifications for a certificate and its corresponding precertificate.\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + os.Exit(2) + } + + certBytes, err := readCertFile(flags.cert) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: error reading certificate: %s\n", programName, err) + os.Exit(1) + } + + certDER, err := parseCertificate(certBytes) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err) + os.Exit(1) + } + + tbsHash, err := computeTBSHash(certDER) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err) + os.Exit(1) + } + + _, err = createNotifiedMarker(flags.stateDir, tbsHash) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err) + os.Exit(1) + } + + os.Exit(0) +} diff --git a/cmd/certspotter-authorize/main_test.go b/cmd/certspotter-authorize/main_test.go new file mode 100644 index 0000000..2836616 --- /dev/null +++ b/cmd/certspotter-authorize/main_test.go @@ -0,0 +1,190 @@ +// Copyright (C) 2026 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 main + +import ( + "bytes" + "encoding/hex" + "os" + "path/filepath" + "testing" +) + +const testCertPEM = ` +-----BEGIN CERTIFICATE----- +MIIGuzCCBSOgAwIBAgIRANubk4g/6c+TF8jITzhFX44wDQYJKoZIhvcNAQELBQAw +YDELMAkGA1UEBhMCR0IxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDE3MDUGA1UE +AxMuU2VjdGlnbyBQdWJsaWMgU2VydmVyIEF1dGhlbnRpY2F0aW9uIENBIERWIFIz +NjAeFw0yNTEyMDIwMDAwMDBaFw0yNjExMjEyMzU5NTlaMBYxFDASBgNVBAMTC3Nz +bG1hdGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA235/3Y/E +4yPAHPa37C7Fgp7KPVjjuTB5vKV9nYIJzfp7NgvDBlf7k5bZFCSsSIj2txhL0hzX +Bwvmy7u7CYR7CApr2Rx2UPOl7Gmlt/DmtfyKac8Iunn2ozuGZDtxq19Go4NL9jl9 +e9O3H/lcL/ZFqzbUNlKIOfkOYkOxM3qpQXHTXuhkeI2MJO/S4wX8y8/8uhArWQ9e +h/YrtJlO9fla60kLUlQF7mtJTc+0oB3+N4eF5t2a8Pav00T6lVvH8hMhbY0nZ/tB +CD6/I6yelh8cP094VRJEGWs+zcEuXpz4FsZggkhF/l+AhQ+DfgxZhno4M60kBKC8 +Un1BTGX5TjfjJQIDAQABo4IDODCCAzQwHwYDVR0jBBgwFoAUaMASFhgOr872h6Yy +V6NGUV3LBycwHQYDVR0OBBYEFKg2kl8xIzdSxjOEAzlNpdpigAazMA4GA1UdDwEB +/wQEAwIFoDAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMEkGA1Ud +IARCMEAwNAYLKwYBBAGyMQECAgcwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0 +aWdvLmNvbS9DUFMwCAYGZ4EMAQIBMIGEBggrBgEFBQcBAQR4MHYwTwYIKwYBBQUH +MAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGlnb1B1YmxpY1NlcnZlckF1 +dGhlbnRpY2F0aW9uQ0FEVlIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3Nw +LnNlY3RpZ28uY29tMIIBfQYKKwYBBAHWeQIEAgSCAW0EggFpAWcAdQDXbX0Q0af1 +d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVpdwAAAZrg2UujAAAEAwBGMEQCIApVmNqo +gzJBNbcVXezO5sSvOFE5FaVZVz/eaqnCbG+2AiAT/A7XPtOYHwsE0wmTUBCTV/0l +bF7lk573b3rNtBvP3QB2AK9niDtXsE7dj6bZfvYuqOuBCsdxYPAkXlXWDC/nhYc6 +AAABmuDZTFoAAAQDAEcwRQIgODruJKtbjW1QJcQP7ARZAw5FgChfI599pJBA2bbQ +suICIQCskIUnzQCD6taycnQCN3zpu+rsz3Vd4AsMeFDM/cDJ5AB2AKyrMHBs6+yE +MfQT0vSRXxEeQiRDsfKmjE88KzunHgLDAAABmuDZS5kAAAQDAEcwRQIgD9IQCPTc +N88jbz5DUILwmDruTo411Ep5M2ZryNjBkywCIQCFwIyqGZEd+PiFv4l+5LOV3yDW +/zUuimFUoAJH5OIiNDBsBgNVHREEZTBjggtzc2xtYXRlLmNvbYIbKi5odHRwLWFw +cHJvdmFsLnNzbG1hdGUuY29tghFjZXJ0cy5zc2xtYXRlLmNvbYITY29uc29sZS5z +c2xtYXRlLmNvbYIPd3d3LnNzbG1hdGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBgQBR +Pjx14qo9PiYYEE1695CHdctA6up8L+n0MRapZcxALN/cetfGeoR00ZEH+7b1X7Ma +F9GGv1OtJXDoCySlAsdwFKHYtKhrUYRuQXdKGkTjdMzKO/+5kXeZIqgsCR10j8nr +Zq0Zcg2ply4j03/0y7+8ZNC1Erp4DB1Tq7ybgXnyURaNQTHsSkDoxMT/bWIrhGD0 +C8kN/ExkFvOBQlzdbuwo2d3v0zSM4mYmnqUhUYHprZllOziYgxIqjM/7mfnDkVAi +ov8yNJtn6EPt1wt6Oo3fC+Ft1T/kbSxeZbqWf3Zgbon5ijmNz+xqkb8br2+JdzM+ +8gEIqO6mNoMl0tayzb4a5KDaHxhczMGB3ggBwpVcdLtYBBa41thrgRP0VARqFTFG +IIkC9gPMjScf+uv9CQPsNk3kFI8vN4T3x4/g54N8Mc3M4JxvLaOsBj8dMeyq7v2p +1zE9WRngMUWuPgx0O94c0Pteumg/+pSGVeRapIuYZxXvkmLJ5wmwgYepix+cw1w= +-----END CERTIFICATE----- +` + +func TestComputeTBSHash(t *testing.T) { + certDER, err := parseCertificate([]byte(testCertPEM)) + if err != nil { + t.Fatalf("parseCertificate failed: %v", err) + } + tbsHash, err := computeTBSHash(certDER) + if err != nil { + t.Fatalf("computeTBSHash failed: %v", err) + } + if expected := [...]byte{0x3c, 0xf6, 0xb2, 0x44, 0xc2, 0x95, 0x85, 0xdb, 0xfb, 0xfd, 0x42, 0x0a, 0x6a, 0x4c, 0x62, 0xf7, 0x96, 0x8f, 0xa9, 0x05, 0xb4, 0xd6, 0xa4, 0xf5, 0x9d, 0x4d, 0x3b, 0xc9, 0xfa, 0xcb, 0x0c, 0xc8}; expected != tbsHash { + t.Fatalf("computeTBSHash returned %x; expected %x", tbsHash, expected) + } +} + +func TestCreateNotifiedMarker(t *testing.T) { + stateDir := t.TempDir() + + certDER, err := parseCertificate([]byte(testCertPEM)) + if err != nil { + t.Fatalf("parseCertificate failed: %v", err) + } + + tbsHash, err := computeTBSHash(certDER) + if err != nil { + t.Fatalf("computeTBSHash failed: %v", err) + } + + // First call should create the marker + notifiedPath, err := createNotifiedMarker(stateDir, tbsHash) + if err != nil { + t.Fatalf("createNotifiedMarker failed: %v", err) + } + + // Verify marker file exists + if !fileExists(notifiedPath) { + t.Fatalf("marker file does not exist: %s", notifiedPath) + } + + // Verify path structure is correct + tbsHex := hex.EncodeToString(tbsHash[:]) + expectedPath := filepath.Join(stateDir, "certs", tbsHex[0:2], "."+tbsHex+".notified") + if notifiedPath != expectedPath { + t.Fatalf("unexpected marker path: got %s, expected %s", notifiedPath, expectedPath) + } + + // Second call should succeed (idempotency) + notifiedPath2, err := createNotifiedMarker(stateDir, tbsHash) + if err != nil { + t.Fatalf("createNotifiedMarker second call failed: %v", err) + } + if notifiedPath != notifiedPath2 { + t.Fatalf("second call returned different path: got %s, expected %s", notifiedPath2, notifiedPath) + } +} + +func TestReadCertFile(t *testing.T) { + // Test reading from a file + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "cert.pem") + if err := os.WriteFile(certPath, []byte(testCertPEM), 0644); err != nil { + t.Fatalf("failed to write test cert: %v", err) + } + + certBytes, err := readCertFile(certPath) + if err != nil { + t.Fatalf("readCertFile failed: %v", err) + } + if !bytes.Equal(certBytes, []byte(testCertPEM)) { + t.Fatal("readCertFile returned different content") + } +} + +func TestFileExists(t *testing.T) { + tmpDir := t.TempDir() + + // Test with non-existent file + if fileExists(filepath.Join(tmpDir, "nonexistent")) { + t.Fatal("fileExists returned true for non-existent file") + } + + // Test with existing file + existingFile := filepath.Join(tmpDir, "existing") + if err := os.WriteFile(existingFile, []byte("test"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + if !fileExists(existingFile) { + t.Fatal("fileExists returned false for existing file") + } +} + +func TestEndToEnd(t *testing.T) { + stateDir := t.TempDir() + + certDER, err := parseCertificate([]byte(testCertPEM)) + if err != nil { + t.Fatalf("parseCertificate failed: %v", err) + } + + tbsHash, err := computeTBSHash(certDER) + if err != nil { + t.Fatalf("computeTBSHash failed: %v", err) + } + + notifiedPath, err := createNotifiedMarker(stateDir, tbsHash) + if err != nil { + t.Fatalf("createNotifiedMarker failed: %v", err) + } + + // Verify the marker file structure matches what monitor/fsstate.go expects + tbsHex := hex.EncodeToString(tbsHash[:]) + expectedDir := filepath.Join(stateDir, "certs", tbsHex[0:2]) + expectedFile := filepath.Join(expectedDir, "."+tbsHex+".notified") + + if notifiedPath != expectedFile { + t.Fatalf("unexpected marker path: got %s, expected %s", notifiedPath, expectedFile) + } + + if !fileExists(expectedFile) { + t.Fatalf("marker file does not exist: %s", expectedFile) + } + + // Verify file is empty (as expected by certspotter) + stat, err := os.Stat(expectedFile) + if err != nil { + t.Fatalf("failed to stat marker file: %v", err) + } + if stat.Size() != 0 { + t.Fatalf("marker file should be empty, but has size %d", stat.Size()) + } +} diff --git a/man/Makefile b/man/Makefile index b99e9f2..58a540e 100644 --- a/man/Makefile +++ b/man/Makefile @@ -1,4 +1,4 @@ -all: certspotter-script.8 certspotter.8 +all: certspotter-script.8 certspotter.8 certspotter-authorize.8 %.8: %.md lowdown -s -Tman \ diff --git a/man/certspotter-authorize.md b/man/certspotter-authorize.md new file mode 100644 index 0000000..24ad1df --- /dev/null +++ b/man/certspotter-authorize.md @@ -0,0 +1,90 @@ +# NAME + +**certspotter-authorize** - Authorize certificates to suppress certspotter notifications + +# SYNOPSIS + +**certspotter-authorize** `-cert` *PATH* [`-state_dir` *PATH*] + +# DESCRIPTION + +**certspotter-authorize** is a utility for preemptively authorizing certificates so +that **certspotter(8)** will not send notifications when those certificates are +discovered in Certificate Transparency logs. + +This is useful for preventing false alarms when you know in advance that a +certificate will be issued. For example, you might run **certspotter-authorize** +immediately after receiving a certificate from your certificate authority, as +part of your certificate issuance pipeline. + +**certspotter-authorize** uses the TBSCertificate hash as defined by RFC +6962 Section 3.2 to identify certificates. This hash is the same for a +certificate and its corresponding precertificate. This means authorizing +the certificate will suppress notifications for the precertificate +as well. Certificates with different serial numbers, validity periods, +or other changes to the TBSCertificate will not be covered by the authorization +and will trigger notifications. + +# OPTIONS + +-cert *PATH* + +: Path to a PEM-encoded certificate. Use `-` to read from stdin. + This option is required. + +-state\_dir *PATH* + +: Directory where certspotter stores state. Defaults to + `$CERTSPOTTER_STATE_DIR` if set, or `~/.certspotter` otherwise. + This should be the same directory used by **certspotter(8)**. + +-version + +: Print version information and exit. + +# EXAMPLES + +Authorize a certificate from a file: + + $ certspotter-authorize -cert /path/to/cert.pem + +Authorize a certificate from stdin: + + $ cat cert.pem | certspotter-authorize -cert - + +Authorize a certificate in a custom state directory: + + $ certspotter-authorize -cert cert.pem -state_dir /var/lib/certspotter + +# OPERATION + +When **certspotter-authorize** is run with a certificate, it computes +the SHA-256 hash of the certificate's TBSCertificate as defined by RFC +6962 Section 3.2 creates a `.notified` marker file in the certspotter +state directory. When certspotter later discovers a certificate with +the same TBSCertificate in a CT log, it will skip sending notifications +because the marker file is present. + +# ENVIRONMENT + +`CERTSPOTTER_STATE_DIR` + +: Directory for storing state. Overridden by `-state_dir`. Defaults to + `~/.certspotter`. This should be the same directory used by **certspotter(8)**. + +# FILES + +`$CERTSPOTTER_STATE_DIR/certs/XX/.HASH.notified` + +: Marker files indicating that a certificate with TBS hash `HASH` + has been authorized. `XX` is the first two hex digits of the hash. + The file is empty; only its existence is checked. + +# EXIT STATUS + +**certspotter-authorize** exits with status 0 on success, 1 on error, or 2 on +invalid usage. + +# SEE ALSO + +certspotter(8), certspotter-script(8)