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 @SerialName("duress_slot") val duressSlot: String? = null,103 ) : AdoptionFrame()104105 106 @Serializable107 @SerialName("ADOPT_SNAPSHOT_APPLIED")108 data class SnapshotApplied(109 @SerialName("entries_received") val entriesReceived: Int,110 @SerialName("tombstones_received") val tombstonesReceived: Int,111 ) : AdoptionFrame()112113 114 @Serializable115 @SerialName("ADOPT_DEVICE_MANIFEST")116 data class DeviceManifestFrame(117 val manifest: SignedDeviceManifest,118 ) : AdoptionFrame()119120 121 @Serializable122 @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")123 data object DeviceManifestApplied : AdoptionFrame()124125 @Serializable126 data class Source(127 @SerialName("device_id") val deviceId: String,128 @SerialName("device_name") val deviceName: String,129 @SerialName("device_type") val deviceType: String,130 @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,131 @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,132 @SerialName("ws_port") val wsPort: Int? = null,133 @SerialName("relay_url") val relayUrl: String? = null,134 )135}136137data class AdoptionPairState(138 val rendezvousSecretHex: String,139 val relayUrl: String? = null,140)141142143144145146147data class ReceivedSnapshot(148 val entries: Map<String, String>,149 val tombstones: Map<String, Long>,150 val duressSlot: String? = null,151)152153154155156157158159const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 160const val MAX_SNAPSHOT_CHUNKS = 200161162163164private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024165166167interface AdoptionReceiverDeps {168 val deviceName: String169 val deviceType: DeviceType170 fun generateDeviceId(): String171 172 suspend fun confirmSas(): Boolean173 174175176177 suspend fun validateSource(source: AdoptionFrame.Source) {}178179 180 suspend fun createVaultFromRootKey(rootKeyHex: String): String181 suspend fun deriveLocalIdentity(): LocalDeviceIdentity182 183 suspend fun clearPairedDeviceRevocation(deviceId: String) {}184 suspend fun upsertPairedDevice(device: PairedDevice)185 suspend fun applyLicense(license: String) {}186 187 suspend fun license(): String? = null188 val pairState: AdoptionPairState? get() = null189 fun onError(error: String)190 191 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}192 193 suspend fun markSynced(deviceId: String) {}194}195196197interface AdoptionSourceDeps {198 val sourceIdentity: LocalDeviceIdentity199 suspend fun rootKeyHex(): String200 suspend fun vaultId(): String201 suspend fun license(): String? = null202 203 suspend fun applyLicense(license: String) {}204205 206207208209210211 suspend fun onConsent(request: AdoptionFrame.Request): Boolean212 suspend fun upsertPairedDevice(device: PairedDevice)213 val pairState: AdoptionPairState? get() = null214 215 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null216 217 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null218 219 suspend fun markSynced(deviceId: String) {}220 221 suspend fun duressSlotJson(): String? = null222}223224private val Hex64 = Regex("^[0-9a-f]{64}\$")225226fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)227228fun isAdoptRequest(frame: AdoptionFrame?): Boolean =229 frame is AdoptionFrame.Request &&230 frame.sessionRequestId.isNotEmpty() &&231 isDeviceType(frame.deviceType)232233fun isAdoptGrant(frame: AdoptionFrame?): Boolean =234 frame is AdoptionFrame.Grant &&235 validateRootKeyHex(frame.rootKeyHex) &&236 Hex64.matches(frame.vaultId) &&237 isDeviceType(frame.source.deviceType) &&238 Hex64.matches(frame.source.longTermPublicKeyHex) &&239 (frame.source.relayUrl == null ||240 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)241242fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =243 frame is AdoptionFrame.Confirm244245fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =246 frame is AdoptionFrame.Identity &&247 frame.deviceId.isNotEmpty() &&248 isDeviceType(frame.deviceType) &&249 Hex64.matches(frame.longTermPublicKeyHex)250251fun isAdoptComplete(frame: AdoptionFrame?): Boolean =252 frame is AdoptionFrame.Complete253254fun isAdoptApplied(frame: AdoptionFrame?): Boolean =255 frame is AdoptionFrame.Applied256257fun isAdoptReject(frame: AdoptionFrame?): Boolean =258 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"259260fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =261 frame is AdoptionFrame.Snapshot &&262 frame.chunkIndex >= 0 &&263 frame.chunkCount >= 1264265266267268269270271272suspend fun requestAdoption(273 channel: PairingTransport,274 deps: AdoptionReceiverDeps,275 nowMs: () -> Long = { System.currentTimeMillis() },276): PairedDevice? {277 val receiverLicense = deps.license()278 279 280 sendFrame(channel, AdoptionFrame.Request(281 sessionRequestId = deps.generateDeviceId(),282 deviceName = deps.deviceName,283 deviceType = deps.deviceType.wireValue,284 license = receiverLicense,285 snapshotCapable = true,286 ))287288 val confirmed = deps.confirmSas()289 sendFrame(channel, AdoptionFrame.Confirm(confirmed))290 if (!confirmed) {291 deps.onError("ADOPT_SAS_REJECTED")292 return null293 }294295 val frame = receiveFrame(channel)296 if (isAdoptReject(frame)) {297 deps.onError((frame as AdoptionFrame.Reject).reason)298 return null299 }300 if (!isAdoptGrant(frame)) {301 deps.onError("ADOPT_UNEXPECTED_FRAME")302 return null303 }304 val grant = frame as AdoptionFrame.Grant305306 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)307 try {308 if (computeVaultId(rootKeyBytes) != grant.vaultId) {309 deps.onError("ADOPT_VAULT_ID_MISMATCH")310 return null311 }312 } finally {313 rootKeyBytes.fill(0)314 }315 if (receiverLicense == null && grant.license == null) {316 deps.onError("ADOPT_PRO_REQUIRED")317 return null318 }319320 deps.validateSource(grant.source)321 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)322 if (vaultId != grant.vaultId) {323 deps.onError("ADOPT_VAULT_ID_MISMATCH")324 return null325 }326327 if (receiverLicense == null) {328 grant.license?.let { deps.applyLicense(it) }329 }330331 val identity = deps.deriveLocalIdentity()332 sendFrame(channel, AdoptionFrame.Identity(333 deviceId = identity.deviceId,334 deviceName = identity.deviceName,335 deviceType = identity.deviceType.wireValue,336 longTermPublicKeyHex = identity.longTermPublicKeyHex,337 signingPublicKeyHex = identity.signingPublicKeyHex,338 ))339340 if (!isAdoptComplete(receiveFrame(channel))) {341 deps.onError("ADOPT_COMPLETION_MISSING")342 return null343 }344345 val sourceDevice = PairedDevice(346 deviceId = grant.source.deviceId,347 deviceName = grant.source.deviceName,348 deviceType = grant.source.deviceType,349 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,350 signingPublicKeyHex = grant.source.signingPublicKeyHex,351 trustSource = "direct",352 wsPort = grant.source.wsPort,353 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,354 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,355 pairedAt = nowMs(),356 )357 358 359 360 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)361 deps.upsertPairedDevice(sourceDevice)362 sendFrame(channel, AdoptionFrame.Applied)363 return sourceDevice364}365366367368369370371suspend fun grantAdoption(372 channel: PairingTransport,373 request: AdoptionFrame.Request,374 deps: AdoptionSourceDeps,375 nowMs: () -> Long = { System.currentTimeMillis() },376): PairedDevice? {377 if (!deps.onConsent(request)) return null378379 val confirmation = receiveFrame(channel)380 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {381 return null382 }383384 var sourceLicense = deps.license()385 if (sourceLicense == null && request.license != null) {386 sourceLicense = runCatching {387 deps.applyLicense(request.license)388 deps.license()389 }.getOrNull()390 }391 if (sourceLicense == null) {392 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))393 throw IllegalStateException("ADOPT_PRO_REQUIRED")394 }395396 val rootKeyHex = deps.rootKeyHex()397 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")398399 sendFrame(channel, AdoptionFrame.Grant(400 rootKeyHex = rootKeyHex,401 vaultId = deps.vaultId(),402 license = sourceLicense,403 source = AdoptionFrame.Source(404 deviceId = deps.sourceIdentity.deviceId,405 deviceName = deps.sourceIdentity.deviceName,406 deviceType = deps.sourceIdentity.deviceType.wireValue,407 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,408 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,409 wsPort = deps.sourceIdentity.wsPort,410 relayUrl = deps.pairState?.relayUrl,411 ),412 ))413414 val identity = receiveFrame(channel)415 if (!isAdoptIdentity(identity)) return null416 identity as AdoptionFrame.Identity417418 val device = PairedDevice(419 deviceId = identity.deviceId,420 deviceName = identity.deviceName,421 deviceType = identity.deviceType,422 longTermPublicKeyHex = identity.longTermPublicKeyHex,423 signingPublicKeyHex = identity.signingPublicKeyHex,424 trustSource = "direct",425 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,426 relayUrl = deps.pairState?.relayUrl,427 pairedAt = nowMs(),428 )429 deps.upsertPairedDevice(device)430 sendFrame(channel, AdoptionFrame.Complete)431 if (!isAdoptApplied(receiveFrame(channel))) return null432 return device433}434435436437438439440val AdoptJson: Json = Json {441 classDiscriminator = "type"442 encodeDefaults = false443 ignoreUnknownKeys = true444}445446suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {447 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))448}449450451suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =452 runCatching {453 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))454 }.getOrNull()455456457458459460461462463suspend fun sendVaultSnapshot(464 channel: PairingTransport,465 entries: Map<String, String>,466 tombstones: Map<String, Long>,467 duressSlot: String? = null,468) {469 val chunks = mutableListOf<Map<String, String>>()470 var current = LinkedHashMap<String, String>()471 var currentBytes = 2 472 for ((id, envelope) in entries) {473 474 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1475 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {476 chunks.add(current)477 current = LinkedHashMap()478 currentBytes = 2479 }480 current[id] = envelope481 currentBytes += entryBytes482 }483 chunks.add(current)484485 val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)486 for (index in 0 until chunkCount) {487 val isFinal = index == chunkCount - 1488 sendFrame(channel, AdoptionFrame.Snapshot(489 chunkIndex = index,490 chunkCount = chunkCount,491 entries = chunks[index],492 tombstones = if (isFinal) tombstones else emptyMap(),493 duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,494 ))495 }496}497498499500501502503504505suspend fun receiveVaultSnapshot(506 channel: PairingTransport,507): ReceivedSnapshot {508 val entries = LinkedHashMap<String, String>()509 val tombstones = LinkedHashMap<String, Long>()510 var duressSlot: String? = null511 var chunkCount = -1512 var received = 0513 while (chunkCount == -1 || received < chunkCount) {514 val raw = channel.receive()515 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {516 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")517 }518 val frame = runCatching {519 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))520 }.getOrNull()521 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")522 frame as AdoptionFrame.Snapshot523 if (chunkCount == -1) {524 chunkCount = frame.chunkCount525 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {526 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")527 }528 } else if (frame.chunkCount != chunkCount) {529 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")530 }531 if (frame.chunkIndex != received) {532 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")533 }534 entries.putAll(frame.entries)535 tombstones.putAll(frame.tombstones)536 if (frame.duressSlot != null) duressSlot = frame.duressSlot537 received++538 }539 return ReceivedSnapshot(entries, tombstones, duressSlot)540}541542private fun isDeviceType(value: String): Boolean =543 DeviceType.entries.any { it.wireValue == value }