Skip to content

Commit c5d1d32

Browse files
authored
feat(shared-core): expose the crypto primitives through SharedCoreKit (#1390)
1 parent 07499e4 commit c5d1d32

26 files changed

Lines changed: 696 additions & 50 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
name: SharedCore tests
2+
3+
# The Swift facade in `kmp/shared-core/spm` and the Kotlin/Native halves of the modules it
4+
# exports both need Xcode, so neither runs in the Ubuntu `CI` workflow. This is the macOS lane
5+
# that covers them. Path-filtered rather than folded into `CI`: a macOS runner is expensive and
6+
# nothing outside these directories can change the answer.
7+
on:
8+
pull_request:
9+
paths:
10+
- 'kmp/shared-core/**'
11+
- 'libs/codes/kikcode/**'
12+
- 'libs/encryption/**'
13+
- 'gradle/libs.versions.toml'
14+
- '.github/workflows/shared-core-tests.yml'
15+
16+
concurrency:
17+
group: shared-core-tests-${{ github.head_ref || github.run_id }}
18+
cancel-in-progress: true
19+
20+
env:
21+
CI: true
22+
23+
jobs:
24+
shared-core-tests:
25+
name: Run SharedCore tests
26+
runs-on: macos-15
27+
steps:
28+
- uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 1
31+
32+
# Cheap supply-chain guard: fails the run if gradle/wrapper/gradle-wrapper.jar
33+
# is not a byte-for-byte match for a jar published by Gradle.
34+
- name: Validate Gradle wrapper
35+
uses: gradle/actions/wrapper-validation@v4
36+
37+
- name: Setup Java env
38+
uses: actions/setup-java@v3
39+
with:
40+
java-version: '21'
41+
distribution: 'corretto'
42+
cache: 'gradle'
43+
44+
# Gradle configures every project in the build, and the Android app applies the secrets
45+
# plugin, which fails configuration outright when `local.properties` is absent. This lane
46+
# only builds the KMP modules — nothing here compiles the app or talks to any of these
47+
# services — so obviously-fake values are enough to get through configuration. Same
48+
# placeholders the publish workflow writes.
49+
- name: Write placeholder local.properties
50+
run: |
51+
set -euo pipefail
52+
{
53+
echo 'BUGSNAG_API_KEY="00000000000000000000000000000000"'
54+
echo 'GOOGLE_CLOUD_PROJECT_NUMBER=000000000000'
55+
echo 'MIXPANEL_API_KEY="00000000000000000000000000000000"'
56+
echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000'
57+
} > ./local.properties
58+
59+
# commonTest runs on the JVM in the Ubuntu lane, which says nothing about the Kotlin/Native
60+
# halves — ed25519's actual is cinterop over vendored C, and the rest go through a different
61+
# compiler backend. These compile anyway as part of the XCFramework below; running them is
62+
# nearly free from here.
63+
- name: Run the Kotlin tests on the iOS simulator target
64+
run: |
65+
./gradlew \
66+
:libs:codes:kikcode:iosSimulatorArm64Test \
67+
:libs:encryption:base58:iosSimulatorArm64Test \
68+
:libs:encryption:ed25519:iosSimulatorArm64Test \
69+
:libs:encryption:hmac:iosSimulatorArm64Test \
70+
:libs:encryption:sha256:iosSimulatorArm64Test \
71+
:libs:encryption:sha512:iosSimulatorArm64Test
72+
73+
# The facade suite has to run against the Kotlin in this checkout, not against the last
74+
# published release, or a facade written for an unreleased Kotlin change is untested until
75+
# after the release that would have caught it.
76+
- name: Assemble the XCFramework
77+
run: ./gradlew :kmp:shared-core:assembleSharedCoreReleaseXCFramework
78+
79+
# Named simulators come and go with the runner image, so pick whatever iPhone this one has
80+
# rather than pinning a name that silently stops existing.
81+
- name: Pick a simulator
82+
id: sim
83+
run: |
84+
set -euo pipefail
85+
udid=$(xcrun simctl list devices available -j \
86+
| 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')))")
87+
echo "udid=$udid" >> "$GITHUB_OUTPUT"
88+
echo "Using simulator $udid"
89+
90+
# The package is iOS-only and the XCFramework has no host slice, so `swift test` cannot
91+
# run this suite — it goes through a simulator destination. FLIPCASH_SHARED_CORE_LOCAL is
92+
# the same override the local loop uses; see docs/proto-local-development.md's sibling,
93+
# shared-core-local-development.md, in the orchestrator repo.
94+
- name: Run the SharedCoreKit tests
95+
working-directory: kmp/shared-core/spm
96+
env:
97+
FLIPCASH_SHARED_CORE_LOCAL: ${{ github.workspace }}
98+
run: |
99+
set -euo pipefail
100+
xcodebuild test \
101+
-scheme SharedCore \
102+
-destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \
103+
-clonedSourcePackagesDirPath "$RUNNER_TEMP/spm" \
104+
-resultBundlePath "$RUNNER_TEMP/SharedCoreKit.xcresult" \
105+
-quiet
106+
107+
# `-quiet` prints nothing on success, so a run that executed no tests at all reads exactly
108+
# like a run that passed all of them — the one failure mode a gate must not have. Print the
109+
# counts and fail on zero.
110+
- name: Report the test counts
111+
if: always()
112+
run: |
113+
set -euo pipefail
114+
bundle="$RUNNER_TEMP/SharedCoreKit.xcresult"
115+
if [ ! -d "$bundle" ]; then
116+
echo "no result bundle at $bundle — the test step never got as far as running"
117+
exit 1
118+
fi
119+
xcrun xcresulttool get test-results summary --path "$bundle" --format json \
120+
> "$RUNNER_TEMP/summary.json"
121+
python3 -c "
122+
import json, sys
123+
summary = json.load(open(sys.argv[1]))
124+
total = summary.get('totalTestCount', 0)
125+
print(total, 'tests:',
126+
summary.get('passedTests', 0), 'passed,',
127+
summary.get('failedTests', 0), 'failed,',
128+
summary.get('skippedTests', 0), 'skipped')
129+
if total == 0:
130+
sys.exit('the suite executed no tests')
131+
" "$RUNNER_TEMP/summary.json"

‎kmp/shared-core/spm/Package.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ let package = Package(
7373
),
7474
.testTarget(
7575
name: "SharedCoreKitTests",
76-
dependencies: ["SharedCoreKit"]
76+
dependencies: ["SharedCoreKit"],
77+
resources: [.copy("Fixtures")]
7778
),
7879
]
7980
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Foundation
2+
import SharedCore
3+
4+
/// Bitcoin/Solana Base58, computed by the shared Kotlin.
5+
public enum Base58 {
6+
7+
public static func encode(_ data: Data) -> String {
8+
SharedCore.Base58.shared.encode(input: data.kotlinByteArray)
9+
}
10+
11+
/// Returns `nil` for input outside the alphabet. Kotlin throws there, but the throw carries
12+
/// nothing a caller can act on beyond "not Base58".
13+
public static func decode(_ string: String) -> Data? {
14+
guard let bytes = try? SharedCore.Base58.shared.decode(input: string) else { return nil }
15+
return Data(bytes)
16+
}
17+
}

‎kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,16 @@ extension Data {
1212
}
1313
return array
1414
}
15+
16+
/// The reverse copy, for the exported functions that hand bytes back. Kotlin bytes are signed,
17+
/// so anything above 0x7F comes across negative and has to be reinterpreted rather than
18+
/// converted.
19+
init(_ array: KotlinByteArray) {
20+
var bytes = [UInt8]()
21+
bytes.reserveCapacity(Int(array.size))
22+
for index in 0..<array.size {
23+
bytes.append(UInt8(bitPattern: array.get(index: index)))
24+
}
25+
self.init(bytes)
26+
}
1527
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
import SharedCore
3+
4+
/// Ed25519 signing, computed by the shared Kotlin over the vendored orlp/ed25519 C.
5+
public enum SharedEd25519 {
6+
7+
/// Sizes follow the orlp convention the C library uses: the private key is `seed || publicKey`.
8+
public struct KeyPair: Equatable {
9+
public let publicKey: Data
10+
public let privateKey: Data
11+
12+
public init(publicKey: Data, privateKey: Data) {
13+
self.publicKey = publicKey
14+
self.privateKey = privateKey
15+
}
16+
}
17+
18+
public static func keyPair(seed: Data) -> KeyPair {
19+
let pair = Ed25519Kmp.shared.createKeyPair(seed: seed.kotlinByteArray)
20+
return KeyPair(publicKey: Data(pair.publicKey), privateKey: Data(pair.privateKey))
21+
}
22+
23+
public static func sign(message: Data, keyPair: KeyPair) -> Data {
24+
Data(Ed25519Kmp.shared.sign(
25+
message: message.kotlinByteArray,
26+
publicKey: keyPair.publicKey.kotlinByteArray,
27+
privateKey: keyPair.privateKey.kotlinByteArray
28+
))
29+
}
30+
31+
public static func verify(signature: Data, message: Data, publicKey: Data) -> Bool {
32+
Ed25519Kmp.shared.verify(
33+
signature: signature.kotlinByteArray,
34+
message: message.kotlinByteArray,
35+
publicKey: publicKey.kotlinByteArray
36+
)
37+
}
38+
39+
public static func isOnCurve(publicKey: Data) -> Bool {
40+
Ed25519Kmp.shared.onCurve(publicKey: publicKey.kotlinByteArray)
41+
}
42+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Foundation
2+
import SharedCore
3+
4+
/// The hash and MAC primitives, computed by the shared Kotlin.
5+
///
6+
/// Namespaced rather than free functions so the call site says where the answer came from while
7+
/// callers still hold their own `Sha256`/`Hmac` types during the swap.
8+
public enum SharedHash {
9+
10+
public static func sha256(_ data: Data) -> Data {
11+
Data(Sha256Hash.companion.hash(input: data.kotlinByteArray))
12+
}
13+
14+
/// SHA-256(SHA-256(data)), the double hash Solana addresses and Bitcoin-derived formats use.
15+
public static func sha256Twice(_ data: Data) -> Data {
16+
Data(Sha256Hash.companion.hashTwice(input: data.kotlinByteArray))
17+
}
18+
19+
public static func sha512(_ data: Data) -> Data {
20+
Data(Sha512.shared.hash(input: data.kotlinByteArray))
21+
}
22+
23+
public static func hmacSHA256(key: Data, message: Data) -> Data {
24+
Data(Hmac.shared.hmac(
25+
algorithm: "HmacSHA256",
26+
key: key.kotlinByteArray,
27+
message: message.kotlinByteArray
28+
))
29+
}
30+
31+
public static func hmacSHA512(key: Data, message: Data) -> Data {
32+
Data(Hmac.shared.hmac(
33+
algorithm: "HmacSHA512",
34+
key: key.kotlinByteArray,
35+
message: message.kotlinByteArray
36+
))
37+
}
38+
39+
/// PBKDF2-HMAC-SHA512. Takes strings rather than bytes because that is the shape BIP-39 needs
40+
/// and the shape the Kotlin exports.
41+
public static func pbkdf2SHA512(
42+
password: String,
43+
salt: String,
44+
iterations: Int,
45+
keyLength: Int
46+
) -> Data {
47+
Data(PBKDF2SHA512.shared.derive(
48+
P: password,
49+
S: salt,
50+
c: Int32(iterations),
51+
dkLen: Int32(keyLength)
52+
))
53+
}
54+
}

‎kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,5 @@ import SharedCore
44
public enum SharedCoreInfo {
55

66
/// The `:kmp:shared-core` version the linked XCFramework was published at.
7-
// Unqualified on purpose: inside this module the name `SharedCore` resolves to the
8-
// Kotlin object, not the framework it lives in.
9-
public static var version: String { SharedCore.shared.version }
7+
public static var version: String { SharedCore.SharedCoreBuild.shared.version }
108
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
import Testing
3+
@testable import SharedCoreKit
4+
5+
@Suite struct Base58Tests {
6+
7+
struct Fixture: Decodable {
8+
struct Vector: Decodable {
9+
let name: String
10+
let bytes: String
11+
let base58: String
12+
}
13+
14+
let vectors: [Vector]
15+
}
16+
17+
@Test func matchesTheCrossPlatformVectors() throws {
18+
let fixture = try Fixtures.load("base58", as: Fixture.self)
19+
#expect(fixture.vectors.count == 7)
20+
21+
for vector in fixture.vectors {
22+
let bytes = try #require(Data(hex: vector.bytes), "\(vector.name): bad fixture hex")
23+
#expect(Base58.encode(bytes) == vector.base58, "\(vector.name): encode")
24+
#expect(Base58.decode(vector.base58) == bytes, "\(vector.name): decode")
25+
}
26+
}
27+
28+
@Test func rejectsCharactersOutsideTheAlphabet() {
29+
// 0, I, O and l are the four excluded from the Bitcoin alphabet.
30+
#expect(Base58.decode("0OIl") == nil)
31+
}
32+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Foundation
2+
import Testing
3+
import SharedCore
4+
@testable import SharedCoreKit
5+
6+
/// Every facade in this module moves bytes across the Kotlin boundary in both directions, so the
7+
/// two copies get their own tests rather than being covered incidentally by whichever facade test
8+
/// happens to run first.
9+
@Suite struct DataBridgeTests {
10+
11+
@Test func roundTripsThroughKotlin() {
12+
let original = Data([0x00, 0x01, 0x7F, 0x80, 0xFF])
13+
#expect(Data(original.kotlinByteArray) == original)
14+
}
15+
16+
@Test func roundTripsEmpty() {
17+
#expect(Data(Data().kotlinByteArray) == Data())
18+
}
19+
20+
@Test func roundTripsEveryByteValue() {
21+
let original = Data((0...255).map { UInt8($0) })
22+
#expect(Data(original.kotlinByteArray) == original)
23+
}
24+
}

0 commit comments

Comments
 (0)