Compare commits

..

14 Commits

Author SHA1 Message Date
nayan c4d01ff050 v2 is the new v1 2026-10-19 19:36:50 -04:00
nayan f5271d88e9 better fallback 2026-10-19 19:04:48 -04:00
nayan 0bbbd2916d Merge remote-tracking branch 'origin/master' into sunnylink-local 2026-10-19 19:04:02 -04:00
nayan 8d4ffffc7e Merge remote-tracking branch 'origin/master' into sunnylink-local 2026-10-12 14:52:22 -04:00
nayan cd835d9982 lesssss 2026-10-08 19:08:18 -04:00
nayan 4285392a15 moreeeeeeeeee 2026-10-08 17:25:03 -04:00
nayan 5e97f2cc46 moreeee 2026-10-08 15:19:49 -04:00
nayan cdbc5f1c1f more 2026-10-08 14:34:31 -04:00
nayan 1e8b418607 more 2026-10-08 14:22:28 -04:00
nayan 75a0535b10 refactor 2026-10-08 13:32:41 -04:00
nayan 5a5a905130 ty 2026-09-11 10:52:59 -04:00
nayan 83658cac21 lint 2026-09-11 10:49:59 -04:00
nayan 0283795fa3 reword 2026-09-11 10:29:50 -04:00
nayan 9c04844adf init local 2026-09-11 10:04:35 -04:00
20 changed files with 4292 additions and 190 deletions
+11
View File
@@ -234,6 +234,17 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"SunnylinkEnabled", {PERSISTENT, BOOL, "1"}},
{"SunnylinkTempFault", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL, "0"}},
{"SunnylinkLocalApps", {PERSISTENT, JSON}},
{"SunnylinkLocalAppsMeta", {PERSISTENT, JSON}},
{"SunnylinkLocalEnabled", {PERSISTENT, BOOL, "1"}},
{"SunnylinkLocalDiscoveredApp", {CLEAR_ON_MANAGER_START, JSON}},
{"SunnylinkLocalPairingRequest", {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}},
{"BackupManager_RestoreVersion", {PERSISTENT, STRING}},
@@ -4,23 +4,48 @@ 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.
"""
import time
import pyray as rl
from functools import partial
from openpilot.cereal import custom
from openpilot.common.params import Params
from openpilot.common.qrcode import make_texture
from openpilot.common.swaglog import cloudlog
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_daemon import (
clear_pairing_params,
read_grant_meta,
read_granted_apps,
read_pairing_qr,
request_revoke,
request_revoke_all,
)
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
MAX_LOCAL_APP_ROWS,
LocalAppRow,
build_local_app_rows,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_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.sunnypilot.widgets.list_view import button_item_sp
from openpilot.system.ui.sunnypilot.widgets.list_view import toggle_item_sp
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
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.button import ButtonStyle, Button, IconButton
from openpilot.system.ui.widgets.confirm_dialog import alert_dialog, ConfirmDialog
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.list_view import dual_button_item
from openpilot.system.ui.widgets.network import NavButton
from openpilot.system.ui.widgets.scroller_tici import Scroller, LineSeparator
from openpilot.common.version import sunnylink_consent_version
# Read-only value colors used by the local-mode rows.
_LOCAL_DISCOVERED_COLOR = rl.Color(170, 170, 170, 255) # grey: no app in sight
_LOCAL_ACTIVE_COLOR = rl.Color(0, 255, 0, 255) # green: discovered / pairing code
class SunnylinkHeader(Widget):
@@ -145,6 +170,7 @@ class SunnylinkLayout(Widget):
self._restore_in_progress = False
self._backup_in_progress = False
self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled")
self._local_enabled = ui_state.params.get_bool("SunnylinkLocalEnabled")
items = self._initialize_items()
self._scroller = Scroller(items, line_separator=False, spacing=0)
@@ -192,6 +218,23 @@ class SunnylinkLayout(Widget):
self._backup_btn.set_button_style(ButtonStyle.NORMAL)
self._restore_btn.set_button_style(ButtonStyle.PRIMARY)
self._local_connections_toggle = toggle_item_sp(
title=tr("Enable local connections"),
description=tr("Let the sunnylink mobile app connect to this device over Wi-Fi. ") +
tr("Turn this off to stop the local daemon and keep sunnylink itself running."),
param="SunnylinkLocalEnabled",
)
self._local_connections_toggle.set_visible(lambda: self._sunnylink_enabled)
self._mobile_app_btn = button_item_sp(
title=tr("Sunnylink Local Connections"),
button_text=tr("CONFIGURE"),
description=tr("Manage the mobile app(s) connected over Wi-Fi: pair a new app ") +
tr("or unpair existing ones."),
callback=self._open_local_apps,
)
self._mobile_app_btn.set_visible(lambda: self._sunnylink_enabled and self._local_enabled)
items = [
SunnylinkHeader(),
LineSeparator(),
@@ -202,9 +245,13 @@ class SunnylinkLayout(Widget):
LineSeparator(),
self._pair_btn,
LineSeparator(),
self._local_connections_toggle,
LineSeparator(),
self._mobile_app_btn,
LineSeparator(),
self._sunnylink_uploader_toggle,
LineSeparator(),
self._sunnylink_backup_restore_buttons
self._sunnylink_backup_restore_buttons,
]
return items
@@ -317,6 +364,8 @@ class SunnylinkLayout(Widget):
gui_app.push_widget(sl_terms_dlg)
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
if not state:
clear_pairing_params()
self._update_description(state)
def _update_description(self, state: bool):
@@ -336,6 +385,7 @@ class SunnylinkLayout(Widget):
def _update_state(self):
super()._update_state()
self._sunnylink_enabled = ui_state.params.get_bool("SunnylinkEnabled")
self._local_enabled = ui_state.params.get_bool("SunnylinkLocalEnabled")
self._sunnylink_toggle.set_right_value(tr("Dongle ID") + ": " + self._get_sunnylink_dongle_id())
self._sunnylink_toggle.action_item.set_enabled(not ui_state.is_onroad())
self._sunnylink_toggle.action_item.set_state(self._sunnylink_enabled)
@@ -352,6 +402,9 @@ class SunnylinkLayout(Widget):
self._pair_btn.action_item.set_text(pair_btn_text)
self._pair_btn.action_item.set_enabled(self._sunnylink_enabled)
def _open_local_apps(self):
gui_app.push_widget(SunnylinkLocalAppLayout())
def _render(self, rect):
self._scroller.render(rect)
@@ -364,3 +417,241 @@ class SunnylinkLayout(Widget):
def hide_event(self):
super().hide_event()
ui_state.sunnylink_state.set_settings_open(False)
class SunnylinkLocalAppLayout(Widget):
def __init__(self):
super().__init__()
self._rows_cache: list[LocalAppRow] = []
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("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_dialog,
)
self._revoke_all_btn = button_item_sp(
title=tr("Unpair all phones"),
button_text=tr("REVOKE ALL"),
description=tr("Revoke every enrolled phone at once. Each phone must pair again ") +
tr("from the sunnylink app to reconnect."),
callback=self._revoke_all,
)
self._revoke_all_btn.set_visible(lambda: self._has_grants)
self._local_app_rows: list[ListItemSP] = []
self._local_app_seps: list[LineSeparator] = []
for i in range(MAX_LOCAL_APP_ROWS):
row = button_item_sp(
title=lambda i=i: self._row_title(i),
button_text=tr("UNPAIR"),
description=lambda i=i: self._row_subtitle(i),
callback=partial(self._unpair_row, i),
)
sep = LineSeparator()
row.set_visible(lambda i=i: self._local_row_visible(i))
sep.set_visible(lambda i=i: self._local_row_visible(i))
self._local_app_rows.append(row)
self._local_app_seps.append(sep)
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)
def _local_row_visible(self, i: int) -> bool:
return i < len(self._rows_cache)
def _row_title(self, i: int) -> str:
return self._rows_cache[i].title if i < len(self._rows_cache) else ""
def _row_subtitle(self, i: int) -> str:
return self._rows_cache[i].subtitle if i < len(self._rows_cache) else ""
def _show_pairing_dialog(self):
gui_app.push_widget(SunnylinkLocalPairingDialog())
def _revoke_all(self):
def on_confirm(_dialog_result: int):
# A request the daemon applies on its next tick.
request_revoke_all()
dialog = ConfirmDialog(
text=tr("Revoke all phones?") + " " + tr("They will need to pair again from the app."),
confirm_text=tr("Revoke all"),
callback=on_confirm,
)
gui_app.push_widget(dialog)
def _unpair_row(self, index: int):
rows = self._rows_cache
if index >= len(rows):
return
row = rows[index]
def on_confirm(_dialog_result: int):
# 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)
dialog = ConfirmDialog(
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,
)
gui_app.push_widget(dialog)
def _update_state(self):
super()._update_state()
grants = read_granted_apps()
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)
self._back_button.render()
content_rect = rl.Rectangle(rect.x, rect.y + self._back_button.rect.height + 40,
rect.width, rect.height - self._back_button.rect.height - 40)
self._scroller.render(content_rect)
def show_event(self):
super().show_event()
self._scroller.show_event()
def hide_event(self):
super().hide_event()
self._scroller.hide_event()
class SunnylinkLocalPairingDialog(Widget):
"""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,
or the daemon's own expiry) closes the window.
"""
# sunnylinkd services the request about once a second; taking longer means it is not running.
ARM_GRACE_S = 5.0
def __init__(self):
super().__init__()
self._params = Params()
self._requested_at = time.monotonic()
self._seen_qr = False
self._qr_string: str | None = None
self._qr_texture: rl.Texture | None = None
self._request_window()
self._close_btn = IconButton(gui_app.texture("icons/close.png", 80, 80))
self._close_btn.set_click_callback(self._cancel)
def _request_window(self) -> None:
try:
self._params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
except Exception:
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.
clear_pairing_params()
gui_app.pop_widget()
def _refresh_qr_texture(self, qr: str | None) -> None:
"""Regenerate the texture only when the published QR actually changed."""
if qr == self._qr_string:
return
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
self._qr_texture = None
self._qr_string = qr
if not qr:
return
try:
self._qr_texture = make_texture(qr)
# The texture is drawn smaller than it is built, and the modules must stay hard-edged for
# a camera to read them.
rl.set_texture_filter(self._qr_texture, rl.TextureFilter.TEXTURE_FILTER_POINT)
except Exception:
cloudlog.exception("sunnylink.local_pairing.qr_texture_failed")
def _update_state(self):
if read_pairing_qr() is not None:
self._seen_qr = True
return
# The daemon clears the published QR when the window ends (enrolled, expired or cancelled).
if self._seen_qr or time.monotonic() - self._requested_at > self.ARM_GRACE_S:
gui_app.pop_widget()
def _render(self, rect) -> int:
rl.clear_background(rl.Color(224, 224, 224, 255))
self._refresh_qr_texture(read_pairing_qr())
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
# Two columns, the layout the cloud pairing dialog uses: the QR takes half the width and the
# full height, which is 900px at this display's 2160x1080 — 31 px per module for the compact
# 29-module pointer, read easily from any scanning distance.
left_width = int(content_rect.width * 0.5 - 15)
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"), 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
qr_size = int(min(right_width, content_rect.height) - 40)
qr_rect = rl.Rectangle(content_rect.x + left_width + 40 + (right_width - qr_size) / 2,
content_rect.y, qr_size, qr_size)
card = rl.Rectangle(qr_rect.x - 10, qr_rect.y - 10, qr_rect.width + 20, qr_rect.height + 20)
rl.draw_rectangle_rounded(card, 0.05, 10, rl.WHITE)
if self._qr_texture is not None:
source = rl.Rectangle(0, 0, self._qr_texture.width, self._qr_texture.height)
rl.draw_texture_pro(self._qr_texture, source, qr_rect, rl.Vector2(0, 0), 0, rl.WHITE)
# Left column: what to do. The QR is the only way to enroll, so what matters here is that the
# steps are readable next to a code that is as large as the panel allows inside the QR's own
# calm zone.
hint_font = gui_app.font(FontWeight.NORMAL)
hints = [
tr("In the sunnylink app, open Local Connectivity → Pair a device."),
tr("Sign in to the same sunnypilot account on both."),
tr("The code expires after 2 minutes."),
] if self._qr_texture is not None else [tr("Preparing a secure pairing code…")]
for hint in hints:
wrapped = wrap_text(hint_font, hint, 38, left_width)
rl.draw_text_ex(hint_font, "\n".join(wrapped), rl.Vector2(content_rect.x, y), 38, 0.0, rl.BLACK)
y += len(wrapped) * 38 + 14
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 phone…")
color = _LOCAL_DISCOVERED_COLOR
status_font = gui_app.font(FontWeight.NORMAL)
rl.draw_text_ex(status_font, status, rl.Vector2(content_rect.x, y + 10), 40, 0.0, color)
return -1
def __del__(self):
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
@@ -4,22 +4,40 @@ 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.
"""
import time
import pyray as rl
from functools import partial
from openpilot.cereal import custom
from openpilot.common.qrcode import make_texture
from openpilot.common.swaglog import cloudlog
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigDialogBase
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage
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_daemon import (
clear_pairing_params,
read_grant_meta,
read_granted_apps,
read_pairing_qr,
request_revoke,
request_revoke_all,
)
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
MAX_LOCAL_APP_ROWS,
LocalAppRow,
build_local_app_rows,
)
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_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
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.common.version import sunnylink_consent_version, sunnylink_consent_declined
class SunnylinkInfo(Widget):
def __init__(self):
@@ -73,11 +91,18 @@ class SunnylinkLayoutMici(NavScroller):
self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False,
toggle_callback=self._sunnylink_uploader_callback)
self._local_toggle = BigToggle(text=tr("local connections"), initial_state=False,
toggle_callback=self._local_connections_callback)
self._mobile_app_btn = BigButton(tr("sunnylink local"), "")
self._mobile_app_btn.set_click_callback(lambda: gui_app.push_widget(LocalAppsPanelMici()))
self._scroller.add_widgets([
self._sunnylink_info,
self._sunnylink_toggle,
self._sunnylink_sponsor_button,
self._sunnylink_pair_button,
self._local_toggle,
self._mobile_app_btn,
self._backup_btn,
self._restore_btn,
self._sunnylink_uploader_toggle
@@ -111,6 +136,11 @@ class SunnylinkLayoutMici(NavScroller):
else:
self._sunnylink_pair_button.set_text(tr("pair"))
local_enabled = ui_state.params.get_bool("SunnylinkLocalEnabled")
self._local_toggle.set_visible(self._sunnylink_enabled)
self._local_toggle.set_checked(local_enabled)
self._mobile_app_btn.set_visible(self._sunnylink_enabled and local_enabled)
def show_event(self):
super().show_event()
ui_state.update_params()
@@ -140,6 +170,8 @@ class SunnylinkLayoutMici(NavScroller):
gui_app.push_widget(sl_terms_dlg)
else:
ui_state.params.put_bool("SunnylinkEnabled", state)
if not state:
clear_pairing_params()
ui_state.update_params()
@@ -147,6 +179,11 @@ class SunnylinkLayoutMici(NavScroller):
def _sunnylink_uploader_callback(state: bool):
ui_state.params.put_bool("EnableSunnylinkUploader", state)
@staticmethod
def _local_connections_callback(state: bool):
ui_state.params.put_bool("SunnylinkLocalEnabled", state)
ui_state.update_params()
def _handle_backup_restore_btn(self, restore: bool = False):
lbl = tr("slide to restore") if restore else tr("slide to backup")
icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
@@ -252,3 +289,164 @@ class SunnylinkPairBigButton(BigButton):
dlg = SunnylinkPairingDialog(sponsor_pairing=False)
if dlg:
gui_app.push_widget(dlg)
class LocalAppsPanelMici(NavScroller):
def __init__(self):
super().__init__()
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(LocalPairingDialogMici()))
self._revoke_all_btn = BigButton(tr("revoke all phones"), "")
self._revoke_all_btn.set_click_callback(self._confirm_revoke_all)
self._local_app_btns: list[BigButton] = []
for i in range(MAX_LOCAL_APP_ROWS):
btn = BigButton("", "")
btn.set_click_callback(partial(self._confirm_unpair_row, i))
self._local_app_btns.append(btn)
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(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(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,
red=True,
)
gui_app.push_widget(dlg)
def _confirm_unpair_row(self, index: int):
rows = self._rows_cache
if index >= len(rows):
return
row = rows[index]
def unpair():
# 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(
tr("slide to unpair"),
icon,
confirm_callback=unpair,
red=True,
)
gui_app.push_widget(dlg)
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
def __init__(self):
super().__init__()
self._requested_at = time.monotonic()
self._seen_qr = False
self._qr_string: str | None = None
self._qr_texture: rl.Texture | None = None
self._request_window()
self.set_back_callback(self._cancel)
subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
# One label doubles as the instruction and the live status: 240px of height has no room for a
# title, an instruction and a status line beside a full-height QR. Scanning is the only way
# in, and this payload is not something anyone could type from a screen.
self._hint = UnifiedLabel(tr("scan this code in the sunnylink app"), font_size=26,
text_color=subheader_color, line_height=0.9)
def _request_window(self) -> None:
try:
ui_state.params.put_bool(PAIRING_REQUEST_KEY, True, block=True)
except Exception:
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.
clear_pairing_params()
def _refresh_qr_texture(self, qr: str | None) -> None:
"""Regenerate the texture only when the published QR actually changed."""
if qr == self._qr_string:
return
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
self._qr_texture = None
self._qr_string = qr
if not qr:
return
try:
# Standard polarity (dark modules, the encoder's own light quiet zone), not light-on-dark:
# on a panel this small the code is dense enough already without asking every scanner to
# handle an inverted symbol too.
self._qr_texture = make_texture(qr)
# The texture is drawn smaller than it is built, and the modules must stay hard-edged for
# a camera to read them.
rl.set_texture_filter(self._qr_texture, rl.TextureFilter.TEXTURE_FILTER_POINT)
except Exception:
cloudlog.exception("sunnylink.local_pairing.qr_texture_failed")
def _update_state(self):
super()._update_state()
if self.is_dismissing:
return
if read_pairing_qr() is not None:
self._seen_qr = True
return
# The daemon clears the published QR when the window ends (enrolled, expired or cancelled).
if self._seen_qr or time.monotonic() - self._requested_at > self.ARM_GRACE_S:
self.dismiss()
def _render(self, _):
self._refresh_qr_texture(read_pairing_qr())
rect = self._rect
# The QR takes the full height, the way the cloud pairing dialog sizes it and the only size the
# code can be scanned at on this display. Four pixels of margin is the border the panel needs;
# the quiet zone the scanner needs is inside the texture.
pad = 4
qr_size = int(rect.height - 2 * pad)
if self._qr_texture is not None:
qr_rect = rl.Rectangle(rect.x + pad, rect.y + pad, qr_size, qr_size)
source = rl.Rectangle(0, 0, self._qr_texture.width, self._qr_texture.height)
rl.draw_texture_pro(self._qr_texture, source, qr_rect, rl.Vector2(0, 0), 0, rl.WHITE)
text_x = int(rect.x + pad + qr_size + 20)
text_width = int(rect.x + rect.width - pad - text_x)
discovered = latest_discovered_app()
if discovered is not None:
endpoint, age = discovered
self._hint.set_text(endpoint if age < 2 else f"{endpoint} ({age}s)")
self._hint.set_text_color(rl.Color(0, 255, 0, 255))
else:
self._hint.set_text(tr("scan this code in the sunnylink app"))
self._hint.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)))
self._hint.set_max_width(text_width)
self._hint.set_position(text_x, rect.y + pad)
self._hint.render()
def __del__(self):
if self._qr_texture is not None and self._qr_texture.id != 0:
rl.unload_texture(self._qr_texture)
@@ -0,0 +1,83 @@
"""One row model for the "Local Connections" screen, shared by both UI panels.
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 import MAX_GRANTS
# 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"
NEVER_CONNECTED = "not connected yet"
@dataclass(frozen=True)
class LocalAppRow:
"""One enrolled phone in the local-connections list."""
app_id: str
title: str
subtitle: str
def short_key(app_id: str, length: int = 10) -> str:
"""A recognizable, privacy-neutral abbreviation of a key id."""
return app_id if len(app_id) <= length else app_id[:length] + "…"
def format_age(seconds: float) -> str:
"""Human-readable 'how long ago', for the display-only last-seen stamp."""
if seconds < 60:
return "just now"
if seconds < 3600:
return f"{int(seconds // 60)}m ago"
if seconds < 86_400:
return f"{int(seconds // 3600)}h ago"
return f"{int(seconds // 86_400)}d ago"
def last_seen_text(entry: Mapping[str, object] | None, now: float) -> str:
"""`last_seen` is monotonic, so a stamp from before the last reboot reads as unknown."""
last_seen = entry.get("last_seen") if isinstance(entry, Mapping) else None
if not isinstance(last_seen, (int, float)) or isinstance(last_seen, bool):
return NEVER_CONNECTED
age = now - float(last_seen)
if age < 0:
return NEVER_CONNECTED
return format_age(age)
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]:
"""One row per enrolled phone, in the order the authority holds them."""
entries = meta or {}
rows = []
for grant in grants:
if not isinstance(grant, dict):
continue
app_id = grant.get("app_id")
app_name = grant.get("app_name")
if not isinstance(app_id, str) or not app_id:
continue
entry = entries.get(app_id)
alias = entry.get("alias") if isinstance(entry, Mapping) else None
if isinstance(alias, str) and alias.strip():
title = alias.strip()
elif isinstance(app_name, str) and app_name:
title = app_name
else:
title = DEFAULT_APP_NAME
rows.append(LocalAppRow(app_id, title,
f"secure · {short_key(app_id)} · {last_seen_text(entry, now)}"))
return rows[:max_rows]
@@ -0,0 +1,523 @@
"""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.md.
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
import secrets
import socket
import ssl
import threading
import ipaddress
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from enum import Enum
from typing import Any
from urllib.parse import urlsplit
from websocket import WebSocket, create_connection
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
VERSION = 2
AUTHENTICATE_PURPOSE = "sunnylink-local-authenticate"
PAIRING_TTL_S = 120
# A cloud-delivered enrollment is store-and-forward: the gateway queues the settings-write and the
# device's local half claims it on a later tick, which is minutes late when the cloud link was down
# or the device was asleep. The session a code was scanned from therefore stays usable after the
# code stops being displayed, so the enrollment for the code the user actually scanned can still
# land. It stays single-use, stays bound to that code, and is dropped the moment the user cancels
# the window or it outlives this grace.
PAIRING_DELIVERY_GRACE_S = 600
# How many codes stay usable for a late delivery. A user who retries gets a fresh code, and the
# enrollment for the code they scanned first can still be in flight.
MAX_RECENT_SESSIONS = 3
MAX_ENROLL_BYTES = 4096
MAX_GRANTS = 8
# The QR the app scans is a POINTER, not a proof: it names one device and one pairing window and
# carries nothing else. It is unsigned because a signature does not fit the panel a comma device
# has: the device identity key is RSA (`/persist/comma/id_rsa` — KEYS in common/api/base.py tries
# id_rsa first, and hw.h hardcodes that path), so PKCS#1 makes the signature 256 bytes and base64
# makes it 342 characters, i.e. a 461-character code: version 15, 77 modules, 2.73 px/module on the
# 536x240 mici panel, where it does not decode. The same frame unsigned is 52 characters, a
# 29-module code at 6.27 px/module there. Nothing is given up for that:
#
# * the DEVICE is the gate, not the code: an enrollment is honored only for a nonce this device
# minted in its own live window (`_fresh`), so a code nobody's device made enrolls nothing;
# * the app can only enroll a device its own account has settings access to (the authenticated
# cloud device details are what resolve the key), so a stranger's code points at a device the
# app refuses to touch;
# * the device proves the identity key itself to the phone over pinned TLS, against a fresh
# challenge, before any session exists (`authenticate_device`) — a stronger proof than a
# signature over a static code, and unlike one it costs the code no characters.
#
# The comma device id is deliberately absent too: the app already gets it from the same
# authenticated cloud details, and a second unauthenticated copy is one more thing to disagree with.
#
# Frame (the whole QR is its base64url):
#
# "SLEN" | version | len(cloud id) | cloud id | session (16) | ttl (1)
QR_MAGIC = b"SLEN"
QR_VERSION = 4
QR_SESSION_BYTES = 16
MAX_QR_FRAME_BYTES = 256
# The only RPCs an authenticated local session may ask for. Long-running operations are
# deliberately absent.
LOCAL_METHODS = frozenset({"getParams", "getParamsAllKeys", "getParamsMetadata", "getMessage", "saveParams",
"updateLocalAppAlias", "unpairLocalApp"})
_TOKEN = re.compile(r"[A-Za-z0-9_-]+\Z")
class AuthorizationError(ValueError):
"""Fail closed without exposing protocol internals to an unauthenticated peer."""
class Origin(Enum):
"""Where a trust mutation came from. Local sessions may never enroll or revoke."""
CLOUD = "cloud"
LOCAL = "local"
def encode64(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def decode64(value: Any, *, max_bytes: int = MAX_ENROLL_BYTES) -> bytes:
if not isinstance(value, str) or not value or len(value) > (max_bytes * 4 + 2) // 3 or not _TOKEN.fullmatch(value):
raise AuthorizationError("invalid encoding")
try:
raw = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
except ValueError as e:
raise AuthorizationError("invalid encoding") from e
if len(raw) > max_bytes or encode64(raw) != value:
raise AuthorizationError("noncanonical encoding")
return raw
def load_app_key(value: str) -> ec.EllipticCurvePublicKey:
raw = decode64(value, max_bytes=128)
try:
key = serialization.load_der_public_key(raw)
except ValueError as e:
raise AuthorizationError("invalid key") from e
if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1):
raise AuthorizationError("unsupported key")
if key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo) != raw:
raise AuthorizationError("noncanonical key")
return key
def key_id_bytes(key: ec.EllipticCurvePublicKey | rsa.RSAPublicKey) -> bytes:
raw = key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
return hashlib.sha256(raw).digest()
def key_id(key: ec.EllipticCurvePublicKey | rsa.RSAPublicKey) -> str:
return encode64(key_id_bytes(key))
@dataclass(frozen=True)
class QrPayload:
"""What the app reads out of the scanned frame. Never authority on its own: the device key it
ends up pinned to comes from authenticated cloud device details, and the nonce only means
something to the window this device is displaying now."""
cloud_device_id: str
session: bytes
ttl_s: int
def qr_frame(cloud_device_id: str, session: bytes, ttl_s: int) -> bytes:
if len(session) != QR_SESSION_BYTES:
raise AuthorizationError("invalid qr session")
if not 0 < ttl_s < 256:
raise AuthorizationError("invalid qr ttl")
encoded = text_field(cloud_device_id).encode("utf-8")
if not 0 < len(encoded) < 256:
raise AuthorizationError("invalid cloud device id")
return QR_MAGIC + bytes([QR_VERSION, len(encoded)]) + encoded + session + bytes([ttl_s])
def decode_qr(raw: str) -> QrPayload:
"""Parse a scanned QR. The app is the side that reads one (it needs the device key from the
cloud); this validates the framing so no caller can disagree with [qr_frame]."""
frame = decode64(raw, max_bytes=MAX_QR_FRAME_BYTES)
header = len(QR_MAGIC) + 2
if len(frame) < header + 1 + QR_SESSION_BYTES + 1 or not frame.startswith(QR_MAGIC):
raise AuthorizationError("invalid qr")
version, id_length = frame[len(QR_MAGIC)], frame[len(QR_MAGIC) + 1]
if version != QR_VERSION or id_length == 0 or len(frame) != header + id_length + QR_SESSION_BYTES + 1:
raise AuthorizationError("invalid qr")
try:
cloud_device_id = text_field(frame[header:header + id_length].decode("utf-8"))
except UnicodeDecodeError as e:
raise AuthorizationError("invalid qr") from e
session_start = header + id_length
return QrPayload(cloud_device_id, frame[session_start:session_start + QR_SESSION_BYTES], frame[-1])
def text_field(value: Any, maximum: int = 128) -> str:
if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum or any(ord(c) < 32 or ord(c) == 127 for c in value):
raise AuthorizationError("invalid field")
return value
def statement(purpose: str, *fields: str) -> bytes:
"""Length-bounded UTF-8 fields; LF is forbidden, avoiding JSON canonicalization."""
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]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise AuthorizationError("duplicate field")
result[key] = value
return result
def parse_object(raw: str) -> dict[str, Any]:
if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ENROLL_BYTES:
raise AuthorizationError("message too large")
try:
obj = json.loads(raw, object_pairs_hook=_unique_object)
except (ValueError, UnicodeError) as e:
raise AuthorizationError("invalid message") from e
if not isinstance(obj, dict):
raise AuthorizationError("invalid message")
return obj
@dataclass(frozen=True)
class Grant:
app_id: str
public_key: str
key_id: str
app_name: str
revision: str
@staticmethod
def from_dict(data: Mapping[str, Any]) -> Grant:
if set(data) != {"app_id", "public_key", "key_id", "app_name", "revision"}:
raise AuthorizationError("invalid grant")
public = text_field(data["public_key"], 256)
actual_id = key_id(load_app_key(public))
if data["key_id"] != actual_id or data["app_id"] != actual_id:
raise AuthorizationError("key mismatch")
revision = text_field(data["revision"], 43)
if len(decode64(revision, max_bytes=32)) != 32:
raise AuthorizationError("invalid revision")
return Grant(actual_id, public, actual_id, text_field(data["app_name"], 80), revision)
def to_dict(self) -> dict[str, str]:
return {"app_id": self.app_id, "public_key": self.public_key, "key_id": self.key_id,
"app_name": self.app_name, "revision": self.revision}
@dataclass(frozen=True)
class Window:
nonce: str
# How long the code is on the device screen and can be scanned.
deadline: float
# [deadline] plus [PAIRING_DELIVERY_GRACE_S]: how long an enrollment for this code may still
# arrive through the cloud after the code itself can no longer be scanned.
usable_until: float
qr: str
class LocalAuthority:
"""The daemon-owned authority. Every trust mutation goes through it, and a persistence
failure never publishes a new grant."""
def __init__(self, cloud_device_id: str, comma_device_id: str, device_private_key: ec.EllipticCurvePrivateKey | rsa.RSAPrivateKey,
persist: Callable[[dict[str, Any]], None], registry: Any = None, clock: Callable[[], float] = time.monotonic):
self.cloud_device_id = text_field(cloud_device_id)
self.comma_device_id = text_field(comma_device_id)
self.device_key = device_private_key
self.device_key_id = key_id(device_private_key.public_key())
self.persist = persist
self.clock = clock
self.lock = threading.RLock()
self.window: Window | None = None
# Codes that are no longer displayed but whose enrollment may still be in flight.
self.recent: list[Window] = []
self.pending: Grant | None = None
# The session [pending] was staged from: the grant is committed while that session's delivery
# grace lasts even if the code has left the screen.
self.pending_session: str | None = None
self.grants: dict[str, Grant] = {}
if isinstance(registry, dict) and type(registry.get("v")) is int and registry.get("v") == VERSION and set(registry) == {"v", "grants"}:
entries = registry["grants"]
if isinstance(entries, list) and len(entries) <= MAX_GRANTS:
try:
decoded = [Grant.from_dict(entry) for entry in entries if isinstance(entry, dict)]
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 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:
the code is a pointer, so minting one does not touch the identity key)."""
with self.lock:
session = secrets.token_bytes(QR_SESSION_BYTES)
qr = encode64(qr_frame(self.cloud_device_id, session, PAIRING_TTL_S))
now = self.clock()
self.window = Window(encode64(session), now + PAIRING_TTL_S, now + PAIRING_TTL_S + PAIRING_DELIVERY_GRACE_S, qr)
self._remember(self.window)
self.pending = None
self.pending_session = None
return qr
def _remember(self, window: Window) -> None:
"""Keep the newest displayed codes usable for a late cloud delivery."""
now = self.clock()
kept = [w for w in self.recent if w.nonce != window.nonce and now < w.usable_until]
self.recent = (kept + [window])[-MAX_RECENT_SESSIONS:]
def cancel(self) -> None:
"""The user cancelled (or the window was replaced): every session it could still be used for
is gone, so a scanned-but-undelivered enrollment cannot land after the fact."""
with self.lock:
self.window = None
self.recent = []
self.pending = None
self.pending_session = None
def expire(self) -> None:
"""The code left the device screen: it can no longer be scanned, but an enrollment for it may
still be in flight, so only the displayed window is dropped — the session stays in [recent]
until its delivery grace ends."""
with self.lock:
self.window = None
def prune_sessions(self) -> None:
with self.lock:
now = self.clock()
self.recent = [w for w in self.recent if now < w.usable_until]
def _fresh(self, nonce: str) -> None:
"""Accept a session that is displayed now, or was displayed recently enough that an enrollment
for it can still be travelling through the cloud ([PAIRING_DELIVERY_GRACE_S])."""
now = self.clock()
with self.lock:
candidates = ([self.window] if self.window is not None else []) + self.recent
if not any(w is not None and now < w.usable_until and secrets.compare_digest(w.nonce, nonce) for w in candidates):
# Name the reason: this line is the only trace of an enrollment that referenced a code the
# device no longer honors, and the difference between "expired" and "never displayed" is
# what tells a user to scan a fresh code instead of retrying the same one.
raise AuthorizationError("pairing code expired")
def enroll(self, raw: str, origin: Origin) -> Grant:
if origin is not Origin.CLOUD:
raise AuthorizationError("cloud authorization required")
data = parse_object(raw)
required = {"v", "cloud_device_id", "comma_device_id", "device_key_id", "pairing_session", "public_key", "key_id", "app_name", "signature"}
if set(data) != required or type(data["v"]) is not int or data["v"] != VERSION:
raise AuthorizationError("invalid enrollment")
if (data["cloud_device_id"], data["comma_device_id"], data["device_key_id"]) != \
(self.cloud_device_id, self.comma_device_id, self.device_key_id):
raise AuthorizationError("wrong device")
public = text_field(data["public_key"], 256)
key = load_app_key(public)
app_id = key_id(key)
if data["key_id"] != app_id:
raise AuthorizationError("wrong key")
# The nonce is the QR's own session field: the window this device is displaying decides it,
# and no other length is honored.
nonce = text_field(data["pairing_session"], 43)
if len(decode64(nonce, max_bytes=32)) != QR_SESSION_BYTES:
raise AuthorizationError("invalid nonce")
name = text_field(data["app_name"], 80)
signed = statement("enroll", self.cloud_device_id, self.comma_device_id, self.device_key_id, nonce, public, app_id, name)
try:
key.verify(decode64(data["signature"], max_bytes=72), signed, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise AuthorizationError("invalid proof") from e
with self.lock:
self._fresh(nonce)
if self.pending is not None:
if (self.pending.public_key, self.pending.app_name) != (public, name):
raise AuthorizationError("window already bound")
return self.pending
if app_id not in self.grants and len(self.grants) >= MAX_GRANTS:
raise AuthorizationError("grant limit")
self.pending = Grant(app_id, public, app_id, name, encode64(secrets.token_bytes(32)))
self.pending_session = nonce
return self.pending
def confirm(self, grant: Grant, tls_peer_key_id: str) -> Grant:
"""Called only after the transport proves the pinned TLS server key."""
with self.lock:
if not secrets.compare_digest(grant.key_id, tls_peer_key_id):
raise AuthorizationError("wrong TLS peer")
if self.pending != grant:
raise AuthorizationError("no pending enrollment")
if self.pending_session is None:
raise AuthorizationError("window closed")
# The enrollment may have arrived late, so what has to hold is that the session it was scanned
# from is still inside its delivery grace — not that the code is still on the screen.
self._fresh(self.pending_session)
updated = self.grants | {grant.key_id: grant}
self.persist({"v": VERSION, "grants": [g.to_dict() for g in updated.values()]})
self.grants = updated
self.cancel()
return grant
def authenticate_device(self, phone_key_id: str, challenge: str) -> dict[str, str]:
"""Proof of device identity for the phone's fresh challenge, sent only inside a TLS
channel already pinned to `phone_key_id`."""
if len(decode64(challenge, max_bytes=32)) != 32:
raise AuthorizationError("invalid challenge")
fields = (self.cloud_device_id, self.comma_device_id, self.device_key_id,
text_field(phone_key_id, 128), text_field(challenge, 128))
proof = statement("authenticate-device", *fields)
if isinstance(self.device_key, ec.EllipticCurvePrivateKey):
signature = self.device_key.sign(proof, ec.ECDSA(hashes.SHA256()))
else:
signature = self.device_key.sign(proof, padding.PKCS1v15(), hashes.SHA256())
return {"cloud_device_id": self.cloud_device_id, "comma_device_id": self.comma_device_id,
"device_key_id": self.device_key_id, "phone_key_id": phone_key_id,
"challenge": challenge, "signature": encode64(signature)}
def revoke_by_self_proof(self, raw: str, origin: Origin) -> str:
"""Cloud-origin self-revocation: a phone drops its OWN grant.
The proof is signed by the very key being dropped and verified against the grant this device
already holds, so it can only ever remove an enrolled phone. Removing a key that is not
enrolled is not an error: there is simply nothing left to drop.
"""
if origin is not Origin.CLOUD:
raise AuthorizationError("cloud authorization required")
data = parse_object(raw)
if set(data) != {"v", "cloud_device_id", "comma_device_id", "key_id", "signature"} \
or type(data["v"]) is not int or data["v"] != VERSION:
raise AuthorizationError("invalid revocation")
if (data["cloud_device_id"], data["comma_device_id"]) != (self.cloud_device_id, self.comma_device_id):
raise AuthorizationError("wrong device")
app_id = text_field(data["key_id"], 128)
if len(decode64(app_id, max_bytes=32)) != 32:
raise AuthorizationError("invalid key id")
with self.lock:
grant = self.grants.get(app_id)
if grant is None:
# Nothing enrolled under this key. `revoke` still drops a matching staged enrollment.
self.revoke(app_id)
return app_id
proof = statement("revoke", self.cloud_device_id, self.comma_device_id, app_id)
key = load_app_key(grant.public_key)
try:
key.verify(decode64(data["signature"], max_bytes=72), proof, ec.ECDSA(hashes.SHA256()))
except InvalidSignature as e:
raise AuthorizationError("invalid proof") from e
self.revoke(app_id)
return app_id
def revoke_all(self) -> int:
"""Drop every grant in one durable write, leaving an open pairing window alone."""
with self.lock:
removed = len(self.grants)
if removed == 0:
return 0
self.persist({"v": VERSION, "grants": []})
self.grants = {}
return removed
def revoke(self, app_key_id: str) -> bool:
with self.lock:
if self.pending is not None and self.pending.key_id == app_key_id:
self.cancel()
if app_key_id not in self.grants:
return False
updated = {k: g for k, g in self.grants.items() if k != app_key_id}
self.persist({"v": VERSION, "grants": [g.to_dict() for g in updated.values()]})
self.grants = updated
return True
def session(self, grant: Grant, tls_peer_key_id: str) -> AuthorizedSession:
with self.lock:
if self.grants.get(grant.key_id) != grant or not secrets.compare_digest(grant.key_id, tls_peer_key_id):
raise AuthorizationError("unauthorized peer")
return AuthorizedSession(self, grant)
class AuthorizedSession:
"""Session-owned authorization, checked immediately before each handler executes."""
def __init__(self, authority: LocalAuthority, grant: Grant):
self.authority = authority
self.grant = grant
self.closed = False
def close(self) -> None:
with self.authority.lock:
self.closed = True
def invoke(self, method: str, handler: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
with self.authority.lock:
if self.closed or self.authority.grants.get(self.grant.key_id) != self.grant or method not in LOCAL_METHODS:
raise AuthorizationError("request denied")
# Serializes trust revocation with handler execution. Long-running operations
# are deliberately excluded from LOCAL_METHODS.
return handler(*args, **kwargs)
def connect_pinned(endpoint: str, expected_key_id: str, timeout_s: float = 10) -> WebSocket:
"""Pin before the HTTP upgrade, so no header or RPC leaves the device unverified.
PKIX is replaced by an exact public-key pin for this local socket only; never reuse this
context for cloud traffic. The pin proves private-key possession, nothing else.
"""
parsed = urlsplit(endpoint)
if parsed.scheme != "wss" or parsed.username is not None or parsed.password is not None \
or parsed.query or parsed.fragment or parsed.path not in ("", "/") or parsed.hostname is None:
raise AuthorizationError("invalid local endpoint")
try:
address = ipaddress.ip_address(parsed.hostname)
port = parsed.port
except ValueError as e:
raise AuthorizationError("invalid local endpoint") from e
if port is None or address.is_unspecified or address.is_multicast:
raise AuthorizationError("invalid local endpoint")
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.minimum_version = ssl.TLSVersion.TLSv1_3
context.maximum_version = ssl.TLSVersion.TLSv1_3
# New context and no supplied session prevent client resumption/early data.
connection = socket.create_connection((str(address), port), timeout=timeout_s)
tls: ssl.SSLSocket | None = None
try:
tls = context.wrap_socket(connection, server_hostname=None)
certificate = tls.getpeercert(binary_form=True)
if certificate is None:
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"])
except Exception:
(tls or connection).close()
raise
def verify_tls_peer(certificate_der: bytes, expected_key_id: str) -> str:
try:
key = x509.load_der_x509_certificate(certificate_der).public_key()
except ValueError as e:
raise AuthorizationError("invalid peer certificate") from e
if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1):
raise AuthorizationError("unsupported peer key")
actual = key_id(key)
if not secrets.compare_digest(actual, expected_key_id):
raise AuthorizationError("wrong TLS peer")
return actual
@@ -0,0 +1,506 @@
"""The local daemon's store: the enrolled phones, their display metadata and the Params the
device UI uses to ask for a pairing window or a revocation.
Only `localsunnylinkd` holds the authority; the settings UI (another process) reads a view and
sends requests, so it can never persist a grant list of its own.
"""
from __future__ import annotations
import threading
import time
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 import (
VERSION,
AuthorizationError,
LocalAuthority,
Origin,
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,
)
MAX_ALIAS_BYTES = 80
_params = Params()
_authority: LocalAuthority | None = None
_authority_lock = threading.Lock()
_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(LOCAL_APPS_KEY, registry, block=True)
def _read_registry() -> dict[str, Any] | None:
raw = _params.get(LOCAL_APPS_KEY)
return raw if isinstance(raw, dict) else None
def _build_authority() -> LocalAuthority | None:
try:
algorithm, private_pem, _public_pem = BaseApi.get_key_pair()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.identity_unavailable")
return None
if algorithm is None or private_pem is None:
return None
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
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.identity_load_failed")
return None
if not isinstance(device_key, (ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey)):
cloudlog.error("sunnylinkd.local_auth.identity_unsupported")
return None
cloud_device_id = _params.get("SunnylinkDongleId")
comma_device_id = _params.get("DongleId")
if not cloud_device_id or not comma_device_id:
return None
return LocalAuthority(
cloud_device_id=cloud_device_id.decode() if isinstance(cloud_device_id, bytes) else cloud_device_id,
comma_device_id=comma_device_id.decode() if isinstance(comma_device_id, bytes) else comma_device_id,
device_private_key=device_key,
persist=_persist_registry,
registry=_read_registry(),
)
def get_authority() -> LocalAuthority | None:
"""The process authority, or None when local trust is unavailable (fail closed).
Every failure reports None instead of raising: the daemon calls this from its serving loop.
"""
global _authority
with _authority_lock:
if _authority is not None:
return _authority
try:
_authority = _build_authority()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.authority_unavailable")
return None
return _authority
def reset_authority() -> None:
global _authority
with _authority_lock:
_authority = 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.arm_failed")
return None
def cancel_local_pairing() -> None:
authority = get_authority()
if authority is not None:
authority.cancel()
def _publish_qr(qr: str | None, params: Params) -> None:
try:
if qr:
params.put(PAIRING_QR_KEY, qr, block=True)
else:
params.remove(PAIRING_QR_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.qr_publish_failed")
def _clear_request(params: Params) -> None:
try:
params.remove(PAIRING_REQUEST_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.pairing_request_clear_failed")
def _armed_qr(authority: LocalAuthority) -> str | None:
with authority.lock:
window = authority.window
if window is None or authority.clock() >= window.deadline:
return None
return window.qr
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_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.qr_read_failed")
return None
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return value if isinstance(value, str) and value else None
def _read_meta() -> dict[str, Any]:
try:
raw = _params.get(LOCAL_APPS_META_KEY)
except Exception:
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(LOCAL_APPS_META_KEY, {"v": VERSION, "apps": apps}, block=True)
def read_grant_meta() -> dict[str, dict[str, Any]]:
"""Per-phone display metadata: `{key_id: {"alias": str, "last_seen": float}}`, fail-soft."""
try:
entries = _read_meta()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_unreadable")
return {}
return {key_id: dict(entry) for key_id, entry in entries.items() if isinstance(entry, dict)}
def set_grant_alias(app_key_id: str, alias: str) -> bool:
"""Record the name a phone calls itself. Metadata only: nothing here grants anything."""
cleaned = (alias or "").strip() if isinstance(alias, str) else ""
if cleaned:
text_field(cleaned, MAX_ALIAS_BYTES)
with _meta_lock:
apps = _read_meta()
entry = dict(apps.get(app_key_id) or {})
entry["alias"] = cleaned
apps[app_key_id] = entry
_write_meta(apps)
return True
def note_grant_seen(app_key_id: str) -> None:
"""Stamp "this phone just connected"; a metadata failure never costs a working session."""
try:
with _meta_lock:
apps = _read_meta()
entry = dict(apps.get(app_key_id) or {})
entry["last_seen"] = time.monotonic()
apps[app_key_id] = entry
_write_meta(apps)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_seen_failed")
def clear_grant_meta(app_key_id: str) -> None:
try:
with _meta_lock:
apps = _read_meta()
if apps.pop(app_key_id, None) is not None:
_write_meta(apps)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
def clear_all_grant_meta() -> None:
try:
with _meta_lock:
if _read_meta():
_write_meta({})
except Exception:
cloudlog.exception("sunnylinkd.local_auth.meta_clear_failed")
def clear_pairing_params(params: Params | None = None) -> None:
"""The UI's cancel signal: the daemon cancels the window it owns on its next tick."""
params = params or _params
_clear_request(params)
_publish_qr(None, params)
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()
def service_pairing_window(params: Params | None = None) -> str | None:
"""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.pairing_window_failed")
return read_pairing_qr(params)
def _service_pairing_window(params: Params) -> str | None:
try:
requested = bool(params.get_bool(PAIRING_REQUEST_KEY))
except Exception:
cloudlog.exception("sunnylinkd.local_auth.pairing_request_unreadable")
requested = False
authority = get_authority()
if authority is None:
# No identity key means nothing could ever enroll: never show a window that cannot complete.
_clear_request(params)
_publish_qr(None, params)
return None
armed = _armed_qr(authority)
published = read_pairing_qr(params)
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()
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
# already have scanned it.
_publish_qr(armed, params)
return armed
if authority.window is not None:
# The code's displayed lifetime is over. Hide it — the UI closes its dialog on the cleared
# param — but keep the session: an enrollment for the code the user just scanned can still be
# in flight through the cloud, which delivers on a later tick. Ending the session here is what
# made a scanned code fail with nothing shown on either side (the phone waited forever, the
# device said nothing). A cancel is different: it clears every session the user could have
# scanned from.
authority.expire()
cloudlog.event("sunnylinkd.local_auth.window_expired")
authority.prune_sessions()
if not requested:
if published is not None:
_publish_qr(None, params)
return None
qr = arm_local_pairing()
_clear_request(params)
_publish_qr(qr, params)
if qr is None:
return None
cloudlog.event("sunnylinkd.local_auth.window_armed")
return qr
def cloud_enroll(raw_enrollment: str) -> dict[str, Any]:
"""Stage an enrollment that arrived through the cloud settings-write transport.
Only ever called with a payload the cloud delivered; raises AuthorizationError for anything
that does not validate. Nothing is durable until the pinned TLS peer proves the key.
"""
authority = get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
grant = authority.enroll(raw_enrollment, Origin.CLOUD)
return {"app_id": grant.app_id, "app_name": grant.app_name, "revision": grant.revision}
def cloud_revoke(raw_revocation: str) -> dict[str, Any]:
"""Apply a phone's signed self-revocation of its own grant, same provenance as enrollment."""
authority = get_authority()
if authority is None:
raise AuthorizationError("revocation unavailable")
return {"app_id": authority.revoke_by_self_proof(raw_revocation, Origin.CLOUD)}
def claim_cloud_commands(params: Params | None = None) -> None:
"""Apply the two commands the cloud may deliver to the local daemon, then always clear them.
The cloud daemon only stores them (`local_params.CLOUD_COMMANDS`); the payload is validated and
staged here. A rejected payload is still cleared, so a bad command cannot be retried or replayed.
"""
params = params or _params
for key, apply in ((CLOUD_ENROLL_KEY, cloud_enroll), (CLOUD_REVOKE_KEY, cloud_revoke)):
value = _take(params, key)
if value is None:
continue
try:
summary = apply(value)
except AuthorizationError as e:
cloudlog.warning(f"sunnylinkd.local_auth.{key}_rejected: {e}")
except Exception:
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.{key}_accepted", app_name=app_name)
def _take(params: Params, key: str) -> str | None:
try:
value = params.get(key)
params.remove(key)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.command_unreadable")
return None
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
return value if isinstance(value, str) and value else None
def confirm_enrollment_tls(grant_key_id: str) -> dict[str, Any]:
"""Commit the pending grant once the pinned TLS peer proved possession of its key."""
authority = get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
pending = authority.pending
if pending is None or pending.key_id != grant_key_id:
raise AuthorizationError("no pending enrollment for this key")
committed = authority.confirm(pending, grant_key_id)
return {"app_id": committed.app_id, "app_name": committed.app_name, "revision": committed.revision}
def revoke_local_app(app_key_id: str) -> bool:
authority = get_authority()
if authority is None:
return False
revoked = authority.revoke(app_key_id)
if revoked:
clear_grant_meta(app_key_id)
return revoked
def revoke_all() -> int:
authority = get_authority()
if authority is None:
return 0
removed = authority.revoke_all()
if not authority.grants:
clear_all_grant_meta()
return removed
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_KEY, app_key_id, block=True)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_request_failed")
def request_revoke_all(params: Params | None = None) -> None:
params = params or _params
try:
params.put_bool(REVOKE_ALL_KEY, True, block=True)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_request_failed")
def service_revoke_requests(params: Params | None = None) -> bool:
"""Apply (and always clear) a pending UI revoke request, so one bad request cannot stick."""
params = params or _params
revoke_all_requested = False
try:
revoke_all_requested = bool(params.get_bool(REVOKE_ALL_KEY))
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_unreadable")
if revoke_all_requested:
removed = 0
try:
removed = revoke_all()
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_all_failed")
finally:
try:
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.revoke_all_clear_failed")
if removed:
cloudlog.event("sunnylinkd.local_auth.grants_revoked", count=removed)
return bool(removed)
try:
requested = params.get(REVOKE_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_request_unreadable")
return False
if isinstance(requested, bytes):
requested = requested.decode("utf-8", errors="replace")
app_key_id = requested if isinstance(requested, str) else ""
if not app_key_id:
return False
revoked = False
try:
revoked = revoke_local_app(app_key_id)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_failed")
finally:
try:
params.remove(REVOKE_KEY)
except Exception:
cloudlog.exception("sunnylinkd.local_auth.revoke_clear_failed")
if revoked:
cloudlog.event("sunnylinkd.local_auth.grant_revoked", app_id=app_key_id)
return revoked
def grants() -> list[dict[str, str]]:
authority = get_authority()
if authority is None:
return []
return [grant.to_dict() for grant in authority.grants.values()]
def read_granted_apps() -> list[dict[str, str]]:
"""Read-only view of the enrolled phones for the device UI, fresh from Params.
Deliberately does not build the authority: the settings UI is another process.
"""
raw = _read_registry()
entries = raw.get("grants") if isinstance(raw, dict) else None
if not isinstance(entries, list):
return []
rows = []
for entry in entries:
if not isinstance(entry, dict):
continue
app_id = entry.get("app_id")
app_name = entry.get("app_name")
if isinstance(app_id, str) and app_id and isinstance(app_name, str):
rows.append({"app_id": app_id, "app_name": app_name})
return rows
def pending_enrollment() -> dict[str, str] | None:
authority = get_authority()
if authority is None:
return None
pending = authority.pending
return pending.to_dict() if pending is not None else None
@@ -0,0 +1,262 @@
"""
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
import json
import socket
import threading
import time
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_state import (
BEACON_PREFIX,
DISCOVERED_APP_KEY,
PAIRING_REQUEST_KEY,
SUNNYLINK_LOCAL_UDP_PORT,
format_endpoint,
)
LOCAL_BEACON_FRESH_S = 30
@dataclass
class AppBeacon:
"""A parsed app beacon — the app announcing it is acting as the local backend."""
app_id: str
ws_port: int
source_ip: str
@property
def endpoint(self) -> str:
return format_endpoint(self.source_ip, self.ws_port)
def parse_beacon(raw: str | bytes, source_ip: str = "") -> AppBeacon | None:
"""
Parse one UDP beacon line from the app.
Wire format: `SUNNYLINK1 {"v":1,"role":"app","app_id":"<uuid>","ws_port":8443}`
Returns None for anything else. Beacons carry ids + addresses only — no secrets.
"""
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
raw = raw.strip()
if not raw.startswith(BEACON_PREFIX + " "):
return None
try:
data = json.loads(raw[len(BEACON_PREFIX) + 1:])
except ValueError:
return None
if not isinstance(data, dict):
return None
if data.get("role") != "app" or data.get("v") != 1:
return None
app_id = data.get("app_id")
ws_port = data.get("ws_port")
if not isinstance(app_id, str) or not app_id:
return None
if not isinstance(ws_port, int) or not (0 < ws_port <= 65535):
return None
return AppBeacon(app_id=app_id, ws_port=ws_port, source_ip=source_ip)
class LocalDiscovery(threading.Thread):
"""Passive UDP listener.
- 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,
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
# 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
# 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()
self._stop_event = threading.Event()
self.write_interval_s = write_interval_s
self._last_write_monotonic = 0.0
self._last_written_endpoint: str | None = None
self._last_written_app_id: str | None = None
self._discovered_cleared = False
def stop(self) -> None:
self._stop_event.set()
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
def latest_endpoint(self) -> str | None:
"""The most recently announced app endpoint (None outside a pairing window)."""
with self._lock:
return self._latest_endpoint
def latest_app_id(self) -> str | None:
"""The app_id of the most recently announced beacon (None outside a window)."""
with self._lock:
return self._latest_app_id
def last_seen_ago(self) -> float | None:
"""Seconds since the last app beacon was heard (None when none heard yet)."""
with self._lock:
if self._last_seen_monotonic == 0.0:
return None
return time.monotonic() - self._last_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."""
now = time.monotonic()
with self._recent_lock:
self._recent_beacons = {endpoint: entry for endpoint, entry in self._recent_beacons.items()
if now - entry[1] <= max_age_s}
recent = sorted(self._recent_beacons.items(), key=lambda item: item[1][1], reverse=True)
return [endpoint for endpoint, _ in recent]
def _record_beacon(self, beacon: AppBeacon) -> None:
with self._recent_lock:
self._recent_beacons[beacon.endpoint] = (beacon.app_id, time.monotonic())
def _pairing_window_open(self) -> bool:
"""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:
return False
try:
return bool(callback())
except Exception:
cloudlog.exception("local_discovery.pairing_window_cb.exception")
return False
def _handle(self, raw: bytes, source_ip: str) -> None:
beacon = parse_beacon(raw, source_ip)
if beacon is None:
return
self._record_beacon(beacon)
if self._pairing_window_open():
with self._lock:
self._latest_endpoint = beacon.endpoint
self._latest_app_id = beacon.app_id
self._last_seen_monotonic = time.monotonic()
self._write_discovered_param(beacon)
cloudlog.debug(f"local_discovery.app_found {beacon.app_id} at {beacon.endpoint}")
else:
with self._lock:
self._latest_endpoint = None
self._latest_app_id = None
self._last_seen_monotonic = 0.0
self._clear_discovered_param()
def _clear_discovered_param(self) -> None:
if self._discovered_cleared:
return
self._discovered_cleared = True
try:
self.params.remove(DISCOVERED_APP_KEY)
except Exception:
cloudlog.exception("local_discovery.param_clear.exception")
def _write_discovered_param(self, beacon: AppBeacon) -> None:
"""Mirror the freshest beacon into a param the settings UI can read."""
now = time.monotonic()
changed = beacon.endpoint != self._last_written_endpoint or beacon.app_id != self._last_written_app_id
if not changed and now - self._last_write_monotonic < self.write_interval_s:
return
self._last_write_monotonic = now
self._last_written_endpoint = beacon.endpoint
self._last_written_app_id = beacon.app_id
self._discovered_cleared = False
payload = {
"endpoint": beacon.endpoint,
"app_id": beacon.app_id,
"ts": int(time.monotonic()),
}
try:
self.params.put(DISCOVERED_APP_KEY, payload, block=True)
except Exception:
cloudlog.exception("local_discovery.param_write.exception")
def _bind(self) -> socket.socket:
if self._sock is not None:
return self._sock
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", self.port))
sock.settimeout(0.5)
return sock
def run(self) -> None:
sock = self._bind()
try:
while not self._stop_event.is_set():
try:
data, addr = sock.recvfrom(4096)
self._handle(data, addr[0] if len(addr) > 0 else "")
except TimeoutError:
continue
except OSError:
# Socket closed by stop() — exit quietly.
if self._stop_event.is_set():
break
cloudlog.exception("local_discovery.recv.exception")
break
finally:
try:
sock.close()
except OSError:
pass
def latest_discovered_app(params: Params | None = None,
fresh_s: float = LOCAL_BEACON_FRESH_S) -> tuple[str, int] | None:
params = params or Params()
data = params.get(DISCOVERED_APP_KEY)
if not isinstance(data, dict):
return None
endpoint = str(data.get("endpoint", ""))
try:
ts = int(data.get("ts") or 0)
except (ValueError, TypeError):
return None
if not endpoint or ts <= 0:
return None
age = time.monotonic() - ts
# Negative age = written before the last reboot (monotonic restarts at boot).
if age < 0 or age > fresh_s:
return None
return endpoint, max(0, int(age))
@@ -0,0 +1,22 @@
"""Param names the local daemon owns, and who may touch them.
`SunnylinkLocal*` (and `_sec_*`) belong to the local daemon: the cloud daemon may not read or write
them, except the two commands an app pushes through the cloud settings-write for it to claim.
"""
from __future__ import annotations
ENROLL = "SunnylinkLocalEnroll"
REVOKE = "SunnylinkLocalCloudRevoke"
CLOUD_COMMANDS = frozenset({ENROLL, REVOKE})
_PREFIXES = ("SunnylinkLocal", "_sec_")
def is_local_param(key: str) -> bool:
"""True for every param the local side owns: hidden from the cloud and from local sessions."""
return key.startswith(_PREFIXES)
def cloud_may_write(key: str) -> bool:
"""The cloud settings-write may deliver its two local commands and nothing else local."""
return key in CLOUD_COMMANDS or not is_local_param(key)
@@ -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}"
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
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 json
import threading
import time
from collections.abc import Callable
from urllib.parse import urlsplit
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.athena import local_auth_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AUTHENTICATE_PURPOSE,
LOCAL_METHODS,
VERSION,
AuthorizationError,
AuthorizedSession,
LocalAuthority,
connect_pinned,
parse_object,
)
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.sunnylinkd import SUNNYLINK_RECONNECT_TIMEOUT_S, Sunnylinkd, log_connection_error
LOCAL_ENABLED_PARAM = "SunnylinkLocalEnabled"
SESSION_READ_TIMEOUT_S = 5
TICK_INTERVAL_S = 1.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
# accumulated on the old link has nothing to do with the new one. While it is inside that window
# the endpoint stays dialable, so the next beacon gets an immediate attempt rather than a penalty.
BACKOFF_FORGET_S = 12.0
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 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.
"""
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.backoffs: dict[str, float] = {}
# 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 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 pairing window counts as a beacon-display window for the settings screen."""
try:
return local_auth_daemon.read_pairing_qr() is not None
except Exception:
return False
def saveParams(self, params_to_update: dict[str, str], compression: bool = False) -> None:
"""Local sessions never touch local params: enrollment and revocation only ever arrive
through the cloud settings-write, so a phone cannot enroll or rename itself here."""
allowed = {key: value for key, value in params_to_update.items() if not is_local_param(key)}
if len(allowed) != len(params_to_update):
cloudlog.warning("sunnylinkd.local.saveParams.refused_local_params")
if allowed:
super().saveParams(allowed, compression)
# --- loop ---------------------------------------------------------------
def serviceable(self) -> bool:
"""Serve the LAN while sunnylink is on, this process is switched on, and there is no fault.
A locally paired device is often not cloud-registered at all, so registration is not part of
this: the cloud daemon waits for it, the local one never does.
"""
return self.params.get_bool("SunnylinkEnabled") and not self.params.get_bool("SunnylinkTempFault") \
and self.params.get_bool(LOCAL_ENABLED_PARAM)
def run(self, exit_event: threading.Event | None = None) -> None:
self.discovery.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 = self.pick_target()
if endpoint is None:
time.sleep(TICK_INTERVAL_S)
else:
self.serve(endpoint, exit_event)
finally:
self.stop_event.set()
self.discovery.stop()
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_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) -> 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()
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.
While the app is closed there is no beacon and nothing to dial, so a failure recorded then says
nothing about the phone the user opens next: the silence window is what tells "the app is gone"
apart from "the app cannot be served". Only a phone that keeps announcing keeps backing off.
The caller passes the short `BACKOFF_FORGET_S` window, so a phone that went quiet across a
network change also loses the ramp it earned on the link it is no longer on.
"""
for endpoint in [key for key in self.backoffs if key.startswith("wss://") and key not in present]:
self.backoffs.pop(endpoint, None)
self.failures.pop(endpoint, None)
def serve(self, endpoint: str, exit_event: threading.Event | None) -> None:
try:
self.dial(endpoint, exit_event)
except Exception as e:
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.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_rpc(self, ws: WebSocket, methods: dict[str, Callable[..., object]],
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():
try:
raw = ws.recv()
except WebSocketTimeoutException:
continue
except Exception as e:
cloudlog.warning(f"sunnylinkd.session.{type(e).__name__}")
return
try:
msg = rpc_module.loads(raw)
except Exception:
continue
if not rpc_module.is_call(msg):
continue
try:
response = rpc_module.handle(msg, methods)
except ValueError:
continue
try:
ws.send(response)
except Exception as e:
cloudlog.warning(f"sunnylinkd.send.{type(e).__name__}")
return
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_daemon.get_authority()
if authority is None:
raise AuthorizationError("enrollment unavailable")
with authority.lock:
pending_key_id = authority.pending.key_id if authority.pending is not None else None
candidates = ([pending_key_id] if pending_key_id is not None else []) + list(authority.grants)
ws: WebSocket | None = None
matched: str | None = None
last_error: Exception | None = None
for key_id in dict.fromkeys(candidates):
try:
ws = connect_pinned(endpoint, key_id)
matched = key_id
break
except Exception as e:
last_error = e
if ws is None or matched is None:
raise last_error or AuthorizationError("no enrolled app at this endpoint")
session: AuthorizedSession | None = None
try:
self.authenticate_phone(ws, authority, matched)
if pending_key_id is not None and matched == pending_key_id:
try:
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.local.confirm_failed")
local_auth_daemon.cancel_local_pairing()
raise
with authority.lock:
grant = authority.grants.get(matched)
if grant is None:
raise AuthorizationError("peer is not enrolled")
session = authority.session(grant, matched)
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()
close_quietly(ws)
def authenticate_phone(self, ws: WebSocket, authority: LocalAuthority, phone_key_id: str) -> None:
"""Answer the phone's fresh challenge, so it can authenticate THIS device first.
Nothing else is sent, and no request is served, on a session the phone has not verified.
"""
ws.settimeout(SUNNYLINK_RECONNECT_TIMEOUT_S)
raw = ws.recv()
if not isinstance(raw, str):
raise AuthorizationError("invalid challenge")
data = parse_object(raw)
if set(data) != {"v", "purpose", "phone_key_id", "challenge"} \
or type(data["v"]) is not int or data["v"] != VERSION \
or data["purpose"] != AUTHENTICATE_PURPOSE \
or data["phone_key_id"] != phone_key_id:
raise AuthorizationError("invalid challenge")
ws.send(json.dumps(authority.authenticate_device(phone_key_id, data["challenge"]), separators=(",", ":")))
def session_methods(self, session: AuthorizedSession) -> dict[str, Callable[..., object]]:
"""Strictly LOCAL_METHODS, re-authorized by the session immediately before each call."""
def gated(name: str, handler: Callable[..., object]) -> Callable[..., object]:
def call(*args: object, **kwargs: object) -> object:
return session.invoke(name, handler, *args, **kwargs)
return call
def unpair_local_app(app_id: str = "") -> dict[str, bool]:
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_daemon.set_grant_alias(session.grant.key_id, alias)
except AuthorizationError:
return {"success": False, "error": "invalid alias"}
return {"success": True, "updated": True}
# This daemon's own handlers win, so an overridden method is the one a session reaches.
overridden = ("unpairLocalApp", "updateLocalAppAlias")
surface = self.methods()
methods: dict[str, Callable[..., object]] = {}
for name in LOCAL_METHODS:
if name in overridden:
continue
handler = surface.get(name) or dispatcher.get(name)
if handler is not None:
methods[name] = gated(name, handler)
methods["unpairLocalApp"] = gated("unpairLocalApp", unpair_local_app)
methods["updateLocalAppAlias"] = gated("updateLocalAppAlias", update_local_app_alias)
return methods
def pinned_endpoint(beacon_endpoint: str) -> str | None:
"""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
return f"wss://{parsed.hostname}:{parsed.port}"
def close_quietly(ws: WebSocket | None) -> None:
if ws is None:
return
try:
ws.close()
except Exception:
pass
def main(exit_event: threading.Event | None = None):
try:
set_core_affinity([0, 1, 2, 3])
except Exception:
cloudlog.exception("failed to set core affinity")
daemon = LocalSunnylinkd()
for method in daemon.methods().values():
dispatcher.add_method(method)
daemon.run(exit_event)
if __name__ == "__main__":
main()
@@ -16,6 +16,7 @@ import ssl
import threading
import time
from collections.abc import Callable
from functools import partial
from openpilot.system.athena.rpc import dispatcher
from openpilot.common.params import Params, ParamKeyType
@@ -34,6 +35,7 @@ from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
from openpilot.sunnypilot.sunnylink.athena.local_params import cloud_may_write, is_local_param
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
@@ -42,8 +44,6 @@ SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload"
SUNNYLINK_RECONNECT_TIMEOUT_S = 70 # FYI changing this will also would require a change on sidebar.cc
DISALLOW_LOG_UPLOAD = threading.Event()
params = Params()
# Parameters that should never be remotely modified
BLOCKED_PARAMS = {
"AdbEnabled",
@@ -58,60 +58,6 @@ BLOCKED_PARAMS = {
}
def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None:
cloudlog.info("sunnylinkd.handle_long_poll started")
sm = messaging.SubMaster(['deviceState'])
end_event = threading.Event()
comma_prime_cellular_end_event = threading.Event()
threads = [
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
] + [
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(startLocalProxy, end_event),), name=f'worker_{x}')
for x in range(HANDLER_THREADS)
]
for thread in threads:
thread.start()
try:
while not end_event.wait(0.1):
if not sunnylink_ready(params):
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
break
sm.update(0)
if exit_event is not None and exit_event.is_set():
end_event.set()
comma_prime_cellular_end_event.set()
prime_type = params.get("PrimeType") or 0
metered = sm['deviceState'].networkMetered
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
comma_prime_cellular_end_event.set()
elif metered and int(prime_type) > 2:
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
comma_prime_cellular_end_event.set()
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
cloudlog.debug(
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
comma_prime_cellular_end_event.clear()
finally:
end_event.set()
comma_prime_cellular_end_event.set()
for thread in threads:
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
thread.join()
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
def ws_recv(ws: WebSocket, end_event: threading.Event) -> None:
last_ping = int(time.monotonic() * 1e9)
while not end_event.is_set():
@@ -158,112 +104,186 @@ def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event
comma_prime_cellular_end_event.set()
@dispatcher.add_method
def toggleLogUpload(enabled: bool):
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
class Sunnylinkd:
"""The cloud half of sunnylink: the athena connection that serves the settings RPCs.
@dispatcher.add_method
def getParamsAllKeys() -> list[str]:
keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
return keys
@dispatcher.add_method
def getParamsMetadata() -> str:
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
Reads settings_ui.json, injects live capabilities from CarParams, compresses,
and returns. Single RPC for the frontend to get the complete settings UI and
runtime capabilities.
The LAN half is `localsunnylinkd.LocalSunnylinkd`, which subclasses this and overrides only the
RPCs and helpers it changes. Every method here is shared, and none of them may read or write a
param the local side owns.
"""
try:
schema = generate_schema()
schema["capabilities"] = generate_capabilities()
schema["capability_labels"] = CAPABILITY_LABELS
schema["default_model"] = DEFAULT_MODEL
schema["default_big_model"] = DEFAULT_BIG_MODEL
schema["chestnut_active"] = params.get_bool("ChestnutActive")
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
except Exception:
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
raise
# What this daemon serves over the wire. `startLocalProxy` is deliberately absent: the athena
# jsonrpc workers install it on the dispatcher themselves, with the event this daemon owns.
RPC_METHODS = ("toggleLogUpload", "getParamsAllKeys", "getParamsMetadata", "getParams", "saveParams")
@dispatcher.add_method
def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
params = Params()
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
def __init__(self, params: Params | None = None):
self.params = params or Params()
try:
zero_values: dict[int, bytes] = {
ParamKeyType.STRING.value: b"",
ParamKeyType.BOOL.value: b"0",
ParamKeyType.INT.value: b"0",
ParamKeyType.FLOAT.value: b"0.0",
ParamKeyType.TIME.value: b"",
ParamKeyType.JSON.value: b"{}",
ParamKeyType.BYTES.value: b"",
}
def methods(self) -> dict[str, Callable[..., object]]:
"""The RPC surface this daemon serves, keyed by wire method name."""
return {name: getattr(self, name) for name in self.RPC_METHODS}
param_keys_validated = [key for key in params_keys if key in available_keys]
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
for key in param_keys_validated:
value = get_param_as_byte(key)
if value is None:
value = get_param_as_byte(key, get_default=True)
if value is None:
param_type = params.get_type(key)
value = zero_values.get(param_type.value, b"")
def handle_long_poll(self, ws: WebSocket, exit_event: threading.Event | None) -> None:
cloudlog.info("sunnylinkd.handle_long_poll started")
sm = messaging.SubMaster(['deviceState'])
end_event = threading.Event()
comma_prime_cellular_end_event = threading.Event()
params_dict["params"].append({
"key": key,
"value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'),
"type": int(params.get_type(key).value),
"is_compressed": compression
})
threads = [
threading.Thread(target=ws_manage, args=(ws, end_event), name='ws_manage'),
threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'),
threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'),
threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'),
threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'),
threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'),
threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'),
] + [
threading.Thread(target=jsonrpc_handler, args=(end_event, partial(self.startLocalProxy, end_event),), name=f'worker_{x}')
for x in range(HANDLER_THREADS)
]
response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])}
response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1
return response
for thread in threads:
thread.start()
try:
while not end_event.wait(0.1):
if not sunnylink_ready(self.params):
cloudlog.warning("Exiting sunnylinkd.handle_long_poll as SunnylinkEnabled is False")
break
except Exception as e:
cloudlog.exception("sunnylinkd.getParams.exception", e)
raise
sm.update(0)
if exit_event is not None and exit_event.is_set():
end_event.set()
comma_prime_cellular_end_event.set()
prime_type = self.params.get("PrimeType") or 0
metered = sm['deviceState'].networkMetered
@dispatcher.add_method
def saveParams(params_to_update: dict[str, str], compression: bool = False) -> None:
for key, value in params_to_update.items():
# disallow modifications to blocked parameters
if key in BLOCKED_PARAMS:
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
continue
if DISALLOW_LOG_UPLOAD.is_set() and not comma_prime_cellular_end_event.is_set():
cloudlog.debug("sunnylinkd.handle_long_poll: DISALLOW_LOG_UPLOAD, setting comma_prime_cellular_end_event")
comma_prime_cellular_end_event.set()
elif metered and int(prime_type) > 2:
cloudlog.debug(f"sunnylinkd.handle_long_poll: PrimeType({prime_type}) > 2 and networkMetered({metered})")
comma_prime_cellular_end_event.set()
elif comma_prime_cellular_end_event.is_set() and not DISALLOW_LOG_UPLOAD.is_set():
cloudlog.debug(
f"sunnylinkd.handle_long_poll: comma_prime_cellular_end_event is set and not PrimeType({prime_type}) > 2 or not networkMetered({metered})")
comma_prime_cellular_end_event.clear()
finally:
end_event.set()
comma_prime_cellular_end_event.set()
for thread in threads:
cloudlog.debug(f"sunnylinkd athena.joining {thread.name}")
thread.join()
cloudlog.debug(f"sunnylinkd athena.joined {thread.name}")
def toggleLogUpload(self, enabled: bool):
DISALLOW_LOG_UPLOAD.clear() if enabled and DISALLOW_LOG_UPLOAD.is_set() else DISALLOW_LOG_UPLOAD.set()
def getParamsAllKeys(self) -> list[str]:
keys: list[str] = [k.decode('utf-8') for k in self.params.all_keys()]
return [key for key in keys if not is_local_param(key)]
def getParamsMetadata(self) -> str:
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
Reads settings_ui.json, injects live capabilities from CarParams, compresses,
and returns. Single RPC for the frontend to get the complete settings UI and
runtime capabilities.
"""
try:
schema = generate_schema()
schema["capabilities"] = generate_capabilities()
schema["capability_labels"] = CAPABILITY_LABELS
schema["default_model"] = DEFAULT_MODEL
schema["default_big_model"] = DEFAULT_BIG_MODEL
schema["chestnut_active"] = self.params.get_bool("ChestnutActive")
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
except Exception:
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
raise
def getParams(self, params_keys: list[str], compression: bool = False) -> str | dict[str, str]:
available_keys: list[str] = [k.decode('utf-8') for k in self.params.all_keys()]
try:
save_param_from_base64_encoded_string(key, value, compression)
zero_values: dict[int, bytes] = {
ParamKeyType.STRING.value: b"",
ParamKeyType.BOOL.value: b"0",
ParamKeyType.INT.value: b"0",
ParamKeyType.FLOAT.value: b"0.0",
ParamKeyType.TIME.value: b"",
ParamKeyType.JSON.value: b"{}",
ParamKeyType.BYTES.value: b"",
}
param_keys_validated = [key for key in params_keys if key in available_keys and not is_local_param(key)]
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
for key in param_keys_validated:
value = get_param_as_byte(key)
if value is None:
value = get_param_as_byte(key, get_default=True)
if value is None:
param_type = self.params.get_type(key)
value = zero_values.get(param_type.value, b"")
params_dict["params"].append({
"key": key,
"value": base64.b64encode(gzip.compress(value) if compression else value).decode('utf-8'),
"type": int(self.params.get_type(key).value),
"is_compressed": compression
})
response = {str(param.get('key')): str(param.get('value')) for param in params_dict.get("params", [])}
response |= {"params": json.dumps(params_dict.get("params", []))} # Upcoming for settings v1
return response
except Exception as e:
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
cloudlog.exception("sunnylinkd.getParams.exception", e)
raise
# Increment version counter for frontend change detection
try:
current = int(params.get("ParamsVersion") or "0")
params.put("ParamsVersion", str(current + 1), block=True)
except Exception:
pass
def saveParams(self, params_to_update: dict[str, str], compression: bool = False) -> None:
for key, value in params_to_update.items():
# disallow modifications to blocked parameters, and to anything the local side owns
if key in BLOCKED_PARAMS or not cloud_may_write(key):
cloudlog.warning(f"sunnylinkd.saveParams.blocked: Attempted to modify blocked parameter '{key}'")
continue
try:
save_param_from_base64_encoded_string(key, value, compression)
except Exception as e:
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
# Increment version counter for frontend change detection
try:
current = int(self.params.get("ParamsVersion") or "0")
self.params.put("ParamsVersion", str(current + 1), block=True)
except Exception:
pass
def startLocalProxy(self, global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
sunnylink_dongle_id = self.params.get("SunnylinkDongleId")
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
cloudlog.debug("athena.startLocalProxy.starting")
ws = create_connection(
remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE}
)
return start_local_proxy_shim(global_end_event, local_port, ws)
def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
sunnylink_dongle_id = params.get("SunnylinkDongleId")
sunnylink_api = SunnylinkApi(sunnylink_dongle_id)
cloudlog.debug("athena.startLocalProxy.starting")
ws = create_connection(
remote_ws_uri, header={"Authorization": f"Bearer {sunnylink_api.get_token()}"}, enable_multithread=True, sslopt={"cert_reqs": ssl.CERT_NONE}
)
return start_local_proxy_shim(global_end_event, local_port, ws)
def log_connection_error(e: Exception) -> None:
"""Report a failed connection the way stock sunnylinkd.main did; shared with localsunnylinkd."""
if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)):
cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}")
elif isinstance(e, OSError):
name = errno.errorcode.get(e.errno or -1, "UNKNOWN")
msg = f"sunnylinkd.main.OSError.{name} ({e.errno})"
is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH)
cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg)
else:
cloudlog.exception("sunnylinkd.main.exception")
def main(exit_event: threading.Event | None = None):
@@ -272,6 +292,12 @@ def main(exit_event: threading.Event | None = None):
except Exception:
cloudlog.exception("failed to set core affinity")
daemon = Sunnylinkd()
for method in daemon.methods().values():
dispatcher.add_method(method)
params = daemon.params
while sunnylink_need_register(params):
cloudlog.info("Waiting for sunnylink registration to complete")
time.sleep(10)
@@ -305,22 +331,13 @@ def main(exit_event: threading.Event | None = None):
conn_retries = 0
cur_upload_items.clear()
handle_long_poll(ws, exit_event)
daemon.handle_long_poll(ws, exit_event)
except (KeyboardInterrupt, SystemExit):
break
except Exception as e:
conn_retries += 1
params.remove("LastSunnylinkPingTime")
if isinstance(e, (ConnectionError, TimeoutError, WebSocketException)):
cloudlog.warning(f"sunnylinkd.main.{type(e).__name__}")
elif isinstance(e, OSError):
name = errno.errorcode.get(e.errno or -1, "UNKNOWN")
msg = f"sunnylinkd.main.OSError.{name} ({e.errno})"
is_expected_error = e.errno in (errno.ENETDOWN, errno.ENETRESET, errno.ENETUNREACH)
cloudlog.warning(msg) if is_expected_error else cloudlog.exception(msg)
else:
cloudlog.exception("sunnylinkd.main.exception")
log_connection_error(e)
time.sleep(backoff(conn_retries))
@@ -0,0 +1,73 @@
from typing import cast
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena.local_app_rows import (
DEFAULT_APP_NAME,
MAX_LOCAL_APP_ROWS,
NEVER_CONNECTED,
build_local_app_rows,
format_age,
short_key,
)
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"}
class TestLocalAppRows(OpenpilotTestCase):
"""The device screen's rows: one per enrolled phone."""
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_a_nameless_grant_still_gets_a_title(self):
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)
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)])
self.assertEqual(MAX_GRANTS, len(rows))
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"}})
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)
def test_each_phone_reports_when_it_was_last_connected(self):
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)
self.assertTrue(NEVER_CONNECTED in rows[0].subtitle)
def test_format_age_is_coarse_and_readable(self):
self.assertEqual("just now", format_age(0))
self.assertEqual("just now", format_age(59.9))
self.assertEqual("5m ago", format_age(300))
self.assertEqual("3h ago", format_age(3 * 3600))
self.assertEqual("2d ago", format_age(2 * 86_400))
@@ -0,0 +1,388 @@
import base64
import hashlib
import json
import socket
import ssl
import tempfile
import threading
from pathlib import Path
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from unittest import mock
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
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 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,
)
# The fields an enrollment signs, in the order the statement joins them.
ENROLL_FIELDS = ("cloud_device_id", "comma_device_id", "device_key_id", "pairing_session",
"public_key", "key_id", "app_name")
# A refused pin makes the connector hang up mid-handshake, so the server side of a real socket
# sees the abort as one of these depending on whether it was reading or writing when the peer
# went away. That race is not the property under test — the assertions that count are that the
# client refused before any HTTP request and that the server received nothing (see below).
ABORTED_CONNECTION_ERRORS = (BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
ssl.SSLEOFError, ssl.SSLError)
class TestLocalAuth(OpenpilotTestCase):
def setup_method(self):
self.now = 10.0
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
self.writes = []
self.authority = LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append, clock=lambda: self.now)
self.qr = self.authority.arm()
def enrollment(self, app_key=None, **changes):
app_key = app_key or self.app_key
public = encode64(app_key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
data = {"v": 2, "cloud_device_id": self.authority.cloud_device_id, "comma_device_id": self.authority.comma_device_id,
"device_key_id": self.authority.device_key_id,
"pairing_session": encode64(decode_qr(self.qr).session),
"public_key": public, "key_id": key_id(app_key.public_key()), "app_name": "Test phone"}
data.update(changes)
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
data["signature"] = encode64(app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
return json.dumps(data)
def committed(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
return self.authority.confirm(grant, grant.key_id)
def test_qr_is_public_fresh_metadata_not_a_capability(self):
"""The code names one device and one window; it carries no key, no signature and no secret."""
self.assertEqual(len(self.qr.split(".")), 1)
frame = decode64(self.qr)
payload = decode_qr(self.qr)
self.assertEqual(payload.cloud_device_id, self.authority.cloud_device_id)
self.assertEqual(payload.session, decode64(self.authority.window.nonce))
self.assertEqual(len(payload.session), QR_SESSION_BYTES)
self.assertEqual(payload.ttl_s, 120)
# Framing is exact: no padding, no extra field, no room for a key id or a signature.
self.assertEqual(frame, qr_frame(self.authority.cloud_device_id, payload.session, 120))
self.assertEqual(len(frame), len("SLEN") + 2 + len(self.authority.cloud_device_id) + QR_SESSION_BYTES + 1)
# Freshness is per window: re-arming publishes a different session.
self.assertNotEqual(decode_qr(self.authority.arm()).session, payload.session)
def test_the_code_is_readable_off_a_240px_panel_whatever_the_identity_key_is(self):
"""An RSA identity key is what a real device has, and a device signature made the code 77
modules — 2.73 px/module on the mici panel, which is not scannable. Both key types mint the
same code now, and the identity key is still what proves the device to the phone."""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
authority = LocalAuthority("cloud-device", "comma-device", key, self.writes.append)
qr = authority.arm()
self.assertEqual(len(qr), len(self.qr))
payload = decode_qr(qr)
self.assertEqual(payload.cloud_device_id, "cloud-device")
self.assertEqual(payload.ttl_s, PAIRING_TTL_S)
challenge = encode64(bytes(32))
proof = authority.authenticate_device("phone-key", challenge)
self.assertEqual(proof["device_key_id"], authority.device_key_id)
expected = statement("authenticate-device", "cloud-device", "comma-device",
authority.device_key_id, "phone-key", challenge)
key.public_key().verify(decode64(proof["signature"]), expected, padding.PKCS1v15(), hashes.SHA256())
def test_device_authenticates_itself_to_the_phone_challenge(self):
grant = self.committed()
challenge = encode64(bytes(32))
proof = self.authority.authenticate_device(grant.key_id, challenge)
self.assertEqual(proof["device_key_id"], self.authority.device_key_id)
self.assertEqual(proof["phone_key_id"], grant.key_id)
expected = statement("authenticate-device", "cloud-device", "comma-device",
self.authority.device_key_id, grant.key_id, challenge)
self.device_key.public_key().verify(decode64(proof["signature"]), expected, ec.ECDSA(hashes.SHA256()))
with self.assertRaises(AuthorizationError):
self.authority.authenticate_device(grant.key_id, encode64(bytes(31)))
def test_local_origin_cannot_enroll_even_with_correct_qr_and_key(self):
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.LOCAL)
self.assertEqual(self.writes, [])
self.assertIsNone(self.authority.pending)
def test_pending_grant_is_not_authorized_and_requires_tls_proof(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.assertEqual(self.writes, [])
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
with self.assertRaises(AuthorizationError):
self.authority.confirm(grant, key_id(self.device_key.public_key()))
self.assertEqual(self.writes, [])
self.authority.confirm(grant, grant.key_id)
self.assertEqual(len(self.writes), 1)
def test_same_key_retries_are_idempotent_but_competing_scanner_cannot_replace_key(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.assertEqual(grant, self.authority.enroll(self.enrollment(), Origin.CLOUD))
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(ec.generate_private_key(ec.SECP256R1())), Origin.CLOUD)
self.assertEqual(self.authority.pending, grant)
def test_a_late_enrollment_lands_inside_its_delivery_grace(self):
"""The cloud is store-and-forward: the enrollment for the code the user scanned reaches the
device after that code has left its screen, and it still has to enroll and still has to be
committable over the pinned dial. Every step below used to fail silently."""
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.authority.expire() # the code left the screen …
self.now = PAIRING_TTL_S + 1 # … and its scannable lifetime ended
self.assertEqual(self.authority.confirm(grant, grant.key_id), grant)
self.assertEqual(len(self.writes), 1)
def test_an_enrollment_past_the_delivery_grace_is_refused(self):
self.authority.expire()
window = self.authority.recent[-1]
self.assertEqual(window.usable_until, window.deadline + PAIRING_DELIVERY_GRACE_S)
self.now = window.usable_until + 1
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_cancelling_a_window_refuses_an_in_flight_enrollment(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
self.authority.expire()
self.authority.cancel()
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.CLOUD)
with self.assertRaises(AuthorizationError):
self.authority.confirm(grant, grant.key_id)
self.assertEqual(self.writes, [])
def test_cancel_and_restart_invalidate_an_old_enrollment(self):
raw = self.enrollment()
grant = self.authority.enroll(raw, Origin.CLOUD)
self.authority.cancel()
with self.assertRaises(AuthorizationError):
self.authority.confirm(grant, grant.key_id)
with self.assertRaises(AuthorizationError):
self.authority.enroll(raw, Origin.CLOUD)
restarted = LocalAuthority("cloud-device", "comma-device", self.device_key, self.writes.append)
with self.assertRaises(AuthorizationError):
restarted.enroll(raw, Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_a_rearm_keeps_the_previous_code_in_flight(self):
"""A user who retries gets a fresh code. The enrollment for the code they scanned first can
still be on its way through the cloud, so a re-arm must not invalidate it."""
raw = self.enrollment()
self.assertIsNotNone(self.authority.enroll(raw, Origin.CLOUD))
self.authority.expire()
self.authority.arm()
self.assertEqual(self.authority.enroll(raw, Origin.CLOUD), self.authority.pending)
def test_the_enrollment_nonce_is_the_code_session_and_nothing_else(self):
"""The window mints a 16-byte session, so that is the only nonce an enrollment may name. Both
payloads below carry a valid signature over the altered field, so the refusal is this rule."""
for wrong in (encode64(b"x" * 32), encode64(b"x" * 8)):
with self.subTest(nonce=wrong), self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(pairing_session=wrong), Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_signature_binds_all_enrollment_fields(self):
for field, value in (("app_name", "Other phone"), ("key_id", "other"), ("device_key_id", "other"), ("comma_device_id", "other"),
("cloud_device_id", "other"), ("pairing_session", encode64(b"x" * QR_SESSION_BYTES))):
with self.subTest(field=field):
data = json.loads(self.enrollment())
data[field] = value
with self.assertRaises(AuthorizationError):
self.authority.enroll(json.dumps(data), Origin.CLOUD)
self.assertEqual(self.writes, [])
def test_bad_versions_fields_keys_and_messages_rejected(self):
for change in ({"v": True}, {"v": 1}, {"extra": "bad"}, {"public_key": "garbage"}, {"signature": "garbage"},
{"app_name": "bad\nname"}, {"app_name": "x" * 81}):
with self.subTest(change=change):
data = json.loads(self.enrollment())
data.update(change)
with self.assertRaises((AuthorizationError, ValueError)):
self.authority.enroll(json.dumps(data), Origin.CLOUD)
for raw in ('{"v":2,"v":2}', '[]', 'null', 'x' * 4097):
with self.assertRaises(AuthorizationError):
parse_object(raw)
def test_persistence_failure_never_publishes_grant(self):
grant = self.authority.enroll(self.enrollment(), Origin.CLOUD)
with mock.patch.object(self.authority, "persist", side_effect=OSError("disk full")):
with self.assertRaises(OSError):
self.authority.confirm(grant, grant.key_id)
self.assertEqual(self.authority.grants, {})
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
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": "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, {})
def test_revocation_and_closed_session_prevent_handler_execution(self):
grant = self.committed()
session = self.authority.session(grant, grant.key_id)
handler = mock.Mock(return_value="ok")
self.assertEqual(session.invoke("getParams", handler), "ok")
self.authority.revoke(grant.key_id)
with self.assertRaises(AuthorizationError):
session.invoke("saveParams", handler)
self.assertEqual(handler.call_count, 1)
with self.assertRaises(AuthorizationError):
self.authority.session(grant, grant.key_id)
with self.assertRaises(AuthorizationError):
self.authority.enroll(self.enrollment(), Origin.CLOUD)
def test_wrong_peer_revision_and_denied_methods_cannot_dispatch(self):
grant = self.committed()
handler = mock.Mock()
with self.assertRaises(AuthorizationError):
self.authority.session(grant, "wrong")
with self.assertRaises(AuthorizationError):
self.authority.session(replace(grant, revision=encode64(b"x" * 32)), grant.key_id)
session = self.authority.session(grant, grant.key_id)
for method in ("startLocalProxy", "pairLocalApp", "uploadFileToUrl", "startStream", "setNavDestination", "unknown"):
with self.assertRaises(AuthorizationError):
session.invoke(method, handler)
session.close()
with self.assertRaises(AuthorizationError):
session.invoke("getMessage", handler)
handler.assert_not_called()
def test_same_installation_key_can_enroll_multiple_devices(self):
original = self.committed()
self.authority = LocalAuthority("second-cloud-device", "second-comma-device", self.device_key, self.writes.append, clock=lambda: self.now)
self.qr = self.authority.arm()
second = self.committed()
self.assertEqual(original.key_id, second.key_id)
self.assertNotEqual(original.revision, second.revision)
def test_canonical_encoding_and_field_limits(self):
for value in ("", "Zg==", "Zh", "a", "💥", 123, "Z g", "Zg\n"):
with self.assertRaises(AuthorizationError):
decode64(value)
self.assertEqual(decode64("Zg"), b"f")
with self.assertRaises(AuthorizationError):
Grant.from_dict({})
def test_pinned_connector_rejects_downgrade_and_invalid_endpoints_before_dial(self):
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"):
with self.subTest(endpoint=endpoint), self.assertRaises(AuthorizationError):
connect_pinned(endpoint, key_id(self.app_key.public_key()))
dial.assert_not_called()
def test_wrong_tls_pin_closes_socket_before_websocket_upgrade(self):
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
transport.getpeercert.return_value = b"certificate"
with self.assertRaises(AuthorizationError):
connect_pinned("wss://192.168.1.2:8443", key_id(self.app_key.public_key()))
transport.close.assert_called_once()
upgrade.assert_not_called()
def test_real_tls_connector_pins_before_http_and_requires_tls13(self):
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test phone")])
now = datetime.now(UTC)
certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(self.app_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1)).sign(self.app_key, hashes.SHA256()))
with tempfile.TemporaryDirectory() as temporary:
cert_path = Path(temporary) / "certificate.pem"
key_path = Path(temporary) / "private.pem"
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(self.app_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_3
context.load_cert_chain(str(cert_path), str(key_path))
for valid in (True, False):
with self.subTest(valid=valid), socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
listener.listen(1)
listener.settimeout(5)
received = []
errors = []
# Every loop-scoped name is bound as a default argument: this runs on another thread
# while the enclosing loop moves on to the next case.
def serve(listener=listener, received=received, errors=errors, valid=valid):
try:
incoming, _ = listener.accept()
with incoming, context.wrap_socket(incoming, server_side=True) as tls:
tls.settimeout(5)
self.assertEqual(tls.version(), "TLSv1.3")
request = b""
while b"\r\n\r\n" not in request:
piece = tls.recv(4096)
if not piece:
break
request += piece
received.append(request)
if not request:
return
headers = dict(line.split(": ", 1) for line in request.decode().split("\r\n")[1:] if ": " in line)
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\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
# valid case is a real failure.
if valid:
errors.append(e)
except Exception as e:
errors.append(e)
thread = threading.Thread(target=serve)
thread.start()
try:
endpoint = f"wss://127.0.0.1:{listener.getsockname()[1]}"
if valid:
client = connect_pinned(endpoint, key_id(self.app_key.public_key()), timeout_s=5)
self.assertEqual(client.sock.version(), "TLSv1.3")
client.close(timeout=1)
else:
with self.assertRaises(AuthorizationError):
connect_pinned(endpoint, key_id(self.device_key.public_key()), timeout_s=5)
finally:
thread.join(timeout=6)
self.assertFalse(thread.is_alive())
# An abort other than the client's own refusal is a failure in both cases.
self.assertEqual(errors, [])
if valid:
# The accepted path is strict: the handshake completes, exactly one request arrives
# and it carries no credential header.
self.assertEqual(len(received), 1)
self.assertTrue(b"GET / HTTP/1.1" in received[0])
self.assertNotIn(b"Authorization", received[0])
else:
# Refusing the pin races the server's own handshake, so the server either lost the
# connection inside `wrap_socket` (nothing recorded) or saw a socket the client
# closed without a request (one empty record). The property that matters is the same
# in both: not one byte of HTTP reached the server.
self.assertLessEqual(len(received), 1)
self.assertEqual([piece for piece in received if piece], [])
def test_tls_pin_checks_key_not_name(self):
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "sunnylink mobile")])
now = datetime.now(UTC)
cert = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(self.app_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(now - timedelta(minutes=1)).not_valid_after(now + timedelta(days=1))
.sign(self.app_key, hashes.SHA256()).public_bytes(serialization.Encoding.DER))
self.assertEqual(verify_tls_peer(cert, key_id(self.app_key.public_key())), key_id(self.app_key.public_key()))
for bad, expected in ((cert, key_id(self.device_key.public_key())), (b"garbage", key_id(self.app_key.public_key()))):
with self.assertRaises(AuthorizationError):
verify_tls_peer(bad, expected)
@@ -0,0 +1,526 @@
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, cast
from unittest import mock
from openpilot.common.params import Params
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_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AuthorizationError,
Grant,
LocalAuthority,
Origin,
encode64,
key_id,
statement,
)
# `<repo>/openpilot/common/params_keys.h` from this file.
PARAMS_KEYS_H = Path(__file__).resolve().parents[4] / "common" / "params_keys.h"
# The fields an enrollment signs, in the order the statement joins them.
ENROLL_FIELDS = ("cloud_device_id", "comma_device_id", "device_key_id",
"pairing_session", "public_key", "key_id", "app_name")
class _ParamsStore:
"""Dict-backed stand-in for the Params surface this module uses.
It deliberately models a JSON-typed param the way libparams does: a dict put in comes back
out as the same dict. That contract is exactly what `_persist_registry` and `_read_registry`
have to agree on, so a mismatched pair (a second base64 decode, say) fails here instead of
only on a device.
"""
def __init__(self, initial: dict[str, Any] | None = None):
self.values: dict[str, Any] = dict(initial or {})
self.removed: list[str] = []
self.fail_reads = False
def get(self, key: str, block: bool = False, return_default: bool = False) -> Any:
if self.fail_reads:
raise RuntimeError("params unavailable")
return self.values.get(key)
def get_bool(self, key: str, block: bool = False) -> bool:
if self.fail_reads:
raise RuntimeError("params unavailable")
return bool(self.values.get(key))
def put(self, key: str, value: Any, block: bool = False) -> None:
self.values[key] = value
def put_bool(self, key: str, value: bool, block: bool = False) -> None:
self.values[key] = bool(value)
def remove(self, key: str) -> None:
self.values.pop(key, None)
self.removed.append(key)
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())
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_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
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()
self.addCleanup(patcher.stop)
def enrollment(self, authority) -> str:
authority.arm()
public = encode64(self.app_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
nonce = authority.window.nonce
data = {"v": 2, "cloud_device_id": authority.cloud_device_id, "comma_device_id": authority.comma_device_id,
"device_key_id": authority.device_key_id, "pairing_session": nonce,
"public_key": public, "key_id": key_id(self.app_key.public_key()), "app_name": "Test phone"}
proof = statement("enroll", *(str(data[field]) for field in ENROLL_FIELDS))
data["signature"] = encode64(self.app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
return json.dumps(data)
def enrolled(self) -> dict[str, str]:
"""Enroll one phone end to end: cloud enrollment staged, then TLS-confirmed."""
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."""
key = app_key or self.app_key
app_id = key_id(key.public_key())
proof = statement("revoke", authority.cloud_device_id, authority.comma_device_id, app_id)
return json.dumps({"v": 2, "cloud_device_id": authority.cloud_device_id,
"comma_device_id": authority.comma_device_id, "key_id": app_id,
"signature": encode64(key.sign(proof, ec.ECDSA(hashes.SHA256())))})
def registry(self) -> dict[str, Any]:
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_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_daemon.CLOUD_ENROLL_KEY, raw)
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
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_daemon.CLOUD_ENROLL_KEY))
def test_a_cloud_enrollment_for_a_code_past_its_grace_is_refused(self):
authority = local_auth_daemon.get_authority()
raw = self.enrollment(authority)
window = authority.recent[-1]
authority.expire()
self.store.put(local_auth_daemon.CLOUD_ENROLL_KEY, raw)
with mock.patch.object(authority, "clock", lambda: window.usable_until + 1):
local_auth_daemon.claim_cloud_commands(cast(Params, self.store))
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_daemon.cloud_enroll(self.enrollment(local_auth_daemon.get_authority()))
# Staged: nothing durable until the TLS peer proves possession.
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"])
self.assertEqual(1, len(registry["grants"]))
self.assertEqual(summary["app_id"], registry["grants"][0]["app_id"])
def test_grants_reload_from_the_persisted_registry(self):
"""The write and read halves must agree: a grant has to survive a daemon restart."""
summary = self.enrolled()
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"])
def test_registry_round_trip_is_the_json_param_itself(self):
self.enrolled()
registry = self.registry()
# Stored as the JSON param, not the base64 cloud-transport form: reading it back through the
# same module must return the identical structure.
self.assertIsInstance(registry, dict)
self.assertEqual({"v", "grants"}, set(registry))
self.assertIs(local_auth_daemon._read_registry(), registry)
def test_revocation_persists_removal(self):
summary = self.enrolled()
self.assertTrue(local_auth_daemon.revoke_local_app(summary["app_id"]))
self.assertEqual([], self.registry()["grants"])
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_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_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_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_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):
self.enrolled()
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_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_daemon.get_authority()))
payload["comma_device_id"] = "some-other-car"
with self.assertRaises(AuthorizationError):
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_daemon.get_authority()
with self.assertRaises(AuthorizationError):
authority.revoke_by_self_proof(self.revocation(authority), Origin.LOCAL)
self.assertEqual(1, len(self.registry()["grants"]))
def test_registry_key_is_declared_in_params_keys(self):
"""Every param this adapter stores must be registered, or Params raises UnknownKeyName.
That failure is not cosmetic: get_authority() is on the daemon's connection path, so an
undeclared key used to take sunnylinkd (and the cloud link with it) down.
"""
declared = PARAMS_KEYS_H.read_text()
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_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_daemon.get_authority())
self.assertEqual([], local_auth_daemon.grants())
self.assertFalse(local_auth_daemon.revoke_local_app("anything"))
with self.assertRaises(AuthorizationError):
local_auth_daemon.cloud_enroll("{}")
def test_no_identity_key_fails_closed(self):
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_daemon.cloud_enroll("{}")
class TestRegistryThroughRealParams(OpenpilotTestCase):
"""The same round trip against the real Params/libparams, in a temporary params directory.
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 `SunnylinkLocalApps`.
"""
def setup_method(self):
self.dir = tempfile.mkdtemp(prefix="sunnylink_params_")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.params = Params(self.dir)
self.params.put("SunnylinkDongleId", "cloud-device", block=True)
self.params.put("DongleId", "comma-device", block=True)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
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_daemon.get_authority()
self.assertIsNotNone(authority)
authority.arm()
nonce = authority.window.nonce
public = encode64(self.app_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
app_id = key_id(self.app_key.public_key())
data = {"v": 2, "cloud_device_id": "cloud-device", "comma_device_id": "comma-device",
"device_key_id": authority.device_key_id, "pairing_session": nonce, "public_key": public,
"key_id": app_id, "app_name": "Test phone"}
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_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_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_daemon.read_granted_apps())
# And a revocation persists through the same store.
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):
"""Display metadata for enrolled phones: cosmetic, and never a source of authority."""
def setup_method(self):
self.store = _ParamsStore()
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_daemon.set_grant_alias("key-a", "Nayan's phone")
local_auth_daemon.note_grant_seen("key-a")
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_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_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_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_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_daemon.read_grant_meta())
# on the session path, so it must swallow the fault rather than break a connection
local_auth_daemon.note_grant_seen("key-a")
def test_revoking_a_phone_drops_its_metadata(self):
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):
"""The device UI's read-only view of enrolled phones."""
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
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_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_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_daemon, "_build_authority",
side_effect=AssertionError("the UI must not load the identity key")):
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_daemon.read_granted_apps())
def test_no_registry_is_an_empty_list(self):
self.assertEqual([], local_auth_daemon.read_granted_apps())
class TestRevokeRequests(OpenpilotTestCase):
"""Revoking from the device UI is a request the daemon applies through its live authority."""
def setup_method(self):
self.store = _ParamsStore()
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_daemon, "revoke_local_app",
side_effect=lambda key: revoked.append(key) or True):
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_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_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_daemon.REVOKE_KEY] = "key-a"
self.store.fail_reads = True
self.assertFalse(local_auth_daemon.service_revoke_requests())
def test_blank_requests_are_ignored(self):
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_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_daemon.REVOKE_ALL_KEY, self.store.values)
self.assertEqual(1, len(cleared))
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_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_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_daemon.REVOKE_KEY, self.store.values)
self.assertNotIn(local_auth_daemon.REVOKE_ALL_KEY, self.store.values)
class TestRevokeAllThroughTheAuthority(OpenpilotTestCase):
"""`revoke_all` on the core: one durable write, and the grants really go away."""
def setup_method(self):
self.store = _ParamsStore()
self.writes: list[dict] = []
def persist(registry: dict) -> None:
self.writes.append(registry)
self.store.values[local_auth_daemon.LOCAL_APPS_KEY] = registry
self.authority = LocalAuthority(
cloud_device_id="cloud-device", comma_device_id="comma-device",
device_private_key=ec.generate_private_key(ec.SECP256R1()),
persist=persist,
registry=None,
)
def test_revoke_all_writes_once_and_empties_the_registry(self):
for key in ("key-a", "key-b"):
self.authority.grants[key] = Grant(key, "spki", key, "Phone", "rev")
self.assertEqual(2, self.authority.revoke_all())
self.assertEqual({}, self.authority.grants)
# One write for the whole action, and the persisted document is an empty registry.
self.assertEqual([{"v": 2, "grants": []}], self.writes)
def test_revoke_all_with_nothing_enrolled_writes_nothing(self):
self.assertEqual(0, self.authority.revoke_all())
self.assertEqual([], self.writes)
class TestPairingWindowParams(OpenpilotTestCase):
def setup_method(self):
self.store = _ParamsStore()
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_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_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)
@@ -0,0 +1,163 @@
from typing import Any, cast
from unittest import mock
from cryptography.hazmat.primitives import serialization
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_daemon
from openpilot.sunnypilot.sunnylink.athena.local_auth import decode_qr, encode64
class _FakeParams:
"""In-memory stand-in for the tiny Params surface the pairing window uses."""
def __init__(self, fail_reads: bool = False):
self.values: dict[str, Any] = {}
self.removed: list[str] = []
self.fail_reads = fail_reads
def get_bool(self, key: str) -> bool:
if self.fail_reads:
raise RuntimeError("params unavailable")
return bool(self.values.get(key))
def put_bool(self, key: str, value: bool, block: bool = False) -> None:
self.values[key] = bool(value)
def get(self, key: str) -> Any:
if self.fail_reads:
raise RuntimeError("params unavailable")
return self.values.get(key)
def put(self, key: str, value: Any, block: bool = False) -> None:
self.values[key] = value
def remove(self, key: str) -> None:
self.values.pop(key, None)
self.removed.append(key)
class TestLocalAuthPairingWindow(OpenpilotTestCase):
"""The UI (another process) arms through Params; the daemon owns the authority window."""
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.private_pem = self.device_key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())
self.store = _FakeParams()
self.params = cast(Params, self.store)
local_auth_daemon.reset_authority()
self.addCleanup(local_auth_daemon.reset_authority)
patchers = [
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()
self.addCleanup(patcher.stop)
def request_window(self) -> None:
"""What the device UI does when the user taps the secure-pairing button."""
self.store.put_bool(local_auth_daemon.PAIRING_REQUEST_KEY, True)
def arm(self) -> str:
self.request_window()
qr = local_auth_daemon.service_pairing_window(self.params)
assert qr is not None
return qr
def published(self) -> str | None:
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_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_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_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_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_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_daemon.PAIRING_QR_KEY)
# The UI cancel only reaches the params; the daemon's tick then cancels the real window.
self.assertIsNone(local_auth_daemon.service_pairing_window(self.params))
self.assertIsNone(self.published())
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_daemon.get_authority()
window = authority.window
with mock.patch.object(authority, "clock", lambda: window.deadline + 1):
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
# the window itself (and any grant it staged) is gone.
self.assertIsNone(authority.window)
self.assertEqual([w.nonce for w in authority.recent], [window.nonce])
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_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_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_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_daemon.PAIRING_REQUEST_KEY, self.store.values)
def test_clear_pairing_window_clears_both_params_and_the_window(self):
self.arm()
local_auth_daemon.clear_pairing_window(self.params)
self.assertIsNone(self.published())
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_daemon.service_pairing_window(self.params))
def test_unreadable_params_never_raise(self):
broken = _FakeParams(fail_reads=True)
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))
@@ -0,0 +1,551 @@
"""
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.
"""
import base64
import json
import shutil
import tempfile
import threading
import time
from collections.abc import Iterable
from types import SimpleNamespace
from typing import cast
from unittest import mock
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
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_daemon, localsunnylinkd, sunnylinkd
from openpilot.sunnypilot.sunnylink.athena.local_auth import (
AUTHENTICATE_PURPOSE,
AuthorizationError,
AuthorizedSession,
LocalAuthority,
Origin,
encode64,
key_id,
statement,
)
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
ENROLLMENT_TRANSPORT_VALUE = "eyJ2IjoyfQ==" # base64 of {"v":2}
class _FakeParams(dict):
"""Only the Params calls the daemon makes; a dict is enough to steer them."""
def get_bool(self, key):
return bool(self.get(key))
def put_bool(self, key, value, block=False):
self[key] = value
def put(self, key, value, block=False):
self[key] = value
def remove(self, key):
self.pop(key, None)
def all_keys(self):
return [key.encode() for key in self]
def get_type(self, key):
return SimpleNamespace(value=1)
class _FakeWs:
"""A WebSocket that replays queued frames, then reports the peer gone."""
def __init__(self, messages=()):
self._messages = list(messages)
self.sent: list[str] = []
self.closed = False
self.timeouts: list[float] = []
def settimeout(self, timeout):
self.timeouts.append(timeout)
def recv(self):
if self._messages:
return self._messages.pop(0)
raise sunnylinkd.WebSocketConnectionClosedException("peer gone")
def send(self, payload):
self.sent.append(payload)
def close(self):
self.closed = True
class _FakeSession:
"""Stands in for an AuthorizedSession: records the gated method, never executes it."""
def __init__(self, key_id: str = "grant-key"):
self.calls: list[str] = []
self.grant = SimpleNamespace(key_id=key_id, app_id=key_id)
self.closed = False
def invoke(self, method, handler, *args, **kwargs):
self.calls.append(method)
return {"ok": True}
def close(self):
self.closed = True
class _CallingSession(_FakeSession):
"""Like _FakeSession, but actually runs the gated handler, as AuthorizedSession.invoke does."""
def invoke(self, method, handler, *args, **kwargs):
return handler(*args, **kwargs)
class _FakeDiscovery:
def __init__(self, endpoints: Iterable[str] = (), recent: Iterable[str] | None = None):
self._endpoints: list[str] = list(endpoints)
# What the shorter backoff window returns; None means "same as the dial window".
self._recent: list[str] | None = None if recent is None else list(recent)
self.latest = None
self.seen: float | None = None
def fresh_endpoints(self, max_age_s=None):
if max_age_s is not None and self._recent is not None:
return list(self._recent)
return list(self._endpoints)
def latest_endpoint(self):
return self.latest
def last_seen_ago(self):
return self.seen
def latest_app_id(self):
return "phone-1"
class _Daemon(localsunnylinkd.LocalSunnylinkd):
"""A local daemon with scripted trust state, so target selection is testable in isolation."""
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._enrolled = enrolled
def enrolled(self):
return self._enrolled
def _challenge(phone_key_id: str) -> str:
return json.dumps({"v": 2, "purpose": AUTHENTICATE_PURPOSE, "phone_key_id": phone_key_id, "challenge": CHALLENGE})
def _authority():
device_key = ec.generate_private_key(ec.SECP256R1())
return LocalAuthority("cloud-device", "comma-device", device_key, persist=lambda _registry: None)
def _enrollment_json(authority: LocalAuthority, app_key: ec.EllipticCurvePrivateKey, name="Test phone") -> str:
public = encode64(app_key.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo))
data = {"v": 2, "cloud_device_id": authority.cloud_device_id, "comma_device_id": authority.comma_device_id,
"device_key_id": authority.device_key_id, "pairing_session": authority.window.nonce, "public_key": public,
"key_id": key_id(app_key.public_key()), "app_name": name}
signed_fields = ("cloud_device_id", "comma_device_id", "device_key_id", "pairing_session",
"public_key", "key_id", "app_name")
proof = statement("enroll", *(str(data[field]) for field in signed_fields))
data["signature"] = encode64(app_key.sign(proof, ec.ECDSA(hashes.SHA256())))
return json.dumps(data)
class TestLocalTargets(OpenpilotTestCase):
"""What the local daemon dials, and when it runs at all."""
def test_pinned_endpoint_converts_a_beacon_to_tls(self):
self.assertEqual("wss://10.0.0.7:8443", localsunnylinkd.pinned_endpoint("ws://10.0.0.7:8443"))
self.assertIsNone(localsunnylinkd.pinned_endpoint("wss://10.0.0.7:8443"))
self.assertIsNone(localsunnylinkd.pinned_endpoint("ws://10.0.0.7"))
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.assertIsNone(daemon.pick_target())
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"])
endpoint = "wss://10.0.0.7:8443"
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(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-minute cloud reconnect ceiling.
self.assertLess(first, localsunnylinkd.ENDPOINT_BACKOFF_S)
self.assertGreater(first, 0)
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"])
endpoint = "wss://10.0.0.7:8443"
with mock.patch.object(daemon, "dial", side_effect=AuthorizationError("refused")):
daemon.serve(endpoint, None)
delay = daemon.backoffs[endpoint] - time.monotonic()
self.assertGreater(delay, 0)
self.assertLessEqual(delay, localsunnylinkd.TICK_INTERVAL_S + 0.05)
def test_a_phone_that_went_quiet_past_the_forget_window_clears_its_ramp(self):
"""A network change silences the app for a moment without moving it out of the dial window.
Moving from home Wi-Fi to the car's hotspot (or roaming between mesh nodes) reassociates the
phone: a few beacon intervals pass with no announcement, and the ramp it earned on the old link
must not follow it onto the new one. The endpoint stays dialable for the full beacon window, so
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)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
daemon.failures[endpoint] = 5
self.assertEqual(endpoint, daemon.pick_target())
self.assertNotIn(endpoint, daemon.backoffs)
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)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
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"])
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 60
daemon.failures[endpoint] = 3
with mock.patch.object(daemon, "dial", lambda endpoint, exit_event: None), \
mock.patch.object(localsunnylinkd, "TICK_INTERVAL_S", 0):
daemon.serve(endpoint, None)
self.assertNotIn(endpoint, daemon.backoffs)
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.
The app is a foreground-only server the user opens and closes: a dial that failed while it
was closed recorded a backoff against an endpoint that then had no beacons at all. Reopening
the app re-announces within seconds, and that record would otherwise hold the device on the
cloud for the rest of the backoff.
"""
discovery = _FakeDiscovery(["ws://10.0.0.7:8443"])
daemon = _Daemon(discovery=discovery)
endpoint = "wss://10.0.0.7:8443"
daemon.backoffs[endpoint] = time.monotonic() + 300
daemon.failures[endpoint] = 1
# The app was closed: it announces nothing, so the failure outlives its reason.
discovery._endpoints = []
self.assertIsNone(daemon.pick_target())
self.assertNotIn(endpoint, daemon.backoffs)
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, 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_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)
self.assertTrue(running.serviceable())
switched_off = _Daemon(SunnylinkEnabled=True, SunnylinkLocalEnabled=False)
self.assertFalse(switched_off.serviceable())
sunnylink_off = _Daemon(SunnylinkEnabled=False, SunnylinkLocalEnabled=True)
self.assertFalse(sunnylink_off.serviceable())
class TestLocalSurface(OpenpilotTestCase):
"""What a local peer may reach, and what it may never touch."""
def test_a_local_session_can_never_write_local_params(self):
"""Enrollment only ever arrives through the cloud, so a local write is refused outright."""
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
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, "SunnylinkLocalApps": "{}", "SpeedLimitOffset": "5"})
self.assertEqual(["SpeedLimitOffset"], saved)
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("startLocalProxy", methods)
self.assertNotIn("echo", methods)
self.assertEqual(methods["getParams"](params_keys=[]), {"ok": True})
self.assertEqual(session.calls, ["getParams"])
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("startLocalProxy", {"port": 8022}, 2),
])
daemon.serve_rpc(cast(WebSocket, ws), methods)
self.assertEqual(session.calls, ["getParams"])
first, second = (json.loads(payload) for payload in ws.sent)
self.assertEqual(1, first["id"])
self.assertEqual({"ok": True}, first["result"])
self.assertEqual(2, second["id"])
self.assertEqual(rpc_module.METHOD_NOT_FOUND, second["error"]["code"])
class TestLocalDial(OpenpilotTestCase):
"""The pinned TLS dial and the challenge the phone must answer."""
def test_the_device_proves_itself_to_the_phone_first(self):
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {"phone_key_id": phone,
"challenge": challenge})
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
ws = _FakeWs([_challenge("pinned-key")])
daemon.authenticate_phone(cast(WebSocket, ws), cast(LocalAuthority, authority), "pinned-key")
proof = json.loads(ws.sent[0])
self.assertEqual("pinned-key", proof["phone_key_id"])
self.assertEqual(CHALLENGE, proof["challenge"])
def test_a_challenge_for_another_key_is_refused(self):
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {})
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with self.assertRaises(AuthorizationError):
daemon.authenticate_phone(cast(WebSocket, _FakeWs([_challenge("someone-else")])),
cast(LocalAuthority, authority), "pinned-key")
def test_a_malformed_challenge_is_refused(self):
authority = SimpleNamespace(authenticate_device=lambda phone, challenge: {})
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with self.assertRaises(AuthorizationError):
daemon.authenticate_phone(cast(WebSocket, _FakeWs(["not json"])),
cast(LocalAuthority, authority), "pinned-key")
def test_the_dial_confirms_a_pending_enrollment_before_serving(self):
pending = SimpleNamespace(key_id="pending-key", app_id="app-1")
grant = SimpleNamespace(key_id="pending-key", app_id="app-1")
authority = SimpleNamespace(lock=threading.RLock(), pending=pending, grants={},
authenticate_device=lambda phone, challenge: {"phone_key_id": phone,
"challenge": challenge})
session = _FakeSession("pending-key")
confirmed: list[str] = []
ws = _FakeWs([_challenge(pending.key_id)])
def confirm(key_id):
confirmed.append(key_id)
authority.grants[key_id] = grant
authority.pending = None
return {"app_id": grant.app_id, "app_name": "phone", "revision": "r"}
daemon = localsunnylinkd.LocalSunnylinkd(params=cast(Params, _FakeParams()))
with mock.patch.object(authority, "session", lambda g, peer: session, create=True), \
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("wss://10.0.0.7:8443", None)
self.assertEqual(["pending-key"], confirmed)
self.assertTrue(session.closed)
self.assertTrue(ws.closed)
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_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("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_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))
result = methods["updateLocalAppAlias"](app_id="another-phone", alias="Nayan's phone")
self.assertEqual([("grant-key", "Nayan's phone")], calls)
self.assertEqual({"success": True, "updated": True}, result)
def test_an_alias_the_protocol_rejects_is_refused(self):
def refuse(key_id, alias):
raise AuthorizationError("invalid alias")
session = _CallingSession("grant-key")
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")
self.assertEqual({"success": False, "error": "invalid alias"}, result)
def test_unpair_revokes_the_sessions_own_grant(self):
revoked: list[str] = []
session = _CallingSession("grant-key")
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))
result = methods["unpairLocalApp"](app_id="another-phone")
self.assertEqual(["grant-key"], revoked)
self.assertEqual({"success": True, "removed": True}, result)
class TestCloudCommandClaim(OpenpilotTestCase):
"""The handoff: the cloud daemon stores a command, the local daemon claims it."""
def setup_method(self):
self.device_key = ec.generate_private_key(ec.SECP256R1())
self.app_key = ec.generate_private_key(ec.SECP256R1())
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_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_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNotNone(self.authority.pending)
self.assertEqual("Test phone", self.authority.pending.app_name)
self.assertEqual(key_id(self.app_key.public_key()), self.authority.pending.key_id)
# Staged, not yet authorized, and never replayable.
self.assertEqual([], self.persisted)
self.assertNotIn(ENROLL, params)
def test_a_bad_payload_is_refused_and_still_cleared(self):
params = _FakeParams({ENROLL: "not an enrollment"})
local_auth_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNone(self.authority.pending)
self.assertNotIn(ENROLL, params)
def test_a_revocation_drops_the_phones_own_grant(self):
committed = self.authority.enroll(_enrollment_json(self.authority, self.app_key), origin=Origin.CLOUD)
self.authority.confirm(committed, committed.key_id)
proof = statement("revoke", self.authority.cloud_device_id, self.authority.comma_device_id, committed.key_id)
revocation = json.dumps({"v": 2, "cloud_device_id": self.authority.cloud_device_id,
"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_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_daemon.claim_cloud_commands(cast(Params, params))
self.assertIsNone(self.authority.pending)
def test_the_command_round_trips_through_the_real_library(self):
"""The declared param is a STRING, so the JSON the app pushes is what the claim reads."""
directory = tempfile.mkdtemp(prefix="sunnylink_params_")
self.addCleanup(shutil.rmtree, directory, ignore_errors=True)
params = Params(directory)
params.put(ENROLL, _enrollment_json(self.authority, self.app_key), block=True)
payload = params.get(ENROLL)
if isinstance(payload, bytes):
payload = payload.decode()
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 ("SunnylinkLocalApps", "SunnylinkLocalPairingQr", "SunnylinkLocalNewTrustKey", "_sec_anything"):
self.assertTrue(is_local_param(key))
self.assertFalse(is_local_param("SpeedLimitOffset"))
# The cloud's only way into the local side is its two commands.
self.assertTrue(cloud_may_write(ENROLL))
self.assertTrue(cloud_may_write(REVOKE))
self.assertTrue(cloud_may_write("SpeedLimitOffset"))
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(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_an_open_window_counts_as_a_live_pairing_window(self):
from openpilot.sunnypilot.sunnylink.athena.local_discovery import latest_discovered_app
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))
window[0] = True
discovery._handle(beacon, "10.0.0.7")
discovered = latest_discovered_app(discovery.params)
self.assertIsNotNone(discovered)
assert isinstance(discovered, tuple)
self.assertEqual("ws://10.0.0.7:8443", discovered[0])
def test_a_failing_window_callback_never_breaks_discovery(self):
def boom() -> bool:
raise RuntimeError("params unavailable")
discovery = LocalDiscovery(write_interval_s=0, pairing_window_cb=boom)
discovery._handle(b'SUNNYLINK1 {"v":1,"role":"app","app_id":"phone-1","ws_port":8443}', "10.0.0.7")
self.assertEqual(["ws://10.0.0.7:8443"], discovery.fresh_endpoints())
class TestEnrollmentTransportValue(OpenpilotTestCase):
"""The exact bytes the app pushes, kept in step with the mobile constant."""
def test_the_transport_value_is_standard_base64(self):
self.assertEqual('{"v":2}', base64.b64decode(ENROLLMENT_TRANSPORT_VALUE).decode())
@@ -4,57 +4,139 @@ 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 openpilot.sunnypilot.sunnylink.athena import sunnylinkd
import base64
import shutil
import tempfile
from unittest import mock
from openpilot.common.params import Params
from openpilot.common.test import OpenpilotTestCase
from openpilot.sunnypilot.sunnylink.athena import sunnylinkd
from openpilot.sunnypilot.sunnylink.athena.local_params import ENROLL, REVOKE
# The local daemon owns this payload's contents; to the cloud transport it is one opaque value.
ENROLLMENT_JSON = '{"v":2,"purpose":"sunnylink-local-enroll"}'
class TestSunnylinkdMethods(OpenpilotTestCase):
class TestCloudRpcSurface(OpenpilotTestCase):
"""The cloud daemon serves settings and must never expose or mutate local trust."""
def setup_method(self):
self.saved_params = []
self.original_save = sunnylinkd.save_param_from_base64_encoded_string
def mock_save_param(key, value, compression=False):
self.saved_params.append((key, value, compression))
sunnylinkd.save_param_from_base64_encoded_string = mock_save_param # ty: ignore[invalid-assignment]
sunnylinkd.save_param_from_base64_encoded_string = self._record # ty: ignore[invalid-assignment]
self.daemon = sunnylinkd.Sunnylinkd()
def teardown_method(self):
sunnylinkd.save_param_from_base64_encoded_string = self.original_save # ty: ignore[invalid-assignment]
def _record(self, key, value, compression=False):
self.saved_params.append((key, value, compression))
def test_methods_are_the_cloud_surface_only(self):
methods = self.daemon.methods()
self.assertTrue("getParams" in methods)
self.assertTrue("saveParams" in methods)
# Local serving is another process: no local pairing or alias RPC is registered here.
for name in ("pairLocalApp", "updateLocalAppAlias", "unpairLocalApp"):
self.assertNotIn(name, methods)
def test_saveParams_blocked(self):
blocked_params = {
"GithubUsername": "attacker",
"GithubSshKeys": "ssh-rsa attacker_key",
}
sunnylinkd.saveParams(blocked_params)
self.daemon.saveParams(blocked_params)
assert len(self.saved_params) == 0
def test_saveParams_allowed(self):
allowed_params = {
self.daemon.saveParams({
"SpeedLimitOffset": "5",
"MyCustomParam": "123"
}
"MyCustomParam": "123",
})
sunnylinkd.saveParams(allowed_params)
# verify content
assert len(self.saved_params) == 2
keys_saved = [p[0] for p in self.saved_params]
assert "SpeedLimitOffset" in keys_saved
assert "MyCustomParam" in keys_saved
def test_saveParams_mixed(self):
mixed_params = {
self.daemon.saveParams({
"GithubUsername": "attacker",
"SpeedLimitOffset": "10"
}
"SpeedLimitOffset": "10",
})
sunnylinkd.saveParams(mixed_params)
# should save allowed one
assert len(self.saved_params) == 1
assert self.saved_params[0][0] == "SpeedLimitOffset"
assert self.saved_params[0][1] == "10"
def test_saveParams_cannot_modify_local_trust(self):
self.daemon.saveParams({
"SunnylinkLocalApps": "attacker registry",
"SunnylinkLocalAppsMeta": "attacker metadata",
"SunnylinkLocalPairingRequest": "1",
"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", "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", "SunnylinkLocalAppsMeta",
"SunnylinkLocalPairingRequest", "SunnylinkLocalPairingQr",
"SunnylinkLocalRevoke", ENROLL, REVOKE])
self.assertEqual(response, {"params": "[]"})
def test_getParams_serves_ordinary_settings(self):
response = self.daemon.getParams(["SunnylinkEnabled"])
self.assertTrue("SunnylinkEnabled" in response)
def test_getParamsAllKeys_excludes_local_params(self):
keys = self.daemon.getParamsAllKeys()
for key in ("SunnylinkLocalApps", "SunnylinkLocalAppsMeta", "SunnylinkLocalPairingQr",
"SunnylinkLocalEnabled", ENROLL, REVOKE):
self.assertNotIn(key, keys)
self.assertTrue("SpeedLimitValueOffset" in keys)
class TestCloudCommandTransport(OpenpilotTestCase):
"""How an app's local command reaches the device: the cloud settings-write stores it.
The two commands are ordinary declared params on the cloud path, so the encoding the app
already uses (standard base64 of a UTF-8 JSON payload) is decoded by the same helper that
every other remotely written param goes through.
"""
def setup_method(self):
self.dir = tempfile.mkdtemp(prefix="sunnylink_params_")
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.daemon = sunnylinkd.Sunnylinkd(params=Params(self.dir))
patcher = mock.patch("openpilot.sunnypilot.sunnylink.utils.Params", lambda: Params(self.dir))
patcher.start()
self.addCleanup(patcher.stop)
def test_the_enrollment_lands_as_json_in_its_param(self):
self.daemon.saveParams({ENROLL: base64.b64encode(ENROLLMENT_JSON.encode()).decode("ascii")})
self.assertEqual(ENROLLMENT_JSON, Params(self.dir).get(ENROLL))
def test_the_revocation_lands_as_json_in_its_param(self):
self.daemon.saveParams({REVOKE: base64.b64encode(b'{"v":2}').decode("ascii")})
self.assertEqual('{"v":2}', Params(self.dir).get(REVOKE))
def test_a_compressed_command_also_arrives_readable(self):
import gzip
self.daemon.saveParams({ENROLL: base64.b64encode(gzip.compress(ENROLLMENT_JSON.encode())).decode("ascii")},
compression=True)
self.assertEqual(ENROLLMENT_JSON, Params(self.dir).get(ENROLL))
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({"SunnylinkLocalApps": base64.b64encode(b"{}").decode("ascii")})
save.assert_not_called()
+7 -4
View File
@@ -2,6 +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_state import is_locally_paired
from openpilot.common.params import Params, ParamKeyType
from openpilot.common.version import is_prebuilt
@@ -16,10 +17,11 @@ def get_sunnylink_status(params=None) -> tuple[bool, bool, bool]:
def sunnylink_ready(params=None) -> bool:
"""Check if the device is ready to communicate with Sunnylink. That means it is enabled and registered."""
"""Enabled and (cloud-registered or locally paired), and not on a temporary
fault. Local pairing makes never-registered devices usable over the LAN."""
params = params or Params()
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
return is_sunnylink_enabled and is_registered and not is_on_temporary_fault
return is_sunnylink_enabled and (is_registered or is_locally_paired(params)) and not is_on_temporary_fault
def use_sunnylink_uploader(params) -> bool:
@@ -28,10 +30,11 @@ def use_sunnylink_uploader(params) -> bool:
def sunnylink_need_register(params=None) -> bool:
"""Check if the device needs to be registered with Sunnylink."""
"""Enabled, unregistered, and not locally paired — a locally paired device
works without cloud registration and must not be blocked."""
params = params or Params()
is_sunnylink_enabled, is_registered, is_on_temporary_fault = get_sunnylink_status(params)
return is_sunnylink_enabled and not is_registered and not is_on_temporary_fault
return is_sunnylink_enabled and not is_registered and not is_locally_paired(params) and not is_on_temporary_fault
def register_sunnylink():
@@ -74,6 +74,11 @@ def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
def use_copyparty(started, params, CP: car.CarParams) -> bool:
return bool(params.get_bool("EnableCopyparty"))
def localsunnylinkd_enabled(started, params, CP: car.CarParams) -> bool:
"""Local connections are a sunnylink feature with their own switch."""
return params.get_bool("SunnylinkEnabled") and not params.get_bool("SunnylinkTempFault") \
and params.get_bool("SunnylinkLocalEnabled")
def sunnylink_ready_shim(started, params, CP: car.CarParams) -> bool:
"""Shim for sunnylink_ready to match the process manager signature."""
return sunnylink_ready(params)
@@ -166,6 +171,7 @@ procs = [
# sunnylink <3
DaemonProcess("manage_sunnylinkd", "openpilot.sunnypilot.sunnylink.athena.manage_sunnylinkd", "SunnylinkdPid"),
PythonProcess("localsunnylinkd", "openpilot.sunnypilot.sunnylink.athena.localsunnylinkd", localsunnylinkd_enabled),
PythonProcess("sunnylink_registration_manager", "openpilot.sunnypilot.sunnylink.registration_manager", sunnylink_need_register_shim),
PythonProcess("statsd_sp", "openpilot.sunnypilot.sunnylink.statsd", and_(always_run, sunnylink_ready_shim)),
]