Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0327012666 | ||
|
|
2e58449aec | ||
|
|
41ffe0ce1c | ||
|
|
81418d71b8 | ||
|
|
99b9cf1704 | ||
|
|
f1e8bfd7ac | ||
|
|
75e617bfb1 |
@@ -20,6 +20,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
|
||||
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
|
||||
## [1.2.4] - 2026-06-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Connection security indicator.** The chat status chip, the connection card, and the route picker now show at a glance whether your connection is encrypted — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale/WireGuard route is now correctly shown as encrypted rather than implied insecure. Adds a new "Is my connection secure?" docs page explaining the difference between TLS and overlay (WireGuard) encryption.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Crash when a dashboard connection drops mid-check.** A transient network blip on the dashboard session check (e.g. a pooled connection aborting or timing out over Tailscale) could close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly, and the connection probe degrades gracefully instead of ever crashing. (#129)
|
||||
|
||||
## [1.2.3] - 2026-06-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-06-24 — Fix SocketTimeoutException crash from DashboardApiClient.currentSession()
|
||||
|
||||
**Why.** An in-app crash report (`FATAL EXCEPTION: main`, `SocketTimeoutException`, `Caused by: java.net.SocketException: Software caused connection abort`) captured on-device over a Tailscale connection. The visible dialog truncated the trace; the full stack was recovered from a background `adb logcat` capture that happened to be running when it fired.
|
||||
|
||||
**Root cause.** `DashboardApiClient.currentSession()` declared `Result<DashboardAuthSession>` but performed a **raw `okHttpClient.newCall(req).execute()` with no try/catch** — the lone outlier among the client's methods (`executeJson`/`executeJsonElement`/`audioRoutesPresent` all catch). Its `.execute()` correctly ran on `Dispatchers.IO`, but a transient network failure (a stale pooled connection aborting over Tailscale) **re-threw** out of `withContext(IO)`. The caller chain — `ConnectionViewModel.probeStandardVoice()` → `viewModelScope.launch` (`Dispatchers.Main.immediate`, the `Suppressed` frame in the trace) — used `try/finally` with **no `catch`**, so the exception was uncaught on the main thread and killed the app. The `.execute()` being off-main is why StrictMode never fired; the uncaught *propagation* to the Main coroutine was the bug.
|
||||
|
||||
**Fix.** (1) `currentSession()` now wraps its request in `try/catch`, returning `Result.failure` on any exception — honoring the `Result` contract every caller relies on (mirrors `executeJson`). (2) Defense-in-depth: `probeStandardVoice()` gained a `catch` (rethrowing `CancellationException`) that degrades the voice/gateway availability state instead of letting any probe sub-call crash the Main coroutine.
|
||||
|
||||
**Verification.** New `DashboardApiClientTest.currentSession_onConnectionAbort_returnsFailure_doesNotThrow` (MockWebServer `DISCONNECT_AT_START`) asserts a connection abort yields `Result.failure`, not a throw. `:app:testSideloadDebugUnitTest` + `:app:lintSideloadDebug` green. On-device confirmation pending a build.
|
||||
|
||||
## 2026-06-23 — Fix NetworkOnMainThreadException crash on TLS connect
|
||||
|
||||
**Why.** Two external bug reports (#118, #124) and the later comment on #70 reported the app hard-closing on connect over an encrypted link (Tailscale Serve / public HTTPS). The auto-captured traces were identical: `android.os.NetworkOnMainThreadException` from `okhttp3.ConnectionPool.evictAll()`, with a suppressed `Dispatchers.Main.immediate [Cancelling]` frame — i.e. a `viewModelScope` coroutine.
|
||||
|
||||
+16
-13
@@ -1,22 +1,22 @@
|
||||
# Hermes-Relay-Android v1.2.3
|
||||
# Hermes-Relay-Android v1.2.4
|
||||
|
||||
**Release Date:** June 23, 2026
|
||||
**Since v1.2.2:** A connection-stability hotfix. Connecting to a server over an **encrypted link** (Tailscale Serve or public HTTPS) could hard-close the app the moment the connection came up; that crash is fixed, so securing your connection no longer force-closes Hermes-Relay.
|
||||
**Release Date:** June 25, 2026
|
||||
**Since v1.2.3:** A second connection-stability fix plus a new way to see whether your connection is encrypted. A transient blip on the dashboard session check — a pooled connection aborting or timing out over Tailscale — could still hard-close the app even after the v1.2.3 fix; that path is now handled cleanly. And the app now shows, at a glance, whether each transport is encrypted.
|
||||
|
||||
v1.2.3 is a focused fix for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected.
|
||||
v1.2.4 is recommended for anyone connecting over Tailscale or public TLS. Plain-LAN connections were never affected by the crash.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v1.2.3 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
v1.2.4 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-1.2.3-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.3-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.3-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.3-sideload-release.aab` | Parity/testing artifact. |
|
||||
| Google Play | `hermes-relay-1.2.4-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.4-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.4-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.4-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
@@ -25,11 +25,14 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
|
||||
## Highlights
|
||||
|
||||
### Fixed
|
||||
- **No more crash on connect over TLS / Tailscale.** Connecting over an encrypted link (Tailscale Serve or public HTTPS) could force-close the app with a `NetworkOnMainThreadException` as the connection came up — a live SSL socket was being closed on the main thread during client teardown, and a TLS close performs a network write. Socket teardown now always runs off the main thread, so connecting over a secured link is stable. Plain-LAN connections were never affected.
|
||||
- **No more crash when the dashboard connection drops mid-check.** A transient network failure on the dashboard session check — for example a pooled connection aborting or timing out over Tailscale — could still force-close the app: the check returned a result type but re-threw the network error instead of reporting it, and it surfaced on the main thread. The check now reports the failure cleanly and the connection probe degrades gracefully, so a flaky link can no longer crash the app. (#129)
|
||||
|
||||
### Added
|
||||
- **See whether your connection is encrypted.** The chat status chip, the connection card, and the route picker now show your encryption state at a glance — 🔒 **Encrypted · TLS**, 🛡️ **Encrypted · Tailscale** (both secure), 🛡️ **Mixed routes**, or ⚠️ **Not encrypted** — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure. A new ["Is my connection secure?"](https://codename-11.github.io/hermes-relay/architecture/connection-security.html) docs page explains the difference between TLS and overlay (WireGuard) encryption.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
- This is an app-side fix on **both** flavors — no Device Control or server changes needed.
|
||||
- If you were crashing on connect over Tailscale or HTTPS, update and reconnect.
|
||||
- `appVersionCode` is **17**.
|
||||
- This is an app-side release on **both** flavors — no Device Control or server changes needed.
|
||||
- If you connect over Tailscale or HTTPS, update and reconnect.
|
||||
- `appVersionCode` is **18**.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
v1.2.3 — Connection crash fix.
|
||||
v1.2.4 — Stability + connection security.
|
||||
|
||||
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
|
||||
• Fixed a crash that could close the app when the dashboard connection dropped mid-check (e.g. a brief Tailscale blip) — it now fails gracefully instead of force-closing.
|
||||
• New: see whether your connection is encrypted at a glance (TLS or Tailscale) from the chat chip, connection card, and route picker, with a per-transport breakdown on tap.
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
v1.2.3 - Connection crash fix
|
||||
v1.2.4 - Stability + connection security
|
||||
|
||||
Stability
|
||||
* Fixed a crash that could close the app right after connecting over an
|
||||
encrypted link (Tailscale or HTTPS). Securing your connection no longer
|
||||
force-closes the app. Plain-LAN connections were never affected.
|
||||
* Fixed a crash that could close the app when the dashboard connection
|
||||
dropped mid-check (e.g. a brief Tailscale blip). The check now fails
|
||||
gracefully instead of force-closing.
|
||||
|
||||
New
|
||||
* See whether your connection is encrypted at a glance — the chat chip,
|
||||
connection card, and route picker now show TLS or Tailscale encryption,
|
||||
with a per-transport breakdown on tap.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Single source of truth for "is this connection encrypted, and by what?"
|
||||
*
|
||||
* Security is **per-surface**: a single paired connection fans out to several
|
||||
* transports (chat/gateway + Manage over the dashboard, API/sessions, relay
|
||||
* tools) and each can independently be TLS, overlay-encrypted, or plain (see
|
||||
* [computeConnectionSecurity]). Every UI surface — the chat status chip, the
|
||||
* connection header, the route picker, the detail sheet — renders the same
|
||||
* derived [ConnectionSecurity] so no two places disagree about what "secure"
|
||||
* means.
|
||||
*
|
||||
* Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard,
|
||||
* the plugin secure proxy), not just TLS. A `ws://` link over a tailnet is
|
||||
* WireGuard-encrypted end-to-end — genuinely secure, just not TLS — so it is
|
||||
* never labelled "insecure". Only a plain scheme with no overlay warns.
|
||||
*/
|
||||
enum class SurfaceSecurityKind { Tls, Overlay, Plain }
|
||||
|
||||
/** Connection-level rollup across the surfaces actually in use. */
|
||||
enum class ConnectionSecurityLevel { Tls, Overlay, Mixed, Plain, Unknown }
|
||||
|
||||
/** Security verdict for one transport surface of a connection. */
|
||||
data class SurfaceSecurity(
|
||||
val label: String,
|
||||
val kind: SurfaceSecurityKind,
|
||||
/** Human mechanism: "TLS", "Tailscale", "WireGuard", "Proxy", "Plain". */
|
||||
val mechanism: String,
|
||||
val url: String,
|
||||
)
|
||||
|
||||
data class ConnectionSecurity(
|
||||
val level: ConnectionSecurityLevel,
|
||||
/** Dominant mechanism for the at-a-glance label. */
|
||||
val mechanism: String,
|
||||
val surfaces: List<SurfaceSecurity>,
|
||||
) {
|
||||
/** True when every in-use surface is encrypted (TLS or overlay). */
|
||||
val isEncrypted: Boolean
|
||||
get() = level == ConnectionSecurityLevel.Tls || level == ConnectionSecurityLevel.Overlay
|
||||
|
||||
companion object {
|
||||
val UNKNOWN = ConnectionSecurity(ConnectionSecurityLevel.Unknown, "", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the URL scheme is TLS (`wss://` / `https://`). */
|
||||
fun isTlsUrl(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
val lower = url.trim().lowercase()
|
||||
return lower.startsWith("wss://") || lower.startsWith("https://")
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the active route is encrypted by an overlay network (Tailscale /
|
||||
* WireGuard) or the plugin secure proxy, even if its scheme is plain. Mirrors
|
||||
* the logic that previously lived privately in `ActiveConnectionSections`.
|
||||
*/
|
||||
fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
|
||||
if (this == null) return false
|
||||
val r = role.lowercase()
|
||||
val hint = security.orEmpty().lowercase()
|
||||
return r == "tailscale" ||
|
||||
(isTailscaleDetected && hint.contains("tailscale")) ||
|
||||
r == "plugin_proxy" ||
|
||||
r == "plugin-proxy" ||
|
||||
hasSecureProxy() ||
|
||||
hint.contains("wireguard") ||
|
||||
hint.contains("https") ||
|
||||
hint.contains("tls")
|
||||
}
|
||||
|
||||
/** Human label for the overlay mechanism encrypting a route. */
|
||||
fun EndpointCandidate?.overlayMechanism(isTailscaleDetected: Boolean): String {
|
||||
if (this == null) return "Encrypted"
|
||||
val r = role.lowercase()
|
||||
val hint = security.orEmpty().lowercase()
|
||||
return when {
|
||||
r == "tailscale" || (isTailscaleDetected && hint.contains("tailscale")) -> "Tailscale"
|
||||
r == "plugin_proxy" || r == "plugin-proxy" || hasSecureProxy() -> "Proxy"
|
||||
hint.contains("wireguard") -> "WireGuard"
|
||||
hint.contains("https") || hint.contains("tls") -> "TLS"
|
||||
else -> "Encrypted"
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a single surface URL against the active route. */
|
||||
fun classifySurfaceSecurity(
|
||||
label: String,
|
||||
url: String,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): SurfaceSecurity {
|
||||
val (kind, mechanism) = when {
|
||||
isTlsUrl(url) -> SurfaceSecurityKind.Tls to "TLS"
|
||||
activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected) ->
|
||||
SurfaceSecurityKind.Overlay to activeEndpoint.overlayMechanism(isTailscaleDetected)
|
||||
else -> SurfaceSecurityKind.Plain to "Plain"
|
||||
}
|
||||
return SurfaceSecurity(label = label, kind = kind, mechanism = mechanism, url = url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll up the per-surface verdicts into one connection-level [ConnectionSecurity].
|
||||
* Pure + side-effect free so it is unit-testable without Android.
|
||||
*/
|
||||
fun computeConnectionSecurity(
|
||||
apiUrl: String,
|
||||
dashboardUrl: String,
|
||||
relayUrl: String,
|
||||
relayConfigured: Boolean,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): ConnectionSecurity {
|
||||
val surfaces = buildList {
|
||||
dashboardUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("Chat & Manage", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
apiUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("API / sessions", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
if (relayConfigured) {
|
||||
relayUrl.trim().takeIf { it.isNotBlank() }?.let {
|
||||
add(classifySurfaceSecurity("Relay tools", it, activeEndpoint, isTailscaleDetected))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (surfaces.isEmpty()) return ConnectionSecurity.UNKNOWN
|
||||
|
||||
val kinds = surfaces.map { it.kind }.toSet()
|
||||
val hasPlain = SurfaceSecurityKind.Plain in kinds
|
||||
val hasSecure = kinds.any { it != SurfaceSecurityKind.Plain }
|
||||
|
||||
val level = when {
|
||||
!hasSecure -> ConnectionSecurityLevel.Plain
|
||||
hasPlain -> ConnectionSecurityLevel.Mixed
|
||||
kinds == setOf(SurfaceSecurityKind.Tls) -> ConnectionSecurityLevel.Tls
|
||||
else -> ConnectionSecurityLevel.Overlay
|
||||
}
|
||||
|
||||
val mechanism = when (level) {
|
||||
ConnectionSecurityLevel.Tls -> "TLS"
|
||||
ConnectionSecurityLevel.Overlay ->
|
||||
surfaces.firstOrNull { it.kind == SurfaceSecurityKind.Overlay }?.mechanism ?: "Encrypted"
|
||||
ConnectionSecurityLevel.Mixed -> "Mixed"
|
||||
ConnectionSecurityLevel.Plain -> when (activeEndpoint?.role?.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"public" -> "Public"
|
||||
null, "" -> "Plain"
|
||||
else -> activeEndpoint.role
|
||||
}
|
||||
ConnectionSecurityLevel.Unknown -> ""
|
||||
}
|
||||
return ConnectionSecurity(level = level, mechanism = mechanism, surfaces = surfaces)
|
||||
}
|
||||
@@ -514,15 +514,26 @@ class DashboardApiClient(
|
||||
.get()
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
return@withContext Result.success(DashboardAuthSession(authenticated = false))
|
||||
// try/catch is NOT optional here: currentSession() returns a Result and
|
||||
// callers (probeStandardVoice on a viewModelScope/Main coroutine) rely
|
||||
// on it NEVER throwing. A raw execute() re-threw transient network
|
||||
// failures — e.g. a stale pooled connection over Tailscale aborting
|
||||
// ("Software caused connection abort") — straight past withContext(IO)
|
||||
// and crashed the app on the main thread. Mirror executeJson()'s
|
||||
// contract: every failure becomes Result.failure.
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when {
|
||||
response.code == 401 || response.code == 403 ->
|
||||
Result.success(DashboardAuthSession(authenticated = false))
|
||||
!response.isSuccessful ->
|
||||
Result.failure(apiFailure(response, "Dashboard session"))
|
||||
else ->
|
||||
Result.success(parseAuthSession(response.readJsonObject(json)))
|
||||
}
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Dashboard session"))
|
||||
}
|
||||
val root = response.readJsonObject(json)
|
||||
Result.success(parseAuthSession(root))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ import com.hermesandroid.relay.ui.components.ConnectionStatusToast
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportTier
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSecurityGlyph
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
@@ -962,6 +963,7 @@ fun RelayApp() {
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val appReady by connectionViewModel.isReady.collectAsState()
|
||||
@@ -1410,6 +1412,11 @@ fun RelayApp() {
|
||||
// Connections — preserves the affordance the dropped
|
||||
// header endpoint chip used to provide.
|
||||
onClick = openConnections,
|
||||
securityGlyph = if (transportStatus.tier != ChatTransportTier.Offline) {
|
||||
{ ConnectionSecurityGlyph(connectionSecurity) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-69
@@ -116,6 +116,15 @@ fun ActiveCardStandardStatusSection(
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired =
|
||||
dashboardStatus?.authRequired == true && dashboardStatus.authenticated != true
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
|
||||
// At-a-glance security rollup, promoted out of the Advanced fold. Tap for
|
||||
// the per-surface breakdown. Single source of truth: ConnectionSecurity.
|
||||
ConnectionSecurityBadgeWithSheet(
|
||||
security = connectionSecurity,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
@@ -1110,56 +1119,19 @@ fun ActiveCardSecurityPosture(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToPairedDevices: () -> Unit,
|
||||
) {
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val insecureReason by connectionViewModel.insecureReason.collectAsState()
|
||||
val connectionSecurity by connectionViewModel.connectionSecurity.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
// ADR 24 — surface the live endpoint role so the insecure badge can
|
||||
// say "Plain (on LAN)" instead of "Insecure (network unknown)" when
|
||||
// the resolver already knows which candidate we're on.
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val selectedRouteUrls = buildList {
|
||||
effectiveApiServerUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
|
||||
effectiveDashboardUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
|
||||
val selectedRelayUrl = effectiveRelayUrl.ifBlank { relayUrl }
|
||||
if (relayConfigured || selectedRelayUrl.isNotBlank()) {
|
||||
selectedRelayUrl.trim().takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
}
|
||||
val secureUrlCount = selectedRouteUrls.count { url ->
|
||||
isSelectedRouteUrlSecure(
|
||||
url = url,
|
||||
activeEndpoint = activeEndpoint,
|
||||
isTailscaleDetected = isTailscaleDetected,
|
||||
)
|
||||
}
|
||||
val transportState = when {
|
||||
selectedRouteUrls.isEmpty() -> null
|
||||
secureUrlCount == selectedRouteUrls.size -> TransportSecurityState.AllSecure
|
||||
secureUrlCount > 0 -> TransportSecurityState.Mixed
|
||||
else -> TransportSecurityState.AllInsecure
|
||||
}
|
||||
|
||||
if (transportState != null) {
|
||||
TransportSecurityBadge(
|
||||
state = transportState,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
TransportSecurityBadge(
|
||||
isSecure = isUrlSecure(relayUrl),
|
||||
reason = insecureReason.ifBlank { null },
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
activeRole = activeEndpoint?.role,
|
||||
)
|
||||
}
|
||||
// Connection-level security rollup (single source of truth —
|
||||
// ConnectionSecurity). Tap for the per-surface breakdown + the
|
||||
// mechanism explainer (TLS vs Tailscale/WireGuard vs plain).
|
||||
ConnectionSecurityBadgeWithSheet(
|
||||
security = connectionSecurity,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (isTailscaleDetected) {
|
||||
Row(
|
||||
@@ -1230,29 +1202,6 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelectedRouteUrlSecure(
|
||||
url: String,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
isTailscaleDetected: Boolean,
|
||||
): Boolean {
|
||||
if (isUrlSecure(url)) return true
|
||||
return activeEndpoint.isEncryptedOverlayRoute(isTailscaleDetected)
|
||||
}
|
||||
|
||||
private fun EndpointCandidate?.isEncryptedOverlayRoute(isTailscaleDetected: Boolean): Boolean {
|
||||
if (this == null) return false
|
||||
val role = role.lowercase()
|
||||
val securityHint = security.orEmpty().lowercase()
|
||||
return role == "tailscale" ||
|
||||
(isTailscaleDetected && securityHint.contains("tailscale")) ||
|
||||
role == "plugin_proxy" ||
|
||||
role == "plugin-proxy" ||
|
||||
hasSecureProxy() ||
|
||||
securityHint.contains("wireguard") ||
|
||||
securityHint.contains("https") ||
|
||||
securityHint.contains("tls")
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered step row for the Manual pairing code fallback. Tightly
|
||||
* coupled to its Card 3 layout — step badge sizing + content shape —
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurity
|
||||
|
||||
private const val LEARN_MORE_URL =
|
||||
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
|
||||
|
||||
/**
|
||||
* Per-surface "Connection security" detail sheet — the tap target for the
|
||||
* connection-security badge. Shows the rollup, the per-transport breakdown,
|
||||
* and a one-line explainer of the mechanism so the at-a-glance badge never
|
||||
* has to lie about a mixed connection.
|
||||
*/
|
||||
/**
|
||||
* Self-contained badge that opens the [ConnectionSecuritySheet] on tap. Drop
|
||||
* it on any surface (connection header, posture strip) without threading sheet
|
||||
* state through the caller.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionSecurityBadgeWithSheet(
|
||||
security: ConnectionSecurity,
|
||||
modifier: Modifier = Modifier,
|
||||
size: TransportSecuritySize = TransportSecuritySize.Chip,
|
||||
) {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
ConnectionSecurityBadge(
|
||||
security = security,
|
||||
modifier = modifier,
|
||||
size = size,
|
||||
onClick = { show = true },
|
||||
)
|
||||
if (show) {
|
||||
ConnectionSecuritySheet(security = security, onDismiss = { show = false })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionSecuritySheet(
|
||||
security: ConnectionSecurity,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Connection security",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
ConnectionSecurityBadge(
|
||||
security = security,
|
||||
size = TransportSecuritySize.Large,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (security.surfaces.isEmpty()) {
|
||||
Text(
|
||||
text = "No active route yet. Connect to a server to see how each " +
|
||||
"part of the connection is protected.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
security.surfaces.forEach { SurfaceSecurityRow(it) }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = explainer(security.level),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
TextButton(onClick = { uriHandler.openUri(LEARN_MORE_URL) }) {
|
||||
Text("Learn about connection security →")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SurfaceSecurityRow(surface: SurfaceSecurity) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SurfaceSecurityGlyph(kind = surface.kind, modifier = Modifier.size(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = surface.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = surface.url,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = surface.mechanism,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun explainer(level: ConnectionSecurityLevel): String = when (level) {
|
||||
ConnectionSecurityLevel.Tls ->
|
||||
"Encrypted with TLS. The server's certificate is pinned on first connect."
|
||||
ConnectionSecurityLevel.Overlay ->
|
||||
"Encrypted by your overlay network (e.g. Tailscale/WireGuard), not TLS. " +
|
||||
"Cert pinning applies only to TLS routes."
|
||||
ConnectionSecurityLevel.Mixed ->
|
||||
"Some parts of this connection are encrypted and some are plain. The app " +
|
||||
"prefers a secure route when one is reachable."
|
||||
ConnectionSecurityLevel.Plain ->
|
||||
"Not encrypted. Only safe on a network you fully trust — anyone in between " +
|
||||
"could read this traffic."
|
||||
ConnectionSecurityLevel.Unknown -> ""
|
||||
}
|
||||
@@ -48,8 +48,11 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.SurfaceSecurityKind
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isEncryptedOverlayRoute
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.data.isTlsUrl
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -241,6 +244,7 @@ private fun EndpointRow(
|
||||
text = candidate.displayLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
SurfaceSecurityGlyph(kind = candidate.routeSecurityKind())
|
||||
if (isActive) {
|
||||
ActiveChip()
|
||||
} else if (isPreferred) {
|
||||
@@ -498,6 +502,18 @@ private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route security classification for the picker glyph. Keyed on the
|
||||
* candidate's own scheme + role (no device-level Tailscale detection needed —
|
||||
* a `tailscale`/`plugin_proxy` role is encrypted regardless), so each row can
|
||||
* be classified independently before it's the active route.
|
||||
*/
|
||||
private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
|
||||
isTlsUrl(api.url) -> SurfaceSecurityKind.Tls
|
||||
isEncryptedOverlayRoute(isTailscaleDetected = false) -> SurfaceSecurityKind.Overlay
|
||||
else -> SurfaceSecurityKind.Plain
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit dialog for an extra fallback route — the manual counterpart of a
|
||||
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
|
||||
|
||||
@@ -33,6 +33,8 @@ fun RelayStatusStrip(
|
||||
trailing: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
/** Optional security marker rendered just before the route label. */
|
||||
securityGlyph: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -65,6 +67,9 @@ fun RelayStatusStrip(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
leadingBadge()
|
||||
if (securityGlyph != null) {
|
||||
securityGlyph()
|
||||
}
|
||||
if (routeLabel.isNotBlank()) {
|
||||
Text(
|
||||
text = "· $routeLabel",
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -22,6 +23,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurityKind
|
||||
|
||||
/**
|
||||
* Visual badge for the current relay transport security posture.
|
||||
@@ -294,3 +298,123 @@ fun isUrlSecure(url: String?): Boolean {
|
||||
val lower = url.trim().lowercase()
|
||||
return lower.startsWith("wss://") || lower.startsWith("https://")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConnectionSecurity-driven badge (single source of truth — see
|
||||
// data/ConnectionSecurity.kt). Mechanism-first copy: a Tailscale/WireGuard
|
||||
// route reads "Encrypted · Tailscale", NOT "Secure — TLS". Both TLS and
|
||||
// overlay are green; only true plaintext-without-overlay warns.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class ConnSecAppearance(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val bg: Color,
|
||||
val fg: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun connSecAppearance(security: ConnectionSecurity): ConnSecAppearance {
|
||||
val green = Color(0xFF2E7D32)
|
||||
val amber = Color(0xFFF9A825)
|
||||
val red = MaterialTheme.colorScheme.error
|
||||
return when (security.level) {
|
||||
ConnectionSecurityLevel.Tls -> ConnSecAppearance(
|
||||
label = "Encrypted · TLS",
|
||||
icon = Icons.Filled.Lock,
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
ConnectionSecurityLevel.Overlay -> ConnSecAppearance(
|
||||
label = "Encrypted · ${security.mechanism}",
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = green.copy(alpha = 0.14f),
|
||||
fg = green,
|
||||
)
|
||||
ConnectionSecurityLevel.Mixed -> ConnSecAppearance(
|
||||
label = "Mixed routes",
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = amber.copy(alpha = 0.16f),
|
||||
fg = amber,
|
||||
)
|
||||
ConnectionSecurityLevel.Plain -> ConnSecAppearance(
|
||||
label = if (security.mechanism.isNotBlank() && security.mechanism != "Plain") {
|
||||
"Not encrypted · ${security.mechanism}"
|
||||
} else {
|
||||
"Not encrypted"
|
||||
},
|
||||
icon = Icons.Filled.LockOpen,
|
||||
bg = red.copy(alpha = 0.16f),
|
||||
fg = red,
|
||||
)
|
||||
ConnectionSecurityLevel.Unknown -> ConnSecAppearance(
|
||||
label = "Checking…",
|
||||
icon = Icons.Filled.Shield,
|
||||
bg = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
fg = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection-level security badge every surface should use. Renders the
|
||||
* rollup from [ConnectionSecurity]; tap (when [onClick] is set) opens the
|
||||
* per-surface detail sheet. Renders nothing while the verdict is Unknown.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionSecurityBadge(
|
||||
security: ConnectionSecurity,
|
||||
modifier: Modifier = Modifier,
|
||||
size: TransportSecuritySize = TransportSecuritySize.Chip,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
if (security.level == ConnectionSecurityLevel.Unknown) return
|
||||
val a = connSecAppearance(security)
|
||||
RenderBadge(
|
||||
label = a.label,
|
||||
bg = a.bg,
|
||||
fg = a.fg,
|
||||
icon = a.icon,
|
||||
size = size,
|
||||
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon-only security marker for tight spots (chat status strip). */
|
||||
@Composable
|
||||
fun ConnectionSecurityGlyph(
|
||||
security: ConnectionSecurity,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (security.level == ConnectionSecurityLevel.Unknown) return
|
||||
val a = connSecAppearance(security)
|
||||
Icon(
|
||||
imageVector = a.icon,
|
||||
contentDescription = a.label,
|
||||
tint = a.fg,
|
||||
modifier = modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Per-route security glyph for the route picker (one [SurfaceSecurityKind]). */
|
||||
@Composable
|
||||
fun SurfaceSecurityGlyph(
|
||||
kind: SurfaceSecurityKind,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val green = Color(0xFF2E7D32)
|
||||
val amber = Color(0xFFF9A825)
|
||||
val (icon, tint, desc) = when (kind) {
|
||||
SurfaceSecurityKind.Tls -> Triple(Icons.Filled.Lock, green, "Encrypted (TLS)")
|
||||
SurfaceSecurityKind.Overlay -> Triple(Icons.Filled.Shield, green, "Encrypted")
|
||||
// Per-route plaintext is amber (informational), not red — a secure
|
||||
// route may exist alongside it.
|
||||
SurfaceSecurityKind.Plain -> Triple(Icons.Filled.LockOpen, amber, "Not encrypted")
|
||||
}
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = desc,
|
||||
tint = tint,
|
||||
modifier = modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,10 @@ import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.ConnectionSecurity
|
||||
import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.computeConnectionSecurity
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
@@ -1122,6 +1124,33 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
val isTailscaleDetected: StateFlow<Boolean> = tailscaleDetector.isTailscaleDetected
|
||||
|
||||
/**
|
||||
* Single source of truth for the connection-security indicator (chat
|
||||
* status chip, connection header, route picker, detail sheet). Rolls up
|
||||
* the per-surface scheme of API / dashboard / relay against the active
|
||||
* route — overlay transports (Tailscale/WireGuard/proxy) count as
|
||||
* encrypted, not just TLS. Declared after [isTailscaleDetected] because it
|
||||
* reads it. See `data/ConnectionSecurity.kt`.
|
||||
*/
|
||||
val connectionSecurity: StateFlow<ConnectionSecurity> = combine(
|
||||
effectiveApiServerUrl,
|
||||
effectiveDashboardUrl,
|
||||
effectiveRelayUrl,
|
||||
relayConfigured,
|
||||
activeEndpoint,
|
||||
) { api, dashboard, relay, relayCfg, endpoint ->
|
||||
arrayOf(api, dashboard, relay, relayCfg, endpoint)
|
||||
}.combine(isTailscaleDetected) { values, tailscale ->
|
||||
computeConnectionSecurity(
|
||||
apiUrl = values[0] as String,
|
||||
dashboardUrl = values[1] as String,
|
||||
relayUrl = values[2] as String,
|
||||
relayConfigured = values[3] as Boolean,
|
||||
activeEndpoint = values[4] as EndpointCandidate?,
|
||||
isTailscaleDetected = tailscale,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectionSecurity.UNKNOWN)
|
||||
|
||||
// What's New tracking
|
||||
private val _showWhatsNew = MutableStateFlow(false)
|
||||
val showWhatsNew: StateFlow<Boolean> = _showWhatsNew.asStateFlow()
|
||||
@@ -3446,6 +3475,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
url = dashboardUrl,
|
||||
)
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Defense-in-depth: this runs in a viewModelScope (Main) coroutine,
|
||||
// so an unexpected throw from any probe sub-call would crash the
|
||||
// app (see the currentSession() stale-connection crash). A probe
|
||||
// failure must only degrade the UI, never be fatal.
|
||||
android.util.Log.w("ConnectionVM", "probeStandardVoice failed: ${e.message}")
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
|
||||
_standardAudioApiReachable.value = false
|
||||
_serverChatDisplaySettings.value = null
|
||||
updateGatewayAvailability(GatewayAvailability.Unreachable)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Locks the connection-security rollup (the single source of truth behind the
|
||||
* in-app indicator). The headline correctness property: a plaintext route over
|
||||
* an overlay network (Tailscale/WireGuard) is **encrypted**, not "insecure".
|
||||
*/
|
||||
class ConnectionSecurityTest {
|
||||
|
||||
private fun endpoint(role: String, security: String? = null) = EndpointCandidate(
|
||||
role = role,
|
||||
api = ApiEndpoint(host = "h", port = 8642),
|
||||
relay = RelayEndpoint(url = "ws://h:8767"),
|
||||
security = security,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun allTlsSurfaces_rollUpToTls() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "https://h:8642",
|
||||
dashboardUrl = "https://h:9119",
|
||||
relayUrl = "wss://h:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("public"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Tls, result.level)
|
||||
assertEquals("TLS", result.mechanism)
|
||||
assertEquals(3, result.surfaces.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plaintextOverTailscale_isEncryptedOverlay_notPlain() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "http://100.71.0.1:8642",
|
||||
dashboardUrl = "http://100.71.0.1:9119",
|
||||
relayUrl = "ws://100.71.0.1:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("tailscale"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
|
||||
assertEquals("Tailscale", result.mechanism)
|
||||
// The whole point: overlay counts as encrypted.
|
||||
assertEquals(true, result.isEncrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun someTlsSomePlain_isMixed() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "https://h:8642",
|
||||
dashboardUrl = "https://h:9119",
|
||||
relayUrl = "ws://h:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("lan"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Mixed, result.level)
|
||||
assertEquals(false, result.isEncrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allPlainLan_isPlain_withRoleMechanism() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "http://192.168.1.10:8642",
|
||||
dashboardUrl = "http://192.168.1.10:9119",
|
||||
relayUrl = "ws://192.168.1.10:8767",
|
||||
relayConfigured = true,
|
||||
activeEndpoint = endpoint("lan"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Plain, result.level)
|
||||
assertEquals("LAN", result.mechanism)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayNotConfigured_excludesRelaySurface() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "https://h:8642",
|
||||
dashboardUrl = "https://h:9119",
|
||||
relayUrl = "ws://h:8767", // plain, but relay not configured → ignored
|
||||
relayConfigured = false,
|
||||
activeEndpoint = endpoint("public"),
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Tls, result.level)
|
||||
assertEquals(2, result.surfaces.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSurfaces_isUnknown() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "",
|
||||
dashboardUrl = "",
|
||||
relayUrl = "",
|
||||
relayConfigured = false,
|
||||
activeEndpoint = null,
|
||||
isTailscaleDetected = false,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Unknown, result.level)
|
||||
assertEquals(ConnectionSecurity.UNKNOWN, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceTailscaleDetected_withSecurityHint_classifiesOverlay() {
|
||||
val result = computeConnectionSecurity(
|
||||
apiUrl = "http://host:8642",
|
||||
dashboardUrl = "http://host:9119",
|
||||
relayUrl = "ws://host:8767",
|
||||
relayConfigured = false,
|
||||
activeEndpoint = endpoint(role = "custom", security = "tailscale-magicdns"),
|
||||
isTailscaleDetected = true,
|
||||
)
|
||||
assertEquals(ConnectionSecurityLevel.Overlay, result.level)
|
||||
assertEquals("Tailscale", result.mechanism)
|
||||
}
|
||||
}
|
||||
+15
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -56,6 +57,20 @@ class DashboardApiClientTest {
|
||||
assertEquals("0.16.0", status.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentSession_onConnectionAbort_returnsFailure_doesNotThrow() = runTest {
|
||||
// Reproduces the crash: a stale pooled connection aborting mid-flight
|
||||
// ("Software caused connection abort"). currentSession() returns a
|
||||
// Result, so a network failure MUST surface as Result.failure — never a
|
||||
// throw that escapes withContext(IO) and crashes the Main coroutine.
|
||||
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val result = client.currentSession()
|
||||
|
||||
assertTrue("network abort must be Result.failure, not a throw", result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getStatus_acceptsProviderObjects() = runTest {
|
||||
server.enqueue(
|
||||
|
||||
+9
-3
@@ -438,7 +438,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
|
||||
|
||||
**Decision:** Replace the minimal pairing model (one-shot code → fixed-30-day session token → no channel separation → `EncryptedSharedPreferences` storage) with a layered architecture built around four ideas:
|
||||
|
||||
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
|
||||
1. **User chooses session TTL at pair time** — 1 day / 7 days / 30 days / 90 days / 1 year / **never expire**. The Android TTL picker dialog always opens on QR scan so the user explicitly confirms. Defaults depend on transport: wss or Tailscale → 30d; plain ws → 7d. (Both `wss` and Tailscale are treated as *secure transports* here — but for different reasons: `wss` is TLS, while Tailscale's security comes from WireGuard end-to-end encryption, not TLS. See [`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).) Never-expire is ALWAYS selectable with an inline warning — per operator direction, trust the user's intent rather than gating on secure-transport detection.
|
||||
2. **Per-channel grants** — one session token, separate expiries for `chat` / `terminal` / `bridge`; later releases added `tui` and split voice grants (`voice:config`, `voice:stt`, `voice:tts`). Blast-radius-heavy channels can have shorter caps, and all grants are clamped to the session lifetime. Chat runs through the hermes-agent API server rather than the relay, so the chat grant is informational only (used by the phone UI to show scope).
|
||||
3. **Hardware-backed token storage with graceful fallback** — `KeystoreTokenStore` requests StrongBox-backed keys via `setRequestStrongBoxBacked(true)` on Android 9+ devices that advertise `FEATURE_STRONGBOX_KEYSTORE`. Falls back to the existing `LegacyEncryptedPrefsTokenStore` (TEE-backed `EncryptedSharedPreferences`) on older devices or when the Keystore path throws. Migration is one-shot and lossless — users never lose a session to an app upgrade.
|
||||
4. **TOFU cert pinning with explicit reset on re-pair** — `CertPinStore` records SHA-256 SPKI fingerprints per `host:port` on the first successful wss connect. Subsequent connects build an OkHttp `CertificatePinner` from the stored pin. A user-initiated QR re-pair (`applyServerIssuedCodeAndReset(code, relayUrl)`) wipes the pin for the target host — re-pair is explicit consent to potentially-new cert material. Plaintext ws:// short-circuits pinning entirely.
|
||||
@@ -1077,8 +1077,14 @@ priority-0 candidate from the top-level fields when `endpoints` is absent.
|
||||
phone falls through to the next candidate in priority order.
|
||||
- **TTL defaults by role** (informational — operator can override at
|
||||
pair time): `lan` → 7 days, `tailscale` → 30 days, `public` → 30 days,
|
||||
unknown role → 7 days (conservative). Plaintext-`ws://` consent still
|
||||
gates any candidate with `transport_hint = "ws"`.
|
||||
unknown role → 7 days (conservative). The longer `tailscale` default
|
||||
reflects that the tailnet is a *secure transport* (WireGuard end-to-end
|
||||
encryption + device identity) — not that the link is TLS. A
|
||||
`tailscale` candidate can carry a plain `transport_hint = "ws"` and
|
||||
still be encrypted; that's WireGuard, not TLS. See
|
||||
[`user-docs/architecture/connection-security.md`](../user-docs/architecture/connection-security.md).
|
||||
Plaintext-`ws://` consent still gates any candidate with
|
||||
`transport_hint = "ws"`.
|
||||
|
||||
**Canonicalization for the HMAC signature:** `canonicalize()` in
|
||||
`plugin/relay/qr_sign.py` uses `json.dumps(sort_keys=True,
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Connection Security Indicator — Surfacing, Wording & Docs Plan
|
||||
|
||||
**Status:** Draft for review (no implementation yet — placement decisions pending)
|
||||
**Date:** 2026-06-24
|
||||
**Owner surface:** Android app (UI), user docs, engineering docs
|
||||
**Companion to:** [`docs/plans/2026-06-18-native-secure-routes.md`](2026-06-18-native-secure-routes.md) (Features-vs-Routes split + plugin secure proxy mechanics). That plan owns *how routes work*; **this plan owns how security is communicated** to the user across every surface.
|
||||
**Goal:** Let a user tell, at a glance and without ambiguity, whether their connection to Hermes is encrypted — and by what (TLS, Tailscale/WireGuard, or not at all) — without the app lying or scaring people who are already secure.
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
Users keep asking "is this secure?" The honest answer today is *"yes, but the app barely tells you, and where it does, it sometimes lies."* The security **model already exists** in code — it's just (a) buried in `Manage → Connections → Advanced`, (b) mislabelled (a Tailscale route is reported as **"Secure — TLS"** when it's actually WireGuard, not TLS), and (c) absent from every at-a-glance surface (the chat status chip, the connection header, the route picker).
|
||||
|
||||
This is a **surfacing + wording + docs** task, not a greenfield feature. We promote the existing computation to a single source of truth, correct the copy, place a glanceable badge on the high-traffic surfaces, add a tap-through "Connection security" explainer, and fix the docs that conflate "Tailscale" with "TLS."
|
||||
|
||||
---
|
||||
|
||||
## What already exists (do not rebuild)
|
||||
|
||||
| Asset | File | What it does |
|
||||
|---|---|---|
|
||||
| Tri-state model | `ui/components/TransportSecurityBadge.kt` — `TransportSecurityState { AllSecure, Mixed, AllInsecure }` | Badge with lock/shield/lock-open icons + 3 size variants. |
|
||||
| Overlay-aware "is this route encrypted" | `ActiveConnectionSections.kt:1233` `isSelectedRouteUrlSecure()` → `isEncryptedOverlayRoute()` (`:1242`) | **Already** treats `role=="tailscale"`, `plugin_proxy`, WireGuard/HTTPS security hints, and `hasSecureProxy()` as encrypted — not just `wss`/`https`. |
|
||||
| Security posture strip | `ActiveConnectionSections.kt:1109` `ActiveCardSecurityPosture` | Renders the badge + "Tailscale detected" + "hardware keystore" + relay-sessions row. **Buried** under the Advanced section. |
|
||||
| Insecure consent | `ui/components/InsecureConnectionAckDialog.kt`; `ConnectionManager.kt:156–325` (`insecureMode`/`isInsecureConnection`, ws:// block) | Threat-model dialog + reason picker; blocks `ws://` unless insecure mode is on. |
|
||||
| TOFU cert pinning | `auth/CertPinStore.kt` (TLS-only, per `host:port`) | Pins on first `wss`/`https` connect. Not surfaced to users. |
|
||||
| Per-endpoint label | `data/Endpoint.kt:136` `displayLabel()` | "LAN" / "Tailscale" / "HTTPS" / "Plugin proxy". |
|
||||
|
||||
**The three concrete defects to fix:**
|
||||
1. **Buried** — the only real security readout lives below `Advanced` on the Manage tab. Most users never see it.
|
||||
2. **The "TLS lie"** — `resolveStateAppearance(AllSecure)` hardcodes the label **"Secure — TLS"** even when the secure-ness comes from Tailscale/WireGuard (`isEncryptedOverlayRoute` returned true for a `ws://` Tailscale route). Saying "TLS" for a non-TLS link is wrong and erodes trust.
|
||||
3. **No glanceable surface** — the chat status chip (`RelayApp.kt` ~`920–975`, `ChatTransportStatusBadge.kt`), the connection card header, and the route picker (`EndpointsCard.kt`) show the *route name* but never its *security*.
|
||||
|
||||
---
|
||||
|
||||
## The hard question: 3 transports → is "secure" even well-defined?
|
||||
|
||||
**You asked: does having 3 potential transports make this hard to call "secure"? Yes — and that's the core design problem.** A single paired connection fans out to several surfaces, each with an **independent** scheme (confirmed in `Endpoint.kt:37–94` + `ConnectionViewModel.kt:746–820`):
|
||||
|
||||
| Surface | Client | Scheme source | Can be plain while others are TLS? |
|
||||
|---|---|---|---|
|
||||
| Gateway chat (`/api/ws`) | `GatewayChatClient` | dashboard URL scheme | yes |
|
||||
| API / sessions (SSE) | `HermesApiClient` | `endpoint.api.tls` | yes |
|
||||
| Dashboard (Manage/voice) | `DashboardApiClient` | `endpoint.dashboard.url` ∨ derived from `api.tls` | yes |
|
||||
| Relay (terminal/bridge/tools) | `ConnectionManager` | `endpoint.relay.url` (`ws`/`wss`) | yes |
|
||||
|
||||
So **a connection is not uniformly secure** — relay can be `ws://` while the API is `https://`. (Concretely: one paired connection can carry API `https://host:8642`, dashboard derived to `https://host:9119`, and relay `ws://host:8767` — secure chat/Manage, plain relay — at the same time.) A single binary "Secure" badge would lie. The existing `AllSecure / Mixed / AllInsecure` rollup is the right instinct; we keep it but make it **honest and overlay-aware**.
|
||||
|
||||
**Decision (proposed):** show a **connection-level rollup for the glance, per-surface truth on tap.**
|
||||
- **Glance badge** = worst-case across the surfaces *actually in use*: all encrypted → secure; some plain → "Mixed"; all plain with no overlay → "Not encrypted."
|
||||
- **Tap → detail sheet** = the per-surface breakdown (Chat/API: 🔒, Relay: ⚠️, …) so power users get the truth without the chip having to.
|
||||
- Crucially, **"encrypted" includes overlay transports** (Tailscale/WireGuard/plugin proxy), not just TLS — because for the user those *are* secure end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## The wording model (the part that fixes the trust problem)
|
||||
|
||||
Reframe from a binary "Secure/Insecure" to **mechanism-first, 4 outcomes**. The key correction: **Tailscale is secure** — WireGuard gives end-to-end encryption + device identity, arguably stronger than TOFU-pinned TLS. Telling a Tailscale user they're "insecure/plain" is both wrong and the likely reason they keep asking.
|
||||
|
||||
| State | When | Icon | Chip copy | Tone |
|
||||
|---|---|---|---|---|
|
||||
| **TLS** | every in-use surface is `wss`/`https` | 🔒 Lock | `Encrypted · TLS` | green |
|
||||
| **Private network** | plain scheme, but route is Tailscale / WireGuard / plugin proxy | 🛡️ Shield | `Encrypted · Tailscale` (or `· WireGuard` / `· Proxy`) | green |
|
||||
| **Mixed** | some surfaces encrypted, some plain (a secure fallback exists) | 🛡️ Shield | `Mixed routes` | amber |
|
||||
| **Not encrypted** | plain `ws`/`http`, no overlay | ⚠️ Lock-open | `Not encrypted · LAN` | amber→red by context |
|
||||
|
||||
Notes:
|
||||
- Both 🔒 and 🛡️ are **green/"secure"** — only true plaintext-without-overlay is a warning. This is the single most important copy change.
|
||||
- Keep "Plain"/"Not encrypted" (never a blank); avoid the word "Insecure" in the chip (reserve it for the consent dialog where the threat model is explained).
|
||||
- The detail sheet spells out the distinction in one line each: *"TLS — encrypted to this server's certificate (pinned on first connect)."* / *"Tailscale — encrypted by your tailnet (WireGuard), not TLS."* / *"Not encrypted — only safe on a network you fully trust."*
|
||||
- **Code change:** replace the hardcoded `"Secure — TLS"` label (`TransportSecurityBadge.kt:219`) with mechanism-derived copy, and split `AllSecure` into `Tls` vs `Overlay` so the badge can say which.
|
||||
|
||||
---
|
||||
|
||||
## Placement audit & recommendation
|
||||
|
||||
Full surface inventory in the appendix. Recommended placements, highest-traffic first:
|
||||
|
||||
### P1 — Chat bottom status chip (the one everyone sees)
|
||||
`RelayApp.kt` ~`920–975`, beside `ChatTransportStatusBadge` + route label. Today: `⚡ Gateway · Tailscale gpt-5.5 / profile: default`. Add a leading security glyph:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ⚡ Gateway 🛡️ Tailscale gpt-5.5 / profile: default │ ← encrypted via Tailscale (green shield)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ⚡ Gateway 🔒 TLS gpt-5.5 / profile: default │ ← encrypted via TLS (green lock)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ⚡ Gateway ⚠️ Not encrypted gpt-5.5 / profile: default │ ← plain LAN, no overlay (amber)
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
Glyph replaces/precedes the bare route word so "Tailscale" now reads as *secure-Tailscale*. Tap the chip → **Connection security** detail sheet.
|
||||
|
||||
### P2 — Connection card header (Manage → Connections)
|
||||
`ActiveConnectionSections.kt` card header — add the same badge next to the `Active` pill so the connection list communicates security without expanding Advanced. Promotes the existing `ActiveCardSecurityPosture` logic up out of the Advanced fold.
|
||||
|
||||
### P3 — Route picker (`EndpointsCard.kt`)
|
||||
Per-route security glyph on each candidate row, so when a user switches routes they see which are encrypted *before* committing:
|
||||
```
|
||||
○ LAN ⚠️ Not encrypted 192.168.x.x · Probe ✓
|
||||
● Tailscale 🛡️ Encrypted 100.x.y.z · Active
|
||||
○ Public 🔒 TLS <host>.ts.net · Probe ✓
|
||||
```
|
||||
|
||||
### P4 — "Connection security" detail sheet (new, the tap target for P1/P2)
|
||||
A small bottom sheet that is the single place the per-surface truth + the explainer lives:
|
||||
```
|
||||
Connection security — <your server>
|
||||
────────────────────────────────────
|
||||
Overall 🛡️ Encrypted (Tailscale)
|
||||
|
||||
Chat (gateway) 🛡️ Tailscale http://100.x.y.z:9119
|
||||
API / sessions 🛡️ Tailscale http://100.x.y.z:8642
|
||||
Relay tools 🛡️ Tailscale ws://100.x.y.z:8767
|
||||
────────────────────────────────────
|
||||
🛡️ Tailscale encrypts this with WireGuard (not TLS).
|
||||
Cert pinning applies only to TLS routes.
|
||||
[ Learn about connection security → ] (docs link)
|
||||
```
|
||||
|
||||
> **For your review:** P1 + P4 are the must-haves (glance + truth-on-tap). P2/P3 are high-value but optional for a first cut. The detail sheet is also the natural home for the **TOFU pin** ("Server identity pinned ✓") and the hardware-keystore line that currently sit in the buried posture strip.
|
||||
|
||||
---
|
||||
|
||||
## Secure proxy: status and how it fits
|
||||
|
||||
The **plugin secure proxy** (the "Secure proxy — Not advertised" row) is a **stub today**: the Android side models it (`Endpoint.kt` `ProxyEndpoint`, `plugin_proxy` role, `hasSecureProxy()`), and `isEncryptedOverlayRoute()` already treats it as encrypted — but **the relay has no proxy-forward implementation, pairing never emits a `plugin_proxy` candidate, and no cert/pin is generated.** Enabling it end-to-end is the unbuilt **Phase 4** of `2026-06-18-native-secure-routes.md` (relay HTTP-forward routes + `RELAY_SSL_*` cert + pairing emission; ~2–3 wk).
|
||||
|
||||
**Implication for this plan:** the indicator must **not block** on the proxy. We design the wording/placement so that *when* a `plugin_proxy` route is advertised it slots in as a 🔒 **TLS (pinned)** route automatically (it already would, via `isEncryptedOverlayRoute`). Until then it stays honestly "Not advertised." Recommend a separate spike to stand it up + test on the server (tracked in `TODO.md`), independent of this UX work.
|
||||
|
||||
---
|
||||
|
||||
## Documentation plan
|
||||
|
||||
The docs currently **conflate "Tailscale" with "TLS/secure"** in several places — fixing this is half the user-facing win.
|
||||
|
||||
**New page:** `user-docs/architecture/connection-security.md` — "Is my connection secure?" Covers: `ws`/`wss` & `http`/`https`; what Tailscale actually does (WireGuard VPN, encrypted + identity, *plus* optional Serve-HTTPS); TLS + TOFU pinning; the per-surface model; how to read the in-app badge; how to get a TLS route (Tailscale Serve `--https`, reverse proxy, or the future plugin proxy). Add to the `/architecture/` sidebar after `security.md`. (Pairs 1:1 with the in-app detail-sheet "Learn more" link.)
|
||||
|
||||
**Conflation fixes (call out "WireGuard ≠ TLS, both are secure"):**
|
||||
- `docs/decisions.md` §15 (`:441` TTL `wss or Tailscale → 30d`) and §24 — annotate that Tailscale's security is WireGuard, separate from `wss`.
|
||||
- `user-docs/architecture/security.md:62–69` — split "Tailscale (VPN + optional managed TLS)" from "reverse proxy (TLS only)."
|
||||
- `user-docs/guide/remote-access.md:27–32, 70–84` — distinguish *who terminates TLS* from *Tailscale provides the network*.
|
||||
- Document TOFU pinning for users for the first time (currently code-only).
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (your call before implementation)
|
||||
|
||||
1. **Is "plain over Tailscale" green or amber?** Recommendation: **green 🛡️ "Encrypted · Tailscale"** (WireGuard is genuinely secure). This is the crux of the trust fix. (Alternative: amber, treating only TLS as fully green — more conservative, but keeps confusing Tailscale users.)
|
||||
2. **Glance scope:** connection-rollup badge + per-surface on tap (recommended), vs. always show per-surface inline (busier).
|
||||
3. **First-cut scope:** P1 (chat chip) + P4 (detail sheet) + wording fix + docs page — vs. also P2/P3 in the same PR.
|
||||
4. **Word choice:** "Encrypted" vs "Secure" vs "Private" for the overlay state. Recommendation: **"Encrypted · <mechanism>"** (concrete, non-marketing).
|
||||
5. **Proxy:** confirm we keep it out of scope here (separate Phase-4 spike).
|
||||
|
||||
---
|
||||
|
||||
## Implementation tiers (after decisions land)
|
||||
|
||||
> Scope: **A** = ship-now UX · **Doc** = docs · effort **S/M/L**.
|
||||
|
||||
### A1 — Single source of truth: `ConnectionSecurity` model · M
|
||||
Lift `isSelectedRouteUrlSecure`/`isEncryptedOverlayRoute` + the per-surface URL scheme reads into a ViewModel-exposed `StateFlow<ConnectionSecurity>` (`{ overall: Tls|Overlay|Mixed|Plain, perSurface: Map<Surface, SecurityKind>, mechanism: String }`). Every surface reads this one flow.
|
||||
**Files:** new `viewmodel/ConnectionSecurity.kt`; `ConnectionViewModel.kt`; refactor `ActiveConnectionSections.kt:1109–1254`.
|
||||
|
||||
### A2 — Fix the wording / split `AllSecure` into Tls vs Overlay · S
|
||||
Replace hardcoded `"Secure — TLS"`; mechanism-derived copy; new state for overlay. Pure `TransportSecurityBadge.kt` change + tests.
|
||||
|
||||
### A3 — P1 chat status chip glyph · S
|
||||
Add the security glyph to the chat bottom strip; tap → detail sheet. **Files:** `RelayApp.kt`, `ChatTransportStatusBadge.kt`.
|
||||
|
||||
### A4 — P4 "Connection security" detail sheet · M
|
||||
New bottom sheet; per-surface rows + explainer + TOFU/keystore lines + docs link. **Files:** new `ui/components/ConnectionSecuritySheet.kt`.
|
||||
|
||||
### A5 — P2 header badge + P3 route-picker glyphs · M (optional first cut)
|
||||
**Files:** `ActiveConnectionSections.kt`, `EndpointsCard.kt`.
|
||||
|
||||
### Doc1 — `connection-security.md` + conflation fixes · M
|
||||
New user-docs page + the four conflation edits + TOFU documentation.
|
||||
|
||||
### Spike — stand up & test the plugin secure proxy · L (separate, not blocking)
|
||||
Phase 4 of `2026-06-18-native-secure-routes.md`. Tracked in `TODO.md`.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — full UI surface inventory
|
||||
|
||||
| Surface | File:area | Shows today | Security data available |
|
||||
|---|---|---|---|
|
||||
| Chat status chip | `RelayApp.kt` ~920–975; `ChatTransportStatusBadge.kt` | transport tier + route + model | route role + per-surface URL schemes |
|
||||
| Connection card header | `ActiveConnectionSections.kt` (card header) | name + Active + route summary | full per-surface |
|
||||
| Status rows (API/Dashboard/Relay/Session) | `ActiveConnectionSections.kt:108–219` | reachable/connected + "Connected · Tailscale" | role known, security not rendered |
|
||||
| Feature rows (incl. "Secure proxy") | `ActiveConnectionSections.kt:227–380` | Ready/Configured/Not advertised | proxy advertise flag |
|
||||
| Security posture strip | `ActiveConnectionSections.kt:1109–1231` | **the existing badge** (buried under Advanced) | full (this is the source to promote) |
|
||||
| Route picker | `EndpointsCard.kt:79–194` | per-route role + health | per-candidate scheme |
|
||||
| Insecure toggle + ack | `ActiveConnectionSections.kt:806–866`; `InsecureConnectionAckDialog.kt` | warning + reason picker | `isInsecureConnection` |
|
||||
| Connection info sheet | `ConnectionInfoSheet.kt` | session state | session relay URL |
|
||||
| Pair wizard confirm | `OnboardingScreen.kt` / pairing flow | route candidates | candidate schemes (good place for per-route glyph at commit time) |
|
||||
@@ -83,9 +83,10 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.2.3 — Connection crash fix.
|
||||
v1.2.4 — Stability + connection security.
|
||||
|
||||
• Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS). Connecting over a secured connection is now stable. Plain local-network connections were never affected.
|
||||
• Fixed a crash that could close the app when the dashboard connection dropped mid-check (e.g. a brief Tailscale blip) — it now fails gracefully instead of force-closing.
|
||||
• New: see whether your connection is encrypted at a glance (TLS or Tailscale) from the chat chip, connection card, and route picker, with a per-transport breakdown on tap.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.2.3"
|
||||
appVersionCode = "17"
|
||||
appVersionName = "1.2.4"
|
||||
appVersionCode = "18"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.4.0"
|
||||
compose-bom = "2026.06.00"
|
||||
|
||||
@@ -137,6 +137,7 @@ export default defineConfig({
|
||||
{ text: 'Flavor Differences', link: '/architecture/flavor-differences' },
|
||||
{ text: 'Decisions', link: '/architecture/decisions' },
|
||||
{ text: 'Security', link: '/architecture/security' },
|
||||
{ text: 'Is my connection secure?', link: '/architecture/connection-security' },
|
||||
{ text: 'Privacy', link: '/architecture/privacy' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Is my connection secure?
|
||||
|
||||
Short answer: **probably yes** — and Hermes-Relay now tells you at a glance, without
|
||||
overstating or understating it.
|
||||
|
||||
This page explains what "encrypted" actually means for your connection, why a Tailscale
|
||||
link is genuinely secure even when it looks like plain `http://`, and how to read the
|
||||
in-app security indicator. If you just want the quick version, jump to
|
||||
[How to read the indicator](#how-to-read-the-in-app-indicator).
|
||||
|
||||
## Plain vs. encrypted: `ws://` vs `wss://`, `http://` vs `https://`
|
||||
|
||||
Every connection uses a URL scheme, and the scheme tells you whether the link is
|
||||
encrypted by **TLS** (the same technology the lock icon in your browser refers to):
|
||||
|
||||
| Scheme | What it is | Privacy |
|
||||
|---|---|---|
|
||||
| `http://` / `ws://` | **Plaintext.** No TLS. | Anyone on the network path can read the traffic. |
|
||||
| `https://` / `wss://` | **TLS-encrypted.** | The traffic is encrypted to the server's certificate. |
|
||||
|
||||
On its own, a plain `http://` or `ws://` link is readable by anyone between your phone
|
||||
and the server — your home router, the coffee-shop Wi-Fi, an upstream ISP. That's why
|
||||
plaintext is only safe on a network you fully trust.
|
||||
|
||||
**But scheme isn't the whole story.** A plain `http://` link can still be fully encrypted
|
||||
if it rides inside an encrypted overlay network like Tailscale. That's the part people
|
||||
get wrong — including, until now, our own docs.
|
||||
|
||||
## What Tailscale actually is
|
||||
|
||||
[Tailscale](https://tailscale.com/) is a **WireGuard-based VPN**. When your phone and your
|
||||
Hermes host are both on your tailnet, every byte between them is **encrypted and
|
||||
authenticated end-to-end by WireGuard** — before it ever touches the URL scheme. This is
|
||||
genuinely secure transport: strong modern encryption plus device identity (only enrolled
|
||||
devices on your tailnet can talk to each other).
|
||||
|
||||
So a connection to `http://100.x.y.z:8642` **over your tailnet is encrypted** — by
|
||||
WireGuard, not by TLS. It is *not* plaintext-on-the-wire even though the scheme says
|
||||
`http`. **Tailscale plaintext is secure; it's just not TLS.** Hermes-Relay treats it as a
|
||||
green/secure route, and never labels a Tailscale route "insecure."
|
||||
|
||||
Tailscale can *also*, separately, terminate TLS for you. Running
|
||||
`tailscale serve --https=<port>` puts a real TLS certificate in front of a service, so the
|
||||
same connection becomes `https://<host>.ts.net:<port>` — now you have **both** WireGuard
|
||||
encryption *and* TLS. You don't need the TLS layer for the link to be secure over a
|
||||
tailnet, but it's there if you want a `wss://`/`https://` route (some tools and proxies
|
||||
expect one).
|
||||
|
||||
::: tip The one thing to remember
|
||||
WireGuard encryption ≠ TLS, but **both are secure transports.** A Tailscale route is
|
||||
encrypted whether or not TLS is also in play. The app's 🛡️ shield means "encrypted by your
|
||||
private network" — it is a green/secure state, not a warning.
|
||||
:::
|
||||
|
||||
## TLS + certificate pinning (TOFU)
|
||||
|
||||
When Hermes-Relay connects over a TLS route (`wss://`/`https://`) for the **first** time,
|
||||
it records a fingerprint of the server's certificate — its SHA‑256 SPKI. This is
|
||||
**trust-on-first-use (TOFU) pinning**: every later connection to that same host must
|
||||
present the *same* certificate, or the app refuses to connect.
|
||||
|
||||
What this buys you:
|
||||
|
||||
- After the first connect, a man-in-the-middle can't swap in a different certificate to
|
||||
intercept your traffic — the pin won't match.
|
||||
- The pin is per `host:port`, stored on-device.
|
||||
- Re-pairing the device (scanning a fresh QR) intentionally resets the pin for that host,
|
||||
because re-pairing is explicit consent to potentially new certificate material.
|
||||
|
||||
Two honest caveats:
|
||||
|
||||
- **Pinning only applies to TLS routes.** A Tailscale-over-`http` route has no TLS
|
||||
certificate to pin — its security comes from WireGuard instead, which provides its own
|
||||
device identity.
|
||||
- **TOFU can't protect the very first connect.** By definition it trusts whatever
|
||||
certificate is present on the initial handshake, so do your first connection over a path
|
||||
you trust (LAN, Tailscale, or VPN). It protects every connection after that.
|
||||
|
||||
## Why one connection has several security states
|
||||
|
||||
A single paired connection isn't one pipe — it fans out to several **surfaces**, and each
|
||||
one can independently be TLS, overlay-encrypted, or plain:
|
||||
|
||||
| Surface | What it carries | Typical port |
|
||||
|---|---|---|
|
||||
| **Chat (gateway)** | Live chat, thinking/reasoning | dashboard `:9119` |
|
||||
| **API / sessions** | Chat fallback, session history | API `:8642` |
|
||||
| **Dashboard (Manage + voice)** | Settings, model config, vanilla voice | dashboard `:9119` |
|
||||
| **Relay tools** | Terminal, bridge, device control | relay `:8767` |
|
||||
|
||||
Because each surface has its own URL, **a connection can be partly encrypted and partly
|
||||
plain at the same time** — for example chat and Manage on `https://`, but relay tools on
|
||||
plain `ws://`. There's no single true/false answer to "is it secure," so a single binary
|
||||
badge would lie.
|
||||
|
||||
Hermes-Relay handles this with a **rollup at a glance, the full truth on tap**:
|
||||
|
||||
- The **glance badge** reflects the worst case across the surfaces actually in use.
|
||||
- **Tapping it** opens a per-surface breakdown so you can see exactly which routes are
|
||||
encrypted and how.
|
||||
|
||||
## How to read the in-app indicator
|
||||
|
||||
The security glyph appears next to the route on the chat status chip and the connection
|
||||
card. There are four outcomes:
|
||||
|
||||
| Indicator | Meaning | Tone |
|
||||
|---|---|---|
|
||||
| 🔒 `Encrypted · TLS` | Every in-use surface is `wss`/`https`, pinned on first connect. | **Secure (green)** |
|
||||
| 🛡️ `Encrypted · Tailscale` | Plain scheme, but the route is Tailscale / WireGuard / a secure proxy — encrypted by the overlay. | **Secure (green)** |
|
||||
| 🛡️ `Mixed routes` | Some surfaces are encrypted, some are plain (a secure fallback exists). | Amber — review the breakdown |
|
||||
| ⚠️ `Not encrypted` | Plain `ws`/`http` with no overlay. | Warning — only safe on a network you fully trust |
|
||||
|
||||
The key idea: **both 🔒 and 🛡️ are green/secure.** Only true plaintext with no overlay is a
|
||||
warning. Tap the indicator for the per-surface detail, where each route is spelled out in
|
||||
one line:
|
||||
|
||||
- *TLS — encrypted to this server's certificate (pinned on first connect).*
|
||||
- *Tailscale — encrypted by your tailnet (WireGuard), not TLS.*
|
||||
- *Not encrypted — only safe on a network you fully trust.*
|
||||
|
||||
If you see ⚠️ **Not encrypted**, you're on a plain `ws://`/`http://` route with nothing
|
||||
wrapping it. That's fine on a home LAN or a trusted VPN, but you should add an encrypted
|
||||
route before using it over public Wi-Fi or the open internet.
|
||||
|
||||
## How to get a TLS (or otherwise encrypted) route
|
||||
|
||||
You have a few ways to make a connection secure. Pick whichever fits your setup:
|
||||
|
||||
### 1. Tailscale (recommended)
|
||||
|
||||
Putting both devices on a tailnet gives you WireGuard encryption immediately — you're
|
||||
secure (🛡️) with no certificates to manage. If you also want TLS-fronted `https://`/`wss://`
|
||||
routes, run Tailscale Serve:
|
||||
|
||||
```bash
|
||||
tailscale serve --https=<port> http://127.0.0.1:<port>
|
||||
```
|
||||
|
||||
The `hermes-relay-tailscale` helper fronts the two relay-owned services for you — relay
|
||||
(`:8767`) and the Hermes API server (`:8642`):
|
||||
|
||||
```bash
|
||||
hermes-relay-tailscale enable
|
||||
```
|
||||
|
||||
The **dashboard** (`:9119`, used for Manage and vanilla voice) is **not** fronted by the
|
||||
helper — if you want a TLS route to the dashboard, you front it yourself with
|
||||
`tailscale serve --https=9119 http://127.0.0.1:9119`. Without that, your dashboard surface
|
||||
rides plain `http` over the tailnet — which is still WireGuard-encrypted and secure, just
|
||||
not TLS.
|
||||
|
||||
### 2. A public reverse proxy
|
||||
|
||||
A proxy like **Caddy**, **nginx**, or **Cloudflare** can terminate TLS in front of your
|
||||
services and expose `https://`/`wss://` routes to the open internet. The proxy holds the
|
||||
certificate; your phone pins it on first connect. This is the path to use when you're
|
||||
exposing Hermes beyond a private network — never expose plain `ws://`/`http://` ports
|
||||
directly.
|
||||
|
||||
### 3. The plugin secure proxy *(not yet available)*
|
||||
|
||||
A future relay-built secure proxy will mint and front its own TLS for the relay surfaces,
|
||||
so you get a pinned `wss://` route without standing up Tailscale Serve or an external
|
||||
proxy. It is **not implemented yet** — when it ships, the app will slot it in
|
||||
automatically as a 🔒 TLS (pinned) route. Until then, use Tailscale or a reverse proxy.
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](./security.md) — full security model: key storage, auth flow, the bridge safety gate.
|
||||
- [Remote access](../guide/remote-access.md) — step-by-step Tailscale and reverse-proxy setup.
|
||||
- [Privacy](./privacy.md) — what data the app stores and where.
|
||||
@@ -103,8 +103,19 @@ Every command is logged to the Bridge tab's activity log (timestamp, status, res
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Use HTTPS** in production — the network security config enforces it by default
|
||||
1. **Use an encrypted route** in production. Two independent ways to get there, both secure:
|
||||
- **TLS** (`https://`/`wss://`) — via a reverse proxy (Caddy/nginx/Cloudflare) or
|
||||
`tailscale serve --https`. Pinned on first connect (TOFU).
|
||||
- **Tailscale / WireGuard** — even a plain `http://`/`ws://` route over your tailnet is
|
||||
**encrypted end-to-end by WireGuard**. This is *not* TLS, but it *is* secure transport;
|
||||
a Tailscale link is not "plaintext on the wire."
|
||||
|
||||
Don't conflate the two: TLS and WireGuard are different mechanisms that both make a
|
||||
connection secure. See [Is my connection secure?](./connection-security.md) for how the
|
||||
app reports each (🔒 TLS vs 🛡️ Tailscale, both green).
|
||||
2. **Rotate API keys** periodically in your Hermes server config
|
||||
3. **Disconnect when idle** — especially if bridge is enabled (or let the auto-disable timer handle it)
|
||||
4. **Avoid public WiFi** for relay connections without additional encryption
|
||||
4. **Avoid plaintext on untrusted networks** — a plain `ws://`/`http://` route with no
|
||||
Tailscale/WireGuard or TLS wrapping it is readable on public Wi-Fi. The app shows
|
||||
⚠️ **Not encrypted** for exactly this case.
|
||||
5. **Keep the app updated** — security patches ship with new releases
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Remote Access
|
||||
|
||||
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, gives you managed TLS, and keeps access inside your tailnet ACLs.
|
||||
Hermes-Relay can keep one paired phone connected as it moves between LAN, Tailscale, a VPN, and a public reverse proxy. The recommended path is Tailscale because it works behind CGNAT, encrypts traffic end-to-end (WireGuard), keeps access inside your tailnet ACLs, and can *optionally* front TLS for you. (Note: the WireGuard encryption is what makes a tailnet link secure — TLS via `tailscale serve --https` is a separate, optional layer on top. See [Is my connection secure?](../architecture/connection-security.md).)
|
||||
|
||||
## What Uses Which Connection
|
||||
|
||||
@@ -24,7 +24,7 @@ hermes-relay-tailscale enable
|
||||
hermes pair --mode auto --prefer tailscale
|
||||
```
|
||||
|
||||
The Tailscale helper publishes both required loopback services:
|
||||
The Tailscale helper publishes both required loopback services, fronting each with TLS:
|
||||
|
||||
```bash
|
||||
tailscale serve --bg --https=8767 http://127.0.0.1:8767
|
||||
@@ -33,6 +33,15 @@ tailscale serve --bg --https=8642 http://127.0.0.1:8642
|
||||
|
||||
Port `8767` carries relay WSS and relay HTTP routes. Port `8642` carries the Hermes API server for chat, API-key voice auth, and endpoint health probes. If only `8767` is served, terminal/bridge may work while chat and API-key voice still fail remotely.
|
||||
|
||||
::: tip Two layers, both optional-to-stack
|
||||
Your tailnet is already encrypted by WireGuard, so even a plain `http://100.x.y.z` route is
|
||||
secure over Tailscale. `tailscale serve --https` adds a *separate* TLS layer on top, giving
|
||||
you a `wss://`/`https://` route fronted by a real certificate (the dashboard on `:9119` is
|
||||
not fronted by the helper — front it yourself if you want TLS there). See
|
||||
[Is my connection secure?](../architecture/connection-security.md) for which the app reports
|
||||
as 🔒 TLS vs 🛡️ Tailscale (both secure).
|
||||
:::
|
||||
|
||||
Check the served ports with:
|
||||
|
||||
```bash
|
||||
@@ -75,10 +84,14 @@ Pick the scheme by how the server is reached:
|
||||
The Hermes API server speaks plain HTTP; an `https://` route against it
|
||||
fails its TLS handshake on every probe and never wins. This also requires
|
||||
the API server to listen beyond loopback (`0.0.0.0:8642` or the tailnet
|
||||
interface).
|
||||
interface). Note that an `http://` route over a raw Tailscale IP is **not
|
||||
plaintext on the wire** — WireGuard encrypts it end-to-end. It's secure
|
||||
transport, just not TLS (the app reports it as 🛡️ Tailscale, not ⚠️ Not
|
||||
encrypted). A plain LAN IP, by contrast, has no such wrapping.
|
||||
- **`*.ts.net` hostname fronted by `hermes-relay-tailscale enable`** →
|
||||
`https://` — Tailscale terminates TLS for the MagicDNS hostname (the cert
|
||||
is only valid for that name, not for the raw `100.x` IP).
|
||||
is only valid for that name, not for the raw `100.x` IP). This adds TLS
|
||||
*on top of* the WireGuard encryption you already had over the tailnet.
|
||||
- **Public reverse proxy** → `https://` with whatever host/port the proxy
|
||||
exposes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user