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
package/README.md CHANGED
@@ -27,11 +27,11 @@ Then open Pairling on your iPhone and scan the QR code that `setup` prints.
27
27
  - Stages the runtime under `~/Library/Application Support/Pairling/runtime/`
28
28
  (versioned releases, atomic `current` symlink flip, `pairling rollback`).
29
29
  - Installs user-domain LaunchAgents (`dev.pairling.companiond`,
30
- `dev.pairling.connectd`). No root. The optional power guardian is a separate,
31
- explicit, sudo-gated step.
30
+ `dev.pairling.connectd`). No root. The optional power guardian and the
31
+ optional silent-join mint broker are separate, explicit, sudo-gated steps.
32
32
  - Verifies the payload against the package's integrity manifest and verifies
33
- the Developer ID signature of the bundled `pairling-connectd` binary before
34
- staging — fail closed.
33
+ the Developer ID signature of the bundled `pairling-connectd` and
34
+ `pairling-tailnet-mintd` binaries before staging — fail closed.
35
35
 
36
36
  ## Commands
37
37
 
@@ -51,8 +51,9 @@ pairling uninstall [--yes]
51
51
  appears in a published manifest.
52
52
  - **Provenance:** releases are published via npm Trusted Publishing (OIDC) with
53
53
  provenance attestations. Verify with `npm audit signatures`.
54
- - **Readable payload:** the runtime is Python/bash source plus one signed Go
55
- binary; inspect it with `npm pack pairling --dry-run`.
54
+ - **Readable payload:** the runtime is Python/bash source plus two signed Go
55
+ binaries (`pairling-connectd` and the `pairling-tailnet-mintd` broker);
56
+ inspect it with `npm pack pairling --dry-run`.
56
57
  - **Integrity chain:** CI records SHA-256 of every payload file in
57
58
  `payload-manifest.json`; `pairling setup` re-verifies before staging;
58
59
  `pairling doctor` re-verifies the staged runtime and the binary signature.
@@ -61,9 +62,10 @@ pairling uninstall [--yes]
61
62
 
62
63
  ## Platform packages
63
64
 
64
- The compiled runtime binary ships as platform-filtered optional dependencies:
65
- `@pairling/runtime-darwin-arm64` and `@pairling/runtime-darwin-x64` — signed,
66
- notarized, and hash-pinned by this package's integrity manifest.
65
+ The compiled runtime binaries (`pairling-connectd` and the optional
66
+ `pairling-tailnet-mintd` silent-join broker) ship as platform-filtered optional
67
+ dependencies: `@pairling/runtime-darwin-arm64` and `@pairling/runtime-darwin-x64`
68
+ — signed, notarized, and hash-pinned by each package's integrity manifest.
67
69
 
68
70
  ## Links
69
71
 
