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 = 200169170171172internal const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024173174175176177178179180const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 181const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000182const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000183184const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH185186187188189190const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES191192193194195196class AdoptSnapshotBudget {197 val entries = LinkedHashMap<String, String>()198 val tombstones = LinkedHashMap<String, Long>()199 var duressSlot: String? = null200 201 var bytes: Int = 0202 var entryCount: Int = 0203 var tombstoneCount: Int = 0204}205206fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()207208209210211212213214215216217218fun applyAdoptSnapshotFrame(219 acc: AdoptSnapshotBudget,220 entries: Map<String, String>,221 tombstones: Map<String, Long>,222 duressSlot: String?,223 frameBytes: Int,224) {225 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {226 throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")227 }228 for ((id, envelope) in entries) {229 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")230 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {231 throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")232 }233 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {234 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")235 }236 acc.entryCount++237 acc.entries[id] = envelope238 }239 for ((id, ts) in tombstones) {240 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")241 242 243 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {244 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")245 }246 acc.tombstoneCount++247 acc.tombstones[id] = ts248 }249 if (!duressSlot.isNullOrEmpty()) {250 251 252 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {253 throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")254 }255 acc.duressSlot = duressSlot256 }257 acc.bytes += frameBytes258}259260261interface AdoptionReceiverDeps {262 val deviceName: String263 val deviceType: DeviceType264 fun generateDeviceId(): String265 266 suspend fun confirmSas(): Boolean267 268269270271 suspend fun validateSource(source: AdoptionFrame.Source) {}272273 274 suspend fun createVaultFromRootKey(rootKeyHex: String): String275 suspend fun deriveLocalIdentity(): LocalDeviceIdentity276 277 suspend fun clearPairedDeviceRevocation(deviceId: String) {}278 suspend fun upsertPairedDevice(device: PairedDevice)279 suspend fun applyLicense(license: String) {}280 281 suspend fun license(): String? = null282 val pairState: AdoptionPairState? get() = null283 fun onError(error: String)284 285 suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String? = null) {}286 287288289290291292293294 suspend fun applyIncomingDeviceManifest(295 manifest: SignedDeviceManifest,296 authenticatedRemoteStaticPublicKey: ByteArray,297 ) {}298 299 suspend fun markSynced(deviceId: String) {}300}301302303interface AdoptionSourceDeps {304 val sourceIdentity: LocalDeviceIdentity305 suspend fun rootKeyHex(): String306 suspend fun vaultId(): String307 suspend fun license(): String? = null308 309 suspend fun applyLicense(license: String) {}310311 312313314315316317 suspend fun onConsent(request: AdoptionFrame.Request): Boolean318 suspend fun upsertPairedDevice(device: PairedDevice)319 val pairState: AdoptionPairState? get() = null320 321 suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null322 323 suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null324 325 suspend fun markSynced(deviceId: String) {}326 327 suspend fun duressSlotJson(): String? = null328}329330private val Hex64 = Regex("^[0-9a-f]{64}\$")331332fun validateRootKeyHex(rootKeyHex: String?): Boolean = rootKeyHex != null && Hex64.matches(rootKeyHex)333334fun isAdoptRequest(frame: AdoptionFrame?): Boolean =335 frame is AdoptionFrame.Request &&336 frame.sessionRequestId.isNotEmpty() &&337 isDeviceType(frame.deviceType)338339fun isAdoptGrant(frame: AdoptionFrame?): Boolean =340 frame is AdoptionFrame.Grant &&341 validateRootKeyHex(frame.rootKeyHex) &&342 Hex64.matches(frame.vaultId) &&343 isDeviceType(frame.source.deviceType) &&344 Hex64.matches(frame.source.longTermPublicKeyHex) &&345 (frame.source.relayUrl == null ||346 runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)347348fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =349 frame is AdoptionFrame.Confirm350351fun isAdoptIdentity(frame: AdoptionFrame?): Boolean =352 frame is AdoptionFrame.Identity &&353 frame.deviceId.isNotEmpty() &&354 isDeviceType(frame.deviceType) &&355 Hex64.matches(frame.longTermPublicKeyHex)356357fun isAdoptComplete(frame: AdoptionFrame?): Boolean =358 frame is AdoptionFrame.Complete359360fun isAdoptApplied(frame: AdoptionFrame?): Boolean =361 frame is AdoptionFrame.Applied362363fun isAdoptReject(frame: AdoptionFrame?): Boolean =364 frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"365366fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =367 frame is AdoptionFrame.Snapshot &&368 frame.chunkIndex >= 0 &&369 frame.chunkCount >= 1370371372373374375376377378suspend fun requestAdoption(379 channel: PairingTransport,380 deps: AdoptionReceiverDeps,381 382 383 384 385 386 onGrant: ((AdoptionFrame.Grant) -> Unit)? = null,387 nowMs: () -> Long = { System.currentTimeMillis() },388): PairedDevice? {389 val receiverLicense = deps.license()390 391 392 sendFrame(channel, AdoptionFrame.Request(393 sessionRequestId = deps.generateDeviceId(),394 deviceName = deps.deviceName,395 deviceType = deps.deviceType.wireValue,396 license = receiverLicense,397 snapshotCapable = true,398 ))399400 val confirmed = deps.confirmSas()401 sendFrame(channel, AdoptionFrame.Confirm(confirmed))402 if (!confirmed) {403 deps.onError("ADOPT_SAS_REJECTED")404 return null405 }406407 val frame = receiveFrame(channel)408 if (isAdoptReject(frame)) {409 deps.onError((frame as AdoptionFrame.Reject).reason)410 return null411 }412 if (!isAdoptGrant(frame)) {413 deps.onError("ADOPT_UNEXPECTED_FRAME")414 return null415 }416 val grant = frame as AdoptionFrame.Grant417 onGrant?.invoke(grant)418419 val rootKeyBytes = hexToPubkey(grant.rootKeyHex)420 try {421 if (computeVaultId(rootKeyBytes) != grant.vaultId) {422 deps.onError("ADOPT_VAULT_ID_MISMATCH")423 return null424 }425 } finally {426 rootKeyBytes.fill(0)427 }428 if (receiverLicense == null && grant.license == null) {429 deps.onError("ADOPT_PRO_REQUIRED")430 return null431 }432433 deps.validateSource(grant.source)434 val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)435 if (vaultId != grant.vaultId) {436 deps.onError("ADOPT_VAULT_ID_MISMATCH")437 return null438 }439440 if (receiverLicense == null) {441 grant.license?.let { deps.applyLicense(it) }442 }443444 val identity = deps.deriveLocalIdentity()445 sendFrame(channel, AdoptionFrame.Identity(446 deviceId = identity.deviceId,447 deviceName = identity.deviceName,448 deviceType = identity.deviceType.wireValue,449 longTermPublicKeyHex = identity.longTermPublicKeyHex,450 signingPublicKeyHex = identity.signingPublicKeyHex,451 ))452453 if (!isAdoptComplete(receiveFrame(channel))) {454 deps.onError("ADOPT_COMPLETION_MISSING")455 return null456 }457458 val sourceDevice = PairedDevice(459 deviceId = grant.source.deviceId,460 deviceName = grant.source.deviceName,461 deviceType = grant.source.deviceType,462 longTermPublicKeyHex = grant.source.longTermPublicKeyHex,463 signingPublicKeyHex = grant.source.signingPublicKeyHex,464 trustSource = "direct",465 wsPort = grant.source.wsPort,466 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,467 relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,468 pairedAt = nowMs(),469 )470 471 472 473 deps.clearPairedDeviceRevocation(sourceDevice.deviceId)474 deps.upsertPairedDevice(sourceDevice)475 sendFrame(channel, AdoptionFrame.Applied)476 return sourceDevice477}478479480481482483484suspend fun grantAdoption(485 channel: PairingTransport,486 request: AdoptionFrame.Request,487 deps: AdoptionSourceDeps,488 nowMs: () -> Long = { System.currentTimeMillis() },489): PairedDevice? {490 if (!deps.onConsent(request)) return null491492 val confirmation = receiveFrame(channel)493 if (!isAdoptConfirm(confirmation) || !(confirmation as AdoptionFrame.Confirm).confirmed) {494 return null495 }496497 var sourceLicense = deps.license()498 if (sourceLicense == null && request.license != null) {499 sourceLicense = runCatching {500 deps.applyLicense(request.license)501 deps.license()502 }.getOrNull()503 }504 if (sourceLicense == null) {505 sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))506 throw IllegalStateException("ADOPT_PRO_REQUIRED")507 }508509 val rootKeyHex = deps.rootKeyHex()510 if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")511512 sendFrame(channel, AdoptionFrame.Grant(513 rootKeyHex = rootKeyHex,514 vaultId = deps.vaultId(),515 license = sourceLicense,516 source = AdoptionFrame.Source(517 deviceId = deps.sourceIdentity.deviceId,518 deviceName = deps.sourceIdentity.deviceName,519 deviceType = deps.sourceIdentity.deviceType.wireValue,520 longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,521 signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,522 wsPort = deps.sourceIdentity.wsPort,523 relayUrl = deps.pairState?.relayUrl,524 ),525 snapshotCapable = request.snapshotCapable == true,526 ))527528 val identity = receiveFrame(channel)529 if (!isAdoptIdentity(identity)) return null530 identity as AdoptionFrame.Identity531532 val device = PairedDevice(533 deviceId = identity.deviceId,534 deviceName = identity.deviceName,535 deviceType = identity.deviceType,536 longTermPublicKeyHex = identity.longTermPublicKeyHex,537 signingPublicKeyHex = identity.signingPublicKeyHex,538 trustSource = "direct",539 rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,540 relayUrl = deps.pairState?.relayUrl,541 pairedAt = nowMs(),542 )543 deps.upsertPairedDevice(device)544 sendFrame(channel, AdoptionFrame.Complete)545 if (!isAdoptApplied(receiveFrame(channel))) return null546 return device547}548549550551552553554val AdoptJson: Json = Json {555 classDiscriminator = "type"556 encodeDefaults = false557 ignoreUnknownKeys = true558}559560suspend fun sendFrame(channel: PairingTransport, frame: AdoptionFrame) {561 channel.send(AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).toByteArray(Charsets.UTF_8))562}563564565suspend fun receiveFrame(channel: PairingTransport): AdoptionFrame? =566 runCatching {567 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))568 }.getOrNull()569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603suspend fun sendVaultSnapshot(604 channel: PairingTransport,605 entries: Map<String, String>,606 tombstones: Map<String, Long>,607 duressSlot: String? = null,608) {609 val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)610 611 612 613 614 615 val acc = createAdoptSnapshotBudget()616 val encoded = frames.map {617 AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()618 }619 for (i in frames.indices) {620 val frame = frames[i]621 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)622 }623 for (raw in encoded) channel.send(raw)624}625626627628629630631private fun buildAdoptSnapshotFrames(632 entries: Map<String, String>,633 tombstones: Map<String, Long>,634 duressSlot: String?,635): List<AdoptionFrame.Snapshot> {636 val includeDuress = !duressSlot.isNullOrBlank()637 638 639 640 641 642 val entryCap = if (includeDuress) MAX_SNAPSHOT_CHUNKS - 1 else MAX_SNAPSHOT_CHUNKS643644 val chunks = mutableListOf<Map<String, String>>()645 var current = LinkedHashMap<String, String>()646 var currentBytes = 2 647 for ((id, envelope) in entries) {648 649 val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1650 if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {651 chunks.add(current)652 current = LinkedHashMap()653 currentBytes = 2654 }655 current[id] = envelope656 currentBytes += entryBytes657 }658 chunks.add(current)659660 val entriesChunkCount = minOf(chunks.size, entryCap)661 val sendDuress = includeDuress662 val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)663 val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(664 chunkIndex = entriesChunkCount,665 chunkCount = totalChunks,666 entries = emptyMap(),667 tombstones = tombstones,668 duressSlot = duressSlot,669 ) else null670671 if (672 duressFrame != null &&673 AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)674 .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING675 ) {676 throw SnapshotDuressFrameTooLargeException()677 }678679 val frames = mutableListOf<AdoptionFrame.Snapshot>()680 for (index in 0 until entriesChunkCount) {681 val isFinalEntries = index == entriesChunkCount - 1682 frames.add(AdoptionFrame.Snapshot(683 chunkIndex = index,684 chunkCount = totalChunks,685 entries = chunks[index],686 687 688 689 tombstones = if (isFinalEntries && !sendDuress) tombstones else emptyMap(),690 duressSlot = null,691 ))692 }693694 if (duressFrame != null) frames.add(duressFrame)695 return frames696}697698internal class SnapshotDuressFrameTooLargeException :699 IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")700701702703704705706707708709710711712suspend fun receiveVaultSnapshot(713 channel: PairingTransport,714): ReceivedSnapshot {715 val acc = createAdoptSnapshotBudget()716 var chunkCount = -1717 var received = 0718 while (chunkCount == -1 || received < chunkCount) {719 val raw = channel.receive()720 if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {721 throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")722 }723 if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {724 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")725 }726 val frame = runCatching {727 AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))728 }.getOrNull()729 if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")730 frame as AdoptionFrame.Snapshot731 if (chunkCount == -1) {732 chunkCount = frame.chunkCount733 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {734 throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")735 }736 } else if (frame.chunkCount != chunkCount) {737 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")738 }739 if (frame.chunkIndex != received) {740 throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")741 }742 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)743 received++744 }745 return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)746}747748private fun isDeviceType(value: String): Boolean =749 DeviceType.entries.any { it.wireValue == value }