summaryrefslogtreecommitdiffhomepage
path: root/tka/deeplink.go
blob: 34f80be034bb0dc94c70a3c1a19a528cab2856cb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

//go:build !ts_omit_tailnetlock

package tka

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"fmt"
	"net/url"
	"strings"
)

const (
	DeeplinkTailscaleURLScheme = "tailscale"
	DeeplinkCommandSign        = "sign-device"
)

// generateHMAC computes a SHA-256 HMAC for the concatenation of components,
// using the Authority stateID as secret.
func (a *Authority) generateHMAC(params NewDeeplinkParams) []byte {
	stateID, _ := a.StateIDs()

	key := make([]byte, 8)
	binary.LittleEndian.PutUint64(key, stateID)
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(params.NodeKey))
	mac.Write([]byte(params.TLPub))
	mac.Write([]byte(params.DeviceName))
	mac.Write([]byte(params.OSName))
	mac.Write([]byte(params.LoginName))
	return mac.Sum(nil)
}

type NewDeeplinkParams struct {
	NodeKey    string
	TLPub      string
	DeviceName string
	OSName     string
	LoginName  string
}

// NewDeeplink creates a signed deeplink using the authority's stateID as a
// secret. This deeplink can then be validated by ValidateDeeplink.
func (a *Authority) NewDeeplink(params NewDeeplinkParams) (string, error) {
	if params.NodeKey == "" || !strings.HasPrefix(params.NodeKey, "nodekey:") {
		return "", fmt.Errorf("invalid node key %q", params.NodeKey)
	}
	if params.TLPub == "" || !strings.HasPrefix(params.TLPub, "tlpub:") {
		return "", fmt.Errorf("invalid tlpub %q", params.TLPub)
	}
	if params.DeviceName == "" {
		return "", fmt.Errorf("invalid device name %q", params.DeviceName)
	}
	if params.OSName == "" {
		return "", fmt.Errorf("invalid os name %q", params.OSName)
	}
	if params.LoginName == "" {
		return "", fmt.Errorf("invalid login name %q", params.LoginName)
	}

	u := url.URL{
		Scheme: DeeplinkTailscaleURLScheme,
		Host:   DeeplinkCommandSign,
		Path:   "/v1/",
	}
	v := url.Values{}
	v.Set("nk", params.NodeKey)
	v.Set("tp", params.TLPub)
	v.Set("dn", params.DeviceName)
	v.Set("os", params.OSName)
	v.Set("em", params.LoginName)

	hmac := a.generateHMAC(params)
	v.Set("hm", hex.EncodeToString(hmac))

	u.RawQuery = v.Encode()
	return u.String(), nil
}

type DeeplinkValidationResult struct {
	IsValid      bool
	Error        string
	Version      uint8
	NodeKey      string
	TLPub        string
	DeviceName   string
	OSName       string
	EmailAddress string
}

// 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",
		}
	}

	computedHMAC := a.generateHMAC(NewDeeplinkParams{
		NodeKey:    nodeKey,
		TLPub:      tlPub,
		DeviceName: deviceName,
		OSName:     osName,
		LoginName:  emailAddress,
	})

	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,
	}
}