pairling 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
CONNECTD_STATUS_URL = "http://127.0.0.1:7774/status"
|
|
11
|
+
PAIRLING_CONNECT_ROUTE_SOURCE = "pairling_connectd"
|
|
12
|
+
PAIRLING_CONNECT_ROUTE_KIND = "tailnet"
|
|
13
|
+
PAIRLING_CONNECT_PORT = 7773
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch_connectd_status(timeout_seconds: float = 1.5) -> dict[str, Any]:
|
|
17
|
+
fixture = os.environ.get("PAIRLING_TEST_CONNECTD_STATUS_JSON")
|
|
18
|
+
if fixture:
|
|
19
|
+
payload = json.loads(fixture)
|
|
20
|
+
return payload if isinstance(payload, dict) else {}
|
|
21
|
+
|
|
22
|
+
req = urllib.request.Request(CONNECTD_STATUS_URL, method="GET")
|
|
23
|
+
try:
|
|
24
|
+
with urllib.request.urlopen(req, timeout=timeout_seconds) as response:
|
|
25
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
26
|
+
except Exception:
|
|
27
|
+
return {}
|
|
28
|
+
return payload if isinstance(payload, dict) else {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def advertised_pairling_connect_routes(status: dict[str, Any]) -> list[dict[str, Any]]:
|
|
32
|
+
if not isinstance(status, dict) or int(status.get("schema_version") or 0) < 2:
|
|
33
|
+
return []
|
|
34
|
+
routes = status.get("advertised_routes") or []
|
|
35
|
+
if not isinstance(routes, list):
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
valid_routes: list[dict[str, Any]] = []
|
|
39
|
+
for route in routes:
|
|
40
|
+
if not isinstance(route, dict):
|
|
41
|
+
continue
|
|
42
|
+
if route.get("source") != PAIRLING_CONNECT_ROUTE_SOURCE:
|
|
43
|
+
continue
|
|
44
|
+
if route.get("kind") != PAIRLING_CONNECT_ROUTE_KIND:
|
|
45
|
+
continue
|
|
46
|
+
if route.get("status") != "ready":
|
|
47
|
+
continue
|
|
48
|
+
host = str(route.get("host") or "").strip()
|
|
49
|
+
try:
|
|
50
|
+
port = int(route.get("port") or 0)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
continue
|
|
53
|
+
if port != PAIRLING_CONNECT_PORT or not _is_tailnet_host(host):
|
|
54
|
+
continue
|
|
55
|
+
base_url = _sanitized_base_url(route.get("base_url"), host, port)
|
|
56
|
+
if not base_url:
|
|
57
|
+
continue
|
|
58
|
+
valid = {
|
|
59
|
+
"id": str(route.get("id") or "pairling-connect-tailnet"),
|
|
60
|
+
"kind": PAIRLING_CONNECT_ROUTE_KIND,
|
|
61
|
+
"source": PAIRLING_CONNECT_ROUTE_SOURCE,
|
|
62
|
+
"priority": int(route.get("priority") or 100),
|
|
63
|
+
"base_url": base_url,
|
|
64
|
+
"host": host,
|
|
65
|
+
"port": port,
|
|
66
|
+
"status": "ready",
|
|
67
|
+
}
|
|
68
|
+
valid_routes.append(valid)
|
|
69
|
+
valid_routes.sort(key=lambda item: int(item.get("priority") or 0), reverse=True)
|
|
70
|
+
return valid_routes
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def preferred_pairling_connect_base_url(status: dict[str, Any]) -> str | None:
|
|
74
|
+
routes = advertised_pairling_connect_routes(status)
|
|
75
|
+
if not routes:
|
|
76
|
+
return None
|
|
77
|
+
return str(routes[0]["base_url"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def redacted_connectd_summary(status: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
routes = advertised_pairling_connect_routes(status)
|
|
82
|
+
return {
|
|
83
|
+
"schema_version": int(status.get("schema_version") or 0) if isinstance(status, dict) else 0,
|
|
84
|
+
"status": "ready" if routes else _degraded_status(status),
|
|
85
|
+
"auth_state": str(status.get("auth_state") or "unknown") if isinstance(status, dict) else "unknown",
|
|
86
|
+
"route_ready": bool(routes),
|
|
87
|
+
"route": routes[0] if routes else None,
|
|
88
|
+
"auth_url_present": bool(status.get("auth_url_present")) if isinstance(status, dict) else False,
|
|
89
|
+
"tailnet_ip_count": int(status.get("tailnet_ip_count") or 0) if isinstance(status, dict) else 0,
|
|
90
|
+
"listener_running": bool(status.get("listener_running")) if isinstance(status, dict) else False,
|
|
91
|
+
"upstream_reachable": bool(status.get("upstream_reachable")) if isinstance(status, dict) else False,
|
|
92
|
+
"local_pairing_available": True,
|
|
93
|
+
"next_action": _next_action(status, bool(routes)),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _degraded_status(status: dict[str, Any]) -> str:
|
|
98
|
+
if not status:
|
|
99
|
+
return "connectd_unavailable"
|
|
100
|
+
if status.get("auth_state") != "authenticated":
|
|
101
|
+
return "auth_pending" if status.get("auth_url_present") else "auth_unknown"
|
|
102
|
+
if not status.get("listener_running"):
|
|
103
|
+
return "listener_down"
|
|
104
|
+
if not status.get("upstream_reachable"):
|
|
105
|
+
return "upstream_unreachable"
|
|
106
|
+
if not status.get("tailnet_ip"):
|
|
107
|
+
return "no_tailnet_ip"
|
|
108
|
+
return "route_missing"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _next_action(status: dict[str, Any], route_ready: bool) -> dict[str, str]:
|
|
112
|
+
if route_ready:
|
|
113
|
+
return {
|
|
114
|
+
"id": "pair_iphone",
|
|
115
|
+
"label": "Pair iPhone",
|
|
116
|
+
"message": "Scan the Mac pairing code in Pairling.",
|
|
117
|
+
}
|
|
118
|
+
if status and status.get("auth_url_present"):
|
|
119
|
+
return {
|
|
120
|
+
"id": "authenticate_pairling_connect",
|
|
121
|
+
"label": "Authenticate Pairling Connect",
|
|
122
|
+
"message": "Approve Pairling Connect in the browser, then recheck this Mac.",
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
"id": "use_local_pairing",
|
|
126
|
+
"label": "Use local pairing",
|
|
127
|
+
"message": "Pair locally now, or retry Pairling Connect after this Mac is ready.",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _sanitized_base_url(value: Any, host: str, port: int) -> str | None:
|
|
132
|
+
raw = str(value or f"http://{host}:{port}").strip()
|
|
133
|
+
try:
|
|
134
|
+
parsed = urllib.parse.urlparse(raw)
|
|
135
|
+
except Exception:
|
|
136
|
+
return None
|
|
137
|
+
if parsed.scheme != "http" or parsed.hostname != host or parsed.port != port:
|
|
138
|
+
return None
|
|
139
|
+
return urllib.parse.urlunparse(("http", f"{host}:{port}", "", "", "", ""))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_tailnet_host(host: str) -> bool:
|
|
143
|
+
if host.endswith(".ts.net"):
|
|
144
|
+
return True
|
|
145
|
+
try:
|
|
146
|
+
ip = ipaddress.ip_address(host)
|
|
147
|
+
except ValueError:
|
|
148
|
+
return False
|
|
149
|
+
return ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10")
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Per-device token registry for the Pairling Mac runtime."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable
|
|
16
|
+
|
|
17
|
+
from runtime_contract import DEFAULT_DEVICE_SCOPES
|
|
18
|
+
from runtime_paths import audit_log_path, devices_db_path, install_id_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SQLITE_BUSY_TIMEOUT_MS = int(os.environ.get("PAIRLING_SQLITE_BUSY_TIMEOUT_MS", "5000"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def utc_epoch() -> float:
|
|
25
|
+
return time.time()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hash_token(token: str) -> str:
|
|
29
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate_token() -> str:
|
|
33
|
+
return "pld_" + secrets.token_urlsafe(32)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_device_id() -> str:
|
|
37
|
+
return "dev_" + secrets.token_hex(16)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_proof_secret() -> str:
|
|
41
|
+
return "prf_" + secrets.token_urlsafe(32)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _redact_for_audit(value: Any) -> Any:
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
redacted: dict[str, Any] = {}
|
|
47
|
+
for key, item in value.items():
|
|
48
|
+
lowered = str(key).lower()
|
|
49
|
+
if any(marker in lowered for marker in ("token", "secret", "proof", "authorization")):
|
|
50
|
+
redacted[str(key)] = "[redacted]"
|
|
51
|
+
else:
|
|
52
|
+
redacted[str(key)] = _redact_for_audit(item)
|
|
53
|
+
return redacted
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return [_redact_for_audit(item) for item in value]
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_private_text(path: Path, text: str) -> None:
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
try:
|
|
62
|
+
os.chmod(path.parent, 0o700)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
66
|
+
with tmp.open("w") as fh:
|
|
67
|
+
fh.write(text)
|
|
68
|
+
fh.flush()
|
|
69
|
+
os.fsync(fh.fileno())
|
|
70
|
+
os.replace(tmp, path)
|
|
71
|
+
try:
|
|
72
|
+
os.chmod(path, 0o600)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_or_create_install_id(path: Path | None = None) -> str:
|
|
78
|
+
target = path or install_id_path()
|
|
79
|
+
try:
|
|
80
|
+
value = target.read_text().strip()
|
|
81
|
+
if value:
|
|
82
|
+
return value
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
pass
|
|
85
|
+
value = "inst_" + secrets.token_hex(16)
|
|
86
|
+
_write_private_text(target, value + "\n")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class DeviceAuthResult:
|
|
92
|
+
ok: bool
|
|
93
|
+
status: int
|
|
94
|
+
reason: str
|
|
95
|
+
device_id: str | None = None
|
|
96
|
+
install_id: str | None = None
|
|
97
|
+
proof_secret: str | None = None
|
|
98
|
+
scopes: frozenset[str] = frozenset()
|
|
99
|
+
|
|
100
|
+
def has_scope(self, scope: str) -> bool:
|
|
101
|
+
return scope in self.scopes
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class CreatedDevice:
|
|
106
|
+
device_id: str
|
|
107
|
+
token: str
|
|
108
|
+
proof_secret: str
|
|
109
|
+
scopes: tuple[str, ...]
|
|
110
|
+
install_id: str
|
|
111
|
+
relay_device_id: str | None = None
|
|
112
|
+
attestation_status: str = "none"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DeviceRegistry:
|
|
116
|
+
def __init__(self, db_path: Path | None = None, audit_path: Path | None = None):
|
|
117
|
+
self.db_path = db_path or devices_db_path()
|
|
118
|
+
self.audit_path = audit_path or audit_log_path()
|
|
119
|
+
|
|
120
|
+
@contextmanager
|
|
121
|
+
def connect(self):
|
|
122
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
try:
|
|
124
|
+
os.chmod(self.db_path.parent, 0o700)
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
conn = sqlite3.connect(
|
|
128
|
+
str(self.db_path),
|
|
129
|
+
timeout=max(SQLITE_BUSY_TIMEOUT_MS, 1) / 1000,
|
|
130
|
+
)
|
|
131
|
+
try:
|
|
132
|
+
conn.row_factory = sqlite3.Row
|
|
133
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
134
|
+
self._ensure_schema(conn)
|
|
135
|
+
yield conn
|
|
136
|
+
conn.commit()
|
|
137
|
+
except Exception:
|
|
138
|
+
conn.rollback()
|
|
139
|
+
raise
|
|
140
|
+
finally:
|
|
141
|
+
conn.close()
|
|
142
|
+
try:
|
|
143
|
+
os.chmod(self.db_path, 0o600)
|
|
144
|
+
except OSError:
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def _ensure_schema(self, conn: sqlite3.Connection) -> None:
|
|
148
|
+
conn.execute(
|
|
149
|
+
"""
|
|
150
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
151
|
+
device_id TEXT PRIMARY KEY,
|
|
152
|
+
device_name TEXT NOT NULL,
|
|
153
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
154
|
+
scopes_json TEXT NOT NULL,
|
|
155
|
+
install_id TEXT NOT NULL,
|
|
156
|
+
created_at REAL NOT NULL,
|
|
157
|
+
last_seen_at REAL,
|
|
158
|
+
revoked_at REAL
|
|
159
|
+
)
|
|
160
|
+
"""
|
|
161
|
+
)
|
|
162
|
+
existing = {
|
|
163
|
+
row["name"]
|
|
164
|
+
for row in conn.execute("PRAGMA table_info(devices)").fetchall()
|
|
165
|
+
}
|
|
166
|
+
additive_columns = {
|
|
167
|
+
"relay_device_id": "ALTER TABLE devices ADD COLUMN relay_device_id TEXT",
|
|
168
|
+
"attestation_status": "ALTER TABLE devices ADD COLUMN attestation_status TEXT DEFAULT 'none'",
|
|
169
|
+
"apns_registered_at": "ALTER TABLE devices ADD COLUMN apns_registered_at REAL",
|
|
170
|
+
"relay_pair_secret_ref": "ALTER TABLE devices ADD COLUMN relay_pair_secret_ref TEXT",
|
|
171
|
+
"device_display_name": "ALTER TABLE devices ADD COLUMN device_display_name TEXT",
|
|
172
|
+
"superseded_by_device_id": "ALTER TABLE devices ADD COLUMN superseded_by_device_id TEXT",
|
|
173
|
+
"proof_secret": "ALTER TABLE devices ADD COLUMN proof_secret TEXT",
|
|
174
|
+
}
|
|
175
|
+
for column, statement in additive_columns.items():
|
|
176
|
+
if column not in existing:
|
|
177
|
+
conn.execute(statement)
|
|
178
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_devices_token_hash ON devices(token_hash)")
|
|
179
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_devices_relay_device_id ON devices(relay_device_id)")
|
|
180
|
+
conn.execute(
|
|
181
|
+
"""
|
|
182
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
183
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
184
|
+
ts REAL NOT NULL,
|
|
185
|
+
event TEXT NOT NULL,
|
|
186
|
+
device_id TEXT,
|
|
187
|
+
outcome TEXT NOT NULL,
|
|
188
|
+
path TEXT,
|
|
189
|
+
detail_json TEXT NOT NULL
|
|
190
|
+
)
|
|
191
|
+
"""
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def create_device(
|
|
195
|
+
self,
|
|
196
|
+
*,
|
|
197
|
+
device_name: str,
|
|
198
|
+
scopes: Iterable[str] | None = None,
|
|
199
|
+
install_id: str | None = None,
|
|
200
|
+
token: str | None = None,
|
|
201
|
+
proof_secret: str | None = None,
|
|
202
|
+
device_id: str | None = None,
|
|
203
|
+
relay_device_id: str | None = None,
|
|
204
|
+
attestation_status: str = "none",
|
|
205
|
+
device_display_name: str | None = None,
|
|
206
|
+
relay_pair_secret_ref: str | None = None,
|
|
207
|
+
) -> CreatedDevice:
|
|
208
|
+
normalized_scopes = tuple(sorted(set(scopes or DEFAULT_DEVICE_SCOPES)))
|
|
209
|
+
token_value = token or generate_token()
|
|
210
|
+
proof_secret_value = proof_secret or generate_proof_secret()
|
|
211
|
+
device_id_value = device_id or generate_device_id()
|
|
212
|
+
install_id_value = install_id or load_or_create_install_id()
|
|
213
|
+
attestation_value = attestation_status if attestation_status in {
|
|
214
|
+
"none",
|
|
215
|
+
"development",
|
|
216
|
+
"production",
|
|
217
|
+
"unsupported",
|
|
218
|
+
"failed",
|
|
219
|
+
} else "failed"
|
|
220
|
+
now = utc_epoch()
|
|
221
|
+
with self.connect() as conn:
|
|
222
|
+
if relay_device_id:
|
|
223
|
+
superseded = conn.execute(
|
|
224
|
+
"""
|
|
225
|
+
SELECT device_id FROM devices
|
|
226
|
+
WHERE relay_device_id = ?
|
|
227
|
+
AND revoked_at IS NULL
|
|
228
|
+
""",
|
|
229
|
+
(relay_device_id,),
|
|
230
|
+
).fetchall()
|
|
231
|
+
for row in superseded:
|
|
232
|
+
old_device_id = row["device_id"]
|
|
233
|
+
conn.execute(
|
|
234
|
+
"""
|
|
235
|
+
UPDATE devices
|
|
236
|
+
SET revoked_at = ?, superseded_by_device_id = ?
|
|
237
|
+
WHERE device_id = ?
|
|
238
|
+
""",
|
|
239
|
+
(now, device_id_value, old_device_id),
|
|
240
|
+
)
|
|
241
|
+
self.record_audit(
|
|
242
|
+
"device.superseded",
|
|
243
|
+
device_id=old_device_id,
|
|
244
|
+
outcome="ok",
|
|
245
|
+
detail={
|
|
246
|
+
"relay_device_id": relay_device_id,
|
|
247
|
+
"new_device_id": device_id_value,
|
|
248
|
+
"policy": "relay_repair_supersedes_old_local_token",
|
|
249
|
+
},
|
|
250
|
+
conn=conn,
|
|
251
|
+
)
|
|
252
|
+
conn.execute(
|
|
253
|
+
"""
|
|
254
|
+
INSERT INTO devices
|
|
255
|
+
(device_id, device_name, token_hash, scopes_json, install_id,
|
|
256
|
+
created_at, last_seen_at, revoked_at, relay_device_id,
|
|
257
|
+
attestation_status, apns_registered_at, relay_pair_secret_ref,
|
|
258
|
+
device_display_name, proof_secret)
|
|
259
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?, ?)
|
|
260
|
+
""",
|
|
261
|
+
(
|
|
262
|
+
device_id_value,
|
|
263
|
+
device_name,
|
|
264
|
+
hash_token(token_value),
|
|
265
|
+
json.dumps(normalized_scopes),
|
|
266
|
+
install_id_value,
|
|
267
|
+
now,
|
|
268
|
+
relay_device_id,
|
|
269
|
+
attestation_value,
|
|
270
|
+
relay_pair_secret_ref,
|
|
271
|
+
device_display_name or device_name,
|
|
272
|
+
proof_secret_value,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
self.record_audit(
|
|
276
|
+
"device.created",
|
|
277
|
+
device_id=device_id_value,
|
|
278
|
+
outcome="ok",
|
|
279
|
+
detail={
|
|
280
|
+
"scopes": list(normalized_scopes),
|
|
281
|
+
"attestation_status": attestation_value,
|
|
282
|
+
"relay_device_id": relay_device_id,
|
|
283
|
+
},
|
|
284
|
+
conn=conn,
|
|
285
|
+
)
|
|
286
|
+
return CreatedDevice(
|
|
287
|
+
device_id_value,
|
|
288
|
+
token_value,
|
|
289
|
+
proof_secret_value,
|
|
290
|
+
normalized_scopes,
|
|
291
|
+
install_id_value,
|
|
292
|
+
relay_device_id,
|
|
293
|
+
attestation_value,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def authenticate(
|
|
297
|
+
self,
|
|
298
|
+
token: str | None,
|
|
299
|
+
*,
|
|
300
|
+
required_scopes: Iterable[str] = (),
|
|
301
|
+
path: str | None = None,
|
|
302
|
+
) -> DeviceAuthResult:
|
|
303
|
+
if not token:
|
|
304
|
+
return DeviceAuthResult(False, 401, "missing_token")
|
|
305
|
+
required = set(required_scopes)
|
|
306
|
+
token_hash = hash_token(token)
|
|
307
|
+
with self.connect() as conn:
|
|
308
|
+
row = conn.execute(
|
|
309
|
+
"SELECT * FROM devices WHERE token_hash = ?",
|
|
310
|
+
(token_hash,),
|
|
311
|
+
).fetchone()
|
|
312
|
+
if row is None:
|
|
313
|
+
self.record_audit(
|
|
314
|
+
"auth.denied",
|
|
315
|
+
device_id=None,
|
|
316
|
+
outcome="invalid_token",
|
|
317
|
+
path=path,
|
|
318
|
+
conn=conn,
|
|
319
|
+
)
|
|
320
|
+
return DeviceAuthResult(False, 403, "invalid_token")
|
|
321
|
+
if row["revoked_at"] is not None:
|
|
322
|
+
self.record_audit(
|
|
323
|
+
"auth.denied",
|
|
324
|
+
device_id=row["device_id"],
|
|
325
|
+
outcome="revoked",
|
|
326
|
+
path=path,
|
|
327
|
+
conn=conn,
|
|
328
|
+
)
|
|
329
|
+
return DeviceAuthResult(False, 403, "revoked")
|
|
330
|
+
scopes = frozenset(json.loads(row["scopes_json"] or "[]"))
|
|
331
|
+
missing = sorted(required.difference(scopes))
|
|
332
|
+
if missing:
|
|
333
|
+
self.record_audit(
|
|
334
|
+
"auth.denied",
|
|
335
|
+
device_id=row["device_id"],
|
|
336
|
+
outcome="missing_scope",
|
|
337
|
+
path=path,
|
|
338
|
+
detail={"missing": missing},
|
|
339
|
+
conn=conn,
|
|
340
|
+
)
|
|
341
|
+
return DeviceAuthResult(
|
|
342
|
+
False,
|
|
343
|
+
403,
|
|
344
|
+
"missing_scope",
|
|
345
|
+
device_id=row["device_id"],
|
|
346
|
+
install_id=row["install_id"],
|
|
347
|
+
proof_secret=row["proof_secret"],
|
|
348
|
+
scopes=scopes,
|
|
349
|
+
)
|
|
350
|
+
return DeviceAuthResult(
|
|
351
|
+
True,
|
|
352
|
+
200,
|
|
353
|
+
"ok",
|
|
354
|
+
device_id=row["device_id"],
|
|
355
|
+
install_id=row["install_id"],
|
|
356
|
+
proof_secret=row["proof_secret"],
|
|
357
|
+
scopes=scopes,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def revoke_device(self, device_id: str, *, reason: str = "revoked") -> bool:
|
|
361
|
+
with self.connect() as conn:
|
|
362
|
+
cur = conn.execute(
|
|
363
|
+
"UPDATE devices SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL",
|
|
364
|
+
(utc_epoch(), device_id),
|
|
365
|
+
)
|
|
366
|
+
changed = cur.rowcount > 0
|
|
367
|
+
self.record_audit(
|
|
368
|
+
"device.revoked",
|
|
369
|
+
device_id=device_id,
|
|
370
|
+
outcome="ok" if changed else "not_found",
|
|
371
|
+
detail={"reason": reason},
|
|
372
|
+
conn=conn,
|
|
373
|
+
)
|
|
374
|
+
return changed
|
|
375
|
+
|
|
376
|
+
def revoke_devices_named(self, device_name: str, *, reason: str = "revoked") -> int:
|
|
377
|
+
now = utc_epoch()
|
|
378
|
+
with self.connect() as conn:
|
|
379
|
+
rows = conn.execute(
|
|
380
|
+
"SELECT device_id FROM devices WHERE device_name = ? AND revoked_at IS NULL",
|
|
381
|
+
(device_name,),
|
|
382
|
+
).fetchall()
|
|
383
|
+
for row in rows:
|
|
384
|
+
device_id = row["device_id"]
|
|
385
|
+
conn.execute(
|
|
386
|
+
"UPDATE devices SET revoked_at = ? WHERE device_id = ?",
|
|
387
|
+
(now, device_id),
|
|
388
|
+
)
|
|
389
|
+
self.record_audit(
|
|
390
|
+
"device.revoked",
|
|
391
|
+
device_id=device_id,
|
|
392
|
+
outcome="ok",
|
|
393
|
+
detail={"reason": reason, "device_name": device_name},
|
|
394
|
+
conn=conn,
|
|
395
|
+
)
|
|
396
|
+
return len(rows)
|
|
397
|
+
|
|
398
|
+
def rotate_token(self, device_id: str) -> str | None:
|
|
399
|
+
token = generate_token()
|
|
400
|
+
with self.connect() as conn:
|
|
401
|
+
cur = conn.execute(
|
|
402
|
+
"""
|
|
403
|
+
UPDATE devices
|
|
404
|
+
SET token_hash = ?, revoked_at = NULL
|
|
405
|
+
WHERE device_id = ?
|
|
406
|
+
""",
|
|
407
|
+
(hash_token(token), device_id),
|
|
408
|
+
)
|
|
409
|
+
if cur.rowcount <= 0:
|
|
410
|
+
self.record_audit(
|
|
411
|
+
"device.rotate_token",
|
|
412
|
+
device_id=device_id,
|
|
413
|
+
outcome="not_found",
|
|
414
|
+
conn=conn,
|
|
415
|
+
)
|
|
416
|
+
return None
|
|
417
|
+
self.record_audit(
|
|
418
|
+
"device.rotate_token",
|
|
419
|
+
device_id=device_id,
|
|
420
|
+
outcome="ok",
|
|
421
|
+
conn=conn,
|
|
422
|
+
)
|
|
423
|
+
return token
|
|
424
|
+
|
|
425
|
+
def record_audit(
|
|
426
|
+
self,
|
|
427
|
+
event: str,
|
|
428
|
+
*,
|
|
429
|
+
device_id: str | None,
|
|
430
|
+
outcome: str,
|
|
431
|
+
path: str | None = None,
|
|
432
|
+
detail: dict[str, Any] | None = None,
|
|
433
|
+
conn: sqlite3.Connection | None = None,
|
|
434
|
+
) -> None:
|
|
435
|
+
audit_detail = _redact_for_audit(detail or {})
|
|
436
|
+
payload = json.dumps(audit_detail, sort_keys=True)
|
|
437
|
+
if conn is not None:
|
|
438
|
+
conn.execute(
|
|
439
|
+
"""
|
|
440
|
+
INSERT INTO audit_events (ts, event, device_id, outcome, path, detail_json)
|
|
441
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
442
|
+
""",
|
|
443
|
+
(utc_epoch(), event, device_id, outcome, path, payload),
|
|
444
|
+
)
|
|
445
|
+
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
line = json.dumps({
|
|
447
|
+
"ts": utc_epoch(),
|
|
448
|
+
"event": event,
|
|
449
|
+
"device_id": device_id,
|
|
450
|
+
"outcome": outcome,
|
|
451
|
+
"path": path,
|
|
452
|
+
"detail": audit_detail,
|
|
453
|
+
}, sort_keys=True)
|
|
454
|
+
with self.audit_path.open("a") as fh:
|
|
455
|
+
fh.write(line + "\n")
|
|
456
|
+
try:
|
|
457
|
+
os.chmod(self.audit_path, 0o600)
|
|
458
|
+
except OSError:
|
|
459
|
+
pass
|