Certificate Info in Go
2023-07-14
Sometimes I write a utility just for the sake of learning something new. This particular code was about wait groups in Golang
We have a tool that inspects the TLS/SSL Certificate on a web server, I wanted to write my own clean room implementation that used wait groups.
Lets take a look at using the crypto/tls
module to spit out some details I want:
package main
import (
"crypto/tls"
"crypto/x509/pkix"
"fmt"
"net"
)
TLSHost struct {
FQDN string `json:"fqdn"`
Port int `json:"port"`
DNSNames []string `json:"dns_names,omitempty"`
SNI string `json:"sni,omitempty"`
Version string `json:"version"`
Issuer pkix.Name `json:"issuer,omitempty"`
Serial string `json:"serial"`
Expiration time.Time `json:"expiration,omitempty"`
Err bool `json:"err"`
Message string `json:"message"`
}
func main() {
h := "mywushublog.com"
p := 443
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", fmt.Sprintf("%s:%d", h, p), &tls.Config{})
if err != nil {
host := TLSHost{
FQDN: h,
Port: p,
Err: true,
Message: fmt.Sprintf("%s", err),
}
*r = append(*r, host)
continue
}
verifyFQDN(conn, h)
host := TLSHost{
FQDN: h,
Port: p,
DNSNames: conn.ConnectionState().PeerCertificates[0].DNSNames,
SNI: getSNI(conn),
Issuer: getIssuer(conn),
Serial: getSerial(conn),
Expiration: getExpiration(conn),
Version: getTlsVersion(conn),
Err: false,
Message: "Okay",
}
js, _ := json.Marshal(host)
fmt.Printf("%s\n", js)
}
First, a couple of things that I discovered while writing this and testing it against good and bad hosts:
- Fail as fast as you can
- Verify the hostname is valid (exists) up front, this will save many seconds per host
- The net dialer timeout is nothing, it’s up to the system! I set it to 5. I honestly think in this day in age it could be 1 second, MAYBE 2.
Alright, moving on.
I made some helper methods:
verifyFQDN
: If a host isn’t resolvable, setErr
to true and move on.getSNI
: its kind of useful to know what the endpoint thinks its Server Name Indicator isgetTlsVersion
: using a map, return a string of the version. The tls module returns a uint16 which I don’t feel is operator friendly enoughgetIssuer
: Who issued the certgetExpiration
: sort of the whole reason for this. Certs expire often and its good info to have up frontgetSignature
: I used this to to REALLY verify if a particular cert is rolled out or not
func verifyFQDN(conn *tls.Conn, h string) {
err := conn.VerifyHostname(h)
if err != nil {
panic("Hostname doesn't match with certificate: " + err.Error())
}
fmt.Printf("Hostname %s is valid\n", h)
}
func getSNI(c *tls.Conn) string {
sni := c.ConnectionState().ServerName
return sni
}
func getTlsVersion(c *tls.Conn) string {
versions := map[uint16]string{
tls.VersionSSL30: "SSL",
tls.VersionTLS10: "TLS 1.0",
tls.VersionTLS11: "TLS 1.1",
tls.VersionTLS12: "TLS 1.2",
tls.VersionTLS13: "TLS 1.3",
}
return versions[c.ConnectionState().Version]
}
func getIssuer(c *tls.Conn) pkix.Name {
return c.ConnectionState().PeerCertificates[0].Issuer
}
func getExpiration(c *tls.Conn) time.Time {
return c.ConnectionState().PeerCertificates[0].NotAfter
}
func getSignature(c *tls.Conn) string {
sig := c.ConnectionState().PeerCertificates[0].Signature
var buf bytes.Buffer
for i, val := range sig {
if (i % 18) == 0 {
buf.WriteString(fmt.Sprintf("\n%9s", ""))
}
buf.WriteString(fmt.Sprintf("%02x", val))
if i != len(sig)-1 {
buf.WriteString(":")
}
}
return buf.String()
}
So lets run this and see what we’re working with:
go run main.go | jq
2023/07/12 12:16:02 Hostname mywushublog.com is valid
[
{
"fqdn": "mywushublog.com",
"port": 443,
"dns_names": [
"mywushublog.com",
"www.mywushublog.com"
],
"sni": "mywushublog.com",
"version": "TLS 1.3",
"issuer": {
"Country": [
"US"
],
"Organization": [
"Amazon"
],
"OrganizationalUnit": null,
"Locality": null,
"Province": null,
"StreetAddress": null,
"PostalCode": null,
"SerialNumber": "",
"CommonName": "Amazon RSA 2048 M01",
"Names": [
{
"Type": [
2,
5,
4,
6
],
"Value": "US"
},
{
"Type": [
2,
5,
4,
10
],
"Value": "Amazon"
},
{
"Type": [
2,
5,
4,
3
],
"Value": "Amazon RSA 2048 M01"
}
],
"ExtraNames": null
},
"serial": "CE39325A91F1B2874E4D1270ED71B5E",
"expiration": "2024-05-10T23:59:59Z",
"err": false,
"message": "Okay"
}
]
One host is fine, how about 2? How about 20?
One design pattern I kind of like is taking input from STDIN:
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
h := scanner.Text()
Hosts = append(Hosts, h)
}
This buffered I/O scanner lets me do something like:
$ echo << EOF > hosts
www.mywushublog.com
www.google.com
www.blizzard.com
www.llnl.gov
EOF
$ cat hosts | tls-example
and our little go app will process all of those individual hostnames as a host
cat hosts | go run main.go | jq '.[] | { fqdn, dns_names, expiration}'
2023/07/12 16:01:02 Hostname www.mywushublog.com is valid
2023/07/12 16:01:02 Hostname www.google.com is valid
2023/07/12 16:01:02 Hostname www.blizzard.com is valid
2023/07/12 16:01:02 Hostname www.llnl.gov is valid
{
"fqdn": "www.mywushublog.com",
"dns_names": [
"mywushublog.com",
"www.mywushublog.com"
],
"expiration": "2024-05-10T23:59:59Z"
}
{
"fqdn": "www.google.com",
"dns_names": [
"www.google.com"
],
"expiration": "2023-09-11T08:21:19Z"
}
{
"fqdn": "www.blizzard.com",
"dns_names": [
"blizzard.com",
"*.blizzard.com"
],
"expiration": "2024-07-19T23:59:59Z"
}
{
"fqdn": "www.llnl.gov",
"dns_names": [
"*.llnl.gov"
],
"expiration": "2024-01-30T23:59:59Z"
}
I’d like to point out again why I like to just emit json as an output, and let jq
do the heavy lifting of selecting data.
running this against 4 hosts isn’t bad, if I slap a time
in there, it reports:
0.31s user 0.08s system 69% cpu 0.556 total
which isn’t bad.
But, what if I use a large list. Like, the top 500 websites:
curl -Ls https://moz.com/top-500/download/\?table\=top500Domains | awk -F, 'gsub("\"","") {print $2}' | tail -n +2 | time go run main.go
By the way, tail -n +2
will remove the CSV header, this is better for our program that doesn’t parse the CSV but just a list of names.
1.11s user 0.22s system 1% cpu 1:43.05 total
Using the built in shell command time
is okay, but lets keep track of it in our code:
package main
imports (
...
"time"
...
)
func main() {
...
start := time.Now()
// the rest of the code...
t := time.Now()
elapsed := t.Sub(start)
fmt.Printf("Finished %v hosts in %v\n", len(Hosts), elapsed)
}
With that, now we get a nicer elapsed time:
curl -Ls https://moz.com/top-500/download/\?table\=top500Domains | awk -F, 'gsub("\"","") {print $2}' | tail -n +2 | go run main.go
Finished 500 hosts in 1m34.135739699s
Wow, 1 minute and 34 seconds! For 500 hosts? Thats not very webscale.
Now lets get into sync/WaitGroup’s, and I’m totally going to cheat here.
First, read this because I don’t really know what I’m talking about.
If I wanted to, I could update my main function to basically do this:
import (
...
"sync"
...
)
func main() {
var wg sync.WaitGroup
for _, h := range Hosts {
wg.Add(1)
go func() {
defer wg.Done()
InspectCertificate(h, Port, &results)
}
}
// wait until all groups are done
wg.Wait()
}
func InspectCertificate(h string, p int, r *TLSHosts) {
//do stuff
}
Here, for every host in the array of TLSHosts, we’re adding on to the wait group. This is fine for small array sizes, but when we have a list of 500+ thats a very large waigt group. I still like to think of waitgroups and go channels as a 1:1 match for cores/threads on my system. I don’t yet have 500+ threads.
What I did find though, was a very useful wrapper library that lets you define your wait group size.
So, We’ll add this module:
"github.com/pieterclaerhout/go-waitgroup"
We’ll move the bulk of the work out of main()
and into its own function:
func InspectCertificate(h string, p int, r *TLSHosts, wg *waitgroup.WaitGroup) {
// wg.Done is used to signal back to the wait group that the task is complete.
defer wg.Done()
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", fmt.Sprintf("%s:%d", h, p), &tls.Config{})
if err != nil {
log.Printf("%s has errors: %s\n", h, err)
host := TLSHost{
FQDN: h,
Port: p,
Err: true,
Message: fmt.Sprintf("%s", err),
}
*r = append(*r, host)
// use return instead of continue, as this is now a separate function
return
}
verifyFQDN(conn, h)
host := TLSHost{
FQDN: h,
Port: p,
DNSNames: conn.ConnectionState().PeerCertificates[0].DNSNames,
SNI: getSNI(conn),
Issuer: getIssuer(conn),
Serial: getSerial(conn),
Expiration: getExpiration(conn),
Version: getTlsVersion(conn),
Err: false,
Message: "Okay",
}
*r = append(*r, host)
}
and main can be updated to:
func main() {
var Hosts []string
var results TLSHosts
start := time.Now()
// use 16 groups
wg := waitgroup.NewWaitGroup(16)
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("Reading hosts from STDIN")
for scanner.Scan() {
h := scanner.Text()
Hosts = append(Hosts, h)
}
// same this, we iterate through a list, but add a wg block and inspect the cert.
for _, h := range Hosts {
wg.BlockAdd()
go InspectCertificate(h, Port, &results, wg)
}
wg.Wait()
t := time.Now()
elapsed := t.Sub(start)
fmt.Printf("Finished %v hosts in %v", len(Hosts), elapsed)
js, _ := json.Marshal(results)
fmt.Printf("%s\n", js)
}
So putting that all together, our total runtime is:
Finished 500 hosts in 9.903309839s
9.9 seconds is better.
But wait(hehe)! We’re not done just yet.
if you mix your prints with your json, tools like jq
will error out.
Convert all of those fmt.Print
statements to log.Print
Final:
package main
import (
"bufio"
"bytes"
"crypto/tls"
"crypto/x509/pkix"
"encoding/json"
"flag"
"fmt"
"github.com/pieterclaerhout/go-waitgroup"
"log"
"net"
"os"
"time"
)
type TLSHost struct {
FQDN string `json:"fqdn"`
Port int `json:"port"`
DNSNames []string `json:"dns_names,omitempty"`
Issuer pkix.Name `json:"issuer,omitempty"`
Serial string `json:"serial"`
Expiration time.Time `json:"expiration,omitempty"`
Err bool `json:"err"`
Message string `json:"message"`
}
type TLSHosts []TLSHost
func main() {
Port := 443
flag.Parse()
var Hosts []string
var results TLSHosts
start := time.Now()
wg := waitgroup.NewWaitGroup(16)
file, err := os.OpenFile("tlsinfo.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
if err != nil {
fmt.Println(err)
}
log.SetOutput(file)
scanner := bufio.NewScanner(os.Stdin)
log.Println("Reading hosts from STDIN")
for scanner.Scan() {
h := scanner.Text()
Hosts = append(Hosts, h)
}
for _, h := range Hosts {
wg.BlockAdd()
go InspectCertificate(h, Port, &results, wg)
}
wg.Wait()
t := time.Now()
elapsed := t.Sub(start)
log.Printf("Finished %v hosts in %v", len(Hosts), elapsed)
js, _ := json.Marshal(results)
fmt.Printf("%s\n", js)
}
func InspectCertificate(h string, p int, r *TLSHosts, wg *waitgroup.WaitGroup) {
defer wg.Done()
log.Printf("checking %s", h)
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", fmt.Sprintf("%s:%d", h, p), &tls.Config{})
if err != nil {
log.Printf("%s has errors: %s\n", h, err)
host := TLSHost{
FQDN: h,
Port: p,
Err: true,
Message: fmt.Sprintf("%s", err),
}
*r = append(*r, host)
return
}
verifyFQDN(conn, h)
host := TLSHost{
FQDN: h,
Port: p,
DNSNames: conn.ConnectionState().PeerCertificates[0].DNSNames,
Issuer: getIssuer(conn),
Serial: getSerial(conn),
Expiration: getExpiration(conn),
Err: false,
Message: "Okay",
}
*r = append(*r, host)
}
func verifyFQDN(conn *tls.Conn, h string) {
err := conn.VerifyHostname(h)
if err != nil {
panic("Hostname doesn't match with certificate: " + err.Error())
}
log.Printf("Hostname %s is valid", h)
}
func getIssuer(c *tls.Conn) pkix.Name {
return c.ConnectionState().PeerCertificates[0].Issuer
}
func getExpiration(c *tls.Conn) time.Time {
return c.ConnectionState().PeerCertificates[0].NotAfter
}
func getSignature(c *tls.Conn) string {
sig := c.ConnectionState().PeerCertificates[0].Signature
var buf bytes.Buffer
for i, val := range sig {
if (i % 18) == 0 {
buf.WriteString(fmt.Sprintf("\n%9s", ""))
}
buf.WriteString(fmt.Sprintf("%02x", val))
if i != len(sig)-1 {
buf.WriteString(":")
}
}
return buf.String()
}
func getSerial(c *tls.Conn) string {
return fmt.Sprintf("%02X", c.ConnectionState().PeerCertificates[0].SerialNumber)
}
Now when you run your code, you get a clean json output:
//cat hosts | go run main.go | jq '.[] | { fqdn, dns_names, expiration}'
{
"fqdn": "badname.xyz",
"dns_names": null,
"expiration": "0001-01-01T00:00:00Z"
}
{
"fqdn": "www.google.com",
"dns_names": [
"www.google.com"
],
"expiration": "2023-09-11T08:21:19Z"
}
{
"fqdn": "www.llnl.gov",
"dns_names": [
"*.llnl.gov"
],
"expiration": "2024-01-30T23:59:59Z"
}
{
"fqdn": "www.mywushublog.com",
"dns_names": [
"mywushublog.com",
"www.mywushublog.com"
],
"expiration": "2024-05-10T23:59:59Z"
}
{
"fqdn": "www.blizzard.com",
"dns_names": [
"blizzard.com",
"*.blizzard.com"
],
"expiration": "2024-07-19T23:59:59Z"
}
but you also get a log file:
cat tlsinfo.log
2023/07/13 10:44:48 Reading hosts from STDIN
2023/07/13 10:44:48 checking www.llnl.gov
2023/07/13 10:44:48 checking www.mywushublog.com
2023/07/13 10:44:48 checking www.blizzard.com
2023/07/13 10:44:48 checking www.google.com
2023/07/13 10:44:48 checking badname.xyz
2023/07/13 10:44:48 badname.xyz has errors: dial tcp: lookup badname.xyz on 192.168.1.2:53: no such host
2023/07/13 10:44:48 Hostname www.google.com is valid
2023/07/13 10:44:48 Hostname www.mywushublog.com is valid
2023/07/13 10:44:48 Hostname www.llnl.gov is valid
2023/07/13 10:44:49 Hostname www.blizzard.com is valid
I threw in a bogus hostname, just to show what it looks like
Alright, hope this is informative for anyone out there.