From 11c7b14ba0dd5766a9b8b8e746d70340d6d0071c Mon Sep 17 00:00:00 2001 From: egdev6 Date: Fri, 30 Oct 2026 14:16:51 +0100 Subject: [PATCH 1/2] ci(macos): make notarization resumable from signed checkpoints --- .../security-drafts/macos-release-resume.yml | 192 +++++++++++ .github/security-drafts/macos-release.yml | 125 +++---- .github/workflows/macos-release-resume.yml | 192 +++++++++++ .github/workflows/macos-release.yml | 125 +++---- docs/build-macos.md | 157 ++++++--- docs/code-signing.en.md | 79 +++-- docs/code-signing.md | 73 ++-- package.json | 2 +- scripts/macos-notary-artifact-fixtures.mjs | 62 ++++ scripts/macos-notary-artifact.mjs | 169 +++++++++ scripts/macos-notary-artifact.test.mjs | 109 ++++++ scripts/macos-notary-checkpoint.mjs | 92 +++++ scripts/macos-notary-checkpoint.test.mjs | 79 +++++ scripts/macos-notary-submit.mjs | 102 ++++++ scripts/macos-notary-submit.test.mjs | 62 ++++ scripts/macos-release-resume-publish.mjs | 81 +++++ scripts/macos-release-resume-publish.test.mjs | 93 +++++ .../macos-release-resume-workflow.test.mjs | 41 +++ scripts/macos-release-resume.mjs | 130 +++++++ scripts/macos-release-resume.test.mjs | 96 ++++++ scripts/macos-release-workflow.test.mjs | 322 +++--------------- scripts/verify-macos-bundle.mjs | 113 +++++- scripts/verify-macos-bundle.test.mjs | 103 +++++- 23 files changed, 2045 insertions(+), 554 deletions(-) create mode 100644 .github/security-drafts/macos-release-resume.yml create mode 100644 .github/workflows/macos-release-resume.yml create mode 100644 scripts/macos-notary-artifact-fixtures.mjs create mode 100644 scripts/macos-notary-artifact.mjs create mode 100644 scripts/macos-notary-artifact.test.mjs create mode 100644 scripts/macos-notary-checkpoint.mjs create mode 100644 scripts/macos-notary-checkpoint.test.mjs create mode 100644 scripts/macos-notary-submit.mjs create mode 100644 scripts/macos-notary-submit.test.mjs create mode 100644 scripts/macos-release-resume-publish.mjs create mode 100644 scripts/macos-release-resume-publish.test.mjs create mode 100644 scripts/macos-release-resume-workflow.test.mjs create mode 100644 scripts/macos-release-resume.mjs create mode 100644 scripts/macos-release-resume.test.mjs diff --git a/.github/security-drafts/macos-release-resume.yml b/.github/security-drafts/macos-release-resume.yml new file mode 100644 index 0000000..9113fea --- /dev/null +++ b/.github/security-drafts/macos-release-resume.yml @@ -0,0 +1,192 @@ +name: macOS notarization resume + +on: + workflow_dispatch: + inputs: + source_run_id: + description: Producer run ID containing the signed checkpoint + required: true + type: string + source_run_attempt: + description: Exact producer attempt, including canceled attempts + required: true + type: string + version: + description: Exact stable version of the existing Windows release + required: true + type: string + source_sha: + description: Full lowercase source SHA of that existing release + required: true + type: string + submission_id: + description: Optional recovery UUID from diagnostics, requires Apple log hash match + required: false + type: string + publish: + description: Explicitly authorize attaching verified DMG and checksum after Environment approval + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + +concurrency: + group: macos-release + cancel-in-progress: false + +jobs: + preflight: + name: Validate existing release before any Apple credentials + if: github.ref == 'refs/heads/main' + runs-on: macos-15 + timeout-minutes: 5 + outputs: + source_sha: ${{ steps.validate.outputs.sourceSha }} + version: ${{ steps.validate.outputs.version }} + release_id: ${{ steps.validate.outputs.releaseId }} + steps: + - name: Checkout trusted workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Node 22 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: 22 + package-manager-cache: false + + - name: Validate existing release + id: validate + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SOURCE_SHA: ${{ inputs.source_sha }} + GH_TOKEN: ${{ github.token }} + run: | + node --input-type=module <<'NODE' + import { appendFileSync } from 'node:fs'; + import { preflightRelease } from './scripts/macos-release-preflight.mjs'; + const result = await preflightRelease(); + for (const [key, value] of Object.entries(result)) { + appendFileSync(process.env.GITHUB_OUTPUT, key + '=' + value + '\n'); + } + NODE + + resume: + name: Validate retained bytes, query exact UUID and verify accepted DMG + needs: preflight + if: github.ref == 'refs/heads/main' + environment: macos-signing + runs-on: macos-15 + timeout-minutes: 30 + outputs: + outcome: ${{ steps.complete.outputs.outcome }} + final_sha256: ${{ steps.complete.outputs.finalSha256 }} + artifact_id: ${{ steps.final.outputs.artifact-id }} + artifact_digest: ${{ steps.final.outputs.artifact-digest }} + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + RELEASE_ID: ${{ needs.preflight.outputs.release_id }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + SOURCE_RUN_ATTEMPT: ${{ inputs.source_run_attempt }} + SUBMISSION_ID: ${{ inputs.submission_id }} + steps: + - name: Checkout trusted verification tools, never build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Node 22 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: 22 + package-manager-cache: false + + - name: Authenticate producer run and artifacts, fatal ZIP digest and content validation + id: checkpoint + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/macos-release-resume.mjs validate + + - name: Query Apple and only if Accepted staple and verify retained DMG + id: complete + env: + GH_TOKEN: ${{ github.token }} + PRODUCER_WORKFLOW_SHA: ${{ steps.checkpoint.outputs.producerWorkflowSha }} + VALIDATED_SUBMISSION_ID: ${{ steps.checkpoint.outputs.submissionId }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node scripts/macos-release-resume.mjs complete + + - name: Stage final bytes and distinct post-staple checksum + if: steps.complete.outputs.outcome == 'accepted' + env: + FINAL_SHA256: ${{ steps.complete.outputs.finalSha256 }} + run: node scripts/macos-release-resume.mjs stage-final + + - name: Retain verified final bytes for separately authorized publication + if: steps.complete.outputs.outcome == 'accepted' + id: final + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: macos-notary-final-${{ github.run_id }}-${{ github.run_attempt }} + path: notary-final/* + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + + - name: Remove private notarization key even on cancellation + if: always() + run: | + node --input-type=module <<'NODE' + import { unlinkSync } from 'node:fs'; + import path from 'node:path'; + const key = path.join(process.env.RUNNER_TEMP, 'quorb-notary-' + process.env.GITHUB_RUN_ID + '-' + process.env.GITHUB_RUN_ATTEMPT + '.p8'); + try { unlinkSync(key); } + catch (error) { if (error.code !== 'ENOENT') throw new Error('Private key cleanup failed'); } + NODE + + publish: + name: Attach only verified final DMG and checksum, no overwrite + needs: [preflight, resume] + if: github.ref == 'refs/heads/main' && inputs.publish && needs.resume.outputs.outcome == 'accepted' + environment: macos-signing + runs-on: macos-15 + timeout-minutes: 10 + permissions: + contents: write + actions: read + steps: + - name: Checkout trusted publication tools + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Node 22 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: 22 + package-manager-cache: false + + - name: Verify final artifact ID and ZIP digest, revalidate release, upload without overwrite + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + RELEASE_ID: ${{ needs.preflight.outputs.release_id }} + FINAL_ARTIFACT_ID: ${{ needs.resume.outputs.artifact_id }} + FINAL_ARTIFACT_DIGEST: ${{ needs.resume.outputs.artifact_digest }} + FINAL_SHA256: ${{ needs.resume.outputs.final_sha256 }} + run: node scripts/macos-release-resume-publish.mjs diff --git a/.github/security-drafts/macos-release.yml b/.github/security-drafts/macos-release.yml index 549095f..28ef1fc 100644 --- a/.github/security-drafts/macos-release.yml +++ b/.github/security-drafts/macos-release.yml @@ -1,4 +1,4 @@ -name: macOS signed release +name: macOS signed release prepare and submit on: workflow_dispatch: @@ -60,14 +60,12 @@ jobs: NODE signing: - name: Sign, notarize and attach macOS ARM64 DMG + name: Prepare signed checkpoint and submit once, without waiting needs: preflight if: github.ref == 'refs/heads/main' environment: macos-signing runs-on: macos-15 timeout-minutes: 120 - permissions: - contents: write env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} @@ -109,95 +107,52 @@ jobs: working-directory: source run: npm ci - - name: Write private notarization key - env: - APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8 - run: | - node --input-type=module <<'NODE' - import { writeFileSync } from 'node:fs'; - const key = process.env.APPLE_API_KEY_P8; - if (!key || !key.trim()) throw new Error('Missing notarization key'); - writeFileSync(process.env.APPLE_API_KEY_PATH, key, { mode: 0o600, flag: 'wx' }); - NODE - - - name: Build, sign, notarize and staple - working-directory: source + - name: Build and sign without any Tauri notarization credentials env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node scripts/macos-notary-checkpoint.mjs build + + - name: Verify signed app and signed DMG and create strict checkpoint + run: node scripts/macos-notary-checkpoint.mjs stage + + - name: Retain immutable signed DMG and manifest BEFORE private key or submit + id: checkpoint + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: macos-notary-checkpoint-${{ github.run_id }}-${{ github.run_attempt }} + path: notary-checkpoint/* + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + + - name: Submit retained DMG once and record UUID, never wait or retry + env: + CHECKPOINT_ARTIFACT_ID: ${{ steps.checkpoint.outputs.artifact-id }} + CHECKPOINT_ARTIFACT_DIGEST: ${{ steps.checkpoint.outputs.artifact-digest }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8 - run: | - set -euo pipefail - test -n "$APPLE_CERTIFICATE" - test -n "$APPLE_CERTIFICATE_PASSWORD" - test -n "$APPLE_SIGNING_IDENTITY" - test -n "$APPLE_API_ISSUER" - test -n "$APPLE_API_KEY" - npm run app:build:mac - dmg="src-tauri/target/release/bundle/dmg/Quorb_${RELEASE_VERSION}_aarch64.dmg" - xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait - xcrun stapler staple "$dmg" - - - name: Remove private notarization key + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node scripts/macos-notary-submit.mjs + + - name: Retain submission receipt separately + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: macos-notary-receipt-${{ github.run_id }}-${{ github.run_attempt }} + path: notary-receipt/receipt.json + if-no-files-found: error + retention-days: 14 + overwrite: false + + - name: Remove private notarization key even on cancellation if: always() - env: - APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8 run: | node --input-type=module <<'NODE' import { unlinkSync } from 'node:fs'; - try { unlinkSync(process.env.APPLE_API_KEY_PATH); } - catch (error) { if (error.code !== 'ENOENT') throw error; } - NODE - - - name: Verify signed app and stapled DMG, then hash final bytes - run: | - node --input-type=module <<'NODE' import path from 'node:path'; - import { writeFileSync } from 'node:fs'; - import { execFileSync, spawnSync } from 'node:child_process'; - import { verifyMacosBundle } from './scripts/verify-macos-bundle.mjs'; - const result = verifyMacosBundle({ root: path.resolve('source'), sourceSha: process.env.RELEASE_SOURCE_SHA }); - const expected = path.resolve('source/src-tauri/target/release/bundle/dmg', 'Quorb_' + process.env.RELEASE_VERSION + '_aarch64.dmg'); - if (result.version !== process.env.RELEASE_VERSION || result.dmg !== expected) throw new Error('Unexpected release bundle'); - const run = (command, args) => execFileSync(command, args, { stdio: 'inherit', timeout: 120000 }); - run('codesign', ['--verify', '--deep', '--strict', result.app]); - const signature = spawnSync('codesign', ['-dv', '--verbose=4', result.app], { encoding: 'utf8', timeout: 120000 }); - if (signature.error || signature.status !== 0 || - !/^Authority=Developer ID Application: .+ \(SRD8T33RU5\)$/m.test(signature.stderr) || - !/^TeamIdentifier=SRD8T33RU5$/m.test(signature.stderr)) throw new Error('Unexpected Developer ID authority or team'); - console.log(signature.stderr); - run('spctl', ['--assess', '--type', 'execute', result.app]); - run('spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', result.dmg]); - run('xcrun', ['stapler', 'validate', result.dmg]); - writeFileSync(result.dmg + '.sha256', result.dmgSha256 + ' ' + path.basename(result.dmg) + '\n', { flag: 'wx' }); - NODE - - - name: Revalidate release and upload only DMG and SHA-256 without overwrite - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - node --input-type=module <<'NODE' - import { preflightRelease } from './scripts/macos-release-preflight.mjs'; - const result = await preflightRelease(); - if (String(result.releaseId) !== process.env.RELEASE_ID) throw new Error('Release identity changed'); + const key = path.join(process.env.RUNNER_TEMP, 'quorb-notary-' + process.env.GITHUB_RUN_ID + '-' + process.env.GITHUB_RUN_ATTEMPT + '.p8'); + try { unlinkSync(key); } + catch (error) { if (error.code !== 'ENOENT') throw new Error('Private key cleanup failed'); } NODE - dmg="source/src-tauri/target/release/bundle/dmg/Quorb_${RELEASE_VERSION}_aarch64.dmg" - name="${dmg##*/}" - dmg_id="$(gh api --method POST -H 'Content-Type: application/x-apple-diskimage' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name" --input "$dmg" --jq .id)" - if ! [[ "$dmg_id" =~ ^[0-9]+$ ]]; then - echo "::error::DMG upload returned a non-numeric asset id; deleting nothing." - exit 1 - fi - if ! gh api --method POST -H 'Content-Type: text/plain' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name.sha256" --input "$dmg.sha256" --jq .id; then - echo "::error::SHA-256 upload failed; removing only the DMG this run uploaded." - if ! gh api --method DELETE "repos/egdev6/quorb/releases/assets/$dmg_id"; then - echo "::error::Rollback failed; manual cleanup needed for $name (asset id $dmg_id)." - exit 1 - fi - exit 1 - fi diff --git a/.github/workflows/macos-release-resume.yml b/.github/workflows/macos-release-resume.yml new file mode 100644 index 0000000..9113fea --- /dev/null +++ b/.github/workflows/macos-release-resume.yml @@ -0,0 +1,192 @@ +name: macOS notarization resume + +on: + workflow_dispatch: + inputs: + source_run_id: + description: Producer run ID containing the signed checkpoint + required: true + type: string + source_run_attempt: + description: Exact producer attempt, including canceled attempts + required: true + type: string + version: + description: Exact stable version of the existing Windows release + required: true + type: string + source_sha: + description: Full lowercase source SHA of that existing release + required: true + type: string + submission_id: + description: Optional recovery UUID from diagnostics, requires Apple log hash match + required: false + type: string + publish: + description: Explicitly authorize attaching verified DMG and checksum after Environment approval + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + +concurrency: + group: macos-release + cancel-in-progress: false + +jobs: + preflight: + name: Validate existing release before any Apple credentials + if: github.ref == 'refs/heads/main' + runs-on: macos-15 + timeout-minutes: 5 + outputs: + source_sha: ${{ steps.validate.outputs.sourceSha }} + version: ${{ steps.validate.outputs.version }} + release_id: ${{ steps.validate.outputs.releaseId }} + steps: + - name: Checkout trusted workflow source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Node 22 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: 22 + package-manager-cache: false + + - name: Validate existing release + id: validate + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SOURCE_SHA: ${{ inputs.source_sha }} + GH_TOKEN: ${{ github.token }} + run: | + node --input-type=module <<'NODE' + import { appendFileSync } from 'node:fs'; + import { preflightRelease } from './scripts/macos-release-preflight.mjs'; + const result = await preflightRelease(); + for (const [key, value] of Object.entries(result)) { + appendFileSync(process.env.GITHUB_OUTPUT, key + '=' + value + '\n'); + } + NODE + + resume: + name: Validate retained bytes, query exact UUID and verify accepted DMG + needs: preflight + if: github.ref == 'refs/heads/main' + environment: macos-signing + runs-on: macos-15 + timeout-minutes: 30 + outputs: + outcome: ${{ steps.complete.outputs.outcome }} + final_sha256: ${{ steps.complete.outputs.finalSha256 }} + artifact_id: ${{ steps.final.outputs.artifact-id }} + artifact_digest: ${{ steps.final.outputs.artifact-digest }} + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + RELEASE_ID: ${{ needs.preflight.outputs.release_id }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + SOURCE_RUN_ATTEMPT: ${{ inputs.source_run_attempt }} + SUBMISSION_ID: ${{ inputs.submission_id }} + steps: + - name: Checkout trusted verification tools, never build source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Node 22 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: 22 + package-manager-cache: false + + - name: Authenticate producer run and artifacts, fatal ZIP digest and content validation + id: checkpoint + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/macos-release-resume.mjs validate + + - name: Query Apple and only if Accepted staple and verify retained DMG + id: complete + env: + GH_TOKEN: ${{ github.token }} + PRODUCER_WORKFLOW_SHA: ${{ steps.checkpoint.outputs.producerWorkflowSha }} + VALIDATED_SUBMISSION_ID: ${{ steps.checkpoint.outputs.submissionId }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node scripts/macos-release-resume.mjs complete + + - name: Stage final bytes and distinct post-staple checksum + if: steps.complete.outputs.outcome == 'accepted' + env: + FINAL_SHA256: ${{ steps.complete.outputs.finalSha256 }} + run: node scripts/macos-release-resume.mjs stage-final + + - name: Retain verified final bytes for separately authorized publication + if: steps.complete.outputs.outcome == 'accepted' + id: final + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: macos-notary-final-${{ github.run_id }}-${{ github.run_attempt }} + path: notary-final/* + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + + - name: Remove private notarization key even on cancellation + if: always() + run: | + node --input-type=module <<'NODE' + import { unlinkSync } from 'node:fs'; + import path from 'node:path'; + const key = path.join(process.env.RUNNER_TEMP, 'quorb-notary-' + process.env.GITHUB_RUN_ID + '-' + process.env.GITHUB_RUN_ATTEMPT + '.p8'); + try { unlinkSync(key); } + catch (error) { if (error.code !== 'ENOENT') throw new Error('Private key cleanup failed'); } + NODE + + publish: + name: Attach only verified final DMG and checksum, no overwrite + needs: [preflight, resume] + if: github.ref == 'refs/heads/main' && inputs.publish && needs.resume.outputs.outcome == 'accepted' + environment: macos-signing + runs-on: macos-15 + timeout-minutes: 10 + permissions: + contents: write + actions: read + steps: + - name: Checkout trusted publication tools + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Node 22 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 + with: + node-version: 22 + package-manager-cache: false + + - name: Verify final artifact ID and ZIP digest, revalidate release, upload without overwrite + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + RELEASE_ID: ${{ needs.preflight.outputs.release_id }} + FINAL_ARTIFACT_ID: ${{ needs.resume.outputs.artifact_id }} + FINAL_ARTIFACT_DIGEST: ${{ needs.resume.outputs.artifact_digest }} + FINAL_SHA256: ${{ needs.resume.outputs.final_sha256 }} + run: node scripts/macos-release-resume-publish.mjs diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 549095f..28ef1fc 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -1,4 +1,4 @@ -name: macOS signed release +name: macOS signed release prepare and submit on: workflow_dispatch: @@ -60,14 +60,12 @@ jobs: NODE signing: - name: Sign, notarize and attach macOS ARM64 DMG + name: Prepare signed checkpoint and submit once, without waiting needs: preflight if: github.ref == 'refs/heads/main' environment: macos-signing runs-on: macos-15 timeout-minutes: 120 - permissions: - contents: write env: RELEASE_VERSION: ${{ needs.preflight.outputs.version }} RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} @@ -109,95 +107,52 @@ jobs: working-directory: source run: npm ci - - name: Write private notarization key - env: - APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8 - run: | - node --input-type=module <<'NODE' - import { writeFileSync } from 'node:fs'; - const key = process.env.APPLE_API_KEY_P8; - if (!key || !key.trim()) throw new Error('Missing notarization key'); - writeFileSync(process.env.APPLE_API_KEY_PATH, key, { mode: 0o600, flag: 'wx' }); - NODE - - - name: Build, sign, notarize and staple - working-directory: source + - name: Build and sign without any Tauri notarization credentials env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node scripts/macos-notary-checkpoint.mjs build + + - name: Verify signed app and signed DMG and create strict checkpoint + run: node scripts/macos-notary-checkpoint.mjs stage + + - name: Retain immutable signed DMG and manifest BEFORE private key or submit + id: checkpoint + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: macos-notary-checkpoint-${{ github.run_id }}-${{ github.run_attempt }} + path: notary-checkpoint/* + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + + - name: Submit retained DMG once and record UUID, never wait or retry + env: + CHECKPOINT_ARTIFACT_ID: ${{ steps.checkpoint.outputs.artifact-id }} + CHECKPOINT_ARTIFACT_DIGEST: ${{ steps.checkpoint.outputs.artifact-digest }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8 - run: | - set -euo pipefail - test -n "$APPLE_CERTIFICATE" - test -n "$APPLE_CERTIFICATE_PASSWORD" - test -n "$APPLE_SIGNING_IDENTITY" - test -n "$APPLE_API_ISSUER" - test -n "$APPLE_API_KEY" - npm run app:build:mac - dmg="src-tauri/target/release/bundle/dmg/Quorb_${RELEASE_VERSION}_aarch64.dmg" - xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait - xcrun stapler staple "$dmg" - - - name: Remove private notarization key + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: node scripts/macos-notary-submit.mjs + + - name: Retain submission receipt separately + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: macos-notary-receipt-${{ github.run_id }}-${{ github.run_attempt }} + path: notary-receipt/receipt.json + if-no-files-found: error + retention-days: 14 + overwrite: false + + - name: Remove private notarization key even on cancellation if: always() - env: - APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8 run: | node --input-type=module <<'NODE' import { unlinkSync } from 'node:fs'; - try { unlinkSync(process.env.APPLE_API_KEY_PATH); } - catch (error) { if (error.code !== 'ENOENT') throw error; } - NODE - - - name: Verify signed app and stapled DMG, then hash final bytes - run: | - node --input-type=module <<'NODE' import path from 'node:path'; - import { writeFileSync } from 'node:fs'; - import { execFileSync, spawnSync } from 'node:child_process'; - import { verifyMacosBundle } from './scripts/verify-macos-bundle.mjs'; - const result = verifyMacosBundle({ root: path.resolve('source'), sourceSha: process.env.RELEASE_SOURCE_SHA }); - const expected = path.resolve('source/src-tauri/target/release/bundle/dmg', 'Quorb_' + process.env.RELEASE_VERSION + '_aarch64.dmg'); - if (result.version !== process.env.RELEASE_VERSION || result.dmg !== expected) throw new Error('Unexpected release bundle'); - const run = (command, args) => execFileSync(command, args, { stdio: 'inherit', timeout: 120000 }); - run('codesign', ['--verify', '--deep', '--strict', result.app]); - const signature = spawnSync('codesign', ['-dv', '--verbose=4', result.app], { encoding: 'utf8', timeout: 120000 }); - if (signature.error || signature.status !== 0 || - !/^Authority=Developer ID Application: .+ \(SRD8T33RU5\)$/m.test(signature.stderr) || - !/^TeamIdentifier=SRD8T33RU5$/m.test(signature.stderr)) throw new Error('Unexpected Developer ID authority or team'); - console.log(signature.stderr); - run('spctl', ['--assess', '--type', 'execute', result.app]); - run('spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', result.dmg]); - run('xcrun', ['stapler', 'validate', result.dmg]); - writeFileSync(result.dmg + '.sha256', result.dmgSha256 + ' ' + path.basename(result.dmg) + '\n', { flag: 'wx' }); - NODE - - - name: Revalidate release and upload only DMG and SHA-256 without overwrite - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - node --input-type=module <<'NODE' - import { preflightRelease } from './scripts/macos-release-preflight.mjs'; - const result = await preflightRelease(); - if (String(result.releaseId) !== process.env.RELEASE_ID) throw new Error('Release identity changed'); + const key = path.join(process.env.RUNNER_TEMP, 'quorb-notary-' + process.env.GITHUB_RUN_ID + '-' + process.env.GITHUB_RUN_ATTEMPT + '.p8'); + try { unlinkSync(key); } + catch (error) { if (error.code !== 'ENOENT') throw new Error('Private key cleanup failed'); } NODE - dmg="source/src-tauri/target/release/bundle/dmg/Quorb_${RELEASE_VERSION}_aarch64.dmg" - name="${dmg##*/}" - dmg_id="$(gh api --method POST -H 'Content-Type: application/x-apple-diskimage' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name" --input "$dmg" --jq .id)" - if ! [[ "$dmg_id" =~ ^[0-9]+$ ]]; then - echo "::error::DMG upload returned a non-numeric asset id; deleting nothing." - exit 1 - fi - if ! gh api --method POST -H 'Content-Type: text/plain' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name.sha256" --input "$dmg.sha256" --jq .id; then - echo "::error::SHA-256 upload failed; removing only the DMG this run uploaded." - if ! gh api --method DELETE "repos/egdev6/quorb/releases/assets/$dmg_id"; then - echo "::error::Rollback failed; manual cleanup needed for $name (asset id $dmg_id)." - exit 1 - fi - exit 1 - fi diff --git a/docs/build-macos.md b/docs/build-macos.md index 4eb588f..3513e82 100644 --- a/docs/build-macos.md +++ b/docs/build-macos.md @@ -66,56 +66,113 @@ Solo `contents: read`/`checks: read`; `GH_TOKEN` solo en preflight, checkout sin No se ha ejecutado este workflow ni realizado uploads/descargas, instalación o prueba GUI en Mac real. La guía enlazada describe cómo obtener/verificar/informar sin quitar cuarentena; no acredita seguridad. -## Release macOS firmada: configurada, todavía no ejecutada - -El workflow manual [macos-release.yml](../.github/workflows/macos-release.yml) se -materializa byte a byte desde `.github/security-drafts/macos-release.yml`. -Es independiente de `release.yml`: recibe `version` estable y `source_sha` de la -release Windows `v` ya existente (también admite su borrador). Solo se -lanza desde `main`; no crea releases, tags ni publica un borrador. - -Un job de preflight sin entorno de firma valida el ref, los formatos estrictos, -la pertenencia de `source_sha` al historial de `origin/main` y la versión de -`package.json` leída con `git show` en ese commit. Consulta la API de GitHub con -paginación para encontrar releases, incluidos borradores, y sus assets. Resuelve -el tag real, tanto ligero como anotado, hasta el commit exacto. Si GitHub todavía -no ha creado el tag de un borrador Windows, solo admite `target_commitish` igual -al SHA completo validado, nunca una rama móvil. Rechaza un DMG o checksum con el -nombre destino ya ocupado. A diferencia de la preview, no impone otra lista de -checks: este preflight protege la fuente y la release existente, no sustituye -las protecciones de `main` ni su revisión. - -El job dependiente usa el entorno protegido `macos-signing`, con aprobación de -`egdev6` y despliegue limitado a `main`. Los únicos secretos Apple requeridos son -`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, -`APPLE_API_ISSUER`, `APPLE_API_KEY` y `APPLE_API_KEY_P8` (sin valores en el repo). -Solo los steps de preparación de la clave y build/firma reciben secretos, nunca -el workflow ni el job completo. La clave `.p8` se escribe de forma exclusiva con -permisos `0600` en `RUNNER_TEMP`, se pasa como `APPLE_API_KEY_PATH` y se elimina -mediante el único step `if: always()`, también si falla el build. - -Compila el SHA validado con `npm run app:build:mac` en `macos-15`, Node 22 y Rust -stable nativos ARM64, con objetivo mínimo macOS **15.0**. Tauri firma con Developer -ID y notariza; después se solicita explícitamente la notarización del DMG y se -pega el ticket con `stapler`. Antes de subir nada se verifica el bundle ARM64, -`codesign --verify --deep --strict`, autoridad `Developer ID Application` y team -`SRD8T33RU5`, evaluación `spctl` de la app y del DMG, y `xcrun stapler validate` -del DMG. El certificado documentado caduca el **2027-11-28**; esto no acredita -que se haya usado correctamente ni que un artefacto publicado esté firmado. - -Tras volver a validar la identidad de la release y sus colisiones, solo adjunta -`Quorb__aarch64.dmg` y `Quorb__aarch64.dmg.sha256`, calculado sobre -los bytes finales con ticket. No sobrescribe assets, no toca `latest.json` ni -artefactos del updater. **Las actualizaciones de macOS siguen siendo manuales.** - -El intento 3 de la ejecución `40949608906` firmó la app, pero quedó esperando en -`Notarizing` hasta su cancelación, sin respuesta visible de Apple; no publicó el -DMG. Siguen pendientes la validación de Gatekeeper del DMG descargado en un Mac -real y la comprobación de primera apertura -con cuarentena de [#118](https://github.com/egdev6/quorb/issues/118), sin retirar -la cuarentena. No acredita instalación, GUI ni un rango de macOS soportado. -Ejecutar firma/notarización, crear tags, lanzar Release o publicar assets requiere -aprobación separada; escribir esta configuración no autoriza esas operaciones. +## Release macOS reanudable: preparación local, runtime pendiente + +Los workflows [prepare/submit](../.github/workflows/macos-release.yml) y +[resume](../.github/workflows/macos-release-resume.yml) tienen fuentes revisables +byte a byte en `.github/security-drafts/`. Solo admiten dispatch manual desde +`main`, con el Environment existente `macos-signing` (reviewer `egdev6`, rama +`main`). Esta corrección no cambia ese Environment ni autoriza ejecuciones. + +### 1. Preparar y enviar una vez + +`macos-release.yml` recibe `version` estable y `source_sha` de la release Windows +`v` existente (borrador permitido). El preflight sigue sin secretos: +comprueba ref, versión con `git show`, ancestría en `origin/main`, release/tag +(incluidos tags anotados), ID de release y colisiones de DMG/checksum. Para un +borrador todavía sin tag exige `target_commitish` igual al SHA completo, no una +rama. No crea releases/tags ni cambia Windows/updater. + +El build ARM64 nativo usa macOS 15, Node 22, Rust stable y mínimo macOS **15.0**. +Solo recibe certificado, contraseña e identidad Developer ID. El helper elimina +del entorno hijo **todas** las variables `APPLE_API_*`, `APPLE_ID`, +`APPLE_PASSWORD` y `APPLE_TEAM_ID`, incluso si fueran heredadas. Según el código +Tauri CLI 2.12.1 / bundler 2.10.1 revisado, la app se firma antes de detectar +credenciales ausentes y omitir notarización; el DMG se firma por separado. + +Tras verificar metadata/ARM64, integridad del DMG y firmas Developer ID de app y +DMG (team `SRD8T33RU5`), guarda solo DMG firmado + `manifest.json` en +`macos-notary-checkpoint--`. Upload inmutable, retención **14 días**, +antes de escribir `.p8` o llamar a Apple. El manifiesto liga repositorio, workflow +path/SHA, source SHA, versión, release ID, run/attempt, nombre, tamaño y SHA256 de +los bytes enviados. No contiene claves ni certificado privado. + +Solo después del upload, `notarytool submit` envía ese mismo DMG exterior sin +`--wait`, sin polling ni retries. Es normal que el workflow **termine aquí**, no +es una release completada. El UUID se imprime y resume; el artifact separado +`macos-notary-receipt--` guarda UUID, hash DMG y artifact ID/digest +del checkpoint devueltos por upload. La `.p8` exclusiva `0600` en `RUNNER_TEMP` se +elimina al salir y mediante cleanup `always()`. No hay permiso `contents: write`. + +### 2. Reanudar sin compilar ni volver a enviar + +Usar `macos-release-resume.yml` con `source_run_id`, `source_run_attempt`, la misma +`version` y `source_sha`; normalmente dejar `submission_id` vacío. Requiere nueva +autorización para consultar Apple. No requiere certificado Developer ID privado, +solo las tres credenciales API en el step de consulta. + +Antes de usar esas credenciales valida el run propio/headrepo, `main`, evento, +workflow path/ID y SHA ancestro aprobado; exige además bytes del workflow productor +iguales a la fuente revisada actual. No acepta un productor antiguo/incompatible. +Resuelve artifacts por run/attempt, nombre único, ID, vínculos API, tiempos de +intento y expiración; **un run cancelado con checkpoint válido es recuperable**. +Exige run terminado, no conclusión `success`. Descarga ZIP crudo con API autenticada, +verifica fatalmente su digest antes de extraer y admite solo archivos esperados: +sin rutas, symlinks, duplicados, extras, bombas o tamaños excesivos (DMG ≤512 MiB). +No usa el comportamiento warning-only de download-artifact. Compara manifiesto con +valores externos de run/preflight, no con sus propias declaraciones. + +- `In Progress`: outcome **pending**, sin staple ni publicación; consultar después. +- `Accepted` sin log disponible: **pending**, no release completada. +- `Invalid`/`Rejected`: muestra issues JSON acotados/saneados y falla; no reenvía. +- `Accepted`: el log debe contener `jobId` igual al UUID y `sha256` igual al DMG + **previo** al staple. Ausente o distinto falla cerrado. El fixture `log.sha256` + es **sintético**, no prueba que Apple real entregue ese campo; debe verificarse + en una ejecución autorizada, nunca omitir el gate para conseguir verde. + +Después de Accepted grapa **solo el DMG retenido**; Apple procesa tickets de los +contenedores anidados. No recompila, re-firma, pega tickets a la app ni reempaqueta. +Verifica firma/ticket del DMG y Gatekeeper; lo monta con `hdiutil attach -readonly +-nobrowse -plist` en un directorio controlado. Analiza plist real, valida entidades +y solo la `Quorb.app` contenida: rutas/symlinks, Developer ID/team, bundle ID +`es.egdev.quorb`, versión, ARM64 y mínimo macOS 15.0; exige Gatekeeper de esa app. +Siempre intenta desmontar. Un error de red/Gatekeeper o cleanup falla cerrado. + +### 3. Autorizar publicación por separado + +Dispatch manual **no basta**: `publish` es `false` por defecto. Solo con +`publish: true`, outcome Accepted verificado y Environment aprobado puede arrancar +el job de publicación, único con `contents: write`. Los demás jobs usan lectura. +Un ZIP final inmutable se vuelve a validar por ID/digest y hash final ligado a +outputs del job verificador. Revalida release ID y colisiones justo antes de +adjuntar exclusivamente DMG y `.dmg.sha256`, sin overwrite. Si falla checksum, +rollback solo puede borrar el ID del DMG subido por esa invocación, nunca un asset +preexistente. Una respuesta ambigua requiere inspección manual. + +Son tres hashes distintos: **ZIP del artifact**, **DMG enviado**, **DMG final con +ticket**. No toca `latest.json`; **updates macOS siguen manuales**. Una ejecución +solo de verificación conserva el artifact final, no publica. Para publicar más +tarde se autoriza otro resume con `publish: true`, repitiendo todas las puertas. + +### Cancelación, ambigüedad y expiración + +Conservar run ID/attempt, checkpoint ID/digest y UUID. Si se interrumpe submit antes +del recibo, el checkpoint sigue retenido: usar diagnostics/history/info/log para +investigar, **no rerun/retry de submit** ni asumir vínculo por nombre/fecha. El +UUID candidato se puede pasar como `submission_id` únicamente para recuperación; +solo el log Apple con UUID y hash coincidentes puede autorizar continuación. + +Tras **14 días**, o si el artifact fue borrado, resume falla: no reconstruye ni +sustituye bytes automáticamente. Acordar recuperación por separado. El intento 3 +del run `40949608906`, anterior a este protocolo, esperó en Tauri `Notarizing` +hasta cancelación y no publicó DMG; no se presume que tenga checkpoint recuperable. + +Esta corrección solo tiene pruebas Linux con procesos/HTTP/filesystem simulados +y ZIP/plist sintéticos. No se ha ejecutado firma, Apple, upload/download real ni +Gatekeeper nativo. Certificado documentado: caduca **2027-11-28**, sin afirmar su +validez actual. Siguen pendientes Mac real, primera apertura con cuarentena y GUI +de [#118](https://github.com/egdev6/quorb/issues/118), sin retirar cuarentena. +Preparar código no autoriza dispatch, publicación, cambios de Environment ni tags. ## Consultar una notarización sin reenviarla diff --git a/docs/code-signing.en.md b/docs/code-signing.en.md index 9ba478c..2f3c5c0 100644 --- a/docs/code-signing.en.md +++ b/docs/code-signing.en.md @@ -38,30 +38,61 @@ at that SHA, ancestry in `origin/main`, release/tag and absence of asset collisi For a draft without a tag, `target_commitish` must equal the full SHA, not a branch. It does not modify the Windows workflow or create releases or tags. -The dependent signing job uses the protected `macos-signing` environment (reviewer -`egdev6`, deployment branch `main`). Its secrets are `APPLE_CERTIFICATE`, -`APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_ISSUER`, -`APPLE_API_KEY` and `APPLE_API_KEY_P8`. They are exposed only to the key preparation -and build/sign steps, never at job or workflow level. The `.p8` content is written -to a private `0600` file under `RUNNER_TEMP`, passed to Tauri as -`APPLE_API_KEY_PATH` and removed even after failure. - -It builds the validated SHA for ARM64 with minimum macOS 15.0, signs with Developer -ID, notarizes and staples the DMG ticket. Before upload it requires -`codesign --verify --deep --strict`, the app's `Developer ID Application` authority -and team `SRD8T33RU5`, `spctl` assessment of the app and DMG, and -`xcrun stapler validate` on the DMG. The documented Developer ID certificate expires -on **2027-11-28**. After revalidating the release, it attaches only -`Quorb__aarch64.dmg` and its `.dmg.sha256` file without overwriting assets; -it does not modify `latest.json` or updater artifacts. macOS updates remain -**manual**. - -**This workflow has not been executed yet.** Actual signing/notarization, -Gatekeeper validation of the downloaded DMG on a real Mac, and the first-open -check with quarantine from [#118](https://github.com/egdev6/quorb/issues/118) remain -pending. Do not remove quarantine to pass that check. Configuration does not prove -support, installation or GUI functionality; running the workflow, creating tags -or publishing assets requires separate approval. +The dependent signing job retains the protected `macos-signing` environment +(reviewer `egdev6`, branch `main`), without changing its rules. The build receives +only `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD` and +`APPLE_SIGNING_IDENTITY`. Its child environment removes all `APPLE_API_*`, +`APPLE_ID`, `APPLE_PASSWORD` and `APPLE_TEAM_ID` credentials: Tauri signs but skips +automatic notarization. Builds target ARM64 and minimum macOS 15.0. + +After verifying app and DMG Developer ID signatures, authority and team +`SRD8T33RU5`, it retains only the signed DMG and strict manifest **before** writing +any `.p8` or submitting to Apple. Immutable checkpoint: +`macos-notary-checkpoint--`, retention **14 days**. It then submits +the outer DMG once without `--wait`, storing the UUID and a separate +`macos-notary-receipt--` with checkpoint artifact ID/digest. +**Stopping after submit is normal, not a completed release.** No submit retries. +Only the submit step receives `APPLE_API_KEY_P8`, `APPLE_API_KEY` and +`APPLE_API_ISSUER`; exclusive `0600` key under `RUNNER_TEMP`, cleanup on exit and +`always()` fallback. No secrets at job/workflow level. + +The [separate resume workflow](../.github/workflows/macos-release-resume.yml) +accepts producer run ID, attempt, version and source SHA. Before Apple credentials, +it authenticates own-repository/main/workflow/approved-SHA provenance, fatal ZIP +digest, extraction allowlist and manifest against external trusted values. It +needs no private Developer ID certificate and never rebuilds, re-signs or +resubmits. `In Progress` or Accepted without an available log is **pending**, with +no publication; rejection reports sanitized issues and fails. Accepted requires +Apple log `jobId` and `sha256` to match the UUID and original DMG. That hash field +has only a **synthetic** fixture, not real Apple evidence; absence fails closed. + +It staples only the retained DMG, checks signature/ticket and Gatekeeper, mounts +readonly using a real plist parser, and verifies the contained `Quorb.app` +(authority/team, bundle ID, version, ARM64, minimum OS, paths/symlinks and +Gatekeeper). It always attempts detach, never repackages the app. Final SHA256 is +separate from input DMG hash and artifact ZIP digest. The documented certificate +expires **2027-11-28**, without asserting current validity. + +`publish` defaults to **false**: dispatch alone is not publication authorization. +Only explicit `publish: true`, verified Accepted outcome and Environment approval +enable the sole `contents: write` job; other jobs are read-only. It revalidates +release ID and asset collisions and attaches only `Quorb__aarch64.dmg` +and `.dmg.sha256` without overwrite. Rollback can delete only its own newly +uploaded asset ID, never preexisting names. No `latest.json` or updater changes; +macOS updates remain **manual**. + +A canceled producer with a valid checkpoint is recoverable without an overall +successful run. If interrupted submit is ambiguous before receipt persistence, +investigate via diagnostics/history/info/log: **never resubmit or link by name**. +Optional recovery `submission_id` authorizes continuation only after Apple log +UUID/hash binding. Expired/deleted artifacts require a separate recovery decision, +not an automatic rebuild. See [the operational guide](./build-macos.md#release-macos-reanudable-preparación-local-runtime-pendiente). + +**This correction has not run against Apple or real macOS.** Linux tests simulate +processes, HTTP and files. Downloaded-DMG Gatekeeper, first open with quarantine +from [#118](https://github.com/egdev6/quorb/issues/118), and GUI checks remain +pending; do not remove quarantine. Configuration proves neither support nor +installation. Dispatch, publication and Environment changes need separate approval. ## Responsibilities diff --git a/docs/code-signing.md b/docs/code-signing.md index 672db1c..8b6291e 100644 --- a/docs/code-signing.md +++ b/docs/code-signing.md @@ -38,29 +38,56 @@ ancestría en `origin/main`, release/tag y ausencia de colisiones de assets. Par un borrador sin tag exige `target_commitish` igual al SHA completo, no una rama. No modifica el workflow Windows ni crea releases o tags. -El job de firma depende de ese preflight y usa el entorno protegido -`macos-signing` (aprobador `egdev6`, rama de despliegue `main`). Los secretos son -`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, -`APPLE_API_ISSUER`, `APPLE_API_KEY` y `APPLE_API_KEY_P8`. Solo se exponen en los -steps de preparación de clave y build/firma, nunca a nivel de job o workflow. -El contenido `.p8` se escribe en un archivo privado `0600` dentro de `RUNNER_TEMP`, -se entrega a Tauri como `APPLE_API_KEY_PATH` y se elimina incluso si hay un fallo. - -Compila el SHA validado para ARM64 con mínimo macOS 15.0, firma con Developer ID, -notariza y pega el ticket al DMG. Antes del upload exige `codesign --verify --deep ---strict`, autoridad `Developer ID Application` y team `SRD8T33RU5` de la app, -evaluación `spctl` de app y DMG, y `xcrun stapler validate` del DMG. El certificado -Developer ID documentado caduca el **2027-11-28**. Tras revalidar la release, -adjunta únicamente `Quorb__aarch64.dmg` y su archivo `.dmg.sha256`, sin -sobrescribir assets; no modifica `latest.json` ni artefactos del updater. -Las actualizaciones de macOS siguen siendo **manuales**. - -**El workflow todavía no se ha ejecutado.** La firma/notarización real, la -validación Gatekeeper del DMG descargado en un Mac real y la primera apertura con -cuarentena de [#118](https://github.com/egdev6/quorb/issues/118) siguen pendientes. -No se debe quitar la cuarentena para dar por superada esa prueba. Esta -configuración no demuestra soporte, instalación ni GUI; ejecutar el workflow, -crear tags o publicar assets requiere aprobación separada. +El job de firma depende de ese preflight y conserva el Environment protegido +`macos-signing` (reviewer `egdev6`, rama `main`), sin cambiar sus reglas. El build +solo recibe `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD` y +`APPLE_SIGNING_IDENTITY`. Elimina del proceso hijo todas las credenciales +`APPLE_API_*`, `APPLE_ID`, `APPLE_PASSWORD` y `APPLE_TEAM_ID`: Tauri firma pero +omite su notarización automática. Build ARM64, mínimo macOS 15.0. + +Verifica firmas Developer ID de app y DMG, autoridad y team `SRD8T33RU5`, y retiene +solo DMG firmado y manifiesto estricto **antes** de escribir `.p8` o enviar a Apple. +Checkpoint inmutable `macos-notary-checkpoint--`, retención **14 días**. +Después envía el DMG exterior una vez sin `--wait`, guarda UUID y recibo separado +`macos-notary-receipt--` con artifact ID/digest. **Terminar tras +submit es normal: no significa release completa.** No reintenta submit. +`APPLE_API_KEY_P8`, `APPLE_API_KEY` y `APPLE_API_ISSUER` solo llegan al step de envío; +clave exclusiva `0600` en `RUNNER_TEMP`, cleanup al salir y `always()`. + +El [resume separado](../.github/workflows/macos-release-resume.yml) recibe run ID, +intento, versión y source SHA. Valida productor propio/main/workflow/SHA aprobado, +ID/digest ZIP autenticado, allowlist de extracción y manifiesto ligado a valores +externos antes de acceder a API Apple. No requiere certificado privado, recompila, +re-firma ni reenvía. `In Progress` o Accepted sin log disponible queda **pending**, +sin publicación; rechazo muestra issues saneados y falla. Accepted exige `jobId` +y `sha256` del log iguales al UUID y DMG original. Ese campo hash solo se ha +probado con fixture **sintético**, no Apple real; ausente falla cerrado. + +Solo grapa el DMG retenido, verifica firma/ticket y Gatekeeper, monta readonly con +plist real y valida la `Quorb.app` contenida (autoridad/team, bundle ID, versión, +ARM64, mínimo macOS, rutas/symlinks y Gatekeeper); siempre intenta desmontar. No +repaqueta la app. Calcula SHA256 final distinto del hash de entrada y digest ZIP. +El certificado documentado caduca **2027-11-28**, sin certificar su validez actual. + +`publish` es **false** por defecto: dispatch no autoriza por sí solo publicación. +Solo `publish: true`, verificación Accepted y Environment aprobado habilitan el +job con `contents: write`; los demás jobs son de lectura. Revalida release ID y +colisiones; adjunta únicamente `Quorb__aarch64.dmg` y `.dmg.sha256`, sin +overwrite. Rollback solo borra el ID propio recién subido. No modifica +`latest.json` ni updater; las actualizaciones macOS siguen **manuales**. + +Cancelación tras checkpoint puede recuperarse sin exigir run exitoso. Si submit +quedó ambiguo antes del recibo, investigar con diagnostics/history/info/log: +**no reenviar ni enlazar por nombre**. `submission_id` opcional solo autoriza +continuación si el log Apple coincide con UUID y hash retenido. Artifacts +caducados/borrados requieren decisión separada, no rebuild automático. Detalles +y pasos en [build-macos.md](./build-macos.md#release-macos-reanudable-preparación-local-runtime-pendiente). + +**Esta corrección no se ha ejecutado contra Apple ni macOS real.** Tests Linux +simulan procesos, HTTP y archivos. Gatekeeper del DMG descargado, primera apertura +con cuarentena de [#118](https://github.com/egdev6/quorb/issues/118) y GUI siguen +pendientes; no quitar cuarentena. No acredita soporte ni instalación. Dispatch, +publicación y cambios de Environment requieren autorización separada. ## Responsabilidades diff --git a/package.json b/package.json index b1ff6ae..71f2817 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "vitest run --dir src && node --test scripts/bump.test.mjs scripts/i18n-inventory.test.mjs scripts/release-check.test.mjs scripts/setup-signing.test.mjs scripts/dev-retirement.test.mjs scripts/uninstall.test.mjs scripts/security/check-repository.test.mjs scripts/security/semgrep-report.test.mjs scripts/security/export-snapshot.test.mjs scripts/security/snapshot-report.test.mjs scripts/security/semgrep-entry.test.mjs scripts/security/run-semgrep.test.mjs scripts/security/semgrep-parse-gate.test.mjs scripts/security/provision-tools.test.mjs scripts/security/zizmor-report.test.mjs scripts/security/zizmor-entry.test.mjs scripts/security/run-zizmor.test.mjs scripts/security/provider-logo-regexp.test.mjs scripts/security/code-safety.test.mjs scripts/verify-macos-bundle.test.mjs scripts/macos-ci.test.mjs scripts/macos-preview-policy.test.mjs scripts/macos-preview-preflight.test.mjs scripts/macos-preview-artifact.test.mjs scripts/macos-preview-workflow.test.mjs scripts/updates-section-regression.test.mjs scripts/verify-linux-bundles.test.mjs scripts/macos-release-preflight.test.mjs scripts/macos-release-workflow.test.mjs scripts/macos-notary-workflow.test.mjs", + "test": "vitest run --dir src && node --test scripts/bump.test.mjs scripts/i18n-inventory.test.mjs scripts/release-check.test.mjs scripts/setup-signing.test.mjs scripts/dev-retirement.test.mjs scripts/uninstall.test.mjs scripts/security/check-repository.test.mjs scripts/security/semgrep-report.test.mjs scripts/security/export-snapshot.test.mjs scripts/security/snapshot-report.test.mjs scripts/security/semgrep-entry.test.mjs scripts/security/run-semgrep.test.mjs scripts/security/semgrep-parse-gate.test.mjs scripts/security/provision-tools.test.mjs scripts/security/zizmor-report.test.mjs scripts/security/zizmor-entry.test.mjs scripts/security/run-zizmor.test.mjs scripts/security/provider-logo-regexp.test.mjs scripts/security/code-safety.test.mjs scripts/verify-macos-bundle.test.mjs scripts/macos-ci.test.mjs scripts/macos-preview-policy.test.mjs scripts/macos-preview-preflight.test.mjs scripts/macos-preview-artifact.test.mjs scripts/macos-preview-workflow.test.mjs scripts/updates-section-regression.test.mjs scripts/verify-linux-bundles.test.mjs scripts/macos-release-preflight.test.mjs scripts/macos-release-workflow.test.mjs scripts/macos-notary-workflow.test.mjs scripts/macos-notary-checkpoint.test.mjs scripts/macos-notary-submit.test.mjs scripts/macos-notary-artifact.test.mjs scripts/macos-release-resume.test.mjs scripts/macos-release-resume-workflow.test.mjs scripts/macos-release-resume-publish.test.mjs", "tauri": "tauri", "app:dev": "tauri dev", "app:build": "tauri build", diff --git a/scripts/macos-notary-artifact-fixtures.mjs b/scripts/macos-notary-artifact-fixtures.mjs new file mode 100644 index 0000000..99eecb9 --- /dev/null +++ b/scripts/macos-notary-artifact-fixtures.mjs @@ -0,0 +1,62 @@ +// Synthetic offline fixtures, NOT Apple/macOS runtime evidence. +import { execFileSync } from 'node:child_process'; +import { hash } from './macos-notary-checkpoint.mjs'; + +export function zipFiles(files) { + return execFileSync('python3', ['-c', `import io,json,zipfile,sys,base64 +b=io.BytesIO() +with zipfile.ZipFile(b,'w',compression=zipfile.ZIP_STORED) as z: + for name,value in json.loads(sys.stdin.read()).items(): z.writestr(name,base64.b64decode(value)) +sys.stdout.buffer.write(b.getvalue())`], { + input: JSON.stringify(Object.fromEntries(Object.entries(files).map(([name, data]) => [name, Buffer.from(data).toString('base64')]))), + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +export function producerFixture() { + const workflowSha = 'a'.repeat(40); + const sourceSha = 'b'.repeat(40); + const submissionId = '12345678-1234-1234-1234-123456789abc'; + const trusted = { repository: 'egdev6/quorb', workflowPath: '.github/workflows/macos-release.yml', workflowSha, sourceSha, version: '0.8.0', releaseId: '42', runId: '123', runAttempt: '2' }; + const bytes = Buffer.from('synthetic signed DMG bytes'); + const manifest = { schema: 1, ...trusted, dmgName: 'Quorb_0.8.0_aarch64.dmg', size: bytes.length, sha256: hash(bytes) }; + const run = { id: 123, run_attempt: 2, repository: { id: 10, full_name: 'egdev6/quorb' }, head_repository: { id: 10, full_name: 'egdev6/quorb' }, + head_branch: 'main', event: 'workflow_dispatch', path: trusted.workflowPath, workflow_id: 99, head_sha: workflowSha, status: 'completed', conclusion: 'cancelled', + run_started_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:05:00Z' }; + const archives = new Map(); + const artifact = (name, id, files) => { + const zip = zipFiles(files); archives.set(id, zip); + return { id, name, expired: false, digest: `sha256:${hash(zip)}`, size_in_bytes: zip.length, + created_at: '2026-01-01T00:01:00Z', expires_at: '2099-01-01T00:00:00Z', + workflow_run: { id: 123, repository_id: 10, head_repository_id: 10, head_branch: 'main', head_sha: workflowSha } }; + }; + const checkpoint = artifact('macos-notary-checkpoint-123-2', 456, { 'manifest.json': JSON.stringify(manifest), [manifest.dmgName]: bytes }); + const receipt = { schema: 1, ...trusted, checkpointArtifactId: '456', checkpointArtifactDigest: checkpoint.digest.slice(7), submissionId, dmgSha256: manifest.sha256 }; + const receiptArtifact = artifact('macos-notary-receipt-123-2', 457, { 'receipt.json': JSON.stringify(receipt) }); + const artifacts = [checkpoint, receiptArtifact]; + const files = new Map([[trusted.workflowPath, 'reviewed workflow bytes']]); + const calls = []; + const io = { + readFileSync: (name) => files.get(name), + mkdirSync: (name) => { if (files.has(name)) throw new Error('exists'); files.set(name, null); }, + writeFileSync: (name, bytes, opts) => { if (files.has(name) || opts.flag !== 'wx') throw new Error('overwrite'); files.set(name, bytes); }, + }; + const env = { GITHUB_REF: 'refs/heads/main', GITHUB_REPOSITORY: trusted.repository, GITHUB_SHA: 'd'.repeat(40), + RELEASE_VERSION: trusted.version, RELEASE_SOURCE_SHA: sourceSha, RELEASE_ID: '42', SOURCE_RUN_ID: '123', SOURCE_RUN_ATTEMPT: '2', GH_TOKEN: 'synthetic' }; + const exec = (command, args) => { + calls.push([command, args]); + if (command !== 'git') throw new Error('Unexpected command'); + return args[0] === 'show' ? files.get(trusted.workflowPath) : ''; + }; + const fetch = async (url, options) => { + calls.push([url, options]); + const route = url.replace('https://api.github.com/repos/egdev6/quorb', ''); + if (route === '/actions/workflows/macos-release.yml') return Response.json({ id: 99, path: trusted.workflowPath }); + if (route === '/actions/runs/123/attempts/2' || route === '/actions/runs/123') return Response.json(run); + if (route.startsWith('/actions/runs/123/artifacts?')) return Response.json({ artifacts }); + const id = route.match(/^\/actions\/artifacts\/([0-9]+)\/zip$/)?.[1]; + if (id && archives.has(Number(id))) return new Response(archives.get(Number(id))); + throw new Error('Unexpected route SECRET'); + }; + return { env, io, exec, fetch, calls, files, run, artifacts, archives, manifest, receipt, checkpoint, receiptArtifact, bytes, submissionId }; +} diff --git a/scripts/macos-notary-artifact.mjs b/scripts/macos-notary-artifact.mjs new file mode 100644 index 0000000..afe746a --- /dev/null +++ b/scripts/macos-notary-artifact.mjs @@ -0,0 +1,169 @@ +import { execFileSync } from 'node:child_process'; +import { hash, positiveId, hex, MAX_DMG, repository, workflowPath } from './macos-notary-checkpoint.mjs'; + +const API = `https://api.github.com/repos/${repository}`; +const MAX_ZIP = MAX_DMG + 1024 * 1024; +const timestamp = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : NaN; + +export function validateRun(run, workflow, { runId, runAttempt }) { + if (!positiveId(runId) || !positiveId(runAttempt) || run?.id !== Number(runId) || run.run_attempt !== Number(runAttempt) || + !Number.isSafeInteger(workflow?.id) || workflow.id <= 0 || workflow.path !== workflowPath || + run.workflow_id !== workflow.id || run.path !== workflowPath || run.event !== 'workflow_dispatch' || + run.head_branch !== 'main' || run.repository?.full_name !== repository || run.head_repository?.full_name !== repository || + !Number.isSafeInteger(run.repository?.id) || run.repository.id <= 0 || run.repository.id !== run.head_repository?.id || + !hex(run.head_sha, 40) || run.status !== 'completed' || !Number.isFinite(timestamp(run.run_started_at)) || + !Number.isFinite(timestamp(run.updated_at)) || timestamp(run.updated_at) < timestamp(run.run_started_at)) { + throw new Error('Untrusted producer run/workflow/attempt'); + } + // Deliberately not checking conclusion=success: cancellation after checkpoint + // upload is recoverable. The attempt must have stopped before resuming it. + return run; +} + +export function validateArtifact(artifact, run, name, { now = Date.now(), end = timestamp(run.updated_at) } = {}) { + const linked = artifact?.workflow_run; + if (!Number.isSafeInteger(artifact?.id) || artifact.id <= 0 || artifact.name !== name || artifact.expired !== false || + !/^sha256:[0-9a-f]{64}$/.test(artifact.digest ?? '') || !Number.isSafeInteger(artifact.size_in_bytes) || + artifact.size_in_bytes < 1 || artifact.size_in_bytes > MAX_ZIP || timestamp(artifact.expires_at) <= now || + !Number.isFinite(timestamp(artifact.expires_at)) || !Number.isFinite(timestamp(artifact.created_at)) || + timestamp(artifact.created_at) < timestamp(run.run_started_at) || timestamp(artifact.created_at) > end || + linked?.id !== run.id || linked.repository_id !== run.repository.id || linked.head_repository_id !== run.repository.id || + linked.head_sha !== run.head_sha || linked.head_branch !== 'main') throw new Error('Untrusted artifact metadata/attempt/expiry'); + return artifact; +} + +export function selectArtifacts(artifacts, run, recovery = false, options = {}) { + const select = (kind, optional) => { + const name = `macos-notary-${kind}-${run.id}-${run.run_attempt}`; + const matches = artifacts.filter((artifact) => artifact.name === name); + if (matches.length > 1) throw new Error('Duplicate artifact name'); + if (!matches.length && optional) return null; + if (matches.length !== 1) throw new Error(`Missing ${kind} artifact`); + return validateArtifact(matches[0], run, name, options); + }; + return { checkpoint: select('checkpoint', false), receipt: select('receipt', recovery) }; +} + +async function boundedBytes(response, limit) { + if (!response.ok || response.status !== 200 || !response.body) throw new Error(); + const length = response.headers.get('content-length'); + if (length !== null && (!/^[0-9]+$/.test(length) || Number(length) > limit)) throw new Error(); + const chunks = []; + let size = 0; + const reader = response.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) throw new Error(); + chunks.push(value); + } + return Buffer.concat(chunks, size); + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } +} + +export function githubClient({ token, fetch = globalThis.fetch } = {}) { + if (typeof token !== 'string' || !token.trim() || /[\r\n]/.test(token)) throw new Error('Invalid GitHub read credentials'); + const signal = AbortSignal.timeout(120_000); + const headers = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + return { + async json(route) { + // Only locally constructed relative API routes; never server-supplied URLs. + if (!/^\/actions\/[A-Za-z0-9_./?=&-]+$/.test(route) || route.includes('..')) throw new Error('Invalid artifact API route'); + try { + const response = await fetch(API + route, { headers, redirect: 'error', signal }); + return JSON.parse((await boundedBytes(response, 1024 * 1024)).toString('utf8')); + } catch { throw new Error('Unable to read trusted GitHub Actions metadata'); } + }, + async artifacts(runId) { + if (!positiveId(runId)) throw new Error('Invalid run ID'); + const result = []; + for (let page = 1; page <= 20; page++) { + const data = await this.json(`/actions/runs/${runId}/artifacts?per_page=100&page=${page}`); + if (!Array.isArray(data.artifacts) || data.artifacts.length > 100) throw new Error('Invalid artifact listing'); + result.push(...data.artifacts); + if (data.artifacts.length < 100) return result; + } + throw new Error('Artifact listing truncated'); + }, + }; +} + +export async function downloadZip(artifact, { token, fetch = globalThis.fetch } = {}) { + if (!Number.isSafeInteger(artifact?.id) || artifact.id <= 0 || !/^sha256:[0-9a-f]{64}$/.test(artifact.digest ?? '') || + !Number.isSafeInteger(artifact.size_in_bytes) || artifact.size_in_bytes < 1 || artifact.size_in_bytes > MAX_ZIP || + typeof token !== 'string' || !token.trim() || /[\r\n]/.test(token)) throw new Error('Invalid artifact download context'); + try { + const signal = AbortSignal.timeout(120_000); + let response = await fetch(`${API}/actions/artifacts/${artifact.id}/zip`, { + method: 'GET', redirect: 'manual', signal, + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + }); + if (response.status === 302) { + const location = new URL(response.headers.get('location')); + // GitHub currently redirects artifact archives to Azure blob storage. + // No alternate user endpoints and no credentials forwarded to blob hosts. + if (location.protocol !== 'https:' || location.username || location.password || location.port || location.hash || + !/^[a-z0-9]+\.blob\.core\.windows\.net$/.test(location.hostname)) throw new Error(); + await response.body?.cancel(); + response = await fetch(location.href, { method: 'GET', redirect: 'error', signal }); + } + const bytes = await boundedBytes(response, Math.min(MAX_ZIP, artifact.size_in_bytes)); + if (bytes.length !== artifact.size_in_bytes || `sha256:${hash(bytes)}` !== artifact.digest) { + throw new Error('digest'); + } + return bytes; + } catch (error) { + throw new Error(error.message === 'digest' ? 'Artifact ZIP digest/size mismatch' : 'Artifact ZIP download failed'); + } +} + +// Validate *before* writing anything. Python's real ZIP parser handles central +// directory and CRC, with a flat exact allowlist and bounded decompression. +export const ZIP_PARSER = String.raw`import sys, io, json, zipfile, base64, stat + +limits = json.loads(sys.argv[1]) +raw = sys.stdin.buffer.read(537919489) +if len(raw) > 537919488: + raise ValueError('ZIP size') +with zipfile.ZipFile(io.BytesIO(raw)) as archive: + entries = archive.infolist() + if len(entries) != len(limits) or len({entry.filename for entry in entries}) != len(entries): + raise ValueError('ZIP entries') + output = {} + for entry in entries: + name = entry.filename + mode = entry.external_attr >> 16 + if entry.orig_filename != name or name not in limits or '/' in name or '\\' in name or entry.is_dir(): + raise ValueError('ZIP path') + if stat.S_IFMT(mode) not in (0, stat.S_IFREG) or entry.flag_bits & 1: + raise ValueError('ZIP type') + if entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise ValueError('ZIP compression') + if entry.file_size < 0 or entry.file_size > limits[name] or entry.file_size > max(1, entry.compress_size) * 200: + raise ValueError('ZIP bomb') + with archive.open(entry) as source: + data = source.read(limits[name] + 1) + if len(data) != entry.file_size or len(data) > limits[name] or source.read(1): + raise ValueError('ZIP size') + output[name] = base64.b64encode(data).decode('ascii') + if set(output) != set(limits): + raise ValueError('ZIP allowlist') + sys.stdout.write(json.dumps(output)) +`; + +export function extractZip(bytes, limits, run = execFileSync) { + try { + if (!Buffer.isBuffer(bytes) || bytes.length > MAX_ZIP || !Object.keys(limits).length || + Object.entries(limits).some(([name, size]) => !/^[A-Za-z0-9_.-]+$/.test(name) || !Number.isSafeInteger(size) || size < 1 || size > MAX_DMG)) throw new Error(); + const result = JSON.parse(run('python3', ['-c', ZIP_PARSER, JSON.stringify(limits)], { + input: bytes, timeout: 120_000, maxBuffer: MAX_ZIP * 2, stdio: ['pipe', 'pipe', 'pipe'], + }).toString('utf8')); + if (Object.keys(result).sort().join(',') !== Object.keys(limits).sort().join(',')) throw new Error(); + return new Map(Object.entries(result).map(([name, data]) => { + if (typeof data !== 'string' || Buffer.byteLength(data, 'base64') > limits[name]) throw new Error(); + return [name, Buffer.from(data, 'base64')]; + })); + } catch { throw new Error('Unsafe or invalid artifact ZIP'); } +} diff --git a/scripts/macos-notary-artifact.test.mjs b/scripts/macos-notary-artifact.test.mjs new file mode 100644 index 0000000..b31a18a --- /dev/null +++ b/scripts/macos-notary-artifact.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { validateRun, selectArtifacts, extractZip, downloadZip, githubClient } from './macos-notary-artifact.mjs'; + +const workflow = { id: 99, path: '.github/workflows/macos-release.yml' }; +const run = { id: 123, run_attempt: 2, repository: { id: 10, full_name: 'egdev6/quorb' }, head_repository: { id: 10, full_name: 'egdev6/quorb' }, + head_branch: 'main', event: 'workflow_dispatch', path: workflow.path, workflow_id: 99, head_sha: 'a'.repeat(40), status: 'completed', conclusion: 'cancelled', + run_started_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:05:00Z' }; +const artifact = (name, id) => ({ id, name, expired: false, digest: `sha256:${'c'.repeat(64)}`, size_in_bytes: 200, + created_at: '2026-01-01T00:01:00Z', expires_at: '2099-01-01T00:00:00Z', + workflow_run: { id: 123, repository_id: 10, head_repository_id: 10, head_branch: 'main', head_sha: run.head_sha } }); +const checkpoint = artifact('macos-notary-checkpoint-123-2', 1); +const receipt = artifact('macos-notary-receipt-123-2', 2); + +test('canceled same-repo producer is recoverable, but provenance and attempts are strict', () => { + assert.doesNotThrow(() => validateRun(run, workflow, { runId: '123', runAttempt: '2' })); + for (const patch of [{ id: 124 }, { run_attempt: 1 }, { head_repository: { id: 11, full_name: 'fork/quorb' } }, { repository: { id: 11, full_name: 'other/quorb' } }, + { head_branch: 'feature' }, { event: 'push' }, { path: 'other.yml' }, { workflow_id: 100 }, { head_sha: '--help' }, { status: 'in_progress' }]) { + assert.throws(() => validateRun({ ...run, ...patch }, workflow, { runId: '123', runAttempt: '2' }), /producer/i); + } + assert.deepEqual(selectArtifacts([checkpoint, receipt], run), { checkpoint, receipt }); + assert.deepEqual(selectArtifacts([checkpoint], run, true), { checkpoint, receipt: null }); + assert.throws(() => selectArtifacts([checkpoint], run), /receipt/i); + for (const patch of [{ expired: true }, { expires_at: '2020-01-01' }, { created_at: '2025-12-31' }, { digest: null }, { name: 'macos-notary-checkpoint-123-1' }, + { workflow_run: { ...checkpoint.workflow_run, id: 999 } }, { workflow_run: { ...checkpoint.workflow_run, head_repository_id: 11 } }]) { + assert.throws(() => selectArtifacts([{ ...checkpoint, ...patch }, receipt], run), /artifact|checkpoint/i); + } + assert.throws(() => selectArtifacts([checkpoint, checkpoint, receipt], run), /duplicate/i); + assert.throws(() => selectArtifacts([checkpoint, receipt, receipt], run, true), /duplicate/i); +}); + +function zip(entries) { + return execFileSync('python3', ['-c', `import io,json,zipfile,sys,base64 +b=io.BytesIO() +with zipfile.ZipFile(b,'w') as z: + for e in json.loads(sys.stdin.read()): + i=zipfile.ZipInfo(e['name']); i.external_attr=e.get('mode',0)<<16 + z.writestr(i,e.get('data','').encode()) +sys.stdout.buffer.write(b.getvalue())`], { input: JSON.stringify(entries) }); +} + +test('real in-memory Python ZIP parser rejects extras, traversal, symlinks, duplicates and oversize before extraction', () => { + const good = zip([{ name: 'manifest.json', data: '{}' }, { name: 'Quorb.dmg', data: 'DMG' }]); + assert.equal(extractZip(good, { 'manifest.json': 65536, 'Quorb.dmg': 100 }).get('Quorb.dmg').toString(), 'DMG'); + for (const entries of [ + [{ name: '../Quorb.dmg' }, { name: 'manifest.json' }], + [{ name: 'Quorb.dmg', mode: 0o120777 }, { name: 'manifest.json' }], + [{ name: 'Quorb.dmg' }, { name: 'manifest.json' }, { name: 'extra' }], + [{ name: 'Quorb.dmg' }, { name: 'Quorb.dmg' }], + [{ name: 'Quorb.dmg', data: 'too large' }, { name: 'manifest.json' }], + ]) assert.throws(() => extractZip(zip(entries), { 'manifest.json': 65536, 'Quorb.dmg': 3 }), /ZIP/); + assert.throws(() => extractZip(Buffer.from('not zip'), { 'manifest.json': 100 }), /ZIP/); + const bomb = execFileSync('python3', ['-c', `import io,zipfile,sys +b=io.BytesIO() +with zipfile.ZipFile(b,'w',compression=zipfile.ZIP_DEFLATED) as z: z.writestr('Quorb.dmg',b'0'*1000000) +sys.stdout.buffer.write(b.getvalue())`]); + assert.throws(() => extractZip(bomb, { 'Quorb.dmg': 1000000 }), /ZIP/); + const crc = Buffer.from(good); crc[40] ^= 0xff; + assert.throws(() => extractZip(crc, { 'manifest.json': 65536, 'Quorb.dmg': 100 }), /ZIP/); +}); + +test('ZIP allowlist rejects NUL-truncated filenames rather than trusting parser normalization', () => { + const hidden = zip([{ name: 'Quorb.dmgXevil', data: 'DMG' }]); + const name = Buffer.from('Quorb.dmgXevil'); + for (const index of [hidden.indexOf(name), hidden.lastIndexOf(name)]) hidden[index + 'Quorb.dmg'.length] = 0; + assert.throws(() => extractZip(hidden, { 'Quorb.dmg': 3 }), /ZIP/); +}); + +test('authenticated API raw ZIP digest is fatal before parsing; redirects never receive bearer', async () => { + const bytes = Buffer.from('raw synthetic ZIP'); + const meta = { ...checkpoint, size_in_bytes: bytes.length, digest: `sha256:${createHash('sha256').update(bytes).digest('hex')}` }; + const calls = []; + const fetch = async (url, options) => { + calls.push([url, options]); + if (calls.length === 1) return new Response(null, { status: 302, headers: { location: 'https://productionresultssa0.blob.core.windows.net/actions/a?sig=synthetic' } }); + return new Response(bytes); + }; + assert.deepEqual(await downloadZip(meta, { token: 'synthetic', fetch }), bytes); + assert.match(calls[0][0], /^https:\/\/api.github.com\/repos\/egdev6\/quorb\/actions\/artifacts\/1\/zip$/); + assert.equal(calls[0][1].redirect, 'manual'); + assert.ok(calls[0][1].headers.Authorization); + assert.equal(calls[1][1].headers?.Authorization, undefined); + await assert.rejects(downloadZip({ ...meta, digest: `sha256:${'d'.repeat(64)}` }, { token: 'synthetic', fetch: async () => new Response(bytes) }), /digest/i); + await assert.rejects(downloadZip(meta, { token: 'synthetic', fetch: async () => new Response(null, { status: 302, headers: { location: 'https://attacker.example/zip' } }) }), /download/i); + for (const response of [new Response(bytes, { headers: { 'content-length': '999999999' } }), new Response(Buffer.concat([bytes, bytes])), new Response('SECRET', { status: 403 })]) { + await assert.rejects(downloadZip(meta, { token: 'synthetic', fetch: async () => response }), /download/i); + } +}); + +test('metadata client paginates completely, bounds data, rejects arbitrary endpoints and sanitizes failures', async () => { + const calls = []; + const api = githubClient({ token: 'synthetic', fetch: async (url) => { + calls.push(url); + return Response.json({ artifacts: url.endsWith('page=1') ? Array(100).fill(checkpoint) : [receipt] }); + } }); + assert.equal((await api.artifacts('123')).length, 101); + assert.equal(calls.length, 2); + for (const route of ['https://attacker.example', '/releases/42', '/actions/../other']) await assert.rejects(api.json(route), /route/); + assert.equal(calls.length, 2); + for (const fetch of [async () => { throw new Error('SECRET'); }, async () => new Response('not JSON'), async () => new Response('x'.repeat(1024 * 1024 + 1)), async () => new Response(null, { status: 302 })]) { + await assert.rejects(githubClient({ token: 'synthetic', fetch }).json('/actions/runs/123'), (error) => !error.message.includes('SECRET')); + } + let pages = 0; + const truncated = githubClient({ token: 'synthetic', fetch: async () => { pages++; return Response.json({ artifacts: Array(100).fill(checkpoint) }); } }); + await assert.rejects(truncated.artifacts('123'), /truncated/); + assert.equal(pages, 20); +}); diff --git a/scripts/macos-notary-checkpoint.mjs b/scripts/macos-notary-checkpoint.mjs new file mode 100644 index 0000000..da1a4f8 --- /dev/null +++ b/scripts/macos-notary-checkpoint.mjs @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { verifyMacosBundle, verifyDeveloperSignature } from './verify-macos-bundle.mjs'; +import { validateReleaseInputs } from './macos-release-preflight.mjs'; + +export const repository = 'egdev6/quorb'; +export const workflowPath = '.github/workflows/macos-release.yml'; +export const MAX_DMG = 512 * 1024 * 1024; +export const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +export const positiveId = (value) => typeof value === 'string' && /^[1-9][0-9]*$/.test(value) && Number.isSafeInteger(Number(value)); +export const uuid = (value) => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +export const hex = (value, length) => typeof value === 'string' && new RegExp(`^[0-9a-f]{${length}}$`).test(value); +const bindingKeys = ['repository', 'workflowPath', 'workflowSha', 'sourceSha', 'version', 'releaseId', 'runId', 'runAttempt']; +const exactKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).sort().join(',') === [...keys].sort().join(','); + +export function validateTrusted(trusted) { + validateReleaseInputs({ ref: 'refs/heads/main', version: trusted.version, sourceSha: trusted.sourceSha }); + if (trusted.repository !== repository || trusted.workflowPath !== workflowPath || !hex(trusted.workflowSha, 40) || + !['releaseId', 'runId', 'runAttempt'].every((key) => positiveId(trusted[key]))) throw new Error('Invalid checkpoint context'); + return trusted; +} + +export function trustedEnvironment(env = process.env) { + if (env.GITHUB_REF !== 'refs/heads/main') throw new Error('Checkpoint requires main'); + return validateTrusted({ repository: env.GITHUB_REPOSITORY, workflowPath, workflowSha: env.GITHUB_SHA, + sourceSha: env.RELEASE_SOURCE_SHA, version: env.RELEASE_VERSION, releaseId: env.RELEASE_ID, + runId: env.GITHUB_RUN_ID, runAttempt: env.GITHUB_RUN_ATTEMPT }); +} + +export function validateManifest(value, trusted) { + validateTrusted(trusted); + if (!exactKeys(value, ['schema', ...bindingKeys, 'dmgName', 'size', 'sha256']) || value.schema !== 1 || + bindingKeys.some((key) => value[key] !== trusted[key]) || value.dmgName !== `Quorb_${trusted.version}_aarch64.dmg` || + !Number.isSafeInteger(value.size) || value.size < 1 || value.size > MAX_DMG || !hex(value.sha256, 64)) { + throw new Error('Invalid checkpoint manifest'); + } + return value; +} + +export function validateDmg(bytes, manifest) { + if (bytes.length !== manifest.size || hash(bytes) !== manifest.sha256) throw new Error('Retained DMG hash/size mismatch'); +} + +export function validateReceipt(value, trusted, artifact, manifest) { + if (!exactKeys(value, ['schema', ...bindingKeys, 'checkpointArtifactId', 'checkpointArtifactDigest', 'submissionId', 'dmgSha256']) || + value.schema !== 1 || bindingKeys.some((key) => value[key] !== trusted[key]) || !uuid(value.submissionId) || + value.checkpointArtifactId !== String(artifact.id) || value.checkpointArtifactDigest !== artifact.digest?.replace(/^sha256:/, '') || + !hex(value.checkpointArtifactDigest, 64) || value.dmgSha256 !== manifest.sha256) throw new Error('Invalid submission receipt'); + return value; +} + +// Tauri bundler 2.10.1 sign.rs notarize_auth checks both flows. Absent, not empty: +// API key/issuer presence alone can trigger its private_keys directory search. +export function signingEnvironment(env) { + return Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith('APPLE_API_') && + !['APPLE_ID', 'APPLE_PASSWORD', 'APPLE_TEAM_ID'].includes(key))); +} + +export function buildSigned({ env = process.env, run = execFileSync } = {}) { + if (!['APPLE_CERTIFICATE', 'APPLE_CERTIFICATE_PASSWORD', 'APPLE_SIGNING_IDENTITY'].every((key) => env[key]?.trim())) { + throw new Error('Missing signing credentials'); + } + try { + run('npm', ['run', 'app:build:mac'], { cwd: 'source', env: signingEnvironment(env), stdio: 'inherit' }); + } catch { throw new Error('Signed build failed'); } +} + +export function createCheckpoint({ env = process.env, io = fs, verify = verifyMacosBundle, signature = verifyDeveloperSignature } = {}) { + const trusted = trustedEnvironment(env); + const result = verify({ root: path.resolve('source'), sourceSha: trusted.sourceSha, io }); + if (result.version !== trusted.version || path.basename(result.dmg) !== `Quorb_${trusted.version}_aarch64.dmg`) throw new Error('Unexpected release bundle'); + signature(result.app); + signature(result.dmg); + const bytes = io.readFileSync(result.dmg); + const manifest = validateManifest({ schema: 1, ...trusted, dmgName: path.basename(result.dmg), size: bytes.length, sha256: hash(bytes) }, trusted); + io.mkdirSync('notary-checkpoint'); // exclusive, no stale files or directory merges + io.writeFileSync(path.join('notary-checkpoint', manifest.dmgName), bytes, { flag: 'wx' }); + io.writeFileSync('notary-checkpoint/manifest.json', JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx' }); + return manifest; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + if (process.argv[2] === 'build') buildSigned(); + else if (process.argv[2] === 'stage') createCheckpoint(); + else throw new Error('Expected build or stage'); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/macos-notary-checkpoint.test.mjs b/scripts/macos-notary-checkpoint.test.mjs new file mode 100644 index 0000000..8c825d2 --- /dev/null +++ b/scripts/macos-notary-checkpoint.test.mjs @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { validateManifest, validateDmg, validateReceipt, signingEnvironment, createCheckpoint, buildSigned } from './macos-notary-checkpoint.mjs'; +import { submitCheckpoint } from './macos-notary-submit.mjs'; + +export const trusted = { + repository: 'egdev6/quorb', workflowPath: '.github/workflows/macos-release.yml', + workflowSha: 'a'.repeat(40), sourceSha: 'b'.repeat(40), version: '0.8.0', + releaseId: '42', runId: '123', runAttempt: '2', +}; +export const bytes = Buffer.from('synthetic signed DMG'); +export const manifest = { + schema: 1, ...trusted, dmgName: 'Quorb_0.8.0_aarch64.dmg', size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), +}; +export const receipt = { + schema: 1, ...trusted, checkpointArtifactId: '456', checkpointArtifactDigest: 'c'.repeat(64), + submissionId: '12345678-1234-1234-1234-123456789abc', dmgSha256: manifest.sha256, +}; + +test('manifest and receipt are strictly bound to externally trusted values, not self-authenticated', () => { + assert.deepEqual(validateManifest(manifest, trusted), manifest); + assert.doesNotThrow(() => validateDmg(bytes, manifest)); + assert.deepEqual(validateReceipt(receipt, trusted, { id: 456, digest: `sha256:${'c'.repeat(64)}` }, manifest), receipt); + for (const key of Object.keys(trusted)) { + assert.throws(() => validateManifest({ ...manifest, [key]: 'wrong' }, trusted), /manifest/i); + assert.throws(() => validateReceipt({ ...receipt, [key]: 'wrong' }, trusted, { id: 456, digest: `sha256:${'c'.repeat(64)}` }, manifest), /receipt/i); + } + for (const change of [{ extra: true }, { sha256: '' }, { size: 0 }, { size: 1.5 }, { dmgName: '../other.dmg' }, { schema: 2 }]) { + assert.throws(() => validateManifest({ ...manifest, ...change }, trusted), /manifest/i); + } + assert.throws(() => validateDmg(Buffer.from('tampered'), manifest), /DMG/); + for (const change of [{ submissionId: '--help' }, { checkpointArtifactId: '457' }, { checkpointArtifactDigest: 'd'.repeat(64) }, { dmgSha256: 'f'.repeat(64) }, { extra: true }]) { + assert.throws(() => validateReceipt({ ...receipt, ...change }, trusted, { id: 456, digest: `sha256:${'c'.repeat(64)}` }, manifest), /receipt/i); + } +}); + +test('build removes every Tauri notarization credential including alternate Apple ID flow', () => { + const env = signingEnvironment({ PATH: '/bin', APPLE_CERTIFICATE: 'cert', APPLE_CERTIFICATE_PASSWORD: 'pass', APPLE_SIGNING_IDENTITY: 'id', + APPLE_API_KEY: 'key', APPLE_API_KEY_PATH: 'path', APPLE_API_ISSUER: 'issuer', APPLE_API_KEY_P8: 'private', + APPLE_ID: 'id', APPLE_PASSWORD: 'password', APPLE_TEAM_ID: 'team' }); + assert.deepEqual(env, { PATH: '/bin', APPLE_CERTIFICATE: 'cert', APPLE_CERTIFICATE_PASSWORD: 'pass', APPLE_SIGNING_IDENTITY: 'id' }); + let calls = 0; + buildSigned({ env: { ...env, APPLE_API_KEY: 'inherited', APPLE_ID: 'inherited' }, run: (command, args, options) => { + calls++; + assert.equal(command, 'npm'); assert.deepEqual(args, ['run', 'app:build:mac']); + assert.equal(options.cwd, 'source'); assert.deepEqual(options.env, env); + } }); + assert.equal(calls, 1); +}); + +test('checkpoint contains only signed DMG and strict manifest; successful upload binding is required before key creation or submit', () => { + const env = { GITHUB_REF: 'refs/heads/main', GITHUB_REPOSITORY: trusted.repository, GITHUB_SHA: trusted.workflowSha, + GITHUB_RUN_ID: trusted.runId, GITHUB_RUN_ATTEMPT: trusted.runAttempt, RELEASE_SOURCE_SHA: trusted.sourceSha, RELEASE_VERSION: trusted.version, RELEASE_ID: trusted.releaseId }; + const files = new Map([['/synthetic/Quorb_0.8.0_aarch64.dmg', bytes]]); + const signatures = []; + const io = { + readFileSync: (name) => files.get(name), + mkdirSync: (name) => { assert.ok(!files.has(name)); files.set(name, null); }, + writeFileSync: (name, data, options) => { assert.equal(options.flag, 'wx'); assert.ok(!files.has(name)); files.set(name, data); }, + }; + const result = createCheckpoint({ env, io, verify: () => ({ version: '0.8.0', app: '/synthetic/Quorb.app', dmg: '/synthetic/Quorb_0.8.0_aarch64.dmg' }), signature: (target) => signatures.push(target) }); + assert.deepEqual(result, manifest); + assert.equal(signatures.length, 2); + assert.deepEqual([...files.keys()].filter((name) => name.startsWith('notary-checkpoint/')).sort(), ['notary-checkpoint/Quorb_0.8.0_aarch64.dmg', 'notary-checkpoint/manifest.json']); + let keyCalls = 0; + const key = (callback) => { keyCalls++; return callback([]); }; + assert.throws(() => submitCheckpoint({ env, io, key }), /upload must succeed/); + assert.equal(keyCalls, 0); + const uploaded = { ...env, CHECKPOINT_ARTIFACT_ID: '456', CHECKPOINT_ARTIFACT_DIGEST: 'c'.repeat(64) }; + const saved = submitCheckpoint({ env: uploaded, io, key, run: (command, args) => { + assert.ok(files.has('notary-checkpoint/manifest.json')); + assert.ok(!args.includes('--wait')); return JSON.stringify({ id: receipt.submissionId }); + } }); + assert.deepEqual(saved, receipt); + assert.equal(keyCalls, 1); + assert.deepEqual(JSON.parse(files.get('notary-receipt/receipt.json')), receipt); +}); diff --git a/scripts/macos-notary-submit.mjs b/scripts/macos-notary-submit.mjs new file mode 100644 index 0000000..fafc278 --- /dev/null +++ b/scripts/macos-notary-submit.mjs @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { trustedEnvironment, validateManifest, validateDmg, positiveId, hex, uuid } from './macos-notary-checkpoint.mjs'; + +const execute = (command, args) => execFileSync(command, args, { + encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'], +}); + +export function withNotaryKey(callback, { env = process.env, io = fs } = {}) { + if (!positiveId(env.GITHUB_RUN_ID) || !positiveId(env.GITHUB_RUN_ATTEMPT) || !path.isAbsolute(env.RUNNER_TEMP ?? '') || + !['APPLE_API_KEY_P8', 'APPLE_API_KEY', 'APPLE_API_ISSUER'].every((key) => typeof env[key] === 'string' && env[key].trim())) { + throw new Error('Missing notarization credentials/context'); + } + const keyPath = path.join(env.RUNNER_TEMP, `quorb-notary-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.p8`); + let written = false; + try { + io.writeFileSync(keyPath, env.APPLE_API_KEY_P8, { mode: 0o600, flag: 'wx' }); + written = true; + return callback(['--key', keyPath, '--key-id', env.APPLE_API_KEY, '--issuer', env.APPLE_API_ISSUER]); + } catch (error) { + // Only our closed diagnostics survive. Never print execFileSync errors/args. + if (error?.safeNotary === true) throw error; + throw new Error('Notarization operation failed'); + } finally { + if (written) { + try { io.unlinkSync(keyPath); } + catch { throw new Error('Notarization private key cleanup failed'); } + } + } +} + +function safeError(message) { + return Object.assign(new Error(message), { safeNotary: true }); +} + +export function submitDmg(dmg, auth, run = execute) { + try { + const response = JSON.parse(run('xcrun', ['notarytool', 'submit', dmg, ...auth, '--output-format', 'json'])); + if (!uuid(response.id)) throw new Error(); + return response.id.toLowerCase(); + } catch { + throw safeError('Submission ambiguous: use diagnostics/history/info/log recovery; never retry submit'); + } +} + +const safeText = (value) => typeof value === 'string' ? value.slice(0, 500).replace(/[^a-zA-Z0-9 ._/@()\-]/g, '?') : ''; +export function querySubmission(id, sha256, auth, run = execute) { + if (!uuid(id) || !hex(sha256, 64)) throw safeError('Invalid submission UUID/hash'); + id = id.toLowerCase(); + let info; + try { info = JSON.parse(run('xcrun', ['notarytool', 'info', id, ...auth, '--output-format', 'json'])); } + catch { throw safeError('Unable to query submission info'); } + if (!uuid(info.id) || info.id.toLowerCase() !== id) throw safeError('Submission identity mismatch'); + if (info.status === 'In Progress') return { outcome: 'pending' }; + if (!['Accepted', 'Invalid', 'Rejected'].includes(info.status)) throw safeError('Unknown submission status'); + let log; + try { + // log is already JSON; --output-format is NOT supported by this subcommand. + log = JSON.parse(run('xcrun', ['notarytool', 'log', id, ...auth])); + } catch { + if (info.status === 'Accepted') return { outcome: 'pending' }; // service has not made log available yet + throw safeError('Unable to retrieve rejection log'); + } + if (info.status !== 'Accepted') { + return { outcome: 'rejected', issues: Array.isArray(log.issues) ? log.issues.slice(0, 50).map((issue) => ({ + severity: safeText(issue?.severity), path: safeText(issue?.path), message: safeText(issue?.message), + })) : [] }; + } + // Synthetic tests exercise sha256; availability in a real Apple log remains an + // explicit runtime prerequisite. Absence must never silently weaken this gate. + if (!uuid(log.jobId) || log.jobId.toLowerCase() !== id || typeof log.sha256 !== 'string' || + log.sha256.toLowerCase() !== sha256 || log.status !== 'Accepted') throw safeError('Apple log UUID/hash/status binding failed'); + return { outcome: 'accepted', submissionId: id }; +} + +export function submitCheckpoint({ env = process.env, io = fs, run = execute, key = withNotaryKey } = {}) { + const trusted = trustedEnvironment(env); + if (!positiveId(env.CHECKPOINT_ARTIFACT_ID) || !hex(env.CHECKPOINT_ARTIFACT_DIGEST, 64)) throw new Error('Checkpoint upload must succeed before submit'); + const manifest = validateManifest(JSON.parse(io.readFileSync('notary-checkpoint/manifest.json', 'utf8')), trusted); + const dmg = path.join('notary-checkpoint', manifest.dmgName); + validateDmg(io.readFileSync(dmg), manifest); + // Directory exists before submit, so a later local setup failure cannot lose UUID. + io.mkdirSync('notary-receipt'); + const submissionId = key((auth) => submitDmg(dmg, auth, run), { env, io }); + console.log(`Submission ID: ${submissionId}`); + const receipt = { schema: 1, ...trusted, checkpointArtifactId: env.CHECKPOINT_ARTIFACT_ID, + checkpointArtifactDigest: env.CHECKPOINT_ARTIFACT_DIGEST, submissionId, dmgSha256: manifest.sha256 }; + io.writeFileSync('notary-receipt/receipt.json', JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' }); + if (env.GITHUB_STEP_SUMMARY) io.appendFileSync(env.GITHUB_STEP_SUMMARY, + `## Submitted; not a completed release\nSubmission: \`${submissionId}\`\nSource run: ${trusted.runId}, attempt: ${trusted.runAttempt}\nCheckpoint artifact: ${env.CHECKPOINT_ARTIFACT_ID}\nResume manually after Apple processing. Do not resubmit.\n`); + return receipt; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { submitCheckpoint(); } + catch (error) { + console.error(error.safeNotary ? error.message : 'Checkpoint submit failed; inspect receipt/diagnostics before any recovery; never auto-resubmit'); + process.exitCode = 1; + } +} diff --git a/scripts/macos-notary-submit.test.mjs b/scripts/macos-notary-submit.test.mjs new file mode 100644 index 0000000..77008fc --- /dev/null +++ b/scripts/macos-notary-submit.test.mjs @@ -0,0 +1,62 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { submitDmg, querySubmission, withNotaryKey } from './macos-notary-submit.mjs'; + +const id = '12345678-1234-1234-1234-123456789abc'; +const sha256 = 'a'.repeat(64); +const auth = ['--key', '/private/key.p8', '--key-id', 'synthetic', '--issuer', 'synthetic']; + +test('submit calls once, never waits or retries; errors cannot leak command credentials', () => { + const calls = []; + assert.equal(submitDmg('/checkpoint/Quorb.dmg', auth, (cmd, args) => { + calls.push([cmd, args]); return JSON.stringify({ id }); + }), id); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], ['xcrun', ['notarytool', 'submit', '/checkpoint/Quorb.dmg', ...auth, '--output-format', 'json']]); + let count = 0; + assert.throws(() => submitDmg('/checkpoint/Quorb.dmg', auth, () => { count++; throw new Error('SECRET'); }), /ambiguous.*diagnostics/i); + assert.equal(count, 1); + assert.throws(() => submitDmg('/checkpoint/Quorb.dmg', auth, () => '{}'), /ambiguous/i); +}); + +test('pending never requests log; accepted requires exact UUID and synthetic pre-staple hash', () => { + for (const status of ['In Progress', 'Accepted']) { + const calls = []; + const result = querySubmission(id, sha256, auth, (cmd, args) => { + calls.push(args); + if (args[1] === 'info') return JSON.stringify({ id, status }); + return JSON.stringify({ jobId: id.toUpperCase(), status: 'Accepted', sha256 }); + }); + assert.equal(result.outcome, status === 'In Progress' ? 'pending' : 'accepted'); + assert.deepEqual(calls.map((args) => args[1]), status === 'In Progress' ? ['info'] : ['info', 'log']); + if (calls[1]) assert.ok(!calls[1].includes('--output-format')); + } + for (const log of [{}, { jobId: id, status: 'Accepted' }, { jobId: id, status: 'Accepted', sha256: 'b'.repeat(64) }, { jobId: '00000000-0000-0000-0000-000000000000', status: 'Accepted', sha256 }]) { + assert.throws(() => querySubmission(id, sha256, auth, (cmd, args) => JSON.stringify(args[1] === 'info' ? { id, status: 'Accepted' } : log)), /binding/i); + } + assert.equal(querySubmission(id, sha256, auth, (cmd, args) => { + if (args[1] === 'info') return JSON.stringify({ id, status: 'Accepted' }); + throw new Error('not ready SECRET'); + }).outcome, 'pending'); +}); + +test('invalid status/UUID and rejected issues fail safely; log does not accept output-format', () => { + assert.throws(() => querySubmission('--help', sha256, auth, () => assert.fail()), /UUID/); + assert.throws(() => querySubmission(id, sha256, auth, () => JSON.stringify({ id: 'wrong', status: 'Accepted' })), /identity/); + assert.throws(() => querySubmission(id, sha256, auth, () => JSON.stringify({ id, status: 'unknown' })), /status/); + const result = querySubmission(id, sha256, auth, (cmd, args) => JSON.stringify(args[1] === 'info' ? { id, status: 'Invalid' } : { + issues: [{ severity: 'error', message: 'bad\n::error::untrusted', path: 'Quorb.app', secret: 'SECRET' }], + })); + assert.equal(result.outcome, 'rejected'); + assert.doesNotMatch(JSON.stringify(result), /SECRET|::error::/); +}); + +test('private key is exclusive 0600 and removed even after failure; no raw error leaks', () => { + const calls = []; + const io = { writeFileSync: (...args) => calls.push(['write', ...args]), unlinkSync: (...args) => calls.push(['unlink', ...args]) }; + const env = { RUNNER_TEMP: '/controlled', GITHUB_RUN_ID: '42', GITHUB_RUN_ATTEMPT: '1', APPLE_API_KEY_P8: 'synthetic', APPLE_API_KEY: 'key', APPLE_API_ISSUER: 'issuer' }; + assert.throws(() => withNotaryKey(() => { throw new Error('SECRET'); }, { env, io }), /Notarization operation failed/); + assert.deepEqual(calls[0].slice(2), ['synthetic', { mode: 0o600, flag: 'wx' }]); + assert.equal(calls[1][0], 'unlink'); + assert.equal(calls[0][1], calls[1][1]); +}); diff --git a/scripts/macos-release-resume-publish.mjs b/scripts/macos-release-resume-publish.mjs new file mode 100644 index 0000000..98b52e1 --- /dev/null +++ b/scripts/macos-release-resume-publish.mjs @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { preflightRelease, validateReleaseInputs } from './macos-release-preflight.mjs'; +import { repository, positiveId, hex, hash, MAX_DMG } from './macos-notary-checkpoint.mjs'; +import { githubClient, validateArtifact, downloadZip, extractZip } from './macos-notary-artifact.mjs'; + +export async function loadFinalArtifact({ env = process.env, io = fs, fetch = globalThis.fetch } = {}) { + validateReleaseInputs({ ref: env.GITHUB_REF, version: env.RELEASE_VERSION, sourceSha: env.RELEASE_SOURCE_SHA }); + if (env.GITHUB_REPOSITORY !== repository || !positiveId(env.GITHUB_REPOSITORY_ID) || !positiveId(env.GITHUB_RUN_ID) || + !positiveId(env.GITHUB_RUN_ATTEMPT) || !positiveId(env.FINAL_ARTIFACT_ID) || !hex(env.FINAL_ARTIFACT_DIGEST, 64) || + !hex(env.FINAL_SHA256, 64) || !hex(env.GITHUB_SHA, 40)) throw new Error('Invalid publication context'); + const api = githubClient({ token: env.GH_TOKEN, fetch }); + const run = await api.json(`/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`); + if (run.id !== Number(env.GITHUB_RUN_ID) || run.run_attempt !== Number(env.GITHUB_RUN_ATTEMPT) || run.head_sha !== env.GITHUB_SHA || + run.head_branch !== 'main' || run.event !== 'workflow_dispatch' || run.path !== '.github/workflows/macos-release-resume.yml' || + run.repository?.id !== Number(env.GITHUB_REPOSITORY_ID) || run.head_repository?.id !== Number(env.GITHUB_REPOSITORY_ID) || + run.repository?.full_name !== repository || run.head_repository?.full_name !== repository || + !Number.isFinite(Date.parse(run.run_started_at))) throw new Error('Untrusted final artifact run'); + const name = `macos-notary-final-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`; + const found = (await api.artifacts(env.GITHUB_RUN_ID)).filter((artifact) => artifact.name === name); + if (found.length !== 1 || String(found[0].id) !== env.FINAL_ARTIFACT_ID || found[0].digest !== `sha256:${env.FINAL_ARTIFACT_DIGEST}`) { + throw new Error('Final artifact ID/digest ambiguity'); + } + const artifact = validateArtifact(found[0], run, name, { end: Date.now() }); + const dmgName = `Quorb_${env.RELEASE_VERSION}_aarch64.dmg`; + const files = extractZip(await downloadZip(artifact, { token: env.GH_TOKEN, fetch }), { [dmgName]: MAX_DMG, [`${dmgName}.sha256`]: 1024 }); + if (hash(files.get(dmgName)) !== env.FINAL_SHA256 || + files.get(`${dmgName}.sha256`).toString('utf8') !== `${env.FINAL_SHA256} ${dmgName}\n`) throw new Error('Final DMG/checksum mismatch'); + io.mkdirSync('notary-publish'); + for (const [name, bytes] of files) io.writeFileSync(path.join('notary-publish', name), bytes, { flag: 'wx' }); + return { dmgName, files }; +} + +const publicationError = (message) => Object.assign(new Error(message), { safePublish: true }); + +export async function publishFinal({ env = process.env, fetch = globalThis.fetch, load = loadFinalArtifact, + preflight = preflightRelease } = {}) { + if (!positiveId(env.RELEASE_ID)) throw new Error('Invalid release identity'); + const { dmgName, files } = await load({ env, fetch }); + // Revalidate the existing release ID, source/tag and both empty asset names + // immediately before publication. POST never uses clobber or overwrite. + const release = await preflight({ env, fetch }); + if (String(release.releaseId) !== env.RELEASE_ID) throw new Error('Release identity changed'); + const headers = { Authorization: `Bearer ${env.GH_TOKEN}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }; + const upload = async (name, type) => { + const response = await fetch(`https://uploads.github.com/repos/${repository}/releases/${env.RELEASE_ID}/assets?name=${name}`, { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(120_000), + headers: { ...headers, 'Content-Type': type }, body: files.get(name), + }); + if (response.status !== 201) throw new Error(); + const text = await response.text(); + if (text.length > 65536) throw new Error(); + const data = JSON.parse(text); + if (!Number.isSafeInteger(data.id) || data.id <= 0) throw new Error(); + return data.id; + }; + let ownedId; + try { ownedId = await upload(dmgName, 'application/x-apple-diskimage'); } + catch { throw publicationError('DMG upload failed or ambiguous; deleting nothing, inspect release assets'); } + try { await upload(`${dmgName}.sha256`, 'text/plain'); } + catch { + // Only this invocation's positive asset ID can ever be rolled back. + try { + const response = await fetch(`https://api.github.com/repos/${repository}/releases/assets/${ownedId}`, { + method: 'DELETE', headers, redirect: 'error', signal: AbortSignal.timeout(30_000), + }); + if (response.status !== 204) throw new Error(); + } catch { throw publicationError(`Checksum upload failed; rollback failed for owned DMG asset ${ownedId}; manual cleanup required`); } + throw publicationError(`Checksum upload failed; removed only owned DMG asset ${ownedId}; inspect any ambiguous checksum`); + } + return { outcome: 'published', dmgName }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { console.log(JSON.stringify(await publishFinal())); } + catch (error) { + console.error(error.safePublish ? error.message : 'Publication failed; inspect release assets before recovery'); + process.exitCode = 1; + } +} diff --git a/scripts/macos-release-resume-publish.test.mjs b/scripts/macos-release-resume-publish.test.mjs new file mode 100644 index 0000000..712c2e2 --- /dev/null +++ b/scripts/macos-release-resume-publish.test.mjs @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { publishFinal, loadFinalArtifact } from './macos-release-resume-publish.mjs'; +import { producerFixture, zipFiles } from './macos-notary-artifact-fixtures.mjs'; +import { hash } from './macos-notary-checkpoint.mjs'; + +const dmgName = 'Quorb_0.8.0_aarch64.dmg'; +function fixture({ checksumFailure = false, dmgFailure = false, rollbackFailure = false, releaseId = 42 } = {}) { + const calls = []; + const files = new Map([[dmgName, Buffer.from('final DMG')], [`${dmgName}.sha256`, Buffer.from('final checksum')]]); + const options = { + env: { RELEASE_ID: '42', GH_TOKEN: 'synthetic' }, + load: async () => { calls.push('load'); return { dmgName, files }; }, + preflight: async () => { calls.push('preflight'); return { releaseId }; }, + fetch: async (url, opts) => { + calls.push([url, opts]); + if (opts.method === 'DELETE') return new Response(null, { status: rollbackFailure ? 500 : 204 }); + if ((url.endsWith('.sha256') && checksumFailure) || (!url.endsWith('.sha256') && dmgFailure)) throw new Error('SECRET'); + return new Response(JSON.stringify({ id: 999 }), { status: 201 }); + }, + }; + return { calls, options }; +} + +test('publication revalidates identity immediately before two non-overwriting fixed-name uploads', async () => { + const f = fixture(); + assert.equal((await publishFinal(f.options)).outcome, 'published'); + assert.deepEqual(f.calls.slice(0, 2), ['load', 'preflight']); + assert.equal(f.calls.length, 4); + assert.equal(f.calls[2][0], `https://uploads.github.com/repos/egdev6/quorb/releases/42/assets?name=${dmgName}`); + assert.equal(f.calls[3][0], `${f.calls[2][0]}.sha256`); + assert.equal(f.calls[2][1].method, 'POST'); + assert.equal(f.calls[2][1].redirect, 'error'); +}); + +test('checksum failure only rolls back the positive owned DMG ID, never preexisting names', async () => { + const f = fixture({ checksumFailure: true }); + await assert.rejects(publishFinal(f.options), /removed only owned/); + assert.equal(f.calls.at(-1)[0], 'https://api.github.com/repos/egdev6/quorb/releases/assets/999'); + assert.equal(f.calls.at(-1)[1].method, 'DELETE'); + const failed = fixture({ checksumFailure: true, rollbackFailure: true }); + await assert.rejects(publishFinal(failed.options), (error) => { + assert.match(error.message, /rollback failed.*999/); + assert.equal(error.safePublish, true, 'CLI must retain the sanitized owned asset ID for manual cleanup'); + return true; + }); +}); + +test('ambiguous first upload or changed release never deletes anything or leaks raw errors', async () => { + for (const scenario of [{ dmgFailure: true }, { releaseId: 43 }]) { + const f = fixture(scenario); + await assert.rejects(publishFinal(f.options), /DMG upload failed or ambiguous|identity changed/); + assert.ok(!f.calls.some((call) => Array.isArray(call) && call[1].method === 'DELETE')); + } +}); + +function finalFixture() { + const f = producerFixture(); + const finalSha256 = hash(f.bytes); + const archive = zipFiles({ [dmgName]: f.bytes, [`${dmgName}.sha256`]: `${finalSha256} ${dmgName}\n` }); + Object.assign(f.env, { GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY_ID: '10', GITHUB_SHA: f.run.head_sha, + FINAL_ARTIFACT_ID: '999', FINAL_ARTIFACT_DIGEST: hash(archive), FINAL_SHA256: finalSha256 }); + f.run.path = '.github/workflows/macos-release-resume.yml'; + const artifact = { ...f.checkpoint, id: 999, name: 'macos-notary-final-123-2', size_in_bytes: archive.length, digest: `sha256:${hash(archive)}` }; + f.artifacts.splice(0, f.artifacts.length, artifact); + f.archives.set(999, archive); + return f; +} + +test('publication artifact transfer independently authenticates same-run ID, ZIP digest and post-staple bytes', async () => { + const f = finalFixture(); + const result = await loadFinalArtifact(f); + assert.equal(result.dmgName, dmgName); + assert.deepEqual(result.files.get(dmgName), f.bytes); + assert.deepEqual(f.files.get(`notary-publish/${dmgName}`), f.bytes); + for (const failure of ['id', 'digest', 'finalHash', 'fork', 'duplicate', 'wrongAttempt', 'checksum']) { + const bad = finalFixture(); + if (failure === 'id') bad.env.FINAL_ARTIFACT_ID = '998'; + if (failure === 'digest') bad.env.FINAL_ARTIFACT_DIGEST = '0'.repeat(64); + if (failure === 'finalHash') bad.env.FINAL_SHA256 = '0'.repeat(64); + if (failure === 'fork') bad.run.head_repository.id = 11; + if (failure === 'duplicate') bad.artifacts.push(bad.artifacts[0]); + if (failure === 'wrongAttempt') bad.artifacts[0].name = 'macos-notary-final-123-1'; + if (failure === 'checksum') { + const archive = zipFiles({ [dmgName]: bad.bytes, [`${dmgName}.sha256`]: 'untrusted checksum' }); + bad.archives.set(999, archive); + Object.assign(bad.artifacts[0], { digest: `sha256:${hash(archive)}`, size_in_bytes: archive.length }); + bad.env.FINAL_ARTIFACT_DIGEST = hash(archive); + } + await assert.rejects(loadFinalArtifact(bad)); + assert.ok(!bad.files.has('notary-publish')); + } +}); diff --git a/scripts/macos-release-resume-workflow.test.mjs b/scripts/macos-release-resume-workflow.test.mjs new file mode 100644 index 0000000..7478fa5 --- /dev/null +++ b/scripts/macos-release-resume-workflow.test.mjs @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; + +const read = (file) => readFileSync(new URL(`../${file}`, import.meta.url)); +const normalize = (text) => text.replaceAll('\r\n', '\n'); +const reviewed = 'a832743594cc2133186f691635e23e62ce64b227131cdd5bb0bf0ea6cea8022a'; +function contract(draft, active) { + assert.deepEqual(active, draft); + assert.equal(createHash('sha256').update(normalize(draft.toString())).digest('hex'), reviewed); +} +const draft = read('.github/security-drafts/macos-release-resume.yml'); +const text = normalize(draft.toString()); + +test('resume exact reviewed workflow keeps read-only validation separate from explicit publication authority', () => { + contract(draft, read('.github/workflows/macos-release-resume.yml')); + assert.match(text, /permissions:\n contents: read\n actions: read/); + assert.equal((text.match(/contents: write/g) ?? []).length, 1); + assert.ok(text.indexOf('contents: write') > text.indexOf('\n publish:')); + assert.match(text, /inputs.publish && needs.resume.outputs.outcome == 'accepted'/); + assert.match(text, /default: false\n type: boolean/); + assert.doesNotMatch(text, /download-artifact|APPLE_CERTIFICATE|APPLE_SIGNING_IDENTITY|notarytool submit|npm ci|npm run|--wait|continue-on-error/); + assert.ok(text.indexOf('macos-release-resume.mjs validate') < text.indexOf('APPLE_API_KEY_P8:')); + assert.equal((text.match(/environment: macos-signing/g) ?? []).length, 2); + assert.equal((text.match(/secrets\./g) ?? []).length, 3); +}); + +test('resume contract supports matching CRLF and rejects every line mutation and nonmatching mirrors', () => { + for (const candidate of [text, text.replaceAll('\n', '\r\n')]) contract(Buffer.from(candidate), Buffer.from(candidate)); + assert.throws(() => contract(Buffer.from(text), Buffer.from(text.replaceAll('\n', '\r\n')))); + const lines = text.split('\n'); + for (const [index, line] of lines.entries()) { + if (!line.trim()) continue; + for (const candidate of [lines.filter((_, i) => i !== index).join('\n'), lines.map((value, i) => i === index ? `${value} # mutation` : value).join('\n')]) { + assert.throws(() => contract(Buffer.from(candidate), Buffer.from(candidate)), undefined, line); + } + } + const swapped = Buffer.from([...lines].reverse().join('\n')); + assert.throws(() => contract(swapped, swapped)); +}); diff --git a/scripts/macos-release-resume.mjs b/scripts/macos-release-resume.mjs new file mode 100644 index 0000000..1a5053a --- /dev/null +++ b/scripts/macos-release-resume.mjs @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { preflightRelease, validateReleaseInputs } from './macos-release-preflight.mjs'; +import { repository, workflowPath, positiveId, uuid, MAX_DMG, validateTrusted, validateManifest, validateDmg, validateReceipt } from './macos-notary-checkpoint.mjs'; +import { githubClient, validateRun, selectArtifacts, downloadZip, extractZip } from './macos-notary-artifact.mjs'; +import { withNotaryKey, querySubmission } from './macos-notary-submit.mjs'; +import { stapleAndVerifyDmg } from './verify-macos-bundle.mjs'; + +function inputs(env) { + validateReleaseInputs({ ref: env.GITHUB_REF, version: env.RELEASE_VERSION, sourceSha: env.RELEASE_SOURCE_SHA }); + if (env.GITHUB_REPOSITORY !== repository || !positiveId(env.SOURCE_RUN_ID) || !positiveId(env.SOURCE_RUN_ATTEMPT) || + !positiveId(env.RELEASE_ID) || (env.SUBMISSION_ID && !uuid(env.SUBMISSION_ID))) throw new Error('Invalid resume inputs'); +} + +export async function loadCheckpoint({ env = process.env, io = fs, exec = execFileSync, fetch = globalThis.fetch } = {}) { + inputs(env); + const api = githubClient({ token: env.GH_TOKEN, fetch }); + const workflow = await api.json('/actions/workflows/macos-release.yml'); + const run = validateRun(await api.json(`/actions/runs/${env.SOURCE_RUN_ID}/attempts/${env.SOURCE_RUN_ATTEMPT}`), workflow, + { runId: env.SOURCE_RUN_ID, runAttempt: env.SOURCE_RUN_ATTEMPT }); + try { + const git = (args) => exec('git', args, { encoding: 'utf8', timeout: 10_000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + // An ancestor alone is insufficient: require the approved producer workflow + // bytes as well, so old wait/build implementations cannot supply checkpoints. + git(['merge-base', '--is-ancestor', run.head_sha, env.GITHUB_SHA]); + git(['merge-base', '--is-ancestor', run.head_sha, 'origin/main']); + if (git(['show', `${run.head_sha}:${workflowPath}`]) !== io.readFileSync(workflowPath, 'utf8')) throw new Error(); + } catch { throw new Error('Producer workflow SHA/content is not approved'); } + const latest = await api.json(`/actions/runs/${env.SOURCE_RUN_ID}`); + if (!Number.isSafeInteger(latest.run_attempt) || latest.run_attempt < run.run_attempt) throw new Error('Invalid producer attempt history'); + let end = Date.parse(run.updated_at); + if (latest.run_attempt > run.run_attempt) { + const next = await api.json(`/actions/runs/${env.SOURCE_RUN_ID}/attempts/${run.run_attempt + 1}`); + if (next.id !== run.id || next.run_attempt !== run.run_attempt + 1 || !Number.isFinite(Date.parse(next.run_started_at))) throw new Error('Invalid producer attempt boundary'); + end = Math.min(end, Date.parse(next.run_started_at) - 1); + } + const artifacts = selectArtifacts(await api.artifacts(env.SOURCE_RUN_ID), run, Boolean(env.SUBMISSION_ID), { end }); + const trusted = validateTrusted({ repository, workflowPath, workflowSha: run.head_sha, version: env.RELEASE_VERSION, + sourceSha: env.RELEASE_SOURCE_SHA, releaseId: env.RELEASE_ID, runId: env.SOURCE_RUN_ID, runAttempt: env.SOURCE_RUN_ATTEMPT }); + const dmgName = `Quorb_${trusted.version}_aarch64.dmg`; + const files = extractZip(await downloadZip(artifacts.checkpoint, { token: env.GH_TOKEN, fetch }), { 'manifest.json': 65536, [dmgName]: MAX_DMG }); + let manifest; + try { manifest = validateManifest(JSON.parse(files.get('manifest.json').toString('utf8')), trusted); } + catch { throw new Error('Invalid checkpoint manifest'); } + validateDmg(files.get(dmgName), manifest); + let receipt; + if (artifacts.receipt) { + const receiptFiles = extractZip(await downloadZip(artifacts.receipt, { token: env.GH_TOKEN, fetch }), { 'receipt.json': 65536 }); + try { receipt = validateReceipt(JSON.parse(receiptFiles.get('receipt.json').toString('utf8')), trusted, artifacts.checkpoint, manifest); } + catch { throw new Error('Invalid submission receipt'); } + } + const submissionId = env.SUBMISSION_ID || receipt?.submissionId; + if (!uuid(submissionId)) throw new Error('Missing submission UUID; use diagnostics, never resubmit'); + // An explicit recovery ID can differ from a receipt, but cannot authorize + // anything until the exact UUID + retained input hash are confirmed in Apple log. + io.mkdirSync('notary-retained'); + for (const [name, bytes] of files) io.writeFileSync(path.join('notary-retained', name), bytes, { flag: 'wx' }); + return { manifest, dmg: path.resolve('notary-retained', dmgName), submissionId: submissionId.toLowerCase() }; +} + +export function preparedCheckpoint({ env = process.env, io = fs } = {}) { + inputs(env); + const trusted = validateTrusted({ repository, workflowPath, workflowSha: env.PRODUCER_WORKFLOW_SHA, version: env.RELEASE_VERSION, + sourceSha: env.RELEASE_SOURCE_SHA, releaseId: env.RELEASE_ID, runId: env.SOURCE_RUN_ID, runAttempt: env.SOURCE_RUN_ATTEMPT }); + const manifest = validateManifest(JSON.parse(io.readFileSync('notary-retained/manifest.json', 'utf8')), trusted); + const dmg = path.resolve('notary-retained', manifest.dmgName); + for (const target of ['notary-retained', 'notary-retained/manifest.json', dmg]) { + if (io.lstatSync(target).isSymbolicLink()) throw new Error('Retained checkpoint symlink'); + } + validateDmg(io.readFileSync(dmg), manifest); + if (!uuid(env.VALIDATED_SUBMISSION_ID)) throw new Error('Invalid validated submission UUID'); + return { manifest, dmg, submissionId: env.VALIDATED_SUBMISSION_ID }; +} + +function emit(result, env = process.env, io = fs) { + for (const [key, value] of Object.entries(result)) { + if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new Error('Unsafe resume output'); + if (env.GITHUB_OUTPUT) io.appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`); + } + if (env.GITHUB_STEP_SUMMARY) io.appendFileSync(env.GITHUB_STEP_SUMMARY, + result.outcome === 'accepted' ? '## Verified DMG ready for separately authorized publication\n' : '## Pending Apple processing — no publication, no resubmit\n'); +} + +export async function resumeNotarization({ load = preparedCheckpoint, preflight = preflightRelease, query = querySubmission, + key = withNotaryKey, verify = stapleAndVerifyDmg, output = emit } = {}) { + const checkpoint = await load(); + const release = await preflight(); + if (String(release.releaseId) !== checkpoint.manifest.releaseId) throw new Error('Release identity changed'); + const result = key((auth) => query(checkpoint.submissionId, checkpoint.manifest.sha256, auth)); + if (result.outcome === 'pending') { output({ outcome: 'pending' }); return result; } + if (result.outcome === 'rejected') { + console.log(JSON.stringify({ outcome: 'rejected', issues: result.issues ?? [] })); + throw new Error('Rejected notarization; inspect safe issues before correcting source'); + } + if (result.outcome !== 'accepted') throw new Error('Unexpected notarization outcome'); + const verified = verify({ dmg: checkpoint.dmg, version: checkpoint.manifest.version, inputSha256: checkpoint.manifest.sha256 }); + output({ outcome: 'accepted', finalSha256: verified.finalSha256 }); + return { ...verified, outcome: 'accepted' }; +} + +export function stageFinal({ env = process.env, io = fs } = {}) { + // This runs only after complete succeeded; the job output carries the final hash + // independently of the final artifact and is rechecked by the publish job. + const name = `Quorb_${env.RELEASE_VERSION}_aarch64.dmg`; + validateReleaseInputs({ ref: env.GITHUB_REF, version: env.RELEASE_VERSION, sourceSha: env.RELEASE_SOURCE_SHA }); + const bytes = io.readFileSync(path.join('notary-retained', name)); + validateDmg(bytes, { size: bytes.length, sha256: env.FINAL_SHA256 }); + io.mkdirSync('notary-final'); + io.writeFileSync(path.join('notary-final', name), bytes, { flag: 'wx' }); + io.writeFileSync(path.join('notary-final', `${name}.sha256`), `${env.FINAL_SHA256} ${name}\n`, { flag: 'wx' }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + if (process.argv[2] === 'validate') { + const result = await loadCheckpoint(); + const release = await preflightRelease(); + if (String(release.releaseId) !== result.manifest.releaseId) throw new Error('Release identity changed'); + emit({ producerWorkflowSha: result.manifest.workflowSha, submissionId: result.submissionId }); + } else if (process.argv[2] === 'complete') await resumeNotarization(); + else if (process.argv[2] === 'stage-final') stageFinal(); + else throw new Error('Invalid resume mode'); + } catch (error) { + // No server, subprocess or filesystem errors/paths are echoed by the CLI. + console.error(error.safeNotary ? error.message : 'Resume validation/verification failed; no release published'); + process.exitCode = 1; + } +} diff --git a/scripts/macos-release-resume.test.mjs b/scripts/macos-release-resume.test.mjs new file mode 100644 index 0000000..6c1816e --- /dev/null +++ b/scripts/macos-release-resume.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resumeNotarization, loadCheckpoint } from './macos-release-resume.mjs'; +import { producerFixture, zipFiles } from './macos-notary-artifact-fixtures.mjs'; +import { hash } from './macos-notary-checkpoint.mjs'; + +const id = '12345678-1234-1234-1234-123456789abc'; +const checkpoint = { manifest: { version: '0.8.0', sourceSha: 'b'.repeat(40), releaseId: '42', sha256: 'a'.repeat(64), dmgName: 'Quorb_0.8.0_aarch64.dmg' }, + dmg: '/controlled/Quorb_0.8.0_aarch64.dmg', submissionId: id }; +function fixture(outcome = 'accepted') { + const calls = []; + const dependencies = { + load: async () => { calls.push('load'); return checkpoint; }, + preflight: async () => { calls.push('preflight'); return { releaseId: 42 }; }, + query: () => { calls.push('query'); return { outcome }; }, + key: (callback) => { calls.push('key'); return callback([]); }, + verify: () => { calls.push('verify'); return { finalSha256: 'f'.repeat(64) }; }, + output: (result) => calls.push(result), + }; + return { calls, dependencies }; +} + +test('resume never builds, signs, submits or publishes; pending/rejected cannot reach stapling', async () => { + for (const outcome of ['pending', 'rejected', 'accepted']) { + const f = fixture(outcome); + if (outcome === 'rejected') await assert.rejects(resumeNotarization(f.dependencies), /Rejected/); + else await resumeNotarization(f.dependencies); + assert.equal(f.calls.includes('verify'), outcome === 'accepted'); + assert.deepEqual(f.calls.slice(0, 4), ['load', 'preflight', 'key', 'query']); + if (outcome === 'pending') assert.deepEqual(f.calls.at(-1), { outcome: 'pending' }); + } +}); + +test('content/run validation and release identity are checked before Apple credentials', async () => { + const f = fixture(); + f.dependencies.load = async () => { throw new Error('bad digest'); }; + await assert.rejects(resumeNotarization(f.dependencies), /bad digest/); + assert.deepEqual(f.calls, []); + const changed = fixture(); + changed.dependencies.preflight = async () => ({ releaseId: 99 }); + await assert.rejects(resumeNotarization(changed.dependencies), /identity/); + assert.deepEqual(changed.calls, ['load']); +}); + +test('offline HTTP/Git/ZIP/files adapter restores exact canceled-attempt checkpoint and authenticated receipt', async () => { + const f = producerFixture(); + const result = await loadCheckpoint(f); + assert.equal(result.submissionId, f.submissionId); + assert.deepEqual(result.manifest, f.manifest); + assert.deepEqual(f.files.get(`notary-retained/${f.manifest.dmgName}`), f.bytes); + assert.equal(f.files.size, 4); // trusted workflow, exclusive directory and two files, no secrets + assert.ok(f.calls.some(([command, args]) => command === 'git' && args[0] === 'merge-base')); + assert.ok(f.calls.every(([command]) => command === 'git' || command.startsWith('https://api.github.com/repos/egdev6/quorb/actions/'))); +}); + +test('recovery UUID without receipt remains only a candidate until Apple hash binding', async () => { + const f = producerFixture(); f.artifacts.pop(); f.env.SUBMISSION_ID = '00000000-0000-0000-0000-000000000001'; + const result = await loadCheckpoint(f); + assert.equal(result.submissionId, f.env.SUBMISSION_ID); + const normal = producerFixture(); normal.artifacts.pop(); + await assert.rejects(loadCheckpoint(normal), /receipt/i); + const malformed = producerFixture(); malformed.env.SUBMISSION_ID = '--help'; + await assert.rejects(loadCheckpoint(malformed), /inputs/i); + assert.deepEqual(malformed.calls, []); +}); + +test('adapter fails closed on unapproved ancestor/workflow bytes, manifest or receipt tampering and raw ZIP hash mismatch', async () => { + for (const failure of ['ancestry', 'workflow', 'manifest', 'receipt', 'zip', 'dmg']) { + const f = producerFixture(); + if (failure === 'ancestry') f.exec = () => { throw new Error('SECRET'); }; + if (failure === 'workflow') f.exec = () => 'unapproved producer'; + if (failure === 'zip') f.archives.set(456, Buffer.from('tampered archive')); + if (['manifest', 'dmg'].includes(failure)) { + const manifest = failure === 'manifest' ? { ...f.manifest, releaseId: '43' } : f.manifest; + const bytes = zipFiles({ 'manifest.json': JSON.stringify(manifest), [manifest.dmgName]: failure === 'dmg' ? 'tampered DMG' : f.bytes }); + f.archives.set(456, bytes); f.checkpoint.digest = `sha256:${hash(bytes)}`; f.checkpoint.size_in_bytes = bytes.length; + } + if (failure === 'receipt') { + const bytes = zipFiles({ 'receipt.json': JSON.stringify({ ...f.receipt, checkpointArtifactId: '999' }) }); + f.archives.set(457, bytes); f.receiptArtifact.digest = `sha256:${hash(bytes)}`; f.receiptArtifact.size_in_bytes = bytes.length; + } + await assert.rejects(loadCheckpoint(f), (error) => !error.message.includes('SECRET')); + assert.ok(!f.files.has('notary-retained')); + } +}); + +test('attempt ownership rejects artifacts created after a later attempt began, with complete pagination', async () => { + const f = producerFixture(); + const fetch = f.fetch; + f.fetch = async (url, options) => { + if (url.endsWith('/actions/runs/123')) return Response.json({ ...f.run, run_attempt: 3 }); + if (url.endsWith('/attempts/3')) return Response.json({ ...f.run, run_attempt: 3, run_started_at: '2026-01-01T00:00:30Z' }); + return fetch(url, options); + }; + await assert.rejects(loadCheckpoint(f), /artifact/i); +}); diff --git a/scripts/macos-release-workflow.test.mjs b/scripts/macos-release-workflow.test.mjs index 8f7da0a..dbd92d8 100644 --- a/scripts/macos-release-workflow.test.mjs +++ b/scripts/macos-release-workflow.test.mjs @@ -1,299 +1,55 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; -const source = '.github/security-drafts/macos-release.yml'; -const active = '.github/workflows/macos-release.yml'; const read = (name) => readFileSync(new URL(`../${name}`, import.meta.url)); -// Exact reviewed text, not a general-purpose YAML parser. Both job boundaries, -// every step and all secret references are part of this allowlist. -const expected = String.raw`name: macOS signed release - -on: - workflow_dispatch: - inputs: - version: - description: Exact stable version of the existing Windows release - required: true - type: string - source_sha: - description: Full lowercase SHA of that release, ancestor of main - required: true - type: string - -permissions: - contents: read - -concurrency: - group: macos-release - cancel-in-progress: false - -jobs: - preflight: - name: Validate release before signing approval - if: github.ref == 'refs/heads/main' - runs-on: macos-15 - timeout-minutes: 5 - outputs: - source_sha: \${{ steps.validate.outputs.sourceSha }} - version: \${{ steps.validate.outputs.version }} - release_id: \${{ steps.validate.outputs.releaseId }} - steps: - - name: Checkout trusted workflow source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: \${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Node 22 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version: 22 - package-manager-cache: false - - - name: Validate existing release - id: validate - env: - RELEASE_VERSION: \${{ inputs.version }} - RELEASE_SOURCE_SHA: \${{ inputs.source_sha }} - GH_TOKEN: \${{ github.token }} - run: | - node --input-type=module <<'NODE' - import { appendFileSync } from 'node:fs'; - import { preflightRelease } from './scripts/macos-release-preflight.mjs'; - const result = await preflightRelease(); - for (const [key, value] of Object.entries(result)) { - appendFileSync(process.env.GITHUB_OUTPUT, key + '=' + value + '\n'); - } - NODE - - signing: - name: Sign, notarize and attach macOS ARM64 DMG - needs: preflight - if: github.ref == 'refs/heads/main' - environment: macos-signing - runs-on: macos-15 - timeout-minutes: 120 - permissions: - contents: write - env: - RELEASE_VERSION: \${{ needs.preflight.outputs.version }} - RELEASE_SOURCE_SHA: \${{ needs.preflight.outputs.source_sha }} - RELEASE_ID: \${{ needs.preflight.outputs.release_id }} - steps: - - name: Checkout trusted verification tools - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: \${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Checkout validated build source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: \${{ needs.preflight.outputs.source_sha }} - path: source - persist-credentials: false - - - name: Node 22 - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 - with: - node-version: 22 - package-manager-cache: false - - - name: Rust stable - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d - with: - toolchain: stable - - - name: Require native ARM64 toolchains - run: | - set -euo pipefail - test "$(uname -m)" = arm64 - test "$(node -p process.arch)" = arm64 - rustc -vV | grep -Fx 'host: aarch64-apple-darwin' - - - name: Install locked dependencies - working-directory: source - run: npm ci - - - name: Write private notarization key - env: - APPLE_API_KEY_P8: \${{ secrets.APPLE_API_KEY_P8 }} - APPLE_API_KEY_PATH: \${{ runner.temp }}/quorb-notary-\${{ github.run_id }}-\${{ github.run_attempt }}.p8 - run: | - node --input-type=module <<'NODE' - import { writeFileSync } from 'node:fs'; - const key = process.env.APPLE_API_KEY_P8; - if (!key || !key.trim()) throw new Error('Missing notarization key'); - writeFileSync(process.env.APPLE_API_KEY_PATH, key, { mode: 0o600, flag: 'wx' }); - NODE - - - name: Build, sign, notarize and staple - working-directory: source - env: - APPLE_CERTIFICATE: \${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: \${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: \${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_API_ISSUER: \${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: \${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: \${{ runner.temp }}/quorb-notary-\${{ github.run_id }}-\${{ github.run_attempt }}.p8 - run: | - set -euo pipefail - test -n "$APPLE_CERTIFICATE" - test -n "$APPLE_CERTIFICATE_PASSWORD" - test -n "$APPLE_SIGNING_IDENTITY" - test -n "$APPLE_API_ISSUER" - test -n "$APPLE_API_KEY" - npm run app:build:mac - dmg="src-tauri/target/release/bundle/dmg/Quorb_\${RELEASE_VERSION}_aarch64.dmg" - xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait - xcrun stapler staple "$dmg" - - - name: Remove private notarization key - if: always() - env: - APPLE_API_KEY_PATH: \${{ runner.temp }}/quorb-notary-\${{ github.run_id }}-\${{ github.run_attempt }}.p8 - run: | - node --input-type=module <<'NODE' - import { unlinkSync } from 'node:fs'; - try { unlinkSync(process.env.APPLE_API_KEY_PATH); } - catch (error) { if (error.code !== 'ENOENT') throw error; } - NODE - - - name: Verify signed app and stapled DMG, then hash final bytes - run: | - node --input-type=module <<'NODE' - import path from 'node:path'; - import { writeFileSync } from 'node:fs'; - import { execFileSync, spawnSync } from 'node:child_process'; - import { verifyMacosBundle } from './scripts/verify-macos-bundle.mjs'; - const result = verifyMacosBundle({ root: path.resolve('source'), sourceSha: process.env.RELEASE_SOURCE_SHA }); - const expected = path.resolve('source/src-tauri/target/release/bundle/dmg', 'Quorb_' + process.env.RELEASE_VERSION + '_aarch64.dmg'); - if (result.version !== process.env.RELEASE_VERSION || result.dmg !== expected) throw new Error('Unexpected release bundle'); - const run = (command, args) => execFileSync(command, args, { stdio: 'inherit', timeout: 120000 }); - run('codesign', ['--verify', '--deep', '--strict', result.app]); - const signature = spawnSync('codesign', ['-dv', '--verbose=4', result.app], { encoding: 'utf8', timeout: 120000 }); - if (signature.error || signature.status !== 0 || - !/^Authority=Developer ID Application: .+ \(SRD8T33RU5\)$/m.test(signature.stderr) || - !/^TeamIdentifier=SRD8T33RU5$/m.test(signature.stderr)) throw new Error('Unexpected Developer ID authority or team'); - console.log(signature.stderr); - run('spctl', ['--assess', '--type', 'execute', result.app]); - run('spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', result.dmg]); - run('xcrun', ['stapler', 'validate', result.dmg]); - writeFileSync(result.dmg + '.sha256', result.dmgSha256 + ' ' + path.basename(result.dmg) + '\n', { flag: 'wx' }); - NODE - - - name: Revalidate release and upload only DMG and SHA-256 without overwrite - env: - GH_TOKEN: \${{ github.token }} - run: | - set -euo pipefail - node --input-type=module <<'NODE' - import { preflightRelease } from './scripts/macos-release-preflight.mjs'; - const result = await preflightRelease(); - if (String(result.releaseId) !== process.env.RELEASE_ID) throw new Error('Release identity changed'); - NODE - dmg="source/src-tauri/target/release/bundle/dmg/Quorb_\${RELEASE_VERSION}_aarch64.dmg" - name="\${dmg##*/}" - dmg_id="$(gh api --method POST -H 'Content-Type: application/x-apple-diskimage' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name" --input "$dmg" --jq .id)" - if ! [[ "$dmg_id" =~ ^[0-9]+$ ]]; then - echo "::error::DMG upload returned a non-numeric asset id; deleting nothing." - exit 1 - fi - if ! gh api --method POST -H 'Content-Type: text/plain' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name.sha256" --input "$dmg.sha256" --jq .id; then - echo "::error::SHA-256 upload failed; removing only the DMG this run uploaded." - if ! gh api --method DELETE "repos/egdev6/quorb/releases/assets/$dmg_id"; then - echo "::error::Rollback failed; manual cleanup needed for $name (asset id $dmg_id)." - exit 1 - fi - exit 1 - fi -`.replaceAll('\\${', '${'); - -function assertContract(draft, workflow, pkg) { - assert.deepEqual(workflow, draft, 'Materialization must be byte-identical'); - assert.equal(draft.toString('utf8').replace(/\r\n/g, '\n'), expected); - assert.ok(pkg.scripts.test.includes('scripts/macos-release-preflight.test.mjs')); - assert.ok(pkg.scripts.test.includes('scripts/macos-release-workflow.test.mjs')); +const normalize = (text) => text.replaceAll('\r\n', '\n'); +const digest = (text) => createHash('sha256').update(normalize(text)).digest('hex'); +// Exact reviewed bytes, including every step, permission, pin and secret. This +// replaces the old inline snapshot, not its strictness: no YAML subset matching. +const reviewed = 'df1055991fe75d4c528361a7abec0c2f78952540393ad15f8c1e12e5689bcade'; +const requiredTests = ['macos-release-preflight', 'macos-release-workflow', 'macos-notary-checkpoint', 'macos-notary-submit', 'macos-notary-artifact', 'macos-release-resume', 'macos-release-resume-workflow', 'macos-release-resume-publish']; + +function contract(draft, active, pkg) { + assert.deepEqual(active, draft, 'Materialization must be byte-identical'); + assert.equal(digest(draft.toString('utf8')), reviewed, 'Exact prepare/submit workflow contract'); + for (const name of requiredTests) assert.ok(pkg.scripts.test.includes(`scripts/${name}.test.mjs`), name); assert.equal(pkg.scripts['app:build:mac'], 'tauri build --config src-tauri/tauri.macos.conf.json'); } const pkg = () => JSON.parse(read('package.json')); - -test('real signed release workflow is exactly the reviewed source and has npm test wiring', () => { - assertContract(read(source), read(active), pkg()); +const draft = () => read('.github/security-drafts/macos-release.yml'); + +test('prepare/submit workflow is exact, byte-mirrored and fully wired', () => { + contract(draft(), read('.github/workflows/macos-release.yml'), pkg()); + const text = normalize(draft().toString()); + assert.doesNotMatch(text, /contents: write|--wait|stapler staple|gh release|latest.json|continue-on-error/); + assert.ok(text.indexOf('id: checkpoint') < text.indexOf('APPLE_API_KEY_P8:')); + assert.ok(text.indexOf('macos-notary-checkpoint.mjs build') < text.indexOf('macos-notary-checkpoint.mjs stage')); + assert.ok(text.indexOf('macos-notary-checkpoint.mjs stage') < text.indexOf('id: checkpoint')); + const build = text.split(' - name: Build and sign')[1].split(' - name: Verify')[0]; + assert.doesNotMatch(build, /APPLE_API|APPLE_ID|APPLE_PASSWORD|APPLE_TEAM_ID/); }); -test('pure exact workflow contract accepts reviewed LF and matching CRLF bytes', () => { - for (const text of [expected, expected.replaceAll('\n', '\r\n')]) { - assertContract(Buffer.from(text), Buffer.from(text), pkg()); - } +test('exact workflow contract is CRLF-safe but never accepts mismatched materialization', () => { + const lf = normalize(draft().toString()); + for (const text of [lf, lf.replaceAll('\n', '\r\n')]) contract(Buffer.from(text), Buffer.from(text), pkg()); + assert.throws(() => contract(Buffer.from(lf), Buffer.from(lf.replaceAll('\n', '\r\n')), pkg())); }); -test('rejects mutations to every reviewed line, step order and byte materialization', () => { - const bytes = Buffer.from(expected); - for (const [index, line] of expected.split('\n').entries()) { +test('rejects deletion or mutation of every reviewed line, step order, pins and wiring', () => { + const text = normalize(draft().toString()); + const lines = text.split('\n'); + for (const [index, line] of lines.entries()) { if (!line.trim()) continue; - const lines = expected.split('\n'); - lines.splice(index, 1); - const changed = Buffer.from(lines.join('\n')); - assert.throws(() => assertContract(changed, changed, pkg()), undefined, `Removed line: ${line}`); - } - const mutations = [ - ['workflow_dispatch:', 'pull_request_target:'], ['contents: read', 'contents: write'], - ['needs: preflight', 'needs: []'], ['environment: macos-signing', 'environment: production'], - ['timeout-minutes: 120', 'timeout-minutes: 45'], ['timeout-minutes: 5\n', 'timeout-minutes: 120\n'], - ['persist-credentials: false', 'persist-credentials: true'], ['macos-15', 'macos-latest'], - ['node-version: 22', 'node-version: 24'], ['if: always()', 'if: success()'], - ['run: npm ci', 'continue-on-error: true\n run: npm ci'], - ['run: npm ci', 'if: always()\n run: npm ci'], - ['run: npm ci', 'run: echo ${{ inputs.version }}'], - [' env:\n', ' env:\n APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}\n'], - ['--input "$dmg"', '--clobber --input "$dmg"'], - ['"$dmg.sha256"', '"$dmg.sha256" latest.json'], ['SRD8T33RU5', 'OTHERTEAM1'], - ['--strict', '--verbose'], ['stapler\', \'validate', 'stapler\', \'staple'], - ['--wait', '--no-wait'], ["flag: 'wx'", "flag: 'w'"], - ['gh api --method DELETE "repos/egdev6/quorb/releases/assets/$dmg_id"', 'gh release delete-asset "v$RELEASE_VERSION" "$name" --repo egdev6/quorb --yes'], - ['releases/assets/$dmg_id', 'releases/assets/$sha_id'], - ['releases/assets/$dmg_id', 'releases/assets/12345'], - ['[[ "$dmg_id" =~ ^[0-9]+$ ]]', 'true'], - ['releases/assets/$dmg_id"; then', 'releases/assets/$dmg_id" || true; then'], - ['manual cleanup needed for $name (asset id $dmg_id).', 'cleanup succeeded.'], - ]; - for (const pin of expected.matchAll(/uses: (\S+)/g)) mutations.push([pin[1], pin[1].replace(/@.*/, '@main')]); - for (const [before, after] of mutations) { - assert.ok(expected.includes(before), `Mutation must apply: ${before}`); - const changed = Buffer.from(expected.replace(before, after)); - assert.throws(() => assertContract(changed, changed, pkg()), undefined, before); + for (const change of [lines.filter((_, i) => i !== index).join('\n'), lines.map((value, i) => i === index ? `${value} # mutation` : value).join('\n')]) { + assert.throws(() => contract(Buffer.from(change), Buffer.from(change), pkg()), undefined, line); + } } - const checksumUpload = 'gh api --method POST -H \'Content-Type: text/plain\' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name.sha256" --input "$dmg.sha256" --jq .id'; - const rollbackStart = expected.indexOf(` if ! ${checksumUpload}; then\n`); - assert.ok(rollbackStart >= 0, 'Rollback removal mutation must apply'); - const rollback = expected.slice(rollbackStart); - assert.ok(rollback.includes('gh api --method DELETE'), 'Rollback mutation must remove the DELETE'); - const changedRollback = expected.replace(rollback, ` ${checksumUpload}\n`); - assert.notEqual(changedRollback, expected, 'Rollback removal mutation must change the workflow'); - assert.throws(() => assertContract(Buffer.from(changedRollback), Buffer.from(changedRollback), pkg()), undefined, 'Removed rollback branch'); - const revalidation = ` node --input-type=module <<'NODE' - import { preflightRelease } from './scripts/macos-release-preflight.mjs'; - const result = await preflightRelease(); - if (String(result.releaseId) !== process.env.RELEASE_ID) throw new Error('Release identity changed'); - NODE -`; - assert.ok(expected.includes(revalidation), 'Upload order mutation must apply'); - const uploadBeforeRevalidation = expected.replace(revalidation, '') + revalidation; - assert.notEqual(uploadBeforeRevalidation, expected, 'Upload order mutation must change the workflow'); - assert.throws(() => assertContract(Buffer.from(uploadBeforeRevalidation), Buffer.from(uploadBeforeRevalidation), pkg()), undefined, 'Upload order changed'); - assert.ok(expected.includes(' npm run app:build:mac\n'), 'Build order mutation must remove the build'); - assert.ok(expected.includes(' xcrun stapler staple "$dmg"'), 'Build order mutation must find stapling'); - const swapped = expected.replace(' npm run app:build:mac\n', '').replace(' xcrun stapler staple "$dmg"', ' xcrun stapler staple "$dmg"\n npm run app:build:mac'); - assert.notEqual(swapped, expected, 'Build order mutation must change the workflow'); - assert.throws(() => assertContract(Buffer.from(swapped), Buffer.from(swapped), pkg())); - assert.throws(() => assertContract(bytes, Buffer.from(expected.replaceAll('\n', '\r\n')), pkg())); - assert.throws(() => assertContract(bytes, Buffer.from(expected + '\n'), pkg())); - for (const name of ['scripts/macos-release-preflight.test.mjs', 'scripts/macos-release-workflow.test.mjs']) { - const changed = pkg(); changed.scripts.test = changed.scripts.test.replace(name, ''); - assert.throws(() => assertContract(bytes, bytes, changed)); + const reversed = Buffer.from([...lines].reverse().join('\n')); + assert.throws(() => contract(reversed, reversed, pkg())); + for (const name of requiredTests) { + const changed = pkg(); changed.scripts.test = changed.scripts.test.replace(`scripts/${name}.test.mjs`, ''); + assert.throws(() => contract(draft(), draft(), changed)); } }); diff --git a/scripts/verify-macos-bundle.mjs b/scripts/verify-macos-bundle.mjs index c01d13b..cfd18db 100644 --- a/scripts/verify-macos-bundle.mjs +++ b/scripts/verify-macos-bundle.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; const projectRoot = fileURLToPath(new URL('../', import.meta.url)); @@ -71,6 +71,117 @@ export function verifyMacosBundle({ root = projectRoot, sourceSha = process.env. }; } +const securityCommand = (command, args) => { + const result = spawnSync(command, args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.error || result.status !== 0) throw new Error('macOS verification command failed'); + return result.stdout + result.stderr; +}; + +export function verifyDeveloperSignature(target, { run = securityCommand } = {}) { + run('/usr/bin/codesign', ['--verify', '--deep', '--strict', target]); + const signature = run('/usr/bin/codesign', ['-dv', '--verbose=4', target]); + if (!/^Authority=Developer ID Application: .+ \(SRD8T33RU5\)$/m.test(signature) || + !/^TeamIdentifier=SRD8T33RU5$/m.test(signature)) throw new Error('Unexpected Developer ID authority or team'); +} + +export function validateMountEntities(plist, mount) { + const entities = plist?.['system-entities']; + if (!Array.isArray(entities) || !entities.length || entities.length > 32 || + entities.some((entity) => !entity || typeof entity !== 'object' || Array.isArray(entity) || + typeof entity['dev-entry'] !== 'string' || !/^\/dev\/disk[0-9]+(?:s[0-9]+)*$/.test(entity['dev-entry']))) { + throw new Error('Invalid mounted DMG entities'); + } + const volumes = entities.filter((entity) => Object.hasOwn(entity, 'mount-point')); + if (volumes.length !== 1 || volumes[0]['mount-point'] !== mount) throw new Error('Unexpected mounted DMG volume'); + return volumes[0]; +} + +export function verifyMountedApp({ mount, version, io = fs, run = securityCommand }) { + if (!path.isAbsolute(mount) || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version ?? '')) throw new Error('Invalid mounted app context'); + function checked(target, directory = false) { + const relative = path.relative(mount, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Mounted app path escape'); + let current = mount; + for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) { + current = segment ? path.join(current, segment) : current; + const stat = io.lstatSync(current); + if (stat.isSymbolicLink()) throw new Error('Mounted app symlink'); + if (current !== target && !stat.isDirectory()) throw new Error('Mounted app parent not directory'); + } + const stat = io.lstatSync(target); + if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error('Invalid mounted app file type'); + return target; + } + checked(mount, true); + const apps = []; + function scan(directory, insideApp = false) { + for (const name of io.readdirSync(directory)) { + const target = path.join(directory, name); + const stat = io.lstatSync(target); + if (stat.isSymbolicLink()) { + // Standard drag-to-Applications shortcut, never followed or verified as code. + if (!insideApp && target === path.join(mount, 'Applications') && io.readlinkSync(target) === '/Applications') continue; + throw new Error('Unexpected mounted DMG symlink'); + } + if (name.endsWith('.app')) { + if (!stat.isDirectory()) throw new Error('Invalid mounted app'); + apps.push(target); + } + if (stat.isDirectory()) scan(target, insideApp || name.endsWith('.app')); + else if (!stat.isFile()) throw new Error('Unexpected mounted DMG file type'); + } + } + scan(mount); + const app = path.join(mount, 'Quorb.app'); + if (apps.length !== 1 || apps[0] !== app) throw new Error('Expected only contained Quorb.app'); + checked(app, true); + const metadata = parseMetadata(run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', checked(path.join(app, 'Contents/Info.plist'))])); + if (metadata.CFBundleIdentifier !== 'es.egdev.quorb' || metadata.CFBundleShortVersionString !== version || metadata.LSMinimumSystemVersion !== '15.0') { + throw new Error('Mounted app identifier/version/minimum macOS mismatch'); + } + const executable = metadata.CFBundleExecutable; + if (typeof executable !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(executable)) throw new Error('Unsafe mounted executable'); + const binary = io.readFileSync(checked(path.join(app, 'Contents/MacOS', executable))); + if (binary.length < 32 || binary.readUInt32LE(0) !== 0xfeedfacf || binary.readUInt32LE(4) !== 0x0100000c || binary.readUInt32LE(12) !== 2) { + throw new Error('Expected mounted native arm64 Mach-O executable'); + } + verifyDeveloperSignature(app, { run }); + run('/usr/sbin/spctl', ['--assess', '--type', 'execute', app]); + return app; +} + +// No build, re-sign, app staple, repackaging or execution. Only the retained outer +// DMG is mutated. Gatekeeper/network failures fail closed, including during cleanup. +export function stapleAndVerifyDmg({ dmg, version, inputSha256, temp = process.env.RUNNER_TEMP, io = fs, run = securityCommand, + parsePlist = (text) => JSON.parse(execFileSync('python3', ['-c', 'import plistlib,json,sys; print(json.dumps(plistlib.loads(sys.stdin.buffer.read())))'], { + input: text, encoding: 'utf8', timeout: 10_000, maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], + })) }) { + if (!path.isAbsolute(temp ?? '') || io.lstatSync(dmg).isSymbolicLink() || !io.lstatSync(dmg).isFile()) throw new Error('Unsafe retained DMG'); + const before = createHash('sha256').update(io.readFileSync(dmg)).digest('hex'); + if (before !== inputSha256) throw new Error('Retained DMG changed before staple'); + run('/usr/bin/hdiutil', ['verify', dmg]); + verifyDeveloperSignature(dmg, { run }); + run('/usr/bin/xcrun', ['stapler', 'staple', dmg]); + run('/usr/bin/xcrun', ['stapler', 'validate', dmg]); + verifyDeveloperSignature(dmg, { run }); + run('/usr/sbin/spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', dmg]); + const mount = io.mkdtempSync(path.join(temp, 'quorb-notary-mount-')); + let attempted = false; + try { + attempted = true; + const attached = run('/usr/bin/hdiutil', ['attach', '-readonly', '-nobrowse', '-plist', '-mountpoint', mount, dmg]); + validateMountEntities(parsePlist(attached), mount); + verifyMountedApp({ mount, version, io, run }); + } finally { + // Also try cleanup when attach failed after partially mounting. Detach only + // our controlled mount point, never an untrusted dev-entry from the image. + if (attempted) run('/usr/bin/hdiutil', ['detach', mount]); + io.rmdirSync(mount); + } + const finalSha256 = createHash('sha256').update(io.readFileSync(dmg)).digest('hex'); + return { dmg, version, inputSha256, finalSha256 }; +} + if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { console.log(JSON.stringify(verifyMacosBundle(), null, 2)); diff --git a/scripts/verify-macos-bundle.test.mjs b/scripts/verify-macos-bundle.test.mjs index b9e6d82..a9681a7 100644 --- a/scripts/verify-macos-bundle.test.mjs +++ b/scripts/verify-macos-bundle.test.mjs @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import path from 'node:path'; -import { verifyMacosBundle } from './verify-macos-bundle.mjs'; +import { verifyMacosBundle, verifyMountedApp, validateMountEntities, stapleAndVerifyDmg } from './verify-macos-bundle.mjs'; const sourceSha = 'a'.repeat(40); const root = path.resolve('/repo'); @@ -44,7 +44,7 @@ function fixture() { if (command === '/usr/bin/hdiutil') return ''; throw new Error('Unexpected command'); }; - return { files, plist, calls, verify: (overrides = {}) => verifyMacosBundle({ root, sourceSha, io, run, ...overrides }) }; + return { files, plist, calls, io, run, verify: (overrides = {}) => verifyMacosBundle({ root, sourceSha, io, run, ...overrides }) }; } test('verifies native bundle metadata, DMG integrity and source-linked digest without launching it', () => { @@ -142,3 +142,102 @@ test('rejects malformed source provenance and propagates plist tool failure', () } assert.throws(() => fixture().verify({ run: () => { throw new Error('plutil exit 1'); } }), /plutil exit 1/); }); + +const signature = 'Authority=Developer ID Application: Synthetic (SRD8T33RU5)\nTeamIdentifier=SRD8T33RU5\n'; +const mount = path.dirname(app); +function mountedFixture() { + const f = fixture(); + const run = (command, args) => { + f.calls.push([command, args]); + if (command.endsWith('/plutil')) return JSON.stringify(f.plist); + if (command.endsWith('/codesign') && args[0] === '-dv') return signature; + return ''; + }; + return { ...f, run, mounted: (overrides = {}) => verifyMountedApp({ mount, version: '0.6.0', io: f.io, run, ...overrides }) }; +} + +test('verifies only the exact app inside the DMG, with authority, metadata, native ARM64 and Gatekeeper', () => { + const f = mountedFixture(); + assert.equal(f.mounted(), app); + assert.ok(f.calls.some(([command, args]) => command.endsWith('/spctl') && args.at(-1) === app)); + for (const [field, value] of [['CFBundleShortVersionString', '0.7.0'], ['CFBundleIdentifier', 'wrong'], ['LSMinimumSystemVersion', '14.0'], ['CFBundleExecutable', '../escape']]) { + const bad = mountedFixture(); bad.plist[field] = value; + assert.throws(() => bad.mounted(), /mismatch|executable/); + } + const binary = mountedFixture(); + binary.files.get(path.join(app, 'Contents/MacOS/quorb')).data.writeUInt32LE(0x01000007, 4); + assert.throws(() => binary.mounted(), /arm64/); + for (const value of [signature.replaceAll('SRD8T33RU5', 'WRONGTEAM1'), 'TeamIdentifier=SRD8T33RU5\n']) { + const bad = mountedFixture(); + assert.throws(() => bad.mounted({ run: (command, args) => command.endsWith('/codesign') ? value : bad.run(command, args) }), /authority or team/); + } + const network = mountedFixture(); + assert.throws(() => network.mounted({ run: (command, args) => { + if (command.endsWith('/spctl')) throw new Error('Gatekeeper offline'); + return network.run(command, args); + } }), /Gatekeeper offline/); +}); + +test('mounted app paths reject symlinks, extra or renamed apps and unsafe volume entities', () => { + for (const target of [mount, app, path.join(app, 'Contents'), path.join(app, 'Contents/Info.plist'), path.join(app, 'Contents/MacOS/quorb')]) { + const f = mountedFixture(); f.files.set(target, { type: 'symbolicLink' }); + assert.throws(() => f.mounted(), /symlink/); + } + const extra = mountedFixture(); extra.files.set(path.join(mount, 'Other.app'), { type: 'directory' }); + assert.throws(() => extra.mounted(), /only contained/); + const good = { 'system-entities': [{ 'dev-entry': '/dev/disk4' }, { 'dev-entry': '/dev/disk4s1', 'mount-point': mount }] }; + assert.doesNotThrow(() => validateMountEntities(good, mount)); + for (const bad of [{}, { 'system-entities': [] }, { 'system-entities': [{ 'dev-entry': '/dev/disk4', 'mount-point': '/Volumes/Other' }] }, + { 'system-entities': [...good['system-entities'], { 'dev-entry': '/dev/disk5', 'mount-point': mount }] }, + { 'system-entities': [{ 'dev-entry': '/dev/disk4; injected', 'mount-point': mount }] }]) { + assert.throws(() => validateMountEntities(bad, mount), /entities|volume/); + } +}); + +test('staples only retained outer DMG, mounts readonly, always detaches, hashes final bytes separately', () => { + for (const fail of ['', 'attach', 'metadata', 'entities', 'detach']) { + const f = mountedFixture(); + f.io.mkdtempSync = () => mount; + f.io.rmdirSync = () => f.calls.push(['cleanup']); + const inputSha256 = createHash('sha256').update('disk image').digest('hex'); + const run = (command, args) => { + if (args[0] === 'stapler' && args[1] === 'staple') f.files.get(dmg).data = Buffer.from('stapled disk image'); + if (args[0] === fail || (fail === 'metadata' && command.endsWith('/plutil'))) { + f.calls.push([command, args]); throw new Error('synthetic failure'); + } + return f.run(command, args); + }; + const verify = () => stapleAndVerifyDmg({ dmg, version: '0.6.0', inputSha256, temp: '/controlled', io: f.io, run, + parsePlist: () => fail === 'entities' ? {} : { 'system-entities': [{ 'dev-entry': '/dev/disk4', 'mount-point': mount }] } }); + if (fail) assert.throws(verify); + else { + const result = verify(); + assert.equal(result.inputSha256, inputSha256); + assert.equal(result.finalSha256, createHash('sha256').update('stapled disk image').digest('hex')); + assert.notEqual(result.finalSha256, result.inputSha256); + } + const attach = f.calls.find(([, args]) => args?.[0] === 'attach'); + assert.ok(attach[1].includes('-readonly')); + assert.ok(attach[1].includes('-nobrowse')); + assert.ok(attach[1].includes('-plist')); + assert.ok(f.calls.some(([, args]) => args?.[0] === 'detach' && args[1] === mount)); + assert.ok(f.calls.filter(([, args]) => args?.[0] === 'stapler').every(([, args]) => args.at(-1) === dmg)); + } +}); + +test('real Python plist parser accepts XML entities and malformed plist still triggers controlled detach', () => { + for (const malformed of [false, true]) { + const f = mountedFixture(); + f.io.mkdtempSync = () => mount; + f.io.rmdirSync = () => {}; + const xml = `system-entitiesdev-entry/dev/disk4mount-point${mount}`; + const run = (command, args) => { + if (args[0] === 'attach') { f.calls.push([command, args]); return malformed ? 'not plist' : xml; } + return f.run(command, args); + }; + const verify = () => stapleAndVerifyDmg({ dmg, version: '0.6.0', inputSha256: createHash('sha256').update('disk image').digest('hex'), temp: '/controlled', io: f.io, run }); + if (malformed) assert.throws(verify); + else assert.equal(verify().version, '0.6.0'); + assert.ok(f.calls.some(([, args]) => args?.[0] === 'detach' && args[1] === mount)); + } +}); From ab0eb76b253b9bc2a75bfbc6e36469aa8d10b6ec Mon Sep 17 00:00:00 2001 From: egdev6 Date: Fri, 30 Oct 2026 14:40:14 +0100 Subject: [PATCH 2/2] test(macos): normalize fixture paths across Windows and POSIX --- scripts/macos-notary-artifact-fixtures.mjs | 19 ++++++++---- scripts/macos-notary-checkpoint.test.mjs | 34 +++++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/scripts/macos-notary-artifact-fixtures.mjs b/scripts/macos-notary-artifact-fixtures.mjs index 99eecb9..9e0e3fd 100644 --- a/scripts/macos-notary-artifact-fixtures.mjs +++ b/scripts/macos-notary-artifact-fixtures.mjs @@ -13,6 +13,18 @@ sys.stdout.buffer.write(b.getvalue())`], { }); } +export function fileFixture(entries = []) { + // Canonical fixture keys only: do not resolve traversal or change manifest/ZIP names. + const key = (name) => name.replaceAll('\\', '/'); + const files = new Map(entries.map(([name, bytes]) => [key(name), bytes])); + const io = { + readFileSync: (name) => files.get(key(name)), + mkdirSync: (name) => { name = key(name); if (files.has(name)) throw new Error('exists'); files.set(name, null); }, + writeFileSync: (name, bytes, opts) => { name = key(name); if (files.has(name) || opts.flag !== 'wx') throw new Error('overwrite'); files.set(name, bytes); }, + }; + return { files, io }; +} + export function producerFixture() { const workflowSha = 'a'.repeat(40); const sourceSha = 'b'.repeat(40); @@ -34,13 +46,8 @@ export function producerFixture() { const receipt = { schema: 1, ...trusted, checkpointArtifactId: '456', checkpointArtifactDigest: checkpoint.digest.slice(7), submissionId, dmgSha256: manifest.sha256 }; const receiptArtifact = artifact('macos-notary-receipt-123-2', 457, { 'receipt.json': JSON.stringify(receipt) }); const artifacts = [checkpoint, receiptArtifact]; - const files = new Map([[trusted.workflowPath, 'reviewed workflow bytes']]); + const { files, io } = fileFixture([[trusted.workflowPath, 'reviewed workflow bytes']]); const calls = []; - const io = { - readFileSync: (name) => files.get(name), - mkdirSync: (name) => { if (files.has(name)) throw new Error('exists'); files.set(name, null); }, - writeFileSync: (name, bytes, opts) => { if (files.has(name) || opts.flag !== 'wx') throw new Error('overwrite'); files.set(name, bytes); }, - }; const env = { GITHUB_REF: 'refs/heads/main', GITHUB_REPOSITORY: trusted.repository, GITHUB_SHA: 'd'.repeat(40), RELEASE_VERSION: trusted.version, RELEASE_SOURCE_SHA: sourceSha, RELEASE_ID: '42', SOURCE_RUN_ID: '123', SOURCE_RUN_ATTEMPT: '2', GH_TOKEN: 'synthetic' }; const exec = (command, args) => { diff --git a/scripts/macos-notary-checkpoint.test.mjs b/scripts/macos-notary-checkpoint.test.mjs index 8c825d2..43a251e 100644 --- a/scripts/macos-notary-checkpoint.test.mjs +++ b/scripts/macos-notary-checkpoint.test.mjs @@ -1,6 +1,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileFixture } from './macos-notary-artifact-fixtures.mjs'; import { validateManifest, validateDmg, validateReceipt, signingEnvironment, createCheckpoint, buildSigned } from './macos-notary-checkpoint.mjs'; import { submitCheckpoint } from './macos-notary-submit.mjs'; @@ -19,6 +21,29 @@ export const receipt = { submissionId: '12345678-1234-1234-1234-123456789abc', dmgSha256: manifest.sha256, }; +for (const [style, paths] of [['POSIX', path.posix], ['Windows', path.win32]]) { + test(`offline filesystem canonicalizes ${style} separators without weakening exclusive writes or exact file checks`, () => { + for (const directory of ['notary-checkpoint', 'notary-retained', 'notary-publish']) { + const { files, io } = fileFixture(); + io.mkdirSync(directory); + const dmg = paths.join(directory, manifest.dmgName); + io.writeFileSync(dmg, bytes, { flag: 'wx' }); + io.writeFileSync(paths.join(directory, 'manifest.json'), JSON.stringify(manifest), { flag: 'wx' }); + assert.deepEqual(files.get(`${directory}/${manifest.dmgName}`), bytes); + assert.deepEqual(io.readFileSync(`${directory}/${manifest.dmgName}`), bytes); + assert.deepEqual(io.readFileSync(dmg), bytes); + assert.deepEqual([...files.keys()].filter((name) => name.startsWith(`${directory}/`)).sort(), + [`${directory}/${manifest.dmgName}`, `${directory}/manifest.json`]); + assert.ok(!files.has(`${directory}/unexpected.dmg`)); + assert.throws(() => io.writeFileSync(`${directory}/${manifest.dmgName}`, bytes, { flag: 'wx' }), /overwrite/); + assert.throws(() => io.writeFileSync(paths.join(directory, 'unexpected.dmg'), bytes, { flag: 'w' }), /overwrite/); + assert.throws(() => io.mkdirSync(directory), /exists/); + assert.deepEqual(files.get(`${directory}/${manifest.dmgName}`), bytes); + assert.ok(!files.has(`${directory}/unexpected.dmg`)); + } + }); +} + test('manifest and receipt are strictly bound to externally trusted values, not self-authenticated', () => { assert.deepEqual(validateManifest(manifest, trusted), manifest); assert.doesNotThrow(() => validateDmg(bytes, manifest)); @@ -53,13 +78,8 @@ test('build removes every Tauri notarization credential including alternate Appl test('checkpoint contains only signed DMG and strict manifest; successful upload binding is required before key creation or submit', () => { const env = { GITHUB_REF: 'refs/heads/main', GITHUB_REPOSITORY: trusted.repository, GITHUB_SHA: trusted.workflowSha, GITHUB_RUN_ID: trusted.runId, GITHUB_RUN_ATTEMPT: trusted.runAttempt, RELEASE_SOURCE_SHA: trusted.sourceSha, RELEASE_VERSION: trusted.version, RELEASE_ID: trusted.releaseId }; - const files = new Map([['/synthetic/Quorb_0.8.0_aarch64.dmg', bytes]]); + const { files, io } = fileFixture([['/synthetic/Quorb_0.8.0_aarch64.dmg', bytes]]); const signatures = []; - const io = { - readFileSync: (name) => files.get(name), - mkdirSync: (name) => { assert.ok(!files.has(name)); files.set(name, null); }, - writeFileSync: (name, data, options) => { assert.equal(options.flag, 'wx'); assert.ok(!files.has(name)); files.set(name, data); }, - }; const result = createCheckpoint({ env, io, verify: () => ({ version: '0.8.0', app: '/synthetic/Quorb.app', dmg: '/synthetic/Quorb_0.8.0_aarch64.dmg' }), signature: (target) => signatures.push(target) }); assert.deepEqual(result, manifest); assert.equal(signatures.length, 2); @@ -69,7 +89,7 @@ test('checkpoint contains only signed DMG and strict manifest; successful upload assert.throws(() => submitCheckpoint({ env, io, key }), /upload must succeed/); assert.equal(keyCalls, 0); const uploaded = { ...env, CHECKPOINT_ARTIFACT_ID: '456', CHECKPOINT_ARTIFACT_DIGEST: 'c'.repeat(64) }; - const saved = submitCheckpoint({ env: uploaded, io, key, run: (command, args) => { + const saved = submitCheckpoint({ env: uploaded, io, key, run: (_command, args) => { assert.ok(files.has('notary-checkpoint/manifest.json')); assert.ok(!args.includes('--wait')); return JSON.stringify({ id: receipt.submissionId }); } });