pairling 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +11 -9
  2. package/bin/pairling.mjs +5 -2
  3. package/package.json +3 -3
  4. package/payload/mac/SOURCE_BRANCH +1 -1
  5. package/payload/mac/SOURCE_REVISION +1 -1
  6. package/payload/mac/VERSION +1 -1
  7. package/payload/mac/companiond/pairling_connectd_status.py +57 -7
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +67 -20
  10. package/payload/mac/companiond/pairlingd.py +269 -16
  11. package/payload/mac/companiond/push_dispatcher.py +31 -1
  12. package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
  13. package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
  14. package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
  15. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
  16. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
  17. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
  18. package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
  19. package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
  20. package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
  21. package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
  22. package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
  23. package/payload/mac/connectd/internal/runtime/config.go +19 -0
  24. package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
  25. package/payload/mac/connectd/internal/status/status.go +67 -1
  26. package/payload/mac/connectd/internal/status/status_test.go +138 -0
  27. package/payload/mac/install/install-runtime.sh +299 -20
  28. package/payload/mac/install/render-launchd.py +54 -10
  29. package/payload-manifest.json +63 -21
@@ -0,0 +1,894 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "net"
10
+ "net/http"
11
+ "net/http/httptest"
12
+ "os"
13
+ "path/filepath"
14
+ "strings"
15
+ "testing"
16
+ "time"
17
+ )
18
+
19
+ func TestMintProducesTaggedPreauthSingleUseShortKey(t *testing.T) {
20
+ var keyRequest map[string]any
21
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
+ switch r.URL.Path {
23
+ case "/oauth/token":
24
+ _ = r.ParseForm()
25
+ w.Header().Set("Content-Type", "application/json")
26
+ _ = json.NewEncoder(w).Encode(map[string]any{
27
+ "access_token": "test-token",
28
+ "token_type": "Bearer",
29
+ "expires_in": 3600,
30
+ })
31
+ case "/api/v2/tailnet/-/keys":
32
+ if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
33
+ t.Fatalf("Authorization header = %q", got)
34
+ }
35
+ if err := json.NewDecoder(r.Body).Decode(&keyRequest); err != nil {
36
+ t.Fatalf("decode key request: %v", err)
37
+ }
38
+ _ = json.NewEncoder(w).Encode(map[string]any{
39
+ "id": "k-test",
40
+ "key": "tskey-auth-test",
41
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
42
+ })
43
+ default:
44
+ t.Fatalf("unexpected path %s", r.URL.Path)
45
+ }
46
+ }))
47
+ defer api.Close()
48
+
49
+ dir := t.TempDir()
50
+ b, err := NewBroker(BrokerConfig{
51
+ SecretPath: filepath.Join(dir, "client_secret.json"),
52
+ StatePath: filepath.Join(dir, "state.json"),
53
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
54
+ OAuthURL: api.URL + "/oauth/token",
55
+ APIBaseURL: api.URL + "/api/v2",
56
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
57
+ LockStatus: func(context.Context) (bool, error) {
58
+ return false, nil
59
+ },
60
+ })
61
+ if err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
65
+ t.Fatal(err)
66
+ }
67
+
68
+ res, err := b.MintPhoneKey(context.Background(), "pair_abc123")
69
+ if err != nil {
70
+ t.Fatalf("MintPhoneKey failed: %v", err)
71
+ }
72
+ if res.AuthKey != "tskey-auth-test" || res.KeyID != "k-test" {
73
+ t.Fatalf("mint response = %+v", res)
74
+ }
75
+
76
+ create := keyRequest["capabilities"].(map[string]any)["devices"].(map[string]any)["create"].(map[string]any)
77
+ if create["reusable"] != false || create["ephemeral"] != false || create["preauthorized"] != true {
78
+ t.Fatalf("create caps = %#v", create)
79
+ }
80
+ tags := create["tags"].([]any)
81
+ if len(tags) != 1 || tags[0] != "tag:pairling-phone" {
82
+ t.Fatalf("tags = %#v", tags)
83
+ }
84
+ if got := int(keyRequest["expirySeconds"].(float64)); got > 600 {
85
+ t.Fatalf("expirySeconds = %d, want <= 600", got)
86
+ }
87
+ }
88
+
89
+ func TestMintRejectsSecondMintForSamePairId(t *testing.T) {
90
+ keyPosts := 0
91
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92
+ switch r.URL.Path {
93
+ case "/oauth/token":
94
+ w.Header().Set("Content-Type", "application/json")
95
+ _ = json.NewEncoder(w).Encode(map[string]any{
96
+ "access_token": "test-token",
97
+ "token_type": "Bearer",
98
+ "expires_in": 3600,
99
+ })
100
+ case "/api/v2/tailnet/-/keys":
101
+ keyPosts++
102
+ _ = json.NewEncoder(w).Encode(map[string]any{
103
+ "id": "k-test",
104
+ "key": "tskey-auth-test",
105
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
106
+ })
107
+ default:
108
+ t.Fatalf("unexpected path %s", r.URL.Path)
109
+ }
110
+ }))
111
+ defer api.Close()
112
+
113
+ dir := t.TempDir()
114
+ b, err := NewBroker(BrokerConfig{
115
+ SecretPath: filepath.Join(dir, "client_secret.json"),
116
+ StatePath: filepath.Join(dir, "state.json"),
117
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
118
+ OAuthURL: api.URL + "/oauth/token",
119
+ APIBaseURL: api.URL + "/api/v2",
120
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
121
+ LockStatus: func(context.Context) (bool, error) {
122
+ return false, nil
123
+ },
124
+ })
125
+ if err != nil {
126
+ t.Fatal(err)
127
+ }
128
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
129
+ t.Fatal(err)
130
+ }
131
+
132
+ if _, err := b.MintPhoneKey(context.Background(), "pair_abc123"); err != nil {
133
+ t.Fatalf("first mint failed: %v", err)
134
+ }
135
+ if _, err := b.MintPhoneKey(context.Background(), "pair_abc123"); err == nil {
136
+ t.Fatal("second mint succeeded, want duplicate pair_id rejection")
137
+ }
138
+ if keyPosts != 1 {
139
+ t.Fatalf("key mint POSTs = %d, want 1", keyPosts)
140
+ }
141
+ }
142
+
143
+ func TestDuplicatePairIDAuditedWithoutKeyMaterial(t *testing.T) {
144
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145
+ switch r.URL.Path {
146
+ case "/oauth/token":
147
+ w.Header().Set("Content-Type", "application/json")
148
+ _ = json.NewEncoder(w).Encode(map[string]any{
149
+ "access_token": "test-token",
150
+ "token_type": "Bearer",
151
+ "expires_in": 3600,
152
+ })
153
+ case "/api/v2/tailnet/-/keys":
154
+ _ = json.NewEncoder(w).Encode(map[string]any{
155
+ "id": "k-duplicate",
156
+ "key": "tskey-auth-duplicate-secret",
157
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
158
+ })
159
+ default:
160
+ t.Fatalf("unexpected path %s", r.URL.Path)
161
+ }
162
+ }))
163
+ defer api.Close()
164
+
165
+ dir := t.TempDir()
166
+ b, err := NewBroker(BrokerConfig{
167
+ SecretPath: filepath.Join(dir, "client_secret.json"),
168
+ StatePath: filepath.Join(dir, "state.json"),
169
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
170
+ OAuthURL: api.URL + "/oauth/token",
171
+ APIBaseURL: api.URL + "/api/v2",
172
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
173
+ LockStatus: func(context.Context) (bool, error) {
174
+ return false, nil
175
+ },
176
+ })
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
181
+ t.Fatal(err)
182
+ }
183
+
184
+ if _, err := b.MintPhoneKey(context.Background(), "pair_dup"); err != nil {
185
+ t.Fatalf("first mint failed: %v", err)
186
+ }
187
+ if _, err := b.MintPhoneKey(context.Background(), "pair_dup"); err == nil {
188
+ t.Fatal("second mint succeeded, want duplicate rejection")
189
+ }
190
+ data, err := os.ReadFile(b.cfg.AuditPath)
191
+ if err != nil {
192
+ t.Fatal(err)
193
+ }
194
+ text := string(data)
195
+ if !strings.Contains(text, `"event":"duplicate_pair_id"`) || !strings.Contains(text, `"pair_id":"pair_dup"`) {
196
+ t.Fatalf("audit missing duplicate_pair_id event: %s", text)
197
+ }
198
+ if strings.Contains(text, "tskey-auth-duplicate-secret") || strings.Contains(text, "authkey") {
199
+ t.Fatalf("audit leaked key material: %s", text)
200
+ }
201
+ }
202
+
203
+ func TestDuplicatePairIDWritesHealthAlert(t *testing.T) {
204
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205
+ switch r.URL.Path {
206
+ case "/oauth/token":
207
+ w.Header().Set("Content-Type", "application/json")
208
+ _ = json.NewEncoder(w).Encode(map[string]any{
209
+ "access_token": "test-token",
210
+ "token_type": "Bearer",
211
+ "expires_in": 3600,
212
+ })
213
+ case "/api/v2/tailnet/-/keys":
214
+ _ = json.NewEncoder(w).Encode(map[string]any{
215
+ "id": "k-alert",
216
+ "key": "tskey-auth-alert-secret",
217
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
218
+ })
219
+ default:
220
+ t.Fatalf("unexpected path %s", r.URL.Path)
221
+ }
222
+ }))
223
+ defer api.Close()
224
+
225
+ dir := t.TempDir()
226
+ b, err := NewBroker(BrokerConfig{
227
+ SecretPath: filepath.Join(dir, "client_secret.json"),
228
+ StatePath: filepath.Join(dir, "state.json"),
229
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
230
+ AlertPath: filepath.Join(dir, "alerts.jsonl"),
231
+ OAuthURL: api.URL + "/oauth/token",
232
+ APIBaseURL: api.URL + "/api/v2",
233
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
234
+ LockStatus: func(context.Context) (bool, error) {
235
+ return false, nil
236
+ },
237
+ })
238
+ if err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ if _, err := b.MintPhoneKey(context.Background(), "pair_alert"); err != nil {
245
+ t.Fatalf("first mint failed: %v", err)
246
+ }
247
+ if _, err := b.MintPhoneKey(context.Background(), "pair_alert"); err == nil {
248
+ t.Fatal("second mint succeeded, want duplicate rejection")
249
+ }
250
+
251
+ data, err := os.ReadFile(b.cfg.AlertPath)
252
+ if err != nil {
253
+ t.Fatal(err)
254
+ }
255
+ text := string(data)
256
+ if !strings.Contains(text, `"event":"duplicate_pair_id"`) || !strings.Contains(text, `"pair_id":"pair_alert"`) {
257
+ t.Fatalf("health alert missing duplicate_pair_id event: %s", text)
258
+ }
259
+ if strings.Contains(text, "tskey-auth-alert-secret") || strings.Contains(text, "authkey") {
260
+ t.Fatalf("health alert leaked key material: %s", text)
261
+ }
262
+ }
263
+
264
+ func TestMintRejectsMalformedPairIDBeforeAPI(t *testing.T) {
265
+ apiCalls := 0
266
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
267
+ apiCalls++
268
+ t.Fatalf("malformed pair_id reached API path %s", r.URL.Path)
269
+ }))
270
+ defer api.Close()
271
+
272
+ dir := t.TempDir()
273
+ b, err := NewBroker(BrokerConfig{
274
+ SecretPath: filepath.Join(dir, "client_secret.json"),
275
+ StatePath: filepath.Join(dir, "state.json"),
276
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
277
+ OAuthURL: api.URL + "/oauth/token",
278
+ APIBaseURL: api.URL + "/api/v2",
279
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
280
+ LockStatus: func(context.Context) (bool, error) {
281
+ return false, nil
282
+ },
283
+ })
284
+ if err != nil {
285
+ t.Fatal(err)
286
+ }
287
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
288
+ t.Fatal(err)
289
+ }
290
+
291
+ if _, err := b.MintPhoneKey(context.Background(), "../../bad"); err == nil {
292
+ t.Fatal("malformed pair_id minted, want validation error")
293
+ }
294
+ if apiCalls != 0 {
295
+ t.Fatalf("apiCalls = %d, want 0", apiCalls)
296
+ }
297
+ }
298
+
299
+ func TestMintRateLimited(t *testing.T) {
300
+ keyPosts := 0
301
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
302
+ switch r.URL.Path {
303
+ case "/oauth/token":
304
+ w.Header().Set("Content-Type", "application/json")
305
+ _ = json.NewEncoder(w).Encode(map[string]any{
306
+ "access_token": "test-token",
307
+ "token_type": "Bearer",
308
+ "expires_in": 3600,
309
+ })
310
+ case "/api/v2/tailnet/-/keys":
311
+ keyPosts++
312
+ _ = json.NewEncoder(w).Encode(map[string]any{
313
+ "id": "k-test",
314
+ "key": "tskey-auth-test",
315
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
316
+ })
317
+ default:
318
+ t.Fatalf("unexpected path %s", r.URL.Path)
319
+ }
320
+ }))
321
+ defer api.Close()
322
+
323
+ dir := t.TempDir()
324
+ now := time.Unix(1700000000, 0)
325
+ b, err := NewBroker(BrokerConfig{
326
+ SecretPath: filepath.Join(dir, "client_secret.json"),
327
+ StatePath: filepath.Join(dir, "state.json"),
328
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
329
+ OAuthURL: api.URL + "/oauth/token",
330
+ APIBaseURL: api.URL + "/api/v2",
331
+ Now: func() time.Time { return now },
332
+ LockStatus: func(context.Context) (bool, error) {
333
+ return false, nil
334
+ },
335
+ })
336
+ if err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
340
+ t.Fatal(err)
341
+ }
342
+
343
+ for _, pairID := range []string{"pair_1", "pair_2", "pair_3"} {
344
+ if _, err := b.MintPhoneKey(context.Background(), pairID); err != nil {
345
+ t.Fatalf("%s mint failed: %v", pairID, err)
346
+ }
347
+ }
348
+ if _, err := b.MintPhoneKey(context.Background(), "pair_4"); err == nil {
349
+ t.Fatal("fourth mint in 10 minutes succeeded, want rate limit")
350
+ }
351
+ if keyPosts != 3 {
352
+ t.Fatalf("key mint POSTs = %d, want 3", keyPosts)
353
+ }
354
+ }
355
+
356
+ func TestRateLimitAuditedWithoutKeyMaterial(t *testing.T) {
357
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
358
+ switch r.URL.Path {
359
+ case "/oauth/token":
360
+ w.Header().Set("Content-Type", "application/json")
361
+ _ = json.NewEncoder(w).Encode(map[string]any{
362
+ "access_token": "test-token",
363
+ "token_type": "Bearer",
364
+ "expires_in": 3600,
365
+ })
366
+ case "/api/v2/tailnet/-/keys":
367
+ _ = json.NewEncoder(w).Encode(map[string]any{
368
+ "id": "k-rate",
369
+ "key": "tskey-auth-rate-secret",
370
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
371
+ })
372
+ default:
373
+ t.Fatalf("unexpected path %s", r.URL.Path)
374
+ }
375
+ }))
376
+ defer api.Close()
377
+
378
+ dir := t.TempDir()
379
+ now := time.Unix(1700000000, 0)
380
+ b, err := NewBroker(BrokerConfig{
381
+ SecretPath: filepath.Join(dir, "client_secret.json"),
382
+ StatePath: filepath.Join(dir, "state.json"),
383
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
384
+ OAuthURL: api.URL + "/oauth/token",
385
+ APIBaseURL: api.URL + "/api/v2",
386
+ Now: func() time.Time { return now },
387
+ LockStatus: func(context.Context) (bool, error) {
388
+ return false, nil
389
+ },
390
+ })
391
+ if err != nil {
392
+ t.Fatal(err)
393
+ }
394
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
395
+ t.Fatal(err)
396
+ }
397
+ for _, pairID := range []string{"pair_rate_1", "pair_rate_2", "pair_rate_3"} {
398
+ if _, err := b.MintPhoneKey(context.Background(), pairID); err != nil {
399
+ t.Fatalf("%s mint failed: %v", pairID, err)
400
+ }
401
+ }
402
+ if _, err := b.MintPhoneKey(context.Background(), "pair_rate_4"); err == nil {
403
+ t.Fatal("fourth mint succeeded, want rate-limit rejection")
404
+ }
405
+ data, err := os.ReadFile(b.cfg.AuditPath)
406
+ if err != nil {
407
+ t.Fatal(err)
408
+ }
409
+ text := string(data)
410
+ if !strings.Contains(text, `"event":"mint_rate_limited"`) || !strings.Contains(text, `"pair_id":"pair_rate_4"`) {
411
+ t.Fatalf("audit missing mint_rate_limited event: %s", text)
412
+ }
413
+ if strings.Contains(text, "tskey-auth-rate-secret") || strings.Contains(text, "authkey") {
414
+ t.Fatalf("audit leaked key material: %s", text)
415
+ }
416
+ }
417
+
418
+ func TestBrokerNeverRequestsDevicesCoreScope(t *testing.T) {
419
+ var tokenScope string
420
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
421
+ switch r.URL.Path {
422
+ case "/oauth/token":
423
+ _ = r.ParseForm()
424
+ tokenScope = r.Form.Get("scope")
425
+ w.Header().Set("Content-Type", "application/json")
426
+ _ = json.NewEncoder(w).Encode(map[string]any{
427
+ "access_token": "test-token",
428
+ "token_type": "Bearer",
429
+ "expires_in": 3600,
430
+ })
431
+ case "/api/v2/tailnet/-/keys":
432
+ if r.Method == http.MethodDelete {
433
+ t.Fatal("broker attempted DeleteDevice/delete-key path")
434
+ }
435
+ _ = json.NewEncoder(w).Encode(map[string]any{
436
+ "id": "k-test",
437
+ "key": "tskey-auth-test",
438
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
439
+ })
440
+ default:
441
+ t.Fatalf("unexpected path %s", r.URL.Path)
442
+ }
443
+ }))
444
+ defer api.Close()
445
+
446
+ dir := t.TempDir()
447
+ b, err := NewBroker(BrokerConfig{
448
+ SecretPath: filepath.Join(dir, "client_secret.json"),
449
+ StatePath: filepath.Join(dir, "state.json"),
450
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
451
+ OAuthURL: api.URL + "/oauth/token",
452
+ APIBaseURL: api.URL + "/api/v2",
453
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
454
+ LockStatus: func(context.Context) (bool, error) {
455
+ return false, nil
456
+ },
457
+ })
458
+ if err != nil {
459
+ t.Fatal(err)
460
+ }
461
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
462
+ t.Fatal(err)
463
+ }
464
+ if _, err := b.MintPhoneKey(context.Background(), "pair_abc123"); err != nil {
465
+ t.Fatalf("MintPhoneKey failed: %v", err)
466
+ }
467
+ if tokenScope != "auth_keys" {
468
+ t.Fatalf("token scope = %q, want auth_keys", tokenScope)
469
+ }
470
+ source, err := os.ReadFile("mintd.go")
471
+ if err != nil {
472
+ t.Fatal(err)
473
+ }
474
+ for _, forbidden := range []string{"devices:core", "DeleteDevice"} {
475
+ if bytes.Contains(source, []byte(forbidden)) {
476
+ t.Fatalf("mintd.go contains forbidden credential/device scope %q", forbidden)
477
+ }
478
+ }
479
+ }
480
+
481
+ func TestBrokerAuditsKeyIdNotKey(t *testing.T) {
482
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
483
+ switch r.URL.Path {
484
+ case "/oauth/token":
485
+ w.Header().Set("Content-Type", "application/json")
486
+ _ = json.NewEncoder(w).Encode(map[string]any{
487
+ "access_token": "test-token",
488
+ "token_type": "Bearer",
489
+ "expires_in": 3600,
490
+ })
491
+ case "/api/v2/tailnet/-/keys":
492
+ _ = json.NewEncoder(w).Encode(map[string]any{
493
+ "id": "k-audit",
494
+ "key": "tskey-auth-secret-value",
495
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
496
+ })
497
+ default:
498
+ t.Fatalf("unexpected path %s", r.URL.Path)
499
+ }
500
+ }))
501
+ defer api.Close()
502
+
503
+ dir := t.TempDir()
504
+ b, err := NewBroker(BrokerConfig{
505
+ SecretPath: filepath.Join(dir, "client_secret.json"),
506
+ StatePath: filepath.Join(dir, "state.json"),
507
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
508
+ OAuthURL: api.URL + "/oauth/token",
509
+ APIBaseURL: api.URL + "/api/v2",
510
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
511
+ LockStatus: func(context.Context) (bool, error) {
512
+ return false, nil
513
+ },
514
+ })
515
+ if err != nil {
516
+ t.Fatal(err)
517
+ }
518
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
519
+ t.Fatal(err)
520
+ }
521
+ if _, err := b.MintPhoneKey(context.Background(), "pair_audit"); err != nil {
522
+ t.Fatalf("MintPhoneKey failed: %v", err)
523
+ }
524
+
525
+ data, err := os.ReadFile(b.cfg.AuditPath)
526
+ if err != nil {
527
+ t.Fatal(err)
528
+ }
529
+ text := string(data)
530
+ if !strings.Contains(text, `"key_id":"k-audit"`) || !strings.Contains(text, `"pair_id":"pair_audit"`) {
531
+ t.Fatalf("audit record missing key_id/pair_id: %s", text)
532
+ }
533
+ if strings.Contains(text, "tskey-auth-secret-value") || strings.Contains(text, "authkey") {
534
+ t.Fatalf("audit record leaked authkey: %s", text)
535
+ }
536
+ }
537
+
538
+ func TestSocketRejectsMalformedRequest(t *testing.T) {
539
+ apiCalls := 0
540
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
541
+ apiCalls++
542
+ t.Fatalf("malformed socket request reached API path %s", r.URL.Path)
543
+ }))
544
+ defer api.Close()
545
+
546
+ dir := t.TempDir()
547
+ b, err := NewBroker(BrokerConfig{
548
+ SecretPath: filepath.Join(dir, "client_secret.json"),
549
+ StatePath: filepath.Join(dir, "state.json"),
550
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
551
+ OAuthURL: api.URL + "/oauth/token",
552
+ APIBaseURL: api.URL + "/api/v2",
553
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
554
+ LockStatus: func(context.Context) (bool, error) {
555
+ return false, nil
556
+ },
557
+ })
558
+ if err != nil {
559
+ t.Fatal(err)
560
+ }
561
+ ctx, cancel := context.WithCancel(context.Background())
562
+ defer cancel()
563
+ socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("pairling-mintd-%d.sock", time.Now().UnixNano()))
564
+ defer os.Remove(socketPath)
565
+ errc := make(chan error, 1)
566
+ go func() { errc <- b.ServeUnix(ctx, socketPath, os.Getuid()) }()
567
+ waitForSocket(t, socketPath)
568
+
569
+ conn, err := net.Dial("unix", socketPath)
570
+ if err != nil {
571
+ t.Fatal(err)
572
+ }
573
+ defer conn.Close()
574
+ if _, err := conn.Write([]byte(`{"op":"bad","pair_id":"pair_socket"}` + "\n")); err != nil {
575
+ t.Fatal(err)
576
+ }
577
+ var response map[string]any
578
+ if err := json.NewDecoder(conn).Decode(&response); err != nil {
579
+ t.Fatal(err)
580
+ }
581
+ if response["ok"] != false || response["error"] == nil {
582
+ t.Fatalf("response = %#v, want ok:false with error", response)
583
+ }
584
+ if apiCalls != 0 {
585
+ t.Fatalf("apiCalls = %d, want 0", apiCalls)
586
+ }
587
+ cancel()
588
+ if err := <-errc; err != nil {
589
+ t.Fatal(err)
590
+ }
591
+ }
592
+
593
+ func TestServeUnixRequiresAuthorizedUID(t *testing.T) {
594
+ b, err := NewBroker(BrokerConfig{
595
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
596
+ })
597
+ if err != nil {
598
+ t.Fatal(err)
599
+ }
600
+ ctx, cancel := context.WithCancel(context.Background())
601
+ defer cancel()
602
+ errc := make(chan error, 1)
603
+ socketPath := filepath.Join("/tmp", fmt.Sprintf("pairling-mintd-noauth-%d.sock", time.Now().UnixNano()))
604
+ defer os.Remove(socketPath)
605
+ go func() {
606
+ errc <- b.ServeUnix(ctx, socketPath, -1)
607
+ }()
608
+ select {
609
+ case err := <-errc:
610
+ if err == nil || !strings.Contains(err.Error(), "authorized uid required") {
611
+ t.Fatalf("ServeUnix error = %v, want authorized uid required", err)
612
+ }
613
+ case <-time.After(100 * time.Millisecond):
614
+ cancel()
615
+ t.Fatal("ServeUnix opened socket with unset authorized uid")
616
+ }
617
+ }
618
+
619
+ func TestHandleConnAuthorizesPeerUIDBeforeMint(t *testing.T) {
620
+ cases := []struct {
621
+ name string
622
+ uid string
623
+ peerErr error
624
+ wantOK bool
625
+ wantError string
626
+ wantAlert string
627
+ wantKeyPost int
628
+ }{
629
+ {
630
+ name: "wrong uid rejected before mint",
631
+ uid: "502",
632
+ wantOK: false,
633
+ wantError: "unauthorized_peer",
634
+ wantAlert: "unexpected_peer_uid",
635
+ wantKeyPost: 0,
636
+ },
637
+ {
638
+ name: "authorized uid accepted",
639
+ uid: "501",
640
+ wantOK: true,
641
+ wantKeyPost: 1,
642
+ },
643
+ {
644
+ name: "peercred error fail closed",
645
+ peerErr: errors.New("peercred boom"),
646
+ wantOK: false,
647
+ wantError: "peercred_unavailable",
648
+ wantKeyPost: 0,
649
+ },
650
+ }
651
+
652
+ for _, tc := range cases {
653
+ t.Run(tc.name, func(t *testing.T) {
654
+ keyPosts := 0
655
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
656
+ switch r.URL.Path {
657
+ case "/oauth/token":
658
+ w.Header().Set("Content-Type", "application/json")
659
+ _ = json.NewEncoder(w).Encode(map[string]any{
660
+ "access_token": "test-token",
661
+ "token_type": "Bearer",
662
+ "expires_in": 3600,
663
+ })
664
+ case "/api/v2/tailnet/-/keys":
665
+ keyPosts++
666
+ _ = json.NewEncoder(w).Encode(map[string]any{
667
+ "id": "k-peer",
668
+ "key": "tskey-auth-peer",
669
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
670
+ })
671
+ default:
672
+ t.Fatalf("unexpected path %s", r.URL.Path)
673
+ }
674
+ }))
675
+ defer api.Close()
676
+
677
+ dir := t.TempDir()
678
+ b, err := NewBroker(BrokerConfig{
679
+ SecretPath: filepath.Join(dir, "client_secret.json"),
680
+ StatePath: filepath.Join(dir, "state.json"),
681
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
682
+ AlertPath: filepath.Join(dir, "alerts.jsonl"),
683
+ OAuthURL: api.URL + "/oauth/token",
684
+ APIBaseURL: api.URL + "/api/v2",
685
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
686
+ })
687
+ if err != nil {
688
+ t.Fatal(err)
689
+ }
690
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
691
+ t.Fatal(err)
692
+ }
693
+
694
+ oldPeerUID := peerUID
695
+ peerUID = func(net.Conn) (string, error) {
696
+ return tc.uid, tc.peerErr
697
+ }
698
+ defer func() { peerUID = oldPeerUID }()
699
+
700
+ server, client := net.Pipe()
701
+ defer client.Close()
702
+ if err := client.SetDeadline(time.Now().Add(time.Second)); err != nil {
703
+ t.Fatal(err)
704
+ }
705
+ done := make(chan struct{})
706
+ go func() {
707
+ defer close(done)
708
+ b.handleConn(context.Background(), server, 501)
709
+ }()
710
+ if tc.peerErr == nil && tc.uid == "501" {
711
+ _, _ = client.Write([]byte(`{"op":"mint_phone_key","pair_id":"pair_peer"}` + "\n"))
712
+ }
713
+ var response socketResponse
714
+ if err := json.NewDecoder(client).Decode(&response); err != nil {
715
+ t.Fatal(err)
716
+ }
717
+ <-done
718
+
719
+ if response.OK != tc.wantOK {
720
+ t.Fatalf("response OK = %v, want %v (response=%+v)", response.OK, tc.wantOK, response)
721
+ }
722
+ if tc.wantError != "" && response.Error != tc.wantError {
723
+ t.Fatalf("response error = %q, want %q", response.Error, tc.wantError)
724
+ }
725
+ if keyPosts != tc.wantKeyPost {
726
+ t.Fatalf("keyPosts = %d, want %d", keyPosts, tc.wantKeyPost)
727
+ }
728
+ alertBytes, _ := os.ReadFile(b.cfg.AlertPath)
729
+ if tc.wantAlert != "" && !strings.Contains(string(alertBytes), tc.wantAlert) {
730
+ t.Fatalf("alert %q missing from %s", tc.wantAlert, alertBytes)
731
+ }
732
+ })
733
+ }
734
+ }
735
+
736
+ func TestOAuthTokenCachedInMemoryNotPersisted(t *testing.T) {
737
+ tokenPosts := 0
738
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
739
+ switch r.URL.Path {
740
+ case "/oauth/token":
741
+ tokenPosts++
742
+ w.Header().Set("Content-Type", "application/json")
743
+ _ = json.NewEncoder(w).Encode(map[string]any{
744
+ "access_token": "cached-test-token",
745
+ "token_type": "Bearer",
746
+ "expires_in": 3600,
747
+ })
748
+ case "/api/v2/tailnet/-/keys":
749
+ if got := r.Header.Get("Authorization"); got != "Bearer cached-test-token" {
750
+ t.Fatalf("Authorization header = %q", got)
751
+ }
752
+ _ = json.NewEncoder(w).Encode(map[string]any{
753
+ "id": "k-test",
754
+ "key": "tskey-auth-test",
755
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
756
+ })
757
+ default:
758
+ t.Fatalf("unexpected path %s", r.URL.Path)
759
+ }
760
+ }))
761
+ defer api.Close()
762
+
763
+ dir := t.TempDir()
764
+ b, err := NewBroker(BrokerConfig{
765
+ SecretPath: filepath.Join(dir, "client_secret.json"),
766
+ StatePath: filepath.Join(dir, "state.json"),
767
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
768
+ OAuthURL: api.URL + "/oauth/token",
769
+ APIBaseURL: api.URL + "/api/v2",
770
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
771
+ LockStatus: func(context.Context) (bool, error) {
772
+ return false, nil
773
+ },
774
+ })
775
+ if err != nil {
776
+ t.Fatal(err)
777
+ }
778
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
779
+ t.Fatal(err)
780
+ }
781
+ for _, pairID := range []string{"pair_cache_1", "pair_cache_2"} {
782
+ if _, err := b.MintPhoneKey(context.Background(), pairID); err != nil {
783
+ t.Fatalf("%s mint failed: %v", pairID, err)
784
+ }
785
+ }
786
+ if tokenPosts != 1 {
787
+ t.Fatalf("OAuth token POSTs = %d, want 1", tokenPosts)
788
+ }
789
+ for _, path := range []string{b.cfg.StatePath, b.cfg.AuditPath} {
790
+ data, err := os.ReadFile(path)
791
+ if err != nil {
792
+ t.Fatal(err)
793
+ }
794
+ if bytes.Contains(data, []byte("cached-test-token")) {
795
+ t.Fatalf("%s persisted OAuth access token", path)
796
+ }
797
+ }
798
+ }
799
+
800
+ func TestMintLockAwareSignDeferred(t *testing.T) {
801
+ apiCalls := 0
802
+ api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
803
+ apiCalls++
804
+ switch r.URL.Path {
805
+ case "/oauth/token":
806
+ w.Header().Set("Content-Type", "application/json")
807
+ _ = json.NewEncoder(w).Encode(map[string]any{
808
+ "access_token": "test-token",
809
+ "token_type": "Bearer",
810
+ "expires_in": 3600,
811
+ })
812
+ case "/api/v2/tailnet/-/keys":
813
+ _ = json.NewEncoder(w).Encode(map[string]any{
814
+ "id": "k-lock",
815
+ "key": "tskey-auth-lock",
816
+ "expires": time.Unix(1700000600, 0).Format(time.RFC3339),
817
+ })
818
+ default:
819
+ t.Fatalf("unexpected path %s", r.URL.Path)
820
+ }
821
+ }))
822
+ defer api.Close()
823
+
824
+ dir := t.TempDir()
825
+ b, err := NewBroker(BrokerConfig{
826
+ SecretPath: filepath.Join(dir, "client_secret.json"),
827
+ StatePath: filepath.Join(dir, "state.json"),
828
+ AuditPath: filepath.Join(dir, "audit.jsonl"),
829
+ OAuthURL: api.URL + "/oauth/token",
830
+ APIBaseURL: api.URL + "/api/v2",
831
+ Now: func() time.Time { return time.Unix(1700000000, 0) },
832
+ LockStatus: func(context.Context) (bool, error) {
833
+ return true, nil
834
+ },
835
+ })
836
+ if err != nil {
837
+ t.Fatal(err)
838
+ }
839
+ if err := writeClientSecret(b.cfg.SecretPath, clientSecret{ClientID: "cid", ClientSecret: "csecret"}); err != nil {
840
+ t.Fatal(err)
841
+ }
842
+ if _, err := b.MintPhoneKey(context.Background(), "pair_lock"); err == nil {
843
+ t.Fatal("mint succeeded while tailnet lock enabled")
844
+ }
845
+ if apiCalls != 0 {
846
+ t.Fatalf("apiCalls = %d, want 0 when lock is enabled", apiCalls)
847
+ }
848
+ }
849
+
850
+ func TestLockStatusFallsBackPastGUIWrapper(t *testing.T) {
851
+ dir := t.TempDir()
852
+ guiWrapper := filepath.Join(dir, "tailscale-gui")
853
+ cli := filepath.Join(dir, "tailscale-cli")
854
+ if err := os.WriteFile(guiWrapper, []byte("#!/bin/sh\necho 'The Tailscale GUI failed to start: test GUI unavailable'\n"), 0o755); err != nil {
855
+ t.Fatal(err)
856
+ }
857
+ if err := os.WriteFile(cli, []byte("#!/bin/sh\necho 'Tailnet Lock is NOT enabled.'\n"), 0o755); err != nil {
858
+ t.Fatal(err)
859
+ }
860
+
861
+ locked, err := lockStatusFromCandidates(context.Background(), []string{guiWrapper, cli})
862
+ if err != nil {
863
+ t.Fatalf("lockStatusFromCandidates failed: %v", err)
864
+ }
865
+ if locked {
866
+ t.Fatal("lock status reported enabled after non-GUI CLI said NOT enabled")
867
+ }
868
+ }
869
+
870
+ func TestLockStatusUsesConnectdStatusBeforeCLI(t *testing.T) {
871
+ status := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
872
+ _ = json.NewEncoder(w).Encode(map[string]any{"tailnet_lock_enabled": false})
873
+ }))
874
+ defer status.Close()
875
+
876
+ locked, err := lockStatusFromConnectdStatus(context.Background(), status.URL)
877
+ if err != nil {
878
+ t.Fatalf("lockStatusFromConnectdStatus failed: %v", err)
879
+ }
880
+ if locked {
881
+ t.Fatal("lock status reported enabled")
882
+ }
883
+ }
884
+
885
+ func waitForSocket(t *testing.T, path string) {
886
+ t.Helper()
887
+ for i := 0; i < 100; i++ {
888
+ if _, err := os.Stat(path); err == nil {
889
+ return
890
+ }
891
+ time.Sleep(10 * time.Millisecond)
892
+ }
893
+ t.Fatalf("socket did not appear: %s", path)
894
+ }