diff --git a/LICENSE-mkcert b/LICENSE-mkcert new file mode 100644 index 0000000..b8651d7 --- /dev/null +++ b/LICENSE-mkcert @@ -0,0 +1,27 @@ +Copyright (c) 2018 The mkcert Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index b69050e..554257b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Generate deterministic [TLS certificates](https://golang.org/pkg/crypto/tls/) for local [Go](https://golang.org/) development servers. The certificates use a [P-256 ECDSA private key](https://csrc.nist.gov/csrc/media/events/workshop-on-elliptic-curve-cryptography-standards/documents/papers/session6-adalier-mehmet.pdf) generated with a total lack of randomness. +Optionally, the certificates generated by this package will be signed by your local [mkcert](https://github.com/FiloSottile/mkcert) root CA. See the [mkcert docs](https://github.com/FiloSottile/mkcert) for more information. + ## Why? So your browser can trust a single certificate from your development servers, and dev/test with TLS. **Do not use in production.** @@ -12,6 +14,10 @@ So your browser can trust a single certificate from your development servers, an `go get github.com/alta/insecure` +### Local CA + +This package works with [mkcert](https://github.com/FiloSottile/mkcert) to generate certificates that are signed by your machine’s local certificate authority (CA). To use this feature, run `mkcert -install` on your development machine before generating a certificate. + ## Usage Get a TLS certificate suitable for `localhost`, `127.0.0.1`, etc: @@ -39,3 +45,5 @@ Seriously, do not use this in production. ## Author Originally developed by [@cee-dub](https://github.com/cee-dub) for Alta Software LLC. + +This package includes functions adapted from [mkcert](https://github.com/FiloSottile/mkcert). Neither the authors of mkcert nor Google, Inc. have promoted or endorsed this project. diff --git a/ca.go b/ca.go new file mode 100644 index 0000000..c983743 --- /dev/null +++ b/ca.go @@ -0,0 +1,110 @@ +package insecure + +import ( + "crypto" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "runtime" +) + +// This file borrows heavily from the mkcert project: +// https://github.com/FiloSottile/mkcert +// +// This package will attempt to sign generated certs with your local mkcert CA, if present. + +const ( + rootName = "rootCA.pem" + rootKeyName = "rootCA-key.pem" +) + +// CA returns the mkcert CA certificate and key if found. +// Returns an error if either fail to load or parse. +func CA() (cert *x509.Certificate, key crypto.PrivateKey, err error) { + certPEMBlock, keyPEMBlock, err := CAPEM() + if err != nil { + return nil, nil, err + } + + certDERBlock, _ := pem.Decode(certPEMBlock) + if certDERBlock == nil || certDERBlock.Type != "CERTIFICATE" { + return nil, nil, errors.New("failed to read the CA certificate: unexpected content") + } + cert, err = x509.ParseCertificate(certDERBlock.Bytes) + if err != nil { + return nil, nil, err + } + + keyDERBlock, _ := pem.Decode(keyPEMBlock) + if keyDERBlock == nil || keyDERBlock.Type != "PRIVATE KEY" { + return nil, nil, errors.New("ERROR: failed to read the CA key: unexpected content") + } + key, err = x509.ParsePKCS8PrivateKey(keyDERBlock.Bytes) + if err != nil { + return nil, nil, err + } + + return +} + +// CAPEM returns the raw PEM mkcert CA certificate and key if found. +// Returns an error if either doesn’t exist or fails to load. +func CAPEM() (cert []byte, key []byte, err error) { + caRoot := getCARoot() + + caPath := filepath.Join(caRoot, rootName) + if !pathExists(caPath) { + return nil, nil, fmt.Errorf("no CA certificate located at: %s", caPath) + } + cert, err = ioutil.ReadFile(caPath) + if err != nil { + return nil, nil, err + } + + keyPath := filepath.Join(caRoot, rootKeyName) + if !pathExists(keyPath) { + return nil, nil, fmt.Errorf("no CA key located at: %s", keyPath) + } + key, err = ioutil.ReadFile(keyPath) + if err != nil { + return nil, nil, err + } + + return +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func getCARoot() string { + if env := os.Getenv("CAROOT"); env != "" { + return env + } + + var dir string + switch { + case runtime.GOOS == "windows": + dir = os.Getenv("LocalAppData") + case os.Getenv("XDG_DATA_HOME") != "": + dir = os.Getenv("XDG_DATA_HOME") + case runtime.GOOS == "darwin": + dir = os.Getenv("HOME") + if dir == "" { + return "" + } + dir = filepath.Join(dir, "Library", "Application Support") + default: // Unix + dir = os.Getenv("HOME") + if dir == "" { + return "" + } + dir = filepath.Join(dir, ".local", "share") + } + return filepath.Join(dir, "mkcert") +} diff --git a/cert.go b/cert.go index d8918d9..09b9a91 100644 --- a/cert.go +++ b/cert.go @@ -1,6 +1,7 @@ package insecure import ( + "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/sha256" @@ -54,7 +55,7 @@ func PEM(sans ...string) (cert []byte, key []byte, err error) { notBefore, notAfter := notBeforeOrAfter(time.Now()) - template := x509.Certificate{ + template := &x509.Certificate{ SerialNumber: big.NewInt(SerialNumber), Subject: pkix.Name{ Organization: []string{Organization}, @@ -83,8 +84,16 @@ func PEM(sans ...string) (cert []byte, key []byte, err error) { template.SerialNumber = big.NewInt(0).SetBytes(hash.Sum(nil)) - // For deterministic output. Do NOT do this for any real server. - b, err := x509.CreateCertificate(zeroes{}, &template, &template, priv.Public(), priv) + // Get CA, if present. + parent := template + signKey := crypto.PrivateKey(priv) + caCert, caKey, err := CA() + if err == nil && caCert != nil && caKey != nil { + parent = caCert + signKey = caKey + } + + b, err := x509.CreateCertificate(zeroes{}, template, parent, priv.Public(), signKey) if err != nil { return nil, nil, fmt.Errorf("failed to create certificate: %s", err) } diff --git a/cert_test.go b/cert_test.go index 855784e..3b96387 100644 --- a/cert_test.go +++ b/cert_test.go @@ -9,11 +9,19 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "os" + "os/exec" "testing" "time" ) -func TestPEM(t *testing.T) { +func TestUnsigned(t *testing.T) { + caRoot := os.Getenv("CAROOT") + os.Setenv("CAROOT", ".") + defer func() { + os.Setenv("CAROOT", caRoot) + }() + tests := []struct { name string sans []string @@ -68,6 +76,60 @@ func TestPEM(t *testing.T) { } } +func TestSigned(t *testing.T) { + caCert, _, err := CA() + if err != nil { + cmd := exec.Command("mkcert", "-install") + err := cmd.Run() + if err != nil { + t.Fatal(err) + } + } + + roots := x509.NewCertPool() + roots.AddCert(caCert) + + tests := []struct { + name string + sans []string + wantNames []string + wantErr bool + }{ + {"computer.local", []string{"computer.local"}, []string{"computer.local"}, false}, + {"local SANs + computer.local", append(LocalSANs(), "computer.local"), append(LocalSANs(), "computer.local"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + certPEM, _, err := PEM(tt.sans...) + if (err != nil) != tt.wantErr { + t.Errorf("PEM() error = %v, wantErr %v", err, tt.wantErr) + return + } + + block, _ := pem.Decode(certPEM) + if block == nil { + t.Fatal("failed to parse certificate PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse certificate: " + err.Error()) + } + + // Verify certificate is valid for all expected names + for _, name := range tt.wantNames { + opts := x509.VerifyOptions{ + DNSName: name, + Roots: roots, + } + + if _, err := cert.Verify(opts); err != nil { + t.Errorf("failed to verify certificate: " + err.Error()) + } + } + }) + } +} + func TestServeCert(t *testing.T) { // Configure server cert, err := Cert()