pairling 0.2.8 → 0.2.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairling",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Pair your iPhone with the AI coding agents running on your Mac. CLI and local runtime installer for the Pairling iOS app.",
5
5
  "keywords": [
6
6
  "pairling",
@@ -40,7 +40,7 @@
40
40
  "url": "https://github.com/mergimg0/pairling-helper"
41
41
  },
42
42
  "optionalDependencies": {
43
- "@pairling/runtime-darwin-arm64": "0.2.8",
44
- "@pairling/runtime-darwin-x64": "0.2.8"
43
+ "@pairling/runtime-darwin-arm64": "0.2.9",
44
+ "@pairling/runtime-darwin-x64": "0.2.9"
45
45
  }
46
46
  }
@@ -1 +1 @@
1
- c07a6fd
1
+ c97bbab
@@ -1 +1 @@
1
- 0.2.8
1
+ 0.2.9
@@ -728,6 +728,7 @@ POST_ONLY_ENDPOINTS = {
728
728
  "/pair/reauth-claim",
729
729
  "/pair/revoke",
730
730
  "/pair/rotate-token",
731
+ "/pair/bind-node",
731
732
  "/aperture-cli/open",
732
733
  "/open",
733
734
  "/inject",
@@ -811,6 +812,12 @@ PROOF_REQUIRED_ENDPOINTS = HIGH_RISK_ENDPOINTS | {
811
812
  "/phone-tools/availability",
812
813
  "/phone-tools/next",
813
814
  "/phone-tools/result",
815
+ # Minimal proof-required POST whose only purpose is to trip the interactive
816
+ # provenance bind in _maybe_persist_tailnet_node_id. The iOS post-pair proof
817
+ # probes the embedded route with GET (never proof-required), so the bind for
818
+ # an untagged D2 interactive node never fired. A proof-required POST here
819
+ # reaches proof_verified=True and binds the device's tailnet_node_id.
820
+ "/pair/bind-node",
814
821
  }
815
822
 
816
823
  MAX_REQUEST_BODY_BYTES = 1_000_000
@@ -1046,7 +1053,31 @@ def _connectd_peer_node_id(headers) -> str:
1046
1053
  return value
1047
1054
 
1048
1055
 
1049
- def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool:
1056
+ def _connectd_peer_provenance(headers):
1057
+ """Parse the connectd-injected X-Pairling-Peer-Provenance header strictly.
1058
+
1059
+ connectd strips any client-supplied copy first, so when this header is
1060
+ present it is trustworthy. Three outcomes, kept deliberately distinct:
1061
+
1062
+ * header ABSENT -> return None. Legacy-eligible: an old connectd that sends
1063
+ X-Pairling-Peer-Node but no provenance keeps the bearer-only bind.
1064
+ * header EXACTLY "tagged" (tag:pairling-phone minted node) or "interactive"
1065
+ (untagged Pairling iOS D2 node) -> return that exact value.
1066
+ * header PRESENT but any other value (including empty) -> return "" as an
1067
+ invalid sentinel. Distinct from None so a present-but-invalid value is
1068
+ rejected, never silently downgraded to the legacy path.
1069
+ """
1070
+ getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
1071
+ raw = getter("X-Pairling-Peer-Provenance", None)
1072
+ if raw is None:
1073
+ return None
1074
+ value = str(raw).strip()
1075
+ if value in ("tagged", "interactive"):
1076
+ return value
1077
+ return ""
1078
+
1079
+
1080
+ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result, proof_verified=False) -> bool:
1050
1081
  if DEVICE_REGISTRY is None or auth_result is None or not getattr(auth_result, "ok", False):
1051
1082
  return False
1052
1083
  device_id = str(getattr(auth_result, "device_id", "") or "").strip()
@@ -1056,9 +1087,26 @@ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool
1056
1087
  return False
1057
1088
  if not _pairdrop_gateway_provenance_ok(headers, client_address):
1058
1089
  return False
1090
+ provenance = _connectd_peer_provenance(headers)
1059
1091
  node_id = _connectd_peer_node_id(headers)
1060
1092
  if not node_id:
1061
1093
  return False
