From cadcfaf4745e7a62d5b739015daa3ab38c0ffdb5 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:06:04 -0700 Subject: [PATCH 1/2] feat(release): sign and notarize macOS builds --- .github/macos-release-identity.json | 11 ++ .github/workflows/release.yml | 34 +++- README.md | 1 + SECURITY.md | 1 + apps/desktop/build/entitlements.mac.plist | 10 + docs/MACOS_SIGNING.md | 76 ++++++++ docs/RELEASE_GATE.md | 2 + electron-builder.config.mjs | 15 +- package.json | 3 +- scripts/check-release-consistency.mjs | 59 +++++- scripts/inspect-macos-release.mjs | 212 ++++++++++++++++++++++ scripts/inspect-macos-release.test.mjs | 65 +++++++ scripts/package-mac-signed.mjs | 35 ++++ scripts/package-mac-unsigned.mjs | 3 +- scripts/packaging.test.mjs | 40 ++++ 15 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 .github/macos-release-identity.json create mode 100644 apps/desktop/build/entitlements.mac.plist create mode 100644 docs/MACOS_SIGNING.md create mode 100644 scripts/inspect-macos-release.mjs create mode 100644 scripts/inspect-macos-release.test.mjs create mode 100644 scripts/package-mac-signed.mjs diff --git a/.github/macos-release-identity.json b/.github/macos-release-identity.json new file mode 100644 index 0000000..dea3085 --- /dev/null +++ b/.github/macos-release-identity.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "bundleId": "com.lycaonsolutions.t4code", + "teamId": "WJLM3D3DK6", + "certificateCommonName": "Developer ID Application: Michael Schoenberger (WJLM3D3DK6)", + "certificateSha256": "7f946ec45aabcb895a46df233f326d8a878d1e874b2d546bfa781d4bd7b081ef", + "certificateAuthority": "Developer ID Certification Authority", + "architecture": "arm64", + "firstSignedReleaseTag": "v0.1.24", + "notarizationRequired": true +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b53a631..c9027c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -308,14 +308,44 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build unsigned macOS packages - run: pnpm package:mac:unsigned + - name: Restore notarization API key + shell: bash + env: + T4_APPLE_API_KEY_BASE64: ${{ secrets.T4_APPLE_API_KEY_BASE64 }} + run: | + set -euo pipefail + if [[ -z "${T4_APPLE_API_KEY_BASE64:-}" ]]; then + echo "required GitHub Actions secret T4_APPLE_API_KEY_BASE64 is not configured" >&2 + exit 1 + fi + key_path="$RUNNER_TEMP/AuthKey.p8" + printf '%s' "$T4_APPLE_API_KEY_BASE64" | openssl base64 -d -A -out "$key_path" + chmod 600 "$key_path" + + - name: Build signed and notarized macOS packages + env: + CSC_LINK: ${{ secrets.T4_MACOS_CERTIFICATE_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.T4_MACOS_CERTIFICATE_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8 + APPLE_API_KEY_ID: ${{ secrets.T4_APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.T4_APPLE_API_ISSUER_ID }} + APPLE_TEAM_ID: ${{ secrets.T4_APPLE_TEAM_ID }} + run: pnpm package:mac - name: Inspect macOS packages run: | pnpm inspect:package -- release/*.zip pnpm inspect:dmg -- release/*.dmg + - name: Verify Developer ID identity and notarization + env: + VERSION: ${{ needs.verify.outputs.version }} + run: >- + node scripts/inspect-macos-release.mjs + "release/T4-Code-${VERSION}-mac-arm64.zip" + "release/T4-Code-${VERSION}-mac-arm64.dmg" + .github/macos-release-identity.json + - name: Stage macOS artifacts shell: bash run: | diff --git a/README.md b/README.md index 175adb1..a476324 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ pnpm test # workspace tests pnpm test:soak # headless 10k-history and 20-reconnect stress checks pnpm package:linux # .deb + AppImage into release/ pnpm package:mac:unsigned # unsigned macOS build (on a Mac) +pnpm package:mac # maintainer-only signed and notarized macOS build ``` The soak command needs no phone, Android emulator, or macOS simulator. It checks the shared data diff --git a/SECURITY.md b/SECURITY.md index fd5883f..4d5e606 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,3 +27,4 @@ We read every report and will reply to tell you what happens next. This is a sma - T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at . - Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome. - The macOS v0.1.23 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app. +- Starting with v0.1.24, the release workflow requires the pinned Developer ID identity, hardened runtime, Apple notarization, a stapled ticket, and a successful Gatekeeper assessment before publishing macOS artifacts. diff --git a/apps/desktop/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist new file mode 100644 index 0000000..9ab52e1 --- /dev/null +++ b/apps/desktop/build/entitlements.mac.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/docs/MACOS_SIGNING.md b/docs/MACOS_SIGNING.md new file mode 100644 index 0000000..4717536 --- /dev/null +++ b/docs/MACOS_SIGNING.md @@ -0,0 +1,76 @@ +# macOS signing and notarization + +T4 Code distributes its Mac build directly through GitHub Releases. It does not use the Mac App Store. Starting with v0.1.24, the protected release workflow must sign the application with Apple Developer ID and submit it to Apple's automated notarization service before publication. + +```text +source tag + | + v +protected macOS runner + | + +-- imports the encrypted Developer ID certificate + +-- builds with hardened runtime + +-- signs every application component + +-- submits the app to Apple notarization + +-- staples Apple's ticket + +-- builds the DMG and ZIP + +-- reopens both artifacts and checks their identity + v +release artifacts +``` + +## Public identity contract + +`.github/macos-release-identity.json` pins the bundle ID, Apple Team ID, Developer ID certificate, target architecture, and first signed release. The release inspector extracts the leaf certificate from the built application and compares its SHA-256 fingerprint with that contract. + +Certificate rotation is a deliberate release change. Create the replacement certificate first, update the public identity contract, verify a dry-run build, and only then replace the GitHub secret. Do not silently accept any valid Developer ID certificate. + +## GitHub Actions secrets + +The repository uses these encrypted secrets: + +| Secret | Purpose | +| --- | --- | +| `T4_MACOS_CERTIFICATE_BASE64` | Base64-encoded password-protected Developer ID `.p12` bundle | +| `T4_MACOS_CERTIFICATE_PASSWORD` | Password for the `.p12` bundle | +| `T4_APPLE_API_KEY_BASE64` | Base64-encoded App Store Connect `.p8` key used for notarization | +| `T4_APPLE_API_KEY_ID` | App Store Connect API key ID | +| `T4_APPLE_API_ISSUER_ID` | App Store Connect API issuer ID | +| `T4_APPLE_TEAM_ID` | Apple Developer team identifier | + +Secrets are available only to the protected release job. Pull requests and ordinary CI keep using dependency-free checks and never receive Apple credentials. + +## Local commands + +Contributors can still build an unsigned artifact without Apple credentials: + +```bash +pnpm package:mac:unsigned +``` + +Maintainers can produce the release form when the five electron-builder credential variables are present: + +```bash +CSC_LINK=/path/to/certificate.p12 \ +CSC_KEY_PASSWORD='...' \ +APPLE_API_KEY=/path/to/AuthKey.p8 \ +APPLE_API_KEY_ID='...' \ +APPLE_API_ISSUER='...' \ +pnpm package:mac +``` + +Do not put these values in `.env` files, shell history, source control, build logs, or issue reports. + +## Verification boundary + +`scripts/inspect-macos-release.mjs` reopens the ZIP and mounts the DMG read-only. For the application inside each artifact it verifies: + +- the complete code signature; +- the exact bundle ID and Apple Team ID; +- the pinned leaf-certificate SHA-256 fingerprint; +- the Developer ID certificate chain; +- hardened runtime and a secure timestamp; +- a valid stapled notarization ticket; and +- acceptance by macOS Gatekeeper. + +The first signed release remains a manual GitHub download. Automatic macOS updates are a separate follow-up because migration from the older unsigned build must be tested independently. diff --git a/docs/RELEASE_GATE.md b/docs/RELEASE_GATE.md index 4492d28..5e47558 100644 --- a/docs/RELEASE_GATE.md +++ b/docs/RELEASE_GATE.md @@ -17,6 +17,7 @@ Every release must pass the layers below. Destructive lifecycle checks use a dis - Run lint, type checks, unit/integration suites, production builds, packaging/tooling checks, and Playwright. - Exercise a complete inventory, a truncated inventory, reconnects, authoritative empty state, stale routes, and two clients observing the same session changes. - Run the separate unsigned Android job with pinned Java 21, Android platform 36, and build tools 36.0.0. It must complete `testDebugUnitTest`, `assembleDebug`, and `lintDebug` without release-signing secrets. + - Build macOS release artifacts only in the protected release job. The ZIP and DMG must contain an app signed with the pinned Developer ID certificate and Team ID, hardened runtime, a secure timestamp, Apple notarization, and a stapled ticket. Gatekeeper must accept the app from both artifacts. 4. Touch layouts - Use real CDP touch input at 320 pixels for model-list drag scrolling and selection. Check Send and session-management control reachability at 320, 360, and 390 pixels, including a short 390 x 500 viewport. - Open and close the session rail, create a session, reach the Send control, drag-scroll the model list, and select its last available model. @@ -31,6 +32,7 @@ Every release must pass the layers below. Destructive lifecycle checks use a dis 6. Open the actual Tailscale Serve HTTPS URL in a touch browser. Confirm connected state, shared history, model selection, prompt round-trip, reload recovery, and usable controls at the narrowest viewport. 7. Confirm the route is Tailscale Serve only. Funnel must be off. 8. Verify the exact seven-asset GitHub bundle: five installable packages, `latest-linux.yml`, and `SHA256SUMS.txt`. The checksum file must contain exactly the five package digests plus the Linux updater-metadata digest. Fetch `https://t4code.net/releases/latest.json` and match its schema, version, tag, release URL, five canonical package records, sizes, immutable URLs, and SHA-256 digests against that GitHub release. +9. On macOS, download the public DMG through a browser, copy T4 Code to Applications, and open it without a quarantine-removal command. Confirm Gatekeeper reports the pinned Developer ID publisher and launches the app normally. Release sequencing is enforced by the workflows. The branch-required `verify` check is a fail-closed aggregate of the core, tooling, and unsigned Android jobs. Main-branch runs are keyed by commit and are not cancelled by a later push; pull requests still cancel stale runs. A release tag must match the package version at its immutable commit and resolve to a commit reachable from `main`; a manual rerun may therefore repair an older valid release after `main` advances. The workflow itself must still be dispatched from current `main`, so historical source cannot replace release-control logic. After that source check, the platform builds and the CI authority check run in parallel. The authority check accepts only a successful, completed `push` run of `.github/workflows/ci.yml` on `main` for the exact release SHA. Its wait budget exceeds the longest required CI leg, and publication waits for that run and all three platform builds. diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index 3482d12..5b4f5a8 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -3,6 +3,7 @@ const runtimeExternalDependencies = [ "node_modules/electron-updater/**/*", "node_modules/ws/**/*", ]; +const signedMacBuild = process.env.T4_MACOS_SIGNED_BUILD === "1"; export const linuxUpdatePublish = { provider: "github", @@ -46,9 +47,17 @@ const config = { mac: { category: "public.app-category.developer-tools", icon: "apps/desktop/build/icon.png", - // The public macOS build is intentionally unsigned and unnotarized. Keep - // it on the explicit-download path until there is an honest signed update - // channel; do not emit latest-mac.yml for electron-updater. + identity: signedMacBuild ? undefined : null, + hardenedRuntime: signedMacBuild, + gatekeeperAssess: false, + entitlements: signedMacBuild ? "apps/desktop/build/entitlements.mac.plist" : undefined, + entitlementsInherit: signedMacBuild + ? "apps/desktop/build/entitlements.mac.plist" + : undefined, + notarize: signedMacBuild, + // The first signed release remains an explicit GitHub download. Keep the + // updater feed disabled until signed-to-signed update migration has its + // own release proof. publish: [], target: [ { target: "dmg", arch: ["arm64"] }, diff --git a/package.json b/package.json index 088d664..ead88ba 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,11 @@ "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && node scripts/package-preflight.mjs", "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", "package:mac:unsigned": "node scripts/package-mac-unsigned.mjs", + "package:mac": "node scripts/package-mac-signed.mjs", "package:dir": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64 --dir", "inspect:package": "node scripts/inspect-package.mjs", "inspect:dmg": "node scripts/inspect-macos-dmg.mjs", - "test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs", + "test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs scripts/inspect-macos-release.test.mjs", "test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs", "check:release": "node scripts/check-release-consistency.mjs", "check:provenance": "node scripts/check-provenance.mjs", diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 30a6346..86a2d7b 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; export const RELEASE_CONTRACT_PATHS = [ ".github/android-release-identity.json", + ".github/macos-release-identity.json", ".github/ISSUE_TEMPLATE/bug_report.yml", ".github/workflows/ci.yml", ".github/workflows/deploy-site.yml", @@ -19,6 +20,7 @@ export const RELEASE_CONTRACT_PATHS = [ "apps/web/src/platform/browser-shell-port.ts", "compat/omp-app-matrix.json", "docs/CURRENT_RELEASE_NOTES.md", + "docs/MACOS_SIGNING.md", "docs/RELEASE_GATE.md", "ops/t4-maintainer/README.md", "packages/client/src/omp-client-frames.ts", @@ -42,6 +44,15 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/u; const SHA256_PATTERN = /^[0-9a-f]{64}$/u; const PATCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +function compareStableVersions(left, right) { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]; + } + return 0; +} + export function expectedReleaseAssetNames(version) { return [ `T4-Code-${version}-android.apk`, @@ -284,6 +295,36 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ); } + const macosIdentityPath = ".github/macos-release-identity.json"; + const macosIdentity = parseJson(files, macosIdentityPath, errors); + if (macosIdentity?.schemaVersion !== 1) { + errors.push(`${macosIdentityPath} schemaVersion must be 1`); + } + if (macosIdentity?.bundleId !== "com.lycaonsolutions.t4code") { + errors.push(`${macosIdentityPath} bundleId must be com.lycaonsolutions.t4code`); + } + if (!/^[A-Z0-9]{10}$/u.test(macosIdentity?.teamId ?? "")) { + errors.push(`${macosIdentityPath} teamId must be 10 uppercase letters or digits`); + } + if ( + typeof macosIdentity?.certificateSha256 !== "string" || + !SHA256_PATTERN.test(macosIdentity.certificateSha256) + ) { + errors.push(`${macosIdentityPath} certificate must be a lowercase SHA-256 digest`); + } + if (macosIdentity?.certificateAuthority !== "Developer ID Certification Authority") { + errors.push(`${macosIdentityPath} must pin the Developer ID Certification Authority`); + } + if (macosIdentity?.architecture !== "arm64") { + errors.push(`${macosIdentityPath} architecture must be arm64`); + } + if (!/^v\d+\.\d+\.\d+$/u.test(macosIdentity?.firstSignedReleaseTag ?? "")) { + errors.push(`${macosIdentityPath} firstSignedReleaseTag must be vX.Y.Z`); + } + if (macosIdentity?.notarizationRequired !== true) { + errors.push(`${macosIdentityPath} must require notarization`); + } + const packagePaths = [...files.keys()] .filter( (path) => path === "package.json" || /^(?:apps|packages)\/[^/]+\/package\.json$/u.test(path), @@ -646,9 +687,22 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { requireText(releaseNotes, expected, "docs/CURRENT_RELEASE_NOTES.md", errors); } + const securityPolicy = files.get("SECURITY.md") ?? ""; + const firstSignedVersion = String(macosIdentity?.firstSignedReleaseTag ?? "").replace(/^v/u, ""); + const signedRelease = VERSION_PATTERN.test(firstSignedVersion) + ? compareStableVersions(version, firstSignedVersion) >= 0 + : false; + requireText( + securityPolicy, + signedRelease + ? `The macOS ${expectedTag} build is signed with Apple Developer ID and notarized by Apple` + : `The macOS ${expectedTag} build is unsigned and unnotarized`, + "SECURITY.md", + errors, + ); requireText( - files.get("SECURITY.md") ?? "", - `The macOS ${expectedTag} build is unsigned and unnotarized`, + securityPolicy, + `Starting with ${macosIdentity?.firstSignedReleaseTag ?? "the first signed release"}`, "SECURITY.md", errors, ); @@ -958,6 +1012,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { const releaseGate = files.get("docs/RELEASE_GATE.md") ?? ""; for (const expected of [ "`testDebugUnitTest`, `assembleDebug`, and `lintDebug`", + "pinned Developer ID certificate", "exact seven-asset GitHub bundle", "defers only when the exact GitHub release lookup returns HTTP 404", "writes `/releases/latest.json`", diff --git a/scripts/inspect-macos-release.mjs b/scripts/inspect-macos-release.mjs new file mode 100644 index 0000000..13cf7aa --- /dev/null +++ b/scripts/inspect-macos-release.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; +const TAG_PATTERN = /^v\d+\.\d+\.\d+$/u; + +function requireString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return value; +} + +export function validateMacosIdentityContract(contract) { + if (!contract || typeof contract !== "object" || Array.isArray(contract)) { + throw new Error("macOS release identity must be an object"); + } + if (contract.schemaVersion !== 1) throw new Error("macOS identity schemaVersion must be 1"); + if (requireString(contract.bundleId, "bundleId") !== "com.lycaonsolutions.t4code") { + throw new Error("macOS identity bundleId must be com.lycaonsolutions.t4code"); + } + if (!TEAM_ID_PATTERN.test(requireString(contract.teamId, "teamId"))) { + throw new Error("macOS identity teamId must be 10 uppercase letters or digits"); + } + requireString(contract.certificateCommonName, "certificateCommonName"); + if (!SHA256_PATTERN.test(requireString(contract.certificateSha256, "certificateSha256"))) { + throw new Error("macOS identity certificateSha256 must be a lowercase SHA-256 digest"); + } + requireString(contract.certificateAuthority, "certificateAuthority"); + if (contract.architecture !== "arm64") { + throw new Error("macOS identity architecture must be arm64"); + } + if (!TAG_PATTERN.test(requireString(contract.firstSignedReleaseTag, "firstSignedReleaseTag"))) { + throw new Error("macOS identity firstSignedReleaseTag must be vX.Y.Z"); + } + if (contract.notarizationRequired !== true) { + throw new Error("macOS identity must require notarization"); + } + return Object.freeze({ ...contract }); +} + +export function parseCodesignDisplay(output) { + const fields = new Map(); + const authorities = []; + let hardenedRuntime = false; + for (const line of String(output).split(/\r?\n/u)) { + const trimmedLine = line.trim(); + const separator = line.indexOf("="); + if (separator >= 0) { + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (key === "Authority") authorities.push(value); + else fields.set(key, value); + } + if (/\bflags=.*\bruntime\b/iu.test(trimmedLine) || trimmedLine.startsWith("Runtime Version=")) { + hardenedRuntime = true; + } + } + return { + identifier: fields.get("Identifier") ?? null, + teamIdentifier: fields.get("TeamIdentifier") ?? null, + timestamp: fields.get("Timestamp") ?? null, + authorities, + hardenedRuntime, + }; +} + +export function validateMacosSignatureReport(report, contract) { + const identity = validateMacosIdentityContract(contract); + const errors = []; + if (report.identifier !== identity.bundleId) { + errors.push(`bundle identifier ${report.identifier ?? "missing"} does not match ${identity.bundleId}`); + } + if (report.teamIdentifier !== identity.teamId) { + errors.push(`team identifier ${report.teamIdentifier ?? "missing"} does not match ${identity.teamId}`); + } + if (!report.authorities.includes(identity.certificateCommonName)) { + errors.push(`signing authority does not include ${identity.certificateCommonName}`); + } + if (!report.authorities.includes(identity.certificateAuthority)) { + errors.push(`certificate chain does not include ${identity.certificateAuthority}`); + } + if (report.certificateSha256 !== identity.certificateSha256) { + errors.push("leaf signing certificate SHA-256 does not match the pinned release identity"); + } + if (!report.hardenedRuntime) errors.push("hardened runtime is not enabled"); + if (!report.timestamp) errors.push("secure signing timestamp is missing"); + if (errors.length > 0) throw new Error(errors.join("; ")); + return Object.freeze({ ...report }); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + timeout: 180_000, + killSignal: "SIGTERM", + ...options, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const details = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + throw new Error( + `${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}${details ? `: ${details}` : ""}`, + ); + } + return `${result.stdout ?? ""}${result.stderr ?? ""}`; +} + +function findSingleApp(directory) { + const apps = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && extname(entry.name).toLowerCase() === ".app") + .map((entry) => join(directory, entry.name)); + if (apps.length !== 1) throw new Error(`expected exactly one top-level macOS app; found ${apps.length}`); + return apps[0]; +} + +function inspectApp(appPath, contract, certificatePrefix) { + run("codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath]); + const display = run("codesign", [ + "--display", + "--verbose=4", + `--extract-certificates=${certificatePrefix}`, + appPath, + ]); + const leafCertificate = readFileSync(`${certificatePrefix}0`); + const report = { + ...parseCodesignDisplay(display), + certificateSha256: createHash("sha256").update(leafCertificate).digest("hex"), + }; + validateMacosSignatureReport(report, contract); + run("spctl", ["--assess", "--type", "execute", "--verbose=4", appPath]); + run("xcrun", ["stapler", "validate", appPath]); + return report; +} + +function requireArtifact(path, extension) { + const absolutePath = resolve(path); + let isFile = false; + try { + isFile = statSync(absolutePath).isFile(); + } catch { + isFile = false; + } + if (!isFile || extname(absolutePath).toLowerCase() !== extension) { + throw new Error(`expected an existing ${extension} artifact: ${absolutePath}`); + } + return absolutePath; +} + +export function inspectMacosRelease(zipPath, dmgPath, identityPath) { + if (process.platform !== "darwin") { + throw new Error(`macOS release inspection requires darwin; current platform is ${process.platform}`); + } + const zip = requireArtifact(zipPath, ".zip"); + const dmg = requireArtifact(dmgPath, ".dmg"); + const identity = validateMacosIdentityContract( + JSON.parse(readFileSync(resolve(identityPath), "utf8")), + ); + const root = mkdtempSync(join(tmpdir(), "t4-macos-release-")); + const zipRoot = join(root, "zip"); + const mountPoint = join(root, "dmg"); + let mounted = false; + try { + mkdirSync(zipRoot); + mkdirSync(mountPoint); + run("ditto", ["-x", "-k", zip, zipRoot]); + const zipReport = inspectApp(findSingleApp(zipRoot), identity, join(root, "zip-cert-")); + + run("hdiutil", ["attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint", mountPoint, dmg]); + mounted = true; + const dmgReport = inspectApp(findSingleApp(mountPoint), identity, join(root, "dmg-cert-")); + return Object.freeze({ zip: zipReport, dmg: dmgReport }); + } finally { + if (mounted) { + try { + run("hdiutil", ["detach", mountPoint]); + } catch { + run("hdiutil", ["detach", "-force", mountPoint]); + } + } + rmSync(root, { recursive: true, force: true }); + } +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (isMain) { + try { + const [zipPath, dmgPath, identityPath = ".github/macos-release-identity.json"] = process.argv.slice(2); + if (!zipPath || !dmgPath) { + throw new Error("usage: node scripts/inspect-macos-release.mjs APP.zip APP.dmg [identity.json]"); + } + inspectMacosRelease(zipPath, dmgPath, identityPath); + console.log("macOS Developer ID identity, hardened runtime, Gatekeeper, and notarization checks passed"); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/inspect-macos-release.test.mjs b/scripts/inspect-macos-release.test.mjs new file mode 100644 index 0000000..510b0b9 --- /dev/null +++ b/scripts/inspect-macos-release.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { + parseCodesignDisplay, + validateMacosIdentityContract, + validateMacosSignatureReport, +} from "./inspect-macos-release.mjs"; + +const identity = JSON.parse( + readFileSync(resolve(import.meta.dirname, "../.github/macos-release-identity.json"), "utf8"), +); + +const displayFixture = ` +Executable=/Applications/T4 Code.app/Contents/MacOS/t4-code +Identifier=com.lycaonsolutions.t4code +Format=app bundle with Mach-O thin (arm64) +CodeDirectory v=20500 size=640 flags=0x10000(runtime) hashes=10+7 location=embedded +Authority=Developer ID Application: Michael Schoenberger (WJLM3D3DK6) +Authority=Developer ID Certification Authority +Authority=Apple Root CA +Timestamp=Jul 18, 2026 at 8:30:00 PM +TeamIdentifier=WJLM3D3DK6 +`; + +test("macOS release identity pins the public Developer ID contract", () => { + assert.doesNotThrow(() => validateMacosIdentityContract(identity)); + assert.equal(identity.firstSignedReleaseTag, "v0.1.24"); + assert.equal(identity.notarizationRequired, true); +}); + +test("codesign display parser preserves identity, runtime, and timestamp", () => { + assert.deepEqual(parseCodesignDisplay(displayFixture), { + identifier: "com.lycaonsolutions.t4code", + teamIdentifier: "WJLM3D3DK6", + timestamp: "Jul 18, 2026 at 8:30:00 PM", + authorities: [ + "Developer ID Application: Michael Schoenberger (WJLM3D3DK6)", + "Developer ID Certification Authority", + "Apple Root CA", + ], + hardenedRuntime: true, + }); +}); + +test("signature report fails closed on certificate, team, runtime, and timestamp drift", async (t) => { + const valid = { + ...parseCodesignDisplay(displayFixture), + certificateSha256: identity.certificateSha256, + }; + assert.doesNotThrow(() => validateMacosSignatureReport(valid, identity)); + + const cases = [ + ["certificate", { certificateSha256: "0".repeat(64) }, /certificate SHA-256/u], + ["team", { teamIdentifier: "AAAAAAAAAA" }, /team identifier/u], + ["runtime", { hardenedRuntime: false }, /hardened runtime/u], + ["timestamp", { timestamp: null }, /timestamp/u], + ]; + for (const [name, override, expected] of cases) { + await t.test(name, () => { + assert.throws(() => validateMacosSignatureReport({ ...valid, ...override }, identity), expected); + }); + } +}); diff --git a/scripts/package-mac-signed.mjs b/scripts/package-mac-signed.mjs new file mode 100644 index 0000000..f06616f --- /dev/null +++ b/scripts/package-mac-signed.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; +import { runElectronBuilder } from "./run-electron-builder.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); + +if (process.platform !== "darwin") { + console.error(`package:mac requires macOS (darwin); current platform is ${process.platform}`); + process.exit(1); +} + +const requiredEnvironment = [ + "CSC_LINK", + "CSC_KEY_PASSWORD", + "APPLE_API_KEY", + "APPLE_API_KEY_ID", + "APPLE_API_ISSUER", +]; +const missingEnvironment = requiredEnvironment.filter((name) => !process.env[name]); +if (missingEnvironment.length > 0) { + console.error(`package:mac requires ${missingEnvironment.join(", ")}`); + process.exit(1); +} + +const signedEnvironment = { ...process.env, T4_MACOS_SIGNED_BUILD: "1" }; +const prepackage = spawnSync("pnpm", ["prepackage"], { + cwd: repoRoot, + env: signedEnvironment, + stdio: "inherit", +}); +if (prepackage.error) throw prepackage.error; +if (prepackage.status !== 0) process.exit(prepackage.status ?? 1); + +process.env.T4_MACOS_SIGNED_BUILD = "1"; +process.exitCode = runElectronBuilder(["--mac", "--arm64", ...process.argv.slice(2)]); diff --git a/scripts/package-mac-unsigned.mjs b/scripts/package-mac-unsigned.mjs index a1157a9..9d362e3 100644 --- a/scripts/package-mac-unsigned.mjs +++ b/scripts/package-mac-unsigned.mjs @@ -11,11 +11,12 @@ if (process.platform !== "darwin") { const prepackage = spawnSync("pnpm", ["prepackage"], { cwd: repoRoot, - env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false" }, + env: { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: "false", T4_MACOS_SIGNED_BUILD: "0" }, stdio: "inherit", }); if (prepackage.error) throw prepackage.error; if (prepackage.status !== 0) process.exit(prepackage.status ?? 1); process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false"; +process.env.T4_MACOS_SIGNED_BUILD = "0"; process.exitCode = runElectronBuilder(["--mac", "--arm64", ...process.argv.slice(2)]); diff --git a/scripts/packaging.test.mjs b/scripts/packaging.test.mjs index 1ef5d5b..d54bfae 100644 --- a/scripts/packaging.test.mjs +++ b/scripts/packaging.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { test } from "node:test"; import { join, resolve } from "node:path"; import config from "../electron-builder.config.mjs"; +import { validateMacosIdentityContract } from "./inspect-macos-release.mjs"; import { createPackage } from "@electron/asar"; import { runPreflight, validatePreloadArtifact, validateWebIndex } from "./package-preflight.mjs"; import { inspectPackage, locateAppRoot } from "./inspect-package.mjs"; @@ -25,6 +26,7 @@ import { const repoRoot = resolve(import.meta.dirname, ".."); const androidIdentity = JSON.parse(readFileSync(resolve(repoRoot, ".github/android-release-identity.json"), "utf8")); +const macosIdentity = JSON.parse(readFileSync(resolve(repoRoot, ".github/macos-release-identity.json"), "utf8")); const productionCertificate = "fa58f53c953a078d8db2b633ee8c226cfd2ad3f7220cd55dd03a2e195a81b0ac"; function androidReleaseFixture(overrides = {}) { @@ -73,9 +75,47 @@ test("builder config keeps release contract", () => { ]); assert.equal(config.mac.category, "public.app-category.developer-tools"); assert.deepEqual(config.mac.publish, []); + assert.equal(config.mac.identity, null); + assert.equal(config.mac.hardenedRuntime, false); + assert.equal(config.mac.notarize, false); assert.equal(config.artifactName, "T4-Code-${version}-${os}-${arch}.${ext}"); }); +test("signed macOS packaging is explicit, credentialed, and release-gated", async () => { + const previousSignedBuild = process.env.T4_MACOS_SIGNED_BUILD; + process.env.T4_MACOS_SIGNED_BUILD = "1"; + try { + const signedConfigUrl = new URL("../electron-builder.config.mjs", import.meta.url); + signedConfigUrl.searchParams.set("signed-test", String(Date.now())); + const { default: signedConfig } = await import(signedConfigUrl.href); + assert.equal(signedConfig.mac.identity, undefined); + assert.equal(signedConfig.mac.hardenedRuntime, true); + assert.equal(signedConfig.mac.notarize, true); + assert.equal(signedConfig.mac.entitlements, "apps/desktop/build/entitlements.mac.plist"); + assert.equal(signedConfig.mac.entitlementsInherit, "apps/desktop/build/entitlements.mac.plist"); + assert.deepEqual(signedConfig.mac.publish, []); + } finally { + if (previousSignedBuild === undefined) delete process.env.T4_MACOS_SIGNED_BUILD; + else process.env.T4_MACOS_SIGNED_BUILD = previousSignedBuild; + } + + assert.doesNotThrow(() => validateMacosIdentityContract(macosIdentity)); + const releaseWorkflow = readFileSync(resolve(repoRoot, ".github/workflows/release.yml"), "utf8"); + for (const expected of [ + "T4_MACOS_CERTIFICATE_BASE64", + "T4_MACOS_CERTIFICATE_PASSWORD", + "T4_APPLE_API_KEY_BASE64", + "T4_APPLE_API_KEY_ID", + "T4_APPLE_API_ISSUER_ID", + "T4_APPLE_TEAM_ID", + "pnpm package:mac", + "scripts/inspect-macos-release.mjs", + ]) { + assert.match(releaseWorkflow, new RegExp(expected, "u")); + } + assert.doesNotMatch(releaseWorkflow, /Build unsigned macOS packages/u); +}); + test("Android release identity is public, pinned, and wired into the release workflow", () => { assert.doesNotThrow(() => validateIdentityContract(androidIdentity)); assert.equal(androidIdentity.applicationId, "com.lycaonsolutions.t4code"); From 8719db2f9c34ad77b437431b10668c726d939919 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:13:43 -0700 Subject: [PATCH 2/2] test(release): require literal workflow entries --- scripts/packaging.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/packaging.test.mjs b/scripts/packaging.test.mjs index d54bfae..5464e0a 100644 --- a/scripts/packaging.test.mjs +++ b/scripts/packaging.test.mjs @@ -111,7 +111,7 @@ test("signed macOS packaging is explicit, credentialed, and release-gated", asyn "pnpm package:mac", "scripts/inspect-macos-release.mjs", ]) { - assert.match(releaseWorkflow, new RegExp(expected, "u")); + assert.ok(releaseWorkflow.includes(expected), `release workflow must include ${expected}`); } assert.doesNotMatch(releaseWorkflow, /Build unsigned macOS packages/u); });