1package com.peachpasswords.sync23import android.util.Log4import java.security.MessageDigest5import javax.inject.Inject6import javax.inject.Singleton7import kotlinx.coroutines.CancellationException8import kotlinx.coroutines.withTimeout9import kotlinx.coroutines.withTimeoutOrNull10import org.json.JSONObject11import com.peachpasswords.sync.hexToPubkey1213141516171819@Singleton20class RelayAdoptionReceiver @Inject constructor() {21 2223242526 suspend fun adopt(27 encodedInvitation: String,28 deps: AdoptionReceiverDeps,29 confirm: suspend (sas: String) -> Boolean,30 windowMs: Long = PAIRING_WINDOW_MS,31 ): PairedDevice {32 val invitation = parsePairingInvitation(encodedInvitation)33 ?: throw IllegalArgumentException("invalid_pairing_invitation")34 require(invitation.vaultStatus == VaultStatus.POPULATED) {35 "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"36 }37 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())38 return adoptFromRelaySocket(39 socket = socket,40 invitation = invitation,41 deps = deps,42 confirm = confirm,43 windowMs = windowMs,44 )45 }4647 48495051525354 suspend fun grantFromInvitation(55 encodedInvitation: String,56 sourceDeps: AdoptionSourceDeps,57 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,58 windowMs: Long = PAIRING_WINDOW_MS,59 ): PairedDevice {60 val invitation = parsePairingInvitation(encodedInvitation)61 ?: throw IllegalArgumentException("invalid_pairing_invitation")62 require(invitation.vaultStatus == VaultStatus.EMPTY) {63 "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"64 }65 val socket = OkHttpRelaySocket.connect(invitation.roomUrl())66 return grantFromRelaySocket(67 socket = socket,68 invitation = invitation,69 sourceDeps = sourceDeps,70 confirm = confirm,71 windowMs = windowMs,72 )73 }74}75767778798081828384internal suspend fun adoptFromRelaySocket(85 socket: RelayBinarySocket,86 invitation: PairingInvitation,87 deps: AdoptionReceiverDeps,88 confirm: suspend (sas: String) -> Boolean,89 windowMs: Long = PAIRING_WINDOW_MS,90): PairedDevice {91 var completed = false92 return try {93 val adopted =94 withTimeout(windowMs) {95 runRelayAdoptionReceiverSession(96 socket = socket,97 invitation = invitation,98 receiverDeps = deps,99 confirm = confirm,100 ) ?: throw IllegalStateException("adoption_rejected")101 }102 completed = true103 adopted104 } finally {105 if (completed) {106 socket.closeAfterQueuedFrames()107 } else {108 socket.close()109 }110 }111}112113114115116117118119internal suspend fun grantFromRelaySocket(120 socket: RelayBinarySocket,121 invitation: PairingInvitation,122 sourceDeps: AdoptionSourceDeps,123 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,124 windowMs: Long = PAIRING_WINDOW_MS,125): PairedDevice {126 var completed = false127 return try {128 val granted = withTimeout(windowMs) {129 runRelayAdoptionInitiatorSourceSession(130 socket = socket,131 invitation = invitation,132 sourceDeps = sourceDeps,133 confirm = confirm,134 ) ?: throw IllegalStateException("adoption_rejected")135 }136 completed = true137 granted138 } finally {139 if (completed) {140 socket.closeAfterQueuedFrames()141 } else {142 socket.close()143 }144 }145}146147148149150151internal suspend fun runRelayAdoptionReceiverSession(152 socket: RelayBinarySocket,153 invitation: PairingInvitation,154 receiverDeps: AdoptionReceiverDeps,155 confirm: suspend (sas: String) -> Boolean,156): PairedDevice? {157 socket.send(158 JSONObject()159 .put("type", "PEACH_HELLO")160 .put("token", invitation.token)161 .toString()162 .toByteArray(Charsets.UTF_8),163 )164165 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))166 require(ack.optString("type") == "PEACH_HELLO_ACK") {167 "expected PEACH_HELLO_ACK"168 }169 val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))170 val invitedStatic = invitation.noiseStaticPublicKeyHex171 ?.let(::hexToPubkey)172 ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")173 val staticMatches = MessageDigest.isEqual(sourceStatic, invitedStatic)174 invitedStatic.fill(0)175 if (!staticMatches) {176 sourceStatic.fill(0)177 throw IllegalArgumentException("pairing_static_key_mismatch")178 }179 val noise = try {180 noiseNKInitiator(181 sourceStatic,182 socket::send,183 socket::receive,184 )185 } finally {186 sourceStatic.fill(0)187 }188 try {189 val channel = object : PairingTransport {190 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))191 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())192 override suspend fun close() = socket.close()193 }194 val pairState = AdoptionPairState(195 rendezvousSecretHex = Rendezvous.bytesToHex(196 Rendezvous.deriveSecret(noise.handshakeHash),197 ),198 relayUrl = invitation.relayUrl,199 )200 val boundDeps = object : AdoptionReceiverDeps {201 override val deviceName = receiverDeps.deviceName202 override val deviceType = receiverDeps.deviceType203 override val pairState = pairState204 override fun generateDeviceId() = receiverDeps.generateDeviceId()205 override suspend fun confirmSas(): Boolean =206 confirm(SASDerivation.derive(noise.handshakeHash))207208 override suspend fun validateSource(source: AdoptionFrame.Source) {209 receiverDeps.validateSource(source)210 }211212 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =213 receiverDeps.createVaultFromRootKey(rootKeyHex)214215 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity =216 receiverDeps.deriveLocalIdentity()217218 override suspend fun clearPairedDeviceRevocation(deviceId: String) {219 receiverDeps.clearPairedDeviceRevocation(deviceId)220 }221222 override suspend fun upsertPairedDevice(device: PairedDevice) {223 receiverDeps.upsertPairedDevice(device)224 }225226 override suspend fun applyLicense(license: String) {227 receiverDeps.applyLicense(license)228 }229230 override suspend fun license(): String? = receiverDeps.license()231232 override fun onError(error: String) {233 receiverDeps.onError(error)234 }235 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {236 receiverDeps.applySnapshot(entries, tombstones, duressSlot)237 }238 override suspend fun applyIncomingDeviceManifest(239 manifest: SignedDeviceManifest,240 authenticatedRemoteStaticPublicKey: ByteArray,241 ) {242 receiverDeps.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)243 }244 override suspend fun markSynced(deviceId: String) {245 receiverDeps.markSynced(deviceId)246 }247 }248 249 250 251 var sourceCanSendSnapshot = false252 val adopted = requestAdoption(253 channel,254 boundDeps,255 onGrant = { grant -> sourceCanSendSnapshot = grant.snapshotCapable },256 ) ?: return null257 if (sourceCanSendSnapshot) {258 259 try {260 val snapshot = withTimeoutOrNull(15_000) {261 receiveVaultSnapshot(channel)262 }263 if (snapshot != null) {264 boundDeps.applySnapshot(snapshot.entries, snapshot.tombstones, snapshot.duressSlot)265 sendFrame(channel, AdoptionFrame.SnapshotApplied(266 entriesReceived = snapshot.entries.size,267 tombstonesReceived = snapshot.tombstones.size,268 ))269 270 271 272 273 274 try {275 val manifestFrame = withTimeoutOrNull(5_000) { receiveFrame(channel) }276 if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {277 val sourceLongTerm = adopted.longTermPublicKeyHex278 if (sourceLongTerm != null && sourceLongTerm.length == 64) {279 boundDeps.applyIncomingDeviceManifest(280 manifestFrame.manifest,281 hexToPubkey(sourceLongTerm),282 )283 }284 sendFrame(channel, AdoptionFrame.DeviceManifestApplied)285 }286 } catch (_: Exception) {}287 boundDeps.markSynced(adopted.deviceId)288 } else {289 Log.i("PeachAdopt", "receiver inline snapshot timed out, falling back to sync")290 }291 } catch (e: Exception) {292 Log.w("PeachAdopt", "receiver inline snapshot failed, falling back to sync", e)293 }294 }295 return adopted296 } finally {297 noise.close()298 }299}300301302303304305306307308309internal suspend fun runRelayAdoptionInitiatorSourceSession(310 socket: RelayBinarySocket,311 invitation: PairingInvitation,312 sourceDeps: AdoptionSourceDeps,313 confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,314): PairedDevice? {315 socket.send(316 JSONObject()317 .put("type", "PEACH_HELLO")318 .put("token", invitation.token)319 .toString()320 .toByteArray(Charsets.UTF_8),321 )322323 val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))324 require(ack.optString("type") == "PEACH_HELLO_ACK") {325 "expected PEACH_HELLO_ACK"326 }327 328 329 330 331 332 333 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))334 val invitedStatic = invitation.noiseStaticPublicKeyHex335 ?.let(::hexToPubkey)336 ?: throw IllegalArgumentException("pairing_invitation_missing_static_key")337 val staticMatches = MessageDigest.isEqual(responderStatic, invitedStatic)338 invitedStatic.fill(0)339 if (!staticMatches) {340 responderStatic.fill(0)341 throw IllegalArgumentException("pairing_static_key_mismatch")342 }343 val noise = try {344 noiseNKInitiator(345 responderStatic,346 socket::send,347 socket::receive,348 )349 } finally {350 responderStatic.fill(0)351 }352 try {353 val channel = object : PairingTransport {354 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))355 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())356 override suspend fun close() = socket.close()357 }358 val request = receiveFrame(channel)359 if (!isAdoptRequest(request)) return null360 request as AdoptionFrame.Request361 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null362363 val pairState = AdoptionPairState(364 rendezvousSecretHex = Rendezvous.bytesToHex(365 Rendezvous.deriveSecret(noise.handshakeHash),366 ),367 relayUrl = invitation.relayUrl,368 )369 val boundDeps = object : AdoptionSourceDeps {370 override val sourceIdentity = sourceDeps.sourceIdentity371 override val pairState = pairState372 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()373 override suspend fun vaultId(): String = sourceDeps.vaultId()374 override suspend fun license(): String? = sourceDeps.license()375 override suspend fun applyLicense(license: String) {376 sourceDeps.applyLicense(license)377 }378 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true379 override suspend fun upsertPairedDevice(device: PairedDevice) {380 sourceDeps.upsertPairedDevice(device)381 }382 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()383 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()384 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)385 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()386 }387 val adopted = grantAdoption(channel, request, boundDeps) ?: return null388 389 if (request.snapshotCapable == true) {390 try {391 val snapshot = boundDeps.vaultSnapshot()392 if (snapshot != null) {393 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()394 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)395 val applied = receiveFrame(channel)396 if (applied is AdoptionFrame.SnapshotApplied) {397 val manifest = boundDeps.outgoingDeviceManifest()398 if (manifest != null) {399 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))400 withTimeoutOrNull(5_000) { receiveFrame(channel) }401 }402 boundDeps.markSynced(adopted.deviceId)403 }404 }405 } catch (error: Exception) {406 if (error is SnapshotDuressFrameTooLargeException) throw error407 if (error is CancellationException) throw error408 Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)409 }410 }411 return adopted412 } finally {413 noise.close()414 }415}