Skip to content

Commit

Permalink
feat: HTTPS hardening (#1)
Browse files Browse the repository at this point in the history
* parallelize tests

```diff
- ok      github.com/caarlos0/go-gumroad  0.889s
+ ok      github.com/caarlos0/go-gumroad  0.284s
```

also, correct a typo: "expacted" -> "expected"

* add generate_cert.go and generated testdata/*.pem

generate_cert.go was copied from stdlib's
`src/crypto/tls/generate_cert.go` and modified so that it can generate
both self-signed certificates and certificates signed by a parent.

`generate_cert.go` was run (`go run generate_cert.go`) with
`-host 127.0.0.1,::1` so that certificates are valid for the IPv4 and
IPv6 localhost addresses.

A self-signed CA certificate was generated with the additional flags
`-ca self -duration 87600h -rsa-bits 4096 -out testdata/ca.pem`,
specifying a self-signed certificate valid for 10 years, using a
4096-bit RSA keypair, written to `testdata/ca.pem`.

A leaf certificate was generated with the additional flags
`-ca testdata/ca.pem -ecdsa-curve P256 -out testdata/mitm.pem`
specifying a server certificate signed by the CA certificate in
`testdata/ca.pem`, using the NIST P-256 elliptic curve for an ECDSA
keypair, and writing the certificate to `testdata/mitm.pem`.

* http.DefaultClient -> gumroad.client

`doCheck` called `http.PostForm`, which delegates to
`http.DefaultClient`, a generic HTTP client that has no specific
security hardening. Instead, a package-local `*http.Client` is provided,
using a new package-local `transport` as its `http.RoundTripper`.
`transport` mimics `http.DefaultTransport`, except that
`transport.TLSClientConfig` is a custom `*tls.Config` with an explicit
certificate pool. The certificate pool is captured in `certPool`, which
contains the system certificate root trust ring available at build time.

`TestMITM` is added in `main_test.go` to validate intended behavior.
  • Loading branch information
MrGossett authored Apr 5, 2022
1 parent 8741baa commit 2ad0dd6
Show file tree
Hide file tree
Showing 5 changed files with 469 additions and 2 deletions.
213 changes: 213 additions & 0 deletions generate_cert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
//go:build ignore

// Generate a X.509 certificate for a TLS server.

package main

import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"flag"
"log"
"math/big"
"net"
"os"
"strings"
"time"
)

var (
host = flag.String("host", "", "Comma-separated hostnames and IPs to generate a certificate for")
validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011")
validFor = flag.Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for")
parentCA = flag.String("ca", "self", "File name for the Certificate Authority that should sign this certificate. A value of \"self\" means this should be a self-signed CA certificate.")
rsaBits = flag.Int("rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set")
ecdsaCurve = flag.String("ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256 (recommended), P384, P521")
ed25519Key = flag.Bool("ed25519", false, "Generate an Ed25519 key")
outFile = flag.String("out", "cert.pem", "File name where the generated certificate and its key should be written")
)

func publicKey(priv any) any {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
case ed25519.PrivateKey:
return k.Public().(ed25519.PublicKey)
default:
return nil
}
}

func main() {
flag.Parse()

if len(*host) == 0 {
log.Fatalf("Missing required --host parameter")
}

var priv crypto.Signer
var err error
switch *ecdsaCurve {
case "":
if *ed25519Key {
_, priv, err = ed25519.GenerateKey(rand.Reader)
} else {
priv, err = rsa.GenerateKey(rand.Reader, *rsaBits)
}
case "P224":
priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
case "P256":
priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case "P384":
priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case "P521":
priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
default:
log.Fatalf("Unrecognized elliptic curve: %q", *ecdsaCurve)
}
if err != nil {
log.Fatalf("Failed to generate private key: %v", err)
}

// ECDSA, ED25519 and RSA subject keys should have the DigitalSignature
// KeyUsage bits set in the x509.Certificate template
keyUsage := x509.KeyUsageDigitalSignature
// Only RSA subject keys should have the KeyEncipherment KeyUsage bits set. In
// the context of TLS this KeyUsage is particular to RSA key exchange and
// authentication.
if _, isRSA := priv.(*rsa.PrivateKey); isRSA {
keyUsage |= x509.KeyUsageKeyEncipherment
}

var notBefore time.Time
if len(*validFrom) == 0 {
notBefore = time.Now()
} else {
notBefore, err = time.Parse("Jan 2 15:04:05 2006", *validFrom)
if err != nil {
log.Fatalf("Failed to parse creation date: %v", err)
}
}

notAfter := notBefore.Add(*validFor)

serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Failed to generate serial number: %v", err)
}

template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{Organization: []string{"Acme Co"}},
NotBefore: notBefore,
NotAfter: notAfter,

KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}

hosts := strings.Split(*host, ",")
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}

var parent *x509.Certificate
var signer crypto.Signer
if *parentCA == "self" {
template.IsCA = true
template.KeyUsage |= x509.KeyUsageCertSign
parent = &template
signer = priv
} else {
bytes, err := os.ReadFile(*parentCA)
if err != nil {
log.Fatal("Failed to read parent certificate: %v", err)
}
var block *pem.Block
for {
block, bytes = pem.Decode(bytes)
if block == nil {
break
}
switch block.Type {
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
log.Fatal("Failed to parse certificate: %v", err)
}
parent = cert

case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Fatal("Failed to parse private key: %v", err)
}
signer = key

case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
log.Fatal("Failed to parse private key: %v", err)
}
switch key := key.(type) {
case *rsa.PrivateKey:
signer = key
case *ecdsa.PrivateKey:
signer = key
case ed25519.PrivateKey:
signer = key
default:
log.Fatal("Unsupported signing key type %T", key)
}
}
}

if parent == nil {
log.Fatal("could not find a parent CA certificate")
}

if signer == nil {
log.Fatal("could not find a parent CA signing key")
}
}

derBytes, err := x509.CreateCertificate(rand.Reader, &template, parent, publicKey(priv), signer)
if err != nil {
log.Fatalf("Failed to create certificate: %v", err)
}

f, err := os.Create(*outFile)
if err != nil {
log.Fatalf("Failed to open %s for writing: %v", *outFile, err)
}
if err := pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
log.Fatalf("Failed to write data to %s: %v", *outFile, err)
}

privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Fatalf("Unable to marshal private key: %v", err)
}
if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
log.Fatalf("Failed to write data to %s: %v", *outFile, err)
}
if err := f.Close(); err != nil {
log.Fatalf("Error closing %s: %v", *outFile, err)
}
log.Printf("wrote %s\n", *outFile)
}
33 changes: 32 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package gumroad

import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
Expand All @@ -15,8 +17,37 @@ func Check(product, key string) error {
return doCheck("https://api.gumroad.com/v2/licenses/verify", product, key)
}

// Capture the root certificate pool at build time. `x509.SystemCertPool` is guaranteed not to return
// an error when GOOS is darwin or windows. When GOOS is unix or plan9, `x509.SystemCertPool` will
// only return an error if it was unable to find or parse any system certificates.
var certPool, _ = x509.SystemCertPool()

// construct a package-level http.RoundTripper to use instead of http.DefaultTransport
var transport = &http.Transport{
// don't use the runtime system's cert pool, since it may include a certificate
// that this package does not want to trust
TLSClientConfig: &tls.Config{RootCAs: certPool},

// since TLSClientConfig above is not nil, HTTP/2 needs to be explicitly enabled
ForceAttemptHTTP2: true,

// copy the other non-zero-value attributes from http.DefaultTransport
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
Proxy: http.ProxyFromEnvironment,
}

// construct a package-local client to use instead of http.DefaultClient
var client = &http.Client{
// 5 seconds should be plenty for GumRoad to respond
Timeout: 5 * time.Second,
Transport: transport,
}

func doCheck(api, product, key string) error {
resp, err := http.PostForm(api,
resp, err := client.PostForm(api,
url.Values{
"product_permalink": {product},
"license_key": {key},
Expand Down
Loading

0 comments on commit 2ad0dd6

Please # to comment.