aboutsummaryrefslogtreecommitdiff
path: root/misc/localca
diff options
context:
space:
mode:
authorXe Iaso <me@xeiaso.net>2023-06-19 12:56:31 -0400
committerXe Iaso <me@xeiaso.net>2023-06-19 12:56:31 -0400
commitc592b6d195aedcd6ec86e8c60d3ba91d524e293b (patch)
treefbdb9fe9331ce491d606402d4b62ba5999ce6122 /misc/localca
parent84e8f57b98fd1038e6f2fc401277d936ef45522a (diff)
downloadx-c592b6d195aedcd6ec86e8c60d3ba91d524e293b.tar.xz
x-c592b6d195aedcd6ec86e8c60d3ba91d524e293b.zip
second reshuffling
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'misc/localca')
-rw-r--r--misc/localca/LICENSE21
-rw-r--r--misc/localca/doc.go10
-rw-r--r--misc/localca/legal.go27
-rw-r--r--misc/localca/localca.go121
-rw-r--r--misc/localca/localca_test.go83
-rw-r--r--misc/localca/minica.go234
-rw-r--r--misc/localca/utils.go83
7 files changed, 579 insertions, 0 deletions
diff --git a/misc/localca/LICENSE b/misc/localca/LICENSE
new file mode 100644
index 0000000..6aa3d70
--- /dev/null
+++ b/misc/localca/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2016 Jacob Hoffman-Andrews
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. \ No newline at end of file
diff --git a/misc/localca/doc.go b/misc/localca/doc.go
new file mode 100644
index 0000000..fb4e829
--- /dev/null
+++ b/misc/localca/doc.go
@@ -0,0 +1,10 @@
+// Package localca uses an autocert.Cache to store and generate TLS certificates
+// for domains on demand.
+//
+// This is kind of powerful, and as such it is limited to only generate
+// certificates as subdomains of a given domain.
+//
+// The design and implementation of this is kinda stolen from minica[1].
+//
+// [1]: https://github.com/jsha/minica
+package localca
diff --git a/misc/localca/legal.go b/misc/localca/legal.go
new file mode 100644
index 0000000..9d35318
--- /dev/null
+++ b/misc/localca/legal.go
@@ -0,0 +1,27 @@
+package localca
+
+import "go4.org/legal"
+
+func init() {
+ legal.RegisterLicense(`MIT License
+
+Copyright (c) 2016 Jacob Hoffman-Andrews
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.`)
+}
diff --git a/misc/localca/localca.go b/misc/localca/localca.go
new file mode 100644
index 0000000..360b53f
--- /dev/null
+++ b/misc/localca/localca.go
@@ -0,0 +1,121 @@
+package localca
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/pem"
+ "errors"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+var (
+ ErrBadData = errors.New("localca: certificate data is bad")
+ ErrDomainDoesntHaveSuffix = errors.New("localca: domain doesn't have the given suffix")
+)
+
+// Manager automatically provisions and caches TLS certificates in a given
+// autocert Cache. If it cannot fetch a certificate on demand, the certificate
+// is dynamically generated with a lifetime of 100 years, which should be good
+// enough.
+type Manager struct {
+ Cache autocert.Cache
+ DomainSuffix string
+
+ *issuer
+}
+
+// New creates a new Manager with the given key filename, certificate filename,
+// allowed domain suffix and autocert cache. All given certificates will be
+// created if they don't already exist.
+func New(keyFile, certFile, suffix string, cache autocert.Cache) (Manager, error) {
+ iss, err := getIssuer(keyFile, certFile, true)
+
+ if err != nil {
+ return Manager{}, err
+ }
+
+ result := Manager{
+ DomainSuffix: suffix,
+ Cache: cache,
+ issuer: iss,
+ }
+
+ return result, nil
+}
+
+func (m Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ name := hello.ServerName
+ if !strings.Contains(strings.Trim(name, "."), ".") {
+ return nil, errors.New("localca: server name component count invalid")
+ }
+
+ if !strings.HasSuffix(name, m.DomainSuffix) {
+ return nil, ErrDomainDoesntHaveSuffix
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ data, err := m.Cache.Get(ctx, name)
+ if err != nil && err != autocert.ErrCacheMiss {
+ return nil, err
+ }
+
+ if err == autocert.ErrCacheMiss {
+ data, _, err = m.issuer.sign([]string{name}, nil)
+ if err != nil {
+ return nil, err
+ }
+ err = m.Cache.Put(ctx, name, data)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ cert, err := loadCertificate(name, data)
+ if err != nil {
+ return nil, err
+ }
+
+ return cert, nil
+}
+
+func loadCertificate(name string, data []byte) (*tls.Certificate, error) {
+ priv, pub := pem.Decode(data)
+ if priv == nil || !strings.Contains(priv.Type, "PRIVATE") {
+ return nil, ErrBadData
+ }
+ privKey, err := parsePrivateKey(priv.Bytes)
+ if err != nil {
+ return nil, err
+ }
+
+ // public
+ var pubDER [][]byte
+ for len(pub) > 0 {
+ var b *pem.Block
+ b, pub = pem.Decode(pub)
+ if b == nil {
+ break
+ }
+ pubDER = append(pubDER, b.Bytes)
+ }
+ if len(pub) > 0 {
+ return nil, ErrBadData
+ }
+
+ // verify and create TLS cert
+ leaf, err := validCert(name, pubDER, privKey, time.Now())
+ if err != nil {
+ return nil, err
+ }
+ tlscert := &tls.Certificate{
+ Certificate: pubDER,
+ PrivateKey: privKey,
+ Leaf: leaf,
+ }
+ return tlscert, nil
+}
diff --git a/misc/localca/localca_test.go b/misc/localca/localca_test.go
new file mode 100644
index 0000000..0c85fba
--- /dev/null
+++ b/misc/localca/localca_test.go
@@ -0,0 +1,83 @@
+package localca
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/acme/autocert"
+)
+
+func TestLocalCA(t *testing.T) {
+ dir, err := ioutil.TempDir("", "localca-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(dir)
+ cache := autocert.DirCache(dir)
+
+ keyFile := path.Join(dir, "key.pem")
+ certFile := path.Join(dir, "cert.pem")
+ const suffix = "club"
+
+ m, err := New(keyFile, certFile, suffix, cache)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("local", func(t *testing.T) {
+ _, err = m.GetCertificate(&tls.ClientHelloInfo{
+ ServerName: "foo.local.cetacean.club",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("network", func(t *testing.T) {
+ t.Skip("no")
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ tc := &tls.Config{
+ GetCertificate: m.GetCertificate,
+ }
+
+ go func() {
+ lis, err := tls.Listen("tcp", ":9293", tc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer lis.Close()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+
+ cli, err := lis.Accept()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cli.Close()
+
+ go io.Copy(cli, cli)
+ }
+ }()
+
+ time.Sleep(130 * time.Millisecond)
+ cli, err := tls.Dial("tcp", "localhost:9293", &tls.Config{InsecureSkipVerify: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer cli.Close()
+
+ cli.Write([]byte("butts"))
+ })
+}
diff --git a/misc/localca/minica.go b/misc/localca/minica.go
new file mode 100644
index 0000000..d47330b
--- /dev/null
+++ b/misc/localca/minica.go
@@ -0,0 +1,234 @@
+package localca
+
+import (
+ "bytes"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/hex"
+ "encoding/pem"
+ "fmt"
+ "io/ioutil"
+ "math"
+ "math/big"
+ "net"
+ "os"
+ "strings"
+ "time"
+)
+
+type issuer struct {
+ key crypto.Signer
+ cert *x509.Certificate
+}
+
+func getIssuer(keyFile, certFile string, autoCreate bool) (*issuer, error) {
+ keyContents, keyErr := ioutil.ReadFile(keyFile)
+ certContents, certErr := ioutil.ReadFile(certFile)
+ if os.IsNotExist(keyErr) && os.IsNotExist(certErr) {
+ err := makeIssuer(keyFile, certFile)
+ if err != nil {
+ return nil, err
+ }
+ return getIssuer(keyFile, certFile, false)
+ } else if keyErr != nil {
+ return nil, fmt.Errorf("%s (but %s exists)", keyErr, certFile)
+ } else if certErr != nil {
+ return nil, fmt.Errorf("%s (but %s exists)", certErr, keyFile)
+ }
+ key, err := readPrivateKey(keyContents)
+ if err != nil {
+ return nil, fmt.Errorf("reading private key from %s: %s", keyFile, err)
+ }
+
+ cert, err := readCert(certContents)
+ if err != nil {
+ return nil, fmt.Errorf("reading CA certificate from %s: %s", certFile, err)
+ }
+
+ equal, err := publicKeysEqual(key.Public(), cert.PublicKey)
+ if err != nil {
+ return nil, fmt.Errorf("comparing public keys: %s", err)
+ } else if !equal {
+ return nil, fmt.Errorf("public key in CA certificate %s doesn't match private key in %s",
+ certFile, keyFile)
+ }
+ return &issuer{key, cert}, nil
+}
+
+func readPrivateKey(keyContents []byte) (crypto.Signer, error) {
+ block, _ := pem.Decode(keyContents)
+ if block == nil {
+ return nil, fmt.Errorf("no PEM found")
+ } else if block.Type != "RSA PRIVATE KEY" && block.Type != "ECDSA PRIVATE KEY" {
+ return nil, fmt.Errorf("incorrect PEM type %s", block.Type)
+ }
+ return x509.ParsePKCS1PrivateKey(block.Bytes)
+}
+
+func readCert(certContents []byte) (*x509.Certificate, error) {
+ block, _ := pem.Decode(certContents)
+ if block == nil {
+ return nil, fmt.Errorf("no PEM found")
+ } else if block.Type != "CERTIFICATE" {
+ return nil, fmt.Errorf("incorrect PEM type %s", block.Type)
+ }
+ return x509.ParseCertificate(block.Bytes)
+}
+
+func makeIssuer(keyFile, certFile string) error {
+ keyData, key, err := makeKey()
+ if err != nil {
+ return err
+ }
+ ioutil.WriteFile(keyFile, keyData, 0600)
+ certData, _, err := makeRootCert(key, certFile)
+ if err != nil {
+ return err
+ }
+ ioutil.WriteFile(certFile, certData, 0600)
+ return nil
+}
+
+func makeKey() ([]byte, *rsa.PrivateKey, error) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return nil, nil, err
+ }
+ der := x509.MarshalPKCS1PrivateKey(key)
+ if err != nil {
+ return nil, nil, err
+ }
+ buf := bytes.NewBuffer([]byte{})
+ err = pem.Encode(buf, &pem.Block{
+ Type: "RSA PRIVATE KEY",
+ Bytes: der,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ return buf.Bytes(), key, nil
+}
+
+func makeRootCert(key crypto.Signer, filename string) ([]byte, *x509.Certificate, error) {
+ serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
+ if err != nil {
+ return nil, nil, err
+ }
+ template := &x509.Certificate{
+ Subject: pkix.Name{
+ CommonName: "localca root ca " + hex.EncodeToString(serial.Bytes()[:3]),
+ },
+ SerialNumber: serial,
+ NotBefore: time.Now(),
+ NotAfter: time.Now().AddDate(100, 0, 0),
+
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+ BasicConstraintsValid: true,
+ IsCA: true,
+ MaxPathLenZero: true,
+ }
+
+ der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
+ if err != nil {
+ return nil, nil, err
+ }
+ buf := bytes.NewBuffer([]byte{})
+ err = pem.Encode(buf, &pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: der,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ result, err := x509.ParseCertificate(der)
+ return buf.Bytes(), result, err
+}
+
+func parseIPs(ipAddresses []string) ([]net.IP, error) {
+ var parsed []net.IP
+ for _, s := range ipAddresses {
+ p := net.ParseIP(s)
+ if p == nil {
+ return nil, fmt.Errorf("invalid IP address %s", s)
+ }
+ parsed = append(parsed, p)
+ }
+ return parsed, nil
+}
+
+func publicKeysEqual(a, b interface{}) (bool, error) {
+ aBytes, err := x509.MarshalPKIXPublicKey(a)
+ if err != nil {
+ return false, err
+ }
+ bBytes, err := x509.MarshalPKIXPublicKey(b)
+ if err != nil {
+ return false, err
+ }
+ return bytes.Compare(aBytes, bBytes) == 0, nil
+}
+
+func (iss *issuer) sign(domains []string, ipAddresses []string) ([]byte, *x509.Certificate, error) {
+ var cn string
+ if len(domains) > 0 {
+ cn = domains[0]
+ } else if len(ipAddresses) > 0 {
+ cn = ipAddresses[0]
+ } else {
+ return nil, nil, fmt.Errorf("must specify at least one domain name or IP address")
+ }
+ keyData, key, err := makeKey()
+ if err != nil {
+ return nil, nil, err
+ }
+ buf := bytes.NewBuffer([]byte{})
+ buf.Write(keyData)
+
+ parsedIPs, err := parseIPs(ipAddresses)
+ if err != nil {
+ return nil, nil, err
+ }
+ serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
+ if err != nil {
+ return nil, nil, err
+ }
+ template := &x509.Certificate{
+ DNSNames: domains,
+ IPAddresses: parsedIPs,
+ Subject: pkix.Name{
+ CommonName: cn,
+ },
+ SerialNumber: serial,
+ NotBefore: time.Now(),
+ NotAfter: time.Now().AddDate(90, 0, 0),
+
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+ BasicConstraintsValid: true,
+ IsCA: false,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, template, iss.cert, key.Public(), iss.key)
+ if err != nil {
+ return nil, nil, err
+ }
+ err = pem.Encode(buf, &pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: der,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ result, err := x509.ParseCertificate(der)
+ return buf.Bytes(), result, err
+}
+
+func split(s string) (results []string) {
+ if len(s) > 0 {
+ return strings.Split(s, ",")
+ }
+ return nil
+}
diff --git a/misc/localca/utils.go b/misc/localca/utils.go
new file mode 100644
index 0000000..efc06f3
--- /dev/null
+++ b/misc/localca/utils.go
@@ -0,0 +1,83 @@
+package localca
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "crypto/x509"
+ "errors"
+ "time"
+)
+
+// validCert parses a cert chain provided as der argument and verifies the leaf and der[0]
+// correspond to the private key, the domain and key type match, and expiration dates
+// are valid. It doesn't do any revocation checking.
+//
+// The returned value is the verified leaf cert.
+func validCert(name string, der [][]byte, key crypto.Signer, now time.Time) (leaf *x509.Certificate, err error) {
+ // parse public part(s)
+ var n int
+ for _, b := range der {
+ n += len(b)
+ }
+ pub := make([]byte, n)
+ n = 0
+ for _, b := range der {
+ n += copy(pub[n:], b)
+ }
+ x509Cert, err := x509.ParseCertificates(pub)
+ if err != nil || len(x509Cert) == 0 {
+ return nil, errors.New("localca: no public key found")
+ }
+ // verify the leaf is not expired and matches the domain name
+ leaf = x509Cert[0]
+ if now.Before(leaf.NotBefore) {
+ return nil, errors.New("localca: certificate is not valid yet")
+ }
+ if now.After(leaf.NotAfter) {
+ return nil, errors.New("localca: expired certificate")
+ }
+ if err := leaf.VerifyHostname(name); err != nil {
+ return nil, err
+ }
+ // ensure the leaf corresponds to the private key and matches the certKey type
+ switch pub := leaf.PublicKey.(type) {
+ case *rsa.PublicKey:
+ prv, ok := key.(*rsa.PrivateKey)
+ if !ok {
+ return nil, errors.New("localca: private key type does not match public key type")
+ }
+ if pub.N.Cmp(prv.N) != 0 {
+ return nil, errors.New("localca: private key does not match public key")
+ }
+ default:
+ return nil, errors.New("localca: unknown public key algorithm")
+ }
+ return leaf, nil
+}
+
+// Attempt to parse the given private key DER block. OpenSSL 0.9.8 generates
+// PKCS#1 private keys by default, while OpenSSL 1.0.0 generates PKCS#8 keys.
+// OpenSSL ecparam generates SEC1 EC private keys for ECDSA. We try all three.
+//
+// Inspired by parsePrivateKey in crypto/tls/tls.go.
+func parsePrivateKey(der []byte) (crypto.Signer, error) {
+ if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
+ return key, nil
+ }
+ if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
+ switch key := key.(type) {
+ case *rsa.PrivateKey:
+ return key, nil
+ case *ecdsa.PrivateKey:
+ return key, nil
+ default:
+ return nil, errors.New("localca: unknown private key type in PKCS#8 wrapping")
+ }
+ }
+ if key, err := x509.ParseECPrivateKey(der); err == nil {
+ return key, nil
+ }
+
+ return nil, errors.New("localca: failed to parse private key")
+}