1094
+ if provenance is None:
1095
+ # Legacy: an old connectd sent a peer-node header but no provenance.
1096
+ # Bind after bearer auth, preserving the pre-Stage-2 behavior.
1097
+ pass
1098
+ elif provenance == "tagged":
1099
+ # tag:pairling-phone minted node: bearer auth is sufficient to bind.
1100
+ pass
1101
+ elif provenance == "interactive":
1102
+ # Untagged, less-trusted Pairling iOS D2 node: bind only after the
1103
+ # request also passed request-proof, never on bearer auth alone.
1104
+ if not proof_verified:
1105
+ return False
1106
+ else:
1107
+ # Present-but-invalid provenance value: reject. Do NOT downgrade to the
1108
+ # legacy path just because the value is unrecognized.
1109
+ return False
1062
1110
  try:
1063
1111
  return bool(DEVICE_REGISTRY.set_tailnet_node_id_if_absent(device_id, node_id))
1064
1112
  except Exception:
@@ -7397,6 +7445,7 @@ class Handler(BaseHTTPRequestHandler):
7397
7445
  self.send_error(405, "POST required")
7398
7446
  return
7399
7447
 
7448
+ proof_verified = False
7400
7449
  if (
7401
7450
  self.pairling_auth is not None
7402
7451
  and _requires_request_proof(u.path, self.command)
@@ -7432,9 +7481,15 @@ class Handler(BaseHTTPRequestHandler):
7432
7481
  },
7433
7482
  }, status=proof_result.status)
7434
7483
  return
7484
+ proof_verified = True
7435
7485
 
7436
7486
  if self.pairling_auth is not None:
7437
- _maybe_persist_tailnet_node_id(self.headers, self.client_address, self.pairling_auth)
7487
+ _maybe_persist_tailnet_node_id(
7488
+ self.headers,
7489
+ self.client_address,
7490
+ self.pairling_auth,
7491
+ proof_verified=proof_verified,
7492
+ )
7438
7493
 
7439
7494
  if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
7440
7495
  max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
@@ -7490,6 +7545,8 @@ class Handler(BaseHTTPRequestHandler):
7490
7545
  self._handle_pair_revoke(q)
7491
7546
  elif u.path == "/pair/rotate-token":
7492
7547
  self._handle_pair_rotate_token(q)
7548
+ elif u.path == "/pair/bind-node":
7549
+ self._handle_pair_bind_node(q)
7493
7550
  elif u.path == "/healthz":
7494
7551
  self._handle_healthz(q)
7495
7552
  elif u.path == "/power-state":
@@ -8430,6 +8487,28 @@ class Handler(BaseHTTPRequestHandler):
8430
8487
  return
8431
8488
  self._send_json({"ok": True, "device_id": device_id, "token": token})
8432
8489
 
8490
+ def _handle_pair_bind_node(self, q):
8491
+ # Minimal proof-required POST whose sole purpose is to trip the
8492
+ # interactive provenance bind. The bind itself already ran in
8493
+ # _maybe_persist_tailnet_node_id BEFORE routing (it fires only when this
8494
+ # request was bearer-authed AND passed request-proof, which a POST to a
8495
+ # PROOF_REQUIRED_ENDPOINTS path is). Here we only report whether the
8496
+ # device now carries a tailnet_node_id. NO other side effects.
8497
+ if self.pairling_auth is None:
8498
+ self._send_json({
8499
+ "ok": False,
8500
+ "error": {
8501
+ "code": "missing_token",
8502
+ "message": "Authorization: Bearer token required",
8503
+ },
8504
+ }, status=401)
8505
+ return
8506
+ bound = bool(
8507
+ DEVICE_REGISTRY
8508
+ and DEVICE_REGISTRY.tailnet_node_id(self.pairling_auth.device_id)
8509
+ )
8510
+ self._send_json({"ok": True, "tailnet_node_id_bound": bound})
8511
+
8433
8512
  def _handle_power_state(self, q):
