Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions lib/core/extensions/rpc/json_rpc_stdio_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

/// Minimal JSON-RPC 2.0 client over newline-delimited JSON on stdio.
///
/// Enough for Block E credential injection and later Block C methods without
/// pulling `json_rpc_2` yet. One JSON object per line on stdin/stdout.
class JsonRpcStdioClient {
JsonRpcStdioClient({
required Stream<List<int>> stdout,
required IOSink stdin,
this.requestTimeout = const Duration(seconds: 10),
}) : _stdin = stdin,
_lines = utf8.decoder.bind(stdout).transform(const LineSplitter()) {
_subscription = _lines.listen(_onLine, onError: _onError, onDone: _onDone);
}

final IOSink _stdin;
final Stream<String> _lines;
final Duration requestTimeout;

final Map<int, Completer<Object?>> _pending = {};
var _nextId = 1;
var _closed = false;
StreamSubscription<String>? _subscription;
Object? _fatalError;

/// Sends a JSON-RPC request and waits for the matching response.
Future<Object?> sendRequest(
String method, [
Object? params,
]) async {
if (_closed) {
throw StateError('JsonRpcStdioClient is closed');
}
if (_fatalError != null) {
throw StateError('JsonRpcStdioClient failed: $_fatalError');
}

final id = _nextId++;
final completer = Completer<Object?>();
_pending[id] = completer;

final payload = <String, Object?>{
'jsonrpc': '2.0',
'id': id,
'method': method,
if (params != null) 'params': params,
};

_stdin.writeln(jsonEncode(payload));
await _stdin.flush();

try {
return await completer.future.timeout(requestTimeout);
} on TimeoutException {
_pending.remove(id);
throw TimeoutException(
'JSON-RPC request "$method" timed out after $requestTimeout',
);
}
}

Future<void> close() async {
if (_closed) return;
_closed = true;
await _subscription?.cancel();
_subscription = null;
for (final pending in _pending.values) {
if (!pending.isCompleted) {
pending.completeError(StateError('JsonRpcStdioClient closed'));
}
}
_pending.clear();
}

void _onLine(String line) {
if (line.trim().isEmpty) return;
late final Map<String, dynamic> message;
try {
final decoded = jsonDecode(line);
if (decoded is! Map<String, dynamic>) return;
message = decoded;
} catch (_) {
return;
}

final id = message['id'];
if (id is! int) return;
final completer = _pending.remove(id);
if (completer == null || completer.isCompleted) return;

if (message.containsKey('error')) {
final error = message['error'];
completer.completeError(
JsonRpcException.fromJson(error is Map ? error : {'message': '$error'}),
);
return;
}
completer.complete(message['result']);
}

void _onError(Object error, StackTrace stackTrace) {
_fatalError = error;
for (final pending in _pending.values) {
if (!pending.isCompleted) {
pending.completeError(error, stackTrace);
}
}
_pending.clear();
}

void _onDone() {
_fatalError ??= StateError('Plugin stdout closed');
for (final pending in _pending.values) {
if (!pending.isCompleted) {
pending.completeError(_fatalError!);
}
}
_pending.clear();
}
}

class JsonRpcException implements Exception {
JsonRpcException({this.code, required this.message, this.data});

factory JsonRpcException.fromJson(Map error) {
return JsonRpcException(
code: error['code'] is int ? error['code'] as int : null,
message: '${error['message'] ?? 'JSON-RPC error'}',
data: error['data'],
);
}

final int? code;
final String message;
final Object? data;

@override
String toString() => 'JsonRpcException($code): $message';
}
141 changes: 141 additions & 0 deletions lib/core/extensions/sandbox/sandbox_credentials_injector.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart';
import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart';
import 'package:querya_desktop/core/storage/connection_secrets_store.dart';

/// Mutable UTF-8 buffer that can be zeroed after use (Block E §5).
///
/// Dart [String] values are immutable and cannot be wiped; keep secrets in
/// [SensitiveUtf8Buffer] while assembling RPC payloads, then [clear].
class SensitiveUtf8Buffer {
SensitiveUtf8Buffer(String value) : _bytes = Uint8List.fromList(utf8.encode(value));

Uint8List? _bytes;

bool get isCleared => _bytes == null;

String? get asString {
final bytes = _bytes;
if (bytes == null) return null;
return utf8.decode(bytes);
}

/// Overwrites the buffer with zeros and drops the reference.
void clear() {
final bytes = _bytes;
if (bytes == null) return;
bytes.fillRange(0, bytes.length, 0);
_bytes = null;
}
}

