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 201 suspend fun markSynced(deviceId: String) {}202}203204205interface AdoptionSourceDeps {206 val sourceIdentity: LocalDeviceIdentity207 suspend fun rootKeyHex(): String208 suspend fun vaultId(): String209 suspend fun license(): String? = null210 211 suspend fun applyLicense(license: String) {}212213 214215216217218219 suspend fun onConsent(request: AdoptionFrame.Request): Boolean220 suspend fun upsertPairedDevice(device: PairedDevice)221 val pairState: AdoptionPairState? get() = null222 223 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null224 225 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null226 227 suspend fun markSynced(deviceId: String) {}228 229 suspend fun duressSlotJson(): String? = null230}231232private val Hex64 = Regex("^[0-9a-f]{64}\$")233234fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)235236fun isAdoptRequest(frame: AdoptionFrame?): Boolean =237 frame is AdoptionFrame.Request &&238 frame.sessionRequestId.isNotEmpty() &&239 isDeviceType(frame.deviceType)240241fun isAdoptGrant(frame: AdoptionFrame?): Boolean =242 frame is AdoptionFrame.Grant &&243 validateRootKeyHex(frame.rootKeyHex) &&244 Hex64.matches(frame.vaultId) &&245 isDeviceType(frame.source.deviceType) &&246 Hex64.matches(frame.source.longTermPublicKeyHex) &&247 (frame.source.relayUrl == null ||248 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)249250fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =251 frame is AdoptionFrame.Confirm252253fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =254 frame is AdoptionFrame.Identity &&255 frame.deviceId.isNotEmpty() &&256 isDeviceType(frame.deviceType) &&257 Hex64.matches(frame.longTermPublicKeyHex)258259fun isAdoptComplete(frame: AdoptionFrame?): Boolean =260 frame is AdoptionFrame.Complete261262fun isAdoptApplied(frame: AdoptionFrame?): Boolean =263 frame is AdoptionFrame.Applied264265fun isAdoptReject(frame: AdoptionFrame?): Boolean =266 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"267268fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =269 frame is AdoptionFrame.Snapshot &&270 frame.chunkIndex >= 0 &&271 frame.chunkCount >= 1272273274275276277278279280suspend fun requestAdoption(281 channel: PairingTransport,282 deps: AdoptionReceiverDeps,283 284 285 286 287 288 onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,289 nowMs: () -> Long = { System.currentTimeMillis() },290): PairedDevice? {291 val receiverLicense = deps.license()292 293 294 sendFrame(channel, AdoptionFrame.Request(295 sessionRequestId = deps.generateDeviceId(),296 deviceName = deps.deviceName,297 deviceType = deps.deviceType.wireValue,298 license = receiverLicense,299 snapshotCapable = true,300 ))301302 val confirmed = deps.confirmSas()303 sendFrame(channel, AdoptionFrame.Confirm(confirmed))304 if (!confirmed) {305 deps.onError("ADOPT_SAS_REJECTED")306 return null307 }308309 val frame = receiveFrame(channel)310 if (isAdoptReject(frame)) {311 deps.onError((frame as AdoptionFrame.Reject).reason)312 return null313 }314 if (!isAdoptGrant(frame)) {315 deps.onError("ADOPT_UNEXPECTED_FRAME")316 return null317 }318 val grant = frame as AdoptionFrame.Grant319 onGrant?.invoke(grant)320321 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)322 try {323 if (computeVaultId(rootKeyBytes) != grant.vaultId) {324 deps.onError("ADOPT_VAULT_ID_MISMATCH")325 return null326 }327 } finally {328 rootKeyBytes.fill(0)329 }330 if (receiverLicense == null && grant.license == null) {331 deps.onError("ADOPT_PRO_REQUIRED")332 return null333 }334335 deps.validateSource(grant.source)336 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)337 if (vaultId != grant.vaultId) {338 deps.onError("ADOPT_VAULT_ID_MISMATCH")339 return null340 }341342 if (receiverLicense == null) {343 grant.license?.let { deps.applyLicense(it) }344 }345346 val identity = deps.deriveLocalIdentity()347 sendFrame(channel, AdoptionFrame.Identity(348 deviceId = identity.deviceId,349 deviceName = identity.deviceName,350 deviceType = identity.deviceType.wireValue,351 longTermPublicKeyHex = identity.longTermPublicKeyHex,352 signingPublicKeyHex = identity.signingPublicKeyHex,353 ))354355 if (!isAdoptComplete(receiveFrame(channel))) {356 deps.onError("ADOPT_COMPLETION_MISSING")357 return null358 }359360 val sourceDevice = PairedDevice(361 deviceId = grant.source.deviceId,362 deviceName = grant.source.deviceName,363 deviceType = grant.source.deviceType,364 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,365 signingPublicKeyHex = grant.source.signingPublicKeyHex,366 trustSource = "direct",367 wsPort = grant.source.wsPort,368 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,369 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,370 pairedAt = nowMs(),371 )372 373 374 375 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)376 deps.upsertPairedDevice(sourceDevice)377 sendFrame(channel, AdoptionFrame.Applied)378 return sourceDevice379}380381382383384385386suspend fun grantAdoption(387 channel: PairingTransport,388 request: AdoptionFrame.Request,389 deps: AdoptionSourceDeps,390 nowMs: () -> Long = { System.currentTimeMillis() },391): PairedDevice? {392 if (!deps.onConsent(request)) return null393394 val confirmation = receiveFrame(channel)395 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {396 return null397 }398399 var sourceLicense = deps.license()400 if (sourceLicense == null && request.license != null) {401 sourceLicense = runCatching {402 deps.applyLicense(request.license)403 deps.license()404 }.getOrNull()405 }406 if (sourceLicense == null) {407 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))408 throw IllegalStateException("ADOPT_PRO_REQUIRED")409 }410411 val rootKeyHex = deps.rootKeyHex()412 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")413414 sendFrame(channel, AdoptionFrame.Grant(415 rootKeyHex = rootKeyHex,416 vaultId = deps.vaultId(),417 license = sourceLicense,418 source = AdoptionFrame.Source(419 deviceId = deps.sourceIdentity.deviceId,420 deviceName = deps.sourceIdentity.deviceName,421 deviceType = deps.sourceIdentity.deviceType.wireValue,422 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,423 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,424 wsPort = deps.sourceIdentity.wsPort,425 relayUrl = deps.pairState?.relayUrl,426 ),427 snapshotCapable = request.snapshotCapable == true,428 ))429430 val identity = receiveFrame(channel)431 if (!isAdoptIdentity(identity)) return null432 identity as AdoptionFrame.Identity433434 val device = PairedDevice(435 deviceId = identity.deviceId,436 deviceName = identity.deviceName,437 deviceType = identity.deviceType,438 longTermPublicKeyHex = identity.longTermPublicKeyHex,439 signingPublicKeyHex = identity.signingPublicKeyHex,440 trustSource = "direct",441 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,442 relayUrl = deps.pairState?.relayUrl,443 pairedAt = nowMs(),444 )445 deps.upsertPairedDevice(device)446 sendFrame(channel, AdoptionFrame.Complete)447 if (!isAdoptApplied(receiveFrame(channel))) return null448 return device449}450451452453454455456val AdoptJson: Json = Json {457 classDiscriminator = "type"458 encodeDefaults = false459 ignoreUnknownKeys = true460}461462suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {463 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))464}465466467suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =468 runCatching {469 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))470 }.getOrNull()471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505suspend fun sendVaultSnapshot(506 channel: PairingTransport,507 entries: Map<String, String>,508 tombstones: Map<String, Long>,509 duressSlot: String? = null,510) {511 val includeDuress = !duressSlot.isNullOrBlank()512 513 514 515 516 517 val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS518519 val chunks = mutableListOf<Map<String, String>>()520 var current = LinkedHashMap<String, String>()521 var currentBytes = 2 522 for ((id, envelope) in entries) {523 524 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1525 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {526 chunks.add(current)527 current = LinkedHashMap()528 currentBytes = 2529 }530 current[id] = envelope531 currentBytes += entryBytes532 }533 chunks.add(current)534535 val entriesChunkCount = minOf(chunks.size, entryCap)536 val sendDuress = includeDuress537 val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)538539 for (index in 0 until entriesChunkCount) {540 val isFinalEntries = index == entriesChunkCount - 1541 sendFrame(channel, AdoptionFrame.Snapshot(542 chunkIndex = index,543 chunkCount = totalChunks,544 entries = chunks[index],545 546 547 548 tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),549 duressSlot = null,550 ))551 }552553 if (sendDuress) {554 555 556 557 558 559 val duressEnvelope = (AdoptJson.encodeToString(AdoptionFrame.Snapshot(560 chunkIndex = entriesChunkCount,561 chunkCount = totalChunks,562 entries = emptyMap(),563 tombstones = tombstones,564 duressSlot = duressSlot,565 ))).encodeToByteArray()566 if (duressEnvelope.size > SNAPSHOT_FRAME_WIRE_CEILING) {567 throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")568 }569 sendFrame(channel, AdoptionFrame.Snapshot(570 chunkIndex = entriesChunkCount,571 chunkCount = totalChunks,572 entries = emptyMap(),573 tombstones = tombstones,574 duressSlot = duressSlot,575 ))576 }577}578579580581582583584585586suspend fun receiveVaultSnapshot(587 channel: PairingTransport,588): ReceivedSnapshot {589 val entries = LinkedHashMap<String, String>()590 val tombstones = LinkedHashMap<String, Long>()591 var duressSlot: String? = null592 var chunkCount = -1593 var received = 0594 while (chunkCount == -1 || received < chunkCount) {595 val raw = channel.receive()596 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {597 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")598 }599 val frame = runCatching {600 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))601 }.getOrNull()602 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")603 frame as AdoptionFrame.Snapshot604 if (chunkCount == -1) {605 chunkCount = frame.chunkCount606 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {607 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")608 }609 } else if (frame.chunkCount != chunkCount) {610 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")611 }612 if (frame.chunkIndex != received) {613 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")614 }615 entries.putAll(frame.entries)616 tombstones.putAll(frame.tombstones)617 if (frame.duressSlot != null) duressSlot = frame.duressSlot618 received++619 }620 return ReceivedSnapshot(entries, tombstones, duressSlot)621}622623private fun isDeviceType(value: String): Boolean =624 DeviceType.entries.any { it.wireValue == value }