From 57b794c338e0603224f3d274e8f6b3addacc6d21 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Tue, 17 Aug 2021 15:03:28 -0700 Subject: [PATCH] ipn/localapi: move cert fetching code to localapi, cache, add cert subcommand Updates #1235 Signed-off-by: Brad Fitzpatrick --- client/tailscale/tailscale.go | 48 ++++ cmd/tailscale/cli/cert.go | 300 ++++--------------------- cmd/tailscale/cli/cli.go | 1 + cmd/tailscale/cli/debug.go | 5 - cmd/tailscale/depaware.txt | 3 +- cmd/tailscaled/depaware.txt | 1 + ipn/localapi/cert.go | 400 +++++++++++++++++++++++++++++++++ ipn/localapi/disabled_stubs.go | 17 ++ ipn/localapi/localapi.go | 10 + 9 files changed, 526 insertions(+), 259 deletions(-) create mode 100644 ipn/localapi/cert.go create mode 100644 ipn/localapi/disabled_stubs.go diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index 30ef2db3c..47d19b05d 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -8,6 +8,7 @@ import ( "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -18,7 +19,9 @@ "net/url" "strconv" "strings" + "time" + "go4.org/mem" "tailscale.com/client/tailscale/apitype" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" @@ -293,3 +296,48 @@ func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) { } return &derpMap, nil } + +// CertPair returns a cert and private key for the provided DNS domain. +// +// It returns a cached certificate from disk if it's still valid. +func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) { + res, err := send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil) + if err != nil { + return nil, nil, err + } + // with ?type=pair, the response PEM is first the one private + // key PEM block, then the cert PEM blocks. + i := mem.Index(mem.B(res), mem.S("--\n--")) + if i == -1 { + return nil, nil, fmt.Errorf("unexpected output: no delimiter") + } + i += len("--\n") + keyPEM, certPEM = res[:i], res[i:] + if mem.Contains(mem.B(certPEM), mem.S(" PRIVATE KEY-----")) { + return nil, nil, fmt.Errorf("unexpected output: key in cert") + } + return certPEM, keyPEM, nil +} + +// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi. +// +// It returns a cached certificate from disk if it's still valid. +// +// It's the right signature to use as the value of +// tls.Config.GetCertificate. +func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { + if hi == nil || hi.ServerName == "" { + return nil, errors.New("no SNI ServerName") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + certPEM, keyPEM, err := CertPair(ctx, hi.ServerName) + if err != nil { + return nil, err + } + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, err + } + return &cert, nil +} diff --git a/cmd/tailscale/cli/cert.go b/cmd/tailscale/cli/cert.go index 74ad1c7b8..ffbeb1d93 100644 --- a/cmd/tailscale/cli/cert.go +++ b/cmd/tailscale/cli/cert.go @@ -5,278 +5,74 @@ package cli import ( - "bytes" "context" - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/json" - "encoding/pem" - "errors" + "crypto/tls" + "flag" "fmt" - "io" "io/ioutil" "log" - "os" - "path/filepath" - "strings" - "time" + "net/http" - "golang.org/x/crypto/acme" + "github.com/peterbourgon/ff/v2/ffcli" "tailscale.com/client/tailscale" - "tailscale.com/ipn/ipnstate" ) -func jout(v interface{}) { - j, err := json.MarshalIndent(v, "", "\t") - if err != nil { - panic(err) - } - fmt.Printf("%T: %s\n", v, j) +var certCmd = &ffcli.Command{ + Name: "cert", + Exec: runCert, + ShortHelp: "get TLS certs", + ShortUsage: "cert [flags] ", + FlagSet: (func() *flag.FlagSet { + fs := flag.NewFlagSet("cert", flag.ExitOnError) + fs.StringVar(&certArgs.certFile, "cert-file", "", "output cert file; defaults to DOMAIN.crt") + fs.StringVar(&certArgs.keyFile, "key-file", "", "output cert file; defaults to DOMAIN.key") + fs.BoolVar(&certArgs.serve, "serve-demo", false, "if true, serve on port :443 using the cert as a demo, instead of writing out the files to disk") + return fs + })(), } -func checkCertDomain(st *ipnstate.Status, domain string) error { - if domain == "" { - return errors.New("missing domain name") - } - for _, d := range st.CertDomains { - if d == domain { - return nil - } - } - // Transitional way while server doesn't yet populate CertDomains: also permit the client - // attempting Self.DNSName. - okay := st.CertDomains[:len(st.CertDomains):len(st.CertDomains)] - if st.Self != nil { - if v := strings.Trim(st.Self.DNSName, "."); v != "" { - if v == domain { - return nil - } - okay = append(okay, v) - } - } - switch len(okay) { - case 0: - return errors.New("your Tailscale account does not support getting TLS certs") - case 1: - return fmt.Errorf("invalid domain %q; only %q is permitted", domain, okay[0]) - default: - return fmt.Errorf("invalid domain %q; must be one of %q", domain, okay) - } +var certArgs struct { + certFile string + keyFile string + serve bool } -func debugGetCert(ctx context.Context, domain string) error { - st, err := tailscale.Status(ctx) - if err != nil { - return fmt.Errorf("getting tailscale status: %w", err) - } - if err := checkCertDomain(st, domain); err != nil { - return err +func runCert(ctx context.Context, args []string) error { + if certArgs.serve { + s := &http.Server{ + TLSConfig: &tls.Config{ + GetCertificate: tailscale.GetCertificate, + }, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "

