remote: support SPICE TLS

This commit is contained in:
osy 2024-02-10 18:23:49 -08:00
parent dbdf7498bf
commit ea958e66d4
14 changed files with 184 additions and 43 deletions

View File

@ -71,6 +71,16 @@ import Virtualization // for getting network interfaces
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("qga")
}
/// Used only if in remote sever mode.
var spiceTlsKeyUrl: URL {
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("pem")
}
/// Used only if in remote sever mode.
var spiceTlsCertUrl: URL {
socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("crt")
}
/// Combined generated and user specified arguments.
@QEMUArgumentBuilder var allArguments: [QEMUArgument] {
generatedArguments
@ -120,15 +130,30 @@ import Virtualization // for getting network interfaces
@QEMUArgumentBuilder private var spiceArguments: [QEMUArgument] {
f("-spice")
if let port = qemu.spiceServerPort {
"port=\(port)"
if qemu.isSpiceServerTlsEnabled {
"tls-port=\(port)"
"tls-channel=default"
"x509-key-file="
spiceTlsKeyUrl
"x509-cert-file="
spiceTlsCertUrl
"x509-cacert-file="
spiceTlsCertUrl
} else {
"port=\(port)"
}
} else {
"unix=on"
"addr=\(spiceSocketURL.lastPathComponent)"
}
"disable-ticketing=on"
"image-compression=off"
"playback-compression=off"
"streaming-video=off"
if !isRemoteSpice {
"image-compression=off"
"playback-compression=off"
"streaming-video=off"
} else {
"streaming-video=filter"
}
"gl=\(isGLSupported && !isRemoteSpice ? "on" : "off")"
f()
f("-chardev")

View File

@ -72,6 +72,12 @@ struct UTMQemuConfigurationQEMU: Codable {
/// Set to open a port for remote SPICE session. Not saved.
var spiceServerPort: UInt16?
/// If true, all SPICE channels will be over TLS. Not saved.
var isSpiceServerTlsEnabled: Bool = false
/// Set to TLS public key for SPICE server in SubjectPublicKey. Not saved.
var spiceServerPublicKey: Data?
enum CodingKeys: String, CodingKey {
case hasDebugLog = "DebugLog"
case hasUefiBoot = "UEFIBoot"

View File

@ -82,7 +82,7 @@ extension UTMData {
/// - options: Start options
/// - server: Remote server
/// - Returns: Port number to SPICE server
func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> UInt16 {
func startRemote(vm: VMData, options: UTMVirtualMachineStartOptions, forClient client: UTMRemoteServer.Remote) async throws -> (port: UInt16, publicKey: Data) {
guard let wrapped = vm.wrapped as? UTMQemuVirtualMachine, type(of: wrapped).capabilities.supportsRemoteSession else {
throw UTMDataError.unsupportedBackend
}
@ -94,7 +94,7 @@ extension UTMData {
}
try await wrapped.start(options: options.union(.remoteSession))
vmWindows[vm] = session
return wrapped.config.qemu.spiceServerPort!
return (wrapped.config.qemu.spiceServerPort!, wrapped.config.qemu.spiceServerPublicKey!)
}
func stop(vm: VMData) {

View File

@ -132,7 +132,95 @@ err:
return 0;
}
_Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
static _Nullable CFDataRef CreateP12FromKey(EVP_PKEY *pkey, X509 *cert) {
PKCS12 *p12;
BIO *mem;
char *ptr;
long length;
CFDataRef data;
p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
if (!p12) {
ERR_print_errors_fp(stderr);
return NULL;
}
mem = BIO_new(BIO_s_mem());
if (!mem || !i2d_PKCS12_bio(mem, p12)) {
ERR_print_errors_fp(stderr);
PKCS12_free(p12);
BIO_free(mem);
return NULL;
}
PKCS12_free(p12);
length = BIO_get_mem_data(mem, &ptr);
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
BIO_free(mem);
return data;
}
static _Nullable CFDataRef CreatePrivatePEMFromKey(EVP_PKEY *pkey) {
BIO *mem;
char *ptr;
long length;
CFDataRef data;
mem = BIO_new(BIO_s_mem());
if (!mem || !PEM_write_bio_PrivateKey(mem, pkey, NULL, NULL, 0, NULL, NULL)) {
ERR_print_errors_fp(stderr);
BIO_free(mem);
return NULL;
}
length = BIO_get_mem_data(mem, &ptr);
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
BIO_free(mem);
return data;
}
static _Nullable CFDataRef CreatePublicPEMFromCert(X509 *cert) {
BIO *mem;
char *ptr;
long length;
CFDataRef data;
mem = BIO_new(BIO_s_mem());
if (!mem || !PEM_write_bio_X509(mem, cert)) {
ERR_print_errors_fp(stderr);
BIO_free(mem);
return NULL;
}
length = BIO_get_mem_data(mem, &ptr);
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
BIO_free(mem);
return data;
}
static _Nullable CFDataRef CreatePublicKeyFromCert(X509 *cert) {
EVP_PKEY* pubkey;
BIO *mem;
char *ptr;
long length;
CFDataRef data;
pubkey = X509_get_pubkey(cert);
if (!pubkey) {
ERR_print_errors_fp(stderr);
return NULL;
}
mem = BIO_new(BIO_s_mem());
if (!mem || !i2d_PUBKEY_bio(mem, pubkey)) {
ERR_print_errors_fp(stderr);
EVP_PKEY_free(pubkey);
BIO_free(mem);
return NULL;
}
length = BIO_get_mem_data(mem, &ptr);
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
BIO_free(mem);
EVP_PKEY_free(pubkey);
return data;
}
_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient) {
char _commonName[X509_ENTRY_MAX_LENGTH];
char _organizationName[X509_ENTRY_MAX_LENGTH];
long _serial = 0;
@ -140,11 +228,8 @@ _Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFSt
int _isClient = 0;
X509 *cert;
EVP_PKEY *pkey;
PKCS12 *p12;
BIO *mem;
char *ptr;
long length;
CFDataRef data;
CFDataRef arr[4] = {NULL};
CFArrayRef cfarr = NULL;
if (!CFStringGetCString(commonName, _commonName, X509_ENTRY_MAX_LENGTH, kCFStringEncodingUTF8)) {
return NULL;
@ -166,22 +251,26 @@ _Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFSt
ERR_print_errors_fp(stderr);
return NULL;
}
p12 = PKCS12_create("password", NULL, pkey, cert, NULL, NID_pbe_WithSHA1And3_Key_TripleDES_CBC, NID_pbe_WithSHA1And40BitRC2_CBC, PKCS12_DEFAULT_ITER, 1, 0);
arr[0] = CreateP12FromKey(pkey, cert);
arr[1] = CreatePrivatePEMFromKey(pkey);
arr[2] = CreatePublicPEMFromCert(cert);
arr[3] = CreatePublicKeyFromCert(cert);
if (arr[0] && arr[1] && arr[2] && arr[3]) {
cfarr = CFArrayCreate(kCFAllocatorDefault, (const void **)arr, 4, &kCFTypeArrayCallBacks);
}
if (arr[0]) {
CFRelease(arr[0]);
}
if (arr[1]) {
CFRelease(arr[1]);
}
if (arr[2]) {
CFRelease(arr[2]);
}
if (arr[3]) {
CFRelease(arr[3]);
}
EVP_PKEY_free(pkey);
X509_free(cert);
if (!p12) {
ERR_print_errors_fp(stderr);
return NULL;
}
mem = BIO_new(BIO_s_mem());
if (!mem || !i2d_PKCS12_bio(mem, p12)) {
ERR_print_errors_fp(stderr);
PKCS12_free(p12);
return NULL;
}
PKCS12_free(p12);
length = BIO_get_mem_data(mem, &ptr);
data = CFDataCreate(kCFAllocatorDefault, (void *)ptr, length);
BIO_free(mem);
return data;
return cfarr;
}

View File

@ -28,6 +28,6 @@
/// - serial: Serial number of the certificate
/// - days: Validity in days from today
/// - isClient: If 0 then a TLS Server certificate is generated, otherwise a TLS Client certificate is generated
_Nullable CFDataRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
_Nullable CFArrayRef GenerateRSACertificate(CFStringRef _Nonnull commonName, CFStringRef _Nonnull organizationName, CFNumberRef _Nullable serial, CFNumberRef _Nullable days, CFBooleanRef _Nonnull isClient);
#endif /* GenerateKey_h */

View File

@ -268,8 +268,9 @@ extension UTMRemoteClient {
return fileUrl
}
func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> UInt16 {
try await _startVirtualMachine(parameters: .init(id: id, options: options)).spiceServerPort
func startVirtualMachine(id: UUID, options: UTMVirtualMachineStartOptions) async throws -> (port: UInt16, publicKey: Data) {
let reply = try await _startVirtualMachine(parameters: .init(id: id, options: options))
return (reply.spiceServerPort, reply.spiceServerPublicKey)
}
func stopVirtualMachine(id: UUID, method: UTMVirtualMachineStopMethod) async throws {

View File

@ -49,12 +49,12 @@ class UTMRemoteKeyManager {
let organizationName = "UTM" as CFString
let serialNumber = Int.random(in: 1..<CLong.max) as CFNumber
let days = 3650 as CFNumber
guard let p12Data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() else {
guard let data = GenerateRSACertificate(commonName, organizationName, serialNumber, days, isClient as CFBoolean)?.takeUnretainedValue() as? [CFData] else {
throw UTMRemoteKeyManagerError.generateKeyFailure
}
let importOptions = [ kSecImportExportPassphrase as String: "password" ] as CFDictionary
var rawItems: CFArray?
try withSecurityThrow(SecPKCS12Import(p12Data, importOptions, &rawItems))
try withSecurityThrow(SecPKCS12Import(data[0], importOptions, &rawItems))
guard let items = (rawItems! as! [[String: Any]]).first else {
throw UTMRemoteKeyManagerError.parseKeyFailure
}

View File

@ -129,6 +129,7 @@ extension UTMRemoteMessageServer {
struct Reply: Serializable, Codable {
let spiceServerPort: UInt16
let spiceServerPublicKey: Data
}
}

View File

@ -631,8 +631,8 @@ extension UTMRemoteServer {
private func _startVirtualMachine(parameters: M.StartVirtualMachine.Request) async throws -> M.StartVirtualMachine.Reply {
let vm = try await findVM(withId: parameters.id)
let port = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
return .init(spiceServerPort: port)
let (port, publicKey) = try await data.startRemote(vm: vm, options: parameters.options, forClient: client)
return .init(spiceServerPort: port, spiceServerPublicKey: publicKey)
}
private func _stopVirtualMachine(parameters: M.StopVirtualMachine.Request) async throws -> M.StopVirtualMachine.Reply {

View File

@ -154,7 +154,7 @@ extension UTMRemoteSpiceVirtualMachine {
extension UTMRemoteSpiceVirtualMachine {
func start(options: UTMVirtualMachineStartOptions) async throws {
try await _state.operation(before: .stopped, during: .starting, after: .started) {
let port = try await server.startVirtualMachine(id: id, options: options)
let spiceServer = try await server.startVirtualMachine(id: id, options: options)
var options = UTMSpiceIOOptions()
if await !config.sound.isEmpty {
options.insert(.hasAudio)
@ -170,7 +170,7 @@ extension UTMRemoteSpiceVirtualMachine {
options.insert(.hasDebugLog)
}
#endif
let ioService = UTMSpiceIO(host: server.host, port: Int(port), options: options)
let ioService = UTMSpiceIO(host: server.host, tlsPort: Int(spiceServer.port), serverPublicKey: spiceServer.publicKey, options: options)
ioService.logHandler = { (line: String) -> Void in
guard !line.contains("spice_make_scancode") else {
return // do not log key presses for privacy reasons

View File

@ -279,6 +279,7 @@ extension UTMQemuVirtualMachine {
await MainActor.run {
config.qemu.isDisposable = isRunningAsDisposible
config.qemu.spiceServerPort = spicePort
config.qemu.isSpiceServerTlsEnabled = true
}
// start TPM
@ -338,6 +339,19 @@ extension UTMQemuVirtualMachine {
}
try pipeInterface.start()
interface = pipeInterface
// generate a TLS key for this session
guard let key = GenerateRSACertificate("UTM Remote SPICE Server" as CFString,
"UTM" as CFString,
Int.random(in: 1..<CLong.max) as CFNumber,
1 as CFNumber,
false as CFBoolean)?.takeUnretainedValue() as? [Data] else {
throw UTMQemuVirtualMachineError.keyGenerationFailed
}
try await key[1].write(to: config.spiceTlsKeyUrl)
try await key[2].write(to: config.spiceTlsCertUrl)
await MainActor.run {
config.qemu.spiceServerPublicKey = key[3]
}
} else {
let ioService = UTMSpiceIO(socketUrl: spiceSocketUrl, options: options)
ioService.logHandler = { [weak system] (line: String) -> Void in
@ -835,6 +849,7 @@ enum UTMQemuVirtualMachineError: Error {
case accessShareFailed
case invalidVmState
case saveSnapshotFailed(Error)
case keyGenerationFailed
}
extension UTMQemuVirtualMachineError: LocalizedError {
@ -851,6 +866,8 @@ extension UTMQemuVirtualMachineError: LocalizedError {
case .invalidVmState: return NSLocalizedString("The virtual machine is in an invalid state.", comment: "UTMQemuVirtualMachine")
case .saveSnapshotFailed(let error):
return String.localizedStringWithFormat(NSLocalizedString("Failed to save VM snapshot. Usually this means at least one device does not support snapshots. %@", comment: "UTMQemuVirtualMachine"), error.localizedDescription)
case .keyGenerationFailed:
return NSLocalizedString("Failed to generate TLS key for server.", comment: "UTMQemuVirtualMachine")
}
}
}

View File

@ -58,7 +58,7 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithSocketUrl:(NSURL *)socketUrl options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithHost:(NSString *)host port:(NSInteger)port options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey options:(UTMSpiceIOOptions)options NS_DESIGNATED_INITIALIZER;
- (void)changeSharedDirectory:(NSURL *)url;
- (BOOL)startWithError:(NSError * _Nullable *)error;

View File

@ -24,7 +24,8 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
@property (nonatomic, nullable) NSURL *socketUrl;
@property (nonatomic, nullable) NSString *host;
@property (nonatomic) NSInteger port;
@property (nonatomic) NSInteger tlsPort;
@property (nonatomic, nullable) NSData *serverPublicKey;
@property (nonatomic) UTMSpiceIOOptions options;
@property (nonatomic, readwrite, nullable) CSDisplay *primaryDisplay;
@property (nonatomic) NSMutableArray<CSDisplay *> *mutableDisplays;
@ -73,10 +74,11 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
return self;
}
- (instancetype)initWithHost:(NSString *)host port:(NSInteger)port options:(UTMSpiceIOOptions)options {
- (instancetype)initWithHost:(NSString *)host tlsPort:(NSInteger)tlsPort serverPublicKey:(NSData *)serverPublicKey options:(UTMSpiceIOOptions)options {
if (self = [super init]) {
self.host = host;
self.port = port;
self.tlsPort = tlsPort;
self.serverPublicKey = serverPublicKey;
self.options = options;
self.mutableDisplays = [NSMutableArray array];
self.mutableSerials = [NSMutableArray array];
@ -91,7 +93,7 @@ NSString *const kUTMErrorDomain = @"com.utmapp.utm";
NSURL *relativeSocketFile = [NSURL fileURLWithPath:self.socketUrl.lastPathComponent];
self.spiceConnection = [[CSConnection alloc] initWithUnixSocketFile:relativeSocketFile];
} else {
self.spiceConnection = [[CSConnection alloc] initWithHost:self.host port:[@(self.port) stringValue]];
self.spiceConnection = [[CSConnection alloc] initWithHost:self.host tlsPort:[@(self.tlsPort) stringValue] serverPublicKey:self.serverPublicKey];
}
self.spiceConnection.delegate = self;
self.spiceConnection.audioEnabled = (self.options & UTMSpiceIOOptionsHasAudio) == UTMSpiceIOOptionsHasAudio;

View File

@ -15,7 +15,7 @@
"location" : "https://github.com/utmapp/CocoaSpice.git",
"state" : {
"branch" : "visionos",
"revision" : "4529c9686259e8d1e94d6253ad2e3a563fd1498d"
"revision" : "9591cdf41282a7e6edbe7b705adbb957592ba347"
}
},
{