diff --git a/Configuration/QEMUConstant.swift b/Configuration/QEMUConstant.swift index 1385d592..56e5dc84 100644 --- a/Configuration/QEMUConstant.swift +++ b/Configuration/QEMUConstant.swift @@ -389,6 +389,7 @@ enum QEMUPackageFileName: String { case images = "Images" case debugLog = "debug.log" case efiVariables = "efi_vars.fd" + case tpmData = "tpmdata" } // MARK: Supported features diff --git a/Configuration/UTMQemuConfiguration+Arguments.swift b/Configuration/UTMQemuConfiguration+Arguments.swift index ca5385b7..6be1651e 100644 --- a/Configuration/UTMQemuConfiguration+Arguments.swift +++ b/Configuration/UTMQemuConfiguration+Arguments.swift @@ -56,6 +56,11 @@ import Virtualization // for getting network interfaces socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("spice") } + /// Return the socket file for communicating with SWTPM + var swtpmSocketURL: URL { + socketURL.appendingPathComponent(information.uuid.uuidString).appendingPathExtension("swtpm") + } + /// Combined generated and user specified arguments. @QEMUArgumentBuilder var allArguments: [QEMUArgument] { generatedArguments @@ -907,6 +912,32 @@ import Virtualization // for getting network interfaces f("-device") f("virtio-balloon-pci") } + if qemu.hasTPMDevice { + tpmArguments + } + } + + @QEMUArgumentBuilder private var tpmArguments: [QEMUArgument] { + f("-chardev") + "socket" + "id=chrtpm0" + "path=\(swtpmSocketURL.lastPathComponent)" + f() + f("-tpmdev") + "emulator" + "id=tpm0" + "chardev=chrtpm0" + f() + f("-device") + if system.target.rawValue.hasPrefix("virt") { + "tpm-tis-device" + } else if system.architecture == .ppc64 { + "tpm-spapr" + } else { + "tpm-tis" + } + "tpmdev=tpm0" + f() } } diff --git a/Configuration/UTMQemuConfigurationQEMU.swift b/Configuration/UTMQemuConfigurationQEMU.swift index 5463f749..6005a533 100644 --- a/Configuration/UTMQemuConfigurationQEMU.swift +++ b/Configuration/UTMQemuConfigurationQEMU.swift @@ -24,6 +24,9 @@ struct UTMQemuConfigurationQEMU: Codable { /// EFI variables if EFI boot is enabled. This property is not saved to file. var efiVarsURL: URL? + /// TPM data file if TPM is enabled. This property is not saved to file. + var tpmDataURL: URL? + /// If true, write standard output to debug.log in the VM bundle. var hasDebugLog: Bool = false @@ -94,6 +97,7 @@ struct UTMQemuConfigurationQEMU: Codable { if let dataURL = decoder.userInfo[.dataURL] as? URL { debugLogURL = dataURL.appendingPathComponent(QEMUPackageFileName.debugLog.rawValue) efiVarsURL = dataURL.appendingPathComponent(QEMUPackageFileName.efiVariables.rawValue) + tpmDataURL = dataURL.appendingPathComponent(QEMUPackageFileName.tpmData.rawValue) } } @@ -153,27 +157,32 @@ extension UTMQemuConfigurationQEMU { extension UTMQemuConfigurationQEMU { @MainActor mutating func saveData(to dataURL: URL, for system: UTMQemuConfigurationSystem) async throws -> [URL] { - guard hasUefiBoot else { - return [] + var existing: [URL] = [] + if hasUefiBoot { + let fileManager = FileManager.default + // save EFI variables + let resourceURL = Bundle.main.url(forResource: "qemu", withExtension: nil)! + let templateVarsURL: URL + if system.architecture == .arm || system.architecture == .aarch64 { + templateVarsURL = resourceURL.appendingPathComponent("edk2-arm-vars.fd") + } else if system.architecture == .i386 || system.architecture == .x86_64 { + templateVarsURL = resourceURL.appendingPathComponent("edk2-i386-vars.fd") + } else { + throw UTMQemuConfigurationError.uefiNotSupported + } + let varsURL = dataURL.appendingPathComponent(QEMUPackageFileName.efiVariables.rawValue) + if !fileManager.fileExists(atPath: varsURL.path) { + try await Task.detached { + try fileManager.copyItem(at: templateVarsURL, to: varsURL) + }.value + } + efiVarsURL = varsURL + existing.append(varsURL) } - let fileManager = FileManager.default - // save EFI variables - let resourceURL = Bundle.main.url(forResource: "qemu", withExtension: nil)! - let templateVarsURL: URL - if system.architecture == .arm || system.architecture == .aarch64 { - templateVarsURL = resourceURL.appendingPathComponent("edk2-arm-vars.fd") - } else if system.architecture == .i386 || system.architecture == .x86_64 { - templateVarsURL = resourceURL.appendingPathComponent("edk2-i386-vars.fd") - } else { - throw UTMQemuConfigurationError.uefiNotSupported + if hasTPMDevice { + tpmDataURL = dataURL.appendingPathComponent(QEMUPackageFileName.tpmData.rawValue) + existing.append(tpmDataURL!) } - let varsURL = dataURL.appendingPathComponent(QEMUPackageFileName.efiVariables.rawValue) - if !fileManager.fileExists(atPath: varsURL.path) { - try await Task.detached { - try fileManager.copyItem(at: templateVarsURL, to: varsURL) - }.value - } - efiVarsURL = varsURL - return [varsURL] + return existing } } diff --git a/Platform/Shared/VMConfigQEMUView.swift b/Platform/Shared/VMConfigQEMUView.swift index 4fdc16b3..aacccf69 100644 --- a/Platform/Shared/VMConfigQEMUView.swift +++ b/Platform/Shared/VMConfigQEMUView.swift @@ -76,10 +76,8 @@ struct VMConfigQEMUView: View { .help("Should be on always unless the guest cannot boot because of this.") Toggle("Balloon Device", isOn: $config.hasBalloonDevice) .help("Should be on always unless the guest cannot boot because of this.") - #if false - Toggle("TPM Device", isOn: $config.hasTPMDevice) - .help("This is required to boot Windows 11.") - #endif + Toggle("TPM 2.0 Device", isOn: $config.hasTPMDevice) + .help("TPM can be used to protect secrets in the guest operating system. Note that the host will always be able to read these secrets and therefore no expectation of physical security is provided.") Toggle("Use Hypervisor", isOn: $config.hasHypervisor) .help("Only available if host architecture matches the target. Otherwise, TCG emulation is used.") .disabled(!system.architecture.hasHypervisorSupport) diff --git a/QEMULauncher/Bootstrap.c b/QEMULauncher/Bootstrap.c index 63809683..1b878bef 100644 --- a/QEMULauncher/Bootstrap.c +++ b/QEMULauncher/Bootstrap.c @@ -27,6 +27,7 @@ typedef struct { int (*qemu_init)(int, const char *[], const char *[]); void (*qemu_main_loop)(void); void (*qemu_cleanup)(void); + int (*swtpm_main)(int argc, const char *argv[], const char *prgname, const char *iface); } qemu_main_t; // http://mac-os-x.10953.n7.nabble.com/Ensure-NSTask-terminates-when-parent-application-does-td31477.html @@ -61,7 +62,8 @@ static int loadQemu(const char *dylibPath, qemu_main_t *funcs) { funcs->qemu_init = dlsym(dlctx, "qemu_init"); funcs->qemu_main_loop = dlsym(dlctx, "qemu_main_loop"); funcs->qemu_cleanup = dlsym(dlctx, "qemu_cleanup"); - if (funcs->main == NULL && (funcs->qemu_init == NULL || funcs->qemu_main_loop == NULL || funcs->qemu_cleanup == NULL)) { + funcs->swtpm_main = dlsym(dlctx, "swtpm_main"); + if (funcs->main == NULL && funcs->swtpm_main == NULL && (funcs->qemu_init == NULL || funcs->qemu_main_loop == NULL || funcs->qemu_cleanup == NULL)) { fprintf(stderr, "Error resolving %s: %s\n", dylibPath, dlerror()); return -1; } @@ -77,6 +79,8 @@ static void __attribute__((noreturn)) runQemu(qemu_main_t *funcs, int argc, cons pthread_detach(thread); if (funcs->main) { funcs->main(argc, argv); + } else if (funcs->swtpm_main) { + funcs->swtpm_main(argc, argv, "swtpm", "socket"); } else { funcs->qemu_main_loop(); funcs->qemu_cleanup(); diff --git a/Services/UTMProcess.h b/Services/UTMProcess.h index 4a9d5669..10de12b1 100644 --- a/Services/UTMProcess.h +++ b/Services/UTMProcess.h @@ -45,6 +45,7 @@ typedef int (*UTMProcessThreadEntry)(UTMProcess *self, int argc, const char * _N - (void)accessDataWithBookmark:(NSData *)bookmark securityScoped:(BOOL)securityScoped completion:(void(^)(BOOL, NSData * _Nullable, NSString * _Nullable))completion; - (void)stopAccessingPath:(nullable NSString *)path; - (void)processHasExited:(NSInteger)exitCode message:(nullable NSString *)message; +- (BOOL)didLoadDylib:(void *)handle; @end diff --git a/Services/UTMQemuVirtualMachine.swift b/Services/UTMQemuVirtualMachine.swift index 8c94644d..d3313e7a 100644 --- a/Services/UTMQemuVirtualMachine.swift +++ b/Services/UTMQemuVirtualMachine.swift @@ -138,6 +138,8 @@ final class UTMQemuVirtualMachine: UTMVirtualMachine { private var startTask: Task? + private var swtpm: UTMSWTPM? + @MainActor required init(packageUrl: URL, configuration: UTMQemuConfiguration? = nil, isShortcut: Bool = false) throws { self.isScopedAccess = packageUrl.startAccessingSecurityScopedResource() // load configuration @@ -241,6 +243,16 @@ extension UTMQemuVirtualMachine { config.qemu.isDisposable = isRunningAsDisposible } + // start TPM + if await config.qemu.hasTPMDevice { + let swtpm = UTMSWTPM() + swtpm.ctrlSocketUrl = await config.swtpmSocketURL + swtpm.dataUrl = await config.qemu.tpmDataURL + swtpm.currentDirectoryUrl = await config.socketURL + try await swtpm.start() + self.swtpm = swtpm + } + let allArguments = await config.allArguments let arguments = allArguments.map({ $0.string }) let resources = allArguments.compactMap({ $0.fileUrls }).flatMap({ $0 }) @@ -524,6 +536,8 @@ extension UTMQemuVirtualMachine: QEMUVirtualMachineDelegate { } func qemuVMDidStop(_ qemuVM: QEMUVirtualMachine) { + swtpm?.stop() + swtpm = nil ioService = nil ioServiceDelegate = nil state = .stopped diff --git a/Services/UTMSWTPM.swift b/Services/UTMSWTPM.swift new file mode 100644 index 00000000..3f586a81 --- /dev/null +++ b/Services/UTMSWTPM.swift @@ -0,0 +1,125 @@ +// +// Copyright © 2023 osy. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +private typealias SwtpmMainFunction = @convention(c) (_ argc: Int32, _ argv: UnsafeMutablePointer>, _ prgname: UnsafePointer, _ iface: UnsafePointer) -> Int32 + +private let kMaxAttempts = 15 +private let kRetryDelay = 1*NSEC_PER_SEC + +class UTMSWTPM: UTMProcess { + private var swtpmMain: SwtpmMainFunction! + private var hasProcessExited: Bool = false + private var lastErrorLine: String? + + var ctrlSocketUrl: URL? + var dataUrl: URL? + + private override init(arguments: [String]) { + super.init(arguments: arguments) + entry = { process, argc, argv, envp in + let _self = process as! UTMSWTPM + return _self.swtpmMain(argc, argv, "swtpm", "socket") + } + standardError = Pipe() + standardError!.fileHandleForReading.readabilityHandler = { [weak self] handle in + let string = String(data: handle.availableData, encoding: .utf8) ?? "" + logger.debug("\(string)") + self?.lastErrorLine = string + } + standardOutput = Pipe() + standardOutput!.fileHandleForReading.readabilityHandler = { handle in + let string = String(data: handle.availableData, encoding: .utf8) ?? "" + logger.debug("\(string)") + } + } + + convenience init() { + self.init(arguments: []) + } + + override func didLoadDylib(_ handle: UnsafeMutableRawPointer) -> Bool { + let sym = dlsym(handle, "swtpm_main") + swtpmMain = unsafeBitCast(sym, to: SwtpmMainFunction.self) + return swtpmMain != nil + } + + override func processHasExited(_ exitCode: Int, message: String?) { + hasProcessExited = true + if let message = message { + logger.error("SWTPM exited: \(message)") + } + } + + func start() async throws { + guard let ctrlSocketUrl = ctrlSocketUrl else { + throw UTMSWTPMError.socketNotSpecified + } + guard let dataUrl = dataUrl else { + throw UTMSWTPMError.dataNotSpecified + } + let fm = FileManager.default + if !fm.fileExists(atPath: dataUrl.path) { + fm.createFile(atPath: dataUrl.path, contents: nil) + } + let dataBookmark = try dataUrl.bookmarkData() + let (success, _, _) = await accessData(withBookmark: dataBookmark, securityScoped: false) + guard success else { + throw UTMSWTPMError.cannotAccessTpmData + } + clearArgv() + pushArgv("--ctrl") + pushArgv("type=unixio,path=\(ctrlSocketUrl.lastPathComponent),terminate") + pushArgv("--tpmstate") + pushArgv("backend-uri=file://\(dataUrl.path)") + pushArgv("--tpm2") + hasProcessExited = false + try? fm.removeItem(at: ctrlSocketUrl) + try await start("swtpm.0") + // monitor for socket to be created + try await Task { + let fm = FileManager.default + for _ in 0...kMaxAttempts { + if hasProcessExited { + throw UTMSWTPMError.swtpmStartupFailed(lastErrorLine) + } + if fm.fileExists(atPath: ctrlSocketUrl.path) { + return + } + try await Task.sleep(nanoseconds: kRetryDelay) + } + }.value + } +} + +enum UTMSWTPMError: Error { + case socketNotSpecified + case dataNotSpecified + case cannotAccessTpmData + case swtpmStartupFailed(String?) +} + +extension UTMSWTPMError: LocalizedError { + var errorDescription: String? { + switch self { + case .socketNotSpecified: return NSLocalizedString("Socket not specified.", comment: "UTMSWTPM") + case .dataNotSpecified: return NSLocalizedString("Data not specified.", comment: "UTMSWTPM") + case .cannotAccessTpmData: return NSLocalizedString("Cannot access TPM data.", comment: "UTMSWTPM") + case .swtpmStartupFailed(let message): return String.localizedStringWithFormat(NSLocalizedString("SW TPM failed to start. %@", comment: "UTMSWTPM"), message ?? "") + } + } +} diff --git a/UTM.xcodeproj/project.pbxproj b/UTM.xcodeproj/project.pbxproj index 20c87f53..ae76b649 100644 --- a/UTM.xcodeproj/project.pbxproj +++ b/UTM.xcodeproj/project.pbxproj @@ -141,6 +141,9 @@ 845F1709289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */; }; 845F170B289CB07200944904 /* VMDisplayAppleDisplayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F170A289CB07200944904 /* VMDisplayAppleDisplayWindowController.swift */; }; 845F170D289CB3DE00944904 /* VMDisplayTerminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F170C289CB3DE00944904 /* VMDisplayTerminal.swift */; }; + 845F95E32A57628400A016D7 /* UTMSWTPM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F95E22A57628400A016D7 /* UTMSWTPM.swift */; }; + 845F95E42A57628400A016D7 /* UTMSWTPM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F95E22A57628400A016D7 /* UTMSWTPM.swift */; }; + 845F95E52A57628400A016D7 /* UTMSWTPM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845F95E22A57628400A016D7 /* UTMSWTPM.swift */; }; 846D878629050B6B0095F10B /* InAppSettingsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 846D878529050B6B0095F10B /* InAppSettingsKit */; }; 8471770627CC974F00D3A50B /* DefaultTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471770527CC974F00D3A50B /* DefaultTextField.swift */; }; 8471770727CC974F00D3A50B /* DefaultTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8471770527CC974F00D3A50B /* DefaultTextField.swift */; }; @@ -1317,6 +1320,7 @@ 845F1708289CA15C00944904 /* VMDisplayAppleTerminalWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleTerminalWindowController.swift; sourceTree = ""; }; 845F170A289CB07200944904 /* VMDisplayAppleDisplayWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayAppleDisplayWindowController.swift; sourceTree = ""; }; 845F170C289CB3DE00944904 /* VMDisplayTerminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDisplayTerminal.swift; sourceTree = ""; }; + 845F95E22A57628400A016D7 /* UTMSWTPM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTMSWTPM.swift; sourceTree = ""; }; 8471770527CC974F00D3A50B /* DefaultTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTextField.swift; sourceTree = ""; }; 8471772727CD3CAB00D3A50B /* DetailedSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedSection.swift; sourceTree = ""; }; 847BF9A92A49C783000BD9AA /* VMData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMData.swift; sourceTree = ""; }; @@ -2298,6 +2302,7 @@ E2D64BC7241DB24B0034E0C6 /* UTMSpiceIO.h */, E2D64BC8241DB24B0034E0C6 /* UTMSpiceIO.m */, E2D64BE0241EAEBE0034E0C6 /* UTMSpiceIODelegate.h */, + 845F95E22A57628400A016D7 /* UTMSWTPM.swift */, CE020BB524B14F8400B44AB6 /* UTMVirtualMachine.swift */, CE928C2926ABE6690099F293 /* UTMAppleVirtualMachine.swift */, 841E999728AC817D003C6CB6 /* UTMQemuVirtualMachine.swift */, @@ -2948,6 +2953,7 @@ CE2D956924AD4F990059923A /* VMPlaceholderView.swift in Sources */, CE2D931524AD46670059923A /* VMDisplayMetalViewController+Pointer.m in Sources */, 84018692288A73310050AC51 /* VMDisplayViewController.m in Sources */, + 845F95E32A57628400A016D7 /* UTMSWTPM.swift in Sources */, 84CE3DB12904C7A100FF068B /* UTMSettingsView.swift in Sources */, 843BF83428450C0B0029D60D /* UTMQemuConfigurationSound.swift in Sources */, CE2D932224AD46670059923A /* VMScroll.m in Sources */, @@ -3031,6 +3037,7 @@ CE2D957624AD4F990059923A /* VMConfigInputView.swift in Sources */, 84F746B9276FF40900A20C87 /* VMDisplayAppleWindowController.swift in Sources */, CE2D958E24AD4F990059923A /* UTMApp.swift in Sources */, + 845F95E52A57628400A016D7 /* UTMSWTPM.swift in Sources */, 844EC0FB2773EE49003C104A /* UTMDownloadIPSWTask.swift in Sources */, 8432329228C2CDAD00CFBC97 /* VMNavigationListView.swift in Sources */, CE0B6CF324AD568400FE012D /* UTMLegacyQemuConfiguration.m in Sources */, @@ -3195,6 +3202,7 @@ 84B36D2A27B790BE00C22685 /* DestructiveButton.swift in Sources */, 842B9F8E28CC58B700031EE7 /* UTMPatches.swift in Sources */, CEE7E937287CFDB100282049 /* UTMLegacyQemuConfiguration+Constants.m in Sources */, + 845F95E42A57628400A016D7 /* UTMSWTPM.swift in Sources */, 841619AB284315F9000034B2 /* UTMConfigurationInfo.swift in Sources */, CEA45E6F263519B5002FA97D /* VMConfigInfoView.swift in Sources */, CEA45E72263519B5002FA97D /* VMKeyboardView.m in Sources */,