mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 18:22:39 +03:00
Add RPC elevate.
This commit is contained in:
parent
5a13389895
commit
cabdc9effd
@ -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<Widget> _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)),
|
||||
|
@ -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<FidoStateNotifier, AsyncValue<FidoState>, 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));
|
||||
|
@ -73,7 +73,8 @@ Future<Widget> 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);
|
||||
|
||||
|
@ -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,29 +56,42 @@ const _py2level = {
|
||||
'CRITICAL': Level.SHOUT,
|
||||
};
|
||||
|
||||
class RpcSession {
|
||||
final Process _process;
|
||||
final StreamController<_Request> _requests = StreamController();
|
||||
class _RpcConnection {
|
||||
final IOSink _sink;
|
||||
final StreamQueue<RpcResponse> _responses;
|
||||
|
||||
RpcSession(this._process)
|
||||
: _responses = StreamQueue(_process.stdout
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())
|
||||
.map((event) {
|
||||
_RpcConnection(this._sink, Stream<String> 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
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())
|
||||
.listen((event) {
|
||||
}));
|
||||
|
||||
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(event);
|
||||
final record = jsonDecode(entry);
|
||||
Logger('rpc.${record['name']}').log(
|
||||
_py2level[record['level']] ?? Level.INFO,
|
||||
record['message'],
|
||||
@ -85,17 +99,100 @@ class RpcSession {
|
||||
//time: DateTime.fromMillisecondsSinceEpoch(event['time'] * 1000),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.error(e.toString(), event);
|
||||
_log.error(e.toString(), entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_log.info('Launched ykman subprocess...');
|
||||
Future<void> initialize() async {
|
||||
final process = await Process.start(executable, []);
|
||||
_log.config('RPC process started');
|
||||
process.stderr
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())
|
||||
.listen(_logEntry);
|
||||
|
||||
// Communicate with rpc over stdin/stdout.
|
||||
_connection = _RpcConnection(
|
||||
process.stdin,
|
||||
process.stdout
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter()),
|
||||
);
|
||||
|
||||
_pump();
|
||||
}
|
||||
|
||||
static Future<RpcSession> launch(String executable) async {
|
||||
var process = await Process.start(executable, []);
|
||||
return RpcSession(process);
|
||||
Future<bool> 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<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,
|
||||
@ -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) {
|
||||
|
@ -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');
|
||||
|
@ -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)
|
@ -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__":
|
||||
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)
|
||||
|
Loading…
Reference in New Issue
Block a user