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 @SerialName("snapshot_capable") val snapshotCapable: Boolean = false,63 ) : AdoptionFrame()6465 @Serializable66 @SerialName("ADOPT_IDENTITY")67 data class Identity(68 @SerialName("device_id") val deviceId: String,69 @SerialName("device_name") val deviceName: String,70 @SerialName("device_type") val deviceType: String,71 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,72 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,73 ) : AdoptionFrame()7475 7677787980 @Serializable81 @SerialName("ADOPT_COMPLETE")82 data object Complete : AdoptionFrame()8384 85 @Serializable86 @SerialName("ADOPT_APPLIED")87 data object Applied : AdoptionFrame()8889 90 @Serializable91 @SerialName("ADOPT_REJECT")92 data class Reject(93 val reason: String,94 ) : AdoptionFrame()9596 979899100101 @Serializable102 @SerialName("ADOPT_SNAPSHOT")103 data class Snapshot(104 @SerialName("chunk_index") val chunkIndex: Int,105 @SerialName("chunk_count") val chunkCount: Int,106 val entries: Map<String, String>,107 val tombstones: Map<String, Long>,108 @SerialName("duress_slot") val duressSlot: String? = null,109 ) : AdoptionFrame()110111 112 @Serializable113 @SerialName("ADOPT_SNAPSHOT_APPLIED")114 data class SnapshotApplied(115 @SerialName("entries_received") val entriesReceived: Int,116 @SerialName("tombstones_received") val tombstonesReceived: Int,117 ) : AdoptionFrame()118119 120 @Serializable121 @SerialName("ADOPT_DEVICE_MANIFEST")122 data class DeviceManifestFrame(123 val manifest: SignedDeviceManifest,124 ) : AdoptionFrame()125126 127 @Serializable128 @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")129 data object DeviceManifestApplied : AdoptionFrame()130131 @Serializable132 data class Source(133 @SerialName("device_id") val deviceId: String,134 @SerialName("device_name") val deviceName: String,135 @SerialName("device_type") val deviceType: String,136 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,137 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,138 @SerialName("ws_port") val wsPort: Int? = null,139 @SerialName("relay_url") val relayUrl: String? = null,140 )141}142143data class AdoptionPairState(144 val rendezvousSecretHex: String,145 val relayUrl: String? = null,146)147148149150151152153data class ReceivedSnapshot(154 val entries: Map<String, String>,155 val tombstones: Map<String, Long>,156 val duressSlot: String? = null,157)158159160161162163164165const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 166const val MAX_SNAPSHOT_CHUNKS = 200167168169170private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024171172173interface AdoptionReceiverDeps {174 val deviceName: String175 val deviceType: DeviceType176 fun generateDeviceId(): String177 178 suspend fun confirmSas(): Boolean179 180181182183 suspend fun validateSource(source: AdoptionFrame.Source) {}184185 186 suspend fun createVaultFromRootKey(rootKeyHex: String): String187 suspend fun deriveLocalIdentity(): LocalDeviceIdentity188 189 suspend fun clearPairedDeviceRevocation(deviceId: String) {}190 suspend fun upsertPairedDevice(device: PairedDevice)191 suspend fun applyLicense(license: String) {}192 193 suspend fun license(): String? = null194 val pairState: AdoptionPairState? get() = null195 fun onError(error: String)196 197 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}198 199 suspend fun markSynced(deviceId: String) {}200}201202203interface AdoptionSourceDeps {204 val sourceIdentity: LocalDeviceIdentity205 suspend fun rootKeyHex(): String206 suspend fun vaultId(): String207 suspend fun license(): String? = null208 209 suspend fun applyLicense(license: String) {}210211 212213214215216217 suspend fun onConsent(request: AdoptionFrame.Request): Boolean218 suspend fun upsertPairedDevice(device: PairedDevice)219 val pairState: AdoptionPairState? get() = null220 221 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null222 223 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null224 225 suspend fun markSynced(deviceId: String) {}226 227 suspend fun duressSlotJson(): String? = null228}229230private val Hex64 = Regex("^[0-9a-f]{64}\$")231232fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)233234fun isAdoptRequest(frame: AdoptionFrame?): Boolean =235 frame is AdoptionFrame.Request &&236 frame.sessionRequestId.isNotEmpty() &&237 isDeviceType(frame.deviceType)238239fun isAdoptGrant(frame: AdoptionFrame?): Boolean =240 frame is AdoptionFrame.Grant &&241 validateRootKeyHex(frame.rootKeyHex) &&242 Hex64.matches(frame.vaultId) &&243 isDeviceType(frame.source.deviceType) &&244 Hex64.matches(frame.source.longTermPublicKeyHex) &&245 (frame.source.relayUrl == null ||246 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)247248fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =249 frame is AdoptionFrame.Confirm250251fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =252 frame is AdoptionFrame.Identity &&253 frame.deviceId.isNotEmpty() &&254 isDeviceType(frame.deviceType) &&255 Hex64.matches(frame.longTermPublicKeyHex)256257fun isAdoptComplete(frame: AdoptionFrame?): Boolean =258 frame is AdoptionFrame.Complete259260fun isAdoptApplied(frame: AdoptionFrame?): Boolean =261 frame is AdoptionFrame.Applied262263fun isAdoptReject(frame: AdoptionFrame?): Boolean =264 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"265266fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =267 frame is AdoptionFrame.Snapshot &&268 frame.chunkIndex >= 0 &&269 frame.chunkCount >= 1270271272273274275276277278suspend fun requestAdoption(279 channel: PairingTransport,280 deps: AdoptionReceiverDeps,281 282 283 284 285 286 onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,287 nowMs: () -> Long = { System.currentTimeMillis() },288): PairedDevice? {289 val receiverLicense = deps.license()290 291 292 sendFrame(channel, AdoptionFrame.Request(293 sessionRequestId = deps.generateDeviceId(),294 deviceName = deps.deviceName,295 deviceType = deps.deviceType.wireValue,296 license = receiverLicense,297 snapshotCapable = true,298 ))299300 val confirmed = deps.confirmSas()301 sendFrame(channel, AdoptionFrame.Confirm(confirmed))302 if (!confirmed) {303 deps.onError("ADOPT_SAS_REJECTED")304 return null305 }306307 val frame = receiveFrame(channel)308 if (isAdoptReject(frame)) {309 deps.onError((frame as AdoptionFrame.Reject).reason)310 return null311 }312 if (!isAdoptGrant(frame)) {313 deps.onError("ADOPT_UNEXPECTED_FRAME")314 return null315 }316 val grant = frame as AdoptionFrame.Grant317 onGrant?.invoke(grant)318319 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)320 try {321 if (computeVaultId(rootKeyBytes) != grant.vaultId) {322 deps.onError("ADOPT_VAULT_ID_MISMATCH")323 return null324 }325 } finally {326 rootKeyBytes.fill(0)327 }328 if (receiverLicense == null && grant.license == null) {329 deps.onError("ADOPT_PRO_REQUIRED")330 return null331 }332333 deps.validateSource(grant.source)334 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)335 if (vaultId != grant.vaultId) {336 deps.onError("ADOPT_VAULT_ID_MISMATCH")337 return null338 }339340 if (receiverLicense == null) {341 grant.license?.let { deps.applyLicense(it) }342 }343344 val identity = deps.deriveLocalIdentity()345 sendFrame(channel, AdoptionFrame.Identity(346 deviceId = identity.deviceId,347 deviceName = identity.deviceName,348 deviceType = identity.deviceType.wireValue,349 longTermPublicKeyHex = identity.longTermPublicKeyHex,350 signingPublicKeyHex = identity.signingPublicKeyHex,351 ))352353 if (!isAdoptComplete(receiveFrame(channel))) {354 deps.onError("ADOPT_COMPLETION_MISSING")355 return null356 }357358 val sourceDevice = PairedDevice(359 deviceId = grant.source.deviceId,360 deviceName = grant.source.deviceName,361 deviceType = grant.source.deviceType,362 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,363 signingPublicKeyHex = grant.source.signingPublicKeyHex,364 trustSource = "direct",365 wsPort = grant.source.wsPort,366 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,367 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,368 pairedAt = nowMs(),369 )370 371 372 373 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)374 deps.upsertPairedDevice(sourceDevice)375 sendFrame(channel, AdoptionFrame.Applied)376 return sourceDevice377}378379380381382383384suspend fun grantAdoption(385 channel: PairingTransport,386 request: AdoptionFrame.Request,387 deps: AdoptionSourceDeps,388 nowMs: () -> Long = { System.currentTimeMillis() },389): PairedDevice? {390 if (!deps.onConsent(request)) return null391392 val confirmation = receiveFrame(channel)393 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {394 return null395 }396397 var sourceLicense = deps.license()398 if (sourceLicense == null && request.license != null) {399 sourceLicense = runCatching {400 deps.applyLicense(request.license)401 deps.license()402 }.getOrNull()403 }404 if (sourceLicense == null) {405 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))406 throw IllegalStateException("ADOPT_PRO_REQUIRED")407 }408409 val rootKeyHex = deps.rootKeyHex()410 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")411412 sendFrame(channel, AdoptionFrame.Grant(413 rootKeyHex = rootKeyHex,414 vaultId = deps.vaultId(),415 license = sourceLicense,416 source = AdoptionFrame.Source(417 deviceId = deps.sourceIdentity.deviceId,418 deviceName = deps.sourceIdentity.deviceName,419 deviceType = deps.sourceIdentity.deviceType.wireValue,420 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,421 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,422 wsPort = deps.sourceIdentity.wsPort,423 relayUrl = deps.pairState?.relayUrl,424 ),425 snapshotCapable = request.snapshotCapable == true,426 ))427428 val identity = receiveFrame(channel)429 if (!isAdoptIdentity(identity)) return null430 identity as AdoptionFrame.Identity431432 val device = PairedDevice(433 deviceId = identity.deviceId,434 deviceName = identity.deviceName,435 deviceType = identity.deviceType,436 longTermPublicKeyHex = identity.longTermPublicKeyHex,437 signingPublicKeyHex = identity.signingPublicKeyHex,438 trustSource = "direct",439 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,440 relayUrl = deps.pairState?.relayUrl,441 pairedAt = nowMs(),442 )443 deps.upsertPairedDevice(device)444 sendFrame(channel, AdoptionFrame.Complete)445 if (!isAdoptApplied(receiveFrame(channel))) return null446 return device447}448449450451452453454val AdoptJson: Json = Json {455 classDiscriminator = "type"456 encodeDefaults = false457 ignoreUnknownKeys = true458}459460suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {461 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))462}463464465suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =466 runCatching {467 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))468 }.getOrNull()469470471472473474475476477suspend fun sendVaultSnapshot(478 channel: PairingTransport,479 entries: Map<String, String>,480 tombstones: Map<String, Long>,481 duressSlot: String? = null,482) {483 val chunks = mutableListOf<Map<String, String>>()484 var current = LinkedHashMap<String, String>()485 var currentBytes = 2 486 for ((id, envelope) in entries) {487 488 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1489 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {490 chunks.add(current)491 current = LinkedHashMap()492 currentBytes = 2493 }494 current[id] = envelope495 currentBytes += entryBytes496 }497 chunks.add(current)498499 val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)500 for (index in 0 until chunkCount) {501 val isFinal = index == chunkCount - 1502 sendFrame(channel, AdoptionFrame.Snapshot(503 chunkIndex = index,504 chunkCount = chunkCount,505 entries = chunks[index],506 tombstones = if (isFinal) tombstones else emptyMap(),507 duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,508 ))509 }510}511512513514515516517518519suspend fun receiveVaultSnapshot(520 channel: PairingTransport,521): ReceivedSnapshot {522 val entries = LinkedHashMap<String, String>()523 val tombstones = LinkedHashMap<String, Long>()524 var duressSlot: String? = null525 var chunkCount = -1526 var received = 0527 while (chunkCount == -1 || received < chunkCount) {528 val raw = channel.receive()529 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {530 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")531 }532 val frame = runCatching {533 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))534 }.getOrNull()535 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")536 frame as AdoptionFrame.Snapshot537 if (chunkCount == -1) {538 chunkCount = frame.chunkCount539 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {540 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")541 }542 } else if (frame.chunkCount != chunkCount) {543 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")544 }545 if (frame.chunkIndex != received) {546 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")547 }548 entries.putAll(frame.entries)549 tombstones.putAll(frame.tombstones)550 if (frame.duressSlot != null) duressSlot = frame.duressSlot551 received++552 }553 return ReceivedSnapshot(entries, tombstones, duressSlot)554}555556private fun isDeviceType(value: String): Boolean =557 DeviceType.entries.any { it.wireValue == value }