diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index 2a28273337..e50e36421e 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -10,6 +10,7 @@ on: - 'kmp/shared-core/**' - 'libs/codes/kikcode/**' - 'libs/currency-math/**' + - 'libs/device-link/**' - 'libs/encryption/**' - 'libs/reporting/**' - 'libs/analytics-events/**' @@ -72,6 +73,7 @@ jobs: :libs:analytics-events:iosSimulatorArm64Test \ :libs:codes:kikcode:iosSimulatorArm64Test \ :libs:currency-math:discrete-curve:iosSimulatorArm64Test \ + :libs:device-link:iosSimulatorArm64Test \ :libs:encryption:base58:iosSimulatorArm64Test \ :libs:encryption:chat-cipher:iosSimulatorArm64Test \ :libs:encryption:ed25519:iosSimulatorArm64Test \ diff --git a/apps/flipcash/shared/device-link/build.gradle.kts b/apps/flipcash/shared/device-link/build.gradle.kts index d8dc6fe88c..42286691dc 100644 --- a/apps/flipcash/shared/device-link/build.gradle.kts +++ b/apps/flipcash/shared/device-link/build.gradle.kts @@ -4,9 +4,10 @@ plugins { alias(libs.plugins.jetbrains.compose) } -// Link a desktop to a phone over the LAN. The protocol, crypto, state and screens are common; the -// platform pieces are the seed store (jvmMain: OS keyring) and the camera, which the Android app -// passes into the phone screen. +// The link screens and the desktop seed store. The protocol, crypto and phone state are in +// :libs:device-link, which has no UI so iOS can use it through SharedCore. The platform pieces here +// are the seed store (jvmMain: OS keyring) and the camera, which the Android app passes into the +// phone screen. kotlin { android { namespace = "${Gradle.flipcashNamespace}.shared.devicelink" @@ -15,8 +16,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) - implementation(libs.sodium.bindings.kmp) - implementation(libs.ktor.network) + api(project(":libs:device-link")) implementation(libs.qrose) implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.foundation) @@ -27,11 +27,9 @@ kotlin { jvmMain.dependencies { implementation(libs.java.keyring) } - // jvmTest, not commonTest: libsodium's Android binding loads its .so from an APK, so the - // protocol can't run in an Android host test (the same reason chat-cipher has no host test). + // jvmTest only for the keyring test, which needs a desktop OS keyring. jvmTest.dependencies { implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) } } } diff --git a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/InMemorySeedVaultTest.kt b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/InMemorySeedVaultTest.kt new file mode 100644 index 0000000000..641865785a --- /dev/null +++ b/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/InMemorySeedVaultTest.kt @@ -0,0 +1,27 @@ +package com.flipcash.app.devicelink + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class InMemorySeedVaultTest { + private val seed = "c2VlZC1mb3ItdGVzdHMtb25seQ==" + + @Test + fun `vault round-trips through the fake`() = runBlocking { + val vault = InMemorySeedVault() + assertNull(vault.read()) + vault.write(seed) + assertEquals(seed, vault.read()) + vault.clear() + assertNull(vault.read()) + } +} + +internal class InMemorySeedVault : SeedVault { + private var value: String? = null + override suspend fun read() = value + override suspend fun write(entropyB64: String) { value = entropyB64 } + override suspend fun clear() { value = null } +} diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index 3bf85b67e9..fb7260fd73 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { export(project(":libs:currency-math:discrete-curve")) export(project(":libs:reporting")) export(project(":libs:analytics-events")) + export(project(":libs:device-link")) } } @@ -61,6 +62,7 @@ kotlin { api(project(":libs:currency-math:discrete-curve")) api(project(":libs:reporting")) api(project(":libs:analytics-events")) + api(project(":libs:device-link")) } } } diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/DeviceLink.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/DeviceLink.swift new file mode 100644 index 0000000000..f0fceb1cd6 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/DeviceLink.swift @@ -0,0 +1,64 @@ +import Foundation +import SharedCore + +/// The phone side of linking a desktop, run by the shared Kotlin: scan the desktop's QR code, +/// show the six-digit code, and send the seed once the user confirms both screens match. +/// +/// The seed is passed to `confirm(entropyBase64:)` and to nothing else, so nothing leaves the +/// phone before the user has compared the codes. Call `close()` when the screen goes away. +@MainActor +public final class SharedDeviceLink { + + public enum State: Equatable, Sendable { + case scanning + case connecting + case confirm(code: String) + case sending + case linked + case failed(reason: String) + } + + private let session: PhoneLinkSession + + /// `onState` is called on the main actor, starting with `.scanning`. + public init(onState: @escaping @MainActor (State) -> Void) { + session = PhoneLinkSession { state in + let mapped = State(state) + Task { @MainActor in onState(mapped) } + } + } + + /// Hands a scanned QR payload to the link. Returns false if it isn't a desktop link invite. + @discardableResult + public func scanned(_ text: String) -> Bool { + session.scanned(text: text) + } + + /// The user saw the same code on both screens: send the account's base64 entropy. + public func confirm(entropyBase64: String) { + session.confirm(entropyB64: entropyBase64) + } + + /// Drops the pending link, if any, and goes back to scanning. + public func cancel() { + session.cancel() + } + + public func close() { + session.close() + } +} + +extension SharedDeviceLink.State { + + init(_ state: PhoneLinkControllerState) { + switch state { + case let confirm as PhoneLinkControllerStateConfirm: self = .confirm(code: confirm.code) + case let failed as PhoneLinkControllerStateFailed: self = .failed(reason: failed.reason) + case is PhoneLinkControllerStateConnecting: self = .connecting + case is PhoneLinkControllerStateSending: self = .sending + case is PhoneLinkControllerStateLinked: self = .linked + default: self = .scanning + } + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/DeviceLinkTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DeviceLinkTests.swift new file mode 100644 index 0000000000..745f2a69b3 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DeviceLinkTests.swift @@ -0,0 +1,71 @@ +import Foundation +import SharedCore +import Testing +@testable import SharedCoreKit + +@Suite struct DeviceLinkTests { + + struct Fixture: Decodable { + struct Case: Decodable { + let name: String + let invite: String + let desktopPublicKey: String + let desktopSecretKey: String + let phonePublicKey: String + let phoneSecretKey: String + let secret: String + let code: String + let entropy: String + let sealedSeed: String + } + + struct Rejects: Decodable { + let invite: [String] + } + + let cases: [Case] + let rejects: Rejects + } + + @Test func derivesTheVectorCodeAndOpensTheVectorSeed() throws { + let fixture = try Fixtures.load("device_link", as: Fixture.self) + #expect(!fixture.cases.isEmpty) + + for c in fixture.cases { + func hex(_ s: String) throws -> Data { try #require(Data(hex: s), "\(c.name): bad fixture hex") } + + let code = DeviceLinkVectors.shared.phoneCode( + phonePublicKey: try hex(c.phonePublicKey).kotlinByteArray, + phoneSecretKey: try hex(c.phoneSecretKey).kotlinByteArray, + desktopPublicKey: try hex(c.desktopPublicKey).kotlinByteArray, + secret: try hex(c.secret).kotlinByteArray + ) + #expect(code == c.code, "\(c.name): code") + + let opened = DeviceLinkVectors.shared.desktopOpenSeed( + desktopPublicKey: try hex(c.desktopPublicKey).kotlinByteArray, + desktopSecretKey: try hex(c.desktopSecretKey).kotlinByteArray, + phonePublicKey: try hex(c.phonePublicKey).kotlinByteArray, + secret: try hex(c.secret).kotlinByteArray, + sealedSeed: try hex(c.sealedSeed).kotlinByteArray + ) + #expect(opened.map { Data($0) } == (try hex(c.entropy)), "\(c.name): seed") + } + } + + @MainActor + @Test func acceptsVectorInvitesAndRejectsTheRest() throws { + let fixture = try Fixtures.load("device_link", as: Fixture.self) + for c in fixture.cases { + // A fresh link per invite: scanning moves it to connecting, which ignores later scans. + let link = SharedDeviceLink { _ in } + #expect(link.scanned(c.invite), "\(c.name)") + link.close() + } + let link = SharedDeviceLink { _ in } + defer { link.close() } + for invite in fixture.rejects.invite { + #expect(!link.scanned(invite), "\(invite)") + } + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/device_link.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/device_link.json new file mode 100644 index 0000000000..8adadcd7f2 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/device_link.json @@ -0,0 +1,96 @@ +{ + "description": "Phone-to-desktop link over the LAN: invite text, crypto_kx session keys, the six-digit code and the sealed seed and ack frames. See gen_device_link.py.", + "cases": [ + { + "name": "lan-12-words", + "invite": "flipcash-link:1?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "host": "192.168.1.20", + "port": 48123, + "desktopPublicKey": "06f78a532553bf74ecb104df61487d7f0c54195645bbd33a09a57d5b9266de1a", + "desktopSecretKey": "9897dc660c9483807e828e85e8843a8f0b5a71fa7fa7911e57cbcfea27061cd8", + "phonePublicKey": "7cee7d3d7029de3850edcbc8b6b06e44dc4cd8a3b23a936f29c1ee6608b9b828", + "phoneSecretKey": "5ea66d564704cf66d7fa188a0af0fec2729ed6c602944ecb305f6ea4a52a4ece", + "secret": "fec5209236c63c75909dc49c1ab965d7", + "transcript": "666c6970636173682d6c696e6b2d763106f78a532553bf74ecb104df61487d7f0c54195645bbd33a09a57d5b9266de1a7cee7d3d7029de3850edcbc8b6b06e44dc4cd8a3b23a936f29c1ee6608b9b828fec5209236c63c75909dc49c1ab965d7", + "phoneToDesktop": "82b5be733101fa50aaa99d09e6fb4e339c1012ae5e5f524c57dd3f137bd478b4", + "desktopToPhone": "f94df86ba1b61ccf06b32a0147958ba533197c910edfb2de70978837883c9600", + "code": "265450", + "entropy": "41174807528bbed5b2a26ac66f5d3473", + "entropyBase64": "QRdIB1KLvtWyomrGb100cw==", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a", + "sealedAck": "eeebf9fc022a7c16e7845f4b4c86cb24b8b1ddbd0a0486c6aeeb03a2ebb2083b97378785eac41dc89d35fb3bcdf1" + }, + { + "name": "lan-24-words", + "invite": "flipcash-link:1?h=10.0.0.7&p=51000&k=q1O5O-eoLNjq4-zPIlPq_1TEqBfmvNms_cGgiyd59Go&s=iGLauo8IGnv18Ec4pq9-ZQ", + "host": "10.0.0.7", + "port": 51000, + "desktopPublicKey": "ab53b93be7a82cd8eae3eccf2253eaff54c4a817e6bcd9acfdc1a08b2779f46a", + "desktopSecretKey": "237dbe196eaf4b0f5e116e13cd448954a75455cb8bb35bca2da766f42e1ab224", + "phonePublicKey": "cb609a0e9fcc6ca6114647b58bddcd81813e30b4ca919552a132952296c68b05", + "phoneSecretKey": "64418c44786ff49dac046376034841ce9fa8b81e1e57d912c2e210aa7a0ec7d5", + "secret": "8862daba8f081a7bf5f04738a6af7e65", + "transcript": "666c6970636173682d6c696e6b2d7631ab53b93be7a82cd8eae3eccf2253eaff54c4a817e6bcd9acfdc1a08b2779f46acb609a0e9fcc6ca6114647b58bddcd81813e30b4ca919552a132952296c68b058862daba8f081a7bf5f04738a6af7e65", + "phoneToDesktop": "aca9e0e6b37a075c0a7490cccbd2f698e1c306f7b18bc67d7ca6f637b382c154", + "desktopToPhone": "0829fc1606ba542001f64145d13be2df9ca0a314a37a725bd0797eb8dd3ba003", + "code": "368657", + "entropy": "a8cfe6841fc2f081c767df1785e03b0b328033f3463b85331c4fcc506e8120cb", + "entropyBase64": "qM/mhB/C8IHHZ98XheA7CzKAM/NGO4UzHE/MUG6BIMs=", + "sealedSeed": "ab169ff6c65b4ab1a467fbd4e1dac11b393b8cd81438621eb1ef9182b278493c3d651a2e7e6b8ae25a6d287f6b876352d565fbd0a6a50410b8a4efa8380f539ad20b5af05a593cc1", + "sealedAck": "b9e51046ffb8b8eb837ff9fb202809b859ad9724741d368355e222b6deef21fc1072024e900bafe435d621c77675" + }, + { + "name": "ipv6-host", + "invite": "flipcash-link:1?h=fe80::1&p=1&k=GeWVIsqc92ADe5heL2c3qmsUdDdq9-fAQxd6GZ06vVU&s=ng6Vmpi7qI-z4Kb31_Ca2g", + "host": "fe80::1", + "port": 1, + "desktopPublicKey": "19e59522ca9cf760037b985e2f6737aa6b1474376af7e7c043177a199d3abd55", + "desktopSecretKey": "e8205bee98a257fddc3f50aa132a2738605fd0b0ea1c447582f39ae9b1a1c4b0", + "phonePublicKey": "2ce067837feb34304205c112225789df4ff75ce1b217b2714207336de56d4d3c", + "phoneSecretKey": "4e93a941037b76e32cd4c7c1cfe72a6bb927e215e3389d849759bc0d58cbc885", + "secret": "9e0e959a98bba88fb3e0a6f7d7f09ada", + "transcript": "666c6970636173682d6c696e6b2d763119e59522ca9cf760037b985e2f6737aa6b1474376af7e7c043177a199d3abd552ce067837feb34304205c112225789df4ff75ce1b217b2714207336de56d4d3c9e0e959a98bba88fb3e0a6f7d7f09ada", + "phoneToDesktop": "528a2c049fcbf66bb2d6d83f9498d1a7318511834adc9290cabee8bdb76977d3", + "desktopToPhone": "de1896076fc5e343db85c786b4e87570747c7eee2f8bfc0e3740c70293491e8c", + "code": "888568", + "entropy": "9c60110c42f90776a024c4ff2e1fb72c", + "entropyBase64": "nGARDEL5B3agJMT/Lh+3LA==", + "sealedSeed": "184bd6a0c00f21a1940576941450b6be0d84429a81c87b110217422a2eb78426b332a513b9fb746c8d3a0fcb751e930eb400045589a63cb9", + "sealedAck": "44042b75c90046d71bd48f04005894b21eb5f7bee6d523b218d0c6054479e2225a28c95d378d7df7443479f6c4ea" + } + ], + "rejects": { + "seed": [ + { + "why": "a flipped ciphertext byte", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f486c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a" + }, + { + "why": "a flipped tag byte", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357b" + }, + { + "why": "a flipped nonce byte", + "sealedSeed": "6a8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a" + }, + { + "why": "nonce only, no ciphertext", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f9" + }, + { + "why": "a transcript with another QR secret", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a", + "transcript": "666c6970636173682d6c696e6b2d763106f78a532553bf74ecb104df61487d7f0c54195645bbd33a09a57d5b9266de1a7cee7d3d7029de3850edcbc8b6b06e44dc4cd8a3b23a936f29c1ee6608b9b82800000000000000000000000000000000" + } + ], + "invite": [ + "flipcash-link:2?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=192.168.1.20&p=0&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=192.168.1.20&p=65536&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho", + "flipcash-link:1?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=AAAAAAAAAAAAAAAAAAAA", + "https://flipcash.com/link" + ] + } +} diff --git a/libs/device-link/build.gradle.kts b/libs/device-link/build.gradle.kts new file mode 100644 index 0000000000..498b4a41cc --- /dev/null +++ b/libs/device-link/build.gradle.kts @@ -0,0 +1,47 @@ +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree + +plugins { + alias(libs.plugins.flipcash.kmp.library) + alias(libs.plugins.flipcash.kmp.test.fixtures) +} + +// Compiles `src/commonTest/resources` (device_link.json, canonical copy in the orchestrator's +// `test-vectors/`) into a generated `TestFixtures.kt` on `commonTest`, readable from every target. +testFixtures { + packageName = "com.flipcash.app.devicelink" +} + +// The link protocol, crypto and phone-side state, with no UI, so iOS can drive it through +// SharedCore. The Compose screens and the desktop seed store live in :apps:flipcash:shared:device-link. +kotlin { + android { + namespace = "com.flipcash.libs.devicelink" + // No host test: libsodium's Android binding loads its .so from the APK, so it doesn't load + // on a JVM host (chat-cipher has the same limit). commonTest runs on jvm, on the Apple + // targets natively, and on Android as a device test. + withDeviceTestBuilder { + sourceSetTreeName = KotlinSourceSetTree.test.name + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain.dependencies { + // StateFlow is part of PhoneLinkController's API. + api(libs.kotlinx.coroutines.core) + implementation(libs.sodium.bindings.kmp) + implementation(libs.ktor.network) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.serialization.json) + } + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.test.runner) + } + } + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt similarity index 100% rename from apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt rename to libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt diff --git a/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DeviceLinkVectors.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DeviceLinkVectors.kt new file mode 100644 index 0000000000..c098c66b9f --- /dev/null +++ b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DeviceLinkVectors.kt @@ -0,0 +1,32 @@ +package com.flipcash.app.devicelink + +/** + * The link crypto with fixed keys, so a caller outside Kotlin (the SharedCoreKit Swift tests) can + * be checked against `test-vectors/device_link.json`. These are pure functions; the real flow + * draws fresh keys for every link and never calls them. + */ +object DeviceLinkVectors { + + /** The six-digit code the phone shows. */ + fun phoneCode( + phonePublicKey: ByteArray, + phoneSecretKey: ByteArray, + desktopPublicKey: ByteArray, + secret: ByteArray, + ): String { + val session = LinkCrypto.phoneSession(LinkCrypto.Ephemeral(phonePublicKey, phoneSecretKey), desktopPublicKey, secret) + return LinkCrypto.shortCode(session).also { session.wipe() } + } + + /** What the desktop recovers from [sealedSeed], or null if it doesn't authenticate. */ + fun desktopOpenSeed( + desktopPublicKey: ByteArray, + desktopSecretKey: ByteArray, + phonePublicKey: ByteArray, + secret: ByteArray, + sealedSeed: ByteArray, + ): ByteArray? { + val session = LinkCrypto.desktopSession(LinkCrypto.Ephemeral(desktopPublicKey, desktopSecretKey), phonePublicKey, secret) + return LinkCrypto.open(session.phoneToDesktop, sealedSeed, session.transcript).also { session.wipe() } + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt similarity index 100% rename from apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt rename to libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt similarity index 90% rename from apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt rename to libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt index 6663337bce..4ef804cbb8 100644 --- a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt +++ b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt @@ -84,11 +84,17 @@ internal object LinkCrypto { fun seal(key: ByteArray, plaintext: ByteArray, transcript: ByteArray): ByteArray { ensureSodium() - val nonce = LibsodiumRandom.buf(NONCE_BYTES) + return seal(key, plaintext, transcript, LibsodiumRandom.buf(NONCE_BYTES).toByteArray()) + } + + /** Fixed-nonce form for the vectors only; production always draws a fresh nonce. */ + internal fun seal(key: ByteArray, plaintext: ByteArray, transcript: ByteArray, nonce: ByteArray): ByteArray { + require(nonce.size == NONCE_BYTES) + ensureSodium() val ct = AuthenticatedEncryptionWithAssociatedData.xChaCha20Poly1305IetfEncrypt( - plaintext.toUByteArray(), transcript.toUByteArray(), nonce, key.toUByteArray(), + plaintext.toUByteArray(), transcript.toUByteArray(), nonce.toUByteArray(), key.toUByteArray(), ) - return nonce.toByteArray() + ct.toByteArray() + return nonce + ct.toByteArray() } /** Null when the ciphertext was tampered with or sealed under another key or transcript. */ diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt similarity index 100% rename from apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt rename to libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt similarity index 100% rename from apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt rename to libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt diff --git a/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkSession.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkSession.kt new file mode 100644 index 0000000000..59e154b891 --- /dev/null +++ b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkSession.kt @@ -0,0 +1,49 @@ +package com.flipcash.app.devicelink + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * [PhoneLinkController] for callers that can't collect a flow or call a suspend function, which in + * practice means Swift through SharedCore. It owns its own scope, so call [close] when the screen + * goes away. + * + * The seed comes from the caller at [confirm], not from a store: iOS already holds it. It is kept + * only until the controller reads it, which happens after the user has confirmed the codes match, + * so the read-after-confirm order of [PhoneLinkController] still holds. + * + * [onState] is called on a background thread; Swift callers hop to the main actor themselves. + */ +class PhoneLinkSession(private val onState: (PhoneLinkController.State) -> Unit) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var entropy: String? = null + private val controller = PhoneLinkController(scope) { entropy.also { entropy = null } } + + init { + scope.launch { controller.state.collect { onState(it) } } + } + + /** Hands a scanned QR payload to the flow. Returns false if it isn't a link invite. */ + fun scanned(text: String): Boolean { + if (LinkInvite.parse(text) == null) return false + controller.onScanned(text) + return true + } + + /** The user saw the same code on both screens: send [entropyB64] to the desktop. */ + fun confirm(entropyB64: String) { + entropy = entropyB64 + controller.confirm() + } + + /** Drops the pending link, if any, and goes back to scanning. */ + fun cancel() = controller.cancel() + + fun close() { + entropy = null + scope.cancel() + } +} diff --git a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt similarity index 98% rename from apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt rename to libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt index 2f385fa06b..b141be08b6 100644 --- a/apps/flipcash/shared/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt +++ b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt @@ -12,7 +12,7 @@ import kotlin.time.Duration.Companion.seconds * The phone side of a link. [connect] dials the invite's address and returns the code to compare; * the seed only leaves the phone through [Pending.confirm], after the user says the codes match. */ -object PhoneLinker { +internal object PhoneLinker { suspend fun connect(invite: LinkInvite): Result { val selector = SelectorManager(Dispatchers.Default) val phone = LinkCrypto.ephemeral() diff --git a/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/DeviceLinkVectorTest.kt b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/DeviceLinkVectorTest.kt new file mode 100644 index 0000000000..a3bdb5e7a4 --- /dev/null +++ b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/DeviceLinkVectorTest.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.devicelink + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** GATE: reproduces every vector in `device_link.json` (canonical: test-vectors/device_link.json). */ +class DeviceLinkVectorTest { + private val root = Json.parseToJsonElement(readTestResource("device_link.json")).jsonObject + private val cases = root["cases"]!!.jsonArray.map { it.jsonObject } + private val rejects = root["rejects"]!!.jsonObject + + private fun JsonObject.str(key: String) = getValue(key).jsonPrimitive.content + private fun JsonObject.hex(key: String) = str(key).hexToByteArray() + + private fun JsonObject.sessions(): Pair { + val desktop = LinkCrypto.Ephemeral(hex("desktopPublicKey"), hex("desktopSecretKey")) + val phone = LinkCrypto.Ephemeral(hex("phonePublicKey"), hex("phoneSecretKey")) + return LinkCrypto.phoneSession(phone, desktop.publicKey, hex("secret")) to + LinkCrypto.desktopSession(desktop, phone.publicKey, hex("secret")) + } + + @Test + fun inviteEncodesAndParses() = cases.forEach { c -> + val invite = LinkInvite(c.str("host"), c.getValue("port").jsonPrimitive.int, c.hex("desktopPublicKey"), c.hex("secret")) + assertEquals(c.str("invite"), invite.encode(), c.str("name")) + val parsed = assertNotNull(LinkInvite.parse(c.str("invite")), c.str("name")) + assertEquals(c.str("host"), parsed.host) + assertContentEquals(c.hex("desktopPublicKey"), parsed.publicKey) + assertContentEquals(c.hex("secret"), parsed.secret) + } + + @Test + fun bothSidesDeriveTheVectorKeysAndCode() = cases.forEach { c -> + val (phone, desktop) = c.sessions() + for (s in listOf(phone, desktop)) { + assertContentEquals(c.hex("transcript"), s.transcript, c.str("name")) + assertContentEquals(c.hex("phoneToDesktop"), s.phoneToDesktop, c.str("name")) + assertContentEquals(c.hex("desktopToPhone"), s.desktopToPhone, c.str("name")) + assertEquals(c.str("code"), LinkCrypto.shortCode(s), c.str("name")) + } + } + + @Test + fun sealedFramesMatchAndOpen() = cases.forEach { c -> + val (phone, desktop) = c.sessions() + val seed = c.hex("sealedSeed") + val ack = c.hex("sealedAck") + assertContentEquals(seed, LinkCrypto.seal(phone.phoneToDesktop, c.hex("entropy"), phone.transcript, seed.copyOf(24))) + assertContentEquals(ack, LinkCrypto.seal(desktop.desktopToPhone, DesktopLinkHost.ACK_BODY, desktop.transcript, ack.copyOf(24))) + val opened = assertNotNull(LinkCrypto.open(desktop.phoneToDesktop, seed, desktop.transcript)) + assertEquals(c.str("entropyBase64"), Base64.encode(opened)) + assertContentEquals(DesktopLinkHost.ACK_BODY, LinkCrypto.open(phone.desktopToPhone, ack, phone.transcript)) + } + + @Test + fun theSwiftFacingEntryPointsMatchTheVectors() = cases.forEach { c -> + assertEquals( + c.str("code"), + DeviceLinkVectors.phoneCode(c.hex("phonePublicKey"), c.hex("phoneSecretKey"), c.hex("desktopPublicKey"), c.hex("secret")), + ) + val opened = DeviceLinkVectors.desktopOpenSeed( + c.hex("desktopPublicKey"), c.hex("desktopSecretKey"), c.hex("phonePublicKey"), c.hex("secret"), c.hex("sealedSeed"), + ) + assertContentEquals(c.hex("entropy"), opened) + } + + @Test + fun rejectsTamperedSeeds() { + val c = cases.first() + val (_, desktop) = c.sessions() + rejects.getValue("seed").jsonArray.map { it.jsonObject }.forEach { r -> + val transcript = r["transcript"]?.let { r.hex("transcript") } ?: desktop.transcript + assertNull(LinkCrypto.open(desktop.phoneToDesktop, r.hex("sealedSeed"), transcript), r.str("why")) + } + } + + @Test + fun rejectsBadInvites() = rejects.getValue("invite").jsonArray.forEach { + assertNull(LinkInvite.parse(it.jsonPrimitive.content), it.jsonPrimitive.content) + } +} diff --git a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt similarity index 84% rename from apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt rename to libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt index b7c19b74b8..998bde85cd 100644 --- a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt +++ b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt @@ -43,7 +43,7 @@ class LinkProtocolTest { } @Test - fun `codes match and the seed arrives`() { + fun codesMatchAndTheSeedArrives() { var phoneCode: String? = null val states = runLink { invite -> val pending = PhoneLinker.connect(invite).getOrThrow() @@ -57,13 +57,13 @@ class LinkProtocolTest { } @Test - fun `cancelling on the phone sends nothing`() { + fun cancellingOnThePhoneSendsNothing() { val states = runLink { invite -> PhoneLinker.connect(invite).getOrThrow().cancel() } assertEquals(DesktopLinkHost.State.Failed("Cancelled on the phone"), states.last()) } @Test - fun `a swapped phone key changes the desktop's code`() { + fun aSwappedPhoneKeyChangesTheDesktopSCode() { // A man in the middle replaces the phone's key on the way to the desktop with one of its own. var phoneCode: String? = null val states = runLink { invite -> @@ -85,7 +85,7 @@ class LinkProtocolTest { } @Test - fun `tampered seed is rejected`() { + fun tamperedSeedIsRejected() { val states = runLink { invite -> val phone = LinkCrypto.ephemeral() val session = LinkCrypto.phoneSession(phone, invite.publicKey, invite.secret) @@ -103,7 +103,7 @@ class LinkProtocolTest { } @Test - fun `a device without the QR secret can't link`() { + fun aDeviceWithoutTheQRSecretCanTLink() { val states = runLink { invite -> val pending = PhoneLinker.connect( LinkInvite(invite.host, invite.port, invite.publicKey, LinkCrypto.secret()), @@ -114,7 +114,7 @@ class LinkProtocolTest { } @Test - fun `invite round-trips and rejects junk`() { + fun inviteRoundTripsAndRejectsJunk() { val invite = LinkInvite("192.168.1.20", 50123, ByteArray(32) { it.toByte() }, ByteArray(16) { 7 }) val parsed = LinkInvite.parse(invite.encode())!! assertEquals(invite.host, parsed.host) @@ -126,17 +126,7 @@ class LinkProtocolTest { } @Test - fun `vault round-trips through the fake`() = runBlocking { - val vault = InMemorySeedVault() - assertNull(vault.read()) - vault.write(seed) - assertEquals(seed, vault.read()) - vault.clear() - assertNull(vault.read()) - } - - @Test - fun `the phone controller reads the seed only after confirm`() { + fun thePhoneControllerReadsTheSeedOnlyAfterConfirm() { var seedReads = 0 val states = runLink { invite -> kotlinx.coroutines.coroutineScope { @@ -154,11 +144,22 @@ class LinkProtocolTest { assertEquals(1, seedReads) assertEquals(seed, assertIs(states.last()).entropyB64) } -} -internal class InMemorySeedVault : SeedVault { - private var value: String? = null - override suspend fun read() = value - override suspend fun write(entropyB64: String) { value = entropyB64 } - override suspend fun clear() { value = null } + @Test + fun theSwiftFacingSessionLinksWithTheSeedGivenAtConfirm() { + val states = runLink { invite -> + val seen = Channel(Channel.UNLIMITED) + val session = PhoneLinkSession { seen.trySend(it) } + try { + assertEquals(false, session.scanned("not an invite")) + assertTrue(session.scanned(invite.encode())) + withTimeout(10_000) { while (seen.receive() !is PhoneLinkController.State.Confirm) Unit } + session.confirm(seed) + withTimeout(10_000) { while (seen.receive() != PhoneLinkController.State.Linked) Unit } + } finally { + session.close() + } + } + assertEquals(seed, assertIs(states.last()).entropyB64) + } } diff --git a/libs/device-link/src/commonTest/resources/device_link.json b/libs/device-link/src/commonTest/resources/device_link.json new file mode 100644 index 0000000000..8adadcd7f2 --- /dev/null +++ b/libs/device-link/src/commonTest/resources/device_link.json @@ -0,0 +1,96 @@ +{ + "description": "Phone-to-desktop link over the LAN: invite text, crypto_kx session keys, the six-digit code and the sealed seed and ack frames. See gen_device_link.py.", + "cases": [ + { + "name": "lan-12-words", + "invite": "flipcash-link:1?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "host": "192.168.1.20", + "port": 48123, + "desktopPublicKey": "06f78a532553bf74ecb104df61487d7f0c54195645bbd33a09a57d5b9266de1a", + "desktopSecretKey": "9897dc660c9483807e828e85e8843a8f0b5a71fa7fa7911e57cbcfea27061cd8", + "phonePublicKey": "7cee7d3d7029de3850edcbc8b6b06e44dc4cd8a3b23a936f29c1ee6608b9b828", + "phoneSecretKey": "5ea66d564704cf66d7fa188a0af0fec2729ed6c602944ecb305f6ea4a52a4ece", + "secret": "fec5209236c63c75909dc49c1ab965d7", + "transcript": "666c6970636173682d6c696e6b2d763106f78a532553bf74ecb104df61487d7f0c54195645bbd33a09a57d5b9266de1a7cee7d3d7029de3850edcbc8b6b06e44dc4cd8a3b23a936f29c1ee6608b9b828fec5209236c63c75909dc49c1ab965d7", + "phoneToDesktop": "82b5be733101fa50aaa99d09e6fb4e339c1012ae5e5f524c57dd3f137bd478b4", + "desktopToPhone": "f94df86ba1b61ccf06b32a0147958ba533197c910edfb2de70978837883c9600", + "code": "265450", + "entropy": "41174807528bbed5b2a26ac66f5d3473", + "entropyBase64": "QRdIB1KLvtWyomrGb100cw==", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a", + "sealedAck": "eeebf9fc022a7c16e7845f4b4c86cb24b8b1ddbd0a0486c6aeeb03a2ebb2083b97378785eac41dc89d35fb3bcdf1" + }, + { + "name": "lan-24-words", + "invite": "flipcash-link:1?h=10.0.0.7&p=51000&k=q1O5O-eoLNjq4-zPIlPq_1TEqBfmvNms_cGgiyd59Go&s=iGLauo8IGnv18Ec4pq9-ZQ", + "host": "10.0.0.7", + "port": 51000, + "desktopPublicKey": "ab53b93be7a82cd8eae3eccf2253eaff54c4a817e6bcd9acfdc1a08b2779f46a", + "desktopSecretKey": "237dbe196eaf4b0f5e116e13cd448954a75455cb8bb35bca2da766f42e1ab224", + "phonePublicKey": "cb609a0e9fcc6ca6114647b58bddcd81813e30b4ca919552a132952296c68b05", + "phoneSecretKey": "64418c44786ff49dac046376034841ce9fa8b81e1e57d912c2e210aa7a0ec7d5", + "secret": "8862daba8f081a7bf5f04738a6af7e65", + "transcript": "666c6970636173682d6c696e6b2d7631ab53b93be7a82cd8eae3eccf2253eaff54c4a817e6bcd9acfdc1a08b2779f46acb609a0e9fcc6ca6114647b58bddcd81813e30b4ca919552a132952296c68b058862daba8f081a7bf5f04738a6af7e65", + "phoneToDesktop": "aca9e0e6b37a075c0a7490cccbd2f698e1c306f7b18bc67d7ca6f637b382c154", + "desktopToPhone": "0829fc1606ba542001f64145d13be2df9ca0a314a37a725bd0797eb8dd3ba003", + "code": "368657", + "entropy": "a8cfe6841fc2f081c767df1785e03b0b328033f3463b85331c4fcc506e8120cb", + "entropyBase64": "qM/mhB/C8IHHZ98XheA7CzKAM/NGO4UzHE/MUG6BIMs=", + "sealedSeed": "ab169ff6c65b4ab1a467fbd4e1dac11b393b8cd81438621eb1ef9182b278493c3d651a2e7e6b8ae25a6d287f6b876352d565fbd0a6a50410b8a4efa8380f539ad20b5af05a593cc1", + "sealedAck": "b9e51046ffb8b8eb837ff9fb202809b859ad9724741d368355e222b6deef21fc1072024e900bafe435d621c77675" + }, + { + "name": "ipv6-host", + "invite": "flipcash-link:1?h=fe80::1&p=1&k=GeWVIsqc92ADe5heL2c3qmsUdDdq9-fAQxd6GZ06vVU&s=ng6Vmpi7qI-z4Kb31_Ca2g", + "host": "fe80::1", + "port": 1, + "desktopPublicKey": "19e59522ca9cf760037b985e2f6737aa6b1474376af7e7c043177a199d3abd55", + "desktopSecretKey": "e8205bee98a257fddc3f50aa132a2738605fd0b0ea1c447582f39ae9b1a1c4b0", + "phonePublicKey": "2ce067837feb34304205c112225789df4ff75ce1b217b2714207336de56d4d3c", + "phoneSecretKey": "4e93a941037b76e32cd4c7c1cfe72a6bb927e215e3389d849759bc0d58cbc885", + "secret": "9e0e959a98bba88fb3e0a6f7d7f09ada", + "transcript": "666c6970636173682d6c696e6b2d763119e59522ca9cf760037b985e2f6737aa6b1474376af7e7c043177a199d3abd552ce067837feb34304205c112225789df4ff75ce1b217b2714207336de56d4d3c9e0e959a98bba88fb3e0a6f7d7f09ada", + "phoneToDesktop": "528a2c049fcbf66bb2d6d83f9498d1a7318511834adc9290cabee8bdb76977d3", + "desktopToPhone": "de1896076fc5e343db85c786b4e87570747c7eee2f8bfc0e3740c70293491e8c", + "code": "888568", + "entropy": "9c60110c42f90776a024c4ff2e1fb72c", + "entropyBase64": "nGARDEL5B3agJMT/Lh+3LA==", + "sealedSeed": "184bd6a0c00f21a1940576941450b6be0d84429a81c87b110217422a2eb78426b332a513b9fb746c8d3a0fcb751e930eb400045589a63cb9", + "sealedAck": "44042b75c90046d71bd48f04005894b21eb5f7bee6d523b218d0c6054479e2225a28c95d378d7df7443479f6c4ea" + } + ], + "rejects": { + "seed": [ + { + "why": "a flipped ciphertext byte", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f486c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a" + }, + { + "why": "a flipped tag byte", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357b" + }, + { + "why": "a flipped nonce byte", + "sealedSeed": "6a8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a" + }, + { + "why": "nonce only, no ciphertext", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f9" + }, + { + "why": "a transcript with another QR secret", + "sealedSeed": "6b8b996d6558ebd2e59af43cf171f158e28317fb33eb01f94b6a7788410f496c75c49131e7d02b7f378736d4cc19dcd3c6159b278d5b357a", + "transcript": "666c6970636173682d6c696e6b2d763106f78a532553bf74ecb104df61487d7f0c54195645bbd33a09a57d5b9266de1a7cee7d3d7029de3850edcbc8b6b06e44dc4cd8a3b23a936f29c1ee6608b9b82800000000000000000000000000000000" + } + ], + "invite": [ + "flipcash-link:2?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=192.168.1.20&p=0&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=192.168.1.20&p=65536&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=_sUgkjbGPHWQncScGrll1w", + "flipcash-link:1?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho", + "flipcash-link:1?h=192.168.1.20&p=48123&k=BveKUyVTv3TssQTfYUh9fwxUGVZFu9M6CaV9W5Jm3ho&s=AAAAAAAAAAAAAAAAAAAA", + "https://flipcash.com/link" + ] + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 567d7bca20..d57728a4be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -213,6 +213,7 @@ include( ":libs:currency-math", ":libs:currency-math:discrete-curve", ":libs:datetime", + ":libs:device-link", ":libs:emojis", ":libs:encryption:base58", ":libs:encryption:chat-cipher", @@ -342,10 +343,12 @@ val kmpUnitTestModules = setOf( // ed25519 and mnemonic excluded: both pull in the JNI-backed Ed25519Kmp Android actual for their // host vector tests, which can't load on the Linux CI runner (see kmpUnitTestModules). chat-cipher // has no host test for the same reason, plus libsodium's Android binding; its vectors run on the -// iOS simulator in the SharedCore tests workflow and as a device test. +// iOS simulator in the SharedCore tests workflow and as a device test. device-link is the same: +// its protocol tests need libsodium, so they run on jvm and the iOS simulator. val noUnitTestModules = setOf( ":apps:flipcash:benchmark", ":kmp:shared-core", + ":libs:device-link", ":libs:encryption:chat-cipher", ":libs:encryption:ed25519", ":libs:encryption:mnemonic",