diff options
| author | Xe Iaso <me@xeiaso.net> | 2023-06-19 12:56:31 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2023-06-19 12:56:31 -0400 |
| commit | c592b6d195aedcd6ec86e8c60d3ba91d524e293b (patch) | |
| tree | fbdb9fe9331ce491d606402d4b62ba5999ce6122 /misc/localca | |
| parent | 84e8f57b98fd1038e6f2fc401277d936ef45522a (diff) | |
| download | x-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/LICENSE | 21 | ||||
| -rw-r--r-- | misc/localca/doc.go | 10 | ||||
| -rw-r--r-- | misc/localca/legal.go | 27 | ||||
| -rw-r--r-- | misc/localca/localca.go | 121 | ||||
| -rw-r--r-- | misc/localca/localca_test.go | 83 | ||||
| -rw-r--r-- | misc/localca/minica.go | 234 | ||||
| -rw-r--r-- | misc/localca/utils.go | 83 |
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") +} |
