diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 34797b9..1430bca 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -19,17 +19,19 @@ concurrency: jobs: package: name: macOS ARM64 self-contained package + if: ${{ github.ref == 'refs/heads/main' && github.ref_protected && inputs.publish_authorized_artifact }} + environment: macos-release runs-on: macos-26 - timeout-minutes: 90 + timeout-minutes: 150 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 with: fetch-depth: 0 submodules: recursive - name: Enforce Object-form artifact authorization - if: ${{ inputs.publish_authorized_artifact && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED != 'true' }} + if: ${{ vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED != 'true' || vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED_SHA != github.sha }} run: | - echo "Artifact upload requires CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED=true" >&2 + echo "Artifact upload requires repository authorization for this exact commit" >&2 exit 1 - name: Verify ARM64 runner run: test "$(uname -m)" = arm64 @@ -61,12 +63,23 @@ jobs: cache-dependency-path: apps/frontend/package-lock.json - name: Install pinned Tauri CLI run: cargo install tauri-cli --version 2.8.4 --locked + - name: Configure Developer ID signing and notarization + env: + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MAC_CERTS: ${{ secrets.MAC_CERTS }} + MAC_CERTS_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }} + run: ./scripts/configure-macos-signing.sh - name: Build and verify self-contained package run: make macos-package + - name: Remove ephemeral signing keychain + if: ${{ always() }} + run: ./scripts/cleanup-macos-signing.sh - name: Add package manifest to summary run: cat target/macos-package/BUILD-MANIFEST.txt >> "$GITHUB_STEP_SUMMARY" - name: Upload authorized package - if: ${{ inputs.publish_authorized_artifact && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED == 'true' }} + if: ${{ vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED == 'true' && vars.CHAT2DB_OBJECT_DISTRIBUTION_AUTHORIZED_SHA == github.sha }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: Chat2DB-Rust-macOS-arm64-${{ github.sha }} diff --git a/apps/chat2db-desktop/tauri.package.conf.json b/apps/chat2db-desktop/tauri.package.conf.json index 14b3f5e..af2ec38 100644 --- a/apps/chat2db-desktop/tauri.package.conf.json +++ b/apps/chat2db-desktop/tauri.package.conf.json @@ -18,7 +18,7 @@ }, "macOS": { "minimumSystemVersion": "12.0", - "hardenedRuntime": false + "hardenedRuntime": true } } } diff --git a/scripts/build-macos-package.sh b/scripts/build-macos-package.sh index 66a43c7..afc8516 100755 --- a/scripts/build-macos-package.sh +++ b/scripts/build-macos-package.sh @@ -7,11 +7,24 @@ build_target="${CHAT2DB_MACOS_BUILD_TARGET:-${repository_root}/target/macos-pack app_path="${build_target}/release/bundle/macos/Chat2DB Rust.app" package_directory="${repository_root}/target/macos-package" staging_directory="" +notary_directory="" +verification_directory="" +dmg_mounted=false cleanup() { + if [[ "${dmg_mounted}" == true && -n "${verification_directory}" ]]; then + hdiutil detach "${verification_directory}" >/dev/null 2>&1 || true + dmg_mounted=false + fi if [[ -n "${staging_directory}" && -d "${staging_directory}" ]]; then rm -rf -- "${staging_directory}" fi + if [[ -n "${notary_directory}" && -d "${notary_directory}" ]]; then + rm -rf -- "${notary_directory}" + fi + if [[ -n "${verification_directory}" && -d "${verification_directory}" ]]; then + rm -rf -- "${verification_directory}" + fi } trap cleanup EXIT @@ -111,12 +124,119 @@ if [[ ! -d "${app_path}" || -L "${app_path}" ]]; then fi signing_identity="${APPLE_SIGNING_IDENTITY:--}" +signing_keychain="${CHAT2DB_SIGNING_KEYCHAIN:-}" +notary_profile="${CHAT2DB_NOTARY_KEYCHAIN_PROFILE:-}" +expected_team_id="${APPLE_TEAM_ID:-}" +notarization_enabled=false +notarization_status="not-submitted" +distribution_status="internal-test-only" + +if [[ -n "${notary_profile}" ]]; then + if [[ "${signing_identity}" == "-" ]]; then + echo "notarization requires a Developer ID signing identity" >&2 + exit 1 + fi + if [[ -z "${signing_keychain}" || "${signing_keychain}" != /* || ! -f "${signing_keychain}" || -L "${signing_keychain}" ]]; then + echo "notarization requires a safe signing keychain" >&2 + exit 1 + fi + if [[ -z "${expected_team_id}" ]]; then + echo "notarization requires APPLE_TEAM_ID" >&2 + exit 1 + fi + notarization_enabled=true +fi + if [[ "${signing_identity}" == "-" ]]; then codesign --force --deep --sign - --timestamp=none "${app_path}" + "${repository_root}/scripts/verify-macos-package.sh" "${app_path}" else - codesign --force --deep --options runtime --timestamp --sign "${signing_identity}" "${app_path}" + # Tauri owns the only Developer ID signing pass so nested runtime + # entitlements and signatures are not destroyed by a deep re-sign. + CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE=true \ + APPLE_TEAM_ID="${expected_team_id}" \ + "${repository_root}/scripts/verify-macos-package.sh" "${app_path}" + distribution_status="developer-id-signed" +fi + +notarize_artifact() { + local artifact_path="$1" + xcrun notarytool submit "${artifact_path}" \ + --keychain-profile "${notary_profile}" \ + --keychain "${signing_keychain}" \ + --wait \ + --timeout 45m +} + +verify_developer_id_signature() { + local artifact_path="$1" + local artifact_kind="$2" + local signature_details + local signing_team_id + local signing_authority + local signing_timestamp + local designated_requirement + + codesign --verify --strict --verbose=2 "${artifact_path}" + signature_details="$(codesign -dv --verbose=4 "${artifact_path}" 2>&1)" + signing_team_id="$(awk -F= '/^TeamIdentifier=/ { print $2; exit }' <<<"${signature_details}")" + signing_authority="$(awk -F= '/^Authority=/ { print $2; exit }' <<<"${signature_details}")" + signing_timestamp="$(awk -F= '/^Timestamp=/ { print $2; exit }' <<<"${signature_details}")" + designated_requirement="$(codesign -d -r- "${artifact_path}" 2>&1)" + + if [[ "${signing_authority}" != Developer\ ID\ Application:* ]]; then + echo "${artifact_kind} is not signed by a Developer ID Application identity" >&2 + exit 1 + fi + if [[ -z "${signing_team_id}" || "${signing_team_id}" == "not set" ]]; then + echo "${artifact_kind} is missing a TeamIdentifier" >&2 + exit 1 + fi + if [[ -n "${expected_team_id}" && "${signing_team_id}" != "${expected_team_id}" ]]; then + echo "${artifact_kind} TeamIdentifier does not match APPLE_TEAM_ID" >&2 + exit 1 + fi + if [[ -z "${signing_timestamp}" || "${signing_timestamp}" == "none" ]]; then + echo "${artifact_kind} is missing a trusted timestamp" >&2 + exit 1 + fi + if [[ "${designated_requirement}" == *"cdhash"* ]]; then + echo "${artifact_kind} still has a build-specific cdhash requirement" >&2 + exit 1 + fi +} + +verify_packaged_app() { + local packaged_app="$1" + if [[ "${signing_identity}" == "-" ]]; then + "${repository_root}/scripts/verify-macos-package.sh" "${packaged_app}" + else + CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE=true \ + APPLE_TEAM_ID="${expected_team_id}" \ + "${repository_root}/scripts/verify-macos-package.sh" "${packaged_app}" + fi + if [[ "${notarization_enabled}" == true ]]; then + xcrun stapler validate "${packaged_app}" + spctl --assess --type execute --verbose=4 "${packaged_app}" + fi +} + +if [[ "${notarization_enabled}" == true ]]; then + notary_directory="$(mktemp -d "${target_root}/.chat2db-notary.XXXXXX")" + notary_app_zip="${notary_directory}/Chat2DB-Rust.app.zip" + ditto -c -k --sequesterRsrc --keepParent "${app_path}" "${notary_app_zip}" + notarize_artifact "${notary_app_zip}" + xcrun stapler staple "${app_path}" + xcrun stapler validate "${app_path}" + spctl --assess --type execute --verbose=4 "${app_path}" + rm -rf -- "${notary_directory}" + notary_directory="" + CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE=true \ + APPLE_TEAM_ID="${expected_team_id}" \ + "${repository_root}/scripts/verify-macos-package.sh" "${app_path}" + notarization_status="accepted" + distribution_status="developer-id-notarized" fi -"${repository_root}/scripts/verify-macos-package.sh" "${app_path}" version="$(awk ' /^\[workspace.package\]$/ { in_package = 1; next } @@ -148,6 +268,12 @@ zip_path="${package_directory}/${artifact_base}.app.zip" dmg_path="${package_directory}/${artifact_base}.dmg" ditto -c -k --sequesterRsrc --keepParent "${app_path}" "${zip_path}" +verification_directory="$(mktemp -d "${target_root}/.chat2db-zip-verify.XXXXXX")" +ditto -x -k "${zip_path}" "${verification_directory}" +verify_packaged_app "${verification_directory}/Chat2DB Rust.app" +rm -rf -- "${verification_directory}" +verification_directory="" + staging_directory="$(mktemp -d "${repository_root}/target/.macos-dmg.staging.XXXXXX")" ditto "${app_path}" "${staging_directory}/Chat2DB Rust.app" ln -s /Applications "${staging_directory}/Applications" @@ -160,6 +286,32 @@ hdiutil create \ rm -rf -- "${staging_directory}" staging_directory="" +if [[ "${signing_identity}" != "-" ]]; then + if [[ -n "${signing_keychain}" ]]; then + codesign --force --sign "${signing_identity}" --keychain "${signing_keychain}" --timestamp "${dmg_path}" + else + codesign --force --sign "${signing_identity}" --timestamp "${dmg_path}" + fi + verify_developer_id_signature "${dmg_path}" "macOS DMG" +fi + +if [[ "${notarization_enabled}" == true ]]; then + notarize_artifact "${dmg_path}" + xcrun stapler staple "${dmg_path}" + xcrun stapler validate "${dmg_path}" + spctl --assess --type open --context context:primary-signature --verbose=4 "${dmg_path}" +fi +hdiutil verify "${dmg_path}" + +verification_directory="$(mktemp -d "${target_root}/.chat2db-dmg-verify.XXXXXX")" +hdiutil attach -readonly -nobrowse -mountpoint "${verification_directory}" "${dmg_path}" +dmg_mounted=true +verify_packaged_app "${verification_directory}/Chat2DB Rust.app" +hdiutil detach "${verification_directory}" +dmg_mounted=false +rm -rf -- "${verification_directory}" +verification_directory="" + ( cd "${package_directory}" shasum -a 256 -- "$(basename "${zip_path}")" "$(basename "${dmg_path}")" > SHA256SUMS @@ -167,6 +319,11 @@ staging_directory="" git_commit="$(git -C "${repository_root}" rev-parse HEAD)" community_commit="$(git -C "${repository_root}/third_party/chat2db-community" rev-parse HEAD)" app_kib="$(du -sk "${app_path}" | awk '{ print $1 }')" +signature_details="$(codesign -dv --verbose=4 "${app_path}" 2>&1)" +signing_team_id="$(awk -F= '/^TeamIdentifier=/ { print $2; exit }' <<<"${signature_details}")" +signing_authority="$(awk -F= '/^Authority=/ { print $2; exit }' <<<"${signature_details}")" +signing_team_id="${signing_team_id:-none}" +signing_authority="${signing_authority:-adhoc}" cat > "${package_directory}/BUILD-MANIFEST.txt" <&2 + exit 1 +fi +case "${keychain_path}" in + "${RUNNER_TEMP}"/chat2db-signing-*.keychain-db) ;; + *) + echo "refusing to clean unexpected signing keychain: ${keychain_path}" >&2 + exit 1 + ;; +esac + +remaining_keychains=() +while IFS= read -r existing_keychain; do + existing_keychain="${existing_keychain//\"/}" + existing_keychain="${existing_keychain#"${existing_keychain%%[![:space:]]*}"}" + existing_keychain="${existing_keychain%"${existing_keychain##*[![:space:]]}"}" + if [[ -n "${existing_keychain}" && "${existing_keychain}" != "${keychain_path}" ]]; then + remaining_keychains+=("${existing_keychain}") + fi +done < <(security list-keychains -d user) +if [[ "${#remaining_keychains[@]}" -gt 0 ]]; then + security list-keychains -d user -s "${remaining_keychains[@]}" +fi +security delete-keychain "${keychain_path}" >/dev/null 2>&1 || true +rm -f -- "${keychain_path}" +echo "Removed ephemeral macOS signing keychain" diff --git a/scripts/community-frontend.lock.json b/scripts/community-frontend.lock.json index 9583e89..adedabd 100644 --- a/scripts/community-frontend.lock.json +++ b/scripts/community-frontend.lock.json @@ -2,7 +2,7 @@ "repository": "https://github.com/OtterMind/Chat2DB.git", "submodulePath": "third_party/chat2db-community", "sourcePath": "chat2db-community-client", - "commit": "1c650f0e8a61d80b6b570e2cdcfc9c1b01f2a4e4", - "tree": "b08643418e5dd71a1ba4c955f16278a96e604939", + "commit": "390109c5bcf2fd249617fe62733ead43ccf85611", + "tree": "91d424a75687cf11ff5c4bda1297f6c2d7cd0a40", "packageManager": "yarn@1.22.22" } diff --git a/scripts/community-frontend.mjs b/scripts/community-frontend.mjs index ec98a05..68a8aa2 100644 --- a/scripts/community-frontend.mjs +++ b/scripts/community-frontend.mjs @@ -197,6 +197,11 @@ function test() { runYarn(['test:ai-model-select'], worktree); runYarn(['test:export-connections'], worktree); runYarn(['test:host-file-transfer'], worktree); + runYarn(['test:canvas-lifecycle'], worktree); + runYarn(['test:result-error-boundary'], worktree); + runYarn(['test:result-resource-activity'], worktree); + runYarn(['test:result-table-lifecycle'], worktree); + runYarn(['test:workspace-resource-activity'], worktree); } function build() { diff --git a/scripts/configure-macos-signing.sh b/scripts/configure-macos-signing.sh new file mode 100755 index 0000000..bdf1c63 --- /dev/null +++ b/scripts/configure-macos-signing.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS signing configuration requires Darwin" >&2 + exit 1 +fi + +for variable in \ + MAC_CERTS \ + MAC_CERTS_PASSWORD \ + APPLE_ID \ + APPLE_APP_SPECIFIC_PASSWORD \ + APPLE_TEAM_ID \ + RUNNER_TEMP \ + GITHUB_ENV; do + if [[ -z "${!variable:-}" ]]; then + echo "required signing environment variable is missing: ${variable}" >&2 + exit 1 + fi +done + +if [[ "${RUNNER_TEMP}" != /* || ! -d "${RUNNER_TEMP}" || -L "${RUNNER_TEMP}" ]]; then + echo "refusing to use unsafe runner temp directory: ${RUNNER_TEMP}" >&2 + exit 1 +fi +if [[ ! -f "${GITHUB_ENV}" || -L "${GITHUB_ENV}" ]]; then + echo "refusing to use unsafe GitHub environment file: ${GITHUB_ENV}" >&2 + exit 1 +fi + +run_id="${GITHUB_RUN_ID:-manual}" +run_attempt="${GITHUB_RUN_ATTEMPT:-1}" +keychain_path="${RUNNER_TEMP}/chat2db-signing-${run_id}-${run_attempt}.keychain-db" +certificate_path="$(mktemp "${RUNNER_TEMP}/chat2db-signing.XXXXXX")" +notary_profile="chat2db-notary-${run_id}-${run_attempt}" +keychain_created=false +configured=false + +cleanup() { + rm -f -- "${certificate_path}" + if [[ "${configured}" != true && "${keychain_created}" == true ]]; then + security delete-keychain "${keychain_path}" >/dev/null 2>&1 || true + rm -f -- "${keychain_path}" + fi +} +trap cleanup EXIT + +case "${keychain_path}" in + "${RUNNER_TEMP}"/chat2db-signing-*.keychain-db) ;; + *) + echo "refusing to create unexpected signing keychain: ${keychain_path}" >&2 + exit 1 + ;; +esac +if [[ -e "${keychain_path}" || -L "${keychain_path}" ]]; then + echo "signing keychain already exists: ${keychain_path}" >&2 + exit 1 +fi + +printf '%s' "${MAC_CERTS}" | tr -d '\r\n ' | /usr/bin/base64 -D > "${certificate_path}" +chmod 600 "${certificate_path}" +if [[ ! -s "${certificate_path}" ]]; then + echo "decoded signing certificate is empty" >&2 + exit 1 +fi + +keychain_password="$(openssl rand -hex 32)" +security create-keychain -p "${keychain_password}" "${keychain_path}" +keychain_created=true +security set-keychain-settings -lut 21600 "${keychain_path}" +security unlock-keychain -p "${keychain_password}" "${keychain_path}" +security import "${certificate_path}" \ + -k "${keychain_path}" \ + -P "${MAC_CERTS_PASSWORD}" \ + -f pkcs12 \ + -T /usr/bin/codesign \ + -T /usr/bin/security +security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "${keychain_password}" \ + "${keychain_path}" >/dev/null + +existing_keychains=() +while IFS= read -r existing_keychain; do + existing_keychain="${existing_keychain//\"/}" + existing_keychain="${existing_keychain#"${existing_keychain%%[![:space:]]*}"}" + existing_keychain="${existing_keychain%"${existing_keychain##*[![:space:]]}"}" + if [[ -n "${existing_keychain}" && "${existing_keychain}" != "${keychain_path}" ]]; then + existing_keychains+=("${existing_keychain}") + fi +done < <(security list-keychains -d user) +security list-keychains -d user -s "${keychain_path}" "${existing_keychains[@]}" + +identity_output="$(security find-identity -v -p codesigning "${keychain_path}")" +signing_identity="$(awk '/\"Developer ID Application:/ { print $2; exit }' <<<"${identity_output}")" +if [[ -z "${signing_identity}" ]]; then + echo "the imported archive does not contain a valid Developer ID Application identity" >&2 + printf '%s\n' "${identity_output}" >&2 + exit 1 +fi + +xcrun notarytool store-credentials "${notary_profile}" \ + --apple-id "${APPLE_ID}" \ + --password "${APPLE_APP_SPECIFIC_PASSWORD}" \ + --team-id "${APPLE_TEAM_ID}" \ + --keychain "${keychain_path}" + +{ + printf 'APPLE_SIGNING_IDENTITY=%s\n' "${signing_identity}" + printf 'APPLE_TEAM_ID=%s\n' "${APPLE_TEAM_ID}" + printf 'CHAT2DB_SIGNING_KEYCHAIN=%s\n' "${keychain_path}" + printf 'CHAT2DB_NOTARY_KEYCHAIN_PROFILE=%s\n' "${notary_profile}" +} >> "${GITHUB_ENV}" + +configured=true +echo "Configured Developer ID signing identity ${signing_identity} in an ephemeral keychain" diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index b589371..c5a4d52 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -99,4 +99,63 @@ if ! lipo -archs "${java_bin}" | tr ' ' '\n' | grep -Fxq "${host_arch}"; then fi codesign --verify --deep --strict --verbose=2 "${app_path}" +if [[ "${CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE:-false}" == true ]]; then + verify_developer_id_code() { + local code_path="$1" + local code_label="$2" + local expected_team_id="$3" + local signature_details + local signing_team_id + local signing_authority + local signing_timestamp + local designated_requirement + + codesign --verify --strict --verbose=2 "${code_path}" + signature_details="$(codesign -dv --verbose=4 "${code_path}" 2>&1)" + signing_team_id="$(awk -F= '/^TeamIdentifier=/ { print $2; exit }' <<<"${signature_details}")" + signing_authority="$(awk -F= '/^Authority=/ { print $2; exit }' <<<"${signature_details}")" + signing_timestamp="$(awk -F= '/^Timestamp=/ { print $2; exit }' <<<"${signature_details}")" + designated_requirement="$(codesign -d -r- "${code_path}" 2>&1)" + if [[ "${signing_authority}" != Developer\ ID\ Application:* ]]; then + echo "${code_label} is not signed by a Developer ID Application identity" >&2 + exit 1 + fi + if [[ -z "${signing_team_id}" || "${signing_team_id}" == "not set" ]]; then + echo "${code_label} is missing a TeamIdentifier" >&2 + exit 1 + fi + if [[ -n "${expected_team_id}" && "${signing_team_id}" != "${expected_team_id}" ]]; then + echo "${code_label} TeamIdentifier does not match APPLE_TEAM_ID" >&2 + exit 1 + fi + if [[ -z "${signing_timestamp}" || "${signing_timestamp}" == "none" ]]; then + echo "${code_label} is missing a trusted timestamp" >&2 + exit 1 + fi + if ! grep -Eq '^CodeDirectory .*[[:space:]]flags=.*\(runtime\)' <<<"${signature_details}"; then + echo "${code_label} is missing the hardened runtime flag" >&2 + exit 1 + fi + if [[ "${designated_requirement}" == *"cdhash"* ]]; then + echo "${code_label} still has a build-specific cdhash requirement" >&2 + exit 1 + fi + } + + verify_developer_id_code "${app_path}" "package" "${APPLE_TEAM_ID:-}" + + runtime_macho_count=0 + while IFS= read -r -d '' runtime_file; do + if [[ "$(file -b "${runtime_file}")" != *"Mach-O"* ]]; then + continue + fi + runtime_macho_count=$((runtime_macho_count + 1)) + verify_developer_id_code "${runtime_file}" "packaged Java code ${runtime_file#"${app_path}/"}" "" + done < <(find "${resource_root}/java" -type f -print0) + if [[ "${runtime_macho_count}" -eq 0 ]]; then + echo "packaged Java runtime contains no Mach-O code" >&2 + exit 1 + fi + echo "Verified ${runtime_macho_count} Developer ID signed Java runtime binaries" +fi echo "Verified self-contained macOS package at ${app_path}" diff --git a/third_party/chat2db-community b/third_party/chat2db-community index 1c650f0..390109c 160000 --- a/third_party/chat2db-community +++ b/third_party/chat2db-community @@ -1 +1 @@ -Subproject commit 1c650f0e8a61d80b6b570e2cdcfc9c1b01f2a4e4 +Subproject commit 390109c5bcf2fd249617fe62733ead43ccf85611