Improve bridge startup reliability and token validation#341
Conversation
PR #341 — 5-Model Consensus ReviewTitle: Improve bridge startup reliability and token validation 🔴 CRITICAL — DevTunnel loopback removal breaks all mobile connections (2/5 models)
The PR removes the loopback bypass and simultaneously sets a random ephemeral token before Before this PR, the flow was: remote client → DevTunnel → localhost → loopback bypass → accepted. After: remote client → DevTunnel (strips header) → localhost → no loopback bypass, no token → rejected. Fix: The random guard token covers the window before the real token is known. Once the real token is set, DevTunnel connections carry it. But the loopback bypass was load-bearing for DevTunnel-proxied connections whose token header gets stripped. Either restore the loopback bypass for DevTunnel connections specifically, or confirm the header is preserved by the DevTunnel infrastructure for this deployment. 🔴 CRITICAL — Silent secret loss during migration (4/5 models)
if (string.IsNullOrEmpty(storedRemote) && !string.IsNullOrEmpty(legacyRemote))
{ SetSecureStorageSync(RemoteTokenKey, legacyRemote); storedRemote = legacyRemote; }
// ...
_secretsDirty = false;
if (hadLegacySecrets)
Save(); // scrubs JSON — [JsonIgnore] means RemoteToken won't be serialized
Fix: Only call 🔴 CRITICAL — Deadlock on iOS main thread (4/5 models)
private static string? GetSecureStorageSync(string key)
{
try { return Task.Run(() => SecureStorage.Default.GetAsync(key)).GetAwaiter().GetResult(); }
catch { return null; }
}
Fix: Make 🟡 MODERATE —
|
PR #341 Re-Review (Round 2) — New commit
|
| # | Finding | Prior Severity | Status |
|---|---|---|---|
| 1 | GetSecureStorageSync deadlock on iOS |
🔴 CRITICAL | |
| 2 | Silent secret loss during migration | 🔴 CRITICAL | 🟡 PARTIALLY FIXED — per-field check added, but partial migration bug remains |
| 3 | DevTunnel loopback removal + mobile rejected | 🔴 CRITICAL | ✅ FIXED — X-Bridge-Authorization header survives DevTunnel proxying |
| 4 | TokenEquals length oracle |
🟡 MODERATE | ✅ FIXED — SHA256 + CryptographicOperations.FixedTimeEquals |
| 5 | Plugin hash scope mismatch | 🟡 MODERATE | ✅ FIXED — both paths use ComputeDirectoryHash(Path.GetFullPath(...)) |
| 6 | Test cleanup not in finally |
🟢 MINOR | ✅ FIXED — both tests use try/finally |
Remaining Issues
🟡 MODERATE — GetSecureStorageSync sync-over-async pattern (Finding #1)
The code still uses:
try { return Task.Run(() => SecureStorage.Default.GetAsync(key)).GetAwaiter().GetResult(); }Task.Run mitigates the classic SynchronizationContext deadlock, and iOS Keychain APIs are generally thread-safe (no main-thread dispatch). So this works in practice on current MAUI/iOS. Downgrading from CRITICAL to MODERATE because:
- The pattern is architecturally brittle (would break if SecureStorage internals ever change)
.GetAwaiter().GetResult()still blocks a thread- An async
Load()overload would be cleaner long-term
Not a ship-blocker, but worth a follow-up.
🟡 MODERATE — Partial migration can still lose secrets (Finding #2, partially fixed)
The per-field SetSecureStorageSync check is good — if a single field fails to migrate, it stays as null in memory. However, the migratedAny flag is shared across all fields:
if (SetSecureStorageSync(RemoteTokenKey, legacyRemote))
{ storedRemote = legacyRemote; migratedAny = true; }
// ...
if (migratedAny)
Save(); // Serializes with [JsonIgnore] → scrubs ALL legacy tokens from JSONScenario: RemoteToken migrates ✅, but LanToken fails ❌ (Keychain full / entitlement issue):
migratedAny = true→Save()calledSave()serializes with[JsonIgnore]on all three token properties- JSON file no longer contains
"LanToken": "..." - LanToken is not in SecureStorage (SetAsync failed) and not in JSON → lost
Suggested fix: Only scrub from JSON after verifying all attempted migrations succeeded, or track per-field migration state:
bool allMigrationsSucceeded = true;
// ... set to false if any SetSecureStorageSync fails
if (migratedAny && allMigrationsSucceeded)
Save();Verdict: ⚠️ Request changes
4 of 6 original findings fixed — excellent progress. The two remaining issues are:
- Sync-over-async pattern (MODERATE, follow-up OK)
- Partial migration secret loss (MODERATE, should fix before merge — low probability but real data loss)
Fix #2 and this is ✅ ready to merge.
PR #341 — Round 3 Re-Review (5-Model Consensus)Models: claude-opus-4.6 ×2, claude-sonnet-4.6, gemini-3-pro-preview, gpt-5.3-codex Previous Findings Status
🟡 MODERATE — Finding #2: Silent secret loss on partial migration (5/5 models — STILL PRESENT)
The Scenario (unchanged from Round 2):
Fix: Use per-field migration flags; only call bool allSucceeded = true;
if (!string.IsNullOrEmpty(legacyLan) && !SetSecureStorageSync(...))
allSucceeded = false;
// ...
if (migratedAny && allSucceeded)
Save();🟡 MODERATE — Finding #1:
|
| Issue | Severity | Ship-blocker? |
|---|---|---|
migratedAny partial migration data loss |
🟡 MODERATE | Yes — real data loss on any Keychain error |
SaveMobileSecretsIfDirty ignores write failures |
🟡 MODERATE | Yes — same data-loss class |
| DevTunnel failure leaves bridge locked | 🟡 MODERATE | Yes — silent breakage with no recovery path |
GetSecureStorageSync sync-over-async |
🟡 MODERATE | No — acceptable follow-up |
ComputeDirectoryHash framing |
🟢 MINOR | No |
MigrateAndLoadMobileSecrets/SaveMobileSecretsIfDirty and the DevTunnel failure lockout are all straightforward fixes.
Round 3 — 5-model review: claude-opus-4.6 ×2, claude-sonnet-4.6, gemini-3-pro-preview, gpt-5.3-codex. Consensus filter: issues flagged by 2+ models only.
068b070 to
b25cd66
Compare
PureWeen
left a comment
There was a problem hiding this comment.
PR #341 Round 4 Review (Multi-Model AI Consensus)
Round 3 Findings Status
| # | Finding | Status |
|---|---|---|
| 1 | Partial migration data loss (MigrateAndLoadMobileSecrets) | ✅ FIXED -- per-field tracking + atomic scrub-on-all-success |
| 2 | SaveMobileSecrets ignores failures | ✅ FIXED -- retains _secretsDirty=true on failure for retry |
| 3 | DevTunnel permanent lockout | ✅ FIXED -- clears AccessToken=null on failure, local-only fallback |
New Findings
🟡 MODERATE -- Crash-path migration residual (ConnectionSettings.cs MigrateAndLoadMobileSecrets)
allSucceeded uses migratedRemote (write-this-launch flag) instead of !string.IsNullOrEmpty(storedRemote) (value-in-SecureStorage). If app crashes between SecureStorage.SetAsync and Save(), next launch sees storedRemote populated, skips migration, migratedRemote=false, allSucceeded=false → plaintext stays in JSON permanently. One-line fix: check stored values not migration flags.
🟡 MODERATE -- Stale test helper (DevTunnelServiceTests.cs:~893)
InvokeValidateClientToken helper still contains old if (isLoopback) return true logic that was removed from production code. Tests pass but validate stale behavior, not the new token-based validation.
🟢 MINOR -- Plugin hash invalidation (PluginLoader.cs)
ComputeDirectoryHash changes hash scheme, invalidating all existing plugin approvals on upgrade with no migration path. Consider a HashVersion field.
🟢 MINOR -- Missing test coverage (PluginLoader.cs)
ComputeDirectoryHash has no unit tests. Existing ComputeHash tests exercise dead code path.
Verdict: ⚠️ Request Changes (minor)
Two moderate findings need one-line fixes each. Overall the security hardening is excellent.
5-model AI consensus review (Round 4)
…ng fan-out When the orchestrator assigns fewer workers than available (e.g., 1 worker for a follow-up on a specific PR), the dispatch loop no longer nudges it to assign ALL remaining workers. This prevents simple tasks like 'post a comment on PR #341' from triggering all 5 workers. Changes: - Remove the iteration loop that forced 'Dispatch ALL remaining workers NOW' - Replace with single-pass parse + nudge-only-on-zero-assignments - Update planning prompt: orchestrator must always delegate (never self-handle) but should only assign workers with RELEVANT work - Tell orchestrator that workers retain conversation history, so it should prefer the worker who already has context for the topic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
b25cd66 to
1b5e8df
Compare
🤖 Multi-Model Review — Round 4 FinalCI: Previous Findings — All Fixed ✅
New Findings Fixed in This Pass🔴 🟡 Reconnect loop missing Verdict: ✅ ApproveAll findings resolved. Both new fixes committed to Review by PR Review Squad (claude-opus-4.6 ×2, claude-sonnet-4.5, gemini-3-pro-preview, gpt-5.2 — 2+ model consensus filter) |
- WsBridgeServer: remove unconditional loopback trust when a token is configured; loopback-only mode (no token set) still allows unauthenticated local connections as before - WsBridgeServer: replace string.Equals token comparison with constant-time comparison using CryptographicOperations.FixedTimeEquals - DevTunnelService: set a random ephemeral token on the bridge before Start() so the listening socket is never open without auth while the tunnel handshake is in progress; replaced by the real access token once issued - PluginLoader: extend integrity verification to cover all DLL files in the plugin directory rather than only the entry-point assembly; filenames are included in the hash so renames are detected; existing plugin approvals will require re-confirmation - ConnectionSettings: on iOS/Android/MacCatalyst, store RemoteToken, LanToken, and ServerPassword in the platform SecureStorage (Keychain on Apple) rather than plain JSON; existing values are migrated and scrubbed from settings.json on first load - Update WsBridgeIntegrationTests to reflect new auth behaviour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n safety - WsBridgeServer: fix TokenEquals length oracle by hashing both sides to SHA-256 before FixedTimeEquals, so comparison time is always constant regardless of input length - WsBridgeClient + WsBridgeServer: add X-Bridge-Authorization header alongside X-Tunnel-Authorization; DevTunnel strips X-Tunnel-Authorization before proxying to localhost but passes custom headers through, so mobile clients now reach the server's token validator even when connecting via DevTunnel - ConnectionSettings: SetSecureStorageSync now returns bool; migration only scrubs legacy JSON field after confirming SecureStorage write succeeded, preventing silent token loss if Keychain write fails - PluginLoader: canonicalize plugin directory path via Path.GetFullPath before hashing to avoid hash mismatches when discovery and load paths differ under symlinks or relative path segments - WsBridgeIntegrationTests: wrap AccessToken teardown in finally blocks so subsequent tests are not affected if an assertion throws Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- ConnectionSettings: per-field SecureStorage migration tracking; JSON is only scrubbed when every attempted field was written successfully so a partial Keychain failure cannot silently lose any token - ConnectionSettings: SaveMobileSecretsIfDirty checks SetSecureStorageSync return values and keeps _secretsDirty=true on partial write failure so the next Save() will retry - DevTunnelService: if IssueAccessTokenAsync returns null, clear the random placeholder token so the bridge reverts to local-only mode rather than remaining permanently locked with an opaque token no client can obtain - PluginLoader: add null-byte framing between filename and content bytes in ComputeDirectoryHash to make the hash scheme provably collision-free - WsBridgeServerAuthTests: update HTTP probe test to reflect new behaviour (loopback no longer bypasses auth when token is configured; correct token via X-Bridge-Authorization is accepted from any origin) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ization to reconnect loop - ConnectionSettings: allSucceeded now treats 'already in SecureStorage' as success so crash-recovery scenario (SecureStorage written, app crashed before Save()) correctly scrubs JSON on the next launch instead of leaving plaintext forever - WsBridgeClient: ReconnectLoopAsync was missing X-Bridge-Authorization on both reconnect paths; DevTunnel strips X-Tunnel-Authorization so reconnects through a tunnel would always fail 401 after the first disconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n logic Remove the loopback bypass (if (isLoopback) return true) that was removed from WsBridgeServer.ValidateClientToken. Add X-Bridge-Authorization header parsing to match production header priority. Retain isLoopback parameter for call-site compatibility but it no longer affects the result. Update ValidateClientToken_LoopbackAlwaysAllowed to assert rejection (False) and rename to ValidateClientToken_Loopback_WithoutToken_Rejected to reflect the new security behavior: loopback without a token is denied when a token is configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
b45e3b0 to
7514a8b
Compare
…LAN probe, clean up migration vars - Disable GET /token endpoint when AccessToken is configured (DevTunnel proxied requests appear as loopback and could exfiltrate the token) - Add X-Bridge-Authorization header to FiestaService WebSocket connections (DevTunnel strips X-Tunnel-Authorization before proxying to localhost) - Add X-Bridge-Authorization header to LAN probe HTTP request for consistency - Remove unused migratedRemote/migratedLan/migratedPass variables - Narrow test assertion from ThrowsAnyAsync<Exception> to check for specific WebSocket/HTTP exception types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR #341 moved ServerPassword, RemoteToken, and LanToken to SecureStorage (Keychain) on Mac Catalyst via [JsonIgnore] and #if IOS || ANDROID || MACCATALYST guards. However, Mac Catalyst runs without app sandbox (disabled in Entitlements.plist), making Keychain unreliable. The password was silently lost on restart, so StartDirectSharingIfEnabled() would skip due to empty password. Fix: - Change SecureStorage guards from IOS || ANDROID || MACCATALYST to IOS || ANDROID only — Mac Catalyst is a desktop platform - Add one-time RecoverSecretsFromSecureStorage() for MACCATALYST to recover any passwords already migrated to Keychain by PR 341 - Only clean up Keychain entries after verifying JSON was written - Add 4 regression tests for secret serialization on desktop Verified via MauiDevFlow: enabled Direct Sharing, relaunched app, confirmed bridge auto-started with 'Stop Direct Sharing' visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Problem PR #341 moved `ServerPassword`, `RemoteToken`, and `LanToken` to SecureStorage (Keychain) on Mac Catalyst via `[JsonIgnore]` and `#if IOS || ANDROID || MACCATALYST` guards. However, Mac Catalyst runs **without app sandbox** (disabled in `Entitlements.plist`), making Keychain unreliable. The password was silently dropped from `settings.json` on save, and never reliably recovered from Keychain on load, so `StartDirectSharingIfEnabled()` would skip due to empty password. **Result:** Direct Sharing was always disabled after every restart despite being enabled by the user. ## Fix 1. **Changed SecureStorage guards** from `#if IOS || ANDROID || MACCATALYST` → `#if IOS || ANDROID` for property definitions, `Save()`, and `Load()` — Mac Catalyst is a desktop platform and should use plain JSON like Windows. 2. **Added one-time reverse migration** (`RecoverSecretsFromSecureStorage`) for `MACCATALYST` to recover any passwords already migrated to Keychain by PR #341. Only cleans up Keychain entries after verifying JSON was successfully written (addresses code review finding about data loss if `Save()` fails). 3. **Added 4 regression tests** validating that secret fields serialize to JSON on desktop platforms. ## Verification - ✅ All 2575 tests pass - ✅ Built and relaunched via `relaunch.sh` - ✅ MauiDevFlow CDP verified: enabled Direct Sharing → relaunched → `Stop Direct Sharing` button visible (bridge auto-started) - ✅ `settings.json` confirmed: `ServerPassword` present and `DirectSharingEnabled: true` persisted across restart --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Changes ### WsBridgeServer - Removed unconditional loopback trust when an access token is configured. Previously, any local process could connect without a token. Now the token is required when set; unauthenticated local connections are only permitted when no token is configured (local-only mode). - Replaced plain string comparison for token validation with a constant-time comparison using \`CryptographicOperations.FixedTimeEquals\`. ### DevTunnelService - The bridge socket was previously open without authentication during the ~30-60 second window while the tunnel handshake runs. Now a random ephemeral token is set on the bridge before \`Start()\`, so the listening socket always requires auth from the moment it opens. The real access token replaces it once issued. ### PluginLoader - Plugin integrity verification previously only covered the entry-point DLL. A replacement dependency assembly would pass the hash check. \`ComputeDirectoryHash\` now hashes all \`.dll\` files in the plugin directory (sorted by filename, filename bytes included) so any substitution or addition is detected. - **Note:** This changes the hash scheme. Existing approved plugins will show as needing re-approval on first run after this update. ### ConnectionSettings - On iOS, Android, and Mac Catalyst, \`RemoteToken\`, \`LanToken\`, and \`ServerPassword\` are now stored in the platform SecureStorage (Keychain on Apple) rather than plain JSON. On first load, any values already in \`settings.json\` are migrated to SecureStorage and scrubbed from the file. - Desktop (non-catalyst) behaviour is unchanged. ### Tests - Updated \`WsBridgeIntegrationTests\` to match the new auth behaviour: added a test asserting loopback-without-token is rejected when a token is configured, and a test confirming loopback-with-correct-token still works. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## Problem PR PureWeen#341 moved `ServerPassword`, `RemoteToken`, and `LanToken` to SecureStorage (Keychain) on Mac Catalyst via `[JsonIgnore]` and `#if IOS || ANDROID || MACCATALYST` guards. However, Mac Catalyst runs **without app sandbox** (disabled in `Entitlements.plist`), making Keychain unreliable. The password was silently dropped from `settings.json` on save, and never reliably recovered from Keychain on load, so `StartDirectSharingIfEnabled()` would skip due to empty password. **Result:** Direct Sharing was always disabled after every restart despite being enabled by the user. ## Fix 1. **Changed SecureStorage guards** from `#if IOS || ANDROID || MACCATALYST` → `#if IOS || ANDROID` for property definitions, `Save()`, and `Load()` — Mac Catalyst is a desktop platform and should use plain JSON like Windows. 2. **Added one-time reverse migration** (`RecoverSecretsFromSecureStorage`) for `MACCATALYST` to recover any passwords already migrated to Keychain by PR PureWeen#341. Only cleans up Keychain entries after verifying JSON was successfully written (addresses code review finding about data loss if `Save()` fails). 3. **Added 4 regression tests** validating that secret fields serialize to JSON on desktop platforms. ## Verification - ✅ All 2575 tests pass - ✅ Built and relaunched via `relaunch.sh` - ✅ MauiDevFlow CDP verified: enabled Direct Sharing → relaunched → `Stop Direct Sharing` button visible (bridge auto-started) - ✅ `settings.json` confirmed: `ServerPassword` present and `DirectSharingEnabled: true` persisted across restart --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Changes
WsBridgeServer
DevTunnelService
PluginLoader
ConnectionSettings
Tests