Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/shared-core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'kmp/shared-core/**'
- 'libs/codes/kikcode/**'
- 'libs/currency-math/**'
- 'libs/device-link/**'
- 'libs/encryption/**'
- 'libs/reporting/**'
- 'libs/analytics-events/**'
Expand Down Expand Up @@ -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 \
Expand Down
14 changes: 6 additions & 8 deletions apps/flipcash/shared/device-link/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
2 changes: 2 additions & 0 deletions kmp/shared-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}

Expand All @@ -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"))
}
}
}
Expand Down
64 changes: 64 additions & 0 deletions kmp/shared-core/spm/Sources/SharedCoreKit/DeviceLink.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
71 changes: 71 additions & 0 deletions kmp/shared-core/spm/Tests/SharedCoreKitTests/DeviceLinkTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
47 changes: 47 additions & 0 deletions libs/device-link/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Loading
Loading