1package com.peachpasswords.sync23import java.security.MessageDigest4import javax.inject.Inject5import javax.inject.Singleton6import kotlinx.coroutines.CancellationException7import kotlinx.coroutines.withTimeout8import kotlinx.coroutines.withTimeoutOrNull9import org.json.JSONObject10import com.peachpasswords.sync.hexToPubkey1112131415161718@Singleton19class RelayAdoptionReceiver @Inject constructor() {20 2122232425 suspend fun adopt(26 encodedInvitation: String,27 deps: AdoptionReceiverDeps,28 confirm: suspend (sas: String) -> Boolean,29 windowMs: Long = PAIRING_WINDOW_MS,30 ): PairedDevice {31 val invitation = parsePairingInvitation(encodedInvitation)32 ?: throw IllegalArgumentException("invalid_pairing_invitation")33 require(invitation.vaultStatus == VaultStatus.POPULATED) {34 "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"35 }36 val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())37 return adoptFromRelaySocket(38 socket = socket,39 invitation = invitation,40 deps = deps,41 confirm = confirm,42 windowMs = windowMs,43 )44 }4546 47484950515253 suspend fun grantFromInvitation(54 encodedInvitation: String,55 sourceDeps: AdoptionSourceDeps,56 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,57 windowMs: Long = PAIRING_WINDOW_MS,58 ): PairedDevice {59 val invitation = parsePairingInvitation(encodedInvitation)60 ?: throw IllegalArgumentException("invalid_pairing_invitation")61 require(invitation.vaultStatus == VaultStatus.EMPTY) {62 "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"63 }64 val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())65 return grantFromRelaySocket(66 socket = socket,67 invitation = invitation,68 sourceDeps = sourceDeps,69 confirm = confirm,70 windowMs = windowMs,71 )72 }73}74757677787980818283internal suspend fun adoptFromRelaySocket(84 socket: RelayBinarySocket,85 invitation: PairingInvitation,86 deps: AdoptionReceiverDeps,87 confirm: suspend (sas: String) -> Boolean,88 windowMs: Long = PAIRING_WINDOW_MS,89): PairedDevice {90 var completed = false91 return try {92 val adopted =93 withTimeout(windowMs) {94 runRelayAdoptionReceiverSession(95 socket = socket,96 invitation = invitation,97 receiverDeps = deps,98 confirm = confirm,99 ) ?: throw IllegalStateException("adoption_rejected")100 }101 completed = true102 adopted103 } finally {104 if (completed) {105 socket.closeAfterQueuedFrames()106 } else {107 socket.close()108 }109 }110}111112113114115116117118internal suspend fun grantFromRelaySocket(119 socket: RelayBinarySocket,120 invitation: PairingInvitation,121 sourceDeps: AdoptionSourceDeps,122 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,123 windowMs: Long = PAIRING_WINDOW_MS,124): PairedDevice {125 var completed = false126 return try {127 val granted = withTimeout(windowMs) {128 runRelayAdoptionInitiatorSourceSession(129 socket = socket,130 invitation = invitation,131 sourceDeps = sourceDeps,132 confirm = confirm,133 ) ?: throw IllegalStateException("adoption_rejected")134 }135 completed = true136 granted137 } finally {138 if (completed) {139 socket.closeAfterQueuedFrames()140 } else {141 socket.close()142 }143 }144}145146147148149150internal suspend fun runRelayAdoptionReceiverSession(151 socket: RelayBinarySocket,152 invitation: PairingInvitation,153 receiverDeps: AdoptionReceiverDeps,154 confirm: suspend (sas: String) -> Boolean,155): PairedDevice? {156 socket.send(157 JSONObject()158 .put("type", "PEACH_HELLO")159 .put("token", invitation.token)160 .toString()161 .toByteArray(Charsets.UTF_8),162 )163164 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))165 require(ack.optString("type") == "PEACH_HELLO_ACK") {166 "expected PEACH_HELLO_ACK"167 }168 val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))169 val invitedStatic = invitation.noiseStaticPublicKeyHex170 ?.let(::hexToPubkey)171 ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")172 val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)173 invitedStatic.fill(0)174 if (!staticMatches) {175 sourceStatic.fill(0)176 throw IllegalArgumentException("pairing_static_key_mismatch")177 }178 val noise = try {179 noiseNKInitiator(180 sourceStatic,181 socket::send,182 socket::receive,183 )184 } finally {185 sourceStatic.fill(0)186 }187 try {188 val channel = object : PairingTransport {189 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))190 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())191 override suspend fun close() = socket.close()192 }193 val pairState = AdoptionPairState(194 rendezvousSecretHex = Rendezvous.bytesToHex(195 Rendezvous.deriveSecret(noise.handshakeHash),196 ),197 relayUrl = invitation.relayUrl,198 )199 val boundDeps = object : AdoptionReceiverDeps {200 override val deviceName = receiverDeps.deviceName201 override val deviceType = receiverDeps.deviceType202 override val pairState = pairState203 override fun generateDeviceId() = receiverDeps.generateDeviceId()204 override suspend fun confirmSas(): Boolean =205 confirm(SASDerivation.derive(noise.handshakeHash))206207 override suspend fun validateSource(source: AdoptionFrame.Source) {208 receiverDeps.validateSource(source)209 }210211 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =212 receiverDeps.createVaultFromRootKey(rootKeyHex)213214 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =215 receiverDeps.deriveLocalIdentity()216217 override suspend fun clearPairedDeviceRevocation(deviceId: String) {218 receiverDeps.clearPairedDeviceRevocation(deviceId)219 }220221 override suspend fun upsertPairedDevice(device: PairedDevice) {222 receiverDeps.upsertPairedDevice(device)223 }224225 override suspend fun applyLicense(license: String) {226 receiverDeps.applyLicense(license)227 }228229 override suspend fun license(): String? = receiverDeps.license()230231 override fun onError(error: String) {232 receiverDeps.onError(error)233 }234 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {235 receiverDeps.applySnapshot(entries, tombstones, duressSlot)236 }237 override suspend fun applyIncomingDeviceManifest(238 manifest: SignedDeviceManifest,239 authenticatedRemoteStaticPublicKey: ByteArray,240 ) {241 receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)242 }243 override suspend fun markSynced(deviceId: String) {244 receiverDeps.markSynced(deviceId)245 }246 }247 248 249 250 var sourceCanSendSnapshot = false251 val adopted = requestAdoption(252 channel,253 boundDeps,254 onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },255 ) ?: return null256 if (sourceCanSendSnapshot) {257 258 try {259 val snapshot = withTimeoutOrNull(15_000) {260 receiveVaultSnapshot(channel)261 }262 if (snapshot != null) {263 boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)264 sendFrame(channel, AdoptionFrame.SnapshotApplied(265 entriesReceived = snapshot.entries.size,266 tombstonesReceived = snapshot.tombstones.size,267 ))268 269 270 271 272 273 try {274 val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }275 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {276 val sourceLongTerm = adopted.longTermPublicKeyHex277 if (sourceLongTerm != null && sourceLongTerm.length == 64) {278 boundDeps.applyIncomingDeviceManifest(279 manifestFrame.manifest,280 hexToPubkey(sourceLongTerm),281 )282 }283 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)284 }285 } catch (_: Exception) {}286 boundDeps.markSynced(adopted.deviceId)287 } else {288 PeachSyncLog.i("adoption: snapshot receive timed out")289 }290 } catch (e: Exception) {291 PeachSyncLog.w("adoption: snapshot receive failed — reason=${relayLogReason(e)}")292 }293 }294 return adopted295 } finally {296 noise.close()297 }298}299300301302303304305306307308internal suspend fun runRelayAdoptionInitiatorSourceSession(309 socket: RelayBinarySocket,310 invitation: PairingInvitation,311 sourceDeps: AdoptionSourceDeps,312 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,313): PairedDevice? {314 socket.send(315 JSONObject()316 .put("type", "PEACH_HELLO")317 .put("token", invitation.token)318 .toString()319 .toByteArray(Charsets.UTF_8),320 )321322 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))323 require(ack.optString("type") == "PEACH_HELLO_ACK") {324 "expected PEACH_HELLO_ACK"325 }326 327 328 329 330 331 332 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))333 val invitedStatic = invitation.noiseStaticPublicKeyHex334 ?.let(::hexToPubkey)335 ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")336 val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)337 invitedStatic.fill(0)338 if (!staticMatches) {339 responderStatic.fill(0)340 throw IllegalArgumentException("pairing_static_key_mismatch")341 }342 val noise = try {343 noiseNKInitiator(344 responderStatic,345 socket::send,346 socket::receive,347 )348 } finally {349 responderStatic.fill(0)350 }351 try {352 val channel = object : PairingTransport {353 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))354 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())355 override suspend fun close() = socket.close()356 }357 val request = receiveFrame(channel)358 if (!isAdoptRequest(request)) return null359 request as AdoptionFrame.Request360 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null361362 val pairState = AdoptionPairState(363 rendezvousSecretHex = Rendezvous.bytesToHex(364 Rendezvous.deriveSecret(noise.handshakeHash),365 ),366 relayUrl = invitation.relayUrl,367 )368 val boundDeps = object : AdoptionSourceDeps {369 override val sourceIdentity = sourceDeps.sourceIdentity370 override val pairState = pairState371 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()372 override suspend fun vaultId(): String = sourceDeps.vaultId()373 override suspend fun license(): String? = sourceDeps.license()374 override suspend fun applyLicense(license: String) {375 sourceDeps.applyLicense(license)376 }377 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true378 override suspend fun upsertPairedDevice(device: PairedDevice) {379 sourceDeps.upsertPairedDevice(device)380 }381 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()382 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()383 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)384 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()385 }386 val adopted = grantAdoption(channel, request, boundDeps) ?: return null387 388 if (request.snapshotCapable == true) {389 try {390 val snapshot = boundDeps.vaultSnapshot()391 if (snapshot != null) {392 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()393 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)394 val applied = receiveFrame(channel)395 if (applied is AdoptionFrame.SnapshotApplied) {396 val manifest = boundDeps.outgoingDeviceManifest()397 if (manifest != null) {398 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))399 withTimeoutOrNull(5_000) { receiveFrame(channel) }400 }401 boundDeps.markSynced(adopted.deviceId)402 }403 }404 } catch (error: Exception) {405 if (error is SnapshotDuressFrameTooLargeException) throw error406 if (error is CancellationException) throw error407 PeachSyncLog.w("adoption: snapshot send failed — reason=${relayLogReason(error)}")408 }409 }410 return adopted411 } finally {412 noise.close()413 }414}