Something occurred to me the other day. This is my blog, and that means I can write about whatever I want. Now you may think that’s totally obvious, but it’s not. For the longest time I wouldn’t blog about anything that I didn’t deem blog-worthy. Small things, like “this is a cool function I found” or “I learned this thing today”, were not blog-worthy in my mind for some reason.
Well today I am changing that. I like writing, but not necessarily so much that I always want to write a super long post. Sometimes, things should be short. Like this one.
So in this super short post I’m gonna show you a cool thing I figured out: How to calculate the the value that curls --pinnedpubkey option needs in Go.
curl’s --insecure flag is insecure — shocker!
For my tiny side project pcopy, I wanted to be able to be able to allow people to easily install/join a remote clipboard using a curl | sh-type install, even when the shared clipboard is internal to a company network and doesn’t have a proper SSL certificate, i.e. when the cert is self-signed.
The only way to make curl work with self-signed certs is with the -k flag (--insecure):
1 2 3 4 5 6 7 8 |
$ pcopy invite # Instructions for clipboard 'default' # Install pcopy on other computers (as root): curl -sSLk https://10.0.160.67:1986/install | sudo sh # Join this clipboard on other computers: curl -sSLk https://10.0.160.67:1986/join | sh |
And that’s obviously not cool, because that flag allows replacing the cert entirely in man-in-the-middle attacks (see my post on mitmproxy for a practical example; wow that post is from 7 years ago — I’m telling you time flies …).
--pinnedpubkey to the rescue
So what to do, what to do? Should we just not care about that? No of course not! I care deeply about security, so let’s figure out if we can use public key pinning in curl (note that this link points to “HTTP public key pinning” not the concept of public key pinning in general; there is strangely no Wikipedia article about that).
Public key pinning allows us to pin a specific public key for a given request, so that even if a certificate is self-signed it can’t be replaced without raising an exception. This technique is pretty useful if you want to support self-signed certs, but still be secure.
And surely enough, curl has a --pinnedpubkey option. From the man page:
1 2 3 4 5 6 7 8 9 10 |
--pinnedpubkey <hashes> (TLS) Tells curl to use the specified public key file (or hashes) to verify the peer. This can be a path to a file which contains a single public key in PEM or DER format, or any number of base64 encoded sha256 hashes preceded by ´sha256//´ and separated by ´;´ When negotiating a TLS or SSL connection, the server sends a certificate indicating its identity. A public key is extracted from this certificate and if it does not exactly match the public key provided to this option, curl will abort the connection before sending or receiving any data. </hashes> |
Now this description wasn’t really helpful when I was trying to figure out what exactly curl was expecting here. Public keys can be encoded as PEM or DER, and then there’s also PKIX and PKCS1. Plus somehow ASN.1 is involved in the whole thing. It’s all pretty confusing, and of course, none of this is mentioned in the tiny man page entry. So it took a while to figure it out.
So with the help of the curl docs on CURLOPT_PINNEDPUBLICKEY and the lovely #security channel on the Go Slack, I figured out that curl is expecting the base64-encoded SHA-256 checksum of the PKIX, ASN.1 DER encoded public key. That’s a mouthful, isn’t it?
In bash (using openssl), that looks like this:
1 2 3 4 5 6 7 8 9 10 |
# Assuming default.crt is a PEM-encoded cert, this extracts the public key # converts it to DER form, hashes it with SHA-256, then base64-encodes it # and prepends "sha256//" echo sha256//$(openssl x509 -in default.crt -pubkey -noout \ | openssl asn1parse -inform PEM -in - -noout -out - \ | openssl dgst -sha256 -binary - \ | openssl base64) # Outputs something like sha256//Y/CGGnkaoZwUgOqArQs12llyoaX0bkjSIgHCPtXba+c= |
In Go, it looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func calculatePublicKeyHashes(certs []*x509.Certificate) ([]string, error) { hashes := make([]string, len(certs)) for i, cert := range certs { derCert, err := x509.MarshalPKIXPublicKey(cert.PublicKey) if err != nil { return nil, err } hash := sha256.New() hash.Write(derCert) hashes[i] = fmt.Sprintf("sha256//%s", base64.StdEncoding.EncodeToString(hash.Sum(nil))) } return hashes, nil } |
It took me a while to figure out that I needed to use x509.MarshalPKIXPublicKey (PKIX, ASN.1 DER form), and not x509.MarshalPKCS1PublicKey (PKCS#1, ASN.1 DER form). Apparently, the PKIX format also includes the public key algorithm (RSA, EC), and not just the raw bytes, and curl expects this form. There is a good explanation of Stack Overflow.
Long story short, now pcopy invite (see source code) can output a secure curl command, even for self-signed certs:
1 2 3 4 5 6 7 8 |
$ pcopy invite # Instructions for clipboard 'default' # Install pcopy on other computers (as root): curl -sSLk --pinnedpubkey sha256//Y/CGGnkaoZwUgOqArQs12llyoaX0bkjSIgHCPtXba+c= https://10.0.160.67:1986/install | sudo sh # Join this clipboard on other computers: curl -sSLk --pinnedpubkey sha256//Y/CGGnkaoZwUgOqArQs12llyoaX0bkjSIgHCPtXba+c= https://10.0.160.67:1986/join | sh |
Note that despite the -k flag still being there, the command cannot be intercepted without curl erroring, because the pinned public key hash won’t match. However, removing the flag will make curl complain with curl: (60) SSL certificate problem: self signed certificate. All that means is that the --pinnedpubkey verification happens after the self-signed cert verification.
Recent Comments