diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5876788..81d6e07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,12 @@ jobs: run: | # Releases must keep the same signing identity, or users lose Keychain trust on update. if [[ "$IS_TAG_RELEASE" == "true" ]]; then - : "${CODESIGN_P12_BASE64:?Set the CODESIGN_P12_BASE64 repository secret}" - : "${CODESIGN_P12_PASSWORD:?Set the CODESIGN_P12_PASSWORD repository secret}" + for name in CODESIGN_P12_BASE64 CODESIGN_P12_PASSWORD; do + if [[ -z "${!name:-}" ]]; then + echo "::error::Add the $name repository secret before tagging a release" + exit 1 + fi + done fi ./scripts/build-dmg.sh "${{ steps.version.outputs.value }}" @@ -72,6 +76,8 @@ jobs: fi - name: Mirror release to Cloudflare R2 + # Manual runs only produce a workflow artifact; publishing would move latest/version.txt. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} diff --git a/Sources/LLMitsApp/UpdateInstaller.swift b/Sources/LLMitsApp/UpdateInstaller.swift index 69e8ee9..7ed623c 100644 --- a/Sources/LLMitsApp/UpdateInstaller.swift +++ b/Sources/LLMitsApp/UpdateInstaller.swift @@ -2,6 +2,7 @@ import AppKit import CryptoKit import Foundation import LLMitsCore +import Security enum UpdateInstallError: LocalizedError { case requiresAppBundle @@ -9,6 +10,7 @@ enum UpdateInstallError: LocalizedError { case invalidDownload case invalidChecksum case invalidApp + case untrustedSignature case commandFailed(String) var errorDescription: String? { @@ -18,6 +20,7 @@ enum UpdateInstallError: LocalizedError { case .invalidDownload: "The update download failed. Please try again." case .invalidChecksum: "The update download did not match its published checksum." case .invalidApp: "The downloaded app could not be verified." + case .untrustedSignature: "The update was not signed by the LLMits release certificate." case let .commandFailed(command): "The update could not complete: \(command)." } } @@ -74,6 +77,10 @@ enum UpdateInstaller { guard FileManager.default.fileExists(atPath: mountedApp.path) else { throw UpdateInstallError.invalidApp } try await run("/usr/bin/ditto", [mountedApp.path, stagedApp.path]) try await run("/usr/bin/codesign", ["--verify", "--deep", "--strict", stagedApp.path]) + if let requirement = designatedRequirement(of: currentApp).flatMap(pinnedRequirement(forDesignated:)), + !satisfies(stagedApp, requirement: requirement) { + throw UpdateInstallError.untrustedSignature + } guard let bundle = Bundle(url: stagedApp), bundle.bundleIdentifier == "com.llmits.app", let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String, @@ -96,6 +103,34 @@ enum UpdateInstaller { return PreparedUpdate(stagedApp: stagedApp, targetApp: currentApp, helper: helper) } + /// A certificate-signed app only accepts updates from the same certificate. Ad-hoc builds are pinned to + /// their own code hash, which no update can match, so they keep the basic signature check. + /// Moving to a new certificate (for example, Developer ID) needs one bridge release, signed with the + /// current certificate, whose accepted requirement also allows the new one. + static func pinnedRequirement(forDesignated requirement: String) -> String? { + requirement.contains("certificate") ? requirement : nil + } + + static func designatedRequirement(of url: URL) -> String? { + var code: SecStaticCode? + var requirement: SecRequirement? + var text: CFString? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, let code, + SecCodeCopyDesignatedRequirement(code, [], &requirement) == errSecSuccess, let requirement, + SecRequirementCopyString(requirement, [], &text) == errSecSuccess else { return nil } + return text as String? + } + + static func satisfies(_ url: URL, requirement text: String) -> Bool { + var code: SecStaticCode? + var requirement: SecRequirement? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, let code, + SecRequirementCreateWithString(text as CFString, [], &requirement) == errSecSuccess, let requirement else { + return false + } + return SecStaticCodeCheckValidity(code, [], requirement) == errSecSuccess + } + private static func expectedChecksum(in text: String) -> String? { let fields = text.split(whereSeparator: \.isWhitespace) guard fields.count == 2, fields[1] == "LLMits.dmg", fields[0].count == 64, diff --git a/Tests/LLMitsAppTests/UpdateSignatureTests.swift b/Tests/LLMitsAppTests/UpdateSignatureTests.swift new file mode 100644 index 0000000..f2a61b6 --- /dev/null +++ b/Tests/LLMitsAppTests/UpdateSignatureTests.swift @@ -0,0 +1,40 @@ +import Foundation +import XCTest +@testable import LLMitsApp + +final class UpdateSignatureTests: XCTestCase { + private let releaseRequirement = #"identifier "com.llmits.app" and certificate root = H"ad1c6cab05feb10232c97e561047c7fd13d0f924""# + + func testCertificateRequirementIsPinned() { + XCTAssertEqual(UpdateInstaller.pinnedRequirement(forDesignated: releaseRequirement), releaseRequirement) + } + + func testAdHocRequirementIsNotPinned() { + XCTAssertNil(UpdateInstaller.pinnedRequirement(forDesignated: #"cdhash H"0123456789abcdef0123456789abcdef01234567""#)) + } + + func testAdHocSignedBinarySatisfiesOnlyItsOwnRequirement() throws { + let binary = try adHocSignedBinary() + defer { try? FileManager.default.removeItem(at: binary.deletingLastPathComponent()) } + + let own = try XCTUnwrap(UpdateInstaller.designatedRequirement(of: binary)) + XCTAssertNil(UpdateInstaller.pinnedRequirement(forDesignated: own)) + XCTAssertTrue(UpdateInstaller.satisfies(binary, requirement: own)) + XCTAssertFalse(UpdateInstaller.satisfies(binary, requirement: releaseRequirement)) + } + + private func adHocSignedBinary() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("LLMitsSignature-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + let binary = directory.appendingPathComponent("fixture") + try FileManager.default.copyItem(at: URL(fileURLWithPath: "/usr/bin/true"), to: binary) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--force", "--sign", "-", "--identifier", "com.llmits.fixture", binary.path] + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + return binary + } +} diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index f9c61e5..4263f2d 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -37,7 +37,10 @@ SIGNING_ARGS=() if [[ -n "${CODESIGN_P12_BASE64:-}" || -n "${CODESIGN_P12_PATH:-}" ]]; then # Import the stable signing identity into a throwaway keychain so every release keeps the same # designated requirement and macOS Keychain trust survives updates. - : "${CODESIGN_P12_PASSWORD:?Set CODESIGN_P12_PASSWORD for the signing certificate}" + if [[ -z "${CODESIGN_P12_PASSWORD:-}" ]]; then + echo "error: the signing certificate needs its passphrase in CODESIGN_P12_PASSWORD" >&2 + exit 1 + fi SIGNING_DIR="$(mktemp -d)" SIGNING_KEYCHAIN="$SIGNING_DIR/signing.keychain-db" SIGNING_KEYCHAIN_PASSWORD="$(openssl rand -hex 16)"