From cabdc9effd96c8b4449ffc320876747f71bd683e Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Wed, 30 Mar 2022 16:45:47 +0200 Subject: [PATCH] Add RPC elevate. --- lib/app/views/no_device_screen.dart | 52 ++++++--- lib/desktop/fido/state.dart | 5 + lib/desktop/init.dart | 3 +- lib/desktop/rpc.dart | 157 ++++++++++++++++++++++------ lib/fido/views/fido_screen.dart | 40 ++++++- ykman-rpc/rpc/__init__.py | 42 +++++++- ykman-rpc/ykman-rpc.py | 17 ++- 7 files changed, 263 insertions(+), 53 deletions(-) diff --git a/lib/app/views/no_device_screen.dart b/lib/app/views/no_device_screen.dart index 64388471..783349cb 100755 --- a/lib/app/views/no_device_screen.dart +++ b/lib/app/views/no_device_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/models.dart'; import '../../desktop/state.dart'; +import '../message.dart'; import '../models.dart'; import 'app_page.dart'; import 'device_avatar.dart'; @@ -13,16 +14,45 @@ class NoDeviceScreen extends ConsumerWidget { final DeviceNode? node; const NoDeviceScreen(this.node, {Key? key}) : super(key: key); - String _getErrorMessage(WidgetRef ref, UsbPid pid) { - // TODO: Handle more cases + List _buildUsbPid(BuildContext context, WidgetRef ref, UsbPid pid) { if (pid.usbInterfaces == UsbInterface.fido.value) { - if (Platform.isWindows) { - if (!ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { - return 'WebAuthn management requires elevated privileges.\nRestart this app as administrator.'; - } + if (Platform.isWindows && + !ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { + 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 @@ -32,13 +62,7 @@ class NoDeviceScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: node?.map(usbYubiKey: (node) { - return [ - const DeviceAvatar(child: Icon(Icons.usb_off)), - Text( - _getErrorMessage(ref, node.pid), - textAlign: TextAlign.center, - ), - ]; + return _buildUsbPid(context, ref, node.pid); }, nfcReader: (node) { return const [ DeviceAvatar(child: Icon(Icons.wifi)), diff --git a/lib/desktop/fido/state.dart b/lib/desktop/fido/state.dart index a7f25acd..156eb807 100755 --- a/lib/desktop/fido/state.dart +++ b/lib/desktop/fido/state.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -31,6 +32,10 @@ final desktopFidoState = StateNotifierProvider.autoDispose .family, DevicePath>( (ref, 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); session.setErrorHandler('state-reset', (_) async { ref.refresh(_sessionProvider(devicePath)); diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index b9b7dce6..8d4cd2b7 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -73,7 +73,8 @@ Future initialize() async { } _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); rpc.setLogLevel(Logger.root.level); diff --git a/lib/desktop/rpc.dart b/lib/desktop/rpc.dart index ea880062..c031345b 100644 --- a/lib/desktop/rpc.dart +++ b/lib/desktop/rpc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:logging/logging.dart'; import 'package:async/async.dart'; @@ -55,47 +56,143 @@ const _py2level = { 'CRITICAL': Level.SHOUT, }; -class RpcSession { - final Process _process; - final StreamController<_Request> _requests = StreamController(); +class _RpcConnection { + final IOSink _sink; final StreamQueue _responses; - - RpcSession(this._process) - : _responses = StreamQueue(_process.stdout - .transform(const Utf8Decoder()) - .transform(const LineSplitter()) - .map((event) { + _RpcConnection(this._sink, Stream stream) + : _responses = StreamQueue(stream.map((event) { try { return RpcResponse.fromJson(jsonDecode(event)); } catch (e) { _log.severe('Response was not valid JSON', event); return RpcResponse.error('invalid-response', e.toString(), {}); } - })) { - _process.stderr + })); + + void send(Map data) { + _sink.writeln(jsonEncode(data)); + } + + Future 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 initialize() async { + final process = await Process.start(executable, []); + _log.config('RPC process started'); + process.stderr .transform(const Utf8Decoder()) .transform(const LineSplitter()) - .listen((event) { - try { - final record = jsonDecode(event); - 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(), event); - } - }); + .listen(_logEntry); + + // Communicate with rpc over stdin/stdout. + _connection = _RpcConnection( + process.stdin, + process.stdout + .transform(const Utf8Decoder()) + .transform(const LineSplitter()), + ); - _log.info('Launched ykman subprocess...'); _pump(); } - static Future launch(String executable) async { - var process = await Process.start(executable, []); - return RpcSession(process); + Future elevate() async { + if (!Platform.isWindows) { + 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(); + 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> command(String action, List? target, @@ -125,7 +222,7 @@ class RpcSession { void _send(Map data) { _log.traffic('SEND', jsonEncode(data)); - _process.stdin.writeln(jsonEncode(data)); + _connection.send(data); } void _pump() async { @@ -138,7 +235,7 @@ class RpcSession { bool completed = false; while (!completed) { - final response = await _responses.next; + final response = await _connection.getResponse(); _log.traffic('RECV', jsonEncode(response)); response.map( signal: (signal) { diff --git a/lib/fido/views/fido_screen.dart b/lib/fido/views/fido_screen.dart index a0b9d3de..5ba21b93 100755 --- a/lib/fido/views/fido_screen.dart +++ b/lib/fido/views/fido_screen.dart @@ -3,11 +3,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../app/message.dart'; import '../../app/models.dart'; import '../../app/state.dart'; import '../../app/views/app_failure_screen.dart'; import '../../app/views/app_loading_screen.dart'; import '../../app/views/app_page.dart'; +import '../../app/views/device_avatar.dart'; import '../../desktop/state.dart'; import '../../management/models.dart'; import '../models.dart'; @@ -48,8 +50,42 @@ class FidoScreen extends ConsumerWidget { if (Platform.isWindows) { if (!ref .watch(rpcStateProvider.select((state) => state.isAdmin))) { - return const AppFailureScreen( - 'WebAuthn management requires elevated privileges.\nRestart this app as administrator.'); + return Center( + 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'); diff --git a/ykman-rpc/rpc/__init__.py b/ykman-rpc/rpc/__init__.py index 7ebf0783..894874a2 100644 --- a/ykman-rpc/rpc/__init__.py +++ b/ykman-rpc/rpc/__init__.py @@ -54,9 +54,9 @@ class _JsonLoggingFormatter(logging.Formatter): return json.dumps(data) -def _init_logging(): +def _init_logging(stream=None): logging.disable(logging.NOTSET) - logging.basicConfig() + logging.basicConfig(stream=stream) logging.root.handlers[0].setFormatter(_JsonLoggingFormatter()) @@ -155,9 +155,43 @@ def run_rpc_pipes(stdout, stdin): stdout.flush() def recv(): - line = stdin.readline() + line = (stdin.readline() or "").strip() 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 + + run_rpc(send, recv) \ No newline at end of file diff --git a/ykman-rpc/ykman-rpc.py b/ykman-rpc/ykman-rpc.py index 365ed6d5..463aa25d 100644 --- a/ykman-rpc/ykman-rpc.py +++ b/ykman-rpc/ykman-rpc.py @@ -1,8 +1,21 @@ #!/usr/bin/env python3 -from rpc import run_rpc_pipes +from rpc import run_rpc_pipes, run_rpc_socket + +import socket import sys 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)