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 val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))328 val noise = try {329 noiseNKInitiator(330 responderStatic,331 socket::send,332 socket::receive,333 )334 } finally {335 responderStatic.fill(0)336 }337 try {338 val channel = object : PairingTransport {339 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))340 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())341 override suspend fun close() = socket.close()342 }343 val request = receiveFrame(channel)344 if (!isAdoptRequest(request)) return null345 request as AdoptionFrame.Request346 if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null347348 val pairState = AdoptionPairState(349 rendezvousSecretHex = Rendezvous.bytesToHex(350 Rendezvous.deriveSecret(noise.handshakeHash),351 ),352 relayUrl = invitation.relayUrl,353 )354 val boundDeps = object : AdoptionSourceDeps {355 override val sourceIdentity = sourceDeps.sourceIdentity356 override val pairState = pairState357 override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()358 override suspend fun vaultId(): String = sourceDeps.vaultId()359 override suspend fun license(): String? = sourceDeps.license()360 override suspend fun applyLicense(license: String) {361 sourceDeps.applyLicense(license)362 }363 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true364 override suspend fun upsertPairedDevice(device: PairedDevice) {365 sourceDeps.upsertPairedDevice(device)366 }367 override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()368 override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()369 override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)370 override suspend fun duressSlotJson() = sourceDeps.duressSlotJson()371 }372 val adopted = grantAdoption(channel, request, boundDeps) ?: return null373 374 if (request.snapshotCapable == true) {375 try {376 val snapshot = boundDeps.vaultSnapshot()377 if (snapshot != null) {378 val duressSlot = runCatching { boundDeps.duressSlotJson() }.getOrNull()379 sendVaultSnapshot(channel, snapshot.first, snapshot.second, duressSlot)380 val applied = receiveFrame(channel)381 if (applied is AdoptionFrame.SnapshotApplied) {382 val manifest = boundDeps.outgoingDeviceManifest()383 if (manifest != null) {384 sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))385 withTimeoutOrNull(5_000) { receiveFrame(channel) }386 }387 boundDeps.markSynced(adopted.deviceId)388 }389 }390 } catch (error: Exception) {391 if (error is SnapshotDuressFrameTooLargeException) throw error392 if (error is CancellationException) throw error393 Log.w("PeachAdopt", "source inline snapshot send failed, receiver falls back to sync", error)394 }395 }396 return adopted397 } finally {398 noise.close()399 }400}