Skip to content
147 changes: 147 additions & 0 deletions docs/remote-control/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Remote Control / IPC — hardened (final candidate)

This revision hardens the IPC subsystem for Files to address resource, security, and correctness issues:
- Strict JSON-RPC 2.0 validation and shape enforcement (includes IsInvalidRequest).
- Encrypted token storage (DPAPI) with epoch-based rotation that invalidates existing sessions.
- Centralized RpcMethodRegistry used everywhere (transports + adapter).
- WebSocket receive caps, per-method caps, per-client queue caps, lossy coalescing by method and per-client token bucket applied for both requests and notifications.
- Named Pipe per-user ACL and per-session randomized pipe name; length-prefixed framing.
- getMetadata: capped by items and timeout; runs off UI thread and honors client cancellation.
- Selection notifications are capped and include truncated flag.
- UIOperationQueue required to be passed a DispatcherQueue; all UI-affecting operations serialized.

## Merge checklist
- [ ] Settings UI: "Enable Remote Control" (ProtectedTokenStore.SetEnabled), "Rotate Token" (RotateTokenAsync), "Enable Long Paths" toggle and display of current pipe name/port only when enabled.
- [ ] ShellViewModel: wire ExecuteActionById / NavigateToPathNormalized or expose small interface for adapter.
- [ ] Packaging decision: Document Kestrel + URLACL if WS is desired in Store/MSIX; default recommended for Store builds is NamedPipe-only.
- [ ] Tests: WS/pipe oversize, slow-consumer (lossy/coalesce), JSON-RPC conformance, getMetadata timeout & cancellation, token rotation invalidation.
- [ ] Telemetry hooks: auth failures, slow-client disconnects, queue drops.

## JSON-RPC error codes used
- -32700 Parse error
- -32600 Invalid Request
- -32601 Method not found
- -32602 Invalid params
- -32001 Authentication required
- -32002 Invalid token
- -32003 Rate limit exceeded
- -32004 Session expired
- -32000 Internal server error

## Architecture

### Core Components

#### JsonRpcMessage
Strict JSON-RPC 2.0 implementation with helpers for creating responses and validating message shapes. Preserves original ID types and enforces result XOR error semantics.

#### ProtectedTokenStore
DPAPI-backed encrypted token storage in LocalSettings with epoch-based rotation. When tokens are rotated, the epoch increments and invalidates all existing client sessions.

#### ClientContext
Per-client state management including:
- Token bucket rate limiting (configurable burst and refill rate)
- Lossy message queue with method-based coalescing
- Authentication state and epoch tracking
- Connection lifecycle management

#### RpcMethodRegistry
Centralized registry for RPC method definitions including:
- Authentication requirements
- Notification permissions
- Per-method payload size limits
- Custom authorization policies

#### Transport Services
- **WebSocketAppCommunicationService**: HTTP listener on loopback with WebSocket upgrade
- **NamedPipeAppCommunicationService**: Per-user ACL with randomized pipe names

#### ShellIpcAdapter
Application logic adapter that:
- Enforces method allowlists and security policies
- Provides path normalization and validation
- Implements resource-bounded operations (metadata with timeouts)
- Serializes UI operations through UIOperationQueue

## Security Features

### Authentication & Authorization
- DPAPI-encrypted token storage
- Per-session token validation with epoch checking
- Method-level authorization policies
- Per-user ACL on named pipes

### Resource Protection
- Configurable message size limits per transport
- Per-client queue size limits with lossy behavior
- Rate limiting with token bucket algorithm
- Operation timeouts and cancellation support

### Attack Mitigation
- Strict JSON-RPC validation prevents malformed requests
- Path normalization rejects device paths and traversal attempts
- Selection notifications capped to prevent resource exhaustion
- Automatic cleanup of inactive/stale connections

## Configuration

