Skip to content

feat(desktop): link a desktop from the phone and store its seed in the keyring - #1849

Closed
bmc08gt wants to merge 5 commits into
refactor/desktop-spike-lift-commonfrom
feat/desktop-spike-a3
Closed

bmc08gt wants to merge 5 commits into
refactor/desktop-spike-lift-commonfrom
feat/desktop-spike-a3

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1847. Third slice of the desktop spike: the desktop gets its seed from a phone over the LAN and keeps it in the OS keyring, and features/login stops depending on features/purchase.

Seed vault and link protocol

New KMP module :apps:flipcash:shared:device-link. Everything is in commonMain except the keyring vault.

  • SeedVault is the interface. KeyringSeedVault (jvmMain) backs it with Keychain, Credential Manager or Secret Service through java-keyring, since those are native APIs with no common surface. Android keeps AuthManager for now.
  • The desktop shows a QR (LinkInvite): host, port, a one-time X25519 key and a 16-byte one-time secret. QR rendering uses qrose, which is multiplatform.
  • LinkCrypto uses libsodium crypto_kx. The transcript binds both public keys and the secret. Both screens show a six-digit code keyed from the transcript, and the seed is sealed with XChaCha20-Poly1305 with the transcript as associated data.
  • The phone sends the sealed seed only from Pending.confirm(), after the user says the codes match. That confirmation is what stops someone on the LAN from swapping in their own key.
  • Sockets use ktor-network so they stay in common code. That adds a second networking stack next to OkHttp/gRPC.

LinkProtocolTest runs both sides over a loopback socket: matching codes, a swapped phone key, a wrong secret, tampered ciphertext, cancel, and the seed arriving only after confirm. These are jvmTest rather than commonTest because the Android libsodium binding loads its .so from an APK. KeyringSeedVaultTest is opt-in (-Pflipcash.keyringTest) and writes a random value under a test-only service name, then deletes it.

Desktop

At launch the desktop signs in with the dev phrase if one is set, else with the seed in the keyring. With neither, it shows DesktopLinkScreen, writes the seed it receives to the vault and signs in. The dev phrase is never written to the vault. Two pieces stay JVM-only in :apps:desktop: DesktopSession (FlipcashDatabase.init takes a java.io.File) and lanAddress() (java.net.NetworkInterface).

Android

Settings > Advanced shows Link a Desktop when FeatureFlag.LinkDesktop is on (default off, Internal track). The screen and its state holder are device-link's common PhoneLinkScreen and PhoneLinkController. LinkDesktopScreen in features/myaccount adds only the camera permission, the ML Kit scanner and a view model that reads the entropy on confirm.

Login → purchase

OnboardingFlowScreen used two internal purchase symbols. It now takes an OnboardingPurchaseStep composable from the app, and login drops project(":apps:flipcash:features:purchase").

Also

LoginViewModelErrorTest and LoginViewModelCreateAccountTest now mock android.util.Base64.encode as well as encodeToString. encodeBase64 goes through encode since #1838, so the wrong-entropy-size test failed on this stack; it passes on code/cash.

New KMP module :apps:flipcash:shared:device-link. Everything is in
commonMain except the OS keyring vault.

- SeedVault: interface for the stored account seed. KeyringSeedVault
  (jvmMain) backs it with Keychain, Credential Manager or Secret Service
  through java-keyring; those are native APIs with no common surface.
  Android keeps AuthManager for now.
- LinkInvite: the QR payload: host, port, the desktop's one-time X25519
  key, and a 16-byte one-time secret.
- LinkCrypto: libsodium kx (phone is client, desktop server). The
  transcript binds both public keys and the secret; the six-digit code
  is a keyed hash of the transcript under the phone-to-desktop key, and
  the seed is sealed with XChaCha20-Poly1305 with the transcript as AD.
- DesktopLinkHost / PhoneLinker: one connection over ktor-network
  sockets. The phone sends the sealed seed only from Pending.confirm(),
  after the user says the codes match.

ktor-network is a second networking stack next to OkHttp/gRPC; it keeps
the sockets in common code instead of an expect/actual per platform.

Tests are jvmTest, not commonTest: the Android libsodium binding loads
its .so from an APK, so the protocol can't run as a host test.
LinkProtocolTest runs both sides over a loopback socket (7 cases,
including a swapped phone key, a wrong secret, tampered ciphertext and
cancel). KeyringSeedVaultTest is opt-in (-Pflipcash.keyringTest) and
writes a random value under a test-only service name, then deletes it.
At launch the desktop signs in with the dev phrase if one is set, else
with the seed a phone linked into the Keychain. With neither, it shows
DesktopLinkScreen; when the phone sends the seed, the desktop writes it
to the vault and signs in. The dev phrase is never written to the vault.

The screens (DesktopLinkScreen, PhoneLinkScreen) and the phone's state
holder (PhoneLinkController) are in device-link's commonMain. The phone
screen takes the camera as a composable slot. PhoneLinkController reads
the seed only after the user confirms the codes match; a new loopback
test checks that.

Two pieces stay in the desktop app (JVM-only):
- DesktopSession: FlipcashDatabase.init takes a java.io.File.
- lanAddress(): picks the address the phone dials from
  java.net.NetworkInterface. Only the desktop needs it.

`snapshot -Pscreen=link` renders the link screen without signing in.
Settings > Advanced shows Link a Desktop when FeatureFlag.LinkDesktop is
on (default off, Internal track). The screen is device-link's common
PhoneLinkScreen and PhoneLinkController; LinkDesktopScreen only adds the
Android pieces: a Hilt view model that reads the entropy from
UserManager on confirm, the camera permission, and the ML Kit
CodeScanner from ui:scanner.

It lives in features/myaccount rather than device-link's androidMain
because the scanner, the permission helpers and the settings
navigation are all Android-only modules that myaccount already sits on.
features/login imported two internal symbols from features/purchase
(PurchaseAccountScreenContent and PurchaseAccountViewModel) to show the
registration purchase inside onboarding. OnboardingFlowScreen now takes an
OnboardingPurchaseStep composable, the app passes purchase's new
OnboardingPurchaseStep, and login no longer depends on the purchase module.

Both modules stay src/main: neither is KMP yet, and converting them is
outside this cut.
encodeBase64 now goes through android.util.Base64.encode (the shared
base64Encode actual from the desktop spike's jvm target). The login view
model tests only mocked encodeToString, so the Android stub returned null
and the wrong-entropy-size test never saw its error. It passes on code/cash.
@bmc08gt bmc08gt self-assigned this Oct 24, 2026
@github-actions github-actions Bot added area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: build-system Gradle, convention plugins, build-logic area: onboarding type: feature New functionality labels Oct 24, 2026
@bmc08gt

bmc08gt commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Landed in #1894 as one squash against code/cash.

@bmc08gt bmc08gt closed this Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-system Gradle, convention plugins, build-logic area: onboarding area: onramp Deposit, purchase, Coinbase, fiat on-ramp type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant