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}221222private val Hex64 = Regex("^[0-9a-f]{64}\$")223224fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)225226fun isAdoptRequest(frame: AdoptionFrame?): Boolean =227 frame is AdoptionFrame.Request &&228 frame.sessionRequestId.isNotEmpty() &&229 isDeviceType(frame.deviceType)230231fun isAdoptGrant(frame: AdoptionFrame?): Boolean =232 frame is AdoptionFrame.Grant &&233 validateRootKeyHex(frame.rootKeyHex) &&234 Hex64.matches(frame.vaultId) &&235 isDeviceType(frame.source.deviceType) &&236 Hex64.matches(frame.source.longTermPublicKeyHex) &&237 (frame.source.relayUrl == null ||238 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)239240fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =241 frame is AdoptionFrame.Confirm242243fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =244 frame is AdoptionFrame.Identity &&245 frame.deviceId.isNotEmpty() &&246 isDeviceType(frame.deviceType) &&247 Hex64.matches(frame.longTermPublicKeyHex)248249fun isAdoptComplete(frame: AdoptionFrame?): Boolean =250 frame is AdoptionFrame.Complete251252fun isAdoptApplied(frame: AdoptionFrame?): Boolean =253 frame is AdoptionFrame.Applied254255fun isAdoptReject(frame: AdoptionFrame?): Boolean =256 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"257258fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =259 frame is AdoptionFrame.Snapshot &&260 frame.chunkIndex >= 0 &&261 frame.chunkCount >= 1262263264265266267268269270suspend fun requestAdoption(271 channel: PairingTransport,272 deps: AdoptionReceiverDeps,273 nowMs: () -> Long = { System.currentTimeMillis() },274): PairedDevice? {275 val receiverLicense = deps.license()276 277 278 sendFrame(channel, AdoptionFrame.Request(279 sessionRequestId = deps.generateDeviceId(),280 deviceName = deps.deviceName,281 deviceType = deps.deviceType.wireValue,282 license = receiverLicense,283 snapshotCapable = true,284 ))285286 val confirmed = deps.confirmSas()287 sendFrame(channel, AdoptionFrame.Confirm(confirmed))288 if (!confirmed) {289 deps.onError("ADOPT_SAS_REJECTED")290 return null291 }292293 val frame = receiveFrame(channel)294 if (isAdoptReject(frame)) {295 deps.onError((frame as AdoptionFrame.Reject).reason)296 return null297 }298 if (!isAdoptGrant(frame)) {299 deps.onError("ADOPT_UNEXPECTED_FRAME")300 return null301 }302 val grant = frame as AdoptionFrame.Grant303304 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)305 try {306 if (computeVaultId(rootKeyBytes) != grant.vaultId) {307 deps.onError("ADOPT_VAULT_ID_MISMATCH")308 return null309 }310 } finally {311 rootKeyBytes.fill(0)312 }313 if (receiverLicense == null && grant.license == null) {314 deps.onError("ADOPT_PRO_REQUIRED")315 return null316 }317318 deps.validateSource(grant.source)319 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)320 if (vaultId != grant.vaultId) {321 deps.onError("ADOPT_VAULT_ID_MISMATCH")322 return null323 }324325 if (receiverLicense == null) {326 grant.license?.let { deps.applyLicense(it) }327 }328329 val identity = deps.deriveLocalIdentity()330 sendFrame(channel, AdoptionFrame.Identity(331 deviceId = identity.deviceId,332 deviceName = identity.deviceName,333 deviceType = identity.deviceType.wireValue,334 longTermPublicKeyHex = identity.longTermPublicKeyHex,335 signingPublicKeyHex = identity.signingPublicKeyHex,336 ))337338 if (!isAdoptComplete(receiveFrame(channel))) {339 deps.onError("ADOPT_COMPLETION_MISSING")340 return null341 }342343 val sourceDevice = PairedDevice(344 deviceId = grant.source.deviceId,345 deviceName = grant.source.deviceName,346 deviceType = grant.source.deviceType,347 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,348 signingPublicKeyHex = grant.source.signingPublicKeyHex,349 trustSource = "direct",350 wsPort = grant.source.wsPort,351 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,352 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,353 pairedAt = nowMs(),354 )355 356 357 358 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)359 deps.upsertPairedDevice(sourceDevice)360 sendFrame(channel, AdoptionFrame.Applied)361 return sourceDevice362}363364365366367368369suspend fun grantAdoption(370 channel: PairingTransport,371 request: AdoptionFrame.Request,372 deps: AdoptionSourceDeps,373 nowMs: () -> Long = { System.currentTimeMillis() },374): PairedDevice? {375 if (!deps.onConsent(request)) return null376377 val confirmation = receiveFrame(channel)378 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {379 return null380 }381382 var sourceLicense = deps.license()383 if (sourceLicense == null && request.license != null) {384 sourceLicense = runCatching {385 deps.applyLicense(request.license)386 deps.license()387 }.getOrNull()388 }389 if (sourceLicense == null) {390 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))391 throw IllegalStateException("ADOPT_PRO_REQUIRED")392 }393394 val rootKeyHex = deps.rootKeyHex()395 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")396397 sendFrame(channel, AdoptionFrame.Grant(398 rootKeyHex = rootKeyHex,399 vaultId = deps.vaultId(),400 license = sourceLicense,401 source = AdoptionFrame.Source(402 deviceId = deps.sourceIdentity.deviceId,403 deviceName = deps.sourceIdentity.deviceName,404 deviceType = deps.sourceIdentity.deviceType.wireValue,405 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,406 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,407 wsPort = deps.sourceIdentity.wsPort,408 relayUrl = deps.pairState?.relayUrl,409 ),410 ))411412 val identity = receiveFrame(channel)413 if (!isAdoptIdentity(identity)) return null414 identity as AdoptionFrame.Identity415416 val device = PairedDevice(417 deviceId = identity.deviceId,418 deviceName = identity.deviceName,419 deviceType = identity.deviceType,420 longTermPublicKeyHex = identity.longTermPublicKeyHex,421 signingPublicKeyHex = identity.signingPublicKeyHex,422 trustSource = "direct",423 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,424 relayUrl = deps.pairState?.relayUrl,425 pairedAt = nowMs(),426 )427 deps.upsertPairedDevice(device)428 sendFrame(channel, AdoptionFrame.Complete)429 if (!isAdoptApplied(receiveFrame(channel))) return null430 return device431}432433434435436437438val AdoptJson: Json = Json {439 classDiscriminator = "type"440 encodeDefaults = false441 ignoreUnknownKeys = true442}443444suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {445 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))446}447448449suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =450 runCatching {451 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))452 }.getOrNull()453454455456457458459460461suspend fun sendVaultSnapshot(462 channel: PairingTransport,463 entries: Map<String, String>,464 tombstones: Map<String, Long>,465 duressSlot: String? = null,466) {467 val chunks = mutableListOf<Map<String, String>>()468 var current = LinkedHashMap<String, String>()469 var currentBytes = 2 470 for ((id, envelope) in entries) {471 472 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1473 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {474 chunks.add(current)475 current = LinkedHashMap()476 currentBytes = 2477 }478 current[id] = envelope479 currentBytes += entryBytes480 }481 chunks.add(current)482483 val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)484 for (index in 0 until chunkCount) {485 val isFinal = index == chunkCount - 1486 sendFrame(channel, AdoptionFrame.Snapshot(487 chunkIndex = index,488 chunkCount = chunkCount,489 entries = chunks[index],490 tombstones = if (isFinal) tombstones else emptyMap(),491 duressSlot = if (isFinal && !duressSlot.isNullOrBlank()) duressSlot else null,492 ))493 }494}495496497498499500501502503suspend fun receiveVaultSnapshot(504 channel: PairingTransport,505): ReceivedSnapshot {506 val entries = LinkedHashMap<String, String>()507 val tombstones = LinkedHashMap<String, Long>()508 var duressSlot: String? = null509 var chunkCount = -1510 var received = 0511 while (chunkCount == -1 || received < chunkCount) {512 val raw = channel.receive()513 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {514 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")515 }516 val frame = runCatching {517 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))518 }.getOrNull()519 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")520 frame as AdoptionFrame.Snapshot521 if (chunkCount == -1) {522 chunkCount = frame.chunkCount523 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {524 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")525 }526 } else if (frame.chunkCount != chunkCount) {527 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")528 }529 if (frame.chunkIndex != received) {530 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")531 }532 entries.putAll(frame.entries)533 tombstones.putAll(frame.tombstones)534 if (frame.duressSlot != null) duressSlot = frame.duressSlot535 received++536 }537 return ReceivedSnapshot(entries, tombstones, duressSlot)538}539540private fun isDeviceType(value: String): Boolean =541 DeviceType.entries.any { it.wireValue == value }