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) 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 "" "")"