8434
8513
  self._send_json(_cached_health_payload(
8435
8514
  full_power=True,
@@ -484,35 +484,59 @@ type tailscalePeerNodeResolver struct {
484
484
  localClient func() (whoIsClient, error)
485
485
  }
486
486
 
487
- func (r tailscalePeerNodeResolver) PeerNodeID(ctx context.Context, remoteAddr string) (string, bool) {
487
+ func (r tailscalePeerNodeResolver) PeerNodeID(ctx context.Context, remoteAddr string) (string, string, bool) {
488
488
  if r.localClient == nil {
489
- return "", false
489
+ return "", "", false
490
490
  }
491
491
  lc, err := r.localClient()
492
492
  if err != nil || lc == nil {
493
- return "", false
493
+ return "", "", false
494
494
  }
495
495
  who, err := lc.WhoIs(ctx, remoteAddr)
496
496
  if err != nil {
497
- return "", false
497
+ return "", "", false
498
498
  }
499
499
  return peerNodeIDFromWhoIs(who)
500
500
  }
501
501
 
502
- func peerNodeIDFromWhoIs(who *apitype.WhoIsResponse) (string, bool) {
502
+ // peerNodeIDFromWhoIs resolves a peer's tailnet node ID and its provenance from
503
+ // a WhoIs response. The node ID is always the WhoIs StableID; it is never
504
+ // derived from any client-controlled value. Two provenance paths are admitted:
505
+ //
506
+ // - "tagged": the old minted path, where the node carries tag:pairling-phone.
507
+ // - "interactive": an untagged, user-owned Pairling iOS node from the D2
508
+ // sign-in path, identified by a WhoIs hostname with the pairling-ios- prefix.
509
+ //
510
+ // connectd's hostname gate here is defense-in-depth; pairlingd performs the real
511
+ // bearer + request-proof gate. As a guard against a non-iOS node spoofing the
512
+ // pairling-ios- hostname, an untagged node is rejected when its Hostinfo reports
513
+ // a non-empty OS that is not iOS.
514
+ func peerNodeIDFromWhoIs(who *apitype.WhoIsResponse) (string, string, bool) {
503
515
  if who == nil || who.Node == nil {
504
- return "", false
516
+ return "", "", false
505
517
  }
506
518
  nodeID := strings.TrimSpace(string(who.Node.StableID))
507
519
  if nodeID == "" {
508
- return "", false
520
+ return "", "", false
509
521
  }
510
522
  for _, tag := range who.Node.Tags {
511
523
  if tag == "tag:pairling-phone" {
512
- return nodeID, true
524
+ return nodeID, "tagged", true
513
525
  }
514
526
  }
515
- return "", false
527
+ hostname := who.Node.ComputedName
528
+ if hostname == "" && who.Node.Hostinfo.Valid() {
529
+ hostname = who.Node.Hostinfo.Hostname()
530
+ }
531
+ if strings.HasPrefix(strings.ToLower(hostname), "pairling-ios-") {
532
+ if who.Node.Hostinfo.Valid() {
533
+ if os := who.Node.Hostinfo.OS(); os != "" && !strings.EqualFold(os, "iOS") {
534
+ return "", "", false
535
+ }
536
+ }
537
+ return nodeID, "interactive", true
538
+ }
539
+ return "", "", false
516
540
  }
517
541
 
518
542
  func nodeIdentityFromStatus(st *ipnstate.Status) NodeIdentity {
@@ -34,7 +34,7 @@ func TestPeerNodeResolverUsesWhoIsRemoteAddr(t *testing.T) {
34
34
  },
35
35
  }
36
36
 
37
- nodeID, ok := resolver.PeerNodeID(context.Background(), "100.64.0.50:12345")
37
+ nodeID, provenance, ok := resolver.PeerNodeID(context.Background(), "100.64.0.50:12345")
38
38
  if !ok {
39
39
  t.Fatal("resolver should accept tagged peer")
40
40
  }
@@ -44,10 +44,13 @@ func TestPeerNodeResolverUsesWhoIsRemoteAddr(t *testing.T) {
44
44
  if nodeID != "nPeerCNTRL" {
45
45
  t.Fatalf("node ID = %q", nodeID)
46
46
  }
47
+ if provenance != "tagged" {
48
+ t.Fatalf("provenance = %q, want tagged", provenance)
49
+ }
47
50
  }
48
51
 
49
52
  func TestWhoIsResolvesPeerStableID(t *testing.T) {
50
- nodeID, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
53
+ nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
51
54
  Node: &tailcfg.Node{
52
55
  StableID: tailcfg.StableNodeID("nPeerCNTRL"),
53
56
  Tags: []string{"tag:pairling-phone"},
@@ -60,6 +63,9 @@ func TestWhoIsResolvesPeerStableID(t *testing.T) {
60
63
  if nodeID != "nPeerCNTRL" {
61
64
  t.Fatalf("node ID = %q, want nPeerCNTRL", nodeID)
62
65
  }
66
+ if provenance != "tagged" {
67
+ t.Fatalf("provenance = %q, want tagged", provenance)
68
+ }
63
69
  }
64
70
 
65
71
  func TestAssertsGrantedPhoneTagBeforePersist(t *testing.T) {
@@ -72,15 +78,149 @@ func TestAssertsGrantedPhoneTagBeforePersist(t *testing.T) {
72
78
  }
73
79
  for _, tc := range cases {
74
80
  t.Run(tc.name, func(t *testing.T) {
75
- nodeID, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
81
+ nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
76
82
  Node: &tailcfg.Node{
77
83
  StableID: tailcfg.StableNodeID("nPeerCNTRL"),
78
84
  Tags: tc.tags,
79
85
  },
80
86
  })
81
- if ok || nodeID != "" {
82
- t.Fatalf("wrongly accepted node ID %q with tags %#v", nodeID, tc.tags)
87
+ if ok || nodeID != "" || provenance != "" {
88
+ t.Fatalf("wrongly accepted node ID %q provenance %q with tags %#v", nodeID, provenance, tc.tags)
89
+ }
90
+ })
91
+ }
92
+ }
93
+
94
+ // TestPeerNodeIDProvenanceFromWhoIs covers the interactive-sign-in provenance
95
+ // path: an untagged, user-owned iOS node whose WhoIs hostname starts with
96
+ // pairling-ios- is admitted as "interactive", while a tagged phone is admitted
97
+ // as "tagged". Non-Pairling untagged nodes and OS-mismatched nodes are rejected.
98
+ func TestPeerNodeIDProvenanceFromWhoIs(t *testing.T) {
99
+ cases := []struct {
100
+ name string
101
+ node *tailcfg.Node
102
+ wantNodeID string
103
+ wantProvenance string
104
+ wantOK bool
105
+ }{
106
+ {
107
+ name: "tagged phone",
108
+ node: &tailcfg.Node{
109
+ StableID: tailcfg.StableNodeID("nPeerCNTRL"),
110
+ Tags: []string{"tag:pairling-phone"},
111
+ },
112
+ wantNodeID: "nPeerCNTRL",
113
+ wantProvenance: "tagged",
114
+ wantOK: true,
115
+ },
116
+ {
117
+ name: "untagged interactive ios computed name",
118
+ node: &tailcfg.Node{
119
+ StableID: tailcfg.StableNodeID("nInteractiveIOS"),
120
+ ComputedName: "pairling-ios-b702bb49",
121
+ },
122
+ wantNodeID: "nInteractiveIOS",
123
+ wantProvenance: "interactive",
124
+ wantOK: true,
125
+ },
126
+ {
127
+ name: "untagged non-pairling laptop",
128
+ node: &tailcfg.Node{
129
+ StableID: tailcfg.StableNodeID("nLaptop"),
130
+ ComputedName: "my-laptop",
131
+ },
132
+ wantNodeID: "",
133
+ wantProvenance: "",
134
+ wantOK: false,
135
+ },
136
+ {
137
+ name: "untagged pairling-ios prefix but macOS hostinfo rejected",
138
+ node: &tailcfg.Node{
139
+ StableID: tailcfg.StableNodeID("nFakeIOS"),
140
+ ComputedName: "pairling-ios-x",
141
+ Hostinfo: (&tailcfg.Hostinfo{OS: "macOS"}).View(),
142
+ },
143
+ wantNodeID: "",
144
+ wantProvenance: "",
145
+ wantOK: false,
146
+ },
147
+ {
148
+ name: "empty stable id",
149
+ node: &tailcfg.Node{
150
+ StableID: tailcfg.StableNodeID(""),
151
+ Tags: []string{"tag:pairling-phone"},
152
+ },
153
+ wantNodeID: "",
154
+ wantProvenance: "",
155
+ wantOK: false,
156
+ },
157
+ }
158
+ for _, tc := range cases {
159
+ t.Run(tc.name, func(t *testing.T) {
160
+ nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{Node: tc.node})
161
+ if ok != tc.wantOK {
162
+ t.Fatalf("ok = %t, want %t (nodeID=%q provenance=%q)", ok, tc.wantOK, nodeID, provenance)
163
+ }
164
+ if nodeID != tc.wantNodeID {
165
+ t.Fatalf("nodeID = %q, want %q", nodeID, tc.wantNodeID)
166
+ }
167
+ if provenance != tc.wantProvenance {
168
+ t.Fatalf("provenance = %q, want %q", provenance, tc.wantProvenance)
83
169
  }
84
170
  })
85
171
  }
86
172
  }
173
+
174
+ // TestPeerNodeIDInteractiveAcceptsIOSHostinfo confirms an untagged Pairling iOS
175
+ // node with Hostinfo OS reported as "iOS" (case-insensitive) is still admitted
176
+ // as interactive — the OS check only rejects a non-empty, non-iOS OS.
177
+ func TestPeerNodeIDInteractiveAcceptsIOSHostinfo(t *testing.T) {
178
+ nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
179
+ Node: &tailcfg.Node{
180
+ StableID: tailcfg.StableNodeID("nIOS"),
181
+ ComputedName: "pairling-ios-abc123",
182
+ Hostinfo: (&tailcfg.Hostinfo{OS: "iOS"}).View(),
183
+ },
184
+ })
185
+ if !ok {
186
+ t.Fatal("untagged pairling iOS node with iOS Hostinfo should be admitted")
187
+ }
188
+ if nodeID != "nIOS" {
189
+ t.Fatalf("nodeID = %q, want nIOS", nodeID)
190
+ }
191
+ if provenance != "interactive" {
192
+ t.Fatalf("provenance = %q, want interactive", provenance)
193
+ }
194
+ }
195
+
196
+ // TestPeerNodeIDInteractiveFallsBackToHostinfoHostname confirms that when
197
+ // ComputedName is empty, the WhoIs hostname is derived from a valid Hostinfo's
198
+ // Hostname() and the pairling-ios- prefix is still honored.
199
+ func TestPeerNodeIDInteractiveFallsBackToHostinfoHostname(t *testing.T) {
200
+ nodeID, provenance, ok := peerNodeIDFromWhoIs(&apitype.WhoIsResponse{
201
+ Node: &tailcfg.Node{
202
+ StableID: tailcfg.StableNodeID("nIOSHost"),
203
+ Hostinfo: (&tailcfg.Hostinfo{Hostname: "pairling-ios-fallback", OS: "iOS"}).View(),
204
+ },
205
+ })
206
+ if !ok {
207
+ t.Fatal("untagged pairling iOS node identified via Hostinfo hostname should be admitted")
208
+ }
209
+ if nodeID != "nIOSHost" {
210
+ t.Fatalf("nodeID = %q, want nIOSHost", nodeID)
211
+ }
212
+ if provenance != "interactive" {
213
+ t.Fatalf("provenance = %q, want interactive", provenance)
214
+ }
215
+ }
216
+
217
+ // TestPeerNodeIDRejectsNilNode confirms a nil WhoIs or nil Node yields the
218
+ // empty-rejection tuple.
219
+ func TestPeerNodeIDRejectsNilNode(t *testing.T) {
220
+ for _, who := range []*apitype.WhoIsResponse{nil, {Node: nil}} {
221
+ nodeID, provenance, ok := peerNodeIDFromWhoIs(who)
222
+ if ok || nodeID != "" || provenance != "" {
223
+ t.Fatalf("nil node wrongly accepted: nodeID=%q provenance=%q ok=%t", nodeID, provenance, ok)
224
+ }
225
+ }
226
+ }
@@ -21,6 +21,18 @@ const pairDropSmallFileMaxBodyBytes int64 = 10 * 1024 * 1024
21
21
  const pairDropUploadChunkMaxBodyBytes int64 = 1024 * 1024
22
22
  const peerNodeHeader = "X-Pairling-Peer-Node"
23
23
 
24
+ // peerProvenanceHeader tells pairlingd how connectd identified the peer node:
25
+ // "tagged" (the old minted tag:pairling-phone path) or "interactive" (an
26
+ // untagged, user-owned Pairling iOS node admitted via the D2 sign-in path).
27
+ // connectd deletes any inbound copy before re-injecting the resolver's value, so
28
+ // a client can never forge it.
29
+ const peerProvenanceHeader = "X-Pairling-Peer-Provenance"
30
+
31
+ const (
32
+ provenanceTagged = "tagged"
33
+ provenanceInteractive = "interactive"
34
+ )
35
+
24
36
  // funnelOriginHeader marks a request that arrived over the public Funnel
25
37
  // listener. connectd sets it only on the funnel handler and deletes any inbound
26
38
  // copy first; every other handler deletes it, so a client can never forge it.
@@ -50,7 +62,7 @@ type Logger interface {
50
62
  }
51
63
 
52
64
  type PeerNodeResolver interface {
53
- PeerNodeID(ctx context.Context, remoteAddr string) (string, bool)
65
+ PeerNodeID(ctx context.Context, remoteAddr string) (nodeID string, provenance string, ok bool)
54
66
  }
55
67
 
56
68
  type Event struct {
@@ -222,14 +234,16 @@ func (h *Handler) rewrite(r *httputil.ProxyRequest) {
222
234
  r.Out.Host = h.upstream.Host
223
235
  r.Out.Header.Del("X-Forwarded-For")
224
236
  r.Out.Header.Del(peerNodeHeader)
237
+ r.Out.Header.Del(peerProvenanceHeader)
225
238
  r.Out.Header.Del(funnelOriginHeader)
226
239
  if h.mode == ExposureModeFunnelBootstrap {
227
240
  r.Out.Header.Set(funnelOriginHeader, "1")
228
241
  }
229
242
  if h.peerNodeResolver != nil {
230
- if nodeID, ok := h.peerNodeResolver.PeerNodeID(in.Context(), in.RemoteAddr); ok {
243
+ if nodeID, provenance, ok := h.peerNodeResolver.PeerNodeID(in.Context(), in.RemoteAddr); ok {
231
244
  if nodeID = strings.TrimSpace(nodeID); nodeID != "" {
232
245
  r.Out.Header.Set(peerNodeHeader, nodeID)
246
+ r.Out.Header.Set(peerProvenanceHeader, provenance)
233
247
  }
234
248
  }
235
249
  }
@@ -21,9 +21,9 @@ type recordingLogger struct {
21
21
  events []Event
22
22
  }
23
23
 
24
- type peerNodeResolverFunc func(context.Context, string) (string, bool)
24
+ type peerNodeResolverFunc func(context.Context, string) (string, string, bool)
25
25
 
26
- func (f peerNodeResolverFunc) PeerNodeID(ctx context.Context, remoteAddr string) (string, bool) {
26
+ func (f peerNodeResolverFunc) PeerNodeID(ctx context.Context, remoteAddr string) (string, string, bool) {
27
27
  return f(ctx, remoteAddr)
28
28
  }
29
29
 
@@ -451,9 +451,11 @@ func TestPairlingConnectStripsForgedPeerNodeHeader(t *testing.T) {
451
451
 
452
452
  func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
453
453
  var forwardedHeader string
454
+ var forwardedProvenance string
454
455
  var resolvedRemote string
455
456
  upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
456
457
  forwardedHeader = r.Header.Get("X-Pairling-Peer-Node")
458
+ forwardedProvenance = r.Header.Get("X-Pairling-Peer-Provenance")
457
459
  w.WriteHeader(http.StatusOK)
458
460
  }))
459
461
  defer upstream.Close()
@@ -465,9 +467,9 @@ func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
465
467
  Upstream: upstreamURL,
466
468
  MaxBodyBytes: 1024,
467
469
  Mode: ExposureModePairlingConnect,
468
- PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, remoteAddr string) (string, bool) {
470
+ PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, remoteAddr string) (string, string, bool) {
469
471
  resolvedRemote = remoteAddr
470
- return "nPeerCNTRL", true
472
+ return "nPeerCNTRL", "tagged", true
471
473
  }),
472
474
  })
