diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 7de4780c1b..56deafcf62 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -235,18 +235,15 @@ inline static std::unordered_map 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}}, diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index c5bbf2a769..46f2281ad2 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -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 diff --git a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py index e96bf6a998..3280a23ac7 100644 --- a/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py +++ b/openpilot/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -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() diff --git a/openpilot/sunnypilot/sunnylink/athena/local_app_rows.py b/openpilot/sunnypilot/sunnylink/athena/local_app_rows.py index cf70c6708b..6e36bc536a 100644 --- a/openpilot/sunnypilot/sunnylink/athena/local_app_rows.py +++ b/openpilot/sunnypilot/sunnylink/athena/local_app_rows.py @@ -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] diff --git a/openpilot/sunnypilot/sunnylink/athena/local_auth_v2.py b/openpilot/sunnypilot/sunnylink/athena/local_auth.py similarity index 98% rename from openpilot/sunnypilot/sunnylink/athena/local_auth_v2.py rename to openpilot/sunnypilot/sunnylink/athena/local_auth.py index f40df346cd..9ec5ac0b3f 100644 --- a/openpilot/sunnypilot/sunnylink/athena/local_auth_v2.py +++ b/openpilot/sunnypilot/sunnylink/athena/local_auth.py @@ -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 diff --git a/openpilot/sunnypilot/sunnylink/athena/local_auth_v2_daemon.py b/openpilot/sunnypilot/sunnylink/athena/local_auth_daemon.py similarity index 76% rename from openpilot/sunnypilot/sunnylink/athena/local_auth_v2_daemon.py rename to openpilot/sunnypilot/sunnylink/athena/local_auth_daemon.py index 52e7c9f780..4e913ffd4a 100644 --- a/openpilot/sunnypilot/sunnylink/athena/local_auth_v2_daemon.py +++ b/openpilot/sunnypilot/sunnylink/athena/local_auth_daemon.py @@ -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 [] diff --git a/openpilot/sunnypilot/sunnylink/athena/local_discovery.py b/openpilot/sunnypilot/sunnylink/athena/local_discovery.py index b3aa71c87b..6db37603ec 100644 --- a/openpilot/sunnypilot/sunnylink/athena/local_discovery.py +++ b/openpilot/sunnypilot/sunnylink/athena/local_discovery.py @@ -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: diff --git a/openpilot/sunnypilot/sunnylink/athena/local_pairing.py b/openpilot/sunnypilot/sunnylink/athena/local_pairing.py deleted file mode 100644 index d5d74a3e42..0000000000 --- a/openpilot/sunnypilot/sunnylink/athena/local_pairing.py +++ /dev/null @@ -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 "" diff --git a/openpilot/sunnypilot/sunnylink/athena/local_params.py b/openpilot/sunnypilot/sunnylink/athena/local_params.py index 938a1d90b0..237d34e187 100644 --- a/openpilot/sunnypilot/sunnylink/athena/local_params.py +++ b/openpilot/sunnypilot/sunnylink/athena/local_params.py @@ -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_") diff --git a/openpilot/sunnypilot/sunnylink/athena/local_state.py b/openpilot/sunnypilot/sunnylink/athena/local_state.py new file mode 100644 index 0000000000..87deba1f0a --- /dev/null +++ b/openpilot/sunnypilot/sunnylink/athena/local_state.py @@ -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}" diff --git a/openpilot/sunnypilot/sunnylink/athena/localsunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/localsunnylinkd.py index fff0ed2926..d1b1468f8b 100755 --- a/openpilot/sunnypilot/sunnylink/athena/localsunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/localsunnylinkd.py @@ -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 diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_app_rows.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_app_rows.py index b3a59f5d6e..a94f773530 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_app_rows.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_app_rows.py @@ -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): diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth.py similarity index 97% rename from openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2.py rename to openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth.py index 41c8b5bb5a..65946a39f2 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth.py @@ -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 diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2_daemon.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_daemon.py similarity index 64% rename from openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2_daemon.py rename to openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_daemon.py index 9ef08110a8..d7a921405e 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2_daemon.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_daemon.py @@ -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) diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2_pairing.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_pairing.py similarity index 64% rename from openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2_pairing.py rename to openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_pairing.py index d3e8ea8d1b..ebf18d17ad 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_v2_pairing.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_local_auth_pairing.py @@ -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)) diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_localsunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_localsunnylinkd.py index 2416f1bb5c..ad5e530546 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_localsunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_localsunnylinkd.py @@ -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) diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py index bcfab10b8a..c6669a63e3 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py @@ -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() diff --git a/openpilot/sunnypilot/sunnylink/utils.py b/openpilot/sunnypilot/sunnylink/utils.py index eb0f3496b0..6a9d3b4133 100644 --- a/openpilot/sunnypilot/sunnylink/utils.py +++ b/openpilot/sunnypilot/sunnylink/utils.py @@ -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