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

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 <[email protected]>

Address code review comments

Signed-off-by: Andrea Gottardo <[email protected]>

Address code review comments by Ross

Signed-off-by: Andrea Gottardo <[email protected]>

* Improve error encoding, fix logic error

Signed-off-by: Andrea Gottardo <[email protected]>

---------

Signed-off-by: Andrea Gottardo <[email protected]>
Andrea Gottardo 2 лет назад
Родитель
Сommit
99f17a7135
3 измененных файлов с 209 добавлено и 0 удалено
  1. 12 0
      ipn/ipnlocal/network-lock.go
  2. 30 0
      ipn/localapi/localapi.go
  3. 167 0
      tka/deeplink.go

+ 12 - 0
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 {

+ 30 - 0
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)

+ 167 - 0
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,
+	}
+}