All limits are configurable via `IpcConfig`:
```csharp
IpcConfig.WebSocketMaxMessageBytes = 16 * 1024 * 1024; // 16 MB
IpcConfig.NamedPipeMaxMessageBytes = 10 * 1024 * 1024; // 10 MB
IpcConfig.PerClientQueueCapBytes = 2 * 1024 * 1024; // 2 MB
IpcConfig.RateLimitPerSecond = 20;
IpcConfig.RateLimitBurst = 60;
IpcConfig.SelectionNotificationCap = 200;
IpcConfig.GetMetadataMaxItems = 500;
IpcConfig.GetMetadataTimeoutSec = 30;
```

## Supported Methods

### Authentication
- `handshake` - Authenticate with token and establish session

### State Query
- `getState` - Get current navigation state
- `listActions` - Get available actions

### Operations
- `navigate` - Navigate to path (with normalization)
- `executeAction` - Execute registered action by ID
- `getMetadata` - Get file/folder metadata (batched, with timeout)

### Notifications (Broadcast)
- `workingDirectoryChanged` - Current directory changed
- `selectionChanged` - File selection changed (with truncation)
- `ping` - Keepalive heartbeat

## Usage

**DO NOT enable IPC by default** — StartAsync refuses to start unless the user explicitly enables Remote Control via Settings. See merge checklist above.

### Enabling Remote Control
```csharp
// In Settings UI
await ProtectedTokenStore.SetEnabled(true);
var token = await ProtectedTokenStore.GetOrCreateTokenAsync();
```

### Starting Services
```csharp
// Only starts if enabled
await webSocketService.StartAsync();
await namedPipeService.StartAsync();
```

### Token Rotation
```csharp
// Invalidates all existing sessions
var newToken = await ProtectedTokenStore.RotateTokenAsync();
```

## Implementation Status

✅ **Complete**: Core IPC framework, security model, transport services
🔄 **Pending**: Settings UI integration, ShellViewModel method wiring
📋 **TODO**: Comprehensive tests, telemetry integration, Kestrel option
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"version": "9.0.200",
"rollForward": "latestMajor"
}
}
}
39 changes: 39 additions & 0 deletions src/Files.App/Communication/ActionRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Files.App.Communication
{
// Simple action registry for IPC system
public sealed class ActionRegistry
{
// readonly fields
private readonly HashSet<string> _allowedActions = new(StringComparer.OrdinalIgnoreCase)
{
"navigate",
"refresh",
"copyPath",
"openInNewTab",
"openInNewWindow",
"toggleDualPane",
"showProperties"
};

// Public methods
public bool CanExecute(string actionId, object? context = null)
{
if (string.IsNullOrEmpty(actionId))
return false;

return _allowedActions.Contains(actionId);
}

public IEnumerable<string> GetAllowedActions() => _allowedActions.ToList();

public void RegisterAction(string actionId)
{
if (!string.IsNullOrEmpty(actionId))
_allowedActions.Add(actionId);
}
}
}
150 changes: 150 additions & 0 deletions src/Files.App/Communication/ClientContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Text;
using System.Threading;