Hello from Tailscale

It works.") + }), + } + log.Printf("running TLS server on :443 ...") + return s.ListenAndServeTLS("", "") } - key, err := acmeKey() + if len(args) != 1 { + return fmt.Errorf("Usage: tailscale cert [flags] ") + } + domain := args[0] + + if certArgs.certFile == "" { + certArgs.certFile = domain + ".crt" + } + if certArgs.keyFile == "" { + certArgs.keyFile = domain + ".key" + } + certPEM, keyPEM, err := tailscale.CertPair(ctx, domain) if err != nil { return err } - ac := &acme.Client{ - Key: key, - } - - logf := log.Printf - - a, err := ac.GetReg(ctx, "unused") - switch { - case err == nil: - // Great, already registered. - logf("Already had ACME account.") - case err == acme.ErrNoAccount: - a, err = ac.Register(ctx, new(acme.Account), acme.AcceptTOS) - if err == acme.ErrAccountAlreadyExists { - // Potential race. Double check. - a, err = ac.GetReg(ctx, "unused") - } - if err != nil { - return fmt.Errorf("acme.Register: %w", err) - } - logf("Registered ACME account.") - jout(a) - default: - return fmt.Errorf("acme.GetReg: %w", err) - - } - if a.Status != acme.StatusValid { - return fmt.Errorf("unexpected ACME account status %q", a.Status) - } - - order, err := ac.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: domain}}) - if err != nil { + if err := ioutil.WriteFile(certArgs.certFile, certPEM, 0644); err != nil { return err } - jout(order) - - for _, aurl := range order.AuthzURLs { - az, err := ac.GetAuthorization(ctx, aurl) - if err != nil { - return err - } - jout(az) - for _, ch := range az.Challenges { - if ch.Type == "dns-01" { - rec, err := ac.DNS01ChallengeRecord(ch.Token) - if err != nil { - return err - } - err = tailscale.SetDNS(ctx, "_acme-challenge."+domain, rec) - log.Printf("SetDNS of %q = %v", rec, err) - - chal, err := ac.Accept(ctx, ch) - if err != nil { - return fmt.Errorf("Accept: %v", err) - } - jout(chal) - break - } - } - } - - t0 := time.Now() - orderURI := order.URI - for { - order, err = ac.WaitOrder(ctx, orderURI) - if err == nil { - break - } - if oe, ok := err.(*acme.OrderError); ok && oe.Status == acme.StatusInvalid { - if time.Since(t0) > 2*time.Minute { - return errors.New("timeout waiting for order to not be invalid") - } - log.Printf("order invalid; waiting...") - select { - case <-time.After(5 * time.Second): - continue - case <-ctx.Done(): - return ctx.Err() - } - } - return fmt.Errorf("WaitOrder: %v", err) - } - jout(order) - - certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { + if err := ioutil.WriteFile(certArgs.keyFile, keyPEM, 0600); err != nil { return err } - var pemBuf bytes.Buffer - if err := encodeECDSAKey(&pemBuf, certPrivKey); err != nil { - return err - } - if err := ioutil.WriteFile(domain+".key", pemBuf.Bytes(), 0600); err != nil { - return err - } - - csr, err := certRequest(certPrivKey, domain, nil) - if err != nil { - return err - } - - der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true) - if err != nil { - return fmt.Errorf("CreateOrder: %v", err) - } - pemBuf.Reset() - for _, b := range der { - pb := &pem.Block{Type: "CERTIFICATE", Bytes: b} - if err := pem.Encode(&pemBuf, pb); err != nil { - return err - } - } - if err := ioutil.WriteFile(domain+".crt", pemBuf.Bytes(), 0644); err != nil { - return err - } - os.Stdout.Write(pemBuf.Bytes()) - fmt.Printf("\nPublic cert and private key written to %s.crt and %s.key\n", domain, domain) + fmt.Printf("Wrote public cert to %v\n", certArgs.certFile) + fmt.Printf("Wrote private key to %v\n", certArgs.keyFile) return nil } - -// certRequest generates a CSR for the given common name cn and optional SANs. -func certRequest(key crypto.Signer, cn string, ext []pkix.Extension, san ...string) ([]byte, error) { - req := &x509.CertificateRequest{ - Subject: pkix.Name{CommonName: cn}, - DNSNames: san, - ExtraExtensions: ext, - } - return x509.CreateCertificateRequest(rand.Reader, req, key) -} - -func acmeKey() (crypto.Signer, error) { - cacheDir, err := os.UserCacheDir() - if err != nil { - return nil, err - } - file := filepath.Join(cacheDir, "tailscale-acme") - if err := os.MkdirAll(file, 0700); err != nil { - return nil, err - } - cacheFile := filepath.Join(file, "acme-account.key.pem") - if v, err := ioutil.ReadFile(cacheFile); err == nil { - priv, _ := pem.Decode(v) - if priv == nil || !strings.Contains(priv.Type, "PRIVATE") { - return nil, errors.New("acme/autocert: invalid account key found in cache") - } - return parsePrivateKey(priv.Bytes) - } - - privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, err - } - var pemBuf bytes.Buffer - if err := encodeECDSAKey(&pemBuf, privKey); err != nil { - return nil, err - } - if err := ioutil.WriteFile(cacheFile, pemBuf.Bytes(), 0600); err != nil { - return nil, err - } - return privKey, nil -} - -func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error { - b, err := x509.MarshalECPrivateKey(key) - if err != nil { - return err - } - pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} - return pem.Encode(w, pb) -} - -// parsePrivateKey is a copy of x/crypto/acme's parsePrivateKey. -// -// 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("acme/autocert: unknown private key type in PKCS#8 wrapping") - } - } - if key, err := x509.ParseECPrivateKey(der); err == nil { - return key, nil - } - - return nil, errors.New("acme/autocert: failed to parse private key") -} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index ab58eb4a3..264a52691 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -107,6 +107,7 @@ func Run(args []string) error { webCmd, fileCmd, bugReportCmd, + certCmd, }, FlagSet: rootfs, Exec: func(context.Context, []string) error { return flag.ErrHelp }, diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index 298ba507f..a25cd41e4 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -36,7 +36,6 @@ fs.BoolVar(&debugArgs.netMap, "netmap", true, "whether to include netmap in --ipn mode") fs.BoolVar(&debugArgs.localCreds, "local-creds", false, "print how to connect to local tailscaled") fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME") - fs.StringVar(&debugArgs.getCert, "get-acme-cert", "", "hostname to start ACME flow for (debug)") return fs })(), } @@ -50,16 +49,12 @@ file string prefs bool pretty bool - getCert string } func runDebug(ctx context.Context, args []string) error { if len(args) > 0 { return errors.New("unknown arguments") } - if debugArgs.getCert != "" { - return debugGetCert(ctx, debugArgs.getCert) - } if debugArgs.localCreds { port, token, err := safesocket.LocalTCPPortAndToken() if err == nil { diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 4e15a0715..5411923ca 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -69,7 +69,6 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/version from tailscale.com/cmd/tailscale/cli+ tailscale.com/version/distro from tailscale.com/cmd/tailscale/cli+ tailscale.com/wgengine/filter from tailscale.com/types/netmap - golang.org/x/crypto/acme from tailscale.com/cmd/tailscale/cli golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 golang.org/x/crypto/chacha20poly1305 from crypto/tls+ @@ -170,7 +169,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep reflect from crypto/x509+ regexp from github.com/tailscale/goupnp/httpu+ regexp/syntax from regexp - runtime/debug from golang.org/x/sync/singleflight+ + runtime/debug from golang.org/x/sync/singleflight sort from compress/flate+ strconv from compress/flate+ strings from bufio+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 8b853cbd2..403785ee5 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -177,6 +177,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/wgengine/wgcfg/nmcfg from tailscale.com/ipn/ipnlocal tailscale.com/wgengine/wglog from tailscale.com/wgengine W 💣 tailscale.com/wgengine/winnet from tailscale.com/wgengine/router + golang.org/x/crypto/acme from tailscale.com/ipn/localapi golang.org/x/crypto/blake2b from golang.org/x/crypto/nacl/box golang.org/x/crypto/blake2s from golang.zx2c4.com/wireguard/device+ golang.org/x/crypto/chacha20 from golang.org/x/crypto/chacha20poly1305 diff --git a/ipn/localapi/cert.go b/ipn/localapi/cert.go new file mode 100644 index 000000000..05906331d --- /dev/null +++ b/ipn/localapi/cert.go @@ -0,0 +1,400 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !ios && !android +// +build !ios,!android + +package localapi + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/acme" + "tailscale.com/ipn/ipnstate" + "tailscale.com/paths" + "tailscale.com/types/logger" +) + +func (h *Handler) certDir() (string, error) { + base := paths.DefaultTailscaledStateFile() + if base == "" { + return "", errors.New("no default DefaultTailscaledStateFile") + } + full := filepath.Join(filepath.Dir(base), "certs") + if err := os.MkdirAll(full, 0700); err != nil { + return "", err + } + return full, nil +} + +var acmeDebug, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_ACME")) + +func (h *Handler) serveCert(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "cert access denied", http.StatusForbidden) + return + } + dir, err := h.certDir() + if err != nil { + h.logf("certDir: %v", err) + http.Error(w, "failed to get cert dir", 500) + return + } + + suff := strings.TrimPrefix(r.URL.Path, "/localapi/v0/cert/") + if suff == r.URL.Path { + http.Error(w, "internal handler config wired wrong", 500) + return + } + domain := suff + + mu := &h.certMu + mu.Lock() + defer mu.Unlock() + + logf := logger.WithPrefix(h.logf, fmt.Sprintf("cert(%q): ", domain)) + traceACME := func(v interface{}) { + if !acmeDebug { + return + } + j, _ := json.MarshalIndent(v, "", "\t") + log.Printf("acme %T: %s", v, j) + } + + pair, err := h.getCertPEM(r.Context(), logf, traceACME, dir, domain, time.Now()) + if err != nil { + logf("getCertPEM: %v", err) + http.Error(w, fmt.Sprint(err), 500) + return + } + + w.Header().Set("Content-Type", "text/plain") + switch r.URL.Query().Get("type") { + case "", "crt", "cert": + w.Write(pair.certPEM) + case "key": + w.Write(pair.keyPEM) + case "pair": + w.Write(pair.keyPEM) + w.Write(pair.certPEM) + default: + http.Error(w, `invalid type; want "cert" (default), "key", or "pair"`, 400) + } +} + +type keyPair struct { + certPEM []byte + keyPEM []byte + cached bool +} + +func (h *Handler) getCertPEM(ctx context.Context, logf logger.Logf, traceACME func(interface{}), dir, domain string, now time.Time) (*keyPair, error) { + keyFile := filepath.Join(dir, domain+".key") + certFile := filepath.Join(dir, domain+".crt") + + if keyPEM, err := os.ReadFile(keyFile); err == nil { + certPEM, _ := os.ReadFile(certFile) + if validCertPEM(domain, keyPEM, certPEM, now) { + return &keyPair{certPEM: certPEM, keyPEM: keyPEM, cached: true}, nil + } + } + + key, err := acmeKey(dir) + if err != nil { + return nil, fmt.Errorf("acmeKey: %w", err) + } + ac := &acme.Client{Key: key} + + a, err := ac.GetReg(ctx, "" /* pre-RFC param */) + switch { + case err == nil: + // Great, already registered. + logf("already had ACME account.") + case err == acme.ErrNoAccount: + a, err = ac.Register(ctx, new(acme.Account), acme.AcceptTOS) + if err == acme.ErrAccountAlreadyExists { + // Potential race. Double check. + a, err = ac.GetReg(ctx, "" /* pre-RFC param */) + } + if err != nil { + return nil, fmt.Errorf("acme.Register: %w", err) + } + logf("registered ACME account.") + traceACME(a) + default: + return nil, fmt.Errorf("acme.GetReg: %w", err) + + } + if a.Status != acme.StatusValid { + return nil, fmt.Errorf("unexpected ACME account status %q", a.Status) + } + + // Before hitting LetsEncrypt, see if this is a domain that Tailscale will do DNS challenges for. + st := h.b.StatusWithoutPeers() + if err := checkCertDomain(st, domain); err != nil { + return nil, err + } + + order, err := ac.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: domain}}) + if err != nil { + return nil, err + } + traceACME(order) + + for _, aurl := range order.AuthzURLs { + az, err := ac.GetAuthorization(ctx, aurl) + if err != nil { + return nil, err + } + traceACME(az) + for _, ch := range az.Challenges { + if ch.Type == "dns-01" { + rec, err := ac.DNS01ChallengeRecord(ch.Token) + if err != nil { + return nil, err + } + key := "_acme-challenge." + domain + + var resolver net.Resolver + var ok bool + txts, _ := resolver.LookupTXT(ctx, key) + for _, txt := range txts { + if txt == rec { + ok = true + logf("TXT record already existed") + break + } + } + if !ok { + err = h.b.SetDNS(ctx, key, rec) + if err != nil { + return nil, fmt.Errorf("SetDNS %q => %q: %w", key, rec, err) + } + logf("did SetDNS") + } + + chal, err := ac.Accept(ctx, ch) + if err != nil { + return nil, fmt.Errorf("Accept: %v", err) + } + traceACME(chal) + break + } + } + } + + wait0 := time.Now() + orderURI := order.URI + for { + order, err = ac.WaitOrder(ctx, orderURI) + if err == nil { + break + } + if oe, ok := err.(*acme.OrderError); ok && oe.Status == acme.StatusInvalid { + if time.Since(wait0) > 2*time.Minute { + return nil, errors.New("timeout waiting for order to not be invalid") + } + log.Printf("order invalid; waiting...") + select { + case <-time.After(5 * time.Second): + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return nil, fmt.Errorf("WaitOrder: %v", err) + } + traceACME(order) + + certPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + var privPEM bytes.Buffer + if err := encodeECDSAKey(&privPEM, certPrivKey); err != nil { + return nil, err + } + if err := ioutil.WriteFile(keyFile, privPEM.Bytes(), 0600); err != nil { + return nil, err + } + + csr, err := certRequest(certPrivKey, domain, nil) + if err != nil { + return nil, err + } + + der, _, err := ac.CreateOrderCert(ctx, order.FinalizeURL, csr, true) + if err != nil { + return nil, fmt.Errorf("CreateOrder: %v", err) + } + + var certPEM bytes.Buffer + for _, b := range der { + pb := &pem.Block{Type: "CERTIFICATE", Bytes: b} + if err := pem.Encode(&certPEM, pb); err != nil { + return nil, err + } + } + if err := ioutil.WriteFile(certFile, certPEM.Bytes(), 0644); err != nil { + return nil, err + } + + return &keyPair{certPEM: certPEM.Bytes(), keyPEM: privPEM.Bytes()}, nil +} + +// certRequest generates a CSR for the given common name cn and optional SANs. +func certRequest(key crypto.Signer, cn string, ext []pkix.Extension, san ...string) ([]byte, error) { + req := &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: cn}, + DNSNames: san, + ExtraExtensions: ext, + } + return x509.CreateCertificateRequest(rand.Reader, req, key) +} + +func encodeECDSAKey(w io.Writer, key *ecdsa.PrivateKey) error { + b, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + pb := &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} + return pem.Encode(w, pb) +} + +// parsePrivateKey is a copy of x/crypto/acme's parsePrivateKey. +// +// 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("acme/autocert: unknown private key type in PKCS#8 wrapping") + } + } + if key, err := x509.ParseECPrivateKey(der); err == nil { + return key, nil + } + + return nil, errors.New("acme/autocert: failed to parse private key") +} + +func acmeKey(dir string) (crypto.Signer, error) { + pemName := filepath.Join(dir, "acme-account.key.pem") + if v, err := ioutil.ReadFile(pemName); err == nil { + priv, _ := pem.Decode(v) + if priv == nil || !strings.Contains(priv.Type, "PRIVATE") { + return nil, errors.New("acme/autocert: invalid account key found in cache") + } + return parsePrivateKey(priv.Bytes) + } + + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + var pemBuf bytes.Buffer + if err := encodeECDSAKey(&pemBuf, privKey); err != nil { + return nil, err + } + if err := ioutil.WriteFile(pemName, pemBuf.Bytes(), 0600); err != nil { + return nil, err + } + return privKey, nil +} + +func validCertPEM(domain string, keyPEM, certPEM []byte, now time.Time) bool { + if len(keyPEM) == 0 || len(certPEM) == 0 { + return false + } + tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return false + } + var leaf *x509.Certificate + intermediates := x509.NewCertPool() + for i, certDER := range tlsCert.Certificate { + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return false + } + if i == 0 { + leaf = cert + } else { + intermediates.AddCert(cert) + } + } + if leaf == nil { + return false + } + _, err = leaf.Verify(x509.VerifyOptions{ + DNSName: domain, + CurrentTime: now, + Intermediates: intermediates, + }) + return err == nil +} + +func checkCertDomain(st *ipnstate.Status, domain string) error { + if domain == "" { + return errors.New("missing domain name") + } + for _, d := range st.CertDomains { + if d == domain { + return nil + } + } + // Transitional way while server doesn't yet populate CertDomains: also permit the client + // attempting Self.DNSName. + okay := st.CertDomains[:len(st.CertDomains):len(st.CertDomains)] + if st.Self != nil { + if v := strings.Trim(st.Self.DNSName, "."); v != "" { + if v == domain { + return nil + } + okay = append(okay, v) + } + } + switch len(okay) { + case 0: + return errors.New("your Tailscale account does not support getting TLS certs") + case 1: + return fmt.Errorf("invalid domain %q; only %q is permitted", domain, okay[0]) + default: + return fmt.Errorf("invalid domain %q; must be one of %q", domain, okay) + } +} diff --git a/ipn/localapi/disabled_stubs.go b/ipn/localapi/disabled_stubs.go new file mode 100644 index 000000000..3538b22e5 --- /dev/null +++ b/ipn/localapi/disabled_stubs.go @@ -0,0 +1,17 @@ +// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build ios || android +// +build ios android + +package localapi + +import ( + "net/http" + "runtime" +) + +func (h *Handler) serveCert(w http.ResponseWriter, r *http.Request) { + http.Error(w, "disabled on "+runtime.GOOS, http.StatusNotFound) +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 742c27926..76ff67732 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -57,6 +57,12 @@ type Handler struct { b *ipnlocal.LocalBackend logf logger.Logf backendLogID string + + // certMu guards all cert/ACME operations, so concurrent + // requests for certs don't slam ACME. The first will go + // through and populate the on-disk cache and the rest should + // use that. + certMu sync.Mutex } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -83,6 +89,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveFilePut(w, r) return } + if strings.HasPrefix(r.URL.Path, "/localapi/v0/cert/") { + h.serveCert(w, r) + return + } switch r.URL.Path { case "/localapi/v0/whois": h.serveWhoIs(w, r)