diff --git a/Configuration/UTMQemuConfiguration+Arguments.swift b/Configuration/UTMQemuConfiguration+Arguments.swift index 2970b137..eb414228 100644 --- a/Configuration/UTMQemuConfiguration+Arguments.swift +++ b/Configuration/UTMQemuConfiguration+Arguments.swift @@ -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") diff --git a/Configuration/UTMQemuConfigurationQEMU.swift b/Configuration/UTMQemuConfigurationQEMU.swift index cb9a44bb..7af246ff 100644 --- a/Configuration/UTMQemuConfigurationQEMU.swift +++ b/Configuration/UTMQemuConfigurationQEMU.swift @@ -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" diff --git a/Platform/macOS/UTMDataExtension.swift b/Platform/macOS/UTMDataExtension.swift index c29fb0de..254c774f 100644 --- a/Platform/macOS/UTMDataExtension.swift +++ b/Platform/macOS/UTMDataExtension.swift @@ -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) { diff --git a/Remote/GenerateKey.c b/Remote/GenerateKey.c index 4f97f731..f7bad39a 100644 --- a/Remote/GenerateKey.c +++ b/Remote/GenerateKey.c @@ -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; } diff --git a/Remote/GenerateKey.h b/Remote/GenerateKey.h index 8d00248c..7e73d5f2 100644 --- a/Remote/GenerateKey.h +++ b/Remote/GenerateKey.h @@ -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 */ diff --git a/Remote/UTMRemoteClient.swift b/Remote/UTMRemoteClient.swift index 8cfa07c0..10b03b97 100644 --- a/Remote/UTMRemoteClient.swift +++ b/Remote/UTMRemoteClient.swift @@ -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 { diff --git a/Remote/UTMRemoteKeyManager.swift b/Remote/UTMRemoteKeyManager.swift index 911e54ce..eadabc51 100644 --- a/Remote/UTMRemoteKeyManager.swift +++ b/Remote/UTMRemoteKeyManager.swift @@ -49,12 +49,12 @@ class UTMRemoteKeyManager { let organizationName = "UTM" as CFString let serialNumber = Int.random(in: 1.. 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 { diff --git a/Remote/UTMRemoteSpiceVirtualMachine.swift b/Remote/UTMRemoteSpiceVirtualMachine.swift index 9ebbfacd..016b87aa 100644 --- a/Remote/UTMRemoteSpiceVirtualMachine.swift +++ b/Remote/UTMRemoteSpiceVirtualMachine.swift @@ -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 diff --git a/Services/UTMQemuVirtualMachine.swift b/Services/UTMQemuVirtualMachine.swift index a59aca48..3512433c 100644 --- a/Services/UTMQemuVirtualMachine.swift +++ b/Services/UTMQemuVirtualMachine.swift @@ -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.. 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") } } } diff --git a/Services/UTMSpiceIO.h b/Services/UTMSpiceIO.h index 09ac887a..e588a26c 100644 --- a/Services/UTMSpiceIO.h +++ b/Services/UTMSpiceIO.h @@ -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; diff --git a/Services/UTMSpiceIO.m b/Services/UTMSpiceIO.m index c15c7d47..67b30a37 100644 --- a/Services/UTMSpiceIO.m +++ b/Services/UTMSpiceIO.m @@ -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 *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; diff --git a/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ad5aaada..7ee61175 100644 --- a/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/UTM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "location" : "https://github.com/utmapp/CocoaSpice.git", "state" : { "branch" : "visionos", - "revision" : "4529c9686259e8d1e94d6253ad2e3a563fd1498d" + "revision" : "9591cdf41282a7e6edbe7b705adbb957592ba347" } }, {