473
475
  if err != nil {
@@ -489,6 +491,103 @@ func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
489
491
  if forwardedHeader != "nPeerCNTRL" {
490
492
  t.Fatalf("peer-node header = %q, want trusted resolver value", forwardedHeader)
491
493
  }
494
+ if forwardedProvenance != "tagged" {
495
+ t.Fatalf("peer-provenance header = %q, want tagged", forwardedProvenance)
496
+ }
497
+ }
498
+
499
+ // TestRewriteStripsForgedProvenanceAndReinjectsFromResolver proves the rewrite
500
+ // step deletes BOTH client-supplied X-Pairling-Peer-Node and
501
+ // X-Pairling-Peer-Provenance headers, then re-injects them from the resolver's
502
+ // trusted return values. A forged "tagged" provenance from the client must not
503
+ // survive when the resolver reports "interactive".
504
+ func TestRewriteStripsForgedProvenanceAndReinjectsFromResolver(t *testing.T) {
505
+ var forwardedHeader string
506
+ var forwardedProvenance string
507
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
508
+ forwardedHeader = r.Header.Get("X-Pairling-Peer-Node")
509
+ forwardedProvenance = r.Header.Get("X-Pairling-Peer-Provenance")
510
+ w.WriteHeader(http.StatusOK)
511
+ }))
512
+ defer upstream.Close()
513
+ upstreamURL, err := url.Parse(upstream.URL)
514
+ if err != nil {
515
+ t.Fatal(err)
516
+ }
517
+ handler, err := NewHandler(Options{
518
+ Upstream: upstreamURL,
519
+ MaxBodyBytes: 1024,
520
+ Mode: ExposureModePairlingConnect,
521
+ PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, _ string) (string, string, bool) {
522
+ return "nResolverIOS", "interactive", true
523
+ }),
524
+ })
525
+ if err != nil {
526
+ t.Fatal(err)
527
+ }
528
+
529
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
530
+ req.RemoteAddr = "100.64.0.50:12345"
531
+ req.Header.Set("Authorization", "Bearer device-token")
532
+ req.Header.Set("X-Pairling-Peer-Node", "forged-node")
533
+ req.Header.Set("X-Pairling-Peer-Provenance", "tagged")
534
+ rec := httptest.NewRecorder()
535
+ handler.ServeHTTP(rec, req)
536
+ if rec.Code != http.StatusOK {
537
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
538
+ }
539
+ if forwardedHeader != "nResolverIOS" {
540
+ t.Fatalf("peer-node header = %q, want resolver value nResolverIOS", forwardedHeader)
541
+ }
542
+ if forwardedProvenance != "interactive" {
543
+ t.Fatalf("peer-provenance header = %q, want resolver value interactive", forwardedProvenance)
544
+ }
545
+ }
546
+
547
+ // TestRewriteInjectsNoHeadersWhenResolverRejects proves that when the resolver
548
+ // returns ok=false, NEITHER the peer-node NOR the peer-provenance header reaches
549
+ // the upstream, and any client-supplied copies are stripped.
550
+ func TestRewriteInjectsNoHeadersWhenResolverRejects(t *testing.T) {
551
+ var sawNode bool
552
+ var sawProvenance bool
553
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
554
+ _, sawNode = r.Header["X-Pairling-Peer-Node"]
555
+ _, sawProvenance = r.Header["X-Pairling-Peer-Provenance"]
556
+ w.WriteHeader(http.StatusOK)
557
+ }))
558
+ defer upstream.Close()
559
+ upstreamURL, err := url.Parse(upstream.URL)
560
+ if err != nil {
561
+ t.Fatal(err)
562
+ }
563
+ handler, err := NewHandler(Options{
564
+ Upstream: upstreamURL,
565
+ MaxBodyBytes: 1024,
566
+ Mode: ExposureModePairlingConnect,
567
+ PeerNodeResolver: peerNodeResolverFunc(func(_ context.Context, _ string) (string, string, bool) {
568
+ return "", "", false
569
+ }),
570
+ })
571
+ if err != nil {
572
+ t.Fatal(err)
573
+ }
574
+
575
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
576
+ req.RemoteAddr = "100.64.0.50:12345"
577
+ req.Header.Set("Authorization", "Bearer device-token")
578
+ req.Header.Set("X-Pairling-Peer-Node", "forged-node")
579
+ req.Header.Set("X-Pairling-Peer-Provenance", "interactive")
580
+ rec := httptest.NewRecorder()
581
+ handler.ServeHTTP(rec, req)
582
+ if rec.Code != http.StatusOK {
583
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
584
+ }
585
+ if sawNode {
586
+ t.Fatal("peer-node header reached upstream when resolver rejected")
587
+ }
588
+ if sawProvenance {
589
+ t.Fatal("peer-provenance header reached upstream when resolver rejected")
590
+ }
492
591
  }