namespace Files.App.Communication
{
// Per-client state with token-bucket, lossy enqueue and LastSeenUtc tracked.
public sealed class ClientContext : IDisposable
{
// readonly fields
private readonly object _rateLock = new();
private readonly ConcurrentQueue<(string payload, bool isNotification, string? method)> _sendQueue = new();

// Fields
private long _queuedBytes = 0;
private int _tokens;
private DateTime _lastRefill;

// _disposed field
private bool _disposed;

// Properties
public Guid Id { get; } = Guid.NewGuid();

public string? ClientInfo { get; set; }

public bool IsAuthenticated { get; set; }

public int AuthEpoch { get; set; } = 0; // set at handshake

public DateTime LastSeenUtc { get; set; } = DateTime.UtcNow;

public long MaxQueuedBytes { get; set; } = IpcConfig.PerClientQueueCapBytes;

public CancellationTokenSource? Cancellation { get; set; }

public WebSocket? WebSocket { get; set; }

public object? TransportHandle { get; set; } // can store session id, pipe name, etc.

internal ConcurrentQueue<(string payload, bool isNotification, string? method)> SendQueue => _sendQueue;

// Constructor
public ClientContext()
{
_tokens = IpcConfig.RateLimitBurst;
_lastRefill = DateTime.UtcNow;
}

// Public methods
public void RefillTokens()
{
lock (_rateLock)
{
var now = DateTime.UtcNow;
var delta = (now - _lastRefill).TotalSeconds;
if (delta <= 0)
return;

var add = (int)(delta * IpcConfig.RateLimitPerSecond);
if (add > 0)
{
_tokens = Math.Min(IpcConfig.RateLimitBurst, _tokens + add);
_lastRefill = now;
}
}
}

public bool TryConsumeToken()
{
RefillTokens();
lock (_rateLock)
{
if (_tokens <= 0)
return false;

_tokens--;
return true;
}
}

// Try enqueue with lossy policy; drops oldest notifications of the same method first when needed.
public bool TryEnqueue(string payload, bool isNotification, string? method = null)
{
var bytes = Encoding.UTF8.GetByteCount(payload);
var newVal = Interlocked.Add(ref _queuedBytes, bytes);
if (newVal > MaxQueuedBytes)
{
// attempt to free by dropping oldest notifications (prefer same-method)
int freed = 0;
var initialQueue = new List<(string payload, bool isNotification, string? method)>();
while (SendQueue.TryDequeue(out var old))
{
if (!old.isNotification)
{
initialQueue.Add(old); // keep responses
}
else if (old.method != null && method != null && old.method.Equals(method, StringComparison.OrdinalIgnoreCase) && freed == 0)
{
// drop one older of same method
var b = Encoding.UTF8.GetByteCount(old.payload);
Interlocked.Add(ref _queuedBytes, -b);
freed += b;
break;
}
else
{
// for fairness, try dropping other notifications as well
var b = Encoding.UTF8.GetByteCount(old.payload);
Interlocked.Add(ref _queuedBytes, -b);
freed += b;
if (Interlocked.Read(ref _queuedBytes) <= MaxQueuedBytes)
break;
}
}

// push back preserved responses
foreach (var item in initialQueue)
SendQueue.Enqueue(item);

newVal = Interlocked.Read(ref _queuedBytes);
if (newVal + bytes > MaxQueuedBytes)
{
// still cannot enqueue
return false;
}
}

SendQueue.Enqueue((payload, isNotification, method));
return true;
}

// Internal methods
internal void DecreaseQueuedBytes(int sentBytes) => Interlocked.Add(ref _queuedBytes, -sentBytes);

// Dispose
public void Dispose()
{
if (_disposed)
return;

try { Cancellation?.Cancel(); } catch { }
try { WebSocket?.Dispose(); } catch { }
_disposed = true;
}
}
}
44 changes: 44 additions & 0 deletions src/Files.App/Communication/IAppCommunicationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Threading.Tasks;

namespace Files.App.Communication
{
/// <summary>
/// Represents a communication service for handling JSON-RPC messages between clients and the application.
/// Implementations provide transport-specific functionality (WebSocket, Named Pipe, etc.)
/// </summary>
public interface IAppCommunicationService
{
/// <summary>
/// Occurs when a JSON-RPC request is received from a client.
/// </summary>
event Func<ClientContext, JsonRpcMessage, Task>? OnRequestReceived;

/// <summary>
/// Starts the communication service and begins listening for client connections.
/// </summary>
/// <returns>A task that represents the asynchronous start operation.</returns>
Task StartAsync();

/// <summary>
/// Stops the communication service and closes all client connections.
/// </summary>
/// <returns>A task that represents the asynchronous stop operation.</returns>
Task StopAsync();

/// <summary>
/// Sends a JSON-RPC response message to a specific client.
/// </summary>
/// <param name="client">The client context to send the response to.</param>
/// <param name="response">The JSON-RPC response message to send.</param>
/// <returns>A task that represents the asynchronous send operation.</returns>
Task SendResponseAsync(ClientContext client, JsonRpcMessage response);

/// <summary>
/// Broadcasts a JSON-RPC notification message to all connected clients.
/// </summary>
/// <param name="notification">The JSON-RPC notification message to broadcast.</param>
/// <returns>A task that represents the asynchronous broadcast operation.</returns>
Task BroadcastAsync(JsonRpcMessage notification);
}
}
Loading