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.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. 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()]