Просмотр исходного кода

control/controlclient: sign RegisterRequest (#1549)

control/controlclient: sign RegisterRequest

Some customers wish to verify eligibility for devices to join their
tailnets using machine identity certificates. TLS client certs could
potentially fulfill this role but the initial customer for this feature
has technical requirements that prevent their use. Instead, the
certificate is loaded from the Windows local machine certificate store
and uses its RSA public key to sign the RegisterRequest message.

There is room to improve the flexibility of this feature in future and
it is currently only tested on Windows (although Darwin theoretically
works too), but this offers a reasonable starting place for now.

Updates tailscale/coral#6

Signed-off-by: Adrian Dewhurst <[email protected]>
Adrian Dewhurst 5 лет назад
Родитель
Сommit
04dd6d1dae

+ 3 - 1
cmd/tailscaled/depaware.txt

@@ -3,6 +3,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
    W 💣 github.com/alexbrainman/sspi                                 from github.com/alexbrainman/sspi/negotiate
    W 💣 github.com/alexbrainman/sspi/negotiate                       from tailscale.com/net/tshttpproxy
    L    github.com/coreos/go-iptables/iptables                       from tailscale.com/wgengine/router
+   W 💣 github.com/github/certstore                                  from tailscale.com/control/controlclient
         github.com/go-multierror/multierror                          from tailscale.com/wgengine/router+
    W 💣 github.com/go-ole/go-ole                                     from github.com/go-ole/go-ole/oleutil+
    W 💣 github.com/go-ole/go-ole/oleutil                             from tailscale.com/wgengine/winnet
@@ -19,6 +20,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
    L 💣 github.com/mdlayher/netlink                                  from github.com/jsimonetti/rtnetlink+
    L 💣 github.com/mdlayher/netlink/nlenc                            from github.com/jsimonetti/rtnetlink+
    L    github.com/mdlayher/sdnotify                                 from tailscale.com/util/systemd
+   W    github.com/pkg/errors                                        from github.com/github/certstore
      💣 github.com/tailscale/wireguard-go/conn                       from github.com/tailscale/wireguard-go/device+
      💣 github.com/tailscale/wireguard-go/device                     from tailscale.com/wgengine+
      💣 github.com/tailscale/wireguard-go/ipc                        from github.com/tailscale/wireguard-go/device
@@ -132,7 +134,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
         tailscale.com/util/pidowner                                  from tailscale.com/ipn/ipnserver
         tailscale.com/util/racebuild                                 from tailscale.com/logpolicy
         tailscale.com/util/systemd                                   from tailscale.com/control/controlclient+
-        tailscale.com/util/winutil                                   from tailscale.com/logpolicy
+        tailscale.com/util/winutil                                   from tailscale.com/logpolicy+
         tailscale.com/version                                        from tailscale.com/cmd/tailscaled+
         tailscale.com/version/distro                                 from tailscale.com/control/controlclient+
         tailscale.com/wgengine                                       from tailscale.com/cmd/tailscaled+

+ 16 - 0
control/controlclient/direct.go

@@ -351,12 +351,14 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
 		err = errors.New("hostinfo: BackendLogID missing")
 		return regen, url, err
 	}