493
592
 
494
593
  func TestPairlingConnectModeMatchesEndpointContract(t *testing.T) {
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "connectd": {
3
3
  "darwin-arm64": {
4
- "sha256": "d4d9439eae6cfa244b491b5086050f932fe78058e5ac18a876b80b20ea8517ad",
4
+ "sha256": "3ee070f12619390609a8c76e0ae4803ab1bc48c9f77361b8f27a4125846f38e3",
5
5
  "team_id": "965AVD34A3"
6
6
  },
7
7
  "darwin-x64": {
8
- "sha256": "936c7046bfc3cd84289881444da57ad025d5c734c44a01c2dcbd9eb2afc7cbb8",
8
+ "sha256": "c1ee211c3da71200d7855c9246ea04cd25fda702f7fb33a366b4d51bdaf9e230",
9
9
  "team_id": "965AVD34A3"
10
10
  }
11
11
  },
@@ -20,11 +20,11 @@
20
20
  },
21
21
  {
22
22
  "path": "payload/mac/SOURCE_REVISION",
23
- "sha256": "354ac93fcb550a4bf353e3857282bafb747f158ce1ad6e7068a5d85c5cd071bd"
23
+ "sha256": "29c2c816c529155cc86f26d44a8e8d79fc170fc26ade74fac3780554b5c864ff"
24
24
  },
