From 3c3478b7e8b0ff03c73075ca4741d079fb10b6e5 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Wed, 8 Apr 2026 20:53:52 -0400 Subject: [PATCH] Feature add reproducible build steps (#19) * reproducible build first step * update xcode version for CI runner * update README.md --- .github/workflows/Xcode-build-analyze.yml | 19 +- .../workflows/reproducible-build-check.yml | 63 ++++ .xcode-version | 1 + Config/Base.xcconfig | 61 ++++ Config/Debug.xcconfig | 13 + Config/Hellbender.xcconfig | 17 ++ Config/Release.xcconfig | 13 + README.md | 40 ++- hellbender.xcodeproj/project.pbxproj | 198 +++---------- hellbender/Info.plist | 52 ++++ scripts/build-release.sh | 25 ++ scripts/compare-builds.sh | 97 +++++++ scripts/normalize-app.sh | 270 ++++++++++++++++++ 13 files changed, 701 insertions(+), 168 deletions(-) create mode 100644 .github/workflows/reproducible-build-check.yml create mode 100644 .xcode-version create mode 100644 Config/Base.xcconfig create mode 100644 Config/Debug.xcconfig create mode 100644 Config/Hellbender.xcconfig create mode 100644 Config/Release.xcconfig create mode 100644 hellbender/Info.plist create mode 100755 scripts/build-release.sh create mode 100755 scripts/compare-builds.sh create mode 100755 scripts/normalize-app.sh diff --git a/.github/workflows/Xcode-build-analyze.yml b/.github/workflows/Xcode-build-analyze.yml index 7625d51..c0e0fef 100644 --- a/.github/workflows/Xcode-build-analyze.yml +++ b/.github/workflows/Xcode-build-analyze.yml @@ -14,7 +14,24 @@ jobs: steps: - name: Checkout uses: actions/checkout@v5 - + + - name: Pin Xcode version + run: | + XCODE_VERSION=$(cat .xcode-version) + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" + xcodebuild -version + + - name: Resolve packages + run: xcodebuild -resolvePackageDependencies -project hellbender.xcodeproj -scheme hellbender + + - name: Validate Package.resolved + run: | + if ! git diff --quiet hellbender.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved; then + echo "::error::Package.resolved has uncommitted changes after resolution. Commit the updated Package.resolved." + git diff hellbender.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + exit 1 + fi + - name: Build env: scheme: hellbender diff --git a/.github/workflows/reproducible-build-check.yml b/.github/workflows/reproducible-build-check.yml new file mode 100644 index 0000000..4c20664 --- /dev/null +++ b/.github/workflows/reproducible-build-check.yml @@ -0,0 +1,63 @@ +name: Reproducible Build Check + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + reproducibility: + name: Verify build reproducibility + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Pin Xcode version + run: | + XCODE_VERSION=$(cat .xcode-version) + sudo xcode-select -s "/Applications/Xcode_${XCODE_VERSION}.app/Contents/Developer" + xcodebuild -version + + - name: Build 1 + run: | + DERIVED_DATA="/tmp/hellbender-build-1" + rm -rf "$DERIVED_DATA" + xcodebuild archive \ + -scheme hellbender \ + -project hellbender.xcodeproj \ + -archivePath "$DERIVED_DATA/hellbender.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + | xcpretty && exit ${PIPESTATUS[0]} + + - name: Build 2 + run: | + DERIVED_DATA="/tmp/hellbender-build-2" + rm -rf "$DERIVED_DATA" + xcodebuild archive \ + -scheme hellbender \ + -project hellbender.xcodeproj \ + -archivePath "$DERIVED_DATA/hellbender.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + | xcpretty && exit ${PIPESTATUS[0]} + + - name: Normalize both builds + run: | + APP1="/tmp/hellbender-build-1/hellbender.xcarchive/Products/Applications/hellbender.app" + APP2="/tmp/hellbender-build-2/hellbender.xcarchive/Products/Applications/hellbender.app" + ./scripts/normalize-app.sh "$APP1" + ./scripts/normalize-app.sh "$APP2" + + - name: Compare builds + run: | + APP1="/tmp/hellbender-build-1/hellbender.xcarchive/Products/Applications/hellbender.app" + APP2="/tmp/hellbender-build-2/hellbender.xcarchive/Products/Applications/hellbender.app" + ./scripts/compare-builds.sh "$APP1" "$APP2" diff --git a/.xcode-version b/.xcode-version new file mode 100644 index 0000000..102074d --- /dev/null +++ b/.xcode-version @@ -0,0 +1 @@ +26.4 diff --git a/Config/Base.xcconfig b/Config/Base.xcconfig new file mode 100644 index 0000000..f8b361d --- /dev/null +++ b/Config/Base.xcconfig @@ -0,0 +1,61 @@ +// Base.xcconfig — Shared build settings for all configurations + +ALWAYS_SEARCH_USER_PATHS = NO +ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES + +// Static analysis +CLANG_ANALYZER_NONNULL = YES +CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE + +// Language standards +CLANG_CXX_LANGUAGE_STANDARD = gnu++20 +GCC_C_LANGUAGE_STANDARD = gnu17 + +// Modules and ARC +CLANG_ENABLE_MODULES = YES +CLANG_ENABLE_OBJC_ARC = YES +CLANG_ENABLE_OBJC_WEAK = YES + +// Warnings — Clang +CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES +CLANG_WARN_BOOL_CONVERSION = YES +CLANG_WARN_COMMA = YES +CLANG_WARN_CONSTANT_CONVERSION = YES +CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES +CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR +CLANG_WARN_DOCUMENTATION_COMMENTS = YES +CLANG_WARN_EMPTY_BODY = YES +CLANG_WARN_ENUM_CONVERSION = YES +CLANG_WARN_INFINITE_RECURSION = YES +CLANG_WARN_INT_CONVERSION = YES +CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +CLANG_WARN_OBJC_LITERAL_CONVERSION = YES +CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR +CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES +CLANG_WARN_RANGE_LOOP_ANALYSIS = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_SUSPICIOUS_MOVE = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN_UNREACHABLE_CODE = YES +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES + +// Warnings — GCC +GCC_NO_COMMON_BLOCKS = YES +GCC_WARN_64_TO_32_BIT_CONVERSION = YES +GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR +GCC_WARN_UNDECLARED_SELECTOR = YES +GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE +GCC_WARN_UNUSED_FUNCTION = YES +GCC_WARN_UNUSED_VARIABLE = YES + +// Build settings +COPY_PHASE_STRIP = NO +DEVELOPMENT_TEAM = ZW85AH743B +ENABLE_STRICT_OBJC_MSGSEND = YES +ENABLE_USER_SCRIPT_SANDBOXING = YES +IPHONEOS_DEPLOYMENT_TARGET = 26.0 +LOCALIZATION_PREFERS_STRING_CATALOGS = YES +MTL_FAST_MATH = YES +SDKROOT = iphoneos +STRING_CATALOG_GENERATE_SYMBOLS = YES diff --git a/Config/Debug.xcconfig b/Config/Debug.xcconfig new file mode 100644 index 0000000..4b2f7a4 --- /dev/null +++ b/Config/Debug.xcconfig @@ -0,0 +1,13 @@ +// Debug.xcconfig — Debug-specific build settings +#include "Base.xcconfig" + +DEBUG_INFORMATION_FORMAT = dwarf +ENABLE_PREVIEWS = YES +ENABLE_TESTABILITY = YES +GCC_DYNAMIC_NO_PIC = NO +GCC_OPTIMIZATION_LEVEL = 0 +GCC_PREPROCESSOR_DEFINITIONS = DEBUG=1 $(inherited) +MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE +ONLY_ACTIVE_ARCH = YES +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited) +SWIFT_OPTIMIZATION_LEVEL = -Onone diff --git a/Config/Hellbender.xcconfig b/Config/Hellbender.xcconfig new file mode 100644 index 0000000..4925970 --- /dev/null +++ b/Config/Hellbender.xcconfig @@ -0,0 +1,17 @@ +// Hellbender.xcconfig — Target-level settings for the hellbender app target + +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon +ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor +CODE_SIGN_STYLE = Automatic +CURRENT_PROJECT_VERSION = 23 +DEVELOPMENT_ASSET_PATHS = "hellbender/Preview Content" +GENERATE_INFOPLIST_FILE = NO +INFOPLIST_FILE = hellbender/Info.plist +IPHONEOS_DEPLOYMENT_TARGET = 18.6 +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks +MARKETING_VERSION = 0.1.2 +PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender +PRODUCT_NAME = $(TARGET_NAME) +SWIFT_EMIT_LOC_STRINGS = YES +SWIFT_VERSION = 5.0 +TARGETED_DEVICE_FAMILY = 1,2 diff --git a/Config/Release.xcconfig b/Config/Release.xcconfig new file mode 100644 index 0000000..d0893af --- /dev/null +++ b/Config/Release.xcconfig @@ -0,0 +1,13 @@ +// Release.xcconfig — Release-specific build settings + reproducibility +#include "Base.xcconfig" + +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +ENABLE_NS_ASSERTIONS = NO +ENABLE_PREVIEWS = NO +MTL_ENABLE_DEBUG_INFO = NO +SWIFT_COMPILATION_MODE = wholemodule +VALIDATE_PRODUCT = YES + +// Reproducibility: remap absolute paths in debug info +OTHER_SWIFT_FLAGS = $(inherited) -debug-prefix-map $(SRCROOT)=. -debug-prefix-map $(HOME)=~ +OTHER_CFLAGS = $(inherited) -fdebug-prefix-map=$(SRCROOT)=. -fdebug-prefix-map=$(HOME)=~ diff --git a/README.md b/README.md index 10eff5a..91d5d83 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Hellbender is an iOS Bitcoin multisig coordinator written in Swift. It operates ### Requirements -- Xcode 16.2+ +- Xcode 26.2+ - iOS 18.6+ deployment target - Swift 5.0 @@ -64,9 +64,45 @@ All dependencies are managed via Swift Package Manager and resolve automatically 3. SPM dependencies resolve automatically on first open 4. Build and run on a simulator or device +For reproducible release builds, see [Reproducible Builds](#reproducible-builds) below. + ### CI -GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`. +GitHub Actions runs `xcodebuild clean build analyze` on every push and pull request to `main`. A separate [reproducibility verification workflow](.github/workflows/reproducible-build-check.yml) builds the project twice, normalizes both outputs, and compares them to catch non-determinism regressions. + +### Reproducible Builds + +Hellbender supports **functionally equivalent** reproducible builds. Given the same source code and Xcode version, two independent builds will produce the same compiled logic after normalization. Certain metadata bytes (Mach-O UUIDs, timestamps, build-machine identifiers) are expected to differ and are zeroed by the normalization step. + +**What IS reproducible** (after normalization): all code-bearing sections, resources, and application logic. + +**What is NOT reproducible**: code signing timestamps, Mach-O LC_UUID values, Xcode build-machine metadata, App Store .ipa files (Apple re-signs and applies FairPlay DRM). + +#### Prerequisites + +- Exact Xcode version matching `.xcode-version` (currently 26.4) +- macOS with the matching SDK + +#### Producing a verifiable build + +```bash +./scripts/build-release.sh +``` + +This creates an unsigned archive at `/tmp/hellbender-build/hellbender.xcarchive`. + +#### Verifying two builds + +```bash +# Normalize both builds +./scripts/normalize-app.sh /path/to/build1.app +./scripts/normalize-app.sh /path/to/build2.app + +# Compare +./scripts/compare-builds.sh /path/to/build1.app /path/to/build2.app +``` + +The comparison exits 0 if the builds are functionally equivalent, 1 if code differences are found. ## Links diff --git a/hellbender.xcodeproj/project.pbxproj b/hellbender.xcodeproj/project.pbxproj index 7ebb4c1..9f61674 100644 --- a/hellbender.xcodeproj/project.pbxproj +++ b/hellbender.xcodeproj/project.pbxproj @@ -35,11 +35,28 @@ 3C9ACE242F5DED94009B00D0 /* hellbender.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = hellbender.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3C9ACE342F5DED95009B00D0 /* hellbenderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3C9ACE3E2F5DED95009B00D0 /* hellbenderUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = hellbenderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + CC0000010000000000000001 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; + CC0000010000000000000002 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + CC0000010000000000000003 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + CC0000010000000000000004 /* Hellbender.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Hellbender.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + CC0000010000000000000006 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 3C9ACE232F5DED94009B00D0 /* hellbender */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 3C9ACE262F5DED94009B00D0 /* hellbender */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + CC0000010000000000000006 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); path = hellbender; sourceTree = ""; }; @@ -88,6 +105,7 @@ 3C9ACE1B2F5DED94009B00D0 = { isa = PBXGroup; children = ( + CC0000010000000000000005 /* Config */, 3C9ACE262F5DED94009B00D0 /* hellbender */, 3C9ACE372F5DED95009B00D0 /* hellbenderTests */, 3C9ACE412F5DED95009B00D0 /* hellbenderUITests */, @@ -105,6 +123,17 @@ name = Products; sourceTree = ""; }; + CC0000010000000000000005 /* Config */ = { + isa = PBXGroup; + children = ( + CC0000010000000000000001 /* Base.xcconfig */, + CC0000010000000000000002 /* Debug.xcconfig */, + CC0000010000000000000004 /* Hellbender.xcconfig */, + CC0000010000000000000003 /* Release.xcconfig */, + ); + path = Config; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -295,190 +324,29 @@ /* Begin XCBuildConfiguration section */ 3C9ACE462F5DED95009B00D0 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CC0000010000000000000002 /* Debug.xcconfig */; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ZW85AH743B; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 3C9ACE472F5DED95009B00D0 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CC0000010000000000000003 /* Release.xcconfig */; buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = ZW85AH743B; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; }; name = Release; }; 3C9ACE492F5DED95009B00D0 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CC0000010000000000000004 /* Hellbender.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; - DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\""; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Hellbender; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - INFOPLIST_KEY_NSCameraUsageDescription = "Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets."; - INFOPLIST_KEY_NSFaceIDUsageDescription = "Hellbender uses Face ID to securely unlock your wallet."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.2; - PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 3C9ACE4A2F5DED95009B00D0 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CC0000010000000000000004 /* Hellbender.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 23; - DEVELOPMENT_ASSET_PATHS = "\"hellbender/Preview Content\""; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = Hellbender; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - INFOPLIST_KEY_NSCameraUsageDescription = "Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets."; - INFOPLIST_KEY_NSFaceIDUsageDescription = "Hellbender uses Face ID to securely unlock your wallet."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 18.6; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.2; - PRODUCT_BUNDLE_IDENTIFIER = com.klockenga.Hellbender; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/hellbender/Info.plist b/hellbender/Info.plist new file mode 100644 index 0000000..fa87107 --- /dev/null +++ b/hellbender/Info.plist @@ -0,0 +1,52 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Hellbender + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.finance + NSCameraUsageDescription + Hellbender needs camera access to scan QR codes for importing cosigner keys and signed PSBTs from hardware wallets. + NSFaceIDUsageDescription + Hellbender uses Face ID to securely unlock your wallet. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + + diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..910c13e --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail + +# build-release.sh — Produce a verifiable unsigned release archive. +# Usage: ./scripts/build-release.sh +# +# Output: /tmp/hellbender-build/hellbender.xcarchive + +DERIVED_DATA="/tmp/hellbender-build" + +echo "==> Cleaning previous build artifacts..." +rm -rf "$DERIVED_DATA" + +echo "==> Archiving (unsigned, Release configuration)..." +xcodebuild archive \ + -scheme hellbender \ + -project hellbender.xcodeproj \ + -archivePath "$DERIVED_DATA/hellbender.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGN_IDENTITY="" \ + | xcpretty && exit ${PIPESTATUS[0]} + +echo "==> Archive complete: $DERIVED_DATA/hellbender.xcarchive" diff --git a/scripts/compare-builds.sh b/scripts/compare-builds.sh new file mode 100755 index 0000000..923880a --- /dev/null +++ b/scripts/compare-builds.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -euo pipefail + +# compare-builds.sh — Compare two normalized .app bundles for functional equivalence. +# Both bundles should have been processed by normalize-app.sh first. +# +# Usage: ./scripts/compare-builds.sh +# +# Exit codes: +# 0 — Functionally equivalent (no code differences) +# 1 — Code differences found + +APP1="${1:?Usage: compare-builds.sh }" +APP2="${2:?Usage: compare-builds.sh }" + +if [ ! -d "$APP1" ]; then + echo "Error: $APP1 is not a directory" >&2 + exit 1 +fi +if [ ! -d "$APP2" ]; then + echo "Error: $APP2 is not a directory" >&2 + exit 1 +fi + +echo "==> Comparing builds:" +echo " Build 1: $APP1" +echo " Build 2: $APP2" +echo "" + +IDENTICAL=0 +DIFFERENT=0 +ONLY_IN_1=0 +ONLY_IN_2=0 +DIFF_FILES=() + +# Get sorted file lists relative to the .app root +FILES1=$(cd "$APP1" && find . -type f | sort) +FILES2=$(cd "$APP2" && find . -type f | sort) + +# Check for files only in one build +ONLY1=$(comm -23 <(echo "$FILES1") <(echo "$FILES2")) +ONLY2=$(comm -13 <(echo "$FILES1") <(echo "$FILES2")) + +if [ -n "$ONLY1" ]; then + ONLY_IN_1=$(echo "$ONLY1" | wc -l | tr -d ' ') + echo "--- Files only in Build 1 ($ONLY_IN_1):" + echo "$ONLY1" | sed 's/^/ /' + echo "" +fi + +if [ -n "$ONLY2" ]; then + ONLY_IN_2=$(echo "$ONLY2" | wc -l | tr -d ' ') + echo "--- Files only in Build 2 ($ONLY_IN_2):" + echo "$ONLY2" | sed 's/^/ /' + echo "" +fi + +# Compare common files +COMMON=$(comm -12 <(echo "$FILES1") <(echo "$FILES2")) + +while IFS= read -r relpath; do + [ -z "$relpath" ] && continue + if cmp -s "$APP1/$relpath" "$APP2/$relpath"; then + IDENTICAL=$((IDENTICAL + 1)) + else + DIFFERENT=$((DIFFERENT + 1)) + DIFF_FILES+=("$relpath") + fi +done <<< "$COMMON" + +# Report +echo "==> Results:" +echo " Identical files: $IDENTICAL" +echo " Different files: $DIFFERENT" +echo " Only in Build 1: $ONLY_IN_1" +echo " Only in Build 2: $ONLY_IN_2" + +if [ "$DIFFERENT" -gt 0 ]; then + echo "" + echo "--- Files with differences ($DIFFERENT):" + for f in "${DIFF_FILES[@]}"; do + SIZE1=$(stat -f%z "$APP1/$f" 2>/dev/null || echo "?") + SIZE2=$(stat -f%z "$APP2/$f" 2>/dev/null || echo "?") + echo " $f (${SIZE1}B vs ${SIZE2}B)" + done +fi + +TOTAL_DIFF=$((DIFFERENT + ONLY_IN_1 + ONLY_IN_2)) +if [ "$TOTAL_DIFF" -eq 0 ]; then + echo "" + echo "==> PASS: Builds are functionally equivalent." + exit 0 +else + echo "" + echo "==> FAIL: Builds differ in $TOTAL_DIFF file(s)." + exit 1 +fi diff --git a/scripts/normalize-app.sh b/scripts/normalize-app.sh new file mode 100755 index 0000000..2ae4cc0 --- /dev/null +++ b/scripts/normalize-app.sh @@ -0,0 +1,270 @@ +#!/bin/bash +set -euo pipefail + +# normalize-app.sh — Normalize an unsigned .app bundle for reproducible comparison. +# Zeros non-deterministic metadata (UUIDs, timestamps, build-machine info, code +# signatures, temp paths) so that two builds from the same source produce identical +# normalized output. +# +# Usage: ./scripts/normalize-app.sh +# +# Operates in-place on the .app bundle. Make a copy first if you need the original. + +APP_PATH="${1:?Usage: normalize-app.sh }" + +if [ ! -d "$APP_PATH" ]; then + echo "Error: $APP_PATH is not a directory" >&2 + exit 1 +fi + +echo "==> Normalizing: $APP_PATH" + +# --------------------------------------------------------------------------- +# 1. Canonicalize Assets.car files +# Replace binary .car with sorted JSON dump (content-equivalent, order- +# independent). Must happen BEFORE any binary zeroing that would corrupt +# the BOM header. +# --------------------------------------------------------------------------- +echo " - Canonicalizing Assets.car..." +find "$APP_PATH" -name "Assets.car" -type f | while read -r car_file; do + json_file="${car_file}.json" + if xcrun assetutil --info "$car_file" > "$json_file" 2>/dev/null; then + # Sort JSON keys and strip non-deterministic fields for canonical representation + python3 << PYEOF +import json + +path = """$json_file""" +with open(path) as f: + data = json.load(f) + +def strip_timestamps(obj): + if isinstance(obj, dict): + return {k: strip_timestamps(v) for k, v in obj.items() + if k not in ("Timestamp",)} + elif isinstance(obj, list): + return [strip_timestamps(item) for item in obj] + return obj + +data = strip_timestamps(data) + +# Sort the list of asset entries by a stable key +if isinstance(data, list): + data.sort(key=lambda x: json.dumps(x, sort_keys=True)) + +with open(path, 'w') as f: + json.dump(data, f, sort_keys=True, indent=2) +PYEOF + # Replace .car with canonical JSON + mv "$json_file" "$car_file" + else + rm -f "$json_file" + echo " Warning: assetutil failed for $car_file, zeroing known variable fields instead" + # Fallback: zero the BOM tree ordering section + python3 << PYEOF +import struct + +path = """$car_file""" +with open(path, 'rb') as f: + data = bytearray(f.read()) + +# Zero BOM tree metadata at known offsets +# BOM header timestamp (offset 0x18) +if len(data) > 0x1C: + struct.pack_into('/dev/null || true + fi +done + +# --------------------------------------------------------------------------- +# 3. Zero LC_UUID in all Mach-O binaries +# --------------------------------------------------------------------------- +echo " - Zeroing LC_UUID in Mach-O binaries..." +find "$APP_PATH" -type f | while read -r file; do + if file "$file" | grep -q "Mach-O"; then + python3 << PYEOF +import struct, sys + +path = """$file""" +with open(path, 'rb') as f: + data = bytearray(f.read()) + +def get_slice_offsets(data): + magic = struct.unpack_from('I', data, 4)[0] + return [struct.unpack_from('>I', data, 8 + i * 20 + 8)[0] for i in range(nfat)] + elif magic in (0xFEEDFACE, 0xFEEDFACF, 0xCEFAEDFE, 0xCFFAEDFE): + return [0] + return [] + +def zero_uuids(data, base): + m = struct.unpack_from(' len(data): + break + cmd = struct.unpack_from('I', data, 4)[0] + return [struct.unpack_from('>I', data, 8 + i * 20 + 8)[0] for i in range(nfat)] + elif magic in (0xFEEDFACE, 0xFEEDFACF, 0xCEFAEDFE, 0xCFFAEDFE): + return [0] + return [] + +for base in get_slice_offsets(data): + m = struct.unpack_from(' len(data): + break + cmd = struct.unpack_from('/dev/null || true + done +fi + +echo "==> Normalization complete: $APP_PATH"