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 '../../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)),
|
||||||
|
@ -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));
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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');
|
||||||
|
@ -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)
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user