pairling 0.2.7 → 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/README.md +10 -11
- package/bin/pairling.mjs +1 -4
- package/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairlingd.py +82 -145
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +33 -9
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +145 -5
- package/payload/mac/connectd/internal/gateway/proxy.go +16 -2
- package/payload/mac/connectd/internal/gateway/proxy_test.go +103 -4
- package/payload/mac/install/install-runtime.sh +113 -294
- package/payload/mac/install/render-launchd.py +2 -45
- package/payload/mac/install/uninstall-runtime.sh +32 -0
- package/payload-manifest.json +14 -36
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +0 -121
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +0 -418
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +0 -894
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
|
|
31
|
-
|
|
30
|
+
`dev.pairling.connectd`). No root. The optional power guardian is a separate,
|
|
31
|
+
explicit, sudo-gated step.
|
|
32
32
|
- Verifies the payload against the package's integrity manifest and verifies
|
|
33
|
-
the Developer ID signature of the bundled `pairling-connectd`
|
|
34
|
-
|
|
33
|
+
the Developer ID signature of the bundled `pairling-connectd` binary before
|
|
34
|
+
staging — fail closed.
|
|
35
35
|
|
|
36
36
|
## Commands
|
|
37
37
|
|
|
@@ -51,9 +51,8 @@ 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
|
|
55
|
-
|
|
56
|
-
inspect it with `npm pack pairling --dry-run`.
|
|
54
|
+
- **Readable payload:** the runtime is Python/bash source plus one signed Go
|
|
55
|
+
binary (`pairling-connectd`); inspect it with `npm pack pairling --dry-run`.
|
|
57
56
|
- **Integrity chain:** CI records SHA-256 of every payload file in
|
|
58
57
|
`payload-manifest.json`; `pairling setup` re-verifies before staging;
|
|
59
58
|
`pairling doctor` re-verifies the staged runtime and the binary signature.
|
|
@@ -62,10 +61,10 @@ pairling uninstall [--yes]
|
|
|
62
61
|
|
|
63
62
|
## Platform packages
|
|
64
63
|
|
|
65
|
-
The compiled runtime
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
The compiled runtime binary (`pairling-connectd`) ships via platform-filtered
|
|
65
|
+
optional dependencies: `@pairling/runtime-darwin-arm64` and
|
|
66
|
+
`@pairling/runtime-darwin-x64` — signed, notarized, and hash-pinned by each
|
|
67
|
+
package's integrity manifest.
|
|
69
68
|
|
|
70
69
|
## Links
|
|
71
70
|
|
package/bin/pairling.mjs
CHANGED
|
@@ -92,7 +92,6 @@ 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;
|
|
96
95
|
const vendoredPython = runtimeDir ? join(runtimeDir, "python", "bin", "python3") : null;
|
|
97
96
|
return {
|
|
98
97
|
packageRoot,
|
|
@@ -101,7 +100,6 @@ function shimEnv() {
|
|
|
101
100
|
payloadRoot,
|
|
102
101
|
runtimePackageDir: runtimeDir,
|
|
103
102
|
connectdPath: connectd && existsSync(connectd) ? connectd : null,
|
|
104
|
-
mintdPath: mintd && existsSync(mintd) ? mintd : null,
|
|
105
103
|
vendoredPython: vendoredPython && existsSync(vendoredPython) ? vendoredPython : null,
|
|
106
104
|
stagedCli: stagedCliPath(),
|
|
107
105
|
stagedRuntimeVersion: stagedRuntimeVersion(),
|
|
@@ -189,7 +187,7 @@ function main() {
|
|
|
189
187
|
|
|
190
188
|
if (existsSync(payloadCli)) {
|
|
191
189
|
const env = shimEnv();
|
|
192
|
-
if (!env.runtimePackageDir || !env.connectdPath
|
|
190
|
+
if (!env.runtimePackageDir || !env.connectdPath) {
|
|
193
191
|
process.stderr.write(
|
|
194
192
|
[
|
|
195
193
|
"pairling: the platform runtime package is missing or incomplete.",
|
|
@@ -208,7 +206,6 @@ function main() {
|
|
|
208
206
|
delegate(payloadCli, args, {
|
|
209
207
|
PAIRLING_REPO_ROOT: join(payloadRoot, "."),
|
|
210
208
|
PAIRLING_CONNECTD_PREBUILT: env.connectdPath,
|
|
211
|
-
PAIRLING_MINTD_PREBUILT: env.mintdPath,
|
|
212
209
|
PAIRLING_DAEMON_PYTHON: env.vendoredPython,
|
|
213
210
|
});
|
|
214
211
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairling",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
44
|
-
"@pairling/runtime-darwin-x64": "0.2.
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.2.9",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.2.9"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
c97bbab
|
package/payload/mac/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.9
|
|
@@ -300,8 +300,6 @@ os.environ["PATH"] = (
|
|
|
300
300
|
|
|
301
301
|
PORT = RUNTIME_PORT
|
|
302
302
|
HOME = Path.home()
|
|
303
|
-
MINT_SOCKET_PATH = "/Library/Application Support/Pairling/run/mintd/mintd.sock"
|
|
304
|
-
MINT_ALERT_PATH = Path("/Library/Application Support/Pairling/run/mintd/alerts.jsonl")
|
|
305
303
|
DEFAULT_COORDINATOR_HOST = (
|
|
306
304
|
os.environ.get("PAIRLING_HOSTNAME")
|
|
307
305
|
or os.environ.get("COMPANION_COORDINATOR_HOST")
|
|
@@ -730,6 +728,7 @@ POST_ONLY_ENDPOINTS = {
|
|
|
730
728
|
"/pair/reauth-claim",
|
|
731
729
|
"/pair/revoke",
|
|
732
730
|
"/pair/rotate-token",
|
|
731
|
+
"/pair/bind-node",
|
|
733
732
|
"/aperture-cli/open",
|
|
734
733
|
"/open",
|
|
735
734
|
"/inject",
|
|
@@ -813,6 +812,12 @@ PROOF_REQUIRED_ENDPOINTS = HIGH_RISK_ENDPOINTS | {
|
|
|
813
812
|
"/phone-tools/availability",
|
|
814
813
|
"/phone-tools/next",
|
|
815
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",
|
|
816
821
|
}
|
|
817
822
|
|
|
818
823
|
MAX_REQUEST_BODY_BYTES = 1_000_000
|
|
@@ -1048,7 +1053,31 @@ def _connectd_peer_node_id(headers) -> str:
|
|
|
1048
1053
|
return value
|
|
1049
1054
|
|
|
1050
1055
|
|
|
1051
|
-
def
|
|
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:
|
|
1052
1081
|
if DEVICE_REGISTRY is None or auth_result is None or not getattr(auth_result, "ok", False):
|
|
1053
1082
|
return False
|
|
1054
1083
|
device_id = str(getattr(auth_result, "device_id", "") or "").strip()
|
|
@@ -1058,9 +1087,26 @@ def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool
|
|
|
1058
1087
|
return False
|
|
1059
1088
|
if not _pairdrop_gateway_provenance_ok(headers, client_address):
|
|
1060
1089
|
return False
|
|
1090
|
+
provenance = _connectd_peer_provenance(headers)
|
|
1061
1091
|
node_id = _connectd_peer_node_id(headers)
|
|
1062
1092
|
if not node_id:
|
|
1063
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
|
|
1064
1110
|
try:
|
|
1065
1111
|
return bool(DEVICE_REGISTRY.set_tailnet_node_id_if_absent(device_id, node_id))
|
|
1066
1112
|
except Exception:
|
|
@@ -2141,81 +2187,6 @@ def _lan_ips(power_state: dict | None = None) -> list[str]:
|
|
|
2141
2187
|
return _cached_probe("lan_ips", _HEALTH_PROBE_CACHE_SECONDS, _probe_lan_ips)
|
|
2142
2188
|
|
|
2143
2189
|
|
|
2144
|
-
def _mint_phone_authkey(pair_id: str) -> dict | None:
|
|
2145
|
-
if not _pairling_mint_enabled():
|
|
2146
|
-
return None
|
|
2147
|
-
path = os.environ.get("PAIRLING_MINT_SOCKET") or MINT_SOCKET_PATH
|
|
2148
|
-
try:
|
|
2149
|
-
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
2150
|
-
sock.settimeout(float(os.environ.get("PAIRLING_MINT_TIMEOUT_SECONDS", "0.8")))
|
|
2151
|
-
sock.connect(path)
|
|
2152
|
-
sock.sendall(json.dumps({"op": "mint_phone_key", "pair_id": pair_id}).encode("utf-8") + b"\n")
|
|
2153
|
-
with sock.makefile("rb") as fh:
|
|
2154
|
-
raw = fh.readline(8192)
|
|
2155
|
-
payload = json.loads(raw.decode("utf-8"))
|
|
2156
|
-
except Exception:
|
|
2157
|
-
return None
|
|
2158
|
-
if not isinstance(payload, dict) or not payload.get("ok"):
|
|
2159
|
-
return None
|
|
2160
|
-
key = str(payload.get("authkey") or "")
|
|
2161
|
-
if not key:
|
|
2162
|
-
return None
|
|
2163
|
-
try:
|
|
2164
|
-
expires_at = int(payload.get("expires_at") or 0)
|
|
2165
|
-
except (TypeError, ValueError):
|
|
2166
|
-
expires_at = 0
|
|
2167
|
-
return {
|
|
2168
|
-
"authkey": key,
|
|
2169
|
-
"key_id": payload.get("key_id"),
|
|
2170
|
-
"expires_at": expires_at,
|
|
2171
|
-
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
def _pairling_mint_enabled() -> bool:
|
|
2175
|
-
return os.environ.get("PAIRLING_MINT_ENABLED", "").strip().lower() in {"1", "true", "yes"}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
def _silent_join_capability(mint_enabled: bool, connectd_status: dict | None) -> tuple[bool, str]:
|
|
2179
|
-
"""D1: whether a cellular/funnel phone can complete a silent join. Minting must
|
|
2180
|
-
be enabled, and the tailnet must not be under network lock (mintd refuses to
|
|
2181
|
-
mint under lock, mintd.go:165-171). Pure, so the phone is told up front."""
|
|
2182
|
-
if not mint_enabled:
|
|
2183
|
-
return False, "mint_disabled"
|
|
2184
|
-
if connectd_status and connectd_status.get("tailnet_lock_enabled"):
|
|
2185
|
-
return False, "tailnet_lock"
|
|
2186
|
-
return True, ""
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
def _mintd_alert_snapshot(limit: int = 5) -> list[dict]:
|
|
2190
|
-
path = Path(os.environ.get("PAIRLING_MINT_ALERT_PATH") or MINT_ALERT_PATH)
|
|
2191
|
-
try:
|
|
2192
|
-
lines = path.read_text().splitlines()
|
|
2193
|
-
except Exception:
|
|
2194
|
-
return []
|
|
2195
|
-
allowed = {"event", "ts", "pair_id", "key_id", "uid", "window"}
|
|
2196
|
-
alerts: list[dict] = []
|
|
2197
|
-
for line in lines[-max(1, limit):]:
|
|
2198
|
-
try:
|
|
2199
|
-
item = json.loads(line)
|
|
2200
|
-
except Exception:
|
|
2201
|
-
continue
|
|
2202
|
-
if not isinstance(item, dict):
|
|
2203
|
-
continue
|
|
2204
|
-
sanitized = {key: item[key] for key in allowed if key in item}
|
|
2205
|
-
if sanitized:
|
|
2206
|
-
alerts.append(sanitized)
|
|
2207
|
-
return alerts
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
def _attach_mintd_alerts(coordinator: dict) -> dict:
|
|
2211
|
-
alerts = _mintd_alert_snapshot()
|
|
2212
|
-
if not alerts:
|
|
2213
|
-
return coordinator
|
|
2214
|
-
out = dict(coordinator)
|
|
2215
|
-
out["mintd_alerts"] = alerts
|
|
2216
|
-
return out
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
2190
|
def _guardian_listener_entries(power_state: dict | None) -> list[str]:
|
|
2220
2191
|
if not isinstance(power_state, dict):
|
|
2221
2192
|
return []
|
|
@@ -2592,7 +2563,6 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
|
|
|
2592
2563
|
if connect.get("summary") is not None:
|
|
2593
2564
|
coordinator = dict(coordinator)
|
|
2594
2565
|
coordinator["pairling_connect"] = connect["summary"]
|
|
2595
|
-
coordinator = _attach_mintd_alerts(coordinator)
|
|
2596
2566
|
routes = _health_routes(coordinator, power_state)
|
|
2597
2567
|
ok = coordinator.get("posture") in ("ready", "warning")
|
|
2598
2568
|
runtime_info = _runtime_info_snapshot()
|
|
@@ -2658,7 +2628,6 @@ def _mac_health_alert_snapshot() -> dict:
|
|
|
2658
2628
|
coordinator = _apply_pairling_connect_posture(
|
|
2659
2629
|
coordinator, _normalize_guardian_state(state), _pairling_connect_health()
|
|
2660
2630
|
)
|
|
2661
|
-
coordinator = _attach_mintd_alerts(coordinator)
|
|
2662
2631
|
return {
|
|
2663
2632
|
"ok": coordinator.get("posture") in ("ready", "warning"),
|
|
2664
2633
|
"schema_version": 1,
|
|
@@ -7476,6 +7445,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7476
7445
|
self.send_error(405, "POST required")
|
|
7477
7446
|
return
|
|
7478
7447
|
|
|
7448
|
+
proof_verified = False
|
|
7479
7449
|
if (
|
|
7480
7450
|
self.pairling_auth is not None
|
|
7481
7451
|
and _requires_request_proof(u.path, self.command)
|
|
@@ -7511,9 +7481,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7511
7481
|
},
|
|
7512
7482
|
}, status=proof_result.status)
|
|
7513
7483
|
return
|
|
7484
|
+
proof_verified = True
|
|
7514
7485
|
|
|
7515
7486
|
if self.pairling_auth is not None:
|
|
7516
|
-
_maybe_persist_tailnet_node_id(
|
|
7487
|
+
_maybe_persist_tailnet_node_id(
|
|
7488
|
+
self.headers,
|
|
7489
|
+
self.client_address,
|
|
7490
|
+
self.pairling_auth,
|
|
7491
|
+
proof_verified=proof_verified,
|
|
7492
|
+
)
|
|
7517
7493
|
|
|
7518
7494
|
if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
|
|
7519
7495
|
max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
|
|
@@ -7569,6 +7545,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7569
7545
|
self._handle_pair_revoke(q)
|
|
7570
7546
|
elif u.path == "/pair/rotate-token":
|
|
7571
7547
|
self._handle_pair_rotate_token(q)
|
|
7548
|
+
elif u.path == "/pair/bind-node":
|
|
7549
|
+
self._handle_pair_bind_node(q)
|
|
7572
7550
|
elif u.path == "/healthz":
|
|
7573
7551
|
self._handle_healthz(q)
|
|
7574
7552
|
elif u.path == "/power-state":
|
|
@@ -8236,15 +8214,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8236
8214
|
else {"ok": False, "reason": "advertiser_unavailable"}
|
|
8237
8215
|
)
|
|
8238
8216
|
pairling_connect_routes = self._pairling_connect_routes()
|
|
8239
|
-
connectd_status = {}
|
|
8240
|
-
if fetch_connectd_status is not None:
|
|
8241
|
-
try:
|
|
8242
|
-
connectd_status = fetch_connectd_status(timeout_seconds=0.7) or {}
|
|
8243
|
-
except Exception:
|
|
8244
|
-
connectd_status = {}
|
|
8245
|
-
silent_join_available, silent_join_reason = _silent_join_capability(
|
|
8246
|
-
_pairling_mint_enabled(), connectd_status
|
|
8247
|
-
)
|
|
8248
8217
|
except ValueError as exc:
|
|
8249
8218
|
self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
|
|
8250
8219
|
return
|
|
@@ -8271,9 +8240,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8271
8240
|
"pairing_nonce": started.pairing_nonce,
|
|
8272
8241
|
"attest_challenge": started.attest_challenge,
|
|
8273
8242
|
"mac_ake_pub": started.mac_ake_pub,
|
|
8274
|
-
"pv": "
|
|
8275
|
-
"silent_join_available": silent_join_available,
|
|
8276
|
-
"silent_join_unavailable_reason": silent_join_reason,
|
|
8243
|
+
"pv": "2" if started.mac_ake_pub else "1",
|
|
8277
8244
|
},
|
|
8278
8245
|
})
|
|
8279
8246
|
|
|
@@ -8393,55 +8360,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8393
8360
|
proof_secret_nonce, enc_proof_secret = PAIRING_STORE.seal_psk_token(
|
|
8394
8361
|
k_token, claim.device.proof_secret, proof_secret_aad
|
|
8395
8362
|
)
|
|
8396
|
-
try:
|
|
8397
|
-
protocol_version = int(payload.get("pv") or 0)
|
|
8398
|
-
except (TypeError, ValueError):
|
|
8399
|
-
protocol_version = 0
|
|
8400
|
-
# Increment 5: validate pv to the known set and gate the mint. A
|
|
8401
|
-
# funnel-origin claim mints only with a verified App Attest, so a
|
|
8402
|
-
# script holding a stolen secret cannot obtain a tagged auth key.
|
|
8403
|
-
if protocol_version not in (1, 2, 3):
|
|
8404
|
-
protocol_version = 0
|
|
8405
|
-
mint_allowed = protocol_version >= 3 and (
|
|
8406
|
-
not funnel_origin or claim.direct_attestation_verified
|
|
8407
|
-
)
|
|
8408
|
-
tailnet_authkey = _mint_phone_authkey(str(payload.get("pair_id") or "")) if mint_allowed else None
|
|
8409
|
-
authkey_nonce = None
|
|
8410
|
-
enc_authkey = None
|
|
8411
|
-
if tailnet_authkey:
|
|
8412
|
-
authkey_aad = aad + b"\npairling.psk.enc_authkey.v1"
|
|
8413
|
-
authkey_nonce, enc_authkey = PAIRING_STORE.seal_psk_token(
|
|
8414
|
-
k_token,
|
|
8415
|
-
str(tailnet_authkey.get("authkey") or ""),
|
|
8416
|
-
authkey_aad,
|
|
8417
|
-
)
|
|
8418
|
-
if funnel_origin:
|
|
8419
|
-
# Operator visibility: a remote, funnel-origin join has no
|
|
8420
|
-
# proximity backstop, so make it loud and auditable, and push a
|
|
8421
|
-
# revoke prompt to the already-paired devices (D4).
|
|
8422
|
-
try:
|
|
8423
|
-
import sys as _sys
|
|
8424
|
-
_sys.stderr.write(
|
|
8425
|
-
"PAIRLING FUNNEL JOIN: a remote device paired over Funnel "
|
|
8426
|
-
f"(device_id={claim.device.device_id}); no proximity backstop, "
|
|
8427
|
-
"review and revoke if unexpected.\n"
|
|
8428
|
-
)
|
|
8429
|
-
_sys.stderr.flush()
|
|
8430
|
-
except Exception:
|
|
8431
|
-
pass
|
|
8432
|
-
if PUSH_DISPATCHER is not None:
|
|
8433
|
-
try:
|
|
8434
|
-
PUSH_DISPATCHER.broadcast_alert(
|
|
8435
|
-
exclude_device_id=claim.device.device_id,
|
|
8436
|
-
event_id=f"funnel_join:{claim.device.device_id}",
|
|
8437
|
-
kind="remote_join",
|
|
8438
|
-
route="pairling-connect",
|
|
8439
|
-
title="New device paired remotely",
|
|
8440
|
-
body="A device joined your Mac over the internet. Tap to review or revoke.",
|
|
8441
|
-
pairling_extra={"device_id": claim.device.device_id, "revoke_path": "/pair/revoke"},
|
|
8442
|
-
)
|
|
8443
|
-
except Exception:
|
|
8444
|
-
pass
|
|
8445
8363
|
if PAIRING_ADVERTISER is not None:
|
|
8446
8364
|
PAIRING_ADVERTISER.stop()
|
|
8447
8365
|
except PairingError as exc:
|
|
@@ -8481,9 +8399,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8481
8399
|
"routes": runtime_routes,
|
|
8482
8400
|
},
|
|
8483
8401
|
}
|
|
8484
|
-
if authkey_nonce and enc_authkey:
|
|
8485
|
-
response["enc_authkey"] = base64.b64encode(enc_authkey).decode("ascii")
|
|
8486
|
-
response["authkey_nonce"] = base64.b64encode(authkey_nonce).decode("ascii")
|
|
8487
8402
|
self._send_json(response)
|
|
8488
8403
|
|
|
8489
8404
|
def _handle_pair_reauth_challenge(self, q):
|
|
@@ -8572,6 +8487,28 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8572
8487
|
return
|
|
8573
8488
|
self._send_json({"ok": True, "device_id": device_id, "token": token})
|
|
8574
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
|
+
|
|
8575
8512
|
def _handle_power_state(self, q):
|
|
8576
8513
|
self._send_json(_cached_health_payload(
|
|
8577
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
|
-
|
|
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
|
-
|
|
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 {
|