diff --git a/docs/design/sentry-relay-enrollment.md b/docs/design/sentry-relay-enrollment.md new file mode 100644 index 0000000000..7c92efd462 --- /dev/null +++ b/docs/design/sentry-relay-enrollment.md @@ -0,0 +1,48 @@ +# Automatic Sentry relay enrollment + +Implemented across [StarPilot #18](https://github.com/dirwin31/openpilot/pull/18), [Android/Worker #3](https://github.com/dirwin31/StarPilotAndroid/pull/3), and [iOS #4](https://github.com/natehuby76/ios-clone/pull/4). StarPilot is based on FixesTest; Android is based on Play-Dev. + +## User flow + +The user connects the phone app to comma and enables alerts. The app requests OS notification permission, registers its FCM/APNs token with comma, and asks comma to enroll automatically. Galaxy's Enable alerts action starts the same flow. Users never copy keys, invitations, tokens or URLs. Each phone must grant permission; comma and phone need internet for delivery. + +The relay is deployed with its provider credentials, and `DEFAULT_RELAY_URL` in `openpilot/starpilot/sentry_mode/relay_config.py` points at it (`https://galaxy-sentry-relay.dannyirwin31.workers.dev`). With an empty value the UI reports that hosting is not configured and does not claim success. The apps discover readiness from comma and need no duplicate URL configuration. + +Enabling is explicit. Existing subscriptions or app upgrades do not silently opt into a new relay. Custom invitation-based relays remain available under advanced controls; they are not the normal user flow. + +## Credential ownership and recovery + +Comma creates a random installation ID and 256-bit sending secret and saves them privately before enrollment. It sends only the SHA-256 digest at enrollment. Later requests use an installation bearer credential. The Worker stores digests; phone apps never receive the comma credential. Provider keys remain Worker secrets. Snapshots expose only capability, lifecycle state, installation ID and destination count. + +Pending enrollment, rotation, revocation and destination reconciliation survive restarts. Requests use HTTPS without redirects; credentials are bound to the configured origin. A lost enrollment response is recovered by authenticating with the saved candidate. Rotation checks the candidate before retrying, including after the old credential's ten-minute overlap expires. Confirmation retires the old credential. + +Revocation stops new local sending immediately and retries remote revocation offline. The Worker keeps a revocation tombstone so retries cannot reactivate the old ID. An already authorized provider request may finish. Forgetting locally does not revoke a copied credential. An explicit new enrollment generates a new ID; anonymous enrollment cannot prove that it represents the same hardware. + +## Public enrollment and capacity + +Automatic enrollment is intentionally public. It does not provide hardware attestation or verified ownership. The Worker requires operator configuration and uses transactional Durable Object state for signup limits, credential checks, destination scope, revocation, send quotas and deduplication. Approximate ingress rate limiting adds another layer. + +Default limits are 5 new installations per IP/day, 100 per relay/day, 1,000 total automatic installations including revoked IDs, 8 destinations per installation, 60 sends/hour/installation and 1,000 sends/day/relay. Operators can change these bounded settings. Capacity exhaustion fails closed. Daily IP hashes expire after two days and are pruned by an alarm. Event receipts expire after a day and are pruned on later sends. + +An attacker can consume capacity or generate new identities. These controls limit resource use; they do not establish physical-device identity. A stronger trust boundary would require accounts, attestation or a challenge, which is outside the one-action anonymous flow. A registered installation can only send to its own registered destinations; provider-token secrecy still matters because this phase does not prove phone ownership cryptographically. + +## Protocol + +| Route | Purpose | +| --- | --- | +| `POST /v2/enroll` | Register installation and digest; an empty invitation selects automatic enrollment | +| `GET /v2/installation` | Recover enrollment or rotation with the saved credential | +| `PUT /v2/subscriptions/{id}` | Register or replace a scoped destination | +| `DELETE /v2/subscriptions/{id}` | Remove a destination | +| `POST /v2/sentry` | Send an allowlisted, bounded event to a registered destination | +| `POST /v2/credentials/rotate` | Activate a replacement with bounded overlap | +| `POST /v2/credentials/confirm` | Retire the old credential | +| `DELETE /v2/installation` | Revoke the installation | + +Operator-only routes issue optional private invitations and revoke installations. Admin credentials never enter apps or comma. Provider responses are acceptance, not proof of display; interrupted provider responses may lead to duplicates. Camera media stays on comma. Authentication failures pause v2 delivery; they never fall back to anonymous or legacy sending. + +## Setup and validation + +The relay is running (see [HOSTING.md](https://github.com/dirwin31/StarPilotAndroid/blob/sentry-cloudflare/push-relay/HOSTING.md)). Remaining work is registering the iOS app and Nate's APNs key in Firebase, signed app builds and delivery tests on real phones. + +Focused notification/recovery tests, Galaxy browser checks, Worker unit tests, Android Play builds/tests and iOS production-model regressions cover the implementation. The Worker was also run locally in the Cloudflare runtime against the real Firebase project. A relay rejecting one phone skips that phone instead of blocking the comma. Full Galaxy HTTP tests need Linux GUI dependencies; full iOS compilation needs upstream's newer SDK. Live delivery to phones has not been tested. diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index 6db0ad1924..e0e9fed98a 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1292,6 +1292,15 @@ button.gx-chip:hover { .gx-sentry-channel__fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: var(--sp-3); margin: var(--sp-3) 0; } .gx-sentry-channel__fields .gx-field { width: 100%; min-width: 0; box-sizing: border-box; } +.gx-sentry-events { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: var(--sp-3); } +.gx-sentry-event { display: flex; flex-direction: column; overflow: hidden; } +.gx-sentry-event .gx-row__info { display: grid; gap: var(--sp-1); } +.gx-sentry-event .gx-row__info small { word-break: break-all; } +.gx-sentry-event__media { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; grid-template-rows: minmax(0, 1fr); gap: 2px; aspect-ratio: 4 / 3; overflow: hidden; } +.gx-sentry-event__media .gx-media { width: 100%; height: 100%; object-fit: cover; } +.gx-sentry-events__bar { justify-content: space-between; } +.gx-sentry-events__bar p { margin: 0; } +.gx-sentry-relay { margin-top: var(--sp-3); } /* Recording library follows the shared section/row control hierarchy. */ .gx-recordings__library, .gx-recordings__list, .gx-recordings__delete { overflow: hidden; } diff --git a/openpilot/starpilot/galaxy/web/js/pip.js b/openpilot/starpilot/galaxy/web/js/pip.js index ef068cfd9e..113d62acff 100644 --- a/openpilot/starpilot/galaxy/web/js/pip.js +++ b/openpilot/starpilot/galaxy/web/js/pip.js @@ -3,7 +3,7 @@ import { GxState } from "./state.js" import { GxNotice } from "./notice.js" import { reactive } from "../vendor/vue/vue.esm-browser.js" import { LiveCameraPreview } from "./cameras.js" -import { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.js" +import { whiteBalancePref } from "./white-balance.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { SettingsFeed, isPipCropRow, settingsBusy } from "./settings.js" import { GalaxySettingRow } from "./galaxy-setting-row.js" @@ -16,7 +16,7 @@ const SIDES = [ export const PipPage = { name: "PipPage", - components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow, WhiteBalanceSwitch }, + components: { GxDialog, GxState, GxNotice, BluetoothNotice, GalaxySettingRow }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -46,6 +46,7 @@ export const PipPage = { return { state, feed, FORMATS, SIDES, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { + this.pref.load() this._dragIndex = -1 this._dragSide = null this.liveCamera = new LiveCameraPreview({ @@ -191,7 +192,6 @@ export const PipPage = {
Camera Crop