25
25
  {
26
26
  "path": "payload/mac/VERSION",
27
- "sha256": "283571d2642fc7b3befd294f45d53b896a1d54d34c86235b13151192b380606a"
27
+ "sha256": "2f2b333be8858cb384114251b668bb1c9e45a0bb8393600b7ad4093d0e8e72f6"
28
28
  },
29
29
  {
30
30
  "path": "payload/mac/companiond/app_attest_lan.py",
@@ -96,7 +96,7 @@
96
96
  },
97
97
  {
98
98
  "path": "payload/mac/companiond/pairlingd.py",
99
- "sha256": "e69188ec651d0a04af5cad73c9dd0a81dfa6455b6e32f8b733c412d11a4ead9e"
99
+ "sha256": "ca1c5551dd235990d4ff8d8b9c3c3f25e912ca9c22c7003f50c9fe1ccb6bf02e"
100
100
  },
101
101
  {
102
102
  "path": "payload/mac/companiond/providers/__init__.py",
@@ -200,11 +200,11 @@
200
200
  },
201
201
  {
202
202
  "path": "payload/mac/connectd/cmd/pairling-connectd/main.go",
203
- "sha256": "4582664c189db3c13fef81328172d69fa0bcd6b064396c0a811a154da98f7f21"
203
+ "sha256": "019288bd16c6e900c60206ee4739f9ae5ed640ea73cd57db6a1f0d618c9bf00c"
204
204
  },
