diff --git a/cmd/cert-revcheck/README.txt b/cmd/cert-revcheck/README.txt new file mode 100644 index 0000000..435b856 --- /dev/null +++ b/cmd/cert-revcheck/README.txt @@ -0,0 +1,36 @@ +cert-revcheck: check certificate expiry and revocation +----------------------------------------------------- + +Description + cert-revcheck accepts a list of certificate files (PEM or DER) or + site addresses (host[:port]) and checks whether the leaf certificate + is expired or revoked. Revocation checks use CRL and OCSP via the + certlib/revoke package. + +Usage + cert-revcheck [options] [...] + +Options + -hardfail treat revocation check failures as fatal (default: false) + -timeout dur HTTP/OCSP/CRL timeout for network operations (default: 10s) + -v verbose output + +Targets + - File paths to certificates in PEM or DER format. + - Site addresses in the form host or host:port. If no port is + provided, 443 is assumed. + +Examples + # Check a PEM file + cert-revcheck ./server.pem + + # Check a DER (single) certificate + cert-revcheck ./server.der + + # Check a live site (leaf certificate) + cert-revcheck example.com:443 + +Notes + - For sites, only the leaf certificate is checked. + - When -hardfail is set, network issues during OCSP/CRL fetch will + cause the check to fail (treated as revoked). diff --git a/cmd/cert-revcheck/main.go b/cmd/cert-revcheck/main.go new file mode 100644 index 0000000..33acec5 --- /dev/null +++ b/cmd/cert-revcheck/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "io/ioutil" + "net" + "os" + "time" + + "git.wntrmute.dev/kyle/goutils/certlib" + hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts" + "git.wntrmute.dev/kyle/goutils/certlib/revoke" + "git.wntrmute.dev/kyle/goutils/fileutil" +) + +var ( + hardfail bool + timeout time.Duration + verbose bool +) + +func main() { + flag.BoolVar(&hardfail, "hardfail", false, "treat revocation check failures as fatal") + flag.DurationVar(&timeout, "timeout", 10*time.Second, "network timeout for OCSP/CRL fetches and TLS site connects") + flag.BoolVar(&verbose, "v", false, "verbose output") + flag.Parse() + + revoke.HardFail = hardfail + // Set HTTP client timeout for revocation library + revoke.HTTPClient.Timeout = timeout + + if flag.NArg() == 0 { + fmt.Fprintf(os.Stderr, "Usage: %s [options] [...]\n", os.Args[0]) + os.Exit(2) + } + + exitCode := 0 + for _, target := range flag.Args() { + status, err := processTarget(target) + switch status { + case "OK": + fmt.Printf("%s: OK\n", target) + case "EXPIRED": + fmt.Printf("%s: EXPIRED: %v\n", target, err) + exitCode = 1 + case "REVOKED": + fmt.Printf("%s: REVOKED\n", target) + exitCode = 1 + case "UNKNOWN": + fmt.Printf("%s: UNKNOWN: %v\n", target, err) + if hardfail { + // In hardfail, treat unknown as failure + exitCode = 1 + } + } + } + + os.Exit(exitCode) +} + +func processTarget(target string) (string, error) { + if fileutil.FileDoesExist(target) { + return checkFile(target) + } + + // Not a file; treat as site + return checkSite(target) +} + +func checkFile(path string) (string, error) { + in, err := ioutil.ReadFile(path) + if err != nil { + return "UNKNOWN", err + } + + // Try PEM first; if that fails, try single DER cert + certs, err := certlib.ReadCertificates(in) + if err != nil || len(certs) == 0 { + cert, _, derr := certlib.ReadCertificate(in) + if derr != nil || cert == nil { + if err == nil { + err = derr + } + return "UNKNOWN", err + } + return evaluateCert(cert) + } + + // Evaluate the first certificate (leaf) by default + return evaluateCert(certs[0]) +} + +func checkSite(hostport string) (string, error) { + // Use certlib/hosts to parse host/port (supports https URLs and host:port) + target, err := hosts.ParseHost(hostport) + if err != nil { + return "UNKNOWN", err + } + + d := &net.Dialer{Timeout: timeout} + conn, err := tls.DialWithDialer(d, "tcp", target.String(), &tls.Config{InsecureSkipVerify: true, ServerName: target.Host}) + if err != nil { + return "UNKNOWN", err + } + defer conn.Close() + + state := conn.ConnectionState() + if len(state.PeerCertificates) == 0 { + return "UNKNOWN", fmt.Errorf("no peer certificates presented") + } + return evaluateCert(state.PeerCertificates[0]) +} + +func evaluateCert(cert *x509.Certificate) (string, error) { + // Expiry check + now := time.Now() + if !now.Before(cert.NotAfter) { + return "EXPIRED", fmt.Errorf("expired at %s", cert.NotAfter) + } + if !now.After(cert.NotBefore) { + return "EXPIRED", fmt.Errorf("not valid until %s", cert.NotBefore) + } + + // Revocation check using certlib/revoke + revoked, ok, err := revoke.VerifyCertificateError(cert) + if revoked { + // If revoked is true, ok will be true per implementation, err may describe why + return "REVOKED", err + } + if !ok { + // Revocation status could not be determined + return "UNKNOWN", err + } + + return "OK", nil +}