Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 144 additions & 4 deletions speaktype/Services/UpdateService.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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…"
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -368,22 +425,105 @@ 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 {
case .mountFailed: return "Failed to mount the update disk image."
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."
}
}
}
69 changes: 69 additions & 0 deletions speaktypeTests/UpdateServiceSecurityTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}