205
205
  {
206
206
  "path": "payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go",
207
- "sha256": "044c1260387b990de073d4f09fa516c3ce15ded7c255b5e1ac3d57f3f5810c27"
207
+ "sha256": "eb1f44e588c2d70e2660f805ee6b7fe09aa793365c0ae442164785a8241c35d8"
208
208
  },
209
209
  {
210
210
  "path": "payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go",
@@ -232,11 +232,11 @@
232
232
  },
233
233
  {
234
234
  "path": "payload/mac/connectd/internal/gateway/proxy.go",
235
- "sha256": "ad7de08a59819fcbdd81f62d707738af128064cc24cea576674aee2f0960c267"
235
+ "sha256": "b852408a35b71a0b62554cc93f413c3352034432f14529ab3ddbe85fa5ee49c8"
236
236
  },
237
237
  {
238
238
  "path": "payload/mac/connectd/internal/gateway/proxy_test.go",
239
- "sha256": "b02cd354dcd3292079f35ee86a1ce75e3ed8286550f064fdae7914e661464910"
239
+ "sha256": "f3a6b1974c1ccba8e5e380d18c31a5aca61458b6d4a1075865b8978e86e54209"
240
240
  },
241
241
  {
242
242
  "path": "payload/mac/connectd/internal/runtime/config.go",
@@ -296,8 +296,8 @@
296
296
  }
297
297
  ],
298
298
  "package": "pairling",
299
- "package_version": "0.2.8",
299
+ "package_version": "0.2.9",
300
300
  "schema_version": 1,
301
301
  "source_dirty": false,
302
- "source_revision": "c07a6fd"
302
+ "source_revision": "c97bbab"
303
303
  }