feat(security): role-aware Plain badge + per-verb bridge trust + AllInsecure pairing ack
Operationalizes the three-tier consent policy documented in DEVLOG 2026-04-22 (II):
Tier 1 = forced confirm at security-boundary crossings (once per install),
Tier 2 = subtle warning when the risk is informed by design (secure fallback
present, user-intent trust model), Tier 3 = per-action Yes/No for reversible
destructive acts. Three linked changes in one commit:
(a) Transport Security badge — role-aware Plain labeling.
Pre-fix the badge derived its label from PairingPreferences.insecureReason,
which only got populated when the user toggled "Allow insecure connections"
ON via the Ack dialog and picked a reason. Pairing directly from a plain-ws://
LAN QR skipped that toggle — the reason stayed blank, and the badge degraded
to the alarming "Insecure (network unknown)" even though the multi-endpoint
resolver knew activeEndpointRole was "lan" in real time.
Fix: insecureReasonLabel(reason, activeRole) now prefers live role over
stored reason. Role-first fallback chain: lan -> "Plain (on LAN)",
tailscale -> "Plain (on Tailscale)", public -> "Plain (on public URL)",
custom -> "Plain (on <custom>)"; then reason-based for legacy acks; then
neutral "Plain (no TLS)" when both are unknown (not "network unknown" —
that read as a bug to users).
ConnectionViewModel.applyPairingPayload auto-stamps insecureReason at pair
time based on the selected endpoint's role — lan -> lan_only, tailscale ->
tailscale_vpn, public / unknown -> leave blank (user should think). Only
overwrites blank values; clears stale reason when the upgrade is to a
secure endpoint. "Insecure" -> "Plain" vocabulary swept through the active
card's insecure-toggle subsection ("Plain connection — traffic is not
encrypted" / "Allow plain (unencrypted) connections") to match.
(b) Bridge destructive-verb "Don't ask again" per verb.
Confirmation fatigue training: a user who has approved send_sms 50 times
has effectively consented; forcing confirm #51 trains them to dismiss
without reading. New trustedDestructiveVerbs: Flow<Set<String>> in
BridgeSafetyPreferences; BridgeSafetyManager short-circuits the
confirmation overlay when the incoming verb is in the trusted set (still
logs to the activity log — audit trail preserved).
DestructiveVerbConfirmDialog gains a `Don't ask again for "{verb}"`
checkbox — off by default on every dialog open, so opt-in is explicit
per-verb per-dialog. Deny never persists trust (denying is not consent).
Kill-switch precedence verified by code-reviewer tracing send_sms through
the full dispatcher: master-disable (BridgeCommandHandler line 525) wins
over blocklist (line 562) wins over per-verb trust (BridgeSafetyManager
line 235). A trusted verb in a blocklisted app still 403s. A trusted verb
with master disabled never fires. BridgeScreen surfaces "Trusted actions
· N actions bypass confirmation" with a Reset button under the existing
safety section — escape hatch findable without deep-linking.
(c) AllInsecure pairing — per-install acknowledgment.
When every endpoint in the scanned QR is plain (no secure sibling in the
same candidate list), ConnectionWizard.ConfirmStep renders an ack
checkbox: "I understand this pairing sends traffic in plain text —
visible to anyone on the network." Per-install via new
PairingPreferences.allInsecurePairAckSeen — once acknowledged, subsequent
AllInsecure pairs are one-tap. Mixed and AllSecure are ungated: Mixed by
definition has a secure fallback in the list, so the existing amber
"Mixed — secure fallback available" warning suffices; AllSecure has
nothing to acknowledge.
Gate correctness verified: gateIsSatisfied is allInsecureAckSeen ||
ackThisPair for AllInsecure only; Mixed and AllSecure fall to else ->
true. Checkbox only renders inside the AllInsecure branch of the
when (securityState) block. Copy explicitly states the consequence
("visible to anyone on the network") rather than just the mechanism
("plain text") — following the principle that consent copy should
describe the effect, not the plumbing.
user-docs: getting-started Transport security section rewritten with the
three-gate taxonomy (scanning an all-plain QR / first "Allow plain"
toggle / never-expire on plain). configuration.md picks up the new
all_insecure_pair_ack_seen and bridge_trusted_destructive_verbs keys in
the settings table. Legacy DataStore key names (insecure_ack_seen,
insecure_reason) preserved for migration compatibility — only the
user-facing descriptions reflect the new "Plain" vocabulary.
Team pipeline: 3 general-purpose implementation agents with isolated
file ownership + 1 feature-dev:code-reviewer sweep. One transient
cross-file compile break caught mid-flight when the Bridge agent's
BridgeSafetyManager edit referenced a method the BridgeScreen edit
hadn't wired up yet; the AllInsecure agent defensively stashed +
restored BridgeScreen to isolate its test. Final combined state
compiles clean on both googlePlay and sideload flavors. Logcat sanity
on device: zero errors from our code during the test session.
Out-of-scope AGP 9.1.1 -> 9.2.0 bump in build.gradle.kts left unstaged
— belongs in its own chore(deps) commit after independent verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a04ede7c1c
commit
3d3e9a77f0
@@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Transport Security badge is now role-aware — "Plain (on LAN)" instead of "Insecure (network unknown)".** The previous badge derived its label from `PairingPreferences.insecureReason`, which only got populated when the user toggled "Allow insecure connections" ON via the Ack dialog and picked a reason. If a user paired directly from a plain-`ws://` LAN QR, they never had to toggle that flag — the connection was already `ws://` — so the reason stayed blank and the badge degraded to the alarming `"Insecure (network unknown)"` even though the multi-endpoint resolver was tracking `activeEndpointRole = "lan"` in real time. Fix: `insecureReasonLabel` now accepts an optional `activeRole: String?` and prefers the live role over the stored ack reason (`Plain (on LAN)` / `Plain (on Tailscale)` / `Plain (on public URL)`). Neutral fallback when both role and reason are unknown is `"Plain (no TLS)"` — matches the new "Plain / Secure" vocabulary, drops the scary "Insecure" adjective. Binary-boolean `TransportSecurityBadge(isSecure, reason, ...)` overload gains an optional `activeRole` param with default `null` so existing call sites compile unchanged. `ConnectionViewModel.applyPairingPayload` auto-stamps `PairingPreferences.insecureReason` at pair time based on the selected endpoint's role (`lan` → `lan_only`, `tailscale` → `tailscale_vpn`, `public`/unknown → leave blank so the user thinks); clears any stale reason when upgrading to a secure endpoint. Only overwrites blank values — never clobbers a user-selected reason. Two user-visible "Insecure" strings inside the Advanced section's insecure-toggle subsection also rewritten to "Plain" for consistency (`"Plain connection — traffic is not encrypted"`, `"Allow plain (unencrypted) connections"`).
|
||||
|
||||
### Added
|
||||
|
||||
- **Bridge destructive-verb "Don't ask again" per verb.** `BridgeSafetyManager` now consults a new `trustedDestructiveVerbs: Flow<Set<String>>` in `BridgeSafetyPreferences` and short-circuits the confirmation overlay when the incoming verb is in the set (logging the auto-approval to the activity log so the trail is preserved). The `DestructiveVerbConfirmDialog` gets a `Don't ask again for "{verb}"` checkbox — off on every dialog open, so the user has to actively opt in per-action. Deny path never persists trust (denying a command is not consent). Kill-switch precedence is preserved and strictly ordered: master-disable wins over blocklist wins over per-verb trust. A trusted verb in a blocklisted app still 403s. `BridgeScreen` surfaces a `Trusted actions · N actions bypass confirmation` row with a `Reset` button under the existing safety section so a user who changes their mind can find the escape hatch without deep-linking to developer options. Addresses the confirmation-fatigue trap where approving `send_sms` 50 times trains the user to click through without reading the 51st.
|
||||
|
||||
- **AllInsecure pairing — one-time acknowledgment gate.** When every endpoint in a scanned QR is plain `ws://` / `http://` (no secure sibling to fall back to), `ConnectionWizard.ConfirmStep` now renders an `"I understand this pairing sends traffic in plain text — visible to anyone on the network."` checkbox that gates the Pair button. Per-install via new `PairingPreferences.allInsecurePairAckSeen` — once the user has acknowledged it, subsequent AllInsecure pairs pair one-tap. Mixed and AllSecure pairings are ungated (the amber "Mixed — secure fallback available" warning on Mixed is sufficient because the secure route exists). Matches the `InsecureConnectionAckDialog` precedent of per-install Tier-1 consent and complements the UX pass's explicit "subtle warning for Tier-2, forced confirm for Tier-1 absolute boundaries" philosophy documented in DEVLOG 2026-04-22.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connection UX self-narration pass — Route / Relay sessions vocabulary + section headers + per-route security chips.** Three linked problems shipped as one commit: (1) pairing step 2 read as "you're stuck with insecure" for any multi-endpoint QR with LAN first, because the security badge + warning card were both computed from `endpoints[0]` alone — never acknowledging a secure Tailscale fallback in the same list; (2) the post-refactor active card had the right structure but no narration — sections stacked without headers, no captions explaining what Routes / Advanced / Security are for, Advanced surfaced manual URLs with no "most people don't need this" framing; (3) "Paired Devices" sounded like Bluetooth to anyone outside the project — the actual concept is server-side relay sessions with per-channel grants. Fix: introduce a shared vocabulary (Route for network path, Active/Fallback for state, Secure/Plain for transport, Relay sessions for server records) used consistently across `ConnectionWizard.kt` ConfirmStep, `ActiveConnectionSections.kt` (all three body sections), `EndpointsCard.kt`, and `PairedDevicesScreen.kt`. New `TransportSecurityState` tri-state (`AllSecure` / `Mixed` / `AllInsecure`) drives a context-aware pairing badge — the Mixed case now reads "LAN is plain ws:// — fine at home or the office, not on public Wi-Fi. Tailscale is encrypted (wss://) and the app uses it automatically when LAN is unreachable. You're safe on any network." — so users see they have a secure fallback without needing to understand the candidate-list mental model. Active card gains four labelMedium section headers (Connection health / Routes (N) / Advanced / Security) each with a one-line bodySmall caption above the section body. Endpoint rows in both surfaces carry per-row Secure/Plain chips (green 🔒 / amber 🔓, not scary red) so each route's security is visible at a glance; ordinal labels are humanized (`1st choice` / `Fallback` / `Fallback 2` on pairing step 2; `Active` / `Fallback` on the active card — different framings because pre-connection the commitment is ordinal and post-connection what matters is state). `PairedDevices` Kotlin identifier and deep-link route string stay — only the user-visible labels change — so nav deep links are unaffected. New intro paragraph on the Relay sessions screen explains that rows are sessions (not Bluetooth pairings), and a tap-for-info icon on "Channel grants" opens a dialog explaining that chat/bridge/voice are per-feature permissions with independent expiries. Delivered as three parallel `general-purpose` implementation agents (one per surface, isolated file ownership) plus a post-implementation `code-reviewer` sweep that caught seven leftover `endpoint`/`Paired Devices` strings across `ConnectionInfoSheet.kt`, `SessionTtlPickerDialog.kt`, `EndpointsCard.kt`, `SettingsScreen.kt`, and the `Screen.PairedDevices` nav title — all corrected before commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-04-22 (II) — Power-user override philosophy: three tightenings + the Transport Security badge reason-derivation fix
|
||||
|
||||
**Context.** Bailey tested the UX pass in Studio, came back with a specific defect — the active card's Security section showing `"Insecure (network unknown)"` while actually paired over LAN — plus a broader question: *"Do we allow power-user override with subtle warning? (No forced confirm) etc?"* The answer codified in this commit is **three-tier**:
|
||||
|
||||
| Tier | Rule | Examples |
|
||||
|------|------|----------|
|
||||
| **1 — Forced confirm (once per install)** | Crosses a security boundary OR flips global policy. | First insecure-toggle enable, first bridge enable, first unattended bridge enable, first run of `send_sms` / `call`, **new: AllInsecure pairing** |
|
||||
| **2 — Subtle warning, no confirm** | Reversible, informational, or risk is informed by design (e.g. secure fallback exists). | Mixed-state pairing (secure fallback present), Never-expire TTL, Plain badge on active route |
|
||||
| **3 — Per-action Yes/No confirm (not persistable)** | Genuinely destructive short-term action. | Revoke session, Remove connection, Kill terminal |
|
||||
|
||||
Today's commit lands four changes that operationalize this framework.
|
||||
|
||||
### (a) Transport Security badge — derive reason from active endpoint role
|
||||
|
||||
**The defect.** `TransportSecurityBadge` has long had a `reason: String?` parameter whose labels were `"Insecure (LAN)"` / `"Insecure (Tailscale)"` / `"Insecure (dev)"` / `"Insecure (network unknown)"`. The `reason` only got populated when the user toggled "Allow insecure connections" ON via the `InsecureConnectionAckDialog` and explicitly picked a reason. But if the user pairs from a plain-`ws://` LAN QR directly, they never hit that toggle — the connection is already `ws://` — so the reason stays blank and the badge falls through to `"Insecure (network unknown)"`. The app had the information (`ConnectionManager.activeEndpoint.role = "lan"`), it just wasn't reading it.
|
||||
|
||||
**The fix.** `insecureReasonLabel(reason, activeRole)` now prefers role over stored reason:
|
||||
- `activeRole == "lan"` → `"Plain (on LAN)"`
|
||||
- `activeRole == "tailscale"` → `"Plain (on Tailscale)"`
|
||||
- `activeRole == "public"` → `"Plain (on public URL)"` (this is the actually-concerning case)
|
||||
- `activeRole` unknown, `reason == "lan_only"` → `"Plain (LAN only)"` (user-stated intent)
|
||||
- Both unknown → `"Plain (no TLS)"` (neutral fallback, not scary)
|
||||
|
||||
Also: `ConnectionViewModel.applyPairingPayload` now auto-stamps `PairingPreferences.insecureReason` at pair time based on which endpoint got selected. `lan` → `"lan_only"`, `tailscale` → `"tailscale_vpn"`, `public`/unknown → leave blank (those cases deserve user thought). Only stamps when the current stored reason is blank (never clobbers user choice). Clears stale reason when upgrading to a secure endpoint so a connection that moves LAN → Tailscale doesn't carry a zombie `"Plain (LAN)"` label.
|
||||
|
||||
**Copy polish.** Swapped "Insecure" → "Plain" everywhere user-facing (the badge labels, the two "Insecure connection" / "Allow insecure connections" strings inside the Advanced section's insecure-toggle subsection). The new vocabulary matches the UX pass's amber-not-red treatment — "Plain" is factual, "Insecure" was connotatively red.
|
||||
|
||||
### (b) Bridge destructive-verb "Don't ask again" per verb
|
||||
|
||||
Confirmation fatigue is real. A user who has approved `send_sms` 50 times has effectively consented — forcing confirm #51 trains them to click through without reading. New `trustedDestructiveVerbs: Flow<Set<String>>` in `BridgeSafetyPreferences`; `BridgeSafetyManager` short-circuits the confirmation overlay when the incoming verb is in the trusted set (still logs to the activity log — the trail is preserved). The `DestructiveVerbConfirmDialog` gets a `Don't ask again for "{verb}"` checkbox, off by default every dialog open, so the user has to actively opt in per-action.
|
||||
|
||||
**Kill-switch precedence** (explicitly verified by the code-reviewer agent, tracing `send_sms` through the full dispatcher): master-disable wins over blocklist wins over per-verb trust. A trusted verb in a blocklisted app still 403s. A trusted verb under a disabled master toggle never fires. Deny never sets trust — denying is not consent.
|
||||
|
||||
`BridgeScreen` surfaces a `"Trusted actions · N actions bypass confirmation"` row under the existing safety section with a `Reset` button (guarded by its own confirm dialog). The escape hatch is findable without deep-linking.
|
||||
|
||||
### (c) AllInsecure pairing — per-install acknowledgment
|
||||
|
||||
When every endpoint in the scanned QR is plain (no secure sibling), `ConnectionWizard.ConfirmStep` renders an ack checkbox above the Pair button. `gateIsSatisfied = allInsecureAckSeen || ackThisPair` for AllInsecure only — Mixed and AllSecure flow through `else → true` and see no gate. Once the user acknowledges once, `PairingPreferences.allInsecurePairAckSeen` persists per-install and the checkbox never shows again. Matches the `insecureAckSeen` precedent for the Allow-insecure toggle.
|
||||
|
||||
Final copy: *"I understand this pairing sends traffic in plain text — visible to anyone on the network."* Concrete — explains the *consequence* ("visible to others"), not just the transport ("plain text"). No legalese.
|
||||
|
||||
**Why Mixed doesn't get this gate.** Mixed by definition has a secure fallback in the same list — LAN + Tailscale means the phone auto-switches to Tailscale when LAN fails, so the user *is* covered on any network. The existing amber "Mixed — secure fallback available" warning card is sufficient. Only the AllInsecure case (no secure sibling) crosses a trust boundary the user needs to acknowledge once.
|
||||
|
||||
### (d) Vocabulary cleanup
|
||||
|
||||
Two "Insecure" stragglers caught by the code-reviewer sweep inside `ActiveConnectionSections.kt`:
|
||||
- `"Insecure connection — traffic is not encrypted"` → `"Plain connection — traffic is not encrypted"`
|
||||
- `"Allow insecure connections"` → `"Allow plain (unencrypted) connections"` (toggle label — functional copy, but "plain" keeps the app's vocabulary consistent without being dismissive of the real risk)
|
||||
|
||||
### Team delivery
|
||||
|
||||
Three parallel `general-purpose` implementation agents (isolated file ownership) + one `feature-dev:code-reviewer` sweep. One transient cross-file compile break caught mid-flight — the Bridge agent's in-progress changes to `BridgeSafetyManager` referenced a method the `BridgeScreen` edit hadn't yet wired up; the AllInsecure agent stashed + restored `BridgeScreen` to isolate its test. Final combined state compiles clean on both flavors without intervention. Lesson logged: **when two parallel agents touch the same concept (Bridge infrastructure + Bridge UI), one of them needs to own both files, even if the actual diff per file is small.** The Bridge agent ended up doing both anyway — the AllInsecure agent's stash was defensive and correct.
|
||||
|
||||
### Logcat sanity
|
||||
|
||||
Pulled full ADB logcat for the test session as a sanity check. Zero errors from our code. The only Hermes-app warning was the `HermesNotifCompanion: Buffered notification (pending=50)` cold-start log — notifications arriving before the WSS multiplexer connects, buffered until capped. This is the designed behavior of the notification listener's cold-start gap; worth a follow-up to confirm we aren't silently losing useful data on systems with high pre-pair notification volume.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-22 — Connection UX self-narration: Route / Relay sessions vocabulary, contextual security, per-route chips
|
||||
|
||||
**Context.** Bailey finished testing the 2026-04-21 connection-settings unification in Studio and came back with five concrete UX observations, all sharing one theme: *the UI has the right information but isn't narrating it*. (1) Add-Connection still had a perceptible lag before the QR scanner opened. (2) On a multi-endpoint QR with LAN + Tailscale, pairing step 2 flashed an amber "Insecure (dev)" badge and a red warning card — making users think they were stuck with insecure forever, even though the Tailscale fallback was right there in the same list. (3) The active card had the right structure post-unification but no narration — sections stacked without headers, Advanced surfaced manual URLs without a "most people don't need this" framing. (4) "Paired Devices" sounded like Bluetooth to anyone outside the project; the actual concept is server-side relay sessions. (5) Priority labels on endpoint rows were `p0` / `p1` / `p2` — developer-speak.
|
||||
|
||||
@@ -110,10 +110,26 @@ class BridgeSafetyManager(
|
||||
private val _settings = MutableStateFlow(BridgeSafetySettings())
|
||||
val settings: StateFlow<BridgeSafetySettings> = _settings.asStateFlow()
|
||||
|
||||
/**
|
||||
* "Don't ask again" set for destructive verbs. When a confirmation is
|
||||
* about to fire and the matched verb is in this set, we short-circuit
|
||||
* to Allow (and let the normal activity-log path record the action so
|
||||
* the user still has an audit trail). This does NOT bypass the master
|
||||
* blocklist or the master-disable toggle — both of those gates run in
|
||||
* [BridgeCommandHandler] before the code ever reaches [awaitConfirmation].
|
||||
*/
|
||||
private val _trustedDestructiveVerbs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val trustedDestructiveVerbs: StateFlow<Set<String>> =
|
||||
_trustedDestructiveVerbs.asStateFlow()
|
||||
|
||||
/** True once the DataStore collector has ticked at least once. */
|
||||
@Volatile
|
||||
private var settingsHydrated: Boolean = false
|
||||
|
||||
/** True once the trusted-verbs collector has ticked at least once. */
|
||||
@Volatile
|
||||
private var trustedHydrated: Boolean = false
|
||||
|
||||
/**
|
||||
* Pending confirmation requests keyed by a monotonic id. The overlay's
|
||||
* Allow / Deny callbacks complete the deferred by looking up the id the
|
||||
@@ -145,6 +161,12 @@ class BridgeSafetyManager(
|
||||
settingsHydrated = true
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
prefsRepo.trustedDestructiveVerbs.collect { latest ->
|
||||
_trustedDestructiveVerbs.value = latest
|
||||
trustedHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blocklist ────────────────────────────────────────────────────────
|
||||
@@ -197,12 +219,32 @@ class BridgeSafetyManager(
|
||||
val timeoutMs = snapshot.confirmationTimeoutSeconds * 1000L
|
||||
val requestId = nextRequestId.incrementAndGet()
|
||||
|
||||
val matchedVerb = text?.let { firstMatchedVerb(it, snapshot.destructiveVerbs) }.orEmpty()
|
||||
|
||||
// "Don't ask again" short-circuit. If the user has previously
|
||||
// allowed this verb with the trust checkbox ticked, skip the
|
||||
// modal entirely and return allow. We intentionally only short-
|
||||
// circuit when the matched verb is non-empty — routes like /call
|
||||
// and /send_sms whose confirm text doesn't match any user verb
|
||||
// (verb = "") always prompt so irreversible actions never bypass.
|
||||
//
|
||||
// This path is NOT consulted before the master blocklist or the
|
||||
// master-disable toggle — both of those live in BridgeCommandHandler
|
||||
// and fail earlier, so even a trusted verb can't slip through a
|
||||
// blocklisted app or a disabled bridge.
|
||||
if (matchedVerb.isNotBlank() &&
|
||||
currentTrustedVerbs().contains(matchedVerb.lowercase())
|
||||
) {
|
||||
Log.i(TAG, "awaitConfirmation: verb '$matchedVerb' is trusted — auto-allowing")
|
||||
return true
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
val pending = PendingConfirmation(
|
||||
id = requestId,
|
||||
method = method,
|
||||
text = text.orEmpty(),
|
||||
verb = text?.let { firstMatchedVerb(it, snapshot.destructiveVerbs) }.orEmpty(),
|
||||
verb = matchedVerb,
|
||||
deferred = deferred,
|
||||
)
|
||||
pendingConfirmations[requestId] = pending
|
||||
@@ -302,6 +344,46 @@ class BridgeSafetyManager(
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add [verb] to the trusted-verb set. Idempotent; no-op on blank input.
|
||||
* Called from the overlay host when the user ticks "Don't ask again"
|
||||
* and taps Allow. Verbs are persisted lowercase/trimmed by the
|
||||
* underlying repository.
|
||||
*/
|
||||
fun trustDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
scope.launch {
|
||||
runCatching { prefsRepo.addTrustedDestructiveVerb(normalized) }
|
||||
.onFailure { Log.w(TAG, "trustDestructiveVerb('$verb') failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the trusted-verb set. Called from [BridgeScreen]'s "Reset"
|
||||
* affordance after the user confirms. After this, every destructive
|
||||
* verb prompts again.
|
||||
*/
|
||||
fun clearTrustedDestructiveVerbs() {
|
||||
scope.launch {
|
||||
runCatching { prefsRepo.clearTrustedDestructiveVerbs() }
|
||||
.onFailure { Log.w(TAG, "clearTrustedDestructiveVerbs failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentTrustedVerbs(): Set<String> {
|
||||
if (trustedHydrated) return _trustedDestructiveVerbs.value
|
||||
return try {
|
||||
val first = prefsRepo.trustedDestructiveVerbs.first()
|
||||
_trustedDestructiveVerbs.value = first
|
||||
trustedHydrated = true
|
||||
first
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "currentTrustedVerbs: DataStore read failed — using empty", t)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentSettings(): BridgeSafetySettings {
|
||||
// Prefer the cached value once the DataStore collector has ticked
|
||||
// at least once. Before that, fall back to a one-shot read of
|
||||
|
||||
@@ -184,11 +184,22 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
method = request.method,
|
||||
verb = request.verb,
|
||||
fullText = request.text,
|
||||
onAllow = {
|
||||
onAllow = { trustVerb ->
|
||||
// Persist the "don't ask again" choice BEFORE
|
||||
// dismissing so a slow write can't race a
|
||||
// follow-up command that arrives while we're
|
||||
// still tearing down the overlay. trustVerb is
|
||||
// already gated by the dialog on verb.isNotBlank,
|
||||
// so passing it through straight is safe.
|
||||
if (trustVerb && request.verb.isNotBlank()) {
|
||||
BridgeSafetyManager.peek()
|
||||
?.trustDestructiveVerb(request.verb)
|
||||
}
|
||||
onResult(true)
|
||||
dismissConfirmation(request.id)
|
||||
},
|
||||
onDeny = {
|
||||
// Deny never writes trust — denying isn't consent.
|
||||
onResult(false)
|
||||
dismissConfirmation(request.id)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -164,6 +165,17 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
booleanPreferencesKey("bridge_unattended_warning_seen")
|
||||
// === END v0.4.1 unattended-access keys ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
// Set of normalized (lowercase, trimmed) destructive verbs the user
|
||||
// has chosen to stop being prompted about. Stored as a native
|
||||
// stringSet (not JSON) because there's no ordering/evolution concern
|
||||
// here — just membership. Empty set by default — every destructive
|
||||
// verb prompts until the user opts in via the confirmation dialog's
|
||||
// "Don't ask again" checkbox.
|
||||
private val KEY_TRUSTED_DESTRUCTIVE_VERBS =
|
||||
stringSetPreferencesKey("bridge_trusted_destructive_verbs")
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
/** Sentinel key we set the first time settings get written. Used to
|
||||
* tell "user cleared the blocklist" from "user has never touched it". */
|
||||
private val KEY_SAFETY_INITIALIZED = booleanPreferencesKey("bridge_safety_initialized")
|
||||
@@ -307,6 +319,52 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
|
||||
// === END v0.4.1 unattended-access setters ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
|
||||
/**
|
||||
* Live Flow of the verbs the user has marked "don't ask again" for.
|
||||
* Values are normalized (lowercase, trimmed) on write, so comparisons
|
||||
* against [BridgeSafetySettings.destructiveVerbs] and the incoming
|
||||
* modal `verb` field don't need any extra casing logic at the read
|
||||
* site. Master blocklist + master-disable still take precedence over
|
||||
* this set — this is only consulted AFTER the destructive-verb gate
|
||||
* decides a confirmation would otherwise fire.
|
||||
*/
|
||||
val trustedDestructiveVerbs: Flow<Set<String>> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS]?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun setTrustedDestructiveVerbs(verbs: Set<String>) {
|
||||
val normalized = verbs
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addTrustedDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] ?: emptySet()
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = current + normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearTrustedDestructiveVerbs() {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = emptySet()
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
private fun decodeSet(raw: String): Set<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw).toSet() }
|
||||
.getOrDefault(emptySet())
|
||||
|
||||
@@ -44,6 +44,8 @@ object PairingPreferences {
|
||||
private val KEY_INSECURE_ACK_SEEN = booleanPreferencesKey("insecure_ack_seen")
|
||||
private val KEY_INSECURE_REASON = stringPreferencesKey("insecure_reason")
|
||||
private val KEY_TOFU_PINS = stringPreferencesKey("tofu_pins")
|
||||
private val KEY_ALL_INSECURE_PAIR_ACK_SEEN =
|
||||
booleanPreferencesKey("all_insecure_pair_ack_seen")
|
||||
|
||||
/**
|
||||
* Prefix for per-device endpoint-candidate keys. Full key is
|
||||
@@ -96,6 +98,27 @@ object PairingPreferences {
|
||||
context.relayDataStore.edit { it[KEY_INSECURE_ACK_SEEN] = seen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-install acknowledgment that the user understands the implications of
|
||||
* pairing to a QR where *every* endpoint candidate is plain text
|
||||
* (`ws://` / `http://` with no secure Tailscale/wss fallback — the
|
||||
* [TransportSecurityState.AllInsecure] case).
|
||||
*
|
||||
* Gates the Pair button on the wizard's Confirm step only for the absolute-
|
||||
* boundary AllInsecure scenario. Mixed pairings (any secure route present)
|
||||
* are NOT gated — the app auto-falls back to the secure one, so the
|
||||
* existing amber advisory is sufficient. Once the user has acknowledged
|
||||
* once on this install the gate is removed for all future AllInsecure
|
||||
* pairs — matches the precedent set by [insecureAckSeen] for the
|
||||
* per-install insecure-mode dialog.
|
||||
*/
|
||||
fun allInsecurePairAckSeen(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { it[KEY_ALL_INSECURE_PAIR_ACK_SEEN] ?: false }
|
||||
|
||||
suspend fun setAllInsecurePairAckSeen(context: Context, seen: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_ALL_INSECURE_PAIR_ACK_SEEN] = seen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason the user selected when they flipped insecure mode on.
|
||||
*
|
||||
|
||||
+7
-2
@@ -444,7 +444,7 @@ private fun InsecureToggleSubsection(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Insecure connection — traffic is not encrypted",
|
||||
text = "Plain connection — traffic is not encrypted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
@@ -458,7 +458,7 @@ private fun InsecureToggleSubsection(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Allow insecure connections",
|
||||
text = "Allow plain (unencrypted) connections",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
@@ -732,12 +732,17 @@ fun ActiveCardSecurityPosture(
|
||||
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()
|
||||
|
||||
TransportSecurityBadge(
|
||||
isSecure = isUrlSecure(relayUrl),
|
||||
reason = insecureReason.ifBlank { null },
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
activeRole = activeEndpoint?.role,
|
||||
)
|
||||
|
||||
if (isTailscaleDetected) {
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -1179,6 +1180,21 @@ private fun ConfirmStep(
|
||||
onBack: () -> Unit,
|
||||
onConfirm: (HermesPairingPayload) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val confirmScope = rememberCoroutineScope()
|
||||
// Per-install acknowledgment for the AllInsecure pair gate
|
||||
// ([PairingPreferences.allInsecurePairAckSeen]). Once true, the
|
||||
// checkbox below is not rendered at all on subsequent pairings —
|
||||
// the user has consented to plain-text pairs and shouldn't keep
|
||||
// seeing friction for an already-made choice.
|
||||
val allInsecureAckSeen by com.hermesandroid.relay.data.PairingPreferences
|
||||
.allInsecurePairAckSeen(context)
|
||||
.collectAsState(initial = false)
|
||||
// Transient, per-pair tick. Resets every time ConfirmStep is composed
|
||||
// for a fresh payload — we don't want a stale tick from a previous
|
||||
// scan to carry forward.
|
||||
var ackThisPair by remember(payload) { mutableStateOf(false) }
|
||||
|
||||
val transportHint = payload.relay?.transportHint
|
||||
val relayUrl = payload.relay?.url
|
||||
val isInsecureRelay = relayUrl?.startsWith("ws://") == true
|
||||
@@ -1436,6 +1452,28 @@ private fun ConfirmStep(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Per-install Tier-1 gate: only render the checkbox when the
|
||||
// user has never acknowledged an AllInsecure pair on this
|
||||
// install. Once they have, we never show it again — the
|
||||
// warning card above stays, but the gate is lifted.
|
||||
if (!allInsecureAckSeen) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = ackThisPair,
|
||||
onCheckedChange = { ackThisPair = it },
|
||||
)
|
||||
Text(
|
||||
text = "I understand this pairing sends traffic in " +
|
||||
"plain text — visible to anyone on the network.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TransportSecurityState.Mixed -> {
|
||||
// Amber-tinted informational card. The secure route is the
|
||||
@@ -1479,6 +1517,15 @@ private fun ConfirmStep(
|
||||
}
|
||||
}
|
||||
|
||||
// Gate for the absolute-boundary AllInsecure case only. Mixed and
|
||||
// AllSecure stay one-tap. Satisfied when either (a) the user has
|
||||
// previously ack'd an AllInsecure pair on this install (per-install,
|
||||
// never expires), or (b) they've ticked the checkbox for this pair.
|
||||
val gateIsSatisfied = when (securityState) {
|
||||
TransportSecurityState.AllInsecure -> allInsecureAckSeen || ackThisPair
|
||||
else -> true
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1491,11 +1538,24 @@ private fun ConfirmStep(
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
// Persist the per-install ack the first time an
|
||||
// AllInsecure pair goes through via the checkbox path.
|
||||
// Future AllInsecure pairs skip the checkbox entirely.
|
||||
if (securityState == TransportSecurityState.AllInsecure &&
|
||||
ackThisPair &&
|
||||
!allInsecureAckSeen
|
||||
) {
|
||||
confirmScope.launch {
|
||||
com.hermesandroid.relay.data.PairingPreferences
|
||||
.setAllInsecurePairAckSeen(context, true)
|
||||
}
|
||||
}
|
||||
val effective = preferRole
|
||||
?.let { reorderByPreferredRole(payload, it) }
|
||||
?: payload
|
||||
onConfirm(effective)
|
||||
},
|
||||
enabled = gateIsSatisfied,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Pair")
|
||||
|
||||
+48
-4
@@ -12,17 +12,23 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
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.graphics.Color
|
||||
@@ -53,9 +59,20 @@ fun DestructiveVerbConfirmDialog(
|
||||
method: String,
|
||||
verb: String,
|
||||
fullText: String,
|
||||
onAllow: () -> Unit,
|
||||
onAllow: (trustVerb: Boolean) -> Unit,
|
||||
onDeny: () -> Unit,
|
||||
) {
|
||||
// Checkbox is always OFF when the dialog opens — the user must
|
||||
// actively opt in to bypass future prompts. Kept local-only: we only
|
||||
// persist the verb to the trusted set when the user then taps Allow.
|
||||
// Tapping Deny with the box checked does NOT add the verb (denying is
|
||||
// not consent to anything).
|
||||
var trustVerb by remember { mutableStateOf(false) }
|
||||
// Only offer the "Don't ask again" escape hatch when we actually have
|
||||
// a specific verb to key the trust on. Routes like /call and
|
||||
// /send_sms come through with verb="" and must always prompt — there's
|
||||
// nothing to trust.
|
||||
val canTrust = verb.isNotBlank()
|
||||
// Root-fills-overlay-with-center-alignment. The overlay already applies
|
||||
// FLAG_DIM_BEHIND so we only need to draw the card itself.
|
||||
Box(
|
||||
@@ -131,6 +148,30 @@ fun DestructiveVerbConfirmDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (canTrust) {
|
||||
// "Don't ask again" row — whole row is clickable so the
|
||||
// label is a tappable target (accessibility + fat-fingers).
|
||||
// Off by default on every open; see the @Composable KDoc.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { trustVerb = !trustVerb }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = trustVerb,
|
||||
onCheckedChange = { trustVerb = it },
|
||||
)
|
||||
Text(
|
||||
text = "Don't ask again for \"$verb\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
@@ -139,13 +180,16 @@ fun DestructiveVerbConfirmDialog(
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
// Deny never writes trust — denying a command isn't
|
||||
// consent to anything, even if the user happened to
|
||||
// tick the checkbox before changing their mind.
|
||||
onClick = onDeny,
|
||||
) {
|
||||
Text("Deny")
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onAllow,
|
||||
onClick = { onAllow(trustVerb && canTrust) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFE53935),
|
||||
contentColor = Color.White,
|
||||
@@ -238,7 +282,7 @@ private fun DestructiveVerbConfirmDialogPreview_TapText() {
|
||||
method = "/tap_text",
|
||||
verb = "Send",
|
||||
fullText = "Send $500 to Alice",
|
||||
onAllow = {},
|
||||
onAllow = { _ -> },
|
||||
onDeny = {},
|
||||
)
|
||||
}
|
||||
@@ -252,7 +296,7 @@ private fun DestructiveVerbConfirmDialogPreview_Type() {
|
||||
method = "/type",
|
||||
verb = "delete",
|
||||
fullText = "delete all messages in #general",
|
||||
onAllow = {},
|
||||
onAllow = { _ -> },
|
||||
onDeny = {},
|
||||
)
|
||||
}
|
||||
|
||||
+42
-13
@@ -104,8 +104,9 @@ fun TransportSecurityBadge(
|
||||
reason: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
size: TransportSecuritySize = TransportSecuritySize.Chip,
|
||||
activeRole: String? = null,
|
||||
) {
|
||||
val (label, bg, fg) = resolveAppearance(isSecure, reason)
|
||||
val (label, bg, fg) = resolveAppearance(isSecure, reason, activeRole)
|
||||
val icon = if (isSecure) Icons.Filled.Lock else Icons.Filled.LockOpen
|
||||
RenderBadge(
|
||||
label = label,
|
||||
@@ -179,12 +180,32 @@ private fun RenderBadge(
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the user-facing label for a given insecure reason code. */
|
||||
fun insecureReasonLabel(reason: String?): String = when (reason) {
|
||||
"lan_only" -> "Insecure (LAN)"
|
||||
"tailscale_vpn" -> "Insecure (Tailscale)"
|
||||
"local_dev" -> "Insecure (dev)"
|
||||
else -> "Insecure (network unknown)"
|
||||
/**
|
||||
* Build the user-facing label for an insecure transport.
|
||||
*
|
||||
* Prefers the **live** endpoint role (`activeRole`) because it reflects
|
||||
* current reality — the stored `reason` only captures user intent at the
|
||||
* moment they toggled the insecure-connection ack dialog, and for LAN QRs
|
||||
* the user never had to toggle anything (the QR was already `ws://`).
|
||||
*
|
||||
* Label copy uses "Plain" rather than "Insecure" — amber, not red, and
|
||||
* matches the UX pass where plaintext LAN is treated as informational.
|
||||
*/
|
||||
fun insecureReasonLabel(reason: String?, activeRole: String? = null): String {
|
||||
val roleLabel = when (activeRole?.lowercase()) {
|
||||
"lan" -> "Plain (on LAN)"
|
||||
"tailscale" -> "Plain (on Tailscale)"
|
||||
"public" -> "Plain (on public URL)"
|
||||
null, "" -> null
|
||||
else -> "Plain (on $activeRole)"
|
||||
}
|
||||
if (roleLabel != null) return roleLabel
|
||||
return when (reason) {
|
||||
"lan_only" -> "Plain (LAN only)"
|
||||
"tailscale_vpn" -> "Plain (Tailscale)"
|
||||
"local_dev" -> "Plain (dev only)"
|
||||
else -> "Plain (no TLS)"
|
||||
}
|
||||
}
|
||||
|
||||
private data class BadgeAppearance(val label: String, val bg: Color, val fg: Color)
|
||||
@@ -221,7 +242,11 @@ private fun resolveStateAppearance(state: TransportSecurityState): BadgeAppearan
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun resolveAppearance(isSecure: Boolean, reason: String?): BadgeAppearance {
|
||||
private fun resolveAppearance(
|
||||
isSecure: Boolean,
|
||||
reason: String?,
|
||||
activeRole: String? = null,
|
||||
): BadgeAppearance {
|
||||
// Secure — green, matches ConnectionStatusBadge connected palette.
|
||||
if (isSecure) {
|
||||
val green = Color(0xFF2E7D32)
|
||||
@@ -232,24 +257,28 @@ private fun resolveAppearance(isSecure: Boolean, reason: String?): BadgeAppearan
|
||||
)
|
||||
}
|
||||
|
||||
// A known live role is just as informative as a stored ack reason —
|
||||
// if the resolver knows we're on LAN/Tailscale/etc., treat it as amber
|
||||
// rather than red, because we have a meaningful label to show.
|
||||
val hasKnownRole = !activeRole.isNullOrBlank()
|
||||
val hasKnownReason = when (reason) {
|
||||
"lan_only", "tailscale_vpn", "local_dev" -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
return if (hasKnownReason) {
|
||||
// Amber — user has acknowledged + picked a reason, UX is informational.
|
||||
return if (hasKnownRole || hasKnownReason) {
|
||||
// Amber — we have a specific context to show, UX is informational.
|
||||
val amber = Color(0xFFF9A825)
|
||||
BadgeAppearance(
|
||||
label = insecureReasonLabel(reason),
|
||||
label = insecureReasonLabel(reason, activeRole),
|
||||
bg = amber.copy(alpha = 0.16f),
|
||||
fg = amber,
|
||||
)
|
||||
} else {
|
||||
// Red — no ack yet, louder warning.
|
||||
// Red — nothing known; louder warning.
|
||||
val red = MaterialTheme.colorScheme.error
|
||||
BadgeAppearance(
|
||||
label = insecureReasonLabel(reason),
|
||||
label = insecureReasonLabel(reason, activeRole),
|
||||
bg = red.copy(alpha = 0.16f),
|
||||
fg = red,
|
||||
)
|
||||
|
||||
@@ -40,7 +40,9 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.LocalContext
|
||||
@@ -171,6 +173,12 @@ fun BridgeScreen(
|
||||
val autoDisableAtMs by (safetyManager?.autoDisableAtMs
|
||||
?: remember { kotlinx.coroutines.flow.MutableStateFlow<Long?>(null) })
|
||||
.collectAsState()
|
||||
// Trusted-verb set — drives the "Trusted actions" row below the safety
|
||||
// summary. Empty-set fallback when safetyManager is null so previews
|
||||
// / test harnesses render cleanly.
|
||||
val trustedVerbs by (safetyManager?.trustedDestructiveVerbs
|
||||
?: remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) })
|
||||
.collectAsState()
|
||||
// === END PHASE3-safety-rails ===
|
||||
|
||||
// Re-run permission + system-status probes whenever the screen resumes.
|
||||
@@ -400,6 +408,18 @@ fun BridgeScreen(
|
||||
autoDisableAtMs = autoDisableAtMs,
|
||||
onManage = onNavigateToBridgeSafety,
|
||||
)
|
||||
|
||||
// 5b. Trusted actions — "Don't ask again" escape hatch.
|
||||
// Sits next to the safety summary so users who change
|
||||
// their minds can find it without digging into
|
||||
// developer options. Only shown when the safety
|
||||
// manager is wired (same gate as the summary).
|
||||
if (safetyManager != null) {
|
||||
TrustedActionsRow(
|
||||
trustedCount = trustedVerbs.size,
|
||||
onReset = { safetyManager.clearTrustedDestructiveVerbs() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Activity log — goes last because it's a history view,
|
||||
@@ -494,6 +514,104 @@ private fun OverlayPermissionNagCard(onTap: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Trusted actions" row. Shows how many destructive verbs the user has
|
||||
* marked "don't ask again" for, plus a Reset button (gated on a confirm
|
||||
* dialog) that clears the set so every destructive action prompts again.
|
||||
*
|
||||
* Copy choices:
|
||||
* - Empty state says "Every action still prompts" — deliberately
|
||||
* reassuring instead of promotional. We don't want to nudge users
|
||||
* into bypassing their own safety rails by advertising the feature
|
||||
* here; it's surfaced at the point of use inside the confirmation
|
||||
* dialog itself.
|
||||
* - Non-empty count is stated factually ("{N} actions bypass
|
||||
* confirmation") so the state is visible at a glance without opening
|
||||
* a sub-screen.
|
||||
*
|
||||
* Reset flow is double-gated (button + AlertDialog) because a single tap
|
||||
* shouldn't un-do weeks of "don't ask again" decisions by accident.
|
||||
* Reset is disabled when count == 0 to avoid dead-tap confusion in the
|
||||
* safe default.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrustedActionsRow(
|
||||
trustedCount: Int,
|
||||
onReset: () -> Unit,
|
||||
) {
|
||||
var showConfirm by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Trusted actions",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = if (trustedCount == 0) {
|
||||
"Every action still prompts"
|
||||
} else {
|
||||
"$trustedCount action${if (trustedCount == 1) "" else "s"} " +
|
||||
"bypass confirmation"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { showConfirm = true },
|
||||
enabled = trustedCount > 0,
|
||||
) {
|
||||
Text("Reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showConfirm) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { showConfirm = false },
|
||||
title = { Text("Reset trusted actions?") },
|
||||
text = {
|
||||
Text(
|
||||
"After reset, every destructive action will prompt " +
|
||||
"for confirmation again. You can re-enable " +
|
||||
"\"Don't ask again\" from any future confirmation dialog."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = {
|
||||
onReset()
|
||||
showConfirm = false
|
||||
},
|
||||
) {
|
||||
Text("Reset")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = { showConfirm = false },
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up the ContextWrapper chain until we find an Activity, or null if
|
||||
* none. `LocalContext.current` in a Compose-in-ComponentActivity setup
|
||||
|
||||
@@ -556,12 +556,16 @@ private fun DeviceCard(
|
||||
else -> null
|
||||
}
|
||||
if (transportSecure != null) {
|
||||
// Prefer the live active-endpoint role (ADR 24) when this is
|
||||
// the current device; otherwise let the neutral fallback
|
||||
// ("Plain (no TLS)") render. The old hardcoded "lan_only"
|
||||
// lied for Tailscale/public rows.
|
||||
val rowRole = if (isCurrent) activeEndpoint?.role else null
|
||||
TransportSecurityBadge(
|
||||
isSecure = transportSecure,
|
||||
// We don't have per-row reason on server-returned data —
|
||||
// fall back to the generic "LAN/dev" label when insecure.
|
||||
reason = if (transportSecure) null else "lan_only",
|
||||
size = TransportSecuritySize.Row
|
||||
reason = null,
|
||||
size = TransportSecuritySize.Row,
|
||||
activeRole = rowRole,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1945,6 +1945,54 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 24 — auto-stamp / clear PairingPreferences.insecureReason
|
||||
// based on the resolved endpoint's role so the Transport Security
|
||||
// badge reads correctly even when the user paired from a plain
|
||||
// LAN QR directly (and thus never had to toggle the insecure-ack
|
||||
// dialog that would otherwise be the only writer of this key).
|
||||
//
|
||||
// - Secure (wss/https) → clear any stale stored reason so a
|
||||
// post-Tailscale-upgrade connection doesn't keep showing
|
||||
// "Insecure (LAN)" from a prior plain-LAN pair.
|
||||
// - Plain + role=lan → stamp "lan_only"
|
||||
// - Plain + role=tailscale → stamp "tailscale_vpn" (rare — usually
|
||||
// wss on Tailscale, but possible)
|
||||
// - Plain + role=public / other / absent → leave blank; the user
|
||||
// should consciously ack via the insecure dialog for those.
|
||||
//
|
||||
// Only overwrite when the stored reason is currently blank so we
|
||||
// never clobber a user-selected choice.
|
||||
run {
|
||||
val ctx = getApplication<Application>()
|
||||
val relayUrl = payload.relay?.url
|
||||
val isSecure = relayUrl?.let {
|
||||
it.startsWith("wss://") || it.startsWith("https://")
|
||||
} ?: false
|
||||
if (isSecure) {
|
||||
// Clear any stale "Insecure (LAN)" stamp left over from
|
||||
// a prior plain pair on the same Connection.
|
||||
if (insecureReason.value.isNotBlank()) {
|
||||
PairingPreferences.setInsecureReason(ctx, "")
|
||||
}
|
||||
} else if (relayUrl != null && insecureReason.value.isBlank()) {
|
||||
// Find the candidate whose relay.url matches what we're
|
||||
// about to connect to; fall back to the first candidate
|
||||
// (priority-0) if the payload has endpoints but none
|
||||
// match exactly.
|
||||
val matched = payload.endpoints?.firstOrNull {
|
||||
it.relay.url == relayUrl
|
||||
} ?: payload.endpoints?.firstOrNull()
|
||||
val autoReason = when (matched?.role?.lowercase()) {
|
||||
"lan" -> "lan_only"
|
||||
"tailscale" -> "tailscale_vpn"
|
||||
else -> null // public / unknown / absent → let user ack
|
||||
}
|
||||
if (autoReason != null) {
|
||||
PairingPreferences.setInsecureReason(ctx, autoReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the just-applied URLs into the active Connection's
|
||||
// store entry. [updateApiServerUrl] / [updateRelayUrl] above
|
||||
// only touch the APP-WIDE flows (_apiServerUrl, _relayUrl) +
|
||||
|
||||
@@ -34,7 +34,7 @@ The landing tab. Shows:
|
||||
|
||||
- **Relay version + uptime + health** — served by the relay's `/relay/info` endpoint. Green dot = reachable, red = `relay unreachable at 127.0.0.1:8767` (the gateway can't see your relay process; check `systemctl --user status hermes-relay`).
|
||||
- **Paired devices list** — one row per active session. Columns: device name (from the phone's `PairedDeviceInfo`), token prefix (first 8 chars — full tokens are never sent), created-at, last-seen, expires-at, per-channel grants (bridge / terminal / chat), transport hint (`wss` / `ws`).
|
||||
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Paired Devices or running `hermes-pair --revoke <prefix>` on the server.
|
||||
- **Revoke button** per row — live. Click to pop a native browser confirm; on OK the button calls `DELETE /api/plugins/hermes-relay/sessions/{prefix}` which the plugin proxy forwards to the relay, and the list auto-reloads on success. Same effect as revoking from the Android app's Settings → Relay sessions or running `hermes-pair --revoke <prefix>` on the server.
|
||||
- **Pair new device** — button in the card header opens the [PairDialog](#pairing-a-new-device) described below.
|
||||
|
||||
<!-- TODO: replace with real screenshot — dashboard Relay Management tab with a paired device row -->
|
||||
|
||||
@@ -151,15 +151,21 @@ The phone's TTL picker dialog always opens on scan, preselected with your chosen
|
||||
`Never expire` is always available in the picker regardless of transport. The phone treats your intent as the trust model rather than gating on secure-transport detection — if you explicitly pick it, the session stays active until you revoke it from **Relay sessions**.
|
||||
:::
|
||||
|
||||
#### Transport security + insecure-mode consent
|
||||
#### Transport security — plain connections and pairing consent
|
||||
|
||||
The app renders a **Transport Security** badge next to each Connection row:
|
||||
The app renders a **Transport Security** badge inside the active connection card's Security section:
|
||||
|
||||
- 🔒 **Secure (TLS)** — paired over `wss://`
|
||||
- 🔓 **Insecure (LAN only / Tailscale / Local dev)** — paired over plain `ws://` with the reason you picked on the consent dialog
|
||||
- 🔓 **Insecure** — plain `ws://` with no reason recorded
|
||||
- 🔒 **Secure (TLS)** — paired over `wss://` / `https://`
|
||||
- 🔓 **Plain (on LAN / Tailscale / public URL)** — paired over `ws://` / `http://`; the label reflects the **currently active route** so a Tailscale fallback reads honestly even if you originally paired over LAN
|
||||
- 🔓 **Plain (no TLS)** — plain transport with no active-route information yet (cold start, or manual URL config before the first probe)
|
||||
|
||||
The first time you toggle insecure mode on, a consent dialog opens with a plain-language threat-model explanation and a reason picker. The reason is displayed on the badge but is not enforced — it's informational, to make the choice visible to you later.
|
||||
Amber, not red — the trust model on `ws://` is the network perimeter, not TLS. A home/office LAN and a private Tailscale network are both legitimate trust domains for plain transport; the badge is factual, not alarming.
|
||||
|
||||
**Three different consent gates** exist for plain transport, each firing at the moment that actually changes the threat model:
|
||||
|
||||
1. **Scanning an all-plain QR** (no secure route in the candidate list) — one-time per install. The pairing confirm step renders a checkbox: *"I understand this pairing sends traffic in plain text — visible to anyone on the network."* Tick it once per install; the Pair button activates. Subsequent all-plain pairs don't re-prompt. Mixed QRs (LAN + Tailscale) are ungated — the secure fallback is your safety net.
|
||||
2. **First toggle of "Allow plain (unencrypted) connections"** in the active card's Advanced section — opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason displays on the badge afterward (though the role-aware label above usually overrides it).
|
||||
3. **Changing a paired TTL to "Never expire"** on a plain connection — inline warning, no forced confirm. The trust model is already established at pair time.
|
||||
|
||||
The app also runs a **Trust On First Use** (TOFU) cert pinning check on `wss://` connections: on the first successful handshake it records the server's certificate fingerprint, and every subsequent connect verifies against it. If the cert changes (because the relay was rebuilt, the Let's Encrypt cert rolled over, or an MITM is happening), the connection fails loudly. Re-pairing via QR is taken as explicit consent to pin a new certificate.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ These are configured during onboarding or from the **Settings → Connections**
|
||||
- **Routes (N)** (when the pairing carries multi-endpoint candidates) — *"The app picks the fastest reachable network automatically and switches when you change networks."* Expander reveals one row per route with role chip (LAN / Tailscale / Public / Custom VPN), per-row Secure/Plain security chip, state chip (Active / Fallback), probe-now button, per-candidate *Prefer this route* override, and per-candidate TOFU pin inspection.
|
||||
- **Advanced** (collapsible) — *"Manual setup — most people don't need this after QR pairing."* Holds:
|
||||
- **Manual URL config** — API Server URL, API Key, Relay URL, each with **Save & Test**.
|
||||
- **Allow insecure connections** toggle — first enable opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason is displayed on the Transport Security badge below but is not enforced — operator intent is the trust model.
|
||||
- **Allow plain (unencrypted) connections** toggle — first enable opens a consent dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason is stored for later but the Transport Security badge usually derives a more accurate label from the live active-route role. Operator intent is the trust model — the toggle gates the UI's ability to save `ws://` / `http://` URLs, nothing server-side.
|
||||
- **Disconnect** button — drops the active WSS without clearing the session token.
|
||||
- **Manual pairing code (fallback)** — the 3-step flow for when you can't use QR scanning. (1) Copy the locally-generated 6-char code; (2) on the host, run `hermes-pair --register-code <code>`; (3) tap **Connect** here. Canonical flow is still the QR from `/hermes-relay-pair` — use this only when QR scanning is physically impossible. Bridge control is gated by the master toggle on the Bridge tab, NOT by this code.
|
||||
- **Security** (always visible) — Transport Security badge (🔒 secure / 🔓 plain with reason / 🔓 unknown), Tailscale-detected chip, Hardware-keystore badge, and a **Relay sessions** row that navigates to the full list of phones paired with this server.
|
||||
@@ -29,8 +29,10 @@ These are configured during onboarding or from the **Settings → Connections**
|
||||
| Relay Session Token | **Keystore** (StrongBox when available), with fallback to EncryptedSharedPreferences | Persistent token from relay pairing flow. Migrated automatically from the legacy EncryptedSharedPreferences file on first launch post-upgrade. |
|
||||
| TOFU Cert Pins | DataStore (`tofu_pins`) | SHA-256 SPKI fingerprints per `host:port`. Recorded on the first successful `wss://` connect, verified on subsequent connects via OkHttp `CertificatePinner`. Wiped explicitly when the user re-pairs via QR (taken as consent to new cert material). |
|
||||
| Pair TTL Preference | DataStore (`pair_ttl_seconds`) | User's last-selected session TTL on the pair flow. Preselected next time. |
|
||||
| Insecure Ack Seen | DataStore (`insecure_ack_seen`) | Whether the user has acknowledged the insecure-mode threat model. The ack dialog only shows once per install; revoke via **Clear data** to reshow. |
|
||||
| Insecure Reason | DataStore (`insecure_reason`) | The reason selected on the insecure ack dialog — `lan_only` / `tailscale_vpn` / `local_dev` / empty. Displayed on the Transport Security badge for context. |
|
||||
| Plain toggle ack seen | DataStore (`insecure_ack_seen`) | Whether the user has acknowledged the Allow-plain-connections toggle threat model. Per-install; revoke via **Clear data** to reshow. (Key name retains the legacy `insecure_` prefix for migration compatibility.) |
|
||||
| Plain toggle reason | DataStore (`insecure_reason`) | Reason selected on the plain-toggle ack dialog — `lan_only` / `tailscale_vpn` / `local_dev` / empty. Auto-stamped at pair time when the resolved endpoint's role is `lan` or `tailscale` (cleared on upgrade to a secure endpoint). The Transport Security badge prefers the live active-route role over this stored value. |
|
||||
| All-plain pairing ack | DataStore (`all_insecure_pair_ack_seen`) | Whether the user has acknowledged the one-time pairing-consent checkbox that appears on step 2 when every route in the scanned QR is plain `ws://` / `http://` (no secure sibling). Per-install. Mixed QRs (LAN + Tailscale) are ungated because the secure route is a safety net. |
|
||||
| Trusted bridge actions | DataStore (`bridge_trusted_destructive_verbs`) | Set of destructive bridge verbs (e.g. `send_sms`, `call`) that bypass the confirmation overlay because the user ticked "Don't ask again" in a prior confirm. The master-disable toggle and the blocklist still override — trust is for eliminating confirmation fatigue on approved verbs, not a kill-switch bypass. Reset from Bridge → Trusted actions → **Reset**. |
|
||||
|
||||
### Pair Flow — TTL Picker
|
||||
|
||||
@@ -52,7 +54,7 @@ Per-channel grants (`terminal`, `bridge`) can be pre-set by the operator via `he
|
||||
|
||||
- Device name + device ID
|
||||
- **Current device** badge if this is the device you're looking at the list on
|
||||
- Transport security badge (secure / insecure / unknown)
|
||||
- Transport security badge (Secure (TLS) / Plain (on `<role>`) / Plain (no TLS))
|
||||
- Session expiry (a date or "Never")
|
||||
- Per-channel grant chips (`chat · terminal · bridge`)
|
||||
- **Extend** button — opens the same TTL picker dialog used during initial pair, preselected with the current remaining lifetime (or "Never" if already never-expiring). Confirming calls `PATCH /sessions/{token_prefix}` with the new TTL; the server restarts the clock from now and auto-clamps any existing grants to the (possibly new) session lifetime. Also works to **shorten** sessions — pick a shorter duration or "Never" to change the policy without re-pairing.
|
||||
|
||||
Reference in New Issue
Block a user