From 04dd6d1daec46c635c4397086a03fe9934f404a0 Mon Sep 17 00:00:00 2001 From: Adrian Dewhurst Date: Fri, 26 Mar 2021 10:01:08 -0400 Subject: [PATCH] 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 --- cmd/tailscaled/depaware.txt | 4 +- control/controlclient/direct.go | 16 +++ control/controlclient/sign.go | 31 +++++ control/controlclient/sign_supported.go | 160 ++++++++++++++++++++++ control/controlclient/sign_unsupported.go | 17 +++ go.mod | 3 + go.sum | 4 + tailcfg/tailcfg.go | 64 +++++++++ 8 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 control/controlclient/sign.go create mode 100644 control/controlclient/sign_supported.go create mode 100644 control/controlclient/sign_unsupported.go diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index bfabd1935..cc9d54e12 100644 --- a/cmd/tailscaled/depaware.txt +++ b/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+ diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 5254e5d17..57631b72e 100644 --- a/control/controlclient/direct.go +++ b/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 diff --git a/control/controlclient/sign.go b/control/controlclient/sign.go new file mode 100644 index 000000000..83b35f6f7 --- /dev/null +++ b/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) +} diff --git a/control/controlclient/sign_supported.go b/control/controlclient/sign_supported.go new file mode 100644 index 000000000..9eadcafd0 --- /dev/null +++ b/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 +} diff --git a/control/controlclient/sign_unsupported.go b/control/controlclient/sign_unsupported.go new file mode 100644 index 000000000..e20ced316 --- /dev/null +++ b/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 +} diff --git a/go.mod b/go.mod index 05158381e..479431041 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 5562d64b6..1d2d82a4f 100644 --- a/go.sum +++ b/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= diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index cd1d159d9..30119b671 100644 --- a/tailcfg/tailcfg.go +++ b/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 }