diff --git a/.github/workflows/ai-device-tests.yml b/.github/workflows/ai-device-tests.yml index 18075d77b..b3ee75040 100644 --- a/.github/workflows/ai-device-tests.yml +++ b/.github/workflows/ai-device-tests.yml @@ -17,7 +17,7 @@ on: simulator_os: description: "iOS Simulator OS version" required: false - default: "26.2" + default: "26.5" env: TERM: xterm-256color @@ -49,7 +49,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.2" + xcode-version: "26.6" - name: Install xcbeautify run: | diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 75e843baa..7b7f23139 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -20,7 +20,7 @@ jobs: # github.event.pull_request.user.login == 'new-developer' || # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 85e0b5406..b8536ec0c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,7 +10,7 @@ on: jobs: claude: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: write # Allow creating branches/commits pull-requests: write # Allow pushing to PR branches diff --git a/.github/workflows/e2e-retarget.yml b/.github/workflows/e2e-retarget.yml index ea581e416..a1c3599a1 100644 --- a/.github/workflows/e2e-retarget.yml +++ b/.github/workflows/e2e-retarget.yml @@ -26,7 +26,7 @@ jobs: (github.event.changes.base != null && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: actions: write contents: write diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 2c3fba80b..a1050d2e9 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -20,7 +20,7 @@ env: FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" SIMULATOR_NAME: "iPhone 17" - IOS_VERSION: "26.2" + IOS_VERSION: "26.5" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} @@ -28,7 +28,7 @@ concurrency: jobs: build-staging: - runs-on: macos-latest + runs-on: macos-26 steps: - name: Checkout @@ -37,7 +37,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.2" + xcode-version: "26.6" - name: System Information run: | @@ -109,7 +109,7 @@ jobs: e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} e2e-tests-staging: - runs-on: macos-latest + runs-on: macos-26 timeout-minutes: 120 needs: [build-staging, e2e-branch] @@ -254,7 +254,7 @@ jobs: always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.post_to_slack == true)) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [build-staging, e2e-branch, e2e-tests-staging] steps: diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6877cd5fb..e53032691 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -51,7 +51,7 @@ concurrency: jobs: detect-changes: if: github.event.pull_request.draft == false - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: code: ${{ (github.event_name == 'workflow_dispatch' && !inputs.pr) || steps.filter.outputs.code == 'true' }} steps: @@ -92,7 +92,7 @@ jobs: checks: write actions: write if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' - runs-on: macos-latest + runs-on: macos-26 steps: - name: Checkout @@ -125,7 +125,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.2" + xcode-version: "26.6" - name: System Information run: | @@ -172,7 +172,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHATWOOT_API: ${{ secrets.CHATWOOT_API }} SIMULATOR_NAME: "iPhone 17" - OS_VERSION: "26.2" + OS_VERSION: "26.5" run: | echo "=== Building iOS app (local) ===" echo "Using simulator: $SIMULATOR_NAME (iOS $OS_VERSION)" @@ -232,7 +232,7 @@ jobs: needs: [detect-changes, build-local, e2e-branch] env: SIMULATOR_NAME: "iPhone 17" - SIMULATOR_OS_VERSION: "26.2" + SIMULATOR_OS_VERSION: "26.5" strategy: fail-fast: false @@ -404,7 +404,7 @@ jobs: e2e-status: if: always() && github.event.pull_request.draft == false name: e2e-status - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [detect-changes, e2e-tests-local] steps: - name: Verify change detection succeeded @@ -430,7 +430,7 @@ jobs: name: Report retarget E2E result if: always() && inputs.retarget_run != '' needs: [detect-changes, build-local, e2e-branch, e2e-tests-local, e2e-status] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: read diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 9153cbcc5..6df318e75 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -30,7 +30,7 @@ env: FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" SIMULATOR_NAME: "iPhone 17" - IOS_VERSION: "26.2" + IOS_VERSION: "26.5" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} @@ -39,7 +39,7 @@ concurrency: jobs: build: if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - runs-on: macos-latest + runs-on: macos-26 steps: - name: Checkout @@ -48,7 +48,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.2" + xcode-version: "26.6" - name: System Information run: | @@ -125,7 +125,7 @@ jobs: resolve-e2e-ref: if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) needs: [e2e-branch] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: ref: ${{ steps.e2e-ref.outputs.ref }} steps: @@ -139,7 +139,7 @@ jobs: migration-plan: if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) needs: [e2e-branch] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: version: ${{ steps.plan.outputs.version }} all: ${{ steps.plan.outputs.all }} @@ -180,7 +180,7 @@ jobs: rn-tests: if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - runs-on: macos-latest + runs-on: macos-26 needs: [build, e2e-branch, migration-plan, prepare-wallets] strategy: @@ -356,7 +356,7 @@ jobs: native-tests: if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - runs-on: macos-latest + runs-on: macos-26 needs: [build, e2e-branch, migration-plan] strategy: @@ -510,7 +510,7 @@ jobs: migration-result: if: always() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-')))) needs: [build, e2e-branch, resolve-e2e-ref, migration-plan, prepare-wallets, rn-tests, native-tests] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Require all migration jobs to succeed env: @@ -529,7 +529,7 @@ jobs: always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.post_to_slack == true)) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [build, e2e-branch, resolve-e2e-ref, migration-plan, prepare-wallets, rn-tests, native-tests, migration-result] steps: - name: Resolve E2E tests ref diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 02a79cc44..f53411ab4 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -46,7 +46,7 @@ jobs: cache-mode: read if: github.event.pull_request.draft == false name: Run Integration Tests - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 permissions: contents: read @@ -89,18 +89,20 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.6' # A fresh simulator takes over 10 minutes to boot on the runner. Booting it now lets that # overlap with resolving and building instead of stalling the test step. - name: Boot simulator + env: + SIMULATOR_NAME: "iPhone 17" + SIMULATOR_OS: "26.5" run: | udid=$(xcrun simctl list devices available -j | python3 -c ' - import json, sys - runtimes = json.load(sys.stdin)["devices"] - version = lambda runtime: [int(part) for part in runtime.rsplit(".", 1)[-1].split("-")[1:]] - ios = sorted((r for r in runtimes if ".iOS-" in r and any(d["name"] == "iPhone 16" for d in runtimes[r])), key=version) - print(next(d["udid"] for d in runtimes[ios[-1]] if d["name"] == "iPhone 16")) + import json, os, sys + runtime = "com.apple.CoreSimulator.SimRuntime.iOS-" + os.environ["SIMULATOR_OS"].replace(".", "-") + devices = json.load(sys.stdin)["devices"].get(runtime, []) + print(next(d["udid"] for d in devices if d["name"] == os.environ["SIMULATOR_NAME"])) ') echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" echo "DESTINATION=platform=iOS Simulator,id=$udid" >> "$GITHUB_ENV" @@ -176,7 +178,7 @@ jobs: name: Report retarget integration result if: always() && inputs.retarget_run != '' needs: [test] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: read diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 60f2423a9..9e3d3e8e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ env: jobs: swiftformat: name: SwiftFormat - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/.github/workflows/retarget-execution-cleanup.yml b/.github/workflows/retarget-execution-cleanup.yml index 8312f868c..b5ae2411d 100644 --- a/.github/workflows/retarget-execution-cleanup.yml +++ b/.github/workflows/retarget-execution-cleanup.yml @@ -15,7 +15,7 @@ permissions: jobs: cleanup: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: actions/github-script@v9 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 055d68e24..44973cdd2 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,7 +19,7 @@ jobs: test: if: github.event.pull_request.draft == false name: Run Tests - runs-on: macos-15 + runs-on: macos-26 timeout-minutes: 60 steps: @@ -29,7 +29,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: '16.4' + xcode-version: '26.6' - name: Install xcbeautify run: | @@ -41,7 +41,7 @@ jobs: - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" - xcrun simctl boot "iPhone 16" || true + xcrun simctl boot "iPhone 17" || true echo "✅ Simulator started at $(date)" - name: Run unit tests @@ -49,7 +49,7 @@ jobs: echo "⏱️ Starting unit tests at $(date)" set -o pipefail && xcodebuild test \ -scheme Bitkit \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \ -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" \ -disablePackageRepositoryCache \ -disableAutomaticPackageResolution \ diff --git a/.github/workflows/validate-translations.yml b/.github/workflows/validate-translations.yml index fbdc01325..6d4260ec5 100644 --- a/.github/workflows/validate-translations.yml +++ b/.github/workflows/validate-translations.yml @@ -9,7 +9,7 @@ on: jobs: validate: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index f83b181fa..c688f66c5 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -22,9 +22,12 @@ 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; - 96E493A42C942FD1000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A32C942FD1000E8BC2 /* secp256k1 */; }; - 96E493A62C94317D000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A52C94317D000E8BC2 /* secp256k1 */; }; - 96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A72C943184000E8BC2 /* secp256k1 */; }; + 96E493A42C942FD1000E8BC2 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A32C942FD1000E8BC2 /* P256K */; }; + 96E493B22C942FD1000E8BC2 /* libsecp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493B12C942FD1000E8BC2 /* libsecp256k1 */; }; + 96E493A62C94317D000E8BC2 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A52C94317D000E8BC2 /* P256K */; }; + 96E493B42C94317D000E8BC2 /* libsecp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493B32C94317D000E8BC2 /* libsecp256k1 */; }; + 96E493A82C943184000E8BC2 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A72C943184000E8BC2 /* P256K */; }; + 96E493B62C943184000E8BC2 /* libsecp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493B52C943184000E8BC2 /* libsecp256k1 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -252,7 +255,8 @@ 3D7626104C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */, 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */, - 96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */, + 96E493A82C943184000E8BC2 /* P256K in Frameworks */, + 96E493B62C943184000E8BC2 /* libsecp256k1 in Frameworks */, 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -265,7 +269,8 @@ 3D76260F4C9C4A53B1E4A001 /* CoreBluetooth.framework in Frameworks */, 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */, 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */, - 96E493A42C942FD1000E8BC2 /* secp256k1 in Frameworks */, + 96E493A42C942FD1000E8BC2 /* P256K in Frameworks */, + 96E493B22C942FD1000E8BC2 /* libsecp256k1 in Frameworks */, 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */, 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */, ); @@ -275,7 +280,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 96E493A62C94317D000E8BC2 /* secp256k1 in Frameworks */, + 96E493A62C94317D000E8BC2 /* P256K in Frameworks */, + 96E493B42C94317D000E8BC2 /* libsecp256k1 in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -365,7 +371,8 @@ ); name = BitkitNotification; packageProductDependencies = ( - 96E493A72C943184000E8BC2 /* secp256k1 */, + 96E493A72C943184000E8BC2 /* P256K */, + 96E493B52C943184000E8BC2 /* libsecp256k1 */, 96DEA03B2DE8BBAB009932BF /* BitkitCore */, 968FE13F2DFB016B0053CD7F /* LDKNode */, 18D65E012EB964BD00252335 /* VssRustClientFfi */, @@ -397,7 +404,8 @@ name = Bitkit; packageProductDependencies = ( 9613018B2C5022D700878183 /* LDKNode */, - 96E493A32C942FD1000E8BC2 /* secp256k1 */, + 96E493A32C942FD1000E8BC2 /* P256K */, + 96E493B12C942FD1000E8BC2 /* libsecp256k1 */, 96DEA0392DE8BBA1009932BF /* BitkitCore */, 4AAB08C92E1FE77600BA63DF /* Lottie */, 18D65DFF2EB964B500252335 /* VssRustClientFfi */, @@ -425,7 +433,8 @@ ); name = BitkitTests; packageProductDependencies = ( - 96E493A52C94317D000E8BC2 /* secp256k1 */, + 96E493A52C94317D000E8BC2 /* P256K */, + 96E493B32C94317D000E8BC2 /* libsecp256k1 */, ); productName = BitkitTests; productReference = 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */; @@ -1240,7 +1249,7 @@ repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1"; requirement = { kind = exactVersion; - version = 0.17.0; + version = 0.24.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -1286,20 +1295,35 @@ package = 96DEA0382DE8BBA1009932BF /* XCRemoteSwiftPackageReference "bitkit-core" */; productName = BitkitCore; }; - 96E493A32C942FD1000E8BC2 /* secp256k1 */ = { + 96E493A32C942FD1000E8BC2 /* P256K */ = { isa = XCSwiftPackageProductDependency; package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; - productName = secp256k1; + productName = P256K; }; - 96E493A52C94317D000E8BC2 /* secp256k1 */ = { + 96E493B12C942FD1000E8BC2 /* libsecp256k1 */ = { isa = XCSwiftPackageProductDependency; package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; - productName = secp256k1; + productName = libsecp256k1; }; - 96E493A72C943184000E8BC2 /* secp256k1 */ = { + 96E493A52C94317D000E8BC2 /* P256K */ = { isa = XCSwiftPackageProductDependency; package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; - productName = secp256k1; + productName = P256K; + }; + 96E493B32C94317D000E8BC2 /* libsecp256k1 */ = { + isa = XCSwiftPackageProductDependency; + package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = libsecp256k1; + }; + 96E493A72C943184000E8BC2 /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = P256K; + }; + 96E493B52C943184000E8BC2 /* libsecp256k1 */ = { + isa = XCSwiftPackageProductDependency; + package = 96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = libsecp256k1; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4a921d6b3..d0490865b 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/21-DOT-DEV/swift-secp256k1", "state" : { - "revision" : "9683e8e311c76d8114cd308b697dad2f9fc58fed", - "version" : "0.17.0" + "revision" : "bcd6dd8e615ce243eab7097f213c8c4b39305a2e", + "version" : "0.24.0" } }, { diff --git a/Bitkit/Services/WatchOnlyAccountService.swift b/Bitkit/Services/WatchOnlyAccountService.swift index 9f9a04b9e..e99eb1536 100644 --- a/Bitkit/Services/WatchOnlyAccountService.swift +++ b/Bitkit/Services/WatchOnlyAccountService.swift @@ -3,7 +3,7 @@ import Combine import CryptoKit import Foundation import LDKNode -import secp256k1 +import P256K enum WatchOnlyAccountSetupState: String, Codable { case pendingDelivery @@ -994,6 +994,6 @@ enum WatchOnlyAccountClaimCodec { } private static func isValidCompressedPublicKey(_ key: Data) -> Bool { - (try? secp256k1.KeyAgreement.PublicKey(dataRepresentation: key, format: .compressed)) != nil + (try? P256K.KeyAgreement.PublicKey(dataRepresentation: key, format: .compressed)) != nil } } diff --git a/Bitkit/Utilities/Crypto.swift b/Bitkit/Utilities/Crypto.swift index 952af5e46..4195de3d9 100644 --- a/Bitkit/Utilities/Crypto.swift +++ b/Bitkit/Utilities/Crypto.swift @@ -1,8 +1,19 @@ import CryptoKit import Foundation -import secp256k1 +import libsecp256k1 + +/// ECDH output: the shared point serialized as a 33-byte compressed key (no hashing). +private let compressedPointHash: secp256k1_ecdh_hash_function = { output, x32, y32, _ in + guard let output, let x32, let y32 else { return 0 } + output[0] = 0x02 | (y32[31] & 1) + (output + 1).update(from: x32, count: 32) + return 1 +} class Crypto { + private static let privateKeyLength = 32 + private static let compressedPublicKeyLength = 33 + struct KeyPair { let privateKey: Data let publicKey: Data @@ -23,15 +34,47 @@ class Crypto { } static func generateSharedSecret(privateKey: Data, nodePubkey: String, derivationName: String? = nil) throws -> Data { - let privateKey = try secp256k1.KeyAgreement.PrivateKey(dataRepresentation: privateKey.bytes) + guard privateKey.count == privateKeyLength else { + throw CryptoError.invalidPrivateKey + } - let publicKey = try secp256k1.KeyAgreement.PublicKey(dataRepresentation: nodePubkey.hexaBytes) + let publicKeyBytes = nodePubkey.hexaBytes + guard publicKeyBytes.count == compressedPublicKeyLength else { + throw CryptoError.invalidPublicKey + } - let baseSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey, format: .compressed).bytes + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_NONE)) else { + throw CryptoError.contextCreationFailed + } + defer { secp256k1_context_destroy(context) } + + var publicKey = secp256k1_pubkey() + guard secp256k1_ec_pubkey_parse(context, &publicKey, publicKeyBytes, publicKeyBytes.count) == 1 else { + throw CryptoError.invalidPublicKey + } + + var baseSecret = [UInt8](repeating: 0, count: compressedPublicKeyLength) + defer { zeroize(&baseSecret) } + let result = privateKey.withUnsafeBytes { keyPtr in + secp256k1_ecdh( + context, + &baseSecret, + &publicKey, + keyPtr.bindMemory(to: UInt8.self).baseAddress!, + compressedPointHash, + nil + ) + } + + // 0 means the private key is zero or not below the curve order. + guard result == 1 else { + throw CryptoError.sharedSecretGenerationFailed + } if let derivationName { let bytes = stringToBytes(derivationName) - let merged = baseSecret + bytes + var merged = baseSecret + bytes + defer { zeroize(&merged) } return try Data(dsha256(merged)) } @@ -39,6 +82,13 @@ class Crypto { return Data(baseSecret) } + private static func zeroize(_ bytes: inout [UInt8]) { + bytes.withUnsafeMutableBytes { buffer in + guard let base = buffer.baseAddress else { return } + _ = memset_s(base, buffer.count, 0, buffer.count) + } + } + static func generateKeyPair() throws -> KeyPair { guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { throw CryptoError.contextCreationFailed @@ -102,6 +152,10 @@ class Crypto { /// Sign using Lightning Network message signing format static func sign(message: String, privateKey: Data) throws -> String { + guard privateKey.count == privateKeyLength else { + throw CryptoError.invalidPrivateKey + } + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { throw CryptoError.contextCreationFailed } @@ -142,6 +196,10 @@ class Crypto { } static func getPublicKey(privateKey: Data) throws -> Data { + guard privateKey.count == privateKeyLength else { + throw CryptoError.invalidPrivateKey + } + guard let context = secp256k1_context_create(UInt32(SECP256K1_CONTEXT_SIGN)) else { throw CryptoError.contextCreationFailed } @@ -174,6 +232,7 @@ class Crypto { case invalidDerivationName case contextCreationFailed case invalidPublicKey + case invalidPrivateKey case publicKeyCreationFailed case randomGenerationFailed case publicKeySerializationFailed diff --git a/BitkitTests/CryptoTests.swift b/BitkitTests/CryptoTests.swift index ef9b8e6cd..3719e7c61 100644 --- a/BitkitTests/CryptoTests.swift +++ b/BitkitTests/CryptoTests.swift @@ -90,4 +90,105 @@ final class CryptoTests: XCTestCase { XCTAssertEqual(String(data: value, encoding: .utf8), decryptedPayload) } + + /// Expected values computed with an independent secp256k1 implementation (RFC 6979 nonces, low-S). + func testGetPublicKeyMatchesReferenceVector() throws { + let privateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680".hexaData + let publicKey = try Crypto.getPublicKey(privateKey: privateKey) + XCTAssertEqual(publicKey.hex, "02f7c231fe139475b8193194b42043ae546768167e5cf836f5a4063f5d448a127f") + } + + func testSignMatchesReferenceVector() throws { + let privateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680".hexaData + let signature = try Crypto.sign(message: "hello bitkit", privateKey: privateKey) + XCTAssertEqual( + signature, + "204b84a4bfc77965c2ea561930829fca5a05610027d3591268e13f4e0cbb65aea2176f20bd78ca453a23214323ac1a6df88045f71b40f2e9dadab360743606a655" + ) + } + + func testGenerateKeyPairPublicKeyMatchesPrivateKey() throws { + let keyPair = try Crypto.generateKeyPair() + XCTAssertEqual(keyPair.privateKey.count, 32) + XCTAssertEqual(keyPair.publicKey.count, 33) + XCTAssertEqual(try Crypto.getPublicKey(privateKey: keyPair.privateKey), keyPair.publicKey) + } + + // MARK: - Input validation (regression: sign/getPublicKey read past the end of short keys) + + private static let validPrivateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680".hexaData + private static let validPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" + private static let curveOrder = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexaData + + private func assertThrows( + _ expected: Crypto.CryptoError, + file: StaticString = #filePath, + line: UInt = #line, + _ body: () throws -> some Any + ) { + XCTAssertThrowsError(try body(), file: file, line: line) { error in + guard let error = error as? Crypto.CryptoError, error == expected else { + return XCTFail("Expected \(expected), got \(error)", file: file, line: line) + } + } + } + + func testSignRejectsPrivateKeyOfWrongLength() { + for length in [0, 1, 31, 33, 64] { + assertThrows(.invalidPrivateKey) { try Crypto.sign(message: "hello", privateKey: Data(repeating: 1, count: length)) } + } + } + + func testSignRejectsOutOfRangePrivateKey() { + assertThrows(.signingFailed) { try Crypto.sign(message: "hello", privateKey: Data(repeating: 0, count: 32)) } + assertThrows(.signingFailed) { try Crypto.sign(message: "hello", privateKey: Self.curveOrder) } + } + + func testGetPublicKeyRejectsPrivateKeyOfWrongLength() { + for length in [0, 1, 31, 33, 64] { + assertThrows(.invalidPrivateKey) { try Crypto.getPublicKey(privateKey: Data(repeating: 1, count: length)) } + } + } + + func testGetPublicKeyRejectsOutOfRangePrivateKey() { + assertThrows(.publicKeyCreationFailed) { try Crypto.getPublicKey(privateKey: Data(repeating: 0, count: 32)) } + assertThrows(.publicKeyCreationFailed) { try Crypto.getPublicKey(privateKey: Self.curveOrder) } + } + + func testGenerateSharedSecretRejectsPrivateKeyOfWrongLength() { + for length in [0, 1, 31, 33, 64] { + assertThrows(.invalidPrivateKey) { + try Crypto.generateSharedSecret(privateKey: Data(repeating: 1, count: length), nodePubkey: Self.validPublicKey) + } + } + } + + func testGenerateSharedSecretRejectsOutOfRangePrivateKey() { + assertThrows(.sharedSecretGenerationFailed) { + try Crypto.generateSharedSecret(privateKey: Data(repeating: 0, count: 32), nodePubkey: Self.validPublicKey) + } + assertThrows(.sharedSecretGenerationFailed) { + try Crypto.generateSharedSecret(privateKey: Self.curveOrder, nodePubkey: Self.validPublicKey) + } + } + + func testGenerateSharedSecretRejectsInvalidPublicKeys() throws { + let compressedHex = try Crypto.getPublicKey(privateKey: Self.validPrivateKey).hex + let xCoordinate = String(compressedHex.dropFirst(2)) + let offCurveX = String(repeating: "0", count: 63) + "5" // x = 5 has no y on secp256k1 + + let invalid = [ + "", + String(Self.validPublicKey.dropLast(2)), // 32 bytes + Self.validPublicKey + "00", // 34 bytes + "04" + xCoordinate + xCoordinate, // 65 bytes: only compressed keys are accepted + "04" + xCoordinate, // uncompressed prefix on 33 bytes + "05" + xCoordinate, // unknown prefix + "02" + offCurveX, + "02" + String(repeating: "f", count: 64), // x >= field prime + ] + for pubkey in invalid { + assertThrows(.invalidPublicKey) { try Crypto.generateSharedSecret(privateKey: Self.validPrivateKey, nodePubkey: pubkey) } + } + } }