1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.encodeToString6import kotlinx.serialization.json.Json78910111213141516171819202122@Serializable23sealed class AdoptionFrame {24 25262728293031323334 @Serializable35 @SerialName("ADOPT_REQUEST")36 data class Request(37 @SerialName("device_id") val sessionRequestId: String,38 @SerialName("device_name") val deviceName: String,39 @SerialName("device_type") val deviceType: String,40 val license: String? = null,41 @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,42 ) : AdoptionFrame()4344 @Serializable45 @SerialName("ADOPT_CONFIRM")46 data class Confirm(47 val confirmed: Boolean,48 ) : AdoptionFrame()4950 @Serializable51 @SerialName("ADOPT_GRANT")52 data class Grant(53 @SerialName("root_key_hex") val rootKeyHex: String,54 @SerialName("vault_id") val vaultId: String,55 val license: String? = null,56 val source: Source,57 58 59 60 61 62 63 64 @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,65 ) : AdoptionFrame()6667 @Serializable68 @SerialName("ADOPT_IDENTITY")69 data class Identity(70 @SerialName("device_id") val deviceId: String,71 @SerialName("device_name") val deviceName: String,72 @SerialName("device_type") val deviceType: String,73 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,74 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,75 ) : AdoptionFrame()7677 7879808182 @Serializable83 @SerialName("ADOPT_COMPLETE")84 data object Complete : AdoptionFrame()8586 87 @Serializable88 @SerialName("ADOPT_APPLIED")89 data object Applied : AdoptionFrame()9091 92 @Serializable93 @SerialName("ADOPT_REJECT")94 data class Reject(95 val reason: String,96 ) : AdoptionFrame()9798 99100101102103 @Serializable104 @SerialName("ADOPT_SNAPSHOT")105 data class Snapshot(106 @SerialName("chunk_index") val chunkIndex: Int,107 @SerialName("chunk_count") val chunkCount: Int,108 val entries: Map<String, String>,109 val tombstones: Map<String, Long>,110 @SerialName("duress_slot") val duressSlot: String? = null,111 ) : AdoptionFrame()112113 114 @Serializable115 @SerialName("ADOPT_SNAPSHOT_APPLIED")116 data class SnapshotApplied(117 @SerialName("entries_received") val entriesReceived: Int,118 @SerialName("tombstones_received") val tombstonesReceived: Int,119 ) : AdoptionFrame()120121 122 @Serializable123 @SerialName("ADOPT_DEVICE_MANIFEST")124 data class DeviceManifestFrame(125 val manifest: SignedDeviceManifest,126 ) : AdoptionFrame()127128 129 @Serializable130 @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")131 data object DeviceManifestApplied : AdoptionFrame()132133 @Serializable134 data class Source(135 @SerialName("device_id") val deviceId: String,136 @SerialName("device_name") val deviceName: String,137 @SerialName("device_type") val deviceType: String,138 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,139 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,140 @SerialName("ws_port") val wsPort: Int? = null,141 @SerialName("relay_url") val relayUrl: String? = null,142 )143}144145data class AdoptionPairState(146 val rendezvousSecretHex: String,147 val relayUrl: String? = null,148)149150151152153154155data class ReceivedSnapshot(156 val entries: Map<String, String>,157 val tombstones: Map<String, Long>,158 val duressSlot: String? = null,159)160161162163164165166167const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 168const val MAX_SNAPSHOT_CHUNKS = 200169170171172private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024173174175interface AdoptionReceiverDeps {176 val deviceName: String177 val deviceType: DeviceType178 fun generateDeviceId(): String179 180 suspend fun confirmSas(): Boolean181 182183184185 suspend fun validateSource(source: AdoptionFrame.Source) {}186187 188 suspend fun createVaultFromRootKey(rootKeyHex: String): String189 suspend fun deriveLocalIdentity(): LocalDeviceIdentity190 191 suspend fun clearPairedDeviceRevocation(deviceId: String) {}192 suspend fun upsertPairedDevice(device: PairedDevice)193 suspend fun applyLicense(license: String) {}194 195 suspend fun license(): String? = null196 val pairState: AdoptionPairState? get() = null197 fun onError(error: String)198 199 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}200 201202203204205206207208 suspend fun applyIncomingDeviceManifest(209 manifest: SignedDeviceManifest,210 authenticatedRemoteStaticPublicKey: ByteArray,211 ) {}212 213 suspend fun markSynced(deviceId: String) {}214}215216217interface AdoptionSourceDeps {218 val sourceIdentity: LocalDeviceIdentity219 suspend fun rootKeyHex(): String220 suspend fun vaultId(): String221 suspend fun license(): String? = null222 223 suspend fun applyLicense(license: String) {}224225 226227228229230231 suspend fun onConsent(request: AdoptionFrame.Request): Boolean232 suspend fun upsertPairedDevice(device: PairedDevice)233 val pairState: AdoptionPairState? get() = null234 235 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null236 237 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null238 239 suspend fun markSynced(deviceId: String) {}240 241 suspend fun duressSlotJson(): String? = null242}243244private val Hex64 = Regex("^[0-9a-f]{64}\$")245246fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)247248fun isAdoptRequest(frame: AdoptionFrame?): Boolean =249 frame is AdoptionFrame.Request &&250 frame.sessionRequestId.isNotEmpty() &&251 isDeviceType(frame.deviceType)252253fun isAdoptGrant(frame: AdoptionFrame?): Boolean =254 frame is AdoptionFrame.Grant &&255 validateRootKeyHex(frame.rootKeyHex) &&256 Hex64.matches(frame.vaultId) &&257 isDeviceType(frame.source.deviceType) &&258 Hex64.matches(frame.source.longTermPublicKeyHex) &&259 (frame.source.relayUrl == null ||260 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)261262fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =263 frame is AdoptionFrame.Confirm264265fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =266 frame is AdoptionFrame.Identity &&267 frame.deviceId.isNotEmpty() &&268 isDeviceType(frame.deviceType) &&269 Hex64.matches(frame.longTermPublicKeyHex)270271fun isAdoptComplete(frame: AdoptionFrame?): Boolean =272 frame is AdoptionFrame.Complete273274fun isAdoptApplied(frame: AdoptionFrame?): Boolean =275 frame is AdoptionFrame.Applied276277fun isAdoptReject(frame: AdoptionFrame?): Boolean =278 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"279280fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =281 frame is AdoptionFrame.Snapshot &&282 frame.chunkIndex >= 0 &&283 frame.chunkCount >= 1284285286287288289290291292suspend fun requestAdoption(293 channel: PairingTransport,294 deps: AdoptionReceiverDeps,295 296 297 298 299 300 onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,301 nowMs: () -> Long = { System.currentTimeMillis() },302): PairedDevice? {303 val receiverLicense = deps.license()304 305 306 sendFrame(channel, AdoptionFrame.Request(307 sessionRequestId = deps.generateDeviceId(),308 deviceName = deps.deviceName,309 deviceType = deps.deviceType.wireValue,310 license = receiverLicense,311 snapshotCapable = true,312 ))313314 val confirmed = deps.confirmSas()315 sendFrame(channel, AdoptionFrame.Confirm(confirmed))316 if (!confirmed) {317 deps.onError("ADOPT_SAS_REJECTED")318 return null319 }320321 val frame = receiveFrame(channel)322 if (isAdoptReject(frame)) {323 deps.onError((frame as AdoptionFrame.Reject).reason)324 return null325 }326 if (!isAdoptGrant(frame)) {327 deps.onError("ADOPT_UNEXPECTED_FRAME")328 return null329 }330 val grant = frame as AdoptionFrame.Grant331 onGrant?.invoke(grant)332333 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)334 try {335 if (computeVaultId(rootKeyBytes) != grant.vaultId) {336 deps.onError("ADOPT_VAULT_ID_MISMATCH")337 return null338 }339 } finally {340 rootKeyBytes.fill(0)341 }342 if (receiverLicense == null && grant.license == null) {343 deps.onError("ADOPT_PRO_REQUIRED")344 return null345 }346347 deps.validateSource(grant.source)348 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)349 if (vaultId != grant.vaultId) {350 deps.onError("ADOPT_VAULT_ID_MISMATCH")351 return null352 }353354 if (receiverLicense == null) {355 grant.license?.let { deps.applyLicense(it) }356 }357358 val identity = deps.deriveLocalIdentity()359 sendFrame(channel, AdoptionFrame.Identity(360 deviceId = identity.deviceId,361 deviceName = identity.deviceName,362 deviceType = identity.deviceType.wireValue,363 longTermPublicKeyHex = identity.longTermPublicKeyHex,364 signingPublicKeyHex = identity.signingPublicKeyHex,365 ))366367 if (!isAdoptComplete(receiveFrame(channel))) {368 deps.onError("ADOPT_COMPLETION_MISSING")369 return null370 }371372 val sourceDevice = PairedDevice(373 deviceId = grant.source.deviceId,374 deviceName = grant.source.deviceName,375 deviceType = grant.source.deviceType,376 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,377 signingPublicKeyHex = grant.source.signingPublicKeyHex,378 trustSource = "direct",379 wsPort = grant.source.wsPort,380 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,381 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,382 pairedAt = nowMs(),383 )384 385 386 387 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)388 deps.upsertPairedDevice(sourceDevice)389 sendFrame(channel, AdoptionFrame.Applied)390 return sourceDevice391}392393394395396397398suspend fun grantAdoption(399 channel: PairingTransport,400 request: AdoptionFrame.Request,401 deps: AdoptionSourceDeps,402 nowMs: () -> Long = { System.currentTimeMillis() },403): PairedDevice? {404 if (!deps.onConsent(request)) return null405406 val confirmation = receiveFrame(channel)407 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {408 return null409 }410411 var sourceLicense = deps.license()412 if (sourceLicense == null && request.license != null) {413 sourceLicense = runCatching {414 deps.applyLicense(request.license)415 deps.license()416 }.getOrNull()417 }418 if (sourceLicense == null) {419 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))420 throw IllegalStateException("ADOPT_PRO_REQUIRED")421 }422423 val rootKeyHex = deps.rootKeyHex()424 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")425426 sendFrame(channel, AdoptionFrame.Grant(427 rootKeyHex = rootKeyHex,428 vaultId = deps.vaultId(),429 license = sourceLicense,430 source = AdoptionFrame.Source(431 deviceId = deps.sourceIdentity.deviceId,432 deviceName = deps.sourceIdentity.deviceName,433 deviceType = deps.sourceIdentity.deviceType.wireValue,434 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,435 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,436 wsPort = deps.sourceIdentity.wsPort,437 relayUrl = deps.pairState?.relayUrl,438 ),439 snapshotCapable = request.snapshotCapable == true,440 ))441442 val identity = receiveFrame(channel)443 if (!isAdoptIdentity(identity)) return null444 identity as AdoptionFrame.Identity445446 val device = PairedDevice(447 deviceId = identity.deviceId,448 deviceName = identity.deviceName,449 deviceType = identity.deviceType,450 longTermPublicKeyHex = identity.longTermPublicKeyHex,451 signingPublicKeyHex = identity.signingPublicKeyHex,452 trustSource = "direct",453 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,454 relayUrl = deps.pairState?.relayUrl,455 pairedAt = nowMs(),456 )457 deps.upsertPairedDevice(device)458 sendFrame(channel, AdoptionFrame.Complete)459 if (!isAdoptApplied(receiveFrame(channel))) return null460 return device461}462463464465466467468val AdoptJson: Json = Json {469 classDiscriminator = "type"470 encodeDefaults = false471 ignoreUnknownKeys = true472}473474suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {475 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))476}477478479suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =480 runCatching {481 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))482 }.getOrNull()483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517suspend fun sendVaultSnapshot(518 channel: PairingTransport,519 entries: Map<String, String>,520 tombstones: Map<String, Long>,521 duressSlot: String? = null,522) {523 val includeDuress = !duressSlot.isNullOrBlank()524 525 526 527 528 529 val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS530531 val chunks = mutableListOf<Map<String, String>>()532 var current = LinkedHashMap<String, String>()533 var currentBytes = 2 534 for ((id, envelope) in entries) {535 536 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1537 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {538 chunks.add(current)539 current = LinkedHashMap()540 currentBytes = 2541 }542 current[id] = envelope543 currentBytes += entryBytes544 }545 chunks.add(current)546547 val entriesChunkCount = minOf(chunks.size, entryCap)548 val sendDuress = includeDuress549 val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)550 val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(551 chunkIndex = entriesChunkCount,552 chunkCount = totalChunks,553 entries = emptyMap(),554 tombstones = tombstones,555 duressSlot = duressSlot,556 ) else null557558 if (559 duressFrame != null &&560 AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)561 .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING562 ) {563 throw SnapshotDuressFrameTooLargeException()564 }565566 for (index in 0 until entriesChunkCount) {567 val isFinalEntries = index == entriesChunkCount - 1568 sendFrame(channel, AdoptionFrame.Snapshot(569 chunkIndex = index,570 chunkCount = totalChunks,571 entries = chunks[index],572 573 574 575 tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),576 duressSlot = null,577 ))578 }579580 if (duressFrame != null) sendFrame(channel, duressFrame)581}582583internal class SnapshotDuressFrameTooLargeException :584 IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")585586587588589590591592593suspend fun receiveVaultSnapshot(594 channel: PairingTransport,595): ReceivedSnapshot {596 val entries = LinkedHashMap<String, String>()597 val tombstones = LinkedHashMap<String, Long>()598 var duressSlot: String? = null599 var chunkCount = -1600 var received = 0601 while (chunkCount == -1 || received < chunkCount) {602 val raw = channel.receive()603 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {604 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")605 }606 val frame = runCatching {607 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))608 }.getOrNull()609 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")610 frame as AdoptionFrame.Snapshot611 if (chunkCount == -1) {612 chunkCount = frame.chunkCount613 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {614 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")615 }616 } else if (frame.chunkCount != chunkCount) {617 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")618 }619 if (frame.chunkIndex != received) {620 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")621 }622 entries.putAll(frame.entries)623 tombstones.putAll(frame.tombstones)624 if (frame.duressSlot != null) duressSlot = frame.duressSlot625 received++626 }627 return ReceivedSnapshot(entries, tombstones, duressSlot)628}629630private fun isDeviceType(value: String): Boolean =631 DeviceType.entries.any { it.wireValue == value }