Add RPC elevate.

This commit is contained in:
Dain Nilsson 2022-03-30 16:45:47 +02:00
parent 5a13389895
commit cabdc9effd
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
7 changed files with 263 additions and 53 deletions

View File

@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models.dart'; import '../../core/models.dart';
import '../../desktop/state.dart'; import '../../desktop/state.dart';
import '../message.dart';
import '../models.dart'; import '../models.dart';
import 'app_page.dart'; import 'app_page.dart';
import 'device_avatar.dart'; import 'device_avatar.dart';
@ -13,16 +14,45 @@ class NoDeviceScreen extends ConsumerWidget {
final DeviceNode? node; final DeviceNode? node;
const NoDeviceScreen(this.node, {Key? key}) : super(key: key); const NoDeviceScreen(this.node, {Key? key}) : super(key: key);
String _getErrorMessage(WidgetRef ref, UsbPid pid) { List<Widget> _buildUsbPid(BuildContext context, WidgetRef ref, UsbPid pid) {
// TODO: Handle more cases
if (pid.usbInterfaces == UsbInterface.fido.value) { if (pid.usbInterfaces == UsbInterface.fido.value) {
if (Platform.isWindows) { if (Platform.isWindows &&
if (!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { !ref.watch(rpcStateProvider.select((state) => state.isAdmin))) {
return 'WebAuthn management requires elevated privileges.\nRestart this app as administrator.'; return [
} const DeviceAvatar(child: Icon(Icons.lock)),
const Text('WebAuthn management requires elevated privileges.'),
OutlinedButton.icon(
icon: const Icon(Icons.lock_open),
label: const Text('Unlock'),
onPressed: () async {
final controller = showMessage(
context, 'Elevating permissions...',
duration: const Duration(seconds: 30));
try {
if (await ref.read(rpcProvider).elevate()) {
ref.refresh(rpcProvider);
} else {
showMessage(context, 'Permission denied');
}
} finally {
controller.close();
}
}),
]
.map((e) => Padding(
child: e,
padding: const EdgeInsets.symmetric(vertical: 8.0),
))
.toList();
} }
} }
return 'This YubiKey cannot be accessed'; return [
const DeviceAvatar(child: Icon(Icons.usb_off)),
const Text(
'This YubiKey cannot be accessed',
textAlign: TextAlign.center,
),
];
} }
@override @override
@ -32,13 +62,7 @@ class NoDeviceScreen extends ConsumerWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: node?.map(usbYubiKey: (node) { children: node?.map(usbYubiKey: (node) {
return [ return _buildUsbPid(context, ref, node.pid);
const DeviceAvatar(child: Icon(Icons.usb_off)),
Text(
_getErrorMessage(ref, node.pid),
textAlign: TextAlign.center,
),
];
}, nfcReader: (node) { }, nfcReader: (node) {
return const [ return const [
DeviceAvatar(child: Icon(Icons.wifi)), DeviceAvatar(child: Icon(Icons.wifi)),

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -31,6 +32,10 @@ final desktopFidoState = StateNotifierProvider.autoDispose
.family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>( .family<FidoStateNotifier, AsyncValue<FidoState>, DevicePath>(
(ref, devicePath) { (ref, devicePath) {
final session = ref.watch(_sessionProvider(devicePath)); final session = ref.watch(_sessionProvider(devicePath));
if (Platform.isWindows) {
// Make sure to rebuild if isAdmin changes
ref.watch(rpcStateProvider.select((state) => state.isAdmin));
}
final notifier = _DesktopFidoStateNotifier(session); final notifier = _DesktopFidoStateNotifier(session);
session.setErrorHandler('state-reset', (_) async { session.setErrorHandler('state-reset', (_) async {
ref.refresh(_sessionProvider(devicePath)); ref.refresh(_sessionProvider(devicePath));

View File

@ -73,7 +73,8 @@ Future<Widget> initialize() async {
} }
_log.info('Starting subprocess: $exe'); _log.info('Starting subprocess: $exe');
final rpc = await RpcSession.launch(exe!); final rpc = RpcSession(exe!);
await rpc.initialize();
_log.info('ykman-rpc process started', exe); _log.info('ykman-rpc process started', exe);
rpc.setLogLevel(Logger.root.level); rpc.setLogLevel(Logger.root.level);

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:async/async.dart'; import 'package:async/async.dart';
@ -55,47 +56,143 @@ const _py2level = {
'CRITICAL': Level.SHOUT, 'CRITICAL': Level.SHOUT,
}; };
class RpcSession { class _RpcConnection {
final Process _process; final IOSink _sink;
final StreamController<_Request> _requests = StreamController();
final StreamQueue<RpcResponse> _responses; final StreamQueue<RpcResponse> _responses;
_RpcConnection(this._sink, Stream<String> stream)
RpcSession(this._process) : _responses = StreamQueue(stream.map((event) {
: _responses = StreamQueue(_process.stdout
.transform(const Utf8Decoder())
.transform(const LineSplitter())
.map((event) {
try { try {
return RpcResponse.fromJson(jsonDecode(event)); return RpcResponse.fromJson(jsonDecode(event));
} catch (e) { } catch (e) {
_log.severe('Response was not valid JSON', event); _log.severe('Response was not valid JSON', event);
return RpcResponse.error('invalid-response', e.toString(), {}); return RpcResponse.error('invalid-response', e.toString(), {});
} }
})) { }));
_process.stderr
void send(Map data) {
_sink.writeln(jsonEncode(data));
}
Future<RpcResponse> getResponse() => _responses.next;
void dispose() {
_sink.writeln('');
_sink.close();
_responses.cancel(immediate: true);
}
}
class RpcSession {
final String executable;
late _RpcConnection _connection;
final StreamController<_Request> _requests = StreamController();
RpcSession(this.executable);
static void _logEntry(String entry) {
try {
final record = jsonDecode(entry);
Logger('rpc.${record['name']}').log(
_py2level[record['level']] ?? Level.INFO,
record['message'],
record['exc_text'],
//time: DateTime.fromMillisecondsSinceEpoch(event['time'] * 1000),
);
} catch (e) {
_log.error(e.toString(), entry);
}
}
Future<void> initialize() async {
final process = await Process.start(executable, []);
_log.config('RPC process started');
process.stderr
.transform(const Utf8Decoder()) .transform(const Utf8Decoder())
.transform(const LineSplitter()) .transform(const LineSplitter())
.listen((event) { .listen(_logEntry);
try {
final record = jsonDecode(event); // Communicate with rpc over stdin/stdout.
Logger('rpc.${record['name']}').log( _connection = _RpcConnection(
_py2level[record['level']] ?? Level.INFO, process.stdin,
record['message'], process.stdout
record['exc_text'], .transform(const Utf8Decoder())
//time: DateTime.fromMillisecondsSinceEpoch(event['time'] * 1000), .transform(const LineSplitter()),
); );
} catch (e) {
_log.error(e.toString(), event);
}
});
_log.info('Launched ykman subprocess...');
_pump(); _pump();
} }
static Future<RpcSession> launch(String executable) async { Future<bool> elevate() async {
var process = await Process.start(executable, []); if (!Platform.isWindows) {
return RpcSession(process); throw Exception('Elevate is only available for Windows');
}
final random = Random.secure();
final nonce = base64Encode(List.generate(32, (_) => random.nextInt(256)));
// Bind to random port
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = server.port;
_log.config('Listening for RPC connection on $port');
// Launch the elevated process
final process =
await Process.start('powershell.exe', ['-NoProfile', '-Command', '-']);
process.stdin.writeln(
'Start-Process $executable -Verb runAs -WindowStyle hidden -ArgumentList "--tcp $port $nonce"');
await process.stdin.flush();
await process.stdin.close();
if (await process.exitCode != 0) {
await server.close();
return false;
}
_log.config('Elevated RPC process started');
// Accept only a single connection
final client = await server.first;
await server.close();
_log.config('Client connected: $client');
// Stop the old subprocess.
_connection.dispose();
bool authenticated = false;
final completer = Completer<void>();
final read =
utf8.decoder.bind(client).transform(const LineSplitter()).map((line) {
// The nonce needs to be received first.
if (!authenticated) {
if (nonce == line) {
_log.config('Client authenticated with correct nonce');
authenticated = true;
completer.complete();
return '';
} else {
_log.warning('Client used WRONG NONCE: $line');
client.close();
completer.completeError(Exception('Invalid nonce'));
throw Exception('Invalid nonce');
}
} else {
// Filter out (and log) log messages
final type = line[0];
final message = line.substring(1);
switch (type) {
case 'O':
return message;
case 'E':
_logEntry(message);
return '';
default:
_log.error('Invalid message: $line');
throw Exception('Invalid message type: $type');
}
}
}).where((line) => line.isNotEmpty);
_connection = _RpcConnection(client, read);
await completer.future;
return true;
} }
Future<Map<String, dynamic>> command(String action, List<String>? target, Future<Map<String, dynamic>> command(String action, List<String>? target,
@ -125,7 +222,7 @@ class RpcSession {
void _send(Map data) { void _send(Map data) {
_log.traffic('SEND', jsonEncode(data)); _log.traffic('SEND', jsonEncode(data));
_process.stdin.writeln(jsonEncode(data)); _connection.send(data);
} }
void _pump() async { void _pump() async {
@ -138,7 +235,7 @@ class RpcSession {
bool completed = false; bool completed = false;
while (!completed) { while (!completed) {
final response = await _responses.next; final response = await _connection.getResponse();
_log.traffic('RECV', jsonEncode(response)); _log.traffic('RECV', jsonEncode(response));
response.map( response.map(
signal: (signal) { signal: (signal) {

View File

@ -3,11 +3,13 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_failure_screen.dart';
import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_loading_screen.dart';
import '../../app/views/app_page.dart'; import '../../app/views/app_page.dart';
import '../../app/views/device_avatar.dart';
import '../../desktop/state.dart'; import '../../desktop/state.dart';
import '../../management/models.dart'; import '../../management/models.dart';
import '../models.dart'; import '../models.dart';
@ -48,8 +50,42 @@ class FidoScreen extends ConsumerWidget {
if (Platform.isWindows) { if (Platform.isWindows) {
if (!ref if (!ref
.watch(rpcStateProvider.select((state) => state.isAdmin))) { .watch(rpcStateProvider.select((state) => state.isAdmin))) {
return const AppFailureScreen( return Center(
'WebAuthn management requires elevated privileges.\nRestart this app as administrator.'); child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const DeviceAvatar(child: Icon(Icons.lock)),
const Text(
'WebAuthn management requires elevated privileges.',
textAlign: TextAlign.center,
),
OutlinedButton.icon(
icon: const Icon(Icons.lock_open),
label: const Text('Unlock'),
onPressed: () async {
final controller = showMessage(
context, 'Elevating permissions...',
duration: const Duration(seconds: 30));
try {
if (await ref.read(rpcProvider).elevate()) {
ref.refresh(rpcProvider);
} else {
showMessage(context, 'Permission denied');
}
} finally {
controller.close();
}
}),
]
.map((e) => Padding(
child: e,
padding:
const EdgeInsets.symmetric(vertical: 8.0),
))
.toList(),
),
);
} }
} }
return AppFailureScreen('$error'); return AppFailureScreen('$error');

View File

@ -54,9 +54,9 @@ class _JsonLoggingFormatter(logging.Formatter):
return json.dumps(data) return json.dumps(data)
def _init_logging(): def _init_logging(stream=None):
logging.disable(logging.NOTSET) logging.disable(logging.NOTSET)
logging.basicConfig() logging.basicConfig(stream=stream)
logging.root.handlers[0].setFormatter(_JsonLoggingFormatter()) logging.root.handlers[0].setFormatter(_JsonLoggingFormatter())
@ -155,9 +155,43 @@ def run_rpc_pipes(stdout, stdin):
stdout.flush() stdout.flush()
def recv(): def recv():
line = stdin.readline() line = (stdin.readline() or "").strip()
if line: if line:
return json.loads(line.strip()) return json.loads(line)
return None
run_rpc(send, recv)
class _WriteLog:
def __init__(self, socket):
self._socket = socket
def write(self, value):
self._socket.sendall(b"E" + value.encode())
def run_rpc_socket(sock):
_init_logging(_WriteLog(sock))
def _json_encode(value):
if isinstance(value, bytes):
return encode_bytes(value)
raise TypeError(type(value))
def send(data):
sock.sendall(b"O" + json.dumps(data, default=_json_encode).encode() + b"\n")
def recv():
line = b""
while not line.endswith(b"\n"):
chunk = sock.recv(1024)
if not chunk:
return None
line += chunk
line = line.strip()
if line:
return json.loads(line)
return None return None
run_rpc(send, recv) run_rpc(send, recv)

View File

@ -1,8 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from rpc import run_rpc_pipes from rpc import run_rpc_pipes, run_rpc_socket
import socket
import sys import sys
if __name__ == "__main__": if __name__ == "__main__":
run_rpc_pipes(sys.stdout, sys.stdin) try:
index = sys.argv.index("--tcp")
port = int(sys.argv[index + 1])
nonce = sys.argv[index + 2].encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("localhost", port))
sock.sendall(nonce + b"\n")
run_rpc_socket(sock)
except ValueError:
run_rpc_pipes(sys.stdout, sys.stdin)