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 ) : AdoptionFrame()5859 @Serializable60 @SerialName("ADOPT_IDENTITY")61 data class Identity(62 @SerialName("device_id") val deviceId: String,63 @SerialName("device_name") val deviceName: String,64 @SerialName("device_type") val deviceType: String,65 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,66 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,67 ) : AdoptionFrame()6869 7071727374 @Serializable75 @SerialName("ADOPT_COMPLETE")76 data object Complete : AdoptionFrame()7778 79 @Serializable80 @SerialName("ADOPT_APPLIED")81 data object Applied : AdoptionFrame()8283 84 @Serializable85 @SerialName("ADOPT_REJECT")86 data class Reject(87 val reason: String,88 ) : AdoptionFrame()8990 9192939495 @Serializable96 @SerialName("ADOPT_SNAPSHOT")97 data class Snapshot(98 @SerialName("chunk_index") val chunkIndex: Int,99 @SerialName("chunk_count") val chunkCount: Int,100 val entries: Map<String, String>,101 val tombstones: Map<String, Long>,102 ) : AdoptionFrame()103104 105 @Serializable106 @SerialName("ADOPT_SNAPSHOT_APPLIED")107 data class SnapshotApplied(108 @SerialName("entries_received") val entriesReceived: Int,109 @SerialName("tombstones_received") val tombstonesReceived: Int,110 ) : AdoptionFrame()111112 113 @Serializable114 @SerialName("ADOPT_DEVICE_MANIFEST")115 data class DeviceManifestFrame(116 val manifest: SignedDeviceManifest,117 ) : AdoptionFrame()118119 120 @Serializable121 @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")122 data object DeviceManifestApplied : AdoptionFrame()123124 @Serializable125 data class Source(126 @SerialName("device_id") val deviceId: String,127 @SerialName("device_name") val deviceName: String,128 @SerialName("device_type") val deviceType: String,129 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,130 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,131 @SerialName("ws_port") val wsPort: Int? = null,132 @SerialName("relay_url") val relayUrl: String? = null,133 )134}135136data class AdoptionPairState(137 val rendezvousSecretHex: String,138 val relayUrl: String? = null,139)140141142143144145146147const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 148const val MAX_SNAPSHOT_CHUNKS = 200149150151152private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024153154155interface AdoptionReceiverDeps {156 val deviceName: String157 val deviceType: DeviceType158 fun generateDeviceId(): String159 160 suspend fun confirmSas(): Boolean161 162163164165 suspend fun validateSource(source: AdoptionFrame.Source) {}166167 168 suspend fun createVaultFromRootKey(rootKeyHex: String): String169 suspend fun deriveLocalIdentity(): LocalDeviceIdentity170 171 suspend fun clearPairedDeviceRevocation(deviceId: String) {}172 suspend fun upsertPairedDevice(device: PairedDevice)173 suspend fun applyLicense(license: String) {}174 175 suspend fun license(): String? = null176 val pairState: AdoptionPairState? get() = null177 fun onError(error: String)178 179 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {}180 181 suspend fun markSynced(deviceId: String) {}182}183184185interface AdoptionSourceDeps {186 val sourceIdentity: LocalDeviceIdentity187 suspend fun rootKeyHex(): String188 suspend fun vaultId(): String189 suspend fun license(): String? = null190 191 suspend fun applyLicense(license: String) {}192193 194195196197198199 suspend fun onConsent(request: AdoptionFrame.Request): Boolean200 suspend fun upsertPairedDevice(device: PairedDevice)201 val pairState: AdoptionPairState? get() = null202 203 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null204 205 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null206 207 suspend fun markSynced(deviceId: String) {}208}209210private val Hex64 = Regex("^[0-9a-f]{64}\$")211212fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)213214fun isAdoptRequest(frame: AdoptionFrame?): Boolean =215 frame is AdoptionFrame.Request &&216 frame.sessionRequestId.isNotEmpty() &&217 isDeviceType(frame.deviceType)218219fun isAdoptGrant(frame: AdoptionFrame?): Boolean =220 frame is AdoptionFrame.Grant &&221 validateRootKeyHex(frame.rootKeyHex) &&222 Hex64.matches(frame.vaultId) &&223 isDeviceType(frame.source.deviceType) &&224 Hex64.matches(frame.source.longTermPublicKeyHex) &&225 (frame.source.relayUrl == null ||226 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)227228fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =229 frame is AdoptionFrame.Confirm230231fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =232 frame is AdoptionFrame.Identity &&233 frame.deviceId.isNotEmpty() &&234 isDeviceType(frame.deviceType) &&235 Hex64.matches(frame.longTermPublicKeyHex)236237fun isAdoptComplete(frame: AdoptionFrame?): Boolean =238 frame is AdoptionFrame.Complete239240fun isAdoptApplied(frame: AdoptionFrame?): Boolean =241 frame is AdoptionFrame.Applied242243fun isAdoptReject(frame: AdoptionFrame?): Boolean =244 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"245246fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =247 frame is AdoptionFrame.Snapshot &&248 frame.chunkIndex >= 0 &&249 frame.chunkCount >= 1250251252253254255256257258suspend fun requestAdoption(259 channel: PairingTransport,260 deps: AdoptionReceiverDeps,261 nowMs: () -> Long = { System.currentTimeMillis() },262): PairedDevice? {263 val receiverLicense = deps.license()264 265 266 sendFrame(channel, AdoptionFrame.Request(267 sessionRequestId = deps.generateDeviceId(),268 deviceName = deps.deviceName,269 deviceType = deps.deviceType.wireValue,270 license = receiverLicense,271 snapshotCapable = true,272 ))273274 val confirmed = deps.confirmSas()275 sendFrame(channel, AdoptionFrame.Confirm(confirmed))276 if (!confirmed) {277 deps.onError("ADOPT_SAS_REJECTED")278 return null279 }280281 val frame = receiveFrame(channel)282 if (isAdoptReject(frame)) {283 deps.onError((frame as AdoptionFrame.Reject).reason)284 return null285 }286 if (!isAdoptGrant(frame)) {287 deps.onError("ADOPT_UNEXPECTED_FRAME")288 return null289 }290 val grant = frame as AdoptionFrame.Grant291292 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)293 try {294 if (computeVaultId(rootKeyBytes) != grant.vaultId) {295 deps.onError("ADOPT_VAULT_ID_MISMATCH")296 return null297 }298 } finally {299 rootKeyBytes.fill(0)300 }301 if (receiverLicense == null && grant.license == null) {302 deps.onError("ADOPT_PRO_REQUIRED")303 return null304 }305306 deps.validateSource(grant.source)307 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)308 if (vaultId != grant.vaultId) {309 deps.onError("ADOPT_VAULT_ID_MISMATCH")310 return null311 }312313 if (receiverLicense == null) {314 grant.license?.let { deps.applyLicense(it) }315 }316317 val identity = deps.deriveLocalIdentity()318 sendFrame(channel, AdoptionFrame.Identity(319 deviceId = identity.deviceId,320 deviceName = identity.deviceName,321 deviceType = identity.deviceType.wireValue,322 longTermPublicKeyHex = identity.longTermPublicKeyHex,323 signingPublicKeyHex = identity.signingPublicKeyHex,324 ))325326 if (!isAdoptComplete(receiveFrame(channel))) {327 deps.onError("ADOPT_COMPLETION_MISSING")328 return null329 }330331 val sourceDevice = PairedDevice(332 deviceId = grant.source.deviceId,333 deviceName = grant.source.deviceName,334 deviceType = grant.source.deviceType,335 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,336 signingPublicKeyHex = grant.source.signingPublicKeyHex,337 trustSource = "direct",338 wsPort = grant.source.wsPort,339 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,340 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,341 pairedAt = nowMs(),342 )343 344 345 346 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)347 deps.upsertPairedDevice(sourceDevice)348 sendFrame(channel, AdoptionFrame.Applied)349 return sourceDevice350}351352353354355356357suspend fun grantAdoption(358 channel: PairingTransport,359 request: AdoptionFrame.Request,360 deps: AdoptionSourceDeps,361 nowMs: () -> Long = { System.currentTimeMillis() },362): PairedDevice? {363 if (!deps.onConsent(request)) return null364365 val confirmation = receiveFrame(channel)366 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {367 return null368 }369370 var sourceLicense = deps.license()371 if (sourceLicense == null && request.license != null) {372 sourceLicense = runCatching {373 deps.applyLicense(request.license)374 deps.license()375 }.getOrNull()376 }377 if (sourceLicense == null) {378 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))379 throw IllegalStateException("ADOPT_PRO_REQUIRED")380 }381382 val rootKeyHex = deps.rootKeyHex()383 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")384385 sendFrame(channel, AdoptionFrame.Grant(386 rootKeyHex = rootKeyHex,387 vaultId = deps.vaultId(),388 license = sourceLicense,389 source = AdoptionFrame.Source(390 deviceId = deps.sourceIdentity.deviceId,391 deviceName = deps.sourceIdentity.deviceName,392 deviceType = deps.sourceIdentity.deviceType.wireValue,393 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,394 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,395 wsPort = deps.sourceIdentity.wsPort,396 relayUrl = deps.pairState?.relayUrl,397 ),398 ))399400 val identity = receiveFrame(channel)401 if (!isAdoptIdentity(identity)) return null402 identity as AdoptionFrame.Identity403404 val device = PairedDevice(405 deviceId = identity.deviceId,406 deviceName = identity.deviceName,407 deviceType = identity.deviceType,408 longTermPublicKeyHex = identity.longTermPublicKeyHex,409 signingPublicKeyHex = identity.signingPublicKeyHex,410 trustSource = "direct",411 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,412 relayUrl = deps.pairState?.relayUrl,413 pairedAt = nowMs(),414 )415 deps.upsertPairedDevice(device)416 sendFrame(channel, AdoptionFrame.Complete)417 if (!isAdoptApplied(receiveFrame(channel))) return null418 return device419}420421422423424425426val AdoptJson: Json = Json {427 classDiscriminator = "type"428 encodeDefaults = false429 ignoreUnknownKeys = true430}431432suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {433 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))434}435436437suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =438 runCatching {439 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))440 }.getOrNull()441442443444445446447448449suspend fun sendVaultSnapshot(450 channel: PairingTransport,451 entries: Map<String, String>,452 tombstones: Map<String, Long>,453) {454 val chunks = mutableListOf<Map<String, String>>()455 var current = LinkedHashMap<String, String>()456 var currentBytes = 2 457 for ((id, envelope) in entries) {458 459 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1460 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {461 chunks.add(current)462 current = LinkedHashMap()463 currentBytes = 2464 }465 current[id] = envelope466 currentBytes += entryBytes467 }468 chunks.add(current)469470 val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)471 for (index in 0 until chunkCount) {472 val isFinal = index == chunkCount - 1473 sendFrame(channel, AdoptionFrame.Snapshot(474 chunkIndex = index,475 chunkCount = chunkCount,476 entries = chunks[index],477 tombstones = if (isFinal) tombstones else emptyMap(),478 ))479 }480}481482483484485486487488489suspend fun receiveVaultSnapshot(490 channel: PairingTransport,491): Pair<Map<String, String>, Map<String, Long>> {492 val entries = LinkedHashMap<String, String>()493 val tombstones = LinkedHashMap<String, Long>()494 var chunkCount = -1495 var received = 0496 while (chunkCount == -1 || received < chunkCount) {497 val raw = channel.receive()498 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {499 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")500 }501 val frame = runCatching {502 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))503 }.getOrNull()504 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")505 frame as AdoptionFrame.Snapshot506 if (chunkCount == -1) {507 chunkCount = frame.chunkCount508 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {509 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")510 }511 } else if (frame.chunkCount != chunkCount) {512 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")513 }514 if (frame.chunkIndex != received) {515 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")516 }517 entries.putAll(frame.entries)518 tombstones.putAll(frame.tombstones)519 received++520 }521 return entries to tombstones522}523524private fun isDeviceType(value: String): Boolean =525 DeviceType.entries.any { it.wireValue == value }