From 913f0f047e9d2b764f8e87603ab50c38ed799fdd Mon Sep 17 00:00:00 2001 From: wenkaifan0720 Date: Thu, 2 Jul 2026 17:24:16 -0700 Subject: [PATCH] security(dist): verify the Developer-ID signature of the fetched/remote cef_host (fail-closed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .sha256 sidecar lives in the same public GCS prefix as the tarball, so it is transport integrity only — the actual root of trust is the Developer-ID signature sealed inside the app, which nothing consulted before executing the fetched binary (posix_spawn bypasses Gatekeeper, curl sets no quarantine). - fetch_cef_host.sh: extract to a private staging dir, then REQUIRE `codesign --verify --deep --strict` with a designated requirement pinning the leaf cert to the publishing team (FLUTTER_CEF_TEAM_ID, default KLAJ5X6PJP) before moving the app into place. Fail-CLOSED on any mismatch (also drops the cached tarball). Offline-safe (no spctl/Gatekeeper round-trip). - publish-cef-host.sh: the "already exists -> skip" idempotency no longer trusts a pre-existing remote object blindly (keys are content hashes of PUBLIC sources, so a bucket-writer could pre-plant an object for a future commit and permanently suppress the legitimate upload). It now downloads + signature-verifies the remote artifact: valid -> no-op as before; invalid -> hard error telling the operator to investigate/delete. Validated live: genuine artifact fetch passes (team KLAJ5X6PJP); a tampered binary with a matching (attacker-controlled) sha256 sidecar is REFUSED and nothing is installed; a wrong FLUTTER_CEF_TEAM_ID is refused; publish verify-remote no-ops against the genuine GCS object. tool/ is not part of the content hash, so no artifact republish is needed. Co-Authored-By: Claude Fable 5 --- .../flutter_cef_macos/tool/fetch_cef_host.sh | 43 ++++++++++++++++--- .../tool/publish-cef-host.sh | 24 +++++++++-- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/flutter_cef_macos/tool/fetch_cef_host.sh b/packages/flutter_cef_macos/tool/fetch_cef_host.sh index 50ef9b8..f78dc37 100755 --- a/packages/flutter_cef_macos/tool/fetch_cef_host.sh +++ b/packages/flutter_cef_macos/tool/fetch_cef_host.sh @@ -9,7 +9,16 @@ # no committed manifest, no per-commit bookkeeping, release-model-agnostic (any # SHA/branch/tag pin that checks out the same native sources resolves to the same # object). Fail-OPEN on network/missing (co-dev + offline builds fall back to -# build-from-source / FLUTTER_CEF_HOST); fail-CLOSED on checksum mismatch. +# build-from-source / FLUTTER_CEF_HOST); fail-CLOSED on checksum mismatch and on +# a bad/foreign code signature. +# +# AUTHENTICITY: the .sha256 sidecar lives in the same public bucket as the tarball, +# so it is transport-integrity only — anyone who could tamper with the tarball could +# tamper with the sidecar. The real root of trust is the Developer-ID signature +# sealed inside the app (tar round-trips it), so after extraction we REQUIRE a +# valid, untampered signature chained to Apple AND pinned to the publishing team +# (FLUTTER_CEF_TEAM_ID, default FlutterFlow's KLAJ5X6PJP) before the binary is +# placed where a build (or posix_spawn, which bypasses Gatekeeper) can use it. set -euo pipefail # Escape hatch: co-dev / build-from-source (native/build_cef_host.sh + a make host). @@ -81,11 +90,35 @@ if [ ! -f "$tarball" ] || [ "$(sha256_file "$tarball")" != "$expected" ]; then mv "$tarball.part" "$tarball" fi -echo "[flutter_cef] extracting prebuilt cef_host -> $DEST" +# Extract to a private staging dir, verify the signature there, and only then move +# into place — a consumer can never observe a half-extracted or unverified host. +STAGE="$(mktemp -d "${TMPDIR:-/tmp}/flutter_cef_fetch.XXXXXX")" +trap 'rm -rf "$STAGE"' EXIT +echo "[flutter_cef] extracting prebuilt cef_host…" +# tar preserves the inside-out Developer-ID signature; the .app + provenance +# stamps (source_sha / version / input_hash) land beside it. +tar -xzf "$tarball" -C "$STAGE" +APP="$STAGE/cef_host.app" +[ -d "$APP" ] || { echo "[flutter_cef] tarball did not contain cef_host.app — refusing." >&2; exit 1; } + +# fail-CLOSED authenticity gate: a valid, strict, deep signature (Chromium framework + +# all nested helpers) whose leaf certificate belongs to the publishing team. +TEAM="${FLUTTER_CEF_TEAM_ID:-KLAJ5X6PJP}" +REQ="anchor apple generic and certificate leaf[subject.OU] = \"$TEAM\"" +if ! err="$(codesign --verify --deep --strict -R="$REQ" "$APP" 2>&1)"; then + echo "[flutter_cef] SIGNATURE VERIFICATION FAILED for the fetched cef_host — refusing." >&2 + echo "[flutter_cef] expected a valid Developer-ID signature from team $TEAM" >&2 + printf '%s\n' "$err" | head -4 | sed 's/^/[flutter_cef] /' >&2 + rm -f "$tarball" # poisoned/corrupt — don't trust the cached copy again + exit 1 +fi +echo "[flutter_cef] signature ok (Developer ID, team $TEAM)." + mkdir -p "$DEST" rm -rf "$DEST/cef_host.app" -# tar preserves the inside-out Developer-ID signature; the .app + provenance -# stamps (source_sha / version / input_hash) land in prebuilt/. -tar -xzf "$tarball" -C "$DEST" +mv "$APP" "$DEST/cef_host.app" +for f in cef_host_source_sha.txt cef_version.txt cef_host_input_hash.txt; do + [ -f "$STAGE/$f" ] && mv "$STAGE/$f" "$DEST/$f" +done printf '%s\n' "$HASH" > "$STAMP" # stamp even if the tarball predates the field echo "[flutter_cef] prebuilt cef_host ready ($HASH)." diff --git a/packages/flutter_cef_macos/tool/publish-cef-host.sh b/packages/flutter_cef_macos/tool/publish-cef-host.sh index 48710b7..62e735e 100755 --- a/packages/flutter_cef_macos/tool/publish-cef-host.sh +++ b/packages/flutter_cef_macos/tool/publish-cef-host.sh @@ -31,10 +31,28 @@ echo "[publish] cef_host input hash: $HASH" DST="gs://$GCS_BUCKET/$GCS_PREFIX/$HASH/$FILE" -# Idempotency: this exact tree was already built + uploaded -> nothing to do. +# Idempotency: this exact tree was already built + uploaded -> nothing to do — but VERIFY the +# remote object first. The keys are content hashes of PUBLIC sources, so anyone with bucket write +# could pre-plant a malicious object for a future commit and this skip would then permanently +# suppress the legitimate upload. Verifying the remote's Developer-ID signature (same gate the +# fetch applies) makes a planted object loud instead of load-bearing. if gsutil -q stat "$DST" 2>/dev/null; then - echo "[publish] $DST already exists — nothing to do." - exit 0 + echo "[publish] $DST already exists — verifying the remote artifact's signature…" + CHECK="$(mktemp -d)" + gsutil -q cp "$DST" "$CHECK/$FILE" + tar -xzf "$CHECK/$FILE" -C "$CHECK" + TEAM="${FLUTTER_CEF_TEAM_ID:-KLAJ5X6PJP}" + if codesign --verify --deep --strict \ + -R="anchor apple generic and certificate leaf[subject.OU] = \"$TEAM\"" \ + "$CHECK/cef_host.app" 2>/dev/null; then + echo "[publish] remote artifact signature ok (team $TEAM) — nothing to do." + rm -rf "$CHECK" + exit 0 + fi + echo "::error:: remote $DST FAILED signature verification (team $TEAM) — possible planted/corrupt object." >&2 + echo "::error:: refusing to skip; investigate + delete the object, then re-run to publish a clean build." >&2 + rm -rf "$CHECK" + exit 1 fi # --- Build the sandboxed, Developer-ID-signed variant ---