pairling 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/bin/pairling.mjs +5 -2
- package/package.json +3 -3
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/pairling_connectd_status.py +57 -7
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +67 -20
- package/payload/mac/companiond/pairlingd.py +269 -16
- package/payload/mac/companiond/push_dispatcher.py +31 -1
- package/payload/mac/connectd/cmd/pairling-connectd/identity_test.go +65 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +150 -1
- package/payload/mac/connectd/cmd/pairling-connectd/peer_identity_test.go +86 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/main.go +121 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd.go +418 -0
- package/payload/mac/connectd/cmd/pairling-tailnet-mintd/mintd_test.go +894 -0
- package/payload/mac/connectd/internal/gateway/adversarial_verify_test.go +99 -0
- package/payload/mac/connectd/internal/gateway/funnel_bootstrap_test.go +265 -0
- package/payload/mac/connectd/internal/gateway/funnel_contract_test.go +56 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +233 -19
- package/payload/mac/connectd/internal/gateway/proxy_test.go +71 -0
- package/payload/mac/connectd/internal/runtime/config.go +19 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +25 -0
- package/payload/mac/connectd/internal/status/status.go +67 -1
- package/payload/mac/connectd/internal/status/status_test.go +138 -0
- package/payload/mac/install/install-runtime.sh +299 -20
- package/payload/mac/install/render-launchd.py +54 -10
- package/payload-manifest.json +62 -20
package/README.md
CHANGED
|
@@ -27,11 +27,11 @@ Then open Pairling on your iPhone and scan the QR code that `setup` prints.
|
|
|
27
27
|
- Stages the runtime under `~/Library/Application Support/Pairling/runtime/`
|
|
28
28
|
(versioned releases, atomic `current` symlink flip, `pairling rollback`).
|
|
29
29
|
- Installs user-domain LaunchAgents (`dev.pairling.companiond`,
|
|
30
|
-
`dev.pairling.connectd`). No root. The optional power guardian
|
|
31
|
-
explicit, sudo-gated
|
|
30
|
+
`dev.pairling.connectd`). No root. The optional power guardian and the
|
|
31
|
+
optional silent-join mint broker are separate, explicit, sudo-gated steps.
|
|
32
32
|
- Verifies the payload against the package's integrity manifest and verifies
|
|
33
|
-
the Developer ID signature of the bundled `pairling-connectd`
|
|
34
|
-
staging — fail closed.
|
|
33
|
+
the Developer ID signature of the bundled `pairling-connectd` and
|
|
34
|
+
`pairling-tailnet-mintd` binaries before staging — fail closed.
|
|
35
35
|
|
|
36
36
|
## Commands
|
|
37
37
|
|
|
@@ -51,8 +51,9 @@ pairling uninstall [--yes]
|
|
|
51
51
|
appears in a published manifest.
|
|
52
52
|
- **Provenance:** releases are published via npm Trusted Publishing (OIDC) with
|
|
53
53
|
provenance attestations. Verify with `npm audit signatures`.
|
|
54
|
-
- **Readable payload:** the runtime is Python/bash source plus
|
|
55
|
-
|
|
54
|
+
- **Readable payload:** the runtime is Python/bash source plus two signed Go
|
|
55
|
+
binaries (`pairling-connectd` and the `pairling-tailnet-mintd` broker);
|
|
56
|
+
inspect it with `npm pack pairling --dry-run`.
|
|
56
57
|
- **Integrity chain:** CI records SHA-256 of every payload file in
|
|
57
58
|
`payload-manifest.json`; `pairling setup` re-verifies before staging;
|
|
58
59
|
`pairling doctor` re-verifies the staged runtime and the binary signature.
|
|
@@ -61,9 +62,10 @@ pairling uninstall [--yes]
|
|
|
61
62
|
|
|
62
63
|
## Platform packages
|
|
63
64
|
|
|
64
|
-
The compiled runtime
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
The compiled runtime binaries (`pairling-connectd` and the optional
|
|
66
|
+
`pairling-tailnet-mintd` silent-join broker) ship as platform-filtered optional
|
|
67
|
+
dependencies: `@pairling/runtime-darwin-arm64` and `@pairling/runtime-darwin-x64`
|
|
68
|
+
— signed, notarized, and hash-pinned by each package's integrity manifest.
|
|
67
69
|
|
|
68
70
|
## Links
|
|
69
71
|
|
package/bin/pairling.mjs
CHANGED
|
@@ -92,6 +92,7 @@ function detectRosetta() {
|
|
|
92
92
|
function shimEnv() {
|
|
93
93
|
const runtimeDir = runtimePackageDir();
|
|
94
94
|
const connectd = runtimeDir ? join(runtimeDir, "bin", "pairling-connectd") : null;
|
|
95
|
+
const mintd = runtimeDir ? join(runtimeDir, "bin", "pairling-tailnet-mintd") : null;
|
|
95
96
|
const vendoredPython = runtimeDir ? join(runtimeDir, "python", "bin", "python3") : null;
|
|
96
97
|
return {
|
|
97
98
|
packageRoot,
|
|
@@ -100,6 +101,7 @@ function shimEnv() {
|
|
|
100
101
|
payloadRoot,
|
|
101
102
|
runtimePackageDir: runtimeDir,
|
|
102
103
|
connectdPath: connectd && existsSync(connectd) ? connectd : null,
|
|
104
|
+
mintdPath: mintd && existsSync(mintd) ? mintd : null,
|
|
103
105
|
vendoredPython: vendoredPython && existsSync(vendoredPython) ? vendoredPython : null,
|
|
104
106
|
stagedCli: stagedCliPath(),
|
|
105
107
|
stagedRuntimeVersion: stagedRuntimeVersion(),
|
|
@@ -187,10 +189,10 @@ function main() {
|
|
|
187
189
|
|
|
188
190
|
if (existsSync(payloadCli)) {
|
|
189
191
|
const env = shimEnv();
|
|
190
|
-
if (!env.runtimePackageDir || !env.connectdPath) {
|
|
192
|
+
if (!env.runtimePackageDir || !env.connectdPath || !env.mintdPath) {
|
|
191
193
|
process.stderr.write(
|
|
192
194
|
[
|
|
193
|
-
"pairling: the platform runtime package is missing.",
|
|
195
|
+
"pairling: the platform runtime package is missing or incomplete.",
|
|
194
196
|
"",
|
|
195
197
|
`Expected: @pairling/runtime-darwin-${process.arch === "arm64" ? "arm64" : "x64"}`,
|
|
196
198
|
"",
|
|
@@ -206,6 +208,7 @@ function main() {
|
|
|
206
208
|
delegate(payloadCli, args, {
|
|
207
209
|
PAIRLING_REPO_ROOT: join(payloadRoot, "."),
|
|
208
210
|
PAIRLING_CONNECTD_PREBUILT: env.connectdPath,
|
|
211
|
+
PAIRLING_MINTD_PREBUILT: env.mintdPath,
|
|
209
212
|
PAIRLING_DAEMON_PYTHON: env.vendoredPython,
|
|
210
213
|
});
|
|
211
214
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pairling",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Pair your iPhone with the AI coding agents running on your Mac. CLI and local runtime installer for the Pairling iOS app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pairling",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"url": "https://github.com/mergimg0/pairling-helper"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
|
-
"@pairling/runtime-darwin-arm64": "0.2.
|
|
44
|
-
"@pairling/runtime-darwin-x64": "0.2.
|
|
43
|
+
"@pairling/runtime-darwin-arm64": "0.2.7",
|
|
44
|
+
"@pairling/runtime-darwin-x64": "0.2.7"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
102b7cf
|
package/payload/mac/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.7
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import ipaddress
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import urllib.parse
|
|
7
8
|
import urllib.request
|
|
8
9
|
from typing import Any
|
|
@@ -10,7 +11,9 @@ from typing import Any
|
|
|
10
11
|
CONNECTD_STATUS_URL = "http://127.0.0.1:7774/status"
|
|
11
12
|
PAIRLING_CONNECT_ROUTE_SOURCE = "pairling_connectd"
|
|
12
13
|
PAIRLING_CONNECT_ROUTE_KIND = "tailnet"
|
|
14
|
+
PAIRLING_CONNECT_FUNNEL_KIND = "funnel"
|
|
13
15
|
PAIRLING_CONNECT_PORT = 7773
|
|
16
|
+
PAIRLING_CONNECT_FUNNEL_PORT = 443
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
def fetch_connectd_status(timeout_seconds: float = 1.5) -> dict[str, Any]:
|
|
@@ -41,7 +44,8 @@ def advertised_pairling_connect_routes(status: dict[str, Any]) -> list[dict[str,
|
|
|
41
44
|
continue
|
|
42
45
|
if route.get("source") != PAIRLING_CONNECT_ROUTE_SOURCE:
|
|
43
46
|
continue
|
|
44
|
-
|
|
47
|
+
kind = route.get("kind")
|
|
48
|
+
if kind not in (PAIRLING_CONNECT_ROUTE_KIND, PAIRLING_CONNECT_FUNNEL_KIND):
|
|
45
49
|
continue
|
|
46
50
|
if route.get("status") != "ready":
|
|
47
51
|
continue
|
|
@@ -50,16 +54,25 @@ def advertised_pairling_connect_routes(status: dict[str, Any]) -> list[dict[str,
|
|
|
50
54
|
port = int(route.get("port") or 0)
|
|
51
55
|
except (TypeError, ValueError):
|
|
52
56
|
continue
|
|
53
|
-
if
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
if kind == PAIRLING_CONNECT_FUNNEL_KIND:
|
|
58
|
+
# Funnel route: public https on a *.ts.net host, lowest priority. The
|
|
59
|
+
# tailnet branch below is unchanged and not loosened.
|
|
60
|
+
base_url = _sanitized_funnel_base_url(route.get("base_url"), host, port)
|
|
61
|
+
default_id = "pairling-connect-funnel"
|
|
62
|
+
default_priority = 10
|
|
63
|
+
else:
|
|
64
|
+
if port != PAIRLING_CONNECT_PORT or not _is_tailnet_host(host):
|
|
65
|
+
continue
|
|
66
|
+
base_url = _sanitized_base_url(route.get("base_url"), host, port)
|
|
67
|
+
default_id = "pairling-connect-tailnet"
|
|
68
|
+
default_priority = 100
|
|
56
69
|
if not base_url:
|
|
57
70
|
continue
|
|
58
71
|
valid = {
|
|
59
|
-
"id": str(route.get("id") or
|
|
60
|
-
"kind":
|
|
72
|
+
"id": str(route.get("id") or default_id),
|
|
73
|
+
"kind": kind,
|
|
61
74
|
"source": PAIRLING_CONNECT_ROUTE_SOURCE,
|
|
62
|
-
"priority": int(route.get("priority") or
|
|
75
|
+
"priority": int(route.get("priority") or default_priority),
|
|
63
76
|
"base_url": base_url,
|
|
64
77
|
"host": host,
|
|
65
78
|
"port": port,
|
|
@@ -87,6 +100,9 @@ def redacted_connectd_summary(status: dict[str, Any]) -> dict[str, Any]:
|
|
|
87
100
|
"route": routes[0] if routes else None,
|
|
88
101
|
"auth_url_present": bool(status.get("auth_url_present")) if isinstance(status, dict) else False,
|
|
89
102
|
"tailnet_ip_count": int(status.get("tailnet_ip_count") or 0) if isinstance(status, dict) else 0,
|
|
103
|
+
"tailnet_node_id": _identity_string(status.get("tailnet_node_id")) if isinstance(status, dict) else "",
|
|
104
|
+
"tags": _identity_list(status.get("tags")) if isinstance(status, dict) else [],
|
|
105
|
+
"tailnet_ips": _identity_list(status.get("tailnet_ips")) if isinstance(status, dict) else [],
|
|
90
106
|
"listener_running": bool(status.get("listener_running")) if isinstance(status, dict) else False,
|
|
91
107
|
"upstream_reachable": bool(status.get("upstream_reachable")) if isinstance(status, dict) else False,
|
|
92
108
|
"local_pairing_available": True,
|
|
@@ -128,6 +144,26 @@ def _next_action(status: dict[str, Any], route_ready: bool) -> dict[str, str]:
|
|
|
128
144
|
}
|
|
129
145
|
|
|
130
146
|
|
|
147
|
+
def _string_list(value: Any) -> list[str]:
|
|
148
|
+
if not isinstance(value, list):
|
|
149
|
+
return []
|
|
150
|
+
return [str(item) for item in value if isinstance(item, (str, int, float))]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_IDENTITY_SECRET_RE = re.compile(r"(tskey|authkey|client_secret|nlprivate)", re.I)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _identity_string(value: Any) -> str:
|
|
157
|
+
raw = str(value or "").strip()
|
|
158
|
+
if _IDENTITY_SECRET_RE.search(raw):
|
|
159
|
+
return "[redacted]"
|
|
160
|
+
return raw
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _identity_list(value: Any) -> list[str]:
|
|
164
|
+
return [_identity_string(item) for item in _string_list(value)]
|
|
165
|
+
|
|
166
|
+
|
|
131
167
|
def _sanitized_base_url(value: Any, host: str, port: int) -> str | None:
|
|
132
168
|
raw = str(value or f"http://{host}:{port}").strip()
|
|
133
169
|
try:
|
|
@@ -139,6 +175,20 @@ def _sanitized_base_url(value: Any, host: str, port: int) -> str | None:
|
|
|
139
175
|
return urllib.parse.urlunparse(("http", f"{host}:{port}", "", "", "", ""))
|
|
140
176
|
|
|
141
177
|
|
|
178
|
+
def _sanitized_funnel_base_url(value: Any, host: str, port: int) -> str | None:
|
|
179
|
+
# A funnel route is accepted only as https on a *.ts.net host at port 443.
|
|
180
|
+
if not host.endswith(".ts.net") or port != PAIRLING_CONNECT_FUNNEL_PORT:
|
|
181
|
+
return None
|
|
182
|
+
raw = str(value or f"https://{host}").strip()
|
|
183
|
+
try:
|
|
184
|
+
parsed = urllib.parse.urlparse(raw)
|
|
185
|
+
except Exception:
|
|
186
|
+
return None
|
|
187
|
+
if parsed.scheme != "https" or parsed.hostname != host or parsed.port not in (None, PAIRLING_CONNECT_FUNNEL_PORT):
|
|
188
|
+
return None
|
|
189
|
+
return urllib.parse.urlunparse(("https", host, "", "", "", ""))
|
|
190
|
+
|
|
191
|
+
|
|
142
192
|
def _is_tailnet_host(host: str) -> bool:
|
|
143
193
|
if host.endswith(".ts.net"):
|
|
144
194
|
return True
|
|
@@ -171,6 +171,7 @@ class DeviceRegistry:
|
|
|
171
171
|
"device_display_name": "ALTER TABLE devices ADD COLUMN device_display_name TEXT",
|
|
172
172
|
"superseded_by_device_id": "ALTER TABLE devices ADD COLUMN superseded_by_device_id TEXT",
|
|
173
173
|
"proof_secret": "ALTER TABLE devices ADD COLUMN proof_secret TEXT",
|
|
174
|
+
"tailnet_node_id": "ALTER TABLE devices ADD COLUMN tailnet_node_id TEXT",
|
|
174
175
|
# WS4: base64 X9.63 (uncompressed P-256 point) of the device's
|
|
175
176
|
# Secure-Enclave public key, registered at first pair. Used to
|
|
176
177
|
# verify zero-interaction re-pair challenge signatures.
|
|
@@ -361,6 +362,40 @@ class DeviceRegistry:
|
|
|
361
362
|
scopes=scopes,
|
|
362
363
|
)
|
|
363
364
|
|
|
365
|
+
def tailnet_node_id(self, device_id: str) -> str | None:
|
|
366
|
+
with self.connect() as conn:
|
|
367
|
+
row = conn.execute(
|
|
368
|
+
"SELECT tailnet_node_id FROM devices WHERE device_id = ?",
|
|
369
|
+
(device_id,),
|
|
370
|
+
).fetchone()
|
|
371
|
+
return None if row is None else row["tailnet_node_id"]
|
|
372
|
+
|
|
373
|
+
def set_tailnet_node_id_if_absent(self, device_id: str, node_id: str) -> bool:
|
|
374
|
+
node_id = str(node_id or "").strip()
|
|
375
|
+
if not device_id or not node_id:
|
|
376
|
+
return False
|
|
377
|
+
with self.connect() as conn:
|
|
378
|
+
cur = conn.execute(
|
|
379
|
+
"""
|
|
380
|
+
UPDATE devices
|
|
381
|
+
SET tailnet_node_id = ?
|
|
382
|
+
WHERE device_id = ?
|
|
383
|
+
AND revoked_at IS NULL
|
|
384
|
+
AND (tailnet_node_id IS NULL OR tailnet_node_id = '')
|
|
385
|
+
""",
|
|
386
|
+
(node_id, device_id),
|
|
387
|
+
)
|
|
388
|
+
changed = cur.rowcount > 0
|
|
389
|
+
if changed:
|
|
390
|
+
self.record_audit(
|
|
391
|
+
"device.tailnet_node_id.bound",
|
|
392
|
+
device_id=device_id,
|
|
393
|
+
outcome="ok",
|
|
394
|
+
detail={"tailnet_node_id": node_id},
|
|
395
|
+
conn=conn,
|
|
396
|
+
)
|
|
397
|
+
return changed
|
|
398
|
+
|
|
364
399
|
def revoke_device(self, device_id: str, *, reason: str = "revoked") -> bool:
|
|
365
400
|
with self.connect() as conn:
|
|
366
401
|
cur = conn.execute(
|
|
@@ -159,6 +159,9 @@ class PairClaim:
|
|
|
159
159
|
cert_pin: str | None
|
|
160
160
|
relay_device_id: str | None = None
|
|
161
161
|
attestation_status: str = "none"
|
|
162
|
+
# Increment 5: True only when a direct App Attest attestation was verified for
|
|
163
|
+
# this claim. Distinct from attestation_status, which is the relay-path field.
|
|
164
|
+
direct_attestation_verified: bool = False
|
|
162
165
|
|
|
163
166
|
|
|
164
167
|
class PairingError(Exception):
|
|
@@ -401,6 +404,42 @@ class PairingStore:
|
|
|
401
404
|
raise PairingError("pair_locked", 429, "too many claim attempts")
|
|
402
405
|
return record, path, now
|
|
403
406
|
|
|
407
|
+
def _verify_claim_attestation(
|
|
408
|
+
self,
|
|
409
|
+
*,
|
|
410
|
+
pair_id: str,
|
|
411
|
+
record: dict,
|
|
412
|
+
attest_object: dict | None,
|
|
413
|
+
attest_key_id: str,
|
|
414
|
+
attest_environment: str,
|
|
415
|
+
require: bool,
|
|
416
|
+
force_production: bool,
|
|
417
|
+
) -> bool:
|
|
418
|
+
"""Verify direct App Attest. Returns True when a valid attestation was
|
|
419
|
+
verified, False when none was required and none supplied. Raises on
|
|
420
|
+
failure or when required-and-missing. force_production pins the
|
|
421
|
+
environment so a funnel client cannot send 'development'."""
|
|
422
|
+
if not (require or _direct_attest_required() or attest_object):
|
|
423
|
+
return False
|
|
424
|
+
if not attest_object:
|
|
425
|
+
raise PairingError("direct_attest_required", 403, "app attest required")
|
|
426
|
+
if _verify_direct_attestation is None:
|
|
427
|
+
raise PairingError("direct_attest_unavailable", 503, "app attest validator unavailable")
|
|
428
|
+
environment = "production" if force_production else attest_environment
|
|
429
|
+
try:
|
|
430
|
+
_verify_direct_attestation(
|
|
431
|
+
attestation=attest_object,
|
|
432
|
+
pair_id=pair_id,
|
|
433
|
+
attest_challenge=str(record.get("attest_challenge") or ""),
|
|
434
|
+
key_id=attest_key_id,
|
|
435
|
+
environment=environment,
|
|
436
|
+
)
|
|
437
|
+
except PairingError:
|
|
438
|
+
raise
|
|
439
|
+
except Exception:
|
|
440
|
+
raise PairingError("direct_attest_invalid", 403, "app attest validation failed")
|
|
441
|
+
return True
|
|
442
|
+
|
|
404
443
|
def _finalize_claim(
|
|
405
444
|
self,
|
|
406
445
|
*,
|
|
@@ -420,31 +459,24 @@ class PairingStore:
|
|
|
420
459
|
relay_device_id: str | None,
|
|
421
460
|
relay_required: bool,
|
|
422
461
|
relay_claim_verifier,
|
|
462
|
+
funnel_origin: bool = False,
|
|
463
|
+
attestation_verified: bool | None = None,
|
|
423
464
|
) -> PairClaim:
|
|
424
465
|
"""Post-authentication finalize, shared by legacy and PSK claims. The
|
|
425
466
|
caller holds _claim_lock and has already proven secret-knowledge (legacy
|
|
426
467
|
compare or PSK key-confirmation): App Attest, relay ticket, device
|
|
427
468
|
creation, SE pubkey registration, record teardown."""
|
|
428
|
-
# WS2: direct
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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")
|
|
469
|
+
# WS2 + Increment 5: direct App Attest. For a funnel-origin claim it is a
|
|
470
|
+
# hard, fail-closed requirement (verified earlier, before the derive). For
|
|
471
|
+
# LAN and tailnet claims it stays opportunistic. attestation_verified
|
|
472
|
+
# carries a result the caller already computed (the funnel pre-derive
|
|
473
|
+
# check); otherwise verify here.
|
|
474
|
+
if attestation_verified is None:
|
|
475
|
+
attestation_verified = self._verify_claim_attestation(
|
|
476
|
+
pair_id=pair_id, record=record, attest_object=attest_object,
|
|
477
|
+
attest_key_id=attest_key_id, attest_environment=attest_environment,
|
|
478
|
+
require=funnel_origin, force_production=funnel_origin,
|
|
479
|
+
)
|
|
448
480
|
relay_status = "none"
|
|
449
481
|
verified_relay_device_id = relay_device_id
|
|
450
482
|
relay_pair_secret = None
|
|
@@ -512,6 +544,7 @@ class PairingStore:
|
|
|
512
544
|
cert_pin,
|
|
513
545
|
verified_relay_device_id,
|
|
514
546
|
relay_status,
|
|
547
|
+
bool(attestation_verified),
|
|
515
548
|
)
|
|
516
549
|
except Exception:
|
|
517
550
|
self._delete_record(marker)
|
|
@@ -535,6 +568,7 @@ class PairingStore:
|
|
|
535
568
|
relay_device_id: str | None = None,
|
|
536
569
|
relay_required: bool = False,
|
|
537
570
|
relay_claim_verifier=None,
|
|
571
|
+
funnel_origin: bool = False,
|
|
538
572
|
) -> tuple[PairClaim, bytes, bytes, bytes]:
|
|
539
573
|
"""WS3 PSK-authenticated ECDH claim. The secret is NEVER received; the
|
|
540
574
|
caller proves knowledge of it by completing the authenticated key
|
|
@@ -549,6 +583,18 @@ class PairingStore:
|
|
|
549
583
|
except Exception:
|
|
550
584
|
raise PairingError("psk_bad_key", 400, "invalid psk material")
|
|
551
585
|
with self._claim_lock:
|
|
586
|
+
attestation_verified = None
|
|
587
|
+
if funnel_origin:
|
|
588
|
+
# Funnel claims prove a genuine device BEFORE the attempt counter
|
|
589
|
+
# and the ECDH derive, so an un-attested spray cannot lock out a
|
|
590
|
+
# live invitation or force crypto work. Fail-closed regardless of
|
|
591
|
+
# the env default, with the environment pinned to production.
|
|
592
|
+
funnel_record, _ = self._load_record(pair_id)
|
|
593
|
+
attestation_verified = self._verify_claim_attestation(
|
|
594
|
+
pair_id=pair_id, record=funnel_record, attest_object=attest_object,
|
|
595
|
+
attest_key_id=attest_key_id, attest_environment=attest_environment,
|
|
596
|
+
require=True, force_production=True,
|
|
597
|
+
)
|
|
552
598
|
record, path, now = self._precheck_claim(pair_id)
|
|
553
599
|
secret = str(record.get("secret") or "")
|
|
554
600
|
mac_priv_b64 = str(record.get("mac_ake_priv") or "")
|
|
@@ -578,6 +624,7 @@ class PairingStore:
|
|
|
578
624
|
attested_claim_ticket=attested_claim_ticket,
|
|
579
625
|
relay_device_id=relay_device_id, relay_required=relay_required,
|
|
580
626
|
relay_claim_verifier=relay_claim_verifier,
|
|
627
|
+
funnel_origin=funnel_origin, attestation_verified=attestation_verified,
|
|
581
628
|
)
|
|
582
629
|
aad = _psk.transcript(pair_id, a_pub, b_pub)
|
|
583
630
|
mac_confirm = _psk.confirm_tag(k_confirm, _psk.CONFIRM_MAC, pair_id, a_pub, b_pub)
|