pairling 0.0.1 → 0.1.0
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 +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Short-lived Pairling pairing records and claim flow."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import socket
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
from pairling_devices import CreatedDevice, DeviceRegistry
|
|
19
|
+
from runtime_contract import DEFAULT_DEVICE_SCOPES, PAIR_SERVICE_TYPE, PORT
|
|
20
|
+
from runtime_paths import app_support_root
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from runtime_contract import RUNTIME_NAME
|
|
24
|
+
except Exception:
|
|
25
|
+
RUNTIME_NAME = "pairling-mac-runtime"
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from pairling_relay_claims import RelayClaimError, RelayClaimVerifier
|
|
29
|
+
except Exception:
|
|
30
|
+
RelayClaimError = None
|
|
31
|
+
RelayClaimVerifier = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
DEFAULT_PAIR_TTL_SECONDS = 180
|
|
35
|
+
MIN_PAIR_TTL_SECONDS = 60
|
|
36
|
+
MAX_PAIR_TTL_SECONDS = 300
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class PairStart:
|
|
41
|
+
pair_id: str
|
|
42
|
+
secret: str
|
|
43
|
+
expires_at: float
|
|
44
|
+
install_id: str
|
|
45
|
+
service_type: str
|
|
46
|
+
txt: dict[str, str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PairClaim:
|
|
51
|
+
device: CreatedDevice
|
|
52
|
+
host_chain: tuple[str, ...]
|
|
53
|
+
runtime_port: int
|
|
54
|
+
cert_pin: str | None
|
|
55
|
+
relay_device_id: str | None = None
|
|
56
|
+
attestation_status: str = "none"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PairingError(Exception):
|
|
60
|
+
def __init__(self, code: str, status: int, message: str):
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.code = code
|
|
63
|
+
self.status = status
|
|
64
|
+
self.message = message
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PairingStore:
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
pair_root: Path,
|
|
71
|
+
registry: DeviceRegistry,
|
|
72
|
+
*,
|
|
73
|
+
runtime_port: int = PORT,
|
|
74
|
+
install_id: str | None = None,
|
|
75
|
+
):
|
|
76
|
+
self.pair_root = pair_root
|
|
77
|
+
self.registry = registry
|
|
78
|
+
self.runtime_port = runtime_port
|
|
79
|
+
self.install_id = install_id or self._load_install_id_from_config()
|
|
80
|
+
self._claim_lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def _computer_name(self) -> str:
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
["/usr/sbin/scutil", "--get", "ComputerName"],
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=2,
|
|
89
|
+
)
|
|
90
|
+
value = (proc.stdout or "").strip()
|
|
91
|
+
if proc.returncode == 0 and value:
|
|
92
|
+
return value[:64]
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
return socket.gethostname()[:64]
|
|
96
|
+
|
|
97
|
+
def _mac_model(self) -> str:
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.run(
|
|
100
|
+
["/usr/sbin/sysctl", "-n", "hw.model"],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=2,
|
|
104
|
+
)
|
|
105
|
+
value = (proc.stdout or "").strip()
|
|
106
|
+
if proc.returncode == 0 and value:
|
|
107
|
+
return value[:64]
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
return "Mac"
|
|
111
|
+
|
|
112
|
+
def _runtime_version(self) -> str:
|
|
113
|
+
return os.environ.get("COMPANION_RUNTIME_VERSION", RUNTIME_NAME)[:64]
|
|
114
|
+
|
|
115
|
+
def _load_install_id_from_config(self) -> str:
|
|
116
|
+
config = self.pair_root.parent / "config.json"
|
|
117
|
+
try:
|
|
118
|
+
payload = json.loads(config.read_text())
|
|
119
|
+
value = payload.get("install_id")
|
|
120
|
+
if isinstance(value, str) and value:
|
|
121
|
+
return value
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
return "inst_" + secrets.token_hex(16)
|
|
125
|
+
|
|
126
|
+
def _ensure_pair_root(self) -> None:
|
|
127
|
+
self.pair_root.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
try:
|
|
129
|
+
os.chmod(self.pair_root, stat.S_IRWXU)
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def _record_path(self, pair_id: str) -> Path:
|
|
134
|
+
if not pair_id or not all(c.isalnum() or c in {"_", "-"} for c in pair_id):
|
|
135
|
+
raise PairingError("invalid_pair_id", 400, "invalid pair id")
|
|
136
|
+
return self.pair_root / f"{pair_id}.json"
|
|
137
|
+
|
|
138
|
+
def _claim_marker_path(self, pair_id: str) -> Path:
|
|
139
|
+
return self._record_path(pair_id).with_suffix(".claim")
|
|
140
|
+
|
|
141
|
+
def _create_claim_marker(self, pair_id: str) -> Path:
|
|
142
|
+
self._ensure_pair_root()
|
|
143
|
+
marker = self._claim_marker_path(pair_id)
|
|
144
|
+
try:
|
|
145
|
+
fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
146
|
+
except FileExistsError:
|
|
147
|
+
raise PairingError("pair_already_claimed", 409, "pair record already claimed")
|
|
148
|
+
else:
|
|
149
|
+
os.close(fd)
|
|
150
|
+
return marker
|
|
151
|
+
|
|
152
|
+
def start_pair(self, *, ttl_seconds: int = DEFAULT_PAIR_TTL_SECONDS) -> PairStart:
|
|
153
|
+
ttl = max(MIN_PAIR_TTL_SECONDS, min(int(ttl_seconds), MAX_PAIR_TTL_SECONDS))
|
|
154
|
+
pair_id = "pair_" + secrets.token_hex(8)
|
|
155
|
+
secret = secrets.token_urlsafe(24)
|
|
156
|
+
expires_at = time.time() + ttl
|
|
157
|
+
record = {
|
|
158
|
+
"pair_id": pair_id,
|
|
159
|
+
"secret": secret,
|
|
160
|
+
"created_at": time.time(),
|
|
161
|
+
"expires_at": expires_at,
|
|
162
|
+
"claimed_at": None,
|
|
163
|
+
"install_id": self.install_id,
|
|
164
|
+
"runtime_port": self.runtime_port,
|
|
165
|
+
}
|
|
166
|
+
self._ensure_pair_root()
|
|
167
|
+
path = self._record_path(pair_id)
|
|
168
|
+
tmp = path.with_suffix(".json.tmp")
|
|
169
|
+
with tmp.open("w") as fh:
|
|
170
|
+
json.dump(record, fh, indent=2, sort_keys=True)
|
|
171
|
+
fh.write("\n")
|
|
172
|
+
fh.flush()
|
|
173
|
+
os.fsync(fh.fileno())
|
|
174
|
+
os.replace(tmp, path)
|
|
175
|
+
try:
|
|
176
|
+
os.chmod(path, 0o600)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
txt = {
|
|
180
|
+
"pair_id": pair_id,
|
|
181
|
+
"version": "2",
|
|
182
|
+
"expires": str(int(expires_at)),
|
|
183
|
+
"install_id": self.install_id,
|
|
184
|
+
"runtime_port": str(self.runtime_port),
|
|
185
|
+
"mac_name": self._computer_name(),
|
|
186
|
+
"mac_model": self._mac_model(),
|
|
187
|
+
"runtime_version": self._runtime_version(),
|
|
188
|
+
"pairing_nonce": secrets.token_urlsafe(9),
|
|
189
|
+
"route_hint": os.environ.get("PAIRLING_ROUTE_HINT", "lan,bonjour,tailnet")[:64],
|
|
190
|
+
}
|
|
191
|
+
return PairStart(pair_id, secret, expires_at, self.install_id, PAIR_SERVICE_TYPE, txt)
|
|
192
|
+
|
|
193
|
+
def _load_record(self, pair_id: str) -> tuple[dict, Path]:
|
|
194
|
+
path = self._record_path(pair_id)
|
|
195
|
+
try:
|
|
196
|
+
record = json.loads(path.read_text())
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
raise PairingError("pair_not_found", 404, "pair record not found")
|
|
199
|
+
except json.JSONDecodeError as exc:
|
|
200
|
+
raise PairingError("pair_corrupt", 500, f"pair record is corrupt: {exc}")
|
|
201
|
+
if not isinstance(record, dict):
|
|
202
|
+
raise PairingError("pair_corrupt", 500, "pair record is not an object")
|
|
203
|
+
return record, path
|
|
204
|
+
|
|
205
|
+
def claim_pair(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
pair_id: str,
|
|
209
|
+
secret: str,
|
|
210
|
+
device_name: str,
|
|
211
|
+
host_chain: Iterable[str],
|
|
212
|
+
scopes: Iterable[str] | None = None,
|
|
213
|
+
cert_pin: str | None = None,
|
|
214
|
+
attested_claim_ticket: str | None = None,
|
|
215
|
+
relay_device_id: str | None = None,
|
|
216
|
+
relay_required: bool = False,
|
|
217
|
+
relay_claim_verifier=None,
|
|
218
|
+
) -> PairClaim:
|
|
219
|
+
with self._claim_lock:
|
|
220
|
+
record, path = self._load_record(pair_id)
|
|
221
|
+
now = time.time()
|
|
222
|
+
if record.get("claimed_at") is not None:
|
|
223
|
+
raise PairingError("pair_already_claimed", 409, "pair record already claimed")
|
|
224
|
+
if now > float(record.get("expires_at") or 0):
|
|
225
|
+
self._delete_record(path)
|
|
226
|
+
raise PairingError("pair_expired", 410, "pair record expired")
|
|
227
|
+
if not secrets.compare_digest(str(record.get("secret") or ""), secret or ""):
|
|
228
|
+
raise PairingError("invalid_secret", 403, "invalid pair secret")
|
|
229
|
+
relay_status = "none"
|
|
230
|
+
verified_relay_device_id = relay_device_id
|
|
231
|
+
relay_pair_secret = None
|
|
232
|
+
relay_pair_secret_ref = None
|
|
233
|
+
if relay_required or attested_claim_ticket:
|
|
234
|
+
if not attested_claim_ticket:
|
|
235
|
+
raise PairingError("attested_claim_required", 403, "relay claim ticket required")
|
|
236
|
+
if relay_claim_verifier is None:
|
|
237
|
+
raise PairingError("attested_claim_invalid", 403, "relay claim verifier unavailable")
|
|
238
|
+
try:
|
|
239
|
+
verification = relay_claim_verifier.verify(
|
|
240
|
+
attested_claim_ticket,
|
|
241
|
+
pair_id=pair_id,
|
|
242
|
+
relay_device_id=relay_device_id,
|
|
243
|
+
device_name=device_name,
|
|
244
|
+
)
|
|
245
|
+
except Exception as exc:
|
|
246
|
+
code = getattr(exc, "code", "attested_claim_invalid")
|
|
247
|
+
message = getattr(exc, "message", str(exc))
|
|
248
|
+
raise PairingError(code, 403, message)
|
|
249
|
+
verified_relay_device_id = verification.relay_device_id
|
|
250
|
+
relay_status = verification.attestation_status
|
|
251
|
+
relay_pair_secret = getattr(verification, "relay_pair_secret", None)
|
|
252
|
+
relay_pair_secret_ref = getattr(verification, "relay_pair_secret_ref", None)
|
|
253
|
+
normalized_hosts = tuple(h for h in host_chain if isinstance(h, str) and h)
|
|
254
|
+
if not normalized_hosts:
|
|
255
|
+
raise PairingError("missing_host_chain", 500, "host chain is empty")
|
|
256
|
+
marker = self._create_claim_marker(pair_id)
|
|
257
|
+
try:
|
|
258
|
+
device = self.registry.create_device(
|
|
259
|
+
device_name=device_name or "Pairling iPhone",
|
|
260
|
+
install_id=str(record.get("install_id") or self.install_id),
|
|
261
|
+
scopes=scopes or DEFAULT_DEVICE_SCOPES,
|
|
262
|
+
relay_device_id=verified_relay_device_id,
|
|
263
|
+
attestation_status=relay_status,
|
|
264
|
+
device_display_name=device_name or "Pairling iPhone",
|
|
265
|
+
relay_pair_secret_ref=relay_pair_secret_ref,
|
|
266
|
+
)
|
|
267
|
+
if relay_pair_secret and verified_relay_device_id:
|
|
268
|
+
self._store_relay_pair_secret(
|
|
269
|
+
device_id=device.device_id,
|
|
270
|
+
relay_device_id=verified_relay_device_id,
|
|
271
|
+
mac_install_id=str(record.get("install_id") or self.install_id),
|
|
272
|
+
relay_pair_secret=str(relay_pair_secret),
|
|
273
|
+
relay_pair_secret_ref=str(relay_pair_secret_ref or ""),
|
|
274
|
+
)
|
|
275
|
+
record["claimed_at"] = now
|
|
276
|
+
record["device_id"] = device.device_id
|
|
277
|
+
path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
|
|
278
|
+
try:
|
|
279
|
+
os.chmod(path, 0o600)
|
|
280
|
+
except OSError:
|
|
281
|
+
pass
|
|
282
|
+
self._delete_record(path)
|
|
283
|
+
self._delete_record(marker)
|
|
284
|
+
return PairClaim(
|
|
285
|
+
device,
|
|
286
|
+
normalized_hosts,
|
|
287
|
+
int(record.get("runtime_port") or self.runtime_port),
|
|
288
|
+
cert_pin,
|
|
289
|
+
verified_relay_device_id,
|
|
290
|
+
relay_status,
|
|
291
|
+
)
|
|
292
|
+
except Exception:
|
|
293
|
+
self._delete_record(marker)
|
|
294
|
+
raise
|
|
295
|
+
|
|
296
|
+
def _delete_record(self, path: Path) -> None:
|
|
297
|
+
try:
|
|
298
|
+
path.unlink()
|
|
299
|
+
except FileNotFoundError:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
def _store_relay_pair_secret(
|
|
303
|
+
self,
|
|
304
|
+
*,
|
|
305
|
+
device_id: str,
|
|
306
|
+
relay_device_id: str,
|
|
307
|
+
mac_install_id: str,
|
|
308
|
+
relay_pair_secret: str,
|
|
309
|
+
relay_pair_secret_ref: str,
|
|
310
|
+
) -> None:
|
|
311
|
+
secret_path = app_support_root() / "push-secrets.json"
|
|
312
|
+
try:
|
|
313
|
+
payload = json.loads(secret_path.read_text(encoding="utf-8"))
|
|
314
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
315
|
+
payload = {}
|
|
316
|
+
if not isinstance(payload, dict):
|
|
317
|
+
payload = {}
|
|
318
|
+
payload.setdefault("schema_version", 1)
|
|
319
|
+
devices = payload.setdefault("devices", {})
|
|
320
|
+
device = devices.setdefault(device_id, {})
|
|
321
|
+
device["relay_device_id"] = relay_device_id
|
|
322
|
+
device["mac_install_id"] = mac_install_id
|
|
323
|
+
device["relay_pair_secret"] = relay_pair_secret
|
|
324
|
+
device["relay_pair_secret_ref"] = relay_pair_secret_ref
|
|
325
|
+
device["updated_at"] = time.time()
|
|
326
|
+
secret_path.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
try:
|
|
328
|
+
os.chmod(secret_path.parent, stat.S_IRWXU)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
tmp = secret_path.with_suffix(secret_path.suffix + ".tmp")
|
|
332
|
+
with tmp.open("w", encoding="utf-8") as fh:
|
|
333
|
+
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
334
|
+
fh.write("\n")
|
|
335
|
+
fh.flush()
|
|
336
|
+
os.fsync(fh.fileno())
|
|
337
|
+
os.replace(tmp, secret_path)
|
|
338
|
+
try:
|
|
339
|
+
os.chmod(secret_path, 0o600)
|
|
340
|
+
except OSError:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class PairingAdvertiser:
|
|
345
|
+
"""Pair-only Bonjour advertiser backed by macOS dns-sd."""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
*,
|
|
350
|
+
dns_sd_path: str = "/usr/bin/dns-sd",
|
|
351
|
+
popen_factory: Callable[..., subprocess.Popen] = subprocess.Popen,
|
|
352
|
+
):
|
|
353
|
+
self.dns_sd_path = dns_sd_path
|
|
354
|
+
self.popen_factory = popen_factory
|
|
355
|
+
self._lock = threading.Lock()
|
|
356
|
+
self._proc = None
|
|
357
|
+
self._timer: threading.Timer | None = None
|
|
358
|
+
|
|
359
|
+
def start(self, started: PairStart, *, port: int) -> dict:
|
|
360
|
+
if os.environ.get("PAIRLING_DISABLE_BONJOUR") in {"1", "true", "TRUE"}:
|
|
361
|
+
return {"ok": False, "reason": "disabled"}
|
|
362
|
+
if not Path(self.dns_sd_path).exists():
|
|
363
|
+
return {"ok": False, "reason": "dns-sd_missing"}
|
|
364
|
+
txt_args = [f"{key}={value}" for key, value in sorted(started.txt.items())]
|
|
365
|
+
cmd = [
|
|
366
|
+
self.dns_sd_path,
|
|
367
|
+
"-R",
|
|
368
|
+
"Pairling",
|
|
369
|
+
started.service_type,
|
|
370
|
+
"local",
|
|
371
|
+
str(port),
|
|
372
|
+
*txt_args,
|
|
373
|
+
]
|
|
374
|
+
with self._lock:
|
|
375
|
+
self.stop()
|
|
376
|
+
try:
|
|
377
|
+
proc = self.popen_factory(
|
|
378
|
+
cmd,
|
|
379
|
+
stdout=subprocess.DEVNULL,
|
|
380
|
+
stderr=subprocess.DEVNULL,
|
|
381
|
+
)
|
|
382
|
+
except OSError as exc:
|
|
383
|
+
return {"ok": False, "reason": f"{type(exc).__name__}: {exc}"}
|
|
384
|
+
self._proc = proc
|
|
385
|
+
ttl = max(1.0, started.expires_at - time.time())
|
|
386
|
+
self._timer = threading.Timer(ttl, self.stop)
|
|
387
|
+
self._timer.daemon = True
|
|
388
|
+
self._timer.start()
|
|
389
|
+
return {
|
|
390
|
+
"ok": True,
|
|
391
|
+
"service_type": started.service_type,
|
|
392
|
+
"runtime_api_advertised": False,
|
|
393
|
+
"pid": getattr(proc, "pid", None),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
def stop(self) -> None:
|
|
397
|
+
timer = self._timer
|
|
398
|
+
self._timer = None
|
|
399
|
+
if timer is not None:
|
|
400
|
+
timer.cancel()
|
|
401
|
+
proc = self._proc
|
|
402
|
+
self._proc = None
|
|
403
|
+
if proc is not None and proc.poll() is None:
|
|
404
|
+
proc.terminate()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Relay-signed pairing claim ticket validation.
|
|
3
|
+
|
|
4
|
+
The production relay should sign compact JWS tickets with an asymmetric key
|
|
5
|
+
whose public key is pinned in the Mac runtime. The stdlib path below supports
|
|
6
|
+
HS256 for local development and tests so the relay-required behavior can be
|
|
7
|
+
exercised before the hosted relay exists.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from cryptography.exceptions import InvalidSignature
|
|
24
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
25
|
+
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
|
26
|
+
except Exception: # pragma: no cover - dependency may be absent in local-only installs.
|
|
27
|
+
InvalidSignature = None
|
|
28
|
+
hashes = None
|
|
29
|
+
serialization = None
|
|
30
|
+
ec = None
|
|
31
|
+
utils = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
EXPECTED_ISSUER = "pairling-relay"
|
|
35
|
+
EXPECTED_AUDIENCE = "dev.pairling.mac-runtime"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RelayClaimError(Exception):
|
|
39
|
+
def __init__(self, code: str, message: str):
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class RelayClaimVerification:
|
|
47
|
+
payload: dict[str, Any]
|
|
48
|
+
relay_device_id: str
|
|
49
|
+
attestation_status: str
|
|
50
|
+
relay_pair_secret: str | None = None
|
|
51
|
+
relay_pair_secret_ref: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _b64url_decode(value: str) -> bytes:
|
|
55
|
+
padding = "=" * (-len(value) % 4)
|
|
56
|
+
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _b64url_json(value: str) -> dict[str, Any]:
|
|
60
|
+
try:
|
|
61
|
+
decoded = json.loads(_b64url_decode(value).decode("utf-8"))
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
raise RelayClaimError("attested_claim_invalid", f"invalid claim json: {exc}")
|
|
64
|
+
if not isinstance(decoded, dict):
|
|
65
|
+
raise RelayClaimError("attested_claim_invalid", "claim component is not an object")
|
|
66
|
+
return decoded
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sha256_hex(value: str) -> str:
|
|
70
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RelayClaimVerifier:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
mac_install_id: str,
|
|
78
|
+
hs256_secret: str | None = None,
|
|
79
|
+
public_key_paths: list[Path] | tuple[Path, ...] | None = None,
|
|
80
|
+
audience: str = EXPECTED_AUDIENCE,
|
|
81
|
+
now_fn=time.time,
|
|
82
|
+
):
|
|
83
|
+
self.mac_install_id = mac_install_id
|
|
84
|
+
self.hs256_secret = hs256_secret
|
|
85
|
+
self.audience = audience
|
|
86
|
+
self.now_fn = now_fn
|
|
87
|
+
self._used_nonces: set[str] = set()
|
|
88
|
+
self._public_keys = self._load_public_keys(public_key_paths or [])
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_environment(cls, *, mac_install_id: str) -> "RelayClaimVerifier":
|
|
92
|
+
return cls(
|
|
93
|
+
mac_install_id=mac_install_id,
|
|
94
|
+
hs256_secret=os.environ.get("PAIRLING_RELAY_CLAIM_HS256_SECRET") or None,
|
|
95
|
+
public_key_paths=configured_public_key_paths(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def can_verify(self) -> bool:
|
|
100
|
+
return bool(self.hs256_secret) or bool(self._public_keys)
|
|
101
|
+
|
|
102
|
+
def verify(
|
|
103
|
+
self,
|
|
104
|
+
ticket: str,
|
|
105
|
+
*,
|
|
106
|
+
pair_id: str,
|
|
107
|
+
relay_device_id: str | None,
|
|
108
|
+
device_name: str | None = None,
|
|
109
|
+
) -> RelayClaimVerification:
|
|
110
|
+
parts = ticket.split(".")
|
|
111
|
+
if len(parts) != 3 or not all(parts):
|
|
112
|
+
raise RelayClaimError("attested_claim_invalid", "claim ticket is not compact JWS")
|
|
113
|
+
|
|
114
|
+
header = _b64url_json(parts[0])
|
|
115
|
+
payload = _b64url_json(parts[1])
|
|
116
|
+
alg = str(header.get("alg") or "")
|
|
117
|
+
if alg == "HS256":
|
|
118
|
+
self._verify_hs256(parts)
|
|
119
|
+
elif alg == "ES256":
|
|
120
|
+
self._verify_es256(parts, kid=str(header.get("kid") or ""))
|
|
121
|
+
else:
|
|
122
|
+
raise RelayClaimError("attested_claim_invalid", f"unsupported relay claim alg {alg or 'missing'}")
|
|
123
|
+
|
|
124
|
+
now = float(self.now_fn())
|
|
125
|
+
exp = float(payload.get("exp") or 0)
|
|
126
|
+
iat = float(payload.get("iat") or 0)
|
|
127
|
+
if exp <= now:
|
|
128
|
+
raise RelayClaimError("attested_claim_expired", "relay claim ticket expired")
|
|
129
|
+
if iat > now + 60:
|
|
130
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim issued in the future")
|
|
131
|
+
if payload.get("iss") != EXPECTED_ISSUER:
|
|
132
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim issuer mismatch")
|
|
133
|
+
if payload.get("aud") != self.audience:
|
|
134
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim audience mismatch")
|
|
135
|
+
if payload.get("pair_id") != pair_id:
|
|
136
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim pair id mismatch")
|
|
137
|
+
if payload.get("mac_install_id") != self.mac_install_id:
|
|
138
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim Mac install id mismatch")
|
|
139
|
+
subject = str(payload.get("sub") or "")
|
|
140
|
+
if relay_device_id and subject and subject != relay_device_id:
|
|
141
|
+
raise RelayClaimError("attested_claim_invalid", "relay device id mismatch")
|
|
142
|
+
nonce = str(payload.get("nonce") or "")
|
|
143
|
+
if not nonce:
|
|
144
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim nonce missing")
|
|
145
|
+
if nonce in self._used_nonces:
|
|
146
|
+
raise RelayClaimError("attested_claim_replayed", "relay claim nonce already used")
|
|
147
|
+
|
|
148
|
+
expected_device_name_hash = payload.get("device_name_hash")
|
|
149
|
+
if expected_device_name_hash and device_name:
|
|
150
|
+
if expected_device_name_hash != _sha256_hex(device_name):
|
|
151
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim device name hash mismatch")
|
|
152
|
+
|
|
153
|
+
relay_pair_secret = payload.get("relay_pair_secret")
|
|
154
|
+
relay_pair_secret_ref = payload.get("relay_pair_secret_ref")
|
|
155
|
+
if relay_pair_secret is not None:
|
|
156
|
+
relay_pair_secret = str(relay_pair_secret)
|
|
157
|
+
computed_ref = _sha256_hex(relay_pair_secret)
|
|
158
|
+
if relay_pair_secret_ref and str(relay_pair_secret_ref) != computed_ref:
|
|
159
|
+
raise RelayClaimError("attested_claim_invalid", "relay pair secret reference mismatch")
|
|
160
|
+
relay_pair_secret_ref = str(relay_pair_secret_ref or computed_ref)
|
|
161
|
+
|
|
162
|
+
self._used_nonces.add(nonce)
|
|
163
|
+
return RelayClaimVerification(
|
|
164
|
+
payload=payload,
|
|
165
|
+
relay_device_id=relay_device_id or subject,
|
|
166
|
+
attestation_status=str(payload.get("app_attest_environment") or "production"),
|
|
167
|
+
relay_pair_secret=relay_pair_secret,
|
|
168
|
+
relay_pair_secret_ref=relay_pair_secret_ref,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _verify_hs256(self, parts: list[str]) -> None:
|
|
172
|
+
if not self.hs256_secret:
|
|
173
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim verifier is not configured for HS256")
|
|
174
|
+
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
|
|
175
|
+
expected = hmac.new(self.hs256_secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
|
|
176
|
+
supplied = _b64url_decode(parts[2])
|
|
177
|
+
if not hmac.compare_digest(expected, supplied):
|
|
178
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim signature is invalid")
|
|
179
|
+
|
|
180
|
+
def _verify_es256(self, parts: list[str], *, kid: str) -> None:
|
|
181
|
+
if not self._public_keys:
|
|
182
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim verifier is not configured for ES256")
|
|
183
|
+
if not all([InvalidSignature, hashes, serialization, ec, utils]):
|
|
184
|
+
raise RelayClaimError("attested_claim_invalid", "ES256 relay claim verifier dependency is unavailable")
|
|
185
|
+
supplied = _b64url_decode(parts[2])
|
|
186
|
+
if len(supplied) != 64:
|
|
187
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim ES256 signature has invalid length")
|
|
188
|
+
r = int.from_bytes(supplied[:32], "big")
|
|
189
|
+
s = int.from_bytes(supplied[32:], "big")
|
|
190
|
+
der_signature = utils.encode_dss_signature(r, s)
|
|
191
|
+
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
|
|
192
|
+
candidates = [
|
|
193
|
+
public_key for key_id, public_key in self._public_keys
|
|
194
|
+
if not kid or key_id == kid
|
|
195
|
+
]
|
|
196
|
+
if not candidates:
|
|
197
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim key id is not pinned")
|
|
198
|
+
for public_key in candidates:
|
|
199
|
+
try:
|
|
200
|
+
public_key.verify(der_signature, signing_input, ec.ECDSA(hashes.SHA256()))
|
|
201
|
+
return
|
|
202
|
+
except InvalidSignature:
|
|
203
|
+
continue
|
|
204
|
+
raise RelayClaimError("attested_claim_invalid", "relay claim signature is invalid")
|
|
205
|
+
|
|
206
|
+
def _load_public_keys(self, paths: list[Path] | tuple[Path, ...]) -> list[tuple[str, Any]]:
|
|
207
|
+
keys: list[tuple[str, Any]] = []
|
|
208
|
+
if not paths:
|
|
209
|
+
return keys
|
|
210
|
+
if not serialization:
|
|
211
|
+
raise RelayClaimError("attested_claim_invalid", "ES256 relay public key dependency is unavailable")
|
|
212
|
+
for path in paths:
|
|
213
|
+
try:
|
|
214
|
+
public_key = serialization.load_pem_public_key(path.read_bytes())
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
raise RelayClaimError("attested_claim_invalid", f"invalid relay public key {path}: {exc}")
|
|
217
|
+
keys.append((path.stem, public_key))
|
|
218
|
+
return keys
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def relay_claims_required() -> bool:
|
|
222
|
+
return os.environ.get("PAIRLING_RELAY_CLAIMS_REQUIRED", "").strip().lower() in {
|
|
223
|
+
"1",
|
|
224
|
+
"true",
|
|
225
|
+
"yes",
|
|
226
|
+
"required",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def configured_public_key_paths() -> list[Path]:
|
|
231
|
+
raw = os.environ.get("PAIRLING_RELAY_PUBLIC_KEYS", "")
|
|
232
|
+
return [Path(item).expanduser() for item in raw.split(":") if item.strip()]
|