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