/// Loads connection secrets from the OS store and injects them into a sandboxed
/// plugin process exclusively via Stdio JSON-RPC (never argv / env).
class SandboxCredentialsInjector {
SandboxCredentialsInjector({
this.requestTimeout = const Duration(seconds: 10),
Future<({String? password, String? connectionString})> Function(int connectionId)?
secretsReader,
JsonRpcStdioClient Function(SandboxProcessHandle handle)? clientFactory,
}) : _secretsReader = secretsReader ?? ConnectionSecretsStore.readForConnection,
_clientFactory = clientFactory;

final Duration requestTimeout;
final Future<({String? password, String? connectionString})> Function(
int connectionId,
) _secretsReader;
final JsonRpcStdioClient Function(SandboxProcessHandle handle)? _clientFactory;

/// Reads secrets for [connectionId] and sends `system.injectCredentials`.
///
/// Returns the RPC result map (or null). Sensitive buffers are cleared in a
/// `finally` block regardless of success or failure.
Future<Object?> injectCredentials({
required SandboxProcessHandle handle,
required int connectionId,
Map<String, Object?> extraParams = const {},
}) async {
final secrets = await _secretsReader(connectionId);
final passwordBuf =
secrets.password != null ? SensitiveUtf8Buffer(secrets.password!) : null;
final connectionStringBuf = secrets.connectionString != null
? SensitiveUtf8Buffer(secrets.connectionString!)
: null;

final client = _clientFactory?.call(handle) ??
JsonRpcStdioClient(
stdout: handle.process.stdout,
stdin: handle.process.stdin,
requestTimeout: requestTimeout,
);
final ownsClient = _clientFactory == null;

try {
final params = <String, Object?>{
'connectionId': connectionId,
if (passwordBuf?.asString != null) 'password': passwordBuf!.asString,
if (connectionStringBuf?.asString != null)
'connectionString': connectionStringBuf!.asString,
...extraParams,
};
return await client.sendRequest('system.injectCredentials', params);
} finally {
passwordBuf?.clear();
connectionStringBuf?.clear();
if (ownsClient) {
await client.close();
}
}
}

/// Reads secrets and sends `db.connect` with host/port plus credentials.
Future<Object?> connect({
required SandboxProcessHandle handle,
required int connectionId,
required String host,
required int port,
String? database,
String? username,
bool ssl = false,
Map<String, Object?> extraParams = const {},
}) async {
final secrets = await _secretsReader(connectionId);
final passwordBuf =
secrets.password != null ? SensitiveUtf8Buffer(secrets.password!) : null;
final connectionStringBuf = secrets.connectionString != null
? SensitiveUtf8Buffer(secrets.connectionString!)
: null;

final client = _clientFactory?.call(handle) ??
JsonRpcStdioClient(
stdout: handle.process.stdout,
stdin: handle.process.stdin,
requestTimeout: requestTimeout,
);
final ownsClient = _clientFactory == null;

try {
final params = <String, Object?>{
'connectionId': connectionId,
'host': host,
'port': port,
if (database != null) 'database': database,
if (username != null) 'username': username,
'ssl': ssl,
if (passwordBuf?.asString != null) 'password': passwordBuf!.asString,
if (connectionStringBuf?.asString != null)
'connectionString': connectionStringBuf!.asString,
...extraParams,
};
return await client.sendRequest('db.connect', params);
} finally {
passwordBuf?.clear();
connectionStringBuf?.clear();
if (ownsClient) {
await client.close();
}
}
}
}
9 changes: 9 additions & 0 deletions lib/core/extensions/sandbox/sandbox_process_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart';
import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart';
import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart';
import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart';

/// Live handle for a sandboxed OS process (Block E Level 2).
class SandboxProcessHandle {
Expand Down Expand Up @@ -98,6 +99,9 @@ class SandboxProcessRunner {
}) processStarter;

/// Spawns [pluginExecutable] inside the OS sandbox for [pluginId].
///
/// Credentials must never be passed via [pluginArguments] or [environment];
/// use [SandboxCredentialsInjector] over Stdio JSON-RPC instead.
Future<SandboxProcessHandle> start({
required String pluginId,
required String pluginExecutable,
Expand All @@ -106,6 +110,11 @@ class SandboxProcessRunner {
SandboxCapabilities? capabilities,
Map<String, String>? environment,
}) async {
SandboxSecretGuard.assertNoSecrets(
arguments: pluginArguments,
environment: environment ?? const {},
);

final scratch = await SandboxScratchDirectory.create(
pluginId: pluginId,
baseDirectory: scratchBaseDirectory,
Expand Down
76 changes: 76 additions & 0 deletions lib/core/extensions/sandbox/sandbox_secret_guard.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/// Guards against leaking secrets via process argv or environment (Block E §5).
class SandboxSecretGuard {
SandboxSecretGuard._();

static final _forbiddenEnvKeys = RegExp(
r'(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|connection[_-]?string)',
caseSensitive: false,
);

static final _forbiddenArgFlags = RegExp(
r'^--?(password|passwd|secret|token|api-?key|private-?key|connection-string)(=|$)',
caseSensitive: false,
);

/// Throws [SandboxSecretLeakException] if [arguments] or [environment]
/// appear to carry credentials.
///
/// When [knownSecrets] is provided, any exact occurrence of those values in
/// argv or env values is also rejected.
static void assertNoSecrets({
List<String> arguments = const [],
Map<String, String> environment = const {},
Iterable<String?> knownSecrets = const [],
}) {
final secrets = knownSecrets
.whereType<String>()
.where((s) => s.isNotEmpty)
.toSet();

for (final arg in arguments) {
if (_forbiddenArgFlags.hasMatch(arg)) {
throw SandboxSecretLeakException(
'Refusing to pass credential flag via process arguments: '
'${_redactArg(arg)}',
);
}
for (final secret in secrets) {
if (arg.contains(secret)) {
throw SandboxSecretLeakException(
'Refusing to pass a known secret value via process arguments.',
);
}
}
}

for (final entry in environment.entries) {
if (_forbiddenEnvKeys.hasMatch(entry.key)) {
throw SandboxSecretLeakException(
'Refusing to pass credential via environment variable "${entry.key}".',
);
}
for (final secret in secrets) {
if (entry.value.contains(secret)) {
throw SandboxSecretLeakException(
'Refusing to pass a known secret value via environment '
'"${entry.key}".',
);
}
}
}
}

static String _redactArg(String arg) {
final eq = arg.indexOf('=');
if (eq <= 0) return arg;
return '${arg.substring(0, eq)}=[REDACTED]';
}
}

class SandboxSecretLeakException implements Exception {
SandboxSecretLeakException(this.message);
final String message;

@override
String toString() => 'SandboxSecretLeakException: $message';
}
Loading
Loading