mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-10-26 09:03:44 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4d01ff050 | |||
| f5271d88e9 | |||
| 0bbbd2916d | |||
| 8d4ffffc7e | |||
| cd835d9982 | |||
| 4285392a15 | |||
| 5e97f2cc46 | |||
| cdbc5f1c1f | |||
| 1e8b418607 | |||
| 75a0535b10 | |||
| 5a5a905130 | |||
| 83658cac21 | |||
| 0283795fa3 | |||
| 9c04844adf |
+1
-1
Submodule opendbc_repo updated: 0936a742e7...5be7507143
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user