diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c39979e..dbf50bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Security + +- Direct macOS release binaries are now signed with hardened-runtime Bun + entitlements and notarized for both architectures. Publication fails closed + unless the final tarballs pass signature, signer, Team ID, entitlement, + architecture, and Gatekeeper validation, the release Mac completes a + host-native signed OpenTUI smoke, and cross-compiled OpenTUI native packages + match their bun.lock packages-array integrity before unpack. + ## [0.3.9] - 2026-08-29 ### Fixed diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..01da3256 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,63 @@ +# Releasing Corbits Code + +Releases are operator-run from macOS with `scripts/release.sh`. The script builds +all four standalone targets and refuses to tag, publish a GitHub release, or +update the Homebrew tap unless both macOS binaries are freshly built, signed, +host-native OpenTUI-smoked on the release Mac, notarized, and validated from their +final tarballs. Cross-compiled opposite-arch macOS binaries still require signature, +notarization, and final-tarball verification, but they are never counted as +host-native smoke. Cross-compile OpenTUI native package fetches must match the +bun.lock packages-array integrity hash before unpack; nested optionalDependencies +pins are ignored for that check. + +## Apple provisioning + +Provision the release Mac outside this repository: + +1. Install the Apple Developer ID Application certificate and private key in the + login Keychain. Record the certificate's full common name and the 10-character + Apple Team ID. +2. Store App Store Connect credentials in a named Keychain profile. Run + `xcrun notarytool store-credentials ` and enter credentials only at + the interactive prompts. Never put an Apple password, app-specific password, + private key, or API key in this repository or on a release command line. +3. Set only the non-secret identifiers in the release shell: + + ```sh + export MACOS_SIGNING_IDENTITY='Developer ID Application: Organization Name (TEAMID1234)' + export MACOS_TEAM_ID='TEAMID1234' + export MACOS_NOTARY_PROFILE='corbits-release' + ``` + +The identity must be the complete `Developer ID Application` certificate name. +The profile is a Keychain profile name, not a password or key. The release gate +checks the signed artifact's authority and Team ID against these values. + +## Credentialed rehearsal + +Before the first public release from a newly provisioned Mac, an authorized +operator must perform a credentialed, no-publication rehearsal from a clean, +disposable release branch with valid release notes: + +```sh +scripts/release.sh X.Y.Z --no-push --skip-tap +``` + +`--no-push` suppresses remote PR, tag, and GitHub release operations; it does not +skip builds, signing, the post-sign host-native OpenTUI smoke, notarization, +tarball extraction, signature checks, entitlement comparison, architecture checks, +or Gatekeeper assessment. Opposite-arch macOS binaries still pass signature and +notarization gates; only the host architecture may satisfy the native-smoke gate. +The script creates a local version commit and tag, so use a disposable branch and +remove it through the normal Git workflow after recording the result. Do not +claim release readiness until this external rehearsal succeeds with the real +Keychain identity and Apple notary service. + +## macOS distribution limitation + +The published artifact is a standalone Mach-O inside a tarball, not an app or +installer bundle, so the notarization ticket cannot be stapled to it. Gatekeeper +uses Apple's online ticket lookup for the first assessment. A first launch may +therefore require internet access and can fail while Apple services are +unreachable; after macOS caches the accepted ticket, later launches can proceed +offline. This online lookup is the current macOS distribution contract. diff --git a/scripts/fetch-opentui-native.sh b/scripts/fetch-opentui-native.sh new file mode 100755 index 00000000..a56819b2 --- /dev/null +++ b/scripts/fetch-opentui-native.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Download one @opentui/core-* native package and unpack it only after the +# tarball matches the sha512 integrity recorded in bun.lock. +# +# scripts/fetch-opentui-native.sh PACKAGE VERSION DEST_DIR [LOCKFILE] +# +# PACKAGE is the short name after @opentui/, e.g. core-darwin-arm64. + +set -euo pipefail + +fail() { + printf 'OpenTUI native fetch failed: %s\n' "$1" >&2 + exit 1 +} + +[ "$#" -eq 3 ] || [ "$#" -eq 4 ] || fail "usage: $0 PACKAGE VERSION DEST_DIR [LOCKFILE]" +pkg=$1 +version=$2 +dest=$3 +lockfile=${4:-bun.lock} + +[[ "$pkg" =~ ^core-[A-Za-z0-9_-]+$ ]] || fail "unsupported OpenTUI package name: $pkg" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$ ]] || fail "invalid package version: $version" +[ -f "$lockfile" ] || fail "lockfile not found: $lockfile" + +for tool in curl openssl tar awk mkdir rm; do + command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool" +done + +# Match the packages-array entry ("@opentui/pkg": [ ... "sha512-..." ]), not a +# nested optionalDependencies version pin that shares the same package name on +# @opentui/core's line and would otherwise yield core's integrity hash. +integrity=$(awk -v key="\"@opentui/${pkg}\": [" ' + index($0, key) && match($0, /"sha512-[^"]+"/) { + print substr($0, RSTART + 1, RLENGTH - 2) + exit + } +' "$lockfile") +[ -n "$integrity" ] || fail "no bun.lock integrity for @opentui/$pkg" + +temporary_directory=$(mktemp -d) +trap 'rm -rf "$temporary_directory"' EXIT +tarball="$temporary_directory/$pkg-$version.tgz" +url="https://registry.npmjs.org/@opentui/$pkg/-/$pkg-$version.tgz" + +curl -fsSL "$url" -o "$tarball" || fail "could not download @opentui/$pkg@$version" +actual="sha512-$(openssl dgst -sha512 -binary "$tarball" | openssl base64 -A)" +[ "$actual" = "$integrity" ] || fail "bun.lock integrity mismatch for @opentui/$pkg (checksum)" + +rm -rf "$dest" +mkdir -p "$dest" +tar -xz -C "$dest" --strip-components=1 -f "$tarball" \ + || fail "could not unpack @opentui/$pkg@$version" diff --git a/scripts/macos-entitlements.plist b/scripts/macos-entitlements.plist new file mode 100644 index 00000000..48f7bf5c --- /dev/null +++ b/scripts/macos-entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/scripts/macos-host-native-smoke.sh b/scripts/macos-host-native-smoke.sh new file mode 100755 index 00000000..10eca56f --- /dev/null +++ b/scripts/macos-host-native-smoke.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Host-native OpenTUI smoke for a signed macOS release binary. +# +# scripts/macos-host-native-smoke.sh LABEL BINARY +# +# Exit codes: +# 0 — host architecture matched and the signed binary initialized OpenTUI +# 2 — LABEL is not the host architecture (caller must not count native smoke) +# 1 — host architecture matched but smoke failed +# +# Opposite-arch artifacts are never executed and never reported as smoked. + +set -euo pipefail + +fail() { + printf 'macOS host-native smoke failed: %s\n' "$1" >&2 + exit 1 +} + +[ "$#" -eq 2 ] || fail "usage: $0 LABEL BINARY" +label=$1 +artifact=$2 + +host_label() { + case "$(uname -s):$(uname -m)" in + Darwin:arm64) echo macos-arm64 ;; + Darwin:x86_64) echo macos-x64 ;; + *) echo "" ;; + esac +} + +host=$(host_label) +[ -n "$host" ] || fail "host architecture is unrecognized; cannot run native smoke" +if [ "$label" != "$host" ]; then + exit 2 +fi + +[ -f "$artifact" ] || fail "artifact does not exist" +[ -x "$artifact" ] || fail "artifact is not executable" +"$artifact" --__release_native_smoke__ >/dev/null 2>&1 \ + || fail "signed $label binary could not initialize OpenTUI native library" diff --git a/scripts/macos-sign-and-notarize.sh b/scripts/macos-sign-and-notarize.sh new file mode 100755 index 00000000..c5283fa9 --- /dev/null +++ b/scripts/macos-sign-and-notarize.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + printf 'macOS release validation failed: %s\n' "$1" >&2 + exit 1 +} + +[ "$#" -eq 3 ] || fail "usage: $0 sign|notarize|verify ARTIFACT arm64|x86_64" +operation=$1 +artifact=$2 +expected_arch=$3 + +case "$operation" in + sign|notarize|verify) ;; + *) fail "unknown operation: $operation" ;; +esac +case "$expected_arch" in + arm64|x86_64) ;; + *) fail "unsupported architecture: $expected_arch" ;; +esac + +[ "$(uname -s)" = Darwin ] || fail "signing and validation must run on macOS" +[ -f "$artifact" ] || fail "artifact does not exist" +[ -x "$artifact" ] || fail "artifact is not executable" + +for tool in codesign ditto jq lipo plutil spctl xcrun; do + command -v "$tool" >/dev/null 2>&1 || fail "missing tool: $tool" +done +xcrun --find notarytool >/dev/null 2>&1 || fail "notarytool is unavailable" + +: "${MACOS_SIGNING_IDENTITY:?MACOS_SIGNING_IDENTITY must name a Developer ID Application identity}" +: "${MACOS_TEAM_ID:?MACOS_TEAM_ID must contain the expected Apple Team ID}" +: "${MACOS_NOTARY_PROFILE:?MACOS_NOTARY_PROFILE must name a notarytool Keychain profile}" + +case "$MACOS_SIGNING_IDENTITY" in + "Developer ID Application: "*) ;; + *) fail "MACOS_SIGNING_IDENTITY must name a Developer ID Application certificate" ;; +esac +[[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || fail "MACOS_TEAM_ID must be a 10-character Team ID" + +script_dir=$(cd "$(dirname "$0")" && pwd) +entitlements="$script_dir/macos-entitlements.plist" +[ -f "$entitlements" ] || fail "source-controlled entitlements are missing" + +temporary_directory=$(mktemp -d) +trap 'rm -rf "$temporary_directory"' EXIT + +verify_artifact() { + local assess_with_gatekeeper=$1 + local details actual_entitlements expected_entitlements architectures + + codesign --verify --strict --verbose=2 "$artifact" >/dev/null 2>&1 || fail "strict code-signature verification failed" + details=$(codesign -dv --verbose=4 "$artifact" 2>&1) || fail "could not inspect code signature" + grep -Fqx "Authority=$MACOS_SIGNING_IDENTITY" <<< "$details" || fail "signer identity does not match" + grep -Fqx "TeamIdentifier=$MACOS_TEAM_ID" <<< "$details" || fail "Team ID does not match" + + actual_entitlements="$temporary_directory/actual-entitlements.plist" + expected_entitlements="$temporary_directory/expected-entitlements.plist" + codesign -d --entitlements :- "$artifact" >"$actual_entitlements" 2>/dev/null || fail "could not read signed entitlements" + plutil -convert xml1 -o "$actual_entitlements.normalized" "$actual_entitlements" >/dev/null || fail "signed entitlements are malformed" + plutil -convert xml1 -o "$expected_entitlements" "$entitlements" >/dev/null || fail "release entitlements are malformed" + cmp -s "$actual_entitlements.normalized" "$expected_entitlements" || fail "signed entitlements do not exactly match the release entitlements" + + architectures=$(lipo -archs "$artifact" 2>/dev/null) || fail "could not inspect Mach-O architecture" + [ "$architectures" = "$expected_arch" ] || fail "artifact architecture is not exactly $expected_arch" + if [ "$assess_with_gatekeeper" = 1 ]; then + spctl -a -t exec -vv "$artifact" >/dev/null 2>&1 || fail "Gatekeeper assessment failed" + fi +} + +if [ "$operation" = sign ]; then + codesign --force --options runtime --timestamp --entitlements "$entitlements" \ + --sign "$MACOS_SIGNING_IDENTITY" "$artifact" >/dev/null || fail "code signing failed" + verify_artifact 0 +elif [ "$operation" = notarize ]; then + verify_artifact 0 + archive="$temporary_directory/notarization.zip" + result="$temporary_directory/notary-result.json" + ditto -c -k --keepParent "$artifact" "$archive" || fail "could not create notarization archive" + xcrun notarytool submit "$archive" --keychain-profile "$MACOS_NOTARY_PROFILE" \ + --wait --output-format json >"$result" || fail "notary submission failed" + status=$(jq -er '.status | select(type == "string")' "$result" 2>/dev/null) || fail "notarytool returned malformed JSON" + [ "$status" = Accepted ] || fail "notary status was not Accepted" + verify_artifact 1 +else + verify_artifact 1 +fi diff --git a/scripts/release.sh b/scripts/release.sh index 5a5a8e4a..54675284 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -35,7 +35,7 @@ # failed release reads as success. # # Requirements: run on a Mac with git, gh (authenticated), bun, jq, ar, tar, -# and shasum available. `bun build --compile` cross-compiles every target +# shasum, and the Apple signing tools available. `bun build --compile` cross-compiles every target # from here; no Linux host is needed. For the tap step, the corbitsdev/tap # tap must be tapped (brew tap corbitsdev/tap) or reachable so it can clone. @@ -88,6 +88,9 @@ RELEASE_BRANCH="release-$VERSION" ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) cd "$ROOT" STAGE="$ROOT/dist/release" +MACOS_RELEASE_HELPER="$ROOT/scripts/macos-sign-and-notarize.sh" +MACOS_HOST_NATIVE_SMOKE="$ROOT/scripts/macos-host-native-smoke.sh" +FETCH_OPENTUI_NATIVE="$ROOT/scripts/fetch-opentui-native.sh" MAINTAINER="$(git config user.name) <$(git config user.email)>" step() { printf '\n\033[1;34m==>\033[0m \033[1m%s\033[0m\n' "$*"; } @@ -221,9 +224,10 @@ EOF # Nothing is written to package.json: these are already declared there as # optionalDependencies, and this only makes the ones bun skipped present. fetch_native_modules() { - local version platform pkg dir url variants bun_target _label _kind _deb + local version platform pkg dir variants bun_target _label _kind _deb version=$(jq -r '.optionalDependencies["@opentui/core-darwin-arm64"] // empty' package.json) [ -n "$version" ] || die "no @opentui/core-* version in package.json optionalDependencies" + [ -x "$FETCH_OPENTUI_NATIVE" ] || die "OpenTUI native fetch helper is missing or not executable" for entry in "${TARGETS[@]}"; do IFS='|' read -r _label bun_target _kind _deb <<< "$entry" platform=${bun_target#bun-} @@ -233,11 +237,8 @@ fetch_native_modules() { [ "$_kind" = linux ] && variants="$variants core-$platform-musl" for pkg in $variants; do dir="node_modules/@opentui/$pkg" - [ -d "$dir" ] && continue - url="https://registry.npmjs.org/@opentui/$pkg/-/$pkg-$version.tgz" info "fetching @opentui/$pkg@$version (cross-compile target)" - mkdir -p "$dir" - curl -fsSL "$url" | tar -xz -C "$dir" --strip-components=1 \ + "$FETCH_OPENTUI_NATIVE" "$pkg" "$version" "$dir" "$ROOT/bun.lock" \ || die "could not fetch @opentui/$pkg@$version from the registry" done done @@ -245,7 +246,15 @@ fetch_native_modules() { # ---- preflight ------------------------------------------------------------- step "Preflight for $TAG" -for t in git gh bun jq ar tar shasum; do command -v "$t" >/dev/null || die "missing tool: $t"; done +[ "$(uname -s)" = Darwin ] || die "releases must run on macOS" +for t in git gh bun jq ar tar shasum openssl curl codesign ditto lipo plutil spctl xcrun; do command -v "$t" >/dev/null || die "missing tool: $t"; done +xcrun --find notarytool >/dev/null 2>&1 || die "missing tool: notarytool" +[ -x "$MACOS_RELEASE_HELPER" ] || die "macOS signing helper is missing or not executable" +[ -x "$MACOS_HOST_NATIVE_SMOKE" ] || die "macOS host-native smoke helper is missing or not executable" +[ -x "$FETCH_OPENTUI_NATIVE" ] || die "OpenTUI native fetch helper is missing or not executable" +: "${MACOS_SIGNING_IDENTITY:?set MACOS_SIGNING_IDENTITY to the Developer ID Application certificate name}" +: "${MACOS_TEAM_ID:?set MACOS_TEAM_ID to the expected Apple Team ID}" +: "${MACOS_NOTARY_PROFILE:?set MACOS_NOTARY_PROFILE to the notarytool Keychain profile name}" gh auth status >/dev/null 2>&1 || die "gh is not authenticated (run: gh auth login)" info "installing dependencies (bun install)" bun install >/dev/null 2>&1 || die "bun install failed" @@ -339,11 +348,13 @@ fi # ---- 3. build binaries, smoke, tarballs, and debs -------------------------- step "Build standalone binaries and packages" mkdir -p "$STAGE" +validated_macos=0 +native_smoked_macos=0 for entry in "${TARGETS[@]}"; do IFS='|' read -r label target kind debarch <<< "$entry" pkg="$FORMULA-$VERSION-$label" tarball="$STAGE/$pkg.tar.gz" - if [ -f "$tarball" ] && [ -f "$tarball.sha256" ]; then + if [ "$kind" != macos ] && [ -f "$tarball" ] && [ -f "$tarball.sha256" ]; then skip "$pkg.tar.gz already built" else info "compiling $label ($target)" @@ -358,7 +369,37 @@ for entry in "${TARGETS[@]}"; do if [ -d "$ROOT/plugins" ]; then cp -R "$ROOT/plugins" "$STAGE/$pkg/plugins" fi + if [ "$kind" = macos ]; then + case "$label" in + macos-arm64) macos_arch=arm64 ;; + macos-x64) macos_arch=x86_64 ;; + *) die "unknown macOS release architecture: $label" ;; + esac + "$MACOS_RELEASE_HELPER" sign "$STAGE/$pkg/$FORMULA" "$macos_arch" + smoke_rc=0 + "$MACOS_HOST_NATIVE_SMOKE" "$label" "$STAGE/$pkg/$FORMULA" || smoke_rc=$? + case "$smoke_rc" in + 0) + info "host-native OpenTUI smoke passed for $label" + native_smoked_macos=$((native_smoked_macos + 1)) + ;; + 2) + info "cross-compiled $label: signature and notarization gates only (no host-native smoke claim)" + ;; + *) + die "native smoke: signed $label binary could not initialize OpenTUI native library" + ;; + esac + "$MACOS_RELEASE_HELPER" notarize "$STAGE/$pkg/$FORMULA" "$macos_arch" + fi tar -C "$STAGE" -czf "$tarball" "$pkg" + if [ "$kind" = macos ]; then + verify_dir=$(mktemp -d) + tar -xzf "$tarball" -C "$verify_dir" || die "could not extract final $label tarball" + "$MACOS_RELEASE_HELPER" verify "$verify_dir/$pkg/$FORMULA" "$macos_arch" + rm -rf "$verify_dir" + validated_macos=$((validated_macos + 1)) + fi ( cd "$STAGE" && shasum -a 256 "$pkg.tar.gz" > "$pkg.tar.gz.sha256" ) rm -rf "$STAGE/$pkg" info "packaged $pkg.tar.gz ($(cd "$STAGE" && du -h "$pkg.tar.gz" | cut -f1))" @@ -384,6 +425,8 @@ for entry in "${TARGETS[@]}"; do fi rm -f "$STAGE/$FORMULA-$label.bin" done +[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation" +[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication" # ---- 4. land the release commit on main via PR, then tag ------------------ # A direct push to main is rejected by the branch ruleset ("N of N required diff --git a/src/index.ts b/src/index.ts index b34e8f10..0aca4a20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { getTelemetry, setTelemetry } from "./telemetry/singleton.js"; import { runExec } from "./exec/runner.js"; import { runOnboarding } from "./tui/onboarding.js"; import { runTUI } from "./tui/runner.js"; +import { smokeOpenTUINativeLibrary } from "./release-native-smoke.js"; export interface Runners { runTUI: (config: import("./config/index.js").Config) => Promise; @@ -287,6 +288,11 @@ export function installSignalHandlers(): void { } if (import.meta.main) { + if (process.argv[2] === "--__release_native_smoke__") { + smokeOpenTUINativeLibrary(); + process.exit(0); + } + installCrashHandlers(); installSignalHandlers(); diff --git a/src/release-native-smoke.ts b/src/release-native-smoke.ts new file mode 100644 index 00000000..7c136f8b --- /dev/null +++ b/src/release-native-smoke.ts @@ -0,0 +1,8 @@ +import { CliRenderer } from "@opentui/core"; + +export function smokeOpenTUINativeLibrary(): void { + const renderer = new CliRenderer(process.stdin, process.stdout, 1, 1, { + useThread: false, + }); + renderer.destroy(); +} diff --git a/tests/unit/macos-release-signing.test.ts b/tests/unit/macos-release-signing.test.ts new file mode 100644 index 00000000..497add58 --- /dev/null +++ b/tests/unit/macos-release-signing.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../.."); +const helper = join(root, "scripts/macos-sign-and-notarize.sh"); +const entitlements = join(root, "scripts/macos-entitlements.plist"); +const temporaryDirectories: string[] = []; + +async function createFixture() { + const directory = await mkdtemp(join(tmpdir(), "corbits-macos-signing-")); + temporaryDirectories.push(directory); + const binDirectory = join(directory, "bin"); + const artifact = join(directory, "corbits"); + await mkdir(binDirectory); + await writeFile(artifact, "stub Mach-O"); + await chmod(artifact, 0o755); + + const command = async (name: string, body: string) => { + const path = join(binDirectory, name); + await writeFile(path, `#!/bin/sh\nset -eu\n${body}\n`); + await chmod(path, 0o755); + }; + + await command( + "codesign", + `case " $* " in + *" --entitlements :- "*) cat "$STUB_ENTITLEMENTS_FILE" ;; + *" -dv "*) + [ "\${STUB_SIGNED:-1}" = 1 ] || exit 1 + printf 'Authority=%s\\nTeamIdentifier=%s\\n' "\${STUB_AUTHORITY:-$MACOS_SIGNING_IDENTITY}" "\${STUB_TEAM:-$MACOS_TEAM_ID}" >&2 ;; + *" --verify "*) [ "\${STUB_SIGNED:-1}" = 1 ] ;; + *) : ;; +esac`, + ); + await command( + "xcrun", + `[ -z "\${STUB_NOTARY_JSON:-}" ] && STUB_NOTARY_JSON='{"status":"Accepted"}' +printf '%s\\n' "$STUB_NOTARY_JSON"`, + ); + await command("uname", `printf 'Darwin\\n'`); + await command("spctl", `[ "\${STUB_SPCTL_OK:-1}" = 1 ]`); + await command("lipo", `printf '%s\\n' "\${STUB_ARCHES:-arm64}"`); + await command("ditto", `: > "$5"`); + await command("plutil", `cp "$5" "$4"`); + + const run = (operation = "sign", architecture = "arm64", overrides = {}) => + Bun.spawnSync({ + cmd: ["bash", helper, operation, artifact, architecture], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + MACOS_SIGNING_IDENTITY: "Developer ID Application: Corbits Labs (TEAM123456)", + MACOS_TEAM_ID: "TEAM123456", + MACOS_NOTARY_PROFILE: "corbits-release", + STUB_ENTITLEMENTS_FILE: entitlements, + ...overrides, + }, + stdout: "pipe", + stderr: "pipe", + }); + + return { run }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("macOS release signing gate", () => { + test("permits OpenTUI's shipped native library under hardened runtime", async () => { + const releaseEntitlements = await readFile(entitlements, "utf8"); + expect(releaseEntitlements).toContain( + "com.apple.security.cs.disable-library-validation\n\t", + ); + }); + + test("signs, notarizes, and validates an accepted artifact", async () => { + const { run } = await createFixture(); + const signed = run(); + expect({ exitCode: signed.exitCode, stderr: signed.stderr.toString() }).toEqual({ + exitCode: 0, + stderr: "", + }); + const notarized = run("notarize"); + expect({ exitCode: notarized.exitCode, stderr: notarized.stderr.toString() }).toEqual({ + exitCode: 0, + stderr: "", + }); + }); + + test("rejects a non-Accepted notarization status", async () => { + const { run } = await createFixture(); + expect( + run("notarize", "arm64", { STUB_NOTARY_JSON: '{"status":"Rejected"}' }).exitCode, + ).not.toBe(0); + }); + + test("rejects malformed notarization output", async () => { + const { run } = await createFixture(); + expect(run("notarize", "arm64", { STUB_NOTARY_JSON: "not-json" }).exitCode).not.toBe(0); + }); + + test("rejects an artifact without a valid signature", async () => { + const { run } = await createFixture(); + expect(run("verify", "arm64", { STUB_SIGNED: "0" }).exitCode).not.toBe(0); + }); + + test("rejects a signer from the wrong Team ID", async () => { + const { run } = await createFixture(); + expect(run("verify", "arm64", { STUB_TEAM: "OTHERTEAM1" }).exitCode).not.toBe(0); + }); + + test("rejects an artifact with the wrong architecture", async () => { + const { run } = await createFixture(); + expect(run("verify", "x86_64", { STUB_ARCHES: "arm64" }).exitCode).not.toBe(0); + }); +}); diff --git a/tests/unit/release-native-validation.test.ts b/tests/unit/release-native-validation.test.ts new file mode 100644 index 00000000..be1d4897 --- /dev/null +++ b/tests/unit/release-native-validation.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../.."); +const release = join(root, "scripts/release.sh"); +const hostNativeSmoke = join(root, "scripts/macos-host-native-smoke.sh"); +const fetchOpentui = join(root, "scripts/fetch-opentui-native.sh"); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createStubBinDirectory() { + const directory = await mkdtemp(join(tmpdir(), "corbits-release-native-")); + temporaryDirectories.push(directory); + const binDirectory = join(directory, "bin"); + await mkdir(binDirectory); + + const command = async (name: string, body: string) => { + const path = join(binDirectory, name); + await writeFile(path, `#!/bin/sh\nset -eu\n${body}\n`); + await chmod(path, 0o755); + }; + + return { directory, binDirectory, command }; +} + +async function stubCurlDownload( + command: (name: string, body: string) => Promise, + tarball: string, +) { + await command( + "curl", + `out="" +prev="" +for arg in "$@"; do + if [ "$prev" = "-o" ]; then out="$arg"; fi + prev="$arg" +done +[ -n "$out" ] +cp "${tarball}" "$out"`, + ); +} + +describe("host-native signed OpenTUI smoke counting", () => { + test("release gate separates host-native smoke from opposite-arch signature validation", async () => { + const source = await readFile(release, "utf8"); + expect(source).toContain("native_smoked_macos"); + expect(source).toContain( + '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', + ); + expect(source).toContain( + '[ "$validated_macos" -eq 2 ] || die "both macOS architectures must rebuild and pass release validation"', + ); + expect(source).toContain('"macos-arm64|bun-darwin-arm64|macos|-"'); + expect(source).toContain('"macos-x64|bun-darwin-x64|macos|-"'); + expect(source).toContain('[ "$kind" != macos ] && [ -f "$tarball" ]'); + + const sign = source.indexOf('"$MACOS_RELEASE_HELPER" sign '); + const smoke = source.indexOf('"$MACOS_HOST_NATIVE_SMOKE" "$label"'); + const notarize = source.indexOf('"$MACOS_RELEASE_HELPER" notarize '); + const extraction = source.indexOf('tar -xzf "$tarball"'); + const checksum = source.indexOf('shasum -a 256 "$pkg.tar.gz"'); + const validated = source.indexOf("validated_macos=$((validated_macos + 1))"); + const nativeGate = source.indexOf( + '[ "$native_smoked_macos" -eq 1 ] || die "host-native signed OpenTUI smoke is required before publication"', + ); + const publication = source.indexOf('step "Land release commit'); + expect(sign).toBeGreaterThan(0); + expect(smoke).toBeGreaterThan(sign); + expect(notarize).toBeGreaterThan(smoke); + expect(extraction).toBeGreaterThan(notarize); + expect(validated).toBeGreaterThan(extraction); + expect(checksum).toBeGreaterThan(validated); + expect(nativeGate).toBeGreaterThan(checksum); + expect(publication).toBeGreaterThan(nativeGate); + }); + + for (const host of [ + { machine: "arm64", hostLabel: "macos-arm64", opposite: "macos-x64" }, + { machine: "x86_64", hostLabel: "macos-x64", opposite: "macos-arm64" }, + ] as const) { + test(`on ${host.hostLabel} host, opposite-arch ${host.opposite} cannot pass native smoke without execution`, async () => { + const { directory, binDirectory, command } = await createStubBinDirectory(); + const artifact = join(directory, "corbits"); + const marker = join(directory, "executed"); + await writeFile(artifact, '#!/bin/sh\necho ran > "$NATIVE_SMOKE_MARKER"\n'); + await chmod(artifact, 0o755); + + await command( + "uname", + `case "$1" in -s) printf 'Darwin\\n' ;; -m) printf '${host.machine}\\n' ;; *) exit 1 ;; esac`, + ); + + const opposite = Bun.spawnSync({ + cmd: ["bash", hostNativeSmoke, host.opposite, artifact], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + NATIVE_SMOKE_MARKER: marker, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(opposite.exitCode).toBe(2); + expect(await Bun.file(marker).exists()).toBe(false); + + const matching = Bun.spawnSync({ + cmd: ["bash", hostNativeSmoke, host.hostLabel, artifact], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + NATIVE_SMOKE_MARKER: marker, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(matching.exitCode).toBe(0); + expect(await Bun.file(marker).exists()).toBe(true); + }); + } +}); + +describe("OpenTUI native package lockfile integrity", () => { + test("release fetch verifies bun.lock integrity before unpacking", async () => { + const source = await readFile(release, "utf8"); + expect(source).toContain("fetch-opentui-native.sh"); + expect(source).not.toContain('curl -fsSL "$url" | tar -xz -C "$dir"'); + expect(source).not.toContain('[ -d "$dir" ] && continue'); + }); + + test("mismatched checksum fails before unpack", async () => { + const { directory, binDirectory, command } = await createStubBinDirectory(); + const lockfile = join(directory, "bun.lock"); + const dest = join(directory, "node_modules/@opentui/core-darwin-arm64"); + const tarball = join(directory, "payload.tgz"); + await writeFile(tarball, "tampered-payload"); + await writeFile( + lockfile, + `{\n "packages": {\n "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", {}, "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="],\n }\n}\n`, + ); + await stubCurlDownload(command, tarball); + + const result = Bun.spawnSync({ + cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toMatch(/integrity|checksum|sha512/i); + expect(await Bun.file(join(dest, "package.json")).exists()).toBe(false); + }); + + test("packages-array hash wins over nested optionalDependencies false match", async () => { + const { directory, binDirectory, command } = await createStubBinDirectory(); + const lockfile = join(directory, "bun.lock"); + const dest = join(directory, "node_modules/@opentui/core-darwin-arm64"); + const packageRoot = join(directory, "package"); + const tarball = join(directory, "core-darwin-arm64-0.5.1.tgz"); + await mkdir(packageRoot); + await writeFile( + join(packageRoot, "package.json"), + JSON.stringify({ name: "@opentui/core-darwin-arm64", version: "0.5.1" }), + ); + const packed = Bun.spawnSync({ + cmd: ["tar", "-czf", tarball, "-C", directory, "package"], + cwd: directory, + stdout: "pipe", + stderr: "pipe", + }); + expect(packed.exitCode).toBe(0); + + const digest = Bun.spawnSync({ + cmd: ["openssl", "dgst", "-sha512", "-binary", tarball], + stdout: "pipe", + stderr: "pipe", + }); + expect(digest.exitCode).toBe(0); + const packagesHash = `sha512-${Buffer.from(digest.stdout).toString("base64")}`; + const coreFalseMatchHash = + "sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg=="; + expect(packagesHash).not.toBe(coreFalseMatchHash); + + await writeFile( + lockfile, + `{ + "packages": { + "@opentui/core": ["@opentui/core@0.5.1", "", { "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.1", "@opentui/core-darwin-x64": "0.5.1" } }, "${coreFalseMatchHash}"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "${packagesHash}"], + } +} +`, + ); + await stubCurlDownload(command, tarball); + + const accept = Bun.spawnSync({ + cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(accept.exitCode).toBe(0); + expect(await Bun.file(join(dest, "package.json")).exists()).toBe(true); + + await rm(dest, { recursive: true, force: true }); + const wrongPackagesHash = + "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + await writeFile( + lockfile, + `{ + "packages": { + "@opentui/core": ["@opentui/core@0.5.1", "", { "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.1" } }, "${packagesHash}"], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.1", "", { "os": "darwin", "cpu": "arm64" }, "${wrongPackagesHash}"], + } +} +`, + ); + const reject = Bun.spawnSync({ + cmd: ["bash", fetchOpentui, "core-darwin-arm64", "0.5.1", dest, lockfile], + cwd: root, + env: { + ...process.env, + PATH: `${binDirectory}:${process.env.PATH ?? ""}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + expect(reject.exitCode).not.toBe(0); + expect(reject.stderr.toString()).toMatch(/integrity|checksum|sha512/i); + expect(await Bun.file(join(dest, "package.json")).exists()).toBe(false); + }); +});