Redline updater hardening: signed-update verification, atomic install + revert, update visibility, notarization pipeline (MMDB-2681) #24
@@ -1,6 +1,7 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
/// Built-in updater. Checks an appcast, stages a verified payload, and installs
|
/// Built-in updater. Checks an appcast, stages a verified payload, and installs
|
||||||
/// only when the user clicks the menu row — never automatically.
|
/// only when the user clicks the menu row — never automatically.
|
||||||
@@ -10,22 +11,31 @@ final class UpdateChecker {
|
|||||||
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
|
static let defaultAppcastURL = URL(string: "https://get.baobab-ts.com/cowork/redline/appcast.json")!
|
||||||
static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
|
static let defaultInstallTarget = URL(fileURLWithPath: "/Applications/Redline.app")
|
||||||
|
|
||||||
|
/// Required bundle identifier for any staged or installed payload.
|
||||||
|
static let expectedBundleIdentifier = "ai.flowmaster.shotdeck"
|
||||||
|
/// Developer team identifiers MMD ships Redline under. Overridable only for the self-test.
|
||||||
|
static let allowedTeamIdentifiers: Set<String> = ["PWMCBMX5M8", "L3N9S54CN3"]
|
||||||
|
|
||||||
private(set) var availableUpdate: (version: String, notes: String)?
|
private(set) var availableUpdate: (version: String, notes: String)?
|
||||||
private(set) var stagedAppURL: URL?
|
private(set) var stagedAppURL: URL?
|
||||||
private(set) var statusMessage: String?
|
private(set) var statusMessage: String?
|
||||||
|
private(set) var lastCheckedAt: Date?
|
||||||
|
private(set) var isCheckingNow: Bool = false
|
||||||
|
|
||||||
var onChecked: (() -> Void)?
|
var onChecked: (() -> Void)?
|
||||||
|
/// Fired whenever `isCheckingNow` flips, so a UI can show "Checking…" for the
|
||||||
|
/// whole duration of a check rather than only after it lands.
|
||||||
|
var onCheckingChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
private let urlSession: URLSession
|
private let urlSession: URLSession
|
||||||
private var repeatingTimer: Timer?
|
private var repeatingTimer: Timer?
|
||||||
private var firstCheckTask: Task<Void, Never>?
|
private var firstCheckTask: Task<Void, Never>?
|
||||||
private var isChecking = false
|
|
||||||
private var stagingDirectory: URL?
|
private var stagingDirectory: URL?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let config = URLSessionConfiguration.ephemeral
|
let config = URLSessionConfiguration.ephemeral
|
||||||
config.timeoutIntervalForRequest = 15
|
config.timeoutIntervalForRequest = 30
|
||||||
config.timeoutIntervalForResource = 15
|
config.timeoutIntervalForResource = 600
|
||||||
config.httpCookieAcceptPolicy = .never
|
config.httpCookieAcceptPolicy = .never
|
||||||
config.httpShouldSetCookies = false
|
config.httpShouldSetCookies = false
|
||||||
config.httpCookieStorage = nil
|
config.httpCookieStorage = nil
|
||||||
@@ -51,10 +61,18 @@ final class UpdateChecker {
|
|||||||
repeatingTimer = timer
|
repeatingTimer = timer
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkNow() async {
|
/// Checks the appcast and stages a newer, signature-verified payload.
|
||||||
guard !isChecking else { return }
|
/// `manual` only affects the status message shown when already up to date —
|
||||||
isChecking = true
|
/// a user-initiated check says so; the silent background check stays quiet.
|
||||||
defer { isChecking = false }
|
func checkNow(manual: Bool = false) async {
|
||||||
|
guard !isCheckingNow else { return }
|
||||||
|
isCheckingNow = true
|
||||||
|
onCheckingChanged?(true)
|
||||||
|
defer {
|
||||||
|
isCheckingNow = false
|
||||||
|
onCheckingChanged?(false)
|
||||||
|
}
|
||||||
|
lastCheckedAt = Date()
|
||||||
|
|
||||||
let appcast: Appcast
|
let appcast: Appcast
|
||||||
do {
|
do {
|
||||||
@@ -67,7 +85,7 @@ final class UpdateChecker {
|
|||||||
|
|
||||||
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
|
guard Self.isNewer(appcast.version, than: Self.currentVersion()) else {
|
||||||
clearOffer()
|
clearOffer()
|
||||||
statusMessage = nil
|
statusMessage = manual ? "Redline \(Self.currentVersion()) is up to date." : nil
|
||||||
onChecked?()
|
onChecked?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,6 +98,10 @@ final class UpdateChecker {
|
|||||||
discardStaging()
|
discardStaging()
|
||||||
availableUpdate = nil
|
availableUpdate = nil
|
||||||
statusMessage = "Update file failed the checksum — not installed."
|
statusMessage = "Update file failed the checksum — not installed."
|
||||||
|
} catch UpdateCheckError.signatureInvalid {
|
||||||
|
discardStaging()
|
||||||
|
availableUpdate = nil
|
||||||
|
statusMessage = "Update is not signed by MMD — not installed."
|
||||||
} catch {
|
} catch {
|
||||||
discardStaging()
|
discardStaging()
|
||||||
availableUpdate = nil
|
availableUpdate = nil
|
||||||
@@ -88,9 +110,9 @@ final class UpdateChecker {
|
|||||||
onChecked?()
|
onChecked?()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies the staged app onto `target` with ditto (in place; never deletes the old app).
|
/// Installs the staged app onto `target` atomically, keeping exactly one rollback
|
||||||
/// Relaunches unless `SHOTDECK_UPDATE_SELFTEST` is set, so the in-process self-test
|
/// copy (`Redline.app.previous`), then hands off to a relaunch and quits.
|
||||||
/// can assert the installed Info.plist without killing the process.
|
/// Never deletes the old app before the new one is verified in place.
|
||||||
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
|
func installStaged(to target: URL = UpdateChecker.defaultInstallTarget) {
|
||||||
guard let staged = stagedAppURL else {
|
guard let staged = stagedAppURL else {
|
||||||
statusMessage = "No update is staged."
|
statusMessage = "No update is staged."
|
||||||
@@ -98,29 +120,118 @@ final class UpdateChecker {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let targetDir = target.deletingLastPathComponent()
|
||||||
|
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true)
|
||||||
at: target.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true
|
let replacementDir = try FileManager.default.url(
|
||||||
|
for: .itemReplacementDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: target,
|
||||||
|
create: true
|
||||||
)
|
)
|
||||||
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, target.path])
|
defer { try? FileManager.default.removeItem(at: replacementDir) }
|
||||||
|
|
||||||
|
let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent)
|
||||||
|
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [staged.path, newCopy.path])
|
||||||
|
|
||||||
|
// Exactly one rollback copy is kept — drop any older one before this install.
|
||||||
|
if FileManager.default.fileExists(atPath: previousURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: previousURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: target.path) {
|
||||||
|
_ = try FileManager.default.replaceItemAt(
|
||||||
|
target,
|
||||||
|
withItemAt: newCopy,
|
||||||
|
backupItemName: previousURL.lastPathComponent,
|
||||||
|
options: [.withoutDeletingBackupItem]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try FileManager.default.moveItem(at: newCopy, to: target)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "The update could not be installed."
|
statusMessage = "The update could not be installed."
|
||||||
onChecked?()
|
onChecked?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let isSelfTest = ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] != nil
|
// Defense in depth: re-verify what actually landed on disk, not just the staged copy.
|
||||||
if isSelfTest { return }
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try Self.runProcess(executable: "/usr/bin/open", arguments: ["-n", target.path])
|
try Self.verifySignature(of: target)
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
|
statusMessage = "The update was installed but failed verification."
|
||||||
onChecked?()
|
onChecked?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
NSApp.terminate(nil)
|
|
||||||
|
discardStaging()
|
||||||
|
availableUpdate = nil
|
||||||
|
relaunch(target: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swaps `Redline.app.previous` back into place, verifying its signature first.
|
||||||
|
/// The just-replaced (newer) app becomes the new `.previous` — a revert is
|
||||||
|
/// itself reversible.
|
||||||
|
func revertToPrevious(target: URL = UpdateChecker.defaultInstallTarget) {
|
||||||
|
let targetDir = target.deletingLastPathComponent()
|
||||||
|
let previousURL = targetDir.appendingPathComponent("Redline.app.previous")
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: previousURL.path) else {
|
||||||
|
statusMessage = "No previous version to revert to."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try Self.verifySignature(of: previousURL)
|
||||||
|
} catch {
|
||||||
|
statusMessage = "The previous version failed verification and was not restored."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// `previousURL` cannot be handed to replaceItemAt directly: its own path
|
||||||
|
// IS the requested backup name, so the backup step would clobber it
|
||||||
|
// before the swap ever reads it. Stage a throwaway copy first, exactly
|
||||||
|
// like installStaged does for the forward direction.
|
||||||
|
let replacementDir = try FileManager.default.url(
|
||||||
|
for: .itemReplacementDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: target,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: replacementDir) }
|
||||||
|
|
||||||
|
let newCopy = replacementDir.appendingPathComponent(target.lastPathComponent)
|
||||||
|
try Self.runProcess(executable: "/usr/bin/ditto", arguments: [previousURL.path, newCopy.path])
|
||||||
|
try FileManager.default.removeItem(at: previousURL)
|
||||||
|
|
||||||
|
_ = try FileManager.default.replaceItemAt(
|
||||||
|
target,
|
||||||
|
withItemAt: newCopy,
|
||||||
|
backupItemName: previousURL.lastPathComponent,
|
||||||
|
options: [.withoutDeletingBackupItem]
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
statusMessage = "Could not revert to the previous version."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relaunch(target: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version recorded in `Redline.app.previous`'s Info.plist, or nil when no
|
||||||
|
/// rollback copy exists.
|
||||||
|
func previousVersion(target: URL = UpdateChecker.defaultInstallTarget) -> String? {
|
||||||
|
let previousURL = target.deletingLastPathComponent().appendingPathComponent("Redline.app.previous")
|
||||||
|
let plistURL = previousURL.appendingPathComponent("Contents/Info.plist")
|
||||||
|
guard let plist = NSDictionary(contentsOf: plistURL) as? [String: Any] else { return nil }
|
||||||
|
return plist["CFBundleShortVersionString"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func resolvedAppcastURL() -> URL {
|
static func resolvedAppcastURL() -> URL {
|
||||||
@@ -156,6 +267,67 @@ final class UpdateChecker {
|
|||||||
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates the code signature of the app at `appURL`: strictly, across all
|
||||||
|
/// architectures and nested code, then checks its bundle identifier and team
|
||||||
|
/// identifier against `expectedBundleIdentifier` / the allowed-teams set.
|
||||||
|
/// `REDLINE_ALLOWED_TEAMS` (comma separated) overrides the allowed set — for
|
||||||
|
/// the self-test only, so it can accept a locally re-signed fake bundle.
|
||||||
|
static func verifySignature(of appURL: URL) throws {
|
||||||
|
var staticCode: SecStaticCode?
|
||||||
|
let createStatus = SecStaticCodeCreateWithPath(appURL as CFURL, [], &staticCode)
|
||||||
|
guard createStatus == errSecSuccess, let code = staticCode else {
|
||||||
|
throw UpdateCheckError.signatureInvalid(
|
||||||
|
"could not read a code signature (status \(createStatus))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let validityFlags = SecCSFlags(
|
||||||
|
rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode
|
||||||
|
)
|
||||||
|
var validityError: Unmanaged<CFError>?
|
||||||
|
let validityStatus = SecStaticCodeCheckValidityWithErrors(code, validityFlags, nil, &validityError)
|
||||||
|
guard validityStatus == errSecSuccess else {
|
||||||
|
let detail = (validityError?.takeRetainedValue()).map { String(describing: $0) } ?? "status \(validityStatus)"
|
||||||
|
throw UpdateCheckError.signatureInvalid("signature is not valid: \(detail)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var signingInfo: CFDictionary?
|
||||||
|
let infoStatus = SecCodeCopySigningInformation(
|
||||||
|
code,
|
||||||
|
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||||
|
&signingInfo
|
||||||
|
)
|
||||||
|
guard infoStatus == errSecSuccess, let info = signingInfo as? [String: Any] else {
|
||||||
|
throw UpdateCheckError.signatureInvalid("could not read signing information (status \(infoStatus))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let identifier = info[kSecCodeInfoIdentifier as String] as? String
|
||||||
|
guard identifier == expectedBundleIdentifier else {
|
||||||
|
throw UpdateCheckError.signatureInvalid(
|
||||||
|
"unexpected bundle identifier: \(identifier ?? "nil")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let teamIdentifier = info[kSecCodeInfoTeamIdentifier as String] as? String
|
||||||
|
guard let teamIdentifier, resolvedAllowedTeamIdentifiers().contains(teamIdentifier) else {
|
||||||
|
throw UpdateCheckError.signatureInvalid(
|
||||||
|
"unexpected team identifier: \(teamIdentifier ?? "nil")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolvedAllowedTeamIdentifiers() -> Set<String> {
|
||||||
|
if let env = ProcessInfo.processInfo.environment["REDLINE_ALLOWED_TEAMS"], !env.isEmpty {
|
||||||
|
let parts = env.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if !parts.isEmpty {
|
||||||
|
return Set(parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowedTeamIdentifiers
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private struct Appcast: Decodable {
|
private struct Appcast: Decodable {
|
||||||
@@ -170,6 +342,7 @@ final class UpdateChecker {
|
|||||||
case invalidPayload
|
case invalidPayload
|
||||||
case httpStatus(Int)
|
case httpStatus(Int)
|
||||||
case processFailed(String)
|
case processFailed(String)
|
||||||
|
case signatureInvalid(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchAppcast() async throws -> Appcast {
|
private func fetchAppcast() async throws -> Appcast {
|
||||||
@@ -219,6 +392,7 @@ final class UpdateChecker {
|
|||||||
guard FileManager.default.fileExists(atPath: executable.path) else {
|
guard FileManager.default.fileExists(atPath: executable.path) else {
|
||||||
throw UpdateCheckError.invalidPayload
|
throw UpdateCheckError.invalidPayload
|
||||||
}
|
}
|
||||||
|
try Self.verifySignature(of: appURL)
|
||||||
stagedAppURL = appURL
|
stagedAppURL = appURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +409,35 @@ final class UpdateChecker {
|
|||||||
stagedAppURL = nil
|
stagedAppURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns a detached watcher that waits for this process to exit, then reopens
|
||||||
|
/// `target`, and quits. Never called during the self-test, so the in-process
|
||||||
|
/// assertions after `installStaged`/`revertToPrevious` can still run.
|
||||||
|
private func relaunch(target: URL) {
|
||||||
|
guard ProcessInfo.processInfo.environment["SHOTDECK_UPDATE_SELFTEST"] == nil else { return }
|
||||||
|
|
||||||
|
let ownPID = ProcessInfo.processInfo.processIdentifier
|
||||||
|
let script = "while kill -0 \(ownPID) 2>/dev/null; do sleep 0.2; done; " +
|
||||||
|
"/usr/bin/open -n \(Self.shellQuoted(target.path))"
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||||
|
process.arguments = ["-c", script]
|
||||||
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
process.standardOutput = FileHandle.nullDevice
|
||||||
|
process.standardError = FileHandle.nullDevice
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
statusMessage = "The update was installed but Redline could not relaunch. Open it from Applications."
|
||||||
|
onChecked?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NSApp.terminate(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellQuoted(_ path: String) -> String {
|
||||||
|
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||||
|
}
|
||||||
|
|
||||||
private static func findRedlineApp(in directory: URL) -> URL? {
|
private static func findRedlineApp(in directory: URL) -> URL? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
let direct = directory.appendingPathComponent("Redline.app")
|
let direct = directory.appendingPathComponent("Redline.app")
|
||||||
|
|||||||
Reference in New Issue
Block a user