From d7ecaf8fe3815443ae53e7009e8cb677cc9edca2 Mon Sep 17 00:00:00 2001 From: Mathias Kowoll Date: Thu, 22 Oct 2026 16:56:37 -0300 Subject: [PATCH 1/2] Verify the CrossOver license against CodeWeavers' key, not whatever key the bundle carries The license was checked with the key inside the CrossOver bundle, so a copied bundle with its own key would verify a license signed with that key. The key is now accepted only if its SHA-256 is the one CrossOver 26.3 and the 2026 Previews ship. Code signatures would not do: patched CrossOver copies no longer pass them, and the key file is the same either way. Co-Authored-By: Claude Opus 5.5 --- .../NotProtonApp/Model/CrossOverLicense.swift | 23 +++++++++-- .../CrossOverLicenseTests.swift | 38 ++++++++++++++----- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/app/Sources/NotProtonApp/Model/CrossOverLicense.swift b/app/Sources/NotProtonApp/Model/CrossOverLicense.swift index d5432c9e..60dad6a1 100644 --- a/app/Sources/NotProtonApp/Model/CrossOverLicense.swift +++ b/app/Sources/NotProtonApp/Model/CrossOverLicense.swift @@ -24,6 +24,13 @@ enum CrossOverLicense { private static let licenseBase = "com.codeweavers.CrossOver" + // SHA-256 of the key CrossOver verifies its own license with (share/crossover/data/tie.pub), + // the same in 26.3 and the 2026 Previews. The bundle's copy is trusted only if it is this + // one: a key swapped into a copied bundle would otherwise verify a license signed with it. + static let codeWeaversKeys: Set = [ + "de88c936ee0fe6233314168a4651833abef7ef374ff798fa1c0b548a525a3686", + ] + static let notActivatedTitle = String(localized: "CrossOver does not appear to be activated.") static let trialUnsupported = String(localized: "The trial version is not supported.") static let notActivatedAdvice = "\(String(localized: "Please run CrossOver and try again.")) \(trialUnsupported)" @@ -34,10 +41,11 @@ enum CrossOverLicense { static func check( crossOverRoot: URL, searchDirs: [URL] = defaultSearchDirs, - openssl: String = defaultOpenssl + openssl: String = defaultOpenssl, + keys: Set = codeWeaversKeys ) -> Status { let status = evaluate( - crossOverRoot: crossOverRoot, searchDirs: searchDirs, openssl: openssl + crossOverRoot: crossOverRoot, searchDirs: searchDirs, openssl: openssl, keys: keys ) AppLog.note("license: \(status.diagnostic)") return status @@ -46,7 +54,8 @@ enum CrossOverLicense { private static func evaluate( crossOverRoot: URL, searchDirs: [URL], - openssl: String + openssl: String, + keys: Set ) -> Status { let keyFile = crossOverRoot.appending( path: "share/crossover/data/tie.pub" @@ -60,6 +69,14 @@ enum CrossOverLicense { ) } + guard let key = Digest.sha256IfPresent(URL(filePath: keyFile)), keys.contains(key) else { + return Status( + licensed: false, + detail: notActivated, + diagnostic: "the verification key in the CrossOver bundle is not CodeWeavers'" + ) + } + var rejection: String? for dir in searchDirs { diff --git a/app/Tests/NotProtonAppTests/CrossOverLicenseTests.swift b/app/Tests/NotProtonAppTests/CrossOverLicenseTests.swift index ae5e7f00..23e265cb 100644 --- a/app/Tests/NotProtonAppTests/CrossOverLicenseTests.swift +++ b/app/Tests/NotProtonAppTests/CrossOverLicenseTests.swift @@ -20,6 +20,9 @@ struct CrossOverLicenseTests { var root: URL { SupportPaths.crossOverRoot(inBundle: bundle) } var publishedKey: URL { root.appending(path: "share/crossover/data/tie.pub") } var searchDirs: [URL] { [local, system] } + // The fixture's own key stands in for CodeWeavers', read when asked so a test that + // rewrites the key is checked against what is there. + var keys: Set { Set([Digest.sha256IfPresent(publishedKey)].compactMap { $0 }) } func remove() { try? FileManager.default.removeItem(at: dir) } } @@ -101,10 +104,24 @@ struct CrossOverLicenseTests { let license = try Self.writeLicense(in: fix.local) try Self.sign(license, with: fix.key, digest: "-sha256", sidecar: "sha256") - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(status.licensed) } + // A copied bundle can carry any key, and a license signed with it would verify. + @Test("A license signed with a key that is not CodeWeavers' is refused") + func swappedKeyIsRefused() throws { + let fix = try Self.fixture() + defer { fix.remove() } + let license = try Self.writeLicense(in: fix.local) + try Self.sign(license, with: fix.key, digest: "-sha256", sidecar: "sha256") + + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + #expect(!status.licensed) + #expect(status.diagnostic == "the verification key in the CrossOver bundle is not CodeWeavers'") + #expect(status.detail == CrossOverLicense.notActivated) + } + @Test("A bundle carrying no verification key is named in the diagnostic") func missingKeyIsNamed() throws { let fix = try Self.fixture(withKey: false) @@ -112,7 +129,7 @@ struct CrossOverLicenseTests { let license = try Self.writeLicense(in: fix.local) try Self.sign(license, with: fix.key, digest: "-sha256", sidecar: "sha256") - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(!status.licensed) #expect(status.diagnostic == "no verification key in the CrossOver bundle") #expect(status.detail == CrossOverLicense.notActivated) @@ -125,7 +142,7 @@ struct CrossOverLicenseTests { let fix = try Self.fixture() defer { fix.remove() } - let absent = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let absent = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(!absent.licensed) #expect(absent.diagnostic == "no CrossOver license file found") #expect(absent.detail == CrossOverLicense.notActivated) @@ -135,7 +152,7 @@ struct CrossOverLicenseTests { try Self.generate(key: other, publishing: nil) try Self.sign(license, with: other, digest: "-sha256", sidecar: "sha256") - let wrong = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let wrong = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(!wrong.licensed) #expect(wrong.diagnostic.contains("verified against this bundle")) #expect(wrong.diagnostic != absent.diagnostic) @@ -148,7 +165,7 @@ struct CrossOverLicenseTests { defer { fix.remove() } try Self.writeLicense(in: fix.local) - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(!status.licensed) #expect(status.diagnostic.contains("has no signature beside it")) #expect(status.detail == CrossOverLicense.notActivated) @@ -166,7 +183,7 @@ struct CrossOverLicenseTests { to: fix.local.appending(path: "\(Self.base).sha256") ) - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(status.licensed) } @@ -177,7 +194,7 @@ struct CrossOverLicenseTests { let license = try Self.writeLicense(in: fix.system) try Self.sign(license, with: fix.key, digest: "-sha256", sidecar: "sha256") - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(status.licensed) #expect(status.diagnostic.contains(fix.system.path(percentEncoded: false))) } @@ -194,7 +211,8 @@ struct CrossOverLicenseTests { let status = CrossOverLicense.check( crossOverRoot: fix.root, searchDirs: fix.searchDirs, - openssl: "/usr/bin/notproton-no-such-openssl" + openssl: "/usr/bin/notproton-no-such-openssl", + keys: fix.keys ) #expect(!status.licensed) #expect(status.diagnostic.contains("could not run")) @@ -235,7 +253,7 @@ struct CrossOverLicenseTests { try Self.sign(license, with: fix.key, digest: "-sha256", sidecar: "sha256") try Data("not a key".utf8).write(to: fix.publishedKey) - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(!status.licensed) #expect(status.diagnostic == "the verification key in the CrossOver bundle could not be read") #expect(status.detail == CrossOverLicense.notActivated) @@ -254,7 +272,7 @@ struct CrossOverLicenseTests { .path(percentEncoded: false) ) - let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs) + let status = CrossOverLicense.check(crossOverRoot: fix.root, searchDirs: fix.searchDirs, keys: fix.keys) #expect(!status.licensed) #expect(status.diagnostic.contains("could not be checked")) #expect(status.detail == CrossOverLicense.notActivated) From 9c9343cdcdb1d1809ecd882140b65e7206d22d1c Mon Sep 17 00:00:00 2001 From: Mathias Kowoll Date: Thu, 22 Oct 2026 16:58:50 -0300 Subject: [PATCH 2/2] Check the CrossOver license again when a game starts CrossOver does not check its license when its Wine is run directly, and the app only checks it before it sets a runner up, so a runner copied to a Mac without a licensed CrossOver would still start games. The run script now verifies the license the same way at each launch, with the runner's key trusted only if it is CodeWeavers', and stops with the app's own message. Co-Authored-By: Claude Opus 5.5 --- dylib/feats/compat_run.sh | 31 +++++++++++++++++++++++++++++++ dylib/tests/buildcheck.sh | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index cdcf82df..d397186a 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -950,6 +950,37 @@ if [ -z "$np_build" ] || [ ! -d "$CX_ROOT/lib/wine" ]; then fi echo "runner: build $np_build ($np_display) at $CX_ROOT" >> "$log" 2>&1 || true +# CrossOver does not check its license when its Wine is run directly, so a runner copied to a +# Mac without a licensed CrossOver would start games too. This is the check the app makes before +# it sets a runner up, made again at each launch: the license in the preferences, verified with +# the runner's key, which is only trusted if it is CodeWeavers' (by SHA-256, as the app does). +np_license_keys="de88c936ee0fe6233314168a4651833abef7ef374ff798fa1c0b548a525a3686" +crossover_licensed() { + key="$CX_ROOT/share/crossover/data/tie.pub" + digest=$(/usr/bin/shasum -a 256 "$key" 2>/dev/null) || return 1 + case " $np_license_keys " in + *" ${digest%% *} "*) ;; + *) return 1 ;; + esac + for dir in "$HOME/Library/Preferences" /Library/Preferences; do + license="$dir/com.codeweavers.CrossOver.license" + [ -f "$license" ] || continue + for pair in sha256:-sha256 sig:-sha1; do + signature="$dir/com.codeweavers.CrossOver.${pair%%:*}" + [ -f "$signature" ] || continue + /usr/bin/openssl dgst "${pair#*:}" -verify "$key" -signature "$signature" "$license" \ + >/dev/null 2>&1 && return 0 + done + done + return 1 +} +stage_step="license check" +if ! crossover_licensed; then + echo "=== no CrossOver license verifies with CodeWeavers' key, run CrossOver to activate it ===" >> "$log" 2>&1 || true + show_alert "CrossOver does not appear to be activated." "Please run CrossOver and try again. The trial version is not supported." + exit 1 +fi + # Each CrossOver build needs its own template. # FEX builds need two, one for FEX/arm64 Wine and one for Rosetta/AMD64 Wine runner_id="" diff --git a/dylib/tests/buildcheck.sh b/dylib/tests/buildcheck.sh index e6966879..ab316c80 100644 --- a/dylib/tests/buildcheck.sh +++ b/dylib/tests/buildcheck.sh @@ -60,6 +60,37 @@ launch() { [ ! -f "$data/notproton-build" ] || head -1 "$data/notproton-build" } +echo "== CrossOver license ==" +license_fn=$(extract crossover_licensed) +[ -n "$license_fn" ] || { echo "FAIL: crossover_licensed not found in $SRC"; exit 1; } +lic="$work/license" +prefs="$lic/home/Library/Preferences" +mkdir -p "$prefs" "$lic/cx/share/crossover/data" +openssl genrsa -out "$lic/private.pem" 2048 2>/dev/null +openssl rsa -in "$lic/private.pem" -pubout -out "$lic/cx/share/crossover/data/tie.pub" 2>/dev/null +printf '[crossmac]\ncustomer=Test\n' > "$prefs/com.codeweavers.CrossOver.license" +ours=$(shasum -a 256 "$lic/cx/share/crossover/data/tie.pub" | cut -d' ' -f1) +licensed() { + ( + HOME="$lic/home" + CX_ROOT="$lic/cx" + np_license_keys=$1 + eval "$license_fn" + crossover_licensed + ) && echo yes || echo no +} +is "a license with no signature beside it is refused" no "$(licensed "$ours")" +openssl dgst -sha256 -sign "$lic/private.pem" -out "$prefs/com.codeweavers.CrossOver.sha256" \ + "$prefs/com.codeweavers.CrossOver.license" +is "a license the accepted key verifies passes" yes "$(licensed "$ours")" +is "the same license is refused when the key is not one CodeWeavers ships" no "$(licensed 0000)" +printf 'customer=Someone else\n' >> "$prefs/com.codeweavers.CrossOver.license" +is "an edited license is refused" no "$(licensed "$ours")" +app_keys=$(sed -n 's/^ *"\([0-9a-f]\{64\}\)",$/\1/p' \ + "$(dirname "$SRC")/../../app/Sources/NotProtonApp/Model/CrossOverLicense.swift") +is "the run script and the app trust the same key" "$app_keys" \ + "$(sed -n 's/^np_license_keys="\(.*\)"$/\1/p' "$SRC")" + echo "== prefixes from before the record ==" is "a fresh prefix is claimed" "status=0 26.3.0.39832" "$(launch 26.3.0.39832 "" "")"