From 80fa2cc340c02fe5676796221da94b1b6a384a3d Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Mon, 20 Jul 2026 19:23:16 +0300 Subject: [PATCH 1/2] Route telemetry through ScientFactory gateway (#43) --- apps/server/src/telemetry/Identify.ts | 80 +++---------------- .../telemetry/Layers/AnalyticsService.test.ts | 43 +++++++--- .../src/telemetry/Layers/AnalyticsService.ts | 29 ++++--- 3 files changed, 57 insertions(+), 95 deletions(-) diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 0f9703f80..4935ae531 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,59 +1,6 @@ -import { Effect, FileSystem, Path, Random, Schema } from "effect"; -import * as Crypto from "node:crypto"; -import { homedir } from "node:os"; +import { Effect, FileSystem, Random } from "effect"; import { ServerConfig } from "../config"; -const CodexAuthJsonSchema = Schema.Struct({ - tokens: Schema.Struct({ - account_id: Schema.String, - }), -}); - -const ClaudeJsonSchema = Schema.Struct({ - userID: Schema.String, -}); - -class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect), -}) {} - -const hash = (value: string) => - Effect.try({ - try: () => Crypto.createHash("sha256").update(value).digest("hex"), - catch: (error) => - new IdentifyUserError({ - message: "Failed to hash identifier", - cause: error, - }), - }); - -const getCodexAccountId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const authJsonPath = path.join(homedir(), ".codex", "auth.json"); - const authJson = yield* Effect.flatMap( - fileSystem.readFileString(authJsonPath), - Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), - ); - - return authJson.tokens.account_id; -}); - -const getClaudeUserId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const claudeJsonPath = path.join(homedir(), ".claude.json"); - const claudeJson = yield* Effect.flatMap( - fileSystem.readFileString(claudeJsonPath), - Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), - ); - - return claudeJson.userID; -}); - const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { anonymousIdPath } = yield* ServerConfig; @@ -68,29 +15,22 @@ const upsertAnonymousId = Effect.gen(function* () { ), ); - return anonymousId; + const trimmed = anonymousId.trim(); + if (trimmed.length > 0) return trimmed; + + const randomId = yield* Random.nextUUIDv4; + yield* fileSystem.writeFileString(anonymousIdPath, randomId); + return randomId; }); /** - * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. - * 1. ~/.codex/auth.json tokens.account_id - * 2. ~/.claude.json userID - * 3. SYNARA_HOME anonymous-id file (for example ~/.synara/userdata/anonymous-id) + * Returns a random installation-scoped identifier stored in Scient's state directory. + * It never reads or derives identity from connected AI-provider accounts. */ export const getTelemetryIdentifier = Effect.gen(function* () { - const codexAccountId = yield* Effect.result(getCodexAccountId); - if (codexAccountId._tag === "Success") { - return yield* hash(codexAccountId.success); - } - - const claudeUserId = yield* Effect.result(getClaudeUserId); - if (claudeUserId._tag === "Success") { - return yield* hash(claudeUserId.success); - } - const anonymousId = yield* Effect.result(upsertAnonymousId); if (anonymousId._tag === "Success") { - return yield* hash(anonymousId.success); + return `installation:${anonymousId.success}`; } return null; diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index ebcf2efde..9159ffb71 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -14,8 +14,14 @@ import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; interface RecordedBatchRequest { readonly path: string; readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; + readonly schema_version?: number; + readonly source?: string; + readonly events?: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly distinct_id?: string; + readonly occurred_at?: string; + readonly privacy_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; @@ -25,8 +31,13 @@ interface RecordedBatchRequest { } interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; + readonly schema_version: number; + readonly source: string; + readonly events: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly distinct_id?: string; + readonly privacy_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; @@ -46,8 +57,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ SYNARA_TELEMETRY_ENABLED: true, - SYNARA_POSTHOG_KEY: "phc_test_key", - SYNARA_POSTHOG_HOST: "", + SYNARA_TELEMETRY_ENDPOINT: "/v1/events", SYNARA_TELEMETRY_FLUSH_BATCH_SIZE: 20, }), ); @@ -88,16 +98,16 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const batchRequests = capturedRequests.filter( (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), + Array.isArray(request.body?.events), ); assert.equal(batchRequests.length, 3); assert.equal( - batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"), + batchRequests.every((request) => request.path === "/v1/events"), true, ); const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") + request.body.events + .filter((event) => event.name === "test.flush.drain") .map((event) => event.properties?.index) .filter((index): index is number => typeof index === "number"), ); @@ -109,8 +119,17 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Array.from({ length: 45 }, (_, index) => index), ); assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), + batchRequests.every( + (request) => + request.body.schema_version === 1 && + request.body.source === "desktop" && + request.body.events.every( + (event) => + event.properties?.clientType === "cli-web-client" && + event.privacy_level === "product" && + event.distinct_id?.startsWith("installation:") === true && + typeof event.id === "string", + ), ), true, ); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 035c050ee..95e5adb8d 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,14 +1,15 @@ /** - * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * AnalyticsServiceLive - First-party ScientFactory telemetry layer. * * Persists a random installation-scoped anonymous id to state dir, buffers - * events in memory, and flushes batches to PostHog over Effect HttpClient. + * events in memory, and flushes batches to the ScientFactory event gateway. * * @module AnalyticsServiceLive */ import { Config, DateTime, Effect, Layer, Ref } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { randomUUID } from "node:crypto"; import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; @@ -16,17 +17,15 @@ import { getTelemetryIdentifier } from "../Identify.ts"; import { version } from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { + readonly id: string; readonly event: string; readonly properties?: Readonly>; readonly capturedAt: string; } const TelemetryEnvConfig = Config.all({ - posthogKey: Config.string("SYNARA_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.string("SYNARA_POSTHOG_HOST").pipe( - Config.withDefault("https://us.i.posthog.com"), + endpoint: Config.string("SYNARA_TELEMETRY_ENDPOINT").pipe( + Config.withDefault("https://events.scientfactory.com/v1/events"), ), enabled: Config.boolean("SYNARA_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), flushBatchSize: Config.number("SYNARA_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), @@ -49,6 +48,7 @@ const makeAnalyticsService = Effect.gen(function* () { const appended = [ ...current, { + id: randomUUID(), event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), @@ -75,24 +75,27 @@ const makeAnalyticsService = Effect.gen(function* () { if (!telemetryConfig.enabled || !identifier) return; const payload = { - api_key: telemetryConfig.posthogKey, - batch: events.map((event) => ({ - event: event.event, + schema_version: 1, + source: "desktop", + sent_at: new Date().toISOString(), + events: events.map((event) => ({ + id: event.id, + name: event.event, distinct_id: identifier, + occurred_at: event.capturedAt, + privacy_level: "product", properties: { ...event.properties, - $process_person_profile: false, platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, synaraCodeVersion: version, clientType, }, - timestamp: event.capturedAt, })), }; - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( + yield* HttpClientRequest.post(telemetryConfig.endpoint).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk), From e55716c1dee00b0618f7e374c6a092cefef1927b Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Mon, 20 Jul 2026 19:41:47 +0300 Subject: [PATCH 2/2] Fix unsigned macOS release signatures (#44) --- docs/release.md | 5 +- scripts/adhoc-sign-mac-app.cjs | 49 ++++++++++ .../build-desktop-artifact-mac-config.test.ts | 25 +++++- scripts/build-desktop-artifact.ts | 26 ++++++ scripts/lib/desktop-platform-build-config.ts | 6 +- scripts/lib/mac-artifact-signature.ts | 90 +++++++++++++++++++ scripts/lib/mac-update-zip-finalize.ts | 9 +- scripts/mac-update-zip-finalize.test.ts | 48 ++++++++++ 8 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 scripts/adhoc-sign-mac-app.cjs create mode 100644 scripts/lib/mac-artifact-signature.ts create mode 100644 scripts/mac-update-zip-finalize.test.ts diff --git a/docs/release.md b/docs/release.md index 4a8148336..d972d2391 100644 --- a/docs/release.md +++ b/docs/release.md @@ -57,7 +57,7 @@ This document covers build-only native validation, promotion through the protect - macOS metadata note: - The build initially emits `latest-mac.yml` for both Intel and Apple Silicon. - The workflow merges the per-arch macOS metadata, then keeps the merged manifest as `latest-mac.yml` and copies it to `scient-mac.yml` for stable releases. - - The desktop build script repacks the macOS update `.zip` with `ditto`, verifies Electron framework symlinks, extracts the zip, validates the extracted app signature, patches the matching `latest-mac*.yml` hash/size, and removes the stale `.zip.blockmap`. + - The desktop build script gives unsigned early-access apps a complete ad-hoc signature before packaging, repacks the macOS update `.zip` with `ditto`, verifies Electron framework symlinks and both source/extracted app signatures, validates the app inside the final DMG, patches the matching `latest-mac*.yml` hash/size, and removes the stale `.zip.blockmap`. - macOS updater downloads intentionally use the full zip payload so Squirrel.Mac installs the exact signed archive validated by release build. - Local smoke test: - Run `bun run release:smoke:mac-update -- --skip-build --build-version 0.1.5` on macOS after local desktop/server/web dist files exist. @@ -137,6 +137,9 @@ Unsigned behavior is platform-specific: Unknown Publisher or SmartScreen warning before installation continues. - macOS checks and downloads the update inside Scient, then opens the downloaded ZIP in Finder. The user must replace Scient in Applications and reopen it. + Early-access bundles are ad-hoc signed so macOS can verify their internal + integrity, but they remain unnotarized and can still show an unidentified + developer warning that the user must explicitly bypass once. - Linux keeps the existing AppImage behavior. Never enable unsigned publication while a platform has a partial signing-secret diff --git a/scripts/adhoc-sign-mac-app.cjs b/scripts/adhoc-sign-mac-app.cjs new file mode 100644 index 000000000..f9f8ed93c --- /dev/null +++ b/scripts/adhoc-sign-mac-app.cjs @@ -0,0 +1,49 @@ +// FILE: adhoc-sign-mac-app.cjs +// Purpose: Gives unsigned early-access macOS bundles a complete ad-hoc signature before packaging. +// Layer: electron-builder afterPack hook +// Depends on: macOS codesign and the staged Electron application bundle. + +const { execFileSync, spawnSync } = require("node:child_process"); +const { join, resolve } = require("node:path"); + +const EXPECTED_BUNDLE_IDENTIFIER = "com.scientfactory.scient"; + +module.exports = async function adhocSignMacApp(context) { + if (context.electronPlatformName !== "darwin") { + return; + } + + const productFilename = context.packager.appInfo.productFilename; + const appPath = join(context.appOutDir, `${productFilename}.app`); + const configuredEntitlements = context.packager.platformSpecificBuildOptions.entitlements; + const args = ["--force", "--deep", "--sign", "-", "--options", "runtime"]; + + if (typeof configuredEntitlements === "string" && configuredEntitlements.length > 0) { + args.push("--entitlements", resolve(context.packager.projectDir, configuredEntitlements)); + } + args.push(appPath); + + console.log(`Ad-hoc signing unsigned macOS bundle: ${appPath}`); + execFileSync("/usr/bin/codesign", args, { stdio: "inherit" }); + execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath], { + stdio: "inherit", + }); + + const detailResult = spawnSync("/usr/bin/codesign", ["-dv", "--verbose=4", appPath], { + encoding: "utf8", + }); + if (detailResult.status !== 0) { + throw new Error( + `Could not inspect ad-hoc signature: ${(detailResult.stderr || detailResult.stdout || "").trim()}`, + ); + } + const details = `${detailResult.stdout || ""}\n${detailResult.stderr || ""}`; + if (!details.includes(`Identifier=${EXPECTED_BUNDLE_IDENTIFIER}`)) { + throw new Error( + `Ad-hoc signed bundle has the wrong identifier; expected ${EXPECTED_BUNDLE_IDENTIFIER}.`, + ); + } + if (!details.includes("Signature=adhoc") || !details.includes("TeamIdentifier=not set")) { + throw new Error("Unsigned macOS bundle was not sealed with the expected ad-hoc identity."); + } +}; diff --git a/scripts/build-desktop-artifact-mac-config.test.ts b/scripts/build-desktop-artifact-mac-config.test.ts index 87114c0dd..42b978772 100644 --- a/scripts/build-desktop-artifact-mac-config.test.ts +++ b/scripts/build-desktop-artifact-mac-config.test.ts @@ -5,6 +5,7 @@ import { MAC_APPSNAP_HELPER_ASAR_EXCLUSION, MAC_APPSNAP_HELPER_BUNDLE_PATH, MAC_APPSNAP_HELPER_STAGE_PATH, + MAC_ADHOC_SIGN_HOOK_PATH, MAC_ENTITLEMENTS_PATH, MAC_INHERITED_ENTITLEMENTS_PATH, MICROPHONE_USAGE_DESCRIPTION, @@ -18,12 +19,14 @@ describe("createDesktopPlatformBuildConfig", () => { it("adds explicit microphone entitlements to macOS builds", () => { const config = createDesktopPlatformBuildConfig({ platform: "mac", + signed: false, target: "dmg", }); const mac = config.mac as Record; const extendInfo = mac.extendInfo as Record; assert.deepStrictEqual(mac.target, ["dmg", "zip"]); + assert.equal(config.afterPack, MAC_ADHOC_SIGN_HOOK_PATH); assert.equal(mac.icon, "icon.icns"); assert.deepStrictEqual(config.asarUnpack, ["node_modules/node-pty/**"]); assert.equal(mac.hardenedRuntime, true); @@ -37,7 +40,11 @@ describe("createDesktopPlatformBuildConfig", () => { "apps/desktop/native/appsnap/build/scient-appsnap-helper", ); assert.equal(MAC_APPSNAP_HELPER_ASAR_EXCLUSION, "!apps/desktop/native/appsnap/build/**"); - assert.deepStrictEqual(config.files, ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION]); + assert.deepStrictEqual(config.files, [ + "**/*", + MAC_APPSNAP_HELPER_ASAR_EXCLUSION, + `!${MAC_ADHOC_SIGN_HOOK_PATH}`, + ]); assert.deepStrictEqual(config.extraFiles, [ { from: "apps/desktop/native/appsnap/build/scient-appsnap-helper", @@ -48,6 +55,22 @@ describe("createDesktopPlatformBuildConfig", () => { assert.equal(extendInfo.NSScreenCaptureUsageDescription, undefined); }); + it("uses ad-hoc sealing only for unsigned macOS bundles", () => { + const unsigned = createDesktopPlatformBuildConfig({ + platform: "mac", + signed: false, + target: "dmg", + }); + const signed = createDesktopPlatformBuildConfig({ + platform: "mac", + signed: true, + target: "dmg", + }); + + assert.equal(unsigned.afterPack, MAC_ADHOC_SIGN_HOOK_PATH); + assert.equal(signed.afterPack, undefined); + }); + it("keeps non-macOS platform configs complete", () => { const linux = createDesktopPlatformBuildConfig({ platform: "linux", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 421721ed7..9cc7a444c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -15,11 +15,13 @@ import serverPackageJson from "../apps/server/package.json" with { type: "json" import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { createDesktopPlatformBuildConfig, + MAC_ADHOC_SIGN_HOOK_PATH, MAC_APPSNAP_HELPER_STAGE_PATH, validateDesktopNativeBuildHost, } from "./lib/desktop-platform-build-config.ts"; import { SCIENT_PRODUCTION_BUNDLE_ID } from "@synara/shared/desktopIdentity"; import { parseBooleanEnvValue } from "./lib/env-bool.ts"; +import { verifySingleMacDmgSignature } from "./lib/mac-artifact-signature.ts"; import { finalizeMacUpdateZip } from "./lib/mac-update-zip-finalize.ts"; import { createReleaseInstallManifest, @@ -757,6 +759,7 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( const platformBuildConfigInput = { platform, + signed, target, ...(windowsAzureSignOptions ? { windowsAzureSignOptions } : {}), } as const; @@ -947,6 +950,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( if (options.platform === "mac") { yield* stageMacAppSnapHelper(stageAppDir, options.arch, options.verbose); + if (!options.signed) { + const hookSourcePath = path.join(repoRoot, MAC_ADHOC_SIGN_HOOK_PATH); + const hookStagePath = path.join(stageAppDir, MAC_ADHOC_SIGN_HOOK_PATH); + yield* fs.makeDirectory(path.dirname(hookStagePath), { recursive: true }); + yield* fs.copyFile(hookSourcePath, hookStagePath); + } } // electron-builder is filtering out stageResourcesDir directory in the AppImage for production @@ -1070,6 +1079,23 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } } + if (options.platform === "mac" && options.target === "dmg") { + yield* Effect.log("[desktop-artifact] Verifying final macOS DMG signature..."); + yield* Effect.try({ + try: () => + verifySingleMacDmgSignature({ + stageDistDir, + requireDeveloperSignature: options.signed, + verbose: options.verbose, + }), + catch: (cause) => + new BuildScriptError({ + message: "macOS DMG signature verification failed.", + cause, + }), + }); + } + const stageEntries = yield* fs.readDirectory(stageDistDir); yield* fs.makeDirectory(options.outputDir, { recursive: true }); diff --git a/scripts/lib/desktop-platform-build-config.ts b/scripts/lib/desktop-platform-build-config.ts index c28a59ede..711af1905 100644 --- a/scripts/lib/desktop-platform-build-config.ts +++ b/scripts/lib/desktop-platform-build-config.ts @@ -10,6 +10,7 @@ export const MAC_INHERITED_ENTITLEMENTS_PATH = "apps/desktop/resources/entitlements.mac.inherit.plist"; export const MAC_APPSNAP_HELPER_STAGE_PATH = "apps/desktop/native/appsnap/build/scient-appsnap-helper"; +export const MAC_ADHOC_SIGN_HOOK_PATH = "scripts/adhoc-sign-mac-app.cjs"; export const MAC_APPSNAP_HELPER_ASAR_EXCLUSION = "!apps/desktop/native/appsnap/build/**"; export const MAC_APPSNAP_HELPER_BUNDLE_PATH = "Contents/Helpers/scient-appsnap-helper"; export const WINDOWS_INSTALLER_GUID = "368107a8-afe6-5db5-ab3b-d4f331684868"; @@ -17,6 +18,7 @@ const MAC_DMG_ICON_PATH = "icon.icns"; export const NODE_PTY_ASAR_UNPACK_GLOBS = ["node_modules/node-pty/**"] as const; export interface DesktopPlatformBuildConfig { + readonly afterPack?: string; readonly asarUnpack?: ReadonlyArray; readonly extraFiles?: ReadonlyArray>; readonly files?: ReadonlyArray; @@ -28,6 +30,7 @@ export interface DesktopPlatformBuildConfig { export interface CreateDesktopPlatformBuildConfigInput { readonly platform: "linux" | "mac" | "win"; + readonly signed?: boolean; readonly target: string; readonly windowsAzureSignOptions?: Record; } @@ -84,7 +87,8 @@ export function createDesktopPlatformBuildConfig( return { ...nativePackaging, - files: ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION], + ...(input.signed === true ? {} : { afterPack: MAC_ADHOC_SIGN_HOOK_PATH }), + files: ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION, `!${MAC_ADHOC_SIGN_HOOK_PATH}`], extraFiles: [ { from: MAC_APPSNAP_HELPER_STAGE_PATH, diff --git a/scripts/lib/mac-artifact-signature.ts b/scripts/lib/mac-artifact-signature.ts new file mode 100644 index 000000000..403dfa9fd --- /dev/null +++ b/scripts/lib/mac-artifact-signature.ts @@ -0,0 +1,90 @@ +// FILE: mac-artifact-signature.ts +// Purpose: Verifies macOS app signatures in unpacked bundles and final DMG release artifacts. +// Layer: Release/build helper +// Depends on: macOS codesign and hdiutil. + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; + +function runCommand( + command: string, + args: ReadonlyArray, + options: { readonly verbose?: boolean } = {}, +): string { + const result = spawnSync(command, [...args], { + encoding: "utf8", + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + }); + if (options.verbose && result.stdout) process.stdout.write(result.stdout); + if (options.verbose && result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed: ${(result.stderr || result.stdout || "").trim()}`, + ); + } + return `${result.stdout || ""}\n${result.stderr || ""}`; +} + +export function verifyMacAppSignature( + appBundlePath: string, + requireDeveloperSignature: boolean, +): void { + runCommand("codesign", ["--verify", "--deep", "--strict", "--verbose=4", appBundlePath]); + const details = runCommand("codesign", ["-dv", "--verbose=4", appBundlePath]); + + if (requireDeveloperSignature) { + if ( + !details.includes("Authority=Developer ID Application:") || + details.includes("TeamIdentifier=not set") + ) { + throw new Error( + `Signed macOS update bundle must use a Developer ID Application identity: ${appBundlePath}`, + ); + } + } else if (!details.includes("Signature=adhoc") || !details.includes("TeamIdentifier=not set")) { + throw new Error( + `Unsigned macOS update bundle must have a complete ad-hoc signature: ${appBundlePath}`, + ); + } +} + +export function verifySingleMacDmgSignature(options: { + readonly stageDistDir: string; + readonly requireDeveloperSignature: boolean; + readonly verbose?: boolean; +}): string { + if (process.platform !== "darwin") { + throw new Error("macOS DMG signature verification must run on macOS."); + } + + const dmgNames = readdirSync(options.stageDistDir).filter((entry) => entry.endsWith(".dmg")); + if (dmgNames.length !== 1 || !dmgNames[0]) { + throw new Error(`Expected one macOS DMG in ${options.stageDistDir}, found ${dmgNames.length}.`); + } + + const dmgPath = join(options.stageDistDir, dmgNames[0]); + const mountPath = mkdtempSync(join(tmpdir(), "scient-dmg-signature-")); + let attached = false; + try { + runCommand("hdiutil", ["attach", "-readonly", "-nobrowse", "-mountpoint", mountPath, dmgPath], { + verbose: options.verbose === true, + }); + attached = true; + + const appNames = readdirSync(mountPath).filter((entry) => entry.endsWith(".app")); + if (appNames.length !== 1 || !appNames[0]) { + throw new Error(`Expected one macOS app in ${dmgNames[0]}, found ${appNames.length}.`); + } + verifyMacAppSignature(join(mountPath, appNames[0]), options.requireDeveloperSignature); + return dmgPath; + } finally { + if (attached) { + runCommand("hdiutil", ["detach", mountPath]); + } + rmSync(mountPath, { recursive: true, force: true }); + } +} diff --git a/scripts/lib/mac-update-zip-finalize.ts b/scripts/lib/mac-update-zip-finalize.ts index ac5582cdb..7b5fc2903 100644 --- a/scripts/lib/mac-update-zip-finalize.ts +++ b/scripts/lib/mac-update-zip-finalize.ts @@ -26,6 +26,7 @@ import { resolveSingleTopLevelMacAppBundle, updateMacUpdateManifestZipEntry, } from "./mac-update-zip.ts"; +import { verifyMacAppSignature } from "./mac-artifact-signature.ts"; export interface FinalizeMacUpdateZipOptions { readonly stageDistDir: string; @@ -124,14 +125,6 @@ function assertMacZipFrameworkSymlinks(zipPath: string): string { return appBundleName; } -function verifyMacAppSignature(appBundlePath: string, requireSignature: boolean): void { - const codeResourcesPath = join(appBundlePath, "Contents", "_CodeSignature", "CodeResources"); - if (!requireSignature && !existsSync(codeResourcesPath)) { - return; - } - runTextCommand("codesign", ["--verify", "--deep", "--strict", "--verbose=4", appBundlePath]); -} - function computeSha512Base64(filePath: string): Promise { return new Promise((resolve, reject) => { const hash = createHash("sha512"); diff --git a/scripts/mac-update-zip-finalize.test.ts b/scripts/mac-update-zip-finalize.test.ts new file mode 100644 index 000000000..b0ec80018 --- /dev/null +++ b/scripts/mac-update-zip-finalize.test.ts @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; + +import { verifyMacAppSignature } from "./lib/mac-artifact-signature.ts"; + +const runOnMac = process.platform === "darwin" ? it : it.skip; + +describe("verifyMacAppSignature", () => { + runOnMac("rejects malformed unsigned bundles and accepts complete ad-hoc signatures", () => { + const root = mkdtempSync(join(tmpdir(), "scient-adhoc-signature-test-")); + const appPath = join(root, "Scient.app"); + const contentsPath = join(appPath, "Contents"); + const executablePath = join(contentsPath, "MacOS", "Scient"); + + try { + mkdirSync(join(contentsPath, "MacOS"), { recursive: true }); + copyFileSync("/usr/bin/true", executablePath); + chmodSync(executablePath, 0o755); + writeFileSync( + join(contentsPath, "Info.plist"), + ` + + +CFBundleExecutableScient +CFBundleIdentifiercom.scientfactory.scient +CFBundleNameScient +CFBundlePackageTypeAPPL + +`, + ); + + assert.throws(() => verifyMacAppSignature(appPath, false)); + + execFileSync("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", appPath]); + assert.doesNotThrow(() => verifyMacAppSignature(appPath, false)); + assert.throws( + () => verifyMacAppSignature(appPath, true), + /Developer ID Application identity/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});