v2 is the new v1

This commit is contained in:
nayan
2026-10-19 19:36:50 -04:00
parent f5271d88e9
commit c4d01ff050
18 changed files with 544 additions and 1224 deletions
+6 -9
View File
@@ -235,18 +235,15 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
{"SunnylinkLocalApps", {PERSISTENT, JSON}},
{"SunnylinkLocalAppsMeta", {PERSISTENT, JSON}},
{"SunnylinkLocalEnabled", {PERSISTENT, BOOL, "1"}},
{"SunnylinkLocalAppsV2", {PERSISTENT, JSON}},
{"SunnylinkLocalAppsV2Meta", {PERSISTENT, JSON}},
{"SunnylinkLocalPairingCode", {CLEAR_ON_MANAGER_START, JSON}},
{"SunnylinkLocalDiscoveredApp", {CLEAR_ON_MANAGER_START, JSON}},
{"SunnylinkLocalPairingRequest", {CLEAR_ON_MANAGER_START, BOOL}},
{"SunnylinkLocalPairingRequestV2", {CLEAR_ON_MANAGER_START, BOOL}},
{"SunnylinkLocalPairingQrV2", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalRevokeV2", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalEnrollV2", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalCloudRevokeV2", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalRevokeAllV2", {CLEAR_ON_MANAGER_START, BOOL}},
{"SunnylinkLocalPairingQr", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalRevoke", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalEnroll", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalCloudRevoke", {CLEAR_ON_MANAGER_START, STRING}},
{"SunnylinkLocalRevokeAll", {CLEAR_ON_MANAGER_START, BOOL}},
// Backup Manager params
{"BackupManager_CreateBackup", {PERSISTENT, BOOL}},
@@ -15,33 +15,23 @@ from openpilot.common.version import sunnylink_consent_version
from openpilot.selfdrive.ui.sunnypilot.layouts.onboarding import SunnylinkConsentPage
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2_daemon import (
PAIRING_REQUEST_V2_KEY,
from openpilot.sunnypilot.sunnylink.athena.local_auth_daemon import (
clear_pairing_params,
read_grant_meta,
read_granted_apps,
read_pairing_qr,
request_revoke_all_v2,
request_revoke_v2,
request_revoke,
request_revoke_all,
)
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
MAX_LOCAL_APP_ROWS,
V2_KIND,
LocalAppRow,
build_local_app_rows,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
from openpilot.sunnypilot.sunnylink.athena.local_pairing import (
arm_pairing,
clear_pairing_request,
get_local_apps,
pairing_requested,
read_pairing_code,
remove_local_app,
)
from openpilot.sunnypilot.sunnylink.athena.local_state import PAIRING_REQUEST_KEY
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.sunnypilot.widgets.list_view import ListItemSP, button_item_sp, toggle_item_sp
from openpilot.system.ui.sunnypilot.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
@@ -375,7 +365,7 @@ class SunnylinkLayout(Widget):
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
if not state:
clear_pairing_request()
clear_pairing_params()
self._update_description(state)
def _update_description(self, state: bool):
@@ -434,27 +424,17 @@ class SunnylinkLocalAppLayout(Widget):
def __init__(self):
super().__init__()
self._rows_cache: list[LocalAppRow] = []
self._v2_active = False
self._has_grants = False
self._back_button = NavButton(tr("Back"))
self._back_button.set_click_callback(gui_app.pop_widget)
self._pair_app_btn = button_item_sp(
title=tr("Pair App"),
button_text=tr("PAIR"),
description=tr("Open a 5-minute pairing window and show the code to ") +
tr("type into the app. Closing the dialog cancels pairing."),
callback=self._show_pairing_code_dialog,
)
# v2 retires the PIN path, so this button goes away once a phone is enrolled.
self._pair_app_btn.set_visible(lambda: not self._v2_active)
self._pair_app_v2_btn = button_item_sp(
title=tr("Pair App (secure)"),
button_text=tr("SCAN"),
description=tr("Show a QR code the sunnylink app scans to enroll this phone's key. ") +
tr("The app must be signed in, and the code expires after 2 minutes."),
callback=self._show_pairing_qr_dialog,
callback=self._show_pairing_dialog,
)
self._revoke_all_btn = button_item_sp(
@@ -464,7 +444,7 @@ class SunnylinkLocalAppLayout(Widget):
tr("from the sunnylink app to reconnect."),
callback=self._revoke_all,
)
self._revoke_all_btn.set_visible(lambda: self._v2_active)
self._revoke_all_btn.set_visible(lambda: self._has_grants)
self._local_app_rows: list[ListItemSP] = []
self._local_app_seps: list[LineSeparator] = []
@@ -481,8 +461,7 @@ class SunnylinkLocalAppLayout(Widget):
self._local_app_rows.append(row)
self._local_app_seps.append(sep)
items = [self._pair_app_v2_btn, LineSeparator(), self._pair_app_btn, LineSeparator(),
self._revoke_all_btn, LineSeparator()]
items = [self._pair_app_btn, LineSeparator(), self._revoke_all_btn, LineSeparator()]
for row, sep in zip(self._local_app_rows, self._local_app_seps, strict=True):
items.extend((row, sep))
self._scroller = Scroller(items, line_separator=False, spacing=0)
@@ -494,23 +473,15 @@ class SunnylinkLocalAppLayout(Widget):
return self._rows_cache[i].title if i < len(self._rows_cache) else ""
def _row_subtitle(self, i: int) -> str:
if i >= len(self._rows_cache):
return ""
row = self._rows_cache[i]
if row.re_pair_required:
return row.subtitle + " · " + tr("re-pair required")
return row.subtitle
return self._rows_cache[i].subtitle if i < len(self._rows_cache) else ""
def _show_pairing_code_dialog(self):
def _show_pairing_dialog(self):
gui_app.push_widget(SunnylinkLocalPairingDialog())
def _show_pairing_qr_dialog(self):
gui_app.push_widget(SunnylinkLocalQrPairingDialog())
def _revoke_all(self):
def on_confirm(_dialog_result: int):
# A request the daemon applies on its next tick.
request_revoke_all_v2()
request_revoke_all()
dialog = ConfirmDialog(
text=tr("Revoke all phones?") + " " + tr("They will need to pair again from the app."),
@@ -526,17 +497,12 @@ class SunnylinkLocalAppLayout(Widget):
row = rows[index]
def on_confirm(_dialog_result: int):
if row.kind == V2_KIND:
# A request only: the daemon revokes through its live authority on its next tick, so a
# phone enrolled after this row was read cannot be dropped by a stale snapshot.
request_revoke_v2(row.app_id)
else:
remove_local_app(row.app_id)
# A request only: the daemon revokes through its live authority on its next tick, so a
# phone enrolled after this row was read cannot be dropped by a stale snapshot.
request_revoke(row.app_id)
body = tr("You will need to enroll this phone again from the app.") if row.kind == V2_KIND \
else tr("You will need the pairing code again to reconnect it.")
dialog = ConfirmDialog(
text=tr("Unpair") + f" {row.title}? " + body,
text=tr("Unpair") + f" {row.title}? " + tr("You will need to enroll this phone again from the app."),
confirm_text=tr("Unpair"),
callback=on_confirm,
)
@@ -545,8 +511,8 @@ class SunnylinkLocalAppLayout(Widget):
def _update_state(self):
super()._update_state()
grants = read_granted_apps()
self._v2_active = bool(grants)
self._rows_cache = build_local_app_rows(get_local_apps(), grants, read_grant_meta(), time.monotonic())
self._has_grants = bool(grants)
self._rows_cache = build_local_app_rows(grants, read_grant_meta(), time.monotonic())
def _render(self, rect):
self._back_button.set_position(self._rect.x, self._rect.y + 20)
@@ -565,72 +531,8 @@ class SunnylinkLocalAppLayout(Widget):
class SunnylinkLocalPairingDialog(Widget):
def __init__(self):
super().__init__()
self._apps_before = len(get_local_apps())
arm_pairing()
self._close_btn = IconButton(gui_app.texture("icons/close.png", 80, 80))
self._close_btn.set_click_callback(self._cancel)
def _cancel(self):
clear_pairing_request()
gui_app.pop_widget()
def _update_state(self):
if len(get_local_apps()) > self._apps_before:
gui_app.pop_widget() # paired — window already cleared
elif not pairing_requested():
gui_app.pop_widget() # window expired
def _render(self, rect) -> int:
rl.clear_background(rl.Color(224, 224, 224, 255))
margin = 70
content_rect = rl.Rectangle(rect.x + margin, rect.y + margin,
rect.width - 2 * margin, rect.height - 2 * margin)
y = content_rect.y
close_size = 80
pad = 20
close_rect = rl.Rectangle(content_rect.x - pad, y - pad, close_size + pad * 2, close_size + pad * 2)
self._close_btn.render(close_rect)
y += close_size + 40
title_font = gui_app.font(FontWeight.NORMAL)
title_wrapped = wrap_text(title_font, tr("Pair with mobile app"), 75, int(content_rect.width))
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 75, 0.0, rl.BLACK)
y += len(title_wrapped) * 75 + 40
code = read_pairing_code() or "—"
code_font = gui_app.font(FontWeight.BOLD)
code_size = measure_text_cached(code_font, code, 110)
rl.draw_text_ex(code_font, code, rl.Vector2(content_rect.x + (content_rect.width - code_size.x) / 2, y),
110, 0.0, rl.BLACK)
y += 170
hint_font = gui_app.font(FontWeight.NORMAL)
hint_wrapped = wrap_text(hint_font, tr("Enter this code in the sunnylink app on your phone."), 45,
int(content_rect.width))
rl.draw_text_ex(hint_font, "\n".join(hint_wrapped), rl.Vector2(content_rect.x, y), 45, 0.0, rl.BLACK)
y += len(hint_wrapped) * 45 + 30
discovered = latest_discovered_app()
if discovered is not None:
endpoint, age = discovered
status = endpoint if age < 2 else f"{endpoint} ({age}s)"
color = _LOCAL_ACTIVE_COLOR
else:
status = tr("Waiting for the app…")
color = _LOCAL_DISCOVERED_COLOR
status_font = gui_app.font(FontWeight.NORMAL)
rl.draw_text_ex(status_font, status, rl.Vector2(content_rect.x, y), 40, 0.0, color)
return -1
class SunnylinkLocalQrPairingDialog(Widget):
"""Secure (v2) pairing: the phone scans this QR, enrolls through the cloud and only then dials
the device over pinned TLS.
"""Pairing: the phone scans this QR, enrolls through the cloud and only then dials the device
over pinned TLS.
The window belongs to the local daemon (another process): this dialog sets the arm request and
renders the QR that gets published. The published QR is the window state — removing it (Cancel,
@@ -653,9 +555,9 @@ class SunnylinkLocalQrPairingDialog(Widget):
def _request_window(self) -> None:
try:
self._params.put_bool(PAIRING_REQUEST_V2_KEY, True, block=True)
self._params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylink.local_pairing_v2.request_failed")
cloudlog.exception("sunnylink.local_pairing.request_failed")
def _cancel(self) -> None:
# Params only: this process does not own the window, the daemon cancels it on its next tick.
@@ -678,7 +580,7 @@ class SunnylinkLocalQrPairingDialog(Widget):
# a camera to read them.
rl.set_texture_filter(self._qr_texture, rl.TextureFilter.TEXTURE_FILTER_POINT)
except Exception:
cloudlog.exception("sunnylink.local_pairing_v2.qr_texture_failed")
cloudlog.exception("sunnylink.local_pairing.qr_texture_failed")
def _update_state(self):
if read_pairing_qr() is not None:
@@ -711,7 +613,7 @@ class SunnylinkLocalQrPairingDialog(Widget):
right_width = int(content_rect.width // 2 - 20)
title_font = gui_app.font(FontWeight.NORMAL)
title_wrapped = wrap_text(title_font, tr("Pair with mobile app (secure)"), 70, left_width)
title_wrapped = wrap_text(title_font, tr("Pair with mobile app"), 70, left_width)
rl.draw_text_ex(title_font, "\n".join(title_wrapped), rl.Vector2(content_rect.x, y), 70, 0.0, rl.BLACK)
y += len(title_wrapped) * 70 + 40
@@ -18,30 +18,21 @@ from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkC
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2_daemon import (
PAIRING_REQUEST_V2_KEY,
from openpilot.sunnypilot.sunnylink.athena.local_auth_daemon import (
clear_pairing_params,
read_grant_meta,
read_granted_apps,
read_pairing_qr,
request_revoke_all_v2,
request_revoke_v2,
request_revoke,
request_revoke_all,
)
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
MAX_LOCAL_APP_ROWS,
V2_KIND,
LocalAppRow,
build_local_app_rows,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
from openpilot.sunnypilot.sunnylink.athena.local_pairing import (
arm_pairing,
clear_pairing_request,
get_local_apps,
pairing_requested,
read_pairing_code,
remove_local_app,
)
from openpilot.sunnypilot.sunnylink.athena.local_state import PAIRING_REQUEST_KEY
from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
@@ -180,7 +171,7 @@ class SunnylinkLayoutMici(NavScroller):
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
if not state:
clear_pairing_request()
clear_pairing_params()
ui_state.update_params()
@@ -307,10 +298,7 @@ class LocalAppsPanelMici(NavScroller):
self._rows_cache: list[LocalAppRow] = []
self._pair_app_btn = BigButton(tr("pair app"), "")
self._pair_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalPairingCodeDialogMici()))
self._pair_app_v2_btn = BigButton(tr("pair app (secure)"), "")
self._pair_app_v2_btn.set_click_callback(lambda: gui_app.push_widget(LocalQrPairingDialogMici()))
self._pair_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalPairingDialogMici()))
self._revoke_all_btn = BigButton(tr("revoke all phones"), "")
self._revoke_all_btn.set_click_callback(self._confirm_revoke_all)
@@ -321,29 +309,26 @@ class LocalAppsPanelMici(NavScroller):
btn.set_click_callback(partial(self._confirm_unpair_row, i))
self._local_app_btns.append(btn)
self._scroller.add_widgets([self._pair_app_v2_btn, self._pair_app_btn, self._revoke_all_btn,
*self._local_app_btns])
self._scroller.add_widgets([self._pair_app_btn, self._revoke_all_btn, *self._local_app_btns])
def _update_state(self):
super()._update_state()
grants = read_granted_apps()
self._rows_cache = build_local_app_rows(get_local_apps(), grants, read_grant_meta(), time.monotonic())
# The PIN path is refused once v2 holds any grant, so it must not be offered.
self._pair_app_btn.set_visible(not grants)
self._rows_cache = build_local_app_rows(grants, read_grant_meta(), time.monotonic())
self._revoke_all_btn.set_visible(bool(grants))
for i, btn in enumerate(self._local_app_btns):
btn.set_visible(i < len(self._rows_cache))
if i < len(self._rows_cache):
row = self._rows_cache[i]
btn.set_text(row.title)
btn.set_value(tr("re-pair required") if row.re_pair_required else row.subtitle)
btn.set_value(row.subtitle)
def _confirm_revoke_all(self):
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
dlg = BigConfirmationDialog(
tr("revoke all phones"),
icon,
confirm_callback=request_revoke_all_v2,
confirm_callback=request_revoke_all,
red=True,
)
gui_app.push_widget(dlg)
@@ -355,11 +340,8 @@ class LocalAppsPanelMici(NavScroller):
row = rows[index]
def unpair():
if row.kind == V2_KIND:
# A request only: sunnylinkd revokes through its live authority on its next tick.
request_revoke_v2(row.app_id)
else:
remove_local_app(row.app_id)
# A request only: sunnylinkd revokes through its live authority on its next tick.
request_revoke(row.app_id)
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
dlg = BigConfirmationDialog(
@@ -371,68 +353,9 @@ class LocalAppsPanelMici(NavScroller):
gui_app.push_widget(dlg)
class LocalPairingCodeDialogMici(BigDialogBase):
def __init__(self):
super().__init__()
self._apps_before = len(get_local_apps())
arm_pairing()
self.set_back_callback(clear_pairing_request)
header_color = rl.Color(255, 255, 255, int(255 * 0.9))
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
self._title = UnifiedLabel(tr("pair with mobile app"), font_size=48, font_weight=FontWeight.BOLD,
text_color=header_color, line_height=0.8)
self._code_label = UnifiedLabel("", font_size=110, font_weight=FontWeight.DISPLAY,
text_color=rl.Color(0, 255, 0, 255))
self._hint = UnifiedLabel(tr("enter this code in the sunnylink app"), font_size=32,
text_color=subheader_color, line_height=0.9)
self._status = UnifiedLabel("", font_size=28,
text_color=rl.Color(255, 255, 255, int(255 * 0.45)), line_height=0.9)
def _update_state(self):
super()._update_state()
if self.is_dismissing:
return
if len(get_local_apps()) > self._apps_before:
self.dismiss() # paired — window already cleared
elif not pairing_requested():
self.dismiss() # window expired
def _render(self, _):
self._code_label.set_text(read_pairing_code() or "—")
discovered = latest_discovered_app()
if discovered is not None:
endpoint, age = discovered
self._status.set_text(endpoint if age < 2 else f"{endpoint} ({age}s)")
self._status.set_text_color(rl.Color(0, 255, 0, 255))
else:
self._status.set_text(tr("waiting for the app…"))
self._status.set_text_color(rl.Color(255, 255, 255, int(255 * 0.45)))
x = self._rect.x + 20
width = int(self._rect.width - 40)
self._title.set_max_width(width)
self._title.set_position(x, self._rect.y + 40)
self._title.render()
self._code_label.set_max_width(width)
self._code_label.set_position(x, self._rect.y + 130)
self._code_label.render()
self._hint.set_max_width(width)
self._hint.set_position(x, self._rect.y + 290)
self._hint.render()
self._status.set_max_width(width)
self._status.set_position(x, self._rect.y + 360)
self._status.render()
class LocalQrPairingDialogMici(BigDialogBase):
"""Secure (v2) pairing for the small display. This dialog only requests the window the local
daemon publishes and renders it; the phone scans it, enrolls through the cloud, then dials."""
class LocalPairingDialogMici(BigDialogBase):
"""Pairing for the small display. This dialog only requests the window the local daemon
publishes and renders it; the phone scans it, enrolls through the cloud, then dials."""
# sunnylinkd services the request about once a second; taking longer means it is not running.
ARM_GRACE_S = 5.0
@@ -455,9 +378,9 @@ class LocalQrPairingDialogMici(BigDialogBase):
def _request_window(self) -> None:
try:
ui_state.params.put_bool(PAIRING_REQUEST_V2_KEY, True, block=True)
ui_state.params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylink.local_pairing_v2.request_failed")
cloudlog.exception("sunnylink.local_pairing.request_failed")
def _cancel(self) -> None:
# Params only: this process does not own the window, the daemon cancels it on its next tick.
@@ -482,7 +405,7 @@ class LocalQrPairingDialogMici(BigDialogBase):
# a camera to read them.
rl.set_texture_filter(self._qr_texture, rl.TextureFilter.TEXTURE_FILTER_POINT)
except Exception:
cloudlog.exception("sunnylink.local_pairing_v2.qr_texture_failed")
cloudlog.exception("sunnylink.local_pairing.qr_texture_failed")
def _update_state(self):
super()._update_state()
@@ -1,25 +1,20 @@
"""One row model for the "Local Connections" screen, shared by both UI panels.
Cloud-enrolled v2 phones and the apps left in the retired PIN registry render through this, so a
row's text and its action cannot drift apart between the two layouts. It reads no Params and no
identity key: the panels pass in what they read. Aliases and last-seen stamps are display
metadata only — the authority decides every connection from the grant itself.
Every row is an enrolled phone, so a row's text and its action cannot drift apart between the two
layouts. It reads no Params and no identity key: the panels pass in what they read. Aliases and
last-seen stamps are display metadata only — the authority decides every connection from the grant
itself.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import MAX_GRANTS
from openpilot.sunnypilot.sunnylink.athena.local_pairing import LocalApp, local_app_display_name
from openpilot.sunnypilot.sunnylink.athena.local_auth import MAX_GRANTS
V2_KIND = "v2"
LEGACY_KIND = "legacy"
# Every grant the authority accepts, plus the legacy slots: a phone the screen hides could never
# be revoked from the device.
MAX_LEGACY_APP_ROWS = 4
MAX_LOCAL_APP_ROWS = MAX_GRANTS + MAX_LEGACY_APP_ROWS
# Every grant the authority accepts: a phone the screen hides could never be revoked from the
# device.
MAX_LOCAL_APP_ROWS = MAX_GRANTS
DEFAULT_APP_NAME = "sunnylink mobile"
@@ -28,13 +23,10 @@ NEVER_CONNECTED = "not connected yet"
@dataclass(frozen=True)
class LocalAppRow:
"""One phone/app row in the local-connections list."""
kind: str
"""One enrolled phone in the local-connections list."""
app_id: str
title: str
subtitle: str
#: A legacy row that v2 has retired: it can no longer connect and needs re-pairing.
re_pair_required: bool = False
def short_key(app_id: str, length: int = 10) -> str:
@@ -64,15 +56,11 @@ def last_seen_text(entry: Mapping[str, object] | None, now: float) -> str:
return format_age(age)
def build_local_app_rows(legacy: list[LocalApp], grants: list[dict[str, str]],
def build_local_app_rows(grants: list[dict[str, str]],
meta: Mapping[str, Mapping[str, object]] | None = None,
now: float = 0.0,
max_rows: int = MAX_LOCAL_APP_ROWS) -> list[LocalAppRow]:
"""Enrolled phones first (the trust that works), then retired legacy apps.
Legacy rows are marked `re_pair_required` as soon as any v2 grant exists: from that point the
daemon refuses every legacy path, so such a row is something to remove, not to use.
"""
"""One row per enrolled phone, in the order the authority holds them."""
entries = meta or {}
rows = []
for grant in grants:
@@ -90,10 +78,6 @@ def build_local_app_rows(legacy: list[LocalApp], grants: list[dict[str, str]],
title = app_name
else:
title = DEFAULT_APP_NAME
rows.append(LocalAppRow(V2_KIND, app_id, title,
rows.append(LocalAppRow(app_id, title,
f"secure · {short_key(app_id)} · {last_seen_text(entry, now)}"))
v2_active = len(rows) > 0
rows += [LocalAppRow(LEGACY_KIND, app.app_id, local_app_display_name(app), app.endpoint,
re_pair_required=v2_active)
for app in legacy]
return rows[:max_rows]
@@ -1,7 +1,7 @@
"""Sunnylink local v2 security core: keys, grants, the pending window and the pinned TLS dial.
"""Sunnylink local security core: keys, grants, the pending window and the pinned TLS dial.
Nothing here is registered with the RPC dispatcher: an enrollment may only be staged from a
cloud-origin payload, never from a local RPC. See sunnylink-mobile/docs/LOCAL_AUTH_V2.md.
cloud-origin payload, never from a local RPC. See sunnylink-mobile/docs/LOCAL_AUTH.md.
"""
from __future__ import annotations
@@ -172,7 +172,7 @@ def text_field(value: Any, maximum: int = 128) -> str:
def statement(purpose: str, *fields: str) -> bytes:
"""Length-bounded UTF-8 fields; LF is forbidden, avoiding JSON canonicalization."""
return ("sunnylink-local-v2\n" + purpose + "\n" + "\n".join(text_field(f, 1024) for f in fields) + "\n").encode("utf-8")
return ("sunnylink-local\n" + purpose + "\n" + "\n".join(text_field(f, 1024) for f in fields) + "\n").encode("utf-8")
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
@@ -262,7 +262,7 @@ class LocalAuthority:
if len(decoded) == len(entries) and len({g.key_id for g in decoded}) == len(decoded):
self.grants = {g.key_id: g for g in decoded}
except AuthorizationError:
pass # Corrupt/legacy storage is not authorization.
pass # Corrupt storage is not authorization.
def arm(self) -> str:
"""Open one window and publish the code for it (see the frame note at the top of the module:
@@ -504,7 +504,7 @@ def connect_pinned(endpoint: str, expected_key_id: str, timeout_s: float = 10) -
raise AuthorizationError("missing peer certificate")
verify_tls_peer(certificate, expected_key_id)
return create_connection(endpoint, socket=tls, enable_multithread=True, timeout=timeout_s,
subprotocols=["sunnylink-local-v2"])
subprotocols=["sunnylink-local"])
except Exception:
(tls or connection).close()
raise
@@ -13,7 +13,7 @@ from typing import Any
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.common.api.base import BaseApi
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
VERSION,
AuthorizationError,
LocalAuthority,
@@ -21,17 +21,17 @@ from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
text_field,
)
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL as CLOUD_ENROLL_KEY, REVOKE as CLOUD_REVOKE_KEY
from openpilot.sunnypilot.sunnylink.athena.local_state import (
LOCAL_APPS_KEY,
LOCAL_APPS_META_KEY,
PAIRING_QR_KEY,
PAIRING_REQUEST_KEY,
REVOKE_ALL_KEY,
REVOKE_KEY,
)
V2_REGISTRY_KEY = "SunnylinkLocalAppsV2"
V2_META_KEY = "SunnylinkLocalAppsV2Meta"
MAX_ALIAS_BYTES = 80
PAIRING_REQUEST_V2_KEY = "SunnylinkLocalPairingRequestV2"
PAIRING_QR_V2_KEY = "SunnylinkLocalPairingQrV2"
REVOKE_V2_KEY = "SunnylinkLocalRevokeV2"
REVOKE_ALL_V2_KEY = "SunnylinkLocalRevokeAllV2"
_params = Params()
_authority: LocalAuthority | None = None
_authority_lock = threading.Lock()
@@ -40,11 +40,11 @@ _meta_lock = threading.Lock()
def _persist_registry(registry: dict[str, Any]) -> None:
"""Durable write before callers publish the change in memory (fail-closed ordering)."""
_params.put(V2_REGISTRY_KEY, registry, block=True)
_params.put(LOCAL_APPS_KEY, registry, block=True)
def _read_registry() -> dict[str, Any] | None:
raw = _params.get(V2_REGISTRY_KEY)
raw = _params.get(LOCAL_APPS_KEY)
return raw if isinstance(raw, dict) else None
@@ -52,7 +52,7 @@ def _build_authority() -> LocalAuthority | None:
try:
algorithm, private_pem, _public_pem = BaseApi.get_key_pair()
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.identity_unavailable")
cloudlog.exception("sunnylinkd.local_auth.identity_unavailable")
return None
if algorithm is None or private_pem is None:
return None
@@ -64,10 +64,10 @@ def _build_authority() -> LocalAuthority | None:
pem = private_pem if isinstance(private_pem, bytes) else str(private_pem).encode()
device_key = serialization.load_pem_private_key(pem, password=None)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.identity_load_failed")
cloudlog.exception("sunnylinkd.local_auth.identity_load_failed")
return None
if not isinstance(device_key, (ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey)):
cloudlog.error("sunnylinkd.local_auth_v2.identity_unsupported")
cloudlog.error("sunnylinkd.local_auth.identity_unsupported")
return None
cloud_device_id = _params.get("SunnylinkDongleId")
@@ -96,7 +96,7 @@ def get_authority() -> LocalAuthority | None:
try:
_authority = _build_authority()
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.authority_unavailable")
cloudlog.exception("sunnylinkd.local_auth.authority_unavailable")
return None
return _authority
@@ -107,18 +107,18 @@ def reset_authority() -> None:
_authority = None
def arm_local_pairing_v2() -> str | None:
def arm_local_pairing() -> str | None:
authority = get_authority()
if authority is None:
return None
try:
return authority.arm()
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.arm_failed")
cloudlog.exception("sunnylinkd.local_auth.arm_failed")
return None
def cancel_local_pairing_v2() -> None:
def cancel_local_pairing() -> None:
authority = get_authority()
if authority is not None:
authority.cancel()
@@ -127,18 +127,18 @@ def cancel_local_pairing_v2() -> None:
def _publish_qr(qr: str | None, params: Params) -> None:
try:
if qr:
params.put(PAIRING_QR_V2_KEY, qr, block=True)
params.put(PAIRING_QR_KEY, qr, block=True)
else:
params.remove(PAIRING_QR_V2_KEY)
params.remove(PAIRING_QR_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.qr_publish_failed")
cloudlog.exception("sunnylinkd.local_auth.qr_publish_failed")
def _clear_request(params: Params) -> None:
try:
params.remove(PAIRING_REQUEST_V2_KEY)
params.remove(PAIRING_REQUEST_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.pairing_request_clear_failed")
cloudlog.exception("sunnylinkd.local_auth.pairing_request_clear_failed")
def _armed_qr(authority: LocalAuthority) -> str | None:
@@ -153,9 +153,9 @@ def read_pairing_qr(params: Params | None = None) -> str | None:
"""The QR currently published for the UI, which is also the window's visible state."""
params = params or _params
try:
value = params.get(PAIRING_QR_V2_KEY)
value = params.get(PAIRING_QR_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.qr_read_failed")
cloudlog.exception("sunnylinkd.local_auth.qr_read_failed")
return None
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
@@ -164,16 +164,16 @@ def read_pairing_qr(params: Params | None = None) -> str | None:
def _read_meta() -> dict[str, Any]:
try:
raw = _params.get(V2_META_KEY)
raw = _params.get(LOCAL_APPS_META_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.meta_unreadable")
cloudlog.exception("sunnylinkd.local_auth.meta_unreadable")
return {}
apps = raw.get("apps") if isinstance(raw, dict) else None
return dict(apps) if isinstance(apps, dict) else {}
def _write_meta(apps: dict[str, Any]) -> None:
_params.put(V2_META_KEY, {"v": VERSION, "apps": apps}, block=True)
_params.put(LOCAL_APPS_META_KEY, {"v": VERSION, "apps": apps}, block=True)
def read_grant_meta() -> dict[str, dict[str, Any]]:
@@ -181,7 +181,7 @@ def read_grant_meta() -> dict[str, dict[str, Any]]:
try:
entries = _read_meta()
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.meta_unreadable")
cloudlog.exception("sunnylinkd.local_auth.meta_unreadable")
return {}
return {key_id: dict(entry) for key_id, entry in entries.items() if isinstance(entry, dict)}
@@ -210,7 +210,7 @@ def note_grant_seen(app_key_id: str) -> None:
apps[app_key_id] = entry
_write_meta(apps)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.meta_seen_failed")
cloudlog.exception("sunnylinkd.local_auth.meta_seen_failed")
def clear_grant_meta(app_key_id: str) -> None:
@@ -220,7 +220,7 @@ def clear_grant_meta(app_key_id: str) -> None:
if apps.pop(app_key_id, None) is not None:
_write_meta(apps)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.meta_clear_failed")
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
def clear_all_grant_meta() -> None:
@@ -229,7 +229,7 @@ def clear_all_grant_meta() -> None:
if _read_meta():
_write_meta({})
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.meta_clear_failed")
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
def clear_pairing_params(params: Params | None = None) -> None:
@@ -242,24 +242,24 @@ def clear_pairing_params(params: Params | None = None) -> None:
def clear_pairing_window(params: Params | None = None) -> None:
"""The daemon's own teardown: cancel the window this process holds and clear both params."""
clear_pairing_params(params)
cancel_local_pairing_v2()
cancel_local_pairing()
def service_pairing_window(params: Params | None = None) -> str | None:
"""Arm, publish or close the v2 window from the UI's request flag. Never raises."""
"""Arm, publish or close the pairing window from the UI's request flag. Never raises."""
params = params or _params
try:
return _service_pairing_window(params)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.pairing_window_failed")
cloudlog.exception("sunnylinkd.local_auth.pairing_window_failed")
return read_pairing_qr(params)
def _service_pairing_window(params: Params) -> str | None:
try:
requested = bool(params.get_bool(PAIRING_REQUEST_V2_KEY))
requested = bool(params.get_bool(PAIRING_REQUEST_KEY))
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.pairing_request_unreadable")
cloudlog.exception("sunnylinkd.local_auth.pairing_request_unreadable")
requested = False
authority = get_authority()
@@ -275,8 +275,8 @@ def _service_pairing_window(params: Params) -> str | None:
if armed is not None:
if published is None:
# A live window the UI no longer shows is a window the user cancelled.
cancel_local_pairing_v2()
cloudlog.event("sunnylinkd.local_auth_v2.window_cancelled")
cancel_local_pairing()
cloudlog.event("sunnylinkd.local_auth.window_cancelled")
return None
if published != armed:
# Only a stale QR is replaced: an armed, displayed window is never rotated, the app may
@@ -292,7 +292,7 @@ def _service_pairing_window(params: Params) -> str | None:
# device said nothing). A cancel is different: it clears every session the user could have
# scanned from.
authority.expire()
cloudlog.event("sunnylinkd.local_auth_v2.window_expired")
cloudlog.event("sunnylinkd.local_auth.window_expired")
authority.prune_sessions()
@@ -301,12 +301,12 @@ def _service_pairing_window(params: Params) -> str | None:
_publish_qr(None, params)
return None
qr = arm_local_pairing_v2()
qr = arm_local_pairing()
_clear_request(params)
_publish_qr(qr, params)
if qr is None:
return None
cloudlog.event("sunnylinkd.local_auth_v2.window_armed")
cloudlog.event("sunnylinkd.local_auth.window_armed")
return qr
@@ -345,15 +345,15 @@ def claim_cloud_commands(params: Params | None = None) -> None:
try:
summary = apply(value)
except AuthorizationError as e:
cloudlog.warning(f"sunnylinkd.local_auth_v2.{key}_rejected: {e}")
cloudlog.warning(f"sunnylinkd.local_auth.{key}_rejected: {e}")
except Exception:
cloudlog.exception(f"sunnylinkd.local_auth_v2.{key}_failed")
cloudlog.exception(f"sunnylinkd.local_auth.{key}_failed")
else:
# An enrollment is staged here and only becomes durable when the phone's pinned TLS dial
# proves its key, but the event still records that the command landed. Without it a
# successful arrival is invisible and a rejected one looks like nothing happened at all.
app_name = summary.get("app_name") if isinstance(summary, dict) else None
cloudlog.event(f"sunnylinkd.local_auth_v2.{key}_accepted", app_name=app_name)
cloudlog.event(f"sunnylinkd.local_auth.{key}_accepted", app_name=app_name)
def _take(params: Params, key: str) -> str | None:
@@ -361,7 +361,7 @@ def _take(params: Params, key: str) -> str | None:
value = params.get(key)
params.remove(key)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.command_unreadable")
cloudlog.exception("sunnylinkd.local_auth.command_unreadable")
return None
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
@@ -380,7 +380,7 @@ def confirm_enrollment_tls(grant_key_id: str) -> dict[str, Any]:
return {"app_id": committed.app_id, "app_name": committed.app_name, "revision": committed.revision}
def revoke_local_app_v2(app_key_id: str) -> bool:
def revoke_local_app(app_key_id: str) -> bool:
authority = get_authority()
if authority is None:
return False
@@ -390,7 +390,7 @@ def revoke_local_app_v2(app_key_id: str) -> bool:
return revoked
def revoke_all_v2() -> int:
def revoke_all() -> int:
authority = get_authority()
if authority is None:
return 0
@@ -400,23 +400,23 @@ def revoke_all_v2() -> int:
return removed
def request_revoke_v2(app_key_id: str, params: Params | None = None) -> None:
def request_revoke(app_key_id: str, params: Params | None = None) -> None:
"""The UI asks the daemon to drop one phone; the daemon applies it through its live authority."""
params = params or _params
if not isinstance(app_key_id, str) or not app_key_id:
return
try:
params.put(REVOKE_V2_KEY, app_key_id, block=True)
params.put(REVOKE_KEY, app_key_id, block=True)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_request_failed")
cloudlog.exception("sunnylinkd.local_auth.revoke_request_failed")
def request_revoke_all_v2(params: Params | None = None) -> None:
def request_revoke_all(params: Params | None = None) -> None:
params = params or _params
try:
params.put_bool(REVOKE_ALL_V2_KEY, True, block=True)
params.put_bool(REVOKE_ALL_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_all_request_failed")
cloudlog.exception("sunnylinkd.local_auth.revoke_all_request_failed")
def service_revoke_requests(params: Params | None = None) -> bool:
@@ -425,30 +425,30 @@ def service_revoke_requests(params: Params | None = None) -> bool:
revoke_all_requested = False
try:
revoke_all_requested = bool(params.get_bool(REVOKE_ALL_V2_KEY))
revoke_all_requested = bool(params.get_bool(REVOKE_ALL_KEY))
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_all_unreadable")
cloudlog.exception("sunnylinkd.local_auth.revoke_all_unreadable")
if revoke_all_requested:
removed = 0
try:
removed = revoke_all_v2()
removed = revoke_all()
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_all_failed")
cloudlog.exception("sunnylinkd.local_auth.revoke_all_failed")
finally:
try:
params.remove(REVOKE_ALL_V2_KEY)
params.remove(REVOKE_V2_KEY) # every grant is gone, so a single request has nothing left
params.remove(REVOKE_ALL_KEY)
params.remove(REVOKE_KEY) # every grant is gone, so a single request has nothing left
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_all_clear_failed")
cloudlog.exception("sunnylinkd.local_auth.revoke_all_clear_failed")
if removed:
cloudlog.event("sunnylinkd.local_auth_v2.grants_revoked", count=removed)
cloudlog.event("sunnylinkd.local_auth.grants_revoked", count=removed)
return bool(removed)
try:
requested = params.get(REVOKE_V2_KEY)
requested = params.get(REVOKE_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_request_unreadable")
cloudlog.exception("sunnylinkd.local_auth.revoke_request_unreadable")
return False
if isinstance(requested, bytes):
requested = requested.decode("utf-8", errors="replace")
@@ -458,20 +458,20 @@ def service_revoke_requests(params: Params | None = None) -> bool:
revoked = False
try:
revoked = revoke_local_app_v2(app_key_id)
revoked = revoke_local_app(app_key_id)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_failed")
cloudlog.exception("sunnylinkd.local_auth.revoke_failed")
finally:
try:
params.remove(REVOKE_V2_KEY)
params.remove(REVOKE_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth_v2.revoke_clear_failed")
cloudlog.exception("sunnylinkd.local_auth.revoke_clear_failed")
if revoked:
cloudlog.event("sunnylinkd.local_auth_v2.grant_revoked", app_id=app_key_id)
cloudlog.event("sunnylinkd.local_auth.grant_revoked", app_id=app_key_id)
return revoked
def v2_grants() -> list[dict[str, str]]:
def grants() -> list[dict[str, str]]:
authority = get_authority()
if authority is None:
return []
@@ -3,6 +3,13 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
Passive discovery of the sunnylink app on this network.
The app broadcasts a beacon every few seconds; this listener records every endpoint it hears and
keeps the freshest one visible to the settings UI while a pairing window is open. A beacon carries
no key material and no capability — it is a locator, and identity is decided by the pinned TLS dial
that follows (`local_auth.py`), so a forged or replayed beacon changes nothing.
"""
from __future__ import annotations
@@ -10,24 +17,23 @@ import json
import socket
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.sunnylink.athena.local_pairing import (
from openpilot.sunnypilot.sunnylink.athena.local_state import (
BEACON_PREFIX,
DISCOVERED_APP_KEY,
PAIRING_REQUEST_KEY,
SUNNYLINK_LOCAL_UDP_PORT,
format_endpoint,
get_local_apps,
pairing_requested,
update_local_app_endpoint,
)
LOCAL_BEACON_FRESH_S = 30
@dataclass
class AppBeacon:
"""A parsed app beacon — the app announcing it is acting as the local backend."""
@@ -70,36 +76,32 @@ def parse_beacon(raw: str | bytes, source_ip: str = "") -> AppBeacon | None:
class LocalDiscovery(threading.Thread):
"""
Passive UDP listener
"""Passive UDP listener.
- While a pairing window is armed (legacy PIN or the v2 QR window): track the
freshest app beacon so the daemon can offer pairing to a NEW app, and mirror
it into a status param for the settings UI.
- Independently of any window: a beacon from an app ALREADY in the paired
registry refreshes its cached endpoint — IPs are not identity, the app can
move between networks.
- Every beacon heard recently is kept as an endpoint a pinned dial may target, keyed by address
because the dial's job is exactly "can I reach the phone at this address?".
- While a pairing window is open, the freshest one is mirrored into a status param so the
settings UI can show that the phone is there.
IPs are not identity: the app moves between networks, so a new address is simply a new endpoint,
and the key it proves over TLS is what decides whether the session is allowed.
"""
def __init__(self, params: Params | None = None, port: int = SUNNYLINK_LOCAL_UDP_PORT,
sock: socket.socket | None = None, write_interval_s: float = 5.0,
paired_refresh_cb: Callable[[AppBeacon], None] | None = None,
pairing_window_cb: Callable[[], bool] | None = None):
super().__init__(name="local_discovery_listener", daemon=True)
self.params = params or Params()
self.port = port
self._sock = sock
self.paired_refresh_cb = paired_refresh_cb
# A v2 window is not the legacy request flag, so the status line needs to see both.
# A window the daemon holds is not visible as a param read the UI can do, so the status line
# needs to see both.
self.pairing_window_cb = pairing_window_cb
self._latest_endpoint: str | None = None
self._latest_app_id: str | None = None
self._last_seen_monotonic: float = 0.0
self._latest_paired_endpoint: str | None = None
self._latest_paired_app_id: str | None = None
self._last_paired_seen_monotonic: float = 0.0
# Every beacon heard recently, keyed by endpoint: a v2 dial finds its phone by TLS key, not
# by app_id, so it cannot rely on the pairing-window trackers above.
# Every beacon heard recently, keyed by endpoint: a dial finds its phone by TLS key, not by
# app_id, so it cannot rely on the pairing-window trackers above.
self._recent_beacons: dict[str, tuple[str, float]] = {}
self._recent_lock = threading.Lock()
self._lock = threading.Lock()
@@ -135,30 +137,8 @@ class LocalDiscovery(threading.Thread):
return None
return time.monotonic() - self._last_seen_monotonic
def latest_paired_endpoint(self) -> str | None:
"""The freshest beacon endpoint announced by an ALREADY-PAIRED."""
with self._lock:
return self._latest_paired_endpoint
def latest_paired_app_id(self) -> str | None:
"""The app_id of the freshest paired-app beacon (None until one is heard)."""
with self._lock:
return self._latest_paired_app_id
def latest_paired_seen_ago(self) -> float | None:
"""Seconds since the freshest paired-app beacon was heard (None when none)."""
with self._lock:
if self._last_paired_seen_monotonic == 0.0:
return None
return time.monotonic() - self._last_paired_seen_monotonic
def fresh_endpoints(self, max_age_s: float = LOCAL_BEACON_FRESH_S) -> list[str]:
"""Endpoints of every beacon heard within max_age_s, freshest first.
Unlike the pairing-window/paired trackers, this is not gated on legacy registry
state: the v2 dial path needs to reach apps whose identity is a TLS key, not an
app_id it already knows.
"""
"""Endpoints of every beacon heard within max_age_s, freshest first."""
now = time.monotonic()
with self._recent_lock:
self._recent_beacons = {endpoint: entry for endpoint, entry in self._recent_beacons.items()
@@ -171,8 +151,8 @@ class LocalDiscovery(threading.Thread):
self._recent_beacons[beacon.endpoint] = (beacon.app_id, time.monotonic())
def _pairing_window_open(self) -> bool:
"""True while ANY pairing window is open: the legacy request flag, or a published v2 QR."""
if pairing_requested(self.params):
"""True while the settings UI asked for a window, or the daemon is displaying one."""
if self.params.get_bool(PAIRING_REQUEST_KEY):
return True
callback = self.pairing_window_cb
if callback is None:
@@ -201,24 +181,6 @@ class LocalDiscovery(threading.Thread):
self._latest_app_id = None
self._last_seen_monotonic = 0.0
self._clear_discovered_param()
self._maybe_refresh_paired_app(beacon)
def _maybe_refresh_paired_app(self, beacon: AppBeacon) -> None:
"""Refresh a paired app's registry endpoint from its beacon."""
if not any(app.app_id == beacon.app_id for app in get_local_apps(self.params)):
return
with self._lock:
self._latest_paired_endpoint = beacon.endpoint
self._latest_paired_app_id = beacon.app_id
self._last_paired_seen_monotonic = time.monotonic()
if update_local_app_endpoint(beacon.app_id, beacon.endpoint, self.params):
cloudlog.debug(f"local_discovery.paired_refresh {beacon.app_id} -> {beacon.endpoint}")
if self.paired_refresh_cb is not None:
try:
self.paired_refresh_cb(beacon)
except Exception:
cloudlog.exception("local_discovery.paired_refresh_cb.exception")
def _clear_discovered_param(self) -> None:
if self._discovered_cleared:
@@ -1,257 +0,0 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
"""
from __future__ import annotations
import secrets
import threading
import time
from dataclasses import asdict, dataclass
from datetime import datetime, UTC
from typing import Any
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
SUNNYLINK_LOCAL_UDP_PORT = 53133
SUNNYLINK_LOCAL_WS_PORT = 8443
LOCAL_APPS_KEY = "SunnylinkLocalApps"
PAIRING_CODE_KEY = "SunnylinkLocalPairingCode"
PAIRING_REQUEST_KEY = "SunnylinkLocalPairingRequest"
DISCOVERED_APP_KEY = "SunnylinkLocalDiscoveredApp"
PAIRING_CODE_LENGTH = 6
PAIRING_CODE_ALPHABET = "0123456789"
DEFAULT_CODE_ROTATION_S = 10 * 60 # re-roll the displayed code every 10 min
PAIRING_WINDOW_S = 5 * 60
BEACON_PREFIX = "SUNNYLINK1"
@dataclass
class LocalApp:
"""One app paired with this device (the app runs the local "backend")."""
app_id: str
endpoint: str
app_name: str = ""
alias: str = ""
paired_at: int = 0 # epoch seconds
@staticmethod
def from_dict(data: dict[str, Any]) -> LocalApp:
return LocalApp(
app_id=str(data.get("app_id", "")),
endpoint=str(data.get("endpoint", "")),
app_name=str(data.get("app_name", "")),
alias=str(data.get("alias", "")),
paired_at=int(data.get("paired_at") or 0),
)
def local_app_display_name(app: LocalApp) -> str:
return app.alias or app.app_name or app.app_id
def is_locally_paired(params: Params | None = None) -> bool:
return len(get_local_apps(params)) > 0
def get_local_apps(params: Params | None = None) -> list[LocalApp]:
"""The paired-app registry (a JSON list persisted in `SunnylinkLocalApps`)."""
params = params or Params()
data = params.get(LOCAL_APPS_KEY)
if not isinstance(data, list):
return []
return [LocalApp.from_dict(item) for item in data if isinstance(item, dict) and item.get("app_id")]
def _save_local_apps(apps: list[LocalApp], params: Params | None = None) -> None:
params = params or Params()
if apps:
params.put(LOCAL_APPS_KEY, [asdict(app) for app in apps], block=True)
else:
params.remove(LOCAL_APPS_KEY)
def add_local_app(app: LocalApp, params: Params | None = None) -> None:
"""Append (or update by app_id) and persist."""
if not app.paired_at:
app.paired_at = int(datetime.now(UTC).replace(tzinfo=None).timestamp())
apps = [existing for existing in get_local_apps(params) if existing.app_id != app.app_id]
apps.append(app)
_save_local_apps(apps, params)
cloudlog.event("local_pairing.app_paired", app_id=app.app_id, endpoint=app.endpoint)
def update_local_app_endpoint(app_id: str, endpoint: str, params: Params | None = None) -> bool:
"""Refresh a PAIRED app's cached endpoint from its beacon."""
apps = get_local_apps(params)
for i, app in enumerate(apps):
if app.app_id != app_id or app.endpoint == endpoint:
continue
apps[i] = LocalApp(app_id=app.app_id, endpoint=endpoint,
app_name=app.app_name, alias=app.alias, paired_at=app.paired_at)
_save_local_apps(apps, params)
cloudlog.event("local_pairing.app_endpoint_refreshed", app_id=app_id, endpoint=endpoint)
return True
return False
def set_local_app_alias(app_id: str, alias: str, params: Params | None = None) -> bool:
apps = get_local_apps(params)
for i, app in enumerate(apps):
if app.app_id != app_id:
continue
if app.alias == alias:
return False
apps[i] = LocalApp(app_id=app.app_id, endpoint=app.endpoint,
app_name=app.app_name, alias=alias, paired_at=app.paired_at)
_save_local_apps(apps, params)
cloudlog.event("local_pairing.app_alias_updated", app_id=app_id, alias=alias)
return True
return False
def remove_local_app(app_id: str, params: Params | None = None) -> bool:
"""Unpair an app by id. Returns True when an app was removed."""
apps = get_local_apps(params)
remaining = [app for app in apps if app.app_id != app_id]
if len(remaining) == len(apps):
return False
_save_local_apps(remaining, params)
cloudlog.event("local_pairing.app_unpaired", app_id=app_id)
return True
def remove_all_local_apps(params: Params | None = None) -> None:
"""Unpair every app."""
_save_local_apps([], params)
cloudlog.event("local_pairing.all_apps_unpaired")
def generate_pairing_code() -> str:
"""A 6-digit numeric pairing code."""
return "".join(secrets.choice(PAIRING_CODE_ALPHABET) for _ in range(PAIRING_CODE_LENGTH))
def _write_pairing_code(code: str, params: Params) -> None:
"""Persist the code with its armed-at monotonic timestamp — the window is derived from it."""
params.put(PAIRING_CODE_KEY, {"code": code, "ts": int(time.monotonic())}, block=True)
def read_pairing_code(params: Params | None = None) -> str | None:
"""The stored pairing code, or None when cleared / not yet generated."""
params = params or Params()
data = params.get(PAIRING_CODE_KEY)
if not isinstance(data, dict):
return None
code = data.get("code")
return str(code) if code else None
def get_pairing_code(params: Params | None = None) -> str:
"""The displayed pairing code, generating and persisting one on first use."""
params = params or Params()
code = read_pairing_code(params)
if code is None:
code = generate_pairing_code()
_write_pairing_code(code, params)
return code
def pairing_requested(params: Params | None = None) -> bool:
"""True while the pairing window is armed and fresh.
Self-expiring: if the code (which carries the armed-at timestamp) is missing
or older than PAIRING_WINDOW_S, the flag is dropped here."""
params = params or Params()
if not params.get_bool(PAIRING_REQUEST_KEY):
return False
data = params.get(PAIRING_CODE_KEY)
ts = data.get("ts") if isinstance(data, dict) else None
if not isinstance(ts, (int, float)):
clear_pairing_request(params)
return False
age = time.monotonic() - ts
# Negative age = armed before the last reboot (monotonic restarts at boot).
if age < 0 or age > PAIRING_WINDOW_S:
clear_pairing_request(params)
return False
return True
def arm_pairing(params: Params | None = None) -> str:
"""Arm a pairing window and return the code for the app.
Rolls a fresh code first, then sets the flag, so pairing_requested never
sees an armed flag without a valid code."""
params = params or Params()
code = generate_pairing_code()
_write_pairing_code(code, params)
params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
return code
def clear_pairing_request(params: Params | None = None) -> None:
"""Close the pairing window: drop the request flag and the code together."""
params = params or Params()
params.remove(PAIRING_REQUEST_KEY)
params.remove(PAIRING_CODE_KEY)
def verify_pairing_code(code: str, params: Params | None = None) -> bool:
"""Constant-time check of a code typed into the app against the displayed one."""
params = params or Params()
current = read_pairing_code(params)
if current is None:
return False
return secrets.compare_digest(str(code).strip().upper(), current)
class PairingCodeRotator(threading.Thread):
"""Re-roll the displayed code while a pairing window is armed; clear it
otherwise — the code is never generated outside a window."""
def __init__(self, params: Params | None = None, rotation_s: float = DEFAULT_CODE_ROTATION_S,
stop_event: threading.Event | None = None, tick_cb: Callable[[], None] | None = None):
super().__init__(name="local_pairing_code_rotator", daemon=True)
self.params = params or Params()
self.rotation_s = rotation_s
self.stop_event = stop_event or threading.Event()
# Test seam: invoked once per loop iteration after state is updated.
self.tick_cb = tick_cb
def rotate(self) -> None:
"""Re-roll the code while the window is armed, clear it otherwise."""
if pairing_requested(self.params):
_write_pairing_code(generate_pairing_code(), self.params)
else:
self.params.remove(PAIRING_CODE_KEY)
def run(self) -> None:
self.rotate()
while not self.stop_event.wait(self.rotation_s):
try:
self.rotate()
if self.tick_cb is not None:
self.tick_cb()
except Exception:
cloudlog.exception("local_pairing.code_rotator.exception")
def format_endpoint(host: str, ws_port: int = SUNNYLINK_LOCAL_WS_PORT) -> str:
return f"ws://{host}:{ws_port}"
def local_identity(params: Params | None = None) -> str:
"""Identity claim on local connections. DongleId always exists on comma
hardware (SunnylinkDongleId is "UnregisteredDevice" until cloud
registration) and is what the app matches against the backend device list
to dedupe cloud + local entries."""
params = params or Params()
return params.get("DongleId") or params.get("HardwareSerial") or ""
@@ -5,8 +5,8 @@ them, except the two commands an app pushes through the cloud settings-write for
"""
from __future__ import annotations
ENROLL = "SunnylinkLocalEnrollV2"
REVOKE = "SunnylinkLocalCloudRevokeV2"
ENROLL = "SunnylinkLocalEnroll"
REVOKE = "SunnylinkLocalCloudRevoke"
CLOUD_COMMANDS = frozenset({ENROLL, REVOKE})
_PREFIXES = ("SunnylinkLocal", "_sec_")
@@ -0,0 +1,50 @@
"""
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
This file is part of sunnypilot and is licensed under the MIT License.
See the LICENSE.md file in the root directory for more details.
Where local mode keeps its state, and the wire constants both halves share.
Every key here is under the `SunnylinkLocal` prefix, which the cloud daemon may neither read nor
write (`local_params.py`): a phone cannot enroll, rename or unpair itself through a local session,
only through the cloud settings-write an account already has to be allowed to make.
"""
from __future__ import annotations
from openpilot.common.params import Params
SUNNYLINK_LOCAL_UDP_PORT = 53133
SUNNYLINK_LOCAL_WS_PORT = 8443
# The beacon line the phone broadcasts: `SUNNYLINK1 {"v":1,"role":"app",...}`. A locator, never a
# credential.
BEACON_PREFIX = "SUNNYLINK1"
# The enrolled phones, `{"v": 2, "grants": [...]}`, written only by the local daemon.
LOCAL_APPS_KEY = "SunnylinkLocalApps"
# Display metadata per enrolled key id: `{"v": 2, "apps": {key_id: {"alias", "last_seen"}}}`.
LOCAL_APPS_META_KEY = "SunnylinkLocalAppsMeta"
# The freshest beacon, for the settings UI to show that a phone is around.
DISCOVERED_APP_KEY = "SunnylinkLocalDiscoveredApp"
# The settings UI asks for a pairing window here; the daemon publishes the QR it minted.
PAIRING_REQUEST_KEY = "SunnylinkLocalPairingRequest"
PAIRING_QR_KEY = "SunnylinkLocalPairingQr"
# The settings UI asks the daemon to drop one phone (key id) or all of them.
REVOKE_KEY = "SunnylinkLocalRevoke"
REVOKE_ALL_KEY = "SunnylinkLocalRevokeAll"
def is_locally_paired(params: Params | None = None) -> bool:
"""True once this device has at least one enrolled phone.
Reads the registry rather than the authority: `sunnylinkd` asks this at startup and the device UI
renders it continuously, and neither may build a second authority in its own process.
"""
params = params or Params()
return bool(params.get(LOCAL_APPS_KEY))
def format_endpoint(host: str, ws_port: int = SUNNYLINK_LOCAL_WS_PORT) -> str:
return f"ws://{host}:{ws_port}"
@@ -8,22 +8,20 @@ See the LICENSE.md file in the root directory for more details.
from __future__ import annotations
import json
import ssl
import threading
import time
from collections.abc import Callable
from urllib.parse import urlsplit
from websocket import WebSocket, WebSocketTimeoutException, create_connection
from websocket import WebSocket, WebSocketTimeoutException
from openpilot.system.athena.rpc import dispatcher
from openpilot.system.athena import rpc as rpc_module
from openpilot.common.realtime import set_core_affinity
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.athena import local_auth_v2_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AUTHENTICATE_PURPOSE,
LOCAL_METHODS,
VERSION,
@@ -33,36 +31,20 @@ from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
connect_pinned,
parse_object,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LOCAL_BEACON_FRESH_S, LocalDiscovery
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LocalDiscovery
from openpilot.sunnypilot.sunnylink.athena.local_params import is_local_param
from openpilot.sunnypilot.sunnylink.athena.local_pairing import (
PAIRING_WINDOW_S,
LocalApp,
PairingCodeRotator,
add_local_app,
clear_pairing_request,
get_local_apps,
is_locally_paired,
local_identity,
pairing_requested,
remove_local_app,
set_local_app_alias,
verify_pairing_code,
)
from openpilot.sunnypilot.sunnylink.athena.sunnylinkd import SUNNYLINK_RECONNECT_TIMEOUT_S, Sunnylinkd, log_connection_error
LOCAL_ENABLED_PARAM = "SunnylinkLocalEnabled"
SESSION_READ_TIMEOUT_S = 5
TICK_INTERVAL_S = 1.0
ENDPOINT_BACKOFF_S = 300
# A v2 endpoint is THE phone, and the app is a foreground-only server the user opens and closes, so
# a dial that failed because the app was closed must not outlive it: the first failure costs a
# single tick and doubles from there, and the ramp is capped well below the legacy five minutes
# because every step of it is a few seconds of the user's device sitting on the cloud instead of
# the LAN. A phone whose beacons have lapsed clears its own record entirely (see
# `LocalSunnylinkd.forget_absent_endpoints`).
V2_ENDPOINT_BACKOFF_S = float(TICK_INTERVAL_S)
V2_ENDPOINT_BACKOFF_MAX_S = 60.0
# A local endpoint is THE phone, and the app is a foreground-only server the user opens and closes,
# so a dial that failed because the app was closed must not outlive it: the first failure costs a
# single tick and doubles from there. The ramp is capped at a minute, because every step of it is a
# few seconds of the user's device sitting on the cloud instead of the LAN. A
# phone whose beacons have lapsed clears its own record entirely (see `forget_absent_endpoints`).
ENDPOINT_BACKOFF_S = float(TICK_INTERVAL_S)
ENDPOINT_BACKOFF_MAX_S = 60.0
# How long a phone may stay silent before its backoff record is dropped. Deliberately shorter than
# the beacon freshness window: a Wi-Fi roam or a switch to another network (home -> the car's
# hotspot) silences the app for a few beacon intervals while it reassociates, and the ramp it had
@@ -70,42 +52,33 @@ V2_ENDPOINT_BACKOFF_MAX_S = 60.0
# the endpoint stays dialable, so the next beacon gets an immediate attempt rather than a penalty.
BACKOFF_FORGET_S = 12.0
TARGET_V2 = "v2"
TARGET_PAIRING = "pairing"
TARGET_LEGACY = "legacy"
class LocalSunnylinkd(Sunnylinkd):
"""The LAN half of sunnylink, in its own process.
It inherits the settings RPCs and adds what is local: discovery, pinned-TLS v2 sessions with the
cloud-enrolled phones, the legacy PIN path for apps that predate v2, and the UI's trust requests.
It never dials the cloud, so the cloud link keeps running while a phone is connected here.
It inherits the settings RPCs and adds what is local: discovery, pinned-TLS sessions with the
cloud-enrolled phones, and the UI's trust requests. It never dials the cloud, so the cloud link
keeps running while a phone is connected here.
"""
RPC_METHODS = Sunnylinkd.RPC_METHODS + ("pairLocalApp", "updateLocalAppAlias", "unpairLocalApp")
def __init__(self, params=None, discovery: LocalDiscovery | None = None):
super().__init__(params)
self.discovery = discovery if discovery is not None else LocalDiscovery(pairing_window_cb=self.pairing_window_open)
self.code_rotator = PairingCodeRotator()
self.active_endpoint: str | None = None
self.active_ws: WebSocket | None = None
self.backoffs: dict[str, float] = {}
# Consecutive failed v2 dials per endpoint, which is what turns the backoff into a ramp.
self.v2_failures: dict[str, int] = {}
# Consecutive failed dials per endpoint, which is what turns the backoff into a ramp.
self.failures: dict[str, int] = {}
self.stop_event = threading.Event()
# --- local trust --------------------------------------------------------
def v2_auth_active(self) -> bool:
"""True once any cloud-enrolled grant exists: the legacy PIN path is then never offered."""
return bool(local_auth_v2_daemon.v2_grants())
def enrolled(self) -> bool:
"""True once any phone is enrolled, or an enrollment is waiting to be confirmed."""
return bool(local_auth_daemon.grants()) or local_auth_daemon.pending_enrollment() is not None
def pairing_window_open(self) -> bool:
"""A live v2 window counts as a pairing window for the settings screen's status line."""
"""A live pairing window counts as a beacon-display window for the settings screen."""
try:
return local_auth_v2_daemon.read_pairing_qr() is not None
return local_auth_daemon.read_pairing_qr() is not None
except Exception:
return False
@@ -118,31 +91,6 @@ class LocalSunnylinkd(Sunnylinkd):
if allowed:
super().saveParams(allowed, compression)
def pairLocalApp(self, code: str, app_id: str = "", app_name: str = "", alias: str = "") -> dict[str, bool | str]:
"""Complete PIN pairing with the app on the CURRENT local connection."""
if self.v2_auth_active():
cloudlog.warning("sunnylinkd.pairLocalApp.refused_v2_active")
return {"success": False, "error": "legacy pairing disabled"}
if self.active_endpoint is None:
return {"success": False, "error": "not connected to a local app"}
if not verify_pairing_code(code):
cloudlog.warning("sunnylinkd.pairLocalApp.invalid_code")
return {"success": False, "error": "invalid code"}
add_local_app(LocalApp(app_id=app_id or f"app@{self.active_endpoint}",
endpoint=self.active_endpoint, app_name=app_name, alias=alias))
clear_pairing_request()
return {"success": True}
def updateLocalAppAlias(self, app_id: str, alias: str) -> dict[str, bool | str]:
if self.active_endpoint is None:
return {"success": False, "error": "not connected to a local app"}
return {"success": True, "updated": set_local_app_alias(app_id, alias)}
def unpairLocalApp(self, app_id: str) -> dict[str, bool | str]:
if self.active_endpoint is None:
return {"success": False, "error": "not connected to a local app"}
return {"success": True, "removed": remove_local_app(app_id)}
# --- loop ---------------------------------------------------------------
def serviceable(self) -> bool:
@@ -156,65 +104,52 @@ class LocalSunnylinkd(Sunnylinkd):
def run(self, exit_event: threading.Event | None = None) -> None:
self.discovery.start()
self.code_rotator.start()
threading.Thread(target=self._tick_loop, name="localsunnylinkd_tick", daemon=True).start()
try:
while (exit_event is None or not exit_event.is_set()) and self.serviceable():
endpoint, kind = self.pick_target()
endpoint = self.pick_target()
if endpoint is None:
time.sleep(TICK_INTERVAL_S)
elif kind == TARGET_V2:
self.serve_v2(endpoint, exit_event)
else:
self.serve_legacy(endpoint, pairing=kind == TARGET_PAIRING, exit_event=exit_event)
self.serve(endpoint, exit_event)
finally:
self.stop_event.set()
self.discovery.stop()
self.code_rotator.stop_event.set()
local_auth_v2_daemon.clear_pairing_window()
local_auth_daemon.clear_pairing_window()
def _tick_loop(self) -> None:
"""The UI's trust requests and the cloud's local commands, ~once a second, in their own
thread: the serving loop above blocks for as long as a session lasts."""
while not self.stop_event.wait(TICK_INTERVAL_S) and self.serviceable():
try:
local_auth_v2_daemon.service_pairing_window()
local_auth_v2_daemon.service_revoke_requests()
local_auth_v2_daemon.claim_cloud_commands()
local_auth_daemon.service_pairing_window()
local_auth_daemon.service_revoke_requests()
local_auth_daemon.claim_cloud_commands()
except Exception:
cloudlog.exception("sunnylinkd.local.tick_failed")
def pick_target(self) -> tuple[str | None, str]:
"""Where to dial next: an enrolled phone over pinned TLS, a pairing window, or a legacy app."""
def pick_target(self) -> str | None:
"""Where to dial next: the freshest phone beacon that is not backing off.
Dialing finds the phone by its pinned TLS key, so any fresh endpoint is worth one attempt: the
key is the authorization, and a peer without it cannot complete the handshake.
"""
if not self.enrolled():
return None
now = time.monotonic()
if self.v2_auth_active() or local_auth_v2_daemon.pending_enrollment() is not None:
pinned_targets = [pinned for pinned in (pinned_endpoint(beacon) for beacon in self.discovery.fresh_endpoints())
if pinned is not None]
# Backoff bookkeeping reads a shorter window than dialing does: a phone that went quiet for a
# few beacon intervals (a roam, a network switch) keeps its endpoint but loses its ramp.
recent = {pinned for pinned in (pinned_endpoint(beacon) for beacon in self.discovery.fresh_endpoints(max_age_s=BACKOFF_FORGET_S))
if pinned is not None}
self.forget_absent_endpoints(recent)
for pinned in pinned_targets:
if self.backoffs.get(pinned, 0.0) <= now:
return pinned, TARGET_V2
return None, TARGET_V2
if pairing_requested():
endpoint = self.discovery.latest_endpoint()
seen = self.discovery.last_seen_ago()
if endpoint is not None and seen is not None and seen <= LOCAL_BEACON_FRESH_S \
and self.discovery.latest_app_id() not in {app.app_id for app in get_local_apps()} \
and self.backoffs.get(endpoint, 0.0) <= now:
return endpoint, TARGET_PAIRING
return None, TARGET_PAIRING
for app in reversed(get_local_apps()):
if self.backoffs.get(app.endpoint, 0.0) <= now:
return app.endpoint, TARGET_LEGACY
return None, TARGET_LEGACY
pinned_targets = [pinned for pinned in (pinned_endpoint(beacon) for beacon in self.discovery.fresh_endpoints())
if pinned is not None]
# Backoff bookkeeping reads a shorter window than dialing does: a phone that went quiet for a
# few beacon intervals (a roam, a network switch) keeps its endpoint but loses its ramp.
recent = {pinned for pinned in (pinned_endpoint(beacon) for beacon in self.discovery.fresh_endpoints(max_age_s=BACKOFF_FORGET_S))
if pinned is not None}
self.forget_absent_endpoints(recent)
for pinned in pinned_targets:
if self.backoffs.get(pinned, 0.0) <= now:
return pinned
return None
def forget_absent_endpoints(self, present: set[str]) -> None:
"""Drop the backoff of a phone that stopped announcing.
@@ -227,100 +162,30 @@ class LocalSunnylinkd(Sunnylinkd):
"""
for endpoint in [key for key in self.backoffs if key.startswith("wss://") and key not in present]:
self.backoffs.pop(endpoint, None)
self.v2_failures.pop(endpoint, None)
self.failures.pop(endpoint, None)
def serve_v2(self, endpoint: str, exit_event: threading.Event | None) -> None:
def serve(self, endpoint: str, exit_event: threading.Event | None) -> None:
try:
self.dial_v2(endpoint, exit_event)
self.dial(endpoint, exit_event)
except Exception as e:
failures = self.v2_failures.get(endpoint, 0) + 1
self.v2_failures[endpoint] = failures
self.backoffs[endpoint] = time.monotonic() + min(V2_ENDPOINT_BACKOFF_S * (2 ** (failures - 1)),
V2_ENDPOINT_BACKOFF_MAX_S)
failures = self.failures.get(endpoint, 0) + 1
self.failures[endpoint] = failures
self.backoffs[endpoint] = time.monotonic() + min(ENDPOINT_BACKOFF_S * (2 ** (failures - 1)),
ENDPOINT_BACKOFF_MAX_S)
self.params.remove("LastSunnylinkPingTime")
log_connection_error(e)
return
self.backoffs.pop(endpoint, None)
self.v2_failures.pop(endpoint, None)
self.failures.pop(endpoint, None)
# A finished session is not an error, but never redial in a tight loop.
time.sleep(TICK_INTERVAL_S)
def serve_legacy(self, endpoint: str, pairing: bool, exit_event: threading.Event | None) -> None:
try:
ws = create_connection(
endpoint,
header=self.dial_header(),
enable_multithread=True,
sslopt={"cert_reqs": ssl.CERT_NONE if "localhost" in endpoint else ssl.CERT_REQUIRED},
timeout=SUNNYLINK_RECONNECT_TIMEOUT_S,
)
except Exception as e:
self.backoffs[endpoint] = time.monotonic() + ENDPOINT_BACKOFF_S
self.params.remove("LastSunnylinkPingTime")
log_connection_error(e)
return
cloudlog.event("sunnylinkd.local.connected", endpoint=endpoint, pairing=pairing)
self.active_endpoint = endpoint
self.active_ws = ws
try:
if pairing and not self.pairing_session(ws):
self.backoffs[endpoint] = time.monotonic() + ENDPOINT_BACKOFF_S
return
# A legacy session serves the shared dispatcher, exactly as before. It is preempted when
# a v2 grant or a new pairing window takes over.
self.serve_rpc(ws, dispatcher, exit_event, should_stop=self.preempted)
except (KeyboardInterrupt, SystemExit):
raise
except Exception as e:
log_connection_error(e)
finally:
self.active_endpoint = None
self.active_ws = None
close_quietly(ws)
def preempted(self) -> bool:
return pairing_requested() or self.v2_auth_active()
def pairing_session(self, ws: WebSocket, timeout_s: float = PAIRING_WINDOW_S) -> bool:
"""Serve ONLY the pairing RPCs to an app that is not in the registry yet, and report whether
pairing completed, so the connection may then serve normally."""
cloudlog.info("sunnylinkd.pairing_session.started")
ws.settimeout(10)
deadline = time.monotonic() + timeout_s
try:
while time.monotonic() < deadline and pairing_requested():
try:
raw = ws.recv() # auto-pongs pings; blocks up to the socket timeout
except WebSocketTimeoutException:
continue
except Exception as e:
cloudlog.warning(f"sunnylinkd.pairing_session.{type(e).__name__}")
return is_locally_paired()
try:
msg = rpc_module.loads(raw)
except Exception:
continue
if not rpc_module.is_call(msg) or msg.get("method") not in ("pairLocalApp", "unpairLocalApp"):
continue
try:
ws.send(rpc_module.handle(msg, dispatcher))
except Exception as e:
cloudlog.warning(f"sunnylinkd.pairing_session.{type(e).__name__}")
return is_locally_paired()
return is_locally_paired()
finally:
ws.settimeout(SUNNYLINK_RECONNECT_TIMEOUT_S)
def serve_rpc(self, ws: WebSocket, methods: dict[str, Callable[..., object]],
exit_event: threading.Event | None = None,
should_stop: Callable[[], bool] | None = None) -> None:
exit_event: threading.Event | None = None) -> None:
"""Answer JSON-RPC calls on one socket until the peer goes away or the session ends."""
ws.settimeout(SESSION_READ_TIMEOUT_S)
while exit_event is None or not exit_event.is_set():
if should_stop is not None and should_stop():
return
try:
raw = ws.recv()
except WebSocketTimeoutException:
@@ -344,18 +209,13 @@ class LocalSunnylinkd(Sunnylinkd):
cloudlog.warning(f"sunnylinkd.send.{type(e).__name__}")
return
def dial_header(self) -> dict[str, str]:
"""Bearer header for the legacy local dial: the app checks the device's local identity."""
api = SunnylinkApi(self.params.get("SunnylinkDongleId"))
return {"Authorization": f"Bearer {api.get_token(payload_extra={'identity': local_identity()})}"}
def dial_v2(self, endpoint: str, exit_event: threading.Event | None) -> None:
def dial(self, endpoint: str, exit_event: threading.Event | None) -> None:
"""Dial an enrolled (or enrolling) phone over pinned TLS and serve it.
The pinned key IS the authorization, so trying each known key is safe: a peer without the
matching private key cannot complete the handshake.
"""
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
@@ -382,11 +242,11 @@ class LocalSunnylinkd(Sunnylinkd):
if pending_key_id is not None and matched == pending_key_id:
try:
local_auth_v2_daemon.confirm_enrollment_tls(matched)
local_auth_daemon.confirm_enrollment_tls(matched)
except Exception:
# The window closed or failed: drop the stale pending rather than redial forever.
cloudlog.exception("sunnylinkd.v2.confirm_failed")
local_auth_v2_daemon.cancel_local_pairing_v2()
cloudlog.exception("sunnylinkd.local.confirm_failed")
local_auth_daemon.cancel_local_pairing()
raise
with authority.lock:
@@ -395,18 +255,12 @@ class LocalSunnylinkd(Sunnylinkd):
raise AuthorizationError("peer is not enrolled")
session = authority.session(grant, matched)
local_auth_v2_daemon.note_grant_seen(grant.key_id)
cloudlog.event("sunnylinkd.v2_session.opened", endpoint=endpoint, app_id=grant.app_id)
self.active_endpoint = endpoint
self.active_ws = ws
local_auth_daemon.note_grant_seen(grant.key_id)
cloudlog.event("sunnylinkd.local.session_opened", endpoint=endpoint, app_id=grant.app_id)
self.serve_rpc(ws, self.session_methods(session), exit_event)
finally:
if session is not None:
session.close()
if self.active_endpoint == endpoint:
self.active_endpoint = None
if self.active_ws is ws:
self.active_ws = None
close_quietly(ws)
def authenticate_phone(self, ws: WebSocket, authority: LocalAuthority, phone_key_id: str) -> None:
@@ -434,13 +288,13 @@ class LocalSunnylinkd(Sunnylinkd):
return call
def unpair_local_app(app_id: str = "") -> dict[str, bool]:
removed = local_auth_v2_daemon.revoke_local_app_v2(session.grant.key_id)
removed = local_auth_daemon.revoke_local_app(session.grant.key_id)
return {"success": bool(removed), "removed": bool(removed)}
def update_local_app_alias(app_id: str = "", alias: str = "") -> dict[str, bool | str]:
# The principal is the session's grant, so a phone can only rename itself.
try:
local_auth_v2_daemon.set_grant_alias(session.grant.key_id, alias)
local_auth_daemon.set_grant_alias(session.grant.key_id, alias)
except AuthorizationError:
return {"success": False, "error": "invalid alias"}
return {"success": True, "updated": True}
@@ -461,7 +315,7 @@ class LocalSunnylinkd(Sunnylinkd):
def pinned_endpoint(beacon_endpoint: str) -> str | None:
"""Beacons announce `ws://ip:port`; a v2 dial uses the same host and port over pinned TLS."""
"""Beacons announce `ws://ip:port`; a dial uses the same host and port over pinned TLS."""
parsed = urlsplit(beacon_endpoint)
if parsed.scheme != "ws" or parsed.hostname is None or parsed.port is None:
return None
@@ -3,90 +3,66 @@ from typing import cast
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
DEFAULT_APP_NAME,
LEGACY_KIND,
MAX_LOCAL_APP_ROWS,
NEVER_CONNECTED,
V2_KIND,
build_local_app_rows,
format_age,
short_key,
)
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import MAX_GRANTS
from openpilot.sunnypilot.sunnylink.athena.local_pairing import LocalApp
from openpilot.sunnypilot.sunnylink.athena.local_auth import MAX_GRANTS
def grant(app_id: str, app_name: str = "Phone") -> dict[str, str]:
return {"app_id": app_id, "app_name": app_name, "public_key": "x", "key_id": app_id, "revision": "r"}
def legacy(app_id: str, endpoint: str = "ws://10.0.0.7:8443") -> LocalApp:
return LocalApp(app_id=app_id, endpoint=endpoint)
class TestLocalAppRows(OpenpilotTestCase):
"""The device screen's rows: enrolled phones and the retired legacy apps in one list."""
"""The device screen's rows: one per enrolled phone."""
def test_enrolled_phones_come_first_and_are_marked_secure(self):
rows = build_local_app_rows([legacy("legacy-app")], [grant("key-a", "Nayan's phone")])
self.assertEqual([V2_KIND, LEGACY_KIND], [row.kind for row in rows])
def test_an_enrolled_phone_is_marked_secure(self):
rows = build_local_app_rows([grant("key-a", "Nayan's phone")])
self.assertEqual("Nayan's phone", rows[0].title)
self.assertTrue(short_key("key-a") in rows[0].subtitle)
self.assertTrue("secure" in rows[0].subtitle)
def test_legacy_rows_need_re_pairing_once_v2_holds_a_grant(self):
with_v2 = build_local_app_rows([legacy("legacy-app")], [grant("key-a")])
without_v2 = build_local_app_rows([legacy("legacy-app")], [])
# With no v2 grant the PIN path is still the trust that works.
self.assertFalse(without_v2[0].re_pair_required)
# Once a phone is enrolled the daemon refuses every legacy path, so the row is a leftover.
self.assertTrue(with_v2[1].re_pair_required)
self.assertEqual("legacy-app", with_v2[1].title)
def test_legacy_rows_still_show_their_registry_name_and_endpoint(self):
app = LocalApp(app_id="app-1", endpoint="ws://10.0.0.9:8443", app_name="", alias="Pixel")
rows = build_local_app_rows([app], [])
self.assertEqual("Pixel", rows[0].title)
self.assertEqual("ws://10.0.0.9:8443", rows[0].subtitle)
def test_a_nameless_grant_still_gets_a_title(self):
rows = build_local_app_rows([], [{"app_id": "key-a", "app_name": ""}, {"app_id": "key-b"}])
rows = build_local_app_rows([{"app_id": "key-a", "app_name": ""}, {"app_id": "key-b"}])
self.assertEqual([DEFAULT_APP_NAME, DEFAULT_APP_NAME], [row.title for row in rows])
def test_malformed_grants_are_skipped_rather_than_rendered(self):
# The registry is JSON, so a hand-edited or older document can hold anything.
malformed = cast(list[dict[str, str]], ["junk", {"app_name": "no id"},
{"app_id": "", "app_name": "blank"}, grant("key-a")])
rows = build_local_app_rows([], malformed)
rows = build_local_app_rows(malformed)
self.assertEqual(["key-a"], [row.app_id for row in rows])
def test_every_authorized_phone_has_a_row(self):
"""A phone the authority accepts must be visible: an invisible grant cannot be revoked."""
rows = build_local_app_rows([], [grant(f"key-{i}") for i in range(MAX_GRANTS)])
rows = build_local_app_rows([grant(f"key-{i}") for i in range(MAX_GRANTS)])
self.assertEqual(MAX_GRANTS, len(rows))
self.assertEqual(MAX_GRANTS + 4, MAX_LOCAL_APP_ROWS)
self.assertEqual(MAX_LOCAL_APP_ROWS, len(build_local_app_rows(
[legacy(f"app-{i}") for i in range(10)], [grant(f"key-{i}") for i in range(MAX_GRANTS)])))
self.assertEqual(MAX_GRANTS, MAX_LOCAL_APP_ROWS)
self.assertEqual(MAX_LOCAL_APP_ROWS, len(build_local_app_rows([grant(f"key-{i}") for i in range(10)])))
def test_short_key_keeps_short_ids_readable(self):
self.assertEqual("abc", short_key("abc"))
self.assertEqual("abcdefghij…", short_key("abcdefghijklmnop"))
def test_a_phone_supplied_alias_wins_over_the_enrolled_name(self):
rows = build_local_app_rows([], [grant("key-a", "Nayan's phone")], {"key-a": {"alias": "Garage phone"}})
rows = build_local_app_rows([grant("key-a", "Nayan's phone")], {"key-a": {"alias": "Garage phone"}})
self.assertEqual("Garage phone", rows[0].title)
# A blank or missing alias falls back to what the phone enrolled with.
for meta in ({}, {"key-a": {}}, {"key-a": {"alias": " "}}, {"key-a": {"alias": 7}}):
self.assertEqual("Nayan's phone", build_local_app_rows([], [grant("key-a", "Nayan's phone")], meta)[0].title)
self.assertEqual("Nayan's phone", build_local_app_rows([grant("key-a", "Nayan's phone")], meta)[0].title)
def test_each_phone_reports_when_it_was_last_connected(self):
rows = build_local_app_rows([], [grant("key-a"), grant("key-b")],
rows = build_local_app_rows([grant("key-a"), grant("key-b")],
{"key-a": {"last_seen": 1_000.0}}, now=1_120.0)
self.assertTrue("2m ago" in rows[0].subtitle)
self.assertTrue(NEVER_CONNECTED in rows[1].subtitle)
def test_a_stamp_from_before_the_last_reboot_reads_as_unknown(self):
# Monotonic time restarts at boot, so a future-looking stamp is not "-5m ago".
rows = build_local_app_rows([], [grant("key-a")], {"key-a": {"last_seen": 5_000.0}}, now=10.0)
rows = build_local_app_rows([grant("key-a")], {"key-a": {"last_seen": 5_000.0}}, now=10.0)
self.assertTrue(NEVER_CONNECTED in rows[0].subtitle)
def test_format_age_is_coarse_and_readable(self):
@@ -16,7 +16,7 @@ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.x509.oid import NameOID
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
PAIRING_DELIVERY_GRACE_S, PAIRING_TTL_S, QR_SESSION_BYTES, AuthorizationError, Grant, LocalAuthority, Origin,
connect_pinned, decode64, decode_qr, encode64, key_id, parse_object, qr_frame, statement, verify_tls_peer,
)
@@ -33,7 +33,7 @@ ABORTED_CONNECTION_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAb
ssl.SSLEOFError, ssl.SSLError)
class TestLocalAuthV2(OpenpilotTestCase):
class TestLocalAuth(OpenpilotTestCase):
def setup_method(self):
self.now = 10.0
self.device_key = ec.generate_private_key(ec.SECP256R1())
@@ -217,12 +217,12 @@ class TestLocalAuthV2(OpenpilotTestCase):
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
def test_grants_reload_but_legacy_corrupt_and_duplicate_registry_fail_closed(self):
def test_grants_reload_but_corrupt_and_duplicate_registry_fail_closed(self):
grant = self.committed()
restarted = LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append, self.writes[-1])
self.assertEqual(restarted.grants, {grant.key_id: grant})
corrupt = grant.to_dict() | {"key_id": "wrong"}
for storage in ([{"app_id": "legacy", "endpoint": "ws://127.0.0.1"}], {"v": 1, "grants": [grant.to_dict()]},
for storage in ([{"app_id": "unknown", "endpoint": "ws://127.0.0.1"}], {"v": 1, "grants": [grant.to_dict()]},
{"v": 2, "grants": [corrupt]}, {"v": 2, "grants": [grant.to_dict(), grant.to_dict()]}):
self.assertEqual(LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append, storage).grants, {})
@@ -273,7 +273,7 @@ class TestLocalAuthV2(OpenpilotTestCase):
Grant.from_dict({})
def test_pinned_connector_rejects_downgrade_and_invalid_endpoints_before_dial(self):
with mock.patch("openpilot.sunnypilot.sunnylink.athena.local_auth_v2.socket.create_connection") as dial:
with mock.patch("openpilot.sunnypilot.sunnylink.athena.local_auth.socket.create_connection") as dial:
for endpoint in ("ws://192.168.1.2:8443", "wss://host.example:8443", "wss://user:pass@192.168.1.2:8443",
"wss://0.0.0.0:8443", "wss://224.0.0.1:8443", "wss://192.168.1.2:8443/path",
"wss://192.168.1.2:8443?token=secret", "wss://192.168.1.2:99999"):
@@ -282,7 +282,7 @@ class TestLocalAuthV2(OpenpilotTestCase):
dial.assert_not_called()
def test_wrong_tls_pin_closes_socket_before_websocket_upgrade(self):
base = "openpilot.sunnypilot.sunnylink.athena.local_auth_v2."
base = "openpilot.sunnypilot.sunnylink.athena.local_auth."
with mock.patch(base + "socket.create_connection"), mock.patch(base + "ssl.SSLContext") as context, \
mock.patch(base + "verify_tls_peer", side_effect=AuthorizationError("wrong key")), mock.patch(base + "create_connection") as upgrade:
transport = context.return_value.wrap_socket.return_value
@@ -336,7 +336,7 @@ class TestLocalAuthV2(OpenpilotTestCase):
accept = base64.b64encode(hashlib.sha1((headers["Sec-WebSocket-Key"] +
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
tls.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
f"Sec-WebSocket-Accept: {accept}\r\nSec-WebSocket-Protocol: sunnylink-local-v2\r\n\r\n").encode())
f"Sec-WebSocket-Accept: {accept}\r\nSec-WebSocket-Protocol: sunnylink-local\r\n\r\n").encode())
tls.recv(4096) # Client close frame.
except ABORTED_CONNECTION_ERRORS as e:
# Expected only when the client is the one that refuses: an error here in the
@@ -11,8 +11,8 @@ from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena import local_auth_v2_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AuthorizationError,
Grant,
LocalAuthority,
@@ -65,7 +65,7 @@ class _ParamsStore:
self.removed.append(key)
class TestLocalAuthV2Daemon(OpenpilotTestCase):
class TestLocalAuthDaemon(OpenpilotTestCase):
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
@@ -73,11 +73,11 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.store = _ParamsStore({"SunnylinkDongleId": "cloud-device", "DongleId": "comma-device"})
local_auth_v2_daemon.reset_authority()
self.addCleanup(local_auth_v2_daemon.reset_authority)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
mock.patch.object(local_auth_v2_daemon, "_params", self.store),
mock.patch.object(local_auth_v2_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
mock.patch.object(local_auth_daemon, "_params", self.store),
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
]
for patcher in patchers:
patcher.start()
@@ -97,8 +97,8 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
def enrolled(self) -> dict[str, str]:
"""Enroll one phone end to end: cloud enrollment staged, then TLS-confirmed."""
summary = local_auth_v2_daemon.cloud_enroll(self.enrollment(local_auth_v2_daemon.get_authority()))
return local_auth_v2_daemon.confirm_enrollment_tls(summary["app_id"])
summary = local_auth_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
return local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
def revocation(self, authority, app_key=None) -> str:
"""What the app pushes when it unpairs: a self-signed request to drop its own grant."""
@@ -110,44 +110,44 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
"signature": encode64(key.sign(proof, ec.ECDSA(hashes.SHA256())))})
def registry(self) -> dict[str, Any]:
return self.store.values[local_auth_v2_daemon.V2_REGISTRY_KEY]
return self.store.values[local_auth_daemon.LOCAL_APPS_KEY]
def test_a_late_cloud_enrollment_from_an_expired_code_still_enrolls(self):
"""The path an app's push actually takes: the gateway stored the settings-write, the local
half claims it on a later tick, and by then the scanned code has left the device screen. This
is what used to be dropped with only a log line, leaving the app waiting forever."""
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
raw = self.enrollment(authority) # the app scanned the armed code
authority.expire() # the code stops being displayed …
self.store.put(local_auth_v2_daemon.CLOUD_ENROLL_KEY, raw)
self.store.put(local_auth_daemon.CLOUD_ENROLL_KEY, raw)
local_auth_v2_daemon.claim_cloud_commands(cast(Params, self.store))
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
pending = local_auth_v2_daemon.pending_enrollment()
pending = local_auth_daemon.pending_enrollment()
assert pending is not None, "the late enrollment was not staged"
self.assertEqual(key_id(self.app_key.public_key()), pending["key_id"])
self.assertEqual("Test phone", pending["app_name"])
# Claimed exactly once, whether or not it validated.
self.assertIsNone(self.store.get(local_auth_v2_daemon.CLOUD_ENROLL_KEY))
self.assertIsNone(self.store.get(local_auth_daemon.CLOUD_ENROLL_KEY))
def test_a_cloud_enrollment_for_a_code_past_its_grace_is_refused(self):
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
raw = self.enrollment(authority)
window = authority.recent[-1]
authority.expire()
self.store.put(local_auth_v2_daemon.CLOUD_ENROLL_KEY, raw)
self.store.put(local_auth_daemon.CLOUD_ENROLL_KEY, raw)
with mock.patch.object(authority, "clock", lambda: window.usable_until + 1):
local_auth_v2_daemon.claim_cloud_commands(cast(Params, self.store))
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
self.assertIsNone(local_auth_v2_daemon.pending_enrollment())
self.assertIsNone(self.store.get(local_auth_v2_daemon.CLOUD_ENROLL_KEY))
self.assertIsNone(local_auth_daemon.pending_enrollment())
self.assertIsNone(self.store.get(local_auth_daemon.CLOUD_ENROLL_KEY))
def test_cloud_enroll_stages_and_tls_confirm_persists(self):
summary = local_auth_v2_daemon.cloud_enroll(self.enrollment(local_auth_v2_daemon.get_authority()))
summary = local_auth_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
# Staged: nothing durable until the TLS peer proves possession.
self.assertNotIn(local_auth_v2_daemon.V2_REGISTRY_KEY, self.store.values)
committed = local_auth_v2_daemon.confirm_enrollment_tls(summary["app_id"])
self.assertNotIn(local_auth_daemon.LOCAL_APPS_KEY, self.store.values)
committed = local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
self.assertEqual(summary["app_id"], committed["app_id"])
registry = self.registry()
self.assertEqual(2, registry["v"])
@@ -158,8 +158,8 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
"""The write and read halves must agree: a grant has to survive a daemon restart."""
summary = self.enrolled()
local_auth_v2_daemon.reset_authority()
grants = local_auth_v2_daemon.v2_grants()
local_auth_daemon.reset_authority()
grants = local_auth_daemon.grants()
self.assertEqual(1, len(grants))
self.assertEqual(summary["app_id"], grants[0]["app_id"])
@@ -172,36 +172,36 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
# same module must return the identical structure.
self.assertIsInstance(registry, dict)
self.assertEqual({"v", "grants"}, set(registry))
self.assertIs(local_auth_v2_daemon._read_registry(), registry)
self.assertIs(local_auth_daemon._read_registry(), registry)
def test_revocation_persists_removal(self):
summary = self.enrolled()
self.assertTrue(local_auth_v2_daemon.revoke_local_app_v2(summary["app_id"]))
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
self.assertEqual([], self.registry()["grants"])
self.assertFalse(local_auth_v2_daemon.revoke_local_app_v2("unknown"))
self.assertFalse(local_auth_daemon.revoke_local_app("unknown"))
def test_cloud_revoke_drops_the_phones_own_grant(self):
"""Unpairing from an app that is not on the LAN: no live session carries `unpairLocalApp`,
so without this the device would keep authorizing that phone forever."""
summary = self.enrolled()
result = local_auth_v2_daemon.cloud_revoke(self.revocation(local_auth_v2_daemon.get_authority()))
result = local_auth_daemon.cloud_revoke(self.revocation(local_auth_daemon.get_authority()))
self.assertEqual(summary["app_id"], result["app_id"])
self.assertEqual([], self.registry()["grants"])
# Persisted: a restart must not resurrect the grant.
local_auth_v2_daemon.reset_authority()
self.assertEqual([], local_auth_v2_daemon.v2_grants())
local_auth_daemon.reset_authority()
self.assertEqual([], local_auth_daemon.grants())
def test_a_forged_proof_for_an_enrolled_key_is_refused(self):
self.enrolled()
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
forged = json.loads(self.revocation(authority))
forged["signature"] = encode64(ec.generate_private_key(ec.SECP256R1()).sign(
statement("revoke", authority.cloud_device_id, authority.comma_device_id, forged["key_id"]),
ec.ECDSA(hashes.SHA256())))
with self.assertRaises(AuthorizationError):
local_auth_v2_daemon.cloud_revoke(json.dumps(forged))
local_auth_daemon.cloud_revoke(json.dumps(forged))
self.assertEqual(1, len(self.registry()["grants"]))
def test_a_proof_from_another_key_cannot_remove_a_grant(self):
@@ -209,23 +209,23 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
other = ec.generate_private_key(ec.SECP256R1())
# A key that is not enrolled has no grant to drop, so this is a no-op — never a way to
# remove someone else's phone.
result = local_auth_v2_daemon.cloud_revoke(
self.revocation(local_auth_v2_daemon.get_authority(), app_key=other))
result = local_auth_daemon.cloud_revoke(
self.revocation(local_auth_daemon.get_authority(), app_key=other))
self.assertEqual(key_id(other.public_key()), result["app_id"])
self.assertEqual(1, len(self.registry()["grants"]))
def test_cloud_revoke_requires_the_right_device(self):
self.enrolled()
payload = json.loads(self.revocation(local_auth_v2_daemon.get_authority()))
payload = json.loads(self.revocation(local_auth_daemon.get_authority()))
payload["comma_device_id"] = "some-other-car"
with self.assertRaises(AuthorizationError):
local_auth_v2_daemon.cloud_revoke(json.dumps(payload))
local_auth_daemon.cloud_revoke(json.dumps(payload))
self.assertEqual(1, len(self.registry()["grants"]))
def test_the_revoke_adapter_requires_cloud_origin(self):
"""Same provenance rule as enrollment: an ordinary local RPC must never be able to reach it."""
self.enrolled()
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
with self.assertRaises(AuthorizationError):
authority.revoke_by_self_proof(self.revocation(authority), Origin.LOCAL)
self.assertEqual(1, len(self.registry()["grants"]))
@@ -237,33 +237,33 @@ class TestLocalAuthV2Daemon(OpenpilotTestCase):
undeclared key used to take sunnylinkd (and the cloud link with it) down.
"""
declared = PARAMS_KEYS_H.read_text()
for key in (local_auth_v2_daemon.V2_REGISTRY_KEY, local_auth_v2_daemon.V2_META_KEY,
local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, local_auth_v2_daemon.PAIRING_QR_V2_KEY,
local_auth_v2_daemon.REVOKE_V2_KEY, local_auth_v2_daemon.REVOKE_ALL_V2_KEY):
for key in (local_auth_daemon.LOCAL_APPS_KEY, local_auth_daemon.LOCAL_APPS_META_KEY,
local_auth_daemon.PAIRING_REQUEST_KEY, local_auth_daemon.PAIRING_QR_KEY,
local_auth_daemon.REVOKE_KEY, local_auth_daemon.REVOKE_ALL_KEY):
self.assertTrue(f'"{key}"' in declared)
def test_read_registry_ignores_a_value_that_is_not_the_registry(self):
self.store.values[local_auth_v2_daemon.V2_REGISTRY_KEY] = "not a registry"
self.assertIsNone(local_auth_v2_daemon._read_registry())
self.assertEqual([], local_auth_v2_daemon.v2_grants())
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = "not a registry"
self.assertIsNone(local_auth_daemon._read_registry())
self.assertEqual([], local_auth_daemon.grants())
def test_authority_fault_fails_closed_without_raising(self):
"""A broken trust store must never propagate out of get_authority(): sunnylinkd calls it
from its connection loop, where an exception ends the process."""
self.store.fail_reads = True
self.assertIsNone(local_auth_v2_daemon.get_authority())
self.assertEqual([], local_auth_v2_daemon.v2_grants())
self.assertFalse(local_auth_v2_daemon.revoke_local_app_v2("anything"))
self.assertIsNone(local_auth_daemon.get_authority())
self.assertEqual([], local_auth_daemon.grants())
self.assertFalse(local_auth_daemon.revoke_local_app("anything"))
with self.assertRaises(AuthorizationError):
local_auth_v2_daemon.cloud_enroll("{}")
local_auth_daemon.cloud_enroll("{}")
def test_no_identity_key_fails_closed(self):
with mock.patch.object(local_auth_v2_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
local_auth_v2_daemon.reset_authority()
self.assertIsNone(local_auth_v2_daemon.get_authority())
self.assertIsNone(local_auth_v2_daemon.arm_local_pairing_v2())
with mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
local_auth_daemon.reset_authority()
self.assertIsNone(local_auth_daemon.get_authority())
self.assertIsNone(local_auth_daemon.arm_local_pairing())
with self.assertRaises(AuthorizationError):
local_auth_v2_daemon.cloud_enroll("{}")
local_auth_daemon.cloud_enroll("{}")
class TestRegistryThroughRealParams(OpenpilotTestCase):
@@ -272,7 +272,7 @@ class TestRegistryThroughRealParams(OpenpilotTestCase):
The double above pins the logic; this proves the actual C library stores and returns the
registry the way the module expects (JSON-typed param: a dict in, the same dict out), which is
where the persistence bug lived. It needs `libparams_c` built from a `params_keys.h` that
declares `SunnylinkLocalAppsV2`.
declares `SunnylinkLocalApps`.
"""
def setup_method(self):
@@ -286,18 +286,18 @@ class TestRegistryThroughRealParams(OpenpilotTestCase):
self.params.put("SunnylinkDongleId", "cloud-device", block=True)
self.params.put("DongleId", "comma-device", block=True)
local_auth_v2_daemon.reset_authority()
self.addCleanup(local_auth_v2_daemon.reset_authority)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
mock.patch.object(local_auth_v2_daemon, "_params", self.params),
mock.patch.object(local_auth_v2_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
mock.patch.object(local_auth_daemon, "_params", self.params),
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
]
for patcher in patchers:
patcher.start()
self.addCleanup(patcher.stop)
def test_grant_survives_a_write_and_reload_through_libparams(self):
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
self.assertIsNotNone(authority)
authority.arm()
nonce = authority.window.nonce
@@ -310,23 +310,23 @@ class TestRegistryThroughRealParams(OpenpilotTestCase):
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
summary = local_auth_v2_daemon.cloud_enroll(json.dumps(data))
local_auth_v2_daemon.confirm_enrollment_tls(summary["app_id"])
summary = local_auth_daemon.cloud_enroll(json.dumps(data))
local_auth_daemon.confirm_enrollment_tls(summary["app_id"])
# A daemon restart is what used to lose every grant.
local_auth_v2_daemon.reset_authority()
grants = local_auth_v2_daemon.v2_grants()
local_auth_daemon.reset_authority()
grants = local_auth_daemon.grants()
self.assertEqual(1, len(grants))
self.assertEqual(summary["app_id"], grants[0]["app_id"])
self.assertEqual("Test phone", grants[0]["app_name"])
self.assertEqual([{"app_id": summary["app_id"], "app_name": "Test phone"}],
local_auth_v2_daemon.read_granted_apps())
local_auth_daemon.read_granted_apps())
# And a revocation persists through the same store.
self.assertTrue(local_auth_v2_daemon.revoke_local_app_v2(summary["app_id"]))
local_auth_v2_daemon.reset_authority()
self.assertEqual([], local_auth_v2_daemon.v2_grants())
self.assertEqual([], local_auth_v2_daemon.read_granted_apps())
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
local_auth_daemon.reset_authority()
self.assertEqual([], local_auth_daemon.grants())
self.assertEqual([], local_auth_daemon.read_granted_apps())
class TestGrantMetadata(OpenpilotTestCase):
@@ -334,46 +334,46 @@ class TestGrantMetadata(OpenpilotTestCase):
def setup_method(self):
self.store = _ParamsStore()
patcher = mock.patch.object(local_auth_v2_daemon, "_params", self.store)
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def test_alias_and_last_seen_round_trip(self):
local_auth_v2_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_v2_daemon.note_grant_seen("key-a")
local_auth_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_daemon.note_grant_seen("key-a")
meta = local_auth_v2_daemon.read_grant_meta()
meta = local_auth_daemon.read_grant_meta()
self.assertEqual("Nayan's phone", meta["key-a"]["alias"])
self.assertIsInstance(meta["key-a"]["last_seen"], float)
def test_a_blank_alias_clears_back_to_the_enrolled_name(self):
local_auth_v2_daemon.set_grant_alias("key-a", "Named")
local_auth_v2_daemon.set_grant_alias("key-a", " ")
self.assertEqual("", local_auth_v2_daemon.read_grant_meta()["key-a"]["alias"])
local_auth_daemon.set_grant_alias("key-a", "Named")
local_auth_daemon.set_grant_alias("key-a", " ")
self.assertEqual("", local_auth_daemon.read_grant_meta()["key-a"]["alias"])
def test_an_oversized_alias_is_refused(self):
with self.assertRaises(AuthorizationError):
local_auth_v2_daemon.set_grant_alias("key-a", "x" * 81)
self.assertEqual({}, local_auth_v2_daemon.read_grant_meta())
local_auth_daemon.set_grant_alias("key-a", "x" * 81)
self.assertEqual({}, local_auth_daemon.read_grant_meta())
def test_metadata_never_grants_anything(self):
# A metadata entry for a key with no grant is inert: the authority is the only judge.
local_auth_v2_daemon.set_grant_alias("un-enrolled-key", "attacker")
self.assertEqual([], local_auth_v2_daemon.v2_grants())
self.assertEqual({"un-enrolled-key": {"alias": "attacker"}}, local_auth_v2_daemon.read_grant_meta())
local_auth_daemon.set_grant_alias("un-enrolled-key", "attacker")
self.assertEqual([], local_auth_daemon.grants())
self.assertEqual({"un-enrolled-key": {"alias": "attacker"}}, local_auth_daemon.read_grant_meta())
def test_unreadable_metadata_never_raises(self):
self.store.values[local_auth_v2_daemon.V2_META_KEY] = "not a metadata document"
self.assertEqual({}, local_auth_v2_daemon.read_grant_meta())
self.store.values[local_auth_daemon.LOCAL_APPS_META_KEY] = "not a metadata document"
self.assertEqual({}, local_auth_daemon.read_grant_meta())
self.store.fail_reads = True
self.assertEqual({}, local_auth_v2_daemon.read_grant_meta())
self.assertEqual({}, local_auth_daemon.read_grant_meta())
# on the session path, so it must swallow the fault rather than break a connection
local_auth_v2_daemon.note_grant_seen("key-a")
local_auth_daemon.note_grant_seen("key-a")
def test_revoking_a_phone_drops_its_metadata(self):
local_auth_v2_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_v2_daemon.clear_grant_meta("key-a")
self.assertEqual({}, local_auth_v2_daemon.read_grant_meta())
local_auth_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_daemon.clear_grant_meta("key-a")
self.assertEqual({}, local_auth_daemon.read_grant_meta())
class TestGrantedAppsView(OpenpilotTestCase):
@@ -384,30 +384,30 @@ class TestGrantedAppsView(OpenpilotTestCase):
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.store = _ParamsStore({"SunnylinkDongleId": "cloud-device", "DongleId": "comma-device"})
local_auth_v2_daemon.reset_authority()
self.addCleanup(local_auth_v2_daemon.reset_authority)
patcher = mock.patch.object(local_auth_v2_daemon, "_params", self.store)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def write_registry(self, grants: list[Any]) -> None:
self.store.values[local_auth_v2_daemon.V2_REGISTRY_KEY] = {"v": 2, "grants": grants}
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = {"v": 2, "grants": grants}
def test_lists_app_id_and_name_without_building_the_authority(self):
self.write_registry([{"app_id": "key-a", "app_name": "Nayan's phone", "public_key": "x", "key_id": "key-a",
"revision": "r"}])
with mock.patch.object(local_auth_v2_daemon, "_build_authority",
with mock.patch.object(local_auth_daemon, "_build_authority",
side_effect=AssertionError("the UI must not load the identity key")):
rows = local_auth_v2_daemon.read_granted_apps()
rows = local_auth_daemon.read_granted_apps()
self.assertEqual([{"app_id": "key-a", "app_name": "Nayan's phone"}], rows)
def test_skips_entries_that_cannot_be_displayed(self):
self.write_registry([{"app_id": "key-a"}, "junk", {"app_name": "no id"}, {"app_id": "", "app_name": "blank"},
{"app_id": "key-b", "app_name": "B"}])
self.assertEqual([{"app_id": "key-b", "app_name": "B"}], local_auth_v2_daemon.read_granted_apps())
self.assertEqual([{"app_id": "key-b", "app_name": "B"}], local_auth_daemon.read_granted_apps())
def test_no_registry_is_an_empty_list(self):
self.assertEqual([], local_auth_v2_daemon.read_granted_apps())
self.assertEqual([], local_auth_daemon.read_granted_apps())
class TestRevokeRequests(OpenpilotTestCase):
@@ -415,65 +415,65 @@ class TestRevokeRequests(OpenpilotTestCase):
def setup_method(self):
self.store = _ParamsStore()
patcher = mock.patch.object(local_auth_v2_daemon, "_params", self.store)
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def test_request_and_service_round_trip(self):
revoked: list[str] = []
with mock.patch.object(local_auth_v2_daemon, "revoke_local_app_v2",
with mock.patch.object(local_auth_daemon, "revoke_local_app",
side_effect=lambda key: revoked.append(key) or True):
local_auth_v2_daemon.request_revoke_v2("key-a")
self.assertEqual("key-a", self.store.values[local_auth_v2_daemon.REVOKE_V2_KEY])
self.assertTrue(local_auth_v2_daemon.service_revoke_requests())
local_auth_daemon.request_revoke("key-a")
self.assertEqual("key-a", self.store.values[local_auth_daemon.REVOKE_KEY])
self.assertTrue(local_auth_daemon.service_revoke_requests())
self.assertEqual(["key-a"], revoked)
# The request is consumed, so it cannot be applied twice on a later tick.
self.assertNotIn(local_auth_v2_daemon.REVOKE_V2_KEY, self.store.values)
self.assertFalse(local_auth_v2_daemon.service_revoke_requests())
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_a_failed_revoke_still_clears_the_request(self):
with mock.patch.object(local_auth_v2_daemon, "revoke_local_app_v2", side_effect=RuntimeError("boom")):
local_auth_v2_daemon.request_revoke_v2("key-a")
self.assertFalse(local_auth_v2_daemon.service_revoke_requests())
self.assertNotIn(local_auth_v2_daemon.REVOKE_V2_KEY, self.store.values)
with mock.patch.object(local_auth_daemon, "revoke_local_app", side_effect=RuntimeError("boom")):
local_auth_daemon.request_revoke("key-a")
self.assertFalse(local_auth_daemon.service_revoke_requests())
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
def test_unreadable_request_never_raises(self):
self.store.values[local_auth_v2_daemon.REVOKE_V2_KEY] = "key-a"
self.store.values[local_auth_daemon.REVOKE_KEY] = "key-a"
self.store.fail_reads = True
self.assertFalse(local_auth_v2_daemon.service_revoke_requests())
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_blank_requests_are_ignored(self):
self.assertFalse(local_auth_v2_daemon.request_revoke_v2(""))
self.assertFalse(local_auth_daemon.request_revoke(""))
self.assertEqual({}, self.store.values)
def test_revoke_all_request_clears_every_grant_once(self):
cleared: list[int] = []
with mock.patch.object(local_auth_v2_daemon, "revoke_all_v2", side_effect=lambda: cleared.append(1) or 3):
local_auth_v2_daemon.request_revoke_all_v2()
self.assertTrue(self.store.values[local_auth_v2_daemon.REVOKE_ALL_V2_KEY])
self.assertTrue(local_auth_v2_daemon.service_revoke_requests())
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=lambda: cleared.append(1) or 3):
local_auth_daemon.request_revoke_all()
self.assertTrue(self.store.values[local_auth_daemon.REVOKE_ALL_KEY])
self.assertTrue(local_auth_daemon.service_revoke_requests())
# Consumed, so a later tick does not revoke a phone the user pairs afterwards.
self.assertNotIn(local_auth_v2_daemon.REVOKE_ALL_V2_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
self.assertEqual(1, len(cleared))
self.assertFalse(local_auth_v2_daemon.service_revoke_requests())
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_a_failed_revoke_all_still_clears_the_request(self):
with mock.patch.object(local_auth_v2_daemon, "revoke_all_v2", side_effect=RuntimeError("boom")):
local_auth_v2_daemon.request_revoke_all_v2()
self.assertFalse(local_auth_v2_daemon.service_revoke_requests())
self.assertNotIn(local_auth_v2_daemon.REVOKE_ALL_V2_KEY, self.store.values)
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=RuntimeError("boom")):
local_auth_daemon.request_revoke_all()
self.assertFalse(local_auth_daemon.service_revoke_requests())
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
def test_revoke_all_takes_precedence_over_a_single_request(self):
calls: list[str] = []
with mock.patch.object(local_auth_v2_daemon, "revoke_all_v2", side_effect=lambda: calls.append("all") or 2), \
mock.patch.object(local_auth_v2_daemon, "revoke_local_app_v2", side_effect=lambda key: calls.append(key) or True):
local_auth_v2_daemon.request_revoke_v2("key-a")
local_auth_v2_daemon.request_revoke_all_v2()
local_auth_v2_daemon.service_revoke_requests()
with mock.patch.object(local_auth_daemon, "revoke_all", side_effect=lambda: calls.append("all") or 2), \
mock.patch.object(local_auth_daemon, "revoke_local_app", side_effect=lambda key: calls.append(key) or True):
local_auth_daemon.request_revoke("key-a")
local_auth_daemon.request_revoke_all()
local_auth_daemon.service_revoke_requests()
# One action, and no leftover request that could revoke a phone paired afterwards.
self.assertEqual(["all"], calls)
self.assertNotIn(local_auth_v2_daemon.REVOKE_V2_KEY, self.store.values)
self.assertNotIn(local_auth_v2_daemon.REVOKE_ALL_V2_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.REVOKE_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
class TestRevokeAllThroughTheAuthority(OpenpilotTestCase):
@@ -485,7 +485,7 @@ class TestRevokeAllThroughTheAuthority(OpenpilotTestCase):
def persist(registry: dict) -> None:
self.writes.append(registry)
self.store.values[local_auth_v2_daemon.V2_REGISTRY_KEY] = registry
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = registry
self.authority = LocalAuthority(
cloud_device_id="cloud-device", comma_device_id="comma-device",
@@ -510,17 +510,17 @@ class TestRevokeAllThroughTheAuthority(OpenpilotTestCase):
class TestPairingWindowParams(OpenpilotTestCase):
def setup_method(self):
self.store = _ParamsStore()
patcher = mock.patch.object(local_auth_v2_daemon, "_params", self.store)
patcher = mock.patch.object(local_auth_daemon, "_params", self.store)
patcher.start()
self.addCleanup(patcher.stop)
def test_clear_pairing_params_does_not_touch_the_authority(self):
"""The UI's cancel is params-only: that process has no window to cancel, and building an
authority there would load the device identity key for nothing."""
self.store.values[local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY] = True
self.store.values[local_auth_v2_daemon.PAIRING_QR_V2_KEY] = "qr"
with mock.patch.object(local_auth_v2_daemon, "get_authority",
self.store.values[local_auth_daemon.PAIRING_REQUEST_KEY] = True
self.store.values[local_auth_daemon.PAIRING_QR_KEY] = "qr"
with mock.patch.object(local_auth_daemon, "get_authority",
side_effect=AssertionError("must not build an authority")):
local_auth_v2_daemon.clear_pairing_params(cast(Params, self.store))
self.assertNotIn(local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, self.store.values)
self.assertNotIn(local_auth_v2_daemon.PAIRING_QR_V2_KEY, self.store.values)
local_auth_daemon.clear_pairing_params(cast(Params, self.store))
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.PAIRING_QR_KEY, self.store.values)
@@ -6,8 +6,8 @@ from cryptography.hazmat.primitives.asymmetric import ec
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena import local_auth_v2_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import decode_qr, encode64
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import decode_qr, encode64
class _FakeParams:
@@ -39,7 +39,7 @@ class _FakeParams:
self.removed.append(key)
class TestLocalAuthV2PairingWindow(OpenpilotTestCase):
class TestLocalAuthPairingWindow(OpenpilotTestCase):
"""The UI (another process) arms through Params; the daemon owns the authority window."""
def setup_method(self):
@@ -49,11 +49,11 @@ class TestLocalAuthV2PairingWindow(OpenpilotTestCase):
self.store = _FakeParams()
self.params = cast(Params, self.store)
local_auth_v2_daemon.reset_authority()
self.addCleanup(local_auth_v2_daemon.reset_authority)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
mock.patch.object(local_auth_v2_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
mock.patch.object(local_auth_v2_daemon, "_read_registry", return_value=None),
mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=("ES256", self.private_pem, "pub")),
mock.patch.object(local_auth_daemon, "_read_registry", return_value=None),
]
for patcher in patchers:
patcher.start()
@@ -61,62 +61,62 @@ class TestLocalAuthV2PairingWindow(OpenpilotTestCase):
def request_window(self) -> None:
"""What the device UI does when the user taps the secure-pairing button."""
self.store.put_bool(local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, True)
self.store.put_bool(local_auth_daemon.PAIRING_REQUEST_KEY, True)
def arm(self) -> str:
self.request_window()
qr = local_auth_v2_daemon.service_pairing_window(self.params)
qr = local_auth_daemon.service_pairing_window(self.params)
assert qr is not None
return qr
def published(self) -> str | None:
return local_auth_v2_daemon.read_pairing_qr(self.params)
return local_auth_daemon.read_pairing_qr(self.params)
def test_arming_publishes_the_qr_and_clears_the_request(self):
qr = self.arm()
# The QR is the compact pointer the app scans: which device, which window, for how long.
payload = decode_qr(qr)
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
self.assertEqual(payload.cloud_device_id, authority.cloud_device_id)
self.assertEqual(encode64(payload.session), authority.window.nonce)
self.assertEqual(payload.ttl_s, 120)
self.assertEqual(qr, self.published())
# The request is consumed, so a UI restart cannot arm a second window by accident.
self.assertNotIn(local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertEqual(qr, authority.window.qr)
self.assertIsNone(authority.pending)
def test_an_armed_window_is_not_rearmed_or_rotated(self):
qr = self.arm()
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
window = authority.window
# Ticks while the window is live must keep the SAME QR: the app may already have scanned it.
for _ in range(3):
self.assertEqual(qr, local_auth_v2_daemon.service_pairing_window(self.params))
self.assertEqual(qr, local_auth_daemon.service_pairing_window(self.params))
self.assertIs(window, authority.window)
self.assertEqual(qr, self.published())
def test_a_live_window_outlives_a_missing_request_flag(self):
qr = self.arm()
# The arm request is already consumed on arming; its absence never closes a live window.
self.assertNotIn(local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, self.store.values)
self.assertEqual(qr, local_auth_v2_daemon.service_pairing_window(self.params))
self.assertIsNotNone(local_auth_v2_daemon.get_authority().window)
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertEqual(qr, local_auth_daemon.service_pairing_window(self.params))
self.assertIsNotNone(local_auth_daemon.get_authority().window)
def test_clearing_the_published_qr_is_the_cancel_signal(self):
self.arm()
self.store.remove(local_auth_v2_daemon.PAIRING_QR_V2_KEY)
self.store.remove(local_auth_daemon.PAIRING_QR_KEY)
# The UI cancel only reaches the params; the daemon's tick then cancels the real window.
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(self.params))
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertIsNone(self.published())
self.assertIsNone(local_auth_v2_daemon.get_authority().window)
self.assertIsNone(local_auth_daemon.get_authority().window)
def test_an_expired_window_is_hidden_but_its_session_survives_the_tick(self):
self.arm()
authority = local_auth_v2_daemon.get_authority()
authority = local_auth_daemon.get_authority()
window = authority.window
with mock.patch.object(authority, "clock", lambda: window.deadline + 1):
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(self.params))
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertIsNone(self.published())
# The code is off the screen, so it can no longer be scanned — but an enrollment for it may
# still be travelling through the cloud, so the session outlives the displayed window. Only
@@ -126,38 +126,38 @@ class TestLocalAuthV2PairingWindow(OpenpilotTestCase):
self.assertIsNone(authority.pending)
# Past the delivery grace the session is forgotten too.
with mock.patch.object(authority, "clock", lambda: window.usable_until + 1):
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(self.params))
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertEqual(authority.recent, [])
def test_a_new_request_after_a_closed_window_arms_a_fresh_qr(self):
first = self.arm()
self.store.remove(local_auth_v2_daemon.PAIRING_QR_V2_KEY)
local_auth_v2_daemon.service_pairing_window(self.params)
self.store.remove(local_auth_daemon.PAIRING_QR_KEY)
local_auth_daemon.service_pairing_window(self.params)
second = self.arm()
self.assertNotEqual(first, second)
self.assertEqual(second, self.published())
def test_missing_identity_fails_closed(self):
self.request_window()
with mock.patch.object(local_auth_v2_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
local_auth_v2_daemon.reset_authority()
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(self.params))
with mock.patch.object(local_auth_daemon.BaseApi, "get_key_pair", return_value=(None, None, None)):
local_auth_daemon.reset_authority()
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
# Nothing is published and the stale request is dropped, so the UI cannot show a window
# that could never complete.
self.assertIsNone(self.published())
self.assertNotIn(local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
def test_clear_pairing_window_clears_both_params_and_the_window(self):
self.arm()
local_auth_v2_daemon.clear_pairing_window(self.params)
local_auth_daemon.clear_pairing_window(self.params)
self.assertIsNone(self.published())
self.assertNotIn(local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY, self.store.values)
self.assertIsNone(local_auth_v2_daemon.get_authority().window)
self.assertNotIn(local_auth_daemon.PAIRING_REQUEST_KEY, self.store.values)
self.assertIsNone(local_auth_daemon.get_authority().window)
# A cleared window is idempotent: another tick publishes nothing.
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(self.params))
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
def test_unreadable_params_never_raise(self):
broken = _FakeParams(fail_reads=True)
broken.values[local_auth_v2_daemon.PAIRING_REQUEST_V2_KEY] = True
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(cast(Params, broken)))
self.assertIsNone(local_auth_v2_daemon.service_pairing_window(self.params))
broken.values[local_auth_daemon.PAIRING_REQUEST_KEY] = True
self.assertIsNone(local_auth_daemon.service_pairing_window(cast(Params, broken)))
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
@@ -22,8 +22,8 @@ from websocket import WebSocket
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.system.athena import rpc as rpc_module
from openpilot.sunnypilot.sunnylink.athena import local_auth_v2_daemon, localsunnylinkd, sunnylinkd
from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
from openpilot.sunnypilot.sunnylink.athena import local_auth_daemon, localsunnylinkd, sunnylinkd
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AUTHENTICATE_PURPOSE,
AuthorizationError,
AuthorizedSession,
@@ -33,7 +33,7 @@ from openpilot.sunnypilot.sunnylink.athena.local_auth_v2 import (
key_id,
statement,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LOCAL_BEACON_FRESH_S, LocalDiscovery
from openpilot.sunnypilot.sunnylink.athena.local_discovery import LocalDiscovery
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL, REVOKE, cloud_may_write, is_local_param
CHALLENGE = "A" * 43 # base64url of 32 zero bytes: the canonical challenge shape
@@ -136,14 +136,14 @@ class _FakeDiscovery:
class _Daemon(localsunnylinkd.LocalSunnylinkd):
"""A local daemon with scripted trust state, so target selection is testable in isolation."""
def __init__(self, endpoints=(), v2=False, discovery=None, **params):
def __init__(self, endpoints=(), enrolled=True, discovery=None, **params):
fake_discovery = discovery if discovery is not None else _FakeDiscovery(endpoints)
super().__init__(params=cast(Params, _FakeParams(**params)),
discovery=cast(LocalDiscovery, fake_discovery))
self._v2 = v2
self._enrolled = enrolled
def v2_auth_active(self):
return self._v2
def enrolled(self):
return self._enrolled
def _challenge(phone_key_id: str) -> str:
@@ -176,37 +176,37 @@ class TestLocalTargets(OpenpilotTestCase):
self.assertIsNone(localsunnylinkd.pinned_endpoint("wss://10.0.0.7:8443"))
self.assertIsNone(localsunnylinkd.pinned_endpoint("ws://10.0.0.7"))
def test_v2_owns_local_trust_so_only_pinned_dials_happen(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"], v2=True)
self.assertEqual(("wss://10.0.0.7:8443", localsunnylinkd.TARGET_V2), daemon.pick_target())
def test_an_enrolled_phone_is_dialed_over_pinned_tls(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
self.assertEqual("wss://10.0.0.7:8443", daemon.pick_target())
daemon.backoffs["wss://10.0.0.7:8443"] = time.monotonic() + 60
self.assertEqual((None, localsunnylinkd.TARGET_V2), daemon.pick_target())
self.assertIsNone(daemon.pick_target())
def test_a_failed_v2_dial_backs_off_for_beacon_intervals_only(self):
def test_a_failed_dial_backs_off_for_a_tick_then_ramps(self):
"""A phone that keeps announcing while it refuses to serve ramps up, and no further."""
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"], v2=True)
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
endpoint = "wss://10.0.0.7:8443"
with mock.patch.object(daemon, "dial_v2", side_effect=AuthorizationError("refused")):
daemon.serve_v2(endpoint, None)
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
daemon.serve(endpoint, None)
first = daemon.backoffs[endpoint] - time.monotonic()
for _ in range(8):
daemon.serve_v2(endpoint, None)
daemon.serve(endpoint, None)
last = daemon.backoffs[endpoint] - time.monotonic()
# The app is a foreground-only server: one missed dial is worth a beacon interval, not the
# five minutes a legacy endpoint gets.
# five-minute cloud reconnect ceiling.
self.assertLess(first, localsunnylinkd.ENDPOINT_BACKOFF_S)
self.assertGreater(first, 0)
self.assertLessEqual(last, localsunnylinkd.V2_ENDPOINT_BACKOFF_MAX_S)
self.assertEqual(9, daemon.v2_failures[endpoint])
self.assertLessEqual(last, localsunnylinkd.ENDPOINT_BACKOFF_MAX_S)
self.assertEqual(9, daemon.failures[endpoint])
def test_the_first_failed_dial_waits_only_one_tick(self):
"""One missed dial is worth a tick, not a beacon interval: nothing else re-dials before then."""
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"], v2=True)
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
endpoint = "wss://10.0.0.7:8443"
with mock.patch.object(daemon, "dial_v2", side_effect=AuthorizationError("refused")):
daemon.serve_v2(endpoint, None)
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
daemon.serve(endpoint, None)
delay = daemon.backoffs[endpoint] - time.monotonic()
self.assertGreater(delay, 0)
@@ -221,37 +221,37 @@ class TestLocalTargets(OpenpilotTestCase):
the very next beacon is an immediate attempt rather than the rest of a penalty.
"""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"], recent=[])
daemon = _Daemon(discovery=discovery, v2=True)
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
daemon.v2_failures[endpoint] = 5
daemon.failures[endpoint] = 5
self.assertEqual((endpoint, localsunnylinkd.TARGET_V2), daemon.pick_target())
self.assertEqual(endpoint, daemon.pick_target())
self.assertNotIn(endpoint, daemon.backoffs)
self.assertNotIn(endpoint, daemon.v2_failures)
self.assertNotIn(endpoint, daemon.failures)
def test_a_phone_still_announcing_keeps_its_ramp(self):
"""The short window must not reset a ramp for a phone that is right there and refusing to serve."""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"], recent=["ws://10.0.0.7:8443"])
daemon = _Daemon(discovery=discovery, v2=True)
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
self.assertEqual((None, localsunnylinkd.TARGET_V2), daemon.pick_target())
self.assertIsNone(daemon.pick_target())
self.assertIn(endpoint, daemon.backoffs)
def test_a_served_session_clears_the_backoff_ramp(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"], v2=True)
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 60
daemon.v2_failures[endpoint] = 3
daemon.failures[endpoint] = 3
with mock.patch.object(daemon, "dial_v2", lambda endpoint, exit_event: None), \
with mock.patch.object(daemon, "dial", lambda endpoint, exit_event: None), \
mock.patch.object(localsunnylinkd, "TICK_INTERVAL_S", 0):
daemon.serve_v2(endpoint, None)
daemon.serve(endpoint, None)
self.assertNotIn(endpoint, daemon.backoffs)
self.assertNotIn(endpoint, daemon.v2_failures)
self.assertNotIn(endpoint, daemon.failures)
def test_a_phone_that_stopped_announcing_forgets_its_backoff(self):
"""Closing the app must not cost cloud-only service after it is reopened.
@@ -262,44 +262,25 @@ class TestLocalTargets(OpenpilotTestCase):
cloud for the rest of the backoff.
"""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"])
daemon = _Daemon(discovery=discovery, v2=True)
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
daemon.v2_failures[endpoint] = 1
daemon.failures[endpoint] = 1
# The app was closed: it announces nothing, so the failure outlives its reason.
discovery._endpoints = []
self.assertEqual((None, localsunnylinkd.TARGET_V2), daemon.pick_target())
self.assertIsNone(daemon.pick_target())
self.assertNotIn(endpoint, daemon.backoffs)
self.assertNotIn(endpoint, daemon.v2_failures)
self.assertNotIn(endpoint, daemon.failures)
# The app is opened again: the device dials it now instead of waiting the record out.
discovery._endpoints = ["ws://10.0.0.7:8443"]
self.assertEqual((endpoint, localsunnylinkd.TARGET_V2), daemon.pick_target())
self.assertEqual(endpoint, daemon.pick_target())
def test_a_pending_enrollment_is_dialed_even_before_any_grant(self):
daemon = _Daemon(endpoints=["ws://10.0.0.7:8443"])
with mock.patch.object(local_auth_v2_daemon, "pending_enrollment", lambda: {"app_id": "pending"}):
self.assertEqual(("wss://10.0.0.7:8443", localsunnylinkd.TARGET_V2), daemon.pick_target())
def test_the_legacy_pairing_window_targets_the_discovered_app(self):
discovery = _FakeDiscovery()
discovery.latest = "ws://10.0.0.7:8443"
discovery.seen = 0.5
daemon = _Daemon(discovery=discovery)
with mock.patch.object(localsunnylinkd, "pairing_requested", lambda: True), \
mock.patch.object(localsunnylinkd, "get_local_apps", list):
self.assertEqual(("ws://10.0.0.7:8443", localsunnylinkd.TARGET_PAIRING), daemon.pick_target())
discovery.seen = LOCAL_BEACON_FRESH_S + 1
self.assertEqual((None, localsunnylinkd.TARGET_PAIRING), daemon.pick_target())
def test_a_paired_legacy_app_is_dialed_when_nothing_else_is_available(self):
daemon = _Daemon()
app = SimpleNamespace(app_id="phone-1", endpoint="ws://10.0.0.7:8443")
with mock.patch.object(localsunnylinkd, "get_local_apps", lambda: [app]):
self.assertEqual(("ws://10.0.0.7:8443", localsunnylinkd.TARGET_LEGACY), daemon.pick_target())
daemon.backoffs["ws://10.0.0.7:8443"] = time.monotonic() + 60
self.assertEqual((None, localsunnylinkd.TARGET_LEGACY), daemon.pick_target())
with mock.patch.object(local_auth_daemon, "pending_enrollment", lambda: {"app_id": "pending"}):
self.assertEqual("wss://10.0.0.7:8443", daemon.pick_target())
def test_the_local_switch_gates_the_process(self):
running = _Daemon(SunnylinkEnabled=True, SunnylinkLocalEnabled=True)
@@ -311,17 +292,6 @@ class TestLocalTargets(OpenpilotTestCase):
sunnylink_off = _Daemon(SunnylinkEnabled=False, SunnylinkLocalEnabled=True)
self.assertFalse(sunnylink_off.serviceable())
def test_a_legacy_session_is_preempted_when_v2_takes_over(self):
daemon = _Daemon(v2=True)
self.assertTrue(daemon.preempted())
daemon = _Daemon(v2=False)
with mock.patch.object(localsunnylinkd, "pairing_requested", lambda: True):
self.assertTrue(daemon.preempted())
with mock.patch.object(localsunnylinkd, "pairing_requested", lambda: False):
self.assertFalse(daemon.preempted())
class TestLocalSurface(OpenpilotTestCase):
"""What a local peer may reach, and what it may never touch."""
@@ -331,60 +301,28 @@ class TestLocalSurface(OpenpilotTestCase):
saved: list[str] = []
with mock.patch.object(sunnylinkd, "save_param_from_base64_encoded_string",
lambda key, value, compression=False: saved.append(key)):
daemon.saveParams({ENROLL: ENROLLMENT_TRANSPORT_VALUE, "SunnylinkLocalAppsV2": "{}", "SpeedLimitOffset": "5"})
daemon.saveParams({ENROLL: ENROLLMENT_TRANSPORT_VALUE, "SunnylinkLocalApps": "{}", "SpeedLimitOffset": "5"})
self.assertEqual(["SpeedLimitOffset"], saved)
def test_pairLocalApp_is_refused_once_v2_owns_trust(self):
daemon = _Daemon(v2=True)
self.assertEqual({"success": False, "error": "legacy pairing disabled"}, daemon.pairLocalApp("123456"))
def test_pairLocalApp_requires_a_live_local_app_and_the_right_code(self):
daemon = _Daemon()
self.assertEqual({"success": False, "error": "not connected to a local app"}, daemon.pairLocalApp("123456"))
daemon.active_endpoint = "ws://10.0.0.7:8443"
with mock.patch.object(localsunnylinkd, "verify_pairing_code", lambda code: False):
self.assertEqual({"success": False, "error": "invalid code"}, daemon.pairLocalApp("123456"))
def test_pairLocalApp_adds_the_app_to_the_legacy_registry(self):
daemon = _Daemon()
daemon.active_endpoint = "ws://10.0.0.7:8443"
added = []
with mock.patch.object(localsunnylinkd, "verify_pairing_code", lambda code: code == "123456"), \
mock.patch.object(localsunnylinkd, "add_local_app", added.append), \
mock.patch.object(localsunnylinkd, "clear_pairing_request") as cleared:
self.assertEqual({"success": True}, daemon.pairLocalApp("123456", app_id="phone-1", app_name="Test phone"))
self.assertEqual(1, len(added))
self.assertEqual("ws://10.0.0.7:8443", added[0].endpoint)
cleared.assert_called_once()
def test_a_pairing_session_serves_nothing_without_an_armed_window(self):
daemon = _Daemon()
ws = _FakeWs([rpc_module.dumps_call("pairLocalApp", {"code": "000000"}, 1)])
with mock.patch.object(localsunnylinkd, "pairing_requested", lambda: False):
self.assertFalse(daemon.pairing_session(cast(WebSocket, ws)))
self.assertEqual([], ws.sent)
def test_v2_session_methods_are_local_only_and_gated(self):
def test_session_methods_are_local_only_and_gated(self):
session = _FakeSession()
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
self.assertTrue("getParams" in methods)
self.assertTrue("saveParams" in methods)
self.assertTrue("unpairLocalApp" in methods)
self.assertNotIn("pairLocalApp", methods)
self.assertNotIn("startLocalProxy", methods)
self.assertNotIn("echo", methods)
self.assertEqual(methods["getParams"](params_keys=[]), {"ok": True})
self.assertEqual(session.calls, ["getParams"])
def test_a_v2_session_serves_local_methods_and_refuses_the_rest(self):
def test_a_session_serves_local_methods_and_refuses_the_rest(self):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
session = _FakeSession()
methods = daemon.session_methods(cast(AuthorizedSession, session))
ws = _FakeWs([
rpc_module.dumps_call("getParams", {"params_keys": []}, 1),
rpc_module.dumps_call("pairLocalApp", {"code": "000000"}, 2),
rpc_module.dumps_call("startLocalProxy", {"port": 8022}, 2),
])
daemon.serve_rpc(cast(WebSocket, ws), methods)
self.assertEqual(session.calls, ["getParams"])
@@ -394,13 +332,6 @@ class TestLocalSurface(OpenpilotTestCase):
self.assertEqual(2, second["id"])
self.assertEqual(rpc_module.METHOD_NOT_FOUND, second["error"]["code"])
def test_serving_stops_as_soon_as_the_session_is_preempted(self):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
ws = _FakeWs([rpc_module.dumps_call("getParams", {"params_keys": []}, 1)])
daemon.serve_rpc(cast(WebSocket, ws), {"getParams": lambda **_: {}}, should_stop=lambda: True)
self.assertEqual([], ws.sent)
class TestLocalDial(OpenpilotTestCase):
"""The pinned TLS dial and the challenge the phone must answer."""
@@ -446,30 +377,29 @@ class TestLocalDial(OpenpilotTestCase):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with mock.patch.object(authority, "session", lambda g, peer: session, create=True), \
mock.patch.object(local_auth_v2_daemon, "get_authority", lambda: authority), \
mock.patch.object(local_auth_v2_daemon, "confirm_enrollment_tls", confirm), \
mock.patch.object(local_auth_daemon, "get_authority", lambda: authority), \
mock.patch.object(local_auth_daemon, "confirm_enrollment_tls", confirm), \
mock.patch.object(localsunnylinkd, "connect_pinned", lambda endpoint, key_id, timeout_s=10: ws):
daemon.dial_v2("wss://10.0.0.7:8443", None)
daemon.dial("wss://10.0.0.7:8443", None)
self.assertEqual(["pending-key"], confirmed)
self.assertTrue(session.closed)
self.assertTrue(ws.closed)
self.assertIsNone(daemon.active_endpoint)
self.assertEqual("pending-key", json.loads(ws.sent[0])["phone_key_id"])
def test_a_dial_without_an_enrolled_peer_never_serves(self):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
authority = SimpleNamespace(lock=threading.RLock(), pending=None, grants={})
with mock.patch.object(local_auth_v2_daemon, "get_authority", lambda: authority), \
with mock.patch.object(local_auth_daemon, "get_authority", lambda: authority), \
mock.patch.object(localsunnylinkd, "connect_pinned",
mock.Mock(side_effect=AuthorizationError("wrong TLS peer"))):
with self.assertRaises(AuthorizationError):
daemon.dial_v2("wss://10.0.0.7:8443", None)
daemon.dial("wss://10.0.0.7:8443", None)
def test_a_rename_only_ever_renames_the_sessions_own_grant(self):
calls: list[tuple[str, str]] = []
session = _CallingSession("grant-key")
with mock.patch.object(local_auth_v2_daemon, "set_grant_alias",
with mock.patch.object(local_auth_daemon, "set_grant_alias",
lambda key_id, alias: calls.append((key_id, alias)) or True):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
@@ -482,7 +412,7 @@ class TestLocalDial(OpenpilotTestCase):
raise AuthorizationError("invalid alias")
session = _CallingSession("grant-key")
with mock.patch.object(local_auth_v2_daemon, "set_grant_alias", refuse):
with mock.patch.object(local_auth_daemon, "set_grant_alias", refuse):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
result = methods["updateLocalAppAlias"](app_id="grant-key", alias="bad\u0000alias")
@@ -491,7 +421,7 @@ class TestLocalDial(OpenpilotTestCase):
def test_unpair_revokes_the_sessions_own_grant(self):
revoked: list[str] = []
session = _CallingSession("grant-key")
with mock.patch.object(local_auth_v2_daemon, "revoke_local_app_v2",
with mock.patch.object(local_auth_daemon, "revoke_local_app",
lambda key_id: revoked.append(key_id) or True):
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
methods = daemon.session_methods(cast(AuthorizedSession, session))
@@ -509,13 +439,13 @@ class TestCloudCommandClaim(OpenpilotTestCase):
self.persisted: list[dict] = []
self.authority = LocalAuthority("cloud-device", "comma-device", self.device_key, persist=self.persisted.append)
self.authority.arm()
patcher = mock.patch.object(local_auth_v2_daemon, "get_authority", lambda: self.authority)
patcher = mock.patch.object(local_auth_daemon, "get_authority", lambda: self.authority)
patcher.start()
self.addCleanup(patcher.stop)
def test_a_valid_enrollment_is_staged_and_the_command_is_claimed_once(self):
params = _FakeParams({ENROLL: _enrollment_json(self.authority, self.app_key)})
local_auth_v2_daemon.claim_cloud_commands(cast(Params, params))
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNotNone(self.authority.pending)
self.assertEqual("Test phone", self.authority.pending.app_name)
@@ -526,7 +456,7 @@ class TestCloudCommandClaim(OpenpilotTestCase):
def test_a_bad_payload_is_refused_and_still_cleared(self):
params = _FakeParams({ENROLL: "not an enrollment"})
local_auth_v2_daemon.claim_cloud_commands(cast(Params, params))
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNone(self.authority.pending)
self.assertNotIn(ENROLL, params)
@@ -540,14 +470,14 @@ class TestCloudCommandClaim(OpenpilotTestCase):
"comma_device_id": self.authority.comma_device_id, "key_id": committed.key_id,
"signature": encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))})
params = _FakeParams({REVOKE: revocation})
local_auth_v2_daemon.claim_cloud_commands(cast(Params, params))
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertEqual({}, self.authority.grants)
self.assertNotIn(REVOKE, params)
def test_no_command_means_no_work(self):
params = _FakeParams()
local_auth_v2_daemon.claim_cloud_commands(cast(Params, params))
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNone(self.authority.pending)
def test_the_command_round_trips_through_the_real_library(self):
@@ -561,13 +491,13 @@ class TestCloudCommandClaim(OpenpilotTestCase):
if isinstance(payload, bytes):
payload = payload.decode()
local_auth_v2_daemon.claim_cloud_commands(params)
local_auth_daemon.claim_cloud_commands(params)
self.assertIsNotNone(self.authority.pending)
self.assertEqual(b"", params.get(ENROLL) or b"")
def test_local_param_policy(self):
for key in ("SunnylinkLocalAppsV2", "SunnylinkLocalPairingCode", "SunnylinkLocalNewTrustKey", "_sec_anything"):
for key in ("SunnylinkLocalApps", "SunnylinkLocalPairingQr", "SunnylinkLocalNewTrustKey", "_sec_anything"):
self.assertTrue(is_local_param(key))
self.assertFalse(is_local_param("SpeedLimitOffset"))
@@ -575,30 +505,30 @@ class TestCloudCommandClaim(OpenpilotTestCase):
self.assertTrue(cloud_may_write(ENROLL))
self.assertTrue(cloud_may_write(REVOKE))
self.assertTrue(cloud_may_write("SpeedLimitOffset"))
for key in ("SunnylinkLocalAppsV2", "SunnylinkLocalPairingRequestV2", "SunnylinkLocalRevokeV2", "_sec_x"):
for key in ("SunnylinkLocalApps", "SunnylinkLocalPairingRequest", "SunnylinkLocalRevoke", "_sec_x"):
self.assertFalse(cloud_may_write(key))
class TestLocalDiscovery(OpenpilotTestCase):
"""Beacon handling: the status line the settings screen shows while pairing."""
def test_fresh_beacons_are_recorded_for_v2(self):
def test_fresh_beacons_are_recorded(self):
discovery = LocalDiscovery(write_interval_s=0)
discovery._handle(b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}', "10.0.0.7")
discovery._handle(b'not a beacon', "10.0.0.8")
self.assertEqual(["ws://10.0.0.7:8443"], discovery.fresh_endpoints())
self.assertEqual([], discovery.fresh_endpoints(max_age_s=-1))
def test_a_v2_window_counts_as_a_live_pairing_window(self):
def test_an_open_window_counts_as_a_live_pairing_window(self):
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
v2_window = [False]
discovery = LocalDiscovery(write_interval_s=0, pairing_window_cb=lambda: v2_window[0])
window = [False]
discovery = LocalDiscovery(write_interval_s=0, pairing_window_cb=lambda: window[0])
beacon = b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}'
discovery._handle(beacon, "10.0.0.7")
self.assertIsNone(latest_discovered_app(discovery.params))
v2_window[0] = True
window[0] = True
discovery._handle(beacon, "10.0.0.7")
discovered = latest_discovered_app(discovery.params)
self.assertIsNotNone(discovered)
@@ -75,24 +75,23 @@ class TestCloudRpcSurface(OpenpilotTestCase):
def test_saveParams_cannot_modify_local_trust(self):
self.daemon.saveParams({
"SunnylinkLocalApps": "attacker registry",
"SunnylinkLocalAppsV2": "attacker registry",
"SunnylinkLocalPairingCode": "attacker code",
"SunnylinkLocalAppsMeta": "attacker metadata",
"SunnylinkLocalPairingRequest": "1",
"SunnylinkLocalPairingQrV2": "attacker qr",
"SunnylinkLocalRevokeV2": "attacker revoke request",
"SunnylinkLocalPairingQr": "attacker qr",
"SunnylinkLocalRevoke": "attacker revoke request",
"SunnylinkLocalFutureKey": "attacker key",
"_sec_SunnylinkLocalFuture": "attacker secret",
})
self.assertEqual(self.saved_params, [])
def test_saveParams_delivers_only_the_two_local_commands(self):
self.daemon.saveParams({ENROLL: "ZW5yb2xs", REVOKE: "cmV2b2tl", "SunnylinkLocalAppsV2": "attacker"})
self.daemon.saveParams({ENROLL: "ZW5yb2xs", REVOKE: "cmV2b2tl", "SunnylinkLocalApps": "attacker"})
self.assertEqual(self.saved_params, [(ENROLL, "ZW5yb2xs", False), (REVOKE, "cmV2b2tl", False)])
def test_getParams_never_exposes_local_parameters(self):
response = self.daemon.getParams(["SunnylinkLocalApps", "SunnylinkLocalAppsV2", "SunnylinkLocalPairingCode",
"SunnylinkLocalPairingRequest", "SunnylinkLocalPairingQrV2",
"SunnylinkLocalRevokeV2", ENROLL, REVOKE])
response = self.daemon.getParams(["SunnylinkLocalApps", "SunnylinkLocalAppsMeta",
"SunnylinkLocalPairingRequest", "SunnylinkLocalPairingQr",
"SunnylinkLocalRevoke", ENROLL, REVOKE])
self.assertEqual(response, {"params": "[]"})
def test_getParams_serves_ordinary_settings(self):
@@ -101,7 +100,7 @@ class TestCloudRpcSurface(OpenpilotTestCase):
def test_getParamsAllKeys_excludes_local_params(self):
keys = self.daemon.getParamsAllKeys()
for key in ("SunnylinkLocalApps", "SunnylinkLocalAppsV2", "SunnylinkLocalPairingCode",
for key in ("SunnylinkLocalApps", "SunnylinkLocalAppsMeta", "SunnylinkLocalPairingQr",
"SunnylinkLocalEnabled", ENROLL, REVOKE):
self.assertNotIn(key, keys)
self.assertTrue("SpeedLimitValueOffset" in keys)
@@ -139,5 +138,5 @@ class TestCloudCommandTransport(OpenpilotTestCase):
def test_the_cloud_may_not_write_any_other_local_param(self):
with mock.patch.object(sunnylinkd, "save_param_from_base64_encoded_string") as save:
self.daemon.saveParams({"SunnylinkLocalAppsV2": base64.b64encode(b"{}").decode("ascii")})
self.daemon.saveParams({"SunnylinkLocalApps": base64.b64encode(b"{}").decode("ascii")})
save.assert_not_called()
+1 -1
View File
@@ -2,7 +2,7 @@ import base64
import gzip
import json
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYLINK_DONGLE_ID
from openpilot.sunnypilot.sunnylink.athena.local_pairing import is_locally_paired
from openpilot.sunnypilot.sunnylink.athena.local_state import is_locally_paired
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.version import is_prebuilt