Redline updater hardening: signed-update verification, atomic install + revert, update visibility, notarization pipeline (MMDB-2681) #24

Merged
kua-agent merged 7 commits from feat/updater-hardening-20260905 into main 2026-09-05 06:07:06 +00:00
Showing only changes of commit 9884c844d4 - Show all commits
+184 -9
View File
@@ -13,7 +13,10 @@ PLISTBUDDY="/usr/libexec/PlistBuddy"
REMOTE_HOST="mmd01"
REMOTE_BASE="/opt/mmd-installer-content/cowork/redline"
PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline"
SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
BUNDLE_ID="ai.flowmaster.shotdeck"
# Used both as the fallback signing identity and as what build-app.sh itself
# still hardcodes for its own (pre-final) signing pass.
FALLBACK_SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
usage() {
echo "Usage: $0 <version> [\"notes\"]" >&2
@@ -72,6 +75,7 @@ else
PUBLIC_DIR="${PUBLIC_BASE}"
fi
APP_BUNDLE="${ROOT}/.build/Redline.app"
ZIP_NAME="Redline-${VERSION}.zip"
DMG_NAME="Redline-${VERSION}.dmg"
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
@@ -139,6 +143,38 @@ else
echo "==> --test: skipping git commit of version bump"
fi
# --- Resolve the signing identity for the shipped artifacts -----------------
# REDLINE_KEYCHAIN (optional): a specific keychain to search/sign against,
# for hosts where the Developer ID identity does not live in the login
# keychain that codesign searches by default.
FIND_IDENTITY_ARGS=(-v -p codesigning)
CODESIGN_KEYCHAIN_ARGS=()
if [[ -n "${REDLINE_KEYCHAIN:-}" ]]; then
FIND_IDENTITY_ARGS+=("${REDLINE_KEYCHAIN}")
CODESIGN_KEYCHAIN_ARGS=(--keychain "${REDLINE_KEYCHAIN}")
fi
if [[ -n "${REDLINE_SIGN_IDENTITY:-}" ]]; then
SIGN_IDENTITY="${REDLINE_SIGN_IDENTITY}"
echo "==> Signing identity: ${SIGN_IDENTITY} (REDLINE_SIGN_IDENTITY)"
else
DEVELOPER_ID_LINE="$(security find-identity "${FIND_IDENTITY_ARGS[@]}" 2>/dev/null \
| grep -o '"Developer ID Application:[^"]*"' | head -n1 || true)"
DEVELOPER_ID="${DEVELOPER_ID_LINE//\"/}"
if [[ -n "${DEVELOPER_ID}" ]]; then
SIGN_IDENTITY="${DEVELOPER_ID}"
echo "==> Signing identity: ${SIGN_IDENTITY} (auto-detected Developer ID Application)"
else
SIGN_IDENTITY="${FALLBACK_SIGN_IDENTITY}"
echo
echo "************************************************************************"
echo "WARNING: signing with Apple Development identity — not Developer ID;"
echo "Gatekeeper will block first install on other Macs."
echo "************************************************************************"
echo
fi
fi
# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign.
# Re-run signed steps with the account's real home when the identity is missing.
signing_home() {
@@ -167,31 +203,85 @@ run_signed() {
echo "==> Building signed Redline.app"
run_signed ./scripts/build-app.sh
if [[ ! -d "${ROOT}/.build/Redline.app" ]]; then
echo "Signed app missing at ${ROOT}/.build/Redline.app" >&2
if [[ ! -d "${APP_BUNDLE}" ]]; then
echo "Signed app missing at ${APP_BUNDLE}" >&2
exit 1
fi
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
# build-app.sh always signs with its own hardcoded Apple Development identity
# first (it has to — that identifier+identity pair is what keeps the Screen
# Recording grant alive). Re-sign here with the identity actually resolved
# above, which is what ships. A no-op when the two happen to be the same.
echo "==> Signing ${APP_BUNDLE} with resolved identity"
run_signed codesign --force --options runtime --timestamp \
"${CODESIGN_KEYCHAIN_ARGS[@]}" \
--sign "${SIGN_IDENTITY}" \
--identifier "${BUNDLE_ID}" \
"${APP_BUNDLE}"
build_zip() {
mkdir -p "${ROOT}/.build"
(
cd "${ROOT}/.build"
rm -f "${ZIP_NAME}"
ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
)
if [[ ! -s "${ZIP_PATH}" ]]; then
echo "Zip was not created at ${ZIP_PATH}" >&2
exit 1
fi
}
echo "==> Building manual installer DMG"
run_signed ./scripts/make-dmg.sh
# Rebuilds the manual-installer DMG from whatever is currently at
# ${APP_BUNDLE} — never re-invokes build-app.sh, so a prior custom signature
# or notarization staple on ${APP_BUNDLE} survives into the DMG untouched.
build_dmg() {
local staging="${ROOT}/.build/dmg-staging"
local mount_point="${ROOT}/.build/dmg-mnt"
rm -rf "${staging}"
mkdir -p "${staging}"
ditto "${APP_BUNDLE}" "${staging}/Redline.app"
ln -s /Applications "${staging}/Applications"
mkdir -p "$(dirname "${DMG_PATH}")"
rm -f "${DMG_PATH}"
hdiutil create -volname "Redline" -srcfolder "${staging}" -ov -format UDZO "${DMG_PATH}"
if [[ -d "${mount_point}" ]] && /sbin/mount | grep -F -q "${mount_point}"; then
hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force
fi
rm -rf "${mount_point}"
mkdir -p "${mount_point}"
hdiutil attach "${DMG_PATH}" -nobrowse -readonly -mountpoint "${mount_point}"
local ok=1
if [[ ! -d "${mount_point}/Redline.app" ]]; then
echo "Verification failed: Redline.app missing from mounted DMG" >&2
ok=0
fi
if [[ "${ok}" -eq 1 && "$(readlink "${mount_point}/Applications" 2>/dev/null || true)" != "/Applications" ]]; then
echo "Verification failed: Applications does not point at /Applications" >&2
ok=0
fi
if [[ "${ok}" -eq 1 ]] && ! codesign --verify --deep --verbose=2 "${mount_point}/Redline.app"; then
ok=0
fi
hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force || true
if [[ "${ok}" -ne 1 ]]; then
exit 1
fi
if [[ ! -s "${DMG_PATH}" ]]; then
echo "DMG was not created at ${DMG_PATH}" >&2
exit 1
fi
}
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
build_zip
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
@@ -200,18 +290,95 @@ PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "==> Zip SHA256: ${SHA256}"
echo " Zip bytes: ${ZIP_BYTES}"
# --- Notarization (optional) -------------------------------------------------
# Either REDLINE_NOTARY_PROFILE (a `notarytool store-credentials` keychain
# profile) or all three of REDLINE_NOTARY_KEY_ID / REDLINE_NOTARY_ISSUER /
# REDLINE_NOTARY_KEY_PATH (App Store Connect API key). Absent both: skip.
NOTARIZED=0
NOTARY_CONFIGURED=0
if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then
NOTARY_CONFIGURED=1
elif [[ -n "${REDLINE_NOTARY_KEY_ID:-}" && -n "${REDLINE_NOTARY_ISSUER:-}" && -n "${REDLINE_NOTARY_KEY_PATH:-}" ]]; then
NOTARY_CONFIGURED=1
fi
if [[ "${NOTARY_CONFIGURED}" -eq 1 ]]; then
echo "==> Submitting ${ZIP_PATH} to notarytool"
NOTARY_ARGS=(xcrun notarytool submit "${ZIP_PATH}" --wait --timeout 30m)
if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then
NOTARY_ARGS+=(--keychain-profile "${REDLINE_NOTARY_PROFILE}")
else
NOTARY_ARGS+=(
--key "${REDLINE_NOTARY_KEY_PATH}"
--key-id "${REDLINE_NOTARY_KEY_ID}"
--issuer "${REDLINE_NOTARY_ISSUER}"
)
fi
set +e
NOTARY_OUTPUT="$("${NOTARY_ARGS[@]}" 2>&1)"
NOTARY_STATUS=$?
set -e
echo "${NOTARY_OUTPUT}"
if [[ "${NOTARY_STATUS}" -ne 0 ]]; then
echo "notarytool submit failed (exit ${NOTARY_STATUS})." >&2
exit 1
fi
if ! grep -qi 'status: *Accepted' <<<"${NOTARY_OUTPUT}"; then
echo "notarytool did not report Accepted." >&2
exit 1
fi
NOTARIZED=1
echo "==> Stapling ${APP_BUNDLE}"
xcrun stapler staple "${APP_BUNDLE}"
echo "==> Rebuilding zip from the stapled app"
build_zip
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
echo " Zip SHA256: ${SHA256}"
echo " Zip bytes: ${ZIP_BYTES}"
echo "==> Rebuilding DMG from the stapled app"
build_dmg
echo "==> Stapling ${DMG_PATH}"
xcrun stapler staple "${DMG_PATH}"
else
echo "==> REDLINE_NOTARY_KEY_ID/ISSUER/KEY_PATH (or REDLINE_NOTARY_PROFILE) not set"
echo "NOT NOTARIZED"
echo "==> Building manual installer DMG"
build_dmg
fi
echo "==> Gatekeeper check: spctl -a -vv -t exec ${APP_BUNDLE}"
set +e
SPCTL_OUTPUT="$(spctl -a -vv -t exec "${APP_BUNDLE}" 2>&1)"
SPCTL_STATUS=$?
set -e
echo "${SPCTL_OUTPUT}"
if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then
echo "spctl did not report accepted for ${APP_BUNDLE}." >&2
exit 1
fi
TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')"
echo "==> Writing ${APPCAST_PATH}"
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" <<'PY'
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY'
import json
import sys
version, zip_url, sha256, notes, pub_date, out_path = sys.argv[1:]
version, zip_url, sha256, notes, pub_date, out_path, notarized, team_identifier = sys.argv[1:]
payload = {
"version": version,
"zipURL": zip_url,
"sha256": sha256,
"notes": notes,
"pubDate": pub_date,
"notarized": notarized == "1",
"teamIdentifier": team_identifier,
}
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
@@ -280,6 +447,14 @@ fi
echo
echo "Published v${VERSION}"
echo " identity: ${SIGN_IDENTITY}"
if [[ "${NOTARIZED}" -eq 1 ]]; then
echo " notarized: yes"
else
echo " notarized: no"
fi
echo " spctl: ${SPCTL_OUTPUT}"
echo " team: ${TEAM_IDENTIFIER}"
echo " appcast: ${APPCAST_URL}"
echo " zip: ${ZIP_URL}"
echo " sha256: ${SHA256}"