From 227777154a19b7f9a67a24a018b31bed498a9fa8 Mon Sep 17 00:00:00 2001 From: Tom DNetto Date: Thu, 13 Oct 2022 10:43:00 -0700 Subject: [PATCH] control/controlclient,ipn/ipnlocal,tailcfg: rotate node-key signature on register CAPVER 47 Signed-off-by: Tom DNetto --- control/controlclient/direct.go | 146 ++++++++++++++++++++++---------- ipn/ipnlocal/local.go | 14 +-- tailcfg/tailcfg.go | 15 +++- tailcfg/tailcfg_clone.go | 2 + tailcfg/tailcfg_view.go | 14 +-- 5 files changed, 134 insertions(+), 57 deletions(-) diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index c506f05cf..bdc09d4eb 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -43,11 +43,13 @@ "tailscale.com/net/tshttpproxy" "tailscale.com/syncs" "tailscale.com/tailcfg" + "tailscale.com/tka" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/netmap" "tailscale.com/types/opt" "tailscale.com/types/persist" + "tailscale.com/types/tkatype" "tailscale.com/util/clientmetric" "tailscale.com/util/multierr" "tailscale.com/util/singleflight" @@ -68,7 +70,7 @@ type Direct struct { linkMon *monitor.Mon // or nil discoPubKey key.DiscoPublic getMachinePrivKey func() (key.MachinePrivate, error) - getNLPublicKey func() (key.NLPublic, error) // or nil + getNLPrivateKey func() (key.NLPrivate, error) // or nil debugFlags []string keepSharerAndUserSplit bool skipIPForwardingCheck bool @@ -115,9 +117,9 @@ type Options struct { Dialer *tsdial.Dialer // non-nil C2NHandler http.Handler // or nil - // GetNLPublicKey specifies an optional function to use + // GetNLPrivateKey specifies an optional function to use // Network Lock. If nil, it's not used. - GetNLPublicKey func() (key.NLPublic, error) + GetNLPrivateKey func() (key.NLPrivate, error) // Status is called when there's a change in status. Status func(Status) @@ -229,7 +231,7 @@ func NewDirect(opts Options) (*Direct, error) { c := &Direct{ httpc: httpc, getMachinePrivKey: opts.GetMachinePrivateKey, - getNLPublicKey: opts.GetNLPublicKey, + getNLPrivateKey: opts.GetNLPrivateKey, serverURL: opts.ServerURL, timeNow: opts.TimeNow, logf: opts.Logf, @@ -324,7 +326,7 @@ func (c *Direct) GetPersist() persist.Persist { func (c *Direct) TryLogout(ctx context.Context) error { c.logf("[v1] direct.TryLogout()") - mustRegen, newURL, err := c.doLogin(ctx, loginOpt{Logout: true}) + mustRegen, newURL, _, err := c.doLogin(ctx, loginOpt{Logout: true}) c.logf("[v1] TryLogout control response: mustRegen=%v, newURL=%v, err=%v", mustRegen, newURL, err) c.mu.Lock() @@ -348,13 +350,14 @@ func (c *Direct) WaitLoginURL(ctx context.Context, url string) (newURL string, e } func (c *Direct) doLoginOrRegen(ctx context.Context, opt loginOpt) (newURL string, err error) { - mustRegen, url, err := c.doLogin(ctx, opt) + mustRegen, url, oldNodeKeySignature, err := c.doLogin(ctx, opt) if err != nil { return url, err } if mustRegen { opt.Regen = true - _, url, err = c.doLogin(ctx, opt) + opt.OldNodeKeySignature = oldNodeKeySignature + _, url, _, err = c.doLogin(ctx, opt) } return url, err } @@ -380,6 +383,10 @@ type loginOpt struct { // It is ignored if Logout is set since Logout works by setting a // expiry time in the far past. Expiry *time.Time + + // OldNodeKeySignature indicates the former NodeKeySignature + // that must be resigned for the new node-key. + OldNodeKeySignature tkatype.MarshaledSignature } // httpClient provides a common interface for the noiseClient and @@ -396,7 +403,7 @@ func (c *Direct) hostInfoLocked() *tailcfg.Hostinfo { return hi } -func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, err error) { +func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, nks tkatype.MarshaledSignature, err error) { c.mu.Lock() persist := c.persist tryingNewKey := c.tryingNewKey @@ -410,10 +417,10 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new machinePrivKey, err := c.getMachinePrivKey() if err != nil { - return false, "", fmt.Errorf("getMachinePrivKey: %w", err) + return false, "", nil, fmt.Errorf("getMachinePrivKey: %w", err) } if machinePrivKey.IsZero() { - return false, "", errors.New("getMachinePrivKey returned zero key") + return false, "", nil, errors.New("getMachinePrivKey returned zero key") } regen := opt.Regen @@ -435,7 +442,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new if serverKey.IsZero() { keys, err := loadServerPubKeys(ctx, c.httpc, c.serverURL) if err != nil { - return regen, opt.URL, err + return regen, opt.URL, nil, err } c.logf("control server key from %s: ts2021=%s, legacy=%v", c.serverURL, keys.PublicKey.ShortString(), keys.LegacyPublicKey.ShortString()) @@ -472,43 +479,53 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new oldNodeKey = persist.OldPrivateNodeKey.Public() } - var nlPub key.NLPublic - if c.getNLPublicKey != nil { - nlPub, err = c.getNLPublicKey() - if err != nil { - return false, "", fmt.Errorf("get nl key: %v", err) - } - } - if tryingNewKey.IsZero() { if opt.Logout { - return false, "", errors.New("no nodekey to log out") + return false, "", nil, errors.New("no nodekey to log out") } log.Fatalf("tryingNewKey is empty, give up") } + + var nlPub key.NLPublic + var nodeKeySignature tkatype.MarshaledSignature + if c.getNLPrivateKey != nil { + priv, err := c.getNLPrivateKey() + if err != nil { + return false, "", nil, fmt.Errorf("get nl key: %v", err) + } + nlPub = priv.Public() + + if !oldNodeKey.IsZero() && opt.OldNodeKeySignature != nil { + if nodeKeySignature, err = resignNKS(priv, tryingNewKey.Public(), opt.OldNodeKeySignature); err != nil { + c.logf("Failed re-signing node-key signature: %v", err) + } + } + } + if backendLogID == "" { err = errors.New("hostinfo: BackendLogID missing") - return regen, opt.URL, err + return regen, opt.URL, nil, err } now := time.Now().Round(time.Second) request := tailcfg.RegisterRequest{ - Version: 1, - OldNodeKey: oldNodeKey, - NodeKey: tryingNewKey.Public(), - NLKey: nlPub, - Hostinfo: hi, - Followup: opt.URL, - Timestamp: &now, - Ephemeral: (opt.Flags & LoginEphemeral) != 0, + Version: 1, + OldNodeKey: oldNodeKey, + NodeKey: tryingNewKey.Public(), + NLKey: nlPub, + Hostinfo: hi, + Followup: opt.URL, + Timestamp: &now, + Ephemeral: (opt.Flags & LoginEphemeral) != 0, + NodeKeySignature: nodeKeySignature, } if opt.Logout { request.Expiry = time.Unix(123, 0) // far in the past } else if opt.Expiry != nil { request.Expiry = *opt.Expiry } - c.logf("RegisterReq: onode=%v node=%v fup=%v", + c.logf("RegisterReq: onode=%v node=%v fup=%v nks=%v", request.OldNodeKey.ShortString(), - request.NodeKey.ShortString(), opt.URL != "") + request.NodeKey.ShortString(), opt.URL != "", len(nodeKeySignature) > 0) request.Auth.Oauth2Token = opt.Token request.Auth.Provider = persist.Provider request.Auth.LoginName = persist.LoginName @@ -542,33 +559,33 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new request.Version = tailcfg.CurrentCapabilityVersion httpc, err = c.getNoiseClient() if err != nil { - return regen, opt.URL, fmt.Errorf("getNoiseClient: %w", err) + return regen, opt.URL, nil, fmt.Errorf("getNoiseClient: %w", err) } url = fmt.Sprintf("%s/machine/register", c.serverURL) url = strings.Replace(url, "http:", "https:", 1) } bodyData, err := encode(request, serverKey, serverNoiseKey, machinePrivKey) if err != nil { - return regen, opt.URL, err + return regen, opt.URL, nil, err } req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyData)) if err != nil { - return regen, opt.URL, err + return regen, opt.URL, nil, err } res, err := httpc.Do(req) if err != nil { - return regen, opt.URL, fmt.Errorf("register request: %w", err) + return regen, opt.URL, nil, fmt.Errorf("register request: %w", err) } if res.StatusCode != 200 { msg, _ := io.ReadAll(res.Body) res.Body.Close() - return regen, opt.URL, fmt.Errorf("register request: http %d: %.200s", + return regen, opt.URL, nil, fmt.Errorf("register request: http %d: %.200s", res.StatusCode, strings.TrimSpace(string(msg))) } resp := tailcfg.RegisterResponse{} if err := decode(res, &resp, serverKey, serverNoiseKey, machinePrivKey); err != nil { c.logf("error decoding RegisterResponse with server key %s and machine key %s: %v", serverKey, machinePrivKey.Public(), err) - return regen, opt.URL, fmt.Errorf("register request: %v", err) + return regen, opt.URL, nil, fmt.Errorf("register request: %v", err) } if debugRegister() { j, _ := json.MarshalIndent(resp, "", "\t") @@ -580,15 +597,19 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new resp.NodeKeyExpired, resp.MachineAuthorized, resp.AuthURL != "") if resp.Error != "" { - return false, "", UserVisibleError(resp.Error) + return false, "", nil, UserVisibleError(resp.Error) } + if len(resp.NodeKeySignature) > 0 { + return true, "", resp.NodeKeySignature, nil + } + if resp.NodeKeyExpired { if regen { - return true, "", fmt.Errorf("weird: regen=true but server says NodeKeyExpired: %v", request.NodeKey) + return true, "", nil, fmt.Errorf("weird: regen=true but server says NodeKeyExpired: %v", request.NodeKey) } c.logf("server reports new node key %v has expired", request.NodeKey.ShortString()) - return true, "", nil + return true, "", nil, nil } if resp.Login.Provider != "" { persist.Provider = resp.Login.Provider @@ -621,12 +642,51 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new c.mu.Unlock() if err != nil { - return regen, "", err + return regen, "", nil, err } if ctx.Err() != nil { - return regen, "", ctx.Err() + return regen, "", nil, ctx.Err() } - return false, resp.AuthURL, nil + return false, resp.AuthURL, nil, nil +} + +// resignNKS re-signs a node-key signature for a new node-key. +// +// This only matters on network-locked tailnets, because node-key signatures are +// how other nodes know that a node-key is authentic. When the node-key is +// rotated then the existing signature becomes invalid, so this function is +// responsible for generating a new wrapping signature to certify the new node-key. +// +// The signature itself is a SigRotation signature, which embeds the old signature +// and certifies the new node-key as a replacement for the old by signing the new +// signature with RotationPubkey (which is the node's own network-lock key). +func resignNKS(priv key.NLPrivate, nodeKey key.NodePublic, oldNKS tkatype.MarshaledSignature) (tkatype.MarshaledSignature, error) { + var oldSig tka.NodeKeySignature + if err := oldSig.Unserialize(oldNKS); err != nil { + return nil, fmt.Errorf("decoding NKS: %w", err) + } + + nk, err := nodeKey.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("marshalling node-key: %w", err) + } + + if bytes.Equal(nk, oldSig.Pubkey) { + // The old signature is valid for the node-key we are using, so just + // use it verbatim. + return oldNKS, nil + } + + newSig := tka.NodeKeySignature{ + SigKind: tka.SigRotation, + Pubkey: nk, + Nested: &oldSig, + } + if newSig.Signature, err = priv.SignNKS(newSig.SigHash()); err != nil { + return nil, fmt.Errorf("signing NKS: %w", err) + } + + return newSig.Serialize(), nil } func sameEndpoints(a, b []tailcfg.Endpoint) bool { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 83ae81908..416a1a1fb 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1164,7 +1164,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error { // but it won't take effect until the next Start(). cc, err := b.getNewControlClientFunc()(controlclient.Options{ GetMachinePrivateKey: b.createGetMachinePrivateKeyFunc(), - GetNLPublicKey: b.createGetNLPublicKeyFunc(), + GetNLPrivateKey: b.createGetNLPrivateKeyFunc(), Logf: logger.WithPrefix(b.logf, "control: "), Persist: *persistv, ServerURL: b.serverURL, @@ -1634,18 +1634,18 @@ func (b *LocalBackend) createGetMachinePrivateKeyFunc() func() (key.MachinePriva } } -func (b *LocalBackend) createGetNLPublicKeyFunc() func() (key.NLPublic, error) { - var cache syncs.AtomicValue[key.NLPublic] - return func() (key.NLPublic, error) { +func (b *LocalBackend) createGetNLPrivateKeyFunc() func() (key.NLPrivate, error) { + var cache syncs.AtomicValue[key.NLPrivate] + return func() (key.NLPrivate, error) { b.mu.Lock() defer b.mu.Unlock() if v, ok := cache.LoadOk(); ok { return v, nil } - pub := b.nlPrivKey.Public() - cache.Store(pub) - return pub, nil + priv := b.nlPrivKey + cache.Store(priv) + return priv, nil } } diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index ab254a57e..cd7fef896 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -83,7 +83,8 @@ // - 44: 2022-09-22: MapResponse.ControlDialPlan // - 45: 2022-09-26: c2n /debug/{goroutines,prefs,metrics} // - 46: 2022-10-04: c2n /debug/component-logging -const CurrentCapabilityVersion CapabilityVersion = 46 +// - 47: 2022-10-11: Register{Request,Response}.NodeKeySignature +const CurrentCapabilityVersion CapabilityVersion = 47 type StableID string @@ -827,6 +828,13 @@ type RegisterRequest struct { // when it stops being active. Ephemeral bool `json:",omitempty"` + // NodeKeySignature is the node's own node-key signature, re-signed + // for its new node key using its network-lock key. + // + // This field is set when the client retries registration after learning + // its NodeKeySignature (which is in need of rotation). + NodeKeySignature tkatype.MarshaledSignature + // The following fields are not used for SignatureNone and are required for // SignatureV1: SignatureType SignatureType `json:",omitempty"` @@ -854,6 +862,7 @@ func (req *RegisterRequest) Clone() *RegisterRequest { } res.DeviceCert = append(res.DeviceCert[:0:0], res.DeviceCert...) res.Signature = append(res.Signature[:0:0], res.Signature...) + res.NodeKeySignature = append(res.NodeKeySignature[:0:0], res.NodeKeySignature...) return res } @@ -865,6 +874,10 @@ type RegisterResponse struct { MachineAuthorized bool // TODO(crawshaw): move to using MachineStatus AuthURL string // if set, authorization pending + // If set, this is the current node-key signature that needs to be + // re-signed for the node's new node-key. + NodeKeySignature tkatype.MarshaledSignature + // Error indicates that authorization failed. If this is non-empty, // other status fields should be ignored. Error string diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index daec87fc9..1be2908c3 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -254,6 +254,7 @@ func (src *RegisterResponse) Clone() *RegisterResponse { dst := new(RegisterResponse) *dst = *src dst.User = *src.User.Clone() + dst.NodeKeySignature = append(src.NodeKeySignature[:0:0], src.NodeKeySignature...) return dst } @@ -264,6 +265,7 @@ func (src *RegisterResponse) Clone() *RegisterResponse { NodeKeyExpired bool MachineAuthorized bool AuthURL string + NodeKeySignature tkatype.MarshaledSignature Error string }{}) diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 30da1d02b..dc6b4df7e 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -581,12 +581,13 @@ func (v *RegisterResponseView) UnmarshalJSON(b []byte) error { return nil } -func (v RegisterResponseView) User() UserView { return v.ж.User.View() } -func (v RegisterResponseView) Login() Login { return v.ж.Login } -func (v RegisterResponseView) NodeKeyExpired() bool { return v.ж.NodeKeyExpired } -func (v RegisterResponseView) MachineAuthorized() bool { return v.ж.MachineAuthorized } -func (v RegisterResponseView) AuthURL() string { return v.ж.AuthURL } -func (v RegisterResponseView) Error() string { return v.ж.Error } +func (v RegisterResponseView) User() UserView { return v.ж.User.View() } +func (v RegisterResponseView) Login() Login { return v.ж.Login } +func (v RegisterResponseView) NodeKeyExpired() bool { return v.ж.NodeKeyExpired } +func (v RegisterResponseView) MachineAuthorized() bool { return v.ж.MachineAuthorized } +func (v RegisterResponseView) AuthURL() string { return v.ж.AuthURL } +func (v RegisterResponseView) NodeKeySignature() mem.RO { return mem.B(v.ж.NodeKeySignature) } +func (v RegisterResponseView) Error() string { return v.ж.Error } // A compilation failure here means this code must be regenerated, with the command at the top of this file. var _RegisterResponseViewNeedsRegeneration = RegisterResponse(struct { @@ -595,6 +596,7 @@ func (v RegisterResponseView) Error() string { return v.ж.Error } NodeKeyExpired bool MachineAuthorized bool AuthURL string + NodeKeySignature tkatype.MarshaledSignature Error string }{})