|
| 1 | +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 2 | +// SPDX-License-Identifier: GPL-2.0-or-later |
| 3 | + |
| 4 | +import Foundation |
| 5 | + |
| 6 | +/// |
| 7 | +/// Discovers signed code components and verifies their team identifiers. |
| 8 | +/// |
| 9 | +enum CodeSignatureVerifier { |
| 10 | + static func verify(at location: URL, expectedTeamIdentifier: String? = nil) throws { |
| 11 | + try verify( |
| 12 | + at: location, |
| 13 | + expectedTeamIdentifier: expectedTeamIdentifier, |
| 14 | + isMachO: { isMachO($0) }, |
| 15 | + signatureDetails: { try codesignDetails(at: $0) } |
| 16 | + ) |
| 17 | + } |
| 18 | + |
| 19 | + static func verify( |
| 20 | + at location: URL, |
| 21 | + expectedTeamIdentifier: String? = nil, |
| 22 | + isMachO: (URL) -> Bool, |
| 23 | + signatureDetails: (URL) throws -> String |
| 24 | + ) throws { |
| 25 | + let components = try discoverCodeComponents(at: location, isMachO: isMachO) |
| 26 | + let signatures = try components.map { component in |
| 27 | + ( |
| 28 | + location: component.path, |
| 29 | + teamIdentifier: TeamIdentifierVerifier.teamIdentifier(from: try signatureDetails(component)) |
| 30 | + ) |
| 31 | + } |
| 32 | + |
| 33 | + if let error = TeamIdentifierVerifier.validationError(for: signatures) { |
| 34 | + throw MacCrafterError.signing(error) |
| 35 | + } |
| 36 | + |
| 37 | + if let expectedTeamIdentifier, |
| 38 | + let error = TeamIdentifierVerifier.validationError( |
| 39 | + for: signatures, |
| 40 | + expectedTeamIdentifier: expectedTeamIdentifier |
| 41 | + ) |
| 42 | + { |
| 43 | + throw MacCrafterError.signing(error) |
| 44 | + } |
| 45 | + |
| 46 | + Log.info("Verified matching TeamIdentifier for \(signatures.count) code components") |
| 47 | + } |
| 48 | + |
| 49 | + static func discoverCodeComponents(at url: URL, isMachO: (URL) -> Bool) throws -> [URL] { |
| 50 | + let codeBundleExtensions = ["app", "appex", "framework", "xpc"] |
| 51 | + let codeSearchDirectories = ["/Contents/MacOS/", "/Contents/Frameworks/", "/Contents/PlugIns/"] |
| 52 | + var components = [URL]() |
| 53 | + |
| 54 | + guard let enumerator = FileManager.default.enumerator( |
| 55 | + at: url, |
| 56 | + includingPropertiesForKeys: [.isRegularFileKey] |
| 57 | + ) else { |
| 58 | + throw MacCrafterError.environmentError("Failed to get enumerator for: \(url.path)") |
| 59 | + } |
| 60 | + |
| 61 | + for case let candidate as URL in enumerator { |
| 62 | + let pathExtension = candidate.pathExtension.lowercased() |
| 63 | + |
| 64 | + if codeBundleExtensions.contains(pathExtension) || pathExtension == "dylib" { |
| 65 | + components.append(candidate) |
| 66 | + continue |
| 67 | + } |
| 68 | + |
| 69 | + guard codeSearchDirectories.contains(where: candidate.path.contains), |
| 70 | + try candidate.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true, |
| 71 | + isMachO(candidate) |
| 72 | + else { |
| 73 | + continue |
| 74 | + } |
| 75 | + |
| 76 | + components.append(candidate) |
| 77 | + } |
| 78 | + |
| 79 | + return [url] + components.sorted { $0.path < $1.path } |
| 80 | + } |
| 81 | + |
| 82 | + private static func isMachO(_ file: URL) -> Bool { |
| 83 | + let task = Process() |
| 84 | + let outputPipe = Pipe() |
| 85 | + task.executableURL = URL(fileURLWithPath: "/usr/bin/file") |
| 86 | + task.arguments = ["-b", file.path] |
| 87 | + task.standardOutput = outputPipe |
| 88 | + task.standardError = Pipe() |
| 89 | + |
| 90 | + do { |
| 91 | + try task.run() |
| 92 | + } catch { |
| 93 | + return false |
| 94 | + } |
| 95 | + |
| 96 | + let output = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" |
| 97 | + task.waitUntilExit() |
| 98 | + return task.terminationStatus == 0 && output.contains("Mach-O") |
| 99 | + } |
| 100 | + |
| 101 | + private static func codesignDetails(at location: URL) throws -> String { |
| 102 | + let task = Process() |
| 103 | + let standardOutput = Pipe() |
| 104 | + let standardError = Pipe() |
| 105 | + task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") |
| 106 | + task.arguments = ["--display", "--verbose=4", location.path] |
| 107 | + task.standardOutput = standardOutput |
| 108 | + task.standardError = standardError |
| 109 | + |
| 110 | + do { |
| 111 | + try task.run() |
| 112 | + } catch { |
| 113 | + throw MacCrafterError.signing("Unable to inspect the code signature of \(location.path): \(error.localizedDescription)") |
| 114 | + } |
| 115 | + |
| 116 | + let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile() |
| 117 | + let errorData = standardError.fileHandleForReading.readDataToEndOfFile() |
| 118 | + task.waitUntilExit() |
| 119 | + |
| 120 | + guard task.terminationStatus == 0 else { |
| 121 | + let errorOutput = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown codesign error" |
| 122 | + throw MacCrafterError.signing("Unable to inspect the code signature of \(location.path): \(errorOutput)") |
| 123 | + } |
| 124 | + |
| 125 | + return [outputData, errorData] |
| 126 | + .compactMap { String(data: $0, encoding: .utf8) } |
| 127 | + .joined(separator: "\n") |
| 128 | + } |
| 129 | +} |
0 commit comments