+	now := time.Now().Round(time.Second)
 	request := tailcfg.RegisterRequest{
 		Version:    1,
 		OldNodeKey: tailcfg.NodeKey(oldNodeKey),
 		NodeKey:    tailcfg.NodeKey(tryingNewKey.Public()),
 		Hostinfo:   hostinfo,
 		Followup:   url,
+		Timestamp:  &now,
 	}
 	c.logf("RegisterReq: onode=%v node=%v fup=%v",
 		request.OldNodeKey.ShortString(),
@@ -365,6 +367,20 @@ func (c *Direct) doLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags Logi
 	request.Auth.Provider = persist.Provider
 	request.Auth.LoginName = persist.LoginName
 	request.Auth.AuthKey = authKey
+	err = signRegisterRequest(&request, c.serverURL, c.serverKey, c.machinePrivKey.Public())
+	if err != nil {
+		// If signing failed, clear all related fields
+		request.SignatureType = tailcfg.SignatureNone
+		request.Timestamp = nil
+		request.DeviceCert = nil
+		request.Signature = nil
+
+		// Don't log the common error types. Signatures are not usually enabled,
+		// so these are expected.
+		if err != errCertificateNotConfigured && err != errNoCertStore {
+			c.logf("RegisterReq sign error: %v", err)
+		}
+	}
 	bodyData, err := encode(request, &serverKey, &c.machinePrivKey)
 	if err != nil {
 		return regen, url, err

+ 31 - 0
control/controlclient/sign.go

@@ -0,0 +1,31 @@
+// Copyright (c) 2020 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.
+
+package controlclient
+
+import (
+	"crypto"
+	"errors"
+	"fmt"
+	"time"
+
+	"tailscale.com/types/wgkey"
+)
+
+var (
+	errNoCertStore              = errors.New("no certificate store")
+	errCertificateNotConfigured = errors.New("no certificate subject configured")
+)
+
+// HashRegisterRequest generates the hash required sign or verify a
+// tailcfg.RegisterRequest with tailcfg.SignatureV1.
+func HashRegisterRequest(ts time.Time, serverURL string, deviceCert []byte, serverPubKey, machinePubKey wgkey.Key) []byte {
+	h := crypto.SHA256.New()
+
+	// hash.Hash.Write never returns an error, so we don't check for one here.
+	fmt.Fprintf(h, "%s%s%s%s%s",
+		ts.UTC().Format(time.RFC3339), serverURL, deviceCert, serverPubKey, machinePubKey)
+
+	return h.Sum(nil)
+}

+ 160 - 0
control/controlclient/sign_supported.go

@@ -0,0 +1,160 @@
+// Copyright (c) 2020 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.
+
+// +build windows,cgo
+
+// darwin,cgo is also supported by certstore but machineCertificateSubject will
+// need to be loaded by a different mechanism, so this is not currently enabled
+// on darwin.
+
+package controlclient
+
+import (
+	"crypto"
+	"crypto/rsa"
+	"crypto/x509"
+	"errors"
+	"fmt"
+	"sync"
+
+	"github.com/github/certstore"
+	"tailscale.com/tailcfg"
+	"tailscale.com/types/wgkey"
+	"tailscale.com/util/winutil"
+)
+
+var getMachineCertificateSubjectOnce struct {
+	sync.Once
+	v string // Subject of machine certificate to search for
+}
+
+// getMachineCertificateSubject returns the exact name of a Subject that needs
+// to be present in an identity's certificate chain to sign a RegisterRequest,
+// formatted as per pkix.Name.String(). The Subject may be that of the identity
+// itself, an intermediate CA or the root CA.
+//
+// If getMachineCertificateSubject() returns "" then no lookup will occur and
+// each RegisterRequest will be unsigned.
+//
+// Example: "CN=Tailscale Inc Test Root CA,OU=Tailscale Inc Test Certificate Authority,O=Tailscale Inc,ST=ON,C=CA"
+func getMachineCertificateSubject() string {
+	getMachineCertificateSubjectOnce.Do(func() {
+		getMachineCertificateSubjectOnce.v = winutil.GetRegString("MachineCertificateSubject", "")
+	})
+
+	return getMachineCertificateSubjectOnce.v
+}
+
+var (
+	errNoMatch    = errors.New("no matching certificate")
+	errBadRequest = errors.New("malformed request")
+)
+
+// findIdentity locates an identity from the Windows or Darwin certificate
+// store. It returns the first certificate with a matching Subject anywhere in
+// its certificate chain, so it is possible to search for the leaf certificate,
+// intermediate CA or root CA. If err is nil then the returned identity will
+// never be nil (if no identity is found, the error errNoMatch will be
+// returned). If an identity is returned then its certificate chain is also
+// returned.
+func findIdentity(subject string, st certstore.Store) (certstore.Identity, []*x509.Certificate, error) {
+	ids, err := st.Identities()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var selected certstore.Identity
+	var chain []*x509.Certificate
+
+	for _, id := range ids {
+		chain, err = id.CertificateChain()
+		if err != nil {
+			continue
+		}
+
+		if chain[0].PublicKeyAlgorithm != x509.RSA {
+			continue
+		}
+
+		for _, c := range chain {
+			if c.Subject.String() == subject {
+				selected = id
+				break
+			}
+		}
+	}
+
+	for _, id := range ids {
+		if id != selected {
+			id.Close()
+		}
+	}
+
+	if selected == nil {
+		return nil, nil, errNoMatch
+	}
+
+	return selected, chain, nil
+}
+
+// signRegisterRequest looks for a suitable machine identity from the local
+// system certificate store, and if one is found, signs the RegisterRequest
+// using that identity's public key. In addition to the signature, the full
+// certificate chain is included so that the control server can validate the
+// certificate from a copy of the root CA's certificate.
+func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey wgkey.Key) (err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("signRegisterRequest: %w", err)
+		}
+	}()
+
+	if req.Timestamp == nil {
+		return errBadRequest
+	}
+
+	machineCertificateSubject := getMachineCertificateSubject()
+	if machineCertificateSubject == "" {
+		return errCertificateNotConfigured
+	}
+
+	st, err := certstore.Open(certstore.System)
+	if err != nil {
+		return fmt.Errorf("open cert store: %w", err)
+	}
+	defer st.Close()
+
+	id, chain, err := findIdentity(machineCertificateSubject, st)
+	if err != nil {
+		return fmt.Errorf("find identity: %w", err)
+	}
+	defer id.Close()
+
+	signer, err := id.Signer()
+	if err != nil {
+		return fmt.Errorf("create signer: %w", err)
+	}
+
+	cl := 0
+	for _, c := range chain {
+		cl += len(c.Raw)
+	}
+	req.DeviceCert = make([]byte, 0, cl)
+	for _, c := range chain {
+		req.DeviceCert = append(req.DeviceCert, c.Raw...)
+	}
+
+	h := HashRegisterRequest(req.Timestamp.UTC(), serverURL, req.DeviceCert, serverPubKey, machinePubKey)
+
+	req.Signature, err = signer.Sign(nil, h, &rsa.PSSOptions{
+		SaltLength: rsa.PSSSaltLengthEqualsHash,
+		Hash:       crypto.SHA256,
+	})
+	if err != nil {
+		return fmt.Errorf("sign: %w", err)
+	}
+	req.SignatureType = tailcfg.SignatureV1
+
+	return nil
+}

