diff --git a/speaktype/Services/UpdateService.swift b/speaktype/Services/UpdateService.swift index 4ead5ab..772ca01 100644 --- a/speaktype/Services/UpdateService.swift +++ b/speaktype/Services/UpdateService.swift @@ -1,10 +1,13 @@ import AppKit import Combine import Foundation +import Security /// Service to check for app updates and manage update preferences class UpdateService: ObservableObject { static let shared = UpdateService() + static let trustedUpdateBundleIdentifier = "com.2048labs.speaktype" + static let trustedUpdateTeamIdentifier = "PCV4UMSRZX" @Published var availableUpdate: AppVersion? @Published var isCheckingForUpdates = false @@ -172,13 +175,16 @@ class UpdateService: ObservableObject { } let appInDMG = try findApp(in: mountPoint) - // 5. Replace the running app + // 5. Verify the mounted app is signed by the expected developer + try verifyCandidateApp(at: appInDMG) + + // 6. Replace the running app try replaceCurrentApp(with: appInDMG) - // 6. Detach the volume (best-effort) + // 7. Detach the volume (best-effort) detachDMG(mountPoint: mountPoint) - // 7. Relaunch + // 8. Relaunch await MainActor.run { self.installPhase = "Relaunching" self.installStatus = "Finishing update…" @@ -304,6 +310,43 @@ class UpdateService: ObservableObject { return appURL } + private func verifyCandidateApp(at appURL: URL) throws { + guard + let bundle = Bundle(url: appURL), + let bundleIdentifier = bundle.bundleIdentifier + else { + throw UpdateError.invalidCandidateApp( + "Downloaded update is missing a bundle identifier." + ) + } + + guard bundleIdentifier == Self.trustedUpdateBundleIdentifier else { + throw UpdateError.invalidCandidateApp( + "Downloaded update has an unexpected bundle identifier." + ) + } + + let staticCode = try Self.loadStaticCode(at: appURL) + let requirement = try Self.makeTrustedUpdateRequirement( + bundleIdentifier: Self.trustedUpdateBundleIdentifier, + teamIdentifier: Self.trustedUpdateTeamIdentifier + ) + + let validityStatus = SecStaticCodeCheckValidity(staticCode, SecCSFlags(), requirement) + guard validityStatus == errSecSuccess else { + throw UpdateError.signatureVerificationFailed + } + + let signingInfo = try Self.copySigningInfo(from: staticCode) + try Self.validateSigningInfo( + signingInfo, + expectedBundleIdentifier: Self.trustedUpdateBundleIdentifier, + expectedTeamIdentifier: Self.trustedUpdateTeamIdentifier + ) + + try verifyGatekeeperAcceptance(of: appURL) + } + private func replaceCurrentApp(with sourceApp: URL) throws { // Determine destination: where the current bundle lives let runningPath = Bundle.main.bundlePath @@ -318,6 +361,20 @@ class UpdateService: ObservableObject { try fm.copyItem(at: sourceApp, to: destURL) } + private func verifyGatekeeperAcceptance(of appURL: URL) throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/sbin/spctl") + proc.arguments = ["--assess", "--type", "execute", appURL.path] + proc.standardOutput = FileHandle.nullDevice + proc.standardError = Pipe() + try proc.run() + proc.waitUntilExit() + + guard proc.terminationStatus == 0 else { + throw UpdateError.gatekeeperAssessmentFailed + } + } + private func detachDMG(mountPoint: URL) { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") @@ -368,15 +425,91 @@ class UpdateService: ObservableObject { private static func byteString(_ bytes: Int64) -> String { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } + + static func trustedUpdateRequirementString( + bundleIdentifier: String, + teamIdentifier: String + ) -> String { + """ + identifier "\(bundleIdentifier)" and anchor apple generic and certificate leaf[subject.OU] = "\(teamIdentifier)" + """ + } + + static func validateSigningInfo( + _ signingInfo: [String: Any], + expectedBundleIdentifier: String, + expectedTeamIdentifier: String + ) throws { + guard + let signingIdentifier = signingInfo[kSecCodeInfoIdentifier as String] as? String, + signingIdentifier == expectedBundleIdentifier + else { + throw UpdateError.invalidCandidateApp( + "Downloaded update has an unexpected bundle identifier." + ) + } + + guard + let teamIdentifier = signingInfo[kSecCodeInfoTeamIdentifier as String] as? String, + teamIdentifier == expectedTeamIdentifier + else { + throw UpdateError.untrustedDeveloper + } + } + + private static func loadStaticCode(at appURL: URL) throws -> SecStaticCode { + var staticCode: SecStaticCode? + let status = SecStaticCodeCreateWithPath(appURL as CFURL, SecCSFlags(), &staticCode) + guard status == errSecSuccess, let staticCode else { + throw UpdateError.signatureVerificationFailed + } + return staticCode + } + + private static func makeTrustedUpdateRequirement( + bundleIdentifier: String, + teamIdentifier: String + ) throws -> SecRequirement { + var requirement: SecRequirement? + let status = SecRequirementCreateWithString( + trustedUpdateRequirementString( + bundleIdentifier: bundleIdentifier, + teamIdentifier: teamIdentifier + ) as CFString, + SecCSFlags(), + &requirement + ) + guard status == errSecSuccess, let requirement else { + throw UpdateError.signatureVerificationFailed + } + return requirement + } + + private static func copySigningInfo(from staticCode: SecStaticCode) throws -> [String: Any] { + var signingInfo: CFDictionary? + let status = SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &signingInfo + ) + guard status == errSecSuccess, let info = signingInfo as? [String: Any] else { + throw UpdateError.signatureVerificationFailed + } + return info + } } // MARK: - Errors -enum UpdateError: LocalizedError { +enum UpdateError: LocalizedError, Equatable { case mountFailed case appNotFoundInDMG case copyFailed(String) case verificationFailed + case invalidCandidateApp(String) + case signatureVerificationFailed + case untrustedDeveloper + case gatekeeperAssessmentFailed var errorDescription: String? { switch self { @@ -384,6 +517,13 @@ enum UpdateError: LocalizedError { case .appNotFoundInDMG: return "Could not find the app inside the downloaded update." case .copyFailed(let msg): return "Failed to install: \(msg)" case .verificationFailed: return "The downloaded update failed verification." + case .invalidCandidateApp(let message): return message + case .signatureVerificationFailed: + return "The downloaded update is not signed by the expected developer." + case .untrustedDeveloper: + return "The downloaded update was signed by an unexpected developer." + case .gatekeeperAssessmentFailed: + return "macOS rejected the downloaded update during Gatekeeper assessment." } } } diff --git a/speaktypeTests/UpdateServiceSecurityTests.swift b/speaktypeTests/UpdateServiceSecurityTests.swift new file mode 100644 index 0000000..d0d74bf --- /dev/null +++ b/speaktypeTests/UpdateServiceSecurityTests.swift @@ -0,0 +1,69 @@ +import Security +import XCTest +@testable import speaktype + +final class UpdateServiceSecurityTests: XCTestCase { + + func testTrustedUpdateRequirementStringPinsBundleAndTeam() { + let requirement = UpdateService.trustedUpdateRequirementString( + bundleIdentifier: "com.example.app", + teamIdentifier: "TEAM123456" + ) + + XCTAssertEqual( + requirement, + #"identifier "com.example.app" and anchor apple generic and certificate leaf[subject.OU] = "TEAM123456""# + ) + } + + func testValidateSigningInfoAcceptsMatchingIdentity() { + let signingInfo: [String: Any] = [ + kSecCodeInfoIdentifier as String: UpdateService.trustedUpdateBundleIdentifier, + kSecCodeInfoTeamIdentifier as String: UpdateService.trustedUpdateTeamIdentifier, + ] + + XCTAssertNoThrow( + try UpdateService.validateSigningInfo( + signingInfo, + expectedBundleIdentifier: UpdateService.trustedUpdateBundleIdentifier, + expectedTeamIdentifier: UpdateService.trustedUpdateTeamIdentifier + ) + ) + } + + func testValidateSigningInfoRejectsUnexpectedBundleIdentifier() { + let signingInfo: [String: Any] = [ + kSecCodeInfoIdentifier as String: "com.example.other", + kSecCodeInfoTeamIdentifier as String: UpdateService.trustedUpdateTeamIdentifier, + ] + + XCTAssertThrowsError( + try UpdateService.validateSigningInfo( + signingInfo, + expectedBundleIdentifier: UpdateService.trustedUpdateBundleIdentifier, + expectedTeamIdentifier: UpdateService.trustedUpdateTeamIdentifier + ) + ) { error in + guard case UpdateError.invalidCandidateApp = error else { + return XCTFail("Expected invalidCandidateApp, got \(error)") + } + } + } + + func testValidateSigningInfoRejectsUnexpectedTeamIdentifier() { + let signingInfo: [String: Any] = [ + kSecCodeInfoIdentifier as String: UpdateService.trustedUpdateBundleIdentifier, + kSecCodeInfoTeamIdentifier as String: "TEAM999999", + ] + + XCTAssertThrowsError( + try UpdateService.validateSigningInfo( + signingInfo, + expectedBundleIdentifier: UpdateService.trustedUpdateBundleIdentifier, + expectedTeamIdentifier: UpdateService.trustedUpdateTeamIdentifier + ) + ) { error in + XCTAssertEqual(error as? UpdateError, .untrustedDeveloper) + } + } +}