package/bin/pairling.mjs CHANGED
@@ -92,6 +92,7 @@ function detectRosetta() {
92
92
  function shimEnv() {
93
93
  const runtimeDir = runtimePackageDir();
94
94
  const connectd = runtimeDir ? join(runtimeDir, "bin", "pairling-connectd") : null;
95
+ const mintd = runtimeDir ? join(runtimeDir, "bin", "pairling-tailnet-mintd") : null;
95
96
  const vendoredPython = runtimeDir ? join(runtimeDir, "python", "bin", "python3") : null;
96
97
  return {
97
98
  packageRoot,
@@ -100,6 +101,7 @@ function shimEnv() {
100
101
  payloadRoot,
101
102
  runtimePackageDir: runtimeDir,
102
103
  connectdPath: connectd && existsSync(connectd) ? connectd : null,
104
+ mintdPath: mintd && existsSync(mintd) ? mintd : null,
103
105
  vendoredPython: vendoredPython && existsSync(vendoredPython) ? vendoredPython : null,
104
106
  stagedCli: stagedCliPath(),
105
107
  stagedRuntimeVersion: stagedRuntimeVersion(),
@@ -187,10 +189,10 @@ function main() {
187
189
 
188
190
  if (existsSync(payloadCli)) {
189
191
  const env = shimEnv();
190
- if (!env.runtimePackageDir || !env.connectdPath) {
192
+ if (!env.runtimePackageDir || !env.connectdPath || !env.mintdPath) {
191
193
  process.stderr.write(
192
194
  [
193
- "pairling: the platform runtime package is missing.",
195
+ "pairling: the platform runtime package is missing or incomplete.",
194
196
  "",
195
197
  `Expected: @pairling/runtime-darwin-${process.arch === "arm64" ? "arm64" : "x64"}`,
196
198
  "",
@@ -206,6 +208,7 @@ function main() {
206
208
  delegate(payloadCli, args, {
207
209
  PAIRLING_REPO_ROOT: join(payloadRoot, "."),
208
210
  PAIRLING_CONNECTD_PREBUILT: env.connectdPath,
211
+ PAIRLING_MINTD_PREBUILT: env.mintdPath,
209
212
  PAIRLING_DAEMON_PYTHON: env.vendoredPython,
210
213
  });
211
214
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pairling",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
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.5",
44
- "@pairling/runtime-darwin-x64": "0.2.5"
43
+ "@pairling/runtime-darwin-arm64": "0.2.7",
44
+ "@pairling/runtime-darwin-x64": "0.2.7"
45
45
  }
46
46
  }
@@ -1 +1 @@
1
- 2f094bd
1
+ 102b7cf
@@ -1 +1 @@
1
- 0.2.5
1
+ 0.2.7
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import ipaddress
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import urllib.parse
7
8
  import urllib.request
8
9
  from typing import Any
@@ -10,7 +11,9 @@ from typing import Any
10
11
  CONNECTD_STATUS_URL = "http://127.0.0.1:7774/status"
11
12
  PAIRLING_CONNECT_ROUTE_SOURCE = "pairling_connectd"
12
13
  PAIRLING_CONNECT_ROUTE_KIND = "tailnet"
14
+ PAIRLING_CONNECT_FUNNEL_KIND = "funnel"
13
15
  PAIRLING_CONNECT_PORT = 7773
16
+ PAIRLING_CONNECT_FUNNEL_PORT = 443
14
17
 
15
18
 
16
19
  def fetch_connectd_status(timeout_seconds: float = 1.5) -> dict[str, Any]:
@@ -41,7 +44,8 @@ def advertised_pairling_connect_routes(status: dict[str, Any]) -> list[dict[str,
41
44
  continue
42
45
  if route.get("source") != PAIRLING_CONNECT_ROUTE_SOURCE:
43
46
  continue
44
- if route.get("kind") != PAIRLING_CONNECT_ROUTE_KIND:
47
+ kind = route.get("kind")
48
+ if kind not in (PAIRLING_CONNECT_ROUTE_KIND, PAIRLING_CONNECT_FUNNEL_KIND):
45
49
  continue
46
50
  if route.get("status") != "ready":
47
51
  continue
@@ -50,16 +54,25 @@ def advertised_pairling_connect_routes(status: dict[str, Any]) -> list[dict[str,
50
54
  port = int(route.get("port") or 0)
51
55
  except (TypeError, ValueError):
52
56
  continue
53
- if port != PAIRLING_CONNECT_PORT or not _is_tailnet_host(host):
54
- continue
55
- base_url = _sanitized_base_url(route.get("base_url"), host, port)
57
+ if kind == PAIRLING_CONNECT_FUNNEL_KIND:
58
+ # Funnel route: public https on a *.ts.net host, lowest priority. The
59
+ # tailnet branch below is unchanged and not loosened.
60
+ base_url = _sanitized_funnel_base_url(route.get("base_url"), host, port)
61
+ default_id = "pairling-connect-funnel"
62
+ default_priority = 10
63
+ else:
64
+ if port != PAIRLING_CONNECT_PORT or not _is_tailnet_host(host):
65
+ continue
66
+ base_url = _sanitized_base_url(route.get("base_url"), host, port)
67
+ default_id = "pairling-connect-tailnet"
68
+ default_priority = 100
56
69
  if not base_url:
57
70
  continue
58
71
  valid = {
59
- "id": str(route.get("id") or "pairling-connect-tailnet"),
60
- "kind": PAIRLING_CONNECT_ROUTE_KIND,
72
+ "id": str(route.get("id") or default_id),
73
+ "kind": kind,
61
74
  "source": PAIRLING_CONNECT_ROUTE_SOURCE,
62
- "priority": int(route.get("priority") or 100),
75
+ "priority": int(route.get("priority") or default_priority),
63
76
  "base_url": base_url,
64
77
  "host": host,
65
78
  "port": port,
@@ -87,6 +100,9 @@ def redacted_connectd_summary(status: dict[str, Any]) -> dict[str, Any]:
87
100
  "route": routes[0] if routes else None,
88
101
  "auth_url_present": bool(status.get("auth_url_present")) if isinstance(status, dict) else False,
89
102
  "tailnet_ip_count": int(status.get("tailnet_ip_count") or 0) if isinstance(status, dict) else 0,
103
+ "tailnet_node_id": _identity_string(status.get("tailnet_node_id")) if isinstance(status, dict) else "",
104
+ "tags": _identity_list(status.get("tags")) if isinstance(status, dict) else [],
105
+ "tailnet_ips": _identity_list(status.get("tailnet_ips")) if isinstance(status, dict) else [],
90
106
  "listener_running": bool(status.get("listener_running")) if isinstance(status, dict) else False,
91
107
  "upstream_reachable": bool(status.get("upstream_reachable")) if isinstance(status, dict) else False,
92
108
  "local_pairing_available": True,
@@ -128,6 +144,26 @@ def _next_action(status: dict[str, Any], route_ready: bool) -> dict[str, str]:
128
144
  }
129
145
 
130
146
 
147
+ def _string_list(value: Any) -> list[str]:
148
+ if not isinstance(value, list):
149
+ return []
150
+ return [str(item) for item in value if isinstance(item, (str, int, float))]
151
+
152
+
153
+ _IDENTITY_SECRET_RE = re.compile(r"(tskey|authkey|client_secret|nlprivate)", re.I)
154
+
155
+
156
+ def _identity_string(value: Any) -> str:
157
+ raw = str(value or "").strip()
158
+ if _IDENTITY_SECRET_RE.search(raw):
159
+ return "[redacted]"
160
+ return raw
161
+
162
+
163
+ def _identity_list(value: Any) -> list[str]:
164
+ return [_identity_string(item) for item in _string_list(value)]
165
+
166
+
131
167
  def _sanitized_base_url(value: Any, host: str, port: int) -> str | None:
132
168
  raw = str(value or f"http://{host}:{port}").strip()
133
169
  try:
@@ -139,6 +175,20 @@ def _sanitized_base_url(value: Any, host: str, port: int) -> str | None:
139
175
  return urllib.parse.urlunparse(("http", f"{host}:{port}", "", "", "", ""))
140
176
 
141
177
 
178
+ def _sanitized_funnel_base_url(value: Any, host: str, port: int) -> str | None:
179
+ # A funnel route is accepted only as https on a *.ts.net host at port 443.
180
+ if not host.endswith(".ts.net") or port != PAIRLING_CONNECT_FUNNEL_PORT:
181
+ return None
182
+ raw = str(value or f"https://{host}").strip()
183
+ try:
184
+ parsed = urllib.parse.urlparse(raw)
185
+ except Exception:
186
+ return None
187
+ if parsed.scheme != "https" or parsed.hostname != host or parsed.port not in (None, PAIRLING_CONNECT_FUNNEL_PORT):
188
+ return None
189
+ return urllib.parse.urlunparse(("https", host, "", "", "", ""))
190
+
191
+
142
192
  def _is_tailnet_host(host: str) -> bool:
143
193
  if host.endswith(".ts.net"):
144
194
  return True
@@ -171,6 +171,7 @@ class DeviceRegistry:
171
171
  "device_display_name": "ALTER TABLE devices ADD COLUMN device_display_name TEXT",
172
172
  "superseded_by_device_id": "ALTER TABLE devices ADD COLUMN superseded_by_device_id TEXT",
173
173
  "proof_secret": "ALTER TABLE devices ADD COLUMN proof_secret TEXT",
174
+ "tailnet_node_id": "ALTER TABLE devices ADD COLUMN tailnet_node_id TEXT",
174
175
  # WS4: base64 X9.63 (uncompressed P-256 point) of the device's
175
176
  # Secure-Enclave public key, registered at first pair. Used to
176
177
  # verify zero-interaction re-pair challenge signatures.
@@ -361,6 +362,40 @@ class DeviceRegistry:
361
362
  scopes=scopes,
362
363
  )
363
364
 
365
+ def tailnet_node_id(self, device_id: str) -> str | None:
366
+ with self.connect() as conn:
367
+ row = conn.execute(
368
+ "SELECT tailnet_node_id FROM devices WHERE device_id = ?",
369
+ (device_id,),
370
+ ).fetchone()
371
+ return None if row is None else row["tailnet_node_id"]
372
+
373
+ def set_tailnet_node_id_if_absent(self, device_id: str, node_id: str) -> bool:
374
+ node_id = str(node_id or "").strip()
375
+ if not device_id or not node_id:
376
+ return False
377
+ with self.connect() as conn:
378
+ cur = conn.execute(
379
+ """
380
+ UPDATE devices
381
+ SET tailnet_node_id = ?
382
+ WHERE device_id = ?
383
+ AND revoked_at IS NULL
384
+ AND (tailnet_node_id IS NULL OR tailnet_node_id = '')
385
+ """,
386
+ (node_id, device_id),
387
+ )
388
+ changed = cur.rowcount > 0
389
+ if changed:
390
+ self.record_audit(
391
+ "device.tailnet_node_id.bound",
392
+ device_id=device_id,
393
+ outcome="ok",
394
+ detail={"tailnet_node_id": node_id},
395
+ conn=conn,
396
+ )
397
+ return changed
398
+
364
399
  def revoke_device(self, device_id: str, *, reason: str = "revoked") -> bool:
365
400
  with self.connect() as conn:
366
401
  cur = conn.execute(
@@ -159,6 +159,9 @@ class PairClaim:
159
159
  cert_pin: str | None
160
160
  relay_device_id: str | None = None
161
161
  attestation_status: str = "none"
162
+ # Increment 5: True only when a direct App Attest attestation was verified for
163
+ # this claim. Distinct from attestation_status, which is the relay-path field.
164
+ direct_attestation_verified: bool = False
162
165
 
163
166
 
164
167
  class PairingError(Exception):
@@ -401,6 +404,42 @@ class PairingStore:
401
404
  raise PairingError("pair_locked", 429, "too many claim attempts")
402
405
  return record, path, now
403
406
 
407
+ def _verify_claim_attestation(
408
+ self,
409
+ *,
410
+ pair_id: str,
411
+ record: dict,
412
+ attest_object: dict | None,
413
+ attest_key_id: str,
414
+ attest_environment: str,
415
+ require: bool,
416
+ force_production: bool,
417
+ ) -> bool:
418
+ """Verify direct App Attest. Returns True when a valid attestation was
419
+ verified, False when none was required and none supplied. Raises on
420
+ failure or when required-and-missing. force_production pins the
421
+ environment so a funnel client cannot send 'development'."""
422
+ if not (require or _direct_attest_required() or attest_object):
423
+ return False
424
+ if not attest_object:
425
+ raise PairingError("direct_attest_required", 403, "app attest required")
426
+ if _verify_direct_attestation is None:
427
+ raise PairingError("direct_attest_unavailable", 503, "app attest validator unavailable")
428
+ environment = "production" if force_production else attest_environment
429
+ try:
430
+ _verify_direct_attestation(
431
+ attestation=attest_object,
432
+ pair_id=pair_id,
433
+ attest_challenge=str(record.get("attest_challenge") or ""),
434
+ key_id=attest_key_id,
435
+ environment=environment,
436
+ )
437
+ except PairingError:
438
+ raise
439
+ except Exception:
440
+ raise PairingError("direct_attest_invalid", 403, "app attest validation failed")
441
+ return True
442
+
404
443
  def _finalize_claim(
405
444
  self,
406
445
  *,
@@ -420,31 +459,24 @@ class PairingStore:
420
459
  relay_device_id: str | None,
421
460
  relay_required: bool,
422
461
  relay_claim_verifier,
462
+ funnel_origin: bool = False,
463
+ attestation_verified: bool | None = None,
423
464
  ) -> PairClaim:
424
465
  """Post-authentication finalize, shared by legacy and PSK claims. The
425
466
  caller holds _claim_lock and has already proven secret-knowledge (legacy
426
467
  compare or PSK key-confirmation): App Attest, relay ticket, device
427
468
  creation, SE pubkey registration, record teardown."""
428
- # WS2: direct-LAN App Attest. When required (or opportunistically
429
- # supplied), the claimant must present a valid Apple attestation bound to
430
- # this invitation. Fails closed if the validator is unavailable while on.
431
- if _direct_attest_required() or attest_object:
432
- if not attest_object:
433
- raise PairingError("direct_attest_required", 403, "app attest required")
434
- if _verify_direct_attestation is None:
435
- raise PairingError("direct_attest_unavailable", 503, "app attest validator unavailable")
436
- try:
437
- _verify_direct_attestation(
438
- attestation=attest_object,
439
- pair_id=pair_id,
440
- attest_challenge=str(record.get("attest_challenge") or ""),
441
- key_id=attest_key_id,
442
- environment=attest_environment,
443
- )
444
- except PairingError:
445
- raise
446
- except Exception:
447
- raise PairingError("direct_attest_invalid", 403, "app attest validation failed")
469
+ # WS2 + Increment 5: direct App Attest. For a funnel-origin claim it is a
470
+ # hard, fail-closed requirement (verified earlier, before the derive). For
471
+ # LAN and tailnet claims it stays opportunistic. attestation_verified
472
+ # carries a result the caller already computed (the funnel pre-derive
473
+ # check); otherwise verify here.
474
+ if attestation_verified is None:
475
+ attestation_verified = self._verify_claim_attestation(
476
+ pair_id=pair_id, record=record, attest_object=attest_object,
477
+ attest_key_id=attest_key_id, attest_environment=attest_environment,
478
+ require=funnel_origin, force_production=funnel_origin,
479
+ )
448
480
  relay_status = "none"
449
481
  verified_relay_device_id = relay_device_id
450
482
  relay_pair_secret = None
@@ -512,6 +544,7 @@ class PairingStore:
512
544
  cert_pin,
513
545
  verified_relay_device_id,
514
546
  relay_status,
547
+ bool(attestation_verified),
515
548
  )
516
549
  except Exception:
517
550
  self._delete_record(marker)
@@ -535,6 +568,7 @@ class PairingStore:
535
568
  relay_device_id: str | None = None,
536
569
  relay_required: bool = False,
537
570
  relay_claim_verifier=None,
571
+ funnel_origin: bool = False,
538
572
  ) -> tuple[PairClaim, bytes, bytes, bytes]:
539
573
  """WS3 PSK-authenticated ECDH claim. The secret is NEVER received; the
540
574
  caller proves knowledge of it by completing the authenticated key
@@ -549,6 +583,18 @@ class PairingStore:
549
583
  except Exception:
550
584
  raise PairingError("psk_bad_key", 400, "invalid psk material")
551
585
  with self._claim_lock:
586
+ attestation_verified = None
587
+ if funnel_origin:
588
+ # Funnel claims prove a genuine device BEFORE the attempt counter
589
+ # and the ECDH derive, so an un-attested spray cannot lock out a
590
+ # live invitation or force crypto work. Fail-closed regardless of
591
+ # the env default, with the environment pinned to production.
592
+ funnel_record, _ = self._load_record(pair_id)
593
+ attestation_verified = self._verify_claim_attestation(
594
+ pair_id=pair_id, record=funnel_record, attest_object=attest_object,
595
+ attest_key_id=attest_key_id, attest_environment=attest_environment,
596
+ require=True, force_production=True,
597
+ )
552
598
  record, path, now = self._precheck_claim(pair_id)
553
599
  secret = str(record.get("secret") or "")
554
600
  mac_priv_b64 = str(record.get("mac_ake_priv") or "")
@@ -578,6 +624,7 @@ class PairingStore:
578
624
  attested_claim_ticket=attested_claim_ticket,
579
625
  relay_device_id=relay_device_id, relay_required=relay_required,
580
626
  relay_claim_verifier=relay_claim_verifier,
627
+ funnel_origin=funnel_origin, attestation_verified=attestation_verified,
581
628
  )
582
629
  aad = _psk.transcript(pair_id, a_pub, b_pub)
583
630
  mac_confirm = _psk.confirm_tag(k_confirm, _psk.CONFIRM_MAC, pair_id, a_pub, b_pub)