Skip to content
Merged
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
10 changes: 8 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"

Expand All @@ -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 }}
Expand Down
35 changes: 35 additions & 0 deletions Sources/LLMitsApp/UpdateInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import AppKit
import CryptoKit
import Foundation
import LLMitsCore
import Security

enum UpdateInstallError: LocalizedError {
case requiresAppBundle
case destinationNotWritable
case invalidDownload
case invalidChecksum
case invalidApp
case untrustedSignature
case commandFailed(String)

var errorDescription: String? {
Expand All @@ -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)."
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions Tests/LLMitsAppTests/UpdateSignatureTests.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 4 additions & 1 deletion scripts/build-dmg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down