diff --git a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh index ed579e7d..ef94da6d 100755 --- a/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh +++ b/TargetBridge-Sender/scripts/build_targetbridge_sender_app.sh @@ -10,11 +10,25 @@ BUILD_DIR="${DERIVED_DATA_DIR}/Build/Products/${CONFIGURATION}" SOURCE_APP="${BUILD_DIR}/TargetBridge.app" DEST_DIR="${REPO_ROOT}/build" DEST_APP="${DEST_DIR}/TargetBridge.app" +# Preserve the upstream disposable CI/development build when no signing setup +# was requested. Once configured (or explicitly required), never fall back. +SIGNING_CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt}" +if [[ -n "${TARGETBRIDGE_CODESIGN_IDENTITY:-}" || + -n "${TARGETBRIDGE_SIGNING_CONFIG:-}" || -e "$SIGNING_CONFIG" || + "${TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING:-0}" == 1 ]]; then + "$SCRIPT_DIR/sign_targetbridge_sender_app.sh" --check-identity +else + echo "WARNING: unconfigured disposable build; this ad-hoc app is not an identity-preserving update." >&2 + export TARGETBRIDGE_CODESIGN_IDENTITY=- + export TARGETBRIDGE_ALLOW_ADHOC=1 +fi cd "$ROOT" xcodegen generate +# Package a standalone app, not Xcode's preview executor plus unsigned debug +# dylibs. This also lets strict signing verify the same bundle on Xcode 16+. xcodebuild \ -scheme TBDisplaySender \ -configuration "$CONFIGURATION" \ @@ -22,15 +36,28 @@ xcodebuild \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ + ENABLE_DEBUG_DYLIB=NO \ build mkdir -p "$DEST_DIR" -rm -rf "$DEST_APP" -ditto "$SOURCE_APP" "$DEST_APP" +STAGING_DIR=$(mktemp -d "$DEST_DIR/.sender-signing.XXXXXX") +STAGED_APP="$STAGING_DIR/TargetBridge.app" +ditto "$SOURCE_APP" "$STAGED_APP" echo "Cleaning extended attributes..." -xattr -cr "$DEST_APP" || true +xattr -cr "$STAGED_APP" echo "Signing sender application..." -codesign --force --deep --sign - "$DEST_APP" || true +"$SCRIPT_DIR/sign_targetbridge_sender_app.sh" "$STAGED_APP" +if [[ -e "$DEST_APP" ]]; then + # Retain the old artifact inside the staging directory for recovery. + mv "$DEST_APP" "$STAGING_DIR/Previous TargetBridge.app" +fi +if ! mv "$STAGED_APP" "$DEST_APP"; then + if [[ ! -e "$DEST_APP" && -e "$STAGING_DIR/Previous TargetBridge.app" ]]; then + mv "$STAGING_DIR/Previous TargetBridge.app" "$DEST_APP" + fi + echo "Could not activate the signed build; recovery files: $STAGING_DIR" >&2 + exit 1 +fi touch "$DEST_APP" echo "TargetBridge sender built: $DEST_APP" diff --git a/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh b/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh new file mode 100755 index 00000000..88ea4f2d --- /dev/null +++ b/TargetBridge-Sender/scripts/sign_targetbridge_sender_app.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +set -euo pipefail + +fail() { print -u2 -- "Sender signing: $*"; exit 1; } +APP="${1:-}" +[[ -n "$APP" && $# -eq 1 ]] || fail "Usage: $0 TargetBridge.app | --check-identity" + +CONFIG="${TARGETBRIDGE_SIGNING_CONFIG:-$HOME/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt}" +IDENTITY="${TARGETBRIDGE_CODESIGN_IDENTITY:-}" +if [[ -z "$IDENTITY" && -f "$CONFIG" ]]; then + IDENTITY=$(<"$CONFIG") +fi +if [[ -z "$IDENTITY" ]]; then + fail "No persistent signing identity. Configure TARGETBRIDGE_CODESIGN_IDENTITY or $CONFIG. See docs/Sender-Signing.md. Refusing an implicit ad-hoc build." +fi +if [[ "$IDENTITY" == - ]]; then + [[ "${TARGETBRIDGE_ALLOW_ADHOC:-0}" == 1 ]] || fail "Ad-hoc signing requires TARGETBRIDGE_ALLOW_ADHOC=1; updates may lose privacy permissions." + print -u2 -- "WARNING: disposable ad-hoc development build; do not install over a persistent signed Sender." +else + [[ "$IDENTITY" =~ '^[[:xdigit:]]{40}$' ]] || fail "Use the exact 40-character certificate fingerprint, not a display name" + IDENTITIES=$(/usr/bin/security find-identity -v -p codesigning) + [[ "${(U)IDENTITIES}" == *" ${(U)IDENTITY} "* ]] || fail "Configured certificate is not a valid code-signing identity in this user's Keychain. Unlock/configure it through macOS; no fallback is allowed." +fi +[[ "$APP" != --check-identity ]] || { print -- 'Signing identity preflight passed'; exit 0; } +PLIST="$APP/Contents/Info.plist" +[[ -f "$PLIST" && ! -L "$APP" ]] || fail "Expected a real application bundle" +BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$PLIST") +[[ "$BUNDLE_ID" == com.targetbridge.sender ]] || fail "Wrong bundle identifier: $BUNDLE_ID" + +# Use the system-generated designated requirement. Never weaken it to just a +# bundle identifier: certificate-bound identity is what makes updates trustworthy. +/usr/bin/codesign --force --sign "$IDENTITY" "$APP" +/usr/bin/codesign --verify --deep --strict --all-architectures "$APP" +DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) +if [[ "$IDENTITY" != - && "$DETAILS" == *"Signature=adhoc"* ]]; then + fail "A persistent signature was requested but the result is ad-hoc" +fi +/usr/bin/codesign -d -r- "$APP" 2>&1 +print -- "Sender signature verified: $APP" diff --git a/TargetBridge-Sender/scripts/test_sender_signing.sh b/TargetBridge-Sender/scripts/test_sender_signing.sh new file mode 100755 index 00000000..86567faa --- /dev/null +++ b/TargetBridge-Sender/scripts/test_sender_signing.sh @@ -0,0 +1,42 @@ +#!/bin/zsh +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/targetbridge-signing-test.XXXXXX") +BASE="${1:?Usage: test_sender_signing.sh signed-TargetBridge.app}" +SIGN="$SCRIPT_DIR/sign_targetbridge_sender_app.sh" +VERIFY="$SCRIPT_DIR/verify_sender_update.sh" +expect_reject() { + local title="$1"; shift + if "$@" >"$WORK/rejection.log" 2>&1; then + print -u2 -- "FAIL: accepted $title"; exit 1 + fi + print -- "PASS: rejected $title" +} +ditto "$BASE" "$WORK/A.app" +ditto "$BASE" "$WORK/B.app" +# Changing a bundle resource changes the sealed CodeDirectory, reproducing the +# identity risk without launching a capture process or requesting permissions. +/usr/libexec/PlistBuddy -c 'Set :CFBundleVersion 999999' "$WORK/B.app/Contents/Info.plist" +zsh "$SIGN" "$WORK/B.app" +zsh "$VERIFY" "$WORK/A.app" "$WORK/B.app" +HASH_A=$(/usr/bin/shasum -a 256 "$WORK/A.app/Contents/MacOS/TargetBridge") +HASH_B=$(/usr/bin/shasum -a 256 "$WORK/B.app/Contents/MacOS/TargetBridge") +[[ "${HASH_A%% *}" != "${HASH_B%% *}" ]] || { print -u2 'FAIL: fixtures have identical signed executables'; exit 1; } +print -- 'PASS: different signed executable hashes, stable identity' +expect_reject 'missing identity' env TARGETBRIDGE_CODESIGN_IDENTITY= TARGETBRIDGE_SIGNING_CONFIG="$WORK/missing" zsh "$SIGN" "$WORK/B.app" +expect_reject 'unavailable identity' env TARGETBRIDGE_CODESIGN_IDENTITY=0000000000000000000000000000000000000000 zsh "$SIGN" "$WORK/B.app" +expect_reject 'implicit ad-hoc signing' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=0 zsh "$SIGN" "$WORK/B.app" +ditto "$WORK/B.app" "$WORK/Adhoc.app" +TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +expect_reject 'ad-hoc update' zsh "$VERIFY" "$WORK/A.app" "$WORK/Adhoc.app" +expect_reject 'ad-hoc baseline' zsh "$VERIFY" "$WORK/Adhoc.app" "$WORK/B.app" +ditto "$WORK/B.app" "$WORK/WrongID.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier com.targetbridge.receiver' "$WORK/WrongID.app/Contents/Info.plist" +expect_reject 'wrong signing bundle ID' zsh "$SIGN" "$WORK/WrongID.app" +expect_reject 'wrong update bundle ID' zsh "$VERIFY" "$WORK/A.app" "$WORK/WrongID.app" +ditto "$WORK/B.app" "$WORK/Tampered.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleVersion 888888' "$WORK/Tampered.app/Contents/Info.plist" +expect_reject 'tampered candidate' zsh "$VERIFY" "$WORK/A.app" "$WORK/Tampered.app" +expect_reject 'tampered installed baseline' zsh "$VERIFY" "$WORK/Tampered.app" "$WORK/B.app" +zsh "$VERIFY" "$WORK/A.app" "$WORK/B.app" +print -- "All signing checks passed. Fixtures retained at $WORK; no installed app or TCC setting changed." diff --git a/TargetBridge-Sender/scripts/test_sender_signing_guards.sh b/TargetBridge-Sender/scripts/test_sender_signing_guards.sh new file mode 100755 index 00000000..6974d0b8 --- /dev/null +++ b/TargetBridge-Sender/scripts/test_sender_signing_guards.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# Negative-path checks that do not need a trusted signing certificate. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/targetbridge-signing-guards.XXXXXX") +BASE="${1:?Usage: test_sender_signing_guards.sh TargetBridge.app}" +SIGN="$SCRIPT_DIR/sign_targetbridge_sender_app.sh" +VERIFY="$SCRIPT_DIR/verify_sender_update.sh" +reject_with() { + local expected="$1"; shift + if "$@" >"$WORK/rejection.log" 2>&1; then + print -u2 -- "FAIL: unexpectedly accepted $expected"; exit 1 + fi + /usr/bin/grep -Fq -- "$expected" "$WORK/rejection.log" || { /bin/cat "$WORK/rejection.log"; exit 1; } + print -- "PASS: $expected" +} +reject_with 'No persistent signing identity' env TARGETBRIDGE_CODESIGN_IDENTITY= TARGETBRIDGE_SIGNING_CONFIG="$WORK/missing" zsh "$SIGN" --check-identity +reject_with 'exact 40-character certificate fingerprint' env TARGETBRIDGE_CODESIGN_IDENTITY='unverified display name' zsh "$SIGN" --check-identity +reject_with 'not a valid code-signing identity' env TARGETBRIDGE_CODESIGN_IDENTITY=0000000000000000000000000000000000000000 zsh "$SIGN" --check-identity +reject_with 'Ad-hoc signing requires' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=0 zsh "$SIGN" --check-identity +ditto "$BASE" "$WORK/Adhoc.app" +TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +reject_with 'Ad-hoc identity cannot preserve' zsh "$VERIFY" "$WORK/Adhoc.app" "$WORK/Adhoc.app" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier com.targetbridge.receiver' "$WORK/Adhoc.app/Contents/Info.plist" +reject_with 'Wrong bundle identifier' env TARGETBRIDGE_CODESIGN_IDENTITY=- TARGETBRIDGE_ALLOW_ADHOC=1 zsh "$SIGN" "$WORK/Adhoc.app" +reject_with 'Unexpected bundle ID' zsh "$VERIFY" "$WORK/Adhoc.app" "$BASE" +print -- "7 negative checks passed. Persistent-signature and TCC migration tests still require a valid identity and human consent. Fixtures: $WORK" diff --git a/TargetBridge-Sender/scripts/verify_sender_update.sh b/TargetBridge-Sender/scripts/verify_sender_update.sh new file mode 100755 index 00000000..aa6bcd12 --- /dev/null +++ b/TargetBridge-Sender/scripts/verify_sender_update.sh @@ -0,0 +1,20 @@ +#!/bin/zsh +set -euo pipefail +fail() { print -u2 -- "Sender update rejected: $*"; exit 1; } +[[ $# -eq 2 ]] || fail "Usage: $0 installed.app candidate.app" +OLD="$1" +NEW="$2" +for APP in "$OLD" "$NEW"; do + [[ -f "$APP/Contents/Info.plist" && ! -L "$APP" ]] || fail "Invalid app: $APP" + ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist") + [[ "$ID" == com.targetbridge.sender ]] || fail "Unexpected bundle ID: $ID" + /usr/bin/codesign --verify --deep --strict --all-architectures "$APP" + DETAILS=$(/usr/bin/codesign -dv "$APP" 2>&1) + [[ "$DETAILS" != *"Signature=adhoc"* ]] || fail "Ad-hoc identity cannot preserve update permissions. Migrate once to a persistent signer and reapprove in macOS." +done +REQUIREMENT=$(/usr/bin/codesign -d -r- "$OLD" 2>&1 | /usr/bin/sed -nE 's/^#? ?designated => //p') +[[ -n "$REQUIREMENT" ]] || fail "Missing installed identity requirement" +# '=' marks a literal requirement; without it codesign treats the text as a path. +/usr/bin/codesign --verify --deep --strict --all-architectures -R "=$REQUIREMENT" "$NEW" || fail "Candidate does not satisfy the installed Sender identity" +print -- "PASS: candidate satisfies the installed Sender identity on every architecture." +print -- "This verifies signing continuity, not a TCC grant. Install at /Applications/TargetBridge.app without re-signing." diff --git a/docs/Sender-Signing.md b/docs/Sender-Signing.md new file mode 100644 index 00000000..b0c275d6 --- /dev/null +++ b/docs/Sender-Signing.md @@ -0,0 +1,93 @@ +# Stable Sender identity and privacy permissions + +macOS recognises updates by the app's code-signing designated requirement, not +just its name or version. An ad-hoc signature binds that requirement to the +binary's hashes. Rebuilding changes those hashes and can invalidate Screen +Recording and Accessibility consent. + +## Build and sign + +Use one persistent certificate and keep `com.targetbridge.sender` as the bundle +identifier. Configure `TARGETBRIDGE_CODESIGN_IDENTITY` with the certificate's +fingerprint, or store that public fingerprint in: + +`~/Library/Application Support/TargetBridge/Build/sender-signing-identity.txt` + +The regular Sender build preserves upstream's disposable ad-hoc CI/development +build when no signing configuration was requested, with a prominent warning. +Set `TARGETBRIDGE_REQUIRE_PERSISTENT_SIGNING=1` to require an update-safe build +even on an unconfigured machine. Configuring an identity or a configuration +file also enables strict preflight: a missing/invalid configured identity never +falls back to ad-hoc. Signing errors and verification failures are fatal. +The build stages the candidate +before replacing the previous build, so a missing/locked signing key leaves the +previous artifact intact. + +The packaging build disables Xcode's debug-dylib/preview executor layout, so +the standalone app can be strictly signed without unsigned preview dylibs. +This does not change normal interactive builds in Xcode. + +For private local builds only, a persistent self-signed code-signing identity +can be configured using Apple's Certificate Assistant in Keychain Access. +Any change to its trust settings requires the Mac owner's explicit approval; +limit trust to Code Signing. These build scripts never create/trust certificates, +unlock a Keychain, export keys, or change TCC settings. Do not use an unrestricted +trust-root setup. Verify the chosen fingerprint against the actual certificate. + +Keep the signing Keychain and always sign on that Mac, even when compilation +happens elsewhere. Do not recreate the certificate for each build. A lost key +requires an intentional identity migration and new consent. A local certificate +is not a substitute for Developer ID and notarization for public distribution. + +For distribution, use Developer ID plus the required notarization workflow; +these scripts alone do not implement notarization. Do not mix local and upstream signers if preserving existing consent +is required. No Apple Developer membership is provisioned by these scripts. + +When using the signing helper directly, a disposable development build must opt in to both +`TARGETBRIDGE_CODESIGN_IDENTITY=-` and `TARGETBRIDGE_ALLOW_ADHOC=1`. Never install +it over a Sender that is already persistently signed. + +## Install/update gate + +Before replacing an installed, persistently signed Sender: + +```sh +scripts/verify_sender_update.sh /Applications/TargetBridge.app ../build/TargetBridge.app +``` + +The check rejects ad-hoc signatures, broken bundles, wrong bundle IDs and a +candidate that fails the installed app's designated requirement on any +architecture. Never re-sign, modify Info.plist or otherwise change the bundle +after that check. Preserve the signature with `ditto` and verify it again at the +destination. Keep a recoverable backup outside Applications. + +An initial migration from an ad-hoc app intentionally fails this gate: it is an +identity change, not a seamless update. Back up the installed app, replace it +once with the persistently signed app at `/Applications/TargetBridge.app`, and +approve Screen Recording and Accessibility there if macOS asks. Do not edit +TCC databases, run blanket permission resets, or disable system protections. + +## Validation + +Test two builds with different executable contents signed with the same +identity: both must pass signature verification and the second must satisfy the +first's designated requirement. Test that an ad-hoc update, a different signer, +wrong bundle ID and a tampered resource are rejected. + +`scripts/test_sender_signing.sh /path/to/signed/TargetBridge.app` covers signing +continuity with a changed sealed bundle, missing/unavailable identities, explicit +ad-hoc opt-in, bundle ID mismatch and tampering. Run it with a configured valid +identity; a refusal at preflight is not a passing integration test. Also test a +separate compiled revision and, when available, a second signer before release. + +Signing continuity is necessary, not proof that TCC granted permissions. The +final acceptance test is a real A-to-B installed update after human consent on +the Sender Mac, checking screen capture and receiver-controlled input without +granting them again. macOS can still ask for consent after a reset, identity +change, certificate loss, or OS privacy-policy change. + +References: +- https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements +- https://developer.apple.com/library/archive/technotes/tn2206/ +- https://developer.apple.com/forums/thread/819406 +- https://support.apple.com/guide/keychain-access/kyca8916/mac