Drag a circle onto the cabin window you want to watch. Both crops share one size.

-

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js index 55267e9b73..4bb103800e 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry-notifications.js +++ b/openpilot/starpilot/galaxy/web/js/sentry-notifications.js @@ -61,7 +61,7 @@ export const SentryNotifications = { components: { GxNotice }, name: "SentryNotifications", props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, - data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', channels, labels, notifications: null, notificationBusy: false, notificationError: "", + data: () => ({ nativePush: !!window.StarPilotApp?.sentryPush, pushState: {}, testPoll: null, relayUrl: '', enrollmentCode: '', relayConfirmed: false, channels, labels, notifications: null, notificationBusy: false, notificationError: "", drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }), created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) }, mounted() { @@ -70,6 +70,7 @@ export const SentryNotifications = { this.start() }, beforeUnmount() { clearInterval(this.testPoll); this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) }, + computed: { relay() { return this.notifications?.relayEnrollment?.supported ? this.notifications.relayEnrollment : null } }, methods: { reportSave, refreshPush() { try { this.pushState = JSON.parse(window.StarPilotApp?.sentryPush?.() || '{}') } catch { this.pushState = {} } }, @@ -83,6 +84,28 @@ export const SentryNotifications = { removePhone(id) { return this.client.run({ action: 'appUnsubscribe', id }) }, simulate() { return this.client.run({ action: 'simulate' }) }, canSimulate() { return !!window.StarPilotApp || location.protocol === 'http:' }, + async enableHostedAlerts() { + if (await this.client.run({ action: 'relayEnroll', confirmed: true })) { + window.StarPilotApp?.setSentryPush?.(true) + this.refreshPush() + setTimeout(() => this.client.run(), 1000) + } + }, + async enrollRelay() { + if (await this.client.run({ action: 'relayEnroll', url: this.relayUrl, invitation: this.enrollmentCode.trim(), confirmed: this.relayConfirmed })) { + this.enrollmentCode = ''; this.relayUrl = ''; this.relayConfirmed = false + } + }, + async relayAction(action) { + if (await this.client.run({ action, confirmed: this.relayConfirmed })) this.relayConfirmed = false + }, + relayChip() { + return ({ unenrolled: 'Not set up', enrolling: 'Setting up', enrolled: 'On', rotating: 'Updating', confirming: 'Updating', revoking: 'Paused', revoked: 'Off', actionRequired: 'Needs attention' })[this.relay?.state] || 'Unknown' + }, + relayStatus() { + const state = this.notifications?.relayEnrollment?.state + return ({ unenrolled: 'Enable alerts to set up this comma and phone automatically.', enrolling: 'Enrolling — waiting for the relay. Retries continue when internet returns.', enrolled: 'Relay enrolled. Enable alerts on each phone, then send a test.', rotating: 'Updating the relay credential.', confirming: 'Confirming the new relay credential.', revoking: 'Sending paused. Revocation is pending until the relay acknowledges it.', revoked: 'Alerts are disabled. Enable alerts to enroll again.', actionRequired: 'Relay authorization needs attention. Ask the operator to check or revoke the installation before enrolling again.' })[state] || 'This comma does not support relay enrollment.' + }, async saveRelay() { if (await this.client.run({ action: 'configure', channel: 'appPush', enabled: true, url: this.relayUrl, token: '' })) this.relayUrl = '' }, @@ -135,28 +158,68 @@ export const SentryNotifications = { The notification queue is full. Some events could not be queued.
`, diff --git a/openpilot/starpilot/galaxy/web/js/sentry.js b/openpilot/starpilot/galaxy/web/js/sentry.js index b9518d0e0e..3a0e042962 100644 --- a/openpilot/starpilot/galaxy/web/js/sentry.js +++ b/openpilot/starpilot/galaxy/web/js/sentry.js @@ -4,7 +4,7 @@ import { GxNotice } from "./notice.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { SentryNotifications } from "./sentry-notifications.js" import { SnapshotFeed } from "./snapshot-feed.js" -import { BalancedImage, WhiteBalanceSwitch } from "./white-balance.js" +import { BalancedImage } from "./white-balance.js" import { deleteRecords, storedSize } from "./record-deletion.js" // Historical motion records and independently configured notifications. @@ -51,7 +51,7 @@ export class SentryEventsFeed extends SnapshotFeed { export const SentryEventsPage = { name: "SentryEventsPage", - components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage, WhiteBalanceSwitch }, + components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage, BalancedImage }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, data: () => ({ bluetooth: bluetoothConnection(), status: "idle", data: null, error: "", deleting: false }), @@ -63,6 +63,7 @@ export const SentryEventsPage = { }, beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() }, methods: { systemTime, storedSize }, + computed: { kinds: () => ({ alarm: { label: 'Alarm', icon: 'bi-bell' }, warning: { label: 'Motion', icon: 'bi-exclamation-triangle' }, selfie: { label: 'Selfie', icon: 'bi-camera' } }) }, template: `

Sentry

@@ -76,18 +77,19 @@ export const SentryEventsPage = { Reading local motion events… {{ error }}
`, diff --git a/openpilot/starpilot/galaxy/web/js/vasm.js b/openpilot/starpilot/galaxy/web/js/vasm.js index 4f5d99955d..dec0666462 100644 --- a/openpilot/starpilot/galaxy/web/js/vasm.js +++ b/openpilot/starpilot/galaxy/web/js/vasm.js @@ -4,14 +4,14 @@ import { reactive } from "../vendor/vue/vue.esm-browser.js" import { SettingsFeed, settingsBusy } from "./settings.js" import { GalaxySettingRow } from "./galaxy-setting-row.js" import { LiveCameraPreview } from "./cameras.js" -import { WhiteBalanceSwitch, whiteBalancePref } from "./white-balance.js" +import { whiteBalancePref } from "./white-balance.js" import { GxNotice } from "./notice.js" import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js" import { FORMATS, annotationDraft, displaySide, sourcePoint } from "./vasm-geometry.js" export const VasmPage = { name: "VasmPage", - components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice, WhiteBalanceSwitch }, + components: { GxDialog, GxState, GalaxySettingRow, GxNotice, BluetoothNotice }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } }, setup(props) { @@ -38,6 +38,7 @@ export const VasmPage = { return { state, feed, FORMATS, displaySide, bluetooth: bluetoothConnection(), pref: whiteBalancePref } }, mounted() { + this.pref.load() this._dragIndex = -1 this._dragSide = null this.liveCamera = new LiveCameraPreview({ @@ -183,7 +184,6 @@ export const VasmPage = {
Camera Window Regions

The display is mirrored: vehicle left is camera right; vehicle right is camera left. Trace visible side glass, leaving pillars and interior out.

-

{{ state.cameraWarming ? "Warming up the camera · about 10 seconds…" : state.imageName ? "Snapshot ready" : "Take a cabin snapshot while parked." }}

diff --git a/openpilot/starpilot/sentry_mode/notifications.py b/openpilot/starpilot/sentry_mode/notifications.py index 4ee2ccd817..2689d00ee2 100644 --- a/openpilot/starpilot/sentry_mode/notifications.py +++ b/openpilot/starpilot/sentry_mode/notifications.py @@ -11,6 +11,7 @@ import os from pathlib import Path import re +import secrets import stat import threading import time @@ -21,7 +22,8 @@ from openpilot.starpilot.storage import starpilot_storage_root from openpilot.starpilot.sentry_mode.storage import EventStore -from openpilot.starpilot.sentry_mode import web_push +from openpilot.starpilot.sentry_mode import web_push, relay_enrollment +from openpilot.starpilot.sentry_mode.relay_config import DEFAULT_RELAY_URL CHANNELS = ('webPush', 'discord', 'webhook', 'ntfy', 'appPush') APP_PUSH_RELAY_URL = '' @@ -54,7 +56,7 @@ def endpoint(value: object, *, allow_http: bool = False) -> str: def fresh_state() -> dict: return {'version': 1, 'channels': {channel: {'enabled': False, 'url': '', 'token': '', 'generation': uuid.uuid4().hex, 'since': 0} for channel in CHANNELS}, - 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False} + 'relayEnrollment': relay_enrollment.fresh(), 'appSubscriptions': [], 'subscriptions': [], 'vapid': '', 'jobs': {}, 'queueFull': False} def wall_seconds() -> float: @@ -142,6 +144,10 @@ def unique(pairs): state['appSubscriptions'] = [] if type(state.get('channels')) is dict: state['channels']['appPush'] = fresh_state()['channels']['appPush'] + if type(state) is dict: + state.setdefault('relayEnrollment', relay_enrollment.fresh()) + if type(state['relayEnrollment']) is dict: + state['relayEnrollment'].setdefault('rejected', {}) self._validate_state(state) self.state = state self.saved_bytes = json.dumps(state, separators=(',', ':'), allow_nan=False).encode() @@ -149,6 +155,7 @@ def unique(pairs): os.close(directory) def _validate_state(self, state: dict) -> None: + relay_enrollment.validate(state.get('relayEnrollment') if isinstance(state, dict) else None) if (type(state) is not dict or set(state) != set(fresh_state()) or type(state['version']) is not int or state['version'] != 1 or type(state['channels']) is not dict or set(state['channels']) != set(CHANNELS) or type(state['subscriptions']) is not list or len(state['subscriptions']) > 16 or @@ -236,12 +243,19 @@ def snapshot(self) -> dict: 'subscriptionCount': len(self.state['subscriptions']), 'subscriptions': [{'id': sub['id']} for sub in self.state['subscriptions']], 'deliverySemantics': 'Retries may repeat delivery after an interrupted remote response.'} - result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} + rejected = self.state['relayEnrollment']['rejected'] + result['appSubscriptions'] = [{key: sub[key] for key in ('id', 'label', 'appId', 'addedAt', 'lastResult', 'lastAt')} | + ({'lastResult': 'Relay does not accept this app'} if sub['id'] in rejected else {}) for sub in self.state['appSubscriptions']] result['removedAppSubscriptions'] = [ {'id': job['subscriptionId'], 'lastResult': 'Phone unregistered — removed', 'lastAt': job['next']} for job in jobs.values() if job['channel'] == 'appPush' and job['error'] == 'expired'][-8:] + enrollment = self.state['relayEnrollment'] + result['relayEnrollment'] = {'supported': True, 'automaticConfigured': bool(DEFAULT_RELAY_URL), 'state': enrollment['state'], + 'installationId': enrollment['id'], 'registeredCount': len(enrollment['registered'])} result['appPushAvailable'] = self.state['channels']['appPush']['enabled'] and bool(self.state['channels']['appPush']['url'] or APP_PUSH_RELAY_URL) + if enrollment['state'] != 'unenrolled': + result['appPushAvailable'] = result['appPushAvailable'] and enrollment['state'] == 'enrolled' for name, cfg in self.state['channels'].items(): relevant = [j for j in jobs.values() if j['channel'] == name and j['generation'] == cfg['generation']] latest = max(relevant, key=lambda j: j['created'], default=None) @@ -265,10 +279,40 @@ def action(self, payload: dict, *, permitted=lambda: True) -> dict: previous = copy.deepcopy(self.state) action = payload['action'] now = self.clock() - if action == 'configure': + if action == 'relayEnroll': + automatic = set(payload) == {'action', 'confirmed'} + if (not automatic and set(payload) != {'action', 'url', 'invitation', 'confirmed'}) or payload.get('confirmed') is not True: + raise ValueError('Confirm relay enrollment') + current = self.state['relayEnrollment'] + if automatic and current['state'] in ('enrolling', 'enrolled', 'rotating', 'confirming'): + return self.snapshot() + if current['state'] not in ('unenrolled', 'revoked', 'actionRequired'): + raise ValueError('Revoke or forget the current enrollment first') + url = DEFAULT_RELAY_URL if automatic else payload['url'] + if not url: + raise ValueError('Hosted alerts are not configured in this release yet') + self.state['relayEnrollment'] = relay_enrollment.begin(url, '' if automatic else payload['invitation']) + self.state['channels']['appPush'].update(url=self.state['relayEnrollment']['url'], enabled=True, generation=uuid.uuid4().hex, since=now) + elif action in ('relayRotate', 'relayRevoke', 'relayForget'): + if set(payload) != {'action', 'confirmed'} or payload['confirmed'] is not True: + raise ValueError('Confirm the relay action') + enrollment = self.state['relayEnrollment'] + if action == 'relayForget': + self.state['relayEnrollment'] = relay_enrollment.fresh() + self.state['channels']['appPush'] = fresh_state()['channels']['appPush'] + elif action == 'relayRotate': + if enrollment['state'] != 'enrolled': + raise ValueError('Enroll the relay before rotating its credential') + enrollment.update(state='rotating', candidate=secrets.token_hex(32), requestId=uuid.uuid4().hex, next=0, attempts=0) + elif enrollment['id']: + enrollment.update(state='revoking', next=0, attempts=0) + self.state['channels']['appPush']['enabled'] = False + elif action == 'configure': if set(payload) != {'action', 'channel', 'enabled', 'url', 'token'} or payload['channel'] not in CHANNELS: raise ValueError('Invalid notification channel') name = payload['channel'] + if name == 'appPush' and self.state['relayEnrollment']['state'] != 'unenrolled': + raise ValueError('Use relay enrollment controls for this channel') if (type(payload['enabled']) is not bool or type(payload['token']) is not str or len(payload['token']) > 512 or any(ord(c) < 32 for c in payload['token'])): raise ValueError('Invalid notification setting') @@ -292,6 +336,8 @@ def action(self, payload: dict, *, permitted=lambda: True) -> dict: elif action == 'forget': if set(payload) != {'action', 'channel'} or payload['channel'] not in CHANNELS: raise ValueError('Choose a notification channel') + if payload['channel'] == 'appPush' and self.state['relayEnrollment']['state'] != 'unenrolled': + raise ValueError('Revoke or forget the relay enrollment first') self.state['channels'][payload['channel']] = fresh_state()['channels'][payload['channel']] if payload['channel'] == 'webPush': self.state['subscriptions'] = [] @@ -328,6 +374,7 @@ def action(self, payload: dict, *, permitted=lambda: True) -> dict: raise ValueError('Invalid app subscription') current = self.state['appSubscriptions'] existing = next((sub for sub in current if sub['token'] == payload['token']), None) + self.state['relayEnrollment']['rejected'].pop(hashlib.sha256(payload['token'].encode()).hexdigest()[:12], None) if existing: existing.update(appId=payload['appId'], label=payload['label']) elif len(current) < 8: @@ -420,6 +467,17 @@ def tick(self) -> None: if not self.available: return now = self.clock() + original_enrollment = copy.deepcopy(self.state['relayEnrollment']) + updated_enrollment = copy.deepcopy(original_enrollment) + subscriptions = copy.deepcopy(self.state['appSubscriptions']) + self.lock.release() + try: + relay_enrollment.step(updated_enrollment, subscriptions, now) + finally: + self.lock.acquire() + if self.state['relayEnrollment'] == original_enrollment: + self.state['relayEnrollment'] = updated_enrollment + self._save() self.state['jobs'] = {key: job for key, job in self.state['jobs'].items() if now - job['created'] <= MAX_AGE} self.state['queueFull'] = False if not any(cfg["enabled"] for cfg in self.state["channels"].values()): @@ -436,6 +494,9 @@ def tick(self) -> None: cfg = self.state['channels'][job['channel']] sub = next((s['subscription'] for s in self.state['subscriptions'] if s['id'] == job['subscriptionId']), None) if job['channel'] == 'appPush': + enrollment = self.state['relayEnrollment'] + if enrollment['state'] != 'unenrolled' and (enrollment['state'] != 'enrolled' or job['subscriptionId'] not in enrollment['registered']): + continue sub = next((s for s in self.state['appSubscriptions'] if s['id'] == job['subscriptionId']), None) if not cfg['enabled'] or cfg['generation'] != job['generation'] or (job['channel'] in ('webPush', 'appPush') and sub is None): job['state'] = 'cancelled' @@ -469,6 +530,12 @@ def tick(self) -> None: current_sub['lastAt'] = self.clock() current_sub['lastResult'] = ('Delivered to relay' if 200 <= code < 300 else 'Relay not configured' if code == 0 else 'Phone unregistered — removed' if code == 410 else 'Rate limited' if code == 429 else f'Relay error {code}') + enrollment = self.state['relayEnrollment'] + if enrollment['state'] != 'unenrolled' and code in (401, 403): + enrollment['state'] = 'actionRequired' + if enrollment['state'] == 'enrolled' and code == 409: + enrollment['registered'].pop(job['subscriptionId'], None) + code = 503 if code == 410: self.state['appSubscriptions'] = [s for s in self.state['appSubscriptions'] if s['id'] != job['subscriptionId']] if 200 <= code < 300: @@ -525,7 +592,15 @@ def _send(self, name: str, cfg: dict, subscription: dict | None, job: dict, key: relay = cfg['url'] or APP_PUSH_RELAY_URL if not relay: return 0 - response = requests.post(relay.rstrip('/') + '/v1/sentry', json={ + protocol = '/v1/sentry' + with self.lock: + enrollment = copy.deepcopy(self.state['relayEnrollment']) + if enrollment['state'] != 'unenrolled': + if enrollment['state'] != 'enrolled' or relay != enrollment['url']: + return 401 + protocol = '/v2/sentry' + headers['Authorization'] = 'Bearer ' + enrollment['id'] + '.' + enrollment['secret'] + response = requests.post(relay.rstrip('/') + protocol, json={ 'token': subscription['token'], 'appId': subscription['appId'], 'kind': job['kind'], 'eventId': job['eventId'], 'wallTimeNs': int(job['created'] * 1e9), 'label': subscription['label']}, headers=headers, timeout=(3, 10), allow_redirects=False, stream=True) diff --git a/openpilot/starpilot/sentry_mode/relay_config.py b/openpilot/starpilot/sentry_mode/relay_config.py new file mode 100644 index 0000000000..32a4474bc4 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/relay_config.py @@ -0,0 +1,2 @@ +"""Hosted Sentry push relay used for one-tap enrollment.""" +DEFAULT_RELAY_URL = 'https://galaxy-sentry-relay.dannyirwin31.workers.dev' diff --git a/openpilot/starpilot/sentry_mode/relay_enrollment.py b/openpilot/starpilot/sentry_mode/relay_enrollment.py new file mode 100644 index 0000000000..549b9c0855 --- /dev/null +++ b/openpilot/starpilot/sentry_mode/relay_enrollment.py @@ -0,0 +1,123 @@ +"""Private relay credential lifecycle; the owner persists each transition.""" +import hashlib +import re +import secrets +import uuid +from urllib.parse import urlsplit + +import requests + + +def fresh(): + return {'version': 1, 'url': '', 'id': '', 'secret': '', 'invitation': '', 'requestId': '', + 'candidate': '', 'state': 'unenrolled', 'registered': {}, 'rejected': {}, 'next': 0, 'attempts': 0} + + +def validate(value): + if not isinstance(value, dict) or set(value) != set(fresh()) or value['version'] != 1: + raise ValueError('Invalid relay enrollment') + if value['state'] not in ('unenrolled', 'enrolling', 'enrolled', 'rotating', 'confirming', 'revoking', 'revoked', 'actionRequired'): + raise ValueError('Invalid relay enrollment state') + for key, size in (('id', 32), ('secret', 64), ('invitation', 64), ('requestId', 32), ('candidate', 64)): + if not isinstance(value[key], str) or (value[key] and not re.fullmatch('[a-f0-9]{' + str(size) + '}', value[key])): + raise ValueError('Invalid relay credential') + if not isinstance(value['url'], str) or len(value['url']) > 2048: + raise ValueError('Invalid relay address') + if value['url']: + normalized_url(value['url']) + if (any(not isinstance(value[key], dict) or len(value[key]) > 8 or + any(not re.fullmatch(r'[a-f0-9]{12}', k) or not isinstance(v, str) or not re.fullmatch(r'[a-f0-9]{64}', v) + for k, v in value[key].items()) for key in ('registered', 'rejected')) or + type(value['next']) not in (int, float) or not 0 <= value['next'] < 2**63 or + type(value['attempts']) is not int or not 0 <= value['attempts'] <= 16): + raise ValueError('Invalid relay recovery state') + if value['state'] not in ('unenrolled', 'revoked') and not all(value[k] for k in ('url', 'id', 'secret')): + raise ValueError('Missing relay credential') + + +def normalized_url(url): + if not isinstance(url, str) or any(ord(c) <= 32 for c in url): + raise ValueError('Use an HTTPS relay base URL') + parts = urlsplit(url) + if parts.scheme != 'https' or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment: + raise ValueError('Use an HTTPS relay base URL without a secret or query') + if parts.path.strip('/'): + raise ValueError('Use the relay host URL without a path') + port = parts.port + return 'https://' + parts.hostname.lower() + (f':{port}' if port and port != 443 else '') + + +def begin(url, invitation=''): + if not isinstance(invitation, str) or (invitation and not re.fullmatch(r'[a-f0-9]{64}', invitation)): + raise ValueError('Enter the operator’s enrollment code') + return {**fresh(), 'url': normalized_url(url), 'id': uuid.uuid4().hex, 'secret': secrets.token_hex(32), + 'invitation': invitation, 'requestId': uuid.uuid4().hex, 'state': 'enrolling'} + + +def fingerprint(sub): + return hashlib.sha256((sub['appId'] + ':' + sub['token']).encode()).hexdigest() + + +def request(state, method, path, data=None, *, secret=None): + headers = {'Authorization': 'Bearer ' + state['id'] + '.' + (secret or state['secret'])} + with requests.request(method, state['url'] + '/v2/' + path, json=data, headers=headers, + timeout=(3, 10), allow_redirects=False, stream=True) as response: + return response.status_code + + +def step(state, subscriptions, now, send=request): + if state['state'] in ('unenrolled', 'revoked', 'actionRequired') or state['next'] > now: + return + phase = state['state'] + try: + if phase == 'enrolling': + code = send(state, 'GET', 'installation') + if code == 401: + code = send(state, 'POST', 'enroll', {'invitation': state['invitation'], 'installationId': state['id'], + 'secretDigest': hashlib.sha256(state['secret'].encode()).hexdigest(), 'requestId': state['requestId']}) + if 200 <= code < 300: + state.update(state='enrolled', invitation='') + elif phase == 'rotating': + code = send({**state, 'secret': state['candidate']}, 'GET', 'installation') + if code == 401: + code = send(state, 'POST', 'credentials/rotate', {'secretDigest': hashlib.sha256(state['candidate'].encode()).hexdigest(), + 'requestId': state['requestId']}) + if 200 <= code < 300: + state.update(secret=state['candidate'], candidate='', state='confirming') + elif phase == 'confirming': + code = send(state, 'POST', 'credentials/confirm', {}) + if 200 <= code < 300: + state['state'] = 'enrolled' + elif phase == 'revoking': + code = send(state, 'DELETE', 'installation') + if 200 <= code < 300 or code == 401: + state.update(fresh(), state='revoked') + return + else: + desired = {sub['id']: fingerprint(sub) for sub in subscriptions} + state['rejected'] = {key: value for key, value in state['rejected'].items() if desired.get(key) == value} + removed = next((key for key in state['registered'] if key not in desired), None) + pending = next((sub for sub in subscriptions if desired[sub['id']] not in (state['registered'].get(sub['id']), state['rejected'].get(sub['id']))), None) + if removed: + code = send(state, 'DELETE', 'subscriptions/' + removed) + if 200 <= code < 300: + del state['registered'][removed] + elif pending: + code = send(state, 'PUT', 'subscriptions/' + pending['id'], {key: pending[key] for key in ('token', 'appId')}) + if 200 <= code < 300: + state['registered'][pending['id']] = desired[pending['id']] + elif code in (400, 409): + state['rejected'][pending['id']] = desired[pending['id']] + code = 200 + else: + return + if 200 <= code < 300: + state.update(attempts=0, next=0) + return + if code not in (408, 429) and code < 500: + state.update(state='actionRequired', invitation='') + return + except requests.RequestException: + pass + state['attempts'] = min(16, state['attempts'] + 1) + state['next'] = now + min(600, 5 * 2 ** min(7, state['attempts'])) diff --git a/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py new file mode 100644 index 0000000000..37ad04c8bd --- /dev/null +++ b/openpilot/starpilot/sentry_mode/tests/test_relay_enrollment.py @@ -0,0 +1,194 @@ +from openpilot.starpilot.sentry_mode import relay_enrollment as relay + + +def test_enrollment_recovers_saved_candidate_without_redeeming_twice(): + state = relay.begin('https://relay.example/', 'a' * 64) + original = state['secret'] + calls = [] + def send(state, method, path, data=None): + calls.append(path) + return 200 + relay.step(state, [], 100, send) + assert calls == ['installation'] + assert state['state'] == 'enrolled' + assert state['secret'] == original + assert state['invitation'] == '' + relay.validate(state) + + +def test_offline_revoke_retries_and_retains_credential_until_acknowledged(): + state = relay.begin('https://relay.example', 'a' * 64) + state['state'] = 'revoking' + relay.step(state, [], 100, lambda *args: 503) + assert state['state'] == 'revoking' and state['secret'] + assert state['next'] > 100 + relay.step(state, [], state['next'], lambda *args: 200) + assert state['state'] == 'revoked' and not state['secret'] + + +def test_subscription_reconciliation_removes_before_adding(): + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='enrolled', invitation='', registered={'0123456789ab': 'b' * 64}) + sub = {'id': 'abcdef012345', 'token': 'phone', 'appId': 'com.starpilot.galaxy'} + calls = [] + def send(state, method, path, data=None): + calls.append((method, path)) + return 200 + relay.step(state, [sub], 100, send) + relay.step(state, [sub], 101, send) + assert calls == [('DELETE', 'subscriptions/0123456789ab'), ('PUT', 'subscriptions/abcdef012345')] + assert state['registered'] == {sub['id']: relay.fingerprint(sub)} + + +def test_rejected_destination_is_skipped_without_blocking_other_phones(): + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='enrolled', invitation='') + iphone = {'id': 'aaaaaaaaaaaa', 'token': 'apns.production.' + 'ab' * 32, 'appId': 'link.galaxy.bluetooth.natehuby76'} + android = {'id': 'bbbbbbbbbbbb', 'token': 'fcm', 'appId': 'com.starpilot.galaxy'} + calls = [] + def send(state, method, path, data=None): + calls.append(path) + return 400 if data and data['appId'] == iphone['appId'] else 200 + for now in range(100, 104): + relay.step(state, [iphone, android], now, send) + assert state['state'] == 'enrolled' + assert calls == ['subscriptions/' + iphone['id'], 'subscriptions/' + android['id']] + assert state['registered'] == {android['id']: relay.fingerprint(android)} + assert state['rejected'] == {iphone['id']: relay.fingerprint(iphone)} + relay.validate(state) + relay.step(state, [android], 104, send) + assert state['rejected'] == {} + + +def test_resubscribing_retries_a_rejected_phone_and_old_state_loads(tmp_path): + import json + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + root = tmp_path / 'notifications' + store = SimpleNamespace(snapshot=lambda: {'events': []}) + owner = NotificationOwner(root, store) + token = 'apns.production.' + 'ab' * 32 + owner.action({'action': 'appSubscribe', 'token': token, 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'}) + sub_id = owner.state['appSubscriptions'][0]['id'] + owner.state['relayEnrollment']['rejected'] = {sub_id: relay.fingerprint(owner.state['appSubscriptions'][0])} + assert owner.snapshot()['appSubscriptions'][0]['lastResult'] == 'Relay does not accept this app' + owner.action({'action': 'appSubscribe', 'token': token, 'appId': 'link.galaxy.bluetooth.natehuby76', 'label': 'iPhone'}) + assert owner.state['relayEnrollment']['rejected'] == {} + del owner.state['relayEnrollment']['rejected'] + saved = json.dumps(owner.state) + owner.close() + (root / 'state.json').write_text(saved) + owner = NotificationOwner(root, store) + assert owner.state['relayEnrollment']['rejected'] == {} + owner.close() + + +def test_owner_persists_candidate_redacts_secrets_and_recovers_restart(tmp_path, monkeypatch): + import json + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + root = tmp_path / 'notifications' + store = SimpleNamespace(snapshot=lambda: {'events': []}) + owner = NotificationOwner(root, store) + result = owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': True}) + assert result['relayEnrollment']['state'] == 'enrolling' + saved = owner.state['relayEnrollment'].copy() + assert saved['secret'] not in json.dumps(result) and 'a' * 64 not in json.dumps(result) + owner.close() + owner = NotificationOwner(root, store) + assert owner.state['relayEnrollment'] == saved + original_step = relay.step + monkeypatch.setattr(relay, 'step', lambda state, subs, now: original_step(state, subs, now, lambda *args: 200)) + owner.tick() + assert owner.snapshot()['appPushAvailable'] + owner.action({'action': 'relayRevoke', 'confirmed': True}) + assert not owner.snapshot()['appPushAvailable'] + owner.tick() + assert owner.snapshot()['relayEnrollment']['state'] == 'revoked' + owner.close() + + +def test_owner_rejects_silent_enrollment_and_host_changes(tmp_path): + import pytest + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + try: + with pytest.raises(ValueError): + owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': False}) + owner.action({'action': 'relayEnroll', 'url': 'https://relay.example', 'invitation': 'a' * 64, 'confirmed': True}) + with pytest.raises(ValueError): + owner.action({'action': 'configure', 'channel': 'appPush', 'enabled': True, 'url': 'https://other.example', 'token': ''}) + finally: + owner.close() + + +def test_rotation_recovers_after_old_credential_overlap_expired(): + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='rotating', invitation='', candidate='b' * 64) + calls = [] + def send(current, method, path, data=None): + calls.append((current['secret'], method, path)) + return 200 if current['secret'] == 'b' * 64 else 401 + relay.step(state, [], 100, send) + assert state['state'] == 'confirming' and state['secret'] == 'b' * 64 + assert calls == [('b' * 64, 'GET', 'installation')] + relay.step(state, [], 101, send) + assert state['state'] == 'enrolled' + + +def test_v2_sender_binds_bearer_to_host_and_disables_redirects(tmp_path, monkeypatch): + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode.notifications import NotificationOwner + from openpilot.starpilot.sentry_mode import notifications + owner = NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + state = relay.begin('https://relay.example', 'a' * 64) + state.update(state='enrolled', invitation='') + owner.state['relayEnrollment'] = state + calls = [] + monkeypatch.setattr(notifications.requests, 'post', lambda url, **kwargs: calls.append((url, kwargs)) or SimpleNamespace(status_code=200, close=lambda: None)) + sub = {'token': 'phone', 'appId': 'com.starpilot.galaxy', 'label': 'Phone'} + job = {'kind': 'test', 'eventId': 'b' * 32, 'created': 100} + try: + assert owner._send('appPush', {'url': state['url']}, sub, job, 'key', '') == 200 + assert calls[0][0] == 'https://relay.example/v2/sentry' + assert calls[0][1]['headers']['Authorization'] == 'Bearer ' + state['id'] + '.' + state['secret'] + assert calls[0][1]['allow_redirects'] is False + assert owner._send('appPush', {'url': 'https://elsewhere.example'}, sub, job, 'key', '') == 401 + assert len(calls) == 1 + state['state'] = 'revoking' + assert owner._send('appPush', {'url': state['url']}, sub, job, 'key', '') == 401 + assert len(calls) == 1 + finally: + owner.close() + + +def test_one_tap_enrollment_uses_release_host_and_preserves_candidate_on_retry(tmp_path, monkeypatch): + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode import notifications + owner = notifications.NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + monkeypatch.setattr(notifications, 'DEFAULT_RELAY_URL', 'https://hosted.example') + try: + status = owner.action({'action': 'relayEnroll', 'confirmed': True}) + saved = owner.state['relayEnrollment'].copy() + assert status['relayEnrollment']['automaticConfigured'] + assert saved['url'] == 'https://hosted.example' and saved['invitation'] == '' + owner.action({'action': 'relayEnroll', 'confirmed': True}) + assert owner.state['relayEnrollment'] == saved + finally: + owner.close() + + +def test_unconfigured_release_does_not_claim_automatic_alerts(tmp_path, monkeypatch): + import pytest + from types import SimpleNamespace + from openpilot.starpilot.sentry_mode import notifications + monkeypatch.setattr(notifications, 'DEFAULT_RELAY_URL', '') + owner = notifications.NotificationOwner(tmp_path / 'notifications', SimpleNamespace(snapshot=lambda: {'events': []})) + try: + assert not owner.snapshot()['relayEnrollment']['automaticConfigured'] + with pytest.raises(ValueError, match='not configured'): + owner.action({'action': 'relayEnroll', 'confirmed': True}) + assert owner.state['relayEnrollment']['state'] == 'unenrolled' + finally: + owner.close()