pairling 0.2.0 → 0.2.2

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 (29) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/payload/mac/SOURCE_BRANCH +1 -1
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/app_attest_lan.py +87 -0
  7. package/payload/mac/companiond/codex_approval.py +69 -0
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +374 -70
  10. package/payload/mac/companiond/pairling_psk.py +100 -0
  11. package/payload/mac/companiond/pairling_tools.py +2 -2
  12. package/payload/mac/companiond/pairlingd.py +977 -104
  13. package/payload/mac/companiond/pty_broker.py +441 -3
  14. package/payload/mac/companiond/pty_broker_client.py +167 -0
  15. package/payload/mac/companiond/pty_broker_service.py +84 -0
  16. package/payload/mac/companiond/runtime_contract.py +0 -2
  17. package/payload/mac/companiond/standard_push_publisher.py +7 -0
  18. package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
  19. package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
  22. package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
  23. package/payload/mac/connectd/internal/status/status.go +9 -0
  24. package/payload/mac/install/doctor.sh +160 -18
  25. package/payload/mac/install/install-runtime.sh +329 -12
  26. package/payload/mac/install/psk_dependency_check.py +40 -0
  27. package/payload/mac/install/render-launchd.py +23 -0
  28. package/payload/mac/install/uninstall-runtime.sh +4 -12
  29. 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": secrets.token_urlsafe(9),
320
+ "pairing_nonce": pairing_nonce,
189
321
  "route_hint": os.environ.get("PAIRLING_ROUTE_HINT", "lan,bonjour,tailnet")[:64],
190
322
  }
191
- return PairStart(pair_id, secret, expires_at, self.install_id, PAIR_SERVICE_TYPE, txt)
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._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")
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
- 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)
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
- 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,
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
- attestation_status=relay_status,
264
- device_display_name=device_name or "Pairling iPhone",
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
- 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,
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 Exception:
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 Mergim's usual voice. Return one of: yes, partial, no. Then give one concrete edit. Be concise. Do not rewrite the whole draft unless asked.",
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 Mergim's usual direct style")
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")