pairling 0.2.0 → 0.2.1
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 +1 -1
- package/package.json +5 -5
- package/payload/mac/SOURCE_BRANCH +1 -1
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/app_attest_lan.py +87 -0
- package/payload/mac/companiond/codex_approval.py +69 -0
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +374 -70
- package/payload/mac/companiond/pairling_psk.py +100 -0
- package/payload/mac/companiond/pairling_tools.py +2 -2
- package/payload/mac/companiond/pairlingd.py +977 -104
- package/payload/mac/companiond/pty_broker.py +441 -3
- package/payload/mac/companiond/pty_broker_client.py +167 -0
- package/payload/mac/companiond/pty_broker_service.py +84 -0
- package/payload/mac/companiond/runtime_contract.py +0 -2
- package/payload/mac/companiond/standard_push_publisher.py +7 -0
- package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
- package/payload/mac/connectd/internal/status/status.go +9 -0
- package/payload/mac/install/doctor.sh +160 -18
- package/payload/mac/install/install-runtime.sh +329 -12
- package/payload/mac/install/psk_dependency_check.py +40 -0
- package/payload/mac/install/render-launchd.py +23 -0
- package/payload/mac/install/uninstall-runtime.sh +4 -12
- package/payload-manifest.json +51 -23
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import base64
|
|
6
7
|
import json
|
|
7
8
|
import os
|
|
8
9
|
import secrets
|
|
@@ -30,12 +31,113 @@ except Exception:
|
|
|
30
31
|
RelayClaimError = None
|
|
31
32
|
RelayClaimVerifier = None
|
|
32
33
|
|
|
34
|
+
try:
|
|
35
|
+
from app_attest_lan import direct_attest_required as _direct_attest_required
|
|
36
|
+
from app_attest_lan import verify_attestation as _verify_direct_attestation
|
|
37
|
+
except Exception:
|
|
38
|
+
def _direct_attest_required() -> bool:
|
|
39
|
+
return False
|
|
40
|
+
_verify_direct_attestation = None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import pairling_psk as _psk
|
|
44
|
+
except Exception:
|
|
45
|
+
_psk = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _psk_required() -> bool:
|
|
49
|
+
# PSK-authenticated ECDH is the only MITM-safe pairing path, so it is REQUIRED by
|
|
50
|
+
# default. Only an explicit opt-out ("0"/"false"/"no"/"off") permits the legacy
|
|
51
|
+
# plaintext /pair/claim — used by contract tests that exercise the legacy branch on
|
|
52
|
+
# purpose, and as a break-glass if the crypto module is ever unavailable.
|
|
53
|
+
return os.environ.get("PAIRLING_PSK_REQUIRED", "on").strip().lower() not in {"0", "false", "no", "off"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Boot-time hard-dependency assertion. With PSK required by default, the cryptography
|
|
57
|
+
# module (imported by pairling_psk) is a hard runtime dependency: if it failed to import,
|
|
58
|
+
# fail LOUD here at daemon startup instead of silently returning 503 from every
|
|
59
|
+
# /pair/psk-claim while legacy is closed — which would brick pairing entirely. Set
|
|
60
|
+
# PAIRLING_PSK_REQUIRED=0 to fall back to legacy plaintext pairing when crypto is absent.
|
|
61
|
+
if _psk is None and _psk_required():
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Pairling pairing requires the 'cryptography' package (pairling_psk failed to "
|
|
64
|
+
"import) because PAIRLING_PSK_REQUIRED is on by default. Install cryptography, or "
|
|
65
|
+
"set PAIRLING_PSK_REQUIRED=0 to permit the legacy plaintext claim."
|
|
66
|
+
)
|
|
67
|
+
|
|
33
68
|
|
|
34
69
|
DEFAULT_PAIR_TTL_SECONDS = 180
|
|
35
70
|
MIN_PAIR_TTL_SECONDS = 60
|
|
36
71
|
MAX_PAIR_TTL_SECONDS = 300
|
|
37
72
|
|
|
38
73
|
|
|
74
|
+
def _nonce_required() -> bool:
|
|
75
|
+
return os.environ.get("PAIRLING_NONCE_REQUIRED", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def verify_p256_signature(point_b64: str, message: bytes, signature_der: bytes) -> bool:
|
|
79
|
+
"""Verify an ECDSA-P256-SHA256 signature from the iOS Secure Enclave.
|
|
80
|
+
|
|
81
|
+
point_b64 is the base64 X9.63 public key (04 || X || Y) returned by
|
|
82
|
+
SecKeyCopyExternalRepresentation; signature_der is the DER (X9.62) ECDSA
|
|
83
|
+
signature from SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256).
|
|
84
|
+
Constant-time / exception-safe: any malformed input returns False.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
from cryptography.exceptions import InvalidSignature
|
|
88
|
+
from cryptography.hazmat.primitives import hashes
|
|
89
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
|
92
|
+
if not point_b64 or not signature_der:
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
point = base64.b64decode(point_b64)
|
|
96
|
+
public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), point)
|
|
97
|
+
public_key.verify(signature_der, message, ec.ECDSA(hashes.SHA256()))
|
|
98
|
+
return True
|
|
99
|
+
except (InvalidSignature, ValueError, TypeError):
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ReauthStore:
|
|
104
|
+
"""WS4: short-lived per-device challenges for zero-interaction re-pair.
|
|
105
|
+
|
|
106
|
+
A challenge is issued for ANY device_id (even unknown) so the endpoint is
|
|
107
|
+
not a device-existence oracle. Verification fails uniformly when the device
|
|
108
|
+
is unknown, revoked, has no SE key, or the signature does not check out.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, registry: DeviceRegistry, *, ttl_seconds: int = 120):
|
|
112
|
+
self.registry = registry
|
|
113
|
+
self.ttl_seconds = ttl_seconds
|
|
114
|
+
self._lock = threading.Lock()
|
|
115
|
+
self._challenges: dict[str, tuple[str, float]] = {}
|
|
116
|
+
|
|
117
|
+
def issue_challenge(self, device_id: str) -> str:
|
|
118
|
+
challenge = secrets.token_hex(32)
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._challenges[device_id] = (challenge, time.time() + self.ttl_seconds)
|
|
121
|
+
return challenge
|
|
122
|
+
|
|
123
|
+
def verify_and_consume(self, device_id: str, challenge: str, signature_der: bytes) -> bool:
|
|
124
|
+
# Single-use: pop regardless of outcome so a captured challenge cannot
|
|
125
|
+
# be replayed even if the first signature was wrong.
|
|
126
|
+
with self._lock:
|
|
127
|
+
entry = self._challenges.pop(device_id, None)
|
|
128
|
+
if entry is None:
|
|
129
|
+
return False
|
|
130
|
+
stored_challenge, expires_at = entry
|
|
131
|
+
if time.time() > expires_at:
|
|
132
|
+
return False
|
|
133
|
+
if not secrets.compare_digest(stored_challenge, challenge or ""):
|
|
134
|
+
return False
|
|
135
|
+
point_b64 = self.registry.get_se_pubkey(device_id)
|
|
136
|
+
if not point_b64:
|
|
137
|
+
return False
|
|
138
|
+
return verify_p256_signature(point_b64, challenge.encode("ascii"), signature_der)
|
|
139
|
+
|
|
140
|
+
|
|
39
141
|
@dataclass(frozen=True)
|
|
40
142
|
class PairStart:
|
|
41
143
|
pair_id: str
|
|
@@ -44,6 +146,9 @@ class PairStart:
|
|
|
44
146
|
install_id: str
|
|
45
147
|
service_type: str
|
|
46
148
|
txt: dict[str, str]
|
|
149
|
+
pairing_nonce: str = ""
|
|
150
|
+
attest_challenge: str = ""
|
|
151
|
+
mac_ake_pub: str = ""
|
|
47
152
|
|
|
48
153
|
|
|
49
154
|
@dataclass(frozen=True)
|
|
@@ -78,6 +183,10 @@ class PairingStore:
|
|
|
78
183
|
self.runtime_port = runtime_port
|
|
79
184
|
self.install_id = install_id or self._load_install_id_from_config()
|
|
80
185
|
self._claim_lock = threading.Lock()
|
|
186
|
+
# P0-B: per-pair_id wrong-guess counter, pre-checked before secret
|
|
187
|
+
# comparison so a racing attacker cannot brute the secret/nonce.
|
|
188
|
+
# In-process only; the pair_id TTL is the outer bound on staleness.
|
|
189
|
+
self._claim_attempts: dict[str, int] = {}
|
|
81
190
|
|
|
82
191
|
def _computer_name(self) -> str:
|
|
83
192
|
try:
|
|
@@ -153,10 +262,33 @@ class PairingStore:
|
|
|
153
262
|
ttl = max(MIN_PAIR_TTL_SECONDS, min(int(ttl_seconds), MAX_PAIR_TTL_SECONDS))
|
|
154
263
|
pair_id = "pair_" + secrets.token_hex(8)
|
|
155
264
|
secret = secrets.token_urlsafe(24)
|
|
265
|
+
# P0-A: the nonce now lives in the on-disk record (it used to be
|
|
266
|
+
# generated only into the Bonjour TXT, so claim_pair() could never
|
|
267
|
+
# verify it). Both the Bonjour TXT and the QR claim payload carry it,
|
|
268
|
+
# so either path can present it back.
|
|
269
|
+
pairing_nonce = secrets.token_urlsafe(9)
|
|
270
|
+
# WS2: per-invitation App Attest challenge. The iOS app binds its
|
|
271
|
+
# attestation to canonical(pair_id, attest_challenge); the Mac verifies
|
|
272
|
+
# against this stored value, so a MITM cannot swap it (Blocker #6).
|
|
273
|
+
attest_challenge = secrets.token_hex(32)
|
|
274
|
+
# WS3: per-invitation Mac ephemeral ECDH key. A_pub goes in the OOB
|
|
275
|
+
# payload (QR/paste); the private half is stored in this mode-600 record
|
|
276
|
+
# so the claim can run a PSK-authenticated ECDH and the secret is never
|
|
277
|
+
# transmitted. Absent when the crypto module is unavailable (legacy only).
|
|
278
|
+
mac_ake_pub = ""
|
|
279
|
+
mac_ake_priv_b64 = ""
|
|
280
|
+
if _psk is not None:
|
|
281
|
+
_ake_priv, _ake_pub = _psk.mac_keygen()
|
|
282
|
+
mac_ake_pub = base64.urlsafe_b64encode(_ake_pub).rstrip(b"=").decode("ascii")
|
|
283
|
+
mac_ake_priv_b64 = base64.b64encode(_psk.dump_private(_ake_priv)).decode("ascii")
|
|
156
284
|
expires_at = time.time() + ttl
|
|
157
285
|
record = {
|
|
158
286
|
"pair_id": pair_id,
|
|
159
287
|
"secret": secret,
|
|
288
|
+
"pairing_nonce": pairing_nonce,
|
|
289
|
+
"attest_challenge": attest_challenge,
|
|
290
|
+
"mac_ake_pub": mac_ake_pub,
|
|
291
|
+
"mac_ake_priv": mac_ake_priv_b64,
|
|
160
292
|
"created_at": time.time(),
|
|
161
293
|
"expires_at": expires_at,
|
|
162
294
|
"claimed_at": None,
|
|
@@ -185,10 +317,13 @@ class PairingStore:
|
|
|
185
317
|
"mac_name": self._computer_name(),
|
|
186
318
|
"mac_model": self._mac_model(),
|
|
187
319
|
"runtime_version": self._runtime_version(),
|
|
188
|
-
"pairing_nonce":
|
|
320
|
+
"pairing_nonce": pairing_nonce,
|
|
189
321
|
"route_hint": os.environ.get("PAIRLING_ROUTE_HINT", "lan,bonjour,tailnet")[:64],
|
|
190
322
|
}
|
|
191
|
-
return PairStart(
|
|
323
|
+
return PairStart(
|
|
324
|
+
pair_id, secret, expires_at, self.install_id, PAIR_SERVICE_TYPE, txt,
|
|
325
|
+
pairing_nonce, attest_challenge, mac_ake_pub,
|
|
326
|
+
)
|
|
192
327
|
|
|
193
328
|
def _load_record(self, pair_id: str) -> tuple[dict, Path]:
|
|
194
329
|
path = self._record_path(pair_id)
|
|
@@ -211,87 +346,249 @@ class PairingStore:
|
|
|
211
346
|
host_chain: Iterable[str],
|
|
212
347
|
scopes: Iterable[str] | None = None,
|
|
213
348
|
cert_pin: str | None = None,
|
|
349
|
+
pairing_nonce: str = "",
|
|
350
|
+
se_public_key_der: str = "",
|
|
351
|
+
attest_object: dict | None = None,
|
|
352
|
+
attest_key_id: str = "",
|
|
353
|
+
attest_environment: str = "",
|
|
214
354
|
attested_claim_ticket: str | None = None,
|
|
215
355
|
relay_device_id: str | None = None,
|
|
216
356
|
relay_required: bool = False,
|
|
217
357
|
relay_claim_verifier=None,
|
|
218
358
|
) -> PairClaim:
|
|
219
359
|
with self._claim_lock:
|
|
220
|
-
record, path = self.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if
|
|
225
|
-
|
|
226
|
-
raise PairingError("pair_expired", 410, "pair record expired")
|
|
360
|
+
record, path, now = self._precheck_claim(pair_id)
|
|
361
|
+
# WS3: once PSK pairing is mandatory, reject legacy plaintext-secret
|
|
362
|
+
# claims outright — the secret must never cross the wire. New clients
|
|
363
|
+
# use /pair/psk-claim instead.
|
|
364
|
+
if _psk_required():
|
|
365
|
+
raise PairingError("psk_required", 403, "psk-authenticated pairing required")
|
|
227
366
|
if not secrets.compare_digest(str(record.get("secret") or ""), secret or ""):
|
|
228
367
|
raise PairingError("invalid_secret", 403, "invalid pair secret")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
if not
|
|
235
|
-
raise PairingError("
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
368
|
+
# P0-A: nonce gate (default off via PAIRLING_NONCE_REQUIRED). Both
|
|
369
|
+
# the QR claim payload and the Bonjour TXT carry pairing_nonce, so
|
|
370
|
+
# legitimate claims on either path present it; an attacker who only
|
|
371
|
+
# hit /pair/start blind (never saw the TXT/QR) cannot.
|
|
372
|
+
if _nonce_required():
|
|
373
|
+
if not secrets.compare_digest(str(record.get("pairing_nonce") or ""), pairing_nonce or ""):
|
|
374
|
+
raise PairingError("invalid_pairing_nonce", 403, "invalid pairing nonce")
|
|
375
|
+
return self._finalize_claim(
|
|
376
|
+
pair_id=pair_id, record=record, path=path, now=now,
|
|
377
|
+
device_name=device_name, host_chain=host_chain, scopes=scopes,
|
|
378
|
+
cert_pin=cert_pin, se_public_key_der=se_public_key_der,
|
|
379
|
+
attest_object=attest_object, attest_key_id=attest_key_id,
|
|
380
|
+
attest_environment=attest_environment,
|
|
381
|
+
attested_claim_ticket=attested_claim_ticket,
|
|
382
|
+
relay_device_id=relay_device_id, relay_required=relay_required,
|
|
383
|
+
relay_claim_verifier=relay_claim_verifier,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def _precheck_claim(self, pair_id: str) -> tuple[dict, Path, float]:
|
|
387
|
+
"""Shared front-matter for both claim paths (caller holds _claim_lock):
|
|
388
|
+
load the record, reject already-claimed/expired, and pre-increment the
|
|
389
|
+
per-pair_id attempt counter so wrong guesses lock out after 5 (P0-B)."""
|
|
390
|
+
record, path = self._load_record(pair_id)
|
|
391
|
+
now = time.time()
|
|
392
|
+
if record.get("claimed_at") is not None:
|
|
393
|
+
raise PairingError("pair_already_claimed", 409, "pair record already claimed")
|
|
394
|
+
if now > float(record.get("expires_at") or 0):
|
|
395
|
+
self._delete_record(path)
|
|
396
|
+
self._claim_attempts.pop(pair_id, None)
|
|
397
|
+
raise PairingError("pair_expired", 410, "pair record expired")
|
|
398
|
+
attempts = self._claim_attempts.get(pair_id, 0) + 1
|
|
399
|
+
self._claim_attempts[pair_id] = attempts
|
|
400
|
+
if attempts > 5:
|
|
401
|
+
raise PairingError("pair_locked", 429, "too many claim attempts")
|
|
402
|
+
return record, path, now
|
|
403
|
+
|
|
404
|
+
def _finalize_claim(
|
|
405
|
+
self,
|
|
406
|
+
*,
|
|
407
|
+
pair_id: str,
|
|
408
|
+
record: dict,
|
|
409
|
+
path: Path,
|
|
410
|
+
now: float,
|
|
411
|
+
device_name: str,
|
|
412
|
+
host_chain: Iterable[str],
|
|
413
|
+
scopes: Iterable[str] | None,
|
|
414
|
+
cert_pin: str | None,
|
|
415
|
+
se_public_key_der: str,
|
|
416
|
+
attest_object: dict | None,
|
|
417
|
+
attest_key_id: str,
|
|
418
|
+
attest_environment: str,
|
|
419
|
+
attested_claim_ticket: str | None,
|
|
420
|
+
relay_device_id: str | None,
|
|
421
|
+
relay_required: bool,
|
|
422
|
+
relay_claim_verifier,
|
|
423
|
+
) -> PairClaim:
|
|
424
|
+
"""Post-authentication finalize, shared by legacy and PSK claims. The
|
|
425
|
+
caller holds _claim_lock and has already proven secret-knowledge (legacy
|
|
426
|
+
compare or PSK key-confirmation): App Attest, relay ticket, device
|
|
427
|
+
creation, SE pubkey registration, record teardown."""
|
|
428
|
+
# WS2: direct-LAN App Attest. When required (or opportunistically
|
|
429
|
+
# supplied), the claimant must present a valid Apple attestation bound to
|
|
430
|
+
# this invitation. Fails closed if the validator is unavailable while on.
|
|
431
|
+
if _direct_attest_required() or attest_object:
|
|
432
|
+
if not attest_object:
|
|
433
|
+
raise PairingError("direct_attest_required", 403, "app attest required")
|
|
434
|
+
if _verify_direct_attestation is None:
|
|
435
|
+
raise PairingError("direct_attest_unavailable", 503, "app attest validator unavailable")
|
|
257
436
|
try:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
437
|
+
_verify_direct_attestation(
|
|
438
|
+
attestation=attest_object,
|
|
439
|
+
pair_id=pair_id,
|
|
440
|
+
attest_challenge=str(record.get("attest_challenge") or ""),
|
|
441
|
+
key_id=attest_key_id,
|
|
442
|
+
environment=attest_environment,
|
|
443
|
+
)
|
|
444
|
+
except PairingError:
|
|
445
|
+
raise
|
|
446
|
+
except Exception:
|
|
447
|
+
raise PairingError("direct_attest_invalid", 403, "app attest validation failed")
|
|
448
|
+
relay_status = "none"
|
|
449
|
+
verified_relay_device_id = relay_device_id
|
|
450
|
+
relay_pair_secret = None
|
|
451
|
+
relay_pair_secret_ref = None
|
|
452
|
+
if relay_required or attested_claim_ticket:
|
|
453
|
+
if not attested_claim_ticket:
|
|
454
|
+
raise PairingError("attested_claim_required", 403, "relay claim ticket required")
|
|
455
|
+
if relay_claim_verifier is None:
|
|
456
|
+
raise PairingError("attested_claim_invalid", 403, "relay claim verifier unavailable")
|
|
457
|
+
try:
|
|
458
|
+
verification = relay_claim_verifier.verify(
|
|
459
|
+
attested_claim_ticket,
|
|
460
|
+
pair_id=pair_id,
|
|
461
|
+
relay_device_id=relay_device_id,
|
|
462
|
+
device_name=device_name,
|
|
463
|
+
)
|
|
464
|
+
except Exception as exc:
|
|
465
|
+
code = getattr(exc, "code", "attested_claim_invalid")
|
|
466
|
+
message = getattr(exc, "message", str(exc))
|
|
467
|
+
raise PairingError(code, 403, message)
|
|
468
|
+
verified_relay_device_id = verification.relay_device_id
|
|
469
|
+
relay_status = verification.attestation_status
|
|
470
|
+
relay_pair_secret = getattr(verification, "relay_pair_secret", None)
|
|
471
|
+
relay_pair_secret_ref = getattr(verification, "relay_pair_secret_ref", None)
|
|
472
|
+
normalized_hosts = tuple(h for h in host_chain if isinstance(h, str) and h)
|
|
473
|
+
if not normalized_hosts:
|
|
474
|
+
raise PairingError("missing_host_chain", 500, "host chain is empty")
|
|
475
|
+
marker = self._create_claim_marker(pair_id)
|
|
476
|
+
try:
|
|
477
|
+
device = self.registry.create_device(
|
|
478
|
+
device_name=device_name or "Pairling iPhone",
|
|
479
|
+
install_id=str(record.get("install_id") or self.install_id),
|
|
480
|
+
scopes=scopes or DEFAULT_DEVICE_SCOPES,
|
|
481
|
+
relay_device_id=verified_relay_device_id,
|
|
482
|
+
attestation_status=relay_status,
|
|
483
|
+
device_display_name=device_name or "Pairling iPhone",
|
|
484
|
+
relay_pair_secret_ref=relay_pair_secret_ref,
|
|
485
|
+
)
|
|
486
|
+
if relay_pair_secret and verified_relay_device_id:
|
|
487
|
+
self._store_relay_pair_secret(
|
|
488
|
+
device_id=device.device_id,
|
|
262
489
|
relay_device_id=verified_relay_device_id,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
relay_pair_secret_ref=relay_pair_secret_ref,
|
|
490
|
+
mac_install_id=str(record.get("install_id") or self.install_id),
|
|
491
|
+
relay_pair_secret=str(relay_pair_secret),
|
|
492
|
+
relay_pair_secret_ref=str(relay_pair_secret_ref or ""),
|
|
266
493
|
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
494
|
+
# WS4: register the device's Secure-Enclave public key so future
|
|
495
|
+
# connections can re-pair with a Face ID signature (no QR/PIN).
|
|
496
|
+
if se_public_key_der:
|
|
497
|
+
self.registry.register_se_pubkey(device.device_id, se_public_key_der)
|
|
498
|
+
record["claimed_at"] = now
|
|
499
|
+
record["device_id"] = device.device_id
|
|
500
|
+
path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
|
|
501
|
+
try:
|
|
502
|
+
os.chmod(path, 0o600)
|
|
503
|
+
except OSError:
|
|
504
|
+
pass
|
|
505
|
+
self._delete_record(path)
|
|
506
|
+
self._delete_record(marker)
|
|
507
|
+
self._claim_attempts.pop(pair_id, None)
|
|
508
|
+
return PairClaim(
|
|
509
|
+
device,
|
|
510
|
+
normalized_hosts,
|
|
511
|
+
int(record.get("runtime_port") or self.runtime_port),
|
|
512
|
+
cert_pin,
|
|
513
|
+
verified_relay_device_id,
|
|
514
|
+
relay_status,
|
|
515
|
+
)
|
|
516
|
+
except Exception:
|
|
517
|
+
self._delete_record(marker)
|
|
518
|
+
raise
|
|
519
|
+
|
|
520
|
+
def psk_claim_pair(
|
|
521
|
+
self,
|
|
522
|
+
*,
|
|
523
|
+
pair_id: str,
|
|
524
|
+
b_pub_b64: str,
|
|
525
|
+
confirm_b64: str,
|
|
526
|
+
device_name: str,
|
|
527
|
+
host_chain: Iterable[str],
|
|
528
|
+
scopes: Iterable[str] | None = None,
|
|
529
|
+
cert_pin: str | None = None,
|
|
530
|
+
se_public_key_der: str = "",
|
|
531
|
+
attest_object: dict | None = None,
|
|
532
|
+
attest_key_id: str = "",
|
|
533
|
+
attest_environment: str = "",
|
|
534
|
+
attested_claim_ticket: str | None = None,
|
|
535
|
+
relay_device_id: str | None = None,
|
|
536
|
+
relay_required: bool = False,
|
|
537
|
+
relay_claim_verifier=None,
|
|
538
|
+
) -> tuple[PairClaim, bytes, bytes, bytes]:
|
|
539
|
+
"""WS3 PSK-authenticated ECDH claim. The secret is NEVER received; the
|
|
540
|
+
caller proves knowledge of it by completing the authenticated key
|
|
541
|
+
exchange (phone confirm tag under K_confirm). Returns
|
|
542
|
+
(PairClaim, K_token, aad, mac_confirm) so the handler can seal the bearer
|
|
543
|
+
token under K_token and echo the Mac key-confirmation."""
|
|
544
|
+
if _psk is None:
|
|
545
|
+
raise PairingError("psk_unavailable", 503, "psk crypto unavailable")
|
|
546
|
+
try:
|
|
547
|
+
b_pub = base64.b64decode(b_pub_b64, validate=True)
|
|
548
|
+
confirm = base64.b64decode(confirm_b64, validate=True)
|
|
549
|
+
except Exception:
|
|
550
|
+
raise PairingError("psk_bad_key", 400, "invalid psk material")
|
|
551
|
+
with self._claim_lock:
|
|
552
|
+
record, path, now = self._precheck_claim(pair_id)
|
|
553
|
+
secret = str(record.get("secret") or "")
|
|
554
|
+
mac_priv_b64 = str(record.get("mac_ake_priv") or "")
|
|
555
|
+
mac_pub_b64url = str(record.get("mac_ake_pub") or "")
|
|
556
|
+
if not mac_priv_b64 or not mac_pub_b64url:
|
|
557
|
+
raise PairingError("psk_unavailable", 409, "invitation has no psk key")
|
|
558
|
+
try:
|
|
559
|
+
a_priv = _psk.load_private(base64.b64decode(mac_priv_b64))
|
|
560
|
+
a_pub = base64.urlsafe_b64decode(mac_pub_b64url + "=" * (-len(mac_pub_b64url) % 4))
|
|
561
|
+
z = _psk.shared_secret(a_priv, b_pub)
|
|
562
|
+
k_confirm, k_token = _psk.derive_keys(
|
|
563
|
+
pair_id=pair_id, a_pub=a_pub, b_pub=b_pub, z=z, secret=secret
|
|
291
564
|
)
|
|
292
|
-
except
|
|
293
|
-
self._delete_record(marker)
|
|
565
|
+
except PairingError:
|
|
294
566
|
raise
|
|
567
|
+
except Exception:
|
|
568
|
+
raise PairingError("psk_bad_key", 400, "invalid psk material")
|
|
569
|
+
expected = _psk.confirm_tag(k_confirm, _psk.CONFIRM_PHONE, pair_id, a_pub, b_pub)
|
|
570
|
+
if not secrets.compare_digest(expected, confirm):
|
|
571
|
+
raise PairingError("psk_confirm_invalid", 403, "psk confirmation invalid")
|
|
572
|
+
claim = self._finalize_claim(
|
|
573
|
+
pair_id=pair_id, record=record, path=path, now=now,
|
|
574
|
+
device_name=device_name, host_chain=host_chain, scopes=scopes,
|
|
575
|
+
cert_pin=cert_pin, se_public_key_der=se_public_key_der,
|
|
576
|
+
attest_object=attest_object, attest_key_id=attest_key_id,
|
|
577
|
+
attest_environment=attest_environment,
|
|
578
|
+
attested_claim_ticket=attested_claim_ticket,
|
|
579
|
+
relay_device_id=relay_device_id, relay_required=relay_required,
|
|
580
|
+
relay_claim_verifier=relay_claim_verifier,
|
|
581
|
+
)
|
|
582
|
+
aad = _psk.transcript(pair_id, a_pub, b_pub)
|
|
583
|
+
mac_confirm = _psk.confirm_tag(k_confirm, _psk.CONFIRM_MAC, pair_id, a_pub, b_pub)
|
|
584
|
+
return claim, k_token, aad, mac_confirm
|
|
585
|
+
|
|
586
|
+
def seal_psk_token(self, k_token: bytes, token: str, aad: bytes) -> tuple[bytes, bytes]:
|
|
587
|
+
"""AES-256-GCM the bearer token under K_token so only the phone (which
|
|
588
|
+
derived the same key) can read it. Returns (nonce, ciphertext‖tag)."""
|
|
589
|
+
if _psk is None:
|
|
590
|
+
raise PairingError("psk_unavailable", 503, "psk crypto unavailable")
|
|
591
|
+
return _psk.seal_token(k_token, token, aad=aad)
|
|
295
592
|
|
|
296
593
|
def _delete_record(self, path: Path) -> None:
|
|
297
594
|
try:
|
|
@@ -357,6 +654,13 @@ class PairingAdvertiser:
|
|
|
357
654
|
self._timer: threading.Timer | None = None
|
|
358
655
|
|
|
359
656
|
def start(self, started: PairStart, *, port: int) -> dict:
|
|
657
|
+
# When PSK pairing is required (the default), the Bonjour-advertised
|
|
658
|
+
# phone-initiated path can no longer complete a claim — legacy /pair/claim
|
|
659
|
+
# returns 403 — so publishing the service is dead surface and a needless
|
|
660
|
+
# LAN signal. Self-disable here rather than editing the pairlingd.py call
|
|
661
|
+
# site. PAIRLING_PSK_REQUIRED=0 (the legacy break-glass) re-enables it.
|
|
662
|
+
if _psk_required():
|
|
663
|
+
return {"ok": False, "reason": "psk_required"}
|
|
360
664
|
if os.environ.get("PAIRLING_DISABLE_BONJOUR") in {"1", "true", "TRUE"}:
|
|
361
665
|
return {"ok": False, "reason": "disabled"}
|
|
362
666
|
if not Path(self.dns_sd_path).exists():
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""WS3: PSK-authenticated ECDH for pairing — the byte-exact reference.
|
|
3
|
+
|
|
4
|
+
The 192-bit pairing secret is a pre-shared key delivered out-of-band (QR / paste);
|
|
5
|
+
it is NEVER transmitted. The claim becomes an authenticated P-256 ECDH whose key
|
|
6
|
+
schedule mixes in the secret, so only a holder of the secret can derive the keys.
|
|
7
|
+
|
|
8
|
+
Native primitives only (cryptography: ECDH + HKDF + HMAC + AES-GCM). The Swift
|
|
9
|
+
side (PairingPSK.swift) mirrors every byte of this; SPEC-ws3-psk-authenticated-ecdh.md
|
|
10
|
+
and the shared vectors in test_psk_vectors.py / PairingPSKTests.swift pin the agreement.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
20
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
21
|
+
from cryptography.hazmat.primitives.hashes import SHA256
|
|
22
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
23
|
+
Encoding,
|
|
24
|
+
NoEncryption,
|
|
25
|
+
PrivateFormat,
|
|
26
|
+
PublicFormat,
|
|
27
|
+
load_der_private_key,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Frozen protocol constants — must match PairingPSK.swift exactly.
|
|
31
|
+
PSK_INFO_PREFIX = b"pairling.psk.v1"
|
|
32
|
+
PSK_SALT = hashlib.sha256(b"pairling.psk.salt.v1").digest()
|
|
33
|
+
CONFIRM_PHONE = b"pairling.psk.confirm.phone.v1"
|
|
34
|
+
CONFIRM_MAC = b"pairling.psk.confirm.mac.v1"
|
|
35
|
+
_CURVE = ec.SECP256R1()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mac_keygen() -> tuple[ec.EllipticCurvePrivateKey, bytes]:
|
|
39
|
+
"""Per-invitation Mac ephemeral key. Returns (private, A_pub X9.63 65 bytes)."""
|
|
40
|
+
priv = ec.generate_private_key(_CURVE)
|
|
41
|
+
return priv, public_x963(priv)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def private_from_scalar(scalar: int) -> ec.EllipticCurvePrivateKey:
|
|
45
|
+
"""Deterministic key for test vectors."""
|
|
46
|
+
return ec.derive_private_key(scalar, _CURVE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def public_x963(priv: ec.EllipticCurvePrivateKey) -> bytes:
|
|
50
|
+
return priv.public_key().public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def public_from_x963(data: bytes) -> ec.EllipticCurvePublicKey:
|
|
54
|
+
return ec.EllipticCurvePublicKey.from_encoded_point(_CURVE, data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dump_private(priv: ec.EllipticCurvePrivateKey) -> bytes:
|
|
58
|
+
"""PKCS8 DER, for storing the per-invitation key in the (mode-600) pair record."""
|
|
59
|
+
return priv.private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_private(der: bytes) -> ec.EllipticCurvePrivateKey:
|
|
63
|
+
return load_der_private_key(der, password=None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def shared_secret(priv: ec.EllipticCurvePrivateKey, peer_pub_x963: bytes) -> bytes:
|
|
67
|
+
"""SEC1 X-coordinate (32 bytes) — the standard ECDH output both libraries return."""
|
|
68
|
+
peer = public_from_x963(peer_pub_x963)
|
|
69
|
+
return priv.exchange(ec.ECDH(), peer)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def transcript(pair_id: str, a_pub_x963: bytes, b_pub_x963: bytes) -> bytes:
|
|
73
|
+
return PSK_INFO_PREFIX + pair_id.encode("utf-8") + a_pub_x963 + b_pub_x963
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def derive_keys(*, pair_id: str, a_pub: bytes, b_pub: bytes, z: bytes, secret: str) -> tuple[bytes, bytes]:
|
|
77
|
+
"""Returns (K_confirm, K_token), each 32 bytes."""
|
|
78
|
+
info = transcript(pair_id, a_pub, b_pub)
|
|
79
|
+
okm = HKDF(algorithm=SHA256(), length=64, salt=PSK_SALT, info=info).derive(z + secret.encode("utf-8"))
|
|
80
|
+
return okm[:32], okm[32:]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def confirm_tag(k_confirm: bytes, domain: bytes, pair_id: str, a_pub: bytes, b_pub: bytes) -> bytes:
|
|
84
|
+
return hmac.new(k_confirm, domain + transcript(pair_id, a_pub, b_pub), hashlib.sha256).digest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_confirm(k_confirm: bytes, domain: bytes, pair_id: str, a_pub: bytes, b_pub: bytes, tag: bytes) -> bool:
|
|
88
|
+
return hmac.compare_digest(confirm_tag(k_confirm, domain, pair_id, a_pub, b_pub), tag or b"")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def seal_token(k_token: bytes, token: str, *, aad: bytes) -> tuple[bytes, bytes]:
|
|
92
|
+
"""AES-256-GCM. Returns (nonce(12), ciphertext+tag). Random nonce → not deterministic."""
|
|
93
|
+
import os
|
|
94
|
+
nonce = os.urandom(12)
|
|
95
|
+
ct = AESGCM(k_token).encrypt(nonce, token.encode("utf-8"), aad)
|
|
96
|
+
return nonce, ct
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def open_token(k_token: bytes, nonce: bytes, ciphertext: bytes, *, aad: bytes) -> str:
|
|
100
|
+
return AESGCM(k_token).decrypt(nonce, ciphertext, aad).decode("utf-8")
|
|
@@ -584,7 +584,7 @@ def _truncate_output(value: str, max_chars: int) -> str:
|
|
|
584
584
|
def _mac_prompt(tool: str, input_payload: dict[str, Any]) -> tuple[str, str]:
|
|
585
585
|
if tool == "vibe_check":
|
|
586
586
|
return (
|
|
587
|
-
"You are checking whether a draft sounds like
|
|
587
|
+
"You are checking whether a draft sounds like the user's usual voice. Return one of: yes, partial, no. Then give one concrete edit. Be concise. Do not rewrite the whole draft unless asked.",
|
|
588
588
|
"Draft:\n" + str(input_payload.get("draft") or ""),
|
|
589
589
|
)
|
|
590
590
|
if tool == "second_opinion":
|
|
@@ -618,7 +618,7 @@ def _deterministic_vibe_check(draft: str, *, reason: str) -> str:
|
|
|
618
618
|
"further to",
|
|
619
619
|
]
|
|
620
620
|
if any(phrase in lowered for phrase in formal_phrases):
|
|
621
|
-
issues.append("a little more formal than
|
|
621
|
+
issues.append("a little more formal than the user's usual direct style")
|
|
622
622
|
edit = "open with the ask directly and drop the polite padding."
|
|
623
623
|
if len(text) > 700:
|
|
624
624
|
issues.append("too long for a quick operational message")
|