+ 17 - 0
control/controlclient/sign_unsupported.go

@@ -0,0 +1,17 @@
+// Copyright (c) 2020 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.
+
+// +build !windows !cgo
+
+package controlclient
+
+import (
+	"tailscale.com/tailcfg"
+	"tailscale.com/types/wgkey"
+)
+
+// signRegisterRequest on non-supported platforms always returns errNoCertStore.
+func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey wgkey.Key) error {
+	return errNoCertStore
+}

+ 3 - 0
go.mod

@@ -7,6 +7,7 @@ require (
 	github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 // indirect
 	github.com/coreos/go-iptables v0.4.5
 	github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
+	github.com/github/certstore v0.1.0
 	github.com/gliderlabs/ssh v0.2.2
 	github.com/go-multierror/multierror v1.0.2
 	github.com/go-ole/go-ole v1.2.4
@@ -42,3 +43,5 @@ require (
 	inet.af/peercred v0.0.0-20210302202138-56e694897155
 	rsc.io/goversion v1.2.0
 )
+
+replace github.com/github/certstore => github.com/cyolosecurity/certstore v0.0.0-20200922073901-ece7f1d353c2

+ 4 - 0
go.sum

@@ -17,12 +17,16 @@ github.com/coreos/go-iptables v0.4.5 h1:DpHb9vJrZQEFMcVLFKAAGMUVX0XoRC0ptCthinRY
 github.com/coreos/go-iptables v0.4.5/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmekav8Dbxlm1MU=
 github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A=
 github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
+github.com/cyolosecurity/certstore v0.0.0-20200922073901-ece7f1d353c2 h1:TGPWAij+nY2FB7TlyUTqTmYvXJon/AZAfRMYc/76K80=
+github.com/cyolosecurity/certstore v0.0.0-20200922073901-ece7f1d353c2/go.mod h1:Sgb3YVYOB2iCO06NJ6We5gjXe7uxxM3zPYoEXjuTKno=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dvyukov/go-fuzz v0.0.0-20201127111758-49e582c6c23d/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
+github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
+github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
 github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
 github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
 github.com/go-multierror/multierror v1.0.2 h1:AwsKbEXkmf49ajdFJgcFXqSG0aLo0HEyAE9zk9JguJo=

+ 64 - 0
tailcfg/tailcfg.go

@@ -539,6 +539,61 @@ func (h *Hostinfo) Equal(h2 *Hostinfo) bool {
 	return reflect.DeepEqual(h, h2)
 }
 
+// SignatureType specifies a scheme for signing RegisterRequest messages. It
+// specifies the crypto algorithms to use, the contents of what is signed, and
+// any other relevant details. Historically, requests were unsigned so the zero
+// value is SignatureNone.
+type SignatureType int
+
+const (
+	// SignatureNone indicates that there is no signature, no Timestamp is
+	// required (but may be specified if desired), and both DeviceCert and
+	// Signature should be empty.
+	SignatureNone = SignatureType(iota)
+	// SignatureUnknown represents an unknown signature scheme, which should
+	// be considered an error if seen.
+	SignatureUnknown
+	// SignatureV1 is computed as RSA-PSS-Sign(privateKeyForDeviceCert,
+	// SHA256(Timestamp || ServerIdentity || DeviceCert || ServerPubKey ||
+	// MachinePubKey)). The PSS salt length is equal to hash length
+	// (rsa.PSSSaltLengthEqualsHash). Device cert is required.
+	SignatureV1
+)
+
+func (st SignatureType) MarshalText() ([]byte, error) {
+	return []byte(st.String()), nil
+}
+
+func (st *SignatureType) UnmarshalText(b []byte) error {
+	switch string(b) {
+	case "signature-none":
+		*st = SignatureNone
+	case "signature-v1":
+		*st = SignatureV1
+	default:
+		var val int
+		if _, err := fmt.Sscanf(string(b), "signature-unknown(%d)", &val); err != nil {
+			*st = SignatureType(val)
+		} else {
+			*st = SignatureUnknown
+		}
+	}
+	return nil
+}
+
+func (st SignatureType) String() string {
+	switch st {
+	case SignatureNone:
+		return "signature-none"
+	case SignatureUnknown:
+		return "signature-unknown"
+	case SignatureV1:
+		return "signature-v1"
+	default:
+		return fmt.Sprintf("signature-unknown(%d)", int(st))
+	}
+}
+
 // RegisterRequest is sent by a client to register the key for a node.
 // It is encoded to JSON, encrypted with golang.org/x/crypto/nacl/box,
 // using the local machine key, and sent to:
@@ -558,6 +613,13 @@ type RegisterRequest struct {
 	Expiry   time.Time // requested key expiry, server policy may override
 	Followup string    // response waits until AuthURL is visited
 	Hostinfo *Hostinfo
+
+	// The following fields are not used for SignatureNone and are required for
+	// SignatureV1:
+	SignatureType SignatureType `json:",omitempty"`
+	Timestamp     *time.Time    `json:",omitempty"` // creation time of request to prevent replay
+	DeviceCert    []byte        `json:",omitempty"` // X.509 certificate for client device
+	Signature     []byte        `json:",omitempty"` // as described by SignatureType
 }
 
 // Clone makes a deep copy of RegisterRequest.
@@ -574,6 +636,8 @@ func (req *RegisterRequest) Clone() *RegisterRequest {
 		tok := *res.Auth.Oauth2Token
 		res.Auth.Oauth2Token = &tok
 	}
+	res.DeviceCert = append(res.DeviceCert[:0:0], res.DeviceCert...)
+	res.Signature = append(res.Signature[:0:0], res.Signature...)
 	return res
 }