From ee58355e6e2447ed4dd162d79dfba879fdb0390a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 19 Jun 2026 10:42:06 -0400 Subject: [PATCH] fix(ci): sign macOS Mach-O natives embedded in bundled jars before notarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.12.2 release was the first to actually codesign + notarize the macOS artifacts (signing was wired after v1.12.1, which shipped unsigned). Both macOS legs failed with "Notarization status: Invalid": Apple's notary service recurses into the bundled jars and rejects the unsigned Mach-O natives inside them (secp256k1, sqlite-bundled, jna, skiko, jkeychain, kdroidFilter mediaplayer) — codesign on the .app and the CLI's loose-file loop never descend into jars. Notary log confirmed the offending entries, e.g. sqlite-bundled-jvm.jar/natives/osx_arm64/libsqliteJni.dylib -> "not signed with a valid Developer ID certificate" / "no secure timestamp" Add scripts/sign-macos-jar-natives.sh: a shared helper that signs every macOS Mach-O inside the bundled jars with hardened runtime + a secure timestamp, skipping Linux ELF via a `file` Mach-O gate and no-opping when no identity is set (local/PR builds unchanged). Wire it into: - the CLI notarize step (runs before the loose-file signing loop) - a desktop signMacJarNatives Gradle task that signs the proguarded jars between proguardReleaseJars and createReleaseDistributable, so Compose seals already-signed code. Validated locally on arm64: clean signed createReleaseDistributable produces an .app that passes `codesign --verify --deep --strict`, with every nested native carrying Developer ID + hardened runtime + secure timestamp. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/create-release.yml | 15 +++-- desktopApp/build.gradle.kts | 36 ++++++++++++ scripts/sign-macos-jar-natives.sh | 86 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100755 scripts/sign-macos-jar-natives.sh diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index e923e01ac2..5bcae99532 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -326,7 +326,12 @@ jobs: set -euo pipefail IMG="cli/build/amy-image/amy" ENTITLEMENTS="cli/packaging/macos/amy.entitlements" - # Sign every Mach-O binary in the bundled JRE. Each is signed + # First sign the macOS Mach-O natives buried INSIDE the bundled jars + # (secp256k1/jna/sqlite/skiko/jkeychain/mediaplayer). The loose-file + # loop below can't see them, but Apple's notary recurses into jars and + # rejects any unsigned Mach-O — so this must run before notarize. + SIGN_IDENTITY="$SIGN_IDENTITY" scripts/sign-macos-jar-natives.sh "$IMG" + # Sign every loose Mach-O binary in the bundled JRE. Each is signed # independently (no enclosing .app seals them), so order is irrelevant. # Executables get the hardened-runtime entitlements; dylibs don't. while IFS= read -r f; do @@ -341,10 +346,10 @@ jobs: done < <(find "$IMG" -type f) codesign --verify --strict --verbose=2 "$IMG/runtime/bin/java" # Notarize: zip the signed image, submit, wait for Apple's verdict. - # The notary service recursively inspects the lib/*.jar files, so any - # unsigned Mach-O embedded in them (secp256k1/jna/sqlite/skiko natives) - # can come back Invalid. Surface the per-file log so the first real run - # is diagnostic rather than a bare failure. + # The notary service recursively inspects the lib/*.jar files; their + # embedded Mach-O natives are signed by sign-macos-jar-natives.sh + # above. Surface the per-file log on any non-Accepted verdict so a + # regression is diagnostic rather than a bare failure. ZIP="$RUNNER_TEMP/amy-notarize.zip" OUT="$RUNNER_TEMP/notary-submit.json" ditto -c -k --keepParent "$IMG" "$ZIP" diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 830fbb5f1f..95ca8ab8df 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -355,3 +355,39 @@ listOf( dependsOn(verifyJkeychainNativeSurvivesProguard) } } + +// macOS notarization: the bundled jars (secp256k1, jna, sqlite-bundled, skiko, +// jkeychain, kdroidFilter mediaplayer) carry Mach-O natives that Compose's +// .app codesign never reaches — codesign doesn't descend into jars, but Apple's +// notary service does and rejects any unsigned Mach-O ("Invalid"). Sign those +// natives inside the *proguarded* jars (the exact artifacts Compose then copies +// into the .app) so the subsequent bundle signing seals already-signed code. +// Runs only on macOS with the Developer ID identity exported — a no-op on every +// other leg and on unsigned local/PR builds. +val signMacJarNatives by tasks.registering { + description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized" + group = "build" + dependsOn("proguardReleaseJars") + val proguardDir = layout.buildDirectory.dir("compose/tmp/main-release/proguard") + val script = rootProject.file("scripts/sign-macos-jar-natives.sh") + val identity = providers.environmentVariable("AMETHYST_MAC_SIGN_IDENTITY") + val isMac = System.getProperty("os.name").lowercase().contains("mac") + onlyIf { isMac && !identity.orNull.isNullOrBlank() } + doLast { + val proc = + ProcessBuilder("bash", script.absolutePath, proguardDir.get().asFile.absolutePath) + .redirectErrorStream(true) + .also { it.environment()["SIGN_IDENTITY"] = identity.get() } + .start() + proc.inputStream.bufferedReader().forEachLine { println(it) } + val code = proc.waitFor() + if (code != 0) throw GradleException("sign-macos-jar-natives.sh failed with exit code $code") + } +} + +// Must run after ProGuard writes the jars and before Compose assembles + signs +// the .app, so wire it onto createReleaseDistributable (which the DMG packaging +// depends on transitively). +tasks.matching { it.name == "createReleaseDistributable" }.configureEach { + dependsOn(signMacJarNatives) +} diff --git a/scripts/sign-macos-jar-natives.sh b/scripts/sign-macos-jar-natives.sh new file mode 100755 index 0000000000..2074fb4cad --- /dev/null +++ b/scripts/sign-macos-jar-natives.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# sign-macos-jar-natives.sh — codesign the macOS Mach-O native libraries that +# ship *inside* bundled jars (jna, secp256k1, sqlite-bundled, skiko, jkeychain, +# kdroidFilter mediaplayer, …). +# +# Why this exists: `codesign` on an .app bundle and a `find … -type f` signing +# loop both only see *loose* Mach-O files — neither descends into `.jar` +# archives. Apple's notary service, however, recursively inspects every jar and +# rejects any unsigned Mach-O inside it ("Notarization status: Invalid"). So we +# unzip each native, sign it with hardened runtime + a secure timestamp, and +# write it back into the jar. Run this BEFORE the enclosing .app/tarball is +# sealed and notarized. +# +# Usage: +# SIGN_IDENTITY="Developer ID Application: NAME (TEAMID)" \ +# scripts/sign-macos-jar-natives.sh [ ...] +# +# Idempotent: re-signing an already-signed native just replaces the signature. +# No-op (exit 0) when SIGN_IDENTITY is empty, so unsigned local/PR builds are +# unaffected — exactly like the rest of the release signing path. +set -euo pipefail + +IDENTITY="${SIGN_IDENTITY:-}" +if [ -z "$IDENTITY" ]; then + echo "sign-macos-jar-natives: SIGN_IDENTITY unset — skipping (unsigned build)." + exit 0 +fi + +if [ "$#" -lt 1 ]; then + echo "usage: SIGN_IDENTITY=… $0 [ ...]" >&2 + exit 2 +fi + +# `jar` (from the JDK) updates a single entry in place without rewriting the +# rest of the archive, so we don't disturb the manifest or other entries. +JAR_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}jar" +command -v "$JAR_BIN" >/dev/null 2>&1 || JAR_BIN="jar" + +signed_total=0 +jars_touched=0 + +sign_one_jar() { + local jar="$1" + # Candidate native entries by extension. The `file` check below is the real + # gate — it filters out the many Linux ELF `.so` siblings (jna, sqlite, …) + # and catches macOS Mach-O hiding behind a `.so` name (jkeychain's + # osxkeychain.so). zipinfo -1 lists entry paths only. + local entries + entries="$(zipinfo -1 "$jar" 2>/dev/null | grep -iE '\.(dylib|jnilib|so)$' || true)" + [ -n "$entries" ] || return 0 + + local work jar_abs dirty=0 + work="$(mktemp -d)" + jar_abs="$(cd "$(dirname "$jar")" && pwd)/$(basename "$jar")" + + while IFS= read -r entry; do + [ -n "$entry" ] || continue + ( cd "$work" && unzip -o -q "$jar_abs" "$entry" ) + local f="$work/$entry" + [ -f "$f" ] || continue + case "$(file -b "$f")" in + *Mach-O*) + codesign --force --options runtime --timestamp --sign "$IDENTITY" "$f" >/dev/null 2>&1 + codesign --verify --strict "$f" + ( cd "$work" && "$JAR_BIN" uf "$jar_abs" "$entry" ) + echo " signed: $(basename "$jar") :: $entry" + signed_total=$((signed_total + 1)) + dirty=1 + ;; + esac + done <<< "$entries" + + [ "$dirty" -eq 1 ] && jars_touched=$((jars_touched + 1)) + rm -rf "$work" +} + +for dir in "$@"; do + [ -d "$dir" ] || { echo " (skip, not a dir: $dir)"; continue; } + echo "Scanning for jar-embedded macOS natives under: $dir" + while IFS= read -r jar; do + sign_one_jar "$jar" + done < <(find "$dir" -type f -name '*.jar') +done + +echo "sign-macos-jar-natives: signed $signed_total native(s) across $jars_touched jar(s)."