pairling 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/bin/pairling.mjs +5 -2
- package/package.json +3 -3
- package/payload/mac/SOURCE_BRANCH +1 -1
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairling_connectd_status.py +57 -7
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +67 -20
- package/payload/mac/companiond/pairlingd.py +269 -16
- package/payload/mac/companiond/push_dispatcher.py +31 -1
- package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
- package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
- package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
- package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
- package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
- package/payload/mac/connectd/internal/runtime/config.go +19 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
- package/payload/mac/connectd/internal/status/status.go +67 -1
- package/payload/mac/connectd/internal/status/status_test.go +138 -0
- package/payload/mac/install/install-runtime.sh +299 -20
- package/payload/mac/install/render-launchd.py +54 -10
- package/payload-manifest.json +63 -21
|
@@ -57,6 +57,7 @@ import copy
|
|
|
57
57
|
import secrets
|
|
58
58
|
import shlex
|
|
59
59
|
import signal
|
|
60
|
+
import socket
|
|
60
61
|
import sqlite3
|
|
61
62
|
import subprocess
|
|
62
63
|
import sys
|
|
@@ -125,6 +126,23 @@ except Exception:
|
|
|
125
126
|
RelayClaimVerifier = None
|
|
126
127
|
relay_claims_required = None
|
|
127
128
|
|
|
129
|
+
# Fail loud at boot: the PSK pair-claim path cannot carry a relay attested-claim
|
|
130
|
+
# ticket, so a relay-required config breaks every PSK pairing with an
|
|
131
|
+
# attested_claim_required 403, and the modern PSK-first client has no fallback.
|
|
132
|
+
# Surface it at startup instead of silently at pair time. Default-off, so this
|
|
133
|
+
# only fires on an explicit opt-in.
|
|
134
|
+
if relay_claims_required is not None and relay_claims_required():
|
|
135
|
+
import sys as _sys
|
|
136
|
+
print(
|
|
137
|
+
"PAIRLING STARTUP WARNING: PAIRLING_RELAY_CLAIMS_REQUIRED is set, but "
|
|
138
|
+
"/pair/psk-claim cannot carry a relay attested-claim ticket. Every PSK "
|
|
139
|
+
"pairing will fail with attested_claim_required 403 and the PSK-first "
|
|
140
|
+
"client has no fallback. Disable relay-required pairing, or add ticket "
|
|
141
|
+
"support to the PSK claim path, before pairing over PSK.",
|
|
142
|
+
file=_sys.stderr,
|
|
143
|
+
flush=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
128
146
|
try:
|
|
129
147
|
from push_dispatcher import PairlingPushDispatcher, PushDispatcherError
|
|
130
148
|
except Exception:
|
|
@@ -282,6 +300,8 @@ os.environ["PATH"] = (
|
|
|
282
300
|
|
|
283
301
|
PORT = RUNTIME_PORT
|
|
284
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")
|
|
285
305
|
DEFAULT_COORDINATOR_HOST = (
|
|
286
306
|
os.environ.get("PAIRLING_HOSTNAME")
|
|
287
307
|
or os.environ.get("COMPANION_COORDINATOR_HOST")
|
|
@@ -882,9 +902,18 @@ def _is_pairdrop_path(path: str) -> bool:
|
|
|
882
902
|
return path == "/pairdrop/events" or path.startswith("/pairdrop/")
|
|
883
903
|
|
|
884
904
|
|
|
885
|
-
def _pairdrop_gateway_provenance_ok(headers) -> bool:
|
|
905
|
+
def _pairdrop_gateway_provenance_ok(headers, client_address=None) -> bool:
|
|
886
906
|
getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
|
|
887
|
-
|
|
907
|
+
header_ok = str(getter("X-Pairling-Connect-Gateway", "") or "") == "pairling-connectd"
|
|
908
|
+
if not header_ok:
|
|
909
|
+
return False
|
|
910
|
+
# The gateway header alone is spoofable, so in the default loopback-only bind
|
|
911
|
+
# also require the loopback hop from connectd. Under PAIRLING_BIND_MODE=all a
|
|
912
|
+
# direct-LAN client is non-loopback, so waive the loopback requirement there
|
|
913
|
+
# to avoid breaking that path.
|
|
914
|
+
if os.environ.get("PAIRLING_BIND_MODE", "loopback").strip().lower() == "all":
|
|
915
|
+
return True
|
|
916
|
+
return _loopback_client_address(client_address)
|
|
888
917
|
|
|
889
918
|
|
|
890
919
|
def _is_pairdrop_mutation(path: str, method: str) -> bool:
|
|
@@ -990,6 +1019,54 @@ def _bearer_token(headers) -> str | None:
|
|
|
990
1019
|
return None
|
|
991
1020
|
|
|
992
1021
|
|
|
1022
|
+
def _loopback_client_address(client_address) -> bool:
|
|
1023
|
+
if not client_address:
|
|
1024
|
+
return False
|
|
1025
|
+
return str(client_address[0]) in ("127.0.0.1", "::1")
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def _funnel_origin_request(headers, client_address) -> bool:
|
|
1029
|
+
"""True only when a request bears connectd's funnel-origin marker AND arrives
|
|
1030
|
+
over the loopback hop from connectd. The marker alone is spoofable, so the
|
|
1031
|
+
loopback peer is required: the connectd-to-pairlingd hop is loopback and is
|
|
1032
|
+
unreachable from the internet, so an internet client cannot forge the marker.
|
|
1033
|
+
A funnel-enabled install uses this to hard-require App Attest (Increment 5)
|
|
1034
|
+
and to refuse /pair/start for funnel-origin requests (Increment 4)."""
|
|
1035
|
+
if not _loopback_client_address(client_address):
|
|
1036
|
+
return False
|
|
1037
|
+
getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
|
|
1038
|
+
return str(getter("X-Pairling-Funnel-Origin", "") or "").strip() == "1"
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _connectd_peer_node_id(headers) -> str:
|
|
1042
|
+
getter = headers.get if hasattr(headers, "get") else lambda key, default=None: default
|
|
1043
|
+
value = str(getter("X-Pairling-Peer-Node", "") or "").strip()
|
|
1044
|
+
if not value or len(value) > 128:
|
|
1045
|
+
return ""
|
|
1046
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
1047
|
+
return ""
|
|
1048
|
+
return value
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _maybe_persist_tailnet_node_id(headers, client_address, auth_result) -> bool:
|
|
1052
|
+
if DEVICE_REGISTRY is None or auth_result is None or not getattr(auth_result, "ok", False):
|
|
1053
|
+
return False
|
|
1054
|
+
device_id = str(getattr(auth_result, "device_id", "") or "").strip()
|
|
1055
|
+
if not device_id:
|
|
1056
|
+
return False
|
|
1057
|
+
if not _loopback_client_address(client_address):
|
|
1058
|
+
return False
|
|
1059
|
+
if not _pairdrop_gateway_provenance_ok(headers, client_address):
|
|
1060
|
+
return False
|
|
1061
|
+
node_id = _connectd_peer_node_id(headers)
|
|
1062
|
+
if not node_id:
|
|
1063
|
+
return False
|
|
1064
|
+
try:
|
|
1065
|
+
return bool(DEVICE_REGISTRY.set_tailnet_node_id_if_absent(device_id, node_id))
|
|
1066
|
+
except Exception:
|
|
1067
|
+
return False
|
|
1068
|
+
|
|
1069
|
+
|
|
993
1070
|
def _auth_cache_key(token: str, *, method: str, path: str, required_scopes: set[str]) -> tuple | None:
|
|
994
1071
|
if AUTH_RESULT_CACHE_SECONDS <= 0:
|
|
995
1072
|
return None
|
|
@@ -2064,6 +2141,81 @@ def _lan_ips(power_state: dict | None = None) -> list[str]:
|
|
|
2064
2141
|
return _cached_probe("lan_ips", _HEALTH_PROBE_CACHE_SECONDS, _probe_lan_ips)
|
|
2065
2142
|
|
|
2066
2143
|
|
|
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
|
+
|
|
2067
2219
|
def _guardian_listener_entries(power_state: dict | None) -> list[str]:
|
|
2068
2220
|
if not isinstance(power_state, dict):
|
|
2069
2221
|
return []
|
|
@@ -2440,6 +2592,7 @@ def _health_payload(full_power: bool = False, authenticated: bool = False, auth_
|
|
|
2440
2592
|
if connect.get("summary") is not None:
|
|
2441
2593
|
coordinator = dict(coordinator)
|
|
2442
2594
|
coordinator["pairling_connect"] = connect["summary"]
|
|
2595
|
+
coordinator = _attach_mintd_alerts(coordinator)
|
|
2443
2596
|
routes = _health_routes(coordinator, power_state)
|
|
2444
2597
|
ok = coordinator.get("posture") in ("ready", "warning")
|
|
2445
2598
|
runtime_info = _runtime_info_snapshot()
|
|
@@ -2505,6 +2658,7 @@ def _mac_health_alert_snapshot() -> dict:
|
|
|
2505
2658
|
coordinator = _apply_pairling_connect_posture(
|
|
2506
2659
|
coordinator, _normalize_guardian_state(state), _pairling_connect_health()
|
|
2507
2660
|
)
|
|
2661
|
+
coordinator = _attach_mintd_alerts(coordinator)
|
|
2508
2662
|
return {
|
|
2509
2663
|
"ok": coordinator.get("posture") in ("ready", "warning"),
|
|
2510
2664
|
"schema_version": 1,
|
|
@@ -7302,7 +7456,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7302
7456
|
}, status=401)
|
|
7303
7457
|
return
|
|
7304
7458
|
|
|
7305
|
-
if _is_pairdrop_path(u.path) and not _pairdrop_gateway_provenance_ok(self.headers):
|
|
7459
|
+
if _is_pairdrop_path(u.path) and not _pairdrop_gateway_provenance_ok(self.headers, self.client_address):
|
|
7306
7460
|
admission.release()
|
|
7307
7461
|
self._send_json({
|
|
7308
7462
|
"ok": False,
|
|
@@ -7358,6 +7512,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7358
7512
|
}, status=proof_result.status)
|
|
7359
7513
|
return
|
|
7360
7514
|
|
|
7515
|
+
if self.pairling_auth is not None:
|
|
7516
|
+
_maybe_persist_tailnet_node_id(self.headers, self.client_address, self.pairling_auth)
|
|
7517
|
+
|
|
7361
7518
|
if self.pairling_auth is not None and _is_high_risk_endpoint(u.path) and DEVICE_REGISTRY is not None:
|
|
7362
7519
|
max_per_min = _rate_limit_for_high_risk_endpoint(u.path)
|
|
7363
7520
|
allowed, retry = _request_rate_check(f"{self.pairling_auth.device_id}:{u.path}", max_per_min=max_per_min)
|
|
@@ -7393,7 +7550,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7393
7550
|
elif u.path == "/manifest":
|
|
7394
7551
|
self._handle_manifest(q)
|
|
7395
7552
|
elif u.path == "/pair/start":
|
|
7396
|
-
self.
|
|
7553
|
+
if _funnel_origin_request(self.headers, self.client_address):
|
|
7554
|
+
# Belt-and-suspenders: /pair/start returns the 192-bit secret
|
|
7555
|
+
# in plaintext and must never be served to a funnel-origin
|
|
7556
|
+
# request, even if connectd's allowlist ever drifted.
|
|
7557
|
+
self._send_json({"ok": False, "error": {"code": "funnel_forbidden", "message": "pair start is not available over funnel"}}, status=403)
|
|
7558
|
+
else:
|
|
7559
|
+
self._handle_pair_start(q)
|
|
7397
7560
|
elif u.path == "/pair/claim":
|
|
7398
7561
|
self._handle_pair_claim(q)
|
|
7399
7562
|
elif u.path == "/pair/psk-claim":
|
|
@@ -8043,8 +8206,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8043
8206
|
},
|
|
8044
8207
|
}, status=503)
|
|
8045
8208
|
return
|
|
8046
|
-
#
|
|
8047
|
-
#
|
|
8209
|
+
# Rate-limit pair starts. NOTE: self.client_address[0] is the loopback
|
|
8210
|
+
# peer for any request proxied through connectd (the upstream is
|
|
8211
|
+
# 127.0.0.1), so this is a GLOBAL circuit breaker, not a per-source-IP
|
|
8212
|
+
# defense. /pair/start is loopback-only today (connectd denies it at the
|
|
8213
|
+
# gateway), so only local callers reach it. Real per-client throttling for
|
|
8214
|
+
# the public funnel path is owned by connectd (see the funnel-hardening
|
|
8215
|
+
# plan, Increment 2); do not rely on this key for per-attacker isolation.
|
|
8048
8216
|
allowed, retry_after = _request_rate_check(
|
|
8049
8217
|
f"pair_start:{self.client_address[0]}", max_per_min=5
|
|
8050
8218
|
)
|
|
@@ -8068,6 +8236,15 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8068
8236
|
else {"ok": False, "reason": "advertiser_unavailable"}
|
|
8069
8237
|
)
|
|
8070
8238
|
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
|
+
)
|
|
8071
8248
|
except ValueError as exc:
|
|
8072
8249
|
self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
|
|
8073
8250
|
return
|
|
@@ -8094,7 +8271,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8094
8271
|
"pairing_nonce": started.pairing_nonce,
|
|
8095
8272
|
"attest_challenge": started.attest_challenge,
|
|
8096
8273
|
"mac_ake_pub": started.mac_ake_pub,
|
|
8097
|
-
"pv": "2" if started.mac_ake_pub else "1",
|
|
8274
|
+
"pv": "3" if started.mac_ake_pub and _pairling_mint_enabled() else "2" if started.mac_ake_pub else "1",
|
|
8275
|
+
"silent_join_available": silent_join_available,
|
|
8276
|
+
"silent_join_unavailable_reason": silent_join_reason,
|
|
8098
8277
|
},
|
|
8099
8278
|
})
|
|
8100
8279
|
|
|
@@ -8182,6 +8361,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8182
8361
|
try:
|
|
8183
8362
|
payload = self._read_json_object()
|
|
8184
8363
|
host_chain = self._pairing_host_chain()
|
|
8364
|
+
funnel_origin = _funnel_origin_request(self.headers, self.client_address)
|
|
8185
8365
|
claim, k_token, aad, mac_confirm = PAIRING_STORE.psk_claim_pair(
|
|
8186
8366
|
pair_id=str(payload.get("pair_id") or ""),
|
|
8187
8367
|
b_pub_b64=str(payload.get("b_pub") or ""),
|
|
@@ -8196,8 +8376,72 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8196
8376
|
relay_device_id=payload.get("relay_device_id"),
|
|
8197
8377
|
relay_required=bool(relay_claims_required and relay_claims_required()),
|
|
8198
8378
|
relay_claim_verifier=RELAY_CLAIM_VERIFIER,
|
|
8379
|
+
funnel_origin=funnel_origin,
|
|
8199
8380
|
)
|
|
8200
8381
|
nonce, enc_token = PAIRING_STORE.seal_psk_token(k_token, claim.device.token, aad)
|
|
8382
|
+
# Seal proof_secret (the request-proof HMAC key) under K_token for
|
|
8383
|
+
# clients that can unseal it, so it never crosses a plaintext transport
|
|
8384
|
+
# in the clear. The bearer token is already sealed; proof_secret was
|
|
8385
|
+
# not, which leaked it to a passive sniffer on the LAN path. Back-compat:
|
|
8386
|
+
# a client that does not send seal_proof_secret still receives the
|
|
8387
|
+
# cleartext field in the response below.
|
|
8388
|
+
seal_proof = bool(payload.get("seal_proof_secret"))
|
|
8389
|
+
proof_secret_nonce = None
|
|
8390
|
+
enc_proof_secret = None
|
|
8391
|
+
if seal_proof:
|
|
8392
|
+
proof_secret_aad = aad + b"\npairling.psk.proof_secret.v1"
|
|
8393
|
+
proof_secret_nonce, enc_proof_secret = PAIRING_STORE.seal_psk_token(
|
|
8394
|
+
k_token, claim.device.proof_secret, proof_secret_aad
|
|
8395
|
+
)
|
|
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
|
|
8201
8445
|
if PAIRING_ADVERTISER is not None:
|
|
8202
8446
|
PAIRING_ADVERTISER.stop()
|
|
8203
8447
|
except PairingError as exc:
|
|
@@ -8211,15 +8455,20 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8211
8455
|
route.get("source") == "pairling_connectd" and route.get("status") == "ready"
|
|
8212
8456
|
for route in runtime_routes
|
|
8213
8457
|
) else "http-local"
|
|
8214
|
-
|
|
8458
|
+
device_block = {
|
|
8459
|
+
"id": claim.device.device_id,
|
|
8460
|
+
"scopes": list(claim.device.scopes),
|
|
8461
|
+
"relay_device_id": claim.relay_device_id,
|
|
8462
|
+
"attestation_status": claim.attestation_status,
|
|
8463
|
+
}
|
|
8464
|
+
if seal_proof and enc_proof_secret is not None and proof_secret_nonce is not None:
|
|
8465
|
+
device_block["enc_proof_secret"] = base64.b64encode(enc_proof_secret).decode("ascii")
|
|
8466
|
+
device_block["proof_secret_nonce"] = base64.b64encode(proof_secret_nonce).decode("ascii")
|
|
8467
|
+
else:
|
|
8468
|
+
device_block["proof_secret"] = claim.device.proof_secret
|
|
8469
|
+
response = {
|
|
8215
8470
|
"ok": True,
|
|
8216
|
-
"device":
|
|
8217
|
-
"id": claim.device.device_id,
|
|
8218
|
-
"proof_secret": claim.device.proof_secret,
|
|
8219
|
-
"scopes": list(claim.device.scopes),
|
|
8220
|
-
"relay_device_id": claim.relay_device_id,
|
|
8221
|
-
"attestation_status": claim.attestation_status,
|
|
8222
|
-
},
|
|
8471
|
+
"device": device_block,
|
|
8223
8472
|
"enc_token": base64.b64encode(enc_token).decode("ascii"),
|
|
8224
8473
|
"nonce": base64.b64encode(nonce).decode("ascii"),
|
|
8225
8474
|
"mac_confirm": base64.b64encode(mac_confirm).decode("ascii"),
|
|
@@ -8231,7 +8480,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
8231
8480
|
"transport": transport,
|
|
8232
8481
|
"routes": runtime_routes,
|
|
8233
8482
|
},
|
|
8234
|
-
}
|
|
8483
|
+
}
|
|
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
|
+
self._send_json(response)
|
|
8235
8488
|
|
|
8236
8489
|
def _handle_pair_reauth_challenge(self, q):
|
|
8237
8490
|
if REAUTH_STORE is None:
|
|
@@ -64,6 +64,7 @@ KIND_CATEGORY = {
|
|
|
64
64
|
"mac_route_risk": "PAIRLING_MAC_HEALTH",
|
|
65
65
|
"worker_pressure": "PAIRLING_WORKER_SENTINEL",
|
|
66
66
|
"deploy_result": "PAIRLING_TURN_DONE",
|
|
67
|
+
"remote_join": "PAIRLING_REMOTE_JOIN",
|
|
67
68
|
"push_diagnostic": "PAIRLING_PUSH_DIAGNOSTIC",
|
|
68
69
|
}
|
|
69
70
|
KIND_ALERT = {
|
|
@@ -78,9 +79,10 @@ KIND_ALERT = {
|
|
|
78
79
|
"mac_route_risk": ("Mac route timed out", "The paired Mac route needs attention."),
|
|
79
80
|
"worker_pressure": ("Pairling worker pressure", "Worker or token pressure needs review."),
|
|
80
81
|
"deploy_result": ("Deploy result ready", "A build or deploy result is available."),
|
|
82
|
+
"remote_join": ("New device paired remotely", "A device joined your Mac over the internet."),
|
|
81
83
|
"push_diagnostic": ("Pairling push test", "Push delivery is configured for this device."),
|
|
82
84
|
}
|
|
83
|
-
TIME_SENSITIVE_KINDS = {"session_attention", "mac_health", "worker_sentinel", "action_required", "turn_failed", "tool_risk", "mac_route_risk", "worker_pressure"}
|
|
85
|
+
TIME_SENSITIVE_KINDS = {"session_attention", "mac_health", "worker_sentinel", "action_required", "turn_failed", "tool_risk", "mac_route_risk", "worker_pressure", "remote_join"}
|
|
84
86
|
|
|
85
87
|
|
|
86
88
|
class PushDispatcherError(Exception):
|
|
@@ -491,6 +493,34 @@ class PairlingPushDispatcher:
|
|
|
491
493
|
self.relay_sender = relay_sender or RelayEventSender(now_fn=now_fn)
|
|
492
494
|
self._lock = threading.RLock()
|
|
493
495
|
|
|
496
|
+
def broadcast_alert(self, *, exclude_device_id, event_id, kind, route, title, body, pairling_extra=None):
|
|
497
|
+
"""Best-effort: send an alert to every registered device except
|
|
498
|
+
exclude_device_id. Per-device failures are swallowed. Returns a count.
|
|
499
|
+
Used to warn already-paired devices of a remote, funnel-origin join."""
|
|
500
|
+
data = self._read()
|
|
501
|
+
sent = 0
|
|
502
|
+
errors = 0
|
|
503
|
+
for device in data.get("devices", []):
|
|
504
|
+
device_id = str(device.get("device_id") or "")
|
|
505
|
+
token = str(device.get("apns_token") or "")
|
|
506
|
+
if not token or device_id == exclude_device_id:
|
|
507
|
+
continue
|
|
508
|
+
try:
|
|
509
|
+
self.apns_sender.send_alert(
|
|
510
|
+
token=token,
|
|
511
|
+
event_id=f"{event_id}:{device_id}",
|
|
512
|
+
kind=kind,
|
|
513
|
+
route=route,
|
|
514
|
+
title=title,
|
|
515
|
+
body=body,
|
|
516
|
+
pairling_extra=pairling_extra,
|
|
517
|
+
interruption_level="time-sensitive",
|
|
518
|
+
)
|
|
519
|
+
sent += 1
|
|
520
|
+
except Exception:
|
|
521
|
+
errors += 1
|
|
522
|
+
return {"sent": sent, "errors": errors}
|
|
523
|
+
|
|
494
524
|
def backfill_live_activity_environments(self, *, device_id: str | None = None) -> dict[str, Any]:
|
|
495
525
|
"""Repair older Live Activity token rows that predate explicit APNs environments."""
|
|
496
526
|
data = self._read()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/netip"
|
|
5
|
+
"reflect"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"tailscale.com/ipn/ipnstate"
|
|
9
|
+
"tailscale.com/tailcfg"
|
|
10
|
+
"tailscale.com/types/views"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestNodeIdentityFromStatusReadsGrantedSelf(t *testing.T) {
|
|
14
|
+
grantedTags := views.SliceOf([]string{"tag:pairling-connect"})
|
|
15
|
+
requestedTags := []string{"tag:requested-only"}
|
|
16
|
+
|
|
17
|
+
identity := nodeIdentityFromStatus(&ipnstate.Status{
|
|
18
|
+
Self: &ipnstate.PeerStatus{
|
|
19
|
+
ID: tailcfg.StableNodeID("nXb6CNTRL"),
|
|
20
|
+
Tags: &grantedTags,
|
|
21
|
+
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.79.217.7")},
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if identity.NodeID != "nXb6CNTRL" {
|
|
26
|
+
t.Fatalf("node ID = %q, want nXb6CNTRL", identity.NodeID)
|
|
27
|
+
}
|
|
28
|
+
if !reflect.DeepEqual(identity.Tags, []string{"tag:pairling-connect"}) {
|
|
29
|
+
t.Fatalf("tags = %#v, want granted tags only", identity.Tags)
|
|
30
|
+
}
|
|
31
|
+
if reflect.DeepEqual(identity.Tags, requestedTags) {
|
|
32
|
+
t.Fatalf("mapper reported requested tags instead of granted tags: %#v", identity.Tags)
|
|
33
|
+
}
|
|
34
|
+
if !reflect.DeepEqual(identity.TailnetIPs, []string{"100.79.217.7"}) {
|
|
35
|
+
t.Fatalf("tailnet IPs = %#v", identity.TailnetIPs)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func TestNodeIdentityFromStatusHandlesNilSelf(t *testing.T) {
|
|
40
|
+
for _, st := range []*ipnstate.Status{nil, {}} {
|
|
41
|
+
identity := nodeIdentityFromStatus(st)
|
|
42
|
+
if identity.NodeID != "" || len(identity.Tags) != 0 || len(identity.TailnetIPs) != 0 {
|
|
43
|
+
t.Fatalf("nil self identity = %#v, want zero value", identity)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestNodeIdentityFromStatusUntaggedNode(t *testing.T) {
|
|
49
|
+
identity := nodeIdentityFromStatus(&ipnstate.Status{
|
|
50
|
+
Self: &ipnstate.PeerStatus{
|
|
51
|
+
ID: tailcfg.StableNodeID("nInteractive"),
|
|
52
|
+
TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.79.217.8")},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if identity.NodeID != "nInteractive" {
|
|
57
|
+
t.Fatalf("node ID = %q, want nInteractive", identity.NodeID)
|
|
58
|
+
}
|
|
59
|
+
if len(identity.Tags) != 0 {
|
|
60
|
+
t.Fatalf("untagged node reported tags: %#v", identity.Tags)
|
|
61
|
+
}
|
|
62
|
+
if !reflect.DeepEqual(identity.TailnetIPs, []string{"100.79.217.8"}) {
|
|
63
|
+
t.Fatalf("tailnet IPs = %#v", identity.TailnetIPs)
|
|
64
|
+
}
|
|
65
|
+
}
|