pairling 0.2.5 → 0.2.7

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 (28) 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_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/pairling_connectd_status.py +57 -7
  7. package/payload/mac/companiond/pairling_devices.py +35 -0
  8. package/payload/mac/companiond/pairling_pairing.py +67 -20
  9. package/payload/mac/companiond/pairlingd.py +269 -16
  10. package/payload/mac/companiond/push_dispatcher.py +31 -1
  11. package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
  12. package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
  13. package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
  14. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
  15. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
  16. package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
  17. package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
  18. package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
  19. package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
  22. package/payload/mac/connectd/internal/runtime/config.go +19 -0
  23. package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
  24. package/payload/mac/connectd/internal/status/status.go +67 -1
  25. package/payload/mac/connectd/internal/status/status_test.go +138 -0
  26. package/payload/mac/install/install-runtime.sh +299 -20
  27. package/payload/mac/install/render-launchd.py +54 -10
  28. package/payload-manifest.json +62 -20
@@ -0,0 +1,99 @@
1
+ package gateway
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "net/url"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ // TestAdversarialPairlingConnectBypass is an independent skeptic test verifying
13
+ // that ExposureModePairlingConnect denies credential/admin/non-pre-pair paths and
14
+ // path-trick variants before reaching the upstream.
15
+ func TestAdversarialPairlingConnectBypass(t *testing.T) {
16
+ var forwarded []string
17
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18
+ forwarded = append(forwarded, r.Method+" "+r.URL.Path+"?raw="+r.URL.RawPath)
19
+ w.WriteHeader(http.StatusOK)
20
+ }))
21
+ defer upstream.Close()
22
+ upstreamURL, _ := url.Parse(upstream.URL)
23
+ handler, err := NewHandler(Options{
24
+ Upstream: upstreamURL,
25
+ MaxBodyBytes: 4096,
26
+ Mode: ExposureModePairlingConnect,
27
+ })
28
+ if err != nil {
29
+ t.Fatal(err)
30
+ }
31
+
32
+ // Each case: method, raw target (as sent on the wire), whether we expect it
33
+ // to reach the upstream (forwarded=true) WITHOUT a bearer token.
34
+ cases := []struct {
35
+ name string
36
+ method string
37
+ target string
38
+ wantForward bool
39
+ }{
40
+ // Mint / admin / non-pre-pair — must be denied unauthenticated
41
+ {"mint plain", "POST", "/mint", false},
42
+ {"mint-token", "POST", "/mint-token", false},
43
+ {"pair/start", "POST", "/pair/start", false},
44
+ {"pair/revoke", "POST", "/pair/revoke", false},
45
+ {"pair/rotate-token", "POST", "/pair/rotate-token", false},
46
+ {"spawn-session", "POST", "/spawn-session", false},
47
+ {"internal arbitrary", "GET", "/internal/secrets", false},
48
+ {"send-text no bearer", "POST", "/send-text", false},
49
+ {"sessions no bearer", "GET", "/sessions", false},
50
+
51
+ // Path tricks aiming to smuggle /pair/start or /mint past the allowlist
52
+ // while decoding to a dangerous path upstream.
53
+ {"encoded slash pair start", "POST", "/pair%2Fstart", false},
54
+ {"encoded mint", "POST", "/%6dint", false}, // %6d = 'm'
55
+ {"case pair start", "POST", "/PAIR/START", false},
56
+ {"case mint", "POST", "/MINT", false},
57
+ {"double slash pair start", "POST", "//pair/start", false},
58
+ {"double slash send-text", "POST", "//send-text", false},
59
+ {"trailing slash send-text", "POST", "/send-text/", false},
60
+ {"dotdot traversal to start", "POST", "/pair/claim/../start", false},
61
+ {"dotdot to send-text", "POST", "/healthz/../send-text", false},
62
+ {"encoded dotdot", "POST", "/pair/claim/%2e%2e/start", false},
63
+ {"healthz encoded", "GET", "/health%7a", false}, // %7a = 'z' -> /healthz only if decoded for match
64
+ {"semicolon param", "GET", "/healthz;/../send-text", false},
65
+ {"null byte", "POST", "/send-text%00", false},
66
+ {"trailing dot", "POST", "/send-text.", false},
67
+
68
+ // Allowed pre-pair paths — these SHOULD forward unauthenticated (sanity)
69
+ {"healthz allowed", "GET", "/healthz", true},
70
+ {"health allowed", "GET", "/health", true},
71
+ {"readyz allowed", "GET", "/readyz", true},
72
+ {"routez allowed", "GET", "/routez", true},
73
+ {"manifest allowed", "GET", "/manifest", true},
74
+ {"pair claim allowed", "POST", "/pair/claim", true},
75
+ {"pair psk-claim allowed", "POST", "/pair/psk-claim", true},
76
+ }
77
+
78
+ for _, tc := range cases {
79
+ t.Run(tc.name, func(t *testing.T) {
80
+ before := len(forwarded)
81
+ rec := httptest.NewRecorder()
82
+ req := httptest.NewRequest(tc.method, "http://pc.local"+tc.target, strings.NewReader("{}"))
83
+ handler.ServeHTTP(rec, req)
84
+ didForward := len(forwarded) > before
85
+ if didForward != tc.wantForward {
86
+ t.Errorf("%s %s: forwarded=%v want=%v (status=%d)", tc.method, tc.target, didForward, tc.wantForward, rec.Code)
87
+ }
88
+ })
89
+ }
90
+
91
+ t.Logf("paths that reached upstream: %+v", forwarded)
92
+ // Hard assertion: nothing dangerous must ever appear in the forwarded list.
93
+ joined := fmt.Sprintf("%+v", forwarded)
94
+ for _, danger := range []string{"start", "mint", "spawn", "internal", "revoke", "rotate"} {
95
+ if strings.Contains(strings.ToLower(joined), danger) {
96
+ t.Fatalf("DANGEROUS path reached upstream containing %q: %s", danger, joined)
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,265 @@
1
+ package gateway
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "net/url"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ // TestFunnelBootstrapAllowlist verifies the public Funnel surface forwards only
12
+ // the minimal bootstrap set and default-denies everything else, including any
13
+ // bearer-authenticated post-pair path. The bearer fallthrough that
14
+ // ExposureModePairlingConnect allows must be structurally unreachable here.
15
+ func TestFunnelBootstrapAllowlist(t *testing.T) {
16
+ forwarded := map[string]bool{}
17
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18
+ forwarded[r.Method+" "+r.URL.Path] = true
19
+ w.WriteHeader(http.StatusOK)
20
+ }))
21
+ defer upstream.Close()
22
+ upstreamURL, _ := url.Parse(upstream.URL)
23
+
24
+ handler, err := NewHandler(Options{
25
+ Upstream: upstreamURL,
26
+ MaxBodyBytes: 4096,
27
+ Mode: ExposureModeFunnelBootstrap,
28
+ })
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+
33
+ type tc struct {
34
+ method string
35
+ path string
36
+ bearer bool
37
+ }
38
+
39
+ allowed := []tc{
40
+ {http.MethodGet, "/health", false},
41
+ {http.MethodGet, "/healthz", false},
42
+ {http.MethodGet, "/readyz", false},
43
+ {http.MethodGet, "/manifest", false},
44
+ {http.MethodPost, "/pair/psk-claim", false},
45
+ }
46
+
47
+ // Every one of these must be denied, even with a valid-looking bearer, since
48
+ // the funnel mode has no bearer post-pair fallthrough.
49
+ denied := []tc{
50
+ {http.MethodPost, "/pair/start", false},
51
+ {http.MethodPost, "/pair/claim", false},
52
+ {http.MethodPost, "/pair/reauth-challenge", false},
53
+ {http.MethodPost, "/pair/reauth-claim", false},
54
+ {http.MethodGet, "/routez", false},
55
+ {http.MethodPost, "/internal/session-register", true},
56
+ {http.MethodPost, "/spawn-session", true},
57
+ {http.MethodPost, "/send-text", true},
58
+ {http.MethodGet, "/sessions", true},
59
+ {http.MethodPost, "/pairling-tools/run", true},
60
+ {http.MethodPost, "/pair/revoke", true},
61
+ {http.MethodPost, "/pair/rotate-token", true},
62
+ }
63
+
64
+ send := func(c tc) *httptest.ResponseRecorder {
65
+ var body string
66
+ if c.method == http.MethodPost {
67
+ body = "{}"
68
+ }
69
+ req := httptest.NewRequest(c.method, c.path, strings.NewReader(body))
70
+ if c.bearer {
71
+ req.Header.Set("Authorization", "Bearer testtoken")
72
+ }
73
+ rec := httptest.NewRecorder()
74
+ handler.ServeHTTP(rec, req)
75
+ return rec
76
+ }
77
+
78
+ for _, c := range allowed {
79
+ rec := send(c)
80
+ if rec.Code != http.StatusOK {
81
+ t.Errorf("allowed %s %s: got %d, want 200", c.method, c.path, rec.Code)
82
+ }
83
+ // Note: /health, /healthz, /manifest are answered by connectd (synthesized)
84
+ // and do not reach upstream; that is asserted in the synthesis test.
85
+ }
86
+
87
+ for _, c := range denied {
88
+ rec := send(c)
89
+ if rec.Code == http.StatusOK {
90
+ t.Errorf("denied %s %s (bearer=%v): returned 200, must be blocked", c.method, c.path, c.bearer)
91
+ }
92
+ if forwarded[c.method+" "+c.path] {
93
+ t.Errorf("denied %s %s (bearer=%v): reached upstream, must be blocked at the gateway", c.method, c.path, c.bearer)
94
+ }
95
+ }
96
+ }
97
+
98
+ // TestFunnelBootstrapIsStrictSubsetOfPrePair asserts the funnel set never admits
99
+ // a path the pre-pair set denies, and is strictly smaller (excludes /routez and
100
+ // /pair/claim). This guards against the funnel mode drifting wider than pre-pair.
101
+ func TestFunnelBootstrapIsStrictSubsetOfPrePair(t *testing.T) {
102
+ for _, m := range []string{http.MethodGet, http.MethodPost} {
103
+ for path := range funnelBootstrapGetPaths {
104
+ if m == http.MethodGet && !prePairAllowed(http.MethodGet, path) {
105
+ t.Errorf("funnel GET %s not allowed by pre-pair: funnel must be a subset", path)
106
+ }
107
+ }
108
+ for path := range funnelBootstrapPostPaths {
109
+ if m == http.MethodPost && !prePairAllowed(http.MethodPost, path) {
110
+ t.Errorf("funnel POST %s not allowed by pre-pair: funnel must be a subset", path)
111
+ }
112
+ }
113
+ }
114
+ if funnelBootstrapGetPaths["/routez"] {
115
+ t.Error("/routez must be excluded from the funnel surface")
116
+ }
117
+ if funnelBootstrapPostPaths["/pair/claim"] {
118
+ t.Error("/pair/claim must be excluded from the funnel surface")
119
+ }
120
+ if funnelBootstrapPostPaths["/pair/start"] {
121
+ t.Error("/pair/start must never be on the funnel surface")
122
+ }
123
+ }
124
+
125
+ // TestFunnelHealthAndManifestAreSynthesizedNotProxied verifies connectd answers
126
+ // funnel /health and /manifest itself with a minimal body and never proxies the
127
+ // upstream's identity, install path, version, or route topology. /readyz still
128
+ // proxies, since it is the readiness probe.
129
+ func TestFunnelHealthAndManifestAreSynthesizedNotProxied(t *testing.T) {
130
+ upstreamHit := map[string]bool{}
131
+ sensitive := `{"ok":true,"install_id":"inst_secret","computer_name":"Bob-MacBook","runtime":{"install_root":"/Users/bob/x","runtime_version":"0.2.5"},"routes":[{"host":"10.0.0.1"}]}`
132
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
133
+ upstreamHit[r.URL.Path] = true
134
+ w.Header().Set("Content-Type", "application/json")
135
+ _, _ = w.Write([]byte(sensitive))
136
+ }))
137
+ defer upstream.Close()
138
+ upstreamURL, _ := url.Parse(upstream.URL)
139
+
140
+ handler, err := NewHandler(Options{
141
+ Upstream: upstreamURL,
142
+ MaxBodyBytes: 4096,
143
+ Mode: ExposureModeFunnelBootstrap,
144
+ FunnelMacIDHash: "deadbeef",
145
+ })
146
+ if err != nil {
147
+ t.Fatal(err)
148
+ }
149
+
150
+ for _, path := range []string{"/health", "/manifest"} {
151
+ req := httptest.NewRequest(http.MethodGet, path, nil)
152
+ rec := httptest.NewRecorder()
153
+ handler.ServeHTTP(rec, req)
154
+ if rec.Code != http.StatusOK {
155
+ t.Fatalf("%s: got %d", path, rec.Code)
156
+ }
157
+ body := rec.Body.String()
158
+ for _, leak := range []string{"inst_secret", "Bob-MacBook", "install_root", "runtime_version", "routes", "10.0.0.1"} {
159
+ if strings.Contains(body, leak) {
160
+ t.Errorf("%s response leaked %q: %s", path, leak, body)
161
+ }
162
+ }
163
+ if !strings.Contains(body, "deadbeef") {
164
+ t.Errorf("%s response missing mac_id_hash: %s", path, body)
165
+ }
166
+ if upstreamHit[path] {
167
+ t.Errorf("%s reached upstream; must be synthesized at connectd", path)
168
+ }
169
+ }
170
+
171
+ req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
172
+ handler.ServeHTTP(httptest.NewRecorder(), req)
173
+ if !upstreamHit["/readyz"] {
174
+ t.Error("/readyz should be proxied to upstream, not synthesized")
175
+ }
176
+ }
177
+
178
+ // TestFunnelLimiterCaps verifies the three identity-independent caps: per-pair_id
179
+ // isolation, the in-flight ECDH concurrency cap, and the global ceiling.
180
+ func TestFunnelLimiterCaps(t *testing.T) {
181
+ // Per-pair cap = 2, global and ecdh high.
182
+ l := NewFunnelLimiter(1000, 2, 100)
183
+ r1, ok := l.Acquire("pairA")
184
+ if !ok {
185
+ t.Fatal("1st pairA should pass")
186
+ }
187
+ r2, ok := l.Acquire("pairA")
188
+ if !ok {
189
+ t.Fatal("2nd pairA should pass")
190
+ }
191
+ if _, ok := l.Acquire("pairA"); ok {
192
+ t.Error("3rd pairA should be capped")
193
+ }
194
+ if rB, ok := l.Acquire("pairB"); !ok {
195
+ t.Error("pairB must be unaffected by pairA's cap (per-pair isolation)")
196
+ } else {
197
+ rB()
198
+ }
199
+ r1()
200
+ r2()
201
+
202
+ // ECDH concurrency cap = 1: an unreleased acquire blocks the next.
203
+ l2 := NewFunnelLimiter(1000, 1000, 1)
204
+ rel, ok := l2.Acquire("x")
205
+ if !ok {
206
+ t.Fatal("first ecdh slot should acquire")
207
+ }
208
+ if _, ok := l2.Acquire("y"); ok {
209
+ t.Error("ecdh cap=1: a second concurrent acquire must fail")
210
+ }
211
+ rel()
212
+ if r, ok := l2.Acquire("z"); !ok {
213
+ t.Error("after release, the ecdh slot should be free")
214
+ } else {
215
+ r()
216
+ }
217
+
218
+ // Global ceiling = 2: a third request fails even with a fresh pair_id.
219
+ l3 := NewFunnelLimiter(2, 1000, 1000)
220
+ if _, ok := l3.Acquire("a"); !ok {
221
+ t.Fatal("global 1 should pass")
222
+ }
223
+ if _, ok := l3.Acquire("b"); !ok {
224
+ t.Fatal("global 2 should pass")
225
+ }
226
+ if _, ok := l3.Acquire("c"); ok {
227
+ t.Error("global ceiling=2: a third request must fail even with a new pair_id")
228
+ }
229
+ }
230
+
231
+ // TestFunnelMarkerInjectedAndStripped verifies connectd sets the funnel-origin
232
+ // marker only on the funnel handler (replacing any inbound spoof) and strips it
233
+ // on the tailnet handler.
234
+ func TestFunnelMarkerInjectedAndStripped(t *testing.T) {
235
+ var gotMarker string
236
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237
+ gotMarker = r.Header.Get("X-Pairling-Funnel-Origin")
238
+ w.WriteHeader(http.StatusOK)
239
+ }))
240
+ defer upstream.Close()
241
+ upstreamURL, _ := url.Parse(upstream.URL)
242
+
243
+ funnel, err := NewHandler(Options{Upstream: upstreamURL, Mode: ExposureModeFunnelBootstrap, FunnelLimiter: NewFunnelLimiter(1000, 1000, 1000)})
244
+ if err != nil {
245
+ t.Fatal(err)
246
+ }
247
+ req := httptest.NewRequest(http.MethodPost, "/pair/psk-claim", strings.NewReader(`{"pair_id":"p"}`))
248
+ req.Header.Set("X-Pairling-Funnel-Origin", "spoofed")
249
+ funnel.ServeHTTP(httptest.NewRecorder(), req)
250
+ if gotMarker != "1" {
251
+ t.Errorf("funnel handler upstream marker = %q, want \"1\" (inbound spoof must be replaced)", gotMarker)
252
+ }
253
+
254
+ gotMarker = ""
255
+ tailnet, err := NewHandler(Options{Upstream: upstreamURL, Mode: ExposureModePairlingConnect})
256
+ if err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ req2 := httptest.NewRequest(http.MethodGet, "/health", nil)
260
+ req2.Header.Set("X-Pairling-Funnel-Origin", "spoofed")
261
+ tailnet.ServeHTTP(httptest.NewRecorder(), req2)
262
+ if gotMarker != "" {
263
+ t.Errorf("tailnet handler upstream marker = %q, want empty (must be stripped)", gotMarker)
264
+ }
265
+ }
@@ -0,0 +1,56 @@
1
+ package gateway
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "runtime"
8
+ "testing"
9
+ )
10
+
11
+ // TestFunnelBootstrapMatchesContract ties funnelBootstrapAllowed to the shared
12
+ // cross-language endpoint contract, so the funnel allowlist cannot drift from the
13
+ // source-of-truth list that the Swift and Python sides also read.
14
+ func TestFunnelBootstrapMatchesContract(t *testing.T) {
15
+ _, thisFile, _, _ := runtime.Caller(0)
16
+ contractPath := filepath.Join(filepath.Dir(thisFile),
17
+ "..", "..", "..", "..", "thoughts", "shared", "contracts", "pairling-connect-endpoints.json")
18
+ data, err := os.ReadFile(contractPath)
19
+ if err != nil {
20
+ t.Fatalf("read contract: %v", err)
21
+ }
22
+ var contract struct {
23
+ Rows []struct {
24
+ Method string `json:"method"`
25
+ SamplePath string `json:"sample_path"`
26
+ ConnectdFunnelBootstrap bool `json:"connectd_funnel_bootstrap"`
27
+ AssertFunnelParity bool `json:"assert_funnel_parity"`
28
+ } `json:"rows"`
29
+ }
30
+ if err := json.Unmarshal(data, &contract); err != nil {
31
+ t.Fatalf("parse contract: %v", err)
32
+ }
33
+
34
+ funnelTrueTotal := 0
35
+ checked := 0
36
+ for _, row := range contract.Rows {
37
+ if row.ConnectdFunnelBootstrap {
38
+ funnelTrueTotal++
39
+ }
40
+ if !row.AssertFunnelParity {
41
+ continue
42
+ }
43
+ checked++
44
+ got := funnelBootstrapAllowed(row.Method, row.SamplePath)
45
+ if got != row.ConnectdFunnelBootstrap {
46
+ t.Errorf("%s %s: funnelBootstrapAllowed=%v, contract connectd_funnel_bootstrap=%v",
47
+ row.Method, row.SamplePath, got, row.ConnectdFunnelBootstrap)
48
+ }
49
+ }
50
+ if checked == 0 {
51
+ t.Fatal("no assert_funnel_parity rows found; the contract is not wired")
52
+ }
53
+ if funnelTrueTotal != 5 {
54
+ t.Errorf("connectd_funnel_bootstrap true rows = %d, want exactly 5 (the funnel set)", funnelTrueTotal)
55
+ }
56
+ }