diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24168af..03202c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,9 @@ jobs: if: runner.os != 'macOS' || matrix.arch != 'x86_64' shell: bash run: meson test -C build --list | grep -q aegisub-wrapper + - name: Check macOS release tests are enabled + if: runner.os == 'macOS' + run: meson test -C build --list | grep -q macos-release-tools - name: Test run: meson test -C build --print-errorlogs - name: Check binary architecture and runtime dependencies diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..0be878a --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,53 @@ +# macOS release signing + +Use the same Developer ID Application identity as Aegisub: its library +validation requires native Automation libraries to have the same Team ID. +These scripts use downloaded CI artifacts without rebuilding dependencies. +They require macOS, Xcode command-line tools, your signing certificate, and +a stored `notarytool` profile. Signing and notarization need network access. + +Check out the commit that produced the artifacts, then download both macOS +packages from its successful CI run: + +```sh +gh run download RUN_ID --name SubInspector-macos-arm64 --dir build/SubInspector-macos-arm64 +gh run download RUN_ID --name SubInspector-macos-x86_64 --dir build/SubInspector-macos-x86_64 + +export SUBINSPECTOR_SIGNATURE='Developer ID Application: Your Name (TEAMID)' +export SUBINSPECTOR_TEAM_ID=TEAMID +export SUBINSPECTOR_NOTARY_PROFILE=aegisub-notary +``` + +You can reuse Aegisub's existing notary profile. To create a new one, run +`xcrun notarytool store-credentials "$SUBINSPECTOR_NOTARY_PROFILE"` once. +For non-default keychains, set `SUBINSPECTOR_SIGNING_KEYCHAIN` and/or +`SUBINSPECTOR_NOTARY_KEYCHAIN`. `SUBINSPECTOR_NOTARY_TIMEOUT` defaults to `30m`. +Set `SUBINSPECTOR_TEAM_ID` to the 10-character `TeamIdentifier` shown by +`codesign --display --verbose=4 /path/to/Aegisub.app`. Notarization verifies +that the dylib is signed by that team before submitting it to Apple. + +```sh +for arch in arm64 x86_64; do + artifact="build/SubInspector-macos-$arch" + tools/osx-sign.sh "$artifact" + tools/osx-notarize.sh "$artifact" "$artifact.zip" +done +``` + +Signing verifies the downloaded checksums, signs the dylib with a secure +timestamp, and atomically replaces `SHA256SUMS`. The notarization script checks +the signed package and produces the requested ZIP only after Apple returns `Accepted`. +The ZIP's top-level folder matches its filename without `.zip`, regardless of +the input directory's name. It refuses to overwrite an existing output, including +one created while awaiting notarization. On failure it prints the submission +ID/status and requests Apple's log when an ID is available; rerun after fixing +the issue. `SUBINSPECTOR_SIGNATURE=-` permits local ad-hoc signing tests, but +those artifacts cannot pass the notarization script's Developer ID check. + +Test the signed dylibs through the wrapper in the signed Aegisub release. +Publish the accepted ZIPs and use the **signed** dylibs when updating the +DependencyControl feed's download URLs and hashes. Keep the wrapper version, +changelog, and dependency changes together with that feed update. + +Standalone dylibs and ZIPs cannot have notarization tickets stapled to them; +Apple records tickets online. See [Apple's notarization workflow](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow). diff --git a/readme.md b/readme.md index d5a8911..c6b9ce2 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,8 @@ Should you prefer a Visual Studio solution, just pass `--backend=vs` along with ### Help and Support +For macOS releases, see the [signing instructions](docs/releasing.md). + Talk to `CoffeeFlux` on `irc.rizon.net`. [libass]: https://github.com/libass/libass diff --git a/tests/meson.build b/tests/meson.build index d9e6db3..827b8e7 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -3,6 +3,13 @@ render_test = executable('render-test', 'render.c', link_with: subinspector) test('render', render_test) +if build_machine.system() == 'darwin' and host_machine.system() == 'darwin' + python = import('python').find_installation() + test('macos-release-tools', python, + args: [files('osx-release.py'), subinspector.full_path()], + depends: subinspector) +endif + if not meson.is_cross_build() luajit = find_program('luajit', required: false) moonc = find_program('moonc', required: false) diff --git a/tests/osx-release.py b/tests/osx-release.py new file mode 100644 index 0000000..e66cbff --- /dev/null +++ b/tests/osx-release.py @@ -0,0 +1,156 @@ +"""Exercise real ad-hoc signing and the release flow without Apple credentials.""" + +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +binary = Path(sys.argv.pop(1)).resolve() +tools = Path(__file__).resolve().parents[1] / 'tools' + + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='SubInspector signing ') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.artifact = self.root / 'macOS artifact' + self.artifact.mkdir() + shutil.copy2(binary, self.artifact / 'libSubInspector.dylib') + (self.artifact / 'COPYING').write_text('License fixture\n') + (self.artifact / 'licenses/libass').mkdir(parents=True) + (self.artifact / 'licenses/libass/COPYING').write_text('Nested license fixture\n') + with (self.artifact / 'SHA256SUMS').open('w') as checksums: + for name in ['COPYING', 'libSubInspector.dylib', 'licenses/libass/COPYING']: + digest = hashlib.sha256((self.artifact / name).read_bytes()).hexdigest() + checksums.write(f'{digest} {name}\n') + self.env = dict(os.environ, SUBINSPECTOR_SIGNATURE='-', + SUBINSPECTOR_TEAM_ID='TESTTEAM01', + SUBINSPECTOR_NOTARY_PROFILE='test-profile') + self.commands = self.root / 'commands' + self.commands.mkdir() + # Never contact Apple, even if a validation regression reaches xcrun. + (self.commands / 'xcrun').write_text('#!/bin/sh\necho "Unexpected notary call"\nexit 1\n') + (self.commands / 'xcrun').chmod(0o755) + self.env['PATH'] = str(self.commands) + os.pathsep + self.env['PATH'] + + def run_tool(self, name, *args): + return subprocess.run([tools / name, self.artifact, *args], env=self.env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + def sign(self): + result = self.run_tool('osx-sign.sh') + self.assertEqual(result.returncode, 0, result.stdout) + for line in (self.artifact / 'SHA256SUMS').read_text().splitlines(): + digest, name = line.split(' ', 1) + self.assertEqual(digest, hashlib.sha256((self.artifact / name).read_bytes()).hexdigest()) + + def test_sign_and_reject_adhoc_notarization(self): + self.sign() + output = self.root / 'release.zip' + result = self.run_tool('osx-notarize.sh', output) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(output.exists()) + self.assertNotIn('Unexpected notary call', result.stdout) + + def test_corrupt_download_is_not_signed(self): + original = (self.artifact / 'libSubInspector.dylib').read_bytes() + checksums = (self.artifact / 'SHA256SUMS').read_bytes() + (self.artifact / 'COPYING').write_text('Changed after download') + result = self.run_tool('osx-sign.sh') + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual(original, (self.artifact / 'libSubInspector.dylib').read_bytes()) + self.assertEqual(checksums, (self.artifact / 'SHA256SUMS').read_bytes()) + + def test_wrong_platform_is_rejected(self): + manifest = self.artifact / 'SHA256SUMS' + manifest.write_text(manifest.read_text().replace('libSubInspector.dylib', 'SubInspector.dll')) + for script, args in [('osx-sign.sh', []), ('osx-notarize.sh', [self.root / 'release.zip'])]: + with self.subTest(script=script): + result = self.run_tool(script, *args) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('Expected a macOS CI artifact', result.stdout) + self.assertNotIn('Unexpected notary call', result.stdout) + + def simulate_notary(self): + self.sign() + # Only simulate Developer ID validation and Apple's service. ditto, + # checksum verification, plist parsing, and output handling remain real. + commands = self.commands + (commands / 'codesign').write_text('''#!/usr/bin/env python3 +import sys +if '--verify' in sys.argv: + requirement = next(arg for arg in sys.argv if arg.startswith('-R=')) + if 'certificate leaf[subject.OU] = "TESTTEAM01"' not in requirement: + sys.exit('Wrong signing team') +else: + print('Timestamp=Sep 12, 2026') + print('CodeDirectory v=20500 flags=0x10000(runtime)') +''') + (commands / 'xcrun').write_text('''#!/usr/bin/env python3 +import hashlib, os, pathlib, plistlib, sys, zipfile +assert sys.argv[1] == 'notarytool' +assert sys.argv[sys.argv.index('--keychain-profile') + 1] == 'test-profile' +assert sys.argv[sys.argv.index('--keychain') + 1] == 'test keychain' +if sys.argv[2] == 'log': + print('Notary service diagnostic') +else: + assert sys.argv[2] == 'submit' + assert sys.argv[sys.argv.index('--timeout') + 1] == '1m' + archive = next(arg for arg in sys.argv if arg.endswith('.zip')) + with zipfile.ZipFile(archive) as package: + prefix = os.environ['TEST_PACKAGE_NAME'] + '/' + for line in package.read(prefix + 'SHA256SUMS').decode().splitlines(): + digest, name = line.split(' ', 1) + assert digest == hashlib.sha256(package.read(prefix + name)).hexdigest() + if os.environ.get('TEST_CONCURRENT_OUTPUT'): + pathlib.Path(os.environ['TEST_CONCURRENT_OUTPUT']).write_text('Created during notarization') + plistlib.dump({'id': 'test-submission', 'status': os.environ['TEST_STATUS']}, sys.stdout.buffer) + sys.exit(int(os.environ['TEST_EXIT'])) +''') + for command in commands.iterdir(): + command.chmod(0o755) + self.env.update(SUBINSPECTOR_NOTARY_KEYCHAIN='test keychain', + SUBINSPECTOR_NOTARY_TIMEOUT='1m') + + def test_notary_results(self): + self.simulate_notary() + for status, exit_code in [('Accepted', '0'), ('Invalid', '0'), ('In Progress', '1')]: + with self.subTest(status=status): + self.env.update(TEST_STATUS=status, TEST_EXIT=exit_code, TEST_PACKAGE_NAME=status) + output = self.root / f'{status}.zip' + result = self.run_tool('osx-notarize.sh', output) + accepted = status == 'Accepted' + self.assertEqual(result.returncode == 0, accepted, result.stdout) + self.assertEqual(output.exists(), accepted) + if accepted: + original = output.read_bytes() + self.assertNotEqual(self.run_tool('osx-notarize.sh', output).returncode, 0) + self.assertEqual(original, output.read_bytes()) + else: + self.assertIn('Notary service diagnostic', result.stdout) + + def test_wrong_team_is_rejected_before_submission(self): + self.simulate_notary() + self.env['SUBINSPECTOR_TEAM_ID'] = 'OTHERTEAM1' + result = self.run_tool('osx-notarize.sh', self.root / 'wrong-team.zip') + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('Wrong signing team', result.stdout) + self.assertFalse((self.root / 'wrong-team.zip').exists()) + + def test_output_created_during_notarization_is_preserved(self): + self.simulate_notary() + output = self.root / 'concurrent.zip' + self.env.update(TEST_STATUS='Accepted', TEST_EXIT='0', + TEST_PACKAGE_NAME='concurrent', TEST_CONCURRENT_OUTPUT=str(output)) + result = self.run_tool('osx-notarize.sh', output) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('appeared during notarization', result.stdout) + self.assertEqual(output.read_text(), 'Created during notarization') + + +unittest.main() diff --git a/tools/osx-notarize.sh b/tools/osx-notarize.sh new file mode 100755 index 0000000..e3e951b --- /dev/null +++ b/tools/osx-notarize.sh @@ -0,0 +1,102 @@ +#!/bin/sh +set -eu + +if test "$#" -ne 2; then + echo "Usage: $0 ARTIFACT_DIR OUTPUT_ZIP" >&2 + exit 1 +fi +if test -z "${SUBINSPECTOR_NOTARY_PROFILE:-}"; then + echo "Set SUBINSPECTOR_NOTARY_PROFILE to a notarytool Keychain profile" >&2 + exit 1 +fi +if ! printf '%s\n' "${SUBINSPECTOR_TEAM_ID:-}" | grep -Eq '^[A-Z0-9]{10}$'; then + echo "Set SUBINSPECTOR_TEAM_ID to Aegisub's 10-character signing Team ID" >&2 + exit 1 +fi + +ARTIFACT_DIR=$(cd "$1" && pwd) +OUTPUT_DIR=$(cd "$(dirname "$2")" && pwd) +OUTPUT_ZIP="${OUTPUT_DIR}/$(basename "$2")" +case "${OUTPUT_ZIP}" in + *.zip) ;; + *) echo "OUTPUT_ZIP must end in .zip" >&2; exit 1 ;; +esac +if test -e "${OUTPUT_ZIP}" || test -L "${OUTPUT_ZIP}"; then + echo "${OUTPUT_ZIP} already exists" >&2 + exit 1 +fi + +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/subinspector-notary.XXXXXX") +trap 'rm -rf "${WORK_DIR}"' EXIT +trap 'exit 1' HUP INT TERM + +# Validate a private copy, then submit exactly those files. +PACKAGE_DIR="${WORK_DIR}/$(basename "${OUTPUT_ZIP}" .zip)" +ditto "${ARTIFACT_DIR}" "${PACKAGE_DIR}" +( + cd "${PACKAGE_DIR}" + if ! grep -Eq '^[[:xdigit:]]{64} libSubInspector[.]dylib$' SHA256SUMS; then + echo "Expected a macOS CI artifact with libSubInspector.dylib in SHA256SUMS" >&2 + exit 1 + fi + shasum -a 256 -c SHA256SUMS + codesign --verify --strict --verbose=2 \ + -R="anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"${SUBINSPECTOR_TEAM_ID}\"" \ + libSubInspector.dylib + SIGNATURE=$(codesign --display --verbose=4 libSubInspector.dylib 2>&1) + if ! printf '%s\n' "${SIGNATURE}" | grep -q '^Timestamp='; then + echo "The dylib has no secure signing timestamp" >&2 + exit 1 + fi + if ! printf '%s\n' "${SIGNATURE}" | grep -q '^CodeDirectory .*flags=.*runtime'; then + echo "The dylib was not signed with --options runtime" >&2 + exit 1 + fi +) +ARCHIVE="${WORK_DIR}/submission.zip" +ditto -c -k --keepParent "${PACKAGE_DIR}" "${ARCHIVE}" + +run_notarytool() { + command=$1 + shift + if test -n "${SUBINSPECTOR_NOTARY_KEYCHAIN:-}"; then + xcrun notarytool "${command}" --keychain-profile "${SUBINSPECTOR_NOTARY_PROFILE}" \ + --keychain "${SUBINSPECTOR_NOTARY_KEYCHAIN}" "$@" + else + xcrun notarytool "${command}" --keychain-profile "${SUBINSPECTOR_NOTARY_PROFILE}" "$@" + fi +} + +RESULT="${WORK_DIR}/result.plist" +if run_notarytool submit "${ARCHIVE}" --wait \ + --timeout "${SUBINSPECTOR_NOTARY_TIMEOUT:-30m}" --output-format plist > "${RESULT}"; then + SUBMIT_EXIT=0 +else + SUBMIT_EXIT=$? +fi +if test -s "${RESULT}"; then + plutil -p "${RESULT}" || cat "${RESULT}" +fi +SUBMISSION_ID=$(plutil -extract id raw -o - "${RESULT}" 2>/dev/null || true) +STATUS=$(plutil -extract status raw -o - "${RESULT}" 2>/dev/null || true) +if test "${SUBMIT_EXIT}" -ne 0 || test "${STATUS}" != Accepted; then + if test -n "${SUBMISSION_ID}"; then + run_notarytool log "${SUBMISSION_ID}" || true + fi + echo "Notarization failed with status ${STATUS:-unknown}" >&2 + exit 1 +fi + +# Apple issues tickets for dylibs, but neither dylibs nor ZIPs support stapling. +# The destination may have appeared while we waited for Apple. +if test -e "${OUTPUT_ZIP}" || test -L "${OUTPUT_ZIP}"; then + echo "${OUTPUT_ZIP} appeared during notarization; leaving it untouched" >&2 + exit 1 +fi +mv -n "${ARCHIVE}" "${OUTPUT_ZIP}" +if test -e "${ARCHIVE}"; then + echo "Could not publish ${OUTPUT_ZIP} without overwriting an existing file" >&2 + exit 1 +fi +shasum -a 256 "${OUTPUT_ZIP}" +echo "Notarized ${OUTPUT_ZIP}" diff --git a/tools/osx-sign.sh b/tools/osx-sign.sh new file mode 100755 index 0000000..3297a67 --- /dev/null +++ b/tools/osx-sign.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +if test "$#" -ne 1; then + echo "Usage: $0 ARTIFACT_DIR" >&2 + exit 1 +fi +if test -z "${SUBINSPECTOR_SIGNATURE:-}"; then + echo "Set SUBINSPECTOR_SIGNATURE to Aegisub's Developer ID identity (or '-' for an ad-hoc test)" >&2 + exit 1 +fi + +cd "$1" +LIBRARY=libSubInspector.dylib +if ! grep -Eq '^[[:xdigit:]]{64} libSubInspector[.]dylib$' SHA256SUMS; then + echo "Expected a macOS CI artifact with libSubInspector.dylib in SHA256SUMS" >&2 + exit 1 +fi +shasum -a 256 -c SHA256SUMS + +if test "${SUBINSPECTOR_SIGNATURE}" = '-'; then + codesign --force --sign - "${LIBRARY}" +else + set -- --force --options runtime --timestamp --sign "${SUBINSPECTOR_SIGNATURE}" + if test -n "${SUBINSPECTOR_SIGNING_KEYCHAIN:-}"; then + set -- "$@" --keychain "${SUBINSPECTOR_SIGNING_KEYCHAIN}" + fi + codesign "$@" "${LIBRARY}" +fi +codesign --verify --strict --verbose=2 "${LIBRARY}" + +# Signing changes only the dylib. Preserve the checksums of the other files. +CHECKSUMS=$(mktemp './.SHA256SUMS.XXXXXX') +trap 'rm -f "${CHECKSUMS}"' EXIT +trap 'exit 1' HUP INT TERM +SIGNED_CHECKSUM=$(shasum -a 256 "${LIBRARY}") +awk -v signed="${SIGNED_CHECKSUM}" ' + $2 == "libSubInspector.dylib" { $0 = signed } + { print } +' SHA256SUMS > "${CHECKSUMS}" +chmod 644 "${CHECKSUMS}" +mv -f "${CHECKSUMS}" SHA256SUMS +shasum -a 256 -c SHA256SUMS +echo "Signed ${LIBRARY} and updated SHA256SUMS"