From 3b1580d9d149b4eee573748b9f97746ae2249bfe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 18:53:07 -0400 Subject: [PATCH 1/4] refactor(device-link): move the link protocol into a UI-free :libs:device-link iOS needs the protocol through SharedCore, which can't take Compose or :ui:theme. The protocol, crypto, invite, PhoneLinker and PhoneLinkController move as-is, same package, so callers in apps/desktop and features/myaccount don't change. :apps:flipcash:shared:device-link keeps the screens, SeedVault and the keyring store. SeedVault isn't UI, but it is desktop storage; iOS passes its seed in rather than reading a vault. The fake-vault test moves with SeedVault into its own file. --- .../shared/device-link/build.gradle.kts | 14 +++++----- .../app/devicelink/InMemorySeedVaultTest.kt | 27 +++++++++++++++++++ libs/device-link/build.gradle.kts | 24 +++++++++++++++++ .../app/devicelink/DesktopLinkHost.kt | 0 .../flipcash/app/devicelink/LinkChannel.kt | 0 .../com/flipcash/app/devicelink/LinkCrypto.kt | 0 .../com/flipcash/app/devicelink/LinkInvite.kt | 0 .../app/devicelink/PhoneLinkController.kt | 0 .../flipcash/app/devicelink/PhoneLinker.kt | 0 .../app/devicelink/LinkProtocolTest.kt | 17 ------------ settings.gradle.kts | 5 +++- 11 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/InMemorySeedVaultTest.kt create mode 100644 libs/device-link/build.gradle.kts rename {apps/flipcash/shared => libs}/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DesktopLinkHost.kt (100%) rename {apps/flipcash/shared => libs}/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkChannel.kt (100%) rename {apps/flipcash/shared => libs}/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt (100%) rename {apps/flipcash/shared => libs}/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkInvite.kt (100%) rename {apps/flipcash/shared => libs}/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkController.kt (100%) rename {apps/flipcash/shared => libs}/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt (100%) rename {apps/flipcash/shared => libs}/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt (92%) 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/libs/device-link/build.gradle.kts b/libs/device-link/build.gradle.kts new file mode 100644 index 0000000000..f30a621c42 --- /dev/null +++ b/libs/device-link/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.flipcash.kmp.library) +} + +// 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" + } + + sourceSets { + commonMain.dependencies { + // StateFlow is part of PhoneLinkController's API. + api(libs.kotlinx.coroutines.core) + implementation(libs.sodium.bindings.kmp) + implementation(libs.ktor.network) + } + jvmTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } +} 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/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 100% 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 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/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 100% 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 diff --git a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt b/libs/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt similarity index 92% rename from apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt rename to libs/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt index b7c19b74b8..a607650a03 100644 --- a/apps/flipcash/shared/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt +++ b/libs/device-link/src/jvmTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt @@ -125,16 +125,6 @@ class LinkProtocolTest { assertNull(LinkInvite.parse(invite.encode().replace("p=50123", "p=0"))) } - @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`() { var seedReads = 0 @@ -155,10 +145,3 @@ class LinkProtocolTest { 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 } -} 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", From f16663fc0b9c7f7882069e5f1759564475c20786 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 18:54:26 -0400 Subject: [PATCH 2/4] test(device-link): run the link protocol tests on every target LinkProtocolTest moves from jvmTest to commonTest and now also runs on iosSimulatorArm64 (7/7, real loopback sockets and the native libsodium). Android gets a device-test source set instead of a host test, because libsodium's Android binding loads its .so from the APK; chat-cipher has the same limit. shared-core-tests runs the iOS target. --- .github/workflows/shared-core-tests.yml | 2 ++ libs/device-link/build.gradle.kts | 19 +++++++++++++++++-- .../app/devicelink/LinkProtocolTest.kt | 14 +++++++------- 3 files changed, 26 insertions(+), 9 deletions(-) rename libs/device-link/src/{jvmTest => commonTest}/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt (94%) 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/libs/device-link/build.gradle.kts b/libs/device-link/build.gradle.kts index f30a621c42..e9b1d588aa 100644 --- a/libs/device-link/build.gradle.kts +++ b/libs/device-link/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree + plugins { alias(libs.plugins.flipcash.kmp.library) } @@ -7,6 +9,14 @@ plugins { 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 { @@ -16,9 +26,14 @@ kotlin { implementation(libs.sodium.bindings.kmp) implementation(libs.ktor.network) } - jvmTest.dependencies { + commonTest.dependencies { implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.test) + } + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.test.runner) + } } } } diff --git a/libs/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 94% rename from libs/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 a607650a03..4ba34b6b82 100644 --- a/libs/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,7 +126,7 @@ class LinkProtocolTest { } @Test - fun `the phone controller reads the seed only after confirm`() { + fun thePhoneControllerReadsTheSeedOnlyAfterConfirm() { var seedReads = 0 val states = runLink { invite -> kotlinx.coroutines.coroutineScope { From bdf1d484340757ed0744ba91d7b325d78d845391 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 18:56:51 -0400 Subject: [PATCH 3/4] test(device-link): check the link against fixed vectors device_link.json (canonical copy in the orchestrator's test-vectors/, from gen_device_link.py over PyNaCl) fixes keys, secret, transcript, both session keys, the six-digit code, and the sealed seed and ack frames for three cases, plus tampered seeds and bad invites. DeviceLinkVectorTest reproduces all of it on jvm and iosSimulatorArm64. LinkCrypto.seal gains an internal fixed-nonce overload for the test; production still draws a fresh nonce. --- libs/device-link/build.gradle.kts | 8 ++ .../com/flipcash/app/devicelink/LinkCrypto.kt | 12 ++- .../app/devicelink/DeviceLinkVectorTest.kt | 79 +++++++++++++++ .../src/commonTest/resources/device_link.json | 96 +++++++++++++++++++ 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/DeviceLinkVectorTest.kt create mode 100644 libs/device-link/src/commonTest/resources/device_link.json diff --git a/libs/device-link/build.gradle.kts b/libs/device-link/build.gradle.kts index e9b1d588aa..498b4a41cc 100644 --- a/libs/device-link/build.gradle.kts +++ b/libs/device-link/build.gradle.kts @@ -2,6 +2,13 @@ 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 @@ -28,6 +35,7 @@ kotlin { } commonTest.dependencies { implementation(kotlin("test")) + implementation(libs.kotlinx.serialization.json) } getByName("androidDeviceTest") { dependencies { diff --git a/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/LinkCrypto.kt index 6663337bce..4ef804cbb8 100644 --- a/libs/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/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..b062e000c8 --- /dev/null +++ b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/DeviceLinkVectorTest.kt @@ -0,0 +1,79 @@ +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 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/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" + ] + } +} From 4eb607f1a59e5f92cee669012d9d29a780c063f6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 19:02:27 -0400 Subject: [PATCH 4/4] feat(shared-core): export the phone link to iOS PhoneLinkSession wraps PhoneLinkController for Swift: scanned(text:), confirm(entropyB64:), cancel(), close(), and an onState callback instead of a StateFlow and suspend functions. The seed is an argument to confirm, since iOS already holds it, and is kept only until the controller reads it after the user confirms the codes. PhoneLinker is now internal, so no ktor or Result types reach SharedCore.h. SharedCoreKit adds SharedDeviceLink, which maps the Kotlin states to a Swift enum on the main actor, and DeviceLinkTests, which check the code and seed opening against device_link.json through DeviceLinkVectors. --- kmp/shared-core/build.gradle.kts | 2 + .../Sources/SharedCoreKit/DeviceLink.swift | 64 +++++++++++++ .../SharedCoreKitTests/DeviceLinkTests.swift | 71 ++++++++++++++ .../Fixtures/device_link.json | 96 +++++++++++++++++++ .../app/devicelink/DeviceLinkVectors.kt | 32 +++++++ .../app/devicelink/PhoneLinkSession.kt | 49 ++++++++++ .../flipcash/app/devicelink/PhoneLinker.kt | 2 +- .../app/devicelink/DeviceLinkVectorTest.kt | 12 +++ .../app/devicelink/LinkProtocolTest.kt | 18 ++++ 9 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/DeviceLink.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/DeviceLinkTests.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/device_link.json create mode 100644 libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/DeviceLinkVectors.kt create mode 100644 libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinkSession.kt 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/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/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/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt b/libs/device-link/src/commonMain/kotlin/com/flipcash/app/devicelink/PhoneLinker.kt index 2f385fa06b..b141be08b6 100644 --- a/libs/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 index b062e000c8..a3bdb5e7a4 100644 --- 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 @@ -62,6 +62,18 @@ class DeviceLinkVectorTest { 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() diff --git a/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt index 4ba34b6b82..998bde85cd 100644 --- a/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt +++ b/libs/device-link/src/commonTest/kotlin/com/flipcash/app/devicelink/LinkProtocolTest.kt @@ -144,4 +144,22 @@ class LinkProtocolTest { assertEquals(1, seedReads) assertEquals(seed, assertIs(states.last()).entropyB64) } + + @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) + } }