From 99f17a7135647af6b7ecee2c1bbbbfaf8921a56e Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Tue, 13 Jun 2023 11:39:23 -0700 Subject: [PATCH] tka: provide verify-deeplink local API endpoint (#8303) * tka: provide verify-deeplink local API endpoint Fixes https://github.com/tailscale/tailscale/issues/8302 Signed-off-by: Andrea Gottardo Address code review comments Signed-off-by: Andrea Gottardo Address code review comments by Ross Signed-off-by: Andrea Gottardo * Improve error encoding, fix logic error Signed-off-by: Andrea Gottardo --------- Signed-off-by: Andrea Gottardo --- ipn/ipnlocal/network-lock.go | 12 +++ ipn/localapi/localapi.go | 30 +++++++ tka/deeplink.go | 167 +++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tka/deeplink.go diff --git a/ipn/ipnlocal/network-lock.go b/ipn/ipnlocal/network-lock.go index cbbff4f80..c661cc787 100644 --- a/ipn/ipnlocal/network-lock.go +++ b/ipn/ipnlocal/network-lock.go @@ -887,6 +887,18 @@ func (b *LocalBackend) NetworkLockWrapPreauthKey(preauthKey string, tkaKey key.N return fmt.Sprintf("%s--TL%s-%s", preauthKey, tkaSuffixEncoder.EncodeToString(sig.Serialize()), tkaSuffixEncoder.EncodeToString(priv)), nil } +// NetworkLockVerifySigningDeeplink asks the authority to verify the given deeplink +// URL. See the comment for ValidateDeeplink for details. +func (b *LocalBackend) NetworkLockVerifySigningDeeplink(url string) tka.DeeplinkValidationResult { + b.mu.Lock() + defer b.mu.Unlock() + if b.tka == nil { + return tka.DeeplinkValidationResult{IsValid: false, Error: errNetworkLockNotActive.Error()} + } + + return b.tka.authority.ValidateDeeplink(url) +} + func signNodeKey(nodeInfo tailcfg.TKASignInfo, signer key.NLPrivate) (*tka.NodeKeySignature, error) { p, err := nodeInfo.NodePublic.MarshalBinary() if err != nil { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index e57567ab5..d99dcad88 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -104,6 +104,7 @@ var handler = map[string]localAPIHandler{ "tka/force-local-disable": (*Handler).serveTKALocalDisable, "tka/affected-sigs": (*Handler).serveTKAAffectedSigs, "tka/wrap-preauth-key": (*Handler).serveTKAWrapPreauthKey, + "tka/verify-deeplink": (*Handler).serveTKAVerifySigningDeeplink, "upload-client-metrics": (*Handler).serveUploadClientMetrics, "watch-ipn-bus": (*Handler).serveWatchIPNBus, "whois": (*Handler).serveWhoIs, @@ -1610,6 +1611,35 @@ func (h *Handler) serveTKAWrapPreauthKey(w http.ResponseWriter, r *http.Request) w.Write([]byte(wrappedKey)) } +func (h *Handler) serveTKAVerifySigningDeeplink(w http.ResponseWriter, r *http.Request) { + if !h.PermitRead { + http.Error(w, "signing deeplink verification access denied", http.StatusForbidden) + return + } + if r.Method != httpm.POST { + http.Error(w, "use POST", http.StatusMethodNotAllowed) + return + } + + type verifyRequest struct { + URL string + } + var req verifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON for verifyRequest body", 400) + return + } + + res := h.b.NetworkLockVerifySigningDeeplink(req.URL) + j, err := json.MarshalIndent(res, "", "\t") + if err != nil { + http.Error(w, "JSON encoding error", 500) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(j) +} + func (h *Handler) serveTKADisable(w http.ResponseWriter, r *http.Request) { if !h.PermitWrite { http.Error(w, "network-lock modify access denied", http.StatusForbidden) diff --git a/tka/deeplink.go b/tka/deeplink.go new file mode 100644 index 000000000..7bd55d667 --- /dev/null +++ b/tka/deeplink.go @@ -0,0 +1,167 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package tka + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net/url" + "strings" +) + +const ( + DeeplinkTailscaleURLScheme = "tailscale" + DeeplinkCommandSign = "sign-device" +) + +type DeeplinkValidationResult struct { + IsValid bool + Error string + Version uint8 + NodeKey string + TLPub string + DeviceName string + OSName string + EmailAddress string +} + +// GenerateHMAC computes a SHA-256 HMAC for the concatenation of components, using +// stateID as secret. +func generateHMAC(stateID uint64, components []string) []byte { + key := make([]byte, 8) + binary.LittleEndian.PutUint64(key, stateID) + mac := hmac.New(sha256.New, key) + for _, component := range components { + mac.Write([]byte(component)) + } + return mac.Sum(nil) +} + +// ValidateDeeplink validates a device signing deeplink using the authority's stateID. +// The input urlString follows this structure: +// +// tailscale://sign-device/v1/?nk=xxx&tp=xxx&dn=xxx&os=xxx&em=xxx&hm=xxx +// +// where: +// - "nk" is the nodekey of the node being signed +// - "tp" is the tailnet lock public key +// - "dn" is the name of the node +// - "os" is the operating system of the node +// - "em" is the email address associated with the node +// - "hm" is a SHA-256 HMAC computed over the concatenation of the above fields, encoded as a hex string +func (a *Authority) ValidateDeeplink(urlString string) DeeplinkValidationResult { + parsedUrl, err := url.Parse(urlString) + if err != nil { + return DeeplinkValidationResult{ + IsValid: false, + Error: err.Error(), + } + } + + if parsedUrl.Scheme != DeeplinkTailscaleURLScheme { + return DeeplinkValidationResult{ + IsValid: false, + Error: fmt.Sprintf("unhandled scheme %s, expected %s", parsedUrl.Scheme, DeeplinkTailscaleURLScheme), + } + } + + if parsedUrl.Host != DeeplinkCommandSign { + return DeeplinkValidationResult{ + IsValid: false, + Error: fmt.Sprintf("unhandled host %s, expected %s", parsedUrl.Host, DeeplinkCommandSign), + } + } + + path := parsedUrl.EscapedPath() + pathComponents := strings.Split(path, "/") + if len(pathComponents) != 3 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "invalid path components number found", + } + } + + if pathComponents[1] != "v1" { + return DeeplinkValidationResult{ + IsValid: false, + Error: fmt.Sprintf("expected v1 deeplink version, found something else: %s", pathComponents[1]), + } + } + + nodeKey := parsedUrl.Query().Get("nk") + if len(nodeKey) == 0 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "missing nk (NodeKey) query parameter", + } + } + + tlPub := parsedUrl.Query().Get("tp") + if len(tlPub) == 0 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "missing tp (TLPub) query parameter", + } + } + + deviceName := parsedUrl.Query().Get("dn") + if len(deviceName) == 0 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "missing dn (DeviceName) query parameter", + } + } + + osName := parsedUrl.Query().Get("os") + if len(deviceName) == 0 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "missing os (OSName) query parameter", + } + } + + emailAddress := parsedUrl.Query().Get("em") + if len(emailAddress) == 0 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "missing em (EmailAddress) query parameter", + } + } + + hmacString := parsedUrl.Query().Get("hm") + if len(hmacString) == 0 { + return DeeplinkValidationResult{ + IsValid: false, + Error: "missing hm (HMAC) query parameter", + } + } + + components := []string{nodeKey, tlPub, deviceName, osName, emailAddress} + stateID1, _ := a.StateIDs() + computedHMAC := generateHMAC(stateID1, components) + + hmacHexBytes, err := hex.DecodeString(hmacString) + if err != nil { + return DeeplinkValidationResult{IsValid: false, Error: "could not hex-decode hmac"} + } + + if !hmac.Equal(computedHMAC, hmacHexBytes) { + return DeeplinkValidationResult{ + IsValid: false, + Error: "hmac authentication failed", + } + } + + return DeeplinkValidationResult{ + IsValid: true, + NodeKey: nodeKey, + TLPub: tlPub, + DeviceName: deviceName, + OSName: osName, + EmailAddress: emailAddress, + } +}