diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml new file mode 100644 index 0000000000..c331a438ae --- /dev/null +++ b/.github/workflows/shared-core-tests.yml @@ -0,0 +1,131 @@ +name: SharedCore tests + +# The Swift facade in `kmp/shared-core/spm` and the Kotlin/Native halves of the modules it +# exports both need Xcode, so neither runs in the Ubuntu `CI` workflow. This is the macOS lane +# that covers them. Path-filtered rather than folded into `CI`: a macOS runner is expensive and +# nothing outside these directories can change the answer. +on: + pull_request: + paths: + - 'kmp/shared-core/**' + - 'libs/codes/kikcode/**' + - 'libs/encryption/**' + - 'gradle/libs.versions.toml' + - '.github/workflows/shared-core-tests.yml' + +concurrency: + group: shared-core-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CI: true + +jobs: + shared-core-tests: + name: Run SharedCore tests + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # Cheap supply-chain guard: fails the run if gradle/wrapper/gradle-wrapper.jar + # is not a byte-for-byte match for a jar published by Gradle. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Setup Java env + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + cache: 'gradle' + + # Gradle configures every project in the build, and the Android app applies the secrets + # plugin, which fails configuration outright when `local.properties` is absent. This lane + # only builds the KMP modules — nothing here compiles the app or talks to any of these + # services — so obviously-fake values are enough to get through configuration. Same + # placeholders the publish workflow writes. + - name: Write placeholder local.properties + run: | + set -euo pipefail + { + echo 'BUGSNAG_API_KEY="00000000000000000000000000000000"' + echo 'GOOGLE_CLOUD_PROJECT_NUMBER=000000000000' + echo 'MIXPANEL_API_KEY="00000000000000000000000000000000"' + echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000' + } > ./local.properties + + # commonTest runs on the JVM in the Ubuntu lane, which says nothing about the Kotlin/Native + # halves — ed25519's actual is cinterop over vendored C, and the rest go through a different + # compiler backend. These compile anyway as part of the XCFramework below; running them is + # nearly free from here. + - name: Run the Kotlin tests on the iOS simulator target + run: | + ./gradlew \ + :libs:codes:kikcode:iosSimulatorArm64Test \ + :libs:encryption:base58:iosSimulatorArm64Test \ + :libs:encryption:ed25519:iosSimulatorArm64Test \ + :libs:encryption:hmac:iosSimulatorArm64Test \ + :libs:encryption:sha256:iosSimulatorArm64Test \ + :libs:encryption:sha512:iosSimulatorArm64Test + + # The facade suite has to run against the Kotlin in this checkout, not against the last + # published release, or a facade written for an unreleased Kotlin change is untested until + # after the release that would have caught it. + - name: Assemble the XCFramework + run: ./gradlew :kmp:shared-core:assembleSharedCoreReleaseXCFramework + + # Named simulators come and go with the runner image, so pick whatever iPhone this one has + # rather than pinning a name that silently stops existing. + - name: Pick a simulator + id: sim + run: | + set -euo pipefail + udid=$(xcrun simctl list devices available -j \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['devices']; print(next(x['udid'] for r in sorted(d, reverse=True) for x in d[r] if x['name'].startswith('iPhone')))") + echo "udid=$udid" >> "$GITHUB_OUTPUT" + echo "Using simulator $udid" + + # The package is iOS-only and the XCFramework has no host slice, so `swift test` cannot + # run this suite — it goes through a simulator destination. FLIPCASH_SHARED_CORE_LOCAL is + # the same override the local loop uses; see docs/proto-local-development.md's sibling, + # shared-core-local-development.md, in the orchestrator repo. + - name: Run the SharedCoreKit tests + working-directory: kmp/shared-core/spm + env: + FLIPCASH_SHARED_CORE_LOCAL: ${{ github.workspace }} + run: | + set -euo pipefail + xcodebuild test \ + -scheme SharedCore \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/spm" \ + -resultBundlePath "$RUNNER_TEMP/SharedCoreKit.xcresult" \ + -quiet + + # `-quiet` prints nothing on success, so a run that executed no tests at all reads exactly + # like a run that passed all of them — the one failure mode a gate must not have. Print the + # counts and fail on zero. + - name: Report the test counts + if: always() + run: | + set -euo pipefail + bundle="$RUNNER_TEMP/SharedCoreKit.xcresult" + if [ ! -d "$bundle" ]; then + echo "no result bundle at $bundle — the test step never got as far as running" + exit 1 + fi + xcrun xcresulttool get test-results summary --path "$bundle" --format json \ + > "$RUNNER_TEMP/summary.json" + python3 -c " + import json, sys + summary = json.load(open(sys.argv[1])) + total = summary.get('totalTestCount', 0) + print(total, 'tests:', + summary.get('passedTests', 0), 'passed,', + summary.get('failedTests', 0), 'failed,', + summary.get('skippedTests', 0), 'skipped') + if total == 0: + sys.exit('the suite executed no tests') + " "$RUNNER_TEMP/summary.json" diff --git a/kmp/shared-core/spm/Package.swift b/kmp/shared-core/spm/Package.swift index 7ab786038a..59ea27ccca 100644 --- a/kmp/shared-core/spm/Package.swift +++ b/kmp/shared-core/spm/Package.swift @@ -73,7 +73,8 @@ let package = Package( ), .testTarget( name: "SharedCoreKitTests", - dependencies: ["SharedCoreKit"] + dependencies: ["SharedCoreKit"], + resources: [.copy("Fixtures")] ), ] ) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift new file mode 100644 index 0000000000..9f1f02c11d --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift @@ -0,0 +1,17 @@ +import Foundation +import SharedCore + +/// Bitcoin/Solana Base58, computed by the shared Kotlin. +public enum Base58 { + + public static func encode(_ data: Data) -> String { + SharedCore.Base58.shared.encode(input: data.kotlinByteArray) + } + + /// Returns `nil` for input outside the alphabet. Kotlin throws there, but the throw carries + /// nothing a caller can act on beyond "not Base58". + public static func decode(_ string: String) -> Data? { + guard let bytes = try? SharedCore.Base58.shared.decode(input: string) else { return nil } + return Data(bytes) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift index 55480c9159..b9f956617f 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift @@ -12,4 +12,16 @@ extension Data { } return array } + + /// The reverse copy, for the exported functions that hand bytes back. Kotlin bytes are signed, + /// so anything above 0x7F comes across negative and has to be reinterpreted rather than + /// converted. + init(_ array: KotlinByteArray) { + var bytes = [UInt8]() + bytes.reserveCapacity(Int(array.size)) + for index in 0.. KeyPair { + let pair = Ed25519Kmp.shared.createKeyPair(seed: seed.kotlinByteArray) + return KeyPair(publicKey: Data(pair.publicKey), privateKey: Data(pair.privateKey)) + } + + public static func sign(message: Data, keyPair: KeyPair) -> Data { + Data(Ed25519Kmp.shared.sign( + message: message.kotlinByteArray, + publicKey: keyPair.publicKey.kotlinByteArray, + privateKey: keyPair.privateKey.kotlinByteArray + )) + } + + public static func verify(signature: Data, message: Data, publicKey: Data) -> Bool { + Ed25519Kmp.shared.verify( + signature: signature.kotlinByteArray, + message: message.kotlinByteArray, + publicKey: publicKey.kotlinByteArray + ) + } + + public static func isOnCurve(publicKey: Data) -> Bool { + Ed25519Kmp.shared.onCurve(publicKey: publicKey.kotlinByteArray) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift new file mode 100644 index 0000000000..04e0997ff9 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift @@ -0,0 +1,54 @@ +import Foundation +import SharedCore + +/// The hash and MAC primitives, computed by the shared Kotlin. +/// +/// Namespaced rather than free functions so the call site says where the answer came from while +/// callers still hold their own `Sha256`/`Hmac` types during the swap. +public enum SharedHash { + + public static func sha256(_ data: Data) -> Data { + Data(Sha256Hash.companion.hash(input: data.kotlinByteArray)) + } + + /// SHA-256(SHA-256(data)), the double hash Solana addresses and Bitcoin-derived formats use. + public static func sha256Twice(_ data: Data) -> Data { + Data(Sha256Hash.companion.hashTwice(input: data.kotlinByteArray)) + } + + public static func sha512(_ data: Data) -> Data { + Data(Sha512.shared.hash(input: data.kotlinByteArray)) + } + + public static func hmacSHA256(key: Data, message: Data) -> Data { + Data(Hmac.shared.hmac( + algorithm: "HmacSHA256", + key: key.kotlinByteArray, + message: message.kotlinByteArray + )) + } + + public static func hmacSHA512(key: Data, message: Data) -> Data { + Data(Hmac.shared.hmac( + algorithm: "HmacSHA512", + key: key.kotlinByteArray, + message: message.kotlinByteArray + )) + } + + /// PBKDF2-HMAC-SHA512. Takes strings rather than bytes because that is the shape BIP-39 needs + /// and the shape the Kotlin exports. + public static func pbkdf2SHA512( + password: String, + salt: String, + iterations: Int, + keyLength: Int + ) -> Data { + Data(PBKDF2SHA512.shared.derive( + P: password, + S: salt, + c: Int32(iterations), + dkLen: Int32(keyLength) + )) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift index d989a7ef37..840cdbd1b8 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift @@ -4,7 +4,5 @@ import SharedCore public enum SharedCoreInfo { /// The `:kmp:shared-core` version the linked XCFramework was published at. - // Unqualified on purpose: inside this module the name `SharedCore` resolves to the - // Kotlin object, not the framework it lives in. - public static var version: String { SharedCore.shared.version } + public static var version: String { SharedCore.SharedCoreBuild.shared.version } } diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift new file mode 100644 index 0000000000..542615419b --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import SharedCoreKit + +@Suite struct Base58Tests { + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let bytes: String + let base58: String + } + + let vectors: [Vector] + } + + @Test func matchesTheCrossPlatformVectors() throws { + let fixture = try Fixtures.load("base58", as: Fixture.self) + #expect(fixture.vectors.count == 7) + + for vector in fixture.vectors { + let bytes = try #require(Data(hex: vector.bytes), "\(vector.name): bad fixture hex") + #expect(Base58.encode(bytes) == vector.base58, "\(vector.name): encode") + #expect(Base58.decode(vector.base58) == bytes, "\(vector.name): decode") + } + } + + @Test func rejectsCharactersOutsideTheAlphabet() { + // 0, I, O and l are the four excluded from the Bitcoin alphabet. + #expect(Base58.decode("0OIl") == nil) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift new file mode 100644 index 0000000000..81b7a92f7b --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +import SharedCore +@testable import SharedCoreKit + +/// Every facade in this module moves bytes across the Kotlin boundary in both directions, so the +/// two copies get their own tests rather than being covered incidentally by whichever facade test +/// happens to run first. +@Suite struct DataBridgeTests { + + @Test func roundTripsThroughKotlin() { + let original = Data([0x00, 0x01, 0x7F, 0x80, 0xFF]) + #expect(Data(original.kotlinByteArray) == original) + } + + @Test func roundTripsEmpty() { + #expect(Data(Data().kotlinByteArray) == Data()) + } + + @Test func roundTripsEveryByteValue() { + let original = Data((0...255).map { UInt8($0) }) + #expect(Data(original.kotlinByteArray) == original) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift new file mode 100644 index 0000000000..37e42cb405 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import SharedCoreKit + +@Suite struct Ed25519Tests { + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let seed: String + let message: String + let publicKey: String + let signature: String + } + + let vectors: [Vector] + } + + @Test func matchesTheCrossPlatformVectors() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + #expect(fixture.vectors.count == 6) + + for vector in fixture.vectors { + let seed = try #require(Data(hex: vector.seed), "\(vector.name): bad fixture hex") + let message = try #require(Data(hex: vector.message), "\(vector.name): bad fixture hex") + let expectedPublicKey = try #require(Data(hex: vector.publicKey)) + let expectedSignature = try #require(Data(hex: vector.signature)) + + let keyPair = SharedEd25519.keyPair(seed: seed) + #expect(keyPair.publicKey == expectedPublicKey, "\(vector.name): public key") + + let signature = SharedEd25519.sign(message: message, keyPair: keyPair) + #expect(signature == expectedSignature, "\(vector.name): signature") + + #expect( + SharedEd25519.verify( + signature: signature, + message: message, + publicKey: keyPair.publicKey + ), + "\(vector.name): verify" + ) + } + } + + @Test func rejectsATamperedSignature() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + let vector = try #require(fixture.vectors.first { !$0.message.isEmpty }) + + let message = try #require(Data(hex: vector.message)) + let publicKey = try #require(Data(hex: vector.publicKey)) + var signature = try #require(Data(hex: vector.signature)) + signature[0] ^= 0x01 + + #expect(!SharedEd25519.verify(signature: signature, message: message, publicKey: publicKey)) + } + + @Test func generatedPublicKeysAreOnCurve() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + + for vector in fixture.vectors { + let seed = try #require(Data(hex: vector.seed)) + #expect(SharedEd25519.isOnCurve(publicKey: SharedEd25519.keyPair(seed: seed).publicKey)) + } + } + + /// The orlp convention, which callers swapping onto this need to know: the 64-byte private key + /// is the clamped SHA-512 expansion of the seed, *not* `seed || publicKey`. Anything that + /// expects to read the seed back out of it — the RFC 8032 layout, and what libsodium calls a + /// secret key — gets the wrong 32 bytes. + @Test func privateKeyIsTheClampedSha512Expansion() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + let vector = try #require(fixture.vectors.first) + let seed = try #require(Data(hex: vector.seed)) + + var expected = SharedHash.sha512(seed) + expected[0] &= 248 + expected[31] &= 63 + expected[31] |= 64 + + let keyPair = SharedEd25519.keyPair(seed: seed) + + #expect(keyPair.privateKey.count == 64) + #expect(keyPair.privateKey == expected) + #expect(keyPair.privateKey.prefix(32) != seed) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift new file mode 100644 index 0000000000..33cc2742f5 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing + +/// The canonical cross-platform vectors live in the orchestrator repo (`code/test-vectors/`) and are +/// copied here on update, the same arrangement both apps use. Loading them through `Bundle.module` +/// means a fixture the test target failed to copy fails loudly instead of silently skipping. +enum Fixtures { + + static func load(_ name: String, as type: T.Type) throws -> T { + let url = try #require( + Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures"), + "missing fixture \(name).json" + ) + return try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } +} + +extension Data { + + /// The fixtures carry bytes as lowercase hex. + init?(hex: String) { + guard hex.count.isMultiple(of: 2) else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index..