a3694f583c46
feat: add relay-first vault adoption source (v2.3.78)
- time
- 2026-07-26 05:08 UTC · 3 months ago
- machine
- koh2
- parent
- b7c31dd4fa00
↓ peach-android-a3694f58.tar.gzatlas at this save
files changed11 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+16 −1app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+1 −1app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+80 −1app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+58 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+183 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+62 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+125 −13app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+107 −0app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+29 −0app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+177 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 23770020- versionName "2.3.77"19+ versionCode 23780020+ versionName "2.3.78"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+16 −1
@@ -71,9 +71,15 @@7171 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,7272 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,7373 @SerialName("ws_port") val wsPort: Int? = null,74+ @SerialName("relay_url") val relayUrl: String? = null,7475 )7576 }7677 78+data class AdoptionPairState(79+ val rendezvousSecretHex: String,80+ val relayUrl: String? = null,81+)82+7783 /** What the RECEIVER (joining device) needs to adopt a vault from a source. */7884 interface AdoptionReceiverDeps {7985 val deviceName: String@@ -87,6 +93,7 @@8793 suspend fun deriveLocalIdentity(): LocalChirpIdentity8894 suspend fun upsertPairedDevice(device: PairedDevice)8995 suspend fun applyLicense(license: String) {}96+ val pairState: AdoptionPairState? get() = null9097 fun onError(error: String)9198 }9299 @@ -105,6 +112,7 @@105112 */106113 suspend fun onConsent(request: AdoptionFrame.Request): Boolean107114 suspend fun upsertPairedDevice(device: PairedDevice)115+ val pairState: AdoptionPairState? get() = null108116 }109117 110118 private val Hex64 = Regex("^[0-9a-f]{64}\$")@@ -121,7 +129,9 @@121129 validateRootKeyHex(frame.rootKeyHex) &&122130 Hex64.matches(frame.vaultId) &&123131 isDeviceType(frame.source.deviceType) &&124- Hex64.matches(frame.source.longTermPublicKeyHex)132+ Hex64.matches(frame.source.longTermPublicKeyHex) &&133+ (frame.source.relayUrl == null ||134+ runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)125135 126136 fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =127137 frame is AdoptionFrame.Confirm@@ -201,6 +211,8 @@201211 signingPublicKeyHex = grant.source.signingPublicKeyHex,202212 trustSource = "direct",203213 wsPort = grant.source.wsPort,214+ rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,215+ relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,204216 pairedAt = nowMs(),205217 )206218 deps.upsertPairedDevice(sourceDevice)@@ -239,6 +251,7 @@239251 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,240252 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,241253 wsPort = deps.sourceIdentity.wsPort,254+ relayUrl = deps.pairState?.relayUrl,242255 ),243256 ))244257 @@ -253,6 +266,8 @@253266 longTermPublicKeyHex = identity.longTermPublicKeyHex,254267 signingPublicKeyHex = identity.signingPublicKeyHex,255268 trustSource = "direct",269+ rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,270+ relayUrl = deps.pairState?.relayUrl,256271 pairedAt = nowMs(),257272 )258273 deps.upsertPairedDevice(device)app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+1 −1
@@ -239,7 +239,7 @@239239 }240240 }241241 242-private object OkHttpRelaySocket {242+internal object OkHttpRelaySocket {243243 private val client = OkHttpClient.Builder()244244 .readTimeout(0, TimeUnit.MILLISECONDS)245245 .build()app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+80 −1
@@ -98,6 +98,7 @@9898 ourStaticPublicKey: ByteArray,9999 localIdentity: LocalChirpIdentity,100100 windowMs: Long = PAIRING_WINDOW_MS,101+ adoptionSourceDeps: AdoptionSourceDeps? = null,101102 ): Flow<PairingEvent> = callbackFlow {102103 val ip = localIpv4()103104 if (ip == null) {@@ -120,13 +121,21 @@120121 delay(windowMs)121122 trySend(PairingEvent.Expired)122123 stop()124+ this@callbackFlow.close()123125 }124126 125127 val acceptJob = scope.launch(Dispatchers.IO) {126128 while (isActive && !socket.isClosed) {127129 val client = runCatching { socket.accept() }.getOrNull() ?: break128130 launch {129- val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)131+ val event = handleClient(132+ client,133+ tokenHex,134+ ourStaticPrivateKey,135+ ourStaticPublicKey,136+ localIdentity,137+ adoptionSourceDeps,138+ )130139 trySend(event)131140 // One code, one device. A rejected or failed attempt leaves132141 // the window open so the user can retry without generating@@ -134,6 +143,7 @@134143 if (event is PairingEvent.Paired) {135144 expiryJob.cancel()136145 stop()146+ this@callbackFlow.close()137147 }138148 }139149 }@@ -157,6 +167,7 @@157167 ourStaticPrivateKey: ByteArray,158168 ourStaticPublicKey: ByteArray,159169 localIdentity: LocalChirpIdentity,170+ adoptionSourceDeps: AdoptionSourceDeps?,160171 ): PairingEvent = withContext(Dispatchers.IO) {161172 val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())162173 try {@@ -185,6 +196,10 @@185196 if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {186197 return@withContext rejectSilently(ws, "bad_token")187198 }199+ val flow = helloJson.optString("flow").ifBlank { null }200+ if (flow != null && flow != "adoption") {201+ return@withContext rejectSilently(ws, "unsupported_flow")202+ }188203 189204 // Binary, for the same reason: the browser reads every frame as190205 // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a@@ -205,6 +220,17 @@205220 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },206221 )207222 223+ if (flow == "adoption") {224+ val deps = adoptionSourceDeps225+ ?: return@withContext rejectSilently(ws, "adoption_unavailable")226+ return@withContext handleAdoption(227+ ws,228+ session,229+ localIdentity,230+ deps,231+ )232+ }233+208234 // E1: the initiator proves it holds the private half of the key it209235 // will advertise. Bind that proof to this NK transcript before any210236 // identity frame is accepted or SAS consent is requested.@@ -259,6 +285,59 @@259285 PairingEvent.Rejected(e.message ?: "pairing_failed")260286 } finally {261287 runCatching { client.close() }288+ }289+ }290+291+ private suspend fun handleAdoption(292+ ws: WebSocketConnection,293+ session: NoiseNKSession,294+ localIdentity: LocalChirpIdentity,295+ sourceDeps: AdoptionSourceDeps,296+ ): PairingEvent {297+ val channel = object : PairingTransport {298+ override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))299+ override suspend fun receive(): ByteArray = session.decrypt(300+ (ws.receive() as? WsMessage.Binary)?.bytes301+ ?: throw IllegalStateException("peer closed"),302+ )303+ override suspend fun close() = ws.closeSilently()304+ }305+ val request = receiveFrame(channel)306+ require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }307+ request as AdoptionFrame.Request308+309+ val pending = PairingSession()310+ val name = request.deviceName.ifBlank { "New device" }311+ confirmationSink?.invoke(312+ PairingEvent.AwaitingConfirmation(313+ SASDerivation.derive(session.handshakeHash),314+ name,315+ pending,316+ ),317+ )318+ if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")319+320+ val pairState = AdoptionPairState(321+ rendezvousSecretHex = Rendezvous.bytesToHex(322+ Rendezvous.deriveSecret(session.handshakeHash),323+ ),324+ relayUrl = localIdentity.relayUrl,325+ )326+ val boundDeps = object : AdoptionSourceDeps {327+ override val sourceIdentity = localIdentity328+ override val pairState = pairState329+ override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()330+ override suspend fun vaultId() = sourceDeps.vaultId()331+ override suspend fun license() = sourceDeps.license()332+ override suspend fun onConsent(request: AdoptionFrame.Request) = true333+ override suspend fun upsertPairedDevice(device: PairedDevice) {334+ sourceDeps.upsertPairedDevice(device)335+ }336+ }337+ return if (grantAdoption(channel, request, boundDeps) != null) {338+ PairingEvent.Paired(name)339+ } else {340+ PairingEvent.Rejected("adoption_rejected")262341 }263342 }264343 app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+58 −0
@@ -0,0 +1,58 @@1+package com.peachpasswords.sync2+3+import java.util.Base644+import kotlinx.serialization.Serializable5+import kotlinx.serialization.encodeToString6+import kotlinx.serialization.json.Json7+8+private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:"9+private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")10+11+data class PairingInvitation(12+ val relayUrl: String,13+ val token: String,14+)15+16+@Serializable17+private data class PairingInvitationWire(18+ val relay: String,19+ val token: String,20+)21+22+/**23+ * QR/manual first-pair payload. Only reachability is present before Noise:24+ * a credential-free wss relay and a fresh 128-bit token. Never add identity or25+ * vault fields here.26+ */27+fun createPairingInvitation(28+ relayUrl: String,29+ token: String = Rendezvous.newPairingToken(),30+): String {31+ require(PairingTokenPattern.matches(token)) {32+ "pairing invitation token must be 128-bit base64url"33+ }34+ val wire = PairingInvitationWire(Rendezvous.normalizeRelayUrl(relayUrl), token)35+ val encoded = Base64.getUrlEncoder().withoutPadding()36+ .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))37+ return PAIRING_INVITATION_PREFIX + encoded38+}39+40+fun parsePairingInvitation(input: String): PairingInvitation? {41+ val value = input.trim()42+ if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null43+ return runCatching {44+ val decoded = Base64.getUrlDecoder().decode(45+ value.removePrefix(PAIRING_INVITATION_PREFIX),46+ )47+ val wire = Json.decodeFromString<PairingInvitationWire>(48+ String(decoded, Charsets.UTF_8),49+ )50+ require(PairingTokenPattern.matches(wire.token))51+ PairingInvitation(52+ relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),53+ token = wire.token,54+ )55+ }.getOrNull()56+}57+58+fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+183 −0
@@ -0,0 +1,183 @@1+package com.peachpasswords.sync2+3+import java.security.MessageDigest4+import javax.inject.Inject5+import javax.inject.Singleton6+import kotlinx.coroutines.CoroutineScope7+import kotlinx.coroutines.Dispatchers8+import kotlinx.coroutines.Job9+import kotlinx.coroutines.channels.awaitClose10+import kotlinx.coroutines.flow.Flow11+import kotlinx.coroutines.flow.callbackFlow12+import kotlinx.coroutines.launch13+import kotlinx.coroutines.withTimeout14+import org.json.JSONObject15+16+sealed interface RelayAdoptionEvent {17+ /** The source is already occupying the non-buffering room; it is safe to show this. */18+ data class InvitationReady(val invitation: String) : RelayAdoptionEvent19+ data class AwaitingConfirmation(20+ val sas: String,21+ val deviceName: String,22+ val session: PairingSession,23+ ) : RelayAdoptionEvent24+ data class Adopted(val deviceName: String) : RelayAdoptionEvent25+ data object Expired : RelayAdoptionEvent26+ data class Rejected(val reason: String) : RelayAdoptionEvent27+}28+29+@Singleton30+class RelayAdoptionHost @Inject constructor(31+ private val relaySettings: RelaySettingsStore,32+) {33+ private var job: Job? = null34+ private var socket: RelayBinarySocket? = null35+36+ fun start(37+ scope: CoroutineScope,38+ ourStaticPrivateKey: ByteArray,39+ ourStaticPublicKey: ByteArray,40+ localIdentity: LocalChirpIdentity,41+ sourceDeps: AdoptionSourceDeps,42+ windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS,43+ ): Flow<RelayAdoptionEvent> = callbackFlow {44+ val relayUrl = relaySettings.relayUrl()45+ val token = Rendezvous.newPairingToken()46+ val invitation = createPairingInvitation(relayUrl, token)47+48+ job = scope.launch(Dispatchers.IO) {49+ try {50+ val connected = OkHttpRelaySocket.connect(51+ Rendezvous.buildUrl(relayUrl, token),52+ )53+ socket = connected54+ // The relay buffers nothing, so the invitation is not surfaced55+ // until the source WebSocket is open and waiting in the room.56+ trySend(RelayAdoptionEvent.InvitationReady(invitation))57+58+ val adopted = withTimeout(windowMs) {59+ runRelayAdoptionSourceSession(60+ socket = connected,61+ expectedToken = token,62+ relayUrl = relayUrl,63+ ourStaticPrivateKey = ourStaticPrivateKey,64+ ourStaticPublicKey = ourStaticPublicKey,65+ localIdentity = localIdentity,66+ sourceDeps = sourceDeps,67+ confirm = { sas, request ->68+ val pending = PairingSession()69+ trySend(70+ RelayAdoptionEvent.AwaitingConfirmation(71+ sas = sas,72+ deviceName = request.deviceName.ifBlank { "New device" },73+ session = pending,74+ ),75+ )76+ pending.await()77+ },78+ )79+ }80+ if (adopted == null) {81+ trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))82+ } else {83+ trySend(84+ RelayAdoptionEvent.Adopted(85+ adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device",86+ ),87+ )88+ }89+ } catch (error: kotlinx.coroutines.TimeoutCancellationException) {90+ trySend(RelayAdoptionEvent.Expired)91+ } catch (error: Throwable) {92+ trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))93+ } finally {94+ socket?.close()95+ socket = null96+ close()97+ }98+ }99+100+ awaitClose { stop() }101+ }102+103+ fun stop() {104+ job?.cancel()105+ job = null106+ socket?.close()107+ socket = null108+ }109+}110+111+/**112+ * Source half of first-contact adoption through an opaque relay splice.113+ *114+ * The relay token selects only a room. Authentication and confidentiality115+ * begin with Noise NK; the root grant remains behind both SAS decisions in116+ * [grantAdoption].117+ */118+internal suspend fun runRelayAdoptionSourceSession(119+ socket: RelayBinarySocket,120+ expectedToken: String,121+ relayUrl: String,122+ ourStaticPrivateKey: ByteArray,123+ ourStaticPublicKey: ByteArray,124+ localIdentity: LocalChirpIdentity,125+ sourceDeps: AdoptionSourceDeps,126+ confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,127+): PairedDevice? {128+ val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))129+ require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }130+ require(131+ MessageDigest.isEqual(132+ expectedToken.toByteArray(Charsets.US_ASCII),133+ hello.optString("token").toByteArray(Charsets.US_ASCII),134+ ),135+ ) { "pairing token mismatch" }136+137+ socket.send(138+ JSONObject()139+ .put("type", "PEACH_HELLO_ACK")140+ .put(141+ "ephemeralPublicKeyHex",142+ ourStaticPublicKey.joinToString("") { "%02x".format(it) },143+ )144+ .toString()145+ .toByteArray(Charsets.UTF_8),146+ )147+148+ val noise = noiseNKResponder(149+ ourStaticPrivateKey,150+ ourStaticPublicKey,151+ socket::send,152+ socket::receive,153+ )154+ val channel = object : PairingTransport {155+ override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))156+ override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())157+ override suspend fun close() = socket.close()158+ }159+160+ val request = receiveFrame(channel)161+ require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }162+ request as AdoptionFrame.Request163+ if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null164+165+ val pairState = AdoptionPairState(166+ rendezvousSecretHex = Rendezvous.bytesToHex(167+ Rendezvous.deriveSecret(noise.handshakeHash),168+ ),169+ relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),170+ )171+ val boundDeps = object : AdoptionSourceDeps {172+ override val sourceIdentity: LocalChirpIdentity = localIdentity173+ override val pairState: AdoptionPairState = pairState174+ override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()175+ override suspend fun vaultId(): String = sourceDeps.vaultId()176+ override suspend fun license(): String? = sourceDeps.license()177+ override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true178+ override suspend fun upsertPairedDevice(device: PairedDevice) {179+ sourceDeps.upsertPairedDevice(device)180+ }181+ }182+ return grantAdoption(channel, request, boundDeps)183+}app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+62 −2
@@ -15,9 +15,11 @@1515 import androidx.compose.foundation.layout.height1616 import androidx.compose.foundation.layout.padding1717 import androidx.compose.foundation.layout.systemBarsPadding18+import androidx.compose.foundation.Canvas1819 import androidx.compose.material3.Text1920 import androidx.compose.runtime.Composable2021 import androidx.compose.runtime.LaunchedEffect22+import androidx.compose.runtime.remember2123 import androidx.compose.runtime.getValue2224 import androidx.compose.ui.platform.LocalContext2325 import androidx.compose.ui.Alignment@@ -26,6 +28,10 @@2628 import androidx.compose.ui.text.style.TextAlign2729 import androidx.compose.ui.unit.dp2830 import androidx.compose.ui.unit.sp31+import androidx.compose.ui.geometry.Offset32+import androidx.compose.ui.geometry.Size33+import androidx.compose.ui.platform.LocalClipboardManager34+import androidx.compose.ui.text.AnnotatedString2935 import androidx.hilt.navigation.compose.hiltViewModel3036 import androidx.lifecycle.compose.collectAsStateWithLifecycle3137 import com.peachpasswords.ui.components.BirdAnimation@@ -35,6 +41,8 @@3541 import com.peachpasswords.ui.theme.PeachType3642 import com.peachpasswords.ui.theme.ShellBackground3743 import com.peachpasswords.ui.theme.peachColors44+import com.google.zxing.BarcodeFormat45+import com.google.zxing.qrcode.QRCodeWriter3846 3947 /**4048 * Pair a device by code. The phone shows the code; the browser types it in and@@ -82,8 +90,31 @@8290 }8391 8492 when (val s = state) {85- is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")93+ is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")8694 95+ is CodePairUiState.ShowingInvitation -> {96+ val clipboard = LocalClipboardManager.current97+ Headline(98+ "Scan this in Peach",99+ "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",100+ )101+ Spacer(Modifier.height(PeachSpacing.md))102+ PairingQrCode(103+ value = s.invitation,104+ modifier = Modifier105+ .fillMaxWidth()106+ .height(260.dp),107+ )108+ Spacer(Modifier.height(PeachSpacing.md))109+ PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {110+ clipboard.setText(AnnotatedString(s.invitation))111+ }112+ Spacer(Modifier.height(PeachSpacing.sm))113+ PillButton("Use local network only", peachColors.T2, filled = false) {114+ vm.useLanOnly(true)115+ }116+ }117+87118 is CodePairUiState.ShowingCode -> {88119 Headline(89120 "Type this on your computer",@@ -115,6 +146,10 @@115146 textAlign = TextAlign.Center,116147 modifier = Modifier.fillMaxWidth(),117148 )149+ Spacer(Modifier.height(PeachSpacing.sm))150+ PillButton("Use secure relay instead", peachColors.T2, filled = false) {151+ vm.useLanOnly(false)152+ }118153 }119154 120155 is CodePairUiState.ConfirmSas -> {@@ -140,7 +175,7 @@140175 }141176 142177 is CodePairUiState.Paired -> {143- Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")178+ Headline("Paired", "${s.deviceName} will now keep your vault in step through the secure relay.")144179 Spacer(Modifier.height(PeachSpacing.lg))145180 PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)146181 }@@ -161,6 +196,31 @@161196 }162197 }163198 Spacer(Modifier.height(PeachSpacing.md))199+ }200+ }201+}202+203+@Composable204+private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {205+ val matrix = remember(value) {206+ QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)207+ }208+ Canvas(modifier = modifier) {209+ val side = minOf(size.width, size.height)210+ val module = side / matrix.width211+ val qrSize = module * matrix.width212+ val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)213+ drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))214+ for (y in 0 until matrix.height) {215+ for (x in 0 until matrix.width) {216+ if (matrix[x, y]) {217+ drawRect(218+ Color.Black,219+ topLeft = Offset(origin.x + x * module, origin.y + y * module),220+ size = Size(module + 0.5f, module + 0.5f),221+ )222+ }223+ }164224 }165225 }166226 }app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+125 −13
@@ -10,11 +10,20 @@1010 import com.peachpasswords.sync.PairingEvent1111 import com.peachpasswords.sync.PairingSession1212 import com.peachpasswords.sync.PeachSyncService13+import com.peachpasswords.sync.RelayAdoptionEvent14+import com.peachpasswords.sync.RelayAdoptionHost15+import com.peachpasswords.sync.RelaySettingsStore16+import com.peachpasswords.sync.AdoptionSourceDeps17+import com.peachpasswords.sync.AdoptionFrame18+import com.peachpasswords.sync.LocalChirpIdentity19+import com.peachpasswords.sync.PairedDevice20+import com.peachpasswords.sync.PairedDeviceStore1321 import com.peachpasswords.sync.buildLocalIdentity1422 import com.peachpasswords.sync.deriveLocalSyncKeypair1523 import com.peachpasswords.sync.getOrCreateDeviceId1624 import com.peachpasswords.vault.VaultCrypto1725 import com.peachpasswords.vault.VaultRepository26+import com.peachpasswords.license.LicenseRepository1827 import dagger.hilt.android.lifecycle.HiltViewModel1928 import dagger.hilt.android.qualifiers.ApplicationContext2029 import kotlinx.coroutines.flow.MutableStateFlow@@ -32,6 +41,7 @@3241 */3342 sealed interface CodePairUiState {3443 data object Starting : CodePairUiState44+ data class ShowingInvitation(val invitation: String) : CodePairUiState3545 data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState3646 data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState3747 data class Paired(val deviceName: String) : CodePairUiState@@ -43,6 +53,10 @@4353 class CodePairViewModel @Inject constructor(4454 private val vault: VaultRepository,4555 private val server: CodePairingServer,56+ private val relayHost: RelayAdoptionHost,57+ private val relaySettings: RelaySettingsStore,58+ private val deviceStore: PairedDeviceStore,59+ private val license: LicenseRepository,4660 @ApplicationContext private val appContext: Context,4761 ) : ViewModel() {4862 @@ -66,7 +80,13 @@6680 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))6781 val identity = buildLocalIdentity(appContext, master)6882 VaultCrypto.zero(master)83+ val sourceDeps = sourceDeps(identity)6984 85+ if (!relaySettings.isLanOnly()) {86+ startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)87+ return88+ }89+7090 // The handler blocks inside the live connection while the user compares7191 // digits, so the SAS has to reach the UI out of band rather than as the7292 // connection's result.@@ -75,36 +95,128 @@7595 }7696 7797 viewModelScope.launch {78- server.start(viewModelScope, keys.secretKey, keys.publicKey, identity).collect { event ->79- _state.value = when (event) {80- is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)81- is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)82- PairingEvent.Expired -> CodePairUiState.Expired83- is PairingEvent.Rejected -> {84- // Nothing else records why a pairing attempt died, and85- // the on-screen copy is deliberately vague. Without this86- // a failure on someone else's phone is undiagnosable.87- if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")88- CodePairUiState.Failed(event.reason)98+ try {99+ server.start(100+ viewModelScope,101+ keys.secretKey,102+ keys.publicKey,103+ identity,104+ adoptionSourceDeps = sourceDeps,105+ ).collect { event ->106+ _state.value = when (event) {107+ is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)108+ is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)109+ PairingEvent.Expired -> CodePairUiState.Expired110+ is PairingEvent.Rejected -> {111+ // Nothing else records why a pairing attempt died, and112+ // the on-screen copy is deliberately vague. Without this113+ // a failure on someone else's phone is undiagnosable.114+ if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")115+ CodePairUiState.Failed(event.reason)116+ }117+ // Delivered via confirmationSink; the flow only sees the outcome.118+ is PairingEvent.AwaitingConfirmation -> _state.value89119 }90- // Delivered via confirmationSink; the flow only sees the outcome.91- is PairingEvent.AwaitingConfirmation -> _state.value92120 }121+ } finally {122+ VaultCrypto.zero(keys.secretKey)123+ VaultCrypto.zero(keys.publicKey)93124 }94125 }95126 }96127 128+ private fun startRelay(129+ privateKey: ByteArray,130+ publicKey: ByteArray,131+ identity: LocalChirpIdentity,132+ sourceDeps: AdoptionSourceDeps,133+ ) {134+ viewModelScope.launch {135+ try {136+ relayHost.start(137+ viewModelScope,138+ privateKey,139+ publicKey,140+ identity,141+ sourceDeps,142+ ).collect { event ->143+ _state.value = when (event) {144+ is RelayAdoptionEvent.InvitationReady ->145+ CodePairUiState.ShowingInvitation(event.invitation)146+ is RelayAdoptionEvent.AwaitingConfirmation ->147+ CodePairUiState.ConfirmSas(148+ event.sas,149+ event.deviceName,150+ event.session,151+ )152+ is RelayAdoptionEvent.Adopted ->153+ CodePairUiState.Paired(event.deviceName)154+ RelayAdoptionEvent.Expired -> CodePairUiState.Expired155+ is RelayAdoptionEvent.Rejected -> {156+ if (BuildConfig.DEBUG) {157+ Log.w(158+ "PeachPairing",159+ "relay adoption rejected: ${event.reason}",160+ )161+ }162+ CodePairUiState.Failed(event.reason)163+ }164+ }165+ }166+ } finally {167+ VaultCrypto.zero(privateKey)168+ VaultCrypto.zero(publicKey)169+ }170+ }171+ }172+173+ private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =174+ object : AdoptionSourceDeps {175+ override val sourceIdentity: LocalChirpIdentity = identity176+177+ override suspend fun rootKeyHex(): String {178+ val root = vault.syncMasterKey()179+ ?: throw IllegalStateException("vault_locked")180+ return try {181+ root.joinToString("") { "%02x".format(it) }182+ } finally {183+ VaultCrypto.zero(root)184+ }185+ }186+187+ override suspend fun vaultId(): String =188+ vault.syncVaultId() ?: throw IllegalStateException("vault_locked")189+190+ override suspend fun license(): String? =191+ license.getLicenseKey()?.takeIf(license::validateLicense)192+193+ // RelayAdoptionHost owns the actual UI consent gate so it can bind194+ // that decision to the session SAS before delegating here.195+ override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true196+197+ override suspend fun upsertPairedDevice(device: PairedDevice) {198+ deviceStore.upsert(device)199+ }200+ }201+97202 fun confirmSas(matches: Boolean) {98203 (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)99204 }100205 101206 fun restart() {207+ relayHost.stop()102208 server.stop()103209 _state.value = CodePairUiState.Starting104210 start()105211 }106212 213+ fun useLanOnly(lanOnly: Boolean) {214+ relaySettings.setLanOnly(lanOnly)215+ restart()216+ }217+107218 fun stop() {219+ relayHost.stop()108220 server.confirmationSink = null109221 server.stop()110222 }app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+107 −0
@@ -11,6 +11,7 @@1111 import okhttp3.WebSocket1212 import okhttp3.WebSocketListener1313 import okio.ByteString.Companion.toByteString14+import org.json.JSONObject1415 import org.junit.Assert.assertEquals1516 import org.junit.Assert.assertTrue1617 import org.junit.Test@@ -134,6 +135,112 @@134135 c.frames.isEmpty(),135136 )136137 assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK"))138+ } finally {139+ srv.stop()140+ }141+ }142+143+ @Test fun `LAN adoption dispatches before device-pair auth and keeps both SAS gates`() = runBlocking {144+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)145+ val registry = FakeRegistry()146+ val srv = CodePairingServer(registry)147+ val root = ByteArray(32) { (it + 1).toByte() }148+ val sourceKeys = deriveLocalSyncKeypair(root, "source-id")149+ val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")150+ val sourceIdentity = identity.copy(151+ deviceId = "source-id",152+ deviceName = "Source phone",153+ longTermPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),154+ relayUrl = "wss://relay.example.test",155+ )156+ var rootRequested = false157+ val sourceDeps = object : AdoptionSourceDeps {158+ override val sourceIdentity = sourceIdentity159+ override suspend fun rootKeyHex(): String {160+ rootRequested = true161+ return Rendezvous.bytesToHex(root)162+ }163+ override suspend fun vaultId() = computeVaultId(root)164+ override suspend fun onConsent(request: AdoptionFrame.Request) = true165+ override suspend fun upsertPairedDevice(device: PairedDevice) {166+ registry.upsert(device)167+ }168+ }169+ val h = Harness(scope)170+ srv.confirmationSink = { it.session.confirm(true) }171+ scope.launch {172+ srv.start(173+ this,174+ sourceKeys.secretKey,175+ sourceKeys.publicKey,176+ sourceIdentity,177+ adoptionSourceDeps = sourceDeps,178+ ).collect { h.events.add(it) }179+ }180+181+ try {182+ val listening = h.await<PairingEvent.Listening>()183+ val token = PairingCode.parse(listening.code)!!.tokenHex184+ val client = Client()185+ client.connect(listening.port)186+ client.ws.send(187+ JSONObject()188+ .put("type", "PEACH_HELLO")189+ .put("token", token)190+ .put("flow", "adoption")191+ .toString()192+ .toByteArray()193+ .toByteString(),194+ )195+ val ack = JSONObject(String(client.binaryFrames.poll(10, TimeUnit.SECONDS)))196+ val noise = noiseNKInitiator(197+ hexToPubkey(ack.getString("ephemeralPublicKeyHex")),198+ { client.ws.send(it.toByteString()) },199+ {200+ client.binaryFrames.poll(10, TimeUnit.SECONDS)201+ ?: throw IllegalStateException("timed out waiting for server frame")202+ },203+ )204+ val channel = object : PairingTransport {205+ override suspend fun send(msg: ByteArray) {206+ client.ws.send(noise.encrypt(msg).toByteString())207+ }208+ override suspend fun receive(): ByteArray {209+ val frame = client.binaryFrames.poll(10, TimeUnit.SECONDS)210+ ?: throw IllegalStateException("timed out waiting for encrypted frame")211+ return noise.decrypt(frame)212+ }213+ override suspend fun close() {214+ client.ws.close(1000, "done")215+ }216+ }217+ val receiver = requestAdoption(218+ channel,219+ object : AdoptionReceiverDeps {220+ override val deviceName = "Browser"221+ override val deviceType = DeviceType.EXTENSION222+ override fun generateDeviceId() = "session-request"223+ override suspend fun confirmSas() = true224+ override suspend fun createVaultFromRootKey(rootKeyHex: String) =225+ computeVaultId(hexToPubkey(rootKeyHex))226+ override suspend fun deriveLocalIdentity() = LocalChirpIdentity(227+ deviceId = "receiver-id",228+ deviceName = "Browser",229+ deviceType = DeviceType.EXTENSION,230+ longTermPublicKeyHex = Rendezvous.bytesToHex(receiverKeys.publicKey),231+ signingPublicKeyHex = "ef".repeat(32),232+ )233+ override suspend fun upsertPairedDevice(device: PairedDevice) = Unit234+ override fun onError(error: String) = throw AssertionError(error)235+ },236+ )237+238+ assertTrue(rootRequested)239+ assertEquals("source-id", receiver?.deviceId)240+ assertEquals("Browser", h.await<PairingEvent.Paired>().deviceName)241+ assertEquals("receiver-id", registry.devices.single().deviceId)242+ assertEquals(64, registry.devices.single().rendezvousSecretHex?.length)243+ assertEquals("wss://relay.example.test", registry.devices.single().relayUrl)137244 } finally {138245 srv.stop()139246 }app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+29 −0
@@ -0,0 +1,29 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class PairingInvitationTest {8+ private val token = "AAECAwQFBgcICQoLDA0ODw"9+ private val relay = "wss://relay.peachpasswords.com"10+ private val kat =11+ "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9"12+13+ @Test fun `matches browser invitation KAT`() {14+ assertEquals(kat, createPairingInvitation("$relay/", token))15+ assertEquals(PairingInvitation(relay, token), parsePairingInvitation(kat))16+ assertEquals("$relay/r/$token", parsePairingInvitation(kat)!!.roomUrl())17+ }18+19+ @Test fun `rejects malformed insecure and short-token invitations`() {20+ assertNull(parsePairingInvitation("not-an-invitation"))21+ assertNull(parsePairingInvitation("peach-pair-v1:%%%"))22+ assertNull(parsePairingInvitation(raw("""{"relay":"ws://relay.test","token":"$token"}""")))23+ assertNull(parsePairingInvitation(raw("""{"relay":"$relay","token":"short"}""")))24+ }25+26+ private fun raw(json: String): String =27+ "peach-pair-v1:" + java.util.Base64.getUrlEncoder().withoutPadding()28+ .encodeToString(json.toByteArray())29+}app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+177 −0
@@ -0,0 +1,177 @@1+package com.peachpasswords.sync2+3+import java.util.concurrent.atomic.AtomicBoolean4+import kotlinx.coroutines.async5+import kotlinx.coroutines.channels.Channel6+import kotlinx.coroutines.runBlocking7+import org.json.JSONObject8+import org.junit.Assert.assertEquals9+import org.junit.Assert.assertFalse10+import org.junit.Assert.assertNotNull11+import org.junit.Assert.assertNull12+import org.junit.Test13+14+class RelayAdoptionHostTest {15+ private class MemoryRelaySocket(16+ private val inbound: Channel<ByteArray>,17+ private val outbound: Channel<ByteArray>,18+ ) : RelayBinarySocket {19+ override fun send(frame: ByteArray) {20+ check(outbound.trySend(frame.copyOf()).isSuccess)21+ }22+23+ override suspend fun receive(): ByteArray = inbound.receive()24+ override fun close(code: Int, reason: String) {25+ outbound.close()26+ }27+ }28+29+ private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30+ val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)31+ val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)32+ return MemoryRelaySocket(rightToLeft, leftToRight) to33+ MemoryRelaySocket(leftToRight, rightToLeft)34+ }35+36+ private fun identity(name: String, publicKey: ByteArray) = LocalChirpIdentity(37+ deviceId = "$name-id",38+ deviceName = name,39+ deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,40+ longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),41+ signingPublicKeyHex = "cd".repeat(32),42+ wsPort = 49142,43+ relayUrl = "wss://relay.example.test",44+ )45+46+ @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {47+ val (sourceSocket, receiverSocket) = socketPair()48+ val token = "AAECAwQFBgcICQoLDA0ODw"49+ val relay = "wss://relay.example.test"50+ val sourceRoot = ByteArray(32) { (it + 1).toByte() }51+ val sourceKeys = deriveLocalSyncKeypair(sourceRoot, "source-id")52+ val receiverKeys = deriveLocalSyncKeypair(sourceRoot, "receiver-id")53+ val sourceIdentity = identity("source", sourceKeys.publicKey)54+ val receiverIdentity = identity("receiver", receiverKeys.publicKey)55+ var sourceStored: PairedDevice? = null56+ var receiverStored: PairedDevice? = null57+58+ val sourceJob = async {59+ runRelayAdoptionSourceSession(60+ socket = sourceSocket,61+ expectedToken = token,62+ relayUrl = relay,63+ ourStaticPrivateKey = sourceKeys.secretKey,64+ ourStaticPublicKey = sourceKeys.publicKey,65+ localIdentity = sourceIdentity,66+ sourceDeps = object : AdoptionSourceDeps {67+ override val sourceIdentity = sourceIdentity68+ override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)69+ override suspend fun vaultId() = computeVaultId(sourceRoot)70+ override suspend fun onConsent(request: AdoptionFrame.Request) = true71+ override suspend fun upsertPairedDevice(device: PairedDevice) {72+ sourceStored = device73+ }74+ },75+ confirm = { sas, request ->76+ assertEquals("receiver", request.deviceName)77+ assertEquals(6, sas.length)78+ true79+ },80+ )81+ }82+83+ receiverSocket.send(84+ JSONObject().put("type", "PEACH_HELLO").put("token", token)85+ .toString().toByteArray(),86+ )87+ val ack = JSONObject(String(receiverSocket.receive()))88+ val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))89+ val noise = noiseNKInitiator(90+ sourcePublic,91+ receiverSocket::send,92+ receiverSocket::receive,93+ )94+ val channel = encryptedChannel(receiverSocket, noise)95+ val received = requestAdoption(96+ channel,97+ object : AdoptionReceiverDeps {98+ override val deviceName = "receiver"99+ override val deviceType = DeviceType.EXTENSION100+ override val pairState = AdoptionPairState(101+ Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),102+ relay,103+ )104+ override fun generateDeviceId() = "session-request"105+ override suspend fun confirmSas() = true106+ override suspend fun createVaultFromRootKey(rootKeyHex: String) =107+ computeVaultId(hexToPubkey(rootKeyHex))108+ override suspend fun deriveLocalIdentity() = receiverIdentity109+ override suspend fun upsertPairedDevice(device: PairedDevice) {110+ receiverStored = device111+ }112+ override fun onError(error: String) = throw AssertionError(error)113+ },114+ )115+116+ assertNotNull(received)117+ assertNotNull(sourceJob.await())118+ assertEquals(relay, receiverStored?.relayUrl)119+ assertEquals(relay, sourceStored?.relayUrl)120+ assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)121+ assertEquals(64, receiverStored?.rendezvousSecretHex?.length)122+ }123+124+ @Test fun `source rejection never requests the root`() = runBlocking {125+ val (sourceSocket, receiverSocket) = socketPair()126+ val token = "AAECAwQFBgcICQoLDA0ODw"127+ val root = ByteArray(32) { (it + 7).toByte() }128+ val sourceKeys = deriveLocalSyncKeypair(root, "source-id")129+ val rootRequested = AtomicBoolean(false)130+131+ val sourceJob = async {132+ runRelayAdoptionSourceSession(133+ sourceSocket,134+ token,135+ "wss://relay.example.test",136+ sourceKeys.secretKey,137+ sourceKeys.publicKey,138+ identity("source", sourceKeys.publicKey),139+ object : AdoptionSourceDeps {140+ override val sourceIdentity = identity("source", sourceKeys.publicKey)141+ override suspend fun rootKeyHex(): String {142+ rootRequested.set(true)143+ return Rendezvous.bytesToHex(root)144+ }145+ override suspend fun vaultId() = computeVaultId(root)146+ override suspend fun onConsent(request: AdoptionFrame.Request) = true147+ override suspend fun upsertPairedDevice(device: PairedDevice) = Unit148+ },149+ confirm = { _, _ -> false },150+ )151+ }152+ receiverSocket.send(153+ JSONObject().put("type", "PEACH_HELLO").put("token", token)154+ .toString().toByteArray(),155+ )156+ val ack = JSONObject(String(receiverSocket.receive()))157+ val noise = noiseNKInitiator(158+ hexToPubkey(ack.getString("ephemeralPublicKeyHex")),159+ receiverSocket::send,160+ receiverSocket::receive,161+ )162+ val channel = encryptedChannel(receiverSocket, noise)163+ sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))164+165+ assertNull(sourceJob.await())166+ assertFalse(rootRequested.get())167+ }168+169+ private fun encryptedChannel(170+ socket: RelayBinarySocket,171+ noise: NoiseNKSession,172+ ): PairingTransport = object : PairingTransport {173+ override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))174+ override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())175+ override suspend fun close() = socket